diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc53ec5eb..7bfaaa8c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,8 +104,8 @@ jobs: apps/push-relay/node_modules key: nm-v2-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json','apps/webhook-relay/package-lock.json','apps/push-relay/package-lock.json') }} - run: cd apps/ade-cli && npm run typecheck - - name: Test release runtime archive guards - run: node --test apps/ade-cli/scripts/native-archive-verification.test.mjs apps/desktop/scripts/mac-runtime-archive-mode.test.mjs + - name: Test release runtime archive and packaging guards + run: node --test apps/ade-cli/scripts/native-archive-verification.test.mjs apps/desktop/scripts/mac-runtime-archive-mode.test.mjs apps/desktop/scripts/packaged-ade-cli-resources.test.mjs typecheck-web: needs: install diff --git a/CHANGELOG.md b/CHANGELOG.md index 8862016ae..d2363056e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.2.34] - 2026-07-22 + +### Web client and sync + +- Bounded initial chat hydration, prioritized visible conversations, and replaced broad startup replay with invalidation-only synchronization. +- Isolated project handoff generations, retired abandoned streams, and bounded subscriptions, transcript cursors, artwork, invalidation hints, and inactive project scopes. +- Renewed relay leases in the background and preserved verified secure routes when cloud presence is temporarily stale. +- Kept peer changes, transcript polling, role transitions, and sync-host handoff progressing while foreground chats hydrate. +- Updated desktop and CLI WebSocket parsing to bound fragmented-frame memory use, and refreshed the hosted router and project YAML parser to their patched compatible releases. + +### Terminals and mobile + +- Made iOS terminal snapshot recovery atomic and retained live bytes that arrive while a snapshot is being applied. +- Restored grouped Work tool activity and stable post-hello synchronization state. + +### Desktop and sessions + +- Coalesced and bounded project switching, lane snapshots, cleanup work, GitHub probes, and runtime process discovery under load. +- Single-flighted initial project registration so concurrent terminal input cannot amplify runtime startup work or lose input ordering. +- Moved historical usage aggregation into SQLite and isolated workers to reduce main-process memory and event-loop pressure. +- Restored exact model, reasoning, fast-mode, and permission controls for imported and resumed Codex chats without broadening access. + ## [1.2.33] - 2026-07-21 ### Connection reliability @@ -907,7 +929,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial public release. -[Unreleased]: https://github.com/arul28/ADE/compare/v1.2.33...HEAD +[Unreleased]: https://github.com/arul28/ADE/compare/v1.2.34...HEAD +[1.2.34]: https://github.com/arul28/ADE/compare/v1.2.33...v1.2.34 [1.2.33]: https://github.com/arul28/ADE/compare/v1.2.32...v1.2.33 [1.2.32]: https://github.com/arul28/ADE/compare/v1.2.31...v1.2.32 [1.2.31]: https://github.com/arul28/ADE/compare/v1.2.30...v1.2.31 diff --git a/apps/account-directory/src/directory.ts b/apps/account-directory/src/directory.ts index 1394a5e9d..75dcc2bbc 100644 --- a/apps/account-directory/src/directory.ts +++ b/apps/account-directory/src/directory.ts @@ -41,6 +41,7 @@ type RegisterInput = { deviceType: string; pubkey: string | null; reachableEndpoints: ReachableEndpoint[]; + retainRelayEndpoints: boolean; }; type MachineRecord = { @@ -172,10 +173,29 @@ function parseRegisterInput(value: unknown): RegisterInput | null { const deviceType = requiredString(value, "deviceType"); const pubkey = optionalString(value, "pubkey"); const reachableEndpoints = parseReachableEndpoints(value.reachableEndpoints); - if (!machineKey || !deviceId || !name || !platform || !deviceType || pubkey === undefined || !reachableEndpoints) { + const retainRelayEndpoints = value.retainRelayEndpoints ?? false; + if ( + !machineKey + || !deviceId + || !name + || !platform + || !deviceType + || pubkey === undefined + || !reachableEndpoints + || typeof retainRelayEndpoints !== "boolean" + ) { return null; } - return { machineKey, deviceId, name, platform, deviceType, pubkey, reachableEndpoints }; + return { + machineKey, + deviceId, + name, + platform, + deviceType, + pubkey, + reachableEndpoints, + retainRelayEndpoints, + }; } function getRemoteJwks(rawUrl: string): ReturnType { @@ -331,7 +351,32 @@ async function handleRegister(request: Request, env: Env, userId: string): Promi platform = excluded.platform, device_type = excluded.device_type, pubkey = excluded.pubkey, - reachable_endpoints = excluded.reachable_endpoints, + reachable_endpoints = case + when ? = 1 + and json_valid(machines.reachable_endpoints) + and exists ( + select 1 + from json_each(machines.reachable_endpoints) + where json_extract(value, '$.kind') = 'relay' + ) + and not exists ( + select 1 + from json_each(excluded.reachable_endpoints) + where json_extract(value, '$.kind') = 'relay' + ) + then ( + select json_group_array(json(endpoint)) + from ( + select value as endpoint + from json_each(excluded.reachable_endpoints) + union all + select value as endpoint + from json_each(machines.reachable_endpoints) + where json_extract(value, '$.kind') = 'relay' + ) + ) + else excluded.reachable_endpoints + end, last_seen_at = excluded.last_seen_at `).bind( userId, @@ -344,6 +389,7 @@ async function handleRegister(request: Request, env: Env, userId: string): Promi JSON.stringify(input.reachableEndpoints), now, now, + input.retainRelayEndpoints ? 1 : 0, ).run(); const row = await env.DB.prepare(` diff --git a/apps/account-directory/test/directory.test.ts b/apps/account-directory/test/directory.test.ts index 54b23831a..379fbebaa 100644 --- a/apps/account-directory/test/directory.test.ts +++ b/apps/account-directory/test/directory.test.ts @@ -153,6 +153,7 @@ class FakeD1Database { run(sql: string, values: unknown[]): number { const normalized = sql.toLowerCase(); if (normalized.includes("insert into machines")) { + const retainRelayEndpoints = values[10] === 1; const row: StoredMachine = { user_id: String(values[0]), machine_key: String(values[1]), @@ -169,6 +170,21 @@ class FakeD1Database { entry.user_id === row.user_id && entry.machine_key === row.machine_key ); if (existing) { + if (retainRelayEndpoints) { + const nextEndpoints = JSON.parse(row.reachable_endpoints ?? "[]") as Array<{ kind?: string }>; + const existingRelayEndpoints = ( + JSON.parse(existing.reachable_endpoints ?? "[]") as Array<{ kind?: string }> + ).filter((endpoint) => endpoint.kind === "relay"); + if ( + !nextEndpoints.some((endpoint) => endpoint.kind === "relay") + && existingRelayEndpoints.length > 0 + ) { + row.reachable_endpoints = JSON.stringify([ + ...nextEndpoints, + ...existingRelayEndpoints, + ]); + } + } Object.assign(existing, row, { created_at: existing.created_at }); } else { this.rows.push(row); @@ -431,6 +447,13 @@ function registerBody(machineKey: string, endpoints: unknown = [{ kind: "lan", h }; } +function registrationWithRelayRetention(machineKey: string, endpoints: unknown) { + return { + ...registerBody(machineKey, endpoints), + retainRelayEndpoints: true, + }; +} + function request( method: string, pathname: string, @@ -1031,6 +1054,95 @@ describe("machine directory", () => { expect(await otherUserList.json()).toEqual({ machines: [] }); }); + it("retains the authenticated machine's verified Relay route during a transient health dip", async () => { + const env = makeEnv(); + const token = await mintToken({ sub: "user_1" }); + const relayEndpoint = { kind: "relay", url: "wss://relay.test/machine-a" }; + await register(env, token, "machine-a", [ + { kind: "lan", host: "old.local", port: 8787 }, + relayEndpoint, + ]); + + const transient = await handleRequest(request( + "POST", + "/account/machines/register", + token, + registrationWithRelayRetention("machine-a", [ + { kind: "lan", host: "new.local", port: 8787 }, + ]), + ), env); + + expect(transient.status).toBe(200); + expect(await transient.json()).toEqual(expect.objectContaining({ + machineKey: "machine-a", + reachableEndpoints: [ + { kind: "lan", host: "new.local", port: 8787 }, + relayEndpoint, + ], + })); + }); + + it("never retains Relay routes across owners, deletion, or an authoritative replacement", async () => { + const env = makeEnv(); + const firstToken = await mintToken({ sub: "user_1" }); + const secondToken = await mintToken({ sub: "user_2" }); + const relayEndpoint = { kind: "relay", url: "wss://relay.test/shared-machine" }; + await register(env, firstToken, "shared-machine", [relayEndpoint]); + + const otherOwner = await handleRequest(request( + "POST", + "/account/machines/register", + secondToken, + registrationWithRelayRetention("shared-machine", [ + { kind: "lan", host: "second.local", port: 8787 }, + ]), + ), env); + expect(await otherOwner.json()).toEqual(expect.objectContaining({ + reachableEndpoints: [{ kind: "lan", host: "second.local", port: 8787 }], + })); + + const authoritative = await register(env, firstToken, "shared-machine", [ + { kind: "lan", host: "first.local", port: 8787 }, + ]); + expect(await authoritative.json()).toEqual(expect.objectContaining({ + reachableEndpoints: [{ kind: "lan", host: "first.local", port: 8787 }], + })); + + await handleRequest(request( + "DELETE", + "/account/machines/shared-machine", + firstToken, + ), env); + const afterDelete = await handleRequest(request( + "POST", + "/account/machines/register", + firstToken, + registrationWithRelayRetention("shared-machine", [ + { kind: "lan", host: "after-delete.local", port: 8787 }, + ]), + ), env); + expect(await afterDelete.json()).toEqual(expect.objectContaining({ + reachableEndpoints: [{ kind: "lan", host: "after-delete.local", port: 8787 }], + })); + }); + + it("rejects a non-boolean Relay-retention instruction", async () => { + const env = makeEnv(); + const token = await mintToken(); + const response = await handleRequest(request( + "POST", + "/account/machines/register", + token, + { + ...registerBody("machine-a"), + retainRelayEndpoints: "yes", + }, + ), env); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "invalid request body" }); + }); + it("returns online and offline machines, online first and newest first", async () => { const env = makeEnv(); const token = await mintToken({ sub: "user_1" }); diff --git a/apps/ade-cli/package-lock.json b/apps/ade-cli/package-lock.json index cc7290bd7..3fdab9283 100644 --- a/apps/ade-cli/package-lock.json +++ b/apps/ade-cli/package-lock.json @@ -5287,9 +5287,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -5308,9 +5308,10 @@ } }, "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "license": "ISC", "bin": { "yaml": "bin.mjs" }, @@ -8396,15 +8397,15 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "requires": {} }, "yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==" + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==" }, "yocto-queue": { "version": "1.2.2", diff --git a/apps/ade-cli/scripts/verify-built-cli.mjs b/apps/ade-cli/scripts/verify-built-cli.mjs index aabb948ad..014ff8c3d 100644 --- a/apps/ade-cli/scripts/verify-built-cli.mjs +++ b/apps/ade-cli/scripts/verify-built-cli.mjs @@ -4,15 +4,16 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; +import packagedAdeCliResourcesModule from "../../desktop/scripts/packaged-ade-cli-resources.cjs"; const execFileAsync = promisify(execFile); const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const distRoot = path.join(packageRoot, "dist"); const cliPath = path.join(packageRoot, "dist", "cli.cjs"); -const bundledRuntimeEntryPaths = [ - cliPath, - path.join(packageRoot, "dist", "bootstrap.cjs"), - path.join(packageRoot, "dist", "adeRpcServer.cjs"), -]; +const { packagedAdeCliBuildResources, sourceContainsPath } = packagedAdeCliResourcesModule; +const bundledRuntimeEntryPaths = (await fs.readdir(distRoot, { withFileTypes: true })) + .filter((entry) => entry.isFile() && entry.name.endsWith(".cjs")) + .map((entry) => path.join(distRoot, entry.name)); const tuiPath = path.join(packageRoot, "dist", "tuiClient", "cli.mjs"); const packageJsonPath = path.join(packageRoot, "package.json"); @@ -96,6 +97,23 @@ for (const entryPath of bundledRuntimeEntryPaths) { } } +const packagedBuildResources = packagedAdeCliBuildResources(); +for (const entryPath of [...bundledRuntimeEntryPaths, tuiPath]) { + if (packagedBuildResources.some((resource) => sourceContainsPath(resource.sourcePath, entryPath))) { + continue; + } + throw new Error( + `[ade-cli:build] ${path.relative(packageRoot, entryPath)} is not shipped by ` + + "apps/desktop/package.json build.extraResources", + ); +} + +for (const marker of ["__ade-usage-ledger-worker", "Usage ledger worker input is invalid"]) { + if (!contents.includes(marker)) { + throw new Error(`[ade-cli:build] dist/cli.cjs is missing embedded usage ledger worker marker: ${marker}`); + } +} + const stat = await fs.stat(cliPath); if (process.platform !== "win32" && (stat.mode & 0o111) === 0) { throw new Error("[ade-cli:build] dist/cli.cjs is not executable"); diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 7013583ae..769499839 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -792,6 +792,22 @@ describe("ADE CLI", () => { }); }); + it("recognizes the hidden usage ledger worker entrypoint", () => { + expect(buildCliPlan(["__ade-usage-ledger-worker"])).toEqual({ + kind: "usage-ledger-worker", + }); + }); + + it("passes the project root to the hidden icon worker entrypoint", () => { + expect(buildCliPlan([ + "__ade-project-icon-worker", + "/tmp/project with spaces", + ])).toEqual({ + kind: "project-icon-worker", + rootPath: "/tmp/project with spaces", + }); + }); + it("classifies only ADE temp runtime sockets as ephemeral", () => { const tempSocket = path.join(os.tmpdir(), "ade-stdio-rpc-test", "sock", "ade.sock"); diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 795ecdec3..0c7d06954 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -49,6 +49,8 @@ import type { ProjectBrowseInput, } from "../../desktop/src/shared/types/core"; import { resolveMachineAdeLayout } from "./services/projects/machineLayout"; +import { markActiveHostProjectOpen } from "./services/projects/projectCatalog"; +import { resolveRemoteProjectIcon } from "./services/projects/projectIconResolver"; import { findAdeManagedWorktreeRoot, normalizeProjectRootPath, @@ -286,6 +288,8 @@ type CliPlan = | { kind: "serve"; rest: string[] } | { kind: "rpc-stdio"; rest: string[] } | { kind: "pty-host-worker" } + | { kind: "usage-ledger-worker" } + | { kind: "project-icon-worker"; rootPath: string } | { kind: "init"; targetPath: string | null } | { kind: "cursor-cloud"; rest: string[] } | { kind: "deeplink"; rest: string[] } @@ -11624,6 +11628,16 @@ function buildCliPlan( if (primary === "__ade-pty-host-worker") { return { kind: "pty-host-worker" }; } + if (primary === "__ade-usage-ledger-worker") { + return { kind: "usage-ledger-worker" }; + } + if (primary === "__ade-project-icon-worker") { + const primaryIndex = args.indexOf(primary); + return { + kind: "project-icon-worker", + rootPath: args.slice(primaryIndex + 1).find((arg) => arg !== "--") ?? "", + }; + } if (primary === "code") { const rest = args; return { kind: "ade-code", rest }; @@ -15131,18 +15145,23 @@ async function runServe( }; }; const machineProjectCatalogProvider: SyncProjectCatalogProvider = { - listProjects: async () => ({ - projects: includeHostProjectInCatalog( + listProjects: async () => { + const activeHostProjectId = scopeRegistry.getActiveSyncHostProjectId(); + const catalogHostProjectId = activeHostProjectId ?? preferredSyncProjectId; + const projects = includeHostProjectInCatalog( projectRegistry.listRecent(), - preferredSyncProjectId - ? projectRegistry.get(preferredSyncProjectId) + catalogHostProjectId + ? projectRegistry.get(catalogHostProjectId) : null, ) .map((record) => toMobileProjectSummary(record, { isAvailable: fs.existsSync(record.rootPath), - })), - }), + })); + return { + projects: markActiveHostProjectOpen(projects, activeHostProjectId), + }; + }, prepareProjectConnection: async ( request: SyncProjectSwitchRequestPayload, ): Promise => { @@ -15366,15 +15385,10 @@ async function runServe( if (!activeScope && sharedSyncListener) { await sharedSyncListener.ensureListening([DEFAULT_SYNC_HOST_PORT]); } - if (activeScope) { - const prewarmTimer = setImmediate(() => { - void scopeRegistry.prewarmRecentScopes({ - excludeProjectId: activeScope?.registryProjectId, - limit: 2, - }); - }); - prewarmTimer.unref?.(); - } + // A ProjectScope is a complete runtime (DB, search, chat, automation, + // polling, PTY, and sync services), not a lightweight metadata cache. + // Keep non-host projects lazy; the sync-host handoff keeps the old host + // authoritative while a newly selected project boots on demand. return activeScope ?? null; }; const disposeServeResources = async () => { @@ -19035,6 +19049,12 @@ async function runCli( output: formatOutput(plan.value, parsed.options, plan.formatter), exitCode: 0, }; + if (plan.kind === "project-icon-worker") { + return { + output: `${JSON.stringify(resolveRemoteProjectIcon(plan.rootPath))}\n`, + exitCode: 0, + }; + } if (plan.kind === "execute" && plan.laneCreationNudge) { const notice = detectUnmergedLaneCreateNudge(plan.laneCreationNudge); if (notice) process.stderr.write(`${notice}\n`); @@ -19110,6 +19130,13 @@ async function runCli( }); return { output: "", exitCode: 0 }; } + if (plan.kind === "usage-ledger-worker") { + const { runUsageLedgerWorkerEntrypoint } = await import( + "../../desktop/src/main/services/usage/usageLedgerWorker" + ); + const exitCode = await runUsageLedgerWorkerEntrypoint(); + return { output: "", exitCode }; + } if (plan.kind === "desktop") { const result = await runDesktopCommand(plan.rest); return { diff --git a/apps/ade-cli/src/headlessLinearServices.ts b/apps/ade-cli/src/headlessLinearServices.ts index 6dc482c10..c98c2cb84 100644 --- a/apps/ade-cli/src/headlessLinearServices.ts +++ b/apps/ade-cli/src/headlessLinearServices.ts @@ -1042,6 +1042,9 @@ export function createHeadlessGitHubService( ); return token; }, + async getTokenOrThrowAsync() { + return service.getTokenOrThrow(); + }, async getAppUserTokenForRelay() { return await appUserAuth.getValidTokenForRelay(); }, diff --git a/apps/ade-cli/src/jsonrpc.test.ts b/apps/ade-cli/src/jsonrpc.test.ts index 9f67171ee..13b0b292d 100644 --- a/apps/ade-cli/src/jsonrpc.test.ts +++ b/apps/ade-cli/src/jsonrpc.test.ts @@ -142,6 +142,52 @@ describe("startJsonRpcServer", () => { stop(); }); + it("keeps session and layout reads responsive during slow lane, GitHub, and mutation calls", async () => { + const transport = new MemoryTransport(); + const slowSnapshot = deferred(); + const slowGithub = deferred(); + const slowDelete = deferred(); + const calls: string[] = []; + const handler = (async (request) => { + const params = request.params as { arguments?: { domain?: string; action?: string } } | undefined; + const action = params?.arguments?.action ?? request.method ?? ""; + calls.push(action); + if (action === "listSnapshots") await slowSnapshot.promise; + if (action === "getStatus") await slowGithub.promise; + if (action === "delete") await slowDelete.promise; + return { ok: true, action }; + }) as JsonRpcHandler; + + const stop = startJsonRpcServer(handler, transport, { nonFatal: true }); + const call = (id: number, domain: string, action: string) => transport.push({ + jsonrpc: "2.0", + id, + method: "ade/actions/call", + params: { arguments: { domain, action } }, + }); + + call(1, "lane", "listSnapshots"); + call(2, "github", "getStatus"); + call(3, "lane", "delete"); + call(4, "session", "list"); + call(5, "layout", "get"); + await waitForDrain(); + + expect(calls).toHaveLength(5); + expect(calls).toEqual(expect.arrayContaining(["listSnapshots", "getStatus", "delete", "list", "get"])); + expect(jsonlResponses(transport)).toEqual([ + { jsonrpc: "2.0", id: 4, result: { ok: true, action: "list" } }, + { jsonrpc: "2.0", id: 5, result: { ok: true, action: "get" } }, + ]); + + slowDelete.resolve(undefined); + slowSnapshot.resolve(undefined); + slowGithub.resolve(undefined); + await stop.waitForIdle(); + expect(calls.filter((action) => action === "delete")).toHaveLength(1); + stop(); + }); + it("waits for active dispatches before reporting idle", async () => { const transport = new MemoryTransport(); const slow = deferred(); diff --git a/apps/ade-cli/src/multiProjectRpcServer.test.ts b/apps/ade-cli/src/multiProjectRpcServer.test.ts index f0411f636..5b65c8be8 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.test.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.test.ts @@ -4,7 +4,10 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { createEventBuffer } from "./eventBuffer"; -import { createMultiProjectRpcRequestHandler } from "./multiProjectRpcServer"; +import { + createMultiProjectRpcRequestHandler, + decorateProjectListWithIcons, +} from "./multiProjectRpcServer"; import * as gitModule from "../../desktop/src/main/services/git/git"; import { ProjectRegistry } from "./services/projects/projectRegistry"; import { ProjectScopeRegistry } from "./services/projects/projectScope"; @@ -121,6 +124,74 @@ function makeRuntime(label: string) { } describe("multi-project RPC server", () => { + it("keeps the complete inline icon catalog below its hard wire budget", async () => { + const records = Array.from({ length: 8 }, (_, index) => ({ + rootPath: `/project-${index}`, + lastOpenedAt: index, + })); + const iconPayload = `data:image/png;base64,${"a".repeat(100 * 1024)}`; + + const decorated = await decorateProjectListWithIcons(records, (rootPath) => ({ + dataUrl: iconPayload, + sourcePath: `${rootPath}/icon.png`, + mimeType: "image/png", + })); + + const iconBytes = decorated.reduce( + (total, record) => total + Buffer.byteLength(record.icon.dataUrl ?? "", "utf8"), + 0, + ); + expect(iconBytes).toBeLessThanOrEqual(512 * 1024); + expect(decorated.filter((record) => record.icon.dataUrl).length).toBe(5); + }); + + it("drops an individually oversized icon before it reaches the catalog", async () => { + const [decorated] = await decorateProjectListWithIcons( + [{ rootPath: "/project", lastOpenedAt: 1 }], + () => ({ + dataUrl: `data:image/png;base64,${"a".repeat(129 * 1024)}`, + sourcePath: "/project/icon.png", + mimeType: "image/png", + }), + ); + + expect(decorated.icon).toEqual({ + dataUrl: null, + sourcePath: null, + mimeType: null, + }); + }); + + it("returns at the wall-clock icon budget when a resolver stalls", async () => { + const startedAt = performance.now(); + let eventLoopTicked = false; + const eventLoopProbe = setTimeout(() => { + eventLoopTicked = true; + }, 5); + const decorated = await decorateProjectListWithIcons( + [{ rootPath: "/slow-project", lastOpenedAt: 1 }], + async () => { + await new Promise((resolve) => setTimeout(resolve, 250)); + return { + dataUrl: "data:image/png;base64,YQ==", + sourcePath: "/slow-project/icon.png", + mimeType: "image/png", + }; + }, + 40, + ); + const elapsedMs = performance.now() - startedAt; + clearTimeout(eventLoopProbe); + + expect(elapsedMs).toBeLessThan(150); + expect(eventLoopTicked).toBe(true); + expect(decorated[0]?.icon).toEqual({ + dataUrl: null, + sourcePath: null, + mimeType: null, + }); + }); + it("reconciles account-owned client trust on sign-out and account switch", async () => { const { registry } = createRegistry(); const accountAuthService = makeAccountAuthServiceMock(); diff --git a/apps/ade-cli/src/multiProjectRpcServer.ts b/apps/ade-cli/src/multiProjectRpcServer.ts index a0da94e2b..09b16385c 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.ts @@ -1,5 +1,6 @@ import { createAdeRpcRequestHandler } from "./adeRpcServer"; import { createHash, randomUUID } from "node:crypto"; +import { execFile } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -26,7 +27,10 @@ import { type JsonRpcRequest, } from "./jsonrpc"; import { resolveMachineAdeLayout } from "./services/projects/machineLayout"; -import { resolveRemoteProjectIcon } from "./services/projects/projectIconResolver"; +import { + REMOTE_ICON_MAX_DATA_URL_BYTES, + resolveRemoteProjectIcon, +} from "./services/projects/projectIconResolver"; import { ProjectRegistry, SYSTEM_PROJECT_REGISTRATION, @@ -251,37 +255,164 @@ const EMPTY_PROJECT_ICON: ResolvedProjectIcon = Object.freeze({ // most this many icons and this many inlined bytes per call. A large or // slow-filesystem registry then can't stall a connect just to render tab // artwork — projects past the budget fall back to a null icon. -const LIST_ICON_COUNT_BUDGET = 64; -const LIST_ICON_BYTE_BUDGET = 12 * 1024 * 1024; +const LIST_ICON_COUNT_BUDGET = 24; +const LIST_ICON_BYTE_BUDGET = 512 * 1024; +const LIST_ICON_RESOLVE_BUDGET_MS = 750; + +type ProjectIconResolver = ( + rootPath: string, + timeoutMs: number, +) => ResolvedProjectIcon | Promise; + +function projectIconWorkerInvocation(rootPath: string): { + command: string; + args: string[]; +} { + const entryPath = process.argv[1] ?? ""; + const isCliScript = /(^|[/\\])cli\.(?:ts|js|cjs)$/i.test(entryPath) + && fs.existsSync(entryPath); + return isCliScript + ? { + command: process.execPath, + args: [ + ...process.execArgv, + entryPath, + "__ade-project-icon-worker", + rootPath, + ], + } + : { + // Node SEA executables re-enter the bundled CLI directly. Its SEA + // banner restores the synthetic cli.cjs argv entry before parsing. + command: process.execPath, + args: ["__ade-project-icon-worker", rootPath], + }; +} + +function isResolvedProjectIcon(value: unknown): value is ResolvedProjectIcon { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const icon = value as Record; + return [icon.dataUrl, icon.sourcePath, icon.mimeType].every( + (field) => field === null || typeof field === "string", + ); +} + +// Icon discovery includes synchronous directory scans and a native rasterizer. +// Run it outside the RPC process so the connect-critical event loop remains +// responsive and the parent can enforce a real wall-clock deadline by killing +// a worker that outlives its remaining catalog budget. +function resolveRemoteProjectIconInWorker( + rootPath: string, + timeoutMs: number, +): Promise { + if (timeoutMs <= 0) return Promise.resolve(EMPTY_PROJECT_ICON); + const invocation = projectIconWorkerInvocation(rootPath); + return new Promise((resolve) => { + execFile( + invocation.command, + invocation.args, + { + timeout: Math.max(1, Math.floor(timeoutMs)), + killSignal: "SIGKILL", + maxBuffer: REMOTE_ICON_MAX_DATA_URL_BYTES + 16 * 1024, + encoding: "utf8", + }, + (error, stdout) => { + if (error) { + resolve(EMPTY_PROJECT_ICON); + return; + } + try { + const icon = JSON.parse(stdout.trim()) as unknown; + resolve(isResolvedProjectIcon(icon) ? icon : EMPTY_PROJECT_ICON); + } catch { + resolve(EMPTY_PROJECT_ICON); + } + }, + ); + }); +} + +async function resolveIconBeforeDeadline( + resolveIcon: ProjectIconResolver, + rootPath: string, + timeoutMs: number, +): Promise { + if (timeoutMs <= 0) return EMPTY_PROJECT_ICON; + let timer: ReturnType | null = null; + try { + return await Promise.race([ + Promise.resolve().then(() => resolveIcon(rootPath, timeoutMs)), + new Promise((resolve) => { + timer = setTimeout(() => resolve(EMPTY_PROJECT_ICON), timeoutMs); + }), + ]); + } catch { + return EMPTY_PROJECT_ICON; + } finally { + if (timer) clearTimeout(timer); + } +} // Stamp a single project record with its host-resolved icon so a remote desktop // can render the real project logo. Used for the records returned by // add/create/clone (which feed the desktop's cached connection.projects), so a // freshly registered project opens with its icon instead of a blank folder. // Best-effort: a failed resolve degrades to a null icon and never throws. -function decorateProjectWithIcon( +async function decorateProjectWithIcon( record: T, -): T & { icon: ResolvedProjectIcon } { - return { ...record, icon: resolveRemoteProjectIcon(record.rootPath) }; +): Promise { + return { + ...record, + icon: await resolveIconBeforeDeadline( + resolveRemoteProjectIconInWorker, + record.rootPath, + LIST_ICON_RESOLVE_BUDGET_MS, + ), + }; } // Decorate a full project list with icons under the connect-path budget above. // Icons are resolved for the most-recently-opened projects first (those most // likely to be open as tabs) while the returned array stays in registry order. -function decorateProjectListWithIcons( +export async function decorateProjectListWithIcons( records: readonly T[], -): Array { + resolveIcon: ProjectIconResolver = resolveRemoteProjectIconInWorker, + resolveBudgetMs = LIST_ICON_RESOLVE_BUDGET_MS, +): Promise> { const icons = new Map(); let count = 0; let bytes = 0; + const startedAt = Date.now(); const byRecency = records .map((record, index) => ({ record, index })) - .sort((a, b) => b.record.lastOpenedAt - a.record.lastOpenedAt); + .sort((a, b) => + b.record.lastOpenedAt - a.record.lastOpenedAt || a.index - b.index + ); for (const { record, index } of byRecency) { - if (count >= LIST_ICON_COUNT_BUDGET || bytes >= LIST_ICON_BYTE_BUDGET) break; - const icon = resolveRemoteProjectIcon(record.rootPath); + const elapsedMs = Date.now() - startedAt; + if ( + count >= LIST_ICON_COUNT_BUDGET + || bytes >= LIST_ICON_BYTE_BUDGET + || elapsedMs >= resolveBudgetMs + ) break; + const icon = await resolveIconBeforeDeadline( + resolveIcon, + record.rootPath, + resolveBudgetMs - elapsedMs, + ); count += 1; - if (icon.dataUrl) bytes += icon.dataUrl.length; + const iconBytes = icon.dataUrl + ? Buffer.byteLength(icon.dataUrl, "utf8") + : 0; + if ( + iconBytes > REMOTE_ICON_MAX_DATA_URL_BYTES + || bytes + iconBytes > LIST_ICON_BYTE_BUDGET + ) { + icons.set(index, EMPTY_PROJECT_ICON); + continue; + } + bytes += iconBytes; icons.set(index, icon); } return records.map((record, index) => ({ @@ -1008,7 +1139,7 @@ export function createMultiProjectRpcRequestHandler( } if (method === "projects.list") { - return decorateProjectListWithIcons(projectRegistry.list()); + return await decorateProjectListWithIcons(projectRegistry.list()); } if (method === "projects.add") { @@ -1020,7 +1151,7 @@ export function createMultiProjectRpcRequestHandler( "projects.add requires rootPath.", ); } - return decorateProjectWithIcon( + return await decorateProjectWithIcon( projectRegistry.add(rootPath, readProjectRegistrationIntent(params)), ); } @@ -1040,7 +1171,7 @@ export function createMultiProjectRpcRequestHandler( registration.catalogVisibility, registration.registrationSource, ); - return project ? decorateProjectWithIcon(project) : null; + return project ? await decorateProjectWithIcon(project) : null; } if (method === "projects.remove") { @@ -1120,7 +1251,7 @@ export function createMultiProjectRpcRequestHandler( await createMachineProjectScaffoldService().createLocalProject( readCreateProjectInput(params), ); - return decorateProjectWithIcon( + return await decorateProjectWithIcon( projectRegistry.add( result.rootPath, readProjectRegistrationIntent(params), @@ -1133,7 +1264,7 @@ export function createMultiProjectRpcRequestHandler( await createMachineProjectScaffoldService().cloneRepository( readCloneProjectInput(params), ); - return decorateProjectWithIcon( + return await decorateProjectWithIcon( projectRegistry.add( result.rootPath, readProjectRegistrationIntent(params), diff --git a/apps/ade-cli/src/services/account/accountMachinePublisherService.test.ts b/apps/ade-cli/src/services/account/accountMachinePublisherService.test.ts index d9b887d0c..19909aae8 100644 --- a/apps/ade-cli/src/services/account/accountMachinePublisherService.test.ts +++ b/apps/ade-cli/src/services/account/accountMachinePublisherService.test.ts @@ -357,7 +357,10 @@ describe("account machine publisher health", () => { let token: string | null = null; let signInListener: (() => void) | null = null; const emitSignIn = () => signInListener?.(); - const fetchImpl = vi.fn(async () => new Response(null, { status: 200 })); + const fetchImpl = vi.fn(async ( + _input: string | URL | Request, + _init?: RequestInit, + ) => new Response(null, { status: 200 })); const service = createAccountMachinePublisherService({ getAccessToken: async () => token, getAccountStatus: () => ({ @@ -394,10 +397,17 @@ describe("account machine publisher health", () => { it("coalesces relay readiness changes into a publish and resets the heartbeat", async () => { vi.useFakeTimers(); const current = routeSnapshot(); - const fetchImpl = vi.fn(async () => new Response(null, { status: 200 })); + const fetchImpl = vi.fn(async ( + _input: string | URL | Request, + _init?: RequestInit, + ) => new Response(null, { status: 200 })); const service = createAccountMachinePublisherService({ getAccessToken: async () => "account-token", - getAccountStatus: () => ({ signedIn: true, sessionReadState: "available" as const }), + getAccountStatus: () => ({ + signedIn: true, + userId: "owner-a", + sessionReadState: "available" as const, + }), getSnapshot: async () => current, getMachineKey: () => "machine-studio", directoryBaseUrl: () => "https://directory.example", @@ -412,6 +422,17 @@ describe("account machine publisher health", () => { current.routeHealth.relay.relayBridgeValidated = false; await vi.advanceTimersByTimeAsync(ACCOUNT_MACHINE_RELAY_STATE_POLL_MS); expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(JSON.parse(String(fetchImpl.mock.calls[1]?.[1]?.body))).toEqual( + expect.objectContaining({ + retainRelayEndpoints: true, + reachableEndpoints: [ + { kind: "lan", host: "192.168.1.20", port: 8787 }, + { kind: "tailnet", host: "studio.tailnet.ts.net", port: 8787 }, + { kind: "relay", url: "wss://relay.example/connect/machine-studio" }, + ], + }), + ); + expect(service.getPublisherHealth().reachableEndpointCount).toBe(3); await vi.advanceTimersByTimeAsync(ACCOUNT_MACHINE_HEARTBEAT_MS - 1); expect(fetchImpl).toHaveBeenCalledTimes(2); @@ -420,6 +441,134 @@ describe("account machine publisher health", () => { service.dispose(); }); + it("does not retain a verified Relay route across account-owner changes", async () => { + let accountOwnerId = "owner-a"; + const current = routeSnapshot(); + const fetchImpl = vi.fn(async ( + _input: string | URL | Request, + _init?: RequestInit, + ) => new Response(null, { status: 204 })); + const service = createAccountMachinePublisherService({ + getAccessToken: async () => "account-token", + getAccountStatus: () => ({ + signedIn: true, + userId: accountOwnerId, + sessionReadState: "available" as const, + }), + getSnapshot: async () => current, + getMachineKey: () => "machine-studio", + directoryBaseUrl: () => "https://directory.example", + fetchImpl, + }); + + await service.publishNow(); + current.routeHealth.relay.relayControlConnected = false; + current.routeHealth.relay.relayBridgeValidated = false; + accountOwnerId = "owner-b"; + await service.publishNow(); + + expect(JSON.parse(String(fetchImpl.mock.calls[1]?.[1]?.body))).toEqual( + expect.objectContaining({ + retainRelayEndpoints: true, + reachableEndpoints: [ + { kind: "lan", host: "192.168.1.20", port: 8787 }, + { kind: "tailnet", host: "studio.tailnet.ts.net", port: 8787 }, + ], + }), + ); + service.dispose(); + }); + + it("clears retained Relay ownership on explicit sign-out", async () => { + let signedIn = true; + const current = routeSnapshot(); + const fetchImpl = vi.fn(async ( + _input: string | URL | Request, + _init?: RequestInit, + ) => new Response(null, { status: 204 })); + const service = createAccountMachinePublisherService({ + getAccessToken: async () => "account-token", + getAccountStatus: () => ({ + signedIn, + userId: signedIn ? "owner-a" : null, + sessionReadState: signedIn ? "available" as const : "missing" as const, + }), + getSnapshot: async () => current, + getMachineKey: () => "machine-studio", + directoryBaseUrl: () => "https://directory.example", + fetchImpl, + }); + + await service.publishNow(); + signedIn = false; + await service.publishNow(); + current.routeHealth.relay.relayControlConnected = false; + current.routeHealth.relay.relayBridgeValidated = false; + signedIn = true; + await service.publishNow(); + + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(JSON.parse(String(fetchImpl.mock.calls[1]?.[1]?.body))).toEqual( + expect.objectContaining({ + retainRelayEndpoints: true, + reachableEndpoints: [ + { kind: "lan", host: "192.168.1.20", port: 8787 }, + { kind: "tailnet", host: "studio.tailnet.ts.net", port: 8787 }, + ], + }), + ); + service.dispose(); + }); + + it("clears retained Relay ownership after an authoritative authentication rejection", async () => { + const current = routeSnapshot(); + let rejectAuthentication = false; + const fetchImpl = vi.fn(async ( + _input: string | URL | Request, + _init?: RequestInit, + ) => rejectAuthentication + ? new Response(JSON.stringify({ error: "invalid token" }), { + status: 401, + headers: { "content-type": "application/json" }, + }) + : new Response(null, { status: 204 })); + const service = createAccountMachinePublisherService({ + getAccessToken: async () => "account-token", + getAccountStatus: () => ({ + signedIn: true, + userId: "owner-a", + sessionReadState: "available" as const, + }), + getSnapshot: async () => current, + getMachineKey: () => "machine-studio", + directoryBaseUrl: () => "https://directory.example", + fetchImpl, + }); + + await service.publishNow(); + current.routeHealth.relay.relayControlConnected = false; + current.routeHealth.relay.relayBridgeValidated = false; + rejectAuthentication = true; + await service.publishNow(); + expect(service.getPublisherHealth()).toMatchObject({ + state: "http_error", + lastHttpStatus: 401, + }); + + rejectAuthentication = false; + await service.publishNow(); + expect(JSON.parse(String(fetchImpl.mock.calls[3]?.[1]?.body))).toEqual( + expect.objectContaining({ + retainRelayEndpoints: true, + reachableEndpoints: [ + { kind: "lan", host: "192.168.1.20", port: 8787 }, + { kind: "tailnet", host: "studio.tailnet.ts.net", port: 8787 }, + ], + }), + ); + service.dispose(); + }); + it("publishes at the first relay poll when the startup snapshot was unavailable", async () => { vi.useFakeTimers(); let ready = false; diff --git a/apps/ade-cli/src/services/account/accountMachinePublisherService.ts b/apps/ade-cli/src/services/account/accountMachinePublisherService.ts index 8ab1e70e8..58301929a 100644 --- a/apps/ade-cli/src/services/account/accountMachinePublisherService.ts +++ b/apps/ade-cli/src/services/account/accountMachinePublisherService.ts @@ -36,6 +36,14 @@ export type AccountMachineRegistration = { deviceType: string; pubkey: null; reachableEndpoints: AdeAccountMachineEndpoint[]; + /** + * Asks a compatible directory to retain its stored Relay endpoint when this + * heartbeat catches the independently asynchronous Relay components between + * ready states. The directory scopes retention to the authenticated owner + * and machine key; current endpoints remain authoritative for every other + * route kind. + */ + retainRelayEndpoints?: true; }; type AccountMachinePublisherLogger = { @@ -53,13 +61,42 @@ export type AccountMachineRegistrationSnapshot = Pick< routeHealth: Pick; }; -type PublisherAccountStatus = Pick & { - sessionReadState: AccountSessionReadState; -}; +type PublisherAccountStatus = Pick & + Partial> & { + sessionReadState: AccountSessionReadState; + }; + +type PublishedRelayEndpoint = Extract; + +function relayEndpoints( + registration: AccountMachineRegistration, +): PublishedRelayEndpoint[] { + return registration.reachableEndpoints.filter( + (endpoint): endpoint is PublishedRelayEndpoint => endpoint.kind === "relay", + ); +} + +function withRetainedRelayEndpoints( + registration: AccountMachineRegistration, + retained: readonly PublishedRelayEndpoint[], +): AccountMachineRegistration { + if (retained.length === 0 || relayEndpoints(registration).length > 0) { + return registration; + } + const reachableEndpoints = [...registration.reachableEndpoints]; + const seen = new Set(reachableEndpoints.map((endpoint) => JSON.stringify(endpoint))); + for (const endpoint of retained) { + const key = JSON.stringify(endpoint); + if (seen.has(key)) continue; + seen.add(key); + reachableEndpoints.push(endpoint); + } + return { ...registration, reachableEndpoints }; +} function isPublisherSignedOut( status: PublisherAccountStatus | null, -): status is PublisherAccountStatus { +): boolean { return status !== null && !status.signedIn && status.source !== "env-token"; @@ -214,6 +251,11 @@ export function createAccountMachinePublisherService(options: { let inFlight: Promise | null = null; let triggeredPublishPending = false; let lastRelayPublishStateSignature: string | null = null; + let lastPublishedRelayState: { + machineKey: string; + accountOwnerId: string | null; + endpoints: PublishedRelayEndpoint[]; + } | null = null; let lastWarning: string | null = null; let transientFailureCount = 0; let unsubscribeSignIn: (() => void) | null = null; @@ -245,6 +287,33 @@ export function createAccountMachinePublisherService(options: { transientFailureCount = 0; }; + const clearRetainedRelayState = (): void => { + lastPublishedRelayState = null; + }; + + const reconcileRetainedRelayOwner = ( + status: PublisherAccountStatus | null, + ): string | null => { + if (isPublisherSignedOut(status)) { + clearRetainedRelayState(); + return null; + } + const accountOwnerId = status?.signedIn + ? status.userId?.trim() || null + : null; + if ( + lastPublishedRelayState + && lastPublishedRelayState.accountOwnerId !== accountOwnerId + // A missing owner is tolerated only when BOTH observations lack one. The + // brain publisher supplies userId, while small embedded/test publishers + // may intentionally omit account identity. + && (lastPublishedRelayState.accountOwnerId !== null || accountOwnerId !== null) + ) { + clearRetainedRelayState(); + } + return accountOwnerId; + }; + const recordOutcome = ( state: SyncAccountDirectoryHealth["state"], args: { @@ -365,12 +434,12 @@ export function createAccountMachinePublisherService(options: { return; } - const registration = buildAccountMachineRegistration({ + const observedRegistration = buildAccountMachineRegistration({ machineKey, snapshot, packageChannel: process.env.ADE_PACKAGE_CHANNEL, }); - if (!registration) { + if (!observedRegistration) { recordOutcome("machine_key_unavailable", { attemptAt, skipReason: "The machine registration could not be built.", @@ -378,8 +447,8 @@ export function createAccountMachinePublisherService(options: { }); return; } - observeRelayPublishState(relayPublishStateSignature(snapshot, registration)); - const reachableEndpointCount = registration.reachableEndpoints.length; + observeRelayPublishState(relayPublishStateSignature(snapshot, observedRegistration)); + const observedReachableEndpointCount = observedRegistration.reachableEndpoints.length; let accountStatus: PublisherAccountStatus | null = null; try { @@ -389,22 +458,53 @@ export function createAccountMachinePublisherService(options: { attemptAt, skipReason: "The ADE brain could not read account status.", directoryOrigin, - reachableEndpointCount, + reachableEndpointCount: observedReachableEndpointCount, }); return; } if (isPublisherSignedOut(accountStatus)) { - const unreadable = accountStatus.sessionReadState === "unreadable"; + clearRetainedRelayState(); + const unreadable = accountStatus?.sessionReadState === "unreadable"; recordOutcome(unreadable ? "token_unreadable" : "account_signed_out", { attemptAt, skipReason: unreadable ? "The ADE brain could not read the stored account session." : "The ADE brain is signed out of the ADE account.", directoryOrigin, - reachableEndpointCount, + reachableEndpointCount: observedReachableEndpointCount, }); return; } + const accountOwnerId = reconcileRetainedRelayOwner(accountStatus); + + // Relay readiness is sampled from multiple independently asynchronous + // components (control socket, local bridge validation, listener handoff). + // A momentary false sample must not overwrite the directory's last verified + // Relay route and strand every browser/mobile client. Retain only a route + // that THIS publisher successfully registered, for the same machine and + // account owner, while Relay remains enabled. Explicit sign-out, owner + // change, terminal auth rejection, or a genuinely disabled Relay clears the + // retention boundary. The process-local compatibility path below retains + // only a route this publisher successfully registered. + const relayTemporarilyUnavailable = snapshot.routeHealth.relay.enabled === true + && relayEndpoints(observedRegistration).length === 0; + const canRetainRelay = relayTemporarilyUnavailable + && lastPublishedRelayState?.machineKey === machineKey + && lastPublishedRelayState.accountOwnerId === accountOwnerId; + const registrationWithRetainedRelay = canRetainRelay + ? withRetainedRelayEndpoints( + observedRegistration, + lastPublishedRelayState?.endpoints ?? [], + ) + : observedRegistration; + // The server-side retention hint protects the same invariant across brain + // restarts, where this process-local compatibility cache is necessarily + // empty. Older directory deployments safely ignore the extra property and + // still benefit from the process-local retained route above. + const registration: AccountMachineRegistration = relayTemporarilyUnavailable + ? { ...registrationWithRetainedRelay, retainRelayEndpoints: true } + : registrationWithRetainedRelay; + const reachableEndpointCount = registration.reachableEndpoints.length; let accessToken: string | null = null; try { @@ -467,6 +567,9 @@ export function createAccountMachinePublisherService(options: { : responseReason; if (response.status >= 500) recordTransientFailure(); else resetPublishCadence(); + if (response.status === 401 || response.status === 403) { + clearRetainedRelayState(); + } recordOutcome("http_error", { attemptAt, skipReason: httpReason @@ -483,6 +586,14 @@ export function createAccountMachinePublisherService(options: { await response.body?.cancel().catch(() => {}); lastWarning = null; resetPublishCadence(); + const publishedRelayEndpoints = relayEndpoints(registration); + lastPublishedRelayState = publishedRelayEndpoints.length > 0 + ? { + machineKey, + accountOwnerId, + endpoints: publishedRelayEndpoints, + } + : null; recordOutcome("published", { attemptAt, skipReason: null, @@ -578,7 +689,11 @@ export function createAccountMachinePublisherService(options: { if (!started || disposed || options.isSyncEnabled?.() === false) return; try { const accountStatus = options.getAccountStatus?.() ?? null; - if (isPublisherSignedOut(accountStatus)) return; + if (isPublisherSignedOut(accountStatus)) { + clearRetainedRelayState(); + return; + } + reconcileRetainedRelayOwner(accountStatus); } catch { return; } @@ -651,6 +766,7 @@ export function createAccountMachinePublisherService(options: { dispose(): void { disposed = true; started = false; + clearRetainedRelayState(); clearHeartbeatTimer(); if (relayStatePollTimer) clearTimeout(relayStatePollTimer); relayStatePollTimer = null; @@ -693,6 +809,7 @@ export function createBrainAccountMachinePublisherService(options: { const status = accountAuthService.getStatus(); return { signedIn: status.signedIn, + userId: status.userId, source: status.source ?? null, sessionReadState: accountAuthService.getSessionReadState(), }; diff --git a/apps/ade-cli/src/services/projects/projectCatalog.ts b/apps/ade-cli/src/services/projects/projectCatalog.ts new file mode 100644 index 000000000..20ea6d406 --- /dev/null +++ b/apps/ade-cli/src/services/projects/projectCatalog.ts @@ -0,0 +1,9 @@ +export function markActiveHostProjectOpen( + projects: T[], + activeHostProjectId: string | null, +): T[] { + return projects.map((project) => { + const isOpen = project.id === activeHostProjectId; + return project.isOpen === isOpen ? project : { ...project, isOpen }; + }); +} diff --git a/apps/ade-cli/src/services/projects/projectIconResolver.test.ts b/apps/ade-cli/src/services/projects/projectIconResolver.test.ts index 30df38b73..d4741bd22 100644 --- a/apps/ade-cli/src/services/projects/projectIconResolver.test.ts +++ b/apps/ade-cli/src/services/projects/projectIconResolver.test.ts @@ -3,7 +3,10 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { resolveRemoteProjectIcon } from "./projectIconResolver"; +import { + REMOTE_ICON_MAX_DATA_URL_BYTES, + resolveRemoteProjectIcon, +} from "./projectIconResolver"; const tempRoots = new Set(); @@ -147,9 +150,11 @@ describe("resolveRemoteProjectIcon", () => { expect(icon.sourcePath).toBeNull(); }); - it("skips an oversized icon but keeps its metadata", () => { + it("never returns an encoded icon above the wire cap and keeps its metadata", () => { const root = makeTempRoot(); - const big = Buffer.alloc(2 * 1024 * 1024 + 1, 0); + // Invalid raster data makes the thumbnailer take its bounded raw-PNG + // fallback, where base64 expansion pushes this beyond the encoded cap. + const big = Buffer.alloc(REMOTE_ICON_MAX_DATA_URL_BYTES, 0); writeFileEnsuringDir(path.join(root, "logo.png"), big); const icon = resolveRemoteProjectIcon(root); diff --git a/apps/ade-cli/src/services/projects/projectIconResolver.ts b/apps/ade-cli/src/services/projects/projectIconResolver.ts index e934b6b3b..1e55e51f6 100644 --- a/apps/ade-cli/src/services/projects/projectIconResolver.ts +++ b/apps/ade-cli/src/services/projects/projectIconResolver.ts @@ -4,21 +4,20 @@ import { resolveProjectIcon, resolveProjectIconPath, } from "../../../../desktop/src/main/services/projects/projectIconResolver"; +import { + PROJECT_ICON_THUMBNAIL_MAX_DATA_URL_BYTES, + resolveMobileProjectIconDataUrl, +} from "../../../../desktop/src/main/services/projects/projectIconThumbnail"; /** * Resolves a project's icon on the machine that hosts the project files, so a * desktop connected to this brain over the remote runtime can show the real * project logo in its project tab instead of a blank folder. * - * This reuses the desktop's `resolveProjectIcon` (already in the brain bundle — - * `cli.ts` imports the same module chain for the mobile sync icon path), so the - * icon a remote desktop sees is exactly the one the host machine would show, - * and we inherit its mtime-keyed result cache. Two things are layered on top: - * 1. A wire-size cap — these icons travel inline in the `projects.list` - * payload, so anything too large is dropped. - * 2. A size preflight via `resolveProjectIconPath` BEFORE `resolveProjectIcon` - * reads/encodes/caches the data URL, so an oversized icon is never inlined - * or retained in the resolver's cache just to be discarded. + * This reuses the same 64px thumbnail path as mobile (already in the brain + * bundle) and applies a strict encoded-size cap. Project art is cosmetic and + * travels inline in `projects.list`, so a full-resolution app icon must never + * consume a relay frame or delay project bootstrap. */ export type RemoteProjectIcon = { dataUrl: string | null; @@ -26,10 +25,10 @@ export type RemoteProjectIcon = { mimeType: string | null; }; -// Cap on the raw icon file. base64 inflates ~33%, so a 2 MB file yields a -// ~2.7 MB data URL — an acceptable ceiling for inline transport, and well below -// the desktop resolver's 10 MB on-disk limit. -const REMOTE_ICON_MAX_FILE_BYTES = 2 * 1024 * 1024; +// Keep this aligned with the persisted remote-project icon boundary. The +// thumbnail normally lands well below this; the cap also protects platforms +// where no rasterizer is available and the helper falls back to a raw PNG. +export const REMOTE_ICON_MAX_DATA_URL_BYTES = PROJECT_ICON_THUMBNAIL_MAX_DATA_URL_BYTES; // Frozen so the shared singleton can't be mutated by a caller and silently // corrupt every subsequent resolve. @@ -71,26 +70,30 @@ export function resolveRemoteProjectIcon(projectRoot: string): RemoteProjectIcon } if (!iconPath) return EMPTY_ICON; - // Preflight the file size BEFORE resolveProjectIcon reads, base64-encodes, and - // caches the full data URL. Without this, an oversized icon (under the - // desktop resolver's 10 MB cap) would be inlined and retained in the shared - // result cache even though we drop it from the wire. - let size: number; - try { - size = fs.statSync(iconPath).size; - } catch { - return EMPTY_ICON; - } - if (size > REMOTE_ICON_MAX_FILE_BYTES) { - return { - dataUrl: null, - sourcePath: iconPath, - mimeType: mimeTypeForIconPath(iconPath), - }; - } - try { - return resolveProjectIcon(root); + const thumbnailDataUrl = resolveMobileProjectIconDataUrl(root, { + resolvedSourcePath: iconPath, + }); + // Headless hosts may not have a rasterizer. Small originals remain safe to + // inline (and preserve SVG/JPEG/WebP project art); larger originals are + // never read merely to discover that base64 would exceed the wire cap. + const dataUrl = thumbnailDataUrl ?? ( + fs.statSync(iconPath).size <= 96 * 1024 + ? resolveProjectIcon(root).dataUrl + : null + ); + if ( + !dataUrl + || Buffer.byteLength(dataUrl, "utf8") > REMOTE_ICON_MAX_DATA_URL_BYTES + ) { + return { + dataUrl: null, + sourcePath: iconPath, + mimeType: mimeTypeForIconPath(iconPath), + }; + } + const mimeType = /^data:([^;,]+)[;,]/i.exec(dataUrl)?.[1] ?? null; + return { dataUrl, sourcePath: iconPath, mimeType }; } catch { return EMPTY_ICON; } diff --git a/apps/ade-cli/src/services/projects/projectScope.test.ts b/apps/ade-cli/src/services/projects/projectScope.test.ts index 68293f4f9..57f6c77d7 100644 --- a/apps/ade-cli/src/services/projects/projectScope.test.ts +++ b/apps/ade-cli/src/services/projects/projectScope.test.ts @@ -2,11 +2,22 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { markActiveHostProjectOpen } from "./projectCatalog"; import { ProjectRegistry } from "./projectRegistry"; import { ProjectScopeRegistry } from "./projectScope"; const createAdeRuntimeMock = vi.fn(); +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((innerResolve, innerReject) => { + resolve = innerResolve; + reject = innerReject; + }); + return { promise, resolve, reject }; +} + vi.mock("../../bootstrap", () => ({ createAdeRuntime: createAdeRuntimeMock, })); @@ -102,7 +113,7 @@ describe("ProjectScopeRegistry", () => { await scopeRegistry.disposeAll(); }); - it("prewarms only recent projects after starting an explicit system host", async () => { + it("keeps inactive recent projects cold after starting an explicit system host", async () => { const { registry, first, second } = createRegistry(); const projectsRoot = path.dirname(first.rootPath); const thirdProjectRoot = path.join(projectsRoot, "third"); @@ -146,17 +157,13 @@ describe("ProjectScopeRegistry", () => { }); await scopeRegistry.ensureSyncHost(first.projectId); - const warmed = await scopeRegistry.prewarmRecentScopes({ - excludeProjectId: first.projectId, - limit: 2, - }); + await new Promise((resolve) => setImmediate(resolve)); - expect(warmed).toEqual([recentSecond.projectId, recentThird.projectId]); expect(createAdeRuntimeMock.mock.calls.map(([args]) => args.projectRoot)).toEqual([ first.rootPath, - recentSecond.rootPath, - recentThird.rootPath, ]); + expect(scopeRegistry.getIfBooted(recentSecond.projectId)).toBeNull(); + expect(scopeRegistry.getIfBooted(recentThird.projectId)).toBeNull(); expect(createAdeRuntimeMock).not.toHaveBeenCalledWith( expect.objectContaining({ projectRoot: healthProbe.rootPath }), ); @@ -165,7 +172,7 @@ describe("ProjectScopeRegistry", () => { }); it("warms the most recently opened project as the sync host", async () => { - const { registry, first, second } = createRegistry(); + const { registry, first } = createRegistry(); const file = JSON.parse(fs.readFileSync(registry.path, "utf8")) as { projects: Array<{ projectId: string; lastOpenedAt: number; addedAt: number }>; }; @@ -194,8 +201,8 @@ describe("ProjectScopeRegistry", () => { projectRoot: first.rootPath, syncRuntime: { enabled: true, - hostStartupEnabled: true, - hostDiscoveryEnabled: true, + hostStartupEnabled: false, + hostDiscoveryEnabled: false, initializeInBackground: true, }, }); @@ -250,8 +257,8 @@ describe("ProjectScopeRegistry", () => { projectRoot: second.rootPath, syncRuntime: { enabled: true, - hostStartupEnabled: true, - hostDiscoveryEnabled: true, + hostStartupEnabled: false, + hostDiscoveryEnabled: false, initializeInBackground: true, }, }); @@ -261,6 +268,354 @@ describe("ProjectScopeRegistry", () => { expect(secondDispose).toHaveBeenCalledTimes(1); }); + it("keeps the previous host active past parked-peer grace throughout a slow target boot", async () => { + vi.useFakeTimers(); + try { + const { registry, first, second } = createRegistry(); + const targetRuntime = deferred(); + const firstSyncService = { + initialize: vi.fn(async () => undefined), + setHostDiscoveryEnabled: vi.fn(), + setHostStartupEnabled: vi.fn(async () => undefined), + }; + const secondSyncService = { + initialize: vi.fn(async () => undefined), + setHostDiscoveryEnabled: vi.fn(), + setHostStartupEnabled: vi.fn(async () => undefined), + }; + createAdeRuntimeMock + .mockResolvedValueOnce({ dispose: vi.fn(), syncService: firstSyncService }) + .mockImplementationOnce(() => targetRuntime.promise); + const scopeRegistry = new ProjectScopeRegistry(registry, { + syncRuntime: { + enabled: true, + hostStartupEnabled: true, + hostDiscoveryEnabled: true, + forceHostRole: false, + runtimeKind: "daemon", + }, + }); + await scopeRegistry.switchSyncHost(first.projectId); + firstSyncService.setHostDiscoveryEnabled.mockClear(); + firstSyncService.setHostStartupEnabled.mockClear(); + + const switching = scopeRegistry.switchSyncHost(second.projectId); + await vi.advanceTimersByTimeAsync(30_001); + + // Parked peers are closed after 30s. The previous listener must still own + // them even when cold target setup outlives that entire grace period. + expect(scopeRegistry.getActiveSyncHostProjectId()).toBe(first.projectId); + expect(firstSyncService.setHostDiscoveryEnabled).not.toHaveBeenCalledWith(false); + expect(firstSyncService.setHostStartupEnabled).not.toHaveBeenCalledWith(false); + targetRuntime.resolve({ dispose: vi.fn(), syncService: secondSyncService }); + await switching; + expect(scopeRegistry.getActiveSyncHostProjectId()).toBe(second.projectId); + expect(firstSyncService.setHostDiscoveryEnabled).toHaveBeenCalledWith(false); + expect(secondSyncService.setHostDiscoveryEnabled).toHaveBeenCalledWith(true); + } finally { + vi.useRealTimers(); + } + }); + + it("restores the previous host when target activation fails", async () => { + const { registry, first, second } = createRegistry(); + const firstSyncService = { + initialize: vi.fn(async () => undefined), + setHostDiscoveryEnabled: vi.fn(), + setHostStartupEnabled: vi.fn(async () => undefined), + }; + const secondSyncService = { + initialize: vi.fn(async () => undefined), + setHostDiscoveryEnabled: vi.fn(), + setHostStartupEnabled: vi.fn(async (enabled: boolean) => { + if (enabled) throw new Error("target activation failed"); + }), + }; + createAdeRuntimeMock + .mockResolvedValueOnce({ dispose: vi.fn(), syncService: firstSyncService }) + .mockResolvedValueOnce({ dispose: vi.fn(), syncService: secondSyncService }); + const scopeRegistry = new ProjectScopeRegistry(registry, { + syncRuntime: { + enabled: true, + hostStartupEnabled: true, + hostDiscoveryEnabled: true, + forceHostRole: false, + runtimeKind: "daemon", + }, + }); + await scopeRegistry.switchSyncHost(first.projectId); + firstSyncService.setHostDiscoveryEnabled.mockClear(); + firstSyncService.setHostStartupEnabled.mockClear(); + + await expect(scopeRegistry.switchSyncHost(second.projectId)).rejects.toThrow("target activation failed"); + + expect(scopeRegistry.getActiveSyncHostProjectId()).toBe(first.projectId); + expect(firstSyncService.setHostStartupEnabled).toHaveBeenNthCalledWith(1, false); + expect(firstSyncService.setHostStartupEnabled).toHaveBeenNthCalledWith(2, true); + expect(secondSyncService.setHostStartupEnabled).toHaveBeenCalledWith(false); + }); + + it("coalesces a queued A -> B -> C selection before booting the superseded target", async () => { + const { registry, first, second } = createRegistry(); + const thirdRoot = path.join(path.dirname(first.rootPath), "third-concurrent"); + fs.mkdirSync(thirdRoot, { recursive: true }); + const third = registry.add(thirdRoot); + const thirdRuntime = deferred(); + const makeSyncService = () => ({ + initialize: vi.fn(async () => undefined), + setHostDiscoveryEnabled: vi.fn(), + setHostStartupEnabled: vi.fn(async () => undefined), + }); + const firstSyncService = makeSyncService(); + const secondSyncService = makeSyncService(); + const thirdSyncService = makeSyncService(); + createAdeRuntimeMock + .mockResolvedValueOnce({ dispose: vi.fn(), syncService: firstSyncService }) + .mockImplementationOnce(() => thirdRuntime.promise); + const scopeRegistry = new ProjectScopeRegistry(registry, { + syncRuntime: { + enabled: true, + hostStartupEnabled: true, + hostDiscoveryEnabled: true, + forceHostRole: false, + runtimeKind: "daemon", + }, + }); + await scopeRegistry.switchSyncHost(first.projectId); + firstSyncService.setHostStartupEnabled.mockClear(); + + const switchToSecond = scopeRegistry.switchSyncHost(second.projectId); + const switchToThird = scopeRegistry.switchSyncHost(third.projectId); + await new Promise((resolve) => setImmediate(resolve)); + expect(createAdeRuntimeMock.mock.calls.map(([args]) => args.projectRoot)).toEqual([ + first.rootPath, + third.rootPath, + ]); + thirdRuntime.resolve({ dispose: vi.fn(), syncService: thirdSyncService }); + const [secondResult, thirdResult] = await Promise.all([switchToSecond, switchToThird]); + + expect(secondResult).toBeNull(); + expect(thirdResult?.registryProjectId).toBe(third.projectId); + expect(scopeRegistry.getActiveSyncHostProjectId()).toBe(third.projectId); + expect(secondSyncService.initialize).not.toHaveBeenCalled(); + expect(secondSyncService.setHostStartupEnabled).not.toHaveBeenCalledWith(true); + expect(thirdSyncService.setHostStartupEnabled).toHaveBeenCalledWith(true); + expect(firstSyncService.setHostStartupEnabled).toHaveBeenCalledTimes(1); + expect(firstSyncService.setHostStartupEnabled).toHaveBeenCalledWith(false); + }); + + it("does not let a never-resolving obsolete cold boot delay the newest host", async () => { + vi.useFakeTimers(); + try { + const { registry, first, second } = createRegistry(); + const thirdRoot = path.join(path.dirname(first.rootPath), "third-after-stuck-boot"); + fs.mkdirSync(thirdRoot, { recursive: true }); + const third = registry.add(thirdRoot); + const stuckSecondRuntime = deferred(); + const makeSyncService = () => ({ + initialize: vi.fn(async () => undefined), + setHostDiscoveryEnabled: vi.fn(), + setHostStartupEnabled: vi.fn(async () => undefined), + }); + const firstSyncService = makeSyncService(); + const thirdSyncService = makeSyncService(); + createAdeRuntimeMock + .mockResolvedValueOnce({ dispose: vi.fn(), syncService: firstSyncService }) + .mockImplementationOnce(() => stuckSecondRuntime.promise) + .mockResolvedValueOnce({ dispose: vi.fn(), syncService: thirdSyncService }); + const scopeRegistry = new ProjectScopeRegistry(registry, { + syncRuntime: { + enabled: true, + hostStartupEnabled: true, + hostDiscoveryEnabled: true, + forceHostRole: false, + runtimeKind: "daemon", + }, + }); + await scopeRegistry.switchSyncHost(first.projectId); + firstSyncService.setHostStartupEnabled.mockClear(); + + const switchToSecond = scopeRegistry.switchSyncHost(second.projectId); + const secondRejection = expect(switchToSecond).rejects.toThrow( + `Sync host cold boot for ${second.projectId} timed out`, + ); + await vi.advanceTimersByTimeAsync(0); + expect(createAdeRuntimeMock).toHaveBeenCalledTimes(2); + + const switchedToThird = await scopeRegistry.switchSyncHost(third.projectId); + expect(switchedToThird?.registryProjectId).toBe(third.projectId); + expect(scopeRegistry.getActiveSyncHostProjectId()).toBe(third.projectId); + expect(firstSyncService.setHostStartupEnabled).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(60_001); + await secondRejection; + expect(scopeRegistry.getIfBooted(second.projectId)).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it("disposes a late cold-boot completion without deleting a successful retry", async () => { + vi.useFakeTimers(); + try { + const { registry, first, second } = createRegistry(); + const staleRuntime = deferred(); + const staleDispose = vi.fn(); + const makeSyncService = () => ({ + initialize: vi.fn(async () => undefined), + setHostDiscoveryEnabled: vi.fn(), + setHostStartupEnabled: vi.fn(async () => undefined), + }); + const firstSyncService = makeSyncService(); + const staleSyncService = makeSyncService(); + const retrySyncService = makeSyncService(); + createAdeRuntimeMock + .mockResolvedValueOnce({ dispose: vi.fn(), syncService: firstSyncService }) + .mockImplementationOnce(() => staleRuntime.promise) + .mockResolvedValueOnce({ dispose: vi.fn(), syncService: retrySyncService }); + const scopeRegistry = new ProjectScopeRegistry(registry, { + syncRuntime: { + enabled: true, + hostStartupEnabled: true, + hostDiscoveryEnabled: true, + forceHostRole: false, + runtimeKind: "daemon", + }, + }); + await scopeRegistry.switchSyncHost(first.projectId); + + const firstAttempt = scopeRegistry.switchSyncHost(second.projectId); + const rejection = expect(firstAttempt).rejects.toThrow( + `Sync host cold boot for ${second.projectId} timed out`, + ); + await vi.advanceTimersByTimeAsync(60_001); + await rejection; + + const retryScope = await scopeRegistry.switchSyncHost(second.projectId); + expect(retryScope?.runtime.syncService).toBe(retrySyncService); + expect(scopeRegistry.getActiveSyncHostProjectId()).toBe(second.projectId); + + staleRuntime.resolve({ dispose: staleDispose, syncService: staleSyncService }); + await vi.advanceTimersByTimeAsync(0); + expect(staleDispose).toHaveBeenCalledTimes(1); + await expect(scopeRegistry.getIfBooted(second.projectId)).resolves.toBe(retryScope); + } finally { + vi.useRealTimers(); + } + }); + + it("bounds a never-resolving target initialization without disabling the old host", async () => { + vi.useFakeTimers(); + try { + const { registry, first, second } = createRegistry(); + const stuckInitialization = deferred(); + const firstSyncService = { + initialize: vi.fn(async () => undefined), + setHostDiscoveryEnabled: vi.fn(), + setHostStartupEnabled: vi.fn(async () => undefined), + }; + const secondSyncService = { + initialize: vi.fn(() => stuckInitialization.promise), + setHostDiscoveryEnabled: vi.fn(), + setHostStartupEnabled: vi.fn(async () => undefined), + }; + createAdeRuntimeMock + .mockResolvedValueOnce({ dispose: vi.fn(), syncService: firstSyncService }) + .mockResolvedValueOnce({ dispose: vi.fn(), syncService: secondSyncService }); + const scopeRegistry = new ProjectScopeRegistry(registry, { + syncRuntime: { + enabled: true, + hostStartupEnabled: true, + hostDiscoveryEnabled: true, + forceHostRole: false, + runtimeKind: "daemon", + }, + }); + await scopeRegistry.switchSyncHost(first.projectId); + firstSyncService.setHostDiscoveryEnabled.mockClear(); + firstSyncService.setHostStartupEnabled.mockClear(); + + const switching = scopeRegistry.switchSyncHost(second.projectId); + const rejection = expect(switching).rejects.toThrow( + `Sync host initialization for ${second.projectId} timed out`, + ); + await vi.advanceTimersByTimeAsync(30_001); + await rejection; + + expect(scopeRegistry.getActiveSyncHostProjectId()).toBe(first.projectId); + expect(firstSyncService.setHostDiscoveryEnabled).not.toHaveBeenCalledWith(false); + expect(firstSyncService.setHostStartupEnabled).not.toHaveBeenCalledWith(false); + expect(secondSyncService.setHostStartupEnabled).not.toHaveBeenCalledWith(true); + expect(scopeRegistry.getIfBooted(second.projectId)).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it("times out a stuck activation, rolls back, and releases the mutation tail", async () => { + vi.useFakeTimers(); + try { + const { registry, first, second } = createRegistry(); + const thirdRoot = path.join(path.dirname(first.rootPath), "third-after-stuck-activation"); + fs.mkdirSync(thirdRoot, { recursive: true }); + const third = registry.add(thirdRoot); + const stuckActivation = deferred(); + const firstSyncService = { + initialize: vi.fn(async () => undefined), + setHostDiscoveryEnabled: vi.fn(), + setHostStartupEnabled: vi.fn(async () => undefined), + }; + const secondSyncService = { + initialize: vi.fn(async () => undefined), + setHostDiscoveryEnabled: vi.fn(), + setHostStartupEnabled: vi.fn((enabled: boolean) => ( + enabled ? stuckActivation.promise : Promise.resolve() + )), + }; + const thirdSyncService = { + initialize: vi.fn(async () => undefined), + setHostDiscoveryEnabled: vi.fn(), + setHostStartupEnabled: vi.fn(async () => undefined), + }; + createAdeRuntimeMock + .mockResolvedValueOnce({ dispose: vi.fn(), syncService: firstSyncService }) + .mockResolvedValueOnce({ dispose: vi.fn(), syncService: secondSyncService }) + .mockResolvedValueOnce({ dispose: vi.fn(), syncService: thirdSyncService }); + const scopeRegistry = new ProjectScopeRegistry(registry, { + syncRuntime: { + enabled: true, + hostStartupEnabled: true, + hostDiscoveryEnabled: true, + forceHostRole: false, + runtimeKind: "daemon", + }, + }); + await scopeRegistry.switchSyncHost(first.projectId); + firstSyncService.setHostStartupEnabled.mockClear(); + + const switching = scopeRegistry.switchSyncHost(second.projectId); + const rejection = expect(switching).rejects.toThrow( + `Sync host activation for ${second.projectId} timed out`, + ); + await vi.advanceTimersByTimeAsync(0); + expect(scopeRegistry.getActiveSyncHostProjectId()).toBe(first.projectId); + expect(firstSyncService.setHostStartupEnabled).toHaveBeenCalledWith(false); + expect(secondSyncService.setHostStartupEnabled).toHaveBeenCalledWith(true); + + await vi.advanceTimersByTimeAsync(10_001); + await rejection; + expect(scopeRegistry.getActiveSyncHostProjectId()).toBe(first.projectId); + expect(secondSyncService.setHostStartupEnabled).toHaveBeenCalledWith(false); + expect(firstSyncService.setHostStartupEnabled).toHaveBeenLastCalledWith(true); + + const switchedToThird = await scopeRegistry.switchSyncHost(third.projectId); + expect(switchedToThird?.registryProjectId).toBe(third.projectId); + expect(scopeRegistry.getActiveSyncHostProjectId()).toBe(third.projectId); + } finally { + vi.useRealTimers(); + } + }); + it("can prepare a new phone sync host before retiring the previous host", async () => { const { registry, first, second } = createRegistry(); const firstSyncService = { @@ -352,5 +707,33 @@ describe("ProjectScopeRegistry", () => { await scopeRegistry.disposeAll(); }); +}); + +describe("markActiveHostProjectOpen", () => { + it("marks the current sync host open even when a stale project is first", () => { + const catalog = [ + { id: "project_stale_mru", displayName: "Stale MRU", isOpen: false }, + { id: "project_active", displayName: "Active host", isOpen: false }, + ]; + + const updated = markActiveHostProjectOpen(catalog, "project_active"); + + expect(updated).toEqual([ + { id: "project_stale_mru", displayName: "Stale MRU", isOpen: false }, + { id: "project_active", displayName: "Active host", isOpen: true }, + ]); + expect(updated.find((project) => project.isOpen)?.id).toBe("project_active"); + }); + + it("moves the open marker when the active sync host changes", () => { + const catalog = [ + { id: "project_previous", isOpen: true }, + { id: "project_current", isOpen: false }, + ]; + expect(markActiveHostProjectOpen(catalog, "project_current")).toEqual([ + { id: "project_previous", isOpen: false }, + { id: "project_current", isOpen: true }, + ]); + }); }); diff --git a/apps/ade-cli/src/services/projects/projectScope.ts b/apps/ade-cli/src/services/projects/projectScope.ts index 68c106f55..7a9f3a874 100644 --- a/apps/ade-cli/src/services/projects/projectScope.ts +++ b/apps/ade-cli/src/services/projects/projectScope.ts @@ -1,15 +1,39 @@ import type { AdeRuntime, AdeRuntimeSyncOptions } from "../../bootstrap"; import type { SyncCommandPayload } from "../../../../desktop/src/shared/types"; -import { ProjectRegistry, type ProjectId, type ProjectRecord } from "./projectRegistry"; +import type { ProjectId, ProjectRecord, ProjectRegistry } from "./projectRegistry"; type SwitchSyncHostOptions = { deactivatePreviousHost?: boolean; }; -type PrewarmRecentScopesOptions = { - excludeProjectId?: ProjectId | null; - limit?: number; -}; +const SYNC_HOST_COLD_BOOT_TIMEOUT_MS = 60_000; +const SYNC_HOST_INITIALIZE_TIMEOUT_MS = 30_000; +const SYNC_HOST_CONFIGURE_TIMEOUT_MS = 10_000; + +class SyncHostPhaseTimeoutError extends Error {} + +async function runSyncHostPhase( + phase: string, + projectId: ProjectId, + timeoutMs: number, + operation: () => T | Promise, +): Promise { + let timer: ReturnType | null = null; + const work = Promise.resolve().then(operation); + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new SyncHostPhaseTimeoutError( + `Sync host ${phase} for ${projectId} timed out after ${timeoutMs}ms.`, + )); + }, timeoutMs); + timer.unref?.(); + }); + try { + return await Promise.race([work, timeout]); + } finally { + if (timer) clearTimeout(timer); + } +} export class ProjectScope { readonly registryProjectId: ProjectId; @@ -35,9 +59,9 @@ export class ProjectScopeRegistry { private readonly scopes = new Map>(); private readonly disposeListeners = new Set<(projectId: ProjectId) => void>(); private syncHostProjectId: ProjectId | null = null; - private syncHostTransitionDepth = 0; - private prewarmStarted = false; - private disposed = false; + private syncHostTransitionTail: Promise = Promise.resolve(); + private latestSyncHostTransitionId = 0; + private latestSyncHostTransitionProjectId: ProjectId | null = null; private readonly remoteCommandExecutor = { execute: async (payload: SyncCommandPayload): Promise => { return await this.executeRemoteCommand(payload); @@ -108,9 +132,15 @@ export class ProjectScopeRegistry { try { return await pending; } catch (error) { - this.scopes.delete(projectId); - if (this.syncHostProjectId === projectId) { - this.syncHostProjectId = null; + // A timed-out cold sync-host boot can be evicted and retried while the + // original createAdeRuntime() promise is still settling. Never let that + // stale completion delete a newer retry from the cache or clear a host + // that the retry successfully promoted. + if (this.scopes.get(projectId) === pending) { + this.scopes.delete(projectId); + if (this.syncHostProjectId === projectId) { + this.syncHostProjectId = null; + } } throw error; } @@ -132,50 +162,10 @@ export class ProjectScopeRegistry { } async disposeAll(): Promise { - this.disposed = true; const projectIds = [...this.scopes.keys()]; await Promise.all(projectIds.map((projectId) => this.dispose(projectId))); } - /** - * One-shot background warm-up for at most two MRU project scopes. Warming - * never changes registry recency and never starts while a sync-host switch - * is active; the active host is already warm and should be excluded by the - * startup hook. - */ - async prewarmRecentScopes( - options: PrewarmRecentScopesOptions = {}, - ): Promise { - if (this.prewarmStarted || this.disposed || this.syncHostTransitionDepth > 0) { - return []; - } - this.prewarmStarted = true; - const limit = Math.min(2, Math.max(0, Math.trunc(options.limit ?? 2))); - const candidates = this.projectRegistry - .list() - .filter((record) => record.catalogVisibility === "recent") - .filter((record) => record.projectId !== options.excludeProjectId) - .filter((record) => !this.scopes.has(record.projectId)) - .sort((left, right) => { - const openedDelta = right.lastOpenedAt - left.lastOpenedAt; - return openedDelta !== 0 ? openedDelta : right.addedAt - left.addedAt; - }) - .slice(0, limit); - - const warmed: ProjectId[] = []; - for (const record of candidates) { - if (this.disposed || this.syncHostTransitionDepth > 0) break; - try { - await this.get(record.projectId, { touch: false }); - warmed.push(record.projectId); - } catch { - // Prewarming is opportunistic. A later real project open retries get() - // normally and surfaces its own actionable error. - } - } - return warmed; - } - async ensureSyncHost( projectId?: ProjectId, options?: SwitchSyncHostOptions, @@ -213,50 +203,166 @@ export class ProjectScopeRegistry { options: SwitchSyncHostOptions = {}, ): Promise { if (!this.options.syncRuntime?.enabled) return null; - this.syncHostTransitionDepth += 1; + const transitionId = ++this.latestSyncHostTransitionId; + this.latestSyncHostTransitionProjectId = projectId; + + // Coalesce calls made in one turn before any cold runtime work begins. + // Once booting has started it stays outside the authority-mutation tail, + // so a slow obsolete target cannot delay a newer ready target. + await Promise.resolve(); + if (transitionId !== this.latestSyncHostTransitionId) return null; + + const scopePromiseBeforeBoot = this.scopes.get(projectId) ?? null; + const scopeOperation = this.get(projectId); + const scopePromise = this.scopes.get(projectId) ?? scopePromiseBeforeBoot; + let scope: ProjectScope; try { - const previousHostId = this.syncHostProjectId; - const deactivatePreviousHost = options.deactivatePreviousHost ?? true; - if (previousHostId && previousHostId !== projectId && deactivatePreviousHost) { - await this.configureCachedSyncHost(previousHostId, false); + scope = await runSyncHostPhase( + "cold boot", + projectId, + SYNC_HOST_COLD_BOOT_TIMEOUT_MS, + () => scopeOperation, + ); + } catch (error) { + if ( + error instanceof SyncHostPhaseTimeoutError + && !scopePromiseBeforeBoot + && this.canAbandonTransitionScope(projectId, transitionId) + ) { + this.abandonTransitionScope(projectId, scopePromise); } - this.syncHostProjectId = projectId; - try { - const scope = await this.get(projectId); - await this.configureSyncHost(scope, true); - return scope; - } catch (error) { - // A failing get() already nulls syncHostProjectId, so check for both. - if (this.syncHostProjectId === projectId) { - this.syncHostProjectId = deactivatePreviousHost ? null : previousHostId; - } - if ( - this.syncHostProjectId == null - && deactivatePreviousHost - && previousHostId - && previousHostId !== projectId - ) { - // The previous host was already stopped. With a brain-level shared - // sync listener that leaves NO host owning the socket: reconnecting - // phones would park until the grace close (4002), forever, since - // nothing else restarts a host. Restore the known-good previous - // host before surfacing the failure. - try { - const previousScope = await this.get(previousHostId); - await this.configureSyncHost(previousScope, true); - this.syncHostProjectId = previousHostId; - } catch { - // Leave syncHostProjectId null; resolveActiveSyncHost() (e.g. the - // next prepareProjectConnection) is the remaining recovery path. + throw error; + } + if (transitionId !== this.latestSyncHostTransitionId) return null; + + try { + await runSyncHostPhase( + "initialization", + projectId, + SYNC_HOST_INITIALIZE_TIMEOUT_MS, + async () => await scope.runtime.syncService?.initialize(), + ); + } catch (error) { + if ( + error instanceof SyncHostPhaseTimeoutError + && !scopePromiseBeforeBoot + && this.canAbandonTransitionScope(projectId, transitionId) + ) { + this.abandonTransitionScope(projectId, scopePromise); + } + throw error; + } + if (transitionId !== this.latestSyncHostTransitionId) return null; + + const work = this.syncHostTransitionTail.then( + () => transitionId === this.latestSyncHostTransitionId + ? this.performSyncHostSwitch(scope, options, transitionId) + : null, + () => transitionId === this.latestSyncHostTransitionId + ? this.performSyncHostSwitch(scope, options, transitionId) + : null, + ); + this.syncHostTransitionTail = work.then( + () => undefined, + () => undefined, + ); + return await work; + } + + private async performSyncHostSwitch( + scope: ProjectScope, + options: SwitchSyncHostOptions, + transitionId: number, + ): Promise { + const projectId = scope.registryProjectId; + const previousHostId = this.syncHostProjectId; + const deactivatePreviousHost = options.deactivatePreviousHost ?? true; + + if (transitionId !== this.latestSyncHostTransitionId) return null; + if (previousHostId === projectId) { + await this.configureSyncHostWithTimeout(scope, true, "activation"); + return scope; + } + + let previousDeactivationAttempted = false; + let targetActivationAttempted = false; + const rollback = async (): Promise => { + if (targetActivationAttempted) { + await this.configureSyncHostWithTimeout(scope, false, "rollback deactivation") + .catch(() => {}); + } + if (previousHostId && previousDeactivationAttempted) { + try { + const previousScope = await this.getCachedScopeWithinTimeout(previousHostId); + if (!previousScope) { + this.syncHostProjectId = null; + return; } + await this.configureSyncHostWithTimeout(previousScope, true, "rollback restoration"); + } catch { + this.syncHostProjectId = null; } - throw error; } - } finally { - this.syncHostTransitionDepth = Math.max(0, this.syncHostTransitionDepth - 1); + }; + + try { + if (previousHostId && deactivatePreviousHost) { + previousDeactivationAttempted = true; + const previousScope = await this.getCachedScopeWithinTimeout(previousHostId); + if (previousScope) { + await this.configureSyncHostWithTimeout(previousScope, false, "previous-host deactivation"); + } + } + if (transitionId !== this.latestSyncHostTransitionId) { + await rollback(); + return null; + } + + targetActivationAttempted = true; + await this.configureSyncHostWithTimeout(scope, true, "activation"); + if (transitionId !== this.latestSyncHostTransitionId) { + await rollback(); + return null; + } + + // Publish authority only after the target has fully activated. Until + // this assignment every failure/timeout still resolves to the old host. + this.syncHostProjectId = projectId; + return scope; + } catch (error) { + await rollback(); + throw error; } } + private canAbandonTransitionScope(projectId: ProjectId, transitionId: number): boolean { + return transitionId === this.latestSyncHostTransitionId + || this.latestSyncHostTransitionProjectId !== projectId; + } + + private abandonTransitionScope( + projectId: ProjectId, + pending: Promise | null, + ): void { + if (!pending || this.scopes.get(projectId) !== pending) return; + this.scopes.delete(projectId); + void pending.then( + (scope) => scope.dispose(), + () => undefined, + ); + } + + private async getCachedScopeWithinTimeout(projectId: ProjectId): Promise { + const cached = this.scopes.get(projectId); + if (!cached) return null; + return await runSyncHostPhase( + "cached-scope lookup", + projectId, + SYNC_HOST_CONFIGURE_TIMEOUT_MS, + () => cached, + ); + } + async deactivateInactiveSyncHosts(activeProjectId: ProjectId | null = this.syncHostProjectId): Promise { if (!activeProjectId) return; await Promise.all( @@ -272,19 +378,44 @@ export class ProjectScopeRegistry { ): Promise { const cached = this.scopes.get(projectId); if (!cached) return; - const scope = await cached.catch(() => null); - if (scope) await this.configureSyncHost(scope, enabled); + const scope = await runSyncHostPhase( + "cached-scope lookup", + projectId, + SYNC_HOST_CONFIGURE_TIMEOUT_MS, + () => cached, + ).catch(() => null); + if (scope) { + await this.configureSyncHostWithTimeout( + scope, + enabled, + enabled ? "activation" : "deactivation", + ); + } + } + + private async configureSyncHostWithTimeout( + scope: ProjectScope, + enabled: boolean, + phase: string, + ): Promise { + await runSyncHostPhase( + phase, + scope.registryProjectId, + SYNC_HOST_CONFIGURE_TIMEOUT_MS, + () => this.configureSyncHost(scope, enabled, { initialize: false }), + ); } private async configureSyncHost( scope: ProjectScope, enabled: boolean, + options: { initialize?: boolean } = {}, ): Promise { const syncService = scope.runtime.syncService; if (!syncService) return; syncService.setHostDiscoveryEnabled?.(enabled); await syncService.setHostStartupEnabled?.(enabled); - if (enabled) await syncService.initialize(); + if (enabled && options.initialize !== false) await syncService.initialize(); } private buildSyncRuntimeOptions(projectId: ProjectId, isHost: boolean): AdeRuntimeSyncOptions | null { diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts index d37f357a9..bdc737101 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -14,12 +14,19 @@ import type { PersonalChatScopeContract, SyncChangesetAckPayload, SyncChangesetBatchPayload, + SyncInvalidationBatchPayload, SyncMobileProjectSummary, SyncPeerMetadata, SyncProjectCatalogPayload, SyncRemoteCommandDescriptor, } from "../../../../desktop/src/shared/types"; -import { SYNC_RELAY_REAUTHORIZE_V1_CAPABILITY } from "../../../../desktop/src/shared/types"; +import { + SYNC_COMPACT_INVALIDATION_V1_CAPABILITY, + SYNC_INVALIDATION_BATCH_MAX_ENVELOPE_BYTES, + SYNC_INVALIDATION_TABLE_MAX_BYTES, + SYNC_INVALIDATION_ONLY_V1_CAPABILITY, + SYNC_RELAY_REAUTHORIZE_V1_CAPABILITY, +} from "../../../../desktop/src/shared/types"; import { MOBILE_SYNC_COMPATIBILITY_CONTRACT_VERSION, MOBILE_SYNC_OPTIONAL_REMOTE_COMMAND_ACTIONS, @@ -30,13 +37,18 @@ import { CHAT_EVENT_REPLAY_MAX_EVENTS, CONNECTION_ATTEMPT_RESERVATION_TTL_MS, SYNC_HOST_CHAT_ACTIVE_BACKGROUND_BACKPRESSURE_BYTES, - SYNC_HOST_CHAT_ACTIVE_MAX_CHANGESET_DEFER_MS, + SYNC_HOST_CHAT_TRANSCRIPT_DELTA_MAX_BYTES, + SYNC_HOST_CHAT_TRANSCRIPT_MAX_RECORD_BYTES, + SYNC_HOST_PRIORITY_MAX_CHANGESET_DEFER_MS, + buildSyncInvalidationBatchPayload, buildSyncHostHelloOkPayload, buildSyncProjectCatalogMessages, compactChatEventEnvelopeForSync, createChatEventReplayBuffer, createSyncHostService, createTerminalInputDedupeLedger, + adoptedSyncHostCursorForPeer, + initialSyncHostCursorForPeer, isRuntimeOnlySyncPeer, isRuntimeHostPairingRecord, planChatEventResume, @@ -56,7 +68,7 @@ import { buildRelayReauthorizationChallenge, sha256RelayToken, } from "./relayAuthorization"; -import { encodeSyncEnvelope, parseSyncEnvelope, SYNC_RUNTIME_ONLY_CAPABILITY, wsDataToText, type ParsedSyncEnvelope } from "./syncProtocol"; +import { encodeSyncEnvelope, parseSyncEnvelope, PEER_BACKPRESSURE_BYTES, SYNC_RUNTIME_ONLY_CAPABILITY, wsDataToText, type ParsedSyncEnvelope } from "./syncProtocol"; import { EncryptedFileCredentialStore } from "../credentials/credentialStore"; import { verifyClerkAccountAttestation } from "../account/accountAttestationVerifier"; @@ -187,6 +199,99 @@ describe("buildSyncHostHelloOkPayload", () => { expect(syncConnectionTransportForOrigin("relay-bridge")).toBe("relay"); }); + it("acknowledges invalidation-only sync only to peers that requested it", () => { + const peer = { + deviceId: "browser-1", + deviceName: "ADE Browser", + platform: "macOS", + deviceType: "browser", + siteId: "browser-site-1", + dbVersion: 0, + capabilities: [SYNC_INVALIDATION_ONLY_V1_CAPABILITY], + } satisfies SyncPeerMetadata; + const base = { + brain: peer, + serverDbVersion: 0, + heartbeatIntervalMs: 30_000, + pollIntervalMs: 400, + projectCatalog: { projects: [] }, + projectCatalogEnabled: false, + projectActionsEnabled: false, + crossProjectChatEnabled: false, + remoteCommandSupportedActions: [], + remoteCommandDescriptors: [], + localCommandDescriptors: [], + }; + + expect(buildSyncHostHelloOkPayload({ ...base, peer }).features.invalidationOnlyV1).toEqual({ enabled: true }); + expect(buildSyncHostHelloOkPayload({ ...base, peer }).features).not.toHaveProperty("compactInvalidationV1"); + expect(buildSyncHostHelloOkPayload({ + ...base, + peer: { + ...peer, + capabilities: [ + SYNC_INVALIDATION_ONLY_V1_CAPABILITY, + SYNC_COMPACT_INVALIDATION_V1_CAPABILITY, + ], + }, + }).features.compactInvalidationV1).toEqual({ enabled: true }); + const phoneFeatures = buildSyncHostHelloOkPayload({ + ...base, + peer: { + ...peer, + deviceType: "phone", + capabilities: [ + SYNC_INVALIDATION_ONLY_V1_CAPABILITY, + SYNC_COMPACT_INVALIDATION_V1_CAPABILITY, + ], + }, + }).features; + expect(phoneFeatures).not.toHaveProperty("invalidationOnlyV1"); + expect(phoneFeatures).not.toHaveProperty("compactInvalidationV1"); + }); + + it("keeps invalidation envelopes bounded when table metadata is oversized", () => { + const oversizedNamePayload = buildSyncInvalidationBatchPayload({ + fromDbVersion: 4, + toDbVersion: 5, + changes: [{ + ...makeChange(5, 0), + table: "t".repeat(SYNC_INVALIDATION_TABLE_MAX_BYTES + 1), + }], + compressionThresholdBytes: Number.MAX_SAFE_INTEGER, + }); + + expect(oversizedNamePayload).toEqual({ + fromDbVersion: 4, + toDbVersion: 5, + tables: [], + fullRefresh: true, + }); + const oversizedEnvelopePayload = buildSyncInvalidationBatchPayload({ + fromDbVersion: 5, + toDbVersion: 6, + changes: Array.from({ length: 128 }, (_, index) => ({ + ...makeChange(6, index), + table: `${String(index).padStart(3, "0")}${"t".repeat(253)}`, + })), + compressionThresholdBytes: Number.MAX_SAFE_INTEGER, + }); + expect(oversizedEnvelopePayload).toEqual({ + fromDbVersion: 5, + toDbVersion: 6, + tables: [], + fullRefresh: true, + }); + + for (const payload of [oversizedNamePayload, oversizedEnvelopePayload]) { + expect(Buffer.byteLength(encodeSyncEnvelope({ + type: "invalidation_batch", + payload, + compressionThresholdBytes: Number.MAX_SAFE_INTEGER, + }), "utf8")).toBeLessThanOrEqual(SYNC_INVALIDATION_BATCH_MAX_ENVELOPE_BYTES); + } + }); + it("advertises daemon-hosted project catalog support in hello_ok without desktop", () => { const peer = { deviceId: "ios-phone-1", @@ -3780,120 +3885,107 @@ describe("CTO-gated Linear sync commands", () => { }); }); -describe("outbound changeset ack retries", () => { - beforeEach(() => { - publishMock.mockReset(); - spawnMock.mockReset(); - bonjourDestroyMock.mockReset(); - bonjourConstructorMock.mockReset(); - spawnMock.mockImplementation(() => ({ kill: vi.fn(), once: vi.fn(), unref: vi.fn() })); +describe("initial hydration priority", () => { + it("keeps historical catch-up for legacy browsers without the invalidation-only capability", () => { + expect(initialSyncHostCursorForPeer({ + peer: { + deviceType: "browser", + dbVersion: 7, + dbVersionBySite: { "site-host": 11 }, + capabilities: [], + }, + serverDbSiteId: "site-host", + serverDbVersion: 99, + })).toBe(11); }); - function createAckRetryHost(projectRoot: string) { - const base = createHostArgs(projectRoot, []); - const changes = [makeChange(1, 0)]; - return createSyncHostService({ - ...base, - pollIntervalMs: 25, - projectId: "project-1", - db: { - sync: { - getSiteId: () => "site-host-ack", - getDbVersion: () => 1, - exportChangesSince: (fromDbVersion: number) => - changes.filter((change) => Number(change.db_version) > fromDbVersion), - applyChanges: () => ({ appliedCount: 0 }), - discardUnpublishedChangesForTables: () => {}, - }, - }, - deviceRegistryService: { - ...base.deviceRegistryService, - upsertPeerMetadata: vi.fn(), - }, - } as unknown as Parameters[0]); - } + it("preserves an invalidation browser's same-DB handoff cursor but resets for a new DB", () => { + const peer = { + deviceType: "browser" as const, + dbVersion: 0, + dbVersionBySite: { "site-host": 4 }, + capabilities: [SYNC_INVALIDATION_ONLY_V1_CAPABILITY], + }; - function createControlledChangesetHost( - projectRoot: string, - state: { dbVersion: number; changes: CrsqlChangeRow[] }, - logger = createDiscoveryLogger(), - ) { + expect(adoptedSyncHostCursorForPeer({ + peer, + serverDbSiteId: "site-host", + serverDbVersion: 9, + snapshotServerDbSiteId: "site-host", + snapshotLastKnownServerDbVersion: 6, + })).toBe(6); + expect(adoptedSyncHostCursorForPeer({ + peer, + serverDbSiteId: "site-new", + serverDbVersion: 9, + snapshotServerDbSiteId: "site-host", + snapshotLastKnownServerDbVersion: 6, + })).toBe(9); + expect(adoptedSyncHostCursorForPeer({ + peer, + serverDbSiteId: "site-host", + serverDbVersion: 9, + snapshotServerDbSiteId: "site-host", + snapshotLastKnownServerDbVersion: 12, + })).toBe(9); + }); + + it("admits a queued chat subscription before a replica peer's initial catch-up", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const exportChangesSince = vi.fn(() => [makeChange(1, 0)]); const base = createHostArgs(projectRoot, []); const host = createSyncHostService({ ...base, - logger, - pollIntervalMs: 25, projectId: "project-1", + discoveryEnabled: false, + pollIntervalMs: 60_000, db: { sync: { - getSiteId: () => "site-host-controlled", - getDbVersion: () => state.dbVersion, - exportChangesSince: (fromDbVersion: number, options?: { maxRows?: number; throughDbVersion?: number }) => - state.changes - .filter((change) => Number(change.db_version) > fromDbVersion) - .filter((change) => Number(change.db_version) <= (options?.throughDbVersion ?? Number.MAX_SAFE_INTEGER)) - .slice(0, options?.maxRows ?? state.changes.length), + getSiteId: () => "site-host-queued-subscribe", + getDbVersion: () => 1, + exportChangesSince, applyChanges: () => ({ appliedCount: 0 }), discardUnpublishedChangesForTables: () => {}, }, }, - deviceRegistryService: { - ...base.deviceRegistryService, - upsertPeerMetadata: vi.fn(), - }, } as unknown as Parameters[0]); - return { host, logger }; - } + let client: WebSocket | null = null; - it("processes pending ACK retries before active-chat background deferral", async () => { - const { projectRoot, cleanup } = createTempProjectRoot(); - const host = createAckRetryHost(projectRoot); - let peer: Awaited> | null = null; - let bufferedAmountSpy: { mockRestore(): void } | null = null; - let dateNowSpy: { mockRestore(): void } | null = null; try { const port = await host.waitUntilListening(); - peer = await connectPeer(port, host.getBootstrapToken(), "ios-ack-retry", { - capabilities: ["changesetAck"], + client = new WebSocket(`ws://127.0.0.1:${port}`); + const { envelopes } = trackClientEnvelopes(client); + await new Promise((resolve, reject) => { + client!.once("open", resolve); + client!.once("error", reject); }); - - const firstBatch = await waitForValue( - () => peer?.envelopes.find((envelope) => envelope.type === "changeset_batch"), - "initial changeset batch", - ); - const firstPayload = firstBatch.payload as { batchId: string; toDbVersion: number }; - - peer.ws.send(encodeSyncEnvelope({ + client.send(encodeSyncEnvelope({ + type: "hello", + requestId: "phone-hello", + payload: { + peer: { + deviceId: "phone-initial-hydration", + deviceName: "Phone", + platform: "iOS", + deviceType: "phone", + siteId: "phone-initial-hydration-site", + dbVersion: 0, + }, + auth: { kind: "bootstrap", token: host.getBootstrapToken() }, + }, + })); + client.send(encodeSyncEnvelope({ type: "chat_subscribe", - requestId: "chat-subscribe", - payload: { sessionId: "session-1" }, + requestId: "phone-chat-subscribe", + projectId: "project-1", + payload: { sessionId: "selected-chat" }, })); - await waitForEnvelope(peer.envelopes, "chat_subscribe", "chat-subscribe"); - - bufferedAmountSpy = vi - .spyOn(WebSocket.prototype, "bufferedAmount", "get") - .mockReturnValue(SYNC_HOST_CHAT_ACTIVE_BACKGROUND_BACKPRESSURE_BYTES); - const realDateNow = Date.now.bind(Date); - dateNowSpy = vi - .spyOn(Date, "now") - .mockImplementation(() => realDateNow() + 11_000); - const resentBatch = await waitForValue( - () => peer?.envelopes.filter((envelope) => - envelope.type === "changeset_batch" - && (envelope.payload as { batchId?: string }).batchId === firstPayload.batchId - )[1], - "resent changeset batch under chat backpressure", - ); - expect(resentBatch.payload).toMatchObject({ - batchId: firstPayload.batchId, - toDbVersion: firstPayload.toDbVersion, - }); + await waitForEnvelope(envelopes, "chat_subscribe", "phone-chat-subscribe"); + expect(exportChangesSince).not.toHaveBeenCalled(); } finally { - dateNowSpy?.mockRestore(); - bufferedAmountSpy?.mockRestore(); try { - peer?.ws.close(); + client?.close(); } catch { // ignore } @@ -3902,67 +3994,755 @@ describe("outbound changeset ack retries", () => { } }); - it("admits one bounded changeset after the active-chat soft defer ages out", async () => { + it("serves other peers while one foreground queue is slow, then admits a bounded batch", async () => { const { projectRoot, cleanup } = createTempProjectRoot(); - const state = { dbVersion: 0, changes: [makeChange(1, 0)] }; - const { host, logger } = createControlledChangesetHost(projectRoot, state); - let peer: Awaited> | null = null; - let bufferedAmountSpy: { mockRestore(): void } | null = null; + const state = { + dbVersion: 0, + changes: Array.from({ length: 200 }, (_, index) => makeChange(index + 1, index)), + }; + const exportChangesSince = vi.fn( + (fromDbVersion: number, options?: { maxRows?: number; throughDbVersion?: number }) => + state.changes + .filter((change) => Number(change.db_version) > fromDbVersion) + .filter((change) => Number(change.db_version) <= (options?.throughDbVersion ?? Number.MAX_SAFE_INTEGER)) + .slice(0, options?.maxRows ?? state.changes.length), + ); + let releaseSummary!: (summary: { status: string }) => void; + const summaryGate = new Promise<{ status: string }>((resolve) => { + releaseSummary = resolve; + }); + const getSessionSummary = vi.fn((sessionId: string) => + sessionId === "slow-chat" ? summaryGate : Promise.resolve(null) + ); + const logger = createDiscoveryLogger(); + const base = createHostArgs(projectRoot, []); + const host = createSyncHostService({ + ...base, + logger, + projectId: "project-1", + discoveryEnabled: false, + pollIntervalMs: 25, + db: { + sync: { + getSiteId: () => "site-host-peer-fairness", + getDbVersion: () => state.dbVersion, + exportChangesSince, + applyChanges: () => ({ appliedCount: 0 }), + discardUnpublishedChangesForTables: () => {}, + }, + }, + agentChatService: { + subscribeToEvents: vi.fn().mockReturnValue(() => {}), + getChatEventHistory: vi.fn(() => ({ + sessionId: "slow-chat", + events: [], + truncated: false, + transcriptTruncated: false, + windowTruncated: false, + sessionFound: true, + })), + getSessionSummary, + }, + deviceRegistryService: { + ...base.deviceRegistryService, + upsertPeerMetadata: vi.fn(), + }, + } as unknown as Parameters[0]); + let slowPeer: Awaited> | null = null; + let fastPeer: Awaited> | null = null; let dateNowSpy: { mockRestore(): void } | null = null; + try { const port = await host.waitUntilListening(); - peer = await connectPeer(port, host.getBootstrapToken(), "ios-chat-fairness", { - capabilities: ["changesetAck"], + slowPeer = await connectPeer(port, host.getBootstrapToken(), "slow-foreground-peer", { + platform: "macOS", + deviceType: "browser", + capabilities: [ + SYNC_INVALIDATION_ONLY_V1_CAPABILITY, + SYNC_COMPACT_INVALIDATION_V1_CAPABILITY, + "changesetAck", + ], }); - expect(peer.envelopes.find((envelope) => envelope.type === "hello_ok")?.payload).toMatchObject({ - connectionTransport: "direct", + fastPeer = await connectPeer(port, host.getBootstrapToken(), "independent-peer", { + platform: "macOS", + deviceType: "browser", + capabilities: [ + SYNC_INVALIDATION_ONLY_V1_CAPABILITY, + SYNC_COMPACT_INVALIDATION_V1_CAPABILITY, + "changesetAck", + ], }); - peer.ws.send(encodeSyncEnvelope({ - type: "chat_subscribe", - requestId: "chat-fairness-subscribe", - payload: { sessionId: "session-1" }, - })); - await waitForEnvelope(peer.envelopes, "chat_subscribe", "chat-fairness-subscribe"); - - bufferedAmountSpy = vi - .spyOn(WebSocket.prototype, "bufferedAmount", "get") - .mockReturnValue(SYNC_HOST_CHAT_ACTIVE_BACKGROUND_BACKPRESSURE_BYTES); const realDateNow = Date.now.bind(Date); let clockOffsetMs = 0; dateNowSpy = vi.spyOn(Date, "now").mockImplementation(() => realDateNow() + clockOffsetMs); - state.dbVersion = 1; + + slowPeer.ws.send(encodeSyncEnvelope({ + type: "chat_subscribe", + requestId: "slow-chat-subscribe", + projectId: "project-1", + payload: { sessionId: "slow-chat" }, + })); + await waitForValue(() => getSessionSummary.mock.calls[0], "slow foreground handler"); + state.dbVersion = 200; await waitForValue( - () => logger.debug.mock.calls.find(([event]) => event === "sync_host.changeset_chat_deferral_started"), - "chat changeset deferral transition", + () => logger.debug.mock.calls.find(([event, fields]) => + event === "sync_host.changeset_priority_deferral_started" + && fields?.peerDeviceId === "slow-foreground-peer" + ), + "per-peer foreground deferral", ); - await new Promise((resolve) => setTimeout(resolve, 75)); - expect(peer.envelopes.some((envelope) => envelope.type === "changeset_batch")).toBe(false); + const independentBatch = await waitForValue( + () => fastPeer?.envelopes.find((envelope) => envelope.type === "invalidation_batch"), + "independent peer invalidation", + ); + expect(independentBatch.payload as SyncInvalidationBatchPayload).toEqual({ + fromDbVersion: 0, + toDbVersion: 200, + tables: ["kv"], + fullRefresh: false, + }); + expect(slowPeer.envelopes.some((envelope) => + envelope.type === "changeset_batch" || envelope.type === "invalidation_batch" + )).toBe(false); + expect(getSessionSummary).toHaveBeenCalledWith("slow-chat"); - clockOffsetMs += SYNC_HOST_CHAT_ACTIVE_MAX_CHANGESET_DEFER_MS + 25; - const batch = await waitForValue( - () => peer?.envelopes.find((envelope) => envelope.type === "changeset_batch"), - "fair changeset admission", + clockOffsetMs += SYNC_HOST_PRIORITY_MAX_CHANGESET_DEFER_MS + 25; + const boundedSlowBatch = await waitForValue( + () => slowPeer?.envelopes.find((envelope) => envelope.type === "invalidation_batch"), + "bounded slow-peer invalidation", ); - expect((batch.payload as SyncChangesetBatchPayload).changes).toHaveLength(1); - expect(logger.debug.mock.calls.filter(([event]) => event === "sync_host.changeset_chat_deferral_started")).toHaveLength(1); + expect(boundedSlowBatch.payload as SyncInvalidationBatchPayload).toEqual({ + fromDbVersion: 0, + toDbVersion: 64, + tables: ["kv"], + fullRefresh: false, + }); expect(logger.debug).toHaveBeenCalledWith( - "sync_host.changeset_chat_deferral_ended", - expect.objectContaining({ reason: "batch_admitted" }), + "sync_host.changeset_priority_deferral_ended", + expect.objectContaining({ + peerDeviceId: "slow-foreground-peer", + reason: "batch_admitted", + }), ); + expect(slowPeer.envelopes.some((envelope) => envelope.type === "chat_subscribe")).toBe(false); + + releaseSummary({ status: "idle" }); + await waitForEnvelope(slowPeer.envelopes, "chat_subscribe", "slow-chat-subscribe"); } finally { + releaseSummary({ status: "idle" }); dateNowSpy?.mockRestore(); - bufferedAmountSpy?.mockRestore(); - peer?.ws.close(); + slowPeer?.ws.close(); + fastPeer?.ws.close(); await host.dispose(); cleanup(); } }); - it("keeps the 4 MiB hard gate even after the fairness deadline", async () => { + it("keeps an independent replica delivering and retrying while another peer's transcript read is stalled", async () => { const { projectRoot, cleanup } = createTempProjectRoot(); - const state = { dbVersion: 0, changes: [makeChange(1, 0)] }; - const { host, logger } = createControlledChangesetHost(projectRoot, state); + const transcriptPath = path.join(projectRoot, "transcripts", "stalled-chat.chat.jsonl"); + fs.mkdirSync(path.dirname(transcriptPath), { recursive: true }); + fs.writeFileSync(transcriptPath, "", "utf8"); + const session = { + id: "stalled-chat", + laneId: "lane-1", + transcriptPath, + status: "running", + runtimeState: "running", + lastOutputPreview: "", + }; + const state = { dbVersion: 0, changes: [] as CrsqlChangeRow[] }; + const logger = createDiscoveryLogger(); + const base = createHostArgs(projectRoot, []); + const host = createSyncHostService({ + ...base, + logger, + projectId: "project-1", + discoveryEnabled: false, + pollIntervalMs: 100, + db: { + sync: { + getSiteId: () => "site-host-transcript-fairness", + getDbVersion: () => state.dbVersion, + exportChangesSince: (fromDbVersion: number) => + state.changes.filter((change) => Number(change.db_version) > fromDbVersion), + applyChanges: () => ({ appliedCount: 0 }), + discardUnpublishedChangesForTables: () => {}, + }, + }, + sessionService: { + list: () => [session], + get: (id: string) => id === session.id ? session : null, + readTranscriptTail: async () => "", + }, + agentChatService: { + subscribeToEvents: vi.fn().mockReturnValue(() => {}), + getChatEventHistory: vi.fn().mockReturnValue({ + sessionId: session.id, + events: [], + truncated: false, + transcriptTruncated: false, + windowTruncated: false, + sessionFound: true, + }), + getSessionSummary: vi.fn().mockResolvedValue({ status: "active" }), + }, + deviceRegistryService: { + ...base.deviceRegistryService, + upsertPeerMetadata: vi.fn(), + }, + } as unknown as Parameters[0]); + let stalledPeer: Awaited> | null = null; + let replicaPeer: Awaited> | null = null; + let openSpy: { mockRestore(): void } | null = null; + let dateNowSpy: { mockRestore(): void } | null = null; + let releaseTranscriptRead = () => {}; + + try { + const port = await host.waitUntilListening(); + stalledPeer = await connectPeer(port, host.getBootstrapToken(), "stalled-chat-peer", { + capabilities: ["changesetAck"], + }); + replicaPeer = await connectPeer(port, host.getBootstrapToken(), "independent-replica-peer", { + capabilities: ["changesetAck"], + }); + stalledPeer.ws.send(encodeSyncEnvelope({ + type: "chat_subscribe", + requestId: "stalled-chat-subscribe", + projectId: "project-1", + payload: { sessionId: session.id }, + })); + await waitForEnvelope(stalledPeer.envelopes, "chat_subscribe", "stalled-chat-subscribe"); + + const realOpen = fs.promises.open.bind(fs.promises); + let markTranscriptReadStarted = () => {}; + const transcriptReadStarted = new Promise((resolve) => { + markTranscriptReadStarted = resolve; + }); + const transcriptReadGate = new Promise((resolve) => { + releaseTranscriptRead = resolve; + }); + let shouldStallTranscriptRead = true; + openSpy = vi.spyOn(fs.promises, "open").mockImplementation((async ( + ...openArgs: Parameters + ) => { + if (String(openArgs[0]) === transcriptPath && shouldStallTranscriptRead) { + shouldStallTranscriptRead = false; + markTranscriptReadStarted(); + await transcriptReadGate; + } + return realOpen(...openArgs); + }) as typeof fs.promises.open); + + const realDateNow = Date.now.bind(Date); + let clockOffsetMs = 0; + dateNowSpy = vi.spyOn(Date, "now").mockImplementation(() => realDateNow() + clockOffsetMs); + const chatEvent: AgentChatEventEnvelope = { + sessionId: session.id, + timestamp: "2026-07-22T10:00:00.000Z", + sequence: 1, + event: { type: "text", text: "chat must lead its own catch-up" }, + }; + fs.appendFileSync(transcriptPath, `${JSON.stringify(chatEvent)}\n`, "utf8"); + state.dbVersion = 1; + state.changes.push(makeChange(1, 0)); + const stalledEnvelopeStart = stalledPeer.envelopes.length; + + await transcriptReadStarted; + const firstReplicaBatch = await waitForValue( + () => replicaPeer?.envelopes.find((entry) => entry.type === "changeset_batch"), + "independent replica changeset while transcript is stalled", + ); + expect(stalledPeer.envelopes.slice(stalledEnvelopeStart).some((entry) => + entry.type === "changeset_batch" || entry.type === "chat_event" + )).toBe(false); + + const firstPayload = firstReplicaBatch.payload as SyncChangesetBatchPayload; + replicaPeer.ws.send(encodeSyncEnvelope({ + type: "changeset_ack", + requestId: firstPayload.batchId, + projectId: "project-1", + payload: { + batchId: firstPayload.batchId, + fromDbVersion: firstPayload.fromDbVersion, + toDbVersion: firstPayload.toDbVersion, + appliedDbVersion: firstPayload.fromDbVersion, + appliedCount: 0, + ok: false, + error: { code: "apply_failed", message: "retry deterministically" }, + } satisfies SyncChangesetAckPayload, + })); + await waitForValue( + () => logger.warn.mock.calls.find(([event, fields]) => + event === "sync_host.changeset_ack_failed" + && fields?.peerDeviceId === "independent-replica-peer" + ), + "independent replica retry scheduling", + ); + clockOffsetMs += 60_000; + await waitForValue( + () => replicaPeer?.envelopes.filter((entry) => + entry.type === "changeset_batch" + && (entry.payload as SyncChangesetBatchPayload).batchId === firstPayload.batchId + ).length === 2 ? true : null, + "independent replica retry while transcript remains stalled", + ); + + releaseTranscriptRead(); + const stalledChatEvent = await waitForValue( + () => stalledPeer?.envelopes.slice(stalledEnvelopeStart).find((entry) => entry.type === "chat_event"), + "stalled peer chat event after read release", + ); + const stalledBatch = await waitForValue( + () => stalledPeer?.envelopes.slice(stalledEnvelopeStart).find((entry) => entry.type === "changeset_batch"), + "stalled peer changeset after chat", + ); + expect(stalledChatEvent.payload).toMatchObject({ + sessionId: session.id, + event: { type: "text", text: "chat must lead its own catch-up" }, + }); + expect(stalledPeer.envelopes.indexOf(stalledChatEvent)).toBeLessThan( + stalledPeer.envelopes.indexOf(stalledBatch), + ); + } finally { + releaseTranscriptRead(); + openSpy?.mockRestore(); + dateNowSpy?.mockRestore(); + stalledPeer?.ws.close(); + replicaPeer?.ws.close(); + await host.dispose(); + cleanup(); + } + }); + + it("hydrates the selected browser chat without replaying historical CRDT rows", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const transcriptPath = path.join(projectRoot, "transcripts", "selected-chat.chat.jsonl"); + fs.mkdirSync(path.dirname(transcriptPath), { recursive: true }); + const event: AgentChatEventEnvelope = { + sessionId: "selected-chat", + timestamp: "2026-07-22T04:50:55.000Z", + sequence: 1, + event: { type: "text", text: "selected transcript" }, + }; + fs.writeFileSync(transcriptPath, `${JSON.stringify(event)}\n`, "utf8"); + + const state = { + dbVersion: 1, + changes: [makeChange(1, 0)], + }; + const exportChangesSince = vi.fn( + (fromDbVersion: number, options?: { maxRows?: number; throughDbVersion?: number }) => + state.changes + .filter((change) => Number(change.db_version) > fromDbVersion) + .filter((change) => Number(change.db_version) <= (options?.throughDbVersion ?? Number.MAX_SAFE_INTEGER)) + .slice(0, options?.maxRows ?? state.changes.length), + ); + const getChatEventHistory = vi.fn(() => ({ + sessionId: "selected-chat", + events: [event], + truncated: false, + transcriptTruncated: false, + windowTruncated: false, + sessionFound: true, + })); + let releaseSummary!: (summary: { status: string }) => void; + const summaryGate = new Promise<{ status: string }>((resolve) => { + releaseSummary = resolve; + }); + const base = createHostArgs(projectRoot, []); + const host = createSyncHostService({ + ...base, + projectId: "project-1", + discoveryEnabled: false, + pollIntervalMs: 100, + db: { + sync: { + getSiteId: () => "site-host-initial-hydration", + getDbVersion: () => state.dbVersion, + exportChangesSince, + applyChanges: () => ({ appliedCount: 0 }), + discardUnpublishedChangesForTables: () => {}, + }, + }, + sessionService: { + list: () => [], + get: (sessionId: string) => sessionId === "selected-chat" + ? { id: sessionId, transcriptPath, status: "running" } + : null, + readTranscriptTail: async () => "", + }, + agentChatService: { + subscribeToEvents: vi.fn().mockReturnValue(() => {}), + getChatEventHistory, + getSessionSummary: vi.fn(() => summaryGate), + }, + } as unknown as Parameters[0]); + let client: WebSocket | null = null; + + try { + const port = await host.waitUntilListening(); + client = new WebSocket(`ws://127.0.0.1:${port}`); + const { envelopes } = trackClientEnvelopes(client); + await new Promise((resolve, reject) => { + client!.once("open", resolve); + client!.once("error", reject); + }); + + // Browsers send their selected-chat subscription as soon as hello_ok + // arrives. Queue both frames here to make the host ordering contract + // deterministic: foreground hydration must beat the initial backlog. + client.send(encodeSyncEnvelope({ + type: "hello", + requestId: "browser-hello", + payload: { + peer: { + deviceId: "browser-initial-hydration", + deviceName: "Browser", + platform: "macOS", + deviceType: "browser", + siteId: "browser-initial-hydration-site", + dbVersion: 0, + capabilities: [ + SYNC_INVALIDATION_ONLY_V1_CAPABILITY, + SYNC_COMPACT_INVALIDATION_V1_CAPABILITY, + ], + }, + auth: { kind: "bootstrap", token: host.getBootstrapToken() }, + }, + })); + client.send(encodeSyncEnvelope({ + type: "chat_subscribe", + requestId: "selected-chat-subscribe", + projectId: "project-1", + payload: { sessionId: "selected-chat", maxBytes: 64 * 1024 }, + })); + + await waitForValue( + () => getChatEventHistory.mock.calls[0], + "selected chat history read", + ); + await new Promise((resolve) => setTimeout(resolve, 250)); + expect(exportChangesSince).not.toHaveBeenCalled(); + releaseSummary({ status: "idle" }); + + const snapshot = await waitForEnvelope( + envelopes, + "chat_subscribe", + "selected-chat-subscribe", + ); + expect(snapshot.payload).toMatchObject({ + sessionId: "selected-chat", + events: [event], + turnActive: false, + }); + expect(getChatEventHistory).toHaveBeenCalledTimes(1); + expect(envelopes.some((envelope) => envelope.type === "changeset_batch")).toBe(false); + + // A browser is invalidation-only: historical rows are skipped, while a + // mutation committed after hello produces a compact live signal even + // when the source row itself is larger than the Relay peer budget. + state.dbVersion = 2; + const oversizedValue = "x".repeat(PEER_BACKPRESSURE_BYTES + 1); + state.changes.push({ + ...makeChange(2, 1, oversizedValue), + table: "operations", + }); + const liveInvalidation = await waitForValue( + () => envelopes.find((envelope) => envelope.type === "invalidation_batch"), + "post-connect browser invalidation", + ); + expect(exportChangesSince).toHaveBeenCalledWith( + 1, + expect.objectContaining({ throughDbVersion: 2 }), + ); + const invalidationPayload = liveInvalidation.payload as SyncInvalidationBatchPayload; + expect(invalidationPayload).toEqual({ + fromDbVersion: 1, + toDbVersion: 2, + tables: ["operations"], + fullRefresh: false, + }); + expect(envelopes.some((envelope) => envelope.type === "changeset_batch")).toBe(false); + expect(Buffer.byteLength(encodeSyncEnvelope({ + type: "invalidation_batch", + payload: invalidationPayload, + compressionThresholdBytes: Number.MAX_SAFE_INTEGER, + }), "utf8")).toBeLessThanOrEqual(SYNC_INVALIDATION_BATCH_MAX_ENVELOPE_BYTES); + } finally { + try { + client?.close(); + } catch { + // ignore + } + await host.dispose(); + cleanup(); + } + }); + + it("keeps older invalidation-only browsers on changeset hints", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const state = { + dbVersion: 0, + changes: [] as CrsqlChangeRow[], + }; + const base = createHostArgs(projectRoot, []); + const host = createSyncHostService({ + ...base, + projectId: "project-1", + discoveryEnabled: false, + pollIntervalMs: 25, + db: { + sync: { + getSiteId: () => "site-host-legacy-browser", + getDbVersion: () => state.dbVersion, + exportChangesSince: (fromDbVersion: number) => + state.changes.filter((change) => Number(change.db_version) > fromDbVersion), + applyChanges: () => ({ appliedCount: 0 }), + discardUnpublishedChangesForTables: () => {}, + }, + }, + deviceRegistryService: { + ...base.deviceRegistryService, + upsertPeerMetadata: vi.fn(), + }, + } as unknown as Parameters[0]); + let browser: WebSocket | null = null; + let envelopes: ParsedSyncEnvelope[] = []; + + try { + const port = await host.waitUntilListening(); + browser = new WebSocket(`ws://127.0.0.1:${port}`); + ({ envelopes } = trackClientEnvelopes(browser)); + await new Promise((resolve, reject) => { + browser!.once("open", resolve); + browser!.once("error", reject); + }); + browser.send(encodeSyncEnvelope({ + type: "hello", + requestId: "legacy-browser-hello", + payload: { + peer: { + deviceId: "legacy-invalidation-browser", + deviceName: "Legacy Browser", + platform: "macOS", + deviceType: "browser", + siteId: "legacy-invalidation-browser-site", + dbVersion: 0, + capabilities: [SYNC_INVALIDATION_ONLY_V1_CAPABILITY], + }, + auth: { kind: "bootstrap", token: host.getBootstrapToken() }, + }, + })); + const helloOkEnvelope = await waitForEnvelope(envelopes, "hello_ok", "legacy-browser-hello"); + expect((helloOkEnvelope.payload as { features?: Record }).features) + .not.toHaveProperty("compactInvalidationV1"); + expect(envelopes.some((envelope) => + envelope.type === "changeset_batch" || envelope.type === "invalidation_batch" + )).toBe(false); + + state.dbVersion = 1; + state.changes.push(makeChange(1, 0)); + const legacyBatch = await waitForValue( + () => envelopes.find((envelope) => envelope.type === "changeset_batch"), + "legacy browser changeset hint", + ); + expect((legacyBatch.payload as SyncChangesetBatchPayload).changes).toEqual([ + expect.objectContaining({ db_version: 1, table: "kv" }), + ]); + expect(envelopes.some((envelope) => envelope.type === "invalidation_batch")).toBe(false); + } finally { + browser?.close(); + await host.dispose(); + cleanup(); + } + }); +}); + +describe("outbound changeset ack retries", () => { + beforeEach(() => { + publishMock.mockReset(); + spawnMock.mockReset(); + bonjourDestroyMock.mockReset(); + bonjourConstructorMock.mockReset(); + spawnMock.mockImplementation(() => ({ kill: vi.fn(), once: vi.fn(), unref: vi.fn() })); + }); + + function createAckRetryHost(projectRoot: string) { + const base = createHostArgs(projectRoot, []); + const changes = [makeChange(1, 0)]; + return createSyncHostService({ + ...base, + pollIntervalMs: 25, + projectId: "project-1", + db: { + sync: { + getSiteId: () => "site-host-ack", + getDbVersion: () => 1, + exportChangesSince: (fromDbVersion: number) => + changes.filter((change) => Number(change.db_version) > fromDbVersion), + applyChanges: () => ({ appliedCount: 0 }), + discardUnpublishedChangesForTables: () => {}, + }, + }, + deviceRegistryService: { + ...base.deviceRegistryService, + upsertPeerMetadata: vi.fn(), + }, + } as unknown as Parameters[0]); + } + + function createControlledChangesetHost( + projectRoot: string, + state: { dbVersion: number; changes: CrsqlChangeRow[] }, + logger = createDiscoveryLogger(), + ) { + const base = createHostArgs(projectRoot, []); + const host = createSyncHostService({ + ...base, + logger, + pollIntervalMs: 25, + projectId: "project-1", + db: { + sync: { + getSiteId: () => "site-host-controlled", + getDbVersion: () => state.dbVersion, + exportChangesSince: (fromDbVersion: number, options?: { maxRows?: number; throughDbVersion?: number }) => + state.changes + .filter((change) => Number(change.db_version) > fromDbVersion) + .filter((change) => Number(change.db_version) <= (options?.throughDbVersion ?? Number.MAX_SAFE_INTEGER)) + .slice(0, options?.maxRows ?? state.changes.length), + applyChanges: () => ({ appliedCount: 0 }), + discardUnpublishedChangesForTables: () => {}, + }, + }, + deviceRegistryService: { + ...base.deviceRegistryService, + upsertPeerMetadata: vi.fn(), + }, + } as unknown as Parameters[0]); + return { host, logger }; + } + + it("processes pending ACK retries before active-chat background deferral", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const host = createAckRetryHost(projectRoot); + let peer: Awaited> | null = null; + let bufferedAmountSpy: { mockRestore(): void } | null = null; + let dateNowSpy: { mockRestore(): void } | null = null; + try { + const port = await host.waitUntilListening(); + peer = await connectPeer(port, host.getBootstrapToken(), "ios-ack-retry", { + capabilities: ["changesetAck"], + }); + + const firstBatch = await waitForValue( + () => peer?.envelopes.find((envelope) => envelope.type === "changeset_batch"), + "initial changeset batch", + ); + const firstPayload = firstBatch.payload as { batchId: string; toDbVersion: number }; + + peer.ws.send(encodeSyncEnvelope({ + type: "chat_subscribe", + requestId: "chat-subscribe", + payload: { sessionId: "session-1" }, + })); + await waitForEnvelope(peer.envelopes, "chat_subscribe", "chat-subscribe"); + + bufferedAmountSpy = vi + .spyOn(WebSocket.prototype, "bufferedAmount", "get") + .mockReturnValue(SYNC_HOST_CHAT_ACTIVE_BACKGROUND_BACKPRESSURE_BYTES); + const realDateNow = Date.now.bind(Date); + dateNowSpy = vi + .spyOn(Date, "now") + .mockImplementation(() => realDateNow() + 11_000); + + const resentBatch = await waitForValue( + () => peer?.envelopes.filter((envelope) => + envelope.type === "changeset_batch" + && (envelope.payload as { batchId?: string }).batchId === firstPayload.batchId + )[1], + "resent changeset batch under chat backpressure", + ); + expect(resentBatch.payload).toMatchObject({ + batchId: firstPayload.batchId, + toDbVersion: firstPayload.toDbVersion, + }); + } finally { + dateNowSpy?.mockRestore(); + bufferedAmountSpy?.mockRestore(); + try { + peer?.ws.close(); + } catch { + // ignore + } + await host.dispose(); + cleanup(); + } + }); + + it("admits one bounded changeset after the active-chat soft defer ages out", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const state = { dbVersion: 0, changes: [makeChange(1, 0)] }; + const { host, logger } = createControlledChangesetHost(projectRoot, state); + let peer: Awaited> | null = null; + let bufferedAmountSpy: { mockRestore(): void } | null = null; + let dateNowSpy: { mockRestore(): void } | null = null; + try { + const port = await host.waitUntilListening(); + peer = await connectPeer(port, host.getBootstrapToken(), "ios-chat-fairness", { + capabilities: ["changesetAck"], + }); + expect(peer.envelopes.find((envelope) => envelope.type === "hello_ok")?.payload).toMatchObject({ + connectionTransport: "direct", + }); + peer.ws.send(encodeSyncEnvelope({ + type: "chat_subscribe", + requestId: "chat-fairness-subscribe", + payload: { sessionId: "session-1" }, + })); + await waitForEnvelope(peer.envelopes, "chat_subscribe", "chat-fairness-subscribe"); + + bufferedAmountSpy = vi + .spyOn(WebSocket.prototype, "bufferedAmount", "get") + .mockReturnValue(SYNC_HOST_CHAT_ACTIVE_BACKGROUND_BACKPRESSURE_BYTES); + const realDateNow = Date.now.bind(Date); + let clockOffsetMs = 0; + dateNowSpy = vi.spyOn(Date, "now").mockImplementation(() => realDateNow() + clockOffsetMs); + state.dbVersion = 1; + + await waitForValue( + () => logger.debug.mock.calls.find(([event]) => event === "sync_host.changeset_priority_deferral_started"), + "chat changeset deferral transition", + ); + await new Promise((resolve) => setTimeout(resolve, 75)); + expect(peer.envelopes.some((envelope) => envelope.type === "changeset_batch")).toBe(false); + + clockOffsetMs += SYNC_HOST_PRIORITY_MAX_CHANGESET_DEFER_MS + 25; + const batch = await waitForValue( + () => peer?.envelopes.find((envelope) => envelope.type === "changeset_batch"), + "fair changeset admission", + ); + expect((batch.payload as SyncChangesetBatchPayload).changes).toHaveLength(1); + expect(logger.debug.mock.calls.filter(([event]) => event === "sync_host.changeset_priority_deferral_started")).toHaveLength(1); + expect(logger.debug).toHaveBeenCalledWith( + "sync_host.changeset_priority_deferral_ended", + expect.objectContaining({ reason: "batch_admitted" }), + ); + } finally { + dateNowSpy?.mockRestore(); + bufferedAmountSpy?.mockRestore(); + peer?.ws.close(); + await host.dispose(); + cleanup(); + } + }); + + it("keeps the 4 MiB hard gate even after the fairness deadline", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const state = { dbVersion: 0, changes: [makeChange(1, 0)] }; + const { host, logger } = createControlledChangesetHost(projectRoot, state); let peer: Awaited> | null = null; let bufferedAmountSpy: { mockRestore(): void } | null = null; let dateNowSpy: { mockRestore(): void } | null = null; @@ -3983,19 +4763,19 @@ describe("outbound changeset ack retries", () => { .spyOn(WebSocket.prototype, "bufferedAmount", "get") .mockImplementation(() => bufferedAmount); const realDateNow = Date.now.bind(Date); - let clockOffsetMs = SYNC_HOST_CHAT_ACTIVE_MAX_CHANGESET_DEFER_MS + 5_000; + let clockOffsetMs = SYNC_HOST_PRIORITY_MAX_CHANGESET_DEFER_MS + 5_000; dateNowSpy = vi.spyOn(Date, "now").mockImplementation(() => realDateNow() + clockOffsetMs); state.dbVersion = 1; await new Promise((resolve) => setTimeout(resolve, 100)); expect(peer.envelopes.some((envelope) => envelope.type === "changeset_batch")).toBe(false); - expect(logger.debug.mock.calls.some(([event]) => event === "sync_host.changeset_chat_deferral_started")).toBe(false); + expect(logger.debug.mock.calls.some(([event]) => event === "sync_host.changeset_priority_deferral_started")).toBe(false); bufferedAmount = SYNC_HOST_CHAT_ACTIVE_BACKGROUND_BACKPRESSURE_BYTES; await waitForValue( - () => logger.debug.mock.calls.find(([event]) => event === "sync_host.changeset_chat_deferral_started"), + () => logger.debug.mock.calls.find(([event]) => event === "sync_host.changeset_priority_deferral_started"), "soft deferral after hard pressure clears", ); - clockOffsetMs += SYNC_HOST_CHAT_ACTIVE_MAX_CHANGESET_DEFER_MS + 25; + clockOffsetMs += SYNC_HOST_PRIORITY_MAX_CHANGESET_DEFER_MS + 25; await waitForValue( () => peer?.envelopes.find((envelope) => envelope.type === "changeset_batch"), "changeset after hard pressure clears", @@ -4856,6 +5636,90 @@ describe("sync host handoff over a shared listener", () => { } }); + it("preserves an invalidation browser's cursor across a same-database handoff", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const tokenPath = path.join(projectRoot, "shared-browser-bootstrap-token"); + const listener = createSharedSyncListener({ bindHost: "127.0.0.1" }); + const db = { + siteId: "site-shared-browser-db", + dbVersion: 1, + changes: [makeHostChange(1, 0)], + }; + let browser: Awaited> | null = null; + let hostA: ReturnType | null = null; + let hostB: ReturnType | null = null; + + try { + const port = await listener.ensureListening([0]); + hostA = createSyncHostService({ + ...createHandoffHostArgs(projectRoot, tokenPath, db), + sharedListener: listener, + pollIntervalMs: 25, + } as unknown as Parameters[0]); + await hostA.waitUntilListening(); + browser = await connectPeer(port, hostA.getBootstrapToken(), "browser-handoff-peer", { + platform: "macOS", + deviceType: "browser", + capabilities: [ + SYNC_INVALIDATION_ONLY_V1_CAPABILITY, + SYNC_COMPACT_INVALIDATION_V1_CAPABILITY, + ], + }); + + expect(hostA.getPeerStates()).toEqual([ + expect.objectContaining({ + deviceId: "browser-handoff-peer", + syncLag: 0, + }), + ]); + expect(browser.envelopes.some((envelope) => + envelope.type === "changeset_batch" || envelope.type === "invalidation_batch" + )).toBe(false); + + await hostA.dispose(); + hostA = null; + const envelopeCountAfterDeposit = browser.envelopes.length; + + // This write lands after the old owner deposits the live socket but + // before the new owner adopts it. The deposited cursor is the only safe + // boundary for a same-DB invalidation-only browser. + db.dbVersion = 2; + db.changes.push(makeHostChange(2, 1)); + hostB = createSyncHostService({ + ...createHandoffHostArgs(projectRoot, tokenPath, db), + sharedListener: listener, + pollIntervalMs: 25, + } as unknown as Parameters[0]); + await hostB.waitUntilListening(); + + const postHandoffBatch = await waitForValue( + () => browser?.envelopes + .slice(envelopeCountAfterDeposit) + .find((envelope) => envelope.type === "invalidation_batch"), + "same-DB post-handoff browser invalidation", + ); + expect(postHandoffBatch.payload as SyncInvalidationBatchPayload).toEqual({ + fromDbVersion: 1, + toDbVersion: 2, + tables: ["kv"], + fullRefresh: false, + }); + expect(browser.closeEvents).toEqual([]); + expect(browser.ws.readyState).toBe(WebSocket.OPEN); + expect(hostB.getPeerStates()).toEqual([ + expect.objectContaining({ + deviceId: "browser-handoff-peer", + }), + ]); + } finally { + browser?.ws.close(); + await hostA?.dispose(); + await hostB?.dispose(); + await listener.close(); + cleanup(); + } + }); + it("keeps personal and project chat subscriptions across handoff without restoring foreign quick looks", async () => { const rootA = createTempProjectRoot(); const rootB = createTempProjectRoot(); @@ -5896,6 +6760,176 @@ describe("chat_subscribe snapshots", () => { } }); + it("bounds transcript deltas at complete JSONL boundaries and recovers partial and oversized records", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const transcriptPath = path.join(projectRoot, "transcripts", "bounded-chat.chat.jsonl"); + fs.mkdirSync(path.dirname(transcriptPath), { recursive: true }); + fs.writeFileSync(transcriptPath, "", "utf8"); + const session = { + id: "bounded-chat", + laneId: "lane-1", + transcriptPath, + status: "running", + runtimeState: "running", + lastOutputPreview: "", + }; + const logger = createDiscoveryLogger(); + const base = createHostArgs(projectRoot, []); + const host = createSyncHostService({ + ...base, + logger, + pollIntervalMs: 100, + projectId: "project-1", + db: { + sync: { + getSiteId: () => "site-host-bounded-chat", + getDbVersion: () => 0, + exportChangesSince: () => [], + applyChanges: () => ({ appliedCount: 0 }), + discardUnpublishedChangesForTables: () => {}, + }, + }, + deviceRegistryService: { + ...base.deviceRegistryService, + upsertPeerMetadata: vi.fn(), + }, + sessionService: { + list: () => [session], + get: (id: string) => id === session.id ? session : null, + readTranscriptTail: async () => "", + }, + agentChatService: { + subscribeToEvents: vi.fn().mockReturnValue(() => {}), + getChatEventHistory: vi.fn().mockReturnValue({ + sessionId: session.id, + events: [], + truncated: false, + transcriptTruncated: false, + windowTruncated: false, + sessionFound: true, + }), + getSessionSummary: vi.fn().mockResolvedValue({ status: "active" }), + }, + } as unknown as Parameters[0]); + let peer: Awaited> | null = null; + let openSpy: { mockRestore(): void } | null = null; + let releaseSecondRead = () => {}; + + const event = (sequence: number, text: string): AgentChatEventEnvelope => ({ + sessionId: session.id, + timestamp: new Date(Date.UTC(2026, 6, 22, 11, 0, sequence)).toISOString(), + sequence, + event: { type: "text", text }, + }); + const line = (entry: AgentChatEventEnvelope): Buffer => + Buffer.from(`${JSON.stringify(entry)}\n`, "utf8"); + const deliveredSequences = (): number[] => (peer?.envelopes ?? []) + .filter((entry) => entry.type === "chat_event") + .map((entry) => Number((entry.payload as AgentChatEventEnvelope).sequence)); + + try { + const port = await host.waitUntilListening(); + peer = await connectPeer(port, host.getBootstrapToken(), "bounded-chat-peer"); + peer.ws.send(encodeSyncEnvelope({ + type: "chat_subscribe", + requestId: "bounded-chat-subscribe", + projectId: "project-1", + payload: { sessionId: session.id }, + })); + await waitForEnvelope(peer.envelopes, "chat_subscribe", "bounded-chat-subscribe"); + + const largeText = "🙂".repeat(18_000); + const completeLines = [line(event(1, largeText)), line(event(2, largeText)), line(event(3, largeText))]; + expect(completeLines[0].length).toBeLessThan(SYNC_HOST_CHAT_TRANSCRIPT_DELTA_MAX_BYTES); + expect(completeLines[0].length + completeLines[1].length).toBeGreaterThan( + SYNC_HOST_CHAT_TRANSCRIPT_DELTA_MAX_BYTES, + ); + const partialLine = line(event(4, "split-🙂-record")); + const emojiOffset = partialLine.indexOf(Buffer.from("🙂", "utf8")); + expect(emojiOffset).toBeGreaterThan(0); + const splitOffset = emojiOffset + 2; + + const realOpen = fs.promises.open.bind(fs.promises); + let openCount = 0; + let markSecondReadStarted = () => {}; + const secondReadStarted = new Promise((resolve) => { + markSecondReadStarted = resolve; + }); + let markPartialRetryStarted = () => {}; + const partialRetryStarted = new Promise((resolve) => { + markPartialRetryStarted = resolve; + }); + const secondReadGate = new Promise((resolve) => { + releaseSecondRead = resolve; + }); + openSpy = vi.spyOn(fs.promises, "open").mockImplementation((async ( + ...openArgs: Parameters + ) => { + if (String(openArgs[0]) === transcriptPath) { + openCount += 1; + if (openCount === 2) { + markSecondReadStarted(); + await secondReadGate; + } else if (openCount === 4) { + markPartialRetryStarted(); + } + } + return realOpen(...openArgs); + }) as typeof fs.promises.open); + + fs.writeFileSync( + transcriptPath, + Buffer.concat([...completeLines, partialLine.subarray(0, splitOffset)]), + ); + await secondReadStarted; + await waitForValue( + () => deliveredSequences().length === 1 ? true : null, + "first bounded transcript chunk", + ); + expect(deliveredSequences()).toEqual([1]); + + releaseSecondRead(); + await partialRetryStarted; + await waitForValue( + () => deliveredSequences().length === 3 ? true : null, + "three complete bounded transcript records", + ); + expect(deliveredSequences()).toEqual([1, 2, 3]); + fs.appendFileSync(transcriptPath, partialLine.subarray(splitOffset)); + await waitForValue( + () => deliveredSequences().includes(4) ? true : null, + "UTF-8 split transcript record recovery", + ); + + const oversized = line(event( + 5, + "x".repeat(SYNC_HOST_CHAT_TRANSCRIPT_MAX_RECORD_BYTES + 1_024), + )); + const recovered = line(event(6, "record after explicit oversized recovery")); + fs.appendFileSync(transcriptPath, Buffer.concat([oversized, recovered])); + await waitForValue( + () => deliveredSequences().includes(6) ? true : null, + "record after oversized transcript recovery", + ); + expect(deliveredSequences()).toEqual([1, 2, 3, 4, 6]); + expect(logger.warn).toHaveBeenCalledWith( + "sync_host.chat_transcript_record_too_large", + expect.objectContaining({ + peerDeviceId: "bounded-chat-peer", + sessionId: session.id, + recordBytes: oversized.length, + maxRecordBytes: SYNC_HOST_CHAT_TRANSCRIPT_MAX_RECORD_BYTES, + }), + ); + } finally { + releaseSecondRead(); + openSpy?.mockRestore(); + peer?.ws.close(); + await host.dispose(); + cleanup(); + } + }); + it("replays a chat event whose optional live send was backpressured", async () => { const { projectRoot, cleanup } = createTempProjectRoot(); const transcriptPath = path.join(projectRoot, "transcripts", "chat-replay.chat.jsonl"); diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index d439f838a..000da006a 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -58,6 +58,7 @@ import type { SyncFileResponsePayload, SyncHelloPayload, SyncHelloErrorPayload, + SyncInvalidationBatchPayload, SyncMobileProjectSummary, SyncPairingRequestPayload, PairedRuntimeHelloOkPayload, @@ -88,7 +89,14 @@ import type { SyncTerminalInputPayload, SyncTerminalSnapshotPayload, } from "../../../../desktop/src/shared/types"; -import { SYNC_RELAY_REAUTHORIZE_V1_CAPABILITY } from "../../../../desktop/src/shared/types"; +import { + SYNC_COMPACT_INVALIDATION_V1_CAPABILITY, + SYNC_INVALIDATION_BATCH_MAX_ENVELOPE_BYTES, + SYNC_INVALIDATION_BATCH_MAX_TABLES, + SYNC_INVALIDATION_TABLE_MAX_BYTES, + SYNC_INVALIDATION_ONLY_V1_CAPABILITY, + SYNC_RELAY_REAUTHORIZE_V1_CAPABILITY, +} from "../../../../desktop/src/shared/types"; import { parseAgentChatTranscript } from "../../../../desktop/src/shared/chatTranscript"; import type { Logger } from "../../../../desktop/src/main/services/logging/logger"; import type { ProductAnalyticsService } from "../../../../desktop/src/main/services/analytics/productAnalyticsService"; @@ -260,7 +268,9 @@ const DEFAULT_SYNC_MESSAGE_TIMEOUT_MS = 60_000; const MAX_SYNC_ARTIFACT_BYTES = 8 * 1024 * 1024; export const SYNC_HOST_CHAT_ACTIVE_BACKGROUND_BACKPRESSURE_BYTES = 512 * 1024; export const SYNC_HOST_CHAT_ACTIVE_CHANGESET_BATCH_BYTES = 64 * 1024; -export const SYNC_HOST_CHAT_ACTIVE_MAX_CHANGESET_DEFER_MS = 2_000; +export const SYNC_HOST_PRIORITY_MAX_CHANGESET_DEFER_MS = 2_000; +export const SYNC_HOST_CHAT_TRANSCRIPT_DELTA_MAX_BYTES = 128 * 1024; +export const SYNC_HOST_CHAT_TRANSCRIPT_MAX_RECORD_BYTES = 2 * 1024 * 1024; const MOBILE_COMMAND_RESULT_CACHE_TTL_MS = 30 * 60 * 1000; const MOBILE_COMMAND_RESULT_CACHE_MAX_ENTRIES = 512; const CHANGESET_ACK_TIMEOUT_MS = 10_000; @@ -474,7 +484,7 @@ type PeerState = { awaitingHeartbeatAt: string | null; missedHeartbeatCount: number; backpressuredSinceMs: number | null; - changesetChatDeferredSinceMs: number | null; + changesetPriorityDeferredSinceMs: number | null; changesetRecoveryLevel: number; changesetRecoveryNotBeforeMs: number; remoteAddress: string | null; @@ -487,6 +497,10 @@ type PeerState = { subscribedChatSessionIds: Set; chatSubscriptionScopes: Map; chatTranscriptOffsets: Map; + // Progress while scanning one JSONL record that exceeded a normal bounded + // transcript-delta read. The durable offset above still advances only after + // a complete newline boundary is found and a deliverable record is parsed. + chatTranscriptScanOffsets: Map; chatEventIdsSent: Map>; // Subscriptions resolved outside the active project's session service: // machine-scoped personal chats and cross-project quick looks. Scope stays @@ -501,6 +515,7 @@ type PeerState = { rosterSeq: number; rosterBaseline: Map; messageQueue: Promise; + queuedMessageCount: number; terminalInputQueue: Promise; pendingTerminalOwnershipChanges: number; /** Local consent for this browser/phone; never mutates machine-wide consent. */ @@ -891,6 +906,115 @@ export function syncHeartbeatMissLimitForPeerMetadata(metadata: Pick; + serverDbSiteId: string; + serverDbVersion: number; +}): number { + // A browser may explicitly negotiate an invalidation-only contract: it has + // no SQLite replica, fully refetches its query domains after hello, and uses + // only post-connect sync messages as invalidation hints. Starting that peer at + // the current watermark avoids replaying CRR history it cannot apply. Keep + // legacy browsers on replica semantics unless they declare the capability. + if (isInvalidationOnlyBrowserPeer(args.peer)) { + return Math.max(0, Math.floor(args.serverDbVersion)); + } + const cursorForThisDb = args.peer.dbVersionBySite?.[args.serverDbSiteId] + ?? (args.peer.dbVersionBySite ? 0 : args.peer.dbVersion); + return Math.max(0, Math.floor(cursorForThisDb)); +} + +export function adoptedSyncHostCursorForPeer(args: { + peer: Pick; + serverDbSiteId: string; + serverDbVersion: number; + snapshotServerDbSiteId?: string | null; + snapshotLastKnownServerDbVersion?: number | null; +}): number { + const initialCursor = initialSyncHostCursorForPeer(args); + if ( + args.snapshotServerDbSiteId !== args.serverDbSiteId + || typeof args.snapshotLastKnownServerDbVersion !== "number" + || !Number.isFinite(args.snapshotLastKnownServerDbVersion) + ) { + return initialCursor; + } + const snapshotCursor = Math.max(0, Math.floor(args.snapshotLastKnownServerDbVersion)); + // Invalidation-only browsers have no replica cursor to merge. On a + // same-DB seamless adoption, the deposited cursor is the exact boundary: + // writes committed while the socket is parked must be exported by the new + // owner. A different DB still starts at that DB's current watermark. + if (isInvalidationOnlyBrowserPeer(args.peer)) { + return Math.min(Math.max(0, Math.floor(args.serverDbVersion)), snapshotCursor); + } + // Replica peers may have advertised a newer durable per-site cursor than + // the depositing host had observed, so retain the fresher same-DB value. + return Math.max(initialCursor, snapshotCursor); +} + +function isInvalidationOnlyBrowserPeer( + peer: Pick | null | undefined, +): boolean { + return peer?.deviceType === "browser" + && peer.capabilities?.includes(SYNC_INVALIDATION_ONLY_V1_CAPABILITY) === true; +} + +function isCompactInvalidationBrowserPeer( + peer: Pick | null | undefined, +): boolean { + return isInvalidationOnlyBrowserPeer(peer) + && peer?.capabilities?.includes(SYNC_COMPACT_INVALIDATION_V1_CAPABILITY) === true; +} + +export function buildSyncInvalidationBatchPayload(args: { + fromDbVersion: number; + toDbVersion: number; + changes: readonly CrsqlChangeRow[]; + compressionThresholdBytes?: number; +}): SyncInvalidationBatchPayload { + const fromDbVersion = Number.isFinite(args.fromDbVersion) + ? Math.max(0, Math.floor(args.fromDbVersion)) + : 0; + const toDbVersion = Number.isFinite(args.toDbVersion) + ? Math.max(fromDbVersion, Math.floor(args.toDbVersion)) + : fromDbVersion; + const fullRefresh = (): SyncInvalidationBatchPayload => ({ + fromDbVersion, + toDbVersion, + tables: [], + fullRefresh: true, + }); + if (args.changes.length === 0) return fullRefresh(); + const tables = new Set(); + for (const change of args.changes) { + const table = typeof change.table === "string" ? change.table : ""; + if ( + !table + || table.trim() !== table + || table.includes("\0") + || Buffer.byteLength(table, "utf8") > SYNC_INVALIDATION_TABLE_MAX_BYTES + ) { + return fullRefresh(); + } + tables.add(table); + if (tables.size > SYNC_INVALIDATION_BATCH_MAX_TABLES) return fullRefresh(); + } + const payload: SyncInvalidationBatchPayload = { + fromDbVersion, + toDbVersion, + tables: [...tables].sort(), + fullRefresh: false, + }; + const envelopeBytes = Buffer.byteLength(encodeSyncEnvelope({ + type: "invalidation_batch", + payload, + compressionThresholdBytes: args.compressionThresholdBytes, + }), "utf8"); + return envelopeBytes <= SYNC_INVALIDATION_BATCH_MAX_ENVELOPE_BYTES + ? payload + : fullRefresh(); +} + export function shouldDeferSyncHostBackgroundChangesForChat(args: { subscribedChatSessionCount: number; bufferedAmount: number; @@ -1053,6 +1177,20 @@ export function buildSyncHostHelloOkPayload(args: { chatStreaming: { enabled: true, }, + ...(isInvalidationOnlyBrowserPeer(args.peer) + ? { + invalidationOnlyV1: { + enabled: true, + }, + } + : {}), + ...(isCompactInvalidationBrowserPeer(args.peer) + ? { + compactInvalidationV1: { + enabled: true as const, + }, + } + : {}), crossProjectChat: { enabled: args.crossProjectChatEnabled, }, @@ -2451,8 +2589,10 @@ export function createSyncHostService(args: SyncHostServiceArgs) { let tailnetServePublishSequence = 0; let tailnetServeActivePublishToken = 0; let discoveryEnabled = args.discoveryEnabled !== false; - let chatPumpInFlight = false; - let changesPumpInFlight = false; + // A peer owns one serialized chat -> changeset poll chain. Keeping the + // in-flight gate per peer prevents a slow transcript filesystem read from + // blocking unrelated peers or their later ack retries. + const pollPumpPeersInFlight = new Set(); // All-projects roster (mobile hub) coalescing state. Each subscribed peer // carries its own monotonic seq (PeerState.rosterSeq); clients re-snapshot on // any seq discontinuity. @@ -2491,33 +2631,38 @@ export function createSyncHostService(args: SyncHostServiceArgs) { args.onStateChanged?.(); }); - const runChatPump = (): void => { - if (disposed || chatPumpInFlight) return; - chatPumpInFlight = true; - void pumpChatEvents() - .catch((error) => { - args.logger.warn("sync_host.chat_poll_failed", { error: error instanceof Error ? error.message : String(error) }); - }) - .finally(() => { - chatPumpInFlight = false; - }); - }; - - const runChangesPump = (): void => { - if (disposed || changesPumpInFlight) return; - changesPumpInFlight = true; - void pumpChanges() - .catch((error) => { - args.logger.warn("sync_host.poll_failed", { error: error instanceof Error ? error.message : String(error) }); - }) - .finally(() => { - changesPumpInFlight = false; + const runPollPump = (): void => { + if (disposed) return; + for (const peer of peers) { + if (pollPumpPeersInFlight.has(peer)) continue; + pollPumpPeersInFlight.add(peer); + void (async () => { + try { + // Preserve chat-first hydration for this peer without making its + // transcript latency part of any other peer's catch-up path. + await pumpChatEvents(peer); + } catch (error) { + args.logger.warn("sync_host.chat_poll_failed", { + peerDeviceId: peer.metadata?.deviceId ?? null, + error: error instanceof Error ? error.message : String(error), + }); + } + try { + await pumpChanges(peer); + } catch (error) { + args.logger.warn("sync_host.poll_failed", { + peerDeviceId: peer.metadata?.deviceId ?? null, + error: error instanceof Error ? error.message : String(error), + }); + } + })().finally(() => { + pollPumpPeersInFlight.delete(peer); }); + } }; const pollTimer = setInterval(() => { - runChatPump(); - runChangesPump(); + runPollPump(); }, pollIntervalMs); const heartbeatTimer = setInterval(() => { pruneExpiredPairFailures(); @@ -2829,7 +2974,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { awaitingHeartbeatAt: null, missedHeartbeatCount: 0, backpressuredSinceMs: null, - changesetChatDeferredSinceMs: null, + changesetPriorityDeferredSinceMs: null, changesetRecoveryLevel: 0, changesetRecoveryNotBeforeMs: 0, remoteAddress, @@ -2842,6 +2987,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { subscribedChatSessionIds: new Set(), chatSubscriptionScopes: new Map(), chatTranscriptOffsets: new Map(), + chatTranscriptScanOffsets: new Map(), chatEventIdsSent: new Map(), resolvedChatTranscriptPaths: new Map(), pendingChangesetBatch: null, @@ -2849,6 +2995,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { rosterSeq: 0, rosterBaseline: new Map(), messageQueue: Promise.resolve(), + queuedMessageCount: 0, terminalInputQueue: Promise.resolve(), pendingTerminalOwnershipChanges: 0, // Paired clients own their local preference. Fail closed on every new @@ -2882,6 +3029,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { return; } if (handleImmediateControlEnvelope(peer, envelope)) return; + peer.queuedMessageCount += 1; const changesTerminalOwnership = envelope.type === "terminal_subscribe" || envelope.type === "terminal_unsubscribe"; if (changesTerminalOwnership) peer.pendingTerminalOwnershipChanges += 1; @@ -2900,6 +3048,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { }); }) .finally(() => { + peer.queuedMessageCount = Math.max(0, peer.queuedMessageCount - 1); if (changesTerminalOwnership) { peer.pendingTerminalOwnershipChanges = Math.max(0, peer.pendingTerminalOwnershipChanges - 1); } @@ -3048,23 +3197,14 @@ export function createSyncHostService(args: SyncHostServiceArgs) { ); terminalInputDedupeLedger.restore(snapshot.terminalInputDedupe ?? []); peer.connectedAt = snapshot.connectedAt; - peer.lastKnownServerDbVersion = Math.max( - 0, - Math.floor(snapshot.metadata.dbVersionBySite?.[args.db.sync.getSiteId()] ?? 0), - ); - if ( - snapshot.serverDbSiteId === args.db.sync.getSiteId() - && typeof snapshot.lastKnownServerDbVersion === "number" - && Number.isFinite(snapshot.lastKnownServerDbVersion) - ) { - // Same project DB as the depositing host (e.g. a same-project host - // restart): its live ack watermark is fresher than the hello-time - // dbVersionBySite snapshot and avoids re-draining the backlog. - peer.lastKnownServerDbVersion = Math.max( - peer.lastKnownServerDbVersion, - Math.floor(snapshot.lastKnownServerDbVersion), - ); - } + const serverDbSiteId = args.db.sync.getSiteId(); + peer.lastKnownServerDbVersion = adoptedSyncHostCursorForPeer({ + peer: snapshot.metadata, + serverDbSiteId, + serverDbVersion: args.db.sync.getDbVersion(), + snapshotServerDbSiteId: snapshot.serverDbSiteId, + snapshotLastKnownServerDbVersion: snapshot.lastKnownServerDbVersion, + }); // Restore live subscriptions so streaming does not silently stop for // a peer that never observes a disconnect. Sessions from a different // project simply no-op on this host; the phone that REQUESTED a @@ -3147,7 +3287,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { } } } - await pumpChanges(); + runPollPump(); } let detachSharedListener: (() => void) | null = null; @@ -3909,7 +4049,14 @@ export function createSyncHostService(args: SyncHostServiceArgs) { attemptCount: 0, retryNotBeforeMs: 0, }; - const sent = send(peer, "changeset_batch", payload); + const sent = isCompactInvalidationBrowserPeer(peer.metadata) + ? send(peer, "invalidation_batch", buildSyncInvalidationBatchPayload({ + fromDbVersion: payload.fromDbVersion, + toDbVersion: payload.toDbVersion, + changes: payload.changes, + compressionThresholdBytes, + })) + : send(peer, "changeset_batch", payload); if (!sent) return null; batch.sentAtMs = Date.now(); batch.attemptCount = 1; @@ -3949,18 +4096,18 @@ export function createSyncHostService(args: SyncHostServiceArgs) { }; } - function finishChangesetChatDeferral( + function finishChangesetPriorityDeferral( peer: PeerState, reason: "pressure_relieved" | "no_changes" | "batch_admitted", nowMs: number, ): void { - if (peer.changesetChatDeferredSinceMs == null) return; - args.logger.debug("sync_host.changeset_chat_deferral_ended", { + if (peer.changesetPriorityDeferredSinceMs == null) return; + args.logger.debug("sync_host.changeset_priority_deferral_ended", { peerDeviceId: peer.metadata?.deviceId ?? null, reason, - deferredMs: Math.max(0, nowMs - peer.changesetChatDeferredSinceMs), + deferredMs: Math.max(0, nowMs - peer.changesetPriorityDeferredSinceMs), }); - peer.changesetChatDeferredSinceMs = null; + peer.changesetPriorityDeferredSinceMs = null; } function abandonPendingChangesetBatch( @@ -4456,32 +4603,141 @@ export function createSyncHostService(args: SyncHostServiceArgs) { async function readChatTranscriptEventsSince( transcriptPath: string, startOffset: number, - ): Promise<{ events: AgentChatEventEnvelope[]; nextOffset: number }> { + scanOffset: number | null, + ): Promise<{ + events: AgentChatEventEnvelope[]; + nextOffset: number; + nextScanOffset: number | null; + droppedOversizedRecordBytes: number | null; + }> { let fh: fs.promises.FileHandle | null = null; try { fh = await fs.promises.open(transcriptPath, "r"); const stat = await fh.stat(); const size = stat.size; - const normalizedStart = Math.max(0, Math.min(startOffset, size)); - if (size <= normalizedStart) { - return { events: [], nextOffset: size }; + const durableStart = Math.max(0, Math.floor(startOffset)); + // A truncation/rotation invalidates both cursors. Restart from the new + // EOF (the same recovery behavior as the old unbounded reader). + if (size < durableStart || (scanOffset != null && size < scanOffset)) { + return { + events: [], + nextOffset: size, + nextScanOffset: null, + droppedOversizedRecordBytes: null, + }; + } + const normalizedScanOffset = scanOffset == null + ? null + : Math.max(durableStart, Math.floor(scanOffset)); + const readStart = normalizedScanOffset ?? durableStart; + if (size <= readStart) { + return { + events: [], + nextOffset: durableStart, + nextScanOffset: normalizedScanOffset, + droppedOversizedRecordBytes: null, + }; } - const out = Buffer.alloc(size - normalizedStart); - await fh.read(out, 0, out.length, normalizedStart); - const lastNewline = out.lastIndexOf(0x0a); + const readLength = Math.min( + size - readStart, + SYNC_HOST_CHAT_TRANSCRIPT_DELTA_MAX_BYTES, + ); + const out = Buffer.alloc(readLength); + const { bytesRead } = await fh.read(out, 0, out.length, readStart); + const readSlice = out.subarray(0, bytesRead); + if (normalizedScanOffset != null) { + const firstNewline = readSlice.indexOf(0x0a); + if (firstNewline < 0) { + return { + events: [], + nextOffset: durableStart, + nextScanOffset: readStart + bytesRead, + droppedOversizedRecordBytes: null, + }; + } + const firstRecordEnd = readStart + firstNewline + 1; + const firstRecordBytes = firstRecordEnd - durableStart; + const lastNewline = readSlice.lastIndexOf(0x0a); + if (firstRecordBytes <= SYNC_HOST_CHAT_TRANSCRIPT_MAX_RECORD_BYTES) { + // The long record is still deliverable. Re-read it once, now that a + // complete boundary is known, together with any later complete rows + // already present in this bounded scan chunk. + const completeEnd = readStart + lastNewline + 1; + const completeBytes = completeEnd - durableStart; + const completeSlice = Buffer.alloc(completeBytes); + let rereadBytes = 0; + while (rereadBytes < completeBytes) { + const reread = await fh.read( + completeSlice, + rereadBytes, + completeBytes - rereadBytes, + durableStart + rereadBytes, + ); + if (reread.bytesRead <= 0) break; + rereadBytes += reread.bytesRead; + } + if (rereadBytes < completeBytes) { + return { + events: [], + nextOffset: durableStart, + nextScanOffset: normalizedScanOffset, + droppedOversizedRecordBytes: null, + }; + } + return { + events: parseAgentChatTranscript(completeSlice.toString("utf8")), + nextOffset: durableStart + completeSlice.length, + nextScanOffset: null, + droppedOversizedRecordBytes: null, + }; + } + + // A single record beyond the explicit one-record ceiling is not safe + // to materialize. Drop exactly that complete row, surface a structured + // warning, and recover at its newline; later complete rows still flow. + const firstCompleteOffset = firstNewline + 1; + const completeSlice = readSlice.subarray(firstCompleteOffset, lastNewline + 1); + return { + events: completeSlice.length > 0 + ? parseAgentChatTranscript(completeSlice.toString("utf8")) + : [], + nextOffset: readStart + lastNewline + 1, + nextScanOffset: null, + droppedOversizedRecordBytes: firstRecordBytes, + }; + } + + const lastNewline = readSlice.lastIndexOf(0x0a); if (lastNewline < 0) { - return { events: [], nextOffset: normalizedStart }; + const hitReadBound = bytesRead === SYNC_HOST_CHAT_TRANSCRIPT_DELTA_MAX_BYTES; + return { + events: [], + nextOffset: durableStart, + // A short trailing record may still be mid-write, so retain and + // retry it. Once one record fills the normal cap, scan for its + // newline in bounded chunks; a record within the separate hard + // ceiling is then re-read and delivered intact. + nextScanOffset: hitReadBound ? readStart + bytesRead : null, + droppedOversizedRecordBytes: null, + }; } - const completeSlice = out.subarray(0, lastNewline + 1); + const completeSlice = readSlice.subarray(0, lastNewline + 1); const raw = completeSlice.toString("utf8"); return { events: parseAgentChatTranscript(raw), - nextOffset: normalizedStart + completeSlice.length, + nextOffset: durableStart + completeSlice.length, + nextScanOffset: null, + droppedOversizedRecordBytes: null, }; } catch { - return { events: [], nextOffset: Math.max(0, startOffset) }; + return { + events: [], + nextOffset: Math.max(0, startOffset), + nextScanOffset: scanOffset, + droppedOversizedRecordBytes: null, + }; } finally { await fh?.close().catch(() => {}); } @@ -4609,33 +4865,49 @@ export function createSyncHostService(args: SyncHostServiceArgs) { return sent ? "sent" : "failed"; } - async function pumpChatEvents(): Promise { - if (disposed) return; - - for (const peer of peers) { - if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue; - if (isPeerBackpressured(peer)) continue; - for (const sessionId of peer.subscribedChatSessionIds) { - // A foreign quick-look session has no local row; tail its resolved - // transcript path directly. Local sessions resolve via sessionService. - const resolvedTranscriptPath = peer.resolvedChatTranscriptPaths.get(sessionId); - const transcriptPath = resolvedTranscriptPath ?? args.sessionService.get(sessionId)?.transcriptPath; - if (!transcriptPath) continue; - - const startOffset = peer.chatTranscriptOffsets.get(sessionId) ?? 0; - const { events, nextOffset } = await readChatTranscriptEventsSince(transcriptPath, startOffset); - let allEventsDelivered = true; - for (const event of events) { - const seq = recordChatEventSeq(event); - if (sendChatEvent(peer, event, seq) === "failed") { - allEventsDelivered = false; - break; - } - } - if (allEventsDelivered && nextOffset !== startOffset) { - peer.chatTranscriptOffsets.set(sessionId, nextOffset); + async function pumpChatEvents(peer: PeerState): Promise { + if (disposed || !peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) return; + if (isPeerBackpressured(peer)) return; + for (const sessionId of peer.subscribedChatSessionIds) { + // A foreign quick-look session has no local row; tail its resolved + // transcript path directly. Local sessions resolve via sessionService. + const resolvedTranscriptPath = peer.resolvedChatTranscriptPaths.get(sessionId); + const transcriptPath = resolvedTranscriptPath ?? args.sessionService.get(sessionId)?.transcriptPath; + if (!transcriptPath) continue; + + const startOffset = peer.chatTranscriptOffsets.get(sessionId) ?? 0; + const scanOffset = peer.chatTranscriptScanOffsets.get(sessionId) ?? null; + const { + events, + nextOffset, + nextScanOffset, + droppedOversizedRecordBytes, + } = await readChatTranscriptEventsSince(transcriptPath, startOffset, scanOffset); + if (droppedOversizedRecordBytes != null) { + args.logger.warn("sync_host.chat_transcript_record_too_large", { + peerDeviceId: peer.metadata?.deviceId ?? null, + sessionId, + recordBytes: droppedOversizedRecordBytes, + maxRecordBytes: SYNC_HOST_CHAT_TRANSCRIPT_MAX_RECORD_BYTES, + }); + } + let allEventsDelivered = true; + for (const event of events) { + const seq = recordChatEventSeq(event); + if (sendChatEvent(peer, event, seq) === "failed") { + allEventsDelivered = false; + break; } } + if (!allEventsDelivered) continue; + if (nextOffset !== startOffset) { + peer.chatTranscriptOffsets.set(sessionId, nextOffset); + } + if (nextScanOffset == null) { + peer.chatTranscriptScanOffsets.delete(sessionId); + } else { + peer.chatTranscriptScanOffsets.set(sessionId, nextScanOffset); + } } } @@ -4658,20 +4930,19 @@ export function createSyncHostService(args: SyncHostServiceArgs) { markRosterDirty(); } - async function pumpChanges(): Promise { + async function pumpChanges(peer: PeerState): Promise { if (disposed) return; const currentDbVersion = args.db.sync.getDbVersion(); const nowMs = Date.now(); - for (const peer of peers) { - if (!peer.authenticated || !peer.metadata || peer.ws.readyState !== WebSocket.OPEN) continue; + if (!peer.authenticated || !peer.metadata || peer.ws.readyState !== WebSocket.OPEN) return; // A paired desktop runtime connection shares this authenticated socket // only for rpc/fwd envelopes. The authoritative pairing record remains // the gate so a phone/browser cannot suppress its normal CRDT stream by // spoofing the hello capability. - if (isRuntimeOnlyPairedHost(peer)) continue; + if (isRuntimeOnlyPairedHost(peer)) return; // The 4 MiB gate is a hard socket-safety boundary. Fair scheduling may // override only the lower chat-priority watermark below. - if (isPeerBackpressured(peer)) continue; + if (isPeerBackpressured(peer)) return; if (peer.pendingChangesetBatch) { const pending = peer.pendingChangesetBatch; const rejectedRetryDue = pending.retryNotBeforeMs > 0 && nowMs >= pending.retryNotBeforeMs; @@ -4680,7 +4951,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { if (rejectedRetryDue || ackTimedOut) { if (pending.attemptCount >= MAX_CHANGESET_SEND_ATTEMPTS) { abandonPendingChangesetBatch(peer, ackTimedOut ? "ack_timeout" : "ack_failed", nowMs); - continue; + return; } const resent = resendPendingChangesetBatch(peer); if (resent) { @@ -4694,32 +4965,39 @@ export function createSyncHostService(args: SyncHostServiceArgs) { }); } } - continue; + return; } if (currentDbVersion <= peer.lastKnownServerDbVersion) { - finishChangesetChatDeferral(peer, "no_changes", nowMs); - continue; + finishChangesetPriorityDeferral(peer, "no_changes", nowMs); + return; } - if (nowMs < peer.changesetRecoveryNotBeforeMs) continue; - if (shouldDeferBackgroundChangesForChat(peer)) { - if (peer.changesetChatDeferredSinceMs == null) { - peer.changesetChatDeferredSinceMs = nowMs; - args.logger.debug("sync_host.changeset_chat_deferral_started", { + if (nowMs < peer.changesetRecoveryNotBeforeMs) return; + const hasQueuedForegroundWork = peer.queuedMessageCount > 0; + const chatBackpressured = shouldDeferBackgroundChangesForChat(peer); + if (hasQueuedForegroundWork || chatBackpressured) { + if (peer.changesetPriorityDeferredSinceMs == null) { + peer.changesetPriorityDeferredSinceMs = nowMs; + args.logger.debug("sync_host.changeset_priority_deferral_started", { peerDeviceId: peer.metadata.deviceId, bufferedAmount: peer.ws.bufferedAmount, thresholdBytes: SYNC_HOST_CHAT_ACTIVE_BACKGROUND_BACKPRESSURE_BYTES, - maxDeferMs: SYNC_HOST_CHAT_ACTIVE_MAX_CHANGESET_DEFER_MS, + hasQueuedForegroundWork, + chatBackpressured, + maxDeferMs: SYNC_HOST_PRIORITY_MAX_CHANGESET_DEFER_MS, }); } - if (nowMs - peer.changesetChatDeferredSinceMs < SYNC_HOST_CHAT_ACTIVE_MAX_CHANGESET_DEFER_MS) { - continue; + if (nowMs - peer.changesetPriorityDeferredSinceMs < SYNC_HOST_PRIORITY_MAX_CHANGESET_DEFER_MS) { + return; } } else { - finishChangesetChatDeferral(peer, "pressure_relieved", nowMs); + finishChangesetPriorityDeferral(peer, "pressure_relieved", nowMs); } const recoveryLimits = changesetBatchLimits(peer); const chatLimits = syncHostChangesetBatchOptionsForChat({ - subscribedChatSessionCount: peer.subscribedChatSessionIds.size, + // Once a foreground queue ages past the deadline, admit only the same + // small batch used for an active chat. This bounds the synchronous + // export pause before the peer returns to its serialized messages. + subscribedChatSessionCount: peer.subscribedChatSessionIds.size + (hasQueuedForegroundWork ? 1 : 0), maxRows: recoveryLimits.maxRows, maxBytes: recoveryLimits.maxBytes, }); @@ -4761,8 +5039,8 @@ export function createSyncHostService(args: SyncHostServiceArgs) { toDbVersion: exportedThroughDbVersion, reason: "peer_owned_changes_only", }); - finishChangesetChatDeferral(peer, "no_changes", nowMs); - continue; + finishChangesetPriorityDeferral(peer, "no_changes", nowMs); + return; } const pending = sendNextChangesetBatch( peer, @@ -4774,15 +5052,14 @@ export function createSyncHostService(args: SyncHostServiceArgs) { ); if (pending) { peer.changesetRecoveryNotBeforeMs = 0; - if (peerSupportsChangesetAck(peer)) { + if (peerSupportsChangesetAck(peer) && !isCompactInvalidationBrowserPeer(peer.metadata)) { peer.pendingChangesetBatch = pending; } else { peer.lastKnownServerDbVersion = Math.max(peer.lastKnownServerDbVersion, pending.toDbVersion); } - finishChangesetChatDeferral(peer, "batch_admitted", nowMs); + finishChangesetPriorityDeferral(peer, "batch_admitted", nowMs); lastBroadcastAt = nowIso(); } - } } function handleChangesetAck(peer: PeerState, payload: SyncChangesetAckPayload | null | undefined): void { @@ -6091,9 +6368,12 @@ export function createSyncHostService(args: SyncHostServiceArgs) { // DB; after a hosted-project change it points into a different DB's // version sequence and silently skips (or replays) the entire backlog. const ownSiteId = args.db.sync.getSiteId(); - const cursorForThisDb = hello.peer.dbVersionBySite?.[ownSiteId] - ?? (hello.peer.dbVersionBySite ? 0 : hello.peer.dbVersion); - peer.lastKnownServerDbVersion = Math.max(0, Math.floor(cursorForThisDb)); + const serverDbVersion = args.db.sync.getDbVersion(); + peer.lastKnownServerDbVersion = initialSyncHostCursorForPeer({ + peer: hello.peer, + serverDbSiteId: ownSiteId, + serverDbVersion, + }); args.deviceRegistryService?.upsertPeerMetadata(hello.peer, { lastSeenAt: nowIso(), lastHost: peer.remoteAddress, @@ -6112,7 +6392,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { send(peer.ws, "hello_ok", buildSyncHostHelloOkPayload({ peer: hello.peer, brain: readBrainMetadata(), - serverDbVersion: args.db.sync.getDbVersion(), + serverDbVersion, serverDbSiteId: ownSiteId, heartbeatIntervalMs, pollIntervalMs, @@ -6137,7 +6417,8 @@ export function createSyncHostService(args: SyncHostServiceArgs) { accountPairing, }), envelope.requestId); args.onStateChanged?.(); - await pumpChanges(); + // Catch-up is background work. The periodic poll starts it after the + // serialized hello queue has had a chance to admit subscriptions. if (!isPeerLifecycleCurrent(peer, lifecycleGeneration)) return; broadcastBrainStatus(); return; @@ -6616,6 +6897,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { ? fs.statSync(transcriptPath).size : 0; peer.chatTranscriptOffsets.set(sessionId, transcriptSize); + peer.chatTranscriptScanOffsets.delete(sessionId); const resumeAck: SyncChatSubscribeSnapshotPayload = { sessionId, capturedAt: nowIso(), @@ -6668,6 +6950,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { } events = events.map(compactChatEventEnvelopeForSync); peer.chatTranscriptOffsets.set(sessionId, transcriptSize); + peer.chatTranscriptScanOffsets.delete(sessionId); const snapshot: SyncChatSubscribeSnapshotPayload = { sessionId, capturedAt: nowIso(), @@ -6688,6 +6971,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { peer.subscribedChatSessionIds.delete(sessionId); peer.chatSubscriptionScopes.delete(sessionId); peer.chatTranscriptOffsets.delete(sessionId); + peer.chatTranscriptScanOffsets.delete(sessionId); peer.chatEventIdsSent.delete(sessionId); peer.resolvedChatTranscriptPaths.delete(sessionId); } diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts index a40bb77a8..1ebe7e073 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts @@ -1208,9 +1208,13 @@ describe("createSyncRemoteCommandService", () => { const result = await service.execute(makePayload("chat.getChatEventHistory", { sessionId: "chat-1", maxEvents: 128, + maxBytes: 131_072, })); - expect(getChatEventHistory).toHaveBeenCalledWith("chat-1", { maxEvents: 128 }); + expect(getChatEventHistory).toHaveBeenCalledWith("chat-1", { + maxEvents: 128, + maxBytes: 131_072, + }); expect(result).toEqual({ sessionId: "chat-1", events: [], diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 0ef236110..b049efb6f 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -1379,6 +1379,30 @@ function parseCliPermissionMode(value: unknown): SyncStartCliSessionArgs["permis return isTrackedCliPermissionMode(mode) ? mode : "default"; } +function parseOptionalCliPermissionMode(value: unknown): SyncSendToSessionArgs["permissionMode"] { + const mode = asTrimmedString(value); + return isTrackedCliPermissionMode(mode) ? mode : undefined; +} + +function parseOptionalCodexApprovalPolicy(value: unknown): SyncSendToSessionArgs["codexApprovalPolicy"] { + const policy = asTrimmedString(value); + return policy === "untrusted" || policy === "on-request" || policy === "on-failure" || policy === "never" + ? policy + : undefined; +} + +function parseOptionalCodexSandbox(value: unknown): SyncSendToSessionArgs["codexSandbox"] { + const sandbox = asTrimmedString(value); + return sandbox === "read-only" || sandbox === "workspace-write" || sandbox === "danger-full-access" + ? sandbox + : undefined; +} + +function parseOptionalCodexConfigSource(value: unknown): SyncSendToSessionArgs["codexConfigSource"] { + const source = asTrimmedString(value); + return source === "flags" || source === "config-toml" ? source : undefined; +} + function parseStartCliSessionArgs(value: Record): SyncStartCliSessionArgs { const laneId = requireString(value.laneId, "work.startCliSession requires laneId."); const provider = parseCliProvider(value.provider); @@ -1435,6 +1459,10 @@ function parseListExternalSessionsArgs(value: Record): SyncList } result.limit = Math.max(1, Math.min(100, Math.floor(value.limit))); } + if (value.sessionId != null) { + if (typeof value.sessionId !== "string") throw new Error("work.listExternalSessions sessionId must be a string."); + result.sessionId = value.sessionId.trim(); + } return result; } @@ -1457,6 +1485,8 @@ function parseImportExternalSessionArgs(value: Record): SyncImp target, mode, ...(asTrimmedString(value.model) ? { model: asTrimmedString(value.model)! } : {}), + ...(asTrimmedString(value.reasoningEffort) ? { reasoningEffort: asTrimmedString(value.reasoningEffort)! } : {}), + ...(typeof value.fastMode === "boolean" ? { fastMode: value.fastMode } : {}), ...(asTrimmedString(value.permissionMode) ? { permissionMode: asTrimmedString(value.permissionMode)! } : {}), }; } @@ -2093,6 +2123,13 @@ function parseSendToSessionArgs(value: Record): SyncSendToSessi text, cols: asOptionalNumber(value.cols), rows: asOptionalNumber(value.rows), + model: asTrimmedString(value.model), + reasoningEffort: asTrimmedString(value.reasoningEffort), + fastMode: asOptionalBoolean(value.fastMode), + permissionMode: parseOptionalCliPermissionMode(value.permissionMode), + codexApprovalPolicy: parseOptionalCodexApprovalPolicy(value.codexApprovalPolicy), + codexSandbox: parseOptionalCodexSandbox(value.codexSandbox), + codexConfigSource: parseOptionalCodexConfigSource(value.codexConfigSource), }; } @@ -3781,6 +3818,13 @@ function registerWorkRemoteCommands({ args, register }: RemoteCommandRegistratio text: parsed.text, ...(parsed.cols != null ? { cols: parsed.cols } : {}), ...(parsed.rows != null ? { rows: parsed.rows } : {}), + ...(parsed.model != null ? { model: parsed.model } : {}), + ...(parsed.reasoningEffort != null ? { reasoningEffort: parsed.reasoningEffort } : {}), + ...(parsed.fastMode != null ? { fastMode: parsed.fastMode } : {}), + ...(parsed.permissionMode != null ? { permissionMode: parsed.permissionMode } : {}), + ...(parsed.codexApprovalPolicy != null ? { codexApprovalPolicy: parsed.codexApprovalPolicy } : {}), + ...(parsed.codexSandbox != null ? { codexSandbox: parsed.codexSandbox } : {}), + ...(parsed.codexConfigSource != null ? { codexConfigSource: parsed.codexConfigSource } : {}), }); return result satisfies SyncSendToSessionResult; }); @@ -3934,7 +3978,15 @@ function registerChatRemoteCommands({ args, register }: RemoteCommandRegistratio const agentChatService = requireService(args.agentChatService, "Agent chat service not available."); const sessionId = requireString(payload.sessionId, "chat.getChatEventHistory requires sessionId."); const maxEvents = asOptionalNumber(payload.maxEvents); - return agentChatService.getChatEventHistory(sessionId, maxEvents == null ? undefined : { maxEvents }); + const maxBytes = asOptionalNumber(payload.maxBytes); + const options = { + ...(maxEvents != null ? { maxEvents } : {}), + ...(maxBytes != null ? { maxBytes } : {}), + }; + return agentChatService.getChatEventHistory( + sessionId, + Object.keys(options).length > 0 ? options : undefined, + ); }); register("chat.getTranscript", { viewerAllowed: true }, async (payload) => { const agentChatService = requireService(args.agentChatService, "Agent chat service not available."); diff --git a/apps/ade-cli/src/services/sync/syncService.ts b/apps/ade-cli/src/services/sync/syncService.ts index 5a8839bd9..ba2d4f55d 100644 --- a/apps/ade-cli/src/services/sync/syncService.ts +++ b/apps/ade-cli/src/services/sync/syncService.ts @@ -592,8 +592,8 @@ export function createSyncService(args: SyncServiceArgs) { lastFailureAt: null, lastSuccessAt: null, }; - let refreshRunning = false; let refreshQueued = false; + let refreshPromise: Promise | null = null; let disposed = false; // Mobile project switch can fire `sync.initialize` as a background task and // then immediately await `service.initialize()` from the dialog handler. @@ -1031,100 +1031,108 @@ export function createSyncService(args: SyncServiceArgs) { return !argsIn.cluster || isStaleNonLocalBrainCluster(argsIn.cluster, argsIn.localDevice.deviceId); }; - const refreshRoleState = async (): Promise => { - if (disposed) return; - if (refreshRunning) { - refreshQueued = true; - return; - } - refreshRunning = true; - try { + const refreshRoleState = (): Promise => { + if (disposed) return Promise.resolve(); + refreshQueued = true; + if (refreshPromise) return refreshPromise; + + // Every caller joins the same drain promise. In particular, a host-start + // request immediately followed by a rollback must not report the rollback + // complete while the first role refresh is still starting the host. + const work = Promise.resolve().then(async () => { + try { do { refreshQueued = false; - const savedDraft = readSavedDraft(); - syncPeerService.setSavedDraft(savedDraft); - const localDevice = deviceRegistryService.ensureLocalDevice(); - let cluster = deviceRegistryService.getClusterState(); - if (forceHostRole) { - if (!cluster || cluster.brainDeviceId !== localDevice.deviceId) { - cluster = deviceRegistryService.setClusterState({ - brainDeviceId: localDevice.deviceId, - brainEpoch: (cluster?.brainEpoch ?? 0) + 1, - updatedByDeviceId: localDevice.deviceId, - }); - } - } else if (!savedDraft) { - if (!cluster) { - cluster = deviceRegistryService.bootstrapLocalBrainIfNeeded(); - } else if (isStaleNonLocalBrainCluster(cluster, localDevice.deviceId)) { - deviceRegistryService.touchLocalDevice({ - lastSeenAt: nowIso(), - lastHost: localDevice.lastHost, - lastPort: localDevice.lastPort ?? DEFAULT_SYNC_HOST_PORT, - }); - cluster = deviceRegistryService.setClusterState({ - brainDeviceId: localDevice.deviceId, - brainEpoch: (cluster?.brainEpoch ?? 0) + 1, - updatedByDeviceId: localDevice.deviceId, - }); - } - } - const isLocalBrain = forceHostRole || (cluster - ? cluster.brainDeviceId === localDevice.deviceId - : !savedDraft); - if (isLocalBrain) { - if (syncPeerService.isConnected()) { - syncPeerService.disconnect({ preserveDraft: true }); + try { + const savedDraft = readSavedDraft(); + syncPeerService.setSavedDraft(savedDraft); + const localDevice = deviceRegistryService.ensureLocalDevice(); + let cluster = deviceRegistryService.getClusterState(); + if (forceHostRole) { + if (!cluster || cluster.brainDeviceId !== localDevice.deviceId) { + cluster = deviceRegistryService.setClusterState({ + brainDeviceId: localDevice.deviceId, + brainEpoch: (cluster?.brainEpoch ?? 0) + 1, + updatedByDeviceId: localDevice.deviceId, + }); + } + } else if (!savedDraft) { + if (!cluster) { + cluster = deviceRegistryService.bootstrapLocalBrainIfNeeded(); + } else if (isStaleNonLocalBrainCluster(cluster, localDevice.deviceId)) { + deviceRegistryService.touchLocalDevice({ + lastSeenAt: nowIso(), + lastHost: localDevice.lastHost, + lastPort: localDevice.lastPort ?? DEFAULT_SYNC_HOST_PORT, + }); + cluster = deviceRegistryService.setClusterState({ + brainDeviceId: localDevice.deviceId, + brainEpoch: (cluster?.brainEpoch ?? 0) + 1, + updatedByDeviceId: localDevice.deviceId, + }); + } } - await startHostIfNeeded(); - } else { - await stopHostIfRunning(); - if (!isCrdtSyncAvailable()) { + const isLocalBrain = forceHostRole || (cluster + ? cluster.brainDeviceId === localDevice.deviceId + : !savedDraft); + if (isLocalBrain) { if (syncPeerService.isConnected()) { syncPeerService.disconnect({ preserveDraft: true }); } - continue; - } - const draft = savedDraft ?? resolveViewerDraftFromRegistry(); - if (draft && !syncPeerService.isConnected()) { - syncPeerService.setSavedDraft(draft); - try { - await syncPeerService.connect(draft); - deviceRegistryService.touchLocalDevice({ lastSeenAt: nowIso() }); - syncPeerService.flushLocalChanges(); - } catch (error) { - args.logger.warn("sync.role.viewer_connect_failed", { - error: error instanceof Error ? error.message : String(error), - }); - if (shouldReclaimStaleViewerDraft({ cluster, localDevice, draft, error })) { - args.logger.warn("sync.role.viewer_stale_draft_reclaimed", { - host: draft.host, - port: draft.port, - previousBrainDeviceId: cluster?.brainDeviceId ?? null, + await startHostIfNeeded(); + } else { + await stopHostIfRunning(); + if (!isCrdtSyncAvailable()) { + if (syncPeerService.isConnected()) { + syncPeerService.disconnect({ preserveDraft: true }); + } + continue; + } + const draft = savedDraft ?? resolveViewerDraftFromRegistry(); + if (draft && !syncPeerService.isConnected()) { + syncPeerService.setSavedDraft(draft); + try { + await syncPeerService.connect(draft); + deviceRegistryService.touchLocalDevice({ lastSeenAt: nowIso() }); + syncPeerService.flushLocalChanges(); + } catch (error) { + args.logger.warn("sync.role.viewer_connect_failed", { error: error instanceof Error ? error.message : String(error), }); - writeSavedDraft(null); - syncPeerService.setSavedDraft(null); - deviceRegistryService.touchLocalDevice({ - lastSeenAt: nowIso(), - lastHost: localDevice.lastHost, - lastPort: localDevice.lastPort ?? DEFAULT_SYNC_HOST_PORT, - }); - cluster = deviceRegistryService.setClusterState({ - brainDeviceId: localDevice.deviceId, - brainEpoch: (cluster?.brainEpoch ?? 0) + 1, - updatedByDeviceId: localDevice.deviceId, - }); - await startHostIfNeeded(); + if (shouldReclaimStaleViewerDraft({ cluster, localDevice, draft, error })) { + args.logger.warn("sync.role.viewer_stale_draft_reclaimed", { + host: draft.host, + port: draft.port, + previousBrainDeviceId: cluster?.brainDeviceId ?? null, + error: error instanceof Error ? error.message : String(error), + }); + writeSavedDraft(null); + syncPeerService.setSavedDraft(null); + deviceRegistryService.touchLocalDevice({ + lastSeenAt: nowIso(), + lastHost: localDevice.lastHost, + lastPort: localDevice.lastPort ?? DEFAULT_SYNC_HOST_PORT, + }); + cluster = deviceRegistryService.setClusterState({ + brainDeviceId: localDevice.deviceId, + brainEpoch: (cluster?.brainEpoch ?? 0) + 1, + updatedByDeviceId: localDevice.deviceId, + }); + await startHostIfNeeded(); + } } } } + } finally { + await emitStatus(); } - } while (refreshQueued); - } finally { - refreshRunning = false; - await emitStatus(); - } + } while (refreshQueued && !disposed); + } finally { + if (refreshPromise === work) refreshPromise = null; + } + }); + refreshPromise = work; + return work; }; const listRuntimeDevices = async (): Promise => { diff --git a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts index a9d2f69ab..be60311d9 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { AgentChatEventEnvelope } from "../../../../desktop/src/shared/types/chat"; -import { archiveChatSession, cancelSteerMessage, createChatSession, DEFAULT_CODEX_REASONING_EFFORT, deleteChatSession, deriveClaudeGoalFromEvents, dispatchSteerMessage, discoverProjectSlashCommands, editSteerMessage, getAvailableModels, getChatHistoryPage, getMainTranscript, latestGoal, latestTokenStats, listChatSessions, listLaneDiffStats, listPrsByLane, listTerminalSessions, messageChatSession, recoverCodexTurn, resumeTerminalSession, runDefaultLaneSetup, sendChatMessage, signalTerminal, startCliTerminalSession, steerChatMessage, trackedCliTerminalProvider, unarchiveChatSession } from "../adeApi"; +import { archiveChatSession, buildPtyContinuationLaunchFields, cancelSteerMessage, createChatSession, DEFAULT_CODEX_REASONING_EFFORT, deleteChatSession, deriveClaudeGoalFromEvents, dispatchSteerMessage, discoverProjectSlashCommands, editSteerMessage, getAvailableModels, getChatHistoryPage, getMainTranscript, latestGoal, latestTokenStats, listChatSessions, listLaneDiffStats, listPrsByLane, listTerminalSessions, messageChatSession, recoverCodexTurn, resumeTerminalSession, runDefaultLaneSetup, sendChatMessage, signalTerminal, startCliTerminalSession, steerChatMessage, trackedCliTerminalProvider, unarchiveChatSession } from "../adeApi"; import type { ChatTerminalSession } from "../../../../desktop/src/shared/types/sessions"; import type { AdeCodeConnection } from "../types"; @@ -1099,6 +1099,44 @@ describe("trackedCliTerminalProvider", () => { }); describe("resumeTerminalSession", () => { + it("forwards stored continuation controls, including legacy codexFastMode", async () => { + const calls: Array<{ domain: string; action: string; args?: Record }> = []; + const connection = { + action: async (domain: string, action: string, args?: Record) => { + calls.push({ domain, action, args }); + return { sessionId: "term-1", ptyId: "pty-1", pid: 123, session: null, resumed: true, reusedExistingRuntime: false }; + }, + } as unknown as AdeCodeConnection; + const continuation = buildPtyContinuationLaunchFields({ + model: "gpt-5.5-codex", + reasoningEffort: "high", + codexFastMode: true, + permissionMode: "full-auto", + codexApprovalPolicy: "never", + codexSandbox: "danger-full-access", + codexConfigSource: "flags", + }); + + await resumeTerminalSession({ connection, sessionId: "term-1", cols: 100, rows: 28, ...continuation }); + + expect(calls[0]).toEqual({ + domain: "pty", + action: "resumeSession", + args: { + sessionId: "term-1", + cols: 100, + rows: 28, + model: "gpt-5.5-codex", + reasoningEffort: "high", + fastMode: true, + permissionMode: "full-auto", + codexApprovalPolicy: "never", + codexSandbox: "danger-full-access", + codexConfigSource: "flags", + }, + }); + }); + it("routes no-prompt terminal resumes through the PTY action domain", async () => { const calls: Array<{ domain: string; action: string; args?: Record }> = []; const connection = { diff --git a/apps/ade-cli/src/tuiClient/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts index 14f46a63e..d1a3ca7ba 100644 --- a/apps/ade-cli/src/tuiClient/adeApi.ts +++ b/apps/ade-cli/src/tuiClient/adeApi.ts @@ -56,6 +56,10 @@ import type { import type { DiffLineStats, GitBranchSummary } from "../../../desktop/src/shared/types/git"; import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; import type { PrLaneSummary } from "../../../desktop/src/shared/types/prs"; +import { + buildPtyContinuationLaunchFields, + type PtyContinuationLaunchFields, +} from "../../../desktop/src/shared/cliLaunch"; import type { ChatTerminalPreviewResult, ChatTerminalSession, @@ -67,6 +71,7 @@ import { discoverAllProjectSlashCommands } from "../../../desktop/src/main/servi import type { AdeCodeConnection, AdeCodeInterfaceMode, AdeCodeProvider, ChatHistorySnapshot, CreatedChat, NavigateRequest, NavigateResult } from "./types"; export const DEFAULT_CODEX_REASONING_EFFORT = "low"; +export { buildPtyContinuationLaunchFields }; export async function listLanes( connection: AdeCodeConnection, @@ -349,12 +354,13 @@ export async function sendToTerminalSession(args: { text: string; cols: number; rows: number; -}): Promise { +} & PtyContinuationLaunchFields): Promise { return await args.connection.action("pty", "sendToSession", { sessionId: args.sessionId, text: args.text, cols: args.cols, rows: args.rows, + ...buildPtyContinuationLaunchFields(args), }); } @@ -363,11 +369,12 @@ export async function resumeTerminalSession(args: { sessionId: string; cols: number; rows: number; -}): Promise { +} & PtyContinuationLaunchFields): Promise { return await args.connection.action("pty", "resumeSession", { sessionId: args.sessionId, cols: args.cols, rows: args.rows, + ...buildPtyContinuationLaunchFields(args), }); } diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 046a6fa08..e9f53a7ca 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -49,6 +49,7 @@ import { DEFAULT_CODEX_REASONING_EFFORT, approveToolUse, archiveChatSession, + buildPtyContinuationLaunchFields, cancelSteerMessage, createChatSession, deleteChatSession, @@ -8635,6 +8636,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, text, cols, rows: terminalRows, + ...buildPtyContinuationLaunchFields(terminal.resumeMetadata?.launch), }); await refreshTerminalPreview(conn, terminal.terminalId); return true; @@ -8645,6 +8647,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, text, cols, rows: terminalRows, + ...buildPtyContinuationLaunchFields(terminal.resumeMetadata?.launch), }); pendingNewChatTitleRef.current = null; setDraftChatMode(false); @@ -8678,6 +8681,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, sessionId: terminal.terminalId, cols, rows: terminalRows, + ...buildPtyContinuationLaunchFields(terminal.resumeMetadata?.launch), }); pendingNewChatTitleRef.current = null; setDraftChatMode(false); diff --git a/apps/ade-cli/tsup.config.ts b/apps/ade-cli/tsup.config.ts index 8d988a8dd..42d02da61 100644 --- a/apps/ade-cli/tsup.config.ts +++ b/apps/ade-cli/tsup.config.ts @@ -44,7 +44,8 @@ export default defineConfig([ adeRpcServer: "src/adeRpcServer.ts", ptyHostWorker: "../desktop/src/main/services/pty/ptyHostWorker.ts", cursorSdkWorker: "../desktop/src/main/services/chat/cursorSdkWorker.ts", - droidSdkWorker: "../desktop/src/main/services/chat/droidSdkWorker.ts" + droidSdkWorker: "../desktop/src/main/services/chat/droidSdkWorker.ts", + usageLedgerWorker: "../desktop/src/main/services/usage/usageLedgerWorkerEntry.ts" }, format: ["cjs"], platform: "node", diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index a68c06caf..9b074b02d 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -17495,9 +17495,9 @@ "license": "0BSD" }, "node_modules/react-router": { - "version": "7.13.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.2.tgz", - "integrity": "sha512-tX1Aee+ArlKQP+NIUd7SE6Li+CiGKwQtbS+FfRxPX6Pe4vHOo6nr9d++u5cwg+Z8K/x8tP+7qLmujDtfrAoUJA==", + "version": "7.15.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz", + "integrity": "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -17517,12 +17517,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.13.2", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.2.tgz", - "integrity": "sha512-aR7SUORwTqAW0JDeiWF07e9SBE9qGpByR9I8kJT5h/FrBKxPMS6TiC7rmVO+gC0q52Bx7JnjWe8Z1sR9faN4YA==", + "version": "7.15.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.1.tgz", + "integrity": "sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==", "license": "MIT", "dependencies": { - "react-router": "7.13.2" + "react-router": "7.15.1" }, "engines": { "node": ">=20.0.0" @@ -21849,9 +21849,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -21914,9 +21914,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 1ad07ef5d..161f4af80 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -235,13 +235,17 @@ "from": "../ade-cli/dist/droidSdkWorker.cjs", "to": "ade-cli/droidSdkWorker.cjs" }, + { + "from": "../ade-cli/dist/usageLedgerWorker.cjs", + "to": "ade-cli/usageLedgerWorker.cjs" + }, { "from": "../ade-cli/dist/adeRpcServer.cjs", "to": "ade-cli/adeRpcServer.cjs" }, { - "from": "../ade-cli/dist/tuiClient", - "to": "ade-cli/tuiClient" + "from": "../ade-cli/dist/tuiClient/cli.mjs", + "to": "ade-cli/tuiClient/cli.mjs" }, { "from": "scripts/ade-cli-macos-wrapper.sh", diff --git a/apps/desktop/scripts/after-pack-runtime-fixes.cjs b/apps/desktop/scripts/after-pack-runtime-fixes.cjs index 5fea8329b..4214abed4 100644 --- a/apps/desktop/scripts/after-pack-runtime-fixes.cjs +++ b/apps/desktop/scripts/after-pack-runtime-fixes.cjs @@ -4,6 +4,10 @@ const { normalizeDesktopRuntimeBinaries, resolvePackagedRuntimeRoot, } = require("./runtimeBinaryPermissions.cjs"); +const { + missingRequiredPackagedAdeCliPayloadPaths, + packagedAdeCliPayloadFiles, +} = require("./packaged-ade-cli-resources.cjs"); const appDir = path.resolve(__dirname, ".."); @@ -390,20 +394,19 @@ module.exports = async function afterPack(context) { } const resourcesRoot = resolveExtraResourcesRoot(context, appBundlePath); - const bundledCliPath = path.join(resourcesRoot, "ade-cli", "cli.cjs"); - const bundledCliBootstrapPath = path.join(resourcesRoot, "ade-cli", "bootstrap.cjs"); - const bundledCliPtyHostWorkerPath = path.join(resourcesRoot, "ade-cli", "ptyHostWorker.cjs"); - const bundledCliCursorSdkWorkerPath = path.join(resourcesRoot, "ade-cli", "cursorSdkWorker.cjs"); - const bundledCliDroidSdkWorkerPath = path.join(resourcesRoot, "ade-cli", "droidSdkWorker.cjs"); - const bundledCliRpcPath = path.join(resourcesRoot, "ade-cli", "adeRpcServer.cjs"); - const bundledCliTuiPath = path.join(resourcesRoot, "ade-cli", "tuiClient", "cli.mjs"); - requireFile(bundledCliPath, "bundled ADE CLI entry"); - requireFile(bundledCliBootstrapPath, "bundled ADE CLI bootstrap entry"); - requireFile(bundledCliPtyHostWorkerPath, "bundled ADE CLI PTY host worker"); - requireFile(bundledCliCursorSdkWorkerPath, "bundled ADE CLI Cursor SDK worker"); - requireFile(bundledCliDroidSdkWorkerPath, "bundled ADE CLI Droid SDK worker"); - requireFile(bundledCliRpcPath, "bundled ADE CLI RPC entry"); - requireFile(bundledCliTuiPath, "bundled ADE CLI TUI entry"); + const bundledAdeCliFiles = packagedAdeCliPayloadFiles({ desktopRoot: appDir }); + const missingRequiredPayload = missingRequiredPackagedAdeCliPayloadPaths(bundledAdeCliFiles); + if (missingRequiredPayload.length > 0) { + throw new Error( + `[afterPack] ADE CLI resources omit required payload: ${missingRequiredPayload.join(", ")}`, + ); + } + for (const resource of bundledAdeCliFiles) { + requireFile( + path.join(resourcesRoot, resource.to), + `bundled ADE CLI resource ${resource.to}`, + ); + } if (platform === "darwin") { const bundledCliBinPath = path.join(resourcesRoot, "ade-cli", "bin", "ade"); diff --git a/apps/desktop/scripts/ensure-ade-cli-build.cjs b/apps/desktop/scripts/ensure-ade-cli-build.cjs index d3e71155f..c3837e72a 100644 --- a/apps/desktop/scripts/ensure-ade-cli-build.cjs +++ b/apps/desktop/scripts/ensure-ade-cli-build.cjs @@ -3,20 +3,14 @@ const cp = require("node:child_process"); const fs = require("node:fs"); const path = require("node:path"); +const { packagedAdeCliBuildResources } = require("./packaged-ade-cli-resources.cjs"); const desktopRoot = path.resolve(__dirname, ".."); const repoRoot = path.resolve(desktopRoot, "..", ".."); const cliRoot = path.join(repoRoot, "apps", "ade-cli"); -const distFiles = [ - path.join(cliRoot, "dist", "cli.cjs"), - path.join(cliRoot, "dist", "bootstrap.cjs"), - path.join(cliRoot, "dist", "adeRpcServer.cjs"), - path.join(cliRoot, "dist", "ptyHostWorker.cjs"), - path.join(cliRoot, "dist", "cursorSdkWorker.cjs"), - path.join(cliRoot, "dist", "droidSdkWorker.cjs"), - path.join(cliRoot, "dist", "tuiClient", "cli.mjs"), -]; +const distFiles = packagedAdeCliBuildResources({ desktopRoot }) + .map((entry) => entry.sourcePath); const sourceEntries = [ path.join(cliRoot, "src"), @@ -45,7 +39,13 @@ function newestMtimeMs(entryPath) { } newest = Math.max(newest, stat.mtimeMs); if (!stat.isDirectory()) continue; - for (const child of fs.readdirSync(current)) { + let children; + try { + children = fs.readdirSync(current); + } catch { + return Number.POSITIVE_INFINITY; + } + for (const child of children) { if (child === "node_modules" || child === "dist" || child === ".turbo") continue; stack.push(path.join(current, child)); } @@ -53,16 +53,34 @@ function newestMtimeMs(entryPath) { return newest; } +function oldestMtimeMs(entryPath) { + let stat; + try { + stat = fs.statSync(entryPath); + } catch { + return 0; + } + if (!stat.isDirectory()) return stat.mtimeMs; + let children; + try { + children = fs.readdirSync(entryPath); + } catch { + return 0; + } + if (children.length === 0) return 0; + return children.reduce( + (oldest, child) => Math.min(oldest, oldestMtimeMs(path.join(entryPath, child))), + Number.POSITIVE_INFINITY, + ); +} + function oldestDistMtimeMs() { + if (distFiles.length === 0) return 0; let oldest = Number.POSITIVE_INFINITY; for (const filePath of distFiles) { - let stat; - try { - stat = fs.statSync(filePath); - } catch { - return 0; - } - oldest = Math.min(oldest, stat.mtimeMs); + const fileMtime = oldestMtimeMs(filePath); + if (fileMtime === 0) return 0; + oldest = Math.min(oldest, fileMtime); } return oldest; } diff --git a/apps/desktop/scripts/packaged-ade-cli-resources.cjs b/apps/desktop/scripts/packaged-ade-cli-resources.cjs new file mode 100644 index 000000000..ffaf05d47 --- /dev/null +++ b/apps/desktop/scripts/packaged-ade-cli-resources.cjs @@ -0,0 +1,134 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +const defaultDesktopRoot = path.resolve(__dirname, ".."); +const REQUIRED_PACKAGED_ADE_CLI_PAYLOAD_PATHS = Object.freeze([ + "cli.cjs", + "bootstrap.cjs", + "ptyHostWorker.cjs", + "cursorSdkWorker.cjs", + "droidSdkWorker.cjs", + "usageLedgerWorker.cjs", + "adeRpcServer.cjs", + "tuiClient/cli.mjs", + "bin/ade", + "bin/ade.cmd", + "install-path.sh", + "install-path.cmd", +]); + +function normalizeResourcePath(value) { + return String(value).replaceAll("\\", "/").replace(/^\.\//, ""); +} + +function readDesktopPackageJson(desktopRoot = defaultDesktopRoot) { + return JSON.parse(fs.readFileSync(path.join(desktopRoot, "package.json"), "utf8")); +} + +function packagedAdeCliResources(options = {}) { + const desktopRoot = options.desktopRoot ?? defaultDesktopRoot; + const packageJson = options.packageJson ?? readDesktopPackageJson(desktopRoot); + const resources = packageJson.build?.extraResources; + if (!Array.isArray(resources)) return []; + + return resources.flatMap((entry) => { + if (!entry || typeof entry.from !== "string" || typeof entry.to !== "string") return []; + const destination = normalizeResourcePath(entry.to); + if (destination !== "ade-cli" && !destination.startsWith("ade-cli/")) return []; + return [{ + from: entry.from, + sourcePath: path.resolve(desktopRoot, entry.from), + to: destination, + relativePath: destination.slice("ade-cli/".length), + }]; + }); +} + +function packagedAdeCliBuildResources(options = {}) { + return packagedAdeCliResources(options).filter((entry) => { + const source = normalizeResourcePath(entry.from); + return source === "../ade-cli/dist" || source.startsWith("../ade-cli/dist/"); + }); +} + +function concretePayloadFile(resource, sourcePath, sourceRelativePath = "") { + const relativeSuffix = normalizeResourcePath(sourceRelativePath); + const destination = relativeSuffix + ? path.posix.join(resource.to, relativeSuffix) + : resource.to; + return { + ...resource, + sourcePath, + to: destination, + relativePath: destination === "ade-cli" + ? "" + : destination.slice("ade-cli/".length), + }; +} + +function expandResourcePayloadFiles(resource, options) { + let stat; + try { + stat = fs.lstatSync(resource.sourcePath); + } catch (error) { + if (options.allowMissingSources && resource.relativePath) { + return [concretePayloadFile(resource, resource.sourcePath)]; + } + const detail = error instanceof Error ? `: ${error.message}` : ""; + throw new Error( + `[ade-cli:resources] Unable to inspect configured resource ${resource.from}${detail}`, + ); + } + + if (!stat.isDirectory()) { + return [concretePayloadFile(resource, resource.sourcePath)]; + } + + const files = []; + const visit = (directoryPath) => { + const entries = fs.readdirSync(directoryPath, { withFileTypes: true }) + .sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + const entryPath = path.join(directoryPath, entry.name); + if (entry.isDirectory()) { + visit(entryPath); + continue; + } + files.push(concretePayloadFile( + resource, + entryPath, + path.relative(resource.sourcePath, entryPath), + )); + } + }; + visit(resource.sourcePath); + return files; +} + +function packagedAdeCliPayloadFiles(options = {}) { + return packagedAdeCliResources(options).flatMap((resource) => ( + expandResourcePayloadFiles(resource, options) + )); +} + +function missingRequiredPackagedAdeCliPayloadPaths(payloadFiles) { + const packagedPaths = new Set(payloadFiles.map((resource) => resource.relativePath)); + return REQUIRED_PACKAGED_ADE_CLI_PAYLOAD_PATHS.filter((relativePath) => ( + !packagedPaths.has(relativePath) + )); +} + +function sourceContainsPath(sourcePath, candidatePath) { + const relative = path.relative(sourcePath, candidatePath); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +module.exports = { + REQUIRED_PACKAGED_ADE_CLI_PAYLOAD_PATHS, + packagedAdeCliBuildResources, + packagedAdeCliPayloadFiles, + packagedAdeCliResources, + missingRequiredPackagedAdeCliPayloadPaths, + readDesktopPackageJson, + sourceContainsPath, +}; diff --git a/apps/desktop/scripts/packaged-ade-cli-resources.test.mjs b/apps/desktop/scripts/packaged-ade-cli-resources.test.mjs new file mode 100644 index 000000000..e29585ff6 --- /dev/null +++ b/apps/desktop/scripts/packaged-ade-cli-resources.test.mjs @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import packagedAdeCliResourcesModule from "./packaged-ade-cli-resources.cjs"; + +const { + missingRequiredPackagedAdeCliPayloadPaths, + packagedAdeCliPayloadFiles, +} = packagedAdeCliResourcesModule; + +function createTempRoot() { + return fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-resources-")); +} + +test("expands directory resources into concrete files without following symlink cycles", () => { + const root = createTempRoot(); + try { + const payloadRoot = path.join(root, "payload"); + fs.mkdirSync(path.join(payloadRoot, "nested"), { recursive: true }); + fs.writeFileSync(path.join(payloadRoot, "cli.cjs"), ""); + fs.writeFileSync(path.join(payloadRoot, "nested", "worker.cjs"), ""); + fs.symlinkSync( + payloadRoot, + path.join(payloadRoot, "nested", "loop"), + process.platform === "win32" ? "junction" : "dir", + ); + + const payloadFiles = packagedAdeCliPayloadFiles({ + desktopRoot: root, + packageJson: { + build: { + extraResources: [{ from: "payload", to: "ade-cli" }], + }, + }, + }); + + assert.deepEqual( + payloadFiles.map((resource) => resource.relativePath), + ["cli.cjs", "nested/loop", "nested/worker.cjs"], + ); + assert.deepEqual( + payloadFiles.map((resource) => resource.to), + ["ade-cli/cli.cjs", "ade-cli/nested/loop", "ade-cli/nested/worker.cjs"], + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("reports every missing runtime-critical ADE CLI payload", () => { + assert.deepEqual( + missingRequiredPackagedAdeCliPayloadPaths([ + { relativePath: "cli.cjs" }, + { relativePath: "usageLedgerWorker.cjs" }, + ]), + [ + "bootstrap.cjs", + "ptyHostWorker.cjs", + "cursorSdkWorker.cjs", + "droidSdkWorker.cjs", + "adeRpcServer.cjs", + "tuiClient/cli.mjs", + "bin/ade", + "bin/ade.cmd", + "install-path.sh", + "install-path.cmd", + ], + ); +}); diff --git a/apps/desktop/scripts/validate-mac-artifacts.mjs b/apps/desktop/scripts/validate-mac-artifacts.mjs index 8e507106d..f455781ac 100644 --- a/apps/desktop/scripts/validate-mac-artifacts.mjs +++ b/apps/desktop/scripts/validate-mac-artifacts.mjs @@ -6,6 +6,7 @@ import { promisify } from "node:util"; import { fileURLToPath, pathToFileURL } from "node:url"; import asar from "@electron/asar"; import { parse as parseYaml } from "yaml"; +import packagedAdeCliResourcesModule from "./packaged-ade-cli-resources.cjs"; const execFileAsync = promisify(execFile); @@ -20,6 +21,10 @@ const DEFAULT_MAX_UNIVERSAL_UNPACKED_BYTES = 1600 * 1024 * 1024; const EXPECTED_APPLICATION_IDENTIFIER = "VQ372F39G6.com.ade.desktop"; const EXPECTED_KEYCHAIN_ACCESS_GROUP = "VQ372F39G6.com.ade.desktop.webauthn"; const ALLOWED_KEYCHAIN_ACCESS_GROUP = "VQ372F39G6.*"; +const { + missingRequiredPackagedAdeCliPayloadPaths, + packagedAdeCliPayloadFiles, +} = packagedAdeCliResourcesModule; const bundledAgentSkills = [ "ade-cli-control-plane", "ade-ios-simulator", @@ -32,17 +37,20 @@ const bundledAgentSkills = [ "ade-deeplinks", "ade-orchestrator", ]; -const bundledAdeCliFiles = [ - ["cli.cjs", "bundled ADE CLI entry"], - ["bootstrap.cjs", "bundled ADE CLI bootstrap entry"], - ["ptyHostWorker.cjs", "bundled ADE CLI PTY host worker"], - ["cursorSdkWorker.cjs", "bundled ADE CLI Cursor SDK worker"], - ["droidSdkWorker.cjs", "bundled ADE CLI Droid SDK worker"], - ["adeRpcServer.cjs", "bundled ADE CLI RPC entry"], - ["tuiClient/cli.mjs", "bundled ADE CLI TUI entry"], - ["bin/ade", "bundled ADE CLI wrapper"], - ["install-path.sh", "bundled ADE CLI PATH installer"], -]; +const bundledAdeCliFiles = packagedAdeCliPayloadFiles({ desktopRoot: appDir }) + .map((resource) => [ + resource.relativePath, + `bundled ADE CLI resource ${resource.to}`, + ]); +const missingRequiredBundledAdeCliFiles = missingRequiredPackagedAdeCliPayloadPaths( + bundledAdeCliFiles.map(([relativePath]) => ({ relativePath })), +); +if (missingRequiredBundledAdeCliFiles.length > 0) { + throw new Error( + `[release:mac] package.json build.extraResources omits required ADE CLI payload: ` + + missingRequiredBundledAdeCliFiles.join(", "), + ); +} function readFlag(name) { const prefix = `${name}=`; diff --git a/apps/desktop/scripts/validate-win-artifacts.mjs b/apps/desktop/scripts/validate-win-artifacts.mjs index fca9fbde4..04e44fcea 100644 --- a/apps/desktop/scripts/validate-win-artifacts.mjs +++ b/apps/desktop/scripts/validate-win-artifacts.mjs @@ -6,11 +6,16 @@ import { spawn } from "node:child_process"; import { fileURLToPath, pathToFileURL } from "node:url"; import asar from "@electron/asar"; import { parse as parseYaml } from "yaml"; +import packagedAdeCliResourcesModule from "./packaged-ade-cli-resources.cjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const desktopRoot = path.resolve(__dirname, ".."); const packageJsonPath = path.join(desktopRoot, "package.json"); const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); +const { + missingRequiredPackagedAdeCliPayloadPaths, + packagedAdeCliPayloadFiles, +} = packagedAdeCliResourcesModule; const productName = pkg.build?.productName ?? pkg.productName ?? "ADE"; const DEFAULT_MAX_APP_ASAR_BYTES = 900 * 1024 * 1024; // The unpacked runtime includes x64 Codex, Claude, OpenCode, node-pty, and @@ -33,17 +38,22 @@ const bundledAgentSkills = [ "ade-deeplinks", "ade-orchestrator", ]; -const bundledAdeCliFiles = [ - ["cli.cjs", "bundled ADE CLI entry"], - ["bootstrap.cjs", "bundled ADE CLI bootstrap entry"], - ["ptyHostWorker.cjs", "bundled ADE CLI PTY host worker"], - ["cursorSdkWorker.cjs", "bundled ADE CLI Cursor SDK worker"], - ["droidSdkWorker.cjs", "bundled ADE CLI Droid SDK worker"], - ["adeRpcServer.cjs", "bundled ADE CLI RPC entry"], - ["tuiClient/cli.mjs", "bundled ADE CLI TUI entry"], - ["bin/ade.cmd", "bundled ADE CLI wrapper"], - ["install-path.cmd", "bundled ADE CLI PATH installer"], -]; +function resolveBundledAdeCliFiles(options = {}) { + return packagedAdeCliPayloadFiles({ + desktopRoot, + packageJson: pkg, + ...options, + }); +} + +function assertRequiredBundledAdeCliFiles(payloadFiles) { + const missing = missingRequiredPackagedAdeCliPayloadPaths(payloadFiles); + if (missing.length > 0) { + fail( + `package.json build.extraResources omits required ADE CLI payload: ${missing.join(", ")}`, + ); + } +} function readFlag(name) { const prefix = `${name}=`; @@ -177,17 +187,6 @@ function requireFile(relativePath, label) { } } -function hasExtraResource(to) { - return Array.isArray(pkg.build?.extraResources) - && pkg.build.extraResources.some((entry) => entry && entry.to === to); -} - -function requireExtraResource(to) { - if (!hasExtraResource(to)) { - fail(`package.json build.extraResources must ship ${to}`); - } -} - function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } @@ -217,19 +216,7 @@ function validatePreflight() { requireFile("scripts/ade-cli-install-path.cmd", "Windows ADE CLI PATH installer"); requireFile("vendor/crsqlite/win32-x64/crsqlite.dll", "Windows cr-sqlite extension"); - for (const relativePath of [ - "cli.cjs", - "bin/ade.cmd", - "bootstrap.cjs", - "ptyHostWorker.cjs", - "cursorSdkWorker.cjs", - "droidSdkWorker.cjs", - "adeRpcServer.cjs", - "tuiClient", - "install-path.cmd", - ]) { - requireExtraResource(`ade-cli/${relativePath}`); - } + assertRequiredBundledAdeCliFiles(resolveBundledAdeCliFiles({ allowMissingSources: true })); if (!Array.isArray(pkg.build?.asarUnpack) || !pkg.build.asarUnpack.includes("vendor/crsqlite/**")) { fail("package.json build.asarUnpack must unpack vendor/crsqlite/**"); } @@ -525,12 +512,17 @@ async function validatePackagedRuntime(appDir) { const sqlJsModulePath = path.join(nodeModulesPath, "sql.js"); const smokeScriptPath = path.join(unpackedPath, "dist", "main", "packagedRuntimeSmoke.cjs"); const crsqliteDllPath = path.join(unpackedPath, "vendor", "crsqlite", "win32-x64", "crsqlite.dll"); + const bundledAdeCliFiles = resolveBundledAdeCliFiles(); + assertRequiredBundledAdeCliFiles(bundledAdeCliFiles); await assertPathExists(appExe, "packaged Windows app executable"); await assertPathExists(appAsarPath, "app.asar payload"); await assertPathExists(unpackedPath, "app.asar.unpacked runtime payload"); - for (const [relativePath, label] of bundledAdeCliFiles) { - await assertPathExists(path.join(resourcesPath, "ade-cli", relativePath), label); + for (const resource of bundledAdeCliFiles) { + await assertPathExists( + path.join(resourcesPath, resource.to), + `bundled ADE CLI resource ${resource.to}`, + ); } await assertBundledAgentSkills(bundledAgentSkillsRoot); await assertPathExists(nodePtyModulePath, "unpacked node-pty module"); diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index f1df01ee3..27ac37166 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -3093,7 +3093,7 @@ app.whenReady().then(async () => { logger, appVersion: app.getVersion(), getAdeCliAgentEnv: adeCliService.agentEnv, - getLocalGitHubToken: () => githubService.getTokenOrThrow(), + getLocalGitHubToken: () => githubService.getTokenOrThrowAsync(), onLinearIssueChatLinked: publishLinearChatLink, onEvent: (event) => { emitProjectEvent(projectRoot, IPC.agentChatEvent, event); diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index b6b715828..b9e6e34dc 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -21867,6 +21867,192 @@ describe("createAgentChatService", () => { expect(page.hasMore).toBe(false); }); + it("pages identical UTF-8 transcript rows by occurrence without skipping the older duplicate", async () => { + installRealTranscriptParser(); + const { service } = createService(); + const session = await service.createSession({ laneId: "lane-1", provider: "codex", model: "gpt-5.4" }); + const duplicate: AgentChatEventEnvelope = { + sessionId: session.id, + timestamp: "2026-06-10T10:00:00.000Z", + event: { type: "text", text: "héllo-🙂-漢字" }, + sequence: 1, + }; + const line = `${JSON.stringify(duplicate)}\n`; + const lineBytes = Buffer.byteLength(line, "utf8"); + expect(lineBytes).toBeGreaterThan(line.length); + + const transcriptFile = path.join(tmpRoot, "transcripts", `${session.id}.chat.jsonl`); + fs.writeFileSync(transcriptFile, `${line}${line}`, "utf8"); + + const history = service.getChatEventHistory(session.id, { maxEvents: 1 }); + expect(history.events).toHaveLength(1); + expect(history.events[0]?.event).toEqual(duplicate.event); + expect(history.tailStartOffset).toBe(lineBytes); + + const page = service.getChatEventHistoryPage(session.id, { + beforeOffset: history.tailStartOffset!, + }); + expect(page.events).toHaveLength(1); + expect(page.events[0]?.event).toEqual(duplicate.event); + expect(page.startOffset).toBe(0); + expect(page.hasMore).toBe(false); + }); + + it("keeps a requested-byte snapshot seamless with its older page and unflushed ring events", async () => { + installRealTranscriptParser(); + const emitted: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => emitted.push(event), + }); + const session = await service.createSession({ laneId: "lane-1", provider: "codex", model: "gpt-5.4" }); + + // Create a normal committed event, then replace the files beneath it so + // the ring is one append ahead of the transcript snapshot (the same + // state as an fs.appendFile still in flight). + const pendingInput = service.requestChatInput({ + chatSessionId: session.id, + title: "Live ring event", + body: "Choose one", + questions: [{ + id: "choice", + question: "Choose one", + options: [{ label: "One" }, { label: "Two" }], + }], + }); + const liveRingEvent = await waitForEvent( + emitted, + (entry): entry is Omit & { + event: Extract; + } => entry.event.type === "approval_request", + ); + + const LINE_BYTES = 16 * 1024; + const LINE_COUNT = 24; + const lines = Array.from({ length: LINE_COUNT }, (_, index) => paddedLine({ + sessionId: session.id, + timestamp: new Date(Date.UTC(2026, 0, 1, 0, 0, index)).toISOString(), + event: { type: "text", text: `persisted-${index}-` }, + sequence: index, + }, LINE_BYTES)); + const raw = lines.join(""); + const legacyTranscript = path.join(tmpRoot, "transcripts", `${session.id}.chat.jsonl`); + const durableTranscript = path.join(tmpRoot, ".ade", "transcripts", "chat", `${session.id}.jsonl`); + fs.mkdirSync(path.dirname(legacyTranscript), { recursive: true }); + fs.mkdirSync(path.dirname(durableTranscript), { recursive: true }); + fs.writeFileSync(legacyTranscript, raw, "utf8"); + fs.writeFileSync(durableTranscript, raw, "utf8"); + + const maxBytes = 128 * 1024; + const history = service.getChatEventHistory(session.id, { maxEvents: 512, maxBytes }); + expect(history.events).toContainEqual(liveRingEvent); + expect(history.tailStartOffset).toEqual(expect.any(Number)); + expect(history.tailStartOffset).toBeGreaterThan(0); + expect(history.events.reduce( + (total, entry) => total + Buffer.byteLength(JSON.stringify(entry), "utf8"), + 0, + )).toBeLessThanOrEqual(maxBytes); + + const snapshotSequences = history.events.flatMap((entry) => + typeof entry.sequence === "number" && entry.event.type === "text" ? [entry.sequence] : []); + expect(snapshotSequences.length).toBeGreaterThan(0); + const firstSnapshotSequence = snapshotSequences[0]!; + expect(history.tailStartOffset).toBe(firstSnapshotSequence * LINE_BYTES); + + const page = service.getChatEventHistoryPage(session.id, { + beforeOffset: history.tailStartOffset!, + maxBytes, + }); + const pageSequences = page.events.flatMap((entry) => + typeof entry.sequence === "number" && entry.event.type === "text" ? [entry.sequence] : []); + expect(pageSequences.at(-1)).toBe(firstSnapshotSequence - 1); + expect(new Set([...pageSequences, ...snapshotSequences]).size) + .toBe(pageSequences.length + snapshotSequences.length); + + await service.respondToInput({ + sessionId: session.id, + itemId: liveRingEvent.event.itemId, + decision: "decline", + }); + await pendingInput; + }); + + it("keeps small snapshots and older pages on one deterministic transcript", async () => { + installRealTranscriptParser(); + const { service } = createService(); + const session = await service.createSession({ laneId: "lane-1", provider: "codex", model: "gpt-5.4" }); + + const LINE_BYTES = 8 * 1024; + const durableTargetCount = 8; + const durableLines = [ + ...Array.from({ length: durableTargetCount }, (_, index) => paddedLine({ + sessionId: session.id, + timestamp: new Date(Date.UTC(2026, 0, 2, 0, 0, index)).toISOString(), + event: { type: "text", text: `durable-${index}-` }, + sequence: index, + }, LINE_BYTES)), + // More than 128 KiB of unrelated trailing data makes the small + // hydration probe see no events for this session, while the fixed + // 2 MiB identity probe still sees the durable target history. + ...Array.from({ length: 18 }, (_, index) => paddedLine({ + sessionId: "other-session", + timestamp: new Date(Date.UTC(2026, 0, 2, 1, 0, index)).toISOString(), + event: { type: "text", text: `foreign-${index}-` }, + sequence: 1_000 + index, + }, LINE_BYTES)), + ]; + const legacyLines = Array.from({ length: 20 }, (_, index) => paddedLine({ + sessionId: session.id, + timestamp: new Date(Date.UTC(2026, 0, 1, 0, 0, index)).toISOString(), + event: { type: "text", text: `legacy-${index}-` }, + sequence: 2_000 + index, + }, LINE_BYTES)); + + const legacyTranscript = path.join(tmpRoot, "transcripts", `${session.id}.chat.jsonl`); + const durableTranscript = path.join(tmpRoot, ".ade", "transcripts", "chat", `${session.id}.jsonl`); + fs.mkdirSync(path.dirname(legacyTranscript), { recursive: true }); + fs.mkdirSync(path.dirname(durableTranscript), { recursive: true }); + fs.writeFileSync(legacyTranscript, legacyLines.join(""), "utf8"); + fs.writeFileSync(durableTranscript, durableLines.join(""), "utf8"); + fs.utimesSync(legacyTranscript, new Date("2026-01-01T00:00:00.000Z"), new Date("2026-01-01T00:00:00.000Z")); + fs.utimesSync(durableTranscript, new Date("2026-01-02T00:00:00.000Z"), new Date("2026-01-02T00:00:00.000Z")); + vi.mocked(parseAgentChatTranscript).mockClear(); + + const maxBytes = 128 * 1024; + const history = service.getChatEventHistory(session.id, { maxEvents: 512, maxBytes }); + expect(history.tailStartOffset).toBe(Buffer.byteLength(durableLines.join(""), "utf8")); + expect(history.events.some((entry) => + entry.event.type === "text" && entry.event.text.startsWith("legacy-"), + )).toBe(false); + + const durableSequences = history.events.flatMap((entry) => + entry.event.type === "text" && entry.event.text.startsWith("durable-") && typeof entry.sequence === "number" + ? [entry.sequence] + : []); + let beforeOffset = history.tailStartOffset!; + while (beforeOffset > 0) { + const parseCallsBeforePage = vi.mocked(parseAgentChatTranscript).mock.calls.length; + const page = service.getChatEventHistoryPage(session.id, { beforeOffset, maxBytes }); + // Candidate ranking reuses both fixed-window cache entries. The only + // parse here is the page payload itself, rather than synchronously + // re-reading both candidates on every scroll-back request. + expect(parseAgentChatTranscript).toHaveBeenCalledTimes(parseCallsBeforePage + 1); + expect(page.events.some((entry) => + entry.event.type === "text" && entry.event.text.startsWith("legacy-"), + )).toBe(false); + durableSequences.push(...page.events.flatMap((entry) => + entry.event.type === "text" && entry.event.text.startsWith("durable-") && typeof entry.sequence === "number" + ? [entry.sequence] + : [])); + expect(page.startOffset).toBeLessThan(beforeOffset); + beforeOffset = page.startOffset; + } + + expect(durableSequences.slice().sort((a, b) => a - b)).toEqual( + Array.from({ length: durableTargetCount }, (_, index) => index), + ); + expect(new Set(durableSequences).size).toBe(durableSequences.length); + }); + it("reports a null tailStartOffset when the transcript is fully hydrated", async () => { const { service } = createService(); const session = await service.createSession({ laneId: "lane-1", provider: "codex", model: "gpt-5.4" }); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index e24df2017..443ca96f0 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -6382,7 +6382,7 @@ export function createAgentChatService(args: { appVersion: string; getAdeCliAgentEnv?: (baseEnv?: NodeJS.ProcessEnv) => NodeJS.ProcessEnv; /** Resolves credentials owned by this runtime only; never supplied by a handoff capsule. */ - getLocalGitHubToken?: () => string | null | undefined; + getLocalGitHubToken?: () => string | null | undefined | Promise; resolveCodexComputerUseMcp?: () => | CodexComputerUseMcpConfig | null @@ -6592,6 +6592,10 @@ export function createAgentChatService(args: { const CHAT_EVENT_HISTORY_RESPONSE_MAX_PER_SESSION = 20_000; const CHAT_EVENT_HISTORY_TRANSCRIPT_MAX_BYTES = 2_000_000; const CHAT_EVENT_HISTORY_TRANSCRIPT_CACHE_MAX_SESSIONS = 32; + // Path selection has at most three candidates; the selected path can also + // be parsed at a smaller client hydration window. Retain that working set + // without allowing arbitrary caller budgets to grow the cache indefinitely. + const CHAT_EVENT_HISTORY_TRANSCRIPT_CACHE_MAX_ENTRIES_PER_SESSION = 4; // Byte budgets alongside the event-count caps above. Individual events are // unbounded (multi-MB tool outputs exist in real transcripts), so count caps // alone cannot keep a history snapshot under the desktop RPC client's @@ -6642,6 +6646,7 @@ export function createAgentChatService(args: { // Envelopes are immutable once recorded; cache their serialized size so // byte-budget trims do not re-stringify multi-MB events on every snapshot. const envelopeSizeCache = new WeakMap(); + const envelopeByteSizeCache = new WeakMap(); const estimateEnvelopeChars = (envelope: AgentChatEventEnvelope): number => { let size = envelopeSizeCache.get(envelope); @@ -6652,6 +6657,19 @@ export function createAgentChatService(args: { return size; }; + const estimateEnvelopeBytes = (envelope: AgentChatEventEnvelope): number => { + let size = envelopeByteSizeCache.get(envelope); + if (size == null) { + try { + size = Buffer.byteLength(JSON.stringify(envelope), "utf8"); + } catch { + size = 2_048; + } + envelopeByteSizeCache.set(envelope, size); + } + return size; + }; + const trimEnvelopesToByteBudget = ( envelopes: AgentChatEventEnvelope[], maxChars: number, @@ -6945,11 +6963,25 @@ export function createAgentChatService(args: { transcriptPath: string; size: number; mtimeMs: number; + maxBytes: number; truncated: boolean; /** Byte offset (line start) where the cached tail window begins; 0 when not truncated. */ startOffset: number; + /** Logical transcript end offset (decompressed bytes for gzip files). */ + endOffset: number; hasCapNotice: boolean; envelopes: AgentChatEventEnvelope[]; + /** + * Cursor metadata keyed by the parsed envelope object itself. Values are + * null only when a custom/legacy parser produced an envelope that could + * not be aligned with a physical JSONL line. + * + * Keeping this index on the immutable cached envelopes avoids retaining a + * second payload-sized JSON.stringify(event) for every row. Object identity + * also distinguishes byte-for-byte duplicate rows without occurrence + * counters or per-request copies of offset arrays. + */ + envelopeStartOffsetByIdentity: WeakMap; }; const transcriptHistoryCacheBySession = new Map>(); type TranscriptSubagentSnapshotCacheEntry = { @@ -6971,7 +7003,14 @@ export function createAgentChatService(args: { entry: TranscriptHistoryCacheEntry, ): void => { const sessionEntries = transcriptHistoryCacheBySession.get(sessionId) ?? new Map(); - sessionEntries.set(entry.transcriptPath, entry); + const cacheKey = `${entry.transcriptPath}\0${entry.maxBytes}`; + sessionEntries.delete(cacheKey); + sessionEntries.set(cacheKey, entry); + while (sessionEntries.size > CHAT_EVENT_HISTORY_TRANSCRIPT_CACHE_MAX_ENTRIES_PER_SESSION) { + const oldestKey = sessionEntries.keys().next().value; + if (typeof oldestKey !== "string") break; + sessionEntries.delete(oldestKey); + } transcriptHistoryCacheBySession.delete(sessionId); transcriptHistoryCacheBySession.set(sessionId, sessionEntries); while (transcriptHistoryCacheBySession.size > CHAT_EVENT_HISTORY_TRANSCRIPT_CACHE_MAX_SESSIONS) { @@ -8236,11 +8275,12 @@ export function createAgentChatService(args: { const readTranscriptTailForHistory = ( transcriptPath: string, stat: fs.Stats, - ): { raw: string; truncated: boolean; startOffset: number } => { + maxBytes: number, + ): { raw: string; truncated: boolean; startOffset: number; endOffset: number } => { if (transcriptPath.endsWith(".gz")) { const full = readHistoryFileSync(transcriptPath); const size = full.length; - const start = Math.max(0, size - CHAT_EVENT_HISTORY_TRANSCRIPT_MAX_BYTES); + const start = Math.max(0, size - maxBytes); let slice = full.subarray(start); let startOffset = start; if (start > 0 && slice.length > 0) { @@ -8256,16 +8296,17 @@ export function createAgentChatService(args: { raw: slice.toString("utf8"), truncated: start > 0, startOffset: start > 0 ? startOffset : 0, + endOffset: size, }; } const size = stat.size; - const start = Math.max(0, size - CHAT_EVENT_HISTORY_TRANSCRIPT_MAX_BYTES); + const start = Math.max(0, size - maxBytes); // Read one extra byte before the window (when possible) so a window // boundary that lands exactly on a line start does not silently drop a // complete line: if byte `start - 1` is "\n" the line at `start` is kept. const readStart = Math.max(0, start - 1); const length = size - readStart; - if (length <= 0) return { raw: "", truncated: false, startOffset: 0 }; + if (length <= 0) return { raw: "", truncated: false, startOffset: 0, endOffset: size }; const fd = fs.openSync(transcriptPath, "r"); try { const out = Buffer.allocUnsafe(length); @@ -8289,7 +8330,12 @@ export function createAgentChatService(args: { startOffset = start; } } - return { raw: slice.toString("utf8"), truncated, startOffset: truncated ? startOffset : 0 }; + return { + raw: slice.toString("utf8"), + truncated, + startOffset: truncated ? startOffset : 0, + endOffset: size, + }; } finally { fs.closeSync(fd); } @@ -8298,38 +8344,86 @@ export function createAgentChatService(args: { const parseTranscriptHistoryTail = ( sessionId: string, transcriptPath: string, - ): { envelopes: AgentChatEventEnvelope[]; truncated: boolean; startOffset: number; hasCapNotice: boolean } => { + requestedMaxBytes = CHAT_EVENT_HISTORY_TRANSCRIPT_MAX_BYTES, + ): { + envelopes: AgentChatEventEnvelope[]; + truncated: boolean; + startOffset: number; + endOffset: number; + hasCapNotice: boolean; + envelopeStartOffsetByIdentity: WeakMap; + } => { const stat = fs.statSync(transcriptPath); - const cached = transcriptHistoryCacheBySession.get(sessionId)?.get(transcriptPath); + const maxBytes = Math.max( + 1_024, + Math.min(CHAT_EVENT_HISTORY_TRANSCRIPT_MAX_BYTES, Math.floor(requestedMaxBytes)), + ); + const cacheKey = `${transcriptPath}\0${maxBytes}`; + const cached = transcriptHistoryCacheBySession.get(sessionId)?.get(cacheKey); if ( cached && cached.transcriptPath === transcriptPath && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs + && cached.maxBytes === maxBytes ) { rememberTranscriptHistoryCache(sessionId, cached); - return { - envelopes: cached.envelopes.slice(), - truncated: cached.truncated, - startOffset: cached.startOffset, - hasCapNotice: cached.hasCapNotice, - }; + return cached; } - const { raw, truncated, startOffset } = readTranscriptTailForHistory(transcriptPath, stat); + const { raw, truncated, startOffset, endOffset } = readTranscriptTailForHistory( + transcriptPath, + stat, + maxBytes, + ); const hasCapNotice = raw.includes(CHAT_TRANSCRIPT_LIMIT_NOTICE.trim()); const envelopes = parseAgentChatTranscript(raw) .filter((entry) => entry.sessionId === sessionId); - rememberTranscriptHistoryCache(sessionId, { + const physicalEnvelopeStartOffsets: number[] = []; + let rawByteOffset = 0; + for (const line of raw.split(/(?<=\n)/)) { + const lineBytes = Buffer.byteLength(line, "utf8"); + const trimmedLine = line.trim(); + if (trimmedLine) { + try { + const parsed = JSON.parse(trimmedLine) as AgentChatEventEnvelope; + if ( + typeof parsed?.sessionId === "string" + && parsed.sessionId.trim() === sessionId + && parsed.event + && typeof parsed.event === "object" + ) { + physicalEnvelopeStartOffsets.push(startOffset + rawByteOffset); + } + } catch { + // Legacy/splice-repaired lines remain readable through the canonical + // parser. Their cursor safely falls back to the tail window start. + } + } + rawByteOffset += lineBytes; + } + const envelopeStartOffsetByIdentity = new WeakMap(); + const offsetsAlignWithParsedEnvelopes = physicalEnvelopeStartOffsets.length === envelopes.length; + for (let index = 0; index < envelopes.length; index += 1) { + envelopeStartOffsetByIdentity.set( + envelopes[index]!, + offsetsAlignWithParsedEnvelopes ? physicalEnvelopeStartOffsets[index]! : null, + ); + } + const entry: TranscriptHistoryCacheEntry = { transcriptPath, size: stat.size, mtimeMs: stat.mtimeMs, + maxBytes, truncated, startOffset, + endOffset, hasCapNotice, envelopes, - }); - return { envelopes: envelopes.slice(), truncated, startOffset, hasCapNotice }; + envelopeStartOffsetByIdentity, + }; + rememberTranscriptHistoryCache(sessionId, entry); + return entry; }; const transcriptPathCandidatesForSessionId = ( @@ -8344,6 +8438,22 @@ export function createAgentChatService(args: { return [...new Set(candidates)]; }; + const readTranscriptHistoryCandidateMetadata = ( + sessionId: string, + transcriptPath: string, + ): { endOffset: number; envelopeCount: number; hasCapNotice: boolean } => { + const cachedWindow = parseTranscriptHistoryTail( + sessionId, + transcriptPath, + CHAT_EVENT_HISTORY_TRANSCRIPT_MAX_BYTES, + ); + return { + endOffset: cachedWindow.endOffset, + envelopeCount: cachedWindow.envelopes.length, + hasCapNotice: cachedWindow.hasCapNotice, + }; + }; + const resolveBestTranscriptPathForSessionId = ( sessionId: string, managed?: ManagedChatSession | null, @@ -8377,13 +8487,19 @@ export function createAgentChatService(args: { if (!transcriptPath) continue; const stat = fs.statSync(transcriptPath); if (!stat.isFile()) continue; - const parsed = parseTranscriptHistoryTail(sessionId, transcriptPath); + // Transcript identity must not depend on the response byte budget. + // Snapshot cursors are raw offsets into this selected file, and older + // history pages resolve the path again without carrying an opaque path + // token. Rank every caller's candidates through the same fixed window + // so a small web hydration and a later page cannot address different + // legacy/durable transcripts. + const metadata = readTranscriptHistoryCandidateMetadata(sessionId, transcriptPath); const candidate: Candidate = { path: transcriptPath, - size: transcriptPath.endsWith(".gz") ? readHistoryFileSync(transcriptPath).length : stat.size, + size: metadata.endOffset, mtimeMs: stat.mtimeMs, - envelopeCount: parsed.envelopes.length, - hasCapNotice: parsed.hasCapNotice, + envelopeCount: metadata.envelopeCount, + hasCapNotice: metadata.hasCapNotice, }; if (!best || isBetterCandidate(candidate, best)) { best = candidate; @@ -8400,17 +8516,36 @@ export function createAgentChatService(args: { // in-memory ring buffer without allocating huge historical transcripts. const readTranscriptEnvelopesForSessionId = ( sessionId: string, - ): { envelopes: AgentChatEventEnvelope[]; truncated: boolean; startOffset: number } => { + maxBytes = CHAT_EVENT_HISTORY_TRANSCRIPT_MAX_BYTES, + ): { + envelopes: AgentChatEventEnvelope[]; + truncated: boolean; + startOffset: number; + endOffset: number; + envelopeStartOffsetByIdentity: WeakMap; + } => { const managed = managedSessions.get(sessionId); const transcriptPath = resolveBestTranscriptPathForSessionId(sessionId, managed); if (transcriptPath) { try { - return parseTranscriptHistoryTail(sessionId, transcriptPath); + return parseTranscriptHistoryTail(sessionId, transcriptPath, maxBytes); } catch { - return { envelopes: [], truncated: false, startOffset: 0 }; + return { + envelopes: [], + truncated: false, + startOffset: 0, + endOffset: 0, + envelopeStartOffsetByIdentity: new WeakMap(), + }; } } - return { envelopes: [], truncated: false, startOffset: 0 }; + return { + envelopes: [], + truncated: false, + startOffset: 0, + endOffset: 0, + envelopeStartOffsetByIdentity: new WeakMap(), + }; }; // Resolve the best on-disk transcript path for a session the same @@ -8624,7 +8759,10 @@ export function createAgentChatService(args: { // transcript is the durable source for project/tab switch recovery, while // the buffer contributes events that fs.appendFile may not have flushed yet. const bufferExisting = eventHistoryBySession.get(trimmedId) ?? []; - const transcriptHistory = readTranscriptEnvelopesForSessionId(trimmedId); + const transcriptHistory = readTranscriptEnvelopesForSessionId( + trimmedId, + requestedMaxBytes == null ? CHAT_EVENT_HISTORY_TRANSCRIPT_MAX_BYTES : responseMaxChars, + ); let merged = mergeEnvelopeStreams(transcriptHistory.envelopes, bufferExisting); const mergedLengthBeforeResponseCap = merged.length; if (merged.length > CHAT_EVENT_HISTORY_RESPONSE_MAX_PER_SESSION) { @@ -8638,20 +8776,40 @@ export function createAgentChatService(args: { const countWindowed = parentVisibleLength > maxEvents ? parentVisibleMerged.slice(-maxEvents) : parentVisibleMerged; - // Backstop byte budget so the serialized snapshot always fits one RPC - // message. The ring and transcript-tail budgets keep snapshots well under - // it, so it only trims when a single envelope dwarfs both (>~6 MB); such - // trimmed events sit AFTER tailStartOffset and are not reachable through - // getChatEventHistoryPage (which pages strictly older) — an accepted - // seam, the alternative being a response the client must discard. - const windowed = trimEnvelopesToByteBudget(countWindowed, responseMaxChars, { - keepOversizeNewest: requestedMaxBytes == null, - }); + // Desktop keeps its historical character-budget behavior. A caller that + // explicitly requests maxBytes gets a strict UTF-8 byte budget, including + // live ring events that have not reached the transcript file yet. + const windowed = requestedMaxBytes == null + ? trimEnvelopesToByteBudget(countWindowed, responseMaxChars) + : keepNewestWithinCharBudget(countWindowed, responseMaxChars, estimateEnvelopeBytes, { + keepOversizeNewest: false, + }); const windowTruncated = mergedLengthBeforeResponseCap > CHAT_EVENT_HISTORY_RESPONSE_MAX_PER_SESSION || parentVisibleLength > maxEvents || windowed.length < countWindowed.length; const truncated = transcriptTruncated || windowTruncated; + let firstReturnedTranscriptOffset: number | null = null; + let returnedTranscriptEvent = false; + for (const envelope of windowed) { + if (!transcriptHistory.envelopeStartOffsetByIdentity.has(envelope)) continue; + returnedTranscriptEvent = true; + const offset = transcriptHistory.envelopeStartOffsetByIdentity.get(envelope); + if (offset != null) { + firstReturnedTranscriptOffset = offset; + break; + } + } + let tailStartOffset: number | null = null; + if (firstReturnedTranscriptOffset != null) { + tailStartOffset = firstReturnedTranscriptOffset > 0 ? firstReturnedTranscriptOffset : null; + } else if (transcriptHistory.endOffset > 0 && (truncated || !returnedTranscriptEvent)) { + // A legacy/repaired transcript line may not have a direct JSONL offset, + // and a snapshot can consist solely of not-yet-flushed ring events. Page + // from the current transcript end in those cases: it may overlap already + // returned events, but it can never skip persisted history. + tailStartOffset = transcriptHistory.endOffset; + } return { sessionId: trimmedId, events: windowed, @@ -8659,10 +8817,10 @@ export function createAgentChatService(args: { transcriptTruncated, windowTruncated, sessionFound: true, - // Pagination cursor: the byte offset (line start) where the hydrated - // transcript tail began. Null when the transcript was fully hydrated - // (or absent) — i.e. there is nothing older on disk to page through. - tailStartOffset: transcriptTruncated ? transcriptHistory.startOffset : null, + // Exact byte offset of the first persisted event in this response. When + // response caps remove rows inside the raw tail window, older pagination + // resumes at that event instead of the original window boundary. + tailStartOffset, }; }; @@ -26961,14 +27119,14 @@ export function createAgentChatService(args: { const crossMachineHandoffRecordKey = (handoffId: string): string => `agent-chat-cross-machine-handoff:v1:${handoffId}`; - const destinationGitEnv = (): NodeJS.ProcessEnv => { + const destinationGitEnv = async (): Promise => { const env: NodeJS.ProcessEnv = { GIT_TERMINAL_PROMPT: "0", GCM_INTERACTIVE: "Never", }; let token = ""; try { - token = getLocalGitHubToken?.()?.trim() ?? ""; + token = (await getLocalGitHubToken?.())?.trim() ?? ""; } catch { // A destination credential helper may still authorize Git. Keep prompts // disabled so a headless handoff fails clearly instead of hanging. @@ -27708,7 +27866,7 @@ export function createAgentChatService(args: { const remote = await runGit(["ls-remote", "--heads", "origin", `refs/heads/${branchRef}`], { cwd: projectRoot, timeoutMs: 30_000, - env: destinationGitEnv(), + env: await destinationGitEnv(), }); if (remote.exitCode !== 0) { blockingErrors.push(`The destination cannot read origin: ${remote.stderr.trim() || "check Git credentials and network access."}`); @@ -28089,7 +28247,7 @@ export function createAgentChatService(args: { const fetch = await runGit(["fetch", "origin", `refs/heads/${branchRef}:refs/remotes/origin/${branchRef}`], { cwd: projectRoot, timeoutMs: 60_000, - env: destinationGitEnv(), + env: await destinationGitEnv(), }); if (fetch.exitCode !== 0) { throw new Error(`The destination could not fetch '${branchRef}': ${fetch.stderr.trim() || "unknown Git error"}`); diff --git a/apps/desktop/src/main/services/externalSessions/discoverCodex.ts b/apps/desktop/src/main/services/externalSessions/discoverCodex.ts index 78f3622b7..bacd73ae5 100644 --- a/apps/desktop/src/main/services/externalSessions/discoverCodex.ts +++ b/apps/desktop/src/main/services/externalSessions/discoverCodex.ts @@ -23,6 +23,16 @@ import { type ExternalSessionFileCandidate, type ExternalSessionDiscoveryRecord, } from "./discoveryUtils"; +import type { + AgentChatCodexApprovalPolicy, + AgentChatCodexSandbox, + AgentChatPermissionMode, + TerminalResumeLaunchConfig, +} from "../../../shared/types"; + +const CODEX_LAUNCH_BACKWARD_SCAN_CHUNK_BYTES = 256 * 1024; +const CODEX_LAUNCH_BACKWARD_SCAN_MAX_BYTES = 64 * 1024 * 1024; +const CODEX_LAUNCH_BACKWARD_SCAN_MAX_LINE_BYTES = 1024 * 1024; type CodexIndexEntry = { id: string; @@ -203,6 +213,174 @@ function firstCodexUserText(records: unknown[]): string | null { : firstUserTextFromRecords(records); } +function codexApprovalPolicy(payload: Record): AgentChatCodexApprovalPolicy | null { + const value = ( + asString(payload.approval_policy) + ?? asString(payload.approvalPolicy) + ?? "" + ).toLowerCase(); + if (value === "untrusted" || value === "on-request" || value === "on-failure" || value === "never") { + return value; + } + return null; +} + +function codexSandbox(payload: Record): AgentChatCodexSandbox | null { + const sandbox = asRecord(payload.sandbox_policy) ?? asRecord(payload.sandboxPolicy); + const value = ( + asString(sandbox?.type) + ?? asString(payload.sandbox_mode) + ?? asString(payload.sandboxMode) + ?? "" + ).toLowerCase(); + if (value === "read-only" || value === "workspace-write" || value === "danger-full-access") { + return value; + } + return null; +} + +function codexPermissionMode( + approvalPolicy: AgentChatCodexApprovalPolicy | null, + sandbox: AgentChatCodexSandbox | null, +): AgentChatPermissionMode | null { + if (approvalPolicy === "never" && sandbox === "danger-full-access") return "full-auto"; + if (approvalPolicy === "untrusted" && sandbox === "workspace-write") return "edit"; + if (approvalPolicy === "on-request" && sandbox === "workspace-write") return "default"; + if (approvalPolicy === "on-request" && sandbox === "read-only") return "plan"; + return null; +} + +function codexLaunchFromRecords(records: unknown[]): TerminalResumeLaunchConfig | null { + let launch: TerminalResumeLaunchConfig | null = null; + for (const item of records) { + const record = asRecord(item); + if (asString(record?.type)?.toLowerCase() !== "turn_context") continue; + const payload = asRecord(record?.payload); + if (!payload) continue; + const model = asString(payload.model) ?? asString(payload.model_id) ?? asString(payload.modelId); + const reasoningEffort = asString(payload.effort) + ?? asString(payload.reasoning_effort) + ?? asString(payload.reasoningEffort); + const approvalPolicy = codexApprovalPolicy(payload); + const sandbox = codexSandbox(payload); + const permissionMode = codexPermissionMode(approvalPolicy, sandbox); + const serviceTier = (asString(payload.service_tier) ?? asString(payload.serviceTier) ?? "").toLowerCase(); + const next: TerminalResumeLaunchConfig = { + ...(model ? { model } : {}), + ...(reasoningEffort ? { reasoningEffort } : {}), + ...(permissionMode ? { permissionMode } : {}), + ...(approvalPolicy ? { codexApprovalPolicy: approvalPolicy } : {}), + ...(sandbox ? { codexSandbox: sandbox } : {}), + ...(approvalPolicy && sandbox ? { codexConfigSource: "flags" as const } : {}), + ...(serviceTier === "fast" ? { fastMode: true } : {}), + ...(serviceTier === "default" || serviceTier === "standard" ? { fastMode: false } : {}), + ...(serviceTier && serviceTier !== "fast" && serviceTier !== "default" && serviceTier !== "standard" + ? { fastMode: null } + : {}), + }; + if (Object.keys(next).length) launch = { ...(launch ?? {}), ...next }; + } + return launch; +} + +async function latestCodexLaunchFromFile( + filePath: string, + logger: ExternalSessionDiscoveryArgs["logger"], +): Promise { + let handle: fs.promises.FileHandle | null = null; + let launch: TerminalResumeLaunchConfig | null = null; + try { + handle = await fs.promises.open(filePath, "r"); + const stat = await handle.stat(); + let position = stat.size; + let bytesScanned = 0; + let partialLeadingLine = Buffer.alloc(0); + while (position > 0 && bytesScanned < CODEX_LAUNCH_BACKWARD_SCAN_MAX_BYTES) { + const bytesToRead = Math.min( + CODEX_LAUNCH_BACKWARD_SCAN_CHUNK_BYTES, + position, + CODEX_LAUNCH_BACKWARD_SCAN_MAX_BYTES - bytesScanned, + ); + const start = position - bytesToRead; + const chunk = Buffer.allocUnsafe(bytesToRead); + const { bytesRead } = await handle.read(chunk, 0, bytesToRead, start); + if (bytesRead <= 0) break; + const combined = Buffer.concat([chunk.subarray(0, bytesRead), partialLeadingLine]); + let completeStart = 0; + if (start > 0) { + const firstNewline = combined.indexOf(0x0a); + if (firstNewline < 0) { + // turn_context rows are tiny. Do not repeatedly concatenate an + // unbounded tool-output row while walking backwards through a large + // rollout; once its fragment exceeds this cap, discard that row and + // keep searching for the preceding newline/context. + partialLeadingLine = combined.length <= CODEX_LAUNCH_BACKWARD_SCAN_MAX_LINE_BYTES + ? combined + : Buffer.alloc(0); + position = start; + bytesScanned += bytesRead; + continue; + } + partialLeadingLine = Buffer.from(combined.subarray(0, firstNewline)); + completeStart = firstNewline + 1; + } else { + partialLeadingLine = Buffer.alloc(0); + } + const lines = combined.subarray(completeStart).toString("utf8").split(/\r?\n/u); + for (let index = lines.length - 1; index >= 0; index -= 1) { + const line = lines[index]; + if (!line?.includes("turn_context")) continue; + const record = safeParseJson(line); + const olderLaunch = record ? codexLaunchFromRecords([record]) : null; + if (!olderLaunch) continue; + // Records are visited newest-first. Fill fields omitted by a partial + // newest context from the prior context without overwriting newer data. + launch = { ...olderLaunch, ...(launch ?? {}) }; + if ( + launch.model?.trim() + && launch.reasoningEffort?.trim() + && launch.codexApprovalPolicy + && launch.codexSandbox + ) return launch; + } + position = start; + bytesScanned += bytesRead; + } + if (position > 0) { + logger?.warn?.("external_sessions.codex_launch_scan_truncated", { + filePath, + bytesScanned, + fileSize: stat.size, + }); + } + return launch; + } catch { + return launch; + } finally { + await handle?.close().catch(() => {}); + } +} + +async function codexLaunchForFile( + filePath: string, + prefixRecords: unknown[], + exactLookup: boolean, + logger: ExternalSessionDiscoveryArgs["logger"], +): Promise { + const prefixLaunch = codexLaunchFromRecords(prefixRecords); + if (!exactLookup) return prefixLaunch; + const latestLaunch = await latestCodexLaunchFromFile(filePath, logger); + if (latestLaunch) return latestLaunch; + if (!prefixLaunch) return null; + const fallback: TerminalResumeLaunchConfig = { + ...(prefixLaunch.model !== undefined ? { model: prefixLaunch.model } : {}), + ...(prefixLaunch.reasoningEffort !== undefined ? { reasoningEffort: prefixLaunch.reasoningEffort } : {}), + ...(prefixLaunch.fastMode !== undefined ? { fastMode: prefixLaunch.fastMode } : {}), + ...(prefixLaunch.codexFastMode !== undefined ? { codexFastMode: prefixLaunch.codexFastMode } : {}), + }; + return Object.keys(fallback).length ? fallback : null; +} + function collectProjectScopedCodexSessionCandidates( root: string, limit: number, @@ -330,6 +508,7 @@ export async function discoverCodexSessions( const indexed = index.get(id); const firstUserText = firstCodexUserText(jsonl); const title = candidate.meta?.title ?? titleFromCodexPayload(payload, indexed); + const launch = await codexLaunchForFile(filePath, jsonl, lookupId != null, args.logger); recordsById.set(id, recordWithFile({ provider: "codex", id, @@ -339,6 +518,7 @@ export async function discoverCodexSessions( createdAt: candidate.meta?.createdAt ?? asEpochMs(payload.timestamp) ?? asEpochMs(first?.timestamp), updatedAt: Math.max(indexed?.updatedAt ?? 0, candidate.mtimeMs), messageCount: countJsonlUserMessagesCheap(filePath, "codex"), + launch, filePath, sourceMtimeMs: candidate.mtimeMs, })); diff --git a/apps/desktop/src/main/services/externalSessions/discoverProviders.test.ts b/apps/desktop/src/main/services/externalSessions/discoverProviders.test.ts index 74630b7b9..92a8828ef 100644 --- a/apps/desktop/src/main/services/externalSessions/discoverProviders.test.ts +++ b/apps/desktop/src/main/services/externalSessions/discoverProviders.test.ts @@ -29,6 +29,23 @@ function writeJsonl(filePath: string, rows: unknown[]): void { fs.writeFileSync(filePath, rows.map((row) => JSON.stringify(row)).join("\n") + "\n", "utf8"); } +function appendLargeCodexAgentMessage(filePath: string, messageBytes: number): void { + const fd = fs.openSync(filePath, "a"); + const chunk = Buffer.alloc(1024 * 1024, 0x78); + try { + fs.writeSync(fd, '{"type":"event_msg","payload":{"type":"agent_message","message":"'); + let remaining = messageBytes; + while (remaining > 0) { + const bytesToWrite = Math.min(remaining, chunk.length); + fs.writeSync(fd, chunk, 0, bytesToWrite); + remaining -= bytesToWrite; + } + fs.writeSync(fd, '"}}\n'); + } finally { + fs.closeSync(fd); + } +} + beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-external-discovery-")); previousHome = process.env.HOME; @@ -196,6 +213,17 @@ describe("external session provider discovery", () => { type: "session_meta", payload: { id, session_id: id, cwd, timestamp: "2026-07-06T10:00:00.000Z", source: "cli", originator: "codex-tui" }, }, + { + timestamp: "2026-07-06T10:00:10.000Z", + type: "turn_context", + payload: { + model: "gpt-5.6-sol", + effort: "max", + service_tier: "fast", + approval_policy: "never", + sandbox_policy: { type: "danger-full-access" }, + }, + }, { timestamp: "2026-07-06T10:00:30.000Z", type: "response_item", payload: { type: "message", role: "user", content: [{ type: "input_text", text: "synthetic" }] } }, { timestamp: "2026-07-06T10:01:00.000Z", type: "event_msg", payload: { type: "user_message", message: "please fix flakes" } }, ]); @@ -212,6 +240,116 @@ describe("external session provider discovery", () => { preview: "please fix flakes", updatedAt: Date.parse("2026-07-06T11:00:00.000Z"), messageCount: 1, + launch: { + model: "gpt-5.6-sol", + reasoningEffort: "max", + fastMode: true, + permissionMode: "full-auto", + codexApprovalPolicy: "never", + codexSandbox: "danger-full-access", + codexConfigSource: "flags", + }, + }); + }); + + it("finds and merges the latest Codex turn context beyond a 2 MiB tail", async () => { + const homeDir = path.join(root, "home"); + const cwd = path.join(root, "repo"); + const id = "24242424-2424-4242-8242-242424242424"; + const prefixFiller = Array.from({ length: 90 }, (_, index) => ({ + type: "event_msg", + payload: { type: "agent_message", message: `prefix-filler-${index}` }, + })); + const filler = [{ + type: "event_msg", + payload: { type: "agent_message", message: `oversized-filler-${"x".repeat(3 * 1024 * 1024)}` }, + }]; + writeJsonl(path.join(homeDir, ".codex", "sessions", "2026", "07", "06", `rollout-${id}.jsonl`), [ + { + type: "session_meta", + payload: { id, cwd, source: "cli", originator: "codex-tui" }, + }, + { + type: "turn_context", + payload: { + model: "gpt-5.4", + effort: "low", + service_tier: "default", + approval_policy: "on-request", + sandbox_policy: { type: "read-only" }, + }, + }, + ...prefixFiller, + { + type: "turn_context", + payload: { + model: "gpt-5.6-sol", + service_tier: "priority", + }, + }, + ...filler, + ]); + + const [broad] = await discoverCodexSessions({ homeDir, limit: 10 }); + expect(broad?.launch).toMatchObject({ + model: "gpt-5.4", + reasoningEffort: "low", + permissionMode: "plan", + codexApprovalPolicy: "on-request", + codexSandbox: "read-only", + }); + + const [exact] = await discoverCodexSessions({ homeDir, sessionId: id, limit: 1 }); + expect(exact?.launch).toEqual({ + model: "gpt-5.6-sol", + reasoningEffort: "low", + fastMode: null, + permissionMode: "plan", + codexApprovalPolicy: "on-request", + codexSandbox: "read-only", + codexConfigSource: "flags", + }); + }); + + it("retains only non-security Codex preferences when a large final record truncates exact tail recovery", async () => { + const homeDir = path.join(root, "home"); + const cwd = path.join(root, "repo"); + const id = "25252525-2525-4252-8252-252525252525"; + const rolloutPath = path.join(homeDir, ".codex", "sessions", "2026", "07", "06", `rollout-${id}.jsonl`); + writeJsonl(rolloutPath, [ + { + type: "session_meta", + payload: { id, cwd, source: "cli", originator: "codex-tui" }, + }, + { + type: "turn_context", + payload: { + model: "gpt-5.6-sol", + effort: "high", + service_tier: "fast", + approval_policy: "never", + sandbox_policy: { type: "danger-full-access" }, + }, + }, + ]); + appendLargeCodexAgentMessage(rolloutPath, 65 * 1024 * 1024); + const warn = vi.fn(); + + const [exact] = await discoverCodexSessions({ + homeDir, + sessionId: id, + limit: 1, + logger: { warn }, + }); + + expect(warn).toHaveBeenCalledWith("external_sessions.codex_launch_scan_truncated", expect.objectContaining({ + filePath: rolloutPath, + bytesScanned: 64 * 1024 * 1024, + })); + expect(exact?.launch).toEqual({ + model: "gpt-5.6-sol", + reasoningEffort: "high", + fastMode: true, }); }); diff --git a/apps/desktop/src/main/services/externalSessions/discoveryUtils.ts b/apps/desktop/src/main/services/externalSessions/discoveryUtils.ts index b0181dfd2..6fe7f39ca 100644 --- a/apps/desktop/src/main/services/externalSessions/discoveryUtils.ts +++ b/apps/desktop/src/main/services/externalSessions/discoveryUtils.ts @@ -5,6 +5,7 @@ import type { ExternalSessionProvider, ExternalSessionSummary, } from "../../../shared/types/externalSessions"; +import type { TerminalResumeLaunchConfig } from "../../../shared/types/sessions"; export type ExternalSessionDiscoveryRecord = Omit< ExternalSessionSummary, @@ -462,6 +463,7 @@ export function recordWithFile(args: { createdAt?: number | null; updatedAt?: number | null; messageCount?: number | null; + launch?: TerminalResumeLaunchConfig | null; filePath?: string | null; sourceMtimeMs?: number | null; }): ExternalSessionDiscoveryRecord { @@ -478,6 +480,7 @@ export function recordWithFile(args: { createdAt: args.createdAt ?? null, updatedAt: args.updatedAt ?? sourceMtimeMs, messageCount: args.messageCount ?? null, + launch: args.launch ?? null, sourcePath: args.filePath ?? null, sourceMtimeMs, }; diff --git a/apps/desktop/src/main/services/externalSessions/externalSessionsService.test.ts b/apps/desktop/src/main/services/externalSessions/externalSessionsService.test.ts index 55a3203a6..684ec4eb4 100644 --- a/apps/desktop/src/main/services/externalSessions/externalSessionsService.test.ts +++ b/apps/desktop/src/main/services/externalSessions/externalSessionsService.test.ts @@ -63,6 +63,27 @@ afterEach(() => { }); describe("externalSessionsService", () => { + it("rejects unsafe exact lookup ids before provider path resolution", async () => { + const service = createExternalSessionsService({ + projectRoot: path.join(root, "repo"), + homeDir: path.join(root, "home"), + laneService: {}, + sessionService: { list: () => [], listClaudeSessionPointers: () => [] }, + ptyService: { create: vi.fn() }, + logger: makeLogger(), + }); + const statSync = vi.spyOn(fs, "statSync"); + try { + await expect(service.list({ providers: ["cursor", "droid"], scope: "all", sessionId: "../../outside" })) + .resolves.toEqual([]); + await expect(service.list({ providers: ["codex"], scope: "all", sessionId: "not-a-uuid" })) + .resolves.toEqual([]); + expect(statSync).not.toHaveBeenCalled(); + } finally { + statSync.mockRestore(); + } + }); + it("lists sessions with imported flags, active flags, capabilities, and lane cwd matching", async () => { const homeDir = path.join(root, "home"); const projectRoot = path.join(root, "repo"); @@ -560,6 +581,16 @@ describe("externalSessionsService", () => { type: "session_meta", payload: { id, cwd: path.join(root, "elsewhere"), timestamp: "2026-07-06T10:00:00.000Z" }, }, + { + type: "turn_context", + payload: { + model: "gpt-5.6-sol", + effort: "max", + service_tier: "fast", + approval_policy: "on-request", + sandbox_policy: { type: "danger-full-access" }, + }, + }, ]); const create = vi.fn(async (_args: PtyCreateArgs) => ({ sessionId: "terminal-1", ptyId: "pty-1", pid: 123 })); const service = createExternalSessionsService({ @@ -578,7 +609,6 @@ describe("externalSessionsService", () => { laneId: "lane-1", target: "cli", mode: "resume", - permissionMode: "edit", }); expect(result).toEqual({ kind: "cli", sessionId: "terminal-1", ptyId: "pty-1", laneId: "lane-1" }); @@ -588,11 +618,24 @@ describe("externalSessionsService", () => { expect(args.allowExternalCwd).toBe(false); expect(args.startupCommand).toContain("codex --no-alt-screen"); expect(args.startupCommand).toContain(`resume ${id}`); + expect(args.startupCommand).toContain("--model gpt-5.6-sol"); + expect(args.startupCommand).toContain("model_reasoning_effort"); + expect(args.startupCommand).toContain("service_tier"); + expect(args.startupCommand).toContain("--sandbox danger-full-access --ask-for-approval on-request"); + expect(args.startupCommand).not.toContain("dangerously-bypass"); expect(args.resumeMetadata).toMatchObject({ provider: "codex", targetKind: "thread", targetId: id, importedFrom: { provider: "codex", targetId: id, mode: "resume" }, + launch: { + model: "gpt-5.6-sol", + reasoningEffort: "max", + fastMode: true, + codexApprovalPolicy: "on-request", + codexSandbox: "danger-full-access", + codexConfigSource: "flags", + }, }); }); diff --git a/apps/desktop/src/main/services/externalSessions/externalSessionsService.ts b/apps/desktop/src/main/services/externalSessions/externalSessionsService.ts index a1ad55204..3afc6ae0c 100644 --- a/apps/desktop/src/main/services/externalSessions/externalSessionsService.ts +++ b/apps/desktop/src/main/services/externalSessions/externalSessionsService.ts @@ -320,11 +320,21 @@ function metadataForImport(args: { originalTargetId: string; mode: "resume" | "fork"; model?: string | null; + reasoningEffort?: string | null; + fastMode?: boolean | null; permissionMode?: string | null; + codexApprovalPolicy?: TerminalResumeMetadata["launch"]["codexApprovalPolicy"]; + codexSandbox?: TerminalResumeMetadata["launch"]["codexSandbox"]; + codexConfigSource?: TerminalResumeMetadata["launch"]["codexConfigSource"]; }): TerminalResumeMetadata { const launch = { ...(args.model ? { model: args.model } : {}), + ...(args.reasoningEffort ? { reasoningEffort: args.reasoningEffort } : {}), + ...(typeof args.fastMode === "boolean" ? { fastMode: args.fastMode } : {}), ...(args.permissionMode ? { permissionMode: args.permissionMode as TerminalResumeMetadata["launch"]["permissionMode"] } : {}), + ...(args.codexApprovalPolicy ? { codexApprovalPolicy: args.codexApprovalPolicy } : {}), + ...(args.codexSandbox ? { codexSandbox: args.codexSandbox } : {}), + ...(args.codexConfigSource ? { codexConfigSource: args.codexConfigSource } : {}), }; return { provider: args.provider, @@ -345,7 +355,12 @@ async function forkCommandFor(args: { metadata: TerminalResumeMetadata; targetId: string; model?: string | null; + reasoningEffort?: string | null; + fastMode?: boolean | null; permissionMode?: string | null; + codexApprovalPolicy?: TerminalResumeMetadata["launch"]["codexApprovalPolicy"]; + codexSandbox?: TerminalResumeMetadata["launch"]["codexSandbox"]; + codexConfigSource?: TerminalResumeMetadata["launch"]["codexConfigSource"]; transplantedClaude: boolean; }): Promise { if (args.provider === "claude") { @@ -353,7 +368,12 @@ async function forkCommandFor(args: { { ...args.metadata, targetId: args.targetId }, { model: args.model, + reasoningEffort: args.reasoningEffort, + fastMode: args.fastMode, permissionMode: args.permissionMode as TerminalResumeMetadata["launch"]["permissionMode"], + codexApprovalPolicy: args.codexApprovalPolicy, + codexSandbox: args.codexSandbox, + codexConfigSource: args.codexConfigSource, }, ); return args.transplantedClaude ? command : `${command} --fork-session`; @@ -364,7 +384,12 @@ async function forkCommandFor(args: { { ...args.metadata, targetId: args.targetId }, { model: args.model, + reasoningEffort: args.reasoningEffort, + fastMode: args.fastMode, permissionMode: args.permissionMode as TerminalResumeMetadata["launch"]["permissionMode"], + codexApprovalPolicy: args.codexApprovalPolicy, + codexSandbox: args.codexSandbox, + codexConfigSource: args.codexConfigSource, codexComputerUse: await resolveCodexComputerUseMcpConfig(), }, ); @@ -380,7 +405,12 @@ async function forkCommandFor(args: { { ...args.metadata, targetId: args.targetId }, { model: args.model, + reasoningEffort: args.reasoningEffort, + fastMode: args.fastMode, permissionMode: args.permissionMode as TerminalResumeMetadata["launch"]["permissionMode"], + codexApprovalPolicy: args.codexApprovalPolicy, + codexSandbox: args.codexSandbox, + codexConfigSource: args.codexConfigSource, }, ); return `${resume} --fork`; @@ -467,12 +497,28 @@ export function createExternalSessionsService(args: ExternalSessionsServiceArgs) }; const list = async (rawArgs: ExternalSessionListArgs = {}): Promise => { - const limit = normalizeExternalSessionLimit(rawArgs.limit); + const hasRequestedSessionId = rawArgs.sessionId != null; + const requestedSessionId = rawArgs.sessionId?.trim() || null; + if (hasRequestedSessionId && !requestedSessionId) return []; + const limit = requestedSessionId ? 1 : normalizeExternalSessionLimit(rawArgs.limit); const projectScoped = rawArgs.scope !== "all"; - const discoveryLimit = projectScoped - ? Math.max(limit, PROJECT_SCOPE_DISCOVERY_LIMIT) - : limit; - const providers = providerSet(rawArgs.providers); + const discoveryLimit = requestedSessionId + ? 1 + : projectScoped + ? Math.max(limit, PROJECT_SCOPE_DISCOVERY_LIMIT) + : limit; + const requestedProviders = providerSet(rawArgs.providers); + const providers = requestedSessionId + ? requestedProviders.filter((provider) => { + try { + validateExternalSessionId(provider, requestedSessionId); + return true; + } catch { + return false; + } + }) + : requestedProviders; + if (providers.length === 0) return []; const requestedLaneCwd = rawArgs.laneId ? resolveLaneCwd(args.laneService, rawArgs.laneId) : null; const requestedCwd = rawArgs.cwd?.trim() ? realish(rawArgs.cwd) : requestedLaneCwd; const scopeRoots = projectScoped ? deriveProjectScopeRoots(args.projectRoot) : []; @@ -482,6 +528,7 @@ export function createExternalSessionsService(args: ExternalSessionsServiceArgs) cwd: requestedCwd, projectRoot: args.projectRoot, limit: discoveryLimit, + sessionId: requestedSessionId, scopeRoots: projectScoped ? scopeRoots : null, logger: args.logger, }; @@ -520,6 +567,7 @@ export function createExternalSessionsService(args: ExternalSessionsServiceArgs) createdAt: session.createdAt, updatedAt: session.updatedAt, messageCount: session.messageCount, + launch: session.launch ?? null, alreadyImported: importedRef != null, importedSessionRef: importedRef, possiblyActive: typeof session.sourceMtimeMs === "number" && session.sourceMtimeMs >= activeCutoffMs, @@ -564,6 +612,7 @@ export function createExternalSessionsService(args: ExternalSessionsServiceArgs) createdAt: session.createdAt, updatedAt: session.updatedAt, messageCount: session.messageCount, + launch: session.launch ?? null, alreadyImported: false, importedSessionRef: null, possiblyActive: typeof session.sourceMtimeMs === "number" @@ -690,26 +739,61 @@ export function createExternalSessionsService(args: ExternalSessionsServiceArgs) } } + const resolvedModel = importArgs.model?.trim() || summary.launch?.model?.trim() || null; + const resolvedReasoningEffort = importArgs.reasoningEffort?.trim() + || summary.launch?.reasoningEffort?.trim() + || null; + const resolvedPermissionMode = importArgs.permissionMode?.trim() + || summary.launch?.permissionMode?.trim() + || null; + const resolvedFastMode = typeof importArgs.fastMode === "boolean" + ? importArgs.fastMode + : summary.launch?.fastMode ?? summary.launch?.codexFastMode ?? null; + const preserveDiscoveredCodexPermissions = provider === "codex" && importArgs.permissionMode == null; + const resolvedCodexApprovalPolicy = preserveDiscoveredCodexPermissions + ? summary.launch?.codexApprovalPolicy ?? null + : null; + const resolvedCodexSandbox = preserveDiscoveredCodexPermissions + ? summary.launch?.codexSandbox ?? null + : null; + const resolvedCodexConfigSource = preserveDiscoveredCodexPermissions + ? summary.launch?.codexConfigSource ?? null + : null; const metadata = metadataForImport({ provider, targetId: metadataTargetId, originalTargetId: sessionId, mode: importArgs.mode, - model: importArgs.model, - permissionMode: importArgs.permissionMode, + model: resolvedModel, + reasoningEffort: resolvedReasoningEffort, + fastMode: resolvedFastMode, + permissionMode: resolvedPermissionMode, + codexApprovalPolicy: resolvedCodexApprovalPolicy, + codexSandbox: resolvedCodexSandbox, + codexConfigSource: resolvedCodexConfigSource, }); const startupCommand = importArgs.mode === "resume" ? buildTrackedCliResumeCommand(metadata, { - model: importArgs.model, - permissionMode: importArgs.permissionMode as TerminalResumeMetadata["launch"]["permissionMode"], + model: resolvedModel, + reasoningEffort: resolvedReasoningEffort, + fastMode: resolvedFastMode, + permissionMode: resolvedPermissionMode as TerminalResumeMetadata["launch"]["permissionMode"], + codexApprovalPolicy: resolvedCodexApprovalPolicy, + codexSandbox: resolvedCodexSandbox, + codexConfigSource: resolvedCodexConfigSource, ...(provider === "codex" ? { codexComputerUse: await resolveCodexComputerUseMcpConfig() } : {}), }) : await forkCommandFor({ provider, metadata, targetId: launchTargetId, - model: importArgs.model, - permissionMode: importArgs.permissionMode, + model: resolvedModel, + reasoningEffort: resolvedReasoningEffort, + fastMode: resolvedFastMode, + permissionMode: resolvedPermissionMode, + codexApprovalPolicy: resolvedCodexApprovalPolicy, + codexSandbox: resolvedCodexSandbox, + codexConfigSource: resolvedCodexConfigSource, transplantedClaude, }); diff --git a/apps/desktop/src/main/services/git/git.ts b/apps/desktop/src/main/services/git/git.ts index 7415dd1fe..db0580395 100644 --- a/apps/desktop/src/main/services/git/git.ts +++ b/apps/desktop/src/main/services/git/git.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; -import { execFileSync, spawn } from "node:child_process"; +import { execFile, spawn } from "node:child_process"; +import { promisify } from "node:util"; import type { ConflictFileType } from "../../../shared/types"; import { terminateProcessTree } from "../shared/processExecution"; import { @@ -13,6 +14,8 @@ import { // Silicon when shell PATH probe times out). Resolve git's absolute path once // and reuse it so spawn never throws ENOENT. let cachedGitExecutable: string | null = null; +let gitExecutableResolution: Promise | null = null; +const execFileAsync = promisify(execFile); export function selectGitExecutable( candidates: readonly ResolvedExecutable[], platform: NodeJS.Platform = process.platform, @@ -31,8 +34,18 @@ export function shouldProbeLoginShellForGit( || (platform === "darwin" && selectedExecutable === "/usr/bin/git"); } -function resolveGitExecutable(): string { +async function resolveGitExecutable(): Promise { if (cachedGitExecutable) return cachedGitExecutable; + if (gitExecutableResolution) return await gitExecutableResolution; + gitExecutableResolution = resolveGitExecutableUncached(); + try { + return await gitExecutableResolution; + } finally { + gitExecutableResolution = null; + } +} + +async function resolveGitExecutableUncached(): Promise { if (process.env.ADE_GIT_EXECUTABLE && fs.existsSync(process.env.ADE_GIT_EXECUTABLE)) { cachedGitExecutable = process.env.ADE_GIT_EXECUTABLE; return cachedGitExecutable; @@ -48,10 +61,11 @@ function resolveGitExecutable(): string { if (process.platform !== "win32" && shouldProbeLoginShellForGit(resolvedCandidate)) { try { const shell = process.env.SHELL?.trim() || "/bin/sh"; - const out = execFileSync(shell, ["-lc", "command -v git"], { + const { stdout } = await execFileAsync(shell, ["-lc", "command -v git"], { encoding: "utf8", timeout: 3_000, - }).trim(); + }); + const out = stdout.trim(); const isIndependentMacGit = process.platform !== "darwin" || out !== "/usr/bin/git"; if (out && fs.existsSync(out) && (isIndependentMacGit || !resolvedCandidate)) { cachedGitExecutable = out; @@ -84,12 +98,12 @@ function gitExecutableNotFoundMessage(executable: string): string { return `git executable not found (tried ${executable}). Install git or set ADE_GIT_EXECUTABLE to git's absolute path.`; } -function gitSpawnErrorMessage(error: NodeJS.ErrnoException, opts: GitRunOptions): string { +function gitSpawnErrorMessage(error: NodeJS.ErrnoException, opts: GitRunOptions, executable: string): string { if (error.code !== "ENOENT") return error.message; if (!fs.existsSync(opts.cwd)) { return `git working directory not found: ${opts.cwd}`; } - return gitExecutableNotFoundMessage(resolveGitExecutable()); + return gitExecutableNotFoundMessage(executable); } export type GitRunOptions = { @@ -136,22 +150,21 @@ function extractIndexLockPath(message: string): string | null { return doubleQuoteMatch?.[1] ?? null; } -function isIndexLockHeldByProcess(lockPath: string): boolean { +async function isIndexLockHeldByProcess(lockPath: string): Promise { if (activeGitPids.size > 0) return true; if (process.platform === "win32") return false; try { - const out = execFileSync("lsof", [lockPath], { + const { stdout } = await execFileAsync("lsof", [lockPath], { encoding: "utf8", timeout: 2_000, - stdio: ["ignore", "pipe", "ignore"], }); - return out.trim().split(/\r?\n/).length > 1; + return stdout.trim().split(/\r?\n/).length > 1; } catch { return false; } } -function recoverStaleIndexLock(lockPath: string): boolean { +async function recoverStaleIndexLock(lockPath: string): Promise { try { const normalizedPath = path.normalize(lockPath); if (path.basename(normalizedPath) !== "index.lock") return false; @@ -160,7 +173,7 @@ function recoverStaleIndexLock(lockPath: string): boolean { const stat = fs.statSync(lockPath); if (!stat.isFile()) return false; if (Date.now() - stat.mtimeMs < STALE_GIT_INDEX_LOCK_MIN_AGE_MS) return false; - if (isIndexLockHeldByProcess(lockPath)) return false; + if (await isIndexLockHeldByProcess(lockPath)) return false; fs.renameSync(lockPath, `${lockPath}.stale-${Date.now()}`); return true; } catch (error) { @@ -171,13 +184,13 @@ function recoverStaleIndexLock(lockPath: string): boolean { } } -function shouldRetryAfterIndexLock(result: GitRunResult): boolean { +async function shouldRetryAfterIndexLock(result: GitRunResult): Promise { if (result.exitCode === 0) return false; const message = `${result.stderr}\n${result.stdout}`; const lockPath = extractIndexLockPath(message); if (!lockPath) return false; if (!message.includes("Another git process seems to be running")) return false; - return recoverStaleIndexLock(lockPath); + return await recoverStaleIndexLock(lockPath); } function appendChunkWithCap(args: { @@ -211,8 +224,9 @@ async function runGitOnce(args: string[], opts: GitRunOptions): Promise((resolve) => { - const child = spawn(resolveGitExecutable(), args, { + const child = spawn(executable, args, { cwd: opts.cwd, env: { ...process.env, ...(opts.env ?? {}) }, stdio: ["ignore", "pipe", "pipe"] @@ -279,7 +293,7 @@ async function runGitOnce(args: string[], opts: GitRunOptions): Promise { - const friendlyMessage = gitSpawnErrorMessage(error as NodeJS.ErrnoException, opts); + const friendlyMessage = gitSpawnErrorMessage(error as NodeJS.ErrnoException, opts, executable); finish({ exitCode: 1, stdout, @@ -303,7 +317,7 @@ async function runGitOnce(args: string[], opts: GitRunOptions): Promise { const first = await runGitOnce(args, opts); - if (!shouldRetryAfterIndexLock(first)) { + if (!(await shouldRetryAfterIndexLock(first))) { return first; } return await runGitOnce(args, opts); diff --git a/apps/desktop/src/main/services/github/githubService.test.ts b/apps/desktop/src/main/services/github/githubService.test.ts index 7450bc6e4..b57f21ec6 100644 --- a/apps/desktop/src/main/services/github/githubService.test.ts +++ b/apps/desktop/src/main/services/github/githubService.test.ts @@ -1,4 +1,5 @@ import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; // --------------------------------------------------------------------------- // vi.hoisted mock state @@ -70,6 +71,7 @@ function resetMocks() { runGitMock.mockReset(); delete process.env.GH_TOKEN; delete process.env.GITHUB_TOKEN; + delete process.env.GH_CONFIG_DIR; delete process.env.ADE_GITHUB_TOKEN; delete process.env.ADE_GITHUB_RELAY_API_BASE_URL; delete process.env.ADE_GITHUB_RELAY_ACCESS_TOKEN; @@ -115,7 +117,9 @@ class MemoryCredentialStore { function makeService(options: { credentialStore?: MemoryCredentialStore; - ghAuthTokenProvider?: () => { token: string | null; ghCliPath: string | null; ghAuthError: string | null }; + ghAuthTokenProvider?: () => + | { token: string | null; ghCliPath: string | null; ghAuthError: string | null } + | Promise<{ token: string | null; ghCliPath: string | null; ghAuthError: string | null }>; githubRelaySecretReader?: (ref: string) => string | null; getAccountAccessToken?: () => Promise; } = {}) { @@ -703,6 +707,222 @@ describe("githubService.getStatus", () => { expect((init.headers as Record).authorization).toBe("Bearer gho_cli_token"); }); + it("reads a cached hosts.yml token synchronously before async status warmup", () => { + delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; + process.env.GH_CONFIG_DIR = "/tmp/gh-fresh-sync-token"; + vi.mocked(fs.readFileSync).mockImplementationOnce(((filePath: fs.PathOrFileDescriptor) => { + if (String(filePath).endsWith("hosts.yml")) { + return "github.com:\n user: alice\n oauth_token: gho_hosts_fresh\n"; + } + return Buffer.from("encrypted"); + }) as typeof fs.readFileSync); + + expect(makeService().getTokenOrThrow()).toBe("gho_hosts_fresh"); + expect(makeService().getTokenOrThrow()).toBe("gho_hosts_fresh"); + expect(fs.readFileSync).toHaveBeenCalledTimes(1); + delete process.env.GH_CONFIG_DIR; + }); + + it("does not read or reuse hosts.yml auth when gh fallback is disabled", () => { + delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; + process.env.GH_CONFIG_DIR = `/tmp/gh-disabled-sync-token-${Date.now()}`; + vi.mocked(fs.readFileSync).mockImplementation(((filePath: fs.PathOrFileDescriptor) => { + if (String(filePath).endsWith("hosts.yml")) { + return "github.com:\n user: alice\n oauth_token: gho_hosts_disabled\n"; + } + return Buffer.from("encrypted"); + }) as typeof fs.readFileSync); + + expect(makeService().getTokenOrThrow()).toBe("gho_hosts_disabled"); + expect(fs.readFileSync).toHaveBeenCalledTimes(1); + + process.env.ADE_DISABLE_GH_AUTH_FALLBACK = "1"; + vi.mocked(fs.readFileSync).mockClear(); + + expect(() => makeService().getTokenOrThrow()).toThrow("GitHub auth missing"); + expect(fs.readFileSync).not.toHaveBeenCalled(); + }); + + it("bounds the process-wide hosts.yml token cache", () => { + delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; + const prefix = `/tmp/gh-bounded-token-cache-${Date.now()}`; + vi.mocked(fs.readFileSync).mockImplementation(((filePath: fs.PathOrFileDescriptor) => { + if (String(filePath).endsWith("hosts.yml")) { + return "github.com:\n user: alice\n oauth_token: gho_hosts_bounded\n"; + } + return Buffer.from("encrypted"); + }) as typeof fs.readFileSync); + + for (let index = 0; index <= 32; index += 1) { + process.env.GH_CONFIG_DIR = `${prefix}-${index}`; + expect(makeService().getTokenOrThrow()).toBe("gho_hosts_bounded"); + } + expect(fs.readFileSync).toHaveBeenCalledTimes(33); + + process.env.GH_CONFIG_DIR = `${prefix}-0`; + expect(makeService().getTokenOrThrow()).toBe("gho_hosts_bounded"); + expect(fs.readFileSync).toHaveBeenCalledTimes(34); + delete process.env.GH_CONFIG_DIR; + }); + + it("awaits keyring-backed gh auth when no synchronous hosts token exists", async () => { + delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; + let resolveAuth!: (value: { token: string; ghCliPath: string; ghAuthError: null }) => void; + const ghAuthTokenProvider = vi.fn(() => new Promise<{ + token: string; + ghCliPath: string; + ghAuthError: null; + }>((resolve) => { + resolveAuth = resolve; + })); + const first = makeService({ ghAuthTokenProvider }); + const second = makeService({ ghAuthTokenProvider }); + + const firstToken = first.getTokenOrThrowAsync(); + const secondToken = second.getTokenOrThrowAsync(); + await Promise.resolve(); + expect(ghAuthTokenProvider).toHaveBeenCalledTimes(1); + + resolveAuth({ + token: "gho_keyring_token", + ghCliPath: "/opt/homebrew/bin/gh", + ghAuthError: null, + }); + await expect(firstToken).resolves.toBe("gho_keyring_token"); + await expect(secondToken).resolves.toBe("gho_keyring_token"); + }); + + it("retries transient status failures after a short shared cooldown", async () => { + stubOriginRemote(); + delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; + const baseNow = Date.now(); + const now = vi.spyOn(Date, "now").mockReturnValue(baseNow); + let resolveAuth!: (value: { token: string; ghCliPath: string; ghAuthError: null }) => void; + const ghAuthTokenProvider = vi.fn(() => new Promise<{ + token: string; + ghCliPath: string; + ghAuthError: null; + }>((resolve) => { + resolveAuth = resolve; + })); + mockFetch.mockImplementation(async (input: string | URL) => { + if (String(input).endsWith("/user")) { + return jsonResponse(200, { login: "alice" }); + } + const timeout = new Error("request timed out"); + timeout.name = "AbortError"; + throw timeout; + }); + const first = makeService({ ghAuthTokenProvider }); + const second = makeService({ ghAuthTokenProvider }); + + const firstStatus = first.getStatus(); + const secondStatus = second.getStatus(); + await Promise.resolve(); + expect(ghAuthTokenProvider).toHaveBeenCalledTimes(1); + + const resolvedAuth = { + token: "github_pat_shared_slow_token", + ghCliPath: "/opt/homebrew/bin/gh", + ghAuthError: null as null, + }; + resolveAuth(resolvedAuth); + ghAuthTokenProvider.mockResolvedValue(resolvedAuth); + const statuses = await Promise.all([firstStatus, secondStatus]); + expect(statuses.map((status) => status.repoAccessOk)).toEqual([false, false]); + expect(mockFetch).toHaveBeenCalledTimes(2); // one /user + one repo probe total + + now.mockReturnValue(baseNow + 31_000); + const third = await makeService({ ghAuthTokenProvider }).getStatus(); + expect(third.repoAccessOk).toBe(false); + expect(ghAuthTokenProvider).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenCalledTimes(4); + now.mockRestore(); + }); + + it("does not extend the shared cooldown for an invalid gh token", async () => { + stubOriginRemote(); + delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; + const baseNow = Date.now(); + const now = vi.spyOn(Date, "now").mockReturnValue(baseNow); + const ghAuthTokenProvider = vi.fn(() => ({ + token: "gho_invalid_token", + ghCliPath: "/opt/homebrew/bin/gh", + ghAuthError: null, + })); + mockFetch.mockResolvedValue(jsonResponse(401, { message: "Bad credentials" })); + + const first = await makeService({ ghAuthTokenProvider }).getStatus(); + expect(first.connected).toBe(false); + expect(ghAuthTokenProvider).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledTimes(1); + + now.mockReturnValue(baseNow + 31_000); + const second = await makeService({ ghAuthTokenProvider }).getStatus(); + expect(second.connected).toBe(false); + expect(ghAuthTokenProvider).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenCalledTimes(2); + now.mockRestore(); + }); + + it("does not reuse a project-local status after the shared gh token changes", async () => { + stubOriginRemote(); + delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; + let token = "gho_shared_token_alice"; + const ghAuthTokenProvider = vi.fn(async () => ({ + token, + ghCliPath: "/opt/homebrew/bin/gh", + ghAuthError: null, + })); + mockFetch.mockImplementation(async (_input: string | URL, init?: RequestInit) => { + const authorization = (init?.headers as Record | undefined)?.authorization ?? ""; + return jsonResponse(200, { + login: authorization.includes("gho_shared_token_bob") ? "bob" : "alice", + }); + }); + const first = makeService({ ghAuthTokenProvider }); + const second = makeService({ ghAuthTokenProvider }); + + await expect(first.getStatus()).resolves.toMatchObject({ userLogin: "alice" }); + token = "gho_shared_token_bob"; + await expect(second.getStatus({ forceRefresh: true })).resolves.toMatchObject({ userLogin: "bob" }); + await expect(first.getStatus()).resolves.toMatchObject({ userLogin: "bob" }); + + expect(ghAuthTokenProvider).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("force-refreshes a hosts.yml token instead of reusing the process cache", async () => { + stubOriginRemote(); + delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; + process.env.GH_CONFIG_DIR = `/tmp/gh-force-refresh-token-${Date.now()}`; + let hostsToken = "gho_hosts_alice"; + vi.mocked(fs.readFileSync).mockImplementation(((filePath: fs.PathOrFileDescriptor) => { + if (String(filePath).endsWith("hosts.yml")) { + return `github.com:\n user: alice\n oauth_token: ${hostsToken}\n`; + } + return Buffer.from("encrypted"); + }) as typeof fs.readFileSync); + mockFetch.mockImplementation(async (_input: string | URL, init?: RequestInit) => { + const authorization = (init?.headers as Record | undefined)?.authorization ?? ""; + return jsonResponse( + 200, + { login: authorization.includes("gho_hosts_bob") ? "bob" : "alice" }, + { "x-oauth-scopes": "repo, workflow" }, + ); + }); + const service = makeService(); + + await expect(service.getStatus()).resolves.toMatchObject({ userLogin: "alice" }); + hostsToken = "gho_hosts_bob"; + await expect(service.getStatus({ forceRefresh: true })).resolves.toMatchObject({ userLogin: "bob" }); + + expect(fs.readFileSync).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect((mockFetch.mock.calls[1]?.[1]?.headers as Record).authorization) + .toBe("Bearer gho_hosts_bob"); + }); + it("clearing a stored PAT falls back to gh auth", async () => { stubOriginRemote(); delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; diff --git a/apps/desktop/src/main/services/github/githubService.ts b/apps/desktop/src/main/services/github/githubService.ts index 9e551e76b..676e1218a 100644 --- a/apps/desktop/src/main/services/github/githubService.ts +++ b/apps/desktop/src/main/services/github/githubService.ts @@ -1,7 +1,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { spawnSync } from "node:child_process"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { promisify } from "node:util"; import { safeStorage } from "electron"; import type { Logger } from "../logging/logger"; import { runGit } from "../git/git"; @@ -27,6 +29,23 @@ const AUTH_STORE_FILE_NAME = "github-token.v1.bin"; const MACHINE_TOKEN_KEY = "github.token.v1"; const GITHUB_API_TIMEOUT_MS = 20_000; const GH_AUTH_TOKEN_CACHE_TTL_MS = 30_000; +const GH_HOSTS_TOKEN_CACHE_MAX_ENTRIES = 32; +const GITHUB_STATUS_FAILURE_COOLDOWN_MS = 30_000; +const execFileAsync = promisify(execFile); +const processGhHostsTokenCache = new Map(); + +function cacheGhHostsToken(hostsPath: string, token: string | null): void { + processGhHostsTokenCache.delete(hostsPath); + processGhHostsTokenCache.set(hostsPath, { + expiresAt: Date.now() + GH_AUTH_TOKEN_CACHE_TTL_MS, + token, + }); + while (processGhHostsTokenCache.size > GH_HOSTS_TOKEN_CACHE_MAX_ENTRIES) { + const oldest = processGhHostsTokenCache.keys().next().value as string | undefined; + if (!oldest) break; + processGhHostsTokenCache.delete(oldest); + } +} type GitHubAuthSource = GitHubStatus["authSource"]; @@ -36,6 +55,54 @@ type GitHubCliAuthResult = { ghAuthError: string | null; }; +type GitHubCliAuthProvider = () => GitHubCliAuthResult | Promise; + +type SharedGithubStatusProbe = { + validated: { userLogin: string | null; scopes: string[]; tokenType: GitHubStatus["tokenType"] }; + repoAccessOk: boolean | null; + repoAccessError: string | null; +}; + +type SharedGithubStatusProbeResult = + | { ok: true; value: SharedGithubStatusProbe } + | { ok: false; error: string }; + +type ProcessGithubAuthState = { + authCache: (GitHubCliAuthResult & { expiresAt: number }) | null; + authInFlight: Promise | null; + statusCache: Map; + statusInFlight: Map>; +}; + +const processGithubAuthStates = new WeakMap(); + +function isTransientGithubProbeFailure(error: string | null): boolean { + return /timed out|timeout|network|fetch failed|aborted|econn(?:reset|refused|aborted)|enotfound|eai_again|socket|tls|temporarily unavailable/i + .test(error ?? ""); +} + +function processGithubAuthState(provider: GitHubCliAuthProvider): ProcessGithubAuthState { + const existing = processGithubAuthStates.get(provider); + if (existing) return existing; + const created: ProcessGithubAuthState = { + authCache: null, + authInFlight: null, + statusCache: new Map(), + statusInFlight: new Map(), + }; + processGithubAuthStates.set(provider, created); + return created; +} + +function githubStatusProbeKey(token: string, repo: GitHubRepoRef | null): string { + const tokenDigest = githubTokenDigest(token); + return `${tokenDigest}:${repo ? `${repo.owner.toLowerCase()}/${repo.name.toLowerCase()}` : "no-repo"}`; +} + +function githubTokenDigest(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + type GitHubTokenLookup = GitHubCliAuthResult & { source: GitHubAuthSource; patTokenStored: boolean; @@ -51,6 +118,12 @@ function readGhHostsFileToken(env: NodeJS.ProcessEnv = process.env): string | nu const configDir = env.GH_CONFIG_DIR?.trim() || path.join(env.XDG_CONFIG_HOME?.trim() || path.join(os.homedir(), ".config"), "gh"); const hostsPath = path.join(configDir, "hosts.yml"); + const cached = processGhHostsTokenCache.get(hostsPath); + if (cached && cached.expiresAt > Date.now()) { + processGhHostsTokenCache.delete(hostsPath); + processGhHostsTokenCache.set(hostsPath, cached); + return cached.token; + } try { const raw = fs.readFileSync(hostsPath, "utf8"); const lines = raw.split(/\r?\n/); @@ -64,26 +137,31 @@ function readGhHostsFileToken(env: NodeJS.ProcessEnv = process.env): string | nu const match = line.match(/^\s+oauth_token\s*:\s*(\S+)\s*$/); if (match) { const token = match[1].replace(/^["']|["']$/g, "").trim(); - if (token) return token; + if (token) { + cacheGhHostsToken(hostsPath, token); + return token; + } } } } catch { // No hosts.yml or unreadable — fall through. } + cacheGhHostsToken(hostsPath, null); return null; } -function readGitHubCliAuthToken(): GitHubCliAuthResult { +async function readGitHubCliAuthToken(): Promise { if (process.env.ADE_DISABLE_GH_AUTH_FALLBACK === "1") { return { token: null, ghCliPath: null, ghAuthError: null }; } + const hostsToken = readGhHostsFileToken(); + if (hostsToken) { + return { token: hostsToken, ghCliPath: null, ghAuthError: null }; + } + const resolved = resolveExecutableFromKnownLocations("gh"); if (!resolved?.path) { - const hostsToken = readGhHostsFileToken(); - if (hostsToken) { - return { token: hostsToken, ghCliPath: null, ghAuthError: null }; - } return { token: null, ghCliPath: null, @@ -92,29 +170,28 @@ function readGitHubCliAuthToken(): GitHubCliAuthResult { } try { - const result = spawnSync(resolved.path, ["auth", "token"], { + const { stdout } = await execFileAsync(resolved.path, ["auth", "token"], { encoding: "utf8", timeout: 5_000, windowsHide: true, + maxBuffer: 256 * 1024, env: { ...process.env, PATH: mergePathEntries(process.env.PATH, path.dirname(resolved.path)), }, }); - const token = typeof result.stdout === "string" ? result.stdout.trim() : ""; - if (result.status === 0 && token.length > 0) { + const token = stdout.trim(); + if (token.length > 0) { return { token, ghCliPath: resolved.path, ghAuthError: null }; } const hostsToken = readGhHostsFileToken(); if (hostsToken) { return { token: hostsToken, ghCliPath: resolved.path, ghAuthError: null }; } - const stderr = typeof result.stderr === "string" ? result.stderr.trim() : ""; - const message = result.error instanceof Error ? result.error.message : stderr; return { token: null, ghCliPath: resolved.path, - ghAuthError: message || "GitHub CLI is installed, but `gh auth token` did not return a token.", + ghAuthError: "GitHub CLI is installed, but `gh auth token` did not return a token.", }; } catch (error) { const hostsToken = readGhHostsFileToken(); @@ -352,7 +429,7 @@ export function createGithubService({ projectRoot: string; appDataDir: string; credentialStore?: SyncCredentialStore | null; - ghAuthTokenProvider?: (() => GitHubCliAuthResult) | null; + ghAuthTokenProvider?: GitHubCliAuthProvider | null; githubRelaySecretReader?: GitHubRelaySecretReader | null; getAccountAccessToken?: (() => Promise) | null; }) { @@ -369,7 +446,10 @@ export function createGithubService({ let tokenDecryptionFailed = false; let machineTokenReadFailed = false; - let ghAuthTokenCache: (GitHubCliAuthResult & { expiresAt: number }) | null = null; + const ghAuthProvider = ghAuthTokenProvider ?? readGitHubCliAuthToken; + const sharedGhAuth = processGithubAuthState(ghAuthProvider); + let statusInFlight: Promise | null = null; + let cachedStatusTokenDigest: string | null = null; const readMachineToken = (): string | null => { if (!credentialStore) return null; @@ -523,25 +603,32 @@ export function createGithubService({ return envToken.length > 0 ? envToken : null; }; - const readGhAuthToken = (): GitHubCliAuthResult => { + const readGhAuthToken = async (): Promise => { if (process.env.ADE_DISABLE_GH_AUTH_FALLBACK === "1") { - ghAuthTokenCache = null; + sharedGhAuth.authCache = null; return { token: null, ghCliPath: null, ghAuthError: null }; } const now = Date.now(); - if (ghAuthTokenCache && ghAuthTokenCache.expiresAt > now) { + if (sharedGhAuth.authCache && sharedGhAuth.authCache.expiresAt > now) { return { - token: ghAuthTokenCache.token, - ghCliPath: ghAuthTokenCache.ghCliPath, - ghAuthError: ghAuthTokenCache.ghAuthError, + token: sharedGhAuth.authCache.token, + ghCliPath: sharedGhAuth.authCache.ghCliPath, + ghAuthError: sharedGhAuth.authCache.ghAuthError, }; } - const result = (ghAuthTokenProvider ?? readGitHubCliAuthToken)(); - ghAuthTokenCache = { ...result, expiresAt: now + GH_AUTH_TOKEN_CACHE_TTL_MS }; - return result; + if (sharedGhAuth.authInFlight) return await sharedGhAuth.authInFlight; + const work = Promise.resolve().then(() => ghAuthProvider()); + sharedGhAuth.authInFlight = work; + try { + const result = await work; + sharedGhAuth.authCache = { ...result, expiresAt: Date.now() + GH_AUTH_TOKEN_CACHE_TTL_MS }; + return result; + } finally { + if (sharedGhAuth.authInFlight === work) sharedGhAuth.authInFlight = null; + } }; - const readAuthToken = (): GitHubTokenLookup => { + const readPrimaryAuthToken = (): GitHubTokenLookup | null => { const patToken = readStoredPatToken(); if (patToken) { return { @@ -564,7 +651,42 @@ export function createGithubService({ }; } - const gh = readGhAuthToken(); + return null; + }; + + const readAuthToken = async (): Promise => { + const primary = readPrimaryAuthToken(); + if (primary) return primary; + const gh = await readGhAuthToken(); + return { + ...gh, + source: gh.token ? "gh" : "none", + patTokenStored: false, + }; + }; + + const readAuthTokenSync = (): GitHubTokenLookup => { + const primary = readPrimaryAuthToken(); + if (primary) return primary; + if (process.env.ADE_DISABLE_GH_AUTH_FALLBACK === "1") { + sharedGhAuth.authCache = null; + return { + token: null, + source: "none", + patTokenStored: false, + ghCliPath: null, + ghAuthError: null, + }; + } + const cachedGh = sharedGhAuth.authCache && sharedGhAuth.authCache.expiresAt > Date.now() + ? sharedGhAuth.authCache + : null; + const hostsToken = cachedGh?.token ? null : readGhHostsFileToken(); + const gh: GitHubCliAuthResult = cachedGh ?? { + token: hostsToken, + ghCliPath: null, + ghAuthError: hostsToken ? null : "GitHub auth has not been resolved yet.", + }; return { ...gh, source: gh.token ? "gh" : "none", @@ -670,6 +792,79 @@ export function createGithubService({ } }; + const computeGithubStatusProbe = async ( + token: string, + repo: GitHubRepoRef | null, + ): Promise => { + try { + const validated = await validateToken(token); + let repoAccessOk: boolean | null = null; + let repoAccessError: string | null = null; + if (repo && validated.tokenType === "fine-grained") { + const probe = await probeRepoAccess(token, repo); + repoAccessOk = probe.ok; + repoAccessError = probe.error; + } + return { ok: true, value: { validated, repoAccessOk, repoAccessError } }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) }; + } + }; + + const readSharedGithubStatusProbe = async ( + token: string, + repo: GitHubRepoRef | null, + forceRefresh: boolean, + ): Promise => { + const key = githubStatusProbeKey(token, repo); + if (forceRefresh) { + const existing = sharedGhAuth.statusInFlight.get(key); + if (existing) await existing.catch(() => {}); + const newer = sharedGhAuth.statusInFlight.get(key); + if (newer && newer !== existing) return await newer; + sharedGhAuth.statusCache.delete(key); + } else { + const cached = sharedGhAuth.statusCache.get(key); + if (cached && cached.expiresAt > Date.now()) return cached.result; + const inFlight = sharedGhAuth.statusInFlight.get(key); + if (inFlight) return await inFlight; + } + + const work = computeGithubStatusProbe(token, repo); + sharedGhAuth.statusInFlight.set(key, work); + try { + const result = await work; + let isNetworkFailure = false; + if (!result.ok) { + isNetworkFailure = isTransientGithubProbeFailure(result.error); + } else if (result.value.repoAccessOk === false) { + isNetworkFailure = isTransientGithubProbeFailure(result.value.repoAccessError); + } + sharedGhAuth.statusCache.set(key, { + expiresAt: Date.now() + (isNetworkFailure + ? GITHUB_STATUS_FAILURE_COOLDOWN_MS + : GH_AUTH_TOKEN_CACHE_TTL_MS), + result, + }); + if (isNetworkFailure && sharedGhAuth.authCache?.token === token) { + sharedGhAuth.authCache.expiresAt = Math.max( + sharedGhAuth.authCache.expiresAt, + Date.now() + GITHUB_STATUS_FAILURE_COOLDOWN_MS, + ); + } + while (sharedGhAuth.statusCache.size > 32) { + const oldest = sharedGhAuth.statusCache.keys().next().value as string | undefined; + if (!oldest) break; + sharedGhAuth.statusCache.delete(oldest); + } + return result; + } finally { + if (sharedGhAuth.statusInFlight.get(key) === work) { + sharedGhAuth.statusInFlight.delete(key); + } + } + }; + // ETag cache for conditional GET requests. Responses that return 304 Not Modified // don't count against GitHub's rate limit, so this dramatically reduces API usage. const etagCache = new Map(); @@ -697,7 +892,7 @@ export function createGithubService({ */ accept?: string; }): Promise<{ data: T; response: Response | null; linkHeader?: string | null }> => { - const token = (args.token ?? readAuthToken().token ?? "").trim(); + const token = (args.token ?? (await readAuthToken()).token ?? "").trim(); if (!token) { throw new Error("GitHub auth missing. Run `gh auth login -h github.com -s repo -s workflow` or add a personal access token in Settings."); } @@ -852,13 +1047,16 @@ export function createGithubService({ return Boolean(args.userLogin); }; - const getStatus = async (opts: { forceRefresh?: boolean } = {}): Promise => { + const computeStatus = async (opts: { forceRefresh?: boolean } = {}): Promise => { if (opts.forceRefresh) { cachedStatus = null; cachedAt = 0; - ghAuthTokenCache = null; + cachedStatusTokenDigest = null; + sharedGhAuth.authCache = null; + sharedGhAuth.statusCache.clear(); + processGhHostsTokenCache.clear(); } - const tokenLookup = readAuthToken(); + const tokenLookup = await readAuthToken(); const token = tokenLookup.token; const { repo, hasOrigin } = await detectOrigin().catch(() => ({ repo: null, hasOrigin: false })); if (!token) { @@ -881,10 +1079,12 @@ export function createGithubService({ connected: false, }; cachedAt = Date.now(); + cachedStatusTokenDigest = null; return cachedStatus; } const now = Date.now(); + const tokenDigest = githubTokenDigest(token); if (cachedStatus && now - cachedAt < 30_000 && cachedStatus.tokenStored) { // Still re-detect repo and re-evaluate `connected` so a remote change is reflected. const repoChanged = @@ -893,9 +1093,10 @@ export function createGithubService({ const authSourceChanged = cachedStatus.authSource !== tokenLookup.source || cachedStatus.patTokenStored !== tokenLookup.patTokenStored; - if (authSourceChanged) { + if (authSourceChanged || cachedStatusTokenDigest !== tokenDigest) { cachedStatus = null; cachedAt = 0; + cachedStatusTokenDigest = null; } else { // If the repo just changed we can't trust the cached probe result. const repoAccessOk = repoChanged ? null : cachedStatus.repoAccessOk; @@ -922,22 +1123,19 @@ export function createGithubService({ } try { - const validated = await validateToken(token); - let repoAccessOk: boolean | null = null; - let repoAccessError: string | null = null; + const statusProbe = tokenLookup.source === "gh" + ? await readSharedGithubStatusProbe(token, repo, opts.forceRefresh === true) + : await computeGithubStatusProbe(token, repo); + if (!statusProbe.ok) throw new Error(statusProbe.error); + const { validated, repoAccessOk, repoAccessError } = statusProbe.value; // Classic PATs and gh OAuth tokens expose scopes in the /user response. // Fine-grained tokens do not expose selected repos and need a repo probe. - if (repo && validated.tokenType === "fine-grained") { - const probe = await probeRepoAccess(token, repo); - repoAccessOk = probe.ok; - repoAccessError = probe.error; - if (!probe.ok) { + if (repo && validated.tokenType === "fine-grained" && repoAccessOk === false) { logger.warn("github.repo_probe_failed", { repo: `${repo.owner}/${repo.name}`, tokenType: validated.tokenType, - error: probe.error, + error: repoAccessError, }); - } } const connected = computeConnected({ tokenStored: true, @@ -966,6 +1164,7 @@ export function createGithubService({ connected, }; cachedAt = now; + cachedStatusTokenDigest = tokenDigest; return cachedStatus; } catch (error) { logger.warn("github.token_validation_failed", { error: error instanceof Error ? error.message : String(error) }); @@ -988,10 +1187,25 @@ export function createGithubService({ connected: false, }; cachedAt = now; + cachedStatusTokenDigest = tokenDigest; return cachedStatus; } }; + const getStatus = async (opts: { forceRefresh?: boolean } = {}): Promise => { + if (statusInFlight) { + if (!opts.forceRefresh) return await statusInFlight; + await statusInFlight.catch(() => {}); + } + const work = computeStatus(opts); + statusInFlight = work; + try { + return await work; + } finally { + if (statusInFlight === work) statusInFlight = null; + } + }; + const listRepoLabels = async (owner: string, name: string): Promise => { return await apiRequestAllPages({ path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/labels`, @@ -1254,7 +1468,7 @@ export function createGithubService({ // `/user/repos`. The renderer now populates `owner` from the connected // login, so detect that case and avoid the org route for personal publishes. const authenticatedLogin = owner - ? ((await validateToken(readAuthToken().token ?? "").catch(() => ({ userLogin: null as string | null }))).userLogin?.trim() || null) + ? ((await validateToken((await readAuthToken()).token ?? "").catch(() => ({ userLogin: null as string | null }))).userLogin?.trim() || null) : null; // Only take the org route when we POSITIVELY resolved the authenticated // login and it differs from `owner`. If token validation failed (transient @@ -1313,7 +1527,7 @@ export function createGithubService({ const publishCurrentProject = async ( args: { owner?: string; name: string; description?: string; isPrivate: boolean }, ): Promise<{ state: "pushed" | "remote_added"; owner: string; name: string; fullName: string; htmlUrl: string }> => { - const token = readAuthToken().token; + const token = (await readAuthToken()).token; if (!token) { const err = new Error("GitHub is not connected. Run `gh auth login -h github.com -s repo -s workflow` or add a personal access token in Settings.") as Error & { code?: string }; err.code = "github_not_connected"; @@ -1407,6 +1621,7 @@ export function createGithubService({ cachedStatus = null; cachedAt = 0; + cachedStatusTokenDigest = null; return { state: resultState, @@ -1443,7 +1658,9 @@ export function createGithubService({ tokenDecryptionFailed = false; cachedStatus = null; cachedAt = 0; - ghAuthTokenCache = null; + cachedStatusTokenDigest = null; + sharedGhAuth.authCache = null; + sharedGhAuth.statusCache.clear(); }, clearToken(): void { @@ -1451,7 +1668,9 @@ export function createGithubService({ tokenDecryptionFailed = false; cachedStatus = null; cachedAt = 0; - ghAuthTokenCache = null; + cachedStatusTokenDigest = null; + sharedGhAuth.authCache = null; + sharedGhAuth.statusCache.clear(); }, async getRepoOrThrow(): Promise { @@ -1461,7 +1680,13 @@ export function createGithubService({ }, getTokenOrThrow(): string { - const token = readAuthToken().token; + const token = readAuthTokenSync().token; + if (!token) throw new Error("GitHub auth missing. Run `gh auth login -h github.com -s repo -s workflow` or add a personal access token in Settings."); + return token; + }, + + async getTokenOrThrowAsync(): Promise { + const token = (await readAuthToken()).token; if (!token) throw new Error("GitHub auth missing. Run `gh auth login -h github.com -s repo -s workflow` or add a personal access token in Settings."); return token; }, diff --git a/apps/desktop/src/main/services/ipc/ipcTimeouts.test.ts b/apps/desktop/src/main/services/ipc/ipcTimeouts.test.ts index 3caeab9ab..c83cedcf1 100644 --- a/apps/desktop/src/main/services/ipc/ipcTimeouts.test.ts +++ b/apps/desktop/src/main/services/ipc/ipcTimeouts.test.ts @@ -1,12 +1,45 @@ import { describe, expect, it } from "vitest"; import { IPC } from "../../../shared/ipc"; import { ipcInvokeTimeoutMs } from "./ipcTimeouts"; +import { + LOCAL_RUNTIME_ACTION_REGISTRY_TIMEOUT_MS, + LOCAL_RUNTIME_ACTION_TIMEOUT_MS, + LOCAL_RUNTIME_EVENT_POLL_TIMEOUT_MS, + LOCAL_RUNTIME_FILE_ACTION_TIMEOUT_MS, + LOCAL_RUNTIME_IPC_COMPLETION_HEADROOM_MS, + LOCAL_RUNTIME_IPC_PROJECT_COMPLETION_TIMEOUT_MS, + LOCAL_RUNTIME_IPC_PROJECT_SETUP_MARGIN_MS, + LOCAL_RUNTIME_IPC_PROJECT_SETUP_TIMEOUT_MS, + LOCAL_RUNTIME_PROJECT_TIMEOUT_MS, + LOCAL_RUNTIME_SYNC_TIMEOUT_MS, + longRunningLocalRuntimeActionTimeoutMs, +} from "../localRuntime/localRuntimeTimeoutPolicy"; describe("ipcInvokeTimeoutMs", () => { - it("uses the lane delete budget for runtime-backed lane delete actions", () => { - expect(ipcInvokeTimeoutMs(IPC.localRuntimeCallAction, [{ + it("keeps local lane delete IPC alive through cold setup and the daemon action", () => { + const innerTimeoutMs = longRunningLocalRuntimeActionTimeoutMs("lane.delete")!; + const outerTimeoutMs = ipcInvokeTimeoutMs(IPC.localRuntimeCallAction, [{ request: { domain: "lane", action: "delete", args: { laneId: "lane-1" } }, - }])).toBe(4 * 60_000); + }]); + + expect(innerTimeoutMs).toBe(4 * 60_000); + expect(outerTimeoutMs).toBe( + LOCAL_RUNTIME_IPC_PROJECT_SETUP_TIMEOUT_MS + + innerTimeoutMs + + LOCAL_RUNTIME_IPC_COMPLETION_HEADROOM_MS, + ); + // Model the full cold setup allowance (connect + projects.add) followed by + // the full daemon delete budget. The renderer timer still owns the explicit + // completion headroom instead of racing the inner timer. + expect( + outerTimeoutMs + - (LOCAL_RUNTIME_IPC_PROJECT_SETUP_TIMEOUT_MS + innerTimeoutMs), + ).toBe(LOCAL_RUNTIME_IPC_COMPLETION_HEADROOM_MS); + expect(LOCAL_RUNTIME_IPC_PROJECT_SETUP_TIMEOUT_MS).toBe( + 2 * LOCAL_RUNTIME_PROJECT_TIMEOUT_MS + LOCAL_RUNTIME_IPC_PROJECT_SETUP_MARGIN_MS, + ); + expect(LOCAL_RUNTIME_IPC_PROJECT_SETUP_TIMEOUT_MS).toBe(270_000); + expect(ipcInvokeTimeoutMs(IPC.remoteRuntimeCallAction, [{ id: "target-1", projectId: "project-1", @@ -14,14 +47,59 @@ describe("ipcInvokeTimeoutMs", () => { }])).toBe(4 * 60_000); }); - it("gives ordinary local runtime calls enough time to bind a cold project", () => { + it("composes cold setup, daemon action, and headroom for archive and unarchive", () => { + expect(ipcInvokeTimeoutMs(IPC.lanesArchive)).toBe(4 * 60_000); + for (const action of ["archive", "unarchive"] as const) { + const innerTimeoutMs = longRunningLocalRuntimeActionTimeoutMs(`lane.${action}`)!; + const outerTimeoutMs = ipcInvokeTimeoutMs(IPC.localRuntimeCallAction, [{ + request: { domain: "lane", action, args: { laneId: "lane-1" } }, + }]); + expect(outerTimeoutMs).toBe( + LOCAL_RUNTIME_IPC_PROJECT_SETUP_TIMEOUT_MS + + innerTimeoutMs + + LOCAL_RUNTIME_IPC_COMPLETION_HEADROOM_MS, + ); + expect( + outerTimeoutMs + - (LOCAL_RUNTIME_IPC_PROJECT_SETUP_TIMEOUT_MS + innerTimeoutMs), + ).toBe(LOCAL_RUNTIME_IPC_COMPLETION_HEADROOM_MS); + } + }); + + it("composes cold setup, the default daemon action, and completion headroom", () => { expect(ipcInvokeTimeoutMs(IPC.localRuntimeCallAction, [{ request: { domain: "lane", action: "list" }, - }])).toBe(150_000); - expect(ipcInvokeTimeoutMs(IPC.localRuntimeCallAction)).toBe(150_000); - expect(ipcInvokeTimeoutMs(IPC.localRuntimeCallSync)).toBe(150_000); - expect(ipcInvokeTimeoutMs(IPC.localRuntimeListActionRegistry)).toBe(150_000); - expect(ipcInvokeTimeoutMs(IPC.localRuntimeStreamEvents)).toBe(150_000); + }])).toBe(315_000); + expect(ipcInvokeTimeoutMs(IPC.localRuntimeCallAction, [{ + request: { domain: "file", action: "readFile", args: {} }, + }])).toBe(293_000); + expect(LOCAL_RUNTIME_IPC_PROJECT_COMPLETION_TIMEOUT_MS).toBe(285_000); + expect(ipcInvokeTimeoutMs(IPC.localRuntimeCallAction)).toBe(285_000); + expect(ipcInvokeTimeoutMs(IPC.localRuntimeCallSync)).toBe(315_000); + expect(ipcInvokeTimeoutMs(IPC.localRuntimeListActionRegistry)).toBe(315_000); + expect(ipcInvokeTimeoutMs(IPC.localRuntimeStreamEvents)).toBe(287_000); + expect(315_000 - LOCAL_RUNTIME_IPC_PROJECT_SETUP_TIMEOUT_MS).toBe( + LOCAL_RUNTIME_ACTION_TIMEOUT_MS + LOCAL_RUNTIME_IPC_COMPLETION_HEADROOM_MS, + ); + expect(293_000 - LOCAL_RUNTIME_IPC_PROJECT_SETUP_TIMEOUT_MS).toBe( + LOCAL_RUNTIME_FILE_ACTION_TIMEOUT_MS + LOCAL_RUNTIME_IPC_COMPLETION_HEADROOM_MS, + ); + expect(315_000 - LOCAL_RUNTIME_IPC_PROJECT_SETUP_TIMEOUT_MS).toBe( + LOCAL_RUNTIME_SYNC_TIMEOUT_MS + LOCAL_RUNTIME_IPC_COMPLETION_HEADROOM_MS, + ); + expect(315_000 - LOCAL_RUNTIME_IPC_PROJECT_SETUP_TIMEOUT_MS).toBe( + LOCAL_RUNTIME_ACTION_REGISTRY_TIMEOUT_MS + LOCAL_RUNTIME_IPC_COMPLETION_HEADROOM_MS, + ); + expect(287_000 - LOCAL_RUNTIME_IPC_PROJECT_SETUP_TIMEOUT_MS).toBe( + LOCAL_RUNTIME_EVENT_POLL_TIMEOUT_MS + LOCAL_RUNTIME_IPC_COMPLETION_HEADROOM_MS, + ); + }); + + it("keeps project switching on setup plus completion without a daemon call budget", () => { + expect(ipcInvokeTimeoutMs(IPC.projectSwitchToPath)).toBe( + LOCAL_RUNTIME_IPC_PROJECT_COMPLETION_TIMEOUT_MS, + ); + expect(ipcInvokeTimeoutMs(IPC.projectSwitchToPath)).toBe(285_000); }); it("gives retryable remote runtime actions enough time to reconnect", () => { @@ -78,10 +156,10 @@ describe("ipcInvokeTimeoutMs", () => { expect(ipcInvokeTimeoutMs(IPC.iosSimulatorRenderCurrentPreview)).toBe(2 * 60_000); expect(ipcInvokeTimeoutMs(IPC.localRuntimeCallAction, [{ request: { domain: "ios_simulator", action: "ensurePreviewWorkspace", args: {} }, - }])).toBe(2 * 60_000); + }])).toBe(315_000); expect(ipcInvokeTimeoutMs(IPC.localRuntimeCallAction, [{ request: { domain: "ios_simulator", action: "renderCurrentPreview", args: {} }, - }])).toBe(2 * 60_000); + }])).toBe(315_000); expect(ipcInvokeTimeoutMs(IPC.remoteRuntimeCallAction, [{ id: "target-1", projectId: "project-1", @@ -108,16 +186,27 @@ describe("ipcInvokeTimeoutMs", () => { }])).toBe(150_000); expect(ipcInvokeTimeoutMs(IPC.localRuntimeCallAction, [{ request: { domain: "chat", action: "handoffSession", args: {} }, - }])).toBe(150_000); + }])).toBe(405_000); }); - it("extends lane creation timeouts on direct and local runtime paths", () => { + it("keeps remote lane creation unchanged while composing the local timeout", () => { expect(ipcInvokeTimeoutMs(IPC.lanesCreate)).toBe(4 * 60_000); - expect(ipcInvokeTimeoutMs(IPC.localRuntimeCallAction, [{ + expect(ipcInvokeTimeoutMs(IPC.remoteRuntimeCallAction, [{ + id: "target-1", + projectId: "project-1", request: { domain: "lane", action: "create", args: {} }, }])).toBe(4 * 60_000); + expect(ipcInvokeTimeoutMs(IPC.localRuntimeCallAction, [{ + request: { domain: "lane", action: "create", args: {} }, + }])).toBe(315_000); expect(ipcInvokeTimeoutMs(IPC.localRuntimeCallAction, [{ request: { domain: "lane", action: "createChild", args: {} }, - }])).toBe(4 * 60_000); + }])).toBe(315_000); + }); + + it("composes an unmapped named daemon override for local runtime actions", () => { + expect(ipcInvokeTimeoutMs(IPC.localRuntimeCallAction, [{ + request: { domain: "chat", action: "suggestLaneNameFromPrompt", args: {} }, + }])).toBe(405_000); }); }); diff --git a/apps/desktop/src/main/services/ipc/ipcTimeouts.ts b/apps/desktop/src/main/services/ipc/ipcTimeouts.ts index 7e7588389..8d5148326 100644 --- a/apps/desktop/src/main/services/ipc/ipcTimeouts.ts +++ b/apps/desktop/src/main/services/ipc/ipcTimeouts.ts @@ -1,5 +1,12 @@ import { IPC } from "../../../shared/ipc"; import { isRetryableRemoteAction } from "../remoteRuntime/retryableRemoteActions"; +import { + localRuntimeActionIpcTimeoutMs, + LOCAL_RUNTIME_IPC_ACTION_REGISTRY_TIMEOUT_MS, + LOCAL_RUNTIME_IPC_EVENT_POLL_TIMEOUT_MS, + LOCAL_RUNTIME_IPC_PROJECT_COMPLETION_TIMEOUT_MS, + LOCAL_RUNTIME_IPC_SYNC_TIMEOUT_MS, +} from "../localRuntime/localRuntimeTimeoutPolicy"; function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); @@ -11,6 +18,7 @@ const RUNTIME_ACTION_CHANNEL: Record> = { createChild: IPC.lanesCreateChild, createFromUnstaged: IPC.lanesCreateFromUnstaged, importBranch: IPC.lanesImportBranch, + archive: IPC.lanesArchive, delete: IPC.lanesDelete, }, ios_simulator: { @@ -24,7 +32,6 @@ const RUNTIME_ACTION_CHANNEL: Record> = { }, }; -const LOCAL_RUNTIME_PROJECT_SETUP_TIMEOUT_MS = 150_000; const REMOTE_RUNTIME_BOOTSTRAP_TIMEOUT_MS = 10 * 60_000; const REMOTE_RUNTIME_RETRYABLE_ACTION_TIMEOUT_MS = 75_000; @@ -49,13 +56,16 @@ function retryableRemoteActionTimeoutMs(args: readonly unknown[]): number | null export function ipcInvokeTimeoutMs(channel: string, args: readonly unknown[] = []): number { if (channel === IPC.localRuntimeCallAction) { - const actionTimeoutMs = runtimeActionTimeoutMs(args); - if (actionTimeoutMs != null) return actionTimeoutMs; - return LOCAL_RUNTIME_PROJECT_SETUP_TIMEOUT_MS; - } - if (channel === IPC.localRuntimeCallSync || channel === IPC.localRuntimeListActionRegistry || channel === IPC.localRuntimeStreamEvents) { - return LOCAL_RUNTIME_PROJECT_SETUP_TIMEOUT_MS; + const payload = args[0]; + const request = isRecord(payload) && isRecord(payload.request) ? payload.request : null; + if (typeof request?.domain === "string" && typeof request.action === "string") { + return localRuntimeActionIpcTimeoutMs(request.domain, request.action); + } + return LOCAL_RUNTIME_IPC_PROJECT_COMPLETION_TIMEOUT_MS; } + if (channel === IPC.localRuntimeCallSync) return LOCAL_RUNTIME_IPC_SYNC_TIMEOUT_MS; + if (channel === IPC.localRuntimeListActionRegistry) return LOCAL_RUNTIME_IPC_ACTION_REGISTRY_TIMEOUT_MS; + if (channel === IPC.localRuntimeStreamEvents) return LOCAL_RUNTIME_IPC_EVENT_POLL_TIMEOUT_MS; if (channel === IPC.remoteRuntimeCallAction) { const actionTimeoutMs = runtimeActionTimeoutMs(args); if (actionTimeoutMs != null) return actionTimeoutMs; @@ -64,6 +74,10 @@ export function ipcInvokeTimeoutMs(channel: string, args: readonly unknown[] = [ return 30_000; } switch (channel) { + // Switching projects can cold-start and bind the local runtime. Keep the + // renderer's outcome known through setup and result delivery. + case IPC.projectSwitchToPath: + return LOCAL_RUNTIME_IPC_PROJECT_COMPLETION_TIMEOUT_MS; case IPC.remoteRuntimeConnect: case IPC.remoteRuntimeListProjects: case IPC.remoteRuntimeAddProject: @@ -84,6 +98,7 @@ export function ipcInvokeTimeoutMs(channel: string, args: readonly unknown[] = [ case IPC.lanesCreateChild: case IPC.lanesCreateFromUnstaged: case IPC.lanesImportBranch: + case IPC.lanesArchive: case IPC.lanesDelete: return 4 * 60_000; // Handoff runs an AI brief + session creation + first-message dispatch diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index cd33d0482..c0c541be7 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -3797,6 +3797,9 @@ export function registerIpc({ ipcMain.handle(IPC.appGetLatestRelease, async (): Promise => { let token: string | null = null; try { + // ADE's release repository is public. Use only an immediately available + // token here so a slow/keyring-backed `gh auth token` lookup cannot hold + // the update affordance behind unrelated GitHub authentication. token = getCtx().githubService.getTokenOrThrow(); } catch { token = null; @@ -4122,9 +4125,9 @@ export function registerIpc({ const runtimeBridge = registerRuntimeBridge({ appVersion: app.getVersion(), bindRemoteProject, - getGitHubTokenForRemoteClone: () => { + getGitHubTokenForRemoteClone: async () => { try { - return getCtx().githubService.getTokenOrThrow(); + return await getCtx().githubService.getTokenOrThrowAsync(); } catch { return null; } @@ -5308,6 +5311,7 @@ export function registerIpc({ ...(typeof record.cwd === "string" || record.cwd === null ? { cwd: record.cwd } : {}), ...(record.scope === "all" || record.scope === "project" ? { scope: record.scope } : {}), ...(typeof record.limit === "number" ? { limit: record.limit } : {}), + ...(typeof record.sessionId === "string" || record.sessionId === null ? { sessionId: record.sessionId } : {}), }; }; @@ -5329,6 +5333,8 @@ export function registerIpc({ target, mode, ...(typeof record.model === "string" ? { model: record.model } : {}), + ...(typeof record.reasoningEffort === "string" ? { reasoningEffort: record.reasoningEffort } : {}), + ...(typeof record.fastMode === "boolean" ? { fastMode: record.fastMode } : {}), ...(typeof record.permissionMode === "string" ? { permissionMode: record.permissionMode } : {}), }; }; diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts index 6a35fec2b..f0855d04d 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts @@ -1393,6 +1393,85 @@ describe("registerIpc sync bridge", () => { vi.useRealTimers(); }); + it("preserves and validates exact lookup and launch overrides across external-session IPC parsing", async () => { + const list = vi.fn(async () => []); + const importExternalSession = vi.fn(async () => ({ + kind: "cli" as const, + sessionId: "terminal-1", + ptyId: "pty-1", + laneId: "lane-1", + })); + registerIpc({ + getCtx: () => ({ + logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() }, + externalSessionsService: { list, importExternalSession }, + }) as any, + getWindowSession: () => ({ + windowId: 7, + project: { rootPath: "/repo", displayName: "Repo" } as any, + binding: localBinding("/repo"), + }), + switchProjectFromDialog: vi.fn(), + closeCurrentProject: vi.fn(), + closeProjectByPath: vi.fn(), + globalStatePath: "/tmp/ade-state.json", + }); + + await ipcHandlers.get(IPC.externalSessionsList)?.(eventForSender(), { + providers: ["codex"], + sessionId: "native-session-1", + limit: 1, + }); + await ipcHandlers.get(IPC.externalSessionsImport)?.(eventForSender(), { + provider: "codex", + sessionId: "native-session-1", + laneId: "lane-1", + target: "cli", + mode: "resume", + model: "gpt-5.6-codex", + reasoningEffort: "high", + fastMode: true, + permissionMode: "default", + }); + await ipcHandlers.get(IPC.externalSessionsList)?.(eventForSender(), { + sessionId: 42, + }); + await ipcHandlers.get(IPC.externalSessionsImport)?.(eventForSender(), { + provider: "codex", + sessionId: "native-session-1", + laneId: "lane-1", + target: "cli", + mode: "resume", + reasoningEffort: 42, + fastMode: "yes", + }); + + expect(list).toHaveBeenNthCalledWith(1, { + providers: ["codex"], + sessionId: "native-session-1", + limit: 1, + }); + expect(importExternalSession).toHaveBeenNthCalledWith(1, { + provider: "codex", + sessionId: "native-session-1", + laneId: "lane-1", + target: "cli", + mode: "resume", + model: "gpt-5.6-codex", + reasoningEffort: "high", + fastMode: true, + permissionMode: "default", + }); + expect(list).toHaveBeenNthCalledWith(2, {}); + expect(importExternalSession).toHaveBeenNthCalledWith(2, { + provider: "codex", + sessionId: "native-session-1", + laneId: "lane-1", + target: "cli", + mode: "resume", + }); + }); + it("shows hidden dotenv variants in the import picker", async () => { showOpenDialogMock.mockResolvedValue({ canceled: true, filePaths: [] }); registerIpc({ diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.ts index b7dd36eb9..c6e9be257 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.ts @@ -94,7 +94,7 @@ type RuntimeBridgeArgs = { binding: OpenProjectBinding & { kind: "remote" }, ) => void; localRuntimeConnectionPool?: LocalRuntimeConnectionPool | null; - getGitHubTokenForRemoteClone?: (() => string | null) | null; + getGitHubTokenForRemoteClone?: (() => string | null | Promise) | null; getLocalMachineIdentity?: (() => AdeAccountLocalMachineIdentity) | null; }; @@ -909,7 +909,7 @@ export function registerRuntimeBridge({ if (!destinationCredentialsOnly && target && hasKnownSshHostKeyForTarget(target)) { try { githubAuthHeader = createGitHubAuthHeader( - getGitHubTokenForRemoteClone?.() ?? null, + await getGitHubTokenForRemoteClone?.() ?? null, ); } catch { githubAuthHeader = null; diff --git a/apps/desktop/src/main/services/lanes/laneCacheKey.test.ts b/apps/desktop/src/main/services/lanes/laneCacheKey.test.ts new file mode 100644 index 000000000..1b37a1507 --- /dev/null +++ b/apps/desktop/src/main/services/lanes/laneCacheKey.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { serializeLaneCacheKeyFields } from "./laneCacheKey"; + +describe("serializeLaneCacheKeyFields", () => { + it("preserves the exact ordered field projection used by lane caches", () => { + const fields = serializeLaneCacheKeyFields({ + id: "lane-1", + parentLaneId: "lane-parent", + branchRef: "refs/heads/feature/work", + baseRef: "main", + worktreePath: "/repo/.ade/worktrees/lane-1", + archivedAt: "2026-07-22T12:00:00.000Z", + }); + + expect(Object.keys(fields)).toEqual([ + "id", + "parentLaneId", + "branchRef", + "baseRef", + "worktreePath", + "archivedAt", + ]); + expect(JSON.stringify(fields)).toBe( + "{\"id\":\"lane-1\",\"parentLaneId\":\"lane-parent\",\"branchRef\":\"refs/heads/feature/work\",\"baseRef\":\"main\",\"worktreePath\":\"/repo/.ade/worktrees/lane-1\",\"archivedAt\":\"2026-07-22T12:00:00.000Z\"}", + ); + }); +}); diff --git a/apps/desktop/src/main/services/lanes/laneCacheKey.ts b/apps/desktop/src/main/services/lanes/laneCacheKey.ts new file mode 100644 index 000000000..c84fd21c5 --- /dev/null +++ b/apps/desktop/src/main/services/lanes/laneCacheKey.ts @@ -0,0 +1,17 @@ +import type { LaneSummary } from "../../../shared/types"; + +export type LaneCacheKeySource = Pick< + LaneSummary, + "id" | "parentLaneId" | "branchRef" | "baseRef" | "worktreePath" | "archivedAt" +>; + +export function serializeLaneCacheKeyFields(lane: LaneCacheKeySource) { + return { + id: lane.id, + parentLaneId: lane.parentLaneId, + branchRef: lane.branchRef, + baseRef: lane.baseRef, + worktreePath: lane.worktreePath, + archivedAt: lane.archivedAt, + }; +} diff --git a/apps/desktop/src/main/services/lanes/laneListSnapshotService.test.ts b/apps/desktop/src/main/services/lanes/laneListSnapshotService.test.ts index 244dac434..d6fa8d852 100644 --- a/apps/desktop/src/main/services/lanes/laneListSnapshotService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneListSnapshotService.test.ts @@ -151,4 +151,176 @@ describe("laneListSnapshotService", () => { sessionCount: 1, }); }); + + it("returns core lane rows when optional rebase enrichment exceeds its budget", async () => { + vi.useFakeTimers(); + try { + const services = { + ...makeHarness({ + id: "session-1", + laneId: "lane-1", + status: "running", + runtimeState: "running", + toolType: "shell", + lastOutputPreview: "working", + }), + rebaseSuggestionService: { + listSuggestions: vi.fn(() => new Promise(() => {})), + }, + }; + + const pending = buildLaneListSnapshots( + services as any, + [{ id: "lane-1", name: "Lane 1", laneType: "worktree", archivedAt: null }] as any, + { + includeConflictStatus: false, + includeRebaseSuggestions: true, + includeAutoRebaseStatus: false, + optionalEnrichmentBudgetMs: 25, + }, + ); + await vi.advanceTimersByTimeAsync(25); + + await expect(pending).resolves.toEqual([ + expect.objectContaining({ + lane: expect.objectContaining({ id: "lane-1" }), + runtime: expect.objectContaining({ bucket: "running", sessionCount: 1 }), + rebaseSuggestion: null, + }), + ]); + expect(services.rebaseSuggestionService.listSuggestions).toHaveBeenCalledTimes(1); + expect(services.logger.info).toHaveBeenCalledWith( + "lanes.listSnapshots.optional_enrichment_deferred", + expect.objectContaining({ budgetMs: 25, laneCount: 1 }), + ); + } finally { + vi.useRealTimers(); + } + }); + + it("preserves last-known decorations while a newer enrichment is still pending", async () => { + vi.useFakeTimers(); + try { + let resolveRefresh!: (value: any[]) => void; + const rebaseSuggestions = vi.fn() + .mockResolvedValueOnce([{ laneId: "lane-1", behindCount: 1 }]) + .mockImplementationOnce(() => new Promise((resolve) => { + resolveRefresh = resolve; + })) + .mockImplementation(() => new Promise(() => {})); + const services = { + ...makeHarness({ + id: "session-1", + laneId: "lane-1", + status: "running", + runtimeState: "running", + toolType: "shell", + lastOutputPreview: "working", + }), + rebaseSuggestionService: { listSuggestions: rebaseSuggestions }, + }; + const lanes = [{ id: "lane-1", name: "Lane 1", laneType: "worktree", archivedAt: null }] as any; + const options = { + includeConflictStatus: false, + includeRebaseSuggestions: true, + includeAutoRebaseStatus: false, + optionalEnrichmentBudgetMs: 25, + }; + + const first = await buildLaneListSnapshots(services as any, lanes, options); + expect(first[0]?.rebaseSuggestion).toEqual(expect.objectContaining({ behindCount: 1 })); + + const secondPending = buildLaneListSnapshots(services as any, lanes, options); + await vi.advanceTimersByTimeAsync(25); + const second = await secondPending; + expect(second[0]?.rebaseSuggestion).toEqual(expect.objectContaining({ behindCount: 1 })); + + resolveRefresh([{ laneId: "lane-1", behindCount: 2 }]); + await Promise.resolve(); + const thirdPending = buildLaneListSnapshots(services as any, lanes, options); + await vi.advanceTimersByTimeAsync(25); + const third = await thirdPending; + expect(third[0]?.rebaseSuggestion).toEqual(expect.objectContaining({ behindCount: 2 })); + } finally { + vi.useRealTimers(); + } + }); + + it("publishes completed decorations when another optional enrichment hangs", async () => { + vi.useFakeTimers(); + try { + const services = { + ...makeHarness({ + id: "session-1", + laneId: "lane-1", + status: "running", + runtimeState: "running", + toolType: "shell", + lastOutputPreview: "working", + }), + rebaseSuggestionService: { + listSuggestions: vi.fn().mockResolvedValue([{ laneId: "lane-1", behindCount: 3 }]), + }, + conflictService: { + getBatchAssessment: vi.fn(() => new Promise(() => {})), + }, + }; + const pending = buildLaneListSnapshots( + services as any, + [{ id: "lane-1", name: "Lane 1", laneType: "worktree", archivedAt: null }] as any, + { + includeConflictStatus: true, + includeRebaseSuggestions: true, + includeAutoRebaseStatus: false, + optionalEnrichmentBudgetMs: 25, + }, + ); + + await vi.advanceTimersByTimeAsync(25); + await expect(pending).resolves.toEqual([ + expect.objectContaining({ + rebaseSuggestion: expect.objectContaining({ behindCount: 3 }), + conflictStatus: null, + }), + ]); + } finally { + vi.useRealTimers(); + } + }); + + it("coalesces repeated snapshot calls onto one optional enrichment per lane set", async () => { + vi.useFakeTimers(); + try { + const never = () => new Promise(() => {}); + const services = { + ...makeHarness({ + id: "session-1", + laneId: "lane-1", + status: "running", + runtimeState: "running", + toolType: "shell", + lastOutputPreview: "working", + }), + rebaseSuggestionService: { listSuggestions: vi.fn(never) }, + autoRebaseService: { listStatuses: vi.fn(never) }, + conflictService: { getBatchAssessment: vi.fn(never) }, + }; + const lanes = [{ id: "lane-1", name: "Lane 1", laneType: "worktree", archivedAt: null }] as any; + const options = { optionalEnrichmentBudgetMs: 25 }; + + const first = buildLaneListSnapshots(services as any, lanes, options); + const second = buildLaneListSnapshots(services as any, lanes, options); + const otherLaneSet = buildLaneListSnapshots(services as any, [ + { id: "lane-2", name: "Lane 2", laneType: "worktree", archivedAt: null }, + ] as any, options); + await vi.advanceTimersByTimeAsync(25); + await Promise.all([first, second, otherLaneSet]); + + expect(services.rebaseSuggestionService.listSuggestions).toHaveBeenCalledTimes(2); + expect(services.autoRebaseService.listStatuses).toHaveBeenCalledTimes(2); + expect(services.conflictService.getBatchAssessment).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/apps/desktop/src/main/services/lanes/laneListSnapshotService.ts b/apps/desktop/src/main/services/lanes/laneListSnapshotService.ts index 441ad3270..72518b42e 100644 --- a/apps/desktop/src/main/services/lanes/laneListSnapshotService.ts +++ b/apps/desktop/src/main/services/lanes/laneListSnapshotService.ts @@ -8,6 +8,7 @@ import type { TerminalSessionSummary, } from "../../../shared/types"; import type { Logger } from "../logging/logger"; +import { serializeLaneCacheKeyFields } from "./laneCacheKey"; type LanePresenceHost = { getLanePresenceSnapshot?: () => Array<{ laneId: string; devicesOpen: DeviceMarker[] }>; @@ -52,12 +53,49 @@ type LaneListSnapshotServices = { logger: Pick; }; +type OptionalLaneListEnrichment = [ + Array>, + Array>, + { lanes?: Array> } | null, +]; + +type OptionalLaneListEnrichmentCacheEntry = { + last: OptionalLaneListEnrichment; + inFlight: Promise | null; + generation: number; +}; + +const OPTIONAL_LANE_ENRICHMENT_CACHE_MAX_ENTRIES = 8; +const OPTIONAL_LANE_ENRICHMENT_RETRY_AFTER_MS = 2 * 60_000; +const optionalEnrichmentByLaneService = new WeakMap< + object, + Map +>(); + +function optionalLaneEnrichmentKey( + lanes: LaneSummary[], + options: LaneListSnapshotOptions, +): string { + return JSON.stringify({ + lanes: lanes + .map(serializeLaneCacheKeyFields) + .sort((left, right) => left.id.localeCompare(right.id)), + conflict: options.includeConflictStatus !== false, + rebase: options.includeRebaseSuggestions !== false, + autoRebase: options.includeAutoRebaseStatus !== false, + }); +} + export type LaneListSnapshotOptions = { includeConflictStatus?: boolean; includeRebaseSuggestions?: boolean; includeAutoRebaseStatus?: boolean; + /** Test/diagnostic override; optional decorations must never gate core rows. */ + optionalEnrichmentBudgetMs?: number; }; +export const OPTIONAL_LANE_ENRICHMENT_BUDGET_MS = 250; + function isChatToolType(toolType: string | null | undefined): boolean { if (!toolType) return false; const t = toolType.trim().toLowerCase(); @@ -242,31 +280,116 @@ export async function buildLaneListSnapshots( } }; - const [sessions, rebaseSuggestions, autoRebaseStatuses, stateSnapshots, batchAssessment] = await Promise.all([ + let optionalCache = optionalEnrichmentByLaneService.get(args.laneService); + if (!optionalCache) { + optionalCache = new Map(); + optionalEnrichmentByLaneService.set(args.laneService, optionalCache); + } + const optionalCacheKey = optionalLaneEnrichmentKey(lanes, options); + let optionalEntry = optionalCache.get(optionalCacheKey); + if (!optionalEntry) { + optionalEntry = { last: [[], [], null], inFlight: null, generation: 0 }; + optionalCache.set(optionalCacheKey, optionalEntry); + while (optionalCache.size > OPTIONAL_LANE_ENRICHMENT_CACHE_MAX_ENTRIES) { + const oldestKey = optionalCache.keys().next().value as string | undefined; + if (!oldestKey) break; + optionalCache.delete(oldestKey); + } + } + const cacheEntry = optionalEntry; + if (!cacheEntry.inFlight) { + const generation = cacheEntry.generation + 1; + cacheEntry.generation = generation; + const rebaseWork = (options.includeRebaseSuggestions === false + ? Promise.resolve([]) + : timePhase("rebase_suggestions", () => + Promise.resolve() + .then(() => args.rebaseSuggestionService?.listSuggestions({ lanes }) ?? []) + .catch(() => cacheEntry.last[0]))) + .then((value) => { + if (cacheEntry.generation === generation) { + cacheEntry.last = [value, cacheEntry.last[1], cacheEntry.last[2]]; + } + return value; + }); + const autoRebaseWork = (options.includeAutoRebaseStatus === false + ? Promise.resolve([]) + : timePhase("auto_rebase_statuses", () => + Promise.resolve() + .then(() => args.autoRebaseService?.listStatuses({ lanes }) ?? []) + .catch(() => cacheEntry.last[1]))) + .then((value) => { + if (cacheEntry.generation === generation) { + cacheEntry.last = [cacheEntry.last[0], value, cacheEntry.last[2]]; + } + return value; + }); + const conflictWork = (options.includeConflictStatus === false + ? Promise.resolve(null) + : timePhase("conflict_assessment", () => + Promise.resolve() + .then(() => args.conflictService?.getBatchAssessment({ lanes }) ?? null) + .catch(() => cacheEntry.last[2]))) + .then((value) => { + if (cacheEntry.generation === generation) { + cacheEntry.last = [cacheEntry.last[0], cacheEntry.last[1], value]; + } + return value; + }); + const work: Promise = Promise.all([ + rebaseWork, + autoRebaseWork, + conflictWork, + ]); + cacheEntry.inFlight = work; + const retryTimer = setTimeout(() => { + if (cacheEntry.inFlight === work) cacheEntry.inFlight = null; + }, OPTIONAL_LANE_ENRICHMENT_RETRY_AFTER_MS); + retryTimer.unref?.(); + void work.then((result) => { + // A watchdog may release a genuinely hung probe so a newer scan can + // start. Never let that older probe overwrite newer last-known data if + // it eventually settles out of order. + if (cacheEntry.inFlight === work) cacheEntry.last = result; + }).finally(() => { + clearTimeout(retryTimer); + if (cacheEntry.inFlight === work) cacheEntry.inFlight = null; + }); + } + const optionalEnrichment = cacheEntry.inFlight ?? Promise.resolve(cacheEntry.last); + const optionalBudgetMs = Math.max( + 0, + Math.floor(options.optionalEnrichmentBudgetMs ?? OPTIONAL_LANE_ENRICHMENT_BUDGET_MS), + ); + let optionalBudgetTimer: ReturnType | null = null; + const optionalWithinBudget: Promise = Promise.race([ + optionalEnrichment, + new Promise((resolve) => { + optionalBudgetTimer = setTimeout(() => resolve(null), optionalBudgetMs); + optionalBudgetTimer.unref?.(); + }), + ]); + + const [sessions, stateSnapshots, optionalResult] = await Promise.all([ timePhase("sessions", () => enrichSessionsForLaneList(args)), - options.includeRebaseSuggestions === false - ? Promise.resolve([]) - : timePhase("rebase_suggestions", () => - Promise.resolve() - .then(() => args.rebaseSuggestionService?.listSuggestions({ lanes }) ?? []) - .catch(() => [])), - options.includeAutoRebaseStatus === false - ? Promise.resolve([]) - : timePhase("auto_rebase_statuses", () => - Promise.resolve() - .then(() => args.autoRebaseService?.listStatuses({ lanes }) ?? []) - .catch(() => [])), timePhase("state_snapshots", () => Promise.resolve() .then(() => args.laneService.listStateSnapshots()) .catch(() => [])), - options.includeConflictStatus === false - ? Promise.resolve(null) - : timePhase("conflict_assessment", () => - Promise.resolve() - .then(() => args.conflictService?.getBatchAssessment({ lanes }) ?? null) - .catch(() => null)), + optionalWithinBudget, ]); + if (optionalBudgetTimer) clearTimeout(optionalBudgetTimer); + const [rebaseSuggestions, autoRebaseStatuses, batchAssessment] = optionalResult + ?? cacheEntry.last; + if (optionalResult === null) { + args.logger.info("lanes.listSnapshots.optional_enrichment_deferred", { + laneCount: lanes.length, + budgetMs: optionalBudgetMs, + includeConflictStatus: options.includeConflictStatus !== false, + includeRebaseSuggestions: options.includeRebaseSuggestions !== false, + includeAutoRebaseStatus: options.includeAutoRebaseStatus !== false, + }); + } const durationMs = Date.now() - startedAt; if (durationMs >= 120) { args.logger.info("lanes.listSnapshots.summary", { diff --git a/apps/desktop/src/main/services/lanes/rebaseSuggestionService.ts b/apps/desktop/src/main/services/lanes/rebaseSuggestionService.ts index f63ab55b8..0fc607a03 100644 --- a/apps/desktop/src/main/services/lanes/rebaseSuggestionService.ts +++ b/apps/desktop/src/main/services/lanes/rebaseSuggestionService.ts @@ -6,6 +6,7 @@ import type { LaneSummary, RebaseSuggestion, RebaseSuggestionsEventPayload, Reba import { branchNameFromLaneRef, shouldLaneTrackParent } from "../../../shared/laneBaseResolution"; import { fetchQueueTargetTrackingBranches, fetchRemoteTrackingBranch, resolveQueueRebaseOverride } from "../shared/queueRebase"; import { isRecord, nowIso } from "../shared/utils"; +import { serializeLaneCacheKeyFields } from "./laneCacheKey"; type StoredSuggestionState = { laneId: string; @@ -20,6 +21,7 @@ type StoredSuggestionState = { const KEY_PREFIX = "rebase:suggestion:"; const SUGGESTION_CACHE_TTL_MS = 10_000; const SUGGESTION_SCAN_CONCURRENCY = 4; +const SUGGESTION_CACHE_MAX_ENTRIES = 8; type ListSuggestionsOptions = { force?: boolean; @@ -27,6 +29,13 @@ type ListSuggestionsOptions = { refreshRemoteTracking?: boolean; }; +function suggestionCacheKey(options: ListSuggestionsOptions): string { + if (!options.lanes) return "default"; + return JSON.stringify(options.lanes + .map(serializeLaneCacheKeyFields) + .sort((left, right) => left.id.localeCompare(right.id))); +} + function keyForLane(laneId: string): string { return `${KEY_PREFIX}${laneId}`; } @@ -109,12 +118,13 @@ export function createRebaseSuggestionService(args: { db.setJson(keyForLane(state.laneId), state); }; - let cachedSuggestions: { atMs: number; suggestions: RebaseSuggestion[] } | null = null; - let suggestionsInFlight: Promise | null = null; + const cachedSuggestions = new Map(); + const suggestionsInFlight = new Map>(); let suggestionsCacheGeneration = 0; const invalidateSuggestionsCache = () => { - cachedSuggestions = null; + cachedSuggestions.clear(); + suggestionsInFlight.clear(); suggestionsCacheGeneration += 1; }; @@ -433,33 +443,41 @@ export function createRebaseSuggestionService(args: { }; const listSuggestions = async (options: ListSuggestionsOptions = {}): Promise => { - // Only share the global cache and in-flight promise for default (no - // request-specific options) requests. Caller-supplied lane subsets and - // refreshRemoteTracking each compute different results, so they must not - // read or populate the shared default-result cache. - const useSharedCache = !options.force && !options.lanes && options.refreshRemoteTracking !== true; + // Snapshot callers provide the lanes they already loaded. Cache those + // bounded scans too; otherwise every invalidation starts the same slow git + // probes again while an earlier timed-out/deferred scan is still running. + const useSharedCache = !options.force && options.refreshRemoteTracking !== true; + const cacheKey = suggestionCacheKey(options); const nowMs = Date.now(); - if (useSharedCache && cachedSuggestions && nowMs - cachedSuggestions.atMs < SUGGESTION_CACHE_TTL_MS) { - return cachedSuggestions.suggestions; + const cached = cachedSuggestions.get(cacheKey); + if (useSharedCache && cached && nowMs - cached.atMs < SUGGESTION_CACHE_TTL_MS) { + return cached.suggestions; } - if (useSharedCache && suggestionsInFlight) { - return suggestionsInFlight; + const inFlight = suggestionsInFlight.get(cacheKey); + if (useSharedCache && inFlight) { + return inFlight; } const generation = suggestionsCacheGeneration; const work = computeSuggestions(options); if (useSharedCache) { - suggestionsInFlight = work; + suggestionsInFlight.set(cacheKey, work); } try { const suggestions = await work; if (useSharedCache && generation === suggestionsCacheGeneration) { - cachedSuggestions = { atMs: Date.now(), suggestions }; + cachedSuggestions.delete(cacheKey); + cachedSuggestions.set(cacheKey, { atMs: Date.now(), suggestions }); + while (cachedSuggestions.size > SUGGESTION_CACHE_MAX_ENTRIES) { + const oldestKey = cachedSuggestions.keys().next().value as string | undefined; + if (!oldestKey) break; + cachedSuggestions.delete(oldestKey); + } } return suggestions; } finally { - if (suggestionsInFlight === work) { - suggestionsInFlight = null; + if (suggestionsInFlight.get(cacheKey) === work) { + suggestionsInFlight.delete(cacheKey); } } }; diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts index 5ff0775be..e66b01917 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts @@ -27,6 +27,11 @@ import { parseRuntimeServiceManagerOutput, shouldAutoInstallRuntimeServiceFromPath, } from "./localRuntimeConnectionPool"; +import { + LOCAL_RUNTIME_ACTION_REGISTRY_TIMEOUT_MS, + LOCAL_RUNTIME_EVENT_POLL_TIMEOUT_MS, + LOCAL_RUNTIME_SYNC_TIMEOUT_MS, +} from "./localRuntimeTimeoutPolicy"; type RawPendingRequest = { resolve: (value: unknown) => void; @@ -860,11 +865,15 @@ describe("local runtime connection pool", () => { const registry = await pool.listActionRegistryForRoot("/repo"); - expect(client.call).toHaveBeenCalledWith("ade/actions/call", { - projectId: "project-1", - name: "list_ade_actions", - arguments: { domain: "all" }, - }); + expect(client.call).toHaveBeenCalledWith( + "ade/actions/call", + { + projectId: "project-1", + name: "list_ade_actions", + arguments: { domain: "all" }, + }, + { timeoutMs: LOCAL_RUNTIME_ACTION_REGISTRY_TIMEOUT_MS }, + ); expect(registry).toEqual([ { domain: "chat", actions: [{ name: "create" }] }, { @@ -954,6 +963,494 @@ describe("local runtime connection pool", () => { expect(call).toHaveBeenCalledTimes(2); }); + it("single-flights initial project registration without dropping or reordering PTY writes", async () => { + let resolveRegistration!: (value: unknown) => void; + const registration = new Promise((resolve) => { + resolveRegistration = resolve; + }); + const rootPath = path.resolve("/repo"); + const project = { + projectId: "project-1", + rootPath, + displayName: "repo", + addedAt: 1, + lastOpenedAt: 1, + gitOriginUrl: null, + }; + const deliveredInput: string[] = []; + const call = vi.fn((method: string, params?: unknown) => { + if (method === "projects.add") return registration; + if (method === "ade/actions/call") { + const data = (params as { + arguments?: { args?: { data?: unknown } }; + }).arguments?.args?.data; + if (typeof data !== "string") throw new Error("PTY write data is missing."); + deliveredInput.push(data); + return Promise.resolve({ + domain: "pty", + action: "write", + result: null, + statusHints: {}, + }); + } + return Promise.reject(new Error(`Unexpected method ${method}`)); + }); + const pool = new LocalRuntimeConnectionPool("1.2.3", { + debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), + } as never); + (pool as unknown as { connection: Promise }).connection = Promise.resolve({ + client: { call, isClosed: vi.fn(() => false) }, + child: null, + socketPath: "/tmp/ade.sock", + }); + const inputChunks = ["a", "\x1b", "\r"]; + const rootPaths = [rootPath, `${rootPath}/.`, `${rootPath}/nested/..`]; + + const writes = inputChunks.map((data, index) => pool.callActionForRoot(rootPaths[index]!, { + domain: "pty", + action: "write", + args: { ptyId: "pty-1", data }, + })); + await new Promise((resolve) => setImmediate(resolve)); + + expect(call.mock.calls.filter(([method]) => method === "projects.add")).toHaveLength(1); + expect(call.mock.calls.filter(([method]) => method === "ade/actions/call")).toHaveLength(0); + expect(deliveredInput).toEqual([]); + + resolveRegistration(project); + await expect(Promise.all(writes)).resolves.toHaveLength(inputChunks.length); + + expect(call.mock.calls.filter(([method]) => method === "projects.add")).toHaveLength(1); + expect(call.mock.calls.filter(([method]) => method === "ade/actions/call")).toHaveLength(inputChunks.length); + expect(deliveredInput).toEqual(inputChunks); + expect(deliveredInput.join("")).toBe(inputChunks.join("")); + }); + + it("lets foreground recent registration satisfy PTY routing after a project switch", async () => { + let resolveRegistration!: (value: unknown) => void; + const registration = new Promise((resolve) => { + resolveRegistration = resolve; + }); + const rootPath = path.resolve("/repo"); + const project = { + projectId: "project-1", + rootPath, + displayName: "repo", + addedAt: 1, + lastOpenedAt: 1, + gitOriginUrl: null, + catalogVisibility: "recent", + registrationSource: "desktop", + } as const; + const deliveredInput: string[] = []; + const call = vi.fn((method: string, params?: unknown) => { + if (method === "projects.add") return registration; + if (method === "ade/actions/call") { + const data = (params as { + arguments?: { args?: { data?: unknown } }; + }).arguments?.args?.data; + if (typeof data !== "string") throw new Error("PTY write data is missing."); + deliveredInput.push(data); + return Promise.resolve({ + domain: "pty", + action: "write", + result: null, + statusHints: {}, + }); + } + return Promise.reject(new Error(`Unexpected method ${method}`)); + }); + const pool = new LocalRuntimeConnectionPool("1.2.3", { + debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), + } as never); + (pool as unknown as { connection: Promise }).connection = Promise.resolve({ + client: { call, isClosed: vi.fn(() => false) }, + child: null, + socketPath: "/tmp/ade.sock", + }); + + // Foregrounding mirrors the desktop-recent intent. Terminal input can + // arrive immediately afterward with the internal system/runtime-auto + // intent used by action routing. + const foreground = pool.ensureProject(rootPath, { + catalogVisibility: "recent", + registrationSource: "desktop", + }); + const writes = ["a", "b", "\r"].map((data) => pool.callActionForRoot(rootPath, { + domain: "pty", + action: "write", + args: { ptyId: "pty-1", data }, + })); + await new Promise((resolve) => setImmediate(resolve)); + + expect(call.mock.calls.filter(([method]) => method === "projects.add")).toHaveLength(1); + expect(call.mock.calls.filter(([method]) => method === "ade/actions/call")).toHaveLength(0); + + resolveRegistration(project); + await expect(Promise.all([foreground, ...writes])).resolves.toHaveLength(4); + + // The authoritative foreground record now satisfies background routing; + // no second projects.add is inserted before the individual PTY writes. + expect(call.mock.calls.filter(([method]) => method === "projects.add")).toHaveLength(1); + expect(call.mock.calls.filter(([method]) => method === "ade/actions/call")).toHaveLength(3); + expect(deliveredInput).toEqual(["a", "b", "\r"]); + }); + + it("preserves registration intent order after a conflicting waiter closes the active flight", async () => { + const rootPath = path.resolve("/repo"); + const project = { + projectId: "project-1", + rootPath, + displayName: "repo", + addedAt: 1, + lastOpenedAt: 1, + gitOriginUrl: null, + }; + const registrations: Array<{ + params: Record; + resolve: (value: unknown) => void; + }> = []; + const call = vi.fn((method: string, params?: unknown) => { + if (method !== "projects.add") { + return Promise.reject(new Error(`Unexpected method ${method}`)); + } + return new Promise((resolve) => { + registrations.push({ params: params as Record, resolve }); + }); + }); + const pool = new LocalRuntimeConnectionPool("1.2.3", { + debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), + } as never); + (pool as unknown as { connection: Promise }).connection = Promise.resolve({ + client: { call, isClosed: vi.fn(() => false) }, + child: null, + socketPath: "/tmp/ade.sock", + }); + const desktopIntent = { + catalogVisibility: "recent" as const, + registrationSource: "desktop" as const, + }; + const mobileIntent = { + catalogVisibility: "recent" as const, + registrationSource: "mobile" as const, + }; + + const firstDesktop = pool.ensureProject(rootPath, desktopIntent); + while (registrations.length < 1) await Promise.resolve(); + const mobile = pool.ensureProject(rootPath, mobileIntent); + const secondDesktop = pool.ensureProject(rootPath, desktopIntent); + await new Promise((resolve) => setImmediate(resolve)); + + expect(registrations).toHaveLength(1); + expect(registrations[0]!.params.registrationSource).toBe("desktop"); + + registrations[0]!.resolve({ ...project, ...desktopIntent }); + while (registrations.length < 2) await Promise.resolve(); + expect(registrations.map(({ params }) => params.registrationSource)) + .toEqual(["desktop", "mobile"]); + + registrations[1]!.resolve({ ...project, ...mobileIntent }); + while (registrations.length < 3) await Promise.resolve(); + expect(registrations.map(({ params }) => params.registrationSource)) + .toEqual(["desktop", "mobile", "desktop"]); + + registrations[2]!.resolve({ ...project, ...desktopIntent }); + await expect(Promise.all([firstDesktop, mobile, secondDesktop])).resolves.toHaveLength(3); + expect(call.mock.calls.filter(([method]) => method === "projects.add")).toHaveLength(3); + }); + + it("clears a failed project registration single-flight so the next action retries", async () => { + let rejectRegistration!: (error: Error) => void; + const firstRegistration = new Promise((_resolve, reject) => { + rejectRegistration = reject; + }); + const rootPath = path.resolve("/repo"); + const project = { + projectId: "project-1", + rootPath, + displayName: "repo", + addedAt: 1, + lastOpenedAt: 1, + gitOriginUrl: null, + }; + let registrationAttempts = 0; + const deliveredInput: string[] = []; + const call = vi.fn((method: string, params?: unknown) => { + if (method === "projects.add") { + registrationAttempts += 1; + return registrationAttempts === 1 ? firstRegistration : Promise.resolve(project); + } + if (method === "ade/actions/call") { + const data = (params as { + arguments?: { args?: { data?: unknown } }; + }).arguments?.args?.data; + if (typeof data !== "string") throw new Error("PTY write data is missing."); + deliveredInput.push(data); + return Promise.resolve({ + domain: "pty", + action: "write", + result: null, + statusHints: {}, + }); + } + return Promise.reject(new Error(`Unexpected method ${method}`)); + }); + const pool = new LocalRuntimeConnectionPool("1.2.3", { + debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), + } as never); + (pool as unknown as { connection: Promise }).connection = Promise.resolve({ + client: { call, isClosed: vi.fn(() => false) }, + child: null, + socketPath: "/tmp/ade.sock", + }); + + const first = pool.callActionForRoot(rootPath, { + domain: "pty", + action: "write", + args: { ptyId: "pty-1", data: "first" }, + }); + const shared = pool.callActionForRoot(`${rootPath}/.`, { + domain: "pty", + action: "write", + args: { ptyId: "pty-1", data: "shared" }, + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(registrationAttempts).toBe(1); + + rejectRegistration(new Error("project registration failed")); + const failures = await Promise.allSettled([first, shared]); + expect(failures.map((result) => result.status)).toEqual(["rejected", "rejected"]); + expect(failures.map((result) => result.status === "rejected" ? result.reason.message : null)) + .toEqual(["project registration failed", "project registration failed"]); + expect(deliveredInput).toEqual([]); + + await expect(pool.callActionForRoot(rootPath, { + domain: "pty", + action: "write", + args: { ptyId: "pty-1", data: "retry" }, + })).resolves.toMatchObject({ domain: "pty", action: "write", result: null }); + + expect(registrationAttempts).toBe(2); + expect(call.mock.calls.filter(([method]) => method === "ade/actions/call")).toHaveLength(1); + expect(deliveredInput).toEqual(["retry"]); + }); + + it("keeps project registration single-flighted while retrying a dropped connection", async () => { + const dropped = new Error("Remote ADE service connection closed."); + let resolveRetry!: (value: unknown) => void; + const retryRegistration = new Promise((resolve) => { + resolveRetry = resolve; + }); + const rootPath = path.resolve("/repo"); + const project = { + projectId: "project-1", + rootPath, + displayName: "repo", + addedAt: 1, + lastOpenedAt: 1, + gitOriginUrl: null, + }; + const deliveredInput: string[] = []; + const firstClient = { + call: vi.fn().mockRejectedValue(dropped), + close: vi.fn(), + isClosed: vi.fn(() => false), + }; + const secondClient = { + call: vi.fn((method: string, params?: unknown) => { + if (method === "projects.add") return retryRegistration; + if (method === "ade/actions/call") { + const data = (params as { + arguments?: { args?: { data?: unknown } }; + }).arguments?.args?.data; + if (typeof data !== "string") throw new Error("PTY write data is missing."); + deliveredInput.push(data); + return Promise.resolve({ domain: "pty", action: "write", result: null, statusHints: {} }); + } + return Promise.reject(new Error(`Unexpected method ${method}`)); + }), + close: vi.fn(), + isClosed: vi.fn(() => false), + }; + const createConnection = vi.fn<[], Promise>() + .mockResolvedValueOnce({ client: firstClient, child: null, socketPath: "/tmp/ade-stale.sock" }) + .mockResolvedValueOnce({ client: secondClient, child: null, socketPath: "/tmp/ade-fresh.sock" }); + const pool = new LocalRuntimeConnectionPool("1.2.3", { + debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), + } as never); + (pool as unknown as { createConnection: () => Promise }).createConnection = createConnection; + + const write = (data: string) => pool.callActionForRoot(rootPath, { + domain: "pty", + action: "write", + args: { ptyId: "pty-1", data }, + }); + const first = write("first"); + while (!secondClient.call.mock.calls.some(([method]) => method === "projects.add")) { + await Promise.resolve(); + } + const writes = [first, write("second"), write("third")]; + await new Promise((resolve) => setImmediate(resolve)); + + expect(createConnection).toHaveBeenCalledTimes(2); + expect(firstClient.call).toHaveBeenCalledTimes(1); + expect(secondClient.call.mock.calls.filter(([method]) => method === "projects.add")).toHaveLength(1); + + resolveRetry(project); + await expect(Promise.all(writes)).resolves.toHaveLength(3); + + expect(firstClient.close).toHaveBeenCalledTimes(1); + expect(secondClient.call.mock.calls.filter(([method]) => method === "projects.add")).toHaveLength(1); + expect(secondClient.call.mock.calls.filter(([method]) => method === "ade/actions/call")).toHaveLength(3); + expect(deliveredInput).toEqual(["first", "second", "third"]); + }); + + it("makes disposal terminal while project registration is in flight", async () => { + let resolveRegistration!: (value: unknown) => void; + const registration = new Promise((resolve) => { + resolveRegistration = resolve; + }); + const rootPath = path.resolve("/repo"); + const project = { + projectId: "project-1", + rootPath, + displayName: "repo", + addedAt: 1, + lastOpenedAt: 1, + gitOriginUrl: null, + }; + const call = vi.fn((method: string) => { + if (method === "projects.add") return registration; + if (method === "ade/actions/call") { + return Promise.resolve({ domain: "pty", action: "write", result: null, statusHints: {} }); + } + return Promise.reject(new Error(`Unexpected method ${method}`)); + }); + const client = { + call, + close: vi.fn(), + isClosed: vi.fn(() => false), + }; + const createConnection = vi.fn(async () => ({ + client, + child: null, + socketPath: "/tmp/ade.sock", + })); + const pool = new LocalRuntimeConnectionPool("1.2.3", { + debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), + } as never); + (pool as unknown as { createConnection: () => Promise }).createConnection = createConnection; + + const pendingWrite = pool.callActionForRoot(rootPath, { + domain: "pty", + action: "write", + args: { ptyId: "pty-1", data: "blocked" }, + }); + while (!call.mock.calls.some(([method]) => method === "projects.add")) { + await Promise.resolve(); + } + + pool.dispose(); + resolveRegistration(project); + + await expect(pendingWrite).rejects.toThrow("Local runtime connection pool is disposed."); + await expect(pool.ensureProject(rootPath)).rejects.toThrow( + "Local runtime connection pool is disposed.", + ); + expect(createConnection).toHaveBeenCalledTimes(1); + expect(call.mock.calls.filter(([method]) => method === "projects.add")).toHaveLength(1); + expect(call.mock.calls.filter(([method]) => method === "ade/actions/call")).toHaveLength(0); + expect((pool as unknown as { projectsByRoot: Map }).projectsByRoot).toHaveLength(0); + expect( + (pool as unknown as { projectRegistrationsByRoot: Map }).projectRegistrationsByRoot, + ).toHaveLength(0); + expect(client.close).toHaveBeenCalledTimes(1); + }); + + it("single-flights an exact lane delete and keeps its client timeout above daemon work", async () => { + let resolveCall!: (value: unknown) => void; + const call = vi.fn(() => new Promise((resolve) => { + resolveCall = resolve; + })); + const pool = new LocalRuntimeConnectionPool("1.2.3", { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as never); + const rootPath = path.resolve("/repo"); + (pool as unknown as { projectsByRoot: Map }).projectsByRoot.set(rootPath, { + projectId: "project-1", + rootPath, + displayName: "repo", + addedAt: 1, + lastOpenedAt: 1, + gitOriginUrl: null, + }); + (pool as unknown as { connection: Promise }).connection = Promise.resolve({ + client: { call, isClosed: vi.fn(() => false) }, + child: null, + socketPath: "/tmp/ade.sock", + }); + const request = { + domain: "lane", + action: "delete", + args: { laneId: "lane-1", force: true, deleteRemoteBranch: true }, + }; + + const first = pool.callActionForRoot(rootPath, request); + const duplicate = pool.callActionForRoot(rootPath, { + ...request, + args: { deleteRemoteBranch: true, force: true, laneId: "lane-1" }, + }); + await new Promise((resolve) => setImmediate(resolve)); + + expect(call).toHaveBeenCalledTimes(1); + expect(call).toHaveBeenCalledWith( + "ade/actions/call", + expect.objectContaining({ + arguments: expect.objectContaining({ domain: "lane", action: "delete" }), + }), + { timeoutMs: 4 * 60_000 }, + ); + + resolveCall({ domain: "lane", action: "delete", result: null, statusHints: {} }); + await expect(Promise.all([first, duplicate])).resolves.toHaveLength(2); + expect(call).toHaveBeenCalledTimes(1); + }); + + it("extends archive mutations while preserving a single delivery attempt", async () => { + const call = vi.fn().mockResolvedValue({ + domain: "lane", + action: "archive", + result: null, + statusHints: {}, + }); + const pool = new LocalRuntimeConnectionPool("1.2.3", { + debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), + } as never); + const rootPath = path.resolve("/repo"); + (pool as unknown as { projectsByRoot: Map }).projectsByRoot.set(rootPath, { + projectId: "project-1", rootPath, displayName: "repo", addedAt: 1, lastOpenedAt: 1, gitOriginUrl: null, + }); + (pool as unknown as { connection: Promise }).connection = Promise.resolve({ + client: { call, isClosed: vi.fn(() => false) }, child: null, socketPath: "/tmp/ade.sock", + }); + + await pool.callActionForRoot(rootPath, { + domain: "lane", + action: "archive", + args: { laneId: "lane-1" }, + }); + + expect(call).toHaveBeenCalledTimes(1); + expect(call).toHaveBeenCalledWith( + "ade/actions/call", + expect.anything(), + { timeoutMs: 120_000 }, + ); + }); + it("retries project registration when the cached runtime connection drops before a read action", async () => { const dropped = new Error("Remote ADE service connection closed."); const logger = { @@ -1996,7 +2493,7 @@ describe("local runtime connection pool", () => { category: "pty", }, }, - { timeoutMs: 2_000 }, + { timeoutMs: LOCAL_RUNTIME_EVENT_POLL_TIMEOUT_MS }, ); expect(result).toEqual({ events: [ @@ -2446,10 +2943,14 @@ describe("local runtime connection pool", () => { connectedPeers: [], }); - expect(call).toHaveBeenCalledWith("sync.getStatus", { - projectId: "project-1", - includeTransferReadiness: true, - }); + expect(call).toHaveBeenCalledWith( + "sync.getStatus", + { + projectId: "project-1", + includeTransferReadiness: true, + }, + { timeoutMs: LOCAL_RUNTIME_SYNC_TIMEOUT_MS }, + ); }); it("routes machine sync calls without adding a project id", async () => { @@ -2481,7 +2982,7 @@ describe("local runtime connection pool", () => { }); }); - it("registers foreground intent and demotes a forgotten project without switching the mobile sync host", async () => { + it("keeps foreground catalog metadata authoritative while routing background actions", async () => { const rootPath = path.resolve("/repo"); const project = { projectId: "project-1", @@ -2517,7 +3018,7 @@ describe("local runtime connection pool", () => { await pool.setProjectCatalogVisibility(rootPath, "system", "desktop"); await pool.ensureProject(rootPath); - expect(call).toHaveBeenCalledTimes(3); + expect(call).toHaveBeenCalledTimes(2); expect(call).toHaveBeenNthCalledWith( 1, "projects.add", @@ -2534,11 +3035,10 @@ describe("local runtime connection pool", () => { }, { timeoutMs: expect.any(Number) }, ); - expect(call).toHaveBeenNthCalledWith( - 3, + expect(call).not.toHaveBeenCalledWith( "projects.add", { rootPath, catalogVisibility: "system", registrationSource: "runtime-auto" }, - { timeoutMs: expect.any(Number) }, + expect.anything(), ); expect(call).not.toHaveBeenCalledWith("sync.switchHost", expect.anything()); }); @@ -2610,12 +3110,16 @@ describe("local runtime connection pool", () => { category: "runtime", }, onEvent); - expect(call).toHaveBeenCalledWith("runtimeEvents.subscribe", { - projectId: "project-1", - cursor: 20, - limit: 5, - category: "runtime", - }); + expect(call).toHaveBeenCalledWith( + "runtimeEvents.subscribe", + { + projectId: "project-1", + cursor: 20, + limit: 5, + category: "runtime", + }, + { timeoutMs: LOCAL_RUNTIME_EVENT_POLL_TIMEOUT_MS }, + ); expect(onEvent).toHaveBeenCalledWith({ id: 21, timestamp: "2026-05-10T12:00:00.000Z", diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts index 836c4e8bd..842c3c15d 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts @@ -26,9 +26,10 @@ import type { SyncRoleSnapshot, } from "../../../shared/types"; import { resolveMachineAdeLayout } from "../../../../../ade-cli/src/services/projects/machineLayout"; -import type { - ProjectRegistrationIntent, - ProjectRegistrationSource, +import { + SYSTEM_PROJECT_REGISTRATION, + type ProjectRegistrationIntent, + type ProjectRegistrationSource, } from "../../../../../ade-cli/src/services/projects/projectRegistry"; import { RuntimeRpcClient, type RuntimeRpcTransport } from "../remoteRuntime/runtimeRpcClient"; import { coerceProjects } from "../remoteRuntime/remoteBootstrap"; @@ -39,6 +40,13 @@ import { readLastFailure } from "../runtime/lastFailureStore"; import type { AdeRecoveryErrorCode } from "../../../shared/types/recovery"; import { LOCAL_RELEASE_BUILD_OUTPUT_RUNTIME_MESSAGE } from "../../../shared/runtimeErrors"; import type { RuntimeHealthSnapshot } from "../../../shared/types/storage"; +import { + LOCAL_RUNTIME_ACTION_REGISTRY_TIMEOUT_MS, + LOCAL_RUNTIME_EVENT_POLL_TIMEOUT_MS, + LOCAL_RUNTIME_PROJECT_TIMEOUT_MS, + LOCAL_RUNTIME_SYNC_TIMEOUT_MS, + localRuntimeActionTimeoutMs, +} from "./localRuntimeTimeoutPolicy"; const SLOW_ACTION_THRESHOLD_MS = 500; const RUNTIME_HEALTH_WINDOW_MS = 24 * 60 * 60_000; @@ -81,25 +89,18 @@ type LocalRuntimeConnectionPoolOptions = { type LocalRuntimeNodePathOptions = PackagedRuntimeNodePathOptions; -const LOCAL_RUNTIME_PROJECT_TIMEOUT_MS = 120_000; -const LOCAL_RUNTIME_ACTION_TIMEOUT_MS = 30_000; const LOCAL_RUNTIME_SERVICE_UNINSTALL_TIMEOUT_MS = 20_000; -const LOCAL_RUNTIME_FILE_ACTION_TIMEOUT_MS = 8_000; -const LOCAL_RUNTIME_EVENT_POLL_TIMEOUT_MS = 2_000; -const LONG_RUNNING_LOCAL_RUNTIME_ACTION_TIMEOUTS: ReadonlyMap = new Map([ - ["chat.suggestLaneNameFromPrompt", 120_000], - // Handoff = AI brief generation (bounded at 45s) + session creation + - // provider dispatch of the first message; the 30s default fired a false - // timeout while the daemon-side handoff kept running to a late "surprise" - // success (ADE-122). - ["chat.handoffSession", 120_000], - ["chat.prepareCrossMachineHandoff", 120_000], -]); const PLACEHOLDER_RUNTIME_VERSION = "0.0.0"; const LOCAL_RUNTIME_OUTPUT_LINE_MAX_CHARS = 4_000; const LOCAL_RUNTIME_OUTPUT_BUFFER_MAX_CHARS = 16_000; const COALESCED_LOCAL_RUNTIME_ACTIONS = new Set([ "chat.listSessions", + // Exact duplicate destructive requests share one in-flight result. This is + // not a retry: mutations still have maxAttempts=1, and different arguments + // or sequential invocations remain independent. + "lane.archive", + "lane.delete", + "lane.unarchive", "layout.get", "project_config.get", "pty.resize", @@ -188,6 +189,34 @@ function coalescedLocalRuntimeActionKey( }); } +function isSameProjectRegistrationIntent( + left: ProjectRegistrationIntent, + right: ProjectRegistrationIntent, +): boolean { + return left.catalogVisibility === right.catalogVisibility + && left.registrationSource === right.registrationSource; +} + +function cachedProjectSatisfiesRegistration( + project: RemoteRuntimeProjectRecord, + registration: ProjectRegistrationIntent, +): boolean { + // The default runtime-auto registration is only an internal lookup: callers + // need a projectId so they can route an action. Any cached registration for + // the same normalized root already satisfies that requirement. In + // particular, do not overwrite a foreground recent/desktop registration + // with system/runtime-auto on every action — that metadata ping-pong forced a + // fresh projects.add in front of PTY writes after project switches. + if ( + isSameProjectRegistrationIntent(registration, SYSTEM_PROJECT_REGISTRATION) + ) { + return true; + } + + return project.catalogVisibility === registration.catalogVisibility + && project.registrationSource === registration.registrationSource; +} + export function buildLocalRuntimeServeArgs( cliPath: string, socketPath: string, @@ -665,6 +694,7 @@ function serviceHealthState( } export class LocalRuntimeConnectionPool { + private disposed = false; private connection: Promise | null = null; private activeConnection: LocalRuntimeConnection | null = null; private activeClient: RuntimeRpcClient | null = null; @@ -675,6 +705,11 @@ export class LocalRuntimeConnectionPool { private lastIsolatedServiceRepairMs = 0; private readonly coalescedActionCalls = new Map>(); private readonly projectsByRoot = new Map(); + private readonly projectRegistrationsByRoot = new Map; + }>(); private serviceInstallStatus: LocalRuntimeStatus["serviceInstall"] = { state: "not_attempted", attempted: false, @@ -1010,24 +1045,60 @@ export class LocalRuntimeConnectionPool { async ensureProject( rootPath: string, - registration: ProjectRegistrationIntent = { - catalogVisibility: "system", - registrationSource: "runtime-auto", - }, + registration: ProjectRegistrationIntent = SYSTEM_PROJECT_REGISTRATION, ): Promise { const normalizedRoot = path.resolve(rootPath); - const cached = this.projectsByRoot.get(normalizedRoot); - if ( - cached - && registration.catalogVisibility === "system" - && (cached.registrationSource ?? "runtime-auto") === registration.registrationSource - ) { - return cached; + while (true) { + this.assertNotDisposed(); + const pending = this.projectRegistrationsByRoot.get(normalizedRoot); + if (pending) { + if ( + pending.acceptsMatchingWaiters + && isSameProjectRegistrationIntent(pending.intent, registration) + ) { + return await pending.promise; + } + pending.acceptsMatchingWaiters = false; + await pending.promise.catch(() => undefined); + continue; + } + + const cached = this.projectsByRoot.get(normalizedRoot); + if (cached && cachedProjectSatisfiesRegistration(cached, registration)) { + return cached; + } + + const registrationPromise = this.registerProject(normalizedRoot, registration); + const nextPending = { + acceptsMatchingWaiters: true, + intent: { ...registration }, + promise: registrationPromise, + }; + this.projectRegistrationsByRoot.set(normalizedRoot, nextPending); + void registrationPromise.then( + () => { + if (this.projectRegistrationsByRoot.get(normalizedRoot) === nextPending) { + this.projectRegistrationsByRoot.delete(normalizedRoot); + } + }, + () => { + if (this.projectRegistrationsByRoot.get(normalizedRoot) === nextPending) { + this.projectRegistrationsByRoot.delete(normalizedRoot); + } + }, + ); + return await registrationPromise; } + } + private async registerProject( + normalizedRoot: string, + registration: ProjectRegistrationIntent, + ): Promise { let lastError: Error | null = null; for (let attempt = 1; attempt <= 2; attempt++) { const entry = await this.connect(); + this.assertNotDisposed(); if (entry.client.isClosed()) { const error = new Error("Remote ADE service connection closed."); this.logger.warn("local_runtime.ensure_project_connection_dropped", { @@ -1049,6 +1120,7 @@ export class LocalRuntimeConnectionPool { { rootPath: normalizedRoot, ...registration }, { timeoutMs: LOCAL_RUNTIME_PROJECT_TIMEOUT_MS }, ); + this.assertNotDisposed(); const record = coerceProjects([project])[0]; if (!record) throw new Error("Local ADE service did not return a project record."); this.projectsByRoot.set(normalizedRoot, record); @@ -1212,12 +1284,8 @@ export class LocalRuntimeConnectionPool { entry = await this.connect(); } const tConnect = Date.now(); - const actionKey = `${request.domain}.${request.action}`; const actionCallOptions = { - timeoutMs: LONG_RUNNING_LOCAL_RUNTIME_ACTION_TIMEOUTS.get(actionKey) - ?? (request.domain === "file" - ? LOCAL_RUNTIME_FILE_ACTION_TIMEOUT_MS - : LOCAL_RUNTIME_ACTION_TIMEOUT_MS), + timeoutMs: localRuntimeActionTimeoutMs(request.domain, request.action), }; let value: unknown = undefined; let callError: Error | null = null; @@ -1318,7 +1386,7 @@ export class LocalRuntimeConnectionPool { projectId: project.projectId, name: "list_ade_actions", arguments: { domain: "all" }, - }); + }, { timeoutMs: LOCAL_RUNTIME_ACTION_REGISTRY_TIMEOUT_MS }); return normalizeAdeActionRegistry(value); } @@ -1399,10 +1467,12 @@ export class LocalRuntimeConnectionPool { return await entry.client.call(method, { ...params, projectId: project.projectId, - }) as T; + }, { timeoutMs: LOCAL_RUNTIME_SYNC_TIMEOUT_MS }) as T; } dispose(): void { + if (this.disposed) return; + this.disposed = true; this.clearIsolatedRecoveryTimer(); this.markIsolatedMode(false, { notify: false }); const pending = this.connection; @@ -1412,6 +1482,7 @@ export class LocalRuntimeConnectionPool { this.activeRuntimePid = null; this.ownedRuntimeChild = null; this.projectsByRoot.clear(); + this.projectRegistrationsByRoot.clear(); void pending?.then((entry) => { try { entry.client.close(); } catch {} disposeOwnedRuntimeChild(entry.child, entry.socketPath); @@ -1419,7 +1490,12 @@ export class LocalRuntimeConnectionPool { } private async connect(): Promise { - if (this.connection) return this.connection; + this.assertNotDisposed(); + if (this.connection) { + const entry = await this.connection; + this.assertNotDisposed(); + return entry; + } const connection = this.createConnection().then((entry) => { if (this.connection === connection) { this.activeConnection = entry; @@ -1435,7 +1511,15 @@ export class LocalRuntimeConnectionPool { throw error; }); this.connection = connection; - return connection; + const entry = await connection; + this.assertNotDisposed(); + return entry; + } + + private assertNotDisposed(): void { + if (this.disposed) { + throw new Error("Local runtime connection pool is disposed."); + } } private isCurrentConnection(entry: LocalRuntimeConnection): boolean { @@ -2126,7 +2210,7 @@ async function subscribeToRuntimeEvents( limit: clampLimit(request.limit), ...(isRemoteRuntimeEventCategory(request.category) ? { category: request.category } : {}), ...(typeof request.replay === "boolean" ? { replay: request.replay } : {}), - }); + }, { timeoutMs: LOCAL_RUNTIME_EVENT_POLL_TIMEOUT_MS }); subscriptionId = readSubscriptionId(value); onSubscribed?.(normalizeRuntimeEventsSubscribeResult(value, request.cursor)); for (const notification of pendingNotifications) { diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeTimeoutPolicy.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeTimeoutPolicy.ts new file mode 100644 index 000000000..996acd931 --- /dev/null +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeTimeoutPolicy.ts @@ -0,0 +1,75 @@ +export const LOCAL_RUNTIME_PROJECT_TIMEOUT_MS = 120_000; +export const LOCAL_RUNTIME_ACTION_TIMEOUT_MS = 30_000; +export const LOCAL_RUNTIME_FILE_ACTION_TIMEOUT_MS = 8_000; +export const LOCAL_RUNTIME_SYNC_TIMEOUT_MS = 30_000; +export const LOCAL_RUNTIME_ACTION_REGISTRY_TIMEOUT_MS = 30_000; +export const LOCAL_RUNTIME_EVENT_POLL_TIMEOUT_MS = 2_000; +export const LOCAL_RUNTIME_IPC_PROJECT_SETUP_MARGIN_MS = 30_000; +export const LOCAL_RUNTIME_IPC_COMPLETION_HEADROOM_MS = 15_000; +const LOCAL_RUNTIME_IPC_PROJECT_REGISTRATION_TIMEOUT_MS = + 2 * LOCAL_RUNTIME_PROJECT_TIMEOUT_MS; + +// Registration can legitimately consume two full attempts. Retain separate +// margin for runtime connection/socket startup around those projects.add calls. +export const LOCAL_RUNTIME_IPC_PROJECT_SETUP_TIMEOUT_MS = + LOCAL_RUNTIME_IPC_PROJECT_REGISTRATION_TIMEOUT_MS + + LOCAL_RUNTIME_IPC_PROJECT_SETUP_MARGIN_MS; +export const LOCAL_RUNTIME_IPC_PROJECT_COMPLETION_TIMEOUT_MS = + LOCAL_RUNTIME_IPC_PROJECT_SETUP_TIMEOUT_MS + + LOCAL_RUNTIME_IPC_COMPLETION_HEADROOM_MS; + +export function localRuntimeCallIpcTimeoutMs(innerTimeoutMs: number): number { + return LOCAL_RUNTIME_IPC_PROJECT_SETUP_TIMEOUT_MS + + innerTimeoutMs + + LOCAL_RUNTIME_IPC_COMPLETION_HEADROOM_MS; +} + +export const LOCAL_RUNTIME_IPC_SYNC_TIMEOUT_MS = + localRuntimeCallIpcTimeoutMs(LOCAL_RUNTIME_SYNC_TIMEOUT_MS); +export const LOCAL_RUNTIME_IPC_ACTION_REGISTRY_TIMEOUT_MS = + localRuntimeCallIpcTimeoutMs(LOCAL_RUNTIME_ACTION_REGISTRY_TIMEOUT_MS); +export const LOCAL_RUNTIME_IPC_EVENT_POLL_TIMEOUT_MS = + localRuntimeCallIpcTimeoutMs(LOCAL_RUNTIME_EVENT_POLL_TIMEOUT_MS); + +const LONG_RUNNING_LOCAL_RUNTIME_ACTION_TIMEOUTS: ReadonlyMap = new Map([ + // Lane deletion can legitimately include a 60s worktree removal followed by + // a 45s remote-branch deletion. The old 30s client budget reported failure + // while the daemon kept mutating state to a successful completion. + ["lane.delete", 4 * 60_000], + ["lane.archive", 120_000], + ["lane.unarchive", 120_000], + ["chat.suggestLaneNameFromPrompt", 120_000], + // Handoff = AI brief generation (bounded at 45s) + session creation + + // provider dispatch of the first message; the 30s default fired a false + // timeout while the daemon-side handoff kept running to a late "surprise" + // success (ADE-122). + ["chat.handoffSession", 120_000], + ["chat.prepareCrossMachineHandoff", 120_000], +]); + +export function longRunningLocalRuntimeActionTimeoutMs( + actionKey: string, +): number | null { + return LONG_RUNNING_LOCAL_RUNTIME_ACTION_TIMEOUTS.get(actionKey) ?? null; +} + +export function localRuntimeActionTimeoutMs( + domain: string, + action: string, +): number { + const actionKey = `${domain}.${action}`; + return longRunningLocalRuntimeActionTimeoutMs(actionKey) + ?? (domain === "file" + ? LOCAL_RUNTIME_FILE_ACTION_TIMEOUT_MS + : LOCAL_RUNTIME_ACTION_TIMEOUT_MS); +} + +// The renderer-side IPC timer starts before cold project setup, while the +// daemon action timer starts afterwards. Compose the actual daemon budget for +// every action with setup margin and result-delivery headroom. +export function localRuntimeActionIpcTimeoutMs( + domain: string, + action: string, +): number { + return localRuntimeCallIpcTimeoutMs(localRuntimeActionTimeoutMs(domain, action)); +} diff --git a/apps/desktop/src/main/services/projects/projectIconResolver.test.ts b/apps/desktop/src/main/services/projects/projectIconResolver.test.ts index ecb1d8267..0daf48896 100644 --- a/apps/desktop/src/main/services/projects/projectIconResolver.test.ts +++ b/apps/desktop/src/main/services/projects/projectIconResolver.test.ts @@ -10,7 +10,10 @@ import { setProjectIconOverride, setProjectIconOverrideFromSelection, } from "./projectIconResolver"; -import { resolveMobileProjectIconDataUrl } from "./projectIconThumbnail"; +import { + PROJECT_ICON_THUMBNAIL_MAX_DATA_URL_BYTES, + resolveMobileProjectIconDataUrl, +} from "./projectIconThumbnail"; const OVER_ICON_LIMIT_BYTES = 10 * 1024 * 1024 + 1; const PNG_DATA = Buffer.from( @@ -185,6 +188,21 @@ describe("projectIconResolver", () => { expect(icon.dataUrl).toMatch(/^data:image\/svg\+xml;base64,/); }); + it("reuses positive icon-path discovery until its source signature changes", () => { + const root = makeProjectRoot(); + const iconPath = writeFile(root, "icon.png", PNG_DATA); + expect(resolveProjectIconPath(root)).toBe(iconPath); + + const readdirSpy = vi.spyOn(fs, "readdirSync"); + expect(resolveProjectIconPath(root)).toBe(iconPath); + expect(readdirSpy).not.toHaveBeenCalled(); + + fs.appendFileSync(iconPath, Buffer.from([0])); + expect(resolveProjectIconPath(root)).toBe(iconPath); + expect(readdirSpy).toHaveBeenCalled(); + readdirSpy.mockRestore(); + }); + it("uses an Electron nativeImage thumbnail for mobile when one can be decoded", () => { const root = makeProjectRoot(); writeFile(root, "icon.png", PNG_DATA); @@ -246,6 +264,25 @@ describe("projectIconResolver", () => { expect(dataUrl).toBe(`data:image/png;base64,${PNG_DATA.toString("base64")}`); }); + it("drops a thumbnail that exceeds the sync payload cap", () => { + const root = makeProjectRoot(); + writeFile(root, "icon.png", PNG_DATA); + + const dataUrl = resolveMobileProjectIconDataUrl(root, { + nativeImage: { + createFromPath: () => ({ + isEmpty: () => false, + resize: () => ({ + toDataURL: () => + `data:image/png;base64,${"a".repeat(PROJECT_ICON_THUMBNAIL_MAX_DATA_URL_BYTES)}`, + }), + }), + }, + }); + + expect(dataUrl).toBeNull(); + }); + it("keeps native and headless mobile thumbnail cache entries separate", () => { const root = makeProjectRoot(); writeFile( diff --git a/apps/desktop/src/main/services/projects/projectIconResolver.ts b/apps/desktop/src/main/services/projects/projectIconResolver.ts index 36fe4a0cb..55736ac3f 100644 --- a/apps/desktop/src/main/services/projects/projectIconResolver.ts +++ b/apps/desktop/src/main/services/projects/projectIconResolver.ts @@ -201,6 +201,19 @@ type ProjectIconResultCacheEntry = { const projectIconResultCache = new Map(); +type ProjectIconPathCacheEntry = { + rootMtimeMs: number; + appsMtimeMs: number; + packagesMtimeMs: number; + configMtimeMs: number; + sourceMtimeMs: number; + sourceSize: number; + expiresAtMs: number; + value: string; +}; + +const projectIconPathCache = new Map(); + function dirMtimeMs(absPath: string): number { try { return fs.statSync(absPath).mtimeMs; @@ -240,6 +253,16 @@ function setProjectIconResultCache(key: string, entry: ProjectIconResultCacheEnt projectIconResultCache.set(key, entry); } +function setProjectIconPathCache(key: string, entry: ProjectIconPathCacheEntry): void { + if (projectIconPathCache.has(key)) { + projectIconPathCache.delete(key); + } else if (projectIconPathCache.size >= PROJECT_ICON_RESULT_CACHE_MAX) { + const oldestKey = projectIconPathCache.keys().next().value; + if (oldestKey !== undefined) projectIconPathCache.delete(oldestKey); + } + projectIconPathCache.set(key, entry); +} + function clearProjectIconResultCache(projectRoot: string): void { const root = path.resolve(projectRoot); for (const key of projectIconResultCache.keys()) { @@ -247,6 +270,11 @@ function clearProjectIconResultCache(projectRoot: string): void { projectIconResultCache.delete(key); } } + for (const key of projectIconPathCache.keys()) { + if (key === root || key.startsWith(`${root}\0`)) { + projectIconPathCache.delete(key); + } + } } // Resolving a project icon scans the project root and every first-level child @@ -531,15 +559,53 @@ export function resolveProjectIconPath( options: { iconPathOverride?: string | null } = {}, ): string | null { const root = path.resolve(projectRoot); + const cacheKey = projectIconResultCacheKey(root, options); + const rootMtimeMs = dirMtimeMs(root); + const appsMtimeMs = dirMtimeMs(path.join(root, "apps")); + const packagesMtimeMs = dirMtimeMs(path.join(root, "packages")); + const configMtimeMs = dirMtimeMs(path.join(root, ".ade", "ade.yaml")); + const cached = projectIconPathCache.get(cacheKey); + if ( + cached + && cached.expiresAtMs > Date.now() + && cached.rootMtimeMs === rootMtimeMs + && cached.appsMtimeMs === appsMtimeMs + && cached.packagesMtimeMs === packagesMtimeMs + && cached.configMtimeMs === configMtimeMs + ) { + const sourceSignature = fileSignature(cached.value); + if ( + sourceSignature.mtimeMs === cached.sourceMtimeMs + && sourceSignature.size === cached.sourceSize + ) { + projectIconPathCache.delete(cacheKey); + projectIconPathCache.set(cacheKey, cached); + return cached.value; + } + } + const cacheValue = (value: string): string => { + const sourceSignature = fileSignature(value); + setProjectIconPathCache(cacheKey, { + rootMtimeMs, + appsMtimeMs, + packagesMtimeMs, + configMtimeMs, + sourceMtimeMs: sourceSignature.mtimeMs, + sourceSize: sourceSignature.size, + expiresAtMs: Date.now() + PROJECT_ICON_RESULT_CACHE_TTL_MS, + value, + }); + return value; + }; const configured = Object.prototype.hasOwnProperty.call(options, "iconPathOverride") ? options.iconPathOverride : readProjectIconOverride(root); if (configured === null) return null; const configuredMatch = resolveConfiguredProjectIconPath(root, configured); - if (configuredMatch) return configuredMatch; + if (configuredMatch) return cacheValue(configuredMatch); const directMatch = findBestDetectedIcon(root); - if (directMatch) return directMatch; + if (directMatch) return cacheValue(directMatch); for (const sourceFile of ICON_SOURCE_FILES) { // Resolve through the real filesystem so a symlinked source file (e.g. @@ -560,7 +626,9 @@ export function resolveProjectIconPath( const href = extractIconHref(source); if (!href || !isLocalIconHref(href)) continue; const existing = findExistingFile(root, resolveIconHref(root, href)); - if (existing && isSupportedIconPath(existing) && isInlineableIconFile(existing)) return existing; + if (existing && isSupportedIconPath(existing) && isInlineableIconFile(existing)) { + return cacheValue(existing); + } } return null; diff --git a/apps/desktop/src/main/services/projects/projectIconThumbnail.ts b/apps/desktop/src/main/services/projects/projectIconThumbnail.ts index 0b41bc808..030464bc9 100644 --- a/apps/desktop/src/main/services/projects/projectIconThumbnail.ts +++ b/apps/desktop/src/main/services/projects/projectIconThumbnail.ts @@ -3,11 +3,13 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { resolveProjectIcon } from "./projectIconResolver"; +import { resolveProjectIcon, resolveProjectIconPath } from "./projectIconResolver"; const MOBILE_PROJECT_ICON_EDGE = 64; const MOBILE_PROJECT_ICON_THUMBNAIL_CACHE_MAX = 64; const SIPS_PATH = "/usr/bin/sips"; +export const PROJECT_ICON_THUMBNAIL_MAX_DATA_URL_BYTES = 128 * 1024; +const IMAGE_DATA_URL_RE = /^data:image\/[a-z0-9.+-]+;base64,/i; type NativeImageInstanceLike = { isEmpty(): boolean; @@ -32,6 +34,7 @@ type ResolveMobileProjectIconDataUrlOptions = { nativeImage?: NativeImageModuleLike; rasterizeWithSips?: SipsRasterizer; tmpRoot?: string; + resolvedSourcePath?: string; }; const thumbnailCache = new Map(); @@ -77,10 +80,17 @@ function defaultSipsRasterizer(sourcePath: string, outputPath: string, edge: num outputPath, ], { stdio: "ignore", - timeout: 5_000, + timeout: 500, }); } +function boundedThumbnailDataUrl(value: string | null): string | null { + if (!value || !IMAGE_DATA_URL_RE.test(value)) return null; + return Buffer.byteLength(value, "utf8") <= PROJECT_ICON_THUMBNAIL_MAX_DATA_URL_BYTES + ? value + : null; +} + function nativeImagePngDataUrl( sourcePath: string, nativeImage: NativeImageModuleLike | undefined, @@ -128,16 +138,16 @@ export function resolveMobileProjectIconDataUrl( projectRoot: string, options: ResolveMobileProjectIconDataUrlOptions = {}, ): string | null { - let icon: ReturnType; + let sourcePath: string | null; try { - icon = resolveProjectIcon(projectRoot); + sourcePath = options.resolvedSourcePath ?? resolveProjectIconPath(projectRoot); } catch { return null; } - if (!icon.sourcePath) return null; + if (!sourcePath) return null; - const signature = fileSignature(icon.sourcePath); - const cacheKey = thumbnailCacheKey(icon.sourcePath, options); + const signature = fileSignature(sourcePath); + const cacheKey = thumbnailCacheKey(sourcePath, options); const cached = thumbnailCache.get(cacheKey); if ( cached @@ -149,14 +159,21 @@ export function resolveMobileProjectIconDataUrl( return cached.value; } - const value = - nativeImagePngDataUrl(icon.sourcePath, options.nativeImage) + const value = boundedThumbnailDataUrl( + nativeImagePngDataUrl(sourcePath, options.nativeImage) ?? sipsPngDataUrl( - icon.sourcePath, + sourcePath, options.rasterizeWithSips ?? defaultSipsRasterizer, options.tmpRoot ?? os.tmpdir(), ) - ?? (icon.mimeType === "image/png" ? icon.dataUrl : null); + // Only read/base64-encode the original after both thumbnail paths fail. + // The common native/sips paths therefore never retain a multi-megabyte + // source image merely to return its tiny thumbnail. + ?? (() => { + const icon = resolveProjectIcon(projectRoot); + return icon.mimeType === "image/png" ? icon.dataUrl : null; + })(), + ); setThumbnailCache(cacheKey, { ...signature, value }); return value; diff --git a/apps/desktop/src/main/services/projects/projectScaffoldService.test.ts b/apps/desktop/src/main/services/projects/projectScaffoldService.test.ts index 44197c48e..ed7e2129e 100644 --- a/apps/desktop/src/main/services/projects/projectScaffoldService.test.ts +++ b/apps/desktop/src/main/services/projects/projectScaffoldService.test.ts @@ -25,9 +25,11 @@ function makeGithubServiceStub(overrides: Partial<{ getTokenOrThrow: ReturnType; parseGitHubRepoFromRemoteUrl: ReturnType; }> = {}) { + const getTokenOrThrow = overrides.getTokenOrThrow ?? vi.fn(() => "ghp_fake_token_12345"); return { apiRequest: overrides.apiRequest ?? vi.fn(), - getTokenOrThrow: overrides.getTokenOrThrow ?? vi.fn(() => "ghp_fake_token_12345"), + getTokenOrThrow, + getTokenOrThrowAsync: vi.fn(async () => getTokenOrThrow()), parseGitHubRepoFromRemoteUrl: overrides.parseGitHubRepoFromRemoteUrl ?? vi.fn((url: string) => { diff --git a/apps/desktop/src/main/services/projects/projectScaffoldService.ts b/apps/desktop/src/main/services/projects/projectScaffoldService.ts index e1ada86b9..066128428 100644 --- a/apps/desktop/src/main/services/projects/projectScaffoldService.ts +++ b/apps/desktop/src/main/services/projects/projectScaffoldService.ts @@ -215,7 +215,7 @@ export function createProjectScaffoldService({ let authHeader = (input.githubAuthHeader ?? "").trim(); if (!authHeader) { try { - const storedToken = githubService.getTokenOrThrow(); + const storedToken = await githubService.getTokenOrThrowAsync(); const basic = Buffer.from(`x-access-token:${storedToken}`, "utf8").toString("base64"); authHeader = `basic ${basic}`; } catch { @@ -260,7 +260,7 @@ export function createProjectScaffoldService({ const listMyGitHubRepos = async (input: ListMyGitHubReposInput): Promise => { let token: string; try { - token = githubService.getTokenOrThrow(); + token = await githubService.getTokenOrThrowAsync(); } catch (err) { const wrapped = new Error("GitHub is not connected. Run gh auth login or add a PAT in Settings.") as Error & { code?: string }; wrapped.code = "github_not_connected"; diff --git a/apps/desktop/src/main/services/prs/prService.test.ts b/apps/desktop/src/main/services/prs/prService.test.ts index 5c86935f7..3d50f7559 100644 --- a/apps/desktop/src/main/services/prs/prService.test.ts +++ b/apps/desktop/src/main/services/prs/prService.test.ts @@ -211,6 +211,8 @@ function makeUnmappedBranchPull(overrides?: Partial>) { } function makeGithubService(overrides?: Record) { + const getTokenOrThrow = (overrides?.getTokenOrThrow as (() => string) | undefined) + ?? vi.fn(() => "ghp_mock"); return { getRepoOrThrow: vi.fn(async () => REPO), apiRequest: vi.fn(), @@ -218,7 +220,8 @@ function makeGithubService(overrides?: Record) { getStatus: vi.fn(), setToken: vi.fn(), clearToken: vi.fn(), - getTokenOrThrow: vi.fn(() => "ghp_mock"), + getTokenOrThrow, + getTokenOrThrowAsync: vi.fn(async () => getTokenOrThrow()), ...overrides, } as any; } diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index ffc8a62af..12a85488c 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -6192,19 +6192,18 @@ export function createPrService({ const runGh = async (ghArgs: string[], opts: { cwd: string; timeoutMs?: number }): Promise => { const timeoutMs = opts.timeoutMs ?? 90_000; + let ghToken: string | null = null; + try { + ghToken = await githubService.getTokenOrThrowAsync(); + } catch { + ghToken = null; + } return await new Promise((resolve) => { let stdout = ""; let stderr = ""; let settled = false; let timer: NodeJS.Timeout | null = null; - let ghToken: string | null = null; - try { - ghToken = githubService.getTokenOrThrow(); - } catch { - ghToken = null; - } - const child = spawn("gh", ghArgs, { cwd: opts.cwd, env: ghToken ? { ...process.env, GH_TOKEN: ghToken, GITHUB_TOKEN: ghToken } : process.env, diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index c3f3378aa..375de1b18 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -189,6 +189,13 @@ const mocks = vi.hoisted(() => { enabled: true; } | null> => null), execFileSync: vi.fn((_file?: unknown, _args?: unknown) => ""), + execFile: vi.fn((...args: unknown[]) => { + const callback = args.at(-1); + if (typeof callback === "function") { + (callback as (...callbackArgs: unknown[]) => void)(null, "", ""); + } + return { kill: vi.fn() }; + }), spawnSync: vi.fn(() => ({ status: 1, stdout: "", stderr: "" })), }; }); @@ -236,6 +243,7 @@ vi.mock("node:crypto", () => ({ })); vi.mock("node:child_process", () => ({ + execFile: mocks.execFile, execFileSync: mocks.execFileSync, spawnSync: mocks.spawnSync, })); @@ -398,6 +406,7 @@ function createHarness(overrides: { ...(args.title !== undefined ? { title: args.title } : {}), ...(args.goal !== undefined ? { goal: args.goal } : {}), ...(args.manuallyNamed !== undefined ? { manuallyNamed: args.manuallyNamed } : {}), + ...(args.resumeMetadata !== undefined ? { resumeMetadata: args.resumeMetadata } : {}), }); return session; }), @@ -484,6 +493,13 @@ describe("ptyService", () => { mocks.derivePreviewFromChunk.mockReturnValue({ nextLine: "", preview: "preview" }); mocks.resolveOpenCodeBinaryPath.mockReturnValue(null); mocks.resolveCodexComputerUseMcpConfig.mockResolvedValue(null); + mocks.execFile.mockImplementation((...args: unknown[]) => { + const callback = args.at(-1); + if (typeof callback === "function") { + (callback as (...callbackArgs: unknown[]) => void)(null, "", ""); + } + return { kill: vi.fn() }; + }); mocks.spawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" }); }); @@ -2776,7 +2792,11 @@ describe("ptyService", () => { rows: 40, model: "gpt-5.4", reasoningEffort: "high", - permissionMode: "plan", + fastMode: true, + permissionMode: "full-auto", + codexApprovalPolicy: "on-request", + codexSandbox: "danger-full-access", + codexConfigSource: "flags", }); await Promise.resolve(); const result = await pending; @@ -2794,9 +2814,18 @@ describe("ptyService", () => { const spawn = (loadPty.mock.results[0]?.value as any).spawn; expect(spawn).toHaveBeenCalledWith( "/bin/bash", - ["--noprofile", "--norc", "-lc", "codex --no-alt-screen --model gpt-5.4 -c \"model_reasoning_effort=\\\"high\\\"\" --sandbox read-only --ask-for-approval on-request resume thread-ended \"fix failing tests\""], + ["--noprofile", "--norc", "-lc", "codex --no-alt-screen --model gpt-5.4 -c \"model_reasoning_effort=\\\"high\\\"\" -c \"service_tier=\\\"fast\\\"\" -c features.fast_mode=true --sandbox danger-full-access --ask-for-approval on-request resume thread-ended \"fix failing tests\""], expect.any(Object), ); + expect(sessionService.get("session-ended-send")?.resumeMetadata?.launch).toMatchObject({ + model: "gpt-5.4", + reasoningEffort: "high", + fastMode: true, + permissionMode: "full-auto", + codexApprovalPolicy: "on-request", + codexSandbox: "danger-full-access", + codexConfigSource: "flags", + }); expect(mockPty.write).not.toHaveBeenCalled(); }); @@ -2845,6 +2874,71 @@ describe("ptyService", () => { ); }); + it("sendToSession persists a coarse Codex permission override across later continuations", async () => { + const { service, sessionService, mockPty, loadPty } = createHarness(); + sessionService.create({ + sessionId: "session-ended-permission-override", + laneId: "lane-1", + ptyId: null, + tracked: true, + title: "Codex CLI", + startedAt: "2026-04-09T12:00:00.000Z", + transcriptPath: "/tmp/transcripts/session-ended-permission-override.log", + toolType: "codex", + resumeCommand: "codex --no-alt-screen --sandbox danger-full-access --ask-for-approval never resume thread-permission-override", + resumeMetadata: { + provider: "codex", + targetKind: "thread", + targetId: "thread-permission-override", + launch: { + permissionMode: "full-auto", + codexApprovalPolicy: "never", + codexSandbox: "danger-full-access", + codexConfigSource: "flags", + }, + }, + }); + sessionService.end({ + sessionId: "session-ended-permission-override", + endedAt: "2026-04-09T12:30:00.000Z", + exitCode: 0, + status: "completed", + }); + + await service.sendToSession({ + sessionId: "session-ended-permission-override", + text: "first continuation", + permissionMode: "plan", + }); + + expect(sessionService.get("session-ended-permission-override")?.resumeMetadata?.launch).toMatchObject({ + permissionMode: "plan", + codexApprovalPolicy: null, + codexSandbox: null, + codexConfigSource: null, + }); + const spawn = (loadPty.mock.results[0]?.value as any).spawn; + expect(spawn).toHaveBeenLastCalledWith( + "/bin/bash", + ["--noprofile", "--norc", "-lc", "codex --no-alt-screen --sandbox read-only --ask-for-approval on-request resume thread-permission-override \"first continuation\""], + expect.any(Object), + ); + + mockPty._emitter.emit("exit", { exitCode: 0 }); + + await service.sendToSession({ + sessionId: "session-ended-permission-override", + text: "later continuation", + }); + + const laterSpawn = (loadPty.mock.results[1]?.value as any).spawn; + expect(laterSpawn).toHaveBeenCalledWith( + "/bin/bash", + ["--noprofile", "--norc", "-lc", "codex --no-alt-screen --sandbox read-only --ask-for-approval on-request resume thread-permission-override \"later continuation\""], + expect.any(Object), + ); + }); + it("sendToSession rebuilds legacy resumeCommand-only sessions with the prompt at launch", async () => { const { service, sessionService, mockPty, loadPty } = createHarness(); sessionService.create({ @@ -4329,6 +4423,7 @@ describe("ptyService", () => { const { service, mockPty, sessionService, broadcastExit } = createHarness(); const { ptyId, sessionId } = await service.create({ laneId: "lane-1", title: "d", cols: 80, rows: 24 }); service.dispose({ ptyId }); + await Promise.resolve(); expect(mockPty.kill).toHaveBeenCalled(); expect(sessionService.end).toHaveBeenCalledWith( expect.objectContaining({ sessionId, status: "disposed" }), @@ -6424,6 +6519,7 @@ describe("ptyService", () => { }); it("signalTerminal sends ^C for SIGINT and forwards SIGTERM to pty.kill", async () => { + const kill = vi.spyOn(process, "kill").mockImplementation(() => true as const); const { service, mockPty } = createChatHarness(); await service.create({ laneId: "lane-1", @@ -6436,8 +6532,247 @@ describe("ptyService", () => { service.signalTerminal({ chatSessionId: "chat-signal", signal: "SIGINT" }); expect(mockPty.write).toHaveBeenCalledWith("\x03"); + mocks.spawnSync.mockClear(); service.signalTerminal({ chatSessionId: "chat-signal", signal: "SIGTERM" }); + await Promise.resolve(); expect(mockPty.kill).toHaveBeenCalledWith("SIGTERM"); + expect(kill).toHaveBeenCalledWith(-12345, "SIGTERM"); + expect(mocks.spawnSync).not.toHaveBeenCalled(); + kill.mockRestore(); + }); + + it("uses node-pty's kill fallback without POSIX process-group signals on Windows", async () => { + const kill = vi.spyOn(process, "kill").mockImplementation(() => true as const); + setPlatform("win32"); + try { + const { service, mockPty } = createChatHarness(); + await service.create({ + laneId: "lane-1", + title: "Signal", + cols: 80, + rows: 24, + chatSessionId: "chat-signal-windows", + }); + + service.signalTerminal({ chatSessionId: "chat-signal-windows", signal: "SIGTERM" }); + expect(mockPty.kill).toHaveBeenCalledWith("SIGTERM"); + expect(kill).not.toHaveBeenCalledWith(-12345, "SIGTERM"); + } finally { + setPlatform(originalPlatform); + kill.mockRestore(); + } + }); + + it("force-kills a stubborn Windows PTY tree without blocking the main process", async () => { + vi.useFakeTimers(); + setPlatform("win32"); + const kill = vi.spyOn(process, "kill").mockImplementation(() => true as const); + try { + const { service, mockPty } = createChatHarness(); + await service.create({ + laneId: "lane-1", + title: "Signal", + cols: 80, + rows: 24, + chatSessionId: "chat-signal-windows-stubborn", + }); + + service.signalTerminal({ + chatSessionId: "chat-signal-windows-stubborn", + signal: "SIGTERM", + }); + expect(mockPty.kill).toHaveBeenCalledWith("SIGTERM"); + + await vi.advanceTimersByTimeAsync(1_500); + expect(kill).toHaveBeenCalledWith(12345, 0); + expect(mocks.execFile).toHaveBeenCalledWith( + "taskkill", + ["/pid", "12345", "/T", "/F"], + expect.objectContaining({ windowsHide: true }), + expect.any(Function), + ); + expect(mocks.spawnSync).not.toHaveBeenCalled(); + } finally { + setPlatform(originalPlatform); + kill.mockRestore(); + vi.useRealTimers(); + } + }); + + it("force-kills a live PTY process group after its leader exits", async () => { + vi.useFakeTimers(); + mocks.execFile.mockImplementation((...args: unknown[]) => { + const callback = args.at(-1); + if (typeof callback === "function") { + (callback as (...callbackArgs: unknown[]) => void)( + null, + "12345 1 12345 12345\n", + "", + ); + } + return { kill: vi.fn() }; + }); + const kill = vi.spyOn(process, "kill").mockImplementation(() => true as const); + try { + const { service } = createChatHarness(); + await service.create({ + laneId: "lane-1", + title: "Signal", + cols: 80, + rows: 24, + chatSessionId: "chat-signal-group", + }); + + service.signalTerminal({ chatSessionId: "chat-signal-group", signal: "SIGTERM" }); + await vi.advanceTimersByTimeAsync(1_500); + + expect(kill).toHaveBeenCalledWith(-12345, "SIGKILL"); + } finally { + kill.mockRestore(); + vi.useRealTimers(); + } + }); + + it("kills a foreground job group after the PTY shell group exits", async () => { + vi.useFakeTimers(); + let scanCount = 0; + mocks.execFile.mockImplementation((...args: unknown[]) => { + scanCount += 1; + const callback = args.at(-1); + if (typeof callback === "function") { + (callback as (...callbackArgs: unknown[]) => void)( + null, + scanCount === 1 + ? [ + "12345 1 12345 23456", + "23456 12345 23456 23456", + "34567 23456 34567 -1", + ].join("\n") + : [ + "23456 1 23456 23456", + "34567 23456 34567 -1", + ].join("\n"), + "", + ); + } + return { kill: vi.fn() }; + }); + const kill = vi.spyOn(process, "kill").mockImplementation(() => true as const); + try { + const { service } = createChatHarness(); + await service.create({ + laneId: "lane-1", + title: "Signal", + cols: 80, + rows: 24, + chatSessionId: "chat-signal-foreground-group", + }); + + service.signalTerminal({ + chatSessionId: "chat-signal-foreground-group", + signal: "SIGTERM", + }); + await vi.advanceTimersByTimeAsync(0); + expect(kill).toHaveBeenCalledWith(-23456, "SIGTERM"); + expect(kill).toHaveBeenCalledWith(-34567, "SIGTERM"); + + await vi.advanceTimersByTimeAsync(1_500); + expect(kill).toHaveBeenCalledWith(-23456, "SIGKILL"); + expect(kill).toHaveBeenCalledWith(23456, "SIGKILL"); + expect(kill).toHaveBeenCalledWith(-34567, "SIGKILL"); + expect(kill).toHaveBeenCalledWith(34567, "SIGKILL"); + expect(mocks.execFile).toHaveBeenCalledWith( + "ps", + ["-axo", "pid=,ppid=,pgid=,tpgid="], + expect.any(Object), + expect.any(Function), + ); + } finally { + kill.mockRestore(); + vi.useRealTimers(); + } + }); + + it("signals the PTY within a bounded delay when the process scan stalls", async () => { + vi.useFakeTimers(); + mocks.execFile.mockImplementation(() => ({ kill: vi.fn() })); + const kill = vi.spyOn(process, "kill").mockImplementation(() => true as const); + try { + const { service, mockPty } = createChatHarness(); + await service.create({ + laneId: "lane-1", + title: "Signal", + cols: 80, + rows: 24, + chatSessionId: "chat-signal-scan-stall", + }); + + service.signalTerminal({ chatSessionId: "chat-signal-scan-stall", signal: "SIGTERM" }); + await vi.advanceTimersByTimeAsync(99); + expect(mockPty.kill).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(kill).toHaveBeenCalledWith(-12345, "SIGTERM"); + expect(mockPty.kill).toHaveBeenCalledWith("SIGTERM"); + } finally { + kill.mockRestore(); + vi.useRealTimers(); + } + }); + + it("force-kills known PTY groups when the reap scan fails under load", async () => { + vi.useFakeTimers(); + let scanCount = 0; + mocks.execFile.mockImplementation((...args: unknown[]) => { + scanCount += 1; + const callback = args.at(-1); + if (typeof callback === "function") { + if (scanCount === 1) { + (callback as (...callbackArgs: unknown[]) => void)( + null, + [ + "12345 1 12345 23456", + "23456 12345 23456 23456", + ].join("\n"), + "", + ); + } else { + (callback as (...callbackArgs: unknown[]) => void)( + new Error("process scan timed out"), + "", + "", + ); + } + } + return { kill: vi.fn() }; + }); + const kill = vi.spyOn(process, "kill").mockImplementation(() => true as const); + try { + const { service } = createChatHarness(); + await service.create({ + laneId: "lane-1", + title: "Signal", + cols: 80, + rows: 24, + chatSessionId: "chat-signal-reap-scan-failure", + }); + + service.signalTerminal({ + chatSessionId: "chat-signal-reap-scan-failure", + signal: "SIGTERM", + }); + await vi.advanceTimersByTimeAsync(0); + kill.mockClear(); + + await vi.advanceTimersByTimeAsync(1_500); + expect(kill).toHaveBeenCalledWith(-12345, "SIGKILL"); + expect(kill).toHaveBeenCalledWith(12345, "SIGKILL"); + expect(kill).toHaveBeenCalledWith(-23456, "SIGKILL"); + expect(kill).toHaveBeenCalledWith(23456, "SIGKILL"); + } finally { + kill.mockRestore(); + vi.useRealTimers(); + } }); it("fails loudly when chat terminal calls cannot resolve a target", async () => { diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 19bfaa194..7b4ddf9c3 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { randomUUID } from "node:crypto"; -import { spawnSync } from "node:child_process"; +import { execFile, spawnSync } from "node:child_process"; import type { IPty, IWindowsPtyForkOptions } from "node-pty"; import type * as ptyNs from "node-pty"; import * as HeadlessXterm from "@xterm/headless"; @@ -165,63 +165,147 @@ const AGENT_CLI_READY_TIMEOUT_MS = 20_000; const AGENT_CLI_READY_POLL_MS = 100; const AGENT_CLI_READY_QUIET_MS = 600; const PTY_PROCESS_TREE_KILL_DELAY_MS = 1500; -const PTY_PROCESS_TREE_MAX_DEPTH = 12; +const PTY_PROCESS_SCAN_SIGNAL_DELAY_MS = 100; +const PTY_PROCESS_SCAN_TIMEOUT_MS = 250; +const PTY_PROCESS_SCAN_MAX_BYTES = 512 * 1024; let cachedOpenCodeReplayResumeSupport: boolean | null = null; -function isPidLive(pid: number): boolean { +function killPidBestEffort(pid: number, signal: NodeJS.Signals): void { + if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid) return; try { - process.kill(pid, 0); - return true; + process.kill(Math.trunc(pid), signal); } catch { - return false; + // The process may have already exited. } } -function childPidsOf(pid: number): number[] { - if (!Number.isFinite(pid) || pid <= 0) return []; +function killPtyProcessGroupBestEffort(rootPid: number, signal: NodeJS.Signals): boolean { + if (process.platform === "win32" || !Number.isFinite(rootPid) || rootPid <= 0) return false; try { - const result = spawnSync("pgrep", ["-P", String(Math.trunc(pid))], { - encoding: "utf8", - timeout: 1000, - }); - if (result.error || result.status === 1) return []; - return String(result.stdout ?? "") - .split(/\s+/) - .map((value) => Number.parseInt(value, 10)) - .filter((value) => Number.isFinite(value) && value > 0); + // node-pty's POSIX backend uses forkpty(3); forkpty's login_tty(3) creates + // a new session, making the child both session and process-group leader. + // Targeting `-pid` therefore signals the PTY group in one syscall, instead + // of recursively running synchronous `pgrep` calls on the main thread. + process.kill(-Math.trunc(rootPid), signal); + return true; } catch { - return []; + return false; } } -function collectDescendantPids(rootPid: number): number[] { - const root = Math.trunc(rootPid); - if (!Number.isFinite(root) || root <= 0) return []; - const seen = new Set([root]); - const descendants: number[] = []; - let frontier = [root]; - for (let depth = 0; depth < PTY_PROCESS_TREE_MAX_DEPTH && frontier.length > 0; depth += 1) { - const next: number[] = []; - for (const parent of frontier) { - for (const child of childPidsOf(parent)) { - if (seen.has(child)) continue; - seen.add(child); - descendants.push(child); - next.push(child); +type PtyTreeProcess = { + pid: number; + parentPid: number; + processGroupId: number; + foregroundProcessGroupId: number; +}; + +type PtyTreeProcessScan = { + processes: PtyTreeProcess[]; + succeeded: boolean; +}; + +function parsePtyTreeProcesses( + stdout: string, + rootPid: number, + knownProcessGroupIds: ReadonlySet = new Set(), +): PtyTreeProcess[] { + const rows = stdout.split(/\r?\n/).flatMap((line) => { + const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(-?\d+)\s*$/); + if (!match) return []; + const [pid, parentPid, processGroupId, foregroundProcessGroupId] = match.slice(1).map((value) => + Number.parseInt(value, 10) + ); + if (![pid, parentPid, processGroupId, foregroundProcessGroupId].every(Number.isFinite)) return []; + return [{ pid, parentPid, processGroupId, foregroundProcessGroupId }]; + }); + const selectedPids = new Set( + rows.some((row) => row.pid === rootPid) ? [rootPid] : [], + ); + const selectedProcessGroups = new Set(knownProcessGroupIds); + for (const row of rows) { + if (knownProcessGroupIds.has(row.processGroupId)) selectedPids.add(row.pid); + } + let added = true; + while (added) { + added = false; + for (const row of rows) { + if ( + !selectedPids.has(row.pid) + && (selectedPids.has(row.parentPid) || selectedProcessGroups.has(row.processGroupId)) + ) { + selectedPids.add(row.pid); + added = true; + } + if (!selectedPids.has(row.pid)) continue; + if (row.processGroupId > 1 && !selectedProcessGroups.has(row.processGroupId)) { + selectedProcessGroups.add(row.processGroupId); + added = true; + } + if ( + row.foregroundProcessGroupId > 1 + && !selectedProcessGroups.has(row.foregroundProcessGroupId) + ) { + selectedProcessGroups.add(row.foregroundProcessGroupId); + added = true; } } - frontier = next; } - return descendants; + return rows.filter((row) => + selectedPids.has(row.pid) || selectedProcessGroups.has(row.processGroupId) + ); } -function killPidBestEffort(pid: number, signal: NodeJS.Signals): void { - if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid) return; - try { - process.kill(Math.trunc(pid), signal); - } catch { - // The process may have already exited. +function collectPtyTreeProcesses( + rootPid: number, + knownProcessGroupIds: ReadonlySet = new Set(), +): Promise { + if (process.platform === "win32" || !Number.isFinite(rootPid) || rootPid <= 0) { + return Promise.resolve({ processes: [], succeeded: false }); + } + return new Promise((resolve) => { + try { + execFile( + "ps", + ["-axo", "pid=,ppid=,pgid=,tpgid="], + { + encoding: "utf8", + timeout: PTY_PROCESS_SCAN_TIMEOUT_MS, + maxBuffer: PTY_PROCESS_SCAN_MAX_BYTES, + windowsHide: true, + }, + (error, stdout) => { + resolve(error + ? { processes: [], succeeded: false } + : { + processes: parsePtyTreeProcesses(String(stdout ?? ""), rootPid, knownProcessGroupIds), + succeeded: true, + }); + }, + ); + } catch { + resolve({ processes: [], succeeded: false }); + } + }); +} + +function signalPtyTreeProcesses( + processes: readonly PtyTreeProcess[], + signal: NodeJS.Signals, +): void { + const processGroups = new Set(processes + .map((entry) => entry.processGroupId) + .filter((processGroupId) => processGroupId > 1 && processGroupId !== process.pid)); + for (const processGroupId of processGroups) { + try { + process.kill(-processGroupId, signal); + } catch { + // A group may have exited between the process scan and signal. + } + } + for (const { pid } of [...processes].reverse()) { + killPidBestEffort(pid, signal); } } @@ -589,29 +673,108 @@ function terminatePtyProcessTree( const rootPid = typeof entry.pty.pid === "number" && Number.isFinite(entry.pty.pid) ? Math.trunc(entry.pty.pid) : null; - const descendants = rootPid ? collectDescendantPids(rootPid) : []; - for (const pid of [...descendants].reverse()) { - killPidBestEffort(pid, signal); + if (!rootPid) { + try { + entry.pty.kill(signal); + } catch { + // No numeric PID is available for a direct fallback. + } + return; } - try { - entry.pty.kill(signal); - } catch { - if (rootPid) killPidBestEffort(rootPid, signal); + if (process.platform === "win32") { + try { + entry.pty.kill(signal); + } catch { + killPidBestEffort(rootPid, signal); + } + if (signal === "SIGKILL") return; + const timer = setTimeout(() => { + try { + process.kill(rootPid, 0); + } catch { + return; + } + try { + execFile( + "taskkill", + ["/pid", String(rootPid), "/T", "/F"], + { timeout: 5_000, maxBuffer: 64 * 1024, windowsHide: true }, + (error) => { + if (error) return; + logger.warn("pty.process_tree_force_killed", { + sessionId: entry.sessionId, + toolType: entry.toolTypeHint, + rootPid, + pids: [rootPid], + }); + }, + ); + } catch { + // taskkill may be unavailable; the initial node-pty signal still ran. + } + }, PTY_PROCESS_TREE_KILL_DELAY_MS); + timer.unref?.(); + return; } - if (signal === "SIGKILL" || (!rootPid && descendants.length === 0)) return; - const pidsToReap = Array.from(new Set([...(rootPid ? [rootPid] : []), ...descendants])); - if (!pidsToReap.length) return; + + let initialProcesses: PtyTreeProcess[] = []; + let initialSignalDispatched = false; + const dispatchInitialSignal = (processes: readonly PtyTreeProcess[]) => { + if (initialSignalDispatched) return; + initialSignalDispatched = true; + killPtyProcessGroupBestEffort(rootPid, signal); + try { + entry.pty.kill(signal); + } catch { + killPidBestEffort(rootPid, signal); + } + signalPtyTreeProcesses(processes, signal); + }; + const initialProcessScan = collectPtyTreeProcesses(rootPid); + const signalFallbackTimer = setTimeout(() => { + dispatchInitialSignal([]); + }, PTY_PROCESS_SCAN_SIGNAL_DELAY_MS); + signalFallbackTimer.unref?.(); + void initialProcessScan.then(({ processes }) => { + initialProcesses = processes; + clearTimeout(signalFallbackTimer); + const signalAlreadyDispatched = initialSignalDispatched; + dispatchInitialSignal(processes); + if (signalAlreadyDispatched) signalPtyTreeProcesses(processes, signal); + }); + if (signal === "SIGKILL") return; const timer = setTimeout(() => { - const stillLive = pidsToReap.filter((pid) => isPidLive(pid)); - for (const pid of stillLive) killPidBestEffort(pid, "SIGKILL"); - if (stillLive.length > 0) { + const knownProcessGroupIds = new Set(initialProcesses.flatMap((process) => [ + process.processGroupId, + process.foregroundProcessGroupId, + ]).filter((processGroupId) => processGroupId > 1)); + void collectPtyTreeProcesses(rootPid, knownProcessGroupIds).then(({ processes: currentProcesses, succeeded }) => { + if (!succeeded) { + // A saturated host can time out the fallback `ps` scan precisely when + // cleanup matters most. Do not interpret an unavailable scan as proof + // that the tree exited: force the known PTY/root groups once more so a + // surviving child cannot keep a lane worktree busy indefinitely. + killPtyProcessGroupBestEffort(rootPid, "SIGKILL"); + killPidBestEffort(rootPid, "SIGKILL"); + signalPtyTreeProcesses(initialProcesses, "SIGKILL"); + logger.warn("pty.process_tree_force_killed", { + sessionId: entry.sessionId, + toolType: entry.toolTypeHint, + rootPid, + pids: Array.from(new Set([rootPid, ...initialProcesses.map(({ pid }) => pid)])), + processScanFailed: true, + }); + return; + } + if (currentProcesses.length === 0) return; + signalPtyTreeProcesses(currentProcesses, "SIGKILL"); logger.warn("pty.process_tree_force_killed", { sessionId: entry.sessionId, toolType: entry.toolTypeHint, rootPid, - pids: stillLive, + pids: Array.from(new Set(currentProcesses.map(({ pid }) => pid))), }); - } + }); }, PTY_PROCESS_TREE_KILL_DELAY_MS); timer.unref?.(); } @@ -3804,7 +3967,16 @@ export function createPtyService({ }; const resumeLaunchOverrides = ( - args: Pick, + args: Pick< + PtySendToSessionArgs, + | "model" + | "reasoningEffort" + | "fastMode" + | "permissionMode" + | "codexApprovalPolicy" + | "codexSandbox" + | "codexConfigSource" + >, ) => ({ model: typeof args.model === "string" && args.model.trim().length ? args.model.trim() @@ -3812,9 +3984,24 @@ export function createPtyService({ reasoningEffort: typeof args.reasoningEffort === "string" && args.reasoningEffort.trim().length ? args.reasoningEffort.trim() : undefined, + fastMode: typeof args.fastMode === "boolean" ? args.fastMode : undefined, permissionMode: typeof args.permissionMode === "string" && args.permissionMode.trim().length ? args.permissionMode : undefined, + codexApprovalPolicy: args.codexApprovalPolicy === "untrusted" + || args.codexApprovalPolicy === "on-request" + || args.codexApprovalPolicy === "on-failure" + || args.codexApprovalPolicy === "never" + ? args.codexApprovalPolicy + : undefined, + codexSandbox: args.codexSandbox === "read-only" + || args.codexSandbox === "workspace-write" + || args.codexSandbox === "danger-full-access" + ? args.codexSandbox + : undefined, + codexConfigSource: args.codexConfigSource === "flags" || args.codexConfigSource === "config-toml" + ? args.codexConfigSource + : undefined, }); const buildResumeCommandForSession = ( @@ -4779,8 +4966,45 @@ export function createPtyService({ ); } - const { session: resumableSession, provider } = await resolveEndedResumeSession(sessionId, session); + const resolvedResume = await resolveEndedResumeSession(sessionId, session); + let resumableSession = resolvedResume.session; + const provider = resolvedResume.provider; const overrides = resumeLaunchOverrides(args); + const resetsStoredCodexPermissionProfile = provider === "codex" + && overrides.permissionMode !== undefined; + const launchOverridePatch = { + ...(overrides.model !== undefined ? { model: overrides.model } : {}), + ...(overrides.reasoningEffort !== undefined ? { reasoningEffort: overrides.reasoningEffort } : {}), + ...(overrides.fastMode !== undefined ? { fastMode: overrides.fastMode } : {}), + ...(overrides.permissionMode !== undefined ? { permissionMode: overrides.permissionMode } : {}), + ...(overrides.codexApprovalPolicy !== undefined + ? { codexApprovalPolicy: overrides.codexApprovalPolicy } + : resetsStoredCodexPermissionProfile + ? { codexApprovalPolicy: null } + : {}), + ...(overrides.codexSandbox !== undefined + ? { codexSandbox: overrides.codexSandbox } + : resetsStoredCodexPermissionProfile + ? { codexSandbox: null } + : {}), + ...(overrides.codexConfigSource !== undefined + ? { codexConfigSource: overrides.codexConfigSource } + : resetsStoredCodexPermissionProfile + ? { codexConfigSource: null } + : {}), + }; + if (resumableSession.resumeMetadata && Object.keys(launchOverridePatch).length > 0) { + resumableSession = sessionService.updateMeta({ + sessionId, + resumeMetadata: { + ...resumableSession.resumeMetadata, + launch: { + ...resumableSession.resumeMetadata.launch, + ...launchOverridePatch, + }, + }, + }) ?? resumableSession; + } const launchMetadata = resumableSession.resumeMetadata?.launch; const openCodeReplayCommand = provider === "opencode" && resumableSession.resumeMetadata?.provider === "opencode" @@ -4790,7 +5014,7 @@ export function createPtyService({ targetId: sanitizeResumeTargetId(resumableSession.resumeMetadata.targetId ?? null), model: overrides.model ?? launchMetadata?.model ?? null, reasoningEffort: overrides.reasoningEffort ?? launchMetadata?.reasoningEffort ?? null, - fastMode: launchMetadata?.fastMode ?? launchMetadata?.codexFastMode ?? null, + fastMode: overrides.fastMode ?? launchMetadata?.fastMode ?? launchMetadata?.codexFastMode ?? null, prompt: text, }) : null; diff --git a/apps/desktop/src/main/services/sessions/sessionService.test.ts b/apps/desktop/src/main/services/sessions/sessionService.test.ts index 714753e49..dc6c17068 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.test.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.test.ts @@ -223,6 +223,7 @@ describe("sessionService resume metadata", () => { permissionMode: "edit", model: "gpt-5.4", reasoningEffort: "medium", + fastMode: true, codexApprovalPolicy: "untrusted", codexSandbox: "workspace-write", codexConfigSource: "flags", @@ -232,7 +233,7 @@ describe("sessionService resume metadata", () => { const created = service.get("session-2"); expect(created?.resumeCommand).toBe( - "codex --no-alt-screen --model gpt-5.4 -c \"model_reasoning_effort=\\\"medium\\\"\" --sandbox workspace-write --ask-for-approval untrusted resume", + "codex --no-alt-screen --model gpt-5.4 -c \"model_reasoning_effort=\\\"medium\\\"\" -c \"service_tier=\\\"fast\\\"\" -c features.fast_mode=true --sandbox workspace-write --ask-for-approval untrusted resume", ); service.setResumeCommand("session-2", "codex resume thread-1"); @@ -246,13 +247,14 @@ describe("sessionService resume metadata", () => { permissionMode: "edit", model: "gpt-5.4", reasoningEffort: "medium", + fastMode: true, codexApprovalPolicy: "untrusted", codexSandbox: "workspace-write", codexConfigSource: "flags", }, }); expect(resumed?.resumeCommand).toBe( - "codex --no-alt-screen --model gpt-5.4 -c \"model_reasoning_effort=\\\"medium\\\"\" --sandbox workspace-write --ask-for-approval untrusted resume thread-1", + "codex --no-alt-screen --model gpt-5.4 -c \"model_reasoning_effort=\\\"medium\\\"\" -c \"service_tier=\\\"fast\\\"\" -c features.fast_mode=true --sandbox workspace-write --ask-for-approval untrusted resume thread-1", ); activeDisposers.push(async () => db.close()); @@ -301,7 +303,9 @@ describe("sessionService resume metadata", () => { codexConfigSource: "flags", }, }); - expect(resumed?.resumeCommand).toBe("codex --no-alt-screen --dangerously-bypass-approvals-and-sandbox resume thread-full-auto"); + expect(resumed?.resumeCommand).toBe( + "codex --no-alt-screen --sandbox danger-full-access --ask-for-approval never resume thread-full-auto", + ); activeDisposers.push(async () => db.close()); }); diff --git a/apps/desktop/src/main/services/sessions/sessionService.ts b/apps/desktop/src/main/services/sessions/sessionService.ts index f025cf5ea..f7b7f10c8 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.ts @@ -137,9 +137,25 @@ function normalizeResumeMetadata(raw: unknown): TerminalResumeMetadata | null { const reasoningEffort = typeof launchRecord.reasoningEffort === "string" && launchRecord.reasoningEffort.trim().length ? launchRecord.reasoningEffort.trim() : null; - const codexApprovalPolicy = typeof launchRecord.codexApprovalPolicy === "string" ? launchRecord.codexApprovalPolicy : null; - const codexSandbox = typeof launchRecord.codexSandbox === "string" ? launchRecord.codexSandbox : null; - const codexConfigSource = typeof launchRecord.codexConfigSource === "string" ? launchRecord.codexConfigSource : null; + const fastMode = typeof launchRecord.fastMode === "boolean" + ? launchRecord.fastMode + : typeof launchRecord.codexFastMode === "boolean" + ? launchRecord.codexFastMode + : null; + const codexApprovalPolicy = launchRecord.codexApprovalPolicy === "untrusted" + || launchRecord.codexApprovalPolicy === "on-request" + || launchRecord.codexApprovalPolicy === "on-failure" + || launchRecord.codexApprovalPolicy === "never" + ? launchRecord.codexApprovalPolicy + : null; + const codexSandbox = launchRecord.codexSandbox === "read-only" + || launchRecord.codexSandbox === "workspace-write" + || launchRecord.codexSandbox === "danger-full-access" + ? launchRecord.codexSandbox + : null; + const codexConfigSource = launchRecord.codexConfigSource === "flags" || launchRecord.codexConfigSource === "config-toml" + ? launchRecord.codexConfigSource + : null; const importedFromRecord = record.importedFrom != null && typeof record.importedFrom === "object" && !Array.isArray(record.importedFrom) ? record.importedFrom as Record : null; @@ -168,6 +184,7 @@ function normalizeResumeMetadata(raw: unknown): TerminalResumeMetadata | null { ...(permissionMode ? { permissionMode } : {}), ...(model ? { model } : {}), ...(reasoningEffort ? { reasoningEffort } : {}), + ...(fastMode !== null ? { fastMode } : {}), ...(claudePermissionMode ? { claudePermissionMode: claudePermissionMode as TerminalResumeMetadata["launch"]["claudePermissionMode"] } : {}), ...(codexApprovalPolicy ? { codexApprovalPolicy: codexApprovalPolicy as TerminalResumeMetadata["launch"]["codexApprovalPolicy"] } : {}), ...(codexSandbox ? { codexSandbox: codexSandbox as TerminalResumeMetadata["launch"]["codexSandbox"] } : {}), diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts index c58632f24..7960078fc 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts @@ -2335,12 +2335,26 @@ describe("createSyncRemoteCommandService", () => { text: "continue here", cols: 999, rows: 999, + model: "gpt-5.6-sol", + reasoningEffort: "max", + fastMode: true, + permissionMode: "full-auto", + codexApprovalPolicy: "on-request", + codexSandbox: "danger-full-access", + codexConfigSource: "flags", })); expect(ptyService.sendToSession).toHaveBeenCalledWith({ sessionId: "pty-existing", text: "continue here", cols: 999, rows: 999, + model: "gpt-5.6-sol", + reasoningEffort: "max", + fastMode: true, + permissionMode: "full-auto", + codexApprovalPolicy: "on-request", + codexSandbox: "danger-full-access", + codexConfigSource: "flags", }); expect(result).toMatchObject({ sessionId: "pty-1", diff --git a/apps/desktop/src/main/services/sync/syncService.test.ts b/apps/desktop/src/main/services/sync/syncService.test.ts index e88d793c3..7a0dc0b60 100644 --- a/apps/desktop/src/main/services/sync/syncService.test.ts +++ b/apps/desktop/src/main/services/sync/syncService.test.ts @@ -1480,5 +1480,65 @@ describe.skipIf(!isCrsqliteAvailable())("syncService", () => { const enabledStatus = await service.getStatus(); expect(enabledStatus.bootstrapToken).toBe("await-token"); }, 30_000); + + it("waits for a queued host rollback before resolving either toggle", async () => { + const projectRoot = makeProjectRoot("ade-sync-service-startup-rollback-"); + const appPairingDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-sync-service-startup-rollback-app-")); + const db = await openKvDb( + path.join(projectRoot, ".ade", "ade.db"), + createLogger() as any, + ); + let resolveListening!: (port: number) => void; + const listening = new Promise((resolve) => { + resolveListening = resolve; + }); + const host = createDefaultSyncHostServiceMock(); + const disposeHost = vi.fn(async () => undefined); + createSyncHostServiceMock.mockReturnValueOnce({ + ...host, + waitUntilListening: () => listening, + dispose: disposeHost, + }); + + const service = createSyncService({ + db, + logger: createLogger() as any, + projectRoot, + phonePairingStateDir: appPairingDir, + fileService: { dispose: () => {} } as any, + laneService: { list: async () => [] } as any, + prService: {} as any, + sessionService: { list: () => [] } as any, + ptyService: {} as any, + computerUseArtifactBrokerService: {} as any, + agentChatService: { listSessions: async () => [] } as any, + processService: { listRuntime: () => [] } as any, + hostStartupEnabled: false, + } as any); + + activeDisposers.push(async () => { + await service.dispose(); + db.close(); + }); + + await service.initialize(); + const enabling = service.setHostStartupEnabled(true); + await vi.waitFor(() => { + expect(createSyncHostServiceMock).toHaveBeenCalledTimes(1); + }); + + let rollbackResolved = false; + const disabling = service.setHostStartupEnabled(false).then(() => { + rollbackResolved = true; + }); + await Promise.resolve(); + expect(rollbackResolved).toBe(false); + + resolveListening(8787); + await Promise.all([enabling, disabling]); + + expect(disposeHost).toHaveBeenCalledTimes(1); + expect(service.getHostService()).toBeNull(); + }, 30_000); }); }); diff --git a/apps/desktop/src/main/services/usage/usageLedgerWorker.ts b/apps/desktop/src/main/services/usage/usageLedgerWorker.ts new file mode 100644 index 000000000..cd7236389 --- /dev/null +++ b/apps/desktop/src/main/services/usage/usageLedgerWorker.ts @@ -0,0 +1,98 @@ +import type { UsageProvider } from "../../../shared/types"; +import { getErrorMessage, isRecord } from "../shared/utils"; +import { refreshDynamicTokenPricing } from "./usagePricing"; +import { + scanClaudeLogs, + scanCodexLogs, + scanCopilotLogs, + scanCursorAgentLogs, + scanCursorLogs, + scanDroidLogs, + scanGeminiLogs, + scanOpenClawLogs, + scanOpenCodeLogs, + type TokenEntry, +} from "./ledgers/localUsageLedgers"; +import { buildCostSnapshots, bucketDaily7d } from "./usageTrackingService"; +import type { UsageLedgerScanResult } from "./usageLedgerWorkerClient"; + +const WORKER_INPUT_MAX_BYTES = 64 * 1024; + +type ProviderScanner = { + provider: string; + scan: () => Promise; +}; + +const providerScanners: ProviderScanner[] = [ + { provider: "claude", scan: scanClaudeLogs }, + { provider: "codex", scan: scanCodexLogs }, + { provider: "cursor", scan: scanCursorLogs }, + { provider: "cursor-agent", scan: scanCursorAgentLogs }, + { provider: "openclaw", scan: scanOpenClawLogs }, + { provider: "opencode", scan: scanOpenCodeLogs }, + { provider: "droid", scan: scanDroidLogs }, + { provider: "copilot", scan: scanCopilotLogs }, + { provider: "gemini", scan: scanGeminiLogs }, +]; + +async function readInput(): Promise<{ projectRoot: string | null }> { + let raw = ""; + for await (const chunk of process.stdin) { + raw += chunk.toString(); + if (Buffer.byteLength(raw, "utf8") > WORKER_INPUT_MAX_BYTES) { + throw new Error("Usage ledger worker input is too large"); + } + } + const parsed = JSON.parse(raw) as unknown; + if (!isRecord(parsed) || (parsed.projectRoot !== null && typeof parsed.projectRoot !== "string")) { + throw new Error("Usage ledger worker input is invalid"); + } + return { projectRoot: parsed.projectRoot }; +} + +async function main(): Promise { + const { projectRoot } = await readInput(); + await refreshDynamicTokenPricing().catch(() => 0); + const result: UsageLedgerScanResult = { + costs: [], + projectCosts: [], + daily7d: {}, + entryCounts: {}, + providerErrors: {}, + }; + const nowMs = Date.now(); + + // Scan and aggregate one provider at a time. The old Promise.all path kept + // every provider's per-turn ledger objects alive together and pushed a busy + // ADE runtime into multi-gigabyte peaks. This worker also keeps that work off + // the runtime's project/chat/sync event loop. + for (const scanner of providerScanners) { + let entries: TokenEntry[]; + try { + entries = await scanner.scan(); + } catch (error) { + result.providerErrors[scanner.provider] = getErrorMessage(error); + result.entryCounts[scanner.provider] = 0; + continue; + } + result.entryCounts[scanner.provider] = entries.length; + const providerEntries = new Map([[scanner.provider, entries]]); + result.costs.push(...buildCostSnapshots(providerEntries, "machine", projectRoot)); + result.projectCosts.push(...buildCostSnapshots(providerEntries, "project", projectRoot)); + if ((scanner.provider === "claude" || scanner.provider === "codex") && entries.length > 0) { + result.daily7d[scanner.provider as UsageProvider] = bucketDaily7d(entries, nowMs); + } + } + + process.stdout.write(JSON.stringify(result)); +} + +export async function runUsageLedgerWorkerEntrypoint(): Promise { + try { + await main(); + return 0; + } catch (error) { + process.stderr.write(getErrorMessage(error).slice(0, 64 * 1024)); + return 1; + } +} diff --git a/apps/desktop/src/main/services/usage/usageLedgerWorkerClient.test.ts b/apps/desktop/src/main/services/usage/usageLedgerWorkerClient.test.ts new file mode 100644 index 000000000..8e781a569 --- /dev/null +++ b/apps/desktop/src/main/services/usage/usageLedgerWorkerClient.test.ts @@ -0,0 +1,163 @@ +import { EventEmitter } from "node:events"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { PassThrough } from "node:stream"; +import { describe, expect, it, vi } from "vitest"; +import { + _testing, + parseUsageLedgerWorkerResult, + resolveUsageLedgerWorkerPath, + scanUsageLedgersInWorker, +} from "./usageLedgerWorkerClient"; + +function resultJson(): string { + return JSON.stringify({ + costs: [{ provider: "codex", todayCostUsd: 1, last30dCostUsd: 2, tokenBreakdown: {} }], + projectCosts: [], + daily7d: { codex: [0, 0, 0, 0, 0, 0, 10] }, + entryCounts: { codex: 1 }, + providerErrors: {}, + }); +} + +function fakeChild() { + const child = new EventEmitter() as EventEmitter & { + stdin: PassThrough; + stdout: PassThrough; + stderr: PassThrough; + kill: ReturnType; + pid: number; + exitCode: number | null; + signalCode: NodeJS.Signals | null; + }; + child.stdin = new PassThrough(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.kill = vi.fn(() => true); + child.pid = 1234; + child.exitCode = null; + child.signalCode = null; + return child; +} + +describe("usage ledger worker client", () => { + it("validates compact worker results", () => { + expect(parseUsageLedgerWorkerResult(resultJson())).toMatchObject({ + entryCounts: { codex: 1 }, + daily7d: { codex: [0, 0, 0, 0, 0, 0, 10] }, + }); + expect(() => parseUsageLedgerWorkerResult(JSON.stringify({ costs: "invalid" }))).toThrow( + "invalid result", + ); + for (const invalid of [ + { daily7d: { codex: "invalid" } }, + { daily7d: { unknown: [1] } }, + { entryCounts: { codex: Number.NaN } }, + { providerErrors: { codex: 42 } }, + ]) { + expect(() => parseUsageLedgerWorkerResult(JSON.stringify({ + ...JSON.parse(resultJson()), + ...invalid, + }))).toThrow("invalid result"); + } + }); + + it("prefers the packaged sibling worker over a source entry", () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-usage-worker-resolution-")); + try { + const packagedWorker = path.join(baseDir, "usageLedgerWorker.cjs"); + fs.writeFileSync(packagedWorker, "// packaged worker\n"); + fs.writeFileSync(path.join(baseDir, "usageLedgerWorkerEntry.ts"), "// source worker\n"); + expect(resolveUsageLedgerWorkerPath(baseDir)).toBe(packagedWorker); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it("streams input and resolves a successful worker result", async () => { + const child = fakeChild(); + let input = ""; + child.stdin.on("data", (chunk) => { input += chunk.toString(); }); + const spawnWorker = vi.fn(() => child); + const promise = scanUsageLedgersInWorker("/repo", { + workerPath: __filename, + spawnWorker: spawnWorker as never, + }); + child.stdout.end(resultJson()); + child.emit("close", 0, null); + + await expect(promise).resolves.toMatchObject({ entryCounts: { codex: 1 } }); + expect(JSON.parse(input)).toEqual({ projectRoot: "/repo" }); + expect(spawnWorker).toHaveBeenCalledWith( + process.execPath, + [__filename], + expect.objectContaining({ stdio: ["pipe", "pipe", "pipe"] }), + ); + }); + + it("uses the source worker with the active tsx loader in development", async () => { + const child = fakeChild(); + const spawnWorker = vi.fn(() => child); + const workerPath = resolveUsageLedgerWorkerPath(); + expect(workerPath).toMatch(/usageLedgerWorkerEntry\.ts$/u); + + const promise = scanUsageLedgersInWorker(null, { + spawnWorker: spawnWorker as never, + embeddedRuntime: false, + }); + child.stdout.end(resultJson()); + child.emit("close", 0, null); + + await expect(promise).resolves.toMatchObject({ entryCounts: { codex: 1 } }); + expect(spawnWorker).toHaveBeenCalledWith( + process.execPath, + [...process.execArgv, workerPath], + expect.objectContaining({ stdio: ["pipe", "pipe", "pipe"] }), + ); + }); + + it("uses the worker embedded in a static SEA runtime", async () => { + const child = fakeChild(); + const spawnWorker = vi.fn(() => child); + const promise = scanUsageLedgersInWorker(null, { + spawnWorker: spawnWorker as never, + embeddedRuntime: true, + }); + child.stdout.end(resultJson()); + child.emit("close", 0, null); + + await expect(promise).resolves.toMatchObject({ entryCounts: { codex: 1 } }); + expect(spawnWorker).toHaveBeenCalledWith( + process.execPath, + [_testing.INTERNAL_LEDGER_WORKER_ARG], + expect.objectContaining({ stdio: ["pipe", "pipe", "pipe"] }), + ); + }); + + it("cancels the worker without waiting for its timeout", async () => { + const child = fakeChild(); + const controller = new AbortController(); + const promise = scanUsageLedgersInWorker(null, { + signal: controller.signal, + workerPath: __filename, + spawnWorker: (() => child) as never, + }); + controller.abort(); + + await expect(promise).rejects.toMatchObject({ name: "AbortError" }); + expect(child.kill).toHaveBeenCalledWith("SIGTERM"); + }); + + it("rejects non-zero exits with bounded stderr context", async () => { + const child = fakeChild(); + const promise = scanUsageLedgersInWorker(null, { + workerPath: __filename, + spawnWorker: (() => child) as never, + }); + child.stderr.end("scanner failed"); + child.emit("close", 1, null); + + await expect(promise).rejects.toThrow("scanner failed"); + }); +}); diff --git a/apps/desktop/src/main/services/usage/usageLedgerWorkerClient.ts b/apps/desktop/src/main/services/usage/usageLedgerWorkerClient.ts new file mode 100644 index 000000000..6eefdc625 --- /dev/null +++ b/apps/desktop/src/main/services/usage/usageLedgerWorkerClient.ts @@ -0,0 +1,218 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import type { CostSnapshot, UsageProvider } from "../../../shared/types"; +import { isRecord } from "../shared/utils"; +import { terminateProcessTree } from "../shared/processExecution"; + +const LEDGER_WORKER_TIMEOUT_MS = 90_000; +const LEDGER_WORKER_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; +const LEDGER_WORKER_MAX_ERROR_BYTES = 64 * 1024; +const INTERNAL_LEDGER_WORKER_ARG = "__ade-usage-ledger-worker"; + +export type UsageLedgerScanResult = { + costs: CostSnapshot[]; + projectCosts: CostSnapshot[]; + daily7d: Partial>; + entryCounts: Record; + providerErrors: Record; +}; + +type WorkerOptions = { + signal?: AbortSignal; + workerPath?: string; + spawnWorker?: typeof spawn; + /** Test seam for the Node SEA runtime, whose executable embeds the worker. */ + embeddedRuntime?: boolean; +}; + +function abortError(): Error { + const error = new Error("Usage ledger scan cancelled"); + error.name = "AbortError"; + return error; +} + +export function resolveUsageLedgerWorkerPath(baseDir = __dirname): string { + const configured = process.env.ADE_USAGE_LEDGER_WORKER_PATH?.trim(); + if (configured) return configured; + const candidates = [ + path.join(baseDir, "usageLedgerWorker.cjs"), + // `npm run dev` executes the source graph through tsx. Reuse its loader in + // the child so development gets the same event-loop isolation as builds. + path.join(baseDir, "usageLedgerWorkerEntry.ts"), + ]; + return candidates.find((candidate) => fs.existsSync(candidate)) ?? candidates[0]!; +} + +function isEmbeddedAdeRuntime(): boolean { + const sea = process.getBuiltinModule?.("node:sea") as { isSea?: () => boolean } | undefined; + return sea?.isSea?.() === true; +} + +function isCostSnapshot(value: unknown): value is CostSnapshot { + return isRecord(value) + && typeof value.provider === "string" + && typeof value.todayCostUsd === "number" + && typeof value.last30dCostUsd === "number" + && isRecord(value.tokenBreakdown); +} + +function isUsageProvider(value: string): value is UsageProvider { + return value === "claude" || value === "codex" || value === "cursor"; +} + +function invalidWorkerResult(): never { + throw new Error("Usage ledger worker returned an invalid result"); +} + +export function parseUsageLedgerWorkerResult(raw: string): UsageLedgerScanResult { + const parsed = JSON.parse(raw) as unknown; + if (!isRecord(parsed) + || !Array.isArray(parsed.costs) + || !parsed.costs.every(isCostSnapshot) + || !Array.isArray(parsed.projectCosts) + || !parsed.projectCosts.every(isCostSnapshot) + || !isRecord(parsed.daily7d) + || !isRecord(parsed.entryCounts) + || !isRecord(parsed.providerErrors)) { + invalidWorkerResult(); + } + + const daily7d: Partial> = {}; + for (const [provider, value] of Object.entries(parsed.daily7d)) { + if ( + !isUsageProvider(provider) + || !Array.isArray(value) + || !value.every((entry) => typeof entry === "number" && Number.isFinite(entry)) + ) { + invalidWorkerResult(); + } + daily7d[provider] = value; + } + + const entryCounts: Record = {}; + for (const [provider, value] of Object.entries(parsed.entryCounts)) { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + invalidWorkerResult(); + } + entryCounts[provider] = value; + } + + const providerErrors: Record = {}; + for (const [provider, value] of Object.entries(parsed.providerErrors)) { + if (typeof value !== "string") invalidWorkerResult(); + providerErrors[provider] = value; + } + + return { + costs: parsed.costs, + projectCosts: parsed.projectCosts, + daily7d, + entryCounts, + providerErrors, + }; +} + +export function scanUsageLedgersInWorker( + projectRoot: string | null | undefined, + options: WorkerOptions = {}, +): Promise { + const embeddedRuntime = options.embeddedRuntime ?? isEmbeddedAdeRuntime(); + const workerPath = options.workerPath ?? (embeddedRuntime ? null : resolveUsageLedgerWorkerPath()); + if (workerPath && !fs.existsSync(workerPath)) { + return Promise.reject(new Error(`Usage ledger worker is missing: ${workerPath}`)); + } + if (options.signal?.aborted) return Promise.reject(abortError()); + + return new Promise((resolve, reject) => { + const spawnWorker = options.spawnWorker ?? spawn; + const env = { ...process.env }; + if (process.versions.electron) env.ELECTRON_RUN_AS_NODE = "1"; + let child: ChildProcessWithoutNullStreams; + try { + const workerArgs = embeddedRuntime + ? [INTERNAL_LEDGER_WORKER_ARG] + : [ + ...(!options.workerPath && workerPath?.endsWith(".ts") ? process.execArgv : []), + workerPath!, + ]; + child = spawnWorker(process.execPath, workerArgs, { + env, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }) as ChildProcessWithoutNullStreams; + } catch (error) { + reject(error); + return; + } + if (!options.spawnWorker && typeof child.pid === "number") { + try { + os.setPriority(child.pid, os.constants.priority.PRIORITY_BELOW_NORMAL); + } catch { + // Best effort: isolation is the correctness boundary; priority is an + // additional guard against ledger IO competing with active chats. + } + } + + let stdout = ""; + let stderr = ""; + let settled = false; + const finish = (callback: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + options.signal?.removeEventListener("abort", onAbort); + callback(); + }; + const fail = (error: Error) => { + finish(() => { + terminateProcessTree(child); + reject(error); + }); + }; + const onAbort = () => fail(abortError()); + const timeout = setTimeout(() => { + fail(new Error(`Usage ledger worker timed out after ${LEDGER_WORKER_TIMEOUT_MS}ms`)); + }, LEDGER_WORKER_TIMEOUT_MS); + timeout.unref?.(); + options.signal?.addEventListener("abort", onAbort, { once: true }); + + child.stdout.on("data", (chunk: Buffer | string) => { + stdout += chunk.toString(); + if (Buffer.byteLength(stdout, "utf8") > LEDGER_WORKER_MAX_OUTPUT_BYTES) { + fail(new Error("Usage ledger worker produced too much output")); + } + }); + child.stderr.on("data", (chunk: Buffer | string) => { + if (Buffer.byteLength(stderr, "utf8") >= LEDGER_WORKER_MAX_ERROR_BYTES) return; + stderr += chunk.toString(); + }); + child.on("error", (error) => finish(() => reject(error))); + child.on("close", (code, signal) => { + finish(() => { + if (code !== 0) { + const detail = stderr.trim().slice(0, LEDGER_WORKER_MAX_ERROR_BYTES); + reject(new Error(`Usage ledger worker exited with ${code ?? signal ?? "unknown"}${detail ? `: ${detail}` : ""}`)); + return; + } + try { + resolve(parseUsageLedgerWorkerResult(stdout)); + } catch (error) { + reject(error); + } + }); + }); + child.stdin.on("error", (error: NodeJS.ErrnoException) => { + if (error.code !== "EPIPE" && error.code !== "ERR_STREAM_DESTROYED") fail(error); + }); + child.stdin.end(JSON.stringify({ projectRoot: projectRoot ?? null })); + }); +} + +export const _testing = { + LEDGER_WORKER_TIMEOUT_MS, + LEDGER_WORKER_MAX_OUTPUT_BYTES, + LEDGER_WORKER_MAX_ERROR_BYTES, + INTERNAL_LEDGER_WORKER_ARG, +}; diff --git a/apps/desktop/src/main/services/usage/usageLedgerWorkerEntry.ts b/apps/desktop/src/main/services/usage/usageLedgerWorkerEntry.ts new file mode 100644 index 000000000..27fbeedbe --- /dev/null +++ b/apps/desktop/src/main/services/usage/usageLedgerWorkerEntry.ts @@ -0,0 +1,5 @@ +import { runUsageLedgerWorkerEntrypoint } from "./usageLedgerWorker"; + +void runUsageLedgerWorkerEntrypoint().then((exitCode) => { + process.exitCode = exitCode; +}); diff --git a/apps/desktop/src/main/services/usage/usageStatsStore.ts b/apps/desktop/src/main/services/usage/usageStatsStore.ts index 31b8d4b3a..1275f82b5 100644 --- a/apps/desktop/src/main/services/usage/usageStatsStore.ts +++ b/apps/desktop/src/main/services/usage/usageStatsStore.ts @@ -444,14 +444,21 @@ export function collectAdeDatabaseUsageStats( `, eventRange.params); const clientDailyRows = safeAll<{ - occurred_at: string; + active_date: string | null; client_surface: AdeUsageClientSurface; + interactions: number; }>(db, ` - select occurred_at, client_surface - from usage_events - where ${eventRange.sql} - order by occurred_at desc - limit ? + select date(occurred_at, 'localtime') active_date, + client_surface, + count(*) interactions + from ( + select occurred_at, client_surface + from usage_events + where ${eventRange.sql} + order by occurred_at desc + limit ? + ) + group by active_date, client_surface `, [...eventRange.params, DAILY_BUCKET_SCAN_MAX_ROWS]); // Summary day counts and streaks must not depend on the capped chart scans @@ -509,14 +516,24 @@ export function collectAdeDatabaseUsageStats( and kind in ('git_commit', 'git_push', 'pr_land', 'git_pull', 'git_sync_merge', 'git_sync_rebase') group by kind `, operationRange.params); - const operationDailyRows = safeAll<{ started_at: string; kind: string }>(db, ` - select started_at, kind - from operations - where ${operationRange.sql} - and status = 'succeeded' - and kind in ('git_commit', 'pr_land') - order by started_at desc - limit ? + const operationDailyRows = safeAll<{ + active_date: string | null; + kind: string; + operations: number; + }>(db, ` + select date(started_at, 'localtime') active_date, + kind, + count(*) operations + from ( + select started_at, kind + from operations + where ${operationRange.sql} + and status = 'succeeded' + and kind in ('git_commit', 'pr_land') + order by started_at desc + limit ? + ) + group by active_date, kind `, [...operationRange.params, DAILY_BUCKET_SCAN_MAX_ROWS]); const operationCounts = new Map(operationRows.map((row) => [row.kind, int(row.count)])); const activityCounts = new Map(interactionRows.map((row) => [row.action, int(row.count)])); @@ -654,24 +671,42 @@ export function collectAdeDatabaseUsageStats( return existing; }; const aiDailyRows = safeAll<{ - timestamp: string; + active_date: string | null; input_tokens: number; output_tokens: number; duration_ms: number; + calls: number; }>(db, ` - select timestamp, - coalesce(input_tokens, 0) input_tokens, - coalesce(output_tokens, 0) output_tokens, - coalesce(duration_ms, 0) duration_ms - from ai_usage_log - where ${aiRange.sql} - order by timestamp desc - limit ? + select date(timestamp, 'localtime') active_date, + sum(max(0, cast(coalesce(input_tokens, 0) as integer))) input_tokens, + sum(max(0, cast(coalesce(output_tokens, 0) as integer))) output_tokens, + sum(max(0, cast(coalesce(duration_ms, 0) as integer))) duration_ms, + count(*) calls + from ( + select timestamp, input_tokens, output_tokens, duration_ms + from ai_usage_log + where ${aiRange.sql} + order by timestamp desc + limit ? + ) + group by active_date `, [...aiRange.params, DAILY_BUCKET_SCAN_MAX_ROWS]); + const clientDailyScanCount = clientDailyRows.reduce( + (sum, row) => sum + int(row.interactions), + 0, + ); + const operationDailyScanCount = operationDailyRows.reduce( + (sum, row) => sum + int(row.operations), + 0, + ); + const aiDailyScanCount = aiDailyRows.reduce( + (sum, row) => sum + int(row.calls), + 0, + ); const cappedDailySources = [ - clientDailyRows.length === DAILY_BUCKET_SCAN_MAX_ROWS ? "usage_events" : null, - operationDailyRows.length === DAILY_BUCKET_SCAN_MAX_ROWS ? "operations" : null, - aiDailyRows.length === DAILY_BUCKET_SCAN_MAX_ROWS ? "ai_usage_log" : null, + clientDailyScanCount === DAILY_BUCKET_SCAN_MAX_ROWS ? "usage_events" : null, + operationDailyScanCount === DAILY_BUCKET_SCAN_MAX_ROWS ? "operations" : null, + aiDailyScanCount === DAILY_BUCKET_SCAN_MAX_ROWS ? "ai_usage_log" : null, ].filter((source): source is string => source !== null); if (cappedDailySources.length > 0) { logger?.debug("usage.daily_bucket_scan_capped", { @@ -680,7 +715,7 @@ export function collectAdeDatabaseUsageStats( }); } for (const row of aiDailyRows) { - const date = isoDate(row.timestamp); + const date = isoDate(row.active_date); if (!date) continue; const day = ensureDay(date); day.inputTokens = int(day.inputTokens) + int(row.input_tokens); @@ -703,21 +738,22 @@ export function collectAdeDatabaseUsageStats( day.deletions = int(day.deletions) + int(row.deletions); } for (const row of clientDailyRows) { - const date = isoDate(row.occurred_at); + const date = isoDate(row.active_date); if (!date) continue; + const interactions = int(row.interactions); const day = ensureDay(date); - day.interactions = int(day.interactions) + 1; + day.interactions = int(day.interactions) + interactions; day.clients = { ...(day.clients ?? {}), - [row.client_surface]: int(day.clients?.[row.client_surface]) + 1, + [row.client_surface]: int(day.clients?.[row.client_surface]) + interactions, }; } for (const row of operationDailyRows) { - const date = isoDate(row.started_at); + const date = isoDate(row.active_date); if (!date) continue; const day = ensureDay(date); - if (row.kind === "git_commit") day.commits = int(day.commits) + 1; - if (row.kind === "pr_land") day.prs = int(day.prs) + 1; + if (row.kind === "git_commit") day.commits = int(day.commits) + int(row.operations); + if (row.kind === "pr_land") day.prs = int(day.prs) + int(row.operations); } const streaks = calculateStreaks(activeDateRows.map((row) => row.active_date), range.until); diff --git a/apps/desktop/src/main/services/usage/usageTrackingService.test.ts b/apps/desktop/src/main/services/usage/usageTrackingService.test.ts index 2420d3b5f..82a0a226d 100644 --- a/apps/desktop/src/main/services/usage/usageTrackingService.test.ts +++ b/apps/desktop/src/main/services/usage/usageTrackingService.test.ts @@ -1958,6 +1958,280 @@ describe("createUsageTrackingService", () => { service.dispose(); }); + it("refreshes missing project costs from an established machine snapshot", async () => { + const logger = createLogger(); + const cachedAt = new Date().toISOString(); + const dependencies = { + ...createFastDependencies(), + scanGitHubStats: vi.fn(async () => ({ + repo: "arul28/ADE", + available: true, + fetchedAt: cachedAt, + error: null, + commitsCreated: 0, + prsTracked: 0, + prsOpen: 0, + prsMerged: 0, + prsClosed: 0, + prAdditions: 0, + prDeletions: 0, + filesChanged: 0, + daily: [], + })), + }; + const cachedSnapshot = { + version: 3, + snapshot: { + windows: [], + pacing: calculatePacing([]), + pacingByProvider: {}, + providerStatus: {}, + costs: [{ + provider: "codex", + last30dCostUsd: 1, + todayCostUsd: 0, + tokenBreakdown: {}, + }], + adeCosts: [], + extraUsage: [], + costsLastPolledAt: cachedAt, + lastPolledAt: cachedAt, + errors: [], + }, + }; + const previousVitest = process.env.VITEST; + const previousNodeEnv = process.env.NODE_ENV; + const readFileSync = vi.spyOn(fs, "readFileSync").mockImplementation((() => ( + JSON.stringify(cachedSnapshot) + )) as unknown as typeof fs.readFileSync); + let service: ReturnType; + try { + process.env.VITEST = "false"; + process.env.NODE_ENV = "development"; + service = createUsageTrackingService({ logger, dependencies }); + } finally { + readFileSync.mockRestore(); + if (previousVitest === undefined) delete process.env.VITEST; + else process.env.VITEST = previousVitest; + if (previousNodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = previousNodeEnv; + } + + await service.getAdeUsageStats({ preset: "7d", scope: "machine" }); + await vi.waitFor(() => expect(dependencies.scanGitHubStats).toHaveBeenCalledTimes(1)); + await new Promise((resolve) => setImmediate(resolve)); + expect(dependencies.scanClaudeLogs).not.toHaveBeenCalled(); + + const projectStats = await service.getAdeUsageStats({ preset: "7d", scope: "project" }); + expect(projectStats.freshness?.state).toBe("refreshing"); + await vi.waitFor(() => expect(dependencies.scanClaudeLogs).toHaveBeenCalledTimes(1)); + await new Promise((resolve) => setImmediate(resolve)); + + await service.getAdeUsageStats({ preset: "7d", scope: "project" }); + expect(dependencies.scanClaudeLogs).toHaveBeenCalledTimes(1); + await vi.waitFor(() => expect(dependencies.scanGitHubStats).toHaveBeenCalledTimes(2)); + await new Promise((resolve) => setImmediate(resolve)); + + expect((await service.getAdeUsageStats({ preset: "7d", scope: "project" })).freshness?.state).toBe("fresh"); + expect(dependencies.scanClaudeLogs).toHaveBeenCalledTimes(1); + + service.dispose(); + }); + + it("preserves established costs and backs off failed project scans without blocking explicit refresh", async () => { + const logger = createLogger(); + const now = Date.now(); + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(now); + const cachedAt = new Date(now).toISOString(); + const establishedCosts = [{ + provider: "codex" as const, + last30dCostUsd: 1, + todayCostUsd: 0, + tokenBreakdown: {}, + }]; + const scanUsageLedgers = vi.fn() + .mockRejectedValueOnce(new Error("automatic ledger worker failed")) + .mockRejectedValueOnce(new Error("explicit ledger worker failed")) + .mockResolvedValue({ + costs: [{ + provider: "codex" as const, + last30dCostUsd: 2, + todayCostUsd: 1, + tokenBreakdown: {}, + }], + projectCosts: [{ + provider: "codex" as const, + last30dCostUsd: 0.5, + todayCostUsd: 0.25, + tokenBreakdown: {}, + }], + daily7d: {}, + entryCounts: { codex: 1 }, + providerErrors: {}, + }); + const scanGitHubStats = vi.fn(async () => ({ + repo: "arul28/ADE", + available: true, + fetchedAt: cachedAt, + error: null, + commitsCreated: 0, + prsTracked: 0, + prsOpen: 0, + prsMerged: 0, + prsClosed: 0, + prAdditions: 0, + prDeletions: 0, + filesChanged: 0, + daily: [], + })); + const cachedSnapshot = { + version: 3, + snapshot: { + windows: [], + pacing: calculatePacing([]), + pacingByProvider: {}, + providerStatus: {}, + costs: establishedCosts, + adeCosts: [], + extraUsage: [], + costsLastPolledAt: cachedAt, + lastPolledAt: cachedAt, + errors: [], + }, + }; + const previousVitest = process.env.VITEST; + const previousNodeEnv = process.env.NODE_ENV; + const readFileSync = vi.spyOn(fs, "readFileSync").mockImplementation((() => ( + JSON.stringify(cachedSnapshot) + )) as unknown as typeof fs.readFileSync); + let service: ReturnType; + try { + process.env.VITEST = "false"; + process.env.NODE_ENV = "development"; + service = createUsageTrackingService({ + logger, + dependencies: { + pollClaudeUsage: vi.fn(async () => ({ windows: [] as never[], extraUsage: null, errors: [] as never[] })), + pollCodexUsage: vi.fn(async () => ({ windows: [] as never[], errors: [] as never[] })), + scanUsageLedgers, + scanGitHubStats, + }, + }); + } finally { + readFileSync.mockRestore(); + if (previousVitest === undefined) delete process.env.VITEST; + else process.env.VITEST = previousVitest; + if (previousNodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = previousNodeEnv; + } + + try { + expect((await service.getAdeUsageStats({ preset: "7d", scope: "project" })).freshness?.state).toBe("refreshing"); + await vi.waitFor(() => expect(scanUsageLedgers).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(logger.warn).toHaveBeenCalledWith( + "usage.refresh.history_failed", + expect.objectContaining({ reason: "automatic", failureCount: 1, retryDelayMs: 60_000 }), + )); + await new Promise((resolve) => setImmediate(resolve)); + + expect(service.getUsageSnapshot()).toMatchObject({ + costs: establishedCosts, + costsLastPolledAt: cachedAt, + }); + expect((await service.getAdeUsageStats({ preset: "7d", scope: "machine" })).freshness?.state).toBe("fresh"); + expect((await service.getAdeUsageStats({ preset: "7d", scope: "project" })).freshness?.state).toBe("stale"); + await service.getAdeUsageStats({ preset: "7d", scope: "project" }); + await new Promise((resolve) => setImmediate(resolve)); + expect(scanUsageLedgers).toHaveBeenCalledTimes(1); + + await expect(service.refreshHistory()).rejects.toThrow("explicit ledger worker failed"); + expect(scanUsageLedgers).toHaveBeenCalledTimes(2); + expect(logger.warn).toHaveBeenCalledWith( + "usage.refresh.history_failed", + expect.objectContaining({ reason: "user", failureCount: 2, retryDelayMs: 120_000 }), + ); + expect(service.getUsageSnapshot()).toMatchObject({ + costs: establishedCosts, + costsLastPolledAt: cachedAt, + }); + + await service.getAdeUsageStats({ preset: "7d", scope: "project" }); + await new Promise((resolve) => setImmediate(resolve)); + expect(scanUsageLedgers).toHaveBeenCalledTimes(2); + + nowSpy.mockReturnValue(now + 2 * 60_000); + expect((await service.getAdeUsageStats({ preset: "7d", scope: "project" })).freshness?.state).toBe("refreshing"); + await vi.waitFor(() => expect(scanUsageLedgers).toHaveBeenCalledTimes(3)); + await new Promise((resolve) => setImmediate(resolve)); + await service.getAdeUsageStats({ preset: "7d", scope: "project" }); + await vi.waitFor(() => expect(scanGitHubStats).toHaveBeenCalledTimes(3)); + await new Promise((resolve) => setImmediate(resolve)); + expect((await service.getAdeUsageStats({ preset: "7d", scope: "project" })).freshness?.state).toBe("fresh"); + } finally { + service.dispose(); + nowSpy.mockRestore(); + } + }); + + it("serves aged provider history without launching a surprise transcript rescan", async () => { + const logger = createLogger(); + const now = new Date("2026-07-22T12:00:00.000Z").getTime(); + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(now); + const dependencies = { + ...createFastDependencies(), + scanGitHubStats: vi.fn(async () => ({ + repo: null, + available: false, + fetchedAt: null, + error: null, + commitsCreated: 0, + prsTracked: 0, + prsOpen: 0, + prsMerged: 0, + prsClosed: 0, + prAdditions: 0, + prDeletions: 0, + filesChanged: 0, + daily: [], + })), + }; + const service = createUsageTrackingService({ logger, dependencies }); + await service.refreshHistory(); + for (const scanner of [ + dependencies.scanClaudeLogs, + dependencies.scanCodexLogs, + dependencies.scanCursorLogs, + dependencies.scanCursorAgentLogs, + dependencies.scanOpenClawLogs, + dependencies.scanOpenCodeLogs, + dependencies.scanDroidLogs, + dependencies.scanCopilotLogs, + dependencies.scanGeminiLogs, + ]) scanner.mockClear(); + + nowSpy.mockReturnValue(now + 2 * 60 * 60_000); + const stats = await service.getAdeUsageStats({ preset: "7d" }); + expect(stats.freshness?.state).toBe("refreshing"); + await vi.waitFor(() => expect(dependencies.scanGitHubStats).toHaveBeenCalledTimes(1)); + await new Promise((resolve) => setImmediate(resolve)); + const settled = await service.getAdeUsageStats({ preset: "7d" }); + expect(settled.freshness?.state).toBe("stale"); + for (const scanner of [ + dependencies.scanClaudeLogs, + dependencies.scanCodexLogs, + dependencies.scanCursorLogs, + dependencies.scanCursorAgentLogs, + dependencies.scanOpenClawLogs, + dependencies.scanOpenCodeLogs, + dependencies.scanDroidLogs, + dependencies.scanCopilotLogs, + dependencies.scanGeminiLogs, + ]) expect(scanner).not.toHaveBeenCalled(); + + service.dispose(); + nowSpy.mockRestore(); + }); + it("runs an explicit history scan independently from a pending startup quota poll", async () => { const logger = createLogger(); const dependencies = createFastDependencies(); @@ -4218,22 +4492,10 @@ describe("ADE database usage aggregation", () => { })); }); - it("caps raw daily bucket scans newest-first without throwing", async () => { + it("caps daily source windows newest-first before aggregating in SQLite", async () => { const db = await createStatsDb(); const logger = createLogger(); const originalAll = db.all.bind(db); - const newestRow = { - timestamp: "2026-07-08T12:00:00.000Z", - input_tokens: 1, - output_tokens: 0, - duration_ms: 0, - }; - const oldestRow = { - ...newestRow, - timestamp: "2026-07-07T12:00:00.000Z", - }; - const rowsBeyondCap = Array(250_001).fill(newestRow); - rowsBeyondCap[0] = oldestRow; let checkedClientQuery = false; let checkedOperationQuery = false; let checkedAiQuery = false; @@ -4241,20 +4503,38 @@ describe("ADE database usage aggregation", () => { ...db, all: ((sql: string, params = []) => { const normalized = sql.replace(/\s+/g, " ").trim(); - if (normalized.startsWith("select occurred_at, client_surface")) { - expect(normalized).toContain("order by occurred_at desc limit ?"); + if (normalized.startsWith("select date(occurred_at, 'localtime') active_date")) { + expect(normalized).toContain("from ( select occurred_at, client_surface"); + expect(normalized).toContain("order by occurred_at desc limit ? ) group by active_date, client_surface"); checkedClientQuery = true; + return [{ + active_date: "2026-07-08", + client_surface: "desktop", + interactions: 250_000, + }]; } - if (normalized.startsWith("select started_at, kind")) { - expect(normalized).toContain("order by started_at desc limit ?"); + if (normalized.startsWith("select date(started_at, 'localtime') active_date")) { + expect(normalized).toContain("from ( select started_at, kind"); + expect(normalized).toContain("order by started_at desc limit ? ) group by active_date, kind"); checkedOperationQuery = true; + return [{ + active_date: "2026-07-08", + kind: "git_commit", + operations: 250_000, + }]; } - if (normalized.startsWith("select timestamp,")) { - expect(normalized).toContain("order by timestamp desc limit ?"); - const limit = Number(params.at(-1)); - expect(rowsBeyondCap.length).toBeGreaterThan(limit); + if (normalized.startsWith("select date(timestamp, 'localtime') active_date")) { + expect(normalized).toContain("from ( select timestamp, input_tokens, output_tokens, duration_ms"); + expect(normalized).toContain("order by timestamp desc limit ? ) group by active_date"); + expect(params.at(-1)).toBe(250_000); checkedAiQuery = true; - return rowsBeyondCap.slice(1, limit + 1); + return [{ + active_date: "2026-07-08", + input_tokens: 250_000, + output_tokens: 0, + duration_ms: 0, + calls: 250_000, + }]; } return originalAll(sql, params); }) as AdeDb["all"], @@ -4272,12 +4552,15 @@ describe("ADE database usage aggregation", () => { date: "2026-07-08", inputTokens: 250_000, totalTokens: 250_000, + commits: 250_000, + interactions: 250_000, + clients: { desktop: 250_000 }, })); expect(stats?.daily.some((point) => point.date === "2026-07-07")).toBe(false); expect(logger.debug).toHaveBeenCalledTimes(1); expect(logger.debug).toHaveBeenCalledWith("usage.daily_bucket_scan_capped", { maxRows: 250_000, - sources: ["ai_usage_log"], + sources: ["usage_events", "operations", "ai_usage_log"], }); }); @@ -4373,11 +4656,13 @@ describe("ADE database usage aggregation", () => { ); db.run( `insert into operations(id, project_id, lane_id, kind, started_at, ended_at, status) - values (?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?)`, + values (?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?), + (?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?)`, [ "op-1", "project-1", "lane-1", "git_commit", "2026-07-08T12:20:00.000Z", "2026-07-08T12:20:01.000Z", "succeeded", "op-2", "project-1", "lane-1", "git_push", "2026-07-08T12:21:00.000Z", "2026-07-08T12:21:01.000Z", "succeeded", "op-3", "project-1", "lane-1", "git_commit", "2026-07-08T12:22:00.000Z", "2026-07-08T12:22:01.000Z", "failed", + "op-4", "project-1", "lane-1", "pr_land", "2026-07-08T12:23:00.000Z", "2026-07-08T12:23:01.000Z", "succeeded", ], ); recordUsageInteraction(db, { projectId: "project-1", client: "desktop", action: "chat.send", sessionId: "session-1", occurredAt: "2026-07-08T12:05:00.000Z" }); @@ -4395,6 +4680,7 @@ describe("ADE database usage aggregation", () => { chatSessions: 1, commitsCreated: 1, pushOperations: 1, + prLandings: 1, filesChanged: 5, insertions: 120, deletions: 20, @@ -4420,10 +4706,14 @@ describe("ADE database usage aggregation", () => { }), expect.objectContaining({ date: "2026-07-08", + inputTokens: 120, + outputTokens: 60, totalTokens: 180, + durationMs: 1_500, sessions: 1, filesChanged: 3, commits: 1, + prs: 1, interactions: 2, clients: { desktop: 1, mobile: 1 }, }), diff --git a/apps/desktop/src/main/services/usage/usageTrackingService.ts b/apps/desktop/src/main/services/usage/usageTrackingService.ts index a2fde8c0b..5b2330bad 100644 --- a/apps/desktop/src/main/services/usage/usageTrackingService.ts +++ b/apps/desktop/src/main/services/usage/usageTrackingService.ts @@ -86,6 +86,10 @@ import { collectAdeDatabaseUsageStats, type AdeDatabaseUsageStats, } from "./usageStatsStore"; +import { + scanUsageLedgersInWorker, + type UsageLedgerScanResult, +} from "./usageLedgerWorkerClient"; import type { FreshUsageProviderPollResult, UsageProviderPollContext, @@ -111,7 +115,9 @@ const ACTIVE_POLL_INTERVAL_MS = 60_000; const IDLE_POLL_INTERVAL_MS = 5 * 60_000; const IDLE_AFTER_MS = 15 * 60_000; const QUOTA_DEMAND_LEASE_MS = 90_000; -const COST_CACHE_TTL_MS = 10 * 60_000; // 10 min +const COST_CACHE_TTL_MS = 60 * 60_000; // 1 hour; history scans are intentionally low priority +const COST_REFRESH_RETRY_BASE_MS = 60_000; +const COST_REFRESH_RETRY_MAX_MS = 15 * 60_000; const CODEX_CLI_RPC_TIMEOUT_MS = 10_000; const CLAUDE_CLI_USAGE_TIMEOUT_MS = 16_000; const QUOTA_REFRESH_RESPONSE_TIMEOUT_MS = 20_000; @@ -902,7 +908,7 @@ async function pollCodexViaCliRpc(logger: Logger): Promise(7).fill(0); const today = new Date(nowMs); const bucketByDay = new Map(); @@ -1092,7 +1098,7 @@ const PROVIDER_ESTIMATION: Readonly; +export type ProviderTokenEntries = Map; function canonicalProjectRoot(projectRoot: string): string { const resolved = path.resolve(projectRoot); @@ -1116,7 +1122,7 @@ function tokenEntryMatchesProject(entry: TokenEntry, projectRoot: string | null return false; } -function buildCostSnapshots( +export function buildCostSnapshots( entriesByProvider: ProviderTokenEntries, scope: AdeUsageScope, projectRoot: string | null | undefined, @@ -2358,6 +2364,7 @@ type UsageTrackingDependencies = { scanGeminiLogs?: () => Promise; scanGitHubStats?: (range: ResolvedAdeUsageRange) => Promise; collectDatabaseStats?: (range: ResolvedAdeUsageRange) => AdeDatabaseUsageStats | null; + scanUsageLedgers?: (projectRoot: string | null | undefined, signal: AbortSignal) => Promise; }; type PollOptions = { @@ -2374,6 +2381,13 @@ function providerBackoffMs(result: UsageProviderPollResult, failureCount: number return exponential; } +function costRefreshBackoffMs(failureCount: number): number { + return Math.min( + COST_REFRESH_RETRY_MAX_MS, + COST_REFRESH_RETRY_BASE_MS * 2 ** Math.min(4, Math.max(0, failureCount - 1)), + ); +} + export function createUsageTrackingService({ logger, pollIntervalMs: configuredInterval, @@ -2403,6 +2417,8 @@ export function createUsageTrackingService({ ?? (cachedCosts.length > 0 || cachedAdeCosts.length > 0 ? lastSnapshot?.lastPolledAt : null); const cachedCostTimestampMs = cachedCostTimestampIso ? Date.parse(cachedCostTimestampIso) : Number.NaN; let costCacheTimestamp = Number.isFinite(cachedCostTimestampMs) ? cachedCostTimestampMs : 0; + let costRefreshFailureCount = 0; + let costRefreshNextRetryAtMs = 0; let cachedDaily7d: Partial> = lastSnapshot?.dailyUsage7d ?? {}; // Track the last poll that returned real windows per provider so carried-forward // (stale) data can still report when it was genuinely fresh. @@ -2446,6 +2462,19 @@ export function createUsageTrackingService({ ?? ((range: ResolvedAdeUsageRange) => scanGithubActivityStats(projectRoot, range)); const collectDatabaseStatsForRange = dependencies?.collectDatabaseStats ?? ((range: ResolvedAdeUsageRange) => collectAdeDatabaseUsageStats(db, range, logger)); + const hasInjectedLedgerScanners = Boolean( + dependencies?.scanClaudeLogs + || dependencies?.scanCodexLogs + || dependencies?.scanCursorLogs + || dependencies?.scanCursorAgentLogs + || dependencies?.scanOpenClawLogs + || dependencies?.scanOpenCodeLogs + || dependencies?.scanDroidLogs + || dependencies?.scanCopilotLogs + || dependencies?.scanGeminiLogs, + ); + const ledgerAbortController = new AbortController(); + let disposed = false; const emptySnapshot = (): UsageSnapshot => ({ windows: [], @@ -2460,6 +2489,7 @@ export function createUsageTrackingService({ }); function emitUpdate(snapshot: UsageSnapshot): void { + if (disposed) return; try { onUpdate?.(snapshot); } catch { @@ -2471,9 +2501,14 @@ export function createUsageTrackingService({ return { costs: cachedCosts, adeCosts: cachedAdeCosts }; } - async function pollCosts(): Promise<{ costs: CostSnapshot[]; adeCosts: CostSnapshot[] }> { + async function pollCosts( + options: { force?: boolean } = {}, + ): Promise<{ costs: CostSnapshot[]; adeCosts: CostSnapshot[] }> { const now = Date.now(); - if (costCacheTimestamp > 0 && now - costCacheTimestamp < COST_CACHE_TTL_MS && projectCostsReady) { + if (!options.force + && costCacheTimestamp > 0 + && now - costCacheTimestamp < COST_CACHE_TTL_MS + && projectCostsReady) { return cachedCostResult(); } @@ -2485,96 +2520,86 @@ export function createUsageTrackingService({ }); } - const [ - claudeEntries, - codexEntries, - cursorEntries, - cursorAgentEntries, - openClawEntries, - openCodeEntries, - droidEntries, - copilotEntries, - geminiEntries, - ] = await Promise.all([ - scanClaudeCostLogs().catch((err) => { - logger.warn("usage.cost_scan.claude_failed", { error: getErrorMessage(err) }); - return [] as TokenEntry[]; - }), - scanCodexCostLogs().catch((err) => { - logger.warn("usage.cost_scan.codex_failed", { error: getErrorMessage(err) }); - return [] as TokenEntry[]; - }), - scanCursorCostLogs().catch((err) => { - logger.warn("usage.cost_scan.cursor_failed", { error: getErrorMessage(err) }); - return [] as TokenEntry[]; - }), - scanCursorAgentCostLogs().catch((err) => { - logger.warn("usage.cost_scan.cursor_agent_failed", { error: getErrorMessage(err) }); - return [] as TokenEntry[]; - }), - scanOpenClawCostLogs().catch((err) => { - logger.warn("usage.cost_scan.openclaw_failed", { error: getErrorMessage(err) }); - return [] as TokenEntry[]; - }), - scanOpenCodeCostLogs().catch((err) => { - logger.warn("usage.cost_scan.opencode_failed", { error: getErrorMessage(err) }); - return [] as TokenEntry[]; - }), - scanDroidCostLogs().catch((err) => { - logger.warn("usage.cost_scan.droid_failed", { error: getErrorMessage(err) }); - return [] as TokenEntry[]; - }), - scanCopilotCostLogs().catch((err) => { - logger.warn("usage.cost_scan.copilot_failed", { error: getErrorMessage(err) }); - return [] as TokenEntry[]; - }), - scanGeminiCostLogs().catch((err) => { - logger.warn("usage.cost_scan.gemini_failed", { error: getErrorMessage(err) }); - return [] as TokenEntry[]; - }), - ]); - - const providerEntries: ProviderTokenEntries = new Map([ - ["claude", claudeEntries], - ["codex", codexEntries], - ["cursor", cursorEntries], - ["cursor-agent", cursorAgentEntries], - ["openclaw", openClawEntries], - ["opencode", openCodeEntries], - ["droid", droidEntries], - ["copilot", copilotEntries], - ["gemini", geminiEntries], - ]); - const costs = buildCostSnapshots(providerEntries, "machine", projectRoot); - const projectCosts = buildCostSnapshots(providerEntries, "project", projectRoot); - - const daily7d: Partial> = {}; - if (claudeEntries.length > 0) daily7d.claude = bucketDaily7d(claudeEntries, now); - if (codexEntries.length > 0) daily7d.codex = bucketDaily7d(codexEntries, now); + let scanResult: UsageLedgerScanResult; + if (!hasInjectedLedgerScanners) { + scanResult = await (dependencies?.scanUsageLedgers ?? ((root, signal) => ( + scanUsageLedgersInWorker(root, { signal }) + )))(projectRoot, ledgerAbortController.signal); + } else { + const scanInjected = async (provider: string, work: () => Promise): Promise => { + try { + return await work(); + } catch (error) { + logger.warn(`usage.cost_scan.${provider}_failed`, { error: getErrorMessage(error) }); + return []; + } + }; + const [ + claudeEntries, + codexEntries, + cursorEntries, + cursorAgentEntries, + openClawEntries, + openCodeEntries, + droidEntries, + copilotEntries, + geminiEntries, + ] = await Promise.all([ + scanInjected("claude", scanClaudeCostLogs), + scanInjected("codex", scanCodexCostLogs), + scanInjected("cursor", scanCursorCostLogs), + scanInjected("cursor_agent", scanCursorAgentCostLogs), + scanInjected("openclaw", scanOpenClawCostLogs), + scanInjected("opencode", scanOpenCodeCostLogs), + scanInjected("droid", scanDroidCostLogs), + scanInjected("copilot", scanCopilotCostLogs), + scanInjected("gemini", scanGeminiCostLogs), + ]); + const providerEntries: ProviderTokenEntries = new Map([ + ["claude", claudeEntries], + ["codex", codexEntries], + ["cursor", cursorEntries], + ["cursor-agent", cursorAgentEntries], + ["openclaw", openClawEntries], + ["opencode", openCodeEntries], + ["droid", droidEntries], + ["copilot", copilotEntries], + ["gemini", geminiEntries], + ]); + scanResult = { + costs: buildCostSnapshots(providerEntries, "machine", projectRoot), + projectCosts: buildCostSnapshots(providerEntries, "project", projectRoot), + daily7d: { + ...(claudeEntries.length > 0 ? { claude: bucketDaily7d(claudeEntries, now) } : {}), + ...(codexEntries.length > 0 ? { codex: bucketDaily7d(codexEntries, now) } : {}), + }, + entryCounts: Object.fromEntries( + Array.from(providerEntries, ([provider, entries]) => [provider, entries.length]), + ), + providerErrors: {}, + }; + } + if (disposed) throw new Error("Usage tracking service disposed during ledger scan"); + for (const [provider, error] of Object.entries(scanResult.providerErrors)) { + logger.warn(`usage.cost_scan.${provider}_failed`, { error }); + } - cachedCosts = costs; + cachedCosts = scanResult.costs; cachedAdeCosts = []; - cachedProjectCosts = projectCosts; + cachedProjectCosts = scanResult.projectCosts; projectCostsReady = true; - cachedDaily7d = daily7d; + cachedDaily7d = scanResult.daily7d; costCacheTimestamp = now; const durationMs = Date.now() - startedAt; if (durationMs > 500) { logger.warn("usage.cost_scan_slow", { durationMs, - providerCount: costs.length, - claudeEntries: claudeEntries.length, - codexEntries: codexEntries.length, - cursorEntries: cursorEntries.length, - cursorAgentEntries: cursorAgentEntries.length, - openClawEntries: openClawEntries.length, - openCodeEntries: openCodeEntries.length, - droidEntries: droidEntries.length, - copilotEntries: copilotEntries.length, - geminiEntries: geminiEntries.length, + isolated: !hasInjectedLedgerScanners, + providerCount: scanResult.costs.length, + entryCounts: scanResult.entryCounts, }); } - return { costs, adeCosts: [] }; + return { costs: scanResult.costs, adeCosts: [] }; } async function poll(options: PollOptions = {}): Promise { @@ -2880,17 +2905,22 @@ export function createUsageTrackingService({ options: { reason?: UsageRefreshReason } = {}, ): Promise { if (inFlightHistoryRefresh) return await inFlightHistoryRefresh; - costCacheTimestamp = 0; + const reason = options.reason ?? "user"; + if (reason === "automatic" && Date.now() < costRefreshNextRetryAtMs) { + return lastSnapshot ?? emptySnapshot(); + } githubStatsCache.clear(); githubStatsInFlight.clear(); const startedAt = Date.now(); let current!: Promise; current = measureUsagePhase( logger, - { phase: "history", reason: options.reason ?? "user" }, - pollCosts, + { phase: "history", reason }, + () => pollCosts({ force: true }), ) .then((costResult) => { + costRefreshFailureCount = 0; + costRefreshNextRetryAtMs = 0; const refreshedAt = nowIso(); const snapshot: UsageSnapshot = { ...(lastSnapshot ?? emptySnapshot()), @@ -2912,6 +2942,18 @@ export function createUsageTrackingService({ }); return snapshot; }) + .catch((error) => { + costRefreshFailureCount += 1; + const retryDelayMs = costRefreshBackoffMs(costRefreshFailureCount); + costRefreshNextRetryAtMs = Date.now() + retryDelayMs; + logger.warn("usage.refresh.history_failed", { + reason, + failureCount: costRefreshFailureCount, + retryDelayMs, + error: getErrorMessage(error), + }); + throw error; + }) .finally(() => { if (inFlightHistoryRefresh === current) inFlightHistoryRefresh = null; }); @@ -2930,10 +2972,18 @@ export function createUsageTrackingService({ const snapshot = scope === "project" ? { ...machineSnapshot, costs: cachedProjectCosts } : machineSnapshot; - const staleCosts = costCacheTimestamp === 0 - || nowMs - costCacheTimestamp > COST_CACHE_TTL_MS - || (scope === "project" && !projectCostsReady); - const providerNeedsRefresh = staleCosts; + const providerHistoryMissing = costCacheTimestamp === 0; + const projectHistoryMissing = scope === "project" && !projectCostsReady; + const providerHistoryIncomplete = providerHistoryMissing || projectHistoryMissing; + const providerHistoryStale = providerHistoryIncomplete + || nowMs - costCacheTimestamp > COST_CACHE_TTL_MS; + // Reading the compact Activity card must never start a multi-gigabyte + // transcript walk merely because a cached history snapshot aged out. A + // first-run install still populates history in the isolated worker, while + // established installs keep serving aged history until an explicit refresh. + // Project scope is the exception because project costs are not persisted. + const providerNeedsRefresh = providerHistoryIncomplete + && nowMs >= costRefreshNextRetryAtMs; const githubNeedsRefresh = !githubCached || nowMs - (githubStatsCache.get(cacheKey)?.fetchedAtMs ?? 0) > GITHUB_STATS_CACHE_TTL_MS; if (providerNeedsRefresh || githubNeedsRefresh) { refreshStatsInBackground(range, { provider: providerNeedsRefresh, github: githubNeedsRefresh }, exactRange); @@ -2946,7 +2996,9 @@ export function createUsageTrackingService({ nowMs, }); stats.freshness = { - state: providerNeedsRefresh || githubNeedsRefresh ? "refreshing" : "fresh", + state: providerNeedsRefresh || githubNeedsRefresh + ? "refreshing" + : providerHistoryStale ? "stale" : "fresh", providerUpdatedAt: machineSnapshot.costsLastPolledAt ?? null, githubUpdatedAt: githubCached?.fetchedAt ?? null, }; @@ -3036,7 +3088,11 @@ export function createUsageTrackingService({ refreshHistory, getAdeUsageStats, poll, - dispose: stop, + dispose: () => { + disposed = true; + ledgerAbortController.abort(); + stop(); + }, }; } diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index 7fdfcff74..e490f6903 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -168,6 +168,58 @@ describe("preload OAuth bridge", () => { expect(invoke).toHaveBeenCalledWith(IPC.appMarkWelcomeVideoSeen, { reason: "completed" }); }); + it("preserves exact lookup and launch overrides in the external-session IPC fallback", async () => { + const imported = { + kind: "cli", + sessionId: "terminal-1", + ptyId: "pty-1", + laneId: "lane-1", + }; + const invoke = vi.fn(async (channel: string) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 7, project: null, binding: null }; + } + if (channel === IPC.externalSessionsList) return []; + if (channel === IPC.externalSessionsImport) return imported; + return undefined; + }); + const exposeInMainWorld = vi.fn((_name: string, value: unknown) => { + (globalThis as any).__adeBridge = value; + }); + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on: vi.fn(), removeListener: vi.fn() }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + const bridge = (globalThis as any).__adeBridge; + const listArgs = { + providers: ["codex"], + sessionId: "native-session-1", + limit: 1, + }; + const importArgs = { + provider: "codex", + sessionId: "native-session-1", + laneId: "lane-1", + target: "cli", + mode: "resume", + reasoningEffort: "high", + fastMode: true, + }; + + await expect(bridge.externalSessions.list(listArgs)).resolves.toEqual([]); + await expect(bridge.externalSessions.import(importArgs)).resolves.toEqual(imported); + + expect(invoke).toHaveBeenCalledWith(IPC.externalSessionsList, listArgs); + expect(invoke).toHaveBeenCalledWith(IPC.externalSessionsImport, importArgs); + }); + it("exposes review IPC methods and cleans up listeners", async () => { const invoke = vi.fn(async () => undefined); const on = vi.fn(); diff --git a/apps/desktop/src/renderer/components/account/AccountPage.test.tsx b/apps/desktop/src/renderer/components/account/AccountPage.test.tsx index 55efaaa22..5f47ca756 100644 --- a/apps/desktop/src/renderer/components/account/AccountPage.test.tsx +++ b/apps/desktop/src/renderer/components/account/AccountPage.test.tsx @@ -67,6 +67,7 @@ describe("AccountPage signed-out card", () => { const originalAde = window.ade; beforeEach(() => { + delete window.__adeWebClient; statusRef.current = SIGNED_OUT; window.ade = { app: { openExternal: vi.fn(async () => undefined) }, @@ -79,6 +80,7 @@ describe("AccountPage signed-out card", () => { afterEach(() => { cleanup(); + delete window.__adeWebClient; beginLogin.mockClear(); refreshAccount.mockClear(); window.ade = originalAde; @@ -142,6 +144,7 @@ describe("AccountPage signed-in", () => { const signOut = vi.fn(async () => SIGNED_OUT); beforeEach(() => { + delete window.__adeWebClient; statusRef.current = { signedIn: true, configured: true, @@ -173,6 +176,7 @@ describe("AccountPage signed-in", () => { afterEach(() => { cleanup(); + delete window.__adeWebClient; listMachines.mockReset(); getLocalMachineIdentity.mockReset(); removeMachine.mockClear(); @@ -306,4 +310,22 @@ describe("AccountPage signed-in", () => { expect(screen.queryByRole("button", { name: "Mobile" })).toBeNull(); expect(screen.queryByRole("button", { name: "Web clients" })).toBeNull(); }); + + it("does not offer the desktop Connections panel in hosted web mode", async () => { + window.__adeWebClient = true; + renderPage(); + await screen.findByText("MacBook Pro"); + + expect(screen.queryByRole("button", { name: /Manage connections/ })).toBeNull(); + }); + + it("directs hosted web users to the machine menu when the directory is unavailable", async () => { + window.__adeWebClient = true; + listMachines.mockResolvedValueOnce({ state: "unavailable", message: null, machines: [] }); + + renderPage(); + + expect(await screen.findByText("Use the machine menu above to switch Macs.")).toBeTruthy(); + expect(screen.queryByText(/still connect from Connections/)).toBeNull(); + }); }); diff --git a/apps/desktop/src/renderer/components/account/AccountPage.tsx b/apps/desktop/src/renderer/components/account/AccountPage.tsx index be2f99de2..47c8127f2 100644 --- a/apps/desktop/src/renderer/components/account/AccountPage.tsx +++ b/apps/desktop/src/renderer/components/account/AccountPage.tsx @@ -48,6 +48,7 @@ import { } from "../remoteTargets/remoteMachineModel"; import { openConnectionsPanel } from "../../lib/connectionsPanel"; import { openExternalUrl } from "../../lib/openExternal"; +import { isWebClientMode } from "../../lib/webClientMode"; import { docs } from "../../onboarding/docsLinks"; import { useClampedFixedPosition } from "../../hooks/useClampedFixedPosition"; @@ -387,6 +388,7 @@ export function SignInCard({ // --------------------------------------------------------------------------- function YourMacsCard() { + const webMode = isWebClientMode(); const [result, setResult] = useState(null); const [loading, setLoading] = useState(true); const [localIdentity, setLocalIdentity] = useState(null); @@ -544,14 +546,16 @@ function YourMacsCard() {
{summary}
- + {!webMode ? ( + + ) : null} {result?.state === "ok" && machines.length > 0 ? ( @@ -676,7 +680,9 @@ function YourMacsCard() { }} > - {result.state === "not_configured" + {webMode + ? "Use the machine menu above to switch Macs." + : result.state === "not_configured" ? "Your Macs still connect from Connections — the shared directory just isn't live yet." : "Your Macs still connect from Connections while the directory reconnects."} diff --git a/apps/desktop/src/renderer/components/app/TopBar.test.tsx b/apps/desktop/src/renderer/components/app/TopBar.test.tsx index f863a2e1d..e699d5836 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.test.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.test.tsx @@ -421,6 +421,16 @@ describe("TopBar", () => { }); }); + it("hides the desktop Connections panel controls in hosted web mode", () => { + globalThis.window.__adeWebClient = true; + + render(); + + expect(screen.queryByRole("button", { name: /^Connections, (?:not )?connected$/ })).toBeNull(); + act(() => openConnectionsPanel("machines")); + expect(screen.queryByRole("dialog", { name: "Connections" })).toBeNull(); + }); + it("shows a closable Chats pseudo-tab when chats are open without a project", () => { const { onNavigate } = renderChatsTopBar({ personalChatsRouteActive: true, diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index 25bbba3d3..59f4229ab 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -899,9 +899,10 @@ export function TopBar({ const connectionsPanelRef = useRef(null); const closeConnections = useCallback(() => setConnectionsOpen(false), []); const openConnections = useCallback((tab: ConnectionsPanelTab = "machines") => { + if (webMode) return; setConnectionsTab(tab); setConnectionsOpen(true); - }, []); + }, [webMode]); const handleConnectionsPanelKeyDown = useDialogFocusTrap( connectionsPanelRef, closeConnections, @@ -911,7 +912,7 @@ export function TopBar({ const isProjectBusy = projectTransition != null || relocatingPath != null; const remoteBinding = projectBinding?.kind === "remote" ? projectBinding : null; - const chromePanelOccludesNativeBrowser = connectionsOpen; + const chromePanelOccludesNativeBrowser = !webMode && connectionsOpen; const workspaceProjectOpen = projectHydrated === true && showWelcome !== true && @@ -1228,10 +1229,11 @@ export function TopBar({ // Let other surfaces (e.g. the Account page) open the Connections panel to a // specific tab. useEffect(() => { + if (webMode) return; return subscribeOpenConnectionsPanel((tab) => { openConnections(tab); }); - }, [openConnections]); + }, [openConnections, webMode]); const checkForActiveWorkloads = useCallback( async (projectRootPath: string): Promise => { @@ -1755,7 +1757,7 @@ export function TopBar({ options?.onActivate?.(); }; - const connectionsChip = ( + const connectionsChip = webMode ? null : ( { }); }); + it("resyncs model tuning controls when a returned chat finishes hydrating", async () => { + const session = buildSession("session-1", { + status: "idle", + reasoningEffort: "medium", + fastMode: false, + executionMode: "focused", + }); + const sessions = [session]; + const { emitChatEvent } = installAdeMocks({ sessions }); + + renderPane(session); + + const fastModeButton = await screen.findByRole("button", { name: "Fast mode" }); + expect(fastModeButton.getAttribute("aria-pressed")).toBe("false"); + + sessions[0] = { + ...session, + reasoningEffort: "xhigh", + fastMode: true, + executionMode: "teams", + }; + emitChatEvent({ + sessionId: session.sessionId, + timestamp: "2026-03-24T07:15:00.000Z", + event: { + type: "done", + status: "completed", + turnId: "turn-hydrated", + model: "gpt-5.4", + }, + }); + + await waitFor(() => { + expect(fastModeButton.getAttribute("aria-pressed")).toBe("true"); + expect(screen.getByLabelText("Reasoning effort").textContent).toContain("XH"); + }); + }); + it("exits plan mode in the composer chip when an exit notice arrives even if the session refetch is stale", async () => { // Reproduces the production bug: the backend accepted the plan and emitted // the exit notice, but the debounced session refetch still reports plan diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 9a1071374..8b00431da 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -5727,6 +5727,9 @@ export function AgentChatPane({ }, [ selectedSession?.sessionId, selectedSessionModelId, + selectedSession?.reasoningEffort, + selectedSession?.fastMode, + selectedSession?.executionMode, selectedSession?.interactionMode, selectedSession?.claudePermissionMode, selectedSession?.codexApprovalPolicy, diff --git a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts index dcb08ae2a..d40bfaebd 100644 --- a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts +++ b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { AgentChatSession, TerminalSessionSummary } from "../../../shared/types"; +import type { AgentChatSession, TerminalResumeLaunchConfig, TerminalSessionSummary } from "../../../shared/types"; import { selectActiveProjectRoot, useAppStore, useAppStoreApi, type WorkDraftKind, type WorkProjectViewState } from "../../state/appStore"; import { listSessionsCached, invalidateSessionListCache } from "../../lib/sessionListCache"; import { sessionStatusBucket } from "../../lib/terminalAttention"; @@ -9,6 +9,7 @@ import { } from "../../lib/chatSessionEvents"; import { buildOptimisticChatSessionSummary, isRunOwnedSession } from "../../lib/sessions"; import { + buildPtyContinuationLaunchFields, forgetWorkPtyLaunchPin, LAUNCH_PROFILE_TITLE, LAUNCH_PROFILE_TOOL_TYPE, @@ -781,12 +782,17 @@ export function useLaneWorkSessions(laneId: string | null) { void refresh({ showLoading: false, force: true }); }, [focusSession, laneId, openSessionTab, refresh, selectLane, upsertOptimisticChatSession]); - const continueCliSession = useCallback(async (session: TerminalSessionSummary, text: string) => { + const continueCliSession = useCallback(async ( + session: TerminalSessionSummary, + text: string, + launch: TerminalResumeLaunchConfig | null = null, + ) => { const sendArgs = { sessionId: session.id, text, cols: 100, rows: 30, + ...buildPtyContinuationLaunchFields(launch), }; const pin = workPtyLaunchPinFor(session); const result = pin diff --git a/apps/desktop/src/renderer/components/onboarding/LaunchGate.test.tsx b/apps/desktop/src/renderer/components/onboarding/LaunchGate.test.tsx index 68f597fee..a14256bc2 100644 --- a/apps/desktop/src/renderer/components/onboarding/LaunchGate.test.tsx +++ b/apps/desktop/src/renderer/components/onboarding/LaunchGate.test.tsx @@ -76,15 +76,17 @@ describe("LaunchGate", () => { expect(screen.queryByText(/Use ADE on this Mac without an account/i)).toBeNull(); expect(screen.getByTestId("launch-gate-drag-region").getAttribute("data-app-region")).toBe("drag"); expect(screen.queryByText("Application")).toBeNull(); - expect(captureAnalytics).toHaveBeenCalledWith({ - event: "ade_screen_viewed", - properties: { - screen: "onboarding", - route_kind: "desktop", - source: "renderer_startup", - }, - dedupeKey: "desktop_launch_account_choice", - minimumIntervalMs: 60 * 60_000, + await waitFor(() => { + expect(captureAnalytics).toHaveBeenCalledWith({ + event: "ade_screen_viewed", + properties: { + screen: "onboarding", + route_kind: "desktop", + source: "renderer_startup", + }, + dedupeKey: "desktop_launch_account_choice", + minimumIntervalMs: 60 * 60_000, + }); }); fireEvent.click(screen.getByRole("button", { name: /continue without an account/i })); diff --git a/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx b/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx index 057de39c8..3f48861c2 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx @@ -2224,6 +2224,69 @@ describe("TerminalView", () => { expect(window.ade.terminal.preview).not.toHaveBeenCalled(); }); + it("accepts input immediately when a live terminal is reopened after a project switch", async () => { + const previewMock = window.ade.terminal.preview as unknown as ReturnType; + let resolvePreview!: (value: unknown) => void; + const pendingPreview = new Promise((resolve) => { + resolvePreview = resolve; + }); + previewMock.mockImplementation(() => pendingPreview); + + const firstView = render( + , + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(120); + }); + + const terminal = mockState.terminalInstances.at(-1) as { + onData: ReturnType; + } | undefined; + expect(terminal).toBeTruthy(); + expect(previewMock).toHaveBeenCalled(); + + firstView.unmount(); + mockState.projectRoot = "/project/b"; + mockState.projectRevision += 1; + disposeTerminalRuntimesForProjectChange(mockState.projectRoot, mockState.projectRevision); + + mockState.projectRoot = "/project/a"; + mockState.projectRevision += 1; + render(); + await act(async () => { + await vi.advanceTimersByTimeAsync(120); + }); + + // Reopening reuses the parked live xterm. Its transcript hydration is + // deliberately still unresolved, but keyboard input is wired by + // createRuntime and must not wait for that snapshot. + expect(mockState.terminalInstances).toHaveLength(1); + const onData = terminal?.onData.mock.calls.at(-1)?.[0] as ((data: string) => void) | undefined; + expect(onData).toBeTruthy(); + + const ptyWrite = window.ade.pty.write as unknown as ReturnType; + ptyWrite.mockClear(); + onData!("\r"); + + expect(ptyWrite).toHaveBeenCalledTimes(1); + expect(ptyWrite).toHaveBeenCalledWith({ + ptyId: "pty-reopen-input", + data: "\r", + }); + + await act(async () => { + resolvePreview({ + terminalId: "session-reopen-input", + session: null, + source: "empty", + snapshot: null, + transcript: null, + capturedAt: new Date().toISOString(), + }); + await Promise.resolve(); + }); + }); + it("does not force live PTY output back to the bottom after the user scrolls up", async () => { render(); await flushAnimationFrame(); diff --git a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx index ea44a9394..3f686face 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx @@ -8,7 +8,7 @@ import { WorkSidebar, type WorkSidebarContextTarget } from "./WorkSidebar"; import { SessionContextMenu, type SessionContextMenuState } from "./SessionContextMenu"; import { SessionInfoPopover, type InfoPopoverState } from "./SessionInfoPopover"; import { ConfirmDialog, useConfirmDialog } from "../shared/InlineDialogs"; -import type { AgentChatSession, TerminalSessionSummary } from "../../../shared/types"; +import type { AgentChatSession, TerminalResumeLaunchConfig, TerminalSessionSummary } from "../../../shared/types"; import { buildDeeplink } from "../../../shared/deeplinks"; import { parseGithubRemoteUrl } from "../../../shared/githubRemote"; import { buildWebClientUrl } from "../../../shared/webClientUrl"; @@ -32,6 +32,7 @@ import { } from "../../lib/handoffLaunchJobs"; import { getLaneDeleteStatusLabel } from "../../lib/laneDeleteProgress"; import { useWorkLaneDeleteProgress } from "./useWorkLaneDeleteProgress"; +import { buildPtyContinuationLaunchFields } from "./cliLaunch"; const TERMINALS_TILING_TREE: PaneSplit = { type: "split", @@ -569,7 +570,7 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { }, [selectedSessions, stopAndDeleteConfirm, work]); const handleContinueCliSession = useCallback( - async (session: TerminalSessionSummary, text: string) => { + async (session: TerminalSessionSummary, text: string, launch: TerminalResumeLaunchConfig | null) => { setSessionActionError(null); try { const result = await window.ade.pty.sendToSession({ @@ -577,6 +578,7 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { text, cols: 100, rows: 30, + ...buildPtyContinuationLaunchFields(launch), }); invalidateSessionListCache(); // Patch the local sessions list with the freshly-resumed snapshot so diff --git a/apps/desktop/src/renderer/components/terminals/WorkViewArea.test.tsx b/apps/desktop/src/renderer/components/terminals/WorkViewArea.test.tsx index 892028607..185632e07 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkViewArea.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkViewArea.test.tsx @@ -190,6 +190,7 @@ const modelsMock = vi.fn(); const sendToSessionMock = vi.fn(); const resumeSessionMock = vi.fn(); const resourceUsageMock = vi.fn(); +const externalSessionsListMock = vi.fn(); const resolvePtyLaunch = async () => ({ sessionId: "test-session", ptyId: "test-pty", pid: null }); beforeEach(() => { @@ -239,6 +240,8 @@ beforeEach(() => { freeMemoryMB: 12_000, totalMemoryMB: 16_000, }); + externalSessionsListMock.mockReset(); + externalSessionsListMock.mockResolvedValue([]); Object.defineProperty(window, "ade", { configurable: true, value: { @@ -254,6 +257,9 @@ beforeEach(() => { resumeSession: resumeSessionMock, sendToSession: sendToSessionMock, }, + externalSessions: { + list: externalSessionsListMock, + }, terminal: { preview: terminalPreviewMock, }, @@ -1050,12 +1056,16 @@ describe("WorkViewArea", () => { fireEvent.change(textarea, { target: { value: "fix the test" } }); fireEvent.keyDown(textarea, { key: "Enter" }); - await waitFor(() => expect(onContinue).toHaveBeenCalledWith(session, "fix the test")); + await waitFor(() => expect(onContinue).toHaveBeenCalledWith( + session, + "fix the test", + { permissionMode: "plan" }, + )); expect((window.ade as any).app.writeClipboardText).toHaveBeenCalledWith("fix the test"); expect((textarea as HTMLTextAreaElement).value).toBe(""); }); - it("does not show resume-time model or permission controls", async () => { + it("shows saved resume state without presenting misleading editable controls", async () => { const session = { ...makeSession(), toolType: "codex" as const, @@ -1064,7 +1074,11 @@ describe("WorkViewArea", () => { provider: "codex" as const, targetKind: "thread" as const, targetId: "thread-1", - launch: { permissionMode: "plan" as const }, + launch: { + model: "gpt-5.4", + reasoningEffort: "high", + permissionMode: "plan" as const, + }, }, }; const view = render( @@ -1085,10 +1099,123 @@ describe("WorkViewArea", () => { const local = within(view.container); expect(await local.findByLabelText("Continue Codex session")).toBeTruthy(); + expect(local.getByText("GPT-5.4")).toBeTruthy(); + expect(local.getByText("high")).toBeTruthy(); + expect(local.getByText("Plan")).toBeTruthy(); expect(local.queryByRole("button", { name: /Select model/i })).toBeNull(); expect(local.queryByLabelText("Codex permission mode")).toBeNull(); }); + it("recovers imported Codex launch state once and uses it for continuation", async () => { + const onContinue = vi.fn().mockResolvedValue(undefined); + externalSessionsListMock.mockResolvedValue([{ + provider: "codex", + id: "019f8135-cd9d-7ba1-8f4f-f594d76d8273", + cwd: "/tmp/lane-1", + title: "Imported Codex", + preview: null, + createdAt: null, + updatedAt: null, + messageCount: null, + launch: { + model: "gpt-5.6-sol", + reasoningEffort: "max", + fastMode: true, + permissionMode: "full-auto", + codexApprovalPolicy: "never", + codexSandbox: "danger-full-access", + codexConfigSource: "flags", + }, + alreadyImported: true, + importedSessionRef: { kind: "cli", sessionId: "session-1" }, + possiblyActive: false, + cwdMatchesRequestedLane: true, + capabilities: { + resumeInPlace: true, + resumeInDifferentCwd: true, + fork: true, + forkIntoDifferentCwd: true, + importToChat: true, + }, + }]); + const session = { + ...makeSession(), + toolType: "codex" as const, + resumeCommand: "codex resume 019f8135-cd9d-7ba1-8f4f-f594d76d8273", + resumeMetadata: { + provider: "codex" as const, + targetKind: "thread" as const, + targetId: "019f8135-cd9d-7ba1-8f4f-f594d76d8273", + launch: {}, + importedFrom: { + provider: "codex" as const, + targetId: "019f8135-cd9d-7ba1-8f4f-f594d76d8273", + mode: "resume" as const, + }, + }, + }; + const view = render( + {}} + onCloseItem={() => {}} + onOpenChatSession={() => {}} + onLaunchPtySession={resolvePtyLaunch} + onShowDraftKind={() => {}} + closingPtyIds={new Set()} + onContinueCliSession={onContinue} + />, + ); + const local = within(view.container); + + expect(await local.findByText("GPT-5.6 Sol")).toBeTruthy(); + expect(local.getByText("max")).toBeTruthy(); + expect(local.getByText("Fast")).toBeTruthy(); + expect(local.getByText("Full access")).toBeTruthy(); + expect(externalSessionsListMock).toHaveBeenCalledTimes(1); + expect(externalSessionsListMock).toHaveBeenCalledWith({ + providers: ["codex"], + scope: "all", + sessionId: "019f8135-cd9d-7ba1-8f4f-f594d76d8273", + limit: 1, + }); + + view.rerender( + {}} + onCloseItem={() => {}} + onOpenChatSession={() => {}} + onLaunchPtySession={resolvePtyLaunch} + onShowDraftKind={() => {}} + closingPtyIds={new Set()} + onContinueCliSession={onContinue} + />, + ); + expect(externalSessionsListMock).toHaveBeenCalledTimes(1); + + const textarea = local.getByLabelText("Continue Codex session"); + fireEvent.change(textarea, { target: { value: "continue" } }); + fireEvent.keyDown(textarea, { key: "Enter" }); + await waitFor(() => expect(onContinue).toHaveBeenCalledWith(session, "continue", { + model: "gpt-5.6-sol", + reasoningEffort: "max", + fastMode: true, + permissionMode: "full-auto", + codexApprovalPolicy: "never", + codexSandbox: "danger-full-access", + codexConfigSource: "flags", + })); + }); + it("shows provider-specific slash command suggestions in the continuation composer", async () => { slashCommandsMock.mockResolvedValue([ { name: "/status", description: "Show status", source: "sdk" }, diff --git a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx index d60ae1449..eaf9f7007 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx @@ -14,6 +14,7 @@ import type { LaneLinearIssue, LaneSummary, TerminalResumeProvider, + TerminalResumeLaunchConfig, TerminalSessionSummary, TerminalSnapshotCell, TerminalSnapshotRow, @@ -44,7 +45,13 @@ import { useChatPrAutoPop } from "../chat/useChatPrAutoPop"; import { isChatToolType, primarySessionLabel, stripTerminalLabelControls, formatToolTypeLabel } from "../../lib/sessions"; import { SmartTooltip } from "../ui/SmartTooltip"; import { cn } from "../ui/cn"; -import { launchProfileForTerminalSession, type WorkPtyLaunchArgs, type WorkPtyLaunchResult } from "./cliLaunch"; +import { + launchProfileForTerminalSession, + mergeContinuationLaunch, + recoverImportedContinuationLaunch, + type WorkPtyLaunchArgs, + type WorkPtyLaunchResult, +} from "./cliLaunch"; import type { ExternalSessionImportResult, ExternalSessionSummary } from "./importSessions/contract"; import { useWorkLaneContextMenu } from "./useWorkLaneContextMenu"; import { copyLaunchPromptToClipboard } from "../../lib/launchPromptClipboard"; @@ -305,21 +312,59 @@ function continuationProviderLabel(provider: TerminalResumeProvider | null): str return "agent CLI"; } +function continuationPermissionLabel(launch: TerminalResumeLaunchConfig | null): string | null { + if (launch?.codexApprovalPolicy === "on-request") return "Ask first"; + if (launch?.codexApprovalPolicy === "on-failure") return "On failure"; + if (launch?.codexApprovalPolicy === "untrusted") return "Restricted"; + if (launch?.codexApprovalPolicy === "never" && launch.codexSandbox === "danger-full-access") return "Full access"; + const mode = launch?.permissionMode; + if (mode === "full-auto") return "Full access"; + if (mode === "plan") return "Plan"; + if (mode === "edit") return "Edit"; + if (mode === "auto") return "Auto"; + if (mode === "config-toml") return "Config"; + if (mode === "default") return "Default"; + return null; +} + function WorkCliContinuationComposer({ session, onContinue, }: { session: TerminalSessionSummary; - onContinue?: (session: TerminalSessionSummary, text: string) => Promise | void; + onContinue?: ( + session: TerminalSessionSummary, + text: string, + launch: TerminalResumeLaunchConfig | null, + ) => Promise | void; }) { const provider = continuationProviderForSession(session); const providerLabel = continuationProviderLabel(provider); // Mirror the active chat composer's model pill: resolve the model the session was // launched with (recorded on its resume metadata) so we show the same glyph + name. - const modelId = session.resumeMetadata?.launch?.model?.trim() || null; + const storedLaunch = session.resumeMetadata?.launch ?? null; + const importedProvider = session.resumeMetadata?.importedFrom?.provider ?? null; + const importedTargetId = session.resumeMetadata?.importedFrom?.targetId?.trim() || ""; + const storedLaunchFingerprint = JSON.stringify([ + storedLaunch?.model ?? null, + storedLaunch?.reasoningEffort ?? null, + storedLaunch?.fastMode ?? null, + storedLaunch?.codexFastMode ?? null, + storedLaunch?.permissionMode ?? null, + storedLaunch?.codexApprovalPolicy ?? null, + storedLaunch?.codexSandbox ?? null, + storedLaunch?.codexConfigSource ?? null, + ]); + const storedLaunchRef = useRef(storedLaunch); + storedLaunchRef.current = storedLaunch; + const recoveryIdentity = `${session.id}:${provider ?? ""}:${importedProvider ?? ""}:${importedTargetId}:${storedLaunchFingerprint}`; + const appliedRecoveryIdentityRef = useRef(null); + const [resolvedLaunch, setResolvedLaunch] = useState(storedLaunch); + const modelId = resolvedLaunch?.model?.trim() || null; const modelDescriptor = modelId ? (resolveModelDescriptorWithRuntimeCatalog(modelId) ?? createUnknownModelPlaceholder(modelId)) : null; + const permissionLabel = continuationPermissionLabel(resolvedLaunch); const textareaRef = useRef(null); const commandMenuRef = useRef(null); const [draft, setDraft] = useState(""); @@ -330,6 +375,40 @@ function WorkCliContinuationComposer({ const [submitError, setSubmitError] = useState(null); const launchPromptClipboardEnabled = useAppStore((s) => s.launchPromptClipboardEnabled); + useEffect(() => { + let cancelled = false; + const currentStoredLaunch = storedLaunchRef.current; + if (appliedRecoveryIdentityRef.current !== recoveryIdentity) { + appliedRecoveryIdentityRef.current = recoveryIdentity; + setResolvedLaunch(currentStoredLaunch); + } + // Historical imports often stored only one launch field (or an empty + // object), so the presence of a permission or fast-mode value must not + // prevent recovery of the model and reasoning effort. + if (provider !== "codex" || ( + currentStoredLaunch?.model?.trim() + && currentStoredLaunch?.reasoningEffort?.trim() + && (currentStoredLaunch?.permissionMode || ( + currentStoredLaunch?.codexApprovalPolicy && currentStoredLaunch?.codexSandbox + )) + )) return () => { + cancelled = true; + }; + const request = recoverImportedContinuationLaunch(provider, importedProvider, importedTargetId); + if (!request) return () => { + cancelled = true; + }; + void request.then((launch) => { + if (!cancelled && launch) setResolvedLaunch(mergeContinuationLaunch(launch, currentStoredLaunch)); + }).catch(() => { + // The native provider transcript may have moved or been compressed. + // Continuing still uses the durable stored resume command. + }); + return () => { + cancelled = true; + }; + }, [importedProvider, importedTargetId, provider, recoveryIdentity]); + useEffect(() => { let cancelled = false; setSlashCommands([]); @@ -395,7 +474,7 @@ function WorkCliContinuationComposer({ if (launchPromptClipboardEnabled) { void copyLaunchPromptToClipboard(text); } - await onContinue?.(session, text); + await onContinue?.(session, text, resolvedLaunch); setDraft(""); setCommandMenuTrigger(null); } catch (err) { @@ -403,7 +482,7 @@ function WorkCliContinuationComposer({ } finally { setSending(false); } - }, [draft, launchPromptClipboardEnabled, onContinue, sending, session]); + }, [draft, launchPromptClipboardEnabled, onContinue, resolvedLaunch, sending, session]); // Auto-grow from a single-line height (matches the active chat composer): start // thin and expand with the draft, capped so the transcript above keeps the room. @@ -421,21 +500,38 @@ function WorkCliContinuationComposer({ className="mx-auto w-full max-w-[var(--chat-column,46rem)]" footer={(
- {modelDescriptor ? ( - - - {modelDescriptor.displayName} - - ) : ( - {providerLabel} - )} +
+ {modelDescriptor ? ( + + + {modelDescriptor.displayName} + + ) : ( + {providerLabel} + )} + {resolvedLaunch?.reasoningEffort ? ( + + {resolvedLaunch.reasoningEffort} + + ) : null} + {(resolvedLaunch?.fastMode ?? resolvedLaunch?.codexFastMode) ? ( + + Fast + + ) : null} + {permissionLabel ? ( + + {permissionLabel} + + ) : null} +