Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/desktop-rollout-diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,6 @@ The installation ID is a random UUID at `<userData>/desktop-service/installation

Harness launch attempts report startup-failure before readiness or harness-crash after readiness. Reporting awaits the actual log-stream write callback. Renderer/GPU loss is persisted synchronously before recovery can exit the app. Main JavaScript fatal exceptions use uncaughtExceptionMonitor and preserve the default crash behavior. A queued fatal event and the next unclean-start marker reuse the same event ID for deduplication.

Normal will-quit and intentional GPU relaunch clear the session marker. Native crashes, power loss or forced termination are reported as **unclean-exit on next launch**, not as a proven crash; they cannot reliably send HTTP from a dead process. If the app never reaches initialization again, there is no guarantee of delivery. Initialization/reporting failure does not prevent application startup.
Normal will-quit, intentional GPU relaunch, and in-app update install clear the session marker. Native crashes, power loss or forced termination are reported as **unclean-exit on next launch** only when the leftover marker version matches the current app version and the previous session log does not look healthy; they are not a proven crash and cannot reliably send HTTP from a dead process. A leftover marker from a different version is treated as an upgrade or overwrite install and is not reported. If the app never reaches initialization again, there is no guarantee of delivery. Initialization/reporting failure does not prevent application startup.

This does not yet track installer/download errors or correlate upgrade attempts with installation success. It also does not change signing or update-package verification settings. Real packaged macOS/Windows crash and upgrade acceptance remains separate from unit/build/CI validation.
3 changes: 2 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 10 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -323,8 +323,16 @@
"!**/*.map",
"!**/*.d.ts",
"!**/*.tsbuildinfo",
"!**/*.md",
"!**/*.markdown",
"!**/README*.md",
"!**/readme*.md",
"!**/CHANGELOG*.md",
"!**/changelog*.md",
"!**/CONTRIBUTING*.md",
"!**/contributing*.md",
"!**/SECURITY*.md",
"!**/security*.md",
"!**/LICENSE*.md",
"!**/license*.md",
"!node_modules/typescript/**",
"!**/node_modules/**/test/**",
"!**/node_modules/**/tests/**",
Expand Down
25 changes: 22 additions & 3 deletions packages/dsh-desktop-market-installer/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,12 @@ export function createDesktopPnpmService(options) {
// untouched, so anything that already loaded it is unaffected.
await rm(marketPath, { force: true })
}
const workspaceYamlPath = join(profile, 'pnpm-workspace.yaml')
try {
await readFile(workspaceYamlPath, 'utf8')
} catch {
await atomicWrite(workspaceYamlPath, 'packages:\n - .\n\nnodeLinker: hoisted\nautoInstallPeers: false\n')
}
const registry = await resolveMarketRegistry({ profileDir: profile, args, environment })
if (isCancelled()) throw new Error('The package operation was aborted.')
const env = buildPnpmEnvironment(binDirectory, environment, executablePath)
Expand All @@ -518,14 +524,27 @@ export function createDesktopPnpmService(options) {
windowsHide: true, detached: process.platform !== 'win32'
})
setCancel(() => killProcessTree(child))
child.stdout?.on('data', chunk => write(chunk.toString('utf8').replace(/\r?\n$/u, '')))
child.stderr?.on('data', chunk => write(chunk.toString('utf8').replace(/\r?\n$/u, '')))
let failureOutput = ''
child.stdout?.on('data', chunk => {
const text = chunk.toString('utf8').replace(/\r?\n$/u, '')
failureOutput = `${failureOutput}\n${text}`.slice(-2000)
write(text)
})
child.stderr?.on('data', chunk => {
const text = chunk.toString('utf8').replace(/\r?\n$/u, '')
failureOutput = `${failureOutput}\n${text}`.slice(-2000)
write(text)
})
const exit = await new Promise((resolveExit, rejectExit) => {
child.once('error', rejectExit)
child.once('close', (code, signal) => resolveExit({ code, signal }))
})
setCancel(() => {})
if (isCancelled() || exit.code !== 0) throw new Error(`Market install failed: ${exit.signal ?? exit.code}`)
if (isCancelled()) throw new Error('The package operation was aborted.')
if (exit.code !== 0) {
const detail = failureOutput.trim()
throw new Error(`Market install failed: ${exit.signal ?? exit.code}${detail ? ` (${detail})` : ''}`)
}
const installed = JSON.parse(await readFile(join(profile, 'node_modules', MARKET_PACKAGE, 'package.json'), 'utf8'))
const expected = spec.slice(spec.lastIndexOf('@') + 1)
if (installed.version !== expected) throw new Error(`Market install expected ${expected}, found ${installed.version}`)
Expand Down
1 change: 1 addition & 0 deletions packages/dsh-plugin-gis/client/client.js.map

Large diffs are not rendered by default.

Binary file modified packages/ppt-bundles/dsh-ppt-0.1.1-rc.2-desktop-20260906.tgz
Binary file not shown.
4 changes: 2 additions & 2 deletions packages/ppt-runtime/artifacts.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"core": {
"file": "dsh-ppt-0.1.1-rc.2-desktop-20260906.tgz",
"sha256": "5d1c1451792474389f55f09db2e1bdfa70cf430384d21ec3ccd55eaea0d8c8ff",
"integrity": "sha512-SyZs2qWMNRY+XTHmz02cjgQzFKWG73escGM4frfgKhuJlb0/ErjiBoi6YcQOIlw4R4/M1qTVkd8vdNxWvDilQw=="
"sha256": "2f493c6aa0dbba384ee57dc875c8647fd45b7b1b79b1ecbbac3994d578f87221",
"integrity": "sha512-xFhBoyKRhgRudBtEVyxSbZvFVVkUGvOcEWVm1cjAjDR/vPaXgtA4h32uH3tI9oLDl8VefPTpdEui089B1XIVPQ=="
},
"adapter": {
"file": "dsh-ppt-composer-0.1.1-rc.2-desktop-20260906.tgz",
Expand Down
71 changes: 64 additions & 7 deletions packages/ppt-runtime/core/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3607,6 +3607,62 @@ const Config = z.object({
pptSkillRoot: z.string()
});
/** Compose storage, browser RPC, and bounded model tools. */
function registerPptRpcRoute(webCtx, channel, rpcHandler) {
webCtx.effect(() => webCtx.webServer.register({
kind: "prefix",
path: channel,
handler: async (req, res) => {
if (req.method !== "POST") {
res.setHeader("Allow", "POST");
res.writeHead(405);
res.end();
return;
}
const connection = webCtx.get("connection");
const rejection = connection?.requestRejection?.(req);
if (rejection !== void 0) {
res.writeHead(rejection);
res.end(rejection === 401 ? "unauthorized" : "forbidden");
return;
}
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
let body;
try {
body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
} catch {
res.writeHead(400);
res.end("body is not JSON");
return;
}
const rawPath = new URL(req.url ?? "/", "http://localhost").pathname;
const endpoint = rawPath.startsWith(`${channel}/`) ? rawPath.slice(channel.length + 1) : "";
try {
const result = await rpcHandler(endpoint, body?.payload);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
type: "server-response",
rpcId: body?.rpcId,
result
}));
} catch (err) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({
type: "server-response",
rpcId: body?.rpcId,
result: {
ok: false,
error: {
code: "internal",
message: err instanceof Error ? err.message : String(err)
}
}
}));
}
}
}), `dsh-ppt: ${channel} rpc route`);
}

async function apply(ctx, config) {
const bundledPptSkillRoot = fileURLToPath(new URL("../skills/dsh-ppt", import.meta.url));
registerPreviewAssets(ctx, previewFiles, path.join(bundledPptSkillRoot, "references"));
Expand All @@ -3615,17 +3671,18 @@ async function apply(ctx, config) {
maxDecksPerSession: config.maxDecksPerSession ?? 50,
maxActivities: config.maxActivities ?? 200
}), { maxSlides: config.maxSlides ?? 40 });
// Harness 0.1.5 registers an RPC channel as a webServer route owned by the
// Context that read `connection`, and that Context must itself declare
// `webServer`. Registering from a scoped inject Context is upstream's own
// pattern; reading `ctx.connection` directly throws
// `cannot get property "webServer" without inject` and fails the whole tree.
const rpcHandler = pptRpc(service);
ctx.inject(["webServer"], (webCtx) => {
webCtx.connection.rpc.handle("/dsh-ppt", pptRpc(service), { authority: "trusted-host" });
registerPptRpcRoute(webCtx, "/dsh-ppt", rpcHandler);
// Older loaded clients can finish their in-flight requests after upgrade.
webCtx.connection.rpc.handle("/kimi-ppt", pptRpc(service), { authority: "trusted-host" });
registerPptRpcRoute(webCtx, "/kimi-ppt", rpcHandler);
try {
webCtx.connection?.rpc?.handle?.("/dsh-ppt", rpcHandler, { authority: "trusted-host" });
webCtx.connection?.rpc?.handle?.("/kimi-ppt", rpcHandler, { authority: "trusted-host" });
} catch {}
});
registerPptTools(ctx, service);
}
//#endregion
export { Config, apply, inject, name };

21 changes: 17 additions & 4 deletions src/main/desktop-service/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@ export function attachDiagnostics(app: EventEmitter, service: DesktopService, op
}
let canSend = false
let pendingPluginFailureEventId: string | undefined
let suppressPluginFailure = false
const capture = (kind: FailureKind, message: string, eventId?: string) => {
safe(() => service.capture(kind, message, eventId))
if (canSend) flush()
if (canSend && eventId !== pendingPluginFailureEventId) flush()
}
safe(() => service.beginSession())
const fatal = (error: Error) => safe(() => service.captureFatal(error))
Expand Down Expand Up @@ -60,10 +61,18 @@ export function attachDiagnostics(app: EventEmitter, service: DesktopService, op
flush()
},
runtimeChanged(snapshot: RuntimeSnapshot, flushLog: () => Promise<void>, attempt = 0) {
if (attempt !== previousAttempt) wasReady = false
if (snapshot.phase === 'starting') wasReady = false
if (attempt !== previousAttempt) {
wasReady = false
suppressPluginFailure = false
pendingPluginFailureEventId = undefined
}
if (snapshot.phase === 'starting') {
wasReady = false
suppressPluginFailure = false
}
if (snapshot.phase === 'ready') {
wasReady = true
suppressPluginFailure = false
if (pendingPluginFailureEventId) {
service.discard(pendingPluginFailureEventId)
pendingPluginFailureEventId = undefined
Expand All @@ -73,8 +82,11 @@ export function attachDiagnostics(app: EventEmitter, service: DesktopService, op
const kind = wasReady ? 'harness-crash' : 'startup-failure'
// Wait for the actual Harness stream callback, not a guessed timer.
void flushLog().catch(error => options.onError?.(error)).then(() => {
const eventId = randomId()
const isPluginFailure = Boolean(snapshot.pluginFailures && snapshot.pluginFailures.length > 0)
if (!wasReady && isPluginFailure && suppressPluginFailure) {
return
}
const eventId = randomId()
if (!wasReady && isPluginFailure) {
pendingPluginFailureEventId = eventId
}
Expand All @@ -85,6 +97,7 @@ export function attachDiagnostics(app: EventEmitter, service: DesktopService, op
previousAttempt = attempt
},
discardPendingPluginFailure() {
suppressPluginFailure = true
if (pendingPluginFailureEventId) {
service.discard(pendingPluginFailureEventId)
pendingPluginFailureEventId = undefined
Expand Down
2 changes: 1 addition & 1 deletion src/main/desktop-service/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export class DesktopService {
if (existsSync(this.marker)) {
try {
const old = JSON.parse(readFileSync(this.marker, 'utf8')) as { eventId: string; version: string }
if (uuid.test(old.eventId) && isVersion(old.version)) {
if (uuid.test(old.eventId) && isVersion(old.version) && old.version === this.options.version) {
const log = tailLog(this.options.logPath)
if (!isHealthySessionLog(log.lines)) {
this.capture('unclean-exit', 'Previous session ended without a clean shutdown (crash, power loss or forced termination).', old.eventId, old.version)
Expand Down
4 changes: 4 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2965,6 +2965,7 @@ async function bootstrap(): Promise<void> {
ipcMain.removeHandler('harness:open-recovery')
ipcMain.handle('harness:open-recovery', async (event, frontendErrorMessage?: unknown) => {
assertTrustedMainWindowEvent(event)
desktopDiagnostics?.discardPendingPluginFailure()
const message = typeof frontendErrorMessage === 'string' ? frontendErrorMessage : undefined
if (message) appendRendererPluginFailureLog(message)
const logs = [...rendererPluginFailureLogs]
Expand Down Expand Up @@ -3128,6 +3129,9 @@ async function bootstrap(): Promise<void> {
const dshHome = join(app.getPath('userData'), 'harness')
await quarantineInstalledLaunchAgentsForUpdate(dshHome)
quitting = true
// NSIS may force-kill before will-quit; clear the marker so the next
// launch does not treat this intentional update as an unclean-exit.
desktopDiagnostics?.markCleanExit()
stopUpdateManager()
}
})
Expand Down
45 changes: 42 additions & 3 deletions src/main/state/market-baseline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,30 @@ export async function demoteMarketGeneration(

// Keep the declaration: dropping it would read as "the market was
// uninstalled" and every later repair would decline to reinstall it.
// If a newer version >= VERIFIED_MARKET_BASELINE was installed, preserve it
// rather than forcing a fallback to VERIFIED_MARKET_BASELINE.
let actualInstalledVersion: string | undefined
try {
actualInstalledVersion = await readInstalledPluginVersion(dshHome, MARKET_PACKAGE)
} catch {
// unreadable or missing
}
const isMeetsBaseline = (v?: string): boolean => {
const clean = v?.replace(/^[~^v=><\s]+/g, '')
return !!clean && !!parseSemver(clean) && compareSemver(clean, VERIFIED_MARKET_BASELINE) >= 0
}

const candidateVersion =
owned?.visibleVersion ??
(isMeetsBaseline(actualInstalledVersion) ? actualInstalledVersion : undefined) ??
(isMeetsBaseline(manifest.dependencies?.[MARKET_PACKAGE]) ? manifest.dependencies?.[MARKET_PACKAGE] : undefined) ??
(isMeetsBaseline(generations.find((g) => g.pluginName === MARKET_PACKAGE)?.version)
? generations.find((g) => g.pluginName === MARKET_PACKAGE)?.version
: undefined) ??
VERIFIED_MARKET_BASELINE

manifest.dependencies ??= {}
manifest.dependencies[MARKET_PACKAGE] = owned?.visibleVersion ?? VERIFIED_MARKET_BASELINE
manifest.dependencies[MARKET_PACKAGE] = candidateVersion
if (owned !== undefined) {
delete manifest.dsh!.desktop!.generationProjection!.plugins![MARKET_PACKAGE]
if (Object.keys(manifest.dsh!.desktop!.generationProjection!.plugins!).length === 0) {
Expand Down Expand Up @@ -147,19 +169,36 @@ export async function ensureMarketBaseline(
.catch(() => false)
if (meetsBaseline(installed) && !isGenerationLink) return

const declaredVersion = manifest.dependencies.dshmarket
const declaredClean = declaredVersion?.replace(/^[~^v=><\s]+/g, '')
const declaredParsed = declaredClean ? parseSemver(declaredClean) : null
const targetVersion =
declaredParsed && compareSemver(declaredClean, VERIFIED_MARKET_BASELINE) > 0
? declaredVersion
: VERIFIED_MARKET_BASELINE

options.note?.(
isGenerationLink
? `[market-baseline] dshmarket ${installed ?? '(unknown)'} is a generation link; reinstalling into the shared tree`
: `[market-baseline] upgrading dshmarket ${installed ?? '(missing)'} to ${VERIFIED_MARKET_BASELINE}`
: `[market-baseline] upgrading dshmarket ${installed ?? '(missing)'} to ${targetVersion}`
)
// Ensure the profile directory has a valid pnpm-workspace.yaml so pnpm --workspace-root succeeds.
const profileDir = dirname(profilePackageJsonPath(options.dshHome))
const workspaceYamlPath = join(profileDir, 'pnpm-workspace.yaml')
try {
await readFile(workspaceYamlPath, 'utf8')
} catch {
await writeFile(workspaceYamlPath, 'packages:\n - .\n\nnodeLinker: hoisted\nautoInstallPeers: false\n', 'utf8')
}

// This normally happens inside Harness boot, which has not run yet. Ensure
// generation peer validation sees this installation's host packages first.
await healProfilesModuleFallback({
installAnchor: join(dirname(options.dshEntryPath), '..', 'package.json'),
home: options.dshHome
})
await clearProfileInstallMarker(options.dshHome)
const result = await upgrade({ ...options, targetVersion: VERIFIED_MARKET_BASELINE })
const result = await upgrade({ ...options, targetVersion })
if (!result.ok) throw new Error(result.detail ?? 'dshmarket installation failed')

const actual = await readInstalledPluginVersion(options.dshHome, 'dshmarket')
Expand Down
24 changes: 22 additions & 2 deletions src/main/state/web-home-import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,20 @@ const KEEP_IN_SHARED_TREE = new Set([
])

const HOME_FILES = ['settings.yaml', '.credentials.yaml'] as const
const HOME_DIRECTORIES = ['sessions', 'storages', '.agent-presets', 'skills'] as const
const PROFILE_FILES = ['package.json', 'cordis.patch.yml', '.npmrc'] as const
const HOME_DIRECTORIES = [
'sessions',
'storages',
'.agent-presets',
'skills',
'attachments',
'plugins'
] as const
const PROFILE_FILES = [
'package.json',
'cordis.patch.yml',
'.npmrc',
'pnpm-workspace.yaml'
] as const
const WORKSPACE_REGISTRY = 'workspace.json'

export type WebImportDecisionKind = 'imported' | 'skipped'
Expand Down Expand Up @@ -206,6 +218,14 @@ async function copyHomePayload(
for (const name of PROFILE_FILES) {
await copyIfPresent(join(sourceProfile, name), join(destProfile, name), dest, note)
}
const destWorkspaceYaml = join(destProfile, 'pnpm-workspace.yaml')
if (!(await exists(destWorkspaceYaml))) {
await writeFile(
destWorkspaceYaml,
'packages:\n - .\n\nnodeLinker: hoisted\nautoInstallPeers: false\n',
'utf8'
)
}

const plugins = await plannedCommunityPlugins(source)
for (const plugin of plugins) {
Expand Down
Loading
Loading