diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index d9c45ae6725..57c3b0ab794 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -21,21 +21,16 @@ inline src/main/codex-accounts/service.ts inline src/main/codex/config-toml-trust.ts inline src/main/codex/hook-service.ts inline src/main/daemon/daemon-init.ts -inline src/main/daemon/daemon-pty-adapter.ts -inline src/main/daemon/daemon-server.ts -inline src/main/daemon/pty-subprocess.ts inline src/main/git/runner.ts inline src/main/git/status.ts inline src/main/git/worktree.ts inline src/main/github/project-view.ts -inline src/main/github/project-view/mutations.ts inline src/main/index.ts inline src/main/ipc/filesystem-watcher.ts inline src/main/ipc/filesystem.ts inline src/main/ipc/repos.ts inline src/main/ipc/ssh.ts inline src/main/ipc/worktree-remote.ts -inline src/main/ipc/worktrees.ts inline src/main/keybindings/keybinding-file.ts inline src/main/linear/issues.ts inline src/main/memory/collector.ts @@ -87,9 +82,6 @@ inline src/renderer/src/components/editor/IpynbViewer.tsx inline src/renderer/src/components/editor/MarkdownPreview.tsx inline src/renderer/src/components/feature-wall/EditorAnimatedVisual.tsx inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx -inline src/renderer/src/components/github-project/ProjectCell.tsx -inline src/renderer/src/components/github-project/ProjectPicker.tsx -inline src/renderer/src/components/github-project/ProjectViewWrapper.tsx inline src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx inline src/renderer/src/components/onboarding/use-onboarding-flow.ts inline src/renderer/src/components/right-sidebar/ChecksPanel.tsx diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs index 989efd2d8e7..6d25698764a 100644 --- a/config/scripts/build-orcad.mjs +++ b/config/scripts/build-orcad.mjs @@ -8,13 +8,17 @@ * the host Node floor at 18 instead of 22.5+. */ import { build } from 'esbuild' -import { mkdirSync, rmSync } from 'node:fs' +import { chmodSync, copyFileSync, mkdirSync, rmSync } from 'node:fs' +import { arch, platform } from 'node:os' import { join } from 'node:path' import process from 'node:process' const ROOT = join(import.meta.dirname, '..', '..') const OUT_DIR = join(ROOT, 'out', 'orcad') const ENTRY = join(ROOT, 'src/main/orcad/main.ts') +const AGENT_BROWSER_NAME = `agent-browser-${platform()}-${arch()}${process.platform === 'win32' ? '.exe' : ''}` +const AGENT_BROWSER_SOURCE = join(ROOT, 'node_modules', 'agent-browser', 'bin', AGENT_BROWSER_NAME) +const AGENT_BROWSER_OUTPUT = join(OUT_DIR, AGENT_BROWSER_NAME) // Native addons must exist on the host; they cannot be bundled. // `electron` is external so a residual import fails loudly at require() time rather @@ -48,6 +52,10 @@ const externalNativeAddons = { rmSync(OUT_DIR, { recursive: true, force: true }) mkdirSync(OUT_DIR, { recursive: true }) +copyFileSync(AGENT_BROWSER_SOURCE, AGENT_BROWSER_OUTPUT) +if (process.platform !== 'win32') { + chmodSync(AGENT_BROWSER_OUTPUT, 0o755) +} const result = await build({ entryPoints: [ENTRY], @@ -78,12 +86,34 @@ for (const [file, info] of Object.entries(result.metafile.inputs)) { } } } +const sqliteImporters = new Set() +for (const [file, info] of Object.entries(result.metafile.inputs)) { + for (const imported of info.imports ?? []) { + const specifier = imported.original ?? imported.path + if (specifier === 'node:sqlite') { + sqliteImporters.add(file) + } + } +} +const graphErrors = [] if (electronImporters.size > 0) { - console.error( - `[build-orcad] ${electronImporters.size} module(s) in the bundle import electron: -${[...electronImporters].map((f) => ` - ${f}`).join('\n')}` + graphErrors.push( + `${electronImporters.size} module(s) in the bundle import electron:\n${[...electronImporters] + .map((file) => ` - ${file}`) + .join('\n')}` ) +} +if (sqliteImporters.size > 0) { + graphErrors.push( + `${sqliteImporters.size} module(s) in the bundle import node:sqlite:\n${[...sqliteImporters] + .map((file) => ` - ${file}`) + .join('\n')}` + ) +} + +if (graphErrors.length > 0) { + console.error(`[build-orcad] ${graphErrors.join('\n')}`) // Why this can exceed the ratchet baseline: the ratchet measures the graph reachable // from orca-runtime + runtime-rpc, but this entry also imports ipc/pty directly to // install the PTY controller. Once orcad ships, it should become a ratchet entry @@ -91,6 +121,6 @@ ${[...electronImporters].map((f) => ` - ${f}`).join('\n')}` process.exitCode = 1 } else { console.log( - `[build-orcad] ok — ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron imports.` + `[build-orcad] ok — ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron and node:sqlite imports.` ) } diff --git a/config/scripts/runtime-serve-terminal-smoke.mjs b/config/scripts/runtime-serve-terminal-smoke.mjs index 21b6cea6b0b..a03c85983d8 100644 --- a/config/scripts/runtime-serve-terminal-smoke.mjs +++ b/config/scripts/runtime-serve-terminal-smoke.mjs @@ -18,11 +18,14 @@ * - a paired client can list worktrees and create a terminal, * - a command run in that terminal produces its output, * - the server exits when asked. + * Optional `--browser` assertions: + * - create, navigate, evaluate, and screenshot through the selected host provider. */ import { spawn, spawnSync } from 'node:child_process' import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' import { randomBytes } from 'node:crypto' import process from 'node:process' @@ -274,6 +277,50 @@ async function main() { throw new Error('worktree.create succeeded but worktree.show cannot resolve it') } log(`server resolves ${shown.id}`) + if (process.argv.includes('--browser')) { + const status = orca(pairingCode, ['status']) + if (!status?.runtime?.capabilities?.includes('browser.headless.v1')) { + throw new Error( + `status omitted browser.headless.v1: ${JSON.stringify(status?.runtime?.capabilities)}` + ) + } + const fixturePath = join(userDataDir, 'browser-smoke.html') + writeFileSync( + fixturePath, + 'Orcad Browser Smoke
browser-ready
' + ) + const browserPageId = orca(pairingCode, [ + 'tab', + 'create', + '--worktree', + created.id, + '--url', + 'about:blank' + ])?.browserPageId + if (!browserPageId) { + throw new Error('browser.tabCreate returned no browser page id') + } + const targetFlags = ['--worktree', created.id, '--page', browserPageId] + const targetUrl = pathToFileURL(fixturePath).href + const navigation = orca(pairingCode, ['goto', ...targetFlags, '--url', targetUrl]) + if (navigation?.url !== targetUrl || navigation?.title !== 'Orcad Browser Smoke') { + throw new Error(`browser.goto returned the wrong page: ${JSON.stringify(navigation)}`) + } + const evaluated = orca(pairingCode, [ + 'eval', + ...targetFlags, + '--expression', + 'document.querySelector("main")?.textContent' + ]) + if (evaluated?.result !== 'browser-ready') { + throw new Error(`browser.eval returned ${JSON.stringify(evaluated)}`) + } + const screenshot = orca(pairingCode, ['screenshot', ...targetFlags]) + if (screenshot?.format !== 'png' || typeof screenshot.data !== 'string' || !screenshot.data) { + throw new Error('browser.screenshot returned no PNG data') + } + log('browser navigate/evaluate/screenshot round trip OK') + } const terminal = orca(pairingCode, ['terminal', 'create', '--worktree', created.id])?.terminal if (!terminal?.handle) { diff --git a/mobile/src/transport/mobile-relay-e2ee-link.test.ts b/mobile/src/transport/mobile-relay-e2ee-link.test.ts index d9093568fc1..aa2d42b13f0 100644 --- a/mobile/src/transport/mobile-relay-e2ee-link.test.ts +++ b/mobile/src/transport/mobile-relay-e2ee-link.test.ts @@ -59,4 +59,114 @@ describe('MobileRelayE2eeLink', () => { ) expect(socket.close).toHaveBeenCalledOnce() }) + + it('keeps a typed close code when transport error precedes close', () => { + const socket = new ThrowingSocket() + const onError = vi.fn() + new MobileRelayE2eeLink({ + endpoint: { + cellUrl: 'https://relay-c1.onorca.dev', + relayHostId: 'AbCdEf0123_-xyZ9' + }, + credential: 'credential', + expectedCredentialKind: 'resume', + deviceToken: 'device-token', + desktopPublicKeyB64: 'desktop-key', + onAuthenticated: vi.fn(), + onText: vi.fn(), + onBinary: vi.fn(), + onError, + createSocket: () => socket as unknown as WebSocket + }) + + socket.onerror?.() + socket.onclose?.({ code: 4409 }) + + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'relay_outer_4409' })) + }) + + it('classifies an opaque close after transport error as 1006', () => { + const socket = new ThrowingSocket() + const onError = vi.fn() + new MobileRelayE2eeLink({ + endpoint: { + cellUrl: 'https://relay-c1.onorca.dev', + relayHostId: 'AbCdEf0123_-xyZ9' + }, + credential: 'credential', + expectedCredentialKind: 'resume', + deviceToken: 'device-token', + desktopPublicKeyB64: 'desktop-key', + onAuthenticated: vi.fn(), + onText: vi.fn(), + onBinary: vi.fn(), + onError, + createSocket: () => socket as unknown as WebSocket + }) + + socket.onerror?.() + socket.onclose?.({ code: 0 }) + + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'relay_outer_1006' })) + }) + + it('bounds an error when the platform never emits close', async () => { + vi.useFakeTimers() + try { + const socket = new ThrowingSocket() + const onError = vi.fn() + const link = new MobileRelayE2eeLink({ + endpoint: { + cellUrl: 'https://relay-c1.onorca.dev', + relayHostId: 'AbCdEf0123_-xyZ9' + }, + credential: 'credential', + expectedCredentialKind: 'resume', + deviceToken: 'device-token', + desktopPublicKeyB64: 'desktop-key', + onAuthenticated: vi.fn(), + onText: vi.fn(), + onBinary: vi.fn(), + onError, + createSocket: () => socket as unknown as WebSocket + }) + socket.onerror?.() + await vi.advanceTimersByTimeAsync(250) + + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'relay_outer_1006' })) + link.close() + } finally { + vi.useRealTimers() + } + }) + + it('cancels the missing-close timer when explicitly closed', async () => { + vi.useFakeTimers() + try { + const socket = new ThrowingSocket() + const onError = vi.fn() + const link = new MobileRelayE2eeLink({ + endpoint: { + cellUrl: 'https://relay-c1.onorca.dev', + relayHostId: 'AbCdEf0123_-xyZ9' + }, + credential: 'credential', + expectedCredentialKind: 'resume', + deviceToken: 'device-token', + desktopPublicKeyB64: 'desktop-key', + onAuthenticated: vi.fn(), + onText: vi.fn(), + onBinary: vi.fn(), + onError, + createSocket: () => socket as unknown as WebSocket + }) + socket.onerror?.() + link.close() + await vi.advanceTimersByTimeAsync(250) + + expect(onError).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) }) diff --git a/mobile/src/transport/mobile-relay-e2ee-link.ts b/mobile/src/transport/mobile-relay-e2ee-link.ts index 80d91589c53..f19417a60f1 100644 --- a/mobile/src/transport/mobile-relay-e2ee-link.ts +++ b/mobile/src/transport/mobile-relay-e2ee-link.ts @@ -6,6 +6,10 @@ import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session' import { MobileE2EEV2PhysicalChannel } from './mobile-e2ee-v2-physical-channel' import { websocketPayloadToUint8 } from './websocket-payload-bytes' +// Native WebSockets normally emit close immediately after error; bound the +// missing-close case so a dead socket cannot leave recovery pending forever. +const RELAY_ERROR_CLOSE_GRACE_MS = 250 + export class RelayOuterError extends Error { constructor(readonly code: number) { super(`relay_outer_${code}`) @@ -32,6 +36,7 @@ export class MobileRelayE2eeLink { private readonly channel: MobileE2EEV2PhysicalChannel private outerReady = false private closed = false + private transportErrorTimer: ReturnType | null = null private inboundChain: Promise = Promise.resolve() constructor(options: MobileRelayE2eeLinkOptions) { @@ -70,6 +75,10 @@ export class MobileRelayE2eeLink { return } this.closed = true + if (this.transportErrorTimer) { + clearTimeout(this.transportErrorTimer) + this.transportErrorTimer = null + } this.channel.dispose() this.socket.close() } @@ -103,8 +112,21 @@ export class MobileRelayE2eeLink { }) .catch((error: unknown) => this.fail(asError(error))) } - this.socket.onerror = () => this.fail(new Error('relay transport error')) - this.socket.onclose = (event) => this.fail(new RelayOuterError(event.code || 1006)) + // `error` is often delivered just before `close`; wait for close so a + // typed relay code is not replaced by a generic transport error. + this.socket.onerror = () => { + this.transportErrorTimer ??= setTimeout(() => { + this.transportErrorTimer = null + this.fail(new RelayOuterError(1006)) + }, RELAY_ERROR_CLOSE_GRACE_MS) + } + this.socket.onclose = (event) => { + if (this.transportErrorTimer) { + clearTimeout(this.transportErrorTimer) + this.transportErrorTimer = null + } + this.fail(new RelayOuterError(event.code || 1006)) + } } private acceptHello(raw: unknown): void { @@ -137,6 +159,10 @@ export class MobileRelayE2eeLink { return } this.closed = true + if (this.transportErrorTimer) { + clearTimeout(this.transportErrorTimer) + this.transportErrorTimer = null + } this.channel.dispose() this.options.onError(error) this.socket.close() diff --git a/mobile/src/transport/mobile-relay-invite-director.test.ts b/mobile/src/transport/mobile-relay-invite-director.test.ts index 04508024d07..77a0cdde270 100644 --- a/mobile/src/transport/mobile-relay-invite-director.test.ts +++ b/mobile/src/transport/mobile-relay-invite-director.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { resolvePairingInviteThroughDirector } from './mobile-relay-invite-director' +import { + RelayDirectorMoveNotNewerError, + resolvePairingInviteThroughDirector +} from './mobile-relay-invite-director' class FakeSocket { sent: string[] = [] @@ -59,7 +62,7 @@ describe('pairing invite director resolution', () => { }) }) - it('rejects same/older epochs and untrusted extra fields', async () => { + it('rejects malformed moves with untrusted extra fields', async () => { const socket = new FakeSocket() const resolving = resolvePairingInviteThroughDirector({ relay, @@ -75,6 +78,31 @@ describe('pairing invite director resolution', () => { }) }) - await expect(resolving).rejects.toThrow(/not strictly newer/) + await expect(resolving).rejects.toThrow(/invalid relay director move/) + }) + + it('describes a same-epoch move without accepting it', async () => { + const socket = new FakeSocket() + const resolving = resolvePairingInviteThroughDirector({ + relay, + createSocket: () => socket as unknown as WebSocket + }) + socket.onmessage?.({ + data: JSON.stringify({ + type: 'relay-moved', + v: 1, + cellUrl: relay.cellUrl, + assignmentEpoch: relay.assignmentEpoch + }) + }) + + const error = await resolving.catch((value: unknown) => value) + expect(error).toBeInstanceOf(RelayDirectorMoveNotNewerError) + expect(error).toMatchObject({ + cellUrl: relay.cellUrl, + assignmentEpoch: relay.assignmentEpoch, + currentCellUrl: relay.cellUrl, + currentAssignmentEpoch: relay.assignmentEpoch + }) }) }) diff --git a/mobile/src/transport/mobile-relay-invite-director.ts b/mobile/src/transport/mobile-relay-invite-director.ts index 5dc83f526b6..408f83c8c09 100644 --- a/mobile/src/transport/mobile-relay-invite-director.ts +++ b/mobile/src/transport/mobile-relay-invite-director.ts @@ -1,6 +1,27 @@ import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer' import { RelayMovedSchema } from '../../../src/shared/mobile-relay-phone-protocol' +export class RelayDirectorMoveNotNewerError extends Error { + readonly cellUrl: string + readonly assignmentEpoch: number + readonly currentCellUrl: string + readonly currentAssignmentEpoch: number + + constructor(args: { + cellUrl: string + assignmentEpoch: number + currentCellUrl: string + currentAssignmentEpoch: number + }) { + super('relay director move was not strictly newer') + this.name = 'RelayDirectorMoveNotNewerError' + this.cellUrl = args.cellUrl + this.assignmentEpoch = args.assignmentEpoch + this.currentCellUrl = args.currentCellUrl + this.currentAssignmentEpoch = args.currentAssignmentEpoch + } +} + export function resolvePairingInviteThroughDirector(args: { relay: PairingRelay timeoutMs?: number @@ -38,8 +59,19 @@ export function resolvePairingInviteThroughDirector(args: { return } const moved = RelayMovedSchema.safeParse(value) - if (!moved.success || moved.data.assignmentEpoch <= args.relay.assignmentEpoch) { - finish(new Error('relay director move was not strictly newer')) + if (!moved.success) { + finish(new Error('invalid relay director move')) + return + } + if (moved.data.assignmentEpoch <= args.relay.assignmentEpoch) { + finish( + new RelayDirectorMoveNotNewerError({ + cellUrl: moved.data.cellUrl, + assignmentEpoch: moved.data.assignmentEpoch, + currentCellUrl: args.relay.cellUrl, + currentAssignmentEpoch: args.relay.assignmentEpoch + }) + ) return } settled = true diff --git a/mobile/src/transport/mobile-relay-physical-client.test.ts b/mobile/src/transport/mobile-relay-physical-client.test.ts index 77972cc6dfd..cf59264a407 100644 --- a/mobile/src/transport/mobile-relay-physical-client.test.ts +++ b/mobile/src/transport/mobile-relay-physical-client.test.ts @@ -134,6 +134,57 @@ describe('mobile relay physical pairing client', () => { expect(fakes.start).not.toHaveBeenCalled() }) + it('keeps the typed close code when transport error precedes close', async () => { + const socket = new FakeSocket() + const client = connectMobileRelayForPairing({ + relay, + deviceToken: 'device-token', + desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', + createSocket: () => socket as unknown as WebSocket + }) + const status = client.sendRequest('status.get') + socket.onerror?.() + socket.onclose?.({ code: 4409 }) + + await expect(status).rejects.toEqual(new RelayOuterError(4409)) + }) + + it('classifies an opaque close after transport error as 1006', async () => { + const socket = new FakeSocket() + const client = connectMobileRelayForPairing({ + relay, + deviceToken: 'device-token', + desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', + createSocket: () => socket as unknown as WebSocket + }) + const status = client.sendRequest('status.get') + socket.onerror?.() + socket.onclose?.({ code: 0 }) + + await expect(status).rejects.toEqual(new RelayOuterError(1006)) + }) + + it('settles after an error when the platform never emits close', async () => { + vi.useFakeTimers() + try { + const socket = new FakeSocket() + const client = connectMobileRelayForPairing({ + relay, + deviceToken: 'device-token', + desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', + createSocket: () => socket as unknown as WebSocket + }) + const status = client.sendRequest('status.get') + const rejected = expect(status).rejects.toEqual(new RelayOuterError(1006)) + socket.onerror?.() + await vi.advanceTimersByTimeAsync(250) + + await rejected + } finally { + vi.useRealTimers() + } + }) + it('narrates dial, outer auth, handshake and authentication without leaking the invite', async () => { const socket = new FakeSocket() const entries: ConnectionLogEntry[] = [] diff --git a/mobile/src/transport/mobile-relay-physical-client.ts b/mobile/src/transport/mobile-relay-physical-client.ts index 37b5d64857e..9e3e4c259f8 100644 --- a/mobile/src/transport/mobile-relay-physical-client.ts +++ b/mobile/src/transport/mobile-relay-physical-client.ts @@ -10,6 +10,8 @@ import { websocketPayloadToUint8 } from './websocket-payload-bytes' export { RelayOuterError } from './mobile-relay-e2ee-link' import { RelayOuterError } from './mobile-relay-e2ee-link' +const RELAY_ERROR_CLOSE_GRACE_MS = 250 + type PendingRequest = { resolve: (response: RpcResponse) => void reject: (error: Error) => void @@ -46,6 +48,7 @@ export function connectMobileRelayForPairing(args: { let requestCounter = 0 let closed = false let intentionallyClosed = false + let transportErrorTimer: ReturnType | null = null let outerReady = false let authenticated = false let resolveAuthenticated!: () => void @@ -111,8 +114,21 @@ export function connectMobileRelayForPairing(args: { }) .catch((error: unknown) => fail(asError(error))) } - socket.onerror = () => fail(new Error('relay transport error')) - socket.onclose = (event) => fail(new RelayOuterError(event.code || 1006)) + // WebSocket implementations commonly emit `error` immediately before + // `close`; the bounded fallback represents an opaque 1006 close. + socket.onerror = () => { + transportErrorTimer ??= setTimeout(() => { + transportErrorTimer = null + fail(new RelayOuterError(1006)) + }, RELAY_ERROR_CLOSE_GRACE_MS) + } + socket.onclose = (event) => { + if (transportErrorTimer) { + clearTimeout(transportErrorTimer) + transportErrorTimer = null + } + fail(new RelayOuterError(event.code || 1006)) + } function acceptRelayHello(raw: unknown): void { if (typeof raw !== 'string') { @@ -144,6 +160,10 @@ export function connectMobileRelayForPairing(args: { return } closed = true + if (transportErrorTimer) { + clearTimeout(transportErrorTimer) + transportErrorTimer = null + } if (intentionallyClosed) { log('info', 'Relay: pairing socket closed', cellHost) } else { diff --git a/mobile/src/transport/pairing-relay-candidate.test.ts b/mobile/src/transport/pairing-relay-candidate.test.ts index 07d315a487d..5586ee7b801 100644 --- a/mobile/src/transport/pairing-relay-candidate.test.ts +++ b/mobile/src/transport/pairing-relay-candidate.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import type { PairingCandidateClient } from './mobile-relay-physical-client' import { RelayOuterError } from './mobile-relay-physical-client' import { createRecoveringPairingRelayCandidate } from './pairing-relay-candidate' +import { RelayDirectorMoveNotNewerError } from './mobile-relay-invite-director' import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal' import type { ConnectionLogEntry } from './types' @@ -89,6 +90,109 @@ describe('recovering pairing relay candidate', () => { expect(stale.close).toHaveBeenCalledOnce() }) + it('retries the authoritative cell when the director confirms the same assignment', async () => { + const stale = client(Promise.reject(new RelayOuterError(1006))) + const target = client(Promise.resolve(success())) + const persistMove = vi.fn() + let connects = 0 + const candidate = createRecoveringPairingRelayCandidate({ + journal, + connect: (relay) => { + expect(relay.cellUrl).toBe(journal.metadata.relay.cellUrl) + expect(relay.assignmentEpoch).toBe(journal.metadata.relay.assignmentEpoch) + return connects++ === 0 ? stale : target + }, + resolveDirector: async (relay) => { + throw new RelayDirectorMoveNotNewerError({ + cellUrl: relay.cellUrl, + assignmentEpoch: relay.assignmentEpoch, + currentCellUrl: relay.cellUrl, + currentAssignmentEpoch: relay.assignmentEpoch + }) + }, + persistMove, + now: () => 1, + random: () => 0, + sleep: async () => {} + }) + + await expect(candidate.sendRequest('status.get')).resolves.toEqual(success()) + expect(connects).toBe(2) + expect(persistMove).not.toHaveBeenCalled() + }) + + it('keeps recovery bounded when the authoritative-cell constructor throws', async () => { + const stale = client(Promise.reject(new RelayOuterError(1006))) + const target = client(Promise.resolve(success())) + const entries: ConnectionLogEntry[] = [] + let connects = 0 + const candidate = createRecoveringPairingRelayCandidate({ + journal, + connect: () => { + connects++ + if (connects === 1) { + return stale + } + if (connects === 2) { + throw new Error('relay constructor failed') + } + return target + }, + resolveDirector: async (relay) => { + throw new RelayDirectorMoveNotNewerError({ + cellUrl: relay.cellUrl, + assignmentEpoch: relay.assignmentEpoch, + currentCellUrl: relay.cellUrl, + currentAssignmentEpoch: relay.assignmentEpoch + }) + }, + persistMove: vi.fn(), + now: () => 1, + random: () => 0, + sleep: async () => {}, + onLog: (entry) => entries.push(entry) + }) + + await expect(candidate.sendRequest('status.get')).resolves.toEqual(success()) + expect(connects).toBe(3) + expect(entries).toContainEqual( + expect.objectContaining({ + level: 'warn', + message: 'Relay: recovery attempt 1 failed', + detail: 'Error: relay constructor failed' + }) + ) + }) + + it('does not retry a same-epoch move to a different cell', async () => { + const stale = client(Promise.reject(new RelayOuterError(1006))) + const resolveDirector = vi.fn(async (relay) => { + throw new RelayDirectorMoveNotNewerError({ + cellUrl: 'https://relay-c2.onorca.dev', + assignmentEpoch: relay.assignmentEpoch, + currentCellUrl: relay.cellUrl, + currentAssignmentEpoch: relay.assignmentEpoch + }) + }) + let connects = 0 + const candidate = createRecoveringPairingRelayCandidate({ + journal, + connect: () => { + connects++ + return stale + }, + resolveDirector, + persistMove: vi.fn(), + now: () => 1, + random: () => 0, + sleep: async () => {} + }) + + await expect(candidate.sendRequest('status.get')).rejects.toThrow(/not strictly newer/) + expect(resolveDirector).toHaveBeenCalledTimes(3) + expect(connects).toBe(1) + }) + it('does not ask the director to reinterpret endpoint-scoped host-offline', async () => { const offline = client(Promise.reject(new RelayOuterError(4404))) const resolveDirector = vi.fn() diff --git a/mobile/src/transport/pairing-relay-candidate.ts b/mobile/src/transport/pairing-relay-candidate.ts index 2293ce1855f..5158bc1e26d 100644 --- a/mobile/src/transport/pairing-relay-candidate.ts +++ b/mobile/src/transport/pairing-relay-candidate.ts @@ -1,6 +1,7 @@ import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer' import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal' import { RelayOuterError, type PairingCandidateClient } from './mobile-relay-physical-client' +import { RelayDirectorMoveNotNewerError } from './mobile-relay-invite-director' import { createPairingRelayLogger, pairingRelayErrorDetail } from './pairing-relay-log' import { redactSocketEndpoint } from './socket-event-debug' import type { ConnectionLogSink } from './types' @@ -88,6 +89,35 @@ export function createRecoveringPairingRelayCandidate(args: { return await client.sendRequest(method, params) } catch (error) { lastError = error + if (isCurrentAssignmentMove(error, relay)) { + log( + 'info', + 'Relay: director kept current assignment', + redactSocketEndpoint(relay.cellUrl) + ) + client.close() + await backOff(attempt) + if (closed) { + throw new Error('relay pairing client closed') + } + try { + client = args.connect(relay, args.onLog) + return await client.sendRequest(method, params) + } catch (retryError) { + lastError = retryError + log( + 'warn', + `Relay: recovery attempt ${attempt + 1} failed`, + pairingRelayErrorDetail(retryError) + ) + if (!isDirectorRecoverable(retryError) || attempt + 1 >= maxAttempts) { + log('error', 'Relay: recovery gave up', `after ${attempt + 1} attempt(s)`) + throw retryError + } + await backOff(attempt) + continue + } + } log('warn', `Relay: recovery attempt ${attempt + 1} failed`, pairingRelayErrorDetail(error)) if (!isDirectorRecoverable(error) || attempt + 1 >= maxAttempts) { log('error', 'Relay: recovery gave up', `after ${attempt + 1} attempt(s)`) @@ -100,6 +130,16 @@ export function createRecoveringPairingRelayCandidate(args: { } } +function isCurrentAssignmentMove(error: unknown, relay: PairingRelay): boolean { + return ( + error instanceof RelayDirectorMoveNotNewerError && + error.assignmentEpoch === relay.assignmentEpoch && + error.cellUrl === relay.cellUrl && + error.currentAssignmentEpoch === relay.assignmentEpoch && + error.currentCellUrl === relay.cellUrl + ) +} + function pairingRelayFromJournal(journal: MobileRelayPairingJournal): PairingRelay { return { ...journal.metadata.relay, diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts index e97c8e28c9e..31c5b94e5c9 100644 --- a/src/cli/runtime/client.ts +++ b/src/cli/runtime/client.ts @@ -172,7 +172,8 @@ export class RuntimeClient { ...(response.result.remoteUpdateSupport ? { remoteUpdateSupport: response.result.remoteUpdateSupport } : {}), - ...(response.result.capabilities ? { capabilities: response.result.capabilities } : {}) + ...(response.result.capabilities ? { capabilities: response.result.capabilities } : {}), + ...(response.result.degradations ? { degradations: response.result.degradations } : {}) }, graph: { state: graphState diff --git a/src/cli/runtime/status.test.ts b/src/cli/runtime/status.test.ts index 68dbbf0e962..9cc595122ef 100644 --- a/src/cli/runtime/status.test.ts +++ b/src/cli/runtime/status.test.ts @@ -47,7 +47,14 @@ describe.skipIf(process.platform === 'win32')('CLI runtime status', () => { rendererGraphEpoch: 1, graphStatus: 'ready', authoritativeWindowId: null, - liveTabCount: 0 + liveTabCount: 0, + degradations: [ + { + code: 'browser_unavailable', + capability: 'browser.headless.v1', + message: 'Browser automation is unavailable.' + } + ] }, _meta: { runtimeId: 'runtime-legacy' } })}\n` @@ -72,7 +79,8 @@ describe.skipIf(process.platform === 'win32')('CLI runtime status', () => { expect(status.result.runtime).toMatchObject({ reachable: true, runtimeId: 'runtime-legacy', - state: 'ready' + state: 'ready', + degradations: [expect.objectContaining({ code: 'browser_unavailable' })] }) }) }) diff --git a/src/cli/runtime/status.ts b/src/cli/runtime/status.ts index 299527eb4e8..e4a181a8d51 100644 --- a/src/cli/runtime/status.ts +++ b/src/cli/runtime/status.ts @@ -56,7 +56,8 @@ export async function getCliStatus( ...(response.result.remoteUpdateSupport ? { remoteUpdateSupport: response.result.remoteUpdateSupport } : {}), - ...(response.result.capabilities ? { capabilities: response.result.capabilities } : {}) + ...(response.result.capabilities ? { capabilities: response.result.capabilities } : {}), + ...(response.result.degradations ? { degradations: response.result.degradations } : {}) }, graph: { state: graphState diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts index 996f708d76f..7f12c6957d9 100644 --- a/src/cli/runtime/websocket-transport.test.ts +++ b/src/cli/runtime/websocket-transport.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { WebSocketServer } from 'ws' import { encodePairingOffer, type PairingOffer } from '../../shared/pairing' +import type { RuntimeStatus } from '../../shared/runtime-types' import { decrypt, deriveSharedKey, @@ -90,7 +91,14 @@ describe('CLI remote WebSocket transport', () => { automatic: false, reason: 'manual-service-update-required' }, - capabilities: ['updater.remote-control.v1'] + capabilities: ['updater.remote-control.v1'], + degradations: [ + { + code: 'browser_unavailable', + capability: 'browser.headless.v1', + message: 'Browser automation is unavailable.' + } + ] }) servers.push(runtime) const offer: PairingOffer = { @@ -113,7 +121,8 @@ describe('CLI remote WebSocket transport', () => { expect(status.result.runtime).toMatchObject({ appVersion: '1.5.0', remoteUpdateSupport: { automatic: false, reason: 'manual-service-update-required' }, - capabilities: ['updater.remote-control.v1'] + capabilities: ['updater.remote-control.v1'], + degradations: [expect.objectContaining({ code: 'browser_unavailable' })] }) }) @@ -243,6 +252,7 @@ async function startTestRuntime( reason: 'manual-service-update-required' } capabilities?: string[] + degradations?: RuntimeStatus['degradations'] } = {} ): Promise { const serverKeyPair = generateKeyPair() @@ -314,7 +324,8 @@ async function startTestRuntime( MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, appVersion: statusOverrides.appVersion, remoteUpdateSupport: statusOverrides.remoteUpdateSupport, - capabilities: statusOverrides.capabilities + capabilities: statusOverrides.capabilities, + degradations: statusOverrides.degradations }, _meta: { runtimeId } } diff --git a/src/main/daemon/daemon-client-connections.ts b/src/main/daemon/daemon-client-connections.ts new file mode 100644 index 00000000000..52f8b0db8cd --- /dev/null +++ b/src/main/daemon/daemon-client-connections.ts @@ -0,0 +1,249 @@ +import type { Socket } from 'node:net' +import { StringDecoder } from 'node:string_decoder' +import type { DaemonFileLog } from './daemon-file-log' +import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' +import { createNdjsonParser, encodeNdjson } from './ndjson' +import type { DaemonRequest, HelloMessage } from './types' + +export type ConnectedDaemonClient = { + clientId: string + controlSocket: Socket + streamSocket: Socket | null + authenticatedPairEstablished: boolean +} + +type DaemonClientConnectionOptions = { + token: string + protocolVersion: number + identity: { + launchNonce: string | null + startedAtMs: number | null + entryPath: string | null + appVersion: string | null + spawnerExecPath: string | null + } + log: DaemonFileLog + streamDataBatcher: DaemonStreamDataBatcher + isAcceptingWork: () => boolean + onTransportChanged: () => void + onConnectionAccepted: () => void + onAuthenticatedPair: () => void + onLastAuthenticatedClientDisconnected: () => void + onControlRequest: (socket: Socket, clientId: string, request: DaemonRequest) => void + onControlReplaced: (clientId: string) => void + onClientDisconnected: (clientId: string) => void + onStreamDisconnected: (clientId: string) => void +} + +export class DaemonClientConnections { + private readonly clients = new Map() + private readonly transportSockets = new Set() + + constructor(private readonly options: DaemonClientConnectionOptions) {} + + accept(socket: Socket): void { + this.options.onConnectionAccepted() + this.transportSockets.add(socket) + socket.once('close', () => { + this.transportSockets.delete(socket) + this.options.onTransportChanged() + }) + socket.on('error', () => socket.destroy()) + + if (!this.options.isAcceptingWork()) { + socket.end( + encodeNdjson({ + type: 'hello', + ok: false, + error: 'Daemon temporarily unavailable; reconnect', + retryable: true + }) + ) + return + } + const decoder = new StringDecoder('utf8') + const parser = createNdjsonParser( + (message) => this.handleFirstMessage(socket, message), + () => socket.destroy() + ) + socket.on('data', (chunk) => parser.feed(decoder.write(chunk))) + } + + get(clientId: string): ConnectedDaemonClient | undefined { + return this.clients.get(clientId) + } + + values(): IterableIterator { + return this.clients.values() + } + + get size(): number { + return this.clients.size + } + + get transportCount(): number { + return this.transportSockets.size + } + + hasOnlyTransportsFor(client: ConnectedDaemonClient): boolean { + return [...this.transportSockets].every( + (transport) => transport === client.controlSocket || transport === client.streamSocket + ) + } + + dispose(): void { + for (const client of this.clients.values()) { + client.controlSocket.destroy() + client.streamSocket?.destroy() + } + this.clients.clear() + for (const socket of this.transportSockets) { + socket.destroy() + } + this.transportSockets.clear() + } + + private handleFirstMessage(socket: Socket, message: unknown): void { + const hello = message as HelloMessage + if (hello.type !== 'hello') { + this.options.log.log('client-hello-rejected', { reason: 'expected-hello' }) + socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Expected hello' })) + socket.destroy() + return + } + if (hello.version !== this.options.protocolVersion) { + this.options.log.log('client-hello-rejected', { + reason: 'protocol-mismatch', + clientVersion: hello.version + }) + socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Protocol version mismatch' })) + socket.destroy() + return + } + if (hello.token !== this.options.token) { + this.options.log.log('client-hello-rejected', { reason: 'invalid-token', role: hello.role }) + socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Invalid token' })) + socket.destroy() + return + } + if (hello.role !== 'control' && hello.role !== 'stream') { + this.options.log.log('client-hello-rejected', { + reason: 'invalid-role', + role: hello.role + }) + socket.end(encodeNdjson({ type: 'hello', ok: false, error: 'Invalid role' })) + return + } + + this.options.log.log('client-hello-accepted', { role: hello.role, clientId: hello.clientId }) + const identity = this.options.identity + socket.write( + encodeNdjson({ + type: 'hello', + ok: true, + ...(identity.launchNonce && identity.startedAtMs + ? { + daemonIdentity: { + pid: process.pid, + startedAtMs: identity.startedAtMs, + launchNonce: identity.launchNonce, + ...(identity.entryPath ? { entryPath: identity.entryPath } : {}), + ...(identity.appVersion ? { appVersion: identity.appVersion } : {}), + ...(identity.spawnerExecPath ? { spawnerExecPath: identity.spawnerExecPath } : {}) + } + } + : {}) + }) + ) + + if (hello.role === 'control') { + this.installControlSocket(socket, hello.clientId) + return + } + if (hello.role === 'stream') { + const client = this.clients.get(hello.clientId) + if (!client) { + socket.destroy() + return + } + this.installStreamSocket(socket, client) + client.authenticatedPairEstablished = true + this.options.onAuthenticatedPair() + return + } + // Parsed wire data is not made safe by the HelloMessage assertion above. + socket.destroy() + } + + private installControlSocket(socket: Socket, clientId: string): void { + const previous = this.clients.get(clientId) + const client: ConnectedDaemonClient = { + clientId, + controlSocket: socket, + streamSocket: null, + authenticatedPairEstablished: false + } + this.clients.set(clientId, client) + this.setupControlParser(socket, clientId) + if (!previous) { + return + } + this.options.onControlReplaced(clientId) + this.recordFullyAuthenticatedDisconnect(previous.authenticatedPairEstablished) + previous.streamSocket?.destroy() + previous.controlSocket.destroy() + } + + private setupControlParser(socket: Socket, clientId: string): void { + const decoder = new StringDecoder('utf8') + const parser = createNdjsonParser( + (message) => this.options.onControlRequest(socket, clientId, message as DaemonRequest), + () => {} + ) + socket.removeAllListeners('data') + socket.on('data', (chunk) => parser.feed(decoder.write(chunk))) + socket.on('close', () => { + const client = this.clients.get(clientId) + if (client?.controlSocket !== socket) { + return + } + this.options.onClientDisconnected(clientId) + const wasFullyAuthenticated = client.authenticatedPairEstablished + client.streamSocket?.destroy() + this.clients.delete(clientId) + this.recordFullyAuthenticatedDisconnect(wasFullyAuthenticated) + this.options.onTransportChanged() + }) + } + + private recordFullyAuthenticatedDisconnect(wasFullyAuthenticated: boolean): void { + if ( + wasFullyAuthenticated && + ![...this.clients.values()].some((client) => client.authenticatedPairEstablished) && + this.options.isAcceptingWork() + ) { + this.options.onLastAuthenticatedClientDisconnected() + } + } + + private installStreamSocket(socket: Socket, client: ConnectedDaemonClient): void { + const previous = client.streamSocket + socket.removeAllListeners('data') + client.streamSocket = socket + socket.on('drain', () => this.options.streamDataBatcher.flush(client.clientId)) + const cleanup = (): void => { + socket.removeListener('close', cleanup) + socket.removeListener('error', cleanup) + if (this.clients.get(client.clientId) !== client || client.streamSocket !== socket) { + return + } + this.options.onStreamDisconnected(client.clientId) + client.streamSocket = null + } + socket.on('close', cleanup) + socket.on('error', cleanup) + if (previous && previous !== socket) { + previous.destroy() + } + } +} diff --git a/src/main/daemon/daemon-client-rpc-request.test.ts b/src/main/daemon/daemon-client-rpc-request.test.ts index 87935bb589e..b495ee07425 100644 --- a/src/main/daemon/daemon-client-rpc-request.test.ts +++ b/src/main/daemon/daemon-client-rpc-request.test.ts @@ -2,7 +2,7 @@ import type { Socket } from 'node:net' import { afterEach, describe, expect, it, vi } from 'vitest' import { DaemonPendingRequests } from './daemon-client-pending-requests' import { requestDaemonRpc } from './daemon-client-rpc-request' -import { isDaemonGoneError } from './daemon-pty-adapter' +import { isDaemonGoneError } from './daemon-endpoint-errors' import { DAEMON_UNAVAILABLE_RECONNECT_MESSAGE, DaemonConnectionLostError, diff --git a/src/main/daemon/daemon-endpoint-errors.ts b/src/main/daemon/daemon-endpoint-errors.ts new file mode 100644 index 00000000000..663b11ff3fa --- /dev/null +++ b/src/main/daemon/daemon-endpoint-errors.ts @@ -0,0 +1,40 @@ +import { DAEMON_ENDPOINT_LOST_MESSAGE } from './daemon-endpoint-ownership' +import { DAEMON_UNAVAILABLE_RECONNECT_MESSAGE } from './types' + +/** + * Narrow on purpose: only the daemon's own reply for a request type it does not implement. + * A transient failure must stay unproven rather than be mistaken for a missing capability. + * The server throws `Unknown request type: `; the client rejects with that text, which + * `addNodePtyRecoveryHint` only ever prepends to. + */ +export function isUnknownRequestTypeError(err: unknown): boolean { + return err instanceof Error && err.message.includes('Unknown request type') +} + +// Why: syscall='connect' distinguishes a dead-socket ENOENT/ECONNREFUSED from token-file ENOENT (no syscall); +// message strings incl. wedged-daemon "Hello response timed out" (#8689) also warrant a respawn. +export function isDaemonGoneError(err: unknown): boolean { + if (!(err instanceof Error)) { + return false + } + const errno = err as NodeJS.ErrnoException + if ((errno.code === 'ENOENT' || errno.code === 'ECONNREFUSED') && errno.syscall === 'connect') { + return true + } + const msg = err.message + return ( + msg === 'Connection lost' || + msg === 'Not connected' || + msg === 'Hello response timed out' || + msg === DAEMON_UNAVAILABLE_RECONNECT_MESSAGE || + msg === DAEMON_ENDPOINT_LOST_MESSAGE + ) +} + +export function isMissingWindowsNamedPipeError(err: unknown): boolean { + if (process.platform !== 'win32' || !(err instanceof Error)) { + return false + } + const errno = err as NodeJS.ErrnoException + return errno.code === 'ENOENT' && errno.syscall === 'connect' +} diff --git a/src/main/daemon/daemon-endpoint-incarnation.ts b/src/main/daemon/daemon-endpoint-incarnation.ts new file mode 100644 index 00000000000..4a7dc6ea4b4 --- /dev/null +++ b/src/main/daemon/daemon-endpoint-incarnation.ts @@ -0,0 +1,46 @@ +import { readFileSync } from 'node:fs' +import type { DaemonEndpointIdentity } from './daemon-hello-protocol' +import type { ExactDaemonIncarnation } from './daemon-incarnation-evidence' +import { parseDaemonPidFile, type ParsedDaemonPid } from './daemon-pid-file-parse' + +export function sameEndpointIdentity( + left: DaemonEndpointIdentity, + right: DaemonEndpointIdentity +): boolean { + return ( + left.pid === right.pid && + left.startedAtMs === right.startedAtMs && + left.launchNonce === right.launchNonce + ) +} + +export function exactDaemonIncarnationForPidRecord( + identity: DaemonEndpointIdentity, + pidRecord: ParsedDaemonPid | null +): ExactDaemonIncarnation { + return { + identity: { ...identity }, + ...(process.platform === 'linux' && + pidRecord?.pid === identity.pid && + pidRecord.startedAtMs === identity.startedAtMs && + pidRecord.launchNonce === identity.launchNonce && + pidRecord.linuxStartTicks && + pidRecord.bootId + ? { + linuxStartTicks: pidRecord.linuxStartTicks, + bootId: pidRecord.bootId + } + : {}) + } +} + +export function readDaemonPidRecord(pidPath: string | null): ParsedDaemonPid | null { + if (!pidPath) { + return null + } + try { + return parseDaemonPidFile(readFileSync(pidPath, 'utf8')) + } catch { + return null + } +} diff --git a/src/main/daemon/daemon-endpoint-lifecycle.ts b/src/main/daemon/daemon-endpoint-lifecycle.ts new file mode 100644 index 00000000000..035d56af574 --- /dev/null +++ b/src/main/daemon/daemon-endpoint-lifecycle.ts @@ -0,0 +1,173 @@ +import { chmodSync, unlinkSync, writeFileSync } from 'node:fs' +import type { DaemonFileLog } from './daemon-file-log' +import { + DaemonEndpointUnavailableError, + getDaemonSocketBindPath, + publishDaemonEndpoint, + readDaemonEndpointOwnershipState, + type DaemonEndpointOwnershipState, + type DaemonSocketIdentity +} from './daemon-endpoint-ownership' +import { probeSocketConnect } from './daemon-endpoint-probe' +import { unlinkOwnedDaemonPidFile, unlinkOwnedDaemonTokenFile } from './daemon-spawner' + +type DaemonEndpointLifecycleOptions = { + socketPath: string + tokenPath: string + pidPath: string | null + launchNonce: string | null + token: string + publishEndpointOwnership: () => void + log: DaemonFileLog + isServing: () => boolean + onOwnershipLost: () => void +} + +export class DaemonEndpointLifecycle { + private static readonly OWNERSHIP_POLL_MS = 30 * 1000 + private static readonly LOSS_CONFIRMATIONS = 2 + private ownedSocketIdentity: DaemonSocketIdentity | null = null + private ownershipTimer: ReturnType | null = null + private ownershipLossStreak = 0 + private ownershipLost = false + + constructor(private readonly options: DaemonEndpointLifecycleOptions) {} + + bindPath(): string { + return process.platform === 'win32' + ? this.options.socketPath + : getDaemonSocketBindPath(this.options.socketPath) + } + + secureBindPath(bindPath: string): void { + try { + chmodSync(bindPath, 0o600) + } catch { + // Best-effort on platforms that support it. + } + } + + abandonBindPath(bindPath: string): void { + if (process.platform === 'win32') { + return + } + try { + unlinkSync(bindPath) + } catch { + // Already consumed by publication, or never created. + } + } + + async publish(bindPath: string): Promise { + const outcome = await publishDaemonEndpoint( + bindPath, + this.options.socketPath, + probeSocketConnect + ) + if (outcome.status !== 'published') { + this.options.log.log('endpoint-publish-declined', { reason: outcome.status }) + console.warn(`[daemon] Endpoint unavailable at startup: reason=${outcome.status}`) + throw new DaemonEndpointUnavailableError(outcome.status) + } + this.ownedSocketIdentity = outcome.identity + let publishedOwnership = false + try { + this.options.publishEndpointOwnership() + publishedOwnership = true + writeFileSync(this.options.tokenPath, this.options.token, { mode: 0o600 }) + } catch (error) { + if (publishedOwnership && this.options.pidPath && this.options.launchNonce) { + unlinkOwnedDaemonPidFile(this.options.pidPath, process.pid, this.options.launchNonce) + } + this.ownedSocketIdentity = null + throw error + } + } + + retireUnstarted(): void { + this.stopOwnershipWatch() + this.unlinkOwnedArtifacts() + } + + unlinkOwnedArtifacts(): void { + unlinkOwnedDaemonTokenFile(this.options.tokenPath, this.options.token) + if (this.options.pidPath && this.options.launchNonce) { + unlinkOwnedDaemonPidFile(this.options.pidPath, process.pid, this.options.launchNonce) + } + // The canonical endpoint stays: removing it could delete a replacement's name. + this.ownedSocketIdentity = null + } + + stopOwnershipWatch(): void { + if (this.ownershipTimer === null) { + return + } + clearInterval(this.ownershipTimer) + this.ownershipTimer = null + } + + hasLostOwnership(): boolean { + if (this.ownershipLost) { + return true + } + return this.observeOwnership() === 'lost' + } + + get lost(): boolean { + return this.ownershipLost + } + + requestRetirementForLoss(): void { + const alreadyLost = this.ownershipLost + this.ownershipLost = true + this.ownedSocketIdentity = null + if (!alreadyLost) { + this.options.log.log('endpoint-ownership-lost', { + socketPath: this.options.socketPath + }) + console.warn( + '[daemon] Endpoint ownership lost to another daemon — retiring once existing sessions end' + ) + } + this.stopOwnershipWatch() + this.options.onOwnershipLost() + } + + startOwnershipWatch(): void { + if (process.platform === 'win32' || !this.ownedSocketIdentity) { + return + } + this.ownershipTimer = setInterval( + () => this.checkOwnership(), + DaemonEndpointLifecycle.OWNERSHIP_POLL_MS + ) + this.ownershipTimer.unref() + } + + private checkOwnership(): void { + if (process.platform === 'win32' || !this.ownedSocketIdentity) { + return + } + if (this.observeOwnership() !== 'lost') { + return + } + this.ownershipLossStreak++ + if (this.ownershipLossStreak >= DaemonEndpointLifecycle.LOSS_CONFIRMATIONS) { + this.requestRetirementForLoss() + } + } + + private observeOwnership(): DaemonEndpointOwnershipState { + if (process.platform === 'win32' || !this.ownedSocketIdentity || !this.options.isServing()) { + return 'indeterminate' + } + const state = readDaemonEndpointOwnershipState( + this.options.socketPath, + this.ownedSocketIdentity + ) + if (state !== 'lost') { + this.ownershipLossStreak = 0 + } + return state + } +} diff --git a/src/main/daemon/daemon-endpoint-ownership.test.ts b/src/main/daemon/daemon-endpoint-ownership.test.ts index adb8eca4ae9..cf6af2e0956 100644 --- a/src/main/daemon/daemon-endpoint-ownership.test.ts +++ b/src/main/daemon/daemon-endpoint-ownership.test.ts @@ -205,13 +205,13 @@ describe('daemon endpoint ownership publication', () => { await server.start() const daemon = server as unknown as { - checkEndpointOwnership: () => void - retirementRequested: boolean + endpoint: { checkOwnership: () => void } + lifecycle: { retirementRequested: boolean } } // An inconclusive or matching probe must never retire a healthy daemon, however often it runs. - daemon.checkEndpointOwnership() - daemon.checkEndpointOwnership() - expect(daemon.retirementRequested).toBe(false) + daemon.endpoint.checkOwnership() + daemon.endpoint.checkOwnership() + expect(daemon.lifecycle.retirementRequested).toBe(false) // Another daemon takes the endpoint name. unlinkSync(socketPath) @@ -224,13 +224,13 @@ describe('daemon endpoint ownership publication', () => { try { // A single observation can land inside a replacement's unlink-then-link gap, so the // first one must not retire anything. - daemon.checkEndpointOwnership() - expect(daemon.retirementRequested).toBe(false) + daemon.endpoint.checkOwnership() + expect(daemon.lifecycle.retirementRequested).toBe(false) - daemon.checkEndpointOwnership() + daemon.endpoint.checkOwnership() // Why: retirement drains rather than kills — an orphaned daemon stops being a // permanent unreachable host without tearing live sessions out from under the user. - expect(daemon.retirementRequested).toBe(true) + expect(daemon.lifecycle.retirementRequested).toBe(true) } finally { await new Promise((resolve) => usurper.close(() => resolve())) try { diff --git a/src/main/daemon/daemon-history-recovery-freeze.ts b/src/main/daemon/daemon-history-recovery-freeze.ts new file mode 100644 index 00000000000..c9c3f66b2b3 --- /dev/null +++ b/src/main/daemon/daemon-history-recovery-freeze.ts @@ -0,0 +1,13 @@ +import type { HistoryRecoveryContext } from './daemon-pty-runtime-state' +import type { HistoryRecoveryFreeze } from './history-manager' + +// Taking and clearing together prevents any recovery branch from retaining a consumed freeze. +export function takeHistoryRecoveryFreeze( + historyRecovery: HistoryRecoveryContext, + sessionId: string +): HistoryRecoveryFreeze | undefined { + const freeze = + historyRecovery.freeze?.sessionId === sessionId ? historyRecovery.freeze : undefined + historyRecovery.freeze = null + return freeze +} diff --git a/src/main/daemon/daemon-idle-shutdown.test.ts b/src/main/daemon/daemon-idle-shutdown.test.ts index 7dce6f7f763..05f4b3aa39f 100644 --- a/src/main/daemon/daemon-idle-shutdown.test.ts +++ b/src/main/daemon/daemon-idle-shutdown.test.ts @@ -58,6 +58,22 @@ class ManualIdleClock { return this.timers.size } } +type DaemonServerInternals = { + connections: { + clients: Map + accept(socket: Socket): void + } + admission: { createOrAttachInFlight: number } + lifecycle: { + retirementRequested: boolean + state: string + beginIdleShutdown(): void + } + requestRouter: { + route(clientId: string, request: unknown): Promise + } + host: { dispose: () => Promise } +} function createMockSubprocess(): SubprocessHandle & { exit(code: number): void } { let onExit: ((code: number) => void) | null = null @@ -222,11 +238,8 @@ describe('current daemon lifecycle retirement', () => { await startServer() const client = new DaemonClient({ socketPath, tokenPath }) await client.ensureConnected() - const daemon = server as unknown as { - clients: Map - host: { dispose: () => Promise } - } - const controlSocket = [...daemon.clients.values()][0].controlSocket + const daemon = server as unknown as DaemonServerInternals + const controlSocket = [...daemon.connections.clients.values()][0].controlSocket const originalWrite = controlSocket.write.bind(controlSocket) let replyFlushed: (() => void) | undefined vi.spyOn(controlSocket, 'write').mockImplementation((( @@ -252,11 +265,8 @@ describe('current daemon lifecycle retirement', () => { await startServer() const client = new DaemonClient({ socketPath, tokenPath }) await client.ensureConnected() - const daemon = server as unknown as { - clients: Map - host: { dispose: () => Promise } - } - const controlSocket = [...daemon.clients.values()][0].controlSocket + const daemon = server as unknown as DaemonServerInternals + const controlSocket = [...daemon.connections.clients.values()][0].controlSocket const originalWrite = controlSocket.write.bind(controlSocket) vi.spyOn(controlSocket, 'write').mockImplementation(((chunk: string | Uint8Array) => originalWrite(chunk)) as unknown as Socket['write']) @@ -301,8 +311,8 @@ describe('current daemon lifecycle retirement', () => { await startServer() const client = new DaemonClient({ socketPath, tokenPath }) await client.ensureConnected() - const daemon = server as unknown as { createOrAttachInFlight: number } - daemon.createOrAttachInFlight = 1 + const daemon = server as unknown as DaemonServerInternals + daemon.admission.createOrAttachInFlight = 1 await expect(client.request('shutdownIfIdle', undefined)).resolves.toEqual({ retiring: false @@ -395,13 +405,13 @@ describe('current daemon lifecycle retirement', () => { await first.ensureConnected() await first.request('createOrAttach', { sessionId: 'reconnected', cols: 80, rows: 24 }) first.disconnect() - const daemon = server as unknown as { retirementRequested: boolean } - await waitFor(() => daemon.retirementRequested) + const daemon = server as unknown as DaemonServerInternals + await waitFor(() => daemon.lifecycle.retirementRequested) expect(onIdleShutdown).not.toHaveBeenCalled() const second = new DaemonClient({ socketPath, tokenPath }) await second.ensureConnected() - await waitFor(() => !daemon.retirementRequested) + await waitFor(() => !daemon.lifecycle.retirementRequested) subprocess.exit(0) expect(onIdleShutdown).not.toHaveBeenCalled() @@ -444,8 +454,8 @@ describe('current daemon lifecycle retirement', () => { role: 'control' })}\n` ) - const daemon = server as unknown as { clients: Map } - await waitFor(() => daemon.clients.has('startup-health-control')) + const daemon = server as unknown as DaemonServerInternals + await waitFor(() => daemon.connections.clients.has('startup-health-control')) clock.advanceBy(1_000) healthControl.destroy() @@ -465,18 +475,15 @@ describe('current daemon lifecycle retirement', () => { role: 'control' })}\n` ) - const daemon = server as unknown as { - clients: Map - retirementRequested: boolean - } - await waitFor(() => daemon.clients.has('startup-control-create')) + const daemon = server as unknown as DaemonServerInternals + await waitFor(() => daemon.connections.clients.has('startup-control-create')) const response = await requestOnRawSocket(control, { id: 'control-only-create', type: 'createOrAttach', payload: { sessionId: 'must-not-start', cols: 80, rows: 24 } }) expect(response.error).toContain('connection is incomplete') - expect(daemon.retirementRequested).toBe(false) + expect(daemon.lifecycle.retirementRequested).toBe(false) expect(clock.pendingCount).toBe(0) clock.advanceBy(1_000) @@ -501,14 +508,11 @@ describe('current daemon lifecycle retirement', () => { role: 'control' })}\n` ) - const daemon = server as unknown as { - clients: Map - retirementRequested: boolean - } - await waitFor(() => daemon.clients.has('control-only-overlap')) + const daemon = server as unknown as DaemonServerInternals + await waitFor(() => daemon.connections.clients.has('control-only-overlap')) paired.disconnect() - await waitFor(() => daemon.retirementRequested) + await waitFor(() => daemon.lifecycle.retirementRequested) expect(clock.pendingCount).toBe(0) const response = await requestOnRawSocket(incomplete, { id: 'overlap-control-create', @@ -516,7 +520,7 @@ describe('current daemon lifecycle retirement', () => { payload: { sessionId: 'must-not-start', cols: 80, rows: 24 } }) expect(response.error).toContain('connection is incomplete') - expect(daemon.retirementRequested).toBe(true) + expect(daemon.lifecycle.retirementRequested).toBe(true) incomplete.destroy() await waitFor(() => onIdleShutdown.mock.calls.length === 1) @@ -538,10 +542,8 @@ describe('current daemon lifecycle retirement', () => { role: 'control' })}\n` ) - const daemon = server as unknown as { - retirementRequested: boolean - } - await waitFor(() => daemon.retirementRequested) + const daemon = server as unknown as DaemonServerInternals + await waitFor(() => daemon.lifecycle.retirementRequested) expect(clock.pendingCount).toBe(0) replacementControl.destroy() @@ -554,8 +556,8 @@ describe('current daemon lifecycle retirement', () => { await client.ensureConnected() await client.request('createOrAttach', { sessionId: 'live', cols: 80, rows: 24 }) client.disconnect() - const daemon = server as unknown as { retirementRequested: boolean } - await waitFor(() => daemon.retirementRequested) + const daemon = server as unknown as DaemonServerInternals + await waitFor(() => daemon.lifecycle.retirementRequested) expect(onIdleShutdown).not.toHaveBeenCalled() expect(clock.pendingCount).toBe(0) @@ -612,14 +614,11 @@ describe('current daemon lifecycle retirement', () => { it('rejects create or attach once the idle admission fence is pending', async () => { await startServer() - const daemon = server as unknown as { - idleShutdownState: string - routeRequest(clientId: string, request: unknown): Promise - } - daemon.idleShutdownState = 'idle-shutdown-pending' + const daemon = server as unknown as DaemonServerInternals + daemon.lifecycle.state = 'idle-shutdown-pending' await expect( - daemon.routeRequest('late-client', { + daemon.requestRouter.route('late-client', { id: 'late-create', type: 'createOrAttach', payload: { sessionId: 'late', cols: 80, rows: 24 } @@ -629,30 +628,23 @@ describe('current daemon lifecycle retirement', () => { it('aborts the pending shutdown when create or attach started before the fence', async () => { await startServer() - const daemon = server as unknown as { - createOrAttachInFlight: number - idleShutdownState: string - beginIdleShutdown(): void - } - daemon.createOrAttachInFlight = 1 + const daemon = server as unknown as DaemonServerInternals + daemon.admission.createOrAttachInFlight = 1 - daemon.beginIdleShutdown() + daemon.lifecycle.beginIdleShutdown() - expect(daemon.idleShutdownState).toBe('running') + expect(daemon.lifecycle.state).toBe('running') expect(onIdleShutdown).not.toHaveBeenCalled() }) it('explicitly marks a post-fence accepted transport as retryable', async () => { await startServer() - const daemon = server as unknown as { - idleShutdownState: string - handleConnection(socket: Socket): void - } - daemon.idleShutdownState = 'idle-shutdown-pending' + const daemon = server as unknown as DaemonServerInternals + daemon.lifecycle.state = 'idle-shutdown-pending' const socket = new EventEmitter() as Socket socket.end = vi.fn() as unknown as Socket['end'] - daemon.handleConnection(socket) + daemon.connections.accept(socket) const payload = vi.mocked(socket.end).mock.calls[0]?.[0] expect(JSON.parse(String(payload))).toMatchObject({ ok: false, retryable: true }) diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index e79d4b9dfc8..d6c4240b19b 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -18,7 +18,8 @@ import { type DaemonProcessHandle } from './daemon-spawner' import { DAEMON_EXIT_ENDPOINT_OCCUPIED } from './daemon-endpoint-ownership' -import { DaemonPtyAdapter, type DaemonRespawnReason } from './daemon-pty-adapter' +import { DaemonPtyAdapter } from './daemon-pty-adapter' +import type { DaemonRespawnReason } from './daemon-pty-runtime-state' import { DaemonPtyRouter } from './daemon-pty-router' import { DaemonClient } from './client' import { diff --git a/src/main/daemon/daemon-listener-registry.ts b/src/main/daemon/daemon-listener-registry.ts new file mode 100644 index 00000000000..2fef9e7eef6 --- /dev/null +++ b/src/main/daemon/daemon-listener-registry.ts @@ -0,0 +1,19 @@ +export function removeDaemonListener(listeners: T[], listener: T): void { + const index = listeners.indexOf(listener) + if (index !== -1) { + listeners.splice(index, 1) + } +} + +export function notifyDaemonAuditListeners( + listeners: readonly ((value: T) => void)[], + value: T +): void { + for (const listener of listeners.slice()) { + try { + listener(value) + } catch { + // Audit observers cannot affect daemon operations. + } + } +} diff --git a/src/main/daemon/daemon-main.ts b/src/main/daemon/daemon-main.ts index 63e763474bc..b2683b0af99 100644 --- a/src/main/daemon/daemon-main.ts +++ b/src/main/daemon/daemon-main.ts @@ -1,4 +1,5 @@ -import { DaemonServer, type DaemonServerOptions } from './daemon-server' +import { DaemonServer } from './daemon-server' +import type { DaemonServerOptions } from './daemon-server-options' import type { DaemonFileLog } from './daemon-file-log' export type DaemonStartOptions = { diff --git a/src/main/daemon/daemon-preflight-client-replacement.test.ts b/src/main/daemon/daemon-preflight-client-replacement.test.ts index e6204708c25..7fbb46ebfd5 100644 --- a/src/main/daemon/daemon-preflight-client-replacement.test.ts +++ b/src/main/daemon/daemon-preflight-client-replacement.test.ts @@ -10,8 +10,8 @@ import { PROTOCOL_VERSION } from './types' import type { SubprocessHandle } from './session-subprocess-handle' type DaemonServerPrivate = { - pendingPtySpawnPreparations: Map> - clients: Map + preparations: { pending: Map> } + connections: { clients: Map } } function createMockSubprocess(): SubprocessHandle { @@ -96,7 +96,7 @@ describe('daemon preflight client replacement', () => { await replacement.ensureConnected() finishPreparation() await vi.waitFor(() => - expect((server as unknown as DaemonServerPrivate).pendingPtySpawnPreparations.size).toBe(0) + expect((server as unknown as DaemonServerPrivate).preparations.pending.size).toBe(0) ) expect(spawnSubprocess).not.toHaveBeenCalled() @@ -139,12 +139,13 @@ describe('daemon preflight client replacement', () => { disconnected.stream.destroy() await vi.waitFor(() => expect( - (server as unknown as DaemonServerPrivate).clients.get(disconnectedClientId)?.streamSocket + (server as unknown as DaemonServerPrivate).connections.clients.get(disconnectedClientId) + ?.streamSocket ).toBeNull() ) finishPreparation() await vi.waitFor(() => - expect((server as unknown as DaemonServerPrivate).pendingPtySpawnPreparations.size).toBe(0) + expect((server as unknown as DaemonServerPrivate).preparations.pending.size).toBe(0) ) expect(spawnSubprocess).toHaveBeenCalledOnce() diff --git a/src/main/daemon/daemon-pty-adapter-protocol-compatibility.test.ts b/src/main/daemon/daemon-pty-adapter-protocol-compatibility.test.ts index ad964da1c85..247dd8c10fa 100644 --- a/src/main/daemon/daemon-pty-adapter-protocol-compatibility.test.ts +++ b/src/main/daemon/daemon-pty-adapter-protocol-compatibility.test.ts @@ -2,7 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { rmSync } from 'node:fs' import { DaemonClient } from './client' -import { DaemonPtyAdapter, LIVENESS_PROBE_TIMEOUT_MS } from './daemon-pty-adapter' +import { DaemonPtyAdapter } from './daemon-pty-adapter' +import { LIVENESS_PROBE_TIMEOUT_MS } from './daemon-pty-session-control' import { COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION, GET_FOREGROUND_PROCESS_PROTOCOL_VERSION, diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 37eaa4068fc..292ab175f43 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -1,3068 +1,9 @@ -/* oxlint-disable max-lines -- Why: history .catch() safety wiring spread across spawn/event-routing is tightly coupled to the adapter↔history lifecycle. */ -import { basename } from 'node:path' -import { existsSync, readFileSync } from 'node:fs' -import { readFile } from 'node:fs/promises' -import { DaemonClient } from './client' -import { DAEMON_ENDPOINT_LOST_MESSAGE } from './daemon-endpoint-ownership' -import { getMacDaemonSystemResolverHealth } from './daemon-health' -import { getMacDaemonTccAttributionHealth } from './daemon-tcc-attribution' -import { isDaemonStaleForCurrentBundle } from './daemon-bundle-staleness' -import { parseDaemonPidFile, type ParsedDaemonPid } from './daemon-pid-file-parse' -import { - HistoryManager, - type HistoryCheckpointResult, - type HistoryRecoveryFreeze -} from './history-manager' -import { HistoryReader, type ColdRestoreInfo } from './history-reader' -import { getRecoveredHistorySeedSegments } from './terminal-history-seed-segments' -import { mintPtySessionId, parsePtySessionId } from './pty-session-id' -import { supportsPtyStartupBarrier } from './shell-ready' -import { CODEX_SHELL_READY_TIMEOUT_MS } from './session-shell-ready-barrier' -import { - CLEAN_DISCONNECT_PROTOCOL_VERSION, - COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION, - GET_FOREGROUND_PROCESS_PROTOCOL_VERSION, - AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, - AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, - DAEMON_UNAVAILABLE_RECONNECT_MESSAGE, - GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION, - PROTOCOL_VERSION, - supportsMode2031UnsubscribeFact, - supportsPtyStartupIngress, - type CreateOrAttachResult, - type DaemonEvent, - type GetSnapshotResult, - type ListSessionsResult, - SessionNotFoundError, - type SessionInfo, - type TakePendingOutputResult -} from './types' -import { - GET_SIZE_PROTOCOL_VERSION, - HISTORY_SEED_TRANSFER_PROTOCOL_VERSION, - SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION, - STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION -} from './daemon-protocol-version' -import { - isAgentSessionClaimedSpawnResult, - isAgentSessionOwnerBinding, - type AgentSessionOwnerBinding -} from '../../shared/agent-session-host-authority' -import { MAX_CLAIMED_AGENT_PTY_OWNER_ENTRIES } from '../../shared/claimed-agent-pty-owner' -import { cloneAgentSessionOwnerBinding } from '../../shared/claimed-agent-pty-owner-snapshot' -import type { - IPtyProvider, - PtyBackgroundStreamEvent, - PtyProviderBufferSnapshot, - PtyProcessInfo, - PtySpawnOptions, - PtySpawnResult -} from '../providers/types' -import type { PtyProcessInspection } from '../providers/pty-process-inspection' -import { parseTerminalKittyKeyboardFlags } from '../../shared/terminal-kitty-keyboard-flags' -import { isShellProcess } from '../../shared/agent-detection' -import { resolveWslSessionContext } from './wsl-session-context' -import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd' -import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' -import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery' -import type { PtyIncarnationId } from '../../shared/pty-incarnation' -import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd' -import { resolveUnixShellPath } from '../providers/local-pty-utils' -import { injectHistoryEnv, injectWslFishHistoryEnv, logHistoryInjection } from '../terminal-history' -import { addWslEnvKeys } from '../wsl-env' -import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error' -import { ColdRestorePayloadCache, type ColdRestorePayload } from './cold-restore-payload-cache' -import { CheckpointSessionQueue } from './daemon-checkpoint-session-queue' -import { buildDurableCheckpointSnapshot } from './daemon-durable-history-snapshot' -import { DAEMON_RESTORE_SCROLLBACK_ROWS } from './daemon-restore-scrollback-depth' -import { DAEMON_SESSION_SCROLLBACK_ROWS } from './daemon-session-scrollback-window' -import { PtyProcessListAdmission } from '../providers/pty-process-list-admission' -import { - iterateTerminalHistorySeedChunks, - measureTerminalHistorySeed, - TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS -} from './terminal-history-seed-chunks' -import { NdjsonLineTooLongError } from './ndjson' -import type { DaemonEndpointIdentity } from './daemon-hello-protocol' -import { - classifyDaemonAuditFailure, - recordAuthenticatedInventory, - type DaemonAuditContext, - type DaemonAuditObservation, - type DaemonAuditTrigger -} from './daemon-audit-classifier' -import type { DaemonEvidenceSource, ExactDaemonIncarnation } from './daemon-incarnation-evidence' -import { createDaemonAuditEligibilityTracker } from './daemon-audit-eligibility-event' -import { normalizeDesktopTerminalSnapshotRows } from '../../shared/terminal-scrollback-policy' -import type { TerminalExitCause } from '../../shared/terminal-exit-cause' -import { TerminalSessionOwnerUnverifiedError } from './daemon-errors' -import type { ExecutionHostId } from '../../shared/execution-host' -import type { TerminalOwnerIdentity } from '../../shared/terminal-owner-identity' +import { DaemonPtyDaemonRecovery } from './daemon-pty-daemon-recovery' +import { supportsMode2031UnsubscribeFact, type DaemonEvent } from './types' +import type { IPtyProvider } from '../providers/types' -type PendingDaemonSpawnOperation = { - exitsBySessionId: Map - ignoredExitIncarnationIds: Set - ignoreNextExit: boolean - acceptsUnroutedExit: boolean -} - -type HistoryRecoveryContext = { - freeze: HistoryRecoveryFreeze | null - unreadableSessionId: string | null - identityChanged: boolean -} - -type SnapshotCheckpointResult = { - checkpoint: HistoryCheckpointResult - snapshot: NonNullable | null -} - -// Why take-and-clear together: every consuming branch must reset the field, so pairing them stops one from forgetting. -function takeRecoveryFreeze( - historyRecovery: HistoryRecoveryContext, - sessionId: string -): HistoryRecoveryFreeze | undefined { - const freeze = - historyRecovery.freeze?.sessionId === sessionId ? historyRecovery.freeze : undefined - historyRecovery.freeze = null - return freeze -} -function providerSequenceFromCreateOrAttach( - result: CreateOrAttachResult -): PtySpawnResult['providerSequence'] { - if (result.isNew) { - return { value: 0, generation: 'reset' } - } - return typeof result.snapshot?.outputSequence === 'number' - ? { value: result.snapshot.outputSequence, generation: 'continued' } - : undefined -} - -export type DaemonPtyAdapterOptions = { - socketPath: string - tokenPath: string - pidPath?: string - profileScope?: string - protocolVersion?: number - /** Directory for disk-based terminal history; when set, raw PTY output is written to disk for cold restore on daemon crash. */ - historyPath?: string - /** Runtime profile directory used to verify daemon TCC attribution. */ - runtimeDir?: string - /** Current packaged version, or null for unpackaged builds. */ - packagedAppVersion?: string | null - /** Forks a fresh daemon after endpoint death or a confirmed health replacement. */ - respawn?: (reason: DaemonRespawnReason) => Promise void)> - /** Canonical execution host for persisted owner identity. */ - executionHostId?: ExecutionHostId -} - -export type DaemonRespawnReason = - | 'daemon_died' - | 'unhealthy_resolver' - | 'stale_bundle' - | 'severed_tcc_attribution' - -export type DaemonIdentityChangeEvent = { - previous: DaemonEndpointIdentity - current: DaemonEndpointIdentity -} - -const MAX_TOMBSTONES = 1000 -const MAX_CONCURRENT_CHECKPOINTS = 4 - -// Why a reattach deadline at all: a warm reattach is a user click, so it must never wait on a -// stalled history filesystem. Why 5s: a healthy deep-history rebuild replays at most -// DAEMON_RESTORE_SCROLLBACK_ROWS through the headless emulator in well under a second, so this -// only fires when the write path is genuinely wedged (STA-4173). -const DURABLE_HISTORY_OVERLAY_DEADLINE_MS = 5_000 - -// Why far below the client's 30s default: a wedged daemon holds its socket open, so an unbounded -// probe stalls a pane mount for the full request timeout — and the owner fan-out waits on every -// adapter, so one hung daemon stalls each restoring pane. Answering "unknown" quickly is strictly -// better here: unknown never authorizes retirement, it only defers it. -export const LIVENESS_PROBE_TIMEOUT_MS = 2_000 - -// Why: providers take an absolute teardown deadline, but the client RPC takes a -// relative timeout — convert only here, at the request itself, so sequential RPCs -// naturally share the remaining budget (undefined keeps the client's 30s default). -function remainingRequestTimeoutMs(deadlineMs: number | undefined): number | undefined { - return deadlineMs === undefined ? undefined : Math.max(1, deadlineMs - Date.now()) -} - -// Why a distinct error: teardown callers must read this as "not proven stopped" and leave the PTY -// alive, not as a kill that failed. It never matches isPtyAlreadyGoneError, so `stopAndWait` reports -// the pty unverified and worktree sleep declines to commit it. -export class FinalCheckpointWaitExpiredError extends Error { - constructor(sessionId: string) { - super(`Final history checkpoint did not settle within the teardown deadline: ${sessionId}`) - this.name = 'FinalCheckpointWaitExpiredError' - } -} - -// Why only the caller's wait is bounded and never the work itself: cancelling durable work would -// silently drop what the user left on screen. This decides how long a caller blocks, nothing more — -// `work` keeps running behind an abandoned wait and still commits. False means the caller gave up. -// A rejection before the deadline still propagates, so a genuinely failed operation is not masked. -async function awaitWithinCallerDeadline( - work: Promise, - deadlineMs: number -): Promise { - // Why up front: once the race is abandoned nothing observes a later rejection here. - void work.catch(() => {}) - let timer: ReturnType | undefined - try { - return await Promise.race([ - work.then(() => true), - new Promise((resolve) => { - timer = setTimeout(() => resolve(false), Math.max(1, deadlineMs - Date.now())) - timer.unref?.() - }) - ]) - } finally { - clearTimeout(timer) - } -} - -export class TerminalKilledError extends Error { - constructor(sessionId: string) { - super(`Session "${sessionId}" was explicitly killed`) - this.name = 'TerminalKilledError' - } -} - -export class DaemonPtyAdapter implements IPtyProvider { - readonly protocolVersion: number - private socketPath: string - private tokenPath: string - private pidPath: string | null - private pidRecord: ParsedDaemonPid | null - private client: DaemonClient - private auditContext: DaemonAuditContext - private lastAuthenticatedIdentity: DaemonEndpointIdentity | null = null - private exactDaemonIncarnation: ExactDaemonIncarnation | null = null - private lastAuditObservation: DaemonAuditObservation | null = null - // Why: every listProcesses call republishes the same observation; unthrottled it drains the shared per-session telemetry ceiling. - private readonly trackAuditEligibility = createDaemonAuditEligibilityTracker() - private auditObservationListeners: ((observation: DaemonAuditObservation) => void)[] = [] - private identityChangeListeners: ((event: DaemonIdentityChangeEvent) => void)[] = [] - private historyManager: HistoryManager | null - private historyReader: HistoryReader | null - private respawnFn: DaemonPtyAdapterOptions['respawn'] | null - private runtimeDir: string | null - private packagedAppVersion: string | null - private readonly executionHostId: ExecutionHostId - private readonly ownerEndpointRef: string - private pendingRespawnAdoptionRelease: (() => void) | null = null - private respawnAdoptionClosed = false - // Why: concurrent spawn() calls hitting a dead daemon would each fork their own; this promise coalesces respawns so only the first forks and the rest await it. - private respawnPromise: Promise | null = null - private staleBundleReplacementPromise: Promise | null = null - private writeRecoveryPromise: Promise | null = null - private writeRecoveryAttempted = false - private dataListeners: ((payload: { - id: string - data: string - sequenceChars?: number - transformed?: boolean - seq?: number - }) => void)[] = [] - private exitListeners: ((payload: { - id: string - code: number - incarnationId?: PtyIncarnationId - cause?: TerminalExitCause - }) => void)[] = [] - private backgroundStreamListeners: ((payload: PtyBackgroundStreamEvent) => void)[] = [] - // Why: lets main fan a dead-endpoint signal to every affected pane, not just the written one (STA-2373 sibling-freeze). - private writeUnavailableListeners: ((payload: { id: string }) => void)[] = [] - private removeEventListener: (() => void) | null = null - private initialCwds = new Map() - private wslDistrosBySessionId = new Map() - // Why: StrictMode/re-render remounts can call createOrAttach for a just-killed session; tombstones stop the daemon resurrecting it (Map evicts oldest-first, per terminal-host.ts). - private killedSessionTombstones = new Map() - // Why: React StrictMode double-mounts; this sticky cache returns the same cold restore data on remount until the renderer acknowledges it. - private sleepRestoreSessionIds = new Set() - private coldRestoreCache = new ColdRestorePayloadCache(undefined, (sessionId) => { - this.sleepRestoreSessionIds.delete(sessionId) - }) - private activeSessionIds = new Set() - // Set only once this daemon has rejected `getSize` as unknown; its protocol number cannot prove it. - private getSizeUnsupported = false - // A replacement daemon has none of the old PTYs; only createOrAttach can make their bindings writable again. - private sessionsAwaitingDaemonRecovery = new Set() - private sessionIncarnations = new Map() - private sessionOwnerIdentities = new Map() - private livenessInventoryInFlight: Promise | null = null - private pendingSpawnOperationsBySessionId = new Map>() - private pendingClaimSpawnOperations = new Set() - private historySpawnLocks = new Map>() - private dirtySessionVersions = new Map() - // Why: a cold-restored session is a fresh shell atop a pre-crash log; incremental appends would be rejected on restore, so the first tick re-anchors with a full snapshot. - private sessionsNeedingFullCheckpoint = new Set() - // Why: overflow or an unpersisted drain breaks disk-to-queue continuity, so only the daemon emulator can safely re-anchor the next checkpoint. - private sessionsNeedingLiveCheckpoint = new Set() - // Why: a fresh adapter may reuse deep disk history only when the checkpoint proves it ends at the daemon's preceding pending-output batch. - private sessionsNeedingContinuityCheckpoint = new Set() - private checkpointTimer: ReturnType | null = null - private checkpointInFlight: Promise | null = null - // Why per session: exclusivity protects one session directory's tmp-write/rename, so ordering - // every session behind one tail let a single stalled checkpoint block all reattaches (STA-4173). - private checkpointQueue = new CheckpointSessionQueue() - private nonFinalCheckpointAdmissionSessionIds = new Set() - private nonFinalAdmissionDeniedSessionIds = new Set() - private nonFinalGlobalAdmissionWarningActive = false - private overlayDeadlineWarnedSessionIds = new Set() - private periodicDeadlineWarnedSessionIds = new Set() - private keepHistoryShutdowns = new Set>() - private disconnectOnlyPromise: Promise | null = null - // Why: checkpoint persistence needs the getSnapshot RPC (v4+); legacy daemons reject it, spamming logs every 5s. - private supportsCheckpoints: boolean - // Why: incremental checkpoints need the takePendingOutput RPC (v13+); older daemons fall back to full-snapshot checkpoints. - private supportsIncrementalCheckpoints: boolean - // Why: producer pause/resume notifications require v19+; gate them to silent no-ops on legacy daemons. - private supportsProducerFlowControl: boolean - private supportsAuthoritativeBufferSnapshots: boolean - private supportsStartupIngress: boolean - private pausedProducerSessionIds = new Set() - // Why tracked here: the daemon's background set dies with the daemon process/socket; re-sync on a fresh connection so hidden panes stay thinned. - private backgroundedSessionIds = new Set() - // Why: a daemon surviving a socket drop can hold a pause whose resume died with the connection; owe a resume on reconnect (daemon's 5s failsafe covers the gap). - private producerResumesOwedOnReconnect = new Set() - private static CHECKPOINT_INTERVAL_MS = 5_000 - // Why the background pass gets a longer deadline: deferral keeps the session dirty, so this - // should sit above slow-but-working disk rather than churn it. - private static PERIODIC_CHECKPOINT_DEADLINE_MS = 15_000 - // Why: streaming sessions re-trigger full multi-MB checkpoints every tick; this cooldown caps cap/overflow snapshots per session (~9x less writes, bounded cold-crash staleness). - private static FULL_CHECKPOINT_COOLDOWN_MS = 45_000 - private lastFullCheckpointAt = new Map() - - supportsGitCredentialGuardHost(): boolean { - return this.protocolVersion >= GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION - } - - canProvideAuthoritativeBufferSnapshot(_id: string): boolean { - return this.supportsAuthoritativeBufferSnapshots - } - - // Why one predicate (#9993): the attach-time clear and setPtyBackgrounded must agree on - // which daemons may hold a background hint. Daemons outlive the desktop that set it, so - // if these two drift a preserved daemon keeps a hint this process would never grant. - private get canDelegateBackgroundToDaemon(): boolean { - return ( - this.supportsAuthoritativeBufferSnapshots && - supportsMode2031UnsubscribeFact(this.protocolVersion) - ) - } - - constructor(opts: DaemonPtyAdapterOptions) { - this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION - this.socketPath = opts.socketPath - this.tokenPath = opts.tokenPath - this.pidPath = opts.pidPath ?? null - this.pidRecord = readDaemonPidRecord(this.pidPath) - this.auditContext = { - protocolGeneration: this.protocolVersion, - provider: 'local-daemon', - endpoint: opts.socketPath, - tokenPath: opts.tokenPath, - endpointKind: process.platform === 'win32' ? 'windows-named-pipe' : 'unix-socket', - profileScope: opts.profileScope ?? '' - } - this.client = new DaemonClient({ - socketPath: opts.socketPath, - tokenPath: opts.tokenPath, - protocolVersion: opts.protocolVersion - }) - this.historyManager = opts.historyPath ? new HistoryManager(opts.historyPath) : null - this.historyReader = opts.historyPath ? new HistoryReader(opts.historyPath) : null - this.respawnFn = opts.respawn ?? null - this.runtimeDir = opts.runtimeDir ?? opts.profileScope ?? null - this.packagedAppVersion = opts.packagedAppVersion ?? null - this.executionHostId = opts.executionHostId ?? 'local' - // Opaque and portable: never persist socket/token paths or profile dirs. - this.ownerEndpointRef = `daemon-v${this.protocolVersion}` - this.supportsCheckpoints = this.protocolVersion >= 4 - this.supportsIncrementalCheckpoints = this.protocolVersion >= 13 - this.supportsProducerFlowControl = this.protocolVersion >= 19 - this.supportsAuthoritativeBufferSnapshots = - this.protocolVersion >= SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION - this.supportsStartupIngress = supportsPtyStartupIngress(this.protocolVersion) - this.client.onDisconnected(() => { - if (!this.respawnAdoptionClosed) { - // Why re-arm here: the latch is otherwise only cleared when every awaiting - // session rebinds, and background sessions (no mounted pane, so nothing ever - // calls createOrAttach for them) never do — which would leave the fan-out - // permanently latched off after the first death. Fires once per connection. - this.writeRecoveryAttempted = false - for (const id of this.activeSessionIds) { - this.sessionsAwaitingDaemonRecovery.add(id) - } - } - for (const id of this.pausedProducerSessionIds) { - this.producerResumesOwedOnReconnect.add(id) - } - this.pausedProducerSessionIds.clear() - this.observeAuditFailure('transport_closed') - }) - } - - getHistoryManager(): HistoryManager | null { - return this.historyManager - } - - getLastAuthenticatedDaemonIdentity(): DaemonEndpointIdentity | null { - return this.lastAuthenticatedIdentity ? { ...this.lastAuthenticatedIdentity } : null - } - - /** Returns durable identity only after the socket has authenticated. */ - private ownerIdentity( - sessionIncarnationId?: PtyIncarnationId - ): TerminalOwnerIdentity | undefined { - const daemon = this.lastAuthenticatedIdentity - if (!daemon || !sessionIncarnationId) { - return undefined - } - return { - executionHostId: this.executionHostId, - ownerKind: 'daemon', - ownerIncarnationId: daemon.launchNonce, - sessionIncarnationId, - protocolVersion: this.protocolVersion, - endpointRef: this.ownerEndpointRef - } - } - - getTerminalOwnerIdentity(id: string): TerminalOwnerIdentity | null { - if (!this.hasExactSessionAuthority(id)) { - return null - } - return this.ownerIdentity(this.sessionIncarnations.get(id)) ?? null - } - - getLastAuditObservation(): DaemonAuditObservation | null { - return this.lastAuditObservation - } - - onDaemonIdentityChanged(listener: (event: DaemonIdentityChangeEvent) => void): () => void { - this.identityChangeListeners.push(listener) - return () => removeListener(this.identityChangeListeners, listener) - } - - onAuditEligibilityObservation( - listener: (observation: DaemonAuditObservation) => void - ): () => void { - this.auditObservationListeners.push(listener) - return () => removeListener(this.auditObservationListeners, listener) - } - - supportsAgentSessionClaims(): boolean { - return this.protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION - } - - providesAgentSessionOwnerListings(_ptyId: string): boolean { - return this.supportsAgentSessionClaims() - } - - supportsAgentSessionCreateOperations(): boolean { - // Why: old daemons never advertised the lower-owner protocol, so preserve their legacy launch. - return this.protocolVersion >= AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION - } - - async spawn(opts: PtySpawnOptions): Promise { - const spawnOpts = this.withHistoryIsolation(opts) - const sessionId = spawnOpts.sessionId ?? mintPtySessionId(spawnOpts.worktreeId) - const operation = { - exitsBySessionId: new Map(), - ignoredExitIncarnationIds: new Set(), - ignoreNextExit: false, - acceptsUnroutedExit: false - } - const operations = this.pendingSpawnOperationsBySessionId.get(sessionId) ?? new Set() - operations.add(operation) - this.pendingSpawnOperationsBySessionId.set(sessionId, operations) - if (opts.agentSessionEnsure) { - this.pendingClaimSpawnOperations.add(operation) - } - const historyRecovery: HistoryRecoveryContext = { - freeze: null, - unreadableSessionId: null, - identityChanged: false - } - try { - return await this.withHistorySpawnLock(sessionId, () => - this.withDaemonRetry(() => - this.doSpawn({ ...spawnOpts, sessionId }, operation, historyRecovery) - ) - ) - } finally { - if (historyRecovery.freeze) { - this.historyManager?.abandonRecoveryFreeze(historyRecovery.freeze) - } - this.pendingClaimSpawnOperations.delete(operation) - operations.delete(operation) - if (operations.size === 0) { - this.pendingSpawnOperationsBySessionId.delete(sessionId) - } - } - } - - private withHistoryIsolation(opts: PtySpawnOptions): PtySpawnOptions { - const wslContext = resolveWslSessionContext({ - cwd: opts.cwd, - sessionId: opts.sessionId, - shellOverride: opts.shellOverride, - terminalWindowsWslDistro: opts.terminalWindowsWslDistro - }) - if ( - opts.attachOnly === true || - (opts.sessionId !== undefined && opts.isNewSession !== true) || - !opts.worktreeId || - opts.historyIsolationEnabled !== true || - (process.platform === 'win32' && !wslContext) - ) { - return opts - } - const env = { ...opts.env } - const preferredShell = wslContext - ? 'bash' - : opts.shellOverride || env.SHELL || process.env.SHELL || '/bin/zsh' - const shellPath = resolveUnixShellPath(preferredShell) - const historyArgs = [ - env, - opts.worktreeId, - shellPath, - opts.cwd ?? resolveSafePtyDefaultCwd() - ] as const - const result = wslContext - ? injectHistoryEnv(...historyArgs, { wslDistro: wslContext.distro }) - : injectHistoryEnv(...historyArgs) - if (wslContext) { - injectWslFishHistoryEnv(env, opts.worktreeId, wslContext.distro) - addWslEnvKeys(env, ['HISTFILE', 'fish_history']) - } - logHistoryInjection(opts.worktreeId, result) - return { ...opts, env } - } - - private async doSpawn( - opts: PtySpawnOptions, - operation: PendingDaemonSpawnOperation, - historyRecovery: HistoryRecoveryContext - ): Promise { - if ( - opts.agentSessionEnsure && - this.protocolVersion < AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION - ) { - throw new Error('agent_session_claim_unavailable') - } - const requestedSessionId = opts.sessionId! - // Why: v31+ daemons already enforce attach-only. Older daemons are handled by the - // legacy create-then-kill fence below, while v36 can still reattach surviving PTYs. - const attachOnly = opts.attachOnly === true - const emulateLegacyAttachOnly = - attachOnly && this.protocolVersion < STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION - let sessionId = requestedSessionId - let wslDistro = resolveWslSessionContext({ - cwd: opts.cwd, - sessionId, - shellOverride: opts.shellOverride, - terminalWindowsWslDistro: opts.terminalWindowsWslDistro - })?.distro - const freezeHistory = async (): Promise => { - if (!this.historyManager) { - return - } - if (historyRecovery.freeze?.sessionId === sessionId) { - return - } - if (historyRecovery.freeze) { - this.historyManager.abandonRecoveryFreeze(historyRecovery.freeze) - } - historyRecovery.freeze = await this.historyManager.freezeForRecovery(sessionId) - historyRecovery.unreadableSessionId = null - } - const detectColdRestore = async (options?: { - ignoreCleanEnd?: boolean - }): Promise => { - if (!this.historyReader) { - return null - } - await freezeHistory() - const detection = await this.historyReader.detectColdRestoreState(sessionId, { - ...options, - wslDistro - }) - if (detection.status === 'unreadable') { - historyRecovery.unreadableSessionId = detection.sessionId - return null - } - const restoreInfo = detection.status === 'restored' ? detection.restoreInfo : null - if (detection.status === 'restored' && detection.hasUnreadableRecovery) { - historyRecovery.unreadableSessionId = detection.sessionId - } - if (!restoreInfo) { - return null - } - return { - ...restoreInfo, - cwd: - normalizeWslColdRestoreCwd({ - recoveredCwd: restoreInfo.cwd, - requestedCwd: opts.cwd ?? resolveSafePtyDefaultCwd(), - wslDistro - }) ?? '' - } - } - - if (this.killedSessionTombstones.has(sessionId)) { - throw new TerminalKilledError(sessionId) - } - - if (opts.isNewSession) { - await this.replaceUnhealthyMacResolverDaemonBeforeNewPty() - await this.replaceStaleBundleDaemonBeforeNewPty() - await this.replaceSeveredMacTccDaemonBeforeNewPty() - } - - await this.ensureConnected() - // Why before createOrAttach: a preserved daemon may still think this session is backgrounded — from - // a v19 that thins without a recoverable seq, or (#9993) from a pre-v29 that a previous desktop - // handed 2031 scan authority to and can never retract it. Clear it before any bytes are attached. - if (!this.canDelegateBackgroundToDaemon) { - this.setPtyBackgrounded(sessionId, false) - } - - // Why detect crash-recovery history before spawning: the revived shell should inherit the recovered cwd/dims, not the renderer's mount-time request. - // Why probe aliveness first: detectColdRestore replays up to ~5MB on the main process, but a live session's snapshot supersedes disk, so the replay would be wasted. - let restoreInfo: ColdRestoreInfo | null = null - let restoreSkippedForLiveSession = false - const historyProbe = opts.attachOnly - ? undefined - : this.historyReader?.probeRestorableHistory(sessionId) - if (historyProbe && historyProbe.status !== 'none') { - if ((await this.getAppliedSize(sessionId)) !== null) { - restoreSkippedForLiveSession = true - if (this.historyManager && !this.historyManager.hasWriter(sessionId)) { - await detectColdRestore() - restoreInfo = null - } - } else { - restoreInfo = await detectColdRestore() - } - } - let effectiveCwd = restoreInfo?.cwd ?? opts.cwd - let effectiveCols = restoreInfo?.cols ?? opts.cols - let effectiveRows = restoreInfo?.rows ?? opts.rows - - const shellReadySupported = opts.command ? supportsPtyStartupBarrier(opts.env ?? {}) : false - const isCodexStartupCommand = - recognizeAgentProcessFromCommandLine(opts.command)?.agent === 'codex' - const shouldWaitForShellReady = - isCodexStartupCommand && - shouldUseShellReadyStartupDelivery({ - command: opts.command, - startupCommandDelivery: opts.startupCommandDelivery - }) - const shellReadyTimeoutMs = - shellReadySupported && isCodexStartupCommand && !shouldWaitForShellReady - ? CODEX_SHELL_READY_TIMEOUT_MS - : undefined - - const requestCreateOrAttach = async ( - historySeed: string | undefined, - historySeedTransferId: string | undefined - ): Promise => { - if (opts.signal?.aborted) { - throw new Error('client_disconnected') - } - const payload = { - sessionId, - cols: effectiveCols, - rows: effectiveRows, - cwd: attachOnly ? undefined : effectiveCwd, - env: attachOnly ? undefined : opts.env, - envToDelete: attachOnly ? undefined : opts.envToDelete, - command: attachOnly ? undefined : opts.command, - startupCommandDelivery: attachOnly ? undefined : opts.startupCommandDelivery, - launchAgent: attachOnly ? undefined : opts.launchAgent, - ...(attachOnly && !emulateLegacyAttachOnly ? { attachOnly: true } : {}), - ...(attachOnly && - opts.expectedIncarnationIsAuthoritative === true && - opts.expectedIncarnationId !== undefined - ? { expectedIncarnationId: opts.expectedIncarnationId } - : {}), - // Why: without forwarding the override, the daemon falls back to cmd.exe/PowerShell, ignoring the shell the renderer chose; this matches LocalPtyProvider. - shellOverride: attachOnly ? undefined : opts.shellOverride, - terminalWindowsWslDistro: attachOnly ? undefined : opts.terminalWindowsWslDistro, - terminalWindowsPowerShellImplementation: attachOnly - ? undefined - : opts.terminalWindowsPowerShellImplementation, - shellReadySupported: attachOnly ? false : shellReadySupported, - ...(!attachOnly && shellReadyTimeoutMs !== undefined ? { shellReadyTimeoutMs } : {}), - ...(historySeed ? { historySeed } : {}), - ...(historySeedTransferId ? { historySeedTransferId } : {}), - ...(this.supportsStartupIngress && !attachOnly && opts.startupIngress - ? { startupIngress: opts.startupIngress } - : {}), - ...(!attachOnly && opts.agentSessionEnsure - ? { agentSessionEnsure: opts.agentSessionEnsure } - : {}) - } - operation.acceptsUnroutedExit = !attachOnly - try { - return await (opts.signal - ? this.client.request( - 'createOrAttach', - payload, - undefined, - opts.signal - ) - : this.client.request('createOrAttach', payload)) - } finally { - operation.acceptsUnroutedExit = false - } - } - - const createOrAttach = async ( - historySeedSegments: readonly string[] | null - ): Promise => { - // Why scoped per call: the aliveness-probe retry re-runs this with its own seed, so a first-call - // delivery failure must not force historySeeded=false on a retry that seeded successfully. - let historySeedUnavailable = false - const deliverSeedAndCreate = async (): Promise => { - if (!historySeedSegments || historySeedSegments.length === 0) { - return requestCreateOrAttach(undefined, undefined) - } - const metrics = measureTerminalHistorySeed(historySeedSegments) - if (metrics.codeUnits <= TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS) { - try { - return await requestCreateOrAttach(historySeedSegments.join(''), undefined) - } catch (error) { - if (!(error instanceof NdjsonLineTooLongError)) { - throw error - } - historySeedUnavailable = true - return requestCreateOrAttach(undefined, undefined) - } - } - if (this.protocolVersion < HISTORY_SEED_TRANSFER_PROTOCOL_VERSION) { - historySeedUnavailable = true - return requestCreateOrAttach(undefined, undefined) - } - - let transferId: string | undefined - try { - const started = await this.client.request<{ transferId: string }>( - 'startHistorySeedTransfer', - metrics - ) - transferId = started.transferId - let index = 0 - for (const data of iterateTerminalHistorySeedChunks(historySeedSegments)) { - await this.client.request('appendHistorySeedTransfer', { transferId, index, data }) - index += 1 - } - await this.client.request('finishHistorySeedTransfer', { transferId }) - } catch (error) { - if (transferId) { - await this.client.request('abortHistorySeedTransfer', { transferId }).catch(() => {}) - } - if (isDaemonGoneError(error)) { - throw error - } - historySeedUnavailable = true - return requestCreateOrAttach(undefined, undefined) - } - return requestCreateOrAttach(undefined, transferId) - } - const result = await deliverSeedAndCreate() - return historySeedUnavailable && result.historySeeded === undefined - ? { ...result, historySeeded: false } - : result - } - - let historySeedSegments = restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null - const adoptSpawnResultSession = async (spawnResult: CreateOrAttachResult): Promise => { - const requestedSessionId = sessionId - if ( - opts.agentSessionEnsure && - !isAgentSessionClaimedSpawnResult(spawnResult.agentSessionEnsure) - ) { - // Why: a claim-incapable owner may already have spawned before returning - // a malformed response; retire only this requested session before failing closed. - await this.client.request('kill', { sessionId: requestedSessionId }).catch(() => {}) - throw new Error('agent_session_claim_unavailable') - } - sessionId = spawnResult.agentSessionEnsure?.owner.ptyId ?? requestedSessionId - if (requestedSessionId === sessionId) { - return - } - if (historyRecovery.freeze) { - this.historyManager?.abandonRecoveryFreeze(historyRecovery.freeze) - historyRecovery.freeze = null - } - historyRecovery.unreadableSessionId = null - historyRecovery.identityChanged = true - restoreInfo = null - historySeedSegments = null - } - let result = await createOrAttach(historySeedSegments) - if (emulateLegacyAttachOnly && result.isNew) { - operation.ignoreNextExit = true - await this.client.request('kill', { sessionId: requestedSessionId, immediate: true }) - throw new SessionNotFoundError(requestedSessionId) - } - await adoptSpawnResultSession(result) - // Both ids: adoptSpawnResultSession may have rewritten sessionId to the claim owner. - this.clearSessionAwaitingDaemonRecovery(requestedSessionId) - this.clearSessionAwaitingDaemonRecovery(sessionId) - const exitedResult = this.resultForExitBeforeSpawnReply(sessionId, result, operation) - if (exitedResult) { - return exitedResult - } - if ( - opts.attachOnly && - opts.expectedIncarnationIsAuthoritative && - (!result.incarnationId || result.incarnationId !== opts.expectedIncarnationId) - ) { - throw new TerminalSessionOwnerUnverifiedError(sessionId) - } - this.recordSessionAuthority(sessionId, result.incarnationId) - const claimResult = (): Pick | Record => - result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {} - const incarnationResult = (): Pick | Record => - result.incarnationId ? { incarnationId: result.incarnationId } : {} - const ownerIdentityResult = (): - | Pick - | Record => { - const identity = this.ownerIdentity(result.incarnationId) - return identity ? { ownerIdentity: identity } : {} - } - let providerWslDistro = result.wslDistro === undefined ? wslDistro : result.wslDistro - // Why: explicit null from a current daemon overrides the caller's WSL preference; undefined keeps compatibility with older daemons. - wslDistro = providerWslDistro ?? undefined - if (wslDistro) { - this.wslDistrosBySessionId.set(sessionId, wslDistro) - } else if (providerWslDistro === null || result.isNew) { - this.wslDistrosBySessionId.delete(sessionId) - } - const launchIdentity = (): { launchAgent?: NonNullable } => - result.launchAgent ? { launchAgent: result.launchAgent } : {} - - if (effectiveCwd) { - this.initialCwds.set(sessionId, effectiveCwd) - } - - // Why: surface the daemon's shell pid via PtySpawnResult so ipc/pty registers with the memory collector without a provider-specific accessor. - let pid = typeof result.pid === 'number' && result.pid > 0 ? result.pid : null - - // Why: check sticky cache first — StrictMode double-mounts call spawn twice; the second call (isNew=false) must still return cached cold restore data. - const cachedRestore = this.coldRestoreCache.get(sessionId) - if (cachedRestore) { - // Why: wake-after-sleep lands here too; sleep dropped active tracking + the history writer, so re-register both or the next sleep/wake restores a blank terminal. - this.activeSessionIds.add(sessionId) - if (this.historyManager && !historyRecovery.identityChanged) { - const recoveryFreeze = takeRecoveryFreeze(historyRecovery, sessionId) - if (historyRecovery.unreadableSessionId === sessionId) { - this.historyManager.suspendSession(sessionId, recoveryFreeze) - } else { - this.historyManager.reopenSession(sessionId, recoveryFreeze) - } - } - return { - id: sessionId, - ...incarnationResult(), - pid, - ...claimResult(), - ...launchIdentity(), - coldRestore: cachedRestore, - ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), - ...(!result.isNew ? { isReattach: true } : {}) - } - } - - // Why: the probe→createOrAttach gap is racy — the session can exit in between, so re-detect to match the unprobed restore path. - // Why ignoreCleanEnd: the raced exit event can write endedAt before the reply; nulling the restore here would delete the checkpoint instead of restoring it. - if (!historyRecovery.identityChanged && result.isNew && restoreSkippedForLiveSession) { - restoreInfo = await detectColdRestore({ ignoreCleanEnd: true }) - historySeedSegments = restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null - if (restoreInfo && historySeedSegments && historySeedSegments.length > 0) { - // Why: the aliveness probe raced with session death, so the first - // create lacked recovery bytes. Replace it before exposing the PTY. - if (result.incarnationId) { - operation.ignoredExitIncarnationIds.add(result.incarnationId) - } - operation.ignoreNextExit = true - await this.client.request('kill', { sessionId, immediate: true }) - effectiveCwd = restoreInfo.cwd - effectiveCols = restoreInfo.cols - effectiveRows = restoreInfo.rows - result = await createOrAttach(historySeedSegments) - await adoptSpawnResultSession(result) - const exitedRetryResult = this.resultForExitBeforeSpawnReply(sessionId, result, operation) - if (exitedRetryResult) { - return exitedRetryResult - } - this.recordSessionAuthority(sessionId, result.incarnationId) - providerWslDistro = result.wslDistro === undefined ? wslDistro : result.wslDistro - wslDistro = providerWslDistro ?? undefined - if (wslDistro) { - this.wslDistrosBySessionId.set(sessionId, wslDistro) - } else if (providerWslDistro === null || result.isNew) { - this.wslDistrosBySessionId.delete(sessionId) - } - pid = typeof result.pid === 'number' && result.pid > 0 ? result.pid : null - this.initialCwds.set(sessionId, effectiveCwd) - } - } else if ( - !historyRecovery.identityChanged && - !result.isNew && - result.historySeeded === false - ) { - restoreInfo = await detectColdRestore() - historySeedSegments = restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null - } - - const wasAlreadyManaged = this.activeSessionIds.has(sessionId) - this.activeSessionIds.add(sessionId) - const providerSequence = providerSequenceFromCreateOrAttach(result) - - // Cold restore: daemon made a new session but disk history shows an unclean shutdown → return saved scrollback. - if (restoreInfo && (result.isNew || result.historySeeded === false)) { - const coldRestore = this.buildColdRestorePayload(restoreInfo) - const canReanchorHistory = - !historySeedSegments || historySeedSegments.length === 0 || result.historySeeded === true - // Why: registerWriter (not openSession) avoids deleting checkpoint.json — the only recovery data if the revived daemon crashes before the next tick. - if (this.historyManager && !historyRecovery.identityChanged) { - const recoveryFreeze = takeRecoveryFreeze(historyRecovery, sessionId) - if (historyRecovery.unreadableSessionId === sessionId) { - await this.historyManager.openSession(sessionId, { - cwd: effectiveCwd ?? '', - cols: effectiveCols, - rows: effectiveRows, - ...(recoveryFreeze ? { recoveryFreeze } : {}), - quarantineUnreadableRecovery: true - }) - if (this.historyManager.hasWriter(sessionId)) { - this.sessionsNeedingFullCheckpoint.add(sessionId) - this.sessionsNeedingLiveCheckpoint.add(sessionId) - this.lastFullCheckpointAt.delete(sessionId) - } - } else if (canReanchorHistory) { - this.historyManager.registerWriter(sessionId, recoveryFreeze) - this.sessionsNeedingFullCheckpoint.add(sessionId) - // Why: the revived generation has no valid checkpoint yet; a cooldown inherited from the pre-crash generation must not defer this re-anchor. - this.lastFullCheckpointAt.delete(sessionId) - } else { - // Preserve old recovery files when the new daemon can't include them; a fresh-only checkpoint would make the data loss permanent. - this.historyManager.suspendSession(sessionId, recoveryFreeze) - } - } - if (coldRestore) { - this.coldRestoreCache.set(sessionId, coldRestore) - return { - id: sessionId, - ...incarnationResult(), - ...ownerIdentityResult(), - pid, - ...claimResult(), - ...launchIdentity(), - coldRestore, - ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), - ...(providerSequence ? { providerSequence } : {}), - ...(!result.isNew ? { isReattach: true } : {}) - } - } - return { - id: sessionId, - ...incarnationResult(), - ...ownerIdentityResult(), - pid, - ...claimResult(), - ...launchIdentity(), - ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), - ...(providerSequence ? { providerSequence } : {}) - } - } - - if (this.historyManager && !historyRecovery.identityChanged && result.isNew) { - const recoveryFreeze = takeRecoveryFreeze(historyRecovery, sessionId) - await this.historyManager.openSession(sessionId, { - cwd: effectiveCwd ?? '', - cols: effectiveCols, - rows: effectiveRows, - ...(recoveryFreeze ? { recoveryFreeze } : {}), - ...(historyRecovery.unreadableSessionId === sessionId - ? { quarantineUnreadableRecovery: true } - : {}) - }) - } else if ( - this.historyManager && - !historyRecovery.identityChanged && - (result.historySeeded === false || historyRecovery.unreadableSessionId === sessionId) - ) { - // Why: the daemon keeps this failure bit with the live session, so a new adapter can't promote its fresh-only snapshot after restart. - this.historyManager.suspendSession(sessionId, takeRecoveryFreeze(historyRecovery, sessionId)) - } else if (this.historyManager && !historyRecovery.identityChanged) { - // Why: on warm reattach after relaunch the HistoryManager is fresh; registerWriter adds a writer without deleting the still-only-valid checkpoint. - this.historyManager.registerWriter(sessionId, takeRecoveryFreeze(historyRecovery, sessionId)) - if (!wasAlreadyManaged) { - // Why: a previous adapter may have drained records it never persisted, so the first compact must prove disk-to-daemon continuity. - this.sessionsNeedingFullCheckpoint.add(sessionId) - this.sessionsNeedingContinuityCheckpoint.add(sessionId) - this.lastFullCheckpointAt.delete(sessionId) - } - } - - const isReattach = !result.isNew - if (!isReattach || !result.snapshot) { - return { - id: sessionId, - ...incarnationResult(), - ...ownerIdentityResult(), - pid, - ...claimResult(), - ...launchIdentity(), - ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), - ...(providerSequence ? { providerSequence } : {}), - ...(isReattach ? { isReattach: true } : {}) - } - } - - const reattachSnapshot = await this.overlayDurableRestoreSnapshot(sessionId, result.snapshot) - const reattachProviderSequence = - typeof reattachSnapshot.outputSequence === 'number' - ? { value: reattachSnapshot.outputSequence, generation: 'continued' as const } - : providerSequence - const isAltScreen = reattachSnapshot.modes.alternateScreen - const snapshotPrefix = reattachSnapshot.scrollbackAnsi + reattachSnapshot.rehydrateSequences - const snapshotFrame = reattachSnapshot.snapshotAnsi - const snapshotPayload = snapshotPrefix + snapshotFrame - // Why kitty flags ride beside the payload, not inside it: the snapshot reaches renderer xterms where POST_REPLAY_REATTACH_RESET's kitty reset must win (terminal-query-authority.md §kitty). - // Why known `0` is no longer dropped: the pane tracker must be able to tell - // "the app negotiated nothing" from "this reattach proved nothing". - const kittyKeyboardFlags = parseTerminalKittyKeyboardFlags( - reattachSnapshot.modes.kittyKeyboardFlags - ) - return { - id: sessionId, - ...incarnationResult(), - ...ownerIdentityResult(), - pid, - ...claimResult(), - ...launchIdentity(), - ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), - snapshot: snapshotPayload, - snapshotCols: reattachSnapshot.cols, - snapshotRows: reattachSnapshot.rows, - // Why only for an alt frame: normal history remains safe to replay at its capture grid. - ...(isAltScreen && snapshotFrame && reattachSnapshot.frameRestoreAnsi - ? { - snapshotPrefixAnsi: snapshotPrefix, - snapshotFrameAnsi: snapshotFrame, - snapshotFrameRestoreAnsi: reattachSnapshot.frameRestoreAnsi - } - : {}), - ...(reattachProviderSequence ? { providerSequence: reattachProviderSequence } : {}), - ...(kittyKeyboardFlags !== undefined - ? { snapshotKittyKeyboardFlags: kittyKeyboardFlags } - : {}), - isReattach: true, - isAlternateScreen: isAltScreen, - // Why: the snapshot ANSI has no title frame; carry lastTitle beside it so main can seed title records after a relaunch. - ...(reattachSnapshot.lastTitle ? { lastTitle: reattachSnapshot.lastTitle } : {}), - // Why: carry the mid-escape tail so the renderer writes it after the reattach reset, else a split escape renders literally (#7329). - ...(reattachSnapshot.pendingEscapeTailAnsi - ? { pendingEscapeTailAnsi: reattachSnapshot.pendingEscapeTailAnsi } - : {}) - } - } - - private resultForExitBeforeSpawnReply( - sessionId: string, - result: CreateOrAttachResult, - operation: PendingDaemonSpawnOperation - ): PtySpawnResult | null { - const matchingExit = (operation.exitsBySessionId.get(sessionId) ?? []).some( - (exit) => - !(exit.incarnationId && operation.ignoredExitIncarnationIds.has(exit.incarnationId)) && - (!exit.incarnationId || - !result.incarnationId || - exit.incarnationId === result.incarnationId) - ) - if (!matchingExit) { - return null - } - // Why: stream exit can beat the control reply; return proof upward without republishing dead adapter state. - const exitedResult: PtySpawnResult = { - id: sessionId, - exitedBeforeSpawnReply: true, - ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), - ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}), - ...(!result.isNew ? { isReattach: true } : {}) - } - return exitedResult - } - - didExitBeforeSpawnReply(result: PtySpawnResult): boolean { - return result.exitedBeforeSpawnReply === true - } - - async attach(id: string): Promise | void> { - await this.ensureConnected() - if (!this.canDelegateBackgroundToDaemon) { - this.setPtyBackgrounded(id, false) - } - - // Why size-first: attach must ride the session's own geometry — a fixed - // 80×24 here could resize a live agent's TUI — and a null size means the - // daemon cannot prove the session, so refuse rather than risk a create. - const size = await this.getAppliedSize(id) - if (!size) { - throw new SessionNotFoundError(id) - } - const result = await this.client.request('createOrAttach', { - sessionId: id, - cols: size.cols, - rows: size.rows, - attachOnly: true - }) - if (result.isNew) { - // Why: a pre-v31 daemon ignores attachOnly; retire its accidental spawn - // instead of publishing a fresh shell as an attach. - await this.client.request('kill', { sessionId: id, immediate: true }).catch((error) => { - // Why surface, not swallow: a failed retire leaves an untracked orphan shell. - console.warn('[daemon] attach-only retire of accidental legacy spawn failed', { - sessionId: id, - error - }) - }) - throw new SessionNotFoundError(id) - } - this.clearSessionAwaitingDaemonRecovery(id) - const providerSequence = providerSequenceFromCreateOrAttach(result) - return providerSequence ? { providerSequence } : undefined - } - - hasPty(id: string): boolean { - return this.activeSessionIds.has(id) - } - - private recordSessionAuthority(id: string, incarnationId?: PtyIncarnationId): void { - if (!incarnationId || !this.lastAuthenticatedIdentity) { - return - } - this.sessionIncarnations.set(id, incarnationId) - this.sessionOwnerIdentities.set(id, { ...this.lastAuthenticatedIdentity }) - } - - private hasExactSessionAuthority(id: string, expectedIncarnationId?: PtyIncarnationId): boolean { - const sessionIncarnationId = this.sessionIncarnations.get(id) - const ownerIdentity = this.sessionOwnerIdentities.get(id) - const currentIdentity = this.lastAuthenticatedIdentity - return Boolean( - sessionIncarnationId && - ownerIdentity && - currentIdentity && - sameEndpointIdentity(ownerIdentity, currentIdentity) && - (expectedIncarnationId === undefined || expectedIncarnationId === sessionIncarnationId) - ) - } - - private listSessionsForLiveness(): Promise { - if (this.livenessInventoryInFlight) { - return this.livenessInventoryInFlight - } - const request = this.client.request( - 'listSessions', - undefined, - LIVENESS_PROBE_TIMEOUT_MS - ) - this.livenessInventoryInFlight = request - void request.then( - () => { - if (this.livenessInventoryInFlight === request) { - this.livenessInventoryInFlight = null - } - }, - () => { - if (this.livenessInventoryInFlight === request) { - this.livenessInventoryInFlight = null - } - } - ) - return request - } - - async probePtyLiveness( - id: string, - expectedIncarnationId?: PtyIncarnationId, - expectedOwnerIdentity?: TerminalOwnerIdentity - ): Promise { - try { - await this.ensureConnected(Date.now() + LIVENESS_PROBE_TIMEOUT_MS) - const ownerIdentityAtProbe = this.lastAuthenticatedIdentity - ? { ...this.lastAuthenticatedIdentity } - : null - const canConfirmAbsence = - this.hasExactSessionAuthority(id, expectedIncarnationId) && - (expectedOwnerIdentity === undefined || - this.ownerIdentity(expectedIncarnationId ?? this.sessionIncarnations.get(id)) - ?.ownerIncarnationId === expectedOwnerIdentity.ownerIncarnationId) - if (expectedOwnerIdentity !== undefined && !canConfirmAbsence) { - return null - } - if ( - (expectedIncarnationId === undefined || canConfirmAbsence) && - !this.getSizeUnsupported && - this.protocolVersion >= GET_SIZE_PROTOCOL_VERSION - ) { - try { - const result = await this.client.request<{ size: { cols: number; rows: number } | null }>( - 'getSize', - { sessionId: id }, - LIVENESS_PROBE_TIMEOUT_MS - ) - if ( - !ownerIdentityAtProbe || - !this.lastAuthenticatedIdentity || - !sameEndpointIdentity(ownerIdentityAtProbe, this.lastAuthenticatedIdentity) - ) { - return null - } - return result.size !== null ? true : canConfirmAbsence ? false : null - } catch (error) { - // Why the capability probe rather than the version alone: `getSize` shipped into an - // already-released protocol without a bump, so a daemon can report a version that - // implies support and still reject the request. Ask what it can do, not what its - // number implies — and remember the answer so later probes skip the dead round trip. - if (!isUnknownRequestTypeError(error)) { - throw error - } - this.getSizeUnsupported = true - } - } - // Why: a daemon without `getSize` would otherwise answer `null` forever, and one `null` - // makes the whole owner fan-out unprovable — a dead pane could then never be retired. - // `listSessions` is the same inventory legacy discovery routes by, and has existed since - // the first daemon protocol. Requested directly rather than through `listProcesses` so a - // liveness probe does not publish inventory audit observations as a side effect; both - // rethrow on failure, so either way a dead socket stays `null` instead of reading absent. - const { sessions } = await this.listSessionsForLiveness() - if ( - !ownerIdentityAtProbe || - !this.lastAuthenticatedIdentity || - !sameEndpointIdentity(ownerIdentityAtProbe, this.lastAuthenticatedIdentity) - ) { - return null - } - const liveSession = sessions.find((session) => session.sessionId === id && session.isAlive) - if (liveSession) { - this.recordSessionAuthority(id, liveSession.incarnationId) - if ( - expectedIncarnationId !== undefined && - liveSession.incarnationId !== expectedIncarnationId - ) { - return canConfirmAbsence ? false : null - } - return true - } - return canConfirmAbsence ? false : null - } catch { - return null - } - } - - write(id: string, data: string): boolean { - const recoverable = this.prepareWrite(id) - return this.finishWrite( - id, - this.client.notify('write', { - sessionId: id, - data, - ...(this.sessionIncarnations.get(id) - ? { expectedIncarnationId: this.sessionIncarnations.get(id) } - : {}) - }), - recoverable - ) - } - - async writeWithSettlement(id: string, data: string): Promise { - const recoverable = this.prepareWrite(id) - return this.finishWrite( - id, - await this.client.notifyWithSettlement('write', { - sessionId: id, - data, - ...(this.sessionIncarnations.get(id) - ? { expectedIncarnationId: this.sessionIncarnations.get(id) } - : {}) - }), - recoverable - ) - } - - private prepareWrite(id: string): boolean { - this.markSessionDirty(id) - // Why recoverable and not just active: rejecting a write asks the pane to remount, - // which only helps if this endpoint can come back. A legacy adapter has no respawn, - // so its reattach fails and the pane rebuilds empty — losing scrollback the user - // could still read. Keep the pre-existing silent drop for those. - const recoverable = - this.activeSessionIds.has(id) && !this.respawnAdoptionClosed && Boolean(this.respawnFn) - if ( - recoverable && - (this.sessionsAwaitingDaemonRecovery.has(id) || !this.client.isConnected()) - ) { - this.sessionsAwaitingDaemonRecovery.add(id) - this.reconnectAfterWriteFailure() - throw new PtyWriteUnavailableError(`Daemon PTY "${id}" is awaiting recovery`) - } - return recoverable - } - - private finishWrite(id: string, delivered: boolean, recoverable: boolean): boolean { - if (!delivered && recoverable) { - this.sessionsAwaitingDaemonRecovery.add(id) - this.reconnectAfterWriteFailure() - throw new PtyWriteUnavailableError(`Daemon PTY "${id}" is awaiting recovery`) - } - return delivered - } - - resize(id: string, cols: number, rows: number): void { - this.markSessionDirty(id) - this.client.notify('resize', { - sessionId: id, - cols, - rows, - ...(this.sessionIncarnations.get(id) - ? { expectedIncarnationId: this.sessionIncarnations.get(id) } - : {}) - }) - } - - pauseProducer(id: string): void { - if (!this.supportsProducerFlowControl) { - return - } - this.pausedProducerSessionIds.add(id) - this.client.notify('pausePty', { sessionId: id }) - } - - resumeProducer(id: string): void { - this.producerResumesOwedOnReconnect.delete(id) - if (!this.supportsProducerFlowControl) { - return - } - this.pausedProducerSessionIds.delete(id) - this.client.notify('resumePty', { sessionId: id }) - } - - // Why fire-and-forget (like pausePty): just a delivery hint for the daemon's keep-tail stream thinning. - setPtyBackgrounded(id: string, background: boolean): void { - if (!this.supportsProducerFlowControl) { - return - } - // Why: preserved daemons without a sequence-safe, faithful serializer cannot heal a thinned stream. - // Why also gate on 2031 (#9993): backgrounding is what hands transient-fact scan - // authority to the daemon. A pre-v29 daemon can announce a 2031 subscribe but never - // retract it, so a TUI exiting while hidden would strand the subscription and the - // next theme flip would inject CSI 997 into its replacement shell. Declining to - // background keeps main's scanner — which emits both facts — authoritative. - const safeBackground = this.canDelegateBackgroundToDaemon && background - if (safeBackground) { - this.backgroundedSessionIds.add(id) - } else { - this.backgroundedSessionIds.delete(id) - } - this.client.notify('setSessionBackground', { sessionId: id, background: safeBackground }) - } - - async shutdown( - id: string, - opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } - ): Promise { - if (opts.keepHistory && this.disconnectOnlyPromise) { - throw new Error('Cannot keep history after daemon disconnect has started') - } - const shutdown = this.withHistorySpawnLock(id, () => this.shutdownWithHistoryLock(id, opts)) - if (!opts.keepHistory) { - await shutdown - return - } - this.keepHistoryShutdowns.add(shutdown) - try { - await shutdown - } finally { - this.keepHistoryShutdowns.delete(shutdown) - } - } - - private async shutdownWithHistoryLock( - id: string, - opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } - ): Promise { - // Why: shutdown can be the first lazy-client operation after restart; connect - // before killing so a healthy daemon session is not orphaned (#7742). Connect, - // the final-checkpoint wait, and kill all share the caller's one absolute - // deadline, so neither a wedged handshake nor a stalled history write can burn - // the whole teardown budget before the kill even starts. Only the waits are - // bounded — the checkpoint itself stays deadline-free and lossless (STA-4228). - await this.ensureConnected(opts.deadlineMs) - // Why: sleep/exact-stop kills the live PTY before the periodic checkpoint may run. - // Force a final snapshot so wake can restore the pane users left. - if (opts.keepHistory) { - const committed = await this.runExclusiveCheckpoint( - async () => { - await this.checkpointSessions([id], { final: true, teardown: true }) - }, - { callerDeadlineMs: opts.deadlineMs } - ) - // Why throw instead of killing anyway: the snapshot the caller asked us to prove is still - // being written. Killing here would race the wake-time restore source to disk, so report the - // pty unverified and leave it alive — worktree sleep declines to commit it and retries. - if (!committed) { - throw new FinalCheckpointWaitExpiredError(id) - } - const wslDistro = this.wslDistrosBySessionId.get(id) - const detection = await this.historyReader?.detectColdRestoreState(id, { wslDistro }) - const detected = detection?.status === 'restored' ? detection.restoreInfo : null - const restoreInfo = detected - ? { - ...detected, - cwd: - normalizeWslColdRestoreCwd({ - recoveredCwd: detected.cwd, - requestedCwd: this.initialCwds.get(id) ?? resolveSafePtyDefaultCwd(), - wslDistro - }) ?? '' - } - : null - const coldRestore = restoreInfo ? this.buildColdRestorePayload(restoreInfo) : null - if (coldRestore) { - this.coldRestoreCache.set(id, coldRestore) - if (this.coldRestoreCache.has(id)) { - this.sleepRestoreSessionIds.add(id) - } - // Why: physical exit must not mark intentional sleep as a clean end; the final checkpoint stays the wake-time recovery authority. - this.historyManager?.suspendSession(id) - } else if ( - detection?.status === 'unreadable' || - (detection?.status === 'restored' && detection.hasUnreadableRecovery) - ) { - this.historyManager?.suspendSession(id) - } - } - await this.client.request( - 'kill', - { sessionId: id, immediate: opts.immediate ?? false }, - remainingRequestTimeoutMs(opts.deadlineMs) - ) - this.activeSessionIds.delete(id) - this.sessionIncarnations.delete(id) - this.sessionOwnerIdentities.delete(id) - this.clearSessionAwaitingDaemonRecovery(id) - this.dirtySessionVersions.delete(id) - if (!opts.keepHistory) { - this.coldRestoreCache.delete(id) - this.sleepRestoreSessionIds.delete(id) - } - // Why: the !keepHistory path takes no final checkpoint, so clear sessionsNeedingFullCheckpoint here or it stays stranded (no-op under keepHistory). - this.sessionsNeedingFullCheckpoint.delete(id) - this.sessionsNeedingLiveCheckpoint.delete(id) - this.sessionsNeedingContinuityCheckpoint.delete(id) - this.overlayDeadlineWarnedSessionIds.delete(id) - this.periodicDeadlineWarnedSessionIds.delete(id) - this.nonFinalAdmissionDeniedSessionIds.delete(id) - this.lastFullCheckpointAt.delete(id) - this.stopCheckpointTimerIfIdle() - this.initialCwds.delete(id) - this.wslDistrosBySessionId.delete(id) - // Why: only remove history on explicit close; sleep also calls shutdown but wake needs the dir intact for cold restore (opts.keepHistory). - if (this.historyManager && !opts.keepHistory) { - await this.historyManager - .removeSession(id) - .catch((err) => console.warn('[history] removeSession failed:', id, err)) - } - - // Why: the tombstone rejects reattach to a user-killed session; sleep legitimately reattaches on wake, so skip it under keepHistory. - if (!opts.keepHistory) { - this.killedSessionTombstones.delete(id) - this.killedSessionTombstones.set(id, Date.now()) - if (this.killedSessionTombstones.size > MAX_TOMBSTONES) { - const oldest = this.killedSessionTombstones.keys().next().value - if (oldest) { - this.killedSessionTombstones.delete(oldest) - } - } - } - } - - ackColdRestore(sessionId: string): void { - this.coldRestoreCache.delete(sessionId) - this.sleepRestoreSessionIds.delete(sessionId) - } - - clearTombstone(sessionId: string): void { - this.killedSessionTombstones.delete(sessionId) - } - - private buildColdRestorePayload(restoreInfo: ColdRestoreInfo): ColdRestorePayload | null { - // Why: alt-screen prefers normal scrollback, else snapshotAnsi alone — not rehydrate, which starts with \x1b[?1049h that POST_REPLAY_MODE_RESET won't undo — so a hibernated TUI's last frame isn't blank on wake. - const scrollback = restoreInfo.modes.alternateScreen - ? restoreInfo.scrollbackAnsi || restoreInfo.snapshotAnsi || null - : restoreInfo.rehydrateSequences + restoreInfo.snapshotAnsi - if (!scrollback) { - return null - } - return { - scrollback, - cwd: restoreInfo.cwd, - cols: restoreInfo.cols, - rows: restoreInfo.rows, - oscLinks: restoreInfo.oscLinks, - ...(restoreInfo.lastTitle ? { lastTitle: restoreInfo.lastTitle } : {}) - } - } - - async sendSignal(id: string, signal: string): Promise { - await this.client.request('signal', { sessionId: id, signal }) - } - - async getCwd(id: string): Promise { - try { - const result = await this.client.request<{ cwd: string | null }>('getCwd', { - sessionId: id - }) - return result.cwd ?? '' - } catch { - return '' - } - } - - async getInitialCwd(id: string): Promise { - return this.initialCwds.get(id) ?? '' - } - - // Why: resize() is fire-and-forget and can be dropped daemon-side; read the actually-applied size so the renderer can detect drift and re-assert. - async getAppliedSize(id: string): Promise<{ cols: number; rows: number } | null> { - try { - const result = await this.client.request<{ size: { cols: number; rows: number } | null }>( - 'getSize', - { sessionId: id } - ) - return result.size ?? null - } catch { - return null - } - } - - async getBufferSnapshot( - id: string, - opts: { scrollbackRows?: number } = {} - ): Promise { - if (!this.supportsAuthoritativeBufferSnapshots) { - return null - } - try { - const scrollbackRows = normalizeDesktopTerminalSnapshotRows(opts.scrollbackRows) - const result = await this.client.request('getSnapshot', { - sessionId: id, - ...(scrollbackRows !== undefined ? { scrollbackRows } : {}) - }) - const snapshot = result.snapshot - // Why: older v19 daemons lack an absolute output sequence, so their snapshot can't reconcile bytes queued on the other socket. - if (!snapshot || typeof snapshot.outputSequence !== 'number') { - return null - } - const restored = - this.historyManager && - this.historyReader && - (scrollbackRows === undefined || scrollbackRows > DAEMON_SESSION_SCROLLBACK_ROWS) - ? await this.overlayDurableRestoreSnapshot(id, snapshot, scrollbackRows) - : snapshot - return this.toProviderBufferSnapshot(restored) - } catch { - return null - } - } - - private toProviderBufferSnapshot( - snapshot: NonNullable - ): PtyProviderBufferSnapshot | null { - if (typeof snapshot.outputSequence !== 'number') { - return null - } - const kittyKeyboardFlags = parseTerminalKittyKeyboardFlags(snapshot.modes.kittyKeyboardFlags) - return { - data: snapshot.rehydrateSequences + snapshot.snapshotAnsi, - frameRestoreAnsi: snapshot.frameRestoreAnsi, - scrollbackAnsi: snapshot.scrollbackAnsi, - cols: snapshot.cols, - rows: snapshot.rows, - cwd: snapshot.cwd, - lastTitle: snapshot.lastTitle, - seq: snapshot.outputSequence, - source: 'headless', - oscLinks: snapshot.oscLinks, - alternateScreen: snapshot.modes.alternateScreen, - // Why known `0` is carried too: it proves the app negotiated nothing at - // this boundary, which is a different fact from a source that cannot say. - ...(kittyKeyboardFlags !== undefined ? { kittyKeyboardFlags } : {}), - ...(snapshot.pendingEscapeTailAnsi - ? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi } - : {}) - } - } - - private async overlayDurableRestoreSnapshot( - sessionId: string, - liveSnapshot: NonNullable, - scrollbackRows?: number - ): Promise> { - if (!this.historyManager || !this.historyReader) { - return liveSnapshot - } - // Why turn the caller away instead of queueing: this session already has a - // compact in flight whose result a third one would only duplicate, and an - // unbounded queue is how a stalled history filesystem grows without limit. - if (this.checkpointQueue.isSaturated(sessionId)) { - return liveSnapshot - } - // Why reserve before enqueueing: pane mounts can arrive in one turn, before any compact starts. - // Count abandoned waits until their writes settle so a relaunch cannot fan out unbounded work. - if (!this.tryAdmitNonFinalCheckpoint(sessionId)) { - return liveSnapshot - } - // Why per session with a deadline: a reattach is a user click, so it must wait - // on this session's own compact and nothing else. A blown deadline does not - // cancel that compact — it keeps running and still commits — so the fallback - // costs restore depth for this one reattach, never durable history (STA-4173). - return await this.checkpointQueue.runWithDeadline( - sessionId, - async () => { - try { - return await this.compactDurableRestoreSnapshot(sessionId, liveSnapshot, scrollbackRows) - } finally { - this.releaseNonFinalCheckpointAdmission(sessionId) - this.overlayDeadlineWarnedSessionIds.delete(sessionId) - } - }, - DURABLE_HISTORY_OVERLAY_DEADLINE_MS, - liveSnapshot, - { - onDeadline: () => { - if (!this.overlayDeadlineWarnedSessionIds.has(sessionId)) { - this.overlayDeadlineWarnedSessionIds.add(sessionId) - console.warn('[history] durable snapshot overlay deadline exceeded:', sessionId) - } - } - } - ) - } - - private tryAdmitNonFinalCheckpoint(sessionId: string): boolean { - if (this.nonFinalCheckpointAdmissionSessionIds.has(sessionId)) { - if (!this.nonFinalAdmissionDeniedSessionIds.has(sessionId)) { - this.nonFinalAdmissionDeniedSessionIds.add(sessionId) - console.warn('[history] non-final checkpoint already in flight:', sessionId) - } - return false - } - if (this.nonFinalCheckpointAdmissionSessionIds.size >= MAX_CONCURRENT_CHECKPOINTS) { - this.reportNonFinalGlobalAdmissionDenial(sessionId) - return false - } - this.nonFinalAdmissionDeniedSessionIds.delete(sessionId) - this.nonFinalCheckpointAdmissionSessionIds.add(sessionId) - return true - } - - private reportNonFinalGlobalAdmissionDenial(sessionId: string): void { - if (!this.nonFinalGlobalAdmissionWarningActive) { - this.nonFinalGlobalAdmissionWarningActive = true - console.warn('[history] non-final checkpoint global admission limit reached:', sessionId) - } - } - - private releaseNonFinalCheckpointAdmission(sessionId: string): void { - if (this.nonFinalCheckpointAdmissionSessionIds.delete(sessionId)) { - this.nonFinalAdmissionDeniedSessionIds.delete(sessionId) - this.nonFinalGlobalAdmissionWarningActive = false - } - } - - private async compactDurableRestoreSnapshot( - sessionId: string, - liveSnapshot: NonNullable, - scrollbackRows?: number - ): Promise> { - if (!this.historyReader) { - return liveSnapshot - } - try { - // Why compact before reading: an independent take/append races the 5s tick, can - // seq-gap the log, and would remount a stale checkpoint over the live window. - const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, { - teardown: false, - forceLiveSnapshot: this.sessionsNeedingLiveCheckpoint.has(sessionId), - requireContinuityProof: this.sessionsNeedingContinuityCheckpoint.has(sessionId) - }) - if (checkpoint.checkpoint === 'committed') { - this.sessionsNeedingFullCheckpoint.delete(sessionId) - } - if (checkpoint.checkpoint !== 'committed' || !checkpoint.snapshot) { - return checkpoint.snapshot ?? liveSnapshot - } - if (scrollbackRows === undefined || scrollbackRows >= DAEMON_RESTORE_SCROLLBACK_ROWS) { - return checkpoint.snapshot - } - const restoreInfo = await this.historyReader.detectColdRestore(sessionId, { - ignoreCleanEnd: true, - wslDistro: this.wslDistrosBySessionId.get(sessionId) - }) - if (!restoreInfo) { - return liveSnapshot - } - return await buildDurableCheckpointSnapshot({ - liveSnapshot: checkpoint.snapshot, - restoreInfo, - scrollbackRows - }) - } catch (error) { - console.warn('[history] durable snapshot overlay failed:', sessionId, error) - return liveSnapshot - } - } - - async clearBuffer(id: string): Promise { - await this.client.request('clearScrollback', { sessionId: id }) - this.markSessionDirty(id) - } - - acknowledgeDataEvent(_id: string, _charCount: number): void { - // No flow control for daemon-backed terminals - } - - // Why: daemon-backed PTYs can host long-lived agents while detached; cleanup prompts must not treat them as idle shells. - private hasChildProcessesFromForeground(foregroundProcess: string | null): boolean { - return foregroundProcess !== null && !isShellProcess(foregroundProcess) - } - - async hasChildProcesses(id: string): Promise { - if (this.protocolVersion < GET_FOREGROUND_PROCESS_PROTOCOL_VERSION) { - return true - } - return this.hasChildProcessesFromForeground(await this.getForegroundProcess(id)) - } - - async inspectProcess(id: string): Promise { - if (this.protocolVersion < GET_FOREGROUND_PROCESS_PROTOCOL_VERSION) { - return { foregroundProcess: null, hasChildProcesses: true, unavailable: true } - } - if (this.protocolVersion < COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION) { - // Why: pre-v27 daemons survive an in-place app update; compose the inspection client-side from the - // one call they do support instead of throwing, or completion detection stays dead until recreate. - // Requests directly (not via getForegroundProcess) so a dead socket still rejects rather than - // reading as an idle foreground and dispatching a false completion. - const { foregroundProcess } = await this.client.request<{ - foregroundProcess: string | null - }>('getForegroundProcess', { sessionId: id }) - return { - foregroundProcess, - hasChildProcesses: this.hasChildProcessesFromForeground(foregroundProcess) - } - } - return this.client.request<{ - foregroundProcess: string | null - hasChildProcesses: boolean - }>('inspectProcess', { sessionId: id }) - } - - async getForegroundProcess(id: string): Promise { - if (this.protocolVersion < GET_FOREGROUND_PROCESS_PROTOCOL_VERSION) { - return null - } - try { - const result = await this.client.request<{ foregroundProcess: string | null }>( - 'getForegroundProcess', - { sessionId: id } - ) - return result.foregroundProcess - } catch { - return null - } - } - - async confirmForegroundProcess(id: string): Promise { - try { - const result = await this.client.request<{ foregroundProcess: string | null }>( - 'confirmForegroundProcess', - { sessionId: id } - ) - return result.foregroundProcess - } catch { - return null - } - } - - async serialize(ids: string[]): Promise { - const sessions: Record = {} - for (const id of ids) { - sessions[id] = { initialCwd: this.initialCwds.get(id) } - } - return JSON.stringify(sessions) - } - - async revive(_state: string): Promise { - // Sessions already live in the daemon — no revival needed - } - - /** Called on app launch. Lists daemon sessions, kills orphans whose workspaceId - * no longer exists, and caches alive session IDs. - * - * IMPORTANT: a session id embeds the worktree's path at spawn time, so a renamed - * worktree keeps its old id. Callers MUST seed `validWorktreeIds` with each live - * worktree's `WorktreeMeta.priorWorktreeIds` or those sessions get reaped as false - * orphans. No production caller yet; wire the alias in when it gains one. */ - async reconcileOnStartup(validWorktreeIds: Set): Promise<{ - alive: string[] - killed: string[] - }> { - await this.ensureConnected() - const result = await this.client.request('listSessions', undefined) - - const alive: string[] = [] - const killed: string[] = [] - - for (const session of result.sessions) { - if (!session.isAlive) { - continue - } - // Why: an unminted session id (worktreeId === null) can't be tied to a live worktree, so it's treated as an orphan. - const { worktreeId } = parsePtySessionId(session.sessionId) - - if (worktreeId === null || !validWorktreeIds.has(worktreeId)) { - try { - await this.client.request('kill', { sessionId: session.sessionId }) - } catch { - /* already dead */ - } - killed.push(session.sessionId) - } else { - alive.push(session.sessionId) - // Why: track background sessions in the checkpoint set so disconnectOnly's final checkpoint doesn't leave stale recovery data. - this.activeSessionIds.add(session.sessionId) - await this.reconcileLiveSessionHistory(session).catch((err) => - console.warn('[history] live-session reconciliation failed:', session.sessionId, err) - ) - } - } - - return { alive, killed } - } - - private async reconcileLiveSessionHistory(session: SessionInfo): Promise { - const historyManager = this.historyManager - const historyReader = this.historyReader - if (!historyManager || !historyReader) { - return - } - await this.withHistorySpawnLock(session.sessionId, async () => { - if (historyManager.hasWriter(session.sessionId)) { - return - } - const probe = historyReader.probeRestorableHistory(session.sessionId) - if (probe.status === 'unreadable') { - return - } - if (probe.status === 'none') { - await historyManager.openSession(session.sessionId, { - cwd: session.cwd ?? '', - cols: session.cols, - rows: session.rows - }) - } else { - const recoveryFreeze = await historyManager.freezeForRecovery(session.sessionId) - try { - const detection = await historyReader.detectColdRestoreState(session.sessionId, { - wslDistro: session.wslDistro ?? undefined - }) - if ( - detection.status === 'unreadable' || - (detection.status === 'restored' && detection.hasUnreadableRecovery) - ) { - historyManager.suspendSession(session.sessionId, recoveryFreeze) - return - } - historyManager.reopenSession(session.sessionId, recoveryFreeze) - } finally { - historyManager.abandonRecoveryFreeze(recoveryFreeze) - } - } - if (historyManager.hasWriter(session.sessionId)) { - this.sessionsNeedingFullCheckpoint.add(session.sessionId) - this.sessionsNeedingContinuityCheckpoint.add(session.sessionId) - this.lastFullCheckpointAt.delete(session.sessionId) - this.markSessionDirty(session.sessionId) - } - }) - } - - async listProcesses(opts?: { deadlineMs?: number }): Promise { - // Why: snapshotted before the request so ids spawned mid-flight can never - // be reconciled away below. - const preRequestActiveIds = new Set(this.activeSessionIds) - try { - // Why: connect + listSessions share the caller's one absolute deadline so a - // wedged handshake cannot burn the whole teardown budget before the list issues. - await this.ensureConnected(opts?.deadlineMs) - const result = await this.client.request( - 'listSessions', - undefined, - remainingRequestTimeoutMs(opts?.deadlineMs) - ) - const admission = new PtyProcessListAdmission() - const processes: PtyProcessInfo[] = [] - const aliveSessionIds = new Set() - for (const session of result.sessions) { - if (!session.isAlive) { - continue - } - aliveSessionIds.add(session.sessionId) - this.recordSessionAuthority(session.sessionId, session.incarnationId) - const { worktreeId } = parsePtySessionId(session.sessionId) - processes.push( - admission.admit({ - id: session.sessionId, - ...(session.incarnationId ? { incarnationId: session.incarnationId } : {}), - ...(this.ownerIdentity(session.incarnationId) - ? { ownerIdentity: this.ownerIdentity(session.incarnationId) } - : {}), - // Why: OSC 7 may not arrive before cleanup; spawn cwd is authoritative until the daemon reports a live cwd. - cwd: session.cwd ?? this.initialCwds.get(session.sessionId) ?? '', - title: 'shell', - ...(worktreeId ? { worktreeId } : {}), - ...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}), - ...(session.wslDistro !== undefined ? { wslDistro: session.wslDistro } : {}), - ...this.validatedAgentSessionOwners(session.agentSessionOwners) - }) - ) - } - // Why: hasPty reads activeSessionIds, and an exit missed while the socket - // was disconnected otherwise survives an authoritative inventory forever — - // defeating every absence proof built on the cache. - for (const id of preRequestActiveIds) { - if (!aliveSessionIds.has(id)) { - this.activeSessionIds.delete(id) - this.sessionIncarnations.delete(id) - this.sessionOwnerIdentities.delete(id) - } - } - this.publishAuditObservation( - recordAuthenticatedInventory(this.auditContext, this.exactDaemonIncarnation) - ) - return processes - } catch (error) { - const missingAuthenticatedToken = this.isRetiredEndpointTokenMissing() - const missingNamedPipe = isMissingWindowsNamedPipeError(error) - this.observeAuditFailure( - missingAuthenticatedToken - ? 'token_missing_after_authenticated_disconnect' - : 'inventory_failed', - this.exactDaemonIncarnation, - [ - ...(missingAuthenticatedToken ? (['token_file'] as const) : []), - ...(missingNamedPipe ? (['windows_named_pipe'] as const) : []) - ], - missingNamedPipe ? 'windows_named_pipe_missing' : undefined - ) - throw error - } - } - - private validatedAgentSessionOwners( - owners: unknown - ): { agentSessionOwners: AgentSessionOwnerBinding[] } | Record { - if (owners === undefined) { - return {} - } - if ( - !Array.isArray(owners) || - owners.length > MAX_CLAIMED_AGENT_PTY_OWNER_ENTRIES || - !owners.every((owner) => isAgentSessionOwnerBinding(owner) && owner.phase === 'live') - ) { - throw new Error('agent_session_ownership_unknown') - } - return owners.length > 0 - ? { agentSessionOwners: owners.map(cloneAgentSessionOwnerBinding) } - : {} - } - - // Why: the Manage Sessions panel needs the full SessionInfo (pid, state, - // createdAt) per session for display; listProcesses drops that detail for - // the IPtyProvider contract. Keep both in parallel rather than widening - // the provider surface. - async listSessions(): Promise { - await this.ensureConnected() - const result = await this.client.request('listSessions', undefined) - return result.sessions - .filter((s) => s.isAlive) - .map((session) => ({ - ...session, - ...this.validatedAgentSessionOwners(session.agentSessionOwners) - })) - } - - getActiveSessionIds(): string[] { - return [...this.activeSessionIds] - } - - // Why: the daemon's kill-all-and-shutdown path suppresses onExit fanout (session.ts:246-252), so synthesize pty:exit - // for every live session before teardown or renderer panes black-hole writes to a disposed adapter forever. - fanoutSyntheticExits(code: number): void { - const ids = [...this.activeSessionIds] - this.activeSessionIds.clear() - this.sessionsAwaitingDaemonRecovery.clear() - this.writeRecoveryAttempted = false - this.dirtySessionVersions.clear() - this.lastFullCheckpointAt.clear() - this.sessionsNeedingFullCheckpoint.clear() - this.sessionsNeedingLiveCheckpoint.clear() - this.sessionsNeedingContinuityCheckpoint.clear() - this.overlayDeadlineWarnedSessionIds.clear() - this.periodicDeadlineWarnedSessionIds.clear() - this.nonFinalAdmissionDeniedSessionIds.clear() - this.pausedProducerSessionIds.clear() - this.producerResumesOwedOnReconnect.clear() - this.stopCheckpointTimer() - for (const id of ids) { - this.coldRestoreCache.delete(id) - // Why: don't catch listener throws — matches the natural onExit fanout so synthetic exits keep the same error semantics. - // oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration - for (const listener of [...this.exitListeners]) { - listener({ - id, - code, - ...(this.sessionIncarnations.get(id) - ? { incarnationId: this.sessionIncarnations.get(id) } - : {}) - }) - } - this.sessionIncarnations.delete(id) - this.sessionOwnerIdentities.delete(id) - } - } - - async getDefaultShell(): Promise { - if (process.platform === 'win32') { - return process.env.COMSPEC || 'powershell.exe' - } - return process.env.SHELL || '/bin/zsh' - } - - async getProfiles(): Promise<{ name: string; path: string }[]> { - if (process.platform === 'win32') { - return [ - { name: 'PowerShell', path: 'powershell.exe' }, - { name: 'Command Prompt', path: 'cmd.exe' } - ] - } - const shells = ['/bin/zsh', '/bin/bash', '/bin/sh'] - return shells.filter((s) => existsSync(s)).map((s) => ({ name: basename(s), path: s })) - } - - onData( - callback: (payload: { - id: string - data: string - sequenceChars?: number - transformed?: boolean - seq?: number - }) => void - ): () => void { - this.dataListeners.push(callback) - return () => { - const idx = this.dataListeners.indexOf(callback) - if (idx !== -1) { - this.dataListeners.splice(idx, 1) - } - } - } - - onBackgroundStreamEvent(callback: (payload: PtyBackgroundStreamEvent) => void): () => void { - this.backgroundStreamListeners.push(callback) - return () => { - const idx = this.backgroundStreamListeners.indexOf(callback) - if (idx !== -1) { - this.backgroundStreamListeners.splice(idx, 1) - } - } - } - - onReplay(_callback: (payload: { id: string; data: string }) => void): () => void { - return () => {} - } - - onExit( - callback: (payload: { id: string; code: number; incarnationId?: PtyIncarnationId }) => void - ): () => void { - this.exitListeners.push(callback) - return () => { - const idx = this.exitListeners.indexOf(callback) - if (idx !== -1) { - this.exitListeners.splice(idx, 1) - } - } - } - - onWriteUnavailable(callback: (payload: { id: string }) => void): () => void { - this.writeUnavailableListeners.push(callback) - return () => { - const idx = this.writeUnavailableListeners.indexOf(callback) - if (idx !== -1) { - this.writeUnavailableListeners.splice(idx, 1) - } - } - } - - private emitWriteUnavailable(id: string): void { - // oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration - for (const listener of [...this.writeUnavailableListeners]) { - listener({ id }) - } - } - - dispose(): void { - this.respawnAdoptionClosed = true - this.sessionsAwaitingDaemonRecovery.clear() - this.writeRecoveryAttempted = false - this.releasePendingRespawnAdoptionLease() - this.stopCheckpointTimer() - this.dirtySessionVersions.clear() - this.lastFullCheckpointAt.clear() - this.sessionsNeedingFullCheckpoint.clear() - this.sessionsNeedingLiveCheckpoint.clear() - this.sessionsNeedingContinuityCheckpoint.clear() - this.overlayDeadlineWarnedSessionIds.clear() - this.periodicDeadlineWarnedSessionIds.clear() - this.nonFinalAdmissionDeniedSessionIds.clear() - this.coldRestoreCache.clear() - this.wslDistrosBySessionId.clear() - this.pausedProducerSessionIds.clear() - this.producerResumesOwedOnReconnect.clear() - this.auditObservationListeners.length = 0 - this.identityChangeListeners.length = 0 - this.removeEventListener?.() - this.removeEventListener = null - // Why: final checkpoints are written daemon-side (TerminalHost.dispose); here the adapter only marks sessions - // cleanly ended so they don't trigger false cold restores. - if (this.historyManager) { - void this.historyManager - .dispose() - .catch((err) => console.warn('[history] dispose failed:', err)) - } - this.client.disconnect() - } - - async establishLifecycleLease(): Promise { - if (this.protocolVersion < CLEAN_DISCONNECT_PROTOCOL_VERSION) { - return - } - // Why: an authenticated pair cancels the adoption watchdog and lets a never-used adapter retire its empty daemon on quit. - await this.client.ensureConnected() - this.recordAuthenticatedIdentity() - } - - // Why: unlike dispose(), leave history files unclean (no endedAt) so the next launch treats them as crash-recoverable, - // but still write a final checkpoint so a daemon crash while Orca is closed has recovery data. - async disconnectOnly(): Promise { - if (!this.disconnectOnlyPromise) { - this.respawnAdoptionClosed = true - this.sessionsAwaitingDaemonRecovery.clear() - this.writeRecoveryAttempted = false - this.releasePendingRespawnAdoptionLease() - this.disconnectOnlyPromise = this.finishDisconnectOnly([...this.keepHistoryShutdowns]) - } - await this.disconnectOnlyPromise - } - - private async finishDisconnectOnly(keepHistoryShutdowns: Promise[]): Promise { - // Why: sleep shutdowns still detect recovery and kill after checkpointing; disconnecting first rejects those admitted operations. - await Promise.allSettled(keepHistoryShutdowns) - this.respawnAdoptionClosed = true - // Why: a final checkpoint covers sessions opened since the last tick (else cold restore finds nothing if the daemon - // later dies). Await it — fire-and-forget would race client.disconnect() and reject the pending getSnapshot RPCs. - await this.runExclusiveCheckpoint(() => this.checkpointAllSessions(), { - rescheduleDirty: false - }) - this.dirtySessionVersions.clear() - this.lastFullCheckpointAt.clear() - this.coldRestoreCache.clear() - this.wslDistrosBySessionId.clear() - // Why: the detached daemon keeps these PTYs alive for warm reattach; a leftover pause would stall shells for a failsafe window. - for (const id of this.pausedProducerSessionIds) { - this.client.notify('resumePty', { sessionId: id }) - } - this.pausedProducerSessionIds.clear() - this.producerResumesOwedOnReconnect.clear() - this.removeEventListener?.() - this.removeEventListener = null - if (this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION) { - try { - // Why: only the authenticated daemon can atomically prove it's empty; a shared budget keeps this off quit's critical path. - const deadlineMs = Date.now() + 250 - if (!this.client.isConnected()) { - await this.client.ensureConnectedWithin(Math.max(1, deadlineMs - Date.now())) - } - await this.client.request('shutdownIfIdle', undefined, Math.max(1, deadlineMs - Date.now())) - } catch { - // An unreachable daemon falls back to event-driven retirement once its auth sockets close and it proves itself empty. - } - } - this.client.disconnect() - } - - private async ensureConnected(deadlineMs?: number): Promise { - try { - // Why: destructive teardown bounds the handshake by its deadline so a wedged - // connect fails fast; undefined keeps the default connect behavior. - await (deadlineMs !== undefined - ? this.client.ensureConnectedWithin(Math.max(1, deadlineMs - Date.now())) - : this.client.ensureConnected()) - } finally { - // Why: a respawn launcher holds a temporary pair until this adapter's permanent reconnect, preventing both gaps and leaks. - this.releasePendingRespawnAdoptionLease() - } - this.recordAuthenticatedIdentity() - // Why sampled before setupEventRouting: "no listener yet" identifies a fresh connect — the only time the - // daemon-side backgrounded set (process state lost with the old daemon) needs a resync. - const isFreshConnection = this.removeEventListener === null - this.setupEventRouting() - this.scheduleCheckpointTimer() - this.flushOwedProducerResumes() - if (isFreshConnection) { - this.resyncBackgroundedSessions() - } - } - - private recordAuthenticatedIdentity(): void { - const current = this.client.getDaemonIdentity() - if (!current) { - return - } - const previous = this.lastAuthenticatedIdentity - if (previous && sameEndpointIdentity(previous, current)) { - return - } - this.lastAuthenticatedIdentity = { ...current } - this.exactDaemonIncarnation = exactDaemonIncarnationForPidRecord(current, this.pidRecord) - if (!previous) { - return - } - const event = { previous: { ...previous }, current: { ...current } } - notifyAuditListeners(this.identityChangeListeners, event) - } - - private async resolveExactDaemonIncarnation( - exactIncarnation: ExactDaemonIncarnation | null - ): Promise { - if ( - !exactIncarnation || - process.platform !== 'linux' || - (exactIncarnation.linuxStartTicks && exactIncarnation.bootId) - ) { - return exactIncarnation - } - const cachedIncarnation = exactDaemonIncarnationForPidRecord( - exactIncarnation.identity, - this.pidRecord - ) - if (cachedIncarnation.linuxStartTicks && cachedIncarnation.bootId) { - return cachedIncarnation - } - const pidRecord = await this.readMatchingPidRecord(exactIncarnation.identity) - this.pidRecord = pidRecord ?? this.pidRecord - return pidRecord?.linuxStartTicks && pidRecord.bootId - ? { - identity: { ...exactIncarnation.identity }, - linuxStartTicks: pidRecord.linuxStartTicks, - bootId: pidRecord.bootId - } - : exactIncarnation - } - - private cacheExactDaemonIncarnation(exactIncarnation: ExactDaemonIncarnation | null): void { - if ( - exactIncarnation && - this.lastAuthenticatedIdentity && - sameEndpointIdentity(exactIncarnation.identity, this.lastAuthenticatedIdentity) - ) { - this.exactDaemonIncarnation = exactIncarnation - } - } - - private async readMatchingPidRecord( - identity: DaemonEndpointIdentity - ): Promise { - if (!this.pidPath) { - return null - } - try { - const parsed = parseDaemonPidFile(await readFile(this.pidPath, 'utf8')) - return parsed?.pid === identity.pid && - parsed.startedAtMs === identity.startedAtMs && - parsed.launchNonce === identity.launchNonce - ? parsed - : null - } catch { - return null - } - } - - private observeAuditFailure( - trigger: Exclude, - exactIncarnation = this.exactDaemonIncarnation, - additionalEvidenceSources: readonly DaemonEvidenceSource[] = [], - endpointGoneProof?: 'windows_named_pipe_missing' - ): void { - void this.resolveExactDaemonIncarnation(exactIncarnation) - .then((resolvedIncarnation) => { - this.cacheExactDaemonIncarnation(resolvedIncarnation) - return classifyDaemonAuditFailure(this.auditContext, trigger, resolvedIncarnation, { - additionalEvidenceSources, - endpointGoneProof - }) - }) - .then((observation) => this.publishAuditObservation(observation)) - .catch(() => {}) - } - - private publishAuditObservation(observation: DaemonAuditObservation): void { - this.lastAuditObservation = observation - this.trackAuditEligibility(observation) - notifyAuditListeners(this.auditObservationListeners, observation) - } - - private resyncBackgroundedSessions(): void { - for (const id of this.backgroundedSessionIds) { - // Harmless no-op for sessions the daemon doesn't know (yet). - this.client.notify('setSessionBackground', { sessionId: id, background: true }) - } - } - - private flushOwedProducerResumes(): void { - if (this.producerResumesOwedOnReconnect.size === 0) { - return - } - for (const id of this.producerResumesOwedOnReconnect) { - // Why: resuming an unknown session is a harmless no-op; leaving a survivor paused would waste 5s of failsafe latency. - this.client.notify('resumePty', { sessionId: id }) - } - this.producerResumesOwedOnReconnect.clear() - } - - private stopCheckpointTimer(): void { - if (!this.checkpointTimer) { - return - } - clearTimeout(this.checkpointTimer) - this.checkpointTimer = null - } - - private stopCheckpointTimerIfIdle(): void { - if (this.dirtySessionVersions.size === 0) { - this.stopCheckpointTimer() - } - } - - private scheduleCheckpointTimer(): void { - if ( - this.checkpointTimer || - !this.historyManager || - !this.supportsCheckpoints || - this.dirtySessionVersions.size === 0 - ) { - return - } - // Why: dirty-gate the timer — a permanent 5s interval woke the main process for idle terminals with nothing to write. - this.checkpointTimer = setTimeout(() => { - this.checkpointTimer = null - // Why: don't overlap checkpoint passes — concurrent tmp-file writes can lose a rename and disable future history writes. - if (this.checkpointInFlight) { - this.scheduleCheckpointTimer() - return - } - const checkpoint = this.checkpointDirtySessions() - this.checkpointInFlight = checkpoint - void checkpoint - .finally(() => { - if (this.checkpointInFlight === checkpoint) { - this.checkpointInFlight = null - this.scheduleCheckpointTimer() - } - }) - // Why: .finally() re-throws, so a rejected checkpoint would surface as an unhandled rejection here. - .catch(() => {}) - }, DaemonPtyAdapter.CHECKPOINT_INTERVAL_MS) - } - - private markSessionDirty(sessionId: string): void { - if (!this.activeSessionIds.has(sessionId)) { - return - } - this.dirtySessionVersions.set(sessionId, (this.dirtySessionVersions.get(sessionId) ?? 0) + 1) - this.scheduleCheckpointTimer() - } - - private async checkpointDirtySessions(): Promise { - if (!this.historyManager || this.dirtySessionVersions.size === 0) { - return - } - // Why: dirty-version filtering avoids re-serializing every idle session every 5s (CPU/disk on large workspaces) - // while not dropping writes that arrive mid-checkpoint. - const versions = new Map( - [...this.dirtySessionVersions].filter(([sessionId]) => this.activeSessionIds.has(sessionId)) - ) - if (versions.size === 0) { - this.dirtySessionVersions.clear() - this.stopCheckpointTimer() - return - } - const completed = await this.checkpointSessions(versions.keys()) - for (const [sessionId, version] of versions) { - if (completed.has(sessionId) && this.dirtySessionVersions.get(sessionId) === version) { - this.dirtySessionVersions.delete(sessionId) - } - } - this.stopCheckpointTimerIfIdle() - } - - /** False only when `callerDeadlineMs` expired first; the checkpoint itself keeps running. */ - private async runExclusiveCheckpoint( - operation: () => Promise, - options: { rescheduleDirty?: boolean; callerDeadlineMs?: number } = {} - ): Promise { - this.stopCheckpointTimer() - // Why: a promise tail keeps every waiter ordered; awaiting one active operation lets sibling waiters resume together. - const previous = this.checkpointInFlight ?? Promise.resolve() - const checkpoint = previous.catch(() => {}).then(operation) - this.checkpointInFlight = checkpoint - // Why the release rides the checkpoint instead of the caller's await: a caller that walks away - // at its deadline must leave this checkpoint as the tail, so the durable write still runs to - // completion, still commits, and the next waiter still queues behind it (STA-4228). - const settled = checkpoint.then( - () => this.releaseExclusiveCheckpoint(checkpoint, options.rescheduleDirty), - (err: unknown) => { - this.releaseExclusiveCheckpoint(checkpoint, options.rescheduleDirty) - throw err - } - ) - if (options.callerDeadlineMs === undefined) { - await settled - return true - } - return await awaitWithinCallerDeadline(settled, options.callerDeadlineMs) - } - - private releaseExclusiveCheckpoint( - checkpoint: Promise, - rescheduleDirty: boolean | undefined - ): void { - if (this.checkpointInFlight === checkpoint) { - this.checkpointInFlight = null - } - this.stopCheckpointTimer() - if (rescheduleDirty !== false) { - this.scheduleCheckpointTimer() - } - } - - // Why final=true not teardown: clean disconnect needs the full daemon-window snapshot as the restore source, but the - // detached daemon's PTYs keep running for warm reattach, so shell-ready scanner state must stay intact. - private async checkpointAllSessions(): Promise { - const completed = await this.checkpointSessions(this.activeSessionIds, { final: true }) - for (const sessionId of completed) { - this.dirtySessionVersions.delete(sessionId) - } - } - - private async checkpointSessions( - sessionIds: Iterable, - opts?: { final?: boolean; teardown?: boolean } - ): Promise> { - const completed = new Set() - if (!this.historyManager) { - return completed - } - const ids = Array.from(sessionIds) - let nextIndex = 0 - - const checkpointNext = async (): Promise => { - for (;;) { - // No worker in this pass awaits abandoned work, so a full admission set cannot open here. - if ( - opts?.final !== true && - this.nonFinalCheckpointAdmissionSessionIds.size >= MAX_CONCURRENT_CHECKPOINTS - ) { - const deferredSessionId = ids[nextIndex] - if (deferredSessionId !== undefined) { - this.reportNonFinalGlobalAdmissionDenial(deferredSessionId) - } - return - } - const index = nextIndex - nextIndex++ - if (index >= ids.length) { - return - } - const sessionId = ids[index] - await this.checkpointSession(sessionId, { - final: opts?.final === true, - teardown: opts?.teardown === true - }) - .then((result) => { - // Why: deferred sessions stay dirty so the checkpoint timer keeps retrying until their full-snapshot cooldown expires. - if (result === 'done') { - completed.add(sessionId) - } - }) - .catch((err) => console.warn('[history] checkpoint failed:', sessionId, err)) - } - } - // Why: snapshot/checkpoint writes are CPU/disk heavy; cap prevents one tick snapshotting every dirty terminal at once. - const workers = Array.from({ length: Math.min(MAX_CONCURRENT_CHECKPOINTS, ids.length) }, () => - checkpointNext() - ) - await Promise.all(workers) - return completed - } - - // Why cooldown starts only after the first full snapshot: a checkpoint-less session must be able to write one immediately. - private isFullCheckpointCoolingDown(sessionId: string): boolean { - const last = this.lastFullCheckpointAt.get(sessionId) - if (last === undefined) { - return false - } - const elapsed = Date.now() - last - // Why elapsed < 0 counts as expired: a backward wall-clock jump must not extend the deferral window. - return elapsed >= 0 && elapsed < DaemonPtyAdapter.FULL_CHECKPOINT_COOLDOWN_MS - } - - private async checkpointSession( - sessionId: string, - opts: { final: boolean; teardown: boolean } - ): Promise<'done' | 'deferred'> { - // Why final waits without a deadline: sleep/disconnect needs the last snapshot on disk, and - // deferring there would silently drop what the user left on screen rather than delay it. - if (opts.final) { - return await this.checkpointQueue.run(sessionId, () => - this.writeSessionCheckpoint(sessionId, opts) - ) - } - // Why 'deferred' is safe: the session stays dirty, so the operation that beat us to the queue - // still commits and the next tick retries this one. Nothing on disk is discarded. - if ( - this.checkpointQueue.isSaturated(sessionId) || - !this.tryAdmitNonFinalCheckpoint(sessionId) - ) { - return 'deferred' - } - const run = async (): Promise<'done' | 'deferred'> => { - try { - return await this.writeSessionCheckpoint(sessionId, opts) - } finally { - this.releaseNonFinalCheckpointAdmission(sessionId) - this.periodicDeadlineWarnedSessionIds.delete(sessionId) - } - } - return await this.checkpointQueue.runWithDeadline( - sessionId, - run, - DaemonPtyAdapter.PERIODIC_CHECKPOINT_DEADLINE_MS, - 'deferred', - { - onDeadline: () => { - if (!this.periodicDeadlineWarnedSessionIds.has(sessionId)) { - this.periodicDeadlineWarnedSessionIds.add(sessionId) - console.warn('[history] periodic checkpoint deadline exceeded:', sessionId) - } - }, - onAbandonedRejection: (error) => { - console.warn('[history] checkpoint failed:', sessionId, error) - } - } - ) - } - - // Why 'deferred' exists: a full snapshot inside the cooldown is postponed and the session stays dirty for retry; - // skipping append meanwhile keeps the on-disk log a consistent (stale) prefix instead of punching a hole. - private async writeSessionCheckpoint( - sessionId: string, - opts: { final: boolean; teardown: boolean } - ): Promise<'done' | 'deferred'> { - if (!this.supportsIncrementalCheckpoints) { - const result = await this.client.request('getSnapshot', { sessionId }) - if (result.snapshot && this.historyManager) { - const checkpoint = await this.historyManager.checkpoint(sessionId, result.snapshot) - return checkpoint === 'retryable' ? 'deferred' : 'done' - } - return 'done' - } - if (opts.final || this.sessionsNeedingFullCheckpoint.has(sessionId)) { - if (!opts.final && this.isFullCheckpointCoolingDown(sessionId)) { - return 'deferred' - } - // Why take-with-snapshot not plain getSnapshot: it clears pending records in the same turn as the serialize, - // so a warm reattach won't re-append records the checkpoint already contains (double-replay on cold restore). - const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, { - teardown: opts.teardown, - forceLiveSnapshot: this.sessionsNeedingLiveCheckpoint.has(sessionId), - requireContinuityProof: this.sessionsNeedingContinuityCheckpoint.has(sessionId) - }) - if (checkpoint.checkpoint === 'retryable') { - this.sessionsNeedingFullCheckpoint.add(sessionId) - return 'deferred' - } - this.sessionsNeedingFullCheckpoint.delete(sessionId) - this.sessionsNeedingLiveCheckpoint.delete(sessionId) - this.sessionsNeedingContinuityCheckpoint.delete(sessionId) - return 'done' - } - const take = await this.client.request('takePendingOutput', { - sessionId - }) - if (!take) { - return 'done' - } - if (take.overflowed) { - // Why: overflow dropped records (log has a hole); only a full snapshot can re-anchor it. - if (this.isFullCheckpointCoolingDown(sessionId)) { - this.sessionsNeedingFullCheckpoint.add(sessionId) - return 'deferred' - } - const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, { - teardown: false, - forceLiveSnapshot: true - }) - if (checkpoint.checkpoint === 'retryable') { - this.sessionsNeedingFullCheckpoint.add(sessionId) - return 'deferred' - } - return 'done' - } - if (take.records.length === 0) { - return 'done' - } - if (!this.historyManager) { - return 'done' - } - const appendResult = await this.historyManager.appendIncrements( - sessionId, - take.seq, - take.records - ) - if (appendResult === 'needs-checkpoint') { - // Why dropping take.records is lossless: applied to the emulator before the take, so the snapshot below contains them. - if (this.isFullCheckpointCoolingDown(sessionId)) { - this.sessionsNeedingFullCheckpoint.add(sessionId) - return 'deferred' - } - const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, { - teardown: false, - forceLiveSnapshot: true - }) - if (checkpoint.checkpoint === 'retryable') { - this.sessionsNeedingFullCheckpoint.add(sessionId) - return 'deferred' - } - } - return 'done' - } - - private async takeSnapshotAndCheckpoint( - sessionId: string, - opts: { - teardown: boolean - forceLiveSnapshot?: boolean - requireContinuityProof?: boolean - } - ): Promise { - const take = await this.client.request('takePendingOutput', { - sessionId, - includeSnapshot: true, - teardownSnapshot: opts.teardown - }) - if (take?.snapshot && this.historyManager) { - // Why require drainedRecords: an older daemon still empties the pending - // queue on includeSnapshot but omits the field. Treating absence as [] - // would compact stale disk history and reset the log. - const snapshot = - take.drainedRecords === undefined || opts.forceLiveSnapshot === true || take.overflowed - ? take.snapshot - : await this.buildDurableHistorySnapshot( - sessionId, - take.snapshot, - [...take.drainedRecords, ...take.records], - { - pendingRecordsAreComplete: take.seq === 1, - ...(opts.requireContinuityProof === true - ? { requiredPreviousPendingOutputSeq: take.seq - 1 } - : {}) - } - ) - const checkpoint = await this.historyManager.checkpoint(sessionId, snapshot, { - pendingOutputSeq: take.seq - }) - if (checkpoint === 'retryable') { - // Why take.records is dropped, not appended: the pending output this take drained went into the snapshot that - // failed to land, so appending the held tail at the next contiguous seq would splice it over that hole and - // defeat the log's seq-gap detection. A stale prefix beats an undetectable hole. - this.sessionsNeedingFullCheckpoint.add(sessionId) - this.sessionsNeedingLiveCheckpoint.add(sessionId) - this.sessionsNeedingContinuityCheckpoint.delete(sessionId) - this.markSessionDirty(sessionId) - return { checkpoint, snapshot: take.snapshot } - } - if (checkpoint === 'unavailable') { - this.sessionsNeedingFullCheckpoint.delete(sessionId) - this.sessionsNeedingLiveCheckpoint.delete(sessionId) - this.sessionsNeedingContinuityCheckpoint.delete(sessionId) - return { checkpoint, snapshot: take.snapshot } - } - this.lastFullCheckpointAt.set(sessionId, Date.now()) - if (take.records.length > 0 && snapshot === take.snapshot) { - // Why: live-window fallback still lacks held parser-state bytes; keep them as a post-checkpoint log tail. - await this.historyManager.appendIncrements(sessionId, take.seq, take.records) - } - this.sessionsNeedingLiveCheckpoint.delete(sessionId) - this.sessionsNeedingContinuityCheckpoint.delete(sessionId) - return { checkpoint: 'committed', snapshot } - } - this.sessionsNeedingFullCheckpoint.delete(sessionId) - this.sessionsNeedingLiveCheckpoint.delete(sessionId) - this.sessionsNeedingContinuityCheckpoint.delete(sessionId) - return { checkpoint: 'unavailable', snapshot: take?.snapshot ?? null } - } - - private async buildDurableHistorySnapshot( - sessionId: string, - liveSnapshot: NonNullable, - pendingRecords: TakePendingOutputResult['records'], - opts: { - pendingRecordsAreComplete: boolean - requiredPreviousPendingOutputSeq?: number - } - ): Promise> { - if (!this.historyReader) { - return liveSnapshot - } - try { - const restoreInfo = await this.historyReader.detectColdRestore(sessionId, { - ignoreCleanEnd: true, - wslDistro: this.wslDistrosBySessionId.get(sessionId) - }) - if ( - (!restoreInfo && !opts.pendingRecordsAreComplete) || - (opts.requiredPreviousPendingOutputSeq !== undefined && - restoreInfo?.pendingOutputSeq !== opts.requiredPreviousPendingOutputSeq) - ) { - console.warn('[history] durable continuity unproven; using live snapshot:', sessionId) - return liveSnapshot - } - return await buildDurableCheckpointSnapshot({ - liveSnapshot, - restoreInfo, - pendingRecords - }) - } catch (error) { - console.warn('[history] durable history rebuild failed:', sessionId, error) - return liveSnapshot - } - } - - // Why: the token read no longer throws, so audit its absence directly after an authenticated drop. - private isRetiredEndpointTokenMissing(): boolean { - return this.client.hasObservedAuthenticatedDisconnect() && !existsSync(this.tokenPath) - } - - private async withDaemonRetry(fn: () => Promise): Promise { - try { - return await fn() - } catch (err) { - const missingRetiredEndpointToken = this.isRetiredEndpointTokenMissing() - if (missingRetiredEndpointToken) { - this.observeAuditFailure( - 'token_missing_after_authenticated_disconnect', - this.exactDaemonIncarnation, - ['token_file'] - ) - } - if (this.respawnAdoptionClosed || !this.respawnFn || !isDaemonGoneError(err)) { - throw err - } - if (!this.respawnPromise) { - this.respawnPromise = this.doRespawn().finally(() => { - this.respawnPromise = null - }) - } - await this.respawnPromise - try { - return await fn() - } finally { - // Why: the retried op may reject before any connection attempt (e.g. a tombstone racing respawn). - this.releasePendingRespawnAdoptionLease() - } - } - } - - private reconnectAfterWriteFailure(): void { - if ( - this.writeRecoveryPromise || - this.writeRecoveryAttempted || - this.respawnAdoptionClosed || - !this.respawnFn - ) { - return - } - this.writeRecoveryAttempted = true - // Why: the dead endpoint took down every session on this daemon. Signal all - // active panes now — while they are still in activeSessionIds, so the - // renderer's liveness gate still reads them live — so background panes - // remount + re-attach alongside the one that was written, instead of being - // left frozen with silently dropped input until each is typed into. - this.notifyActiveSessionsWriteUnavailable() - const recovery = this.withDaemonRetry(() => this.ensureConnected()) - .catch((error) => console.warn('[daemon] Failed to recover after rejected PTY input:', error)) - .finally(() => { - this.releasePendingRespawnAdoptionLease() - if (this.writeRecoveryPromise === recovery) { - this.writeRecoveryPromise = null - } - }) - this.writeRecoveryPromise = recovery - } - - private notifyActiveSessionsWriteUnavailable(): void { - // Snapshot first: a listener that kills a pane would mutate activeSessionIds - // mid-iteration and silently skip the sibling this fan-out exists to reach. - const ids = [...this.activeSessionIds] - for (const id of ids) { - this.sessionsAwaitingDaemonRecovery.add(id) - this.emitWriteUnavailable(id) - } - } - - private clearSessionAwaitingDaemonRecovery(sessionId: string): void { - this.sessionsAwaitingDaemonRecovery.delete(sessionId) - if (this.sessionsAwaitingDaemonRecovery.size === 0) { - this.writeRecoveryAttempted = false - } - } - - private async withHistorySpawnLock( - sessionId: string, - operation: () => Promise - ): Promise { - if (!this.historyManager) { - return await operation() - } - const previous = this.historySpawnLocks.get(sessionId) ?? Promise.resolve() - let release!: () => void - const current = new Promise((resolve) => { - release = resolve - }) - const tail = previous.then( - () => current, - () => current - ) - this.historySpawnLocks.set(sessionId, tail) - await previous.catch(() => {}) - try { - return await operation() - } finally { - release() - if (this.historySpawnLocks.get(sessionId) === tail) { - this.historySpawnLocks.delete(sessionId) - } - } - } - - private async replaceUnhealthyMacResolverDaemonBeforeNewPty(): Promise { - if (!this.respawnFn) { - return - } - - const health = await getMacDaemonSystemResolverHealth( - this.socketPath, - this.tokenPath, - this.protocolVersion - ) - if (health !== 'unhealthy') { - return - } - - const daemonLiveSessionCount = await this.getDaemonLiveSessionCount() - const liveSessionCount = Math.max(this.activeSessionIds.size, daemonLiveSessionCount ?? 0) - if (daemonLiveSessionCount === null || liveSessionCount > 0) { - console.warn( - daemonLiveSessionCount === null - ? '[daemon] macOS system resolver unavailable - preserving daemon because live session state could not be verified' - : `[daemon] macOS system resolver unavailable - preserving daemon because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}` - ) - return - } - - // Why: replacing the daemon kills its sessions without exit fanout; emit exits first so panes don't write to dead PTYs. - this.fanoutSyntheticExits(-1) - if (!this.respawnPromise) { - this.respawnPromise = this.doRespawn( - '[daemon] macOS system resolver unavailable - respawning daemon', - 'unhealthy_resolver' - ).finally(() => { - this.respawnPromise = null - }) - } - await this.respawnPromise - } - - /** Replace a stale packaged daemon only after its live sessions drain. */ - private async replaceStaleBundleDaemonBeforeNewPty(): Promise { - if (!this.respawnFn || !this.runtimeDir || !this.packagedAppVersion) { - return - } - if (!this.staleBundleReplacementPromise) { - this.staleBundleReplacementPromise = this.replaceStaleBundleDaemonOnce( - this.runtimeDir, - this.packagedAppVersion - ).finally(() => { - this.staleBundleReplacementPromise = null - }) - } - await this.staleBundleReplacementPromise - } - - private async replaceStaleBundleDaemonOnce( - runtimeDir: string, - packagedAppVersion: string - ): Promise { - const stale = await isDaemonStaleForCurrentBundle( - runtimeDir, - this.socketPath, - this.tokenPath, - packagedAppVersion, - this.protocolVersion - ) - if (!stale) { - return - } - - const daemonLiveSessionCount = await this.getDaemonLiveSessionCount() - const liveSessionCount = Math.max(this.activeSessionIds.size, daemonLiveSessionCount ?? 0) - if (daemonLiveSessionCount === null || liveSessionCount > 0) { - console.warn( - daemonLiveSessionCount === null - ? '[daemon] Packaged daemon is stale - preserving it because live session state could not be verified' - : `[daemon] Packaged daemon is stale - preserving it because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}` - ) - return - } - - this.fanoutSyntheticExits(-1) - if (!this.respawnPromise) { - this.respawnPromise = this.doRespawn( - '[daemon] Packaged daemon is stale - respawning from the current app bundle', - 'stale_bundle' - ).finally(() => { - this.respawnPromise = null - }) - } - await this.respawnPromise - } - - /** Replace a TCC-severed daemon only after its live sessions drain. */ - private async replaceSeveredMacTccDaemonBeforeNewPty(): Promise { - // Why no platform gate: getMacDaemonTccAttributionHealth returns 'unknown' off macOS. - if (!this.respawnFn || !this.runtimeDir) { - return - } - - const health = await getMacDaemonTccAttributionHealth( - this.runtimeDir, - this.socketPath, - this.tokenPath, - this.protocolVersion - ) - if (health !== 'severed') { - return - } - - const daemonLiveSessionCount = await this.getDaemonLiveSessionCount() - const liveSessionCount = Math.max(this.activeSessionIds.size, daemonLiveSessionCount ?? 0) - if (daemonLiveSessionCount === null || liveSessionCount > 0) { - console.warn( - daemonLiveSessionCount === null - ? '[daemon] macOS TCC attribution severed - preserving daemon because live session state could not be verified' - : `[daemon] macOS TCC attribution severed - preserving daemon because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}; restart from Manage Sessions when ready` - ) - return - } - - this.fanoutSyntheticExits(-1) - if (!this.respawnPromise) { - this.respawnPromise = this.doRespawn( - '[daemon] macOS TCC attribution severed - respawning daemon under the current app binary', - 'severed_tcc_attribution' - ).finally(() => { - this.respawnPromise = null - }) - } - await this.respawnPromise - } - - private async getDaemonLiveSessionCount(): Promise { - try { - await this.client.ensureConnected() - const result = await this.client.request('listSessions', undefined) - return result.sessions.filter((session) => session.isAlive).length - } catch { - return null - } - } - - private emitBackgroundStreamEvent(payload: PtyBackgroundStreamEvent): void { - // oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration - for (const listener of [...this.backgroundStreamListeners]) { - listener(payload) - } - } - - private async doRespawn( - message = '[daemon] Daemon died — respawning', - reason: DaemonRespawnReason = 'daemon_died' - ): Promise { - console.warn(message) - this.removeEventListener?.() - this.removeEventListener = null - this.client.disconnect() - const releaseAdoptionLease = await this.respawnFn!(reason) - if (this.respawnAdoptionClosed) { - // Why: app teardown may win mid-respawn; a late result must not reinstall a lease nobody owns. - releaseAdoptionLease?.() - throw new Error('Daemon adapter closed during respawn') - } - this.pendingRespawnAdoptionRelease = releaseAdoptionLease ?? null - } - - private releasePendingRespawnAdoptionLease(): void { - const release = this.pendingRespawnAdoptionRelease - this.pendingRespawnAdoptionRelease = null - release?.() - } - - private setupEventRouting(): void { +export class DaemonPtyAdapter extends DaemonPtyDaemonRecovery implements IPtyProvider { + protected setupEventRouting(): void { if (this.removeEventListener) { return } @@ -3109,12 +50,8 @@ export class DaemonPtyAdapter implements IPtyProvider { : { sequenceChars: event.payload.sequenceChars }) }) } else if (event.event === 'transientFact') { - // Why (#9993): belt-and-braces behind the setPtyBackgrounded gate. A pre-v29 - // daemon is never asked to background, so it should emit no transient facts at - // all — but one preserved across a reconnect could still have a stale relay - // tracker. An unretractable subscribe is the harmful direction, so drop it. - // An unsubscribe is always forwarded: retiring a subscription main registered - // can only ever help, never strand one. + // Why (#9993): a preserved pre-v29 daemon can retain a stale relay tracker. Its + // unretractable subscribe is harmful; an unsubscribe is always safe to forward. if ( event.payload.kind === '2031-subscribe' && !supportsMode2031UnsubscribeFact(this.protocolVersion) @@ -3159,21 +96,18 @@ export class DaemonPtyAdapter implements IPtyProvider { this.activeSessionIds.delete(event.sessionId) this.clearSessionAwaitingDaemonRecovery(event.sessionId) this.dirtySessionVersions.delete(event.sessionId) - // Why: a reused sessionId must not inherit the dead session's owed resume (stray resumePty) or backgrounded/thinned state. this.pausedProducerSessionIds.delete(event.sessionId) this.producerResumesOwedOnReconnect.delete(event.sessionId) this.backgroundedSessionIds.delete(event.sessionId) if (!this.sleepRestoreSessionIds.has(event.sessionId)) { this.coldRestoreCache.delete(event.sessionId) } - // Why: an exited session can't be checkpointed again; clearing its pending-full flag prevents a permanent leak. this.sessionsNeedingFullCheckpoint.delete(event.sessionId) this.sessionsNeedingLiveCheckpoint.delete(event.sessionId) this.sessionsNeedingContinuityCheckpoint.delete(event.sessionId) this.overlayDeadlineWarnedSessionIds.delete(event.sessionId) this.periodicDeadlineWarnedSessionIds.delete(event.sessionId) this.nonFinalAdmissionDeniedSessionIds.delete(event.sessionId) - // Why: a reused sessionId (renderer respawns a persisted ptyId) must not inherit the dead session's snapshot cooldown. this.lastFullCheckpointAt.delete(event.sessionId) this.stopCheckpointTimerIfIdle() if (this.historyManager) { @@ -3208,104 +142,3 @@ export class DaemonPtyAdapter implements IPtyProvider { return result.appliedSeq } } - -function sameEndpointIdentity( - left: DaemonEndpointIdentity, - right: DaemonEndpointIdentity -): boolean { - return ( - left.pid === right.pid && - left.startedAtMs === right.startedAtMs && - left.launchNonce === right.launchNonce - ) -} - -function exactDaemonIncarnationForPidRecord( - identity: DaemonEndpointIdentity, - pidRecord: ParsedDaemonPid | null -): ExactDaemonIncarnation { - return { - identity: { ...identity }, - ...(process.platform === 'linux' && - pidRecord?.pid === identity.pid && - pidRecord.startedAtMs === identity.startedAtMs && - pidRecord.launchNonce === identity.launchNonce && - pidRecord.linuxStartTicks && - pidRecord.bootId - ? { - linuxStartTicks: pidRecord.linuxStartTicks, - bootId: pidRecord.bootId - } - : {}) - } -} - -function readDaemonPidRecord(pidPath: string | null): ParsedDaemonPid | null { - if (!pidPath) { - return null - } - try { - return parseDaemonPidFile(readFileSync(pidPath, 'utf8')) - } catch { - return null - } -} - -function removeListener(listeners: T[], listener: T): void { - const index = listeners.indexOf(listener) - if (index !== -1) { - listeners.splice(index, 1) - } -} - -function notifyAuditListeners(listeners: readonly ((value: T) => void)[], value: T): void { - for (const listener of listeners.slice()) { - try { - listener(value) - } catch { - // Audit observers cannot affect daemon operations. - } - } -} - -/** - * Narrow on purpose: only the daemon's own reply for a request type it does not implement. - * A transient failure must stay unproven rather than be mistaken for a missing capability. - * The server throws `Unknown request type: `; the client rejects with that text, which - * `addNodePtyRecoveryHint` only ever prepends to. - */ -function isUnknownRequestTypeError(err: unknown): boolean { - return err instanceof Error && err.message.includes('Unknown request type') -} - -// Why: syscall='connect' distinguishes a dead-socket ENOENT/ECONNREFUSED from token-file ENOENT (no syscall); -// message strings incl. wedged-daemon "Hello response timed out" (#8689) also warrant a respawn. -/** Exported so a test can pin it against the server's refusal wording, which it must match. */ -export function isDaemonGoneError(err: unknown): boolean { - if (!(err instanceof Error)) { - return false - } - const errno = err as NodeJS.ErrnoException - if ((errno.code === 'ENOENT' || errno.code === 'ECONNREFUSED') && errno.syscall === 'connect') { - return true - } - const msg = err.message - return ( - msg === 'Connection lost' || - msg === 'Not connected' || - msg === 'Hello response timed out' || - // Both the daemon's own drain refusal and the client's wedged-daemon signal. - msg === DAEMON_UNAVAILABLE_RECONNECT_MESSAGE || - // Why retry: the daemon refused because the endpoint now resolves elsewhere. Reconnecting - // reaches whoever owns it; surfacing this to the user would strand the request instead. - msg === DAEMON_ENDPOINT_LOST_MESSAGE - ) -} - -function isMissingWindowsNamedPipeError(err: unknown): boolean { - if (process.platform !== 'win32' || !(err instanceof Error)) { - return false - } - const errno = err as NodeJS.ErrnoException - return errno.code === 'ENOENT' && errno.syscall === 'connect' -} diff --git a/src/main/daemon/daemon-pty-buffer-snapshots.ts b/src/main/daemon/daemon-pty-buffer-snapshots.ts new file mode 100644 index 00000000000..cf65d85fa61 --- /dev/null +++ b/src/main/daemon/daemon-pty-buffer-snapshots.ts @@ -0,0 +1,202 @@ +import { normalizeDesktopTerminalSnapshotRows } from '../../shared/terminal-scrollback-policy' +import { parseTerminalKittyKeyboardFlags } from '../../shared/terminal-kitty-keyboard-flags' +import { buildDurableCheckpointSnapshot } from './daemon-durable-history-snapshot' +import { DaemonPtySessionControl } from './daemon-pty-session-control' +import { DAEMON_RESTORE_SCROLLBACK_ROWS } from './daemon-restore-scrollback-depth' +import { DAEMON_SESSION_SCROLLBACK_ROWS } from './daemon-session-scrollback-window' +import type { GetSnapshotResult } from './types' +import type { PtyProviderBufferSnapshot } from '../providers/types' + +const DURABLE_HISTORY_OVERLAY_DEADLINE_MS = 5_000 + +export abstract class DaemonPtyBufferSnapshots extends DaemonPtySessionControl { + async getBufferSnapshot( + id: string, + opts: { scrollbackRows?: number } = {} + ): Promise { + if (!this.supportsAuthoritativeBufferSnapshots) { + return null + } + try { + const scrollbackRows = normalizeDesktopTerminalSnapshotRows(opts.scrollbackRows) + const result = await this.client.request('getSnapshot', { + sessionId: id, + ...(scrollbackRows !== undefined ? { scrollbackRows } : {}) + }) + const snapshot = result.snapshot + // Why: older v19 daemons lack an absolute output sequence, so their snapshot can't reconcile bytes queued on the other socket. + if (!snapshot || typeof snapshot.outputSequence !== 'number') { + return null + } + const restored = + this.historyManager && + this.historyReader && + (scrollbackRows === undefined || scrollbackRows > DAEMON_SESSION_SCROLLBACK_ROWS) + ? await this.overlayDurableRestoreSnapshot(id, snapshot, scrollbackRows) + : snapshot + return this.toProviderBufferSnapshot(restored) + } catch { + return null + } + } + + protected toProviderBufferSnapshot( + snapshot: NonNullable + ): PtyProviderBufferSnapshot | null { + if (typeof snapshot.outputSequence !== 'number') { + return null + } + const kittyKeyboardFlags = parseTerminalKittyKeyboardFlags(snapshot.modes.kittyKeyboardFlags) + return { + data: snapshot.rehydrateSequences + snapshot.snapshotAnsi, + frameRestoreAnsi: snapshot.frameRestoreAnsi, + scrollbackAnsi: snapshot.scrollbackAnsi, + cols: snapshot.cols, + rows: snapshot.rows, + cwd: snapshot.cwd, + lastTitle: snapshot.lastTitle, + seq: snapshot.outputSequence, + source: 'headless', + oscLinks: snapshot.oscLinks, + alternateScreen: snapshot.modes.alternateScreen, + // Why known `0` is carried too: it proves the app negotiated nothing at + // this boundary, which is a different fact from a source that cannot say. + ...(kittyKeyboardFlags !== undefined ? { kittyKeyboardFlags } : {}), + ...(snapshot.pendingEscapeTailAnsi + ? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi } + : {}) + } + } + + protected async overlayDurableRestoreSnapshot( + sessionId: string, + liveSnapshot: NonNullable, + scrollbackRows?: number + ): Promise> { + if (!this.historyManager || !this.historyReader) { + return liveSnapshot + } + // Why turn the caller away instead of queueing: this session already has a + // compact in flight whose result a third one would only duplicate, and an + // unbounded queue is how a stalled history filesystem grows without limit. + if (this.checkpointQueue.isSaturated(sessionId)) { + return liveSnapshot + } + // Why reserve before enqueueing: pane mounts can arrive in one turn, before any compact starts. + // Count abandoned waits until their writes settle so a relaunch cannot fan out unbounded work. + if (!this.tryAdmitNonFinalCheckpoint(sessionId)) { + return liveSnapshot + } + // Why per session with a deadline: a reattach is a user click, so it must wait + // on this session's own compact and nothing else. A blown deadline does not + // cancel that compact — it keeps running and still commits — so the fallback + // costs restore depth for this one reattach, never durable history (STA-4173). + return await this.checkpointQueue.runWithDeadline( + sessionId, + async () => { + try { + return await this.compactDurableRestoreSnapshot(sessionId, liveSnapshot, scrollbackRows) + } finally { + this.releaseNonFinalCheckpointAdmission(sessionId) + this.overlayDeadlineWarnedSessionIds.delete(sessionId) + } + }, + DURABLE_HISTORY_OVERLAY_DEADLINE_MS, + liveSnapshot, + { + onDeadline: () => { + if (!this.overlayDeadlineWarnedSessionIds.has(sessionId)) { + this.overlayDeadlineWarnedSessionIds.add(sessionId) + console.warn('[history] durable snapshot overlay deadline exceeded:', sessionId) + } + } + } + ) + } + + protected tryAdmitNonFinalCheckpoint(sessionId: string): boolean { + if (this.nonFinalCheckpointAdmissionSessionIds.has(sessionId)) { + if (!this.nonFinalAdmissionDeniedSessionIds.has(sessionId)) { + this.nonFinalAdmissionDeniedSessionIds.add(sessionId) + console.warn('[history] non-final checkpoint already in flight:', sessionId) + } + return false + } + if ( + this.nonFinalCheckpointAdmissionSessionIds.size >= + (this.constructor as typeof DaemonPtyBufferSnapshots).MAX_CONCURRENT_CHECKPOINTS + ) { + this.reportNonFinalGlobalAdmissionDenial(sessionId) + return false + } + this.nonFinalAdmissionDeniedSessionIds.delete(sessionId) + this.nonFinalCheckpointAdmissionSessionIds.add(sessionId) + return true + } + + protected reportNonFinalGlobalAdmissionDenial(sessionId: string): void { + if (!this.nonFinalGlobalAdmissionWarningActive) { + this.nonFinalGlobalAdmissionWarningActive = true + console.warn('[history] non-final checkpoint global admission limit reached:', sessionId) + } + } + + protected releaseNonFinalCheckpointAdmission(sessionId: string): void { + if (this.nonFinalCheckpointAdmissionSessionIds.delete(sessionId)) { + this.nonFinalAdmissionDeniedSessionIds.delete(sessionId) + this.nonFinalGlobalAdmissionWarningActive = false + } + } + + protected async compactDurableRestoreSnapshot( + sessionId: string, + liveSnapshot: NonNullable, + scrollbackRows?: number + ): Promise> { + if (!this.historyReader) { + return liveSnapshot + } + try { + // Why compact before reading: an independent take/append races the 5s tick, can + // seq-gap the log, and would remount a stale checkpoint over the live window. + const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, { + teardown: false, + forceLiveSnapshot: this.sessionsNeedingLiveCheckpoint.has(sessionId), + requireContinuityProof: this.sessionsNeedingContinuityCheckpoint.has(sessionId) + }) + if (checkpoint.checkpoint === 'committed') { + this.sessionsNeedingFullCheckpoint.delete(sessionId) + } + if (checkpoint.checkpoint !== 'committed' || !checkpoint.snapshot) { + return checkpoint.snapshot ?? liveSnapshot + } + if (scrollbackRows === undefined || scrollbackRows >= DAEMON_RESTORE_SCROLLBACK_ROWS) { + return checkpoint.snapshot + } + const restoreInfo = await this.historyReader.detectColdRestore(sessionId, { + ignoreCleanEnd: true, + wslDistro: this.wslDistrosBySessionId.get(sessionId) + }) + if (!restoreInfo) { + return liveSnapshot + } + return await buildDurableCheckpointSnapshot({ + liveSnapshot: checkpoint.snapshot, + restoreInfo, + scrollbackRows + }) + } catch (error) { + console.warn('[history] durable snapshot overlay failed:', sessionId, error) + return liveSnapshot + } + } + + async clearBuffer(id: string): Promise { + await this.client.request('clearScrollback', { sessionId: id }) + this.markSessionDirty(id) + } + + acknowledgeDataEvent(_id: string, _charCount: number): void { + // No flow control for daemon-backed terminals + } +} diff --git a/src/main/daemon/daemon-pty-checkpoint-persistence.ts b/src/main/daemon/daemon-pty-checkpoint-persistence.ts new file mode 100644 index 00000000000..ed8ffd3f26f --- /dev/null +++ b/src/main/daemon/daemon-pty-checkpoint-persistence.ts @@ -0,0 +1,192 @@ +import { buildDurableCheckpointSnapshot } from './daemon-durable-history-snapshot' +import { DaemonPtyCheckpointScheduler } from './daemon-pty-checkpoint-scheduler' +import type { SnapshotCheckpointResult } from './daemon-pty-runtime-state' +import type { GetSnapshotResult, TakePendingOutputResult } from './types' + +export abstract class DaemonPtyCheckpointPersistence extends DaemonPtyCheckpointScheduler { + // Why 'deferred' exists: a full snapshot inside the cooldown is postponed and the session stays dirty for retry; + // skipping append meanwhile keeps the on-disk log a consistent (stale) prefix instead of punching a hole. + protected async writeSessionCheckpoint( + sessionId: string, + opts: { final: boolean; teardown: boolean } + ): Promise<'done' | 'deferred'> { + if (!this.supportsIncrementalCheckpoints) { + const result = await this.client.request('getSnapshot', { sessionId }) + if (result.snapshot && this.historyManager) { + const checkpoint = await this.historyManager.checkpoint(sessionId, result.snapshot) + return checkpoint === 'retryable' ? 'deferred' : 'done' + } + return 'done' + } + if (opts.final || this.sessionsNeedingFullCheckpoint.has(sessionId)) { + if (!opts.final && this.isFullCheckpointCoolingDown(sessionId)) { + return 'deferred' + } + // Why take-with-snapshot not plain getSnapshot: it clears pending records in the same turn as the serialize, + // so a warm reattach won't re-append records the checkpoint already contains (double-replay on cold restore). + const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, { + teardown: opts.teardown, + forceLiveSnapshot: this.sessionsNeedingLiveCheckpoint.has(sessionId), + requireContinuityProof: this.sessionsNeedingContinuityCheckpoint.has(sessionId) + }) + if (checkpoint.checkpoint === 'retryable') { + this.sessionsNeedingFullCheckpoint.add(sessionId) + return 'deferred' + } + this.sessionsNeedingFullCheckpoint.delete(sessionId) + this.sessionsNeedingLiveCheckpoint.delete(sessionId) + this.sessionsNeedingContinuityCheckpoint.delete(sessionId) + return 'done' + } + const take = await this.client.request('takePendingOutput', { + sessionId + }) + if (!take) { + return 'done' + } + if (take.overflowed) { + // Why: overflow dropped records (log has a hole); only a full snapshot can re-anchor it. + if (this.isFullCheckpointCoolingDown(sessionId)) { + this.sessionsNeedingFullCheckpoint.add(sessionId) + return 'deferred' + } + const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, { + teardown: false, + forceLiveSnapshot: true + }) + if (checkpoint.checkpoint === 'retryable') { + this.sessionsNeedingFullCheckpoint.add(sessionId) + return 'deferred' + } + return 'done' + } + if (take.records.length === 0) { + return 'done' + } + if (!this.historyManager) { + return 'done' + } + const appendResult = await this.historyManager.appendIncrements( + sessionId, + take.seq, + take.records + ) + if (appendResult === 'needs-checkpoint') { + // Why dropping take.records is lossless: applied to the emulator before the take, so the snapshot below contains them. + if (this.isFullCheckpointCoolingDown(sessionId)) { + this.sessionsNeedingFullCheckpoint.add(sessionId) + return 'deferred' + } + const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, { + teardown: false, + forceLiveSnapshot: true + }) + if (checkpoint.checkpoint === 'retryable') { + this.sessionsNeedingFullCheckpoint.add(sessionId) + return 'deferred' + } + } + return 'done' + } + + protected async takeSnapshotAndCheckpoint( + sessionId: string, + opts: { + teardown: boolean + forceLiveSnapshot?: boolean + requireContinuityProof?: boolean + } + ): Promise { + const take = await this.client.request('takePendingOutput', { + sessionId, + includeSnapshot: true, + teardownSnapshot: opts.teardown + }) + if (take?.snapshot && this.historyManager) { + // Why require drainedRecords: an older daemon still empties the pending + // queue on includeSnapshot but omits the field. Treating absence as [] + // would compact stale disk history and reset the log. + const snapshot = + take.drainedRecords === undefined || opts.forceLiveSnapshot === true || take.overflowed + ? take.snapshot + : await this.buildDurableHistorySnapshot( + sessionId, + take.snapshot, + [...take.drainedRecords, ...take.records], + { + pendingRecordsAreComplete: take.seq === 1, + ...(opts.requireContinuityProof === true + ? { requiredPreviousPendingOutputSeq: take.seq - 1 } + : {}) + } + ) + const checkpoint = await this.historyManager.checkpoint(sessionId, snapshot, { + pendingOutputSeq: take.seq + }) + if (checkpoint === 'retryable') { + // Why take.records is dropped, not appended: the pending output this take drained went into the snapshot that + // failed to land, so appending the held tail at the next contiguous seq would splice it over that hole and + // defeat the log's seq-gap detection. A stale prefix beats an undetectable hole. + this.sessionsNeedingFullCheckpoint.add(sessionId) + this.sessionsNeedingLiveCheckpoint.add(sessionId) + this.sessionsNeedingContinuityCheckpoint.delete(sessionId) + this.markSessionDirty(sessionId) + return { checkpoint, snapshot: take.snapshot } + } + if (checkpoint === 'unavailable') { + this.sessionsNeedingFullCheckpoint.delete(sessionId) + this.sessionsNeedingLiveCheckpoint.delete(sessionId) + this.sessionsNeedingContinuityCheckpoint.delete(sessionId) + return { checkpoint, snapshot: take.snapshot } + } + this.lastFullCheckpointAt.set(sessionId, Date.now()) + if (take.records.length > 0 && snapshot === take.snapshot) { + // Why: live-window fallback still lacks held parser-state bytes; keep them as a post-checkpoint log tail. + await this.historyManager.appendIncrements(sessionId, take.seq, take.records) + } + this.sessionsNeedingLiveCheckpoint.delete(sessionId) + this.sessionsNeedingContinuityCheckpoint.delete(sessionId) + return { checkpoint: 'committed', snapshot } + } + this.sessionsNeedingFullCheckpoint.delete(sessionId) + this.sessionsNeedingLiveCheckpoint.delete(sessionId) + this.sessionsNeedingContinuityCheckpoint.delete(sessionId) + return { checkpoint: 'unavailable', snapshot: take?.snapshot ?? null } + } + + protected async buildDurableHistorySnapshot( + sessionId: string, + liveSnapshot: NonNullable, + pendingRecords: TakePendingOutputResult['records'], + opts: { + pendingRecordsAreComplete: boolean + requiredPreviousPendingOutputSeq?: number + } + ): Promise> { + if (!this.historyReader) { + return liveSnapshot + } + try { + const restoreInfo = await this.historyReader.detectColdRestore(sessionId, { + ignoreCleanEnd: true, + wslDistro: this.wslDistrosBySessionId.get(sessionId) + }) + if ( + (!restoreInfo && !opts.pendingRecordsAreComplete) || + (opts.requiredPreviousPendingOutputSeq !== undefined && + restoreInfo?.pendingOutputSeq !== opts.requiredPreviousPendingOutputSeq) + ) { + console.warn('[history] durable continuity unproven; using live snapshot:', sessionId) + return liveSnapshot + } + return await buildDurableCheckpointSnapshot({ + liveSnapshot, + restoreInfo, + pendingRecords + }) + } catch (error) { + console.warn('[history] durable history rebuild failed:', sessionId, error) + return liveSnapshot + } + } +} diff --git a/src/main/daemon/daemon-pty-checkpoint-scheduler.ts b/src/main/daemon/daemon-pty-checkpoint-scheduler.ts new file mode 100644 index 00000000000..56561f88a6c --- /dev/null +++ b/src/main/daemon/daemon-pty-checkpoint-scheduler.ts @@ -0,0 +1,251 @@ +import { awaitDaemonWorkWithinCallerDeadline } from './daemon-request-deadline' +import { DaemonPtyConnectionLifecycle } from './daemon-pty-connection-lifecycle' + +export abstract class DaemonPtyCheckpointScheduler extends DaemonPtyConnectionLifecycle { + protected stopCheckpointTimer(): void { + if (!this.checkpointTimer) { + return + } + clearTimeout(this.checkpointTimer) + this.checkpointTimer = null + } + + protected stopCheckpointTimerIfIdle(): void { + if (this.dirtySessionVersions.size === 0) { + this.stopCheckpointTimer() + } + } + + protected scheduleCheckpointTimer(): void { + if ( + this.checkpointTimer || + !this.historyManager || + !this.supportsCheckpoints || + this.dirtySessionVersions.size === 0 + ) { + return + } + // Why: dirty-gate the timer — a permanent 5s interval woke the main process for idle terminals with nothing to write. + this.checkpointTimer = setTimeout( + () => { + this.checkpointTimer = null + // Why: don't overlap checkpoint passes — concurrent tmp-file writes can lose a rename and disable future history writes. + if (this.checkpointInFlight) { + this.scheduleCheckpointTimer() + return + } + const checkpoint = this.checkpointDirtySessions() + this.checkpointInFlight = checkpoint + void checkpoint + .finally(() => { + if (this.checkpointInFlight === checkpoint) { + this.checkpointInFlight = null + this.scheduleCheckpointTimer() + } + }) + // Why: .finally() re-throws, so a rejected checkpoint would surface as an unhandled rejection here. + .catch(() => {}) + }, + (this.constructor as typeof DaemonPtyCheckpointScheduler).CHECKPOINT_INTERVAL_MS + ) + } + + protected markSessionDirty(sessionId: string): void { + if (!this.activeSessionIds.has(sessionId)) { + return + } + this.dirtySessionVersions.set(sessionId, (this.dirtySessionVersions.get(sessionId) ?? 0) + 1) + this.scheduleCheckpointTimer() + } + + protected async checkpointDirtySessions(): Promise { + if (!this.historyManager || this.dirtySessionVersions.size === 0) { + return + } + // Why: dirty-version filtering avoids re-serializing every idle session every 5s (CPU/disk on large workspaces) + // while not dropping writes that arrive mid-checkpoint. + const versions = new Map( + [...this.dirtySessionVersions].filter(([sessionId]) => this.activeSessionIds.has(sessionId)) + ) + if (versions.size === 0) { + this.dirtySessionVersions.clear() + this.stopCheckpointTimer() + return + } + const completed = await this.checkpointSessions(versions.keys()) + for (const [sessionId, version] of versions) { + if (completed.has(sessionId) && this.dirtySessionVersions.get(sessionId) === version) { + this.dirtySessionVersions.delete(sessionId) + } + } + this.stopCheckpointTimerIfIdle() + } + + /** False only when `callerDeadlineMs` expired first; the checkpoint itself keeps running. */ + protected async runExclusiveCheckpoint( + operation: () => Promise, + options: { rescheduleDirty?: boolean; callerDeadlineMs?: number } = {} + ): Promise { + this.stopCheckpointTimer() + // Why: a promise tail keeps every waiter ordered; awaiting one active operation lets sibling waiters resume together. + const previous = this.checkpointInFlight ?? Promise.resolve() + const checkpoint = previous.catch(() => {}).then(operation) + this.checkpointInFlight = checkpoint + // Why the release rides the checkpoint instead of the caller's await: a caller that walks away + // at its deadline must leave this checkpoint as the tail, so the durable write still runs to + // completion, still commits, and the next waiter still queues behind it (STA-4228). + const settled = checkpoint.then( + () => this.releaseExclusiveCheckpoint(checkpoint, options.rescheduleDirty), + (err: unknown) => { + this.releaseExclusiveCheckpoint(checkpoint, options.rescheduleDirty) + throw err + } + ) + if (options.callerDeadlineMs === undefined) { + await settled + return true + } + return await awaitDaemonWorkWithinCallerDeadline(settled, options.callerDeadlineMs) + } + + protected releaseExclusiveCheckpoint( + checkpoint: Promise, + rescheduleDirty: boolean | undefined + ): void { + if (this.checkpointInFlight === checkpoint) { + this.checkpointInFlight = null + } + this.stopCheckpointTimer() + if (rescheduleDirty !== false) { + this.scheduleCheckpointTimer() + } + } + + // Why final=true not teardown: clean disconnect needs the full daemon-window snapshot as the restore source, but the + // detached daemon's PTYs keep running for warm reattach, so shell-ready scanner state must stay intact. + protected async checkpointAllSessions(): Promise { + const completed = await this.checkpointSessions(this.activeSessionIds, { final: true }) + for (const sessionId of completed) { + this.dirtySessionVersions.delete(sessionId) + } + } + + protected async checkpointSessions( + sessionIds: Iterable, + opts?: { final?: boolean; teardown?: boolean } + ): Promise> { + const completed = new Set() + if (!this.historyManager) { + return completed + } + const ids = Array.from(sessionIds) + let nextIndex = 0 + + const checkpointNext = async (): Promise => { + for (;;) { + // No worker in this pass awaits abandoned work, so a full admission set cannot open here. + if ( + opts?.final !== true && + this.nonFinalCheckpointAdmissionSessionIds.size >= + (this.constructor as typeof DaemonPtyCheckpointScheduler).MAX_CONCURRENT_CHECKPOINTS + ) { + const deferredSessionId = ids[nextIndex] + if (deferredSessionId !== undefined) { + this.reportNonFinalGlobalAdmissionDenial(deferredSessionId) + } + return + } + const index = nextIndex + nextIndex++ + if (index >= ids.length) { + return + } + const sessionId = ids[index] + await this.checkpointSession(sessionId, { + final: opts?.final === true, + teardown: opts?.teardown === true + }) + .then((result) => { + // Why: deferred sessions stay dirty so the checkpoint timer keeps retrying until their full-snapshot cooldown expires. + if (result === 'done') { + completed.add(sessionId) + } + }) + .catch((err) => console.warn('[history] checkpoint failed:', sessionId, err)) + } + } + // Why: snapshot/checkpoint writes are CPU/disk heavy; cap prevents one tick snapshotting every dirty terminal at once. + const workers = Array.from( + { + length: Math.min( + (this.constructor as typeof DaemonPtyCheckpointScheduler).MAX_CONCURRENT_CHECKPOINTS, + ids.length + ) + }, + () => checkpointNext() + ) + await Promise.all(workers) + return completed + } + + // Why cooldown starts only after the first full snapshot: a checkpoint-less session must be able to write one immediately. + protected isFullCheckpointCoolingDown(sessionId: string): boolean { + const last = this.lastFullCheckpointAt.get(sessionId) + if (last === undefined) { + return false + } + const elapsed = Date.now() - last + // Why elapsed < 0 counts as expired: a backward wall-clock jump must not extend the deferral window. + return ( + elapsed >= 0 && + elapsed < + (this.constructor as typeof DaemonPtyCheckpointScheduler).FULL_CHECKPOINT_COOLDOWN_MS + ) + } + + protected async checkpointSession( + sessionId: string, + opts: { final: boolean; teardown: boolean } + ): Promise<'done' | 'deferred'> { + // Why final waits without a deadline: sleep/disconnect needs the last snapshot on disk, and + // deferring there would silently drop what the user left on screen rather than delay it. + if (opts.final) { + return await this.checkpointQueue.run(sessionId, () => + this.writeSessionCheckpoint(sessionId, opts) + ) + } + // Why 'deferred' is safe: the session stays dirty, so the operation that beat us to the queue + // still commits and the next tick retries this one. Nothing on disk is discarded. + if ( + this.checkpointQueue.isSaturated(sessionId) || + !this.tryAdmitNonFinalCheckpoint(sessionId) + ) { + return 'deferred' + } + const run = async (): Promise<'done' | 'deferred'> => { + try { + return await this.writeSessionCheckpoint(sessionId, opts) + } finally { + this.releaseNonFinalCheckpointAdmission(sessionId) + this.periodicDeadlineWarnedSessionIds.delete(sessionId) + } + } + return await this.checkpointQueue.runWithDeadline( + sessionId, + run, + (this.constructor as typeof DaemonPtyCheckpointScheduler).PERIODIC_CHECKPOINT_DEADLINE_MS, + 'deferred', + { + onDeadline: () => { + if (!this.periodicDeadlineWarnedSessionIds.has(sessionId)) { + this.periodicDeadlineWarnedSessionIds.add(sessionId) + console.warn('[history] periodic checkpoint deadline exceeded:', sessionId) + } + }, + onAbandonedRejection: (error) => { + console.warn('[history] checkpoint failed:', sessionId, error) + } + } + ) + } +} diff --git a/src/main/daemon/daemon-pty-connection-lifecycle.ts b/src/main/daemon/daemon-pty-connection-lifecycle.ts new file mode 100644 index 00000000000..496961dff16 --- /dev/null +++ b/src/main/daemon/daemon-pty-connection-lifecycle.ts @@ -0,0 +1,156 @@ +import { readFile } from 'node:fs/promises' +import { + classifyDaemonAuditFailure, + type DaemonAuditObservation, + type DaemonAuditTrigger +} from './daemon-audit-classifier' +import { + exactDaemonIncarnationForPidRecord, + sameEndpointIdentity +} from './daemon-endpoint-incarnation' +import type { DaemonEndpointIdentity } from './daemon-hello-protocol' +import type { DaemonEvidenceSource, ExactDaemonIncarnation } from './daemon-incarnation-evidence' +import { notifyDaemonAuditListeners } from './daemon-listener-registry' +import { DaemonPtyEventSubscriptions } from './daemon-pty-event-subscriptions' +import { parseDaemonPidFile, type ParsedDaemonPid } from './daemon-pid-file-parse' + +export abstract class DaemonPtyConnectionLifecycle extends DaemonPtyEventSubscriptions { + protected async ensureConnected(deadlineMs?: number): Promise { + try { + // Why: destructive teardown bounds the handshake by its deadline so a wedged + // connect fails fast; undefined keeps the default connect behavior. + await (deadlineMs !== undefined + ? this.client.ensureConnectedWithin(Math.max(1, deadlineMs - Date.now())) + : this.client.ensureConnected()) + } finally { + // Why: a respawn launcher holds a temporary pair until this adapter's permanent reconnect, preventing both gaps and leaks. + this.releasePendingRespawnAdoptionLease() + } + this.recordAuthenticatedIdentity() + // Why sampled before setupEventRouting: "no listener yet" identifies a fresh connect — the only time the + // daemon-side backgrounded set (process state lost with the old daemon) needs a resync. + const isFreshConnection = this.removeEventListener === null + this.setupEventRouting() + this.scheduleCheckpointTimer() + this.flushOwedProducerResumes() + if (isFreshConnection) { + this.resyncBackgroundedSessions() + } + } + + protected recordAuthenticatedIdentity(): void { + const current = this.client.getDaemonIdentity() + if (!current) { + return + } + const previous = this.lastAuthenticatedIdentity + if (previous && sameEndpointIdentity(previous, current)) { + return + } + this.lastAuthenticatedIdentity = { ...current } + this.exactDaemonIncarnation = exactDaemonIncarnationForPidRecord(current, this.pidRecord) + if (!previous) { + return + } + const event = { previous: { ...previous }, current: { ...current } } + notifyDaemonAuditListeners(this.identityChangeListeners, event) + } + + protected async resolveExactDaemonIncarnation( + exactIncarnation: ExactDaemonIncarnation | null + ): Promise { + if ( + !exactIncarnation || + process.platform !== 'linux' || + (exactIncarnation.linuxStartTicks && exactIncarnation.bootId) + ) { + return exactIncarnation + } + const cachedIncarnation = exactDaemonIncarnationForPidRecord( + exactIncarnation.identity, + this.pidRecord + ) + if (cachedIncarnation.linuxStartTicks && cachedIncarnation.bootId) { + return cachedIncarnation + } + const pidRecord = await this.readMatchingPidRecord(exactIncarnation.identity) + this.pidRecord = pidRecord ?? this.pidRecord + return pidRecord?.linuxStartTicks && pidRecord.bootId + ? { + identity: { ...exactIncarnation.identity }, + linuxStartTicks: pidRecord.linuxStartTicks, + bootId: pidRecord.bootId + } + : exactIncarnation + } + + protected cacheExactDaemonIncarnation(exactIncarnation: ExactDaemonIncarnation | null): void { + if ( + exactIncarnation && + this.lastAuthenticatedIdentity && + sameEndpointIdentity(exactIncarnation.identity, this.lastAuthenticatedIdentity) + ) { + this.exactDaemonIncarnation = exactIncarnation + } + } + + protected async readMatchingPidRecord( + identity: DaemonEndpointIdentity + ): Promise { + if (!this.pidPath) { + return null + } + try { + const parsed = parseDaemonPidFile(await readFile(this.pidPath, 'utf8')) + return parsed?.pid === identity.pid && + parsed.startedAtMs === identity.startedAtMs && + parsed.launchNonce === identity.launchNonce + ? parsed + : null + } catch { + return null + } + } + + protected observeAuditFailure( + trigger: Exclude, + exactIncarnation = this.exactDaemonIncarnation, + additionalEvidenceSources: readonly DaemonEvidenceSource[] = [], + endpointGoneProof?: 'windows_named_pipe_missing' + ): void { + void this.resolveExactDaemonIncarnation(exactIncarnation) + .then((resolvedIncarnation) => { + this.cacheExactDaemonIncarnation(resolvedIncarnation) + return classifyDaemonAuditFailure(this.auditContext, trigger, resolvedIncarnation, { + additionalEvidenceSources, + endpointGoneProof + }) + }) + .then((observation) => this.publishAuditObservation(observation)) + .catch(() => {}) + } + + protected publishAuditObservation(observation: DaemonAuditObservation): void { + this.lastAuditObservation = observation + this.trackAuditEligibility(observation) + notifyDaemonAuditListeners(this.auditObservationListeners, observation) + } + + protected resyncBackgroundedSessions(): void { + for (const id of this.backgroundedSessionIds) { + // Harmless no-op for sessions the daemon doesn't know (yet). + this.client.notify('setSessionBackground', { sessionId: id, background: true }) + } + } + + protected flushOwedProducerResumes(): void { + if (this.producerResumesOwedOnReconnect.size === 0) { + return + } + for (const id of this.producerResumesOwedOnReconnect) { + // Why: resuming an unknown session is a harmless no-op; leaving a survivor paused would waste 5s of failsafe latency. + this.client.notify('resumePty', { sessionId: id }) + } + this.producerResumesOwedOnReconnect.clear() + } +} diff --git a/src/main/daemon/daemon-pty-daemon-recovery.ts b/src/main/daemon/daemon-pty-daemon-recovery.ts new file mode 100644 index 00000000000..73ef2591be6 --- /dev/null +++ b/src/main/daemon/daemon-pty-daemon-recovery.ts @@ -0,0 +1,290 @@ +import { existsSync } from 'node:fs' +import { getMacDaemonSystemResolverHealth } from './daemon-health' +import { getMacDaemonTccAttributionHealth } from './daemon-tcc-attribution' +import { isDaemonStaleForCurrentBundle } from './daemon-bundle-staleness' +import { isDaemonGoneError } from './daemon-endpoint-errors' +import { DaemonPtyCheckpointPersistence } from './daemon-pty-checkpoint-persistence' +import type { DaemonRespawnReason } from './daemon-pty-runtime-state' +import type { ListSessionsResult } from './types' +import type { PtyBackgroundStreamEvent } from '../providers/types' + +export abstract class DaemonPtyDaemonRecovery extends DaemonPtyCheckpointPersistence { + // Why: the token read no longer throws, so audit its absence directly after an authenticated drop. + protected isRetiredEndpointTokenMissing(): boolean { + return this.client.hasObservedAuthenticatedDisconnect() && !existsSync(this.tokenPath) + } + + protected async withDaemonRetry(fn: () => Promise): Promise { + try { + return await fn() + } catch (err) { + const missingRetiredEndpointToken = this.isRetiredEndpointTokenMissing() + if (missingRetiredEndpointToken) { + this.observeAuditFailure( + 'token_missing_after_authenticated_disconnect', + this.exactDaemonIncarnation, + ['token_file'] + ) + } + if (this.respawnAdoptionClosed || !this.respawnFn || !isDaemonGoneError(err)) { + throw err + } + if (!this.respawnPromise) { + this.respawnPromise = this.doRespawn().finally(() => { + this.respawnPromise = null + }) + } + await this.respawnPromise + try { + return await fn() + } finally { + // Why: the retried op may reject before any connection attempt (e.g. a tombstone racing respawn). + this.releasePendingRespawnAdoptionLease() + } + } + } + + protected reconnectAfterWriteFailure(): void { + if ( + this.writeRecoveryPromise || + this.writeRecoveryAttempted || + this.respawnAdoptionClosed || + !this.respawnFn + ) { + return + } + this.writeRecoveryAttempted = true + // Why: the dead endpoint took down every session on this daemon. Signal all + // active panes now — while they are still in activeSessionIds, so the + // renderer's liveness gate still reads them live — so background panes + // remount + re-attach alongside the one that was written, instead of being + // left frozen with silently dropped input until each is typed into. + this.notifyActiveSessionsWriteUnavailable() + const recovery = this.withDaemonRetry(() => this.ensureConnected()) + .catch((error) => console.warn('[daemon] Failed to recover after rejected PTY input:', error)) + .finally(() => { + this.releasePendingRespawnAdoptionLease() + if (this.writeRecoveryPromise === recovery) { + this.writeRecoveryPromise = null + } + }) + this.writeRecoveryPromise = recovery + } + + protected notifyActiveSessionsWriteUnavailable(): void { + // Snapshot first: a listener that kills a pane would mutate activeSessionIds + // mid-iteration and silently skip the sibling this fan-out exists to reach. + const ids = [...this.activeSessionIds] + for (const id of ids) { + this.sessionsAwaitingDaemonRecovery.add(id) + this.emitWriteUnavailable(id) + } + } + + protected clearSessionAwaitingDaemonRecovery(sessionId: string): void { + this.sessionsAwaitingDaemonRecovery.delete(sessionId) + if (this.sessionsAwaitingDaemonRecovery.size === 0) { + this.writeRecoveryAttempted = false + } + } + + protected async withHistorySpawnLock( + sessionId: string, + operation: () => Promise + ): Promise { + if (!this.historyManager) { + return await operation() + } + const previous = this.historySpawnLocks.get(sessionId) ?? Promise.resolve() + let release!: () => void + const current = new Promise((resolve) => { + release = resolve + }) + const tail = previous.then( + () => current, + () => current + ) + this.historySpawnLocks.set(sessionId, tail) + await previous.catch(() => {}) + try { + return await operation() + } finally { + release() + if (this.historySpawnLocks.get(sessionId) === tail) { + this.historySpawnLocks.delete(sessionId) + } + } + } + + protected async replaceUnhealthyMacResolverDaemonBeforeNewPty(): Promise { + if (!this.respawnFn) { + return + } + + const health = await getMacDaemonSystemResolverHealth( + this.socketPath, + this.tokenPath, + this.protocolVersion + ) + if (health !== 'unhealthy') { + return + } + + const daemonLiveSessionCount = await this.getDaemonLiveSessionCount() + const liveSessionCount = Math.max(this.activeSessionIds.size, daemonLiveSessionCount ?? 0) + if (daemonLiveSessionCount === null || liveSessionCount > 0) { + console.warn( + daemonLiveSessionCount === null + ? '[daemon] macOS system resolver unavailable - preserving daemon because live session state could not be verified' + : `[daemon] macOS system resolver unavailable - preserving daemon because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}` + ) + return + } + + // Why: replacing the daemon kills its sessions without exit fanout; emit exits first so panes don't write to dead PTYs. + this.fanoutSyntheticExits(-1) + if (!this.respawnPromise) { + this.respawnPromise = this.doRespawn( + '[daemon] macOS system resolver unavailable - respawning daemon', + 'unhealthy_resolver' + ).finally(() => { + this.respawnPromise = null + }) + } + await this.respawnPromise + } + + /** Replace a stale packaged daemon only after its live sessions drain. */ + protected async replaceStaleBundleDaemonBeforeNewPty(): Promise { + if (!this.respawnFn || !this.runtimeDir || !this.packagedAppVersion) { + return + } + if (!this.staleBundleReplacementPromise) { + this.staleBundleReplacementPromise = this.replaceStaleBundleDaemonOnce( + this.runtimeDir, + this.packagedAppVersion + ).finally(() => { + this.staleBundleReplacementPromise = null + }) + } + await this.staleBundleReplacementPromise + } + + protected async replaceStaleBundleDaemonOnce( + runtimeDir: string, + packagedAppVersion: string + ): Promise { + const stale = await isDaemonStaleForCurrentBundle( + runtimeDir, + this.socketPath, + this.tokenPath, + packagedAppVersion, + this.protocolVersion + ) + if (!stale) { + return + } + + const daemonLiveSessionCount = await this.getDaemonLiveSessionCount() + const liveSessionCount = Math.max(this.activeSessionIds.size, daemonLiveSessionCount ?? 0) + if (daemonLiveSessionCount === null || liveSessionCount > 0) { + console.warn( + daemonLiveSessionCount === null + ? '[daemon] Packaged daemon is stale - preserving it because live session state could not be verified' + : `[daemon] Packaged daemon is stale - preserving it because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}` + ) + return + } + + this.fanoutSyntheticExits(-1) + if (!this.respawnPromise) { + this.respawnPromise = this.doRespawn( + '[daemon] Packaged daemon is stale - respawning from the current app bundle', + 'stale_bundle' + ).finally(() => { + this.respawnPromise = null + }) + } + await this.respawnPromise + } + + /** Replace a TCC-severed daemon only after its live sessions drain. */ + protected async replaceSeveredMacTccDaemonBeforeNewPty(): Promise { + // Why no platform gate: getMacDaemonTccAttributionHealth returns 'unknown' off macOS. + if (!this.respawnFn || !this.runtimeDir) { + return + } + + const health = await getMacDaemonTccAttributionHealth( + this.runtimeDir, + this.socketPath, + this.tokenPath, + this.protocolVersion + ) + if (health !== 'severed') { + return + } + + const daemonLiveSessionCount = await this.getDaemonLiveSessionCount() + const liveSessionCount = Math.max(this.activeSessionIds.size, daemonLiveSessionCount ?? 0) + if (daemonLiveSessionCount === null || liveSessionCount > 0) { + console.warn( + daemonLiveSessionCount === null + ? '[daemon] macOS TCC attribution severed - preserving daemon because live session state could not be verified' + : `[daemon] macOS TCC attribution severed - preserving daemon because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}; restart from Manage Sessions when ready` + ) + return + } + + this.fanoutSyntheticExits(-1) + if (!this.respawnPromise) { + this.respawnPromise = this.doRespawn( + '[daemon] macOS TCC attribution severed - respawning daemon under the current app binary', + 'severed_tcc_attribution' + ).finally(() => { + this.respawnPromise = null + }) + } + await this.respawnPromise + } + + protected async getDaemonLiveSessionCount(): Promise { + try { + await this.client.ensureConnected() + const result = await this.client.request('listSessions', undefined) + return result.sessions.filter((session) => session.isAlive).length + } catch { + return null + } + } + + protected emitBackgroundStreamEvent(payload: PtyBackgroundStreamEvent): void { + // oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration + for (const listener of [...this.backgroundStreamListeners]) { + listener(payload) + } + } + + protected async doRespawn( + message = '[daemon] Daemon died — respawning', + reason: DaemonRespawnReason = 'daemon_died' + ): Promise { + console.warn(message) + this.removeEventListener?.() + this.removeEventListener = null + this.client.disconnect() + const releaseAdoptionLease = await this.respawnFn!(reason) + if (this.respawnAdoptionClosed) { + // Why: app teardown may win mid-respawn; a late result must not reinstall a lease nobody owns. + releaseAdoptionLease?.() + throw new Error('Daemon adapter closed during respawn') + } + this.pendingRespawnAdoptionRelease = releaseAdoptionLease ?? null + } + + protected releasePendingRespawnAdoptionLease(): void { + const release = this.pendingRespawnAdoptionRelease + this.pendingRespawnAdoptionRelease = null + release?.() + } +} diff --git a/src/main/daemon/daemon-pty-event-subscriptions.ts b/src/main/daemon/daemon-pty-event-subscriptions.ts new file mode 100644 index 00000000000..20929def1ba --- /dev/null +++ b/src/main/daemon/daemon-pty-event-subscriptions.ts @@ -0,0 +1,157 @@ +import type { PtyIncarnationId } from '../../shared/pty-incarnation' +import { DaemonPtySessionInventory } from './daemon-pty-session-inventory' +import { CLEAN_DISCONNECT_PROTOCOL_VERSION } from './types' +import type { PtyBackgroundStreamEvent } from '../providers/types' + +export abstract class DaemonPtyEventSubscriptions extends DaemonPtySessionInventory { + onData( + callback: (payload: { + id: string + data: string + sequenceChars?: number + transformed?: boolean + seq?: number + }) => void + ): () => void { + this.dataListeners.push(callback) + return () => { + const idx = this.dataListeners.indexOf(callback) + if (idx !== -1) { + this.dataListeners.splice(idx, 1) + } + } + } + + onBackgroundStreamEvent(callback: (payload: PtyBackgroundStreamEvent) => void): () => void { + this.backgroundStreamListeners.push(callback) + return () => { + const idx = this.backgroundStreamListeners.indexOf(callback) + if (idx !== -1) { + this.backgroundStreamListeners.splice(idx, 1) + } + } + } + + onReplay(_callback: (payload: { id: string; data: string }) => void): () => void { + return () => {} + } + + onExit( + callback: (payload: { id: string; code: number; incarnationId?: PtyIncarnationId }) => void + ): () => void { + this.exitListeners.push(callback) + return () => { + const idx = this.exitListeners.indexOf(callback) + if (idx !== -1) { + this.exitListeners.splice(idx, 1) + } + } + } + + onWriteUnavailable(callback: (payload: { id: string }) => void): () => void { + this.writeUnavailableListeners.push(callback) + return () => { + const idx = this.writeUnavailableListeners.indexOf(callback) + if (idx !== -1) { + this.writeUnavailableListeners.splice(idx, 1) + } + } + } + + protected emitWriteUnavailable(id: string): void { + // oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration + for (const listener of [...this.writeUnavailableListeners]) { + listener({ id }) + } + } + + dispose(): void { + this.respawnAdoptionClosed = true + this.sessionsAwaitingDaemonRecovery.clear() + this.writeRecoveryAttempted = false + this.releasePendingRespawnAdoptionLease() + this.stopCheckpointTimer() + this.dirtySessionVersions.clear() + this.lastFullCheckpointAt.clear() + this.sessionsNeedingFullCheckpoint.clear() + this.sessionsNeedingLiveCheckpoint.clear() + this.sessionsNeedingContinuityCheckpoint.clear() + this.overlayDeadlineWarnedSessionIds.clear() + this.periodicDeadlineWarnedSessionIds.clear() + this.nonFinalAdmissionDeniedSessionIds.clear() + this.coldRestoreCache.clear() + this.wslDistrosBySessionId.clear() + this.pausedProducerSessionIds.clear() + this.producerResumesOwedOnReconnect.clear() + this.auditObservationListeners.length = 0 + this.identityChangeListeners.length = 0 + this.removeEventListener?.() + this.removeEventListener = null + // Why: final checkpoints are written daemon-side (TerminalHost.dispose); here the adapter only marks sessions + // cleanly ended so they don't trigger false cold restores. + if (this.historyManager) { + void this.historyManager + .dispose() + .catch((err) => console.warn('[history] dispose failed:', err)) + } + this.client.disconnect() + } + + async establishLifecycleLease(): Promise { + if (this.protocolVersion < CLEAN_DISCONNECT_PROTOCOL_VERSION) { + return + } + // Why: an authenticated pair cancels the adoption watchdog and lets a never-used adapter retire its empty daemon on quit. + await this.client.ensureConnected() + this.recordAuthenticatedIdentity() + } + + // Why: unlike dispose(), leave history files unclean (no endedAt) so the next launch treats them as crash-recoverable, + // but still write a final checkpoint so a daemon crash while Orca is closed has recovery data. + async disconnectOnly(): Promise { + if (!this.disconnectOnlyPromise) { + this.respawnAdoptionClosed = true + this.sessionsAwaitingDaemonRecovery.clear() + this.writeRecoveryAttempted = false + this.releasePendingRespawnAdoptionLease() + this.disconnectOnlyPromise = this.finishDisconnectOnly([...this.keepHistoryShutdowns]) + } + await this.disconnectOnlyPromise + } + + protected async finishDisconnectOnly(keepHistoryShutdowns: Promise[]): Promise { + // Why: sleep shutdowns still detect recovery and kill after checkpointing; disconnecting first rejects those admitted operations. + await Promise.allSettled(keepHistoryShutdowns) + this.respawnAdoptionClosed = true + // Why: a final checkpoint covers sessions opened since the last tick (else cold restore finds nothing if the daemon + // later dies). Await it — fire-and-forget would race client.disconnect() and reject the pending getSnapshot RPCs. + await this.runExclusiveCheckpoint(() => this.checkpointAllSessions(), { + rescheduleDirty: false + }) + this.dirtySessionVersions.clear() + this.lastFullCheckpointAt.clear() + this.coldRestoreCache.clear() + this.wslDistrosBySessionId.clear() + // Why: the detached daemon keeps these PTYs alive for warm reattach; a leftover pause would stall shells for a failsafe window. + for (const id of this.pausedProducerSessionIds) { + this.client.notify('resumePty', { sessionId: id }) + } + this.pausedProducerSessionIds.clear() + this.producerResumesOwedOnReconnect.clear() + this.removeEventListener?.() + this.removeEventListener = null + if (this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION) { + try { + // Why: only the authenticated daemon can atomically prove it's empty; a shared budget keeps this off quit's critical path. + const deadlineMs = Date.now() + 250 + if (!this.client.isConnected()) { + await this.client.ensureConnectedWithin(Math.max(1, deadlineMs - Date.now())) + } + await this.client.request('shutdownIfIdle', undefined, Math.max(1, deadlineMs - Date.now())) + } catch { + // An unreachable daemon falls back to event-driven retirement once its auth sockets close and it proves itself empty. + } + } + this.client.disconnect() + } +} diff --git a/src/main/daemon/daemon-pty-lifecycle-errors.ts b/src/main/daemon/daemon-pty-lifecycle-errors.ts new file mode 100644 index 00000000000..d27a362a8b9 --- /dev/null +++ b/src/main/daemon/daemon-pty-lifecycle-errors.ts @@ -0,0 +1,13 @@ +export class FinalCheckpointWaitExpiredError extends Error { + constructor(sessionId: string) { + super(`Final history checkpoint did not settle within the teardown deadline: ${sessionId}`) + this.name = 'FinalCheckpointWaitExpiredError' + } +} + +export class TerminalKilledError extends Error { + constructor(sessionId: string) { + super(`Session "${sessionId}" was explicitly killed`) + this.name = 'TerminalKilledError' + } +} diff --git a/src/main/daemon/daemon-pty-process-inspection.ts b/src/main/daemon/daemon-pty-process-inspection.ts new file mode 100644 index 00000000000..d1dd3ad4e36 --- /dev/null +++ b/src/main/daemon/daemon-pty-process-inspection.ts @@ -0,0 +1,177 @@ +import { isShellProcess } from '../../shared/agent-detection' +import { DaemonPtyBufferSnapshots } from './daemon-pty-buffer-snapshots' +import { parsePtySessionId } from './pty-session-id' +import { + COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION, + GET_FOREGROUND_PROCESS_PROTOCOL_VERSION, + type ListSessionsResult, + type SessionInfo +} from './types' +import type { PtyProcessInspection } from '../providers/pty-process-inspection' + +export abstract class DaemonPtyProcessInspection extends DaemonPtyBufferSnapshots { + // Why: daemon-backed PTYs can host long-lived agents while detached; cleanup prompts must not treat them as idle shells. + protected hasChildProcessesFromForeground(foregroundProcess: string | null): boolean { + return foregroundProcess !== null && !isShellProcess(foregroundProcess) + } + + async hasChildProcesses(id: string): Promise { + if (this.protocolVersion < GET_FOREGROUND_PROCESS_PROTOCOL_VERSION) { + return true + } + return this.hasChildProcessesFromForeground(await this.getForegroundProcess(id)) + } + + async inspectProcess(id: string): Promise { + if (this.protocolVersion < GET_FOREGROUND_PROCESS_PROTOCOL_VERSION) { + return { foregroundProcess: null, hasChildProcesses: true, unavailable: true } + } + if (this.protocolVersion < COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION) { + // Why: pre-v27 daemons survive an in-place app update; compose the inspection client-side from the + // one call they do support instead of throwing, or completion detection stays dead until recreate. + // Requests directly (not via getForegroundProcess) so a dead socket still rejects rather than + // reading as an idle foreground and dispatching a false completion. + const { foregroundProcess } = await this.client.request<{ + foregroundProcess: string | null + }>('getForegroundProcess', { sessionId: id }) + return { + foregroundProcess, + hasChildProcesses: this.hasChildProcessesFromForeground(foregroundProcess) + } + } + return this.client.request<{ + foregroundProcess: string | null + hasChildProcesses: boolean + }>('inspectProcess', { sessionId: id }) + } + + async getForegroundProcess(id: string): Promise { + if (this.protocolVersion < GET_FOREGROUND_PROCESS_PROTOCOL_VERSION) { + return null + } + try { + const result = await this.client.request<{ foregroundProcess: string | null }>( + 'getForegroundProcess', + { sessionId: id } + ) + return result.foregroundProcess + } catch { + return null + } + } + + async confirmForegroundProcess(id: string): Promise { + try { + const result = await this.client.request<{ foregroundProcess: string | null }>( + 'confirmForegroundProcess', + { sessionId: id } + ) + return result.foregroundProcess + } catch { + return null + } + } + + async serialize(ids: string[]): Promise { + const sessions: Record = {} + for (const id of ids) { + sessions[id] = { initialCwd: this.initialCwds.get(id) } + } + return JSON.stringify(sessions) + } + + async revive(_state: string): Promise { + // Sessions already live in the daemon — no revival needed + } + + /** Called on app launch. Lists daemon sessions, kills orphans whose workspaceId + * no longer exists, and caches alive session IDs. + * + * IMPORTANT: a session id embeds the worktree's path at spawn time, so a renamed + * worktree keeps its old id. Callers MUST seed `validWorktreeIds` with each live + * worktree's `WorktreeMeta.priorWorktreeIds` or those sessions get reaped as false + * orphans. No production caller yet; wire the alias in when it gains one. */ + async reconcileOnStartup(validWorktreeIds: Set): Promise<{ + alive: string[] + killed: string[] + }> { + await this.ensureConnected() + const result = await this.client.request('listSessions', undefined) + + const alive: string[] = [] + const killed: string[] = [] + + for (const session of result.sessions) { + if (!session.isAlive) { + continue + } + // Why: an unminted session id (worktreeId === null) can't be tied to a live worktree, so it's treated as an orphan. + const { worktreeId } = parsePtySessionId(session.sessionId) + + if (worktreeId === null || !validWorktreeIds.has(worktreeId)) { + try { + await this.client.request('kill', { sessionId: session.sessionId }) + } catch { + /* already dead */ + } + killed.push(session.sessionId) + } else { + alive.push(session.sessionId) + // Why: track background sessions in the checkpoint set so disconnectOnly's final checkpoint doesn't leave stale recovery data. + this.activeSessionIds.add(session.sessionId) + await this.reconcileLiveSessionHistory(session).catch((err) => + console.warn('[history] live-session reconciliation failed:', session.sessionId, err) + ) + } + } + + return { alive, killed } + } + + protected async reconcileLiveSessionHistory(session: SessionInfo): Promise { + const historyManager = this.historyManager + const historyReader = this.historyReader + if (!historyManager || !historyReader) { + return + } + await this.withHistorySpawnLock(session.sessionId, async () => { + if (historyManager.hasWriter(session.sessionId)) { + return + } + const probe = historyReader.probeRestorableHistory(session.sessionId) + if (probe.status === 'unreadable') { + return + } + if (probe.status === 'none') { + await historyManager.openSession(session.sessionId, { + cwd: session.cwd ?? '', + cols: session.cols, + rows: session.rows + }) + } else { + const recoveryFreeze = await historyManager.freezeForRecovery(session.sessionId) + try { + const detection = await historyReader.detectColdRestoreState(session.sessionId, { + wslDistro: session.wslDistro ?? undefined + }) + if ( + detection.status === 'unreadable' || + (detection.status === 'restored' && detection.hasUnreadableRecovery) + ) { + historyManager.suspendSession(session.sessionId, recoveryFreeze) + return + } + historyManager.reopenSession(session.sessionId, recoveryFreeze) + } finally { + historyManager.abandonRecoveryFreeze(recoveryFreeze) + } + } + if (historyManager.hasWriter(session.sessionId)) { + this.sessionsNeedingFullCheckpoint.add(session.sessionId) + this.sessionsNeedingContinuityCheckpoint.add(session.sessionId) + this.lastFullCheckpointAt.delete(session.sessionId) + this.markSessionDirty(session.sessionId) + } + }) + } +} diff --git a/src/main/daemon/daemon-pty-provider-sequence.ts b/src/main/daemon/daemon-pty-provider-sequence.ts new file mode 100644 index 00000000000..fbc38ab9221 --- /dev/null +++ b/src/main/daemon/daemon-pty-provider-sequence.ts @@ -0,0 +1,13 @@ +import type { CreateOrAttachResult } from './types' +import type { PtySpawnResult } from '../providers/types' + +export function providerSequenceFromCreateOrAttach( + result: CreateOrAttachResult +): PtySpawnResult['providerSequence'] { + if (result.isNew) { + return { value: 0, generation: 'reset' } + } + return typeof result.snapshot?.outputSequence === 'number' + ? { value: result.snapshot.outputSequence, generation: 'continued' } + : undefined +} diff --git a/src/main/daemon/daemon-pty-runtime-state.ts b/src/main/daemon/daemon-pty-runtime-state.ts new file mode 100644 index 00000000000..897f744de82 --- /dev/null +++ b/src/main/daemon/daemon-pty-runtime-state.ts @@ -0,0 +1,319 @@ +import { DaemonClient } from './client' +import { createDaemonAuditEligibilityTracker } from './daemon-audit-eligibility-event' +import type { + DaemonAuditContext, + DaemonAuditObservation, + DaemonAuditTrigger +} from './daemon-audit-classifier' +import { CheckpointSessionQueue } from './daemon-checkpoint-session-queue' +import { SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION } from './daemon-protocol-version' +import type { DaemonEndpointIdentity } from './daemon-hello-protocol' +import type { DaemonEvidenceSource, ExactDaemonIncarnation } from './daemon-incarnation-evidence' +import { readDaemonPidRecord, sameEndpointIdentity } from './daemon-endpoint-incarnation' +import { removeDaemonListener } from './daemon-listener-registry' +import type { ParsedDaemonPid } from './daemon-pid-file-parse' +import { + AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, + AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, + GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION, + PROTOCOL_VERSION, + supportsMode2031UnsubscribeFact, + supportsPtyStartupIngress, + type ListSessionsResult, + type TakePendingOutputResult +} from './types' +import { ColdRestorePayloadCache } from './cold-restore-payload-cache' +import { + HistoryManager, + type HistoryCheckpointResult, + type HistoryRecoveryFreeze +} from './history-manager' +import { HistoryReader } from './history-reader' +import type { PtyBackgroundStreamEvent } from '../providers/types' +import type { PtyIncarnationId } from '../../shared/pty-incarnation' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { TerminalExitCause } from '../../shared/terminal-exit-cause' +import type { TerminalOwnerIdentity } from '../../shared/terminal-owner-identity' + +export type PendingDaemonSpawnOperation = { + exitsBySessionId: Map + ignoredExitIncarnationIds: Set + ignoreNextExit: boolean + acceptsUnroutedExit: boolean +} + +export type HistoryRecoveryContext = { + freeze: HistoryRecoveryFreeze | null + unreadableSessionId: string | null + identityChanged: boolean +} + +export type SnapshotCheckpointResult = { + checkpoint: HistoryCheckpointResult + snapshot: NonNullable | null +} + +export type DaemonPtyAdapterOptions = { + socketPath: string + tokenPath: string + pidPath?: string + profileScope?: string + protocolVersion?: number + historyPath?: string + runtimeDir?: string + packagedAppVersion?: string | null + respawn?: (reason: DaemonRespawnReason) => Promise void)> + executionHostId?: ExecutionHostId +} + +export type DaemonRespawnReason = + | 'daemon_died' + | 'unhealthy_resolver' + | 'stale_bundle' + | 'severed_tcc_attribution' + +export type DaemonIdentityChangeEvent = { + previous: DaemonEndpointIdentity + current: DaemonEndpointIdentity +} + +export abstract class DaemonPtyRuntimeState { + readonly protocolVersion: number + protected socketPath: string + protected tokenPath: string + protected pidPath: string | null + protected pidRecord: ParsedDaemonPid | null + protected client: DaemonClient + protected auditContext: DaemonAuditContext + protected lastAuthenticatedIdentity: DaemonEndpointIdentity | null = null + protected exactDaemonIncarnation: ExactDaemonIncarnation | null = null + protected lastAuditObservation: DaemonAuditObservation | null = null + protected readonly trackAuditEligibility = createDaemonAuditEligibilityTracker() + protected auditObservationListeners: ((observation: DaemonAuditObservation) => void)[] = [] + protected identityChangeListeners: ((event: DaemonIdentityChangeEvent) => void)[] = [] + protected historyManager: HistoryManager | null + protected historyReader: HistoryReader | null + protected respawnFn: DaemonPtyAdapterOptions['respawn'] | null + protected runtimeDir: string | null + protected packagedAppVersion: string | null + protected readonly executionHostId: ExecutionHostId + protected readonly ownerEndpointRef: string + protected pendingRespawnAdoptionRelease: (() => void) | null = null + protected respawnAdoptionClosed = false + protected respawnPromise: Promise | null = null + protected staleBundleReplacementPromise: Promise | null = null + protected writeRecoveryPromise: Promise | null = null + protected writeRecoveryAttempted = false + protected dataListeners: ((payload: { + id: string + data: string + sequenceChars?: number + transformed?: boolean + seq?: number + }) => void)[] = [] + protected exitListeners: ((payload: { + id: string + code: number + incarnationId?: PtyIncarnationId + cause?: TerminalExitCause + }) => void)[] = [] + protected backgroundStreamListeners: ((payload: PtyBackgroundStreamEvent) => void)[] = [] + protected writeUnavailableListeners: ((payload: { id: string }) => void)[] = [] + protected removeEventListener: (() => void) | null = null + protected initialCwds = new Map() + protected wslDistrosBySessionId = new Map() + protected killedSessionTombstones = new Map() + protected sleepRestoreSessionIds = new Set() + protected coldRestoreCache = new ColdRestorePayloadCache(undefined, (sessionId) => { + this.sleepRestoreSessionIds.delete(sessionId) + }) + protected activeSessionIds = new Set() + protected getSizeUnsupported = false + protected sessionsAwaitingDaemonRecovery = new Set() + protected sessionIncarnations = new Map() + protected sessionOwnerIdentities = new Map() + protected livenessInventoryInFlight: Promise | null = null + protected pendingSpawnOperationsBySessionId = new Map>() + protected pendingClaimSpawnOperations = new Set() + protected historySpawnLocks = new Map>() + protected dirtySessionVersions = new Map() + protected sessionsNeedingFullCheckpoint = new Set() + protected sessionsNeedingLiveCheckpoint = new Set() + protected sessionsNeedingContinuityCheckpoint = new Set() + protected checkpointTimer: ReturnType | null = null + protected checkpointInFlight: Promise | null = null + protected checkpointQueue = new CheckpointSessionQueue() + protected nonFinalCheckpointAdmissionSessionIds = new Set() + protected nonFinalAdmissionDeniedSessionIds = new Set() + protected nonFinalGlobalAdmissionWarningActive = false + protected overlayDeadlineWarnedSessionIds = new Set() + protected periodicDeadlineWarnedSessionIds = new Set() + protected keepHistoryShutdowns = new Set>() + protected disconnectOnlyPromise: Promise | null = null + protected supportsCheckpoints: boolean + protected supportsIncrementalCheckpoints: boolean + protected supportsProducerFlowControl: boolean + protected supportsAuthoritativeBufferSnapshots: boolean + protected supportsStartupIngress: boolean + protected pausedProducerSessionIds = new Set() + protected backgroundedSessionIds = new Set() + protected producerResumesOwedOnReconnect = new Set() + protected static CHECKPOINT_INTERVAL_MS = 5_000 + protected static PERIODIC_CHECKPOINT_DEADLINE_MS = 15_000 + protected static MAX_CONCURRENT_CHECKPOINTS = 4 + protected static FULL_CHECKPOINT_COOLDOWN_MS = 45_000 + protected lastFullCheckpointAt = new Map() + + protected abstract observeAuditFailure( + trigger: Exclude, + exactIncarnation?: ExactDaemonIncarnation | null, + additionalEvidenceSources?: readonly DaemonEvidenceSource[], + endpointGoneProof?: 'windows_named_pipe_missing' + ): void + + constructor(opts: DaemonPtyAdapterOptions) { + this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION + this.socketPath = opts.socketPath + this.tokenPath = opts.tokenPath + this.pidPath = opts.pidPath ?? null + this.pidRecord = readDaemonPidRecord(this.pidPath) + this.auditContext = { + protocolGeneration: this.protocolVersion, + provider: 'local-daemon', + endpoint: opts.socketPath, + tokenPath: opts.tokenPath, + endpointKind: process.platform === 'win32' ? 'windows-named-pipe' : 'unix-socket', + profileScope: opts.profileScope ?? '' + } + this.client = new DaemonClient({ + socketPath: opts.socketPath, + tokenPath: opts.tokenPath, + protocolVersion: opts.protocolVersion + }) + this.historyManager = opts.historyPath ? new HistoryManager(opts.historyPath) : null + this.historyReader = opts.historyPath ? new HistoryReader(opts.historyPath) : null + this.respawnFn = opts.respawn ?? null + this.runtimeDir = opts.runtimeDir ?? opts.profileScope ?? null + this.packagedAppVersion = opts.packagedAppVersion ?? null + this.executionHostId = opts.executionHostId ?? 'local' + this.ownerEndpointRef = `daemon-v${this.protocolVersion}` + this.supportsCheckpoints = this.protocolVersion >= 4 + this.supportsIncrementalCheckpoints = this.protocolVersion >= 13 + this.supportsProducerFlowControl = this.protocolVersion >= 19 + this.supportsAuthoritativeBufferSnapshots = + this.protocolVersion >= SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION + this.supportsStartupIngress = supportsPtyStartupIngress(this.protocolVersion) + this.client.onDisconnected(() => { + if (!this.respawnAdoptionClosed) { + this.writeRecoveryAttempted = false + for (const id of this.activeSessionIds) { + this.sessionsAwaitingDaemonRecovery.add(id) + } + } + for (const id of this.pausedProducerSessionIds) { + this.producerResumesOwedOnReconnect.add(id) + } + this.pausedProducerSessionIds.clear() + this.observeAuditFailure('transport_closed') + }) + } + + supportsGitCredentialGuardHost(): boolean { + return this.protocolVersion >= GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION + } + + canProvideAuthoritativeBufferSnapshot(_id: string): boolean { + return this.supportsAuthoritativeBufferSnapshots + } + + protected get canDelegateBackgroundToDaemon(): boolean { + return ( + this.supportsAuthoritativeBufferSnapshots && + supportsMode2031UnsubscribeFact(this.protocolVersion) + ) + } + + getHistoryManager(): HistoryManager | null { + return this.historyManager + } + + getLastAuthenticatedDaemonIdentity(): DaemonEndpointIdentity | null { + return this.lastAuthenticatedIdentity ? { ...this.lastAuthenticatedIdentity } : null + } + + protected ownerIdentity( + sessionIncarnationId?: PtyIncarnationId + ): TerminalOwnerIdentity | undefined { + const daemon = this.lastAuthenticatedIdentity + if (!daemon || !sessionIncarnationId) { + return undefined + } + return { + executionHostId: this.executionHostId, + ownerKind: 'daemon', + ownerIncarnationId: daemon.launchNonce, + sessionIncarnationId, + protocolVersion: this.protocolVersion, + endpointRef: this.ownerEndpointRef + } + } + + getTerminalOwnerIdentity(id: string): TerminalOwnerIdentity | null { + if (!this.hasExactSessionAuthority(id)) { + return null + } + return this.ownerIdentity(this.sessionIncarnations.get(id)) ?? null + } + + protected recordSessionAuthority(id: string, incarnationId?: PtyIncarnationId): void { + if (!incarnationId || !this.lastAuthenticatedIdentity) { + return + } + this.sessionIncarnations.set(id, incarnationId) + this.sessionOwnerIdentities.set(id, { ...this.lastAuthenticatedIdentity }) + } + + protected hasExactSessionAuthority( + id: string, + expectedIncarnationId?: PtyIncarnationId + ): boolean { + const sessionIncarnationId = this.sessionIncarnations.get(id) + const ownerIdentity = this.sessionOwnerIdentities.get(id) + const currentIdentity = this.lastAuthenticatedIdentity + return Boolean( + sessionIncarnationId && + ownerIdentity && + currentIdentity && + sameEndpointIdentity(ownerIdentity, currentIdentity) && + (expectedIncarnationId === undefined || expectedIncarnationId === sessionIncarnationId) + ) + } + + getLastAuditObservation(): DaemonAuditObservation | null { + return this.lastAuditObservation + } + + onDaemonIdentityChanged(listener: (event: DaemonIdentityChangeEvent) => void): () => void { + this.identityChangeListeners.push(listener) + return () => removeDaemonListener(this.identityChangeListeners, listener) + } + + onAuditEligibilityObservation( + listener: (observation: DaemonAuditObservation) => void + ): () => void { + this.auditObservationListeners.push(listener) + return () => removeDaemonListener(this.auditObservationListeners, listener) + } + + supportsAgentSessionClaims(): boolean { + return this.protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION + } + + providesAgentSessionOwnerListings(_ptyId: string): boolean { + return this.supportsAgentSessionClaims() + } + + supportsAgentSessionCreateOperations(): boolean { + return this.protocolVersion >= AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION + } +} diff --git a/src/main/daemon/daemon-pty-session-authority.ts b/src/main/daemon/daemon-pty-session-authority.ts new file mode 100644 index 00000000000..f482c315833 --- /dev/null +++ b/src/main/daemon/daemon-pty-session-authority.ts @@ -0,0 +1,105 @@ +import { isUnknownRequestTypeError } from './daemon-endpoint-errors' +import { sameEndpointIdentity } from './daemon-endpoint-incarnation' +import { GET_SIZE_PROTOCOL_VERSION } from './daemon-protocol-version' +import { DaemonPtySessionSpawn } from './daemon-pty-session-spawn' +import type { ListSessionsResult } from './types' +import type { PtyIncarnationId } from '../../shared/pty-incarnation' +import type { TerminalOwnerIdentity } from '../../shared/terminal-owner-identity' + +export const LIVENESS_PROBE_TIMEOUT_MS = 2_000 + +export abstract class DaemonPtySessionAuthority extends DaemonPtySessionSpawn { + private listSessionsForLiveness(): Promise { + if (this.livenessInventoryInFlight) { + return this.livenessInventoryInFlight + } + const request = this.client.request( + 'listSessions', + undefined, + LIVENESS_PROBE_TIMEOUT_MS + ) + this.livenessInventoryInFlight = request + void request.then( + () => { + if (this.livenessInventoryInFlight === request) { + this.livenessInventoryInFlight = null + } + }, + () => { + if (this.livenessInventoryInFlight === request) { + this.livenessInventoryInFlight = null + } + } + ) + return request + } + + async probePtyLiveness( + id: string, + expectedIncarnationId?: PtyIncarnationId, + expectedOwnerIdentity?: TerminalOwnerIdentity + ): Promise { + try { + await this.ensureConnected(Date.now() + LIVENESS_PROBE_TIMEOUT_MS) + const ownerIdentityAtProbe = this.lastAuthenticatedIdentity + ? { ...this.lastAuthenticatedIdentity } + : null + const canConfirmAbsence = + this.hasExactSessionAuthority(id, expectedIncarnationId) && + (expectedOwnerIdentity === undefined || + this.ownerIdentity(expectedIncarnationId ?? this.sessionIncarnations.get(id)) + ?.ownerIncarnationId === expectedOwnerIdentity.ownerIncarnationId) + if (expectedOwnerIdentity !== undefined && !canConfirmAbsence) { + return null + } + if ( + (expectedIncarnationId === undefined || canConfirmAbsence) && + !this.getSizeUnsupported && + this.protocolVersion >= GET_SIZE_PROTOCOL_VERSION + ) { + try { + const result = await this.client.request<{ size: { cols: number; rows: number } | null }>( + 'getSize', + { sessionId: id }, + LIVENESS_PROBE_TIMEOUT_MS + ) + if ( + !ownerIdentityAtProbe || + !this.lastAuthenticatedIdentity || + !sameEndpointIdentity(ownerIdentityAtProbe, this.lastAuthenticatedIdentity) + ) { + return null + } + return result.size !== null ? true : canConfirmAbsence ? false : null + } catch (error) { + if (!isUnknownRequestTypeError(error)) { + throw error + } + this.getSizeUnsupported = true + } + } + const { sessions } = await this.listSessionsForLiveness() + if ( + !ownerIdentityAtProbe || + !this.lastAuthenticatedIdentity || + !sameEndpointIdentity(ownerIdentityAtProbe, this.lastAuthenticatedIdentity) + ) { + return null + } + const liveSession = sessions.find((session) => session.sessionId === id && session.isAlive) + if (liveSession) { + this.recordSessionAuthority(id, liveSession.incarnationId) + if ( + expectedIncarnationId !== undefined && + liveSession.incarnationId !== expectedIncarnationId + ) { + return canConfirmAbsence ? false : null + } + return true + } + return canConfirmAbsence ? false : null + } catch { + return null + } + } +} diff --git a/src/main/daemon/daemon-pty-session-control.ts b/src/main/daemon/daemon-pty-session-control.ts new file mode 100644 index 00000000000..6d0481ca5f0 --- /dev/null +++ b/src/main/daemon/daemon-pty-session-control.ts @@ -0,0 +1,342 @@ +import type { ColdRestorePayload } from './cold-restore-payload-cache' +import { FinalCheckpointWaitExpiredError } from './daemon-pty-lifecycle-errors' +import { DaemonPtySessionAuthority } from './daemon-pty-session-authority' +import { providerSequenceFromCreateOrAttach } from './daemon-pty-provider-sequence' +import { remainingDaemonRequestTimeoutMs } from './daemon-request-deadline' +import type { ColdRestoreInfo } from './history-reader' +import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd' +import { SessionNotFoundError, type CreateOrAttachResult } from './types' +import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd' +import type { PtySpawnResult } from '../providers/types' +import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error' +export { LIVENESS_PROBE_TIMEOUT_MS } from './daemon-pty-session-authority' + +const MAX_TOMBSTONES = 1000 + +export abstract class DaemonPtySessionControl extends DaemonPtySessionAuthority { + async attach(id: string): Promise | void> { + await this.ensureConnected() + if (!this.canDelegateBackgroundToDaemon) { + this.setPtyBackgrounded(id, false) + } + + // Why size-first: attach must ride the session's own geometry — a fixed + // 80×24 here could resize a live agent's TUI — and a null size means the + // daemon cannot prove the session, so refuse rather than risk a create. + const size = await this.getAppliedSize(id) + if (!size) { + throw new SessionNotFoundError(id) + } + const result = await this.client.request('createOrAttach', { + sessionId: id, + cols: size.cols, + rows: size.rows, + attachOnly: true + }) + if (result.isNew) { + // Why: a pre-v31 daemon ignores attachOnly; retire its accidental spawn + // instead of publishing a fresh shell as an attach. + await this.client.request('kill', { sessionId: id, immediate: true }).catch((error) => { + // Why surface, not swallow: a failed retire leaves an untracked orphan shell. + console.warn('[daemon] attach-only retire of accidental legacy spawn failed', { + sessionId: id, + error + }) + }) + throw new SessionNotFoundError(id) + } + this.clearSessionAwaitingDaemonRecovery(id) + const providerSequence = providerSequenceFromCreateOrAttach(result) + return providerSequence ? { providerSequence } : undefined + } + + hasPty(id: string): boolean { + return this.activeSessionIds.has(id) + } + + write(id: string, data: string): boolean { + const recoverable = this.prepareWrite(id) + return this.finishWrite( + id, + this.client.notify('write', { + sessionId: id, + data, + ...(this.sessionIncarnations.get(id) + ? { expectedIncarnationId: this.sessionIncarnations.get(id) } + : {}) + }), + recoverable + ) + } + + async writeWithSettlement(id: string, data: string): Promise { + const recoverable = this.prepareWrite(id) + return this.finishWrite( + id, + await this.client.notifyWithSettlement('write', { + sessionId: id, + data, + ...(this.sessionIncarnations.get(id) + ? { expectedIncarnationId: this.sessionIncarnations.get(id) } + : {}) + }), + recoverable + ) + } + + protected prepareWrite(id: string): boolean { + this.markSessionDirty(id) + // Why recoverable and not just active: rejecting a write asks the pane to remount, + // which only helps if this endpoint can come back. A legacy adapter has no respawn, + // so its reattach fails and the pane rebuilds empty — losing scrollback the user + // could still read. Keep the pre-existing silent drop for those. + const recoverable = + this.activeSessionIds.has(id) && !this.respawnAdoptionClosed && Boolean(this.respawnFn) + if ( + recoverable && + (this.sessionsAwaitingDaemonRecovery.has(id) || !this.client.isConnected()) + ) { + this.sessionsAwaitingDaemonRecovery.add(id) + this.reconnectAfterWriteFailure() + throw new PtyWriteUnavailableError(`Daemon PTY "${id}" is awaiting recovery`) + } + return recoverable + } + + protected finishWrite(id: string, delivered: boolean, recoverable: boolean): boolean { + if (!delivered && recoverable) { + this.sessionsAwaitingDaemonRecovery.add(id) + this.reconnectAfterWriteFailure() + throw new PtyWriteUnavailableError(`Daemon PTY "${id}" is awaiting recovery`) + } + return delivered + } + + resize(id: string, cols: number, rows: number): void { + this.markSessionDirty(id) + this.client.notify('resize', { + sessionId: id, + cols, + rows, + ...(this.sessionIncarnations.get(id) + ? { expectedIncarnationId: this.sessionIncarnations.get(id) } + : {}) + }) + } + + pauseProducer(id: string): void { + if (!this.supportsProducerFlowControl) { + return + } + this.pausedProducerSessionIds.add(id) + this.client.notify('pausePty', { sessionId: id }) + } + + resumeProducer(id: string): void { + this.producerResumesOwedOnReconnect.delete(id) + if (!this.supportsProducerFlowControl) { + return + } + this.pausedProducerSessionIds.delete(id) + this.client.notify('resumePty', { sessionId: id }) + } + + // Why fire-and-forget (like pausePty): just a delivery hint for the daemon's keep-tail stream thinning. + setPtyBackgrounded(id: string, background: boolean): void { + if (!this.supportsProducerFlowControl) { + return + } + // Why: preserved daemons without a sequence-safe, faithful serializer cannot heal a thinned stream. + // Why also gate on 2031 (#9993): backgrounding is what hands transient-fact scan + // authority to the daemon. A pre-v29 daemon can announce a 2031 subscribe but never + // retract it, so a TUI exiting while hidden would strand the subscription and the + // next theme flip would inject CSI 997 into its replacement shell. Declining to + // background keeps main's scanner — which emits both facts — authoritative. + const safeBackground = this.canDelegateBackgroundToDaemon && background + if (safeBackground) { + this.backgroundedSessionIds.add(id) + } else { + this.backgroundedSessionIds.delete(id) + } + this.client.notify('setSessionBackground', { sessionId: id, background: safeBackground }) + } + + async shutdown( + id: string, + opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } + ): Promise { + if (opts.keepHistory && this.disconnectOnlyPromise) { + throw new Error('Cannot keep history after daemon disconnect has started') + } + const shutdown = this.withHistorySpawnLock(id, () => this.shutdownWithHistoryLock(id, opts)) + if (!opts.keepHistory) { + await shutdown + return + } + this.keepHistoryShutdowns.add(shutdown) + try { + await shutdown + } finally { + this.keepHistoryShutdowns.delete(shutdown) + } + } + + protected async shutdownWithHistoryLock( + id: string, + opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } + ): Promise { + // Why: shutdown can be the first lazy-client operation after restart; connect + // before killing so a healthy daemon session is not orphaned (#7742). Connect, + // the final-checkpoint wait, and kill all share the caller's one absolute + // deadline, so neither a wedged handshake nor a stalled history write can burn + // the whole teardown budget before the kill even starts. Only the waits are + // bounded — the checkpoint itself stays deadline-free and lossless (STA-4228). + await this.ensureConnected(opts.deadlineMs) + // Why: sleep/exact-stop kills the live PTY before the periodic checkpoint may run. + // Force a final snapshot so wake can restore the pane users left. + if (opts.keepHistory) { + const committed = await this.runExclusiveCheckpoint( + async () => { + await this.checkpointSessions([id], { final: true, teardown: true }) + }, + { callerDeadlineMs: opts.deadlineMs } + ) + // Why throw instead of killing anyway: the snapshot the caller asked us to prove is still + // being written. Killing here would race the wake-time restore source to disk, so report the + // pty unverified and leave it alive — worktree sleep declines to commit it and retries. + if (!committed) { + throw new FinalCheckpointWaitExpiredError(id) + } + const wslDistro = this.wslDistrosBySessionId.get(id) + const detection = await this.historyReader?.detectColdRestoreState(id, { wslDistro }) + const detected = detection?.status === 'restored' ? detection.restoreInfo : null + const restoreInfo = detected + ? { + ...detected, + cwd: + normalizeWslColdRestoreCwd({ + recoveredCwd: detected.cwd, + requestedCwd: this.initialCwds.get(id) ?? resolveSafePtyDefaultCwd(), + wslDistro + }) ?? '' + } + : null + const coldRestore = restoreInfo ? this.buildColdRestorePayload(restoreInfo) : null + if (coldRestore) { + this.coldRestoreCache.set(id, coldRestore) + if (this.coldRestoreCache.has(id)) { + this.sleepRestoreSessionIds.add(id) + } + // Why: physical exit must not mark intentional sleep as a clean end; the final checkpoint stays the wake-time recovery authority. + this.historyManager?.suspendSession(id) + } else if ( + detection?.status === 'unreadable' || + (detection?.status === 'restored' && detection.hasUnreadableRecovery) + ) { + this.historyManager?.suspendSession(id) + } + } + await this.client.request( + 'kill', + { sessionId: id, immediate: opts.immediate ?? false }, + remainingDaemonRequestTimeoutMs(opts.deadlineMs) + ) + this.activeSessionIds.delete(id) + this.sessionIncarnations.delete(id) + this.sessionOwnerIdentities.delete(id) + this.clearSessionAwaitingDaemonRecovery(id) + this.dirtySessionVersions.delete(id) + if (!opts.keepHistory) { + this.coldRestoreCache.delete(id) + this.sleepRestoreSessionIds.delete(id) + } + // Why: the !keepHistory path takes no final checkpoint, so clear sessionsNeedingFullCheckpoint here or it stays stranded (no-op under keepHistory). + this.sessionsNeedingFullCheckpoint.delete(id) + this.sessionsNeedingLiveCheckpoint.delete(id) + this.sessionsNeedingContinuityCheckpoint.delete(id) + this.overlayDeadlineWarnedSessionIds.delete(id) + this.periodicDeadlineWarnedSessionIds.delete(id) + this.nonFinalAdmissionDeniedSessionIds.delete(id) + this.lastFullCheckpointAt.delete(id) + this.stopCheckpointTimerIfIdle() + this.initialCwds.delete(id) + this.wslDistrosBySessionId.delete(id) + // Why: only remove history on explicit close; sleep also calls shutdown but wake needs the dir intact for cold restore (opts.keepHistory). + if (this.historyManager && !opts.keepHistory) { + await this.historyManager + .removeSession(id) + .catch((err) => console.warn('[history] removeSession failed:', id, err)) + } + + // Why: the tombstone rejects reattach to a user-killed session; sleep legitimately reattaches on wake, so skip it under keepHistory. + if (!opts.keepHistory) { + this.killedSessionTombstones.delete(id) + this.killedSessionTombstones.set(id, Date.now()) + if (this.killedSessionTombstones.size > MAX_TOMBSTONES) { + const oldest = this.killedSessionTombstones.keys().next().value + if (oldest) { + this.killedSessionTombstones.delete(oldest) + } + } + } + } + + ackColdRestore(sessionId: string): void { + this.coldRestoreCache.delete(sessionId) + this.sleepRestoreSessionIds.delete(sessionId) + } + + clearTombstone(sessionId: string): void { + this.killedSessionTombstones.delete(sessionId) + } + + protected buildColdRestorePayload(restoreInfo: ColdRestoreInfo): ColdRestorePayload | null { + // Why: alt-screen prefers normal scrollback, else snapshotAnsi alone — not rehydrate, which starts with \x1b[?1049h that POST_REPLAY_MODE_RESET won't undo — so a hibernated TUI's last frame isn't blank on wake. + const scrollback = restoreInfo.modes.alternateScreen + ? restoreInfo.scrollbackAnsi || restoreInfo.snapshotAnsi || null + : restoreInfo.rehydrateSequences + restoreInfo.snapshotAnsi + if (!scrollback) { + return null + } + return { + scrollback, + cwd: restoreInfo.cwd, + cols: restoreInfo.cols, + rows: restoreInfo.rows, + oscLinks: restoreInfo.oscLinks, + ...(restoreInfo.lastTitle ? { lastTitle: restoreInfo.lastTitle } : {}) + } + } + + async sendSignal(id: string, signal: string): Promise { + await this.client.request('signal', { sessionId: id, signal }) + } + + async getCwd(id: string): Promise { + try { + const result = await this.client.request<{ cwd: string | null }>('getCwd', { + sessionId: id + }) + return result.cwd ?? '' + } catch { + return '' + } + } + + async getInitialCwd(id: string): Promise { + return this.initialCwds.get(id) ?? '' + } + + // Why: resize() is fire-and-forget and can be dropped daemon-side; read the actually-applied size so the renderer can detect drift and re-assert. + async getAppliedSize(id: string): Promise<{ cols: number; rows: number } | null> { + try { + const result = await this.client.request<{ size: { cols: number; rows: number } | null }>( + 'getSize', + { sessionId: id } + ) + return result.size ?? null + } catch { + return null + } + } +} diff --git a/src/main/daemon/daemon-pty-session-inventory.ts b/src/main/daemon/daemon-pty-session-inventory.ts new file mode 100644 index 00000000000..2129121ed5a --- /dev/null +++ b/src/main/daemon/daemon-pty-session-inventory.ts @@ -0,0 +1,181 @@ +import { basename } from 'node:path' +import { existsSync } from 'node:fs' +import { + isAgentSessionOwnerBinding, + type AgentSessionOwnerBinding +} from '../../shared/agent-session-host-authority' +import { MAX_CLAIMED_AGENT_PTY_OWNER_ENTRIES } from '../../shared/claimed-agent-pty-owner' +import { cloneAgentSessionOwnerBinding } from '../../shared/claimed-agent-pty-owner-snapshot' +import { recordAuthenticatedInventory } from './daemon-audit-classifier' +import { isMissingWindowsNamedPipeError } from './daemon-endpoint-errors' +import { DaemonPtyProcessInspection } from './daemon-pty-process-inspection' +import { remainingDaemonRequestTimeoutMs } from './daemon-request-deadline' +import { parsePtySessionId } from './pty-session-id' +import type { ListSessionsResult, SessionInfo } from './types' +import { PtyProcessListAdmission } from '../providers/pty-process-list-admission' +import type { PtyProcessInfo } from '../providers/types' + +export abstract class DaemonPtySessionInventory extends DaemonPtyProcessInspection { + async listProcesses(opts?: { deadlineMs?: number }): Promise { + // Why: snapshotted before the request so ids spawned mid-flight can never + // be reconciled away below. + const preRequestActiveIds = new Set(this.activeSessionIds) + try { + // Why: connect + listSessions share the caller's one absolute deadline so a + // wedged handshake cannot burn the whole teardown budget before the list issues. + await this.ensureConnected(opts?.deadlineMs) + const result = await this.client.request( + 'listSessions', + undefined, + remainingDaemonRequestTimeoutMs(opts?.deadlineMs) + ) + const admission = new PtyProcessListAdmission() + const processes: PtyProcessInfo[] = [] + const aliveSessionIds = new Set() + for (const session of result.sessions) { + if (!session.isAlive) { + continue + } + aliveSessionIds.add(session.sessionId) + this.recordSessionAuthority(session.sessionId, session.incarnationId) + const { worktreeId } = parsePtySessionId(session.sessionId) + processes.push( + admission.admit({ + id: session.sessionId, + ...(session.incarnationId ? { incarnationId: session.incarnationId } : {}), + ...(this.ownerIdentity(session.incarnationId) + ? { ownerIdentity: this.ownerIdentity(session.incarnationId) } + : {}), + // Why: OSC 7 may not arrive before cleanup; spawn cwd is authoritative until the daemon reports a live cwd. + cwd: session.cwd ?? this.initialCwds.get(session.sessionId) ?? '', + title: 'shell', + ...(worktreeId ? { worktreeId } : {}), + ...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}), + ...(session.wslDistro !== undefined ? { wslDistro: session.wslDistro } : {}), + ...this.validatedAgentSessionOwners(session.agentSessionOwners) + }) + ) + } + // Why: hasPty reads activeSessionIds, and an exit missed while the socket + // was disconnected otherwise survives an authoritative inventory forever — + // defeating every absence proof built on the cache. + for (const id of preRequestActiveIds) { + if (!aliveSessionIds.has(id)) { + this.activeSessionIds.delete(id) + this.sessionIncarnations.delete(id) + this.sessionOwnerIdentities.delete(id) + } + } + this.publishAuditObservation( + recordAuthenticatedInventory(this.auditContext, this.exactDaemonIncarnation) + ) + return processes + } catch (error) { + const missingAuthenticatedToken = this.isRetiredEndpointTokenMissing() + const missingNamedPipe = isMissingWindowsNamedPipeError(error) + this.observeAuditFailure( + missingAuthenticatedToken + ? 'token_missing_after_authenticated_disconnect' + : 'inventory_failed', + this.exactDaemonIncarnation, + [ + ...(missingAuthenticatedToken ? (['token_file'] as const) : []), + ...(missingNamedPipe ? (['windows_named_pipe'] as const) : []) + ], + missingNamedPipe ? 'windows_named_pipe_missing' : undefined + ) + throw error + } + } + + protected validatedAgentSessionOwners( + owners: unknown + ): { agentSessionOwners: AgentSessionOwnerBinding[] } | Record { + if (owners === undefined) { + return {} + } + if ( + !Array.isArray(owners) || + owners.length > MAX_CLAIMED_AGENT_PTY_OWNER_ENTRIES || + !owners.every((owner) => isAgentSessionOwnerBinding(owner) && owner.phase === 'live') + ) { + throw new Error('agent_session_ownership_unknown') + } + return owners.length > 0 + ? { agentSessionOwners: owners.map(cloneAgentSessionOwnerBinding) } + : {} + } + + // Why: the Manage Sessions panel needs the full SessionInfo (pid, state, + // createdAt) per session for display; listProcesses drops that detail for + // the IPtyProvider contract. Keep both in parallel rather than widening + // the provider surface. + async listSessions(): Promise { + await this.ensureConnected() + const result = await this.client.request('listSessions', undefined) + return result.sessions + .filter((s) => s.isAlive) + .map((session) => ({ + ...session, + ...this.validatedAgentSessionOwners(session.agentSessionOwners) + })) + } + + getActiveSessionIds(): string[] { + return [...this.activeSessionIds] + } + + // Why: the daemon's kill-all-and-shutdown path suppresses onExit fanout (session.ts:246-252), so synthesize pty:exit + // for every live session before teardown or renderer panes black-hole writes to a disposed adapter forever. + fanoutSyntheticExits(code: number): void { + const ids = [...this.activeSessionIds] + this.activeSessionIds.clear() + this.sessionsAwaitingDaemonRecovery.clear() + this.writeRecoveryAttempted = false + this.dirtySessionVersions.clear() + this.lastFullCheckpointAt.clear() + this.sessionsNeedingFullCheckpoint.clear() + this.sessionsNeedingLiveCheckpoint.clear() + this.sessionsNeedingContinuityCheckpoint.clear() + this.overlayDeadlineWarnedSessionIds.clear() + this.periodicDeadlineWarnedSessionIds.clear() + this.nonFinalAdmissionDeniedSessionIds.clear() + this.pausedProducerSessionIds.clear() + this.producerResumesOwedOnReconnect.clear() + this.stopCheckpointTimer() + for (const id of ids) { + this.coldRestoreCache.delete(id) + // Why: don't catch listener throws — matches the natural onExit fanout so synthetic exits keep the same error semantics. + // oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration + for (const listener of [...this.exitListeners]) { + listener({ + id, + code, + ...(this.sessionIncarnations.get(id) + ? { incarnationId: this.sessionIncarnations.get(id) } + : {}) + }) + } + this.sessionIncarnations.delete(id) + this.sessionOwnerIdentities.delete(id) + } + } + + async getDefaultShell(): Promise { + if (process.platform === 'win32') { + return process.env.COMSPEC || 'powershell.exe' + } + return process.env.SHELL || '/bin/zsh' + } + + async getProfiles(): Promise<{ name: string; path: string }[]> { + if (process.platform === 'win32') { + return [ + { name: 'PowerShell', path: 'powershell.exe' }, + { name: 'Command Prompt', path: 'cmd.exe' } + ] + } + const shells = ['/bin/zsh', '/bin/bash', '/bin/sh'] + return shells.filter((s) => existsSync(s)).map((s) => ({ name: basename(s), path: s })) + } +} diff --git a/src/main/daemon/daemon-pty-session-spawn.ts b/src/main/daemon/daemon-pty-session-spawn.ts new file mode 100644 index 00000000000..2feb3281b40 --- /dev/null +++ b/src/main/daemon/daemon-pty-session-spawn.ts @@ -0,0 +1,282 @@ +import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' +import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery' +import type { + HistoryRecoveryContext, + PendingDaemonSpawnOperation +} from './daemon-pty-runtime-state' +import { STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION } from './daemon-protocol-version' +import { TerminalKilledError } from './daemon-pty-lifecycle-errors' +import { DaemonPtySpawnResult } from './daemon-pty-spawn-result' +import type { DaemonPtySpawnContext } from './daemon-pty-spawn-request' +import type { ColdRestoreInfo } from './history-reader' +import { mintPtySessionId } from './pty-session-id' +import { CODEX_SHELL_READY_TIMEOUT_MS } from './session-shell-ready-barrier' +import { supportsPtyStartupBarrier } from './shell-ready' +import { getRecoveredHistorySeedSegments } from './terminal-history-seed-segments' +import { AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, type CreateOrAttachResult } from './types' +import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd' +import { resolveWslSessionContext } from './wsl-session-context' +import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd' +import { resolveUnixShellPath } from '../providers/local-pty-utils' +import type { PtySpawnOptions, PtySpawnResult } from '../providers/types' +import { injectHistoryEnv, injectWslFishHistoryEnv, logHistoryInjection } from '../terminal-history' +import { addWslEnvKeys } from '../wsl-env' + +export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult { + async spawn(opts: PtySpawnOptions): Promise { + const spawnOpts = this.withHistoryIsolation(opts) + const sessionId = spawnOpts.sessionId ?? mintPtySessionId(spawnOpts.worktreeId) + const operation = { + exitsBySessionId: new Map(), + ignoredExitIncarnationIds: new Set(), + ignoreNextExit: false, + acceptsUnroutedExit: false + } + const operations = this.pendingSpawnOperationsBySessionId.get(sessionId) ?? new Set() + operations.add(operation) + this.pendingSpawnOperationsBySessionId.set(sessionId, operations) + if (opts.agentSessionEnsure) { + this.pendingClaimSpawnOperations.add(operation) + } + const historyRecovery: HistoryRecoveryContext = { + freeze: null, + unreadableSessionId: null, + identityChanged: false + } + try { + return await this.withHistorySpawnLock(sessionId, () => + this.withDaemonRetry(() => + this.doSpawn({ ...spawnOpts, sessionId }, operation, historyRecovery) + ) + ) + } finally { + if (historyRecovery.freeze) { + this.historyManager?.abandonRecoveryFreeze(historyRecovery.freeze) + } + this.pendingClaimSpawnOperations.delete(operation) + operations.delete(operation) + if (operations.size === 0) { + this.pendingSpawnOperationsBySessionId.delete(sessionId) + } + } + } + + protected withHistoryIsolation(opts: PtySpawnOptions): PtySpawnOptions { + const wslContext = resolveWslSessionContext({ + cwd: opts.cwd, + sessionId: opts.sessionId, + shellOverride: opts.shellOverride, + terminalWindowsWslDistro: opts.terminalWindowsWslDistro + }) + if ( + opts.attachOnly === true || + (opts.sessionId !== undefined && opts.isNewSession !== true) || + !opts.worktreeId || + opts.historyIsolationEnabled !== true || + (process.platform === 'win32' && !wslContext) + ) { + return opts + } + const env = { ...opts.env } + const preferredShell = wslContext + ? 'bash' + : opts.shellOverride || env.SHELL || process.env.SHELL || '/bin/zsh' + const shellPath = resolveUnixShellPath(preferredShell) + const historyArgs = [ + env, + opts.worktreeId, + shellPath, + opts.cwd ?? resolveSafePtyDefaultCwd() + ] as const + const result = wslContext + ? injectHistoryEnv(...historyArgs, { wslDistro: wslContext.distro }) + : injectHistoryEnv(...historyArgs) + if (wslContext) { + injectWslFishHistoryEnv(env, opts.worktreeId, wslContext.distro) + addWslEnvKeys(env, ['HISTFILE', 'fish_history']) + } + logHistoryInjection(opts.worktreeId, result) + return { ...opts, env } + } + + protected async doSpawn( + opts: PtySpawnOptions, + operation: PendingDaemonSpawnOperation, + historyRecovery: HistoryRecoveryContext + ): Promise { + if ( + opts.agentSessionEnsure && + this.protocolVersion < AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION + ) { + throw new Error('agent_session_claim_unavailable') + } + const requestedSessionId = opts.sessionId! + // Why: v30 daemons survive upgrades; reject their accidental create result before publication. + const attachOnly = opts.attachOnly === true + const emulateLegacyAttachOnly = + attachOnly && this.protocolVersion < STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION + let sessionId = requestedSessionId + let wslDistro = resolveWslSessionContext({ + cwd: opts.cwd, + sessionId, + shellOverride: opts.shellOverride, + terminalWindowsWslDistro: opts.terminalWindowsWslDistro + })?.distro + let activeSpawnContext: DaemonPtySpawnContext | null = null + const freezeHistory = async (): Promise => { + if (!this.historyManager) { + return + } + const recoverySessionId = activeSpawnContext?.sessionId ?? sessionId + if (historyRecovery.freeze?.sessionId === recoverySessionId) { + return + } + if (historyRecovery.freeze) { + this.historyManager.abandonRecoveryFreeze(historyRecovery.freeze) + } + historyRecovery.freeze = await this.historyManager.freezeForRecovery(recoverySessionId) + historyRecovery.unreadableSessionId = null + } + const detectColdRestore = async (options?: { + ignoreCleanEnd?: boolean + }): Promise => { + if (!this.historyReader) { + return null + } + await freezeHistory() + const recoverySessionId = activeSpawnContext?.sessionId ?? sessionId + const recoveryWslDistro = activeSpawnContext?.wslDistro ?? wslDistro + const detection = await this.historyReader.detectColdRestoreState(recoverySessionId, { + ...options, + wslDistro: recoveryWslDistro + }) + if (detection.status === 'unreadable') { + historyRecovery.unreadableSessionId = detection.sessionId + return null + } + const restoreInfo = detection.status === 'restored' ? detection.restoreInfo : null + if (detection.status === 'restored' && detection.hasUnreadableRecovery) { + historyRecovery.unreadableSessionId = detection.sessionId + } + if (!restoreInfo) { + return null + } + return { + ...restoreInfo, + cwd: + normalizeWslColdRestoreCwd({ + recoveredCwd: restoreInfo.cwd, + requestedCwd: opts.cwd ?? resolveSafePtyDefaultCwd(), + wslDistro: recoveryWslDistro + }) ?? '' + } + } + + if (this.killedSessionTombstones.has(sessionId)) { + throw new TerminalKilledError(sessionId) + } + + if (opts.isNewSession) { + await this.replaceUnhealthyMacResolverDaemonBeforeNewPty() + await this.replaceStaleBundleDaemonBeforeNewPty() + await this.replaceSeveredMacTccDaemonBeforeNewPty() + } + + await this.ensureConnected() + // Why before createOrAttach: a preserved daemon may still think this session is backgrounded — from + // a v19 that thins without a recoverable seq, or (#9993) from a pre-v29 that a previous desktop + // handed 2031 scan authority to and can never retract it. Clear it before any bytes are attached. + if (!this.canDelegateBackgroundToDaemon) { + this.setPtyBackgrounded(sessionId, false) + } + + // Why detect crash-recovery history before spawning: the revived shell should inherit the recovered cwd/dims, not the renderer's mount-time request. + // Why probe aliveness first: detectColdRestore replays up to ~5MB on the main process, but a live session's snapshot supersedes disk, so the replay would be wasted. + let restoreInfo: ColdRestoreInfo | null = null + let restoreSkippedForLiveSession = false + const historyProbe = opts.attachOnly + ? undefined + : this.historyReader?.probeRestorableHistory(sessionId) + if (historyProbe && historyProbe.status !== 'none') { + if ((await this.getAppliedSize(sessionId)) !== null) { + restoreSkippedForLiveSession = true + if (this.historyManager && !this.historyManager.hasWriter(sessionId)) { + await detectColdRestore() + restoreInfo = null + } + } else { + restoreInfo = await detectColdRestore() + } + } + let effectiveCwd = restoreInfo?.cwd ?? opts.cwd + let effectiveCols = restoreInfo?.cols ?? opts.cols + let effectiveRows = restoreInfo?.rows ?? opts.rows + + const shellReadySupported = opts.command ? supportsPtyStartupBarrier(opts.env ?? {}) : false + const isCodexStartupCommand = + recognizeAgentProcessFromCommandLine(opts.command)?.agent === 'codex' + const shouldWaitForShellReady = + isCodexStartupCommand && + shouldUseShellReadyStartupDelivery({ + command: opts.command, + startupCommandDelivery: opts.startupCommandDelivery + }) + const shellReadyTimeoutMs = + shellReadySupported && isCodexStartupCommand && !shouldWaitForShellReady + ? CODEX_SHELL_READY_TIMEOUT_MS + : undefined + + const context: DaemonPtySpawnContext = { + opts, + operation, + historyRecovery, + requestedSessionId, + attachOnly, + emulateLegacyAttachOnly, + sessionId, + wslDistro, + restoreInfo, + restoreSkippedForLiveSession, + effectiveCwd, + effectiveCols, + effectiveRows, + shellReadySupported, + shellReadyTimeoutMs, + historySeedSegments: restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null, + detectColdRestore + } + activeSpawnContext = context + const result = await this.createOrAttachSpawn(context, context.historySeedSegments) + return this.finishSpawn(context, result) + } + + protected resultForExitBeforeSpawnReply( + sessionId: string, + result: CreateOrAttachResult, + operation: PendingDaemonSpawnOperation + ): PtySpawnResult | null { + const matchingExit = (operation.exitsBySessionId.get(sessionId) ?? []).some( + (exit) => + !(exit.incarnationId && operation.ignoredExitIncarnationIds.has(exit.incarnationId)) && + (!exit.incarnationId || + !result.incarnationId || + exit.incarnationId === result.incarnationId) + ) + if (!matchingExit) { + return null + } + // Why: stream exit can beat the control reply; return proof upward without republishing dead adapter state. + const exitedResult: PtySpawnResult = { + id: sessionId, + exitedBeforeSpawnReply: true, + ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), + ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}), + ...(!result.isNew ? { isReattach: true } : {}) + } + return exitedResult + } + + didExitBeforeSpawnReply(result: PtySpawnResult): boolean { + return result.exitedBeforeSpawnReply === true + } +} diff --git a/src/main/daemon/daemon-pty-spawn-identity-fields.ts b/src/main/daemon/daemon-pty-spawn-identity-fields.ts new file mode 100644 index 00000000000..f6ae3d72bb8 --- /dev/null +++ b/src/main/daemon/daemon-pty-spawn-identity-fields.ts @@ -0,0 +1,13 @@ +import type { TerminalOwnerIdentity } from '../../shared/terminal-owner-identity' +import type { PtySpawnResult } from '../providers/types' +import type { CreateOrAttachResult } from './types' + +export function daemonPtySpawnIdentityFields( + result: Pick, + ownerIdentity: TerminalOwnerIdentity | undefined +): Pick { + return { + ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), + ...(ownerIdentity ? { ownerIdentity } : {}) + } +} diff --git a/src/main/daemon/daemon-pty-spawn-preparations.ts b/src/main/daemon/daemon-pty-spawn-preparations.ts new file mode 100644 index 00000000000..633f0d1c7f5 --- /dev/null +++ b/src/main/daemon/daemon-pty-spawn-preparations.ts @@ -0,0 +1,118 @@ +import { TerminalAttachCanceledError } from './types' + +export type PendingPtySpawnPreparation = { + canceled: boolean + // Why: `canceled` is only polled between spawn steps; the signal is what + // actually interrupts an in-progress cwd probe on a dead share. + readonly controller: AbortController + cancelTimer?: ReturnType + // Why: preparations are keyed by sessionId, but a control-socket close must + // cancel only the disconnecting client's preps, not another client's (F4). + clientId: string + requestId: string +} + +export class DaemonPtySpawnPreparations { + private readonly pending = new Map>() + private readonly cancellationByPreparation = new WeakMap< + PendingPtySpawnPreparation, + { promise: Promise; resolve: () => void } + >() + + constructor(private readonly preparePtySpawn: () => Promise) {} + + register( + sessionId: string, + clientId: string, + requestId: string, + cancelAfterMs: unknown + ): PendingPtySpawnPreparation { + const preparation: PendingPtySpawnPreparation = { + canceled: false, + controller: new AbortController(), + clientId, + requestId + } + this.cancellationByPreparation.set(preparation, Promise.withResolvers()) + if (Number.isSafeInteger(cancelAfterMs) && Number(cancelAfterMs) > 0) { + preparation.cancelTimer = setTimeout( + () => this.cancelPreparation(preparation), + Math.min(Number(cancelAfterMs), 300_000) + ) + preparation.cancelTimer.unref() + } + const pendingForSession = this.pending.get(sessionId) ?? new Set() + pendingForSession.add(preparation) + this.pending.set(sessionId, pendingForSession) + return preparation + } + + async prepareUnlessCanceled( + sessionId: string, + preparation: PendingPtySpawnPreparation + ): Promise { + // Race cancellation against preflight so shutdown/disconnect can return the + // protocol cancellation error before tearing down the client transport. + const cancellation = this.cancellationByPreparation.get(preparation) + const preparationTask = cancellation + ? Promise.race([this.preparePtySpawn(), cancellation.promise]) + : this.preparePtySpawn() + await preparationTask + if (preparation.canceled) { + throw new TerminalAttachCanceledError(sessionId) + } + } + + finish(sessionId: string, preparation: PendingPtySpawnPreparation): void { + if (preparation.cancelTimer) { + clearTimeout(preparation.cancelTimer) + } + this.cancellationByPreparation.delete(preparation) + const pendingForSession = this.pending.get(sessionId) + pendingForSession?.delete(preparation) + if (pendingForSession?.size === 0) { + this.pending.delete(sessionId) + } + } + + cancel(sessionId: string, request?: { clientId: string; requestId?: string }): boolean { + const pendingForSession = this.pending.get(sessionId) + if (!pendingForSession) { + return false + } + let canceled = false + for (const preparation of pendingForSession) { + if ( + request && + (preparation.clientId !== request.clientId || + (request.requestId !== undefined && preparation.requestId !== request.requestId)) + ) { + continue + } + this.cancelPreparation(preparation) + canceled = true + } + return canceled + } + + cancelForClient(clientId: string): void { + for (const pendingForSession of this.pending.values()) { + for (const preparation of pendingForSession) { + if (preparation.clientId === clientId) { + this.cancelPreparation(preparation) + } + } + } + } + private cancelPreparation(preparation: PendingPtySpawnPreparation): void { + preparation.canceled = true + preparation.controller.abort() + this.cancellationByPreparation.get(preparation)?.resolve() + } + + cancelAll(): void { + for (const sessionId of this.pending.keys()) { + this.cancel(sessionId) + } + } +} diff --git a/src/main/daemon/daemon-pty-spawn-request.ts b/src/main/daemon/daemon-pty-spawn-request.ts new file mode 100644 index 00000000000..a012d20eafd --- /dev/null +++ b/src/main/daemon/daemon-pty-spawn-request.ts @@ -0,0 +1,212 @@ +import type { ColdRestorePayload } from './cold-restore-payload-cache' +import type { DaemonAuditObservation } from './daemon-audit-classifier' +import { + DaemonPtyRuntimeState, + type HistoryRecoveryContext, + type PendingDaemonSpawnOperation, + type SnapshotCheckpointResult +} from './daemon-pty-runtime-state' +import { isDaemonGoneError } from './daemon-endpoint-errors' +import { HISTORY_SEED_TRANSFER_PROTOCOL_VERSION } from './daemon-protocol-version' +import type { ColdRestoreInfo } from './history-reader' +import { NdjsonLineTooLongError } from './ndjson' +import { + iterateTerminalHistorySeedChunks, + measureTerminalHistorySeed, + TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS +} from './terminal-history-seed-chunks' +import type { CreateOrAttachResult, GetSnapshotResult } from './types' +import type { PtySpawnOptions, PtySpawnResult } from '../providers/types' + +export type DaemonPtySpawnContext = { + opts: PtySpawnOptions + operation: PendingDaemonSpawnOperation + historyRecovery: HistoryRecoveryContext + requestedSessionId: string + attachOnly: boolean + emulateLegacyAttachOnly: boolean + sessionId: string + wslDistro: string | undefined + restoreInfo: ColdRestoreInfo | null + restoreSkippedForLiveSession: boolean + effectiveCwd: string | undefined + effectiveCols: number + effectiveRows: number + shellReadySupported: boolean + shellReadyTimeoutMs: number | undefined + historySeedSegments: readonly string[] | null + detectColdRestore(options?: { ignoreCleanEnd?: boolean }): Promise +} + +export abstract class DaemonPtySpawnRequest extends DaemonPtyRuntimeState { + protected abstract takeSnapshotAndCheckpoint( + sessionId: string, + opts: { + teardown: boolean + forceLiveSnapshot?: boolean + requireContinuityProof?: boolean + } + ): Promise + protected abstract markSessionDirty(sessionId: string): void + protected abstract writeSessionCheckpoint( + sessionId: string, + opts: { final: boolean; teardown: boolean } + ): Promise<'done' | 'deferred'> + protected abstract releasePendingRespawnAdoptionLease(): void + protected abstract setupEventRouting(): void + protected abstract scheduleCheckpointTimer(): void + protected abstract stopCheckpointTimer(): void + protected abstract stopCheckpointTimerIfIdle(): void + protected abstract recordAuthenticatedIdentity(): void + protected abstract runExclusiveCheckpoint( + operation: () => Promise, + options?: { rescheduleDirty?: boolean; callerDeadlineMs?: number } + ): Promise + protected abstract checkpointAllSessions(): Promise + protected abstract ensureConnected(deadlineMs?: number): Promise + protected abstract withHistorySpawnLock( + sessionId: string, + operation: () => Promise + ): Promise + protected abstract clearSessionAwaitingDaemonRecovery(sessionId: string): void + protected abstract reconnectAfterWriteFailure(): void + protected abstract checkpointSessions( + sessionIds: Iterable, + opts?: { final?: boolean; teardown?: boolean } + ): Promise> + protected abstract publishAuditObservation(observation: DaemonAuditObservation): void + protected abstract isRetiredEndpointTokenMissing(): boolean + protected abstract withDaemonRetry(fn: () => Promise): Promise + protected abstract replaceUnhealthyMacResolverDaemonBeforeNewPty(): Promise + protected abstract replaceStaleBundleDaemonBeforeNewPty(): Promise + protected abstract replaceSeveredMacTccDaemonBeforeNewPty(): Promise + abstract setPtyBackgrounded(id: string, background: boolean): void + abstract getAppliedSize(id: string): Promise<{ cols: number; rows: number } | null> + protected abstract resultForExitBeforeSpawnReply( + sessionId: string, + result: CreateOrAttachResult, + operation: PendingDaemonSpawnOperation + ): PtySpawnResult | null + protected abstract buildColdRestorePayload( + restoreInfo: ColdRestoreInfo + ): ColdRestorePayload | null + protected abstract overlayDurableRestoreSnapshot( + sessionId: string, + liveSnapshot: NonNullable, + scrollbackRows?: number + ): Promise> + + protected async createOrAttachSpawn( + context: DaemonPtySpawnContext, + historySeedSegments: readonly string[] | null + ): Promise { + const requestCreateOrAttach = async ( + historySeed: string | undefined, + historySeedTransferId: string | undefined + ) => { + const { opts } = context + if (opts.signal?.aborted) { + throw new Error('client_disconnected') + } + const payload = { + sessionId: context.sessionId, + cols: context.effectiveCols, + rows: context.effectiveRows, + cwd: context.attachOnly ? undefined : context.effectiveCwd, + env: context.attachOnly ? undefined : opts.env, + envToDelete: context.attachOnly ? undefined : opts.envToDelete, + command: context.attachOnly ? undefined : opts.command, + startupCommandDelivery: context.attachOnly ? undefined : opts.startupCommandDelivery, + launchAgent: context.attachOnly ? undefined : opts.launchAgent, + ...(context.attachOnly && !context.emulateLegacyAttachOnly ? { attachOnly: true } : {}), + ...(context.attachOnly && + opts.expectedIncarnationIsAuthoritative === true && + opts.expectedIncarnationId !== undefined + ? { expectedIncarnationId: opts.expectedIncarnationId } + : {}), + shellOverride: context.attachOnly ? undefined : opts.shellOverride, + terminalWindowsWslDistro: context.attachOnly ? undefined : opts.terminalWindowsWslDistro, + terminalWindowsPowerShellImplementation: context.attachOnly + ? undefined + : opts.terminalWindowsPowerShellImplementation, + shellReadySupported: context.attachOnly ? false : context.shellReadySupported, + ...(!context.attachOnly && context.shellReadyTimeoutMs !== undefined + ? { shellReadyTimeoutMs: context.shellReadyTimeoutMs } + : {}), + ...(historySeed ? { historySeed } : {}), + ...(historySeedTransferId ? { historySeedTransferId } : {}), + ...(this.supportsStartupIngress && !context.attachOnly && opts.startupIngress + ? { startupIngress: opts.startupIngress } + : {}), + ...(!context.attachOnly && opts.agentSessionEnsure + ? { agentSessionEnsure: opts.agentSessionEnsure } + : {}) + } + context.operation.acceptsUnroutedExit = !context.attachOnly + try { + return await (opts.signal + ? this.client.request( + 'createOrAttach', + payload, + undefined, + opts.signal + ) + : this.client.request('createOrAttach', payload)) + } finally { + context.operation.acceptsUnroutedExit = false + } + } + + let historySeedUnavailable = false + const deliverSeedAndCreate = async (): Promise => { + if (!historySeedSegments || historySeedSegments.length === 0) { + return requestCreateOrAttach(undefined, undefined) + } + const metrics = measureTerminalHistorySeed(historySeedSegments) + if (metrics.codeUnits <= TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS) { + try { + return await requestCreateOrAttach(historySeedSegments.join(''), undefined) + } catch (error) { + if (!(error instanceof NdjsonLineTooLongError)) { + throw error + } + historySeedUnavailable = true + return requestCreateOrAttach(undefined, undefined) + } + } + if (this.protocolVersion < HISTORY_SEED_TRANSFER_PROTOCOL_VERSION) { + historySeedUnavailable = true + return requestCreateOrAttach(undefined, undefined) + } + + let transferId: string | undefined + try { + const started = await this.client.request<{ transferId: string }>( + 'startHistorySeedTransfer', + metrics + ) + transferId = started.transferId + let index = 0 + for (const data of iterateTerminalHistorySeedChunks(historySeedSegments)) { + await this.client.request('appendHistorySeedTransfer', { transferId, index, data }) + index += 1 + } + await this.client.request('finishHistorySeedTransfer', { transferId }) + } catch (error) { + if (transferId) { + await this.client.request('abortHistorySeedTransfer', { transferId }).catch(() => {}) + } + if (isDaemonGoneError(error)) { + throw error + } + historySeedUnavailable = true + return requestCreateOrAttach(undefined, undefined) + } + return requestCreateOrAttach(undefined, transferId) + } + const result = await deliverSeedAndCreate() + return historySeedUnavailable && result.historySeeded === undefined + ? { ...result, historySeeded: false } + : result + } +} diff --git a/src/main/daemon/daemon-pty-spawn-result.ts b/src/main/daemon/daemon-pty-spawn-result.ts new file mode 100644 index 00000000000..13482b75414 --- /dev/null +++ b/src/main/daemon/daemon-pty-spawn-result.ts @@ -0,0 +1,329 @@ +import { isAgentSessionClaimedSpawnResult } from '../../shared/agent-session-host-authority' +import { parseTerminalKittyKeyboardFlags } from '../../shared/terminal-kitty-keyboard-flags' +import { DaemonPtySpawnRequest, type DaemonPtySpawnContext } from './daemon-pty-spawn-request' +import { providerSequenceFromCreateOrAttach } from './daemon-pty-provider-sequence' +import { TerminalSessionOwnerUnverifiedError } from './daemon-errors' +import { takeHistoryRecoveryFreeze } from './daemon-history-recovery-freeze' +import { daemonPtySpawnIdentityFields } from './daemon-pty-spawn-identity-fields' +import { getRecoveredHistorySeedSegments } from './terminal-history-seed-segments' +import { SessionNotFoundError, type CreateOrAttachResult } from './types' +import type { PtySpawnResult } from '../providers/types' + +export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest { + protected async finishSpawn( + context: DaemonPtySpawnContext, + initialResult: CreateOrAttachResult + ): Promise { + const { + opts, + operation, + historyRecovery, + requestedSessionId, + emulateLegacyAttachOnly, + restoreSkippedForLiveSession, + detectColdRestore + } = context + let { sessionId, wslDistro, restoreInfo, effectiveCwd, effectiveCols, effectiveRows } = context + const createOrAttach = (historySeedSegments: readonly string[] | null) => { + Object.assign(context, { + sessionId, + effectiveCwd, + effectiveCols, + effectiveRows + }) + return this.createOrAttachSpawn(context, historySeedSegments) + } + let result = initialResult + let historySeedSegments = restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null + const adoptSpawnResultSession = async (spawnResult: CreateOrAttachResult): Promise => { + const requestedSessionId = sessionId + if ( + opts.agentSessionEnsure && + !isAgentSessionClaimedSpawnResult(spawnResult.agentSessionEnsure) + ) { + // Why: a claim-incapable owner may already have spawned before returning + // a malformed response; retire only this requested session before failing closed. + await this.client.request('kill', { sessionId: requestedSessionId }).catch(() => {}) + throw new Error('agent_session_claim_unavailable') + } + sessionId = spawnResult.agentSessionEnsure?.owner.ptyId ?? requestedSessionId + context.sessionId = sessionId + if (requestedSessionId === sessionId) { + return + } + if (historyRecovery.freeze) { + this.historyManager?.abandonRecoveryFreeze(historyRecovery.freeze) + historyRecovery.freeze = null + } + historyRecovery.unreadableSessionId = null + historyRecovery.identityChanged = true + restoreInfo = null + historySeedSegments = null + } + if (emulateLegacyAttachOnly && result.isNew) { + operation.ignoreNextExit = true + await this.client.request('kill', { sessionId: requestedSessionId, immediate: true }) + throw new SessionNotFoundError(requestedSessionId) + } + await adoptSpawnResultSession(result) + // Both ids: adoptSpawnResultSession may have rewritten sessionId to the claim owner. + this.clearSessionAwaitingDaemonRecovery(requestedSessionId) + this.clearSessionAwaitingDaemonRecovery(sessionId) + const exitedResult = this.resultForExitBeforeSpawnReply(sessionId, result, operation) + if (exitedResult) { + return exitedResult + } + if ( + opts.attachOnly && + opts.expectedIncarnationIsAuthoritative && + (!result.incarnationId || result.incarnationId !== opts.expectedIncarnationId) + ) { + throw new TerminalSessionOwnerUnverifiedError(sessionId) + } + this.recordSessionAuthority(sessionId, result.incarnationId) + const claimResult = (): Pick | Record => + result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {} + let providerWslDistro = result.wslDistro === undefined ? wslDistro : result.wslDistro + // Why: explicit null from a current daemon overrides the caller's WSL preference; undefined keeps compatibility with older daemons. + wslDistro = providerWslDistro ?? undefined + context.wslDistro = wslDistro + if (wslDistro) { + this.wslDistrosBySessionId.set(sessionId, wslDistro) + } else if (providerWslDistro === null || result.isNew) { + this.wslDistrosBySessionId.delete(sessionId) + } + const launchIdentity = (): { launchAgent?: NonNullable } => + result.launchAgent ? { launchAgent: result.launchAgent } : {} + + if (effectiveCwd) { + this.initialCwds.set(sessionId, effectiveCwd) + } + + // Why: surface the daemon's shell pid via PtySpawnResult so ipc/pty registers with the memory collector without a provider-specific accessor. + let pid = typeof result.pid === 'number' && result.pid > 0 ? result.pid : null + + // Why: check sticky cache first — StrictMode double-mounts call spawn twice; the second call (isNew=false) must still return cached cold restore data. + const cachedRestore = this.coldRestoreCache.get(sessionId) + if (cachedRestore) { + // Why: wake-after-sleep lands here too; sleep dropped active tracking + the history writer, so re-register both or the next sleep/wake restores a blank terminal. + this.activeSessionIds.add(sessionId) + if (this.historyManager && !historyRecovery.identityChanged) { + const recoveryFreeze = takeHistoryRecoveryFreeze(historyRecovery, sessionId) + if (historyRecovery.unreadableSessionId === sessionId) { + this.historyManager.suspendSession(sessionId, recoveryFreeze) + } else { + this.historyManager.reopenSession(sessionId, recoveryFreeze) + } + } + return { + id: sessionId, + ...daemonPtySpawnIdentityFields(result, this.ownerIdentity(result.incarnationId)), + pid, + ...claimResult(), + ...launchIdentity(), + coldRestore: cachedRestore, + ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), + ...(!result.isNew ? { isReattach: true } : {}) + } + } + + // Why: the probe→createOrAttach gap is racy — the session can exit in between, so re-detect to match the unprobed restore path. + // Why ignoreCleanEnd: the raced exit event can write endedAt before the reply; nulling the restore here would delete the checkpoint instead of restoring it. + if (!historyRecovery.identityChanged && result.isNew && restoreSkippedForLiveSession) { + restoreInfo = await detectColdRestore({ ignoreCleanEnd: true }) + historySeedSegments = restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null + if (restoreInfo && historySeedSegments && historySeedSegments.length > 0) { + // Why: the aliveness probe raced with session death, so the first + // create lacked recovery bytes. Replace it before exposing the PTY. + if (result.incarnationId) { + operation.ignoredExitIncarnationIds.add(result.incarnationId) + } + operation.ignoreNextExit = true + await this.client.request('kill', { sessionId, immediate: true }) + effectiveCwd = restoreInfo.cwd + effectiveCols = restoreInfo.cols + effectiveRows = restoreInfo.rows + result = await createOrAttach(historySeedSegments) + await adoptSpawnResultSession(result) + const exitedRetryResult = this.resultForExitBeforeSpawnReply(sessionId, result, operation) + if (exitedRetryResult) { + return exitedRetryResult + } + this.recordSessionAuthority(sessionId, result.incarnationId) + providerWslDistro = result.wslDistro === undefined ? wslDistro : result.wslDistro + wslDistro = providerWslDistro ?? undefined + context.wslDistro = wslDistro + if (wslDistro) { + this.wslDistrosBySessionId.set(sessionId, wslDistro) + } else if (providerWslDistro === null || result.isNew) { + this.wslDistrosBySessionId.delete(sessionId) + } + pid = typeof result.pid === 'number' && result.pid > 0 ? result.pid : null + this.initialCwds.set(sessionId, effectiveCwd) + } + } else if ( + !historyRecovery.identityChanged && + !result.isNew && + result.historySeeded === false + ) { + restoreInfo = await detectColdRestore() + historySeedSegments = restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null + } + + const wasAlreadyManaged = this.activeSessionIds.has(sessionId) + this.activeSessionIds.add(sessionId) + const providerSequence = providerSequenceFromCreateOrAttach(result) + + // Cold restore: daemon made a new session but disk history shows an unclean shutdown → return saved scrollback. + if (restoreInfo && (result.isNew || result.historySeeded === false)) { + const coldRestore = this.buildColdRestorePayload(restoreInfo) + const canReanchorHistory = + !historySeedSegments || historySeedSegments.length === 0 || result.historySeeded === true + // Why: registerWriter (not openSession) avoids deleting checkpoint.json — the only recovery data if the revived daemon crashes before the next tick. + if (this.historyManager && !historyRecovery.identityChanged) { + const recoveryFreeze = takeHistoryRecoveryFreeze(historyRecovery, sessionId) + if (historyRecovery.unreadableSessionId === sessionId) { + await this.historyManager.openSession(sessionId, { + cwd: effectiveCwd ?? '', + cols: effectiveCols, + rows: effectiveRows, + ...(recoveryFreeze ? { recoveryFreeze } : {}), + quarantineUnreadableRecovery: true + }) + if (this.historyManager.hasWriter(sessionId)) { + this.sessionsNeedingFullCheckpoint.add(sessionId) + this.sessionsNeedingLiveCheckpoint.add(sessionId) + this.lastFullCheckpointAt.delete(sessionId) + } + } else if (canReanchorHistory) { + this.historyManager.registerWriter(sessionId, recoveryFreeze) + this.sessionsNeedingFullCheckpoint.add(sessionId) + // Why: the revived generation has no valid checkpoint yet; a cooldown inherited from the pre-crash generation must not defer this re-anchor. + this.lastFullCheckpointAt.delete(sessionId) + } else { + // Preserve old recovery files when the new daemon can't include them; a fresh-only checkpoint would make the data loss permanent. + this.historyManager.suspendSession(sessionId, recoveryFreeze) + } + } + if (coldRestore) { + this.coldRestoreCache.set(sessionId, coldRestore) + return { + id: sessionId, + ...daemonPtySpawnIdentityFields(result, this.ownerIdentity(result.incarnationId)), + pid, + ...claimResult(), + ...launchIdentity(), + coldRestore, + ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), + ...(providerSequence ? { providerSequence } : {}), + ...(!result.isNew ? { isReattach: true } : {}) + } + } + return { + id: sessionId, + ...daemonPtySpawnIdentityFields(result, this.ownerIdentity(result.incarnationId)), + pid, + ...claimResult(), + ...launchIdentity(), + ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), + ...(providerSequence ? { providerSequence } : {}) + } + } + + if (this.historyManager && !historyRecovery.identityChanged && result.isNew) { + const recoveryFreeze = takeHistoryRecoveryFreeze(historyRecovery, sessionId) + await this.historyManager.openSession(sessionId, { + cwd: effectiveCwd ?? '', + cols: effectiveCols, + rows: effectiveRows, + ...(recoveryFreeze ? { recoveryFreeze } : {}), + ...(historyRecovery.unreadableSessionId === sessionId + ? { quarantineUnreadableRecovery: true } + : {}) + }) + } else if ( + this.historyManager && + !historyRecovery.identityChanged && + (result.historySeeded === false || historyRecovery.unreadableSessionId === sessionId) + ) { + // Why: the daemon keeps this failure bit with the live session, so a new adapter can't promote its fresh-only snapshot after restart. + this.historyManager.suspendSession( + sessionId, + takeHistoryRecoveryFreeze(historyRecovery, sessionId) + ) + } else if (this.historyManager && !historyRecovery.identityChanged) { + // Why: on warm reattach after relaunch the HistoryManager is fresh; registerWriter adds a writer without deleting the still-only-valid checkpoint. + this.historyManager.registerWriter( + sessionId, + takeHistoryRecoveryFreeze(historyRecovery, sessionId) + ) + if (!wasAlreadyManaged) { + // Why: a previous adapter may have drained records it never persisted, so the first compact must prove disk-to-daemon continuity. + this.sessionsNeedingFullCheckpoint.add(sessionId) + this.sessionsNeedingContinuityCheckpoint.add(sessionId) + this.lastFullCheckpointAt.delete(sessionId) + } + } + + const isReattach = !result.isNew + if (!isReattach || !result.snapshot) { + return { + id: sessionId, + ...daemonPtySpawnIdentityFields(result, this.ownerIdentity(result.incarnationId)), + pid, + ...claimResult(), + ...launchIdentity(), + ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), + ...(providerSequence ? { providerSequence } : {}), + ...(isReattach ? { isReattach: true } : {}) + } + } + + const reattachSnapshot = await this.overlayDurableRestoreSnapshot(sessionId, result.snapshot) + const reattachProviderSequence = + typeof reattachSnapshot.outputSequence === 'number' + ? { value: reattachSnapshot.outputSequence, generation: 'continued' as const } + : providerSequence + const isAltScreen = reattachSnapshot.modes.alternateScreen + const snapshotPrefix = reattachSnapshot.scrollbackAnsi + reattachSnapshot.rehydrateSequences + const snapshotFrame = reattachSnapshot.snapshotAnsi + const snapshotPayload = snapshotPrefix + snapshotFrame + // Why kitty flags ride beside the payload, not inside it: the snapshot reaches renderer xterms where POST_REPLAY_REATTACH_RESET's kitty reset must win (terminal-query-authority.md §kitty). + // Why known `0` is no longer dropped: the pane tracker must be able to tell + // "the app negotiated nothing" from "this reattach proved nothing". + const kittyKeyboardFlags = parseTerminalKittyKeyboardFlags( + reattachSnapshot.modes.kittyKeyboardFlags + ) + return { + id: sessionId, + ...daemonPtySpawnIdentityFields(result, this.ownerIdentity(result.incarnationId)), + pid, + ...claimResult(), + ...launchIdentity(), + ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), + snapshot: snapshotPayload, + snapshotCols: reattachSnapshot.cols, + snapshotRows: reattachSnapshot.rows, + // Why only for an alt frame: normal history remains safe to replay at its capture grid. + ...(isAltScreen && snapshotFrame && reattachSnapshot.frameRestoreAnsi + ? { + snapshotPrefixAnsi: snapshotPrefix, + snapshotFrameAnsi: snapshotFrame, + snapshotFrameRestoreAnsi: reattachSnapshot.frameRestoreAnsi + } + : {}), + ...(reattachProviderSequence ? { providerSequence: reattachProviderSequence } : {}), + ...(kittyKeyboardFlags !== undefined + ? { snapshotKittyKeyboardFlags: kittyKeyboardFlags } + : {}), + isReattach: true, + isAlternateScreen: isAltScreen, + // Why: the snapshot ANSI has no title frame; carry lastTitle beside it so main can seed title records after a relaunch. + ...(reattachSnapshot.lastTitle ? { lastTitle: reattachSnapshot.lastTitle } : {}), + // Why: carry the mid-escape tail so the renderer writes it after the reattach reset, else a split escape renders literally (#7329). + ...(reattachSnapshot.pendingEscapeTailAnsi + ? { pendingEscapeTailAnsi: reattachSnapshot.pendingEscapeTailAnsi } + : {}) + } + } +} diff --git a/src/main/daemon/daemon-request-deadline.ts b/src/main/daemon/daemon-request-deadline.ts new file mode 100644 index 00000000000..d98c1c5f7dd --- /dev/null +++ b/src/main/daemon/daemon-request-deadline.ts @@ -0,0 +1,25 @@ +export function remainingDaemonRequestTimeoutMs( + deadlineMs: number | undefined +): number | undefined { + return deadlineMs === undefined ? undefined : Math.max(1, deadlineMs - Date.now()) +} + +// The caller's wait is bounded without cancelling durable work, which keeps running and committing. +export async function awaitDaemonWorkWithinCallerDeadline( + work: Promise, + deadlineMs: number +): Promise { + void work.catch(() => {}) + let timer: ReturnType | undefined + try { + return await Promise.race([ + work.then(() => true), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), Math.max(1, deadlineMs - Date.now())) + timer.unref?.() + }) + ]) + } finally { + clearTimeout(timer) + } +} diff --git a/src/main/daemon/daemon-request-router.ts b/src/main/daemon/daemon-request-router.ts new file mode 100644 index 00000000000..7b8096c5a42 --- /dev/null +++ b/src/main/daemon/daemon-request-router.ts @@ -0,0 +1,291 @@ +import { performance } from 'node:perf_hooks' +import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health' +import type { ConnectedDaemonClient, DaemonClientConnections } from './daemon-client-connections' +import type { DaemonFileLog } from './daemon-file-log' +import type { DaemonPtySpawnPreparations } from './daemon-pty-spawn-preparations' +import type { DaemonServerLifecycle } from './daemon-server-lifecycle' +import type { DaemonSessionAttachments } from './daemon-session-attachments' +import type { DaemonSessionBackgroundRouting } from './daemon-session-background-routing' +import { recordDaemonStreamBacklogEvent } from './daemon-stream-backlog-probe' +import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' +import type { DaemonTerminalAdmission } from './daemon-terminal-admission' +import type { TerminalHistorySeedTransferRegistry } from './terminal-history-seed-transfer-registry' +import type { TerminalHost } from './terminal-host' +import { SessionNotFoundError, type DaemonRequest } from './types' + +type DaemonRequestRouterOptions = { + host: TerminalHost + connections: DaemonClientConnections + lifecycle: DaemonServerLifecycle + admission: DaemonTerminalAdmission + preparations: DaemonPtySpawnPreparations + attachments: DaemonSessionAttachments + historySeedTransfers: TerminalHistorySeedTransferRegistry + sessionBackgroundRouting: DaemonSessionBackgroundRouting + streamDataBatcher: DaemonStreamDataBatcher + ptySpawnHealthCheck: () => Promise + log: DaemonFileLog +} + +export class DaemonRequestRouter { + constructor(private readonly options: DaemonRequestRouterOptions) {} + + async route(clientId: string, request: DaemonRequest): Promise { + const client = this.options.connections.get(clientId) + switch (request.type) { + case 'startHistorySeedTransfer': { + if (!client?.authenticatedPairEstablished || client.streamSocket === null) { + throw new Error('Daemon client connection is incomplete; reconnect') + } + return { + transferId: this.options.historySeedTransfers.start(clientId, request.payload) + } + } + case 'appendHistorySeedTransfer': + this.options.historySeedTransfers.append( + clientId, + request.payload.transferId, + request.payload.index, + request.payload.data + ) + return {} + case 'finishHistorySeedTransfer': + this.options.historySeedTransfers.finish(clientId, request.payload.transferId) + return {} + case 'abortHistorySeedTransfer': + this.options.historySeedTransfers.abort(clientId, request.payload.transferId) + return {} + case 'createOrAttach': + return this.options.admission.createOrAttach(clientId, request) + case 'cancelCreateOrAttach': + return { + canceled: this.options.preparations.cancel(request.payload.sessionId, { + clientId, + ...(typeof request.payload.requestId === 'string' + ? { requestId: request.payload.requestId } + : {}) + }) + } + case 'closeStartupQueryAuthority': + return { + appliedSeq: this.options.host.closeStartupQueryAuthority(request.payload.sessionId) + } + case 'write': + return this.write( + client, + request.payload.sessionId, + request.payload.data, + request.payload.expectedIncarnationId + ) + case 'resize': + return this.resize( + client, + request.payload.sessionId, + request.payload.cols, + request.payload.rows, + request.payload.expectedIncarnationId + ) + case 'pausePty': + this.options.host.pauseProducer(request.payload.sessionId) + return {} + case 'resumePty': + this.options.host.resumeProducer(request.payload.sessionId) + return {} + case 'setSessionBackground': + return this.options.sessionBackgroundRouting.setBackground( + request.payload.sessionId, + request.payload.background === true + ) + case 'kill': + return this.kill(clientId, request.payload.sessionId, request.payload.immediate) + case 'signal': + this.options.host.signal(request.payload.sessionId, request.payload.signal) + return {} + case 'detach': + this.options.attachments.detachSessionForClient(request.payload.sessionId, clientId) + this.options.log.log('session-detached', { sessionId: request.payload.sessionId }) + return {} + case 'getCwd': + return { cwd: await this.options.host.getCwd(request.payload.sessionId) } + case 'getForegroundProcess': + return { + foregroundProcess: this.options.host.getForegroundProcess(request.payload.sessionId) + } + case 'inspectProcess': + return this.options.host.inspectProcess(request.payload.sessionId) + case 'confirmForegroundProcess': + return { + foregroundProcess: await this.options.host.confirmForegroundProcess( + request.payload.sessionId + ) + } + case 'clearScrollback': + this.options.host.clearScrollback(request.payload.sessionId) + return {} + case 'listSessions': + return { sessions: this.options.host.listSessions() } + case 'shutdownIfIdle': + return this.shutdownIfIdle(clientId, request.id) + case 'getSnapshot': + return this.getSnapshot(request.payload.sessionId, request.payload.scrollbackRows) + case 'getSize': + return { size: this.options.host.getAppliedSize(request.payload.sessionId) } + case 'takePendingOutput': + return this.options.host.takePendingOutput( + request.payload.sessionId, + request.payload.includeSnapshot === true, + { teardownSnapshot: request.payload.teardownSnapshot === true } + ) + case 'ping': + return { pong: true } + case 'systemResolverHealth': + return { health: await readCurrentProcessMacSystemResolverHealth() } + case 'ptySpawnHealth': + await this.options.ptySpawnHealthCheck() + return { healthy: true } + case 'shutdown': + return this.shutdown(clientId, request.id, request.payload.killSessions) + } + throw new Error(`Unknown request type: ${(request as { type: string }).type}`) + } + + private write( + client: ConnectedDaemonClient | undefined, + sessionId: string, + data: string, + expectedIncarnationId?: string + ): Record { + try { + this.options.attachments.recordInput(sessionId) + this.options.host.write(sessionId, data, expectedIncarnationId) + } catch (error) { + this.options.attachments.clearInput(sessionId) + if (error instanceof SessionNotFoundError) { + this.sendExitEvent(client, sessionId, -1, expectedIncarnationId) + } + throw error + } + return {} + } + + private resize( + client: ConnectedDaemonClient | undefined, + sessionId: string, + cols: number, + rows: number, + expectedIncarnationId?: string + ): Record { + try { + this.options.host.resize(sessionId, cols, rows, expectedIncarnationId) + } catch (error) { + if (error instanceof SessionNotFoundError) { + this.sendExitEvent(client, sessionId, -1, expectedIncarnationId) + } + throw error + } + return {} + } + + private async kill( + clientId: string, + sessionId: string, + immediate: boolean | undefined + ): Promise> { + const canceledPendingSpawn = this.options.preparations.cancel(sessionId) + this.options.attachments.clearInput(sessionId) + const attribution = { sessionId, immediate: immediate === true, clientId } + try { + await this.options.host.kill(sessionId, { immediate }) + } catch (error) { + if (!(canceledPendingSpawn && error instanceof SessionNotFoundError)) { + this.options.log.log('session-kill-failed', attribution) + throw error + } + } + this.options.log.log('session-killed', attribution) + return {} + } + + private shutdownIfIdle(clientId: string, requestId: string): { retiring: boolean } { + const client = this.options.connections.get(clientId) + const retiring = + client !== undefined && + client.streamSocket !== null && + this.options.connections.size === 1 && + this.options.admission.inFlight === 0 && + this.options.host.listSessions().length === 0 && + this.options.connections.hasOnlyTransportsFor(client) + if (retiring) { + this.options.lifecycle.retireAfterIdleReply(clientId, requestId, client.controlSocket) + } + return { retiring } + } + + private getSnapshot(sessionId: string, requestedRows: unknown): unknown { + const startedAt = performance.now() + const scrollbackRows = + typeof requestedRows === 'number' && Number.isFinite(requestedRows) + ? Math.max(0, Math.min(50_000, Math.floor(requestedRows))) + : undefined + const snapshot = this.options.host.getSnapshot(sessionId, { scrollbackRows }) + const snapshotMs = performance.now() - startedAt + if (snapshotMs >= 25) { + recordDaemonStreamBacklogEvent('slowGetSnapshot', { + sessionIdSuffix: sessionId.slice(-10), + snapshotMs: Math.round(snapshotMs) + }) + } + return { snapshot } + } + + private async shutdown( + clientId: string, + requestId: string, + killSessions: boolean + ): Promise> { + this.options.log.log('shutdown', { + reason: 'rpc', + killSessions: killSessions === true + }) + const serverClose = this.options.lifecycle.beginOrdinaryShutdownFence() + if (killSessions) { + try { + await this.options.host.dispose() + } catch (error) { + this.options.log.log('shutdown-dispose-failed', { + error: error instanceof Error ? error.message : String(error) + }) + } + } + const controlSocket = this.options.connections.get(clientId)?.controlSocket + if (controlSocket) { + this.options.lifecycle.deferRpcShutdownUntilReply( + clientId, + requestId, + controlSocket, + serverClose + ) + } else { + this.options.lifecycle.finishRpcShutdownWithoutReply(serverClose) + } + return {} + } + + private sendExitEvent( + client: ConnectedDaemonClient | undefined, + sessionId: string, + code: number, + incarnationId?: string + ): void { + if (!client?.streamSocket) { + return + } + this.options.streamDataBatcher.enqueueControlEvent(client.clientId, sessionId, { + type: 'event', + event: 'exit', + sessionId, + payload: { code, ...(incarnationId ? { incarnationId } : {}) } + }) + this.options.streamDataBatcher.flush(client.clientId) + } +} diff --git a/src/main/daemon/daemon-server-async-spawn-cancellation.test.ts b/src/main/daemon/daemon-server-async-spawn-cancellation.test.ts index 6440fd123db..57585dc9318 100644 --- a/src/main/daemon/daemon-server-async-spawn-cancellation.test.ts +++ b/src/main/daemon/daemon-server-async-spawn-cancellation.test.ts @@ -6,6 +6,12 @@ import { DaemonClient } from './client' import { createMockSubprocess } from './daemon-pty-adapter-test-harness' import { DaemonServer } from './daemon-server' import { getDaemonSocketPath } from './daemon-spawner' +type DaemonServerInternals = { + preparations: { + pending: Map> + } + host: { listSessions: () => unknown[] } +} describe('daemon async spawn cancellation', () => { let directory: string @@ -82,14 +88,11 @@ describe('daemon async spawn cancellation', () => { await spawnStarted client.disconnect() - const daemon = server as unknown as { - pendingPtySpawnPreparations: Map> - host: { listSessions: () => unknown[] } - } + const daemon = server as unknown as DaemonServerInternals await vi.waitFor(() => - expect( - [...(daemon.pendingPtySpawnPreparations.get('disconnected-spawn') ?? [])][0]?.canceled - ).toBe(true) + expect([...(daemon.preparations.pending.get('disconnected-spawn') ?? [])][0]?.canceled).toBe( + true + ) ) releaseSpawn() await create @@ -145,14 +148,10 @@ describe('daemon async spawn cancellation', () => { 30_000, abort.signal ) - const daemon = server as unknown as { - pendingPtySpawnPreparations: Map> - } + const daemon = server as unknown as DaemonServerInternals // Attach-only used to register nothing, so the daemon could not match the // cancel and the client dropped its only timeout. - await vi.waitFor(() => - expect(daemon.pendingPtySpawnPreparations.get('shared-session')?.size).toBe(2) - ) + await vi.waitFor(() => expect(daemon.preparations.pending.get('shared-session')?.size).toBe(2)) abort.abort() await expect(attach).rejects.toThrow('client_disconnected') @@ -175,12 +174,8 @@ describe('daemon async spawn cancellation', () => { abort.signal ) await spawnStarted - const daemon = server as unknown as { - pendingPtySpawnPreparations: Map> - } - await vi.waitFor(() => - expect(daemon.pendingPtySpawnPreparations.get('shared-session')?.size).toBe(2) - ) + const daemon = server as unknown as DaemonServerInternals + await vi.waitFor(() => expect(daemon.preparations.pending.get('shared-session')?.size).toBe(2)) abort.abort() await expect(second).rejects.toThrow('client_disconnected') diff --git a/src/main/daemon/daemon-server-attachment-lifecycle.test.ts b/src/main/daemon/daemon-server-attachment-lifecycle.test.ts index 34d78739f13..2f4d1b734ac 100644 --- a/src/main/daemon/daemon-server-attachment-lifecycle.test.ts +++ b/src/main/daemon/daemon-server-attachment-lifecycle.test.ts @@ -35,9 +35,13 @@ type DaemonAttachmentPrivate = { createOrAttach: (options: CreateOrAttachOptions) => Promise sessions: Map } - clients: Map - streamClientIdBySessionId: Map - attachTokenBySessionId: Map + connections: { + clients: Map + } + attachments: { + clientIdBySessionId: Map + tokenBySessionId: Map + } } describe('DaemonServer attachment lifecycle', () => { @@ -73,7 +77,7 @@ describe('DaemonServer attachment lifecycle', () => { await createSession('transport-owned-session') const daemon = server as unknown as DaemonAttachmentPrivate const detachClients = vi.spyOn(daemon.host, 'detachClients') - const streamSocket = [...daemon.clients.values()][0]?.streamSocket + const streamSocket = [...daemon.connections.clients.values()][0]?.streamSocket streamSocket?.destroy() @@ -82,8 +86,8 @@ describe('DaemonServer attachment lifecycle', () => { { sessionId: 'transport-owned-session', token: expect.any(Symbol) } ]) expect(daemon.host.sessions.get('transport-owned-session')?.hasAttachedClients).toBe(false) - expect(daemon.streamClientIdBySessionId.has('transport-owned-session')).toBe(false) - expect(daemon.attachTokenBySessionId.has('transport-owned-session')).toBe(false) + expect(daemon.attachments.clientIdBySessionId.has('transport-owned-session')).toBe(false) + expect(daemon.attachments.tokenBySessionId.has('transport-owned-session')).toBe(false) }) it('parks only the requesting client attachment on explicit detach', async () => { @@ -95,8 +99,8 @@ describe('DaemonServer attachment lifecycle', () => { expect(detach).toHaveBeenCalledWith('explicitly-detached-session', expect.any(Symbol)) expect(daemon.host.sessions.get('explicitly-detached-session')?.hasAttachedClients).toBe(false) - expect(daemon.streamClientIdBySessionId.has('explicitly-detached-session')).toBe(false) - expect(daemon.attachTokenBySessionId.has('explicitly-detached-session')).toBe(false) + expect(daemon.attachments.clientIdBySessionId.has('explicitly-detached-session')).toBe(false) + expect(daemon.attachments.tokenBySessionId.has('explicitly-detached-session')).toBe(false) }) it('parks an attachment completed after its transport already closed', async () => { @@ -122,7 +126,7 @@ describe('DaemonServer attachment lifecycle', () => { await created client.disconnect() - await vi.waitFor(() => expect(daemon.clients.size).toBe(0)) + await vi.waitFor(() => expect(daemon.connections.clients.size).toBe(0)) expect(daemon.host.sessions.get('close-race-session')?.hasAttachedClients).toBe(true) finishRequest() @@ -130,7 +134,7 @@ describe('DaemonServer attachment lifecycle', () => { await vi.waitFor(() => expect(daemon.host.sessions.get('close-race-session')?.hasAttachedClients).toBe(false) ) - expect(daemon.streamClientIdBySessionId.has('close-race-session')).toBe(false) - expect(daemon.attachTokenBySessionId.has('close-race-session')).toBe(false) + expect(daemon.attachments.clientIdBySessionId.has('close-race-session')).toBe(false) + expect(daemon.attachments.tokenBySessionId.has('close-race-session')).toBe(false) }) }) diff --git a/src/main/daemon/daemon-server-error-handling.test.ts b/src/main/daemon/daemon-server-error-handling.test.ts index 3d61551cda0..7ab9d1ffa95 100644 --- a/src/main/daemon/daemon-server-error-handling.test.ts +++ b/src/main/daemon/daemon-server-error-handling.test.ts @@ -1,14 +1,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { chmodSync, linkSync, mkdtempSync, rmSync, unlinkSync } from 'node:fs' +import { chmodSync, linkSync, mkdtempSync, readFileSync, rmSync, unlinkSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { createServer, type Server } from 'node:net' +import { connect, createServer, type Server } from 'node:net' import { DaemonServer } from './daemon-server' +import type { ConnectedDaemonClient } from './daemon-client-connections' import { DaemonClient } from './client' -import { isDaemonGoneError } from './daemon-pty-adapter' +import { isDaemonGoneError } from './daemon-endpoint-errors' import { DAEMON_ENDPOINT_LOST_MESSAGE } from './daemon-endpoint-ownership' +import { encodeNdjson } from './ndjson' import { getDaemonSocketPath } from './daemon-spawner' import type { SubprocessHandle } from './session-subprocess-handle' +import { PROTOCOL_VERSION } from './types' import { waitForEndpointUnreachable } from './daemon-endpoint-reachability-test-harness' // A killed process must actually report its exit: teardown waits @@ -32,6 +35,21 @@ function createMockSubprocess(): SubprocessHandle { dispose() {} } } +type DaemonServerInternals = { + endpoint: { + hasLostOwnership(): boolean + requestRetirementForLoss(): void + checkOwnership(): void + ownershipLossStreak: number + ownershipLost: boolean + } + lifecycle: { + retirementRequested: boolean + state: string + server: Server | null + } + connections: { clients: Map } +} describe('daemon server error handling', () => { let dir: string @@ -88,8 +106,8 @@ describe('daemon server error handling', () => { ).rejects.toThrow(/no longer owns its endpoint/) // And it stands down rather than lingering as an unreachable host. - const daemon = server as unknown as { retirementRequested: boolean } - expect(daemon.retirementRequested).toBe(true) + const daemon = server as unknown as DaemonServerInternals + expect(daemon.lifecycle.retirementRequested).toBe(true) } finally { await new Promise((resolve) => usurper.close(() => resolve())) } @@ -108,17 +126,14 @@ describe('daemon server error handling', () => { spawnSubprocess: () => createMockSubprocess() }) await server.start() - const daemon = server as unknown as { - hasLostEndpointOwnership: () => boolean - retirementRequested: boolean - } + const daemon = server as unknown as DaemonServerInternals try { // Drop search permission on the directory so the ownership stat fails EACCES. chmodSync(dir, 0o600) - expect(daemon.hasLostEndpointOwnership()).toBe(false) - expect(daemon.retirementRequested).toBe(false) + expect(daemon.endpoint.hasLostOwnership()).toBe(false) + expect(daemon.lifecycle.retirementRequested).toBe(false) } finally { chmodSync(dir, 0o700) } @@ -150,8 +165,8 @@ describe('daemon server error handling', () => { linkSync(usurperBind, socketPath) // The guard is still armed here — no creation has been refused yet. - const daemon = server as unknown as { hasLostEndpointOwnership: () => boolean } - expect(daemon.hasLostEndpointOwnership()).toBe(true) + const daemon = server as unknown as DaemonServerInternals + expect(daemon.endpoint.hasLostOwnership()).toBe(true) await expect( client.request('createOrAttach', { @@ -198,14 +213,11 @@ describe('daemon server error handling', () => { ).rejects.toThrow(/no longer owns its endpoint/) // Simulate a late client completing its handshake and clearing pending retirement. - const daemon = server as unknown as { - retirementRequested: boolean - hasLostEndpointOwnership: () => boolean - } - daemon.retirementRequested = false + const daemon = server as unknown as DaemonServerInternals + daemon.lifecycle.retirementRequested = false // The loss must still be remembered, so creation stays closed. - expect(daemon.hasLostEndpointOwnership()).toBe(true) + expect(daemon.endpoint.hasLostOwnership()).toBe(true) await expect( client.request('createOrAttach', { sessionId: 'b', cols: 80, rows: 24 }) ).rejects.toThrow(/no longer owns its endpoint/) @@ -248,16 +260,13 @@ describe('daemon server error handling', () => { // Losing the endpoint with nothing left to drain must stand the daemon down, even // though this client is still connected. - const daemon = server as unknown as { - requestRetirementForLostEndpoint: () => void - idleShutdownState: string - } - expect(daemon.idleShutdownState).toBe('running') - daemon.requestRetirementForLostEndpoint() + const daemon = server as unknown as DaemonServerInternals + expect(daemon.lifecycle.state).toBe('running') + daemon.endpoint.requestRetirementForLoss() // Drained and unreachable: it must begin standing down rather than wait for this // client, which can never make it routable again. - expect(daemon.idleShutdownState).not.toBe('running') + expect(daemon.lifecycle.state).not.toBe('running') } finally { await new Promise((resolve) => usurper.close(() => resolve())) } @@ -320,19 +329,15 @@ describe('daemon server error handling', () => { unlinkSync(socketPath) linkSync(usurperBind, socketPath) - const daemon = server as unknown as { - retirementRequested: boolean - endpointOwnershipLost: boolean - checkEndpointOwnership: () => void - } + const daemon = server as unknown as DaemonServerInternals // Retirement is already pending for an unrelated reason. - daemon.retirementRequested = true + daemon.lifecycle.retirementRequested = true - daemon.checkEndpointOwnership() - daemon.checkEndpointOwnership() + daemon.endpoint.checkOwnership() + daemon.endpoint.checkOwnership() // The loss must be recorded regardless, so a later hello cannot undo it. - expect(daemon.endpointOwnershipLost).toBe(true) + expect(daemon.endpoint.ownershipLost).toBe(true) } finally { await new Promise((resolve) => usurper.close(() => resolve())) } @@ -352,12 +357,7 @@ describe('daemon server error handling', () => { spawnSubprocess: () => createMockSubprocess() }) await server.start() - const daemon = server as unknown as { - checkEndpointOwnership: () => void - hasLostEndpointOwnership: () => boolean - endpointOwnershipLost: boolean - endpointOwnershipLossStreak: number - } + const daemon = server as unknown as DaemonServerInternals // A second name for our own socket inode, so ownership can be handed back. const ourAlias = join(dir, '.ours') linkSync(socketPath, ourAlias) @@ -369,21 +369,21 @@ describe('daemon server error handling', () => { // First loss. unlinkSync(socketPath) linkSync(usurperBind, socketPath) - daemon.checkEndpointOwnership() - expect(daemon.endpointOwnershipLossStreak).toBe(1) - expect(daemon.endpointOwnershipLost).toBe(false) + daemon.endpoint.checkOwnership() + expect(daemon.endpoint.ownershipLossStreak).toBe(1) + expect(daemon.endpoint.ownershipLost).toBe(false) // Ownership demonstrably returns, observed through the admission path. unlinkSync(socketPath) linkSync(ourAlias, socketPath) - expect(daemon.hasLostEndpointOwnership()).toBe(false) - expect(daemon.endpointOwnershipLossStreak).toBe(0) + expect(daemon.endpoint.hasLostOwnership()).toBe(false) + expect(daemon.endpoint.ownershipLossStreak).toBe(0) // A later isolated loss is confirmation #1, not #2, so it must not retire. unlinkSync(socketPath) linkSync(usurperBind, socketPath) - daemon.checkEndpointOwnership() - expect(daemon.endpointOwnershipLost).toBe(false) + daemon.endpoint.checkOwnership() + expect(daemon.endpoint.ownershipLost).toBe(false) } finally { await new Promise((resolve) => usurper.close(() => resolve())) } @@ -400,19 +400,21 @@ describe('daemon server error handling', () => { spawnSubprocess: () => createMockSubprocess() }) await server.start() - const daemon = server as unknown as { server: Server | null } + const daemon = server as unknown as DaemonServerInternals - expect(daemon.server?.listenerCount('error')).toBe(1) + expect(daemon.lifecycle.server?.listenerCount('error')).toBe(1) const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { // Twice on purpose: a one-shot listener survives the first error and dies on the second, // so a single emit cannot tell a permanent handler from `once`. - expect(() => daemon.server?.emit('error', new Error('EMFILE: accept failed'))).not.toThrow() expect(() => - daemon.server?.emit('error', new Error('EMFILE: accept failed again')) + daemon.lifecycle.server?.emit('error', new Error('EMFILE: accept failed')) + ).not.toThrow() + expect(() => + daemon.lifecycle.server?.emit('error', new Error('EMFILE: accept failed again')) ).not.toThrow() expect(warn).toHaveBeenCalledTimes(2) - expect(daemon.server?.listenerCount('error')).toBe(1) + expect(daemon.lifecycle.server?.listenerCount('error')).toBe(1) } finally { warn.mockRestore() } @@ -422,6 +424,44 @@ describe('daemon server error handling', () => { expect(client.isConnected()).toBe(true) }) + it('rejects unsupported hello roles without replacing an authenticated stream', async () => { + const onAuthenticatedClientPair = vi.fn() + server = new DaemonServer({ + socketPath, + tokenPath, + onAuthenticatedClientPair, + spawnSubprocess: () => createMockSubprocess() + }) + await server.start() + client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + const daemon = server as unknown as DaemonServerInternals + const connectedClient = [...daemon.connections.clients.values()][0] + const originalStream = connectedClient.streamSocket + + const unsupported = connect(socketPath) + unsupported.on('error', () => {}) + await new Promise((resolve) => unsupported.once('connect', resolve)) + const response = new Promise<{ ok: boolean; error?: string }>((resolve) => { + unsupported.once('data', (data) => resolve(JSON.parse(data.toString()))) + }) + unsupported.write( + encodeNdjson({ + type: 'hello', + version: PROTOCOL_VERSION, + token: readFileSync(tokenPath, 'utf8'), + clientId: connectedClient.clientId, + role: 'observer' + }) + ) + await expect(response).resolves.toMatchObject({ ok: false, error: 'Invalid role' }) + unsupported.destroy() + + expect(connectedClient.streamSocket).toBe(originalStream) + expect(connectedClient.authenticatedPairEstablished).toBe(true) + expect(onAuthenticatedClientPair).toHaveBeenCalledOnce() + }) + it.skipIf(process.platform === 'win32')( 'stands down instead of serving when the server errors while publication is in flight', async () => { @@ -443,14 +483,14 @@ describe('daemon server error handling', () => { spawnSubprocess: () => createMockSubprocess() }) const started = server.start() - const daemon = server as unknown as { server: Server | null } - await vi.waitFor(() => expect(daemon.server?.listening).toBe(true)) - daemon.server?.emit('error', new Error('injected accept failure')) + const daemon = server as unknown as DaemonServerInternals + await vi.waitFor(() => expect(daemon.lifecycle.server?.listening).toBe(true)) + daemon.lifecycle.server?.emit('error', new Error('injected accept failure')) await expect(started).rejects.toThrow('injected accept failure') // Why wait: start() rejects the moment the error lands, while publication is still in // flight. Reporting failure must end with the daemon actually not serving. - await vi.waitFor(() => expect(daemon.server).toBeNull()) + await vi.waitFor(() => expect(daemon.lifecycle.server).toBeNull()) await expect(waitForEndpointUnreachable(socketPath)).resolves.toBe(true) } ) diff --git a/src/main/daemon/daemon-server-kill-attribution.test.ts b/src/main/daemon/daemon-server-kill-attribution.test.ts index 2c4f903ce38..8fa6805e862 100644 --- a/src/main/daemon/daemon-server-kill-attribution.test.ts +++ b/src/main/daemon/daemon-server-kill-attribution.test.ts @@ -7,8 +7,12 @@ import { SessionNotFoundError, type DaemonRequest } from './types' type DaemonServerPrivate = { host: { kill: (sessionId: string, opts?: { immediate?: boolean }) => void | Promise } - pendingPtySpawnPreparations: Map> - routeRequest(clientId: string, request: DaemonRequest): Promise + preparations: { + pending: Map> + } + requestRouter: { + route(clientId: string, request: DaemonRequest): Promise + } } describe('daemon kill attribution', () => { @@ -38,7 +42,7 @@ describe('daemon kill attribution', () => { }) vi.spyOn(daemon.host, 'kill').mockReturnValue(killFinished) - const request = daemon.routeRequest('control-42', { + const request = daemon.requestRouter.route('control-42', { id: 'kill-1', type: 'kill', payload: { sessionId: 'agent-session', immediate: true } @@ -71,7 +75,7 @@ describe('daemon kill attribution', () => { vi.spyOn(daemon.host, 'kill').mockRejectedValue(new Error('kill refused')) await expect( - daemon.routeRequest('control-42', { + daemon.requestRouter.route('control-42', { id: 'kill-1', type: 'kill', payload: { sessionId: 'agent-session', immediate: true } @@ -103,10 +107,10 @@ describe('daemon kill attribution', () => { controller: new AbortController(), clientId: 'control-42' } - daemon.pendingPtySpawnPreparations.set('agent-session', new Set([pendingPreparation])) + daemon.preparations.pending.set('agent-session', new Set([pendingPreparation])) vi.spyOn(daemon.host, 'kill').mockRejectedValue(new SessionNotFoundError('agent-session')) - await daemon.routeRequest('control-42', { + await daemon.requestRouter.route('control-42', { id: 'kill-1', type: 'kill', payload: { sessionId: 'agent-session', immediate: true } diff --git a/src/main/daemon/daemon-server-lifecycle.ts b/src/main/daemon/daemon-server-lifecycle.ts new file mode 100644 index 00000000000..0d4379b95e0 --- /dev/null +++ b/src/main/daemon/daemon-server-lifecycle.ts @@ -0,0 +1,278 @@ +import { createServer, type Server, type Socket } from 'node:net' +import type { DaemonEndpointLifecycle } from './daemon-endpoint-lifecycle' +import type { DaemonFileLog } from './daemon-file-log' +import { CLEAN_DISCONNECT_PROTOCOL_VERSION } from './types' + +type PendingShutdownReply = { start: () => void } + +type DaemonServerLifecycleOptions = { + protocolVersion: number + initialAdoptionTimeoutMs: number + clock: { + setTimeout(callback: () => void, delayMs: number): unknown + clearTimeout(handle: unknown): void + now(): number + } + endpoint: DaemonEndpointLifecycle + log: DaemonFileLog + isIdle: () => boolean + disposeResources: () => Promise + onIdleShutdown: () => void + onRpcShutdown: () => void +} + +export class DaemonServerLifecycle { + private static readonly SHUTDOWN_REPLY_FLUSH_TIMEOUT_MS = 1_000 + private server: Server | null = null + private startupFailure: Error | null = null + private state: 'running' | 'idle-shutdown-pending' | 'shutting-down' = 'running' + private initialAdoptionTimer: unknown = null + private initialAdoptionDeadlineMs: number | null = null + private retirementRequested = false + private shutdownPromise: Promise | null = null + private ordinaryShutdownServerClose: Promise | null = null + private readonly pendingShutdownReplies = new Map() + + constructor(private readonly options: DaemonServerLifecycleOptions) {} + + isAcceptingWork(): boolean { + return this.state === 'running' + } + + async start(handleConnection: (socket: Socket) => void): Promise { + return new Promise((resolve, reject) => { + this.server = createServer(handleConnection) + let startupSettled = false + const onServerError = (error: Error): void => { + if (startupSettled) { + this.options.log.log('server-error', { message: error.message }) + console.warn(`[daemon] Socket server error: ${error.message}`) + return + } + startupSettled = true + this.startupFailure = error + reject(error) + } + this.server.on('error', onServerError) + const bindPath = this.options.endpoint.bindPath() + this.server.listen(bindPath, () => { + this.options.endpoint.secureBindPath(bindPath) + const abandonStartup = (error: unknown): void => { + startupSettled = true + const server = this.server + this.server = null + reject(error) + server?.close() + this.options.endpoint.abandonBindPath(bindPath) + } + void this.options.endpoint.publish(bindPath).then(() => { + if (this.startupFailure) { + this.options.endpoint.retireUnstarted() + this.cancelInitialAdoptionTimer() + abandonStartup(this.startupFailure) + return + } + if (this.options.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION) { + this.armInitialAdoptionTimeout() + } + this.options.endpoint.startOwnershipWatch() + startupSettled = true + resolve() + }, abandonStartup) + }) + }) + } + + shutdown(): Promise { + if (!this.shutdownPromise) { + const serverClose = this.beginOrdinaryShutdownFence() + this.shutdownPromise = this.finishOrdinaryShutdown(serverClose) + } + return this.shutdownPromise + } + + onConnectionAccepted(): void { + this.cancelInitialAdoptionTimer() + } + + onAuthenticatedPair(): void { + this.initialAdoptionDeadlineMs = null + this.cancelInitialAdoptionTimer() + if (!this.options.endpoint.lost) { + this.retirementRequested = false + } + } + + onLastAuthenticatedClientDisconnected(): void { + if (this.state === 'running') { + this.retirementRequested = true + } + } + + onEndpointOwnershipLost(): void { + this.retirementRequested = true + this.reevaluateIdleShutdown() + } + + reevaluateIdleShutdown(): void { + if (this.state !== 'running') { + return + } + if (this.retirementRequested) { + this.cancelInitialAdoptionTimer() + if (this.options.isIdle()) { + this.beginIdleShutdown() + } + return + } + if (!this.options.isIdle() || this.initialAdoptionDeadlineMs === null) { + this.cancelInitialAdoptionTimer() + return + } + if (this.initialAdoptionTimer !== null) { + return + } + const remainingMs = Math.max(0, this.initialAdoptionDeadlineMs - this.options.clock.now()) + if (remainingMs === 0) { + this.initialAdoptionDeadlineMs = null + this.retirementRequested = true + this.beginIdleShutdown() + return + } + this.initialAdoptionTimer = this.options.clock.setTimeout(() => { + this.initialAdoptionTimer = null + this.initialAdoptionDeadlineMs = null + this.retirementRequested = true + this.beginIdleShutdown() + }, remainingMs) + } + + beginOrdinaryShutdownFence(): Promise { + this.state = 'shutting-down' + this.cancelInitialAdoptionTimer() + this.ordinaryShutdownServerClose ??= this.beginServerClose() + return this.ordinaryShutdownServerClose + } + + deferRpcShutdownUntilReply( + clientId: string, + requestId: string, + socket: Socket, + serverClose: Promise + ): void { + this.deferShutdownUntilReply(clientId, requestId, socket, () => + this.finishRpcShutdown(serverClose) + ) + } + finishRpcShutdownWithoutReply(serverClose: Promise): void { + this.shutdownPromise ??= this.finishRpcShutdown(serverClose) + } + + retireAfterIdleReply(clientId: string, requestId: string, socket: Socket): void { + this.state = 'shutting-down' + this.initialAdoptionDeadlineMs = null + this.retirementRequested = false + this.cancelInitialAdoptionTimer() + const serverClose = this.beginServerClose() + this.deferShutdownUntilReply(clientId, requestId, socket, () => + this.finishIdleShutdown(serverClose) + ) + } + + startPendingShutdownReply(clientId: string, requestId: string): void { + this.pendingShutdownReplies.get(this.shutdownReplyKey(clientId, requestId))?.start() + } + + private armInitialAdoptionTimeout(): void { + this.initialAdoptionDeadlineMs = + this.options.clock.now() + this.options.initialAdoptionTimeoutMs + this.reevaluateIdleShutdown() + } + + private cancelInitialAdoptionTimer(): void { + if (this.initialAdoptionTimer === null) { + return + } + this.options.clock.clearTimeout(this.initialAdoptionTimer) + this.initialAdoptionTimer = null + } + + private beginIdleShutdown(): void { + this.initialAdoptionTimer = null + if (this.state !== 'running') { + return + } + this.state = 'idle-shutdown-pending' + if (!this.options.isIdle()) { + this.state = 'running' + this.reevaluateIdleShutdown() + return + } + this.state = 'shutting-down' + const serverClose = this.beginServerClose() + this.shutdownPromise = this.finishIdleShutdown(serverClose) + } + + private async finishOrdinaryShutdown(serverClose: Promise): Promise { + this.options.endpoint.unlinkOwnedArtifacts() + await this.disposeResources() + await serverClose + } + + private async finishRpcShutdown(serverClose: Promise): Promise { + await this.finishOrdinaryShutdown(serverClose) + this.options.onRpcShutdown() + } + + private async finishIdleShutdown(serverClose: Promise): Promise { + this.options.endpoint.unlinkOwnedArtifacts() + await this.disposeResources() + await serverClose + this.options.onIdleShutdown() + } + + private async disposeResources(): Promise { + await this.options.disposeResources() + this.pendingShutdownReplies.clear() + } + + private beginServerClose(): Promise { + const server = this.server + this.server = null + if (!server) { + return Promise.resolve() + } + return new Promise((resolve) => server.close(() => resolve())) + } + + private deferShutdownUntilReply( + clientId: string, + requestId: string, + socket: Socket, + finish: () => Promise + ): void { + const key = this.shutdownReplyKey(clientId, requestId) + let started = false + let timer: ReturnType + const start = (): void => { + if (started) { + return + } + started = true + clearTimeout(timer) + socket.off('close', start) + socket.off('error', start) + this.pendingShutdownReplies.delete(key) + this.shutdownPromise ??= finish() + } + timer = setTimeout(start, DaemonServerLifecycle.SHUTDOWN_REPLY_FLUSH_TIMEOUT_MS) + timer.unref() + socket.once('close', start) + socket.once('error', start) + this.pendingShutdownReplies.set(key, { start }) + } + + private shutdownReplyKey(clientId: string, requestId: string): string { + return `${clientId}\u0000${requestId}` + } +} diff --git a/src/main/daemon/daemon-server-options.ts b/src/main/daemon/daemon-server-options.ts new file mode 100644 index 00000000000..06f2cda6cb9 --- /dev/null +++ b/src/main/daemon/daemon-server-options.ts @@ -0,0 +1,40 @@ +import type { DaemonFileLog } from './daemon-file-log' +import type { SubprocessHandle } from './session-subprocess-handle' + +export type DaemonServerOptions = { + socketPath: string + tokenPath: string + pidPath?: string + launchNonce?: string + startedAtMs?: number + publishEndpointOwnership?: () => void + entryPath?: string + appVersion?: string + spawnerExecPath?: string + protocolVersion?: number + onIdleShutdown?: () => void + onRpcShutdown?: () => void + initialAdoptionTestConfig?: { + timeoutMs: number + clock: { + setTimeout(callback: () => void, delayMs: number): unknown + clearTimeout(handle: unknown): void + now(): number + } + } + ptySpawnHealthCheck?: () => Promise + preparePtySpawn?: () => Promise + onPtySessionExit?: (sessionId: string) => void + onAuthenticatedClientPair?: () => void + log?: DaemonFileLog + spawnSubprocess: (opts: { + sessionId: string + cols: number + rows: number + cwd?: string + env?: Record + command?: string + shellOverride?: string + isCanceled?: () => boolean + }) => SubprocessHandle | Promise +} diff --git a/src/main/daemon/daemon-server.test.ts b/src/main/daemon/daemon-server.test.ts index a389cd88365..a9d62ac8b6f 100644 --- a/src/main/daemon/daemon-server.test.ts +++ b/src/main/daemon/daemon-server.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { existsSync, mkdtempSync, rmSync, readFileSync, writeFileSync } from 'node:fs' import { DaemonServer } from './daemon-server' +import type { ConnectedDaemonClient } from './daemon-client-connections' import { DaemonClient } from './client' import { encodeNdjson } from './ndjson' import { PROTOCOL_VERSION, type DaemonRequest } from './types' @@ -50,21 +51,16 @@ function createMockSubprocess(): SubprocessHandle & { } type DaemonServerPrivate = { - server: Server | null - pendingPtySpawnPreparations: Map> + lifecycle: { server: Server | null } + preparations: { pending: Map> } host: { kill: (sessionId: string, opts?: { immediate?: boolean }) => void | Promise + dispose: () => Promise + } + connections: { clients: Map } + requestRouter: { + route(clientId: string, request: DaemonRequest): Promise } - clients: Map< - string, - { - clientId: string - controlSocket: Socket - streamSocket: Socket | null - authenticatedPairEstablished: boolean - } - > - routeRequest(clientId: string, request: DaemonRequest): Promise } describe('DaemonServer', () => { @@ -345,11 +341,11 @@ describe('DaemonServer', () => { // Wait for the server to process the close (and cancel the prep) before // releasing the preflight, else the resumed spawn races ahead of cancellation. await vi.waitFor(() => - expect((server as unknown as DaemonServerPrivate).clients.size).toBe(0) + expect((server as unknown as DaemonServerPrivate).connections.clients.size).toBe(0) ) finishPreparation() await vi.waitFor(() => - expect((server as unknown as DaemonServerPrivate).pendingPtySpawnPreparations.size).toBe(0) + expect((server as unknown as DaemonServerPrivate).preparations.pending.size).toBe(0) ) expect(spawnSubprocess).not.toHaveBeenCalled() }) @@ -486,8 +482,8 @@ describe('DaemonServer', () => { const kill = vi.spyOn(daemon.host, 'kill').mockReturnValue(teardown) let acknowledged = false - const routed = daemon - .routeRequest('client-1', { + const routed = daemon.requestRouter + .route('client-1', { id: 'kill-1', type: 'kill', payload: { sessionId: 'agent-session', immediate: true } @@ -592,14 +588,14 @@ describe('DaemonServer', () => { write: vi.fn() } as unknown as Socket & { write: ReturnType } - daemon.clients.set('client-1', { + daemon.connections.clients.set('client-1', { clientId: 'client-1', controlSocket, streamSocket, authenticatedPairEstablished: true }) - await daemon.routeRequest('client-1', { + await daemon.requestRouter.route('client-1', { id: 'req-1', type: 'createOrAttach', payload: { sessionId: 'test-session', cols: 80, rows: 24 } @@ -612,7 +608,7 @@ describe('DaemonServer', () => { expect(String(streamSocket.write.mock.calls[0]?.[0])).toContain('"data":"background"') streamSocket.write.mockClear() - await daemon.routeRequest('client-1', { + await daemon.requestRouter.route('client-1', { id: 'req-2', type: 'write', payload: { sessionId: 'test-session', data: 'x' } @@ -649,14 +645,14 @@ describe('DaemonServer', () => { write: vi.fn() } as unknown as Socket & { write: ReturnType } - daemon.clients.set('client-1', { + daemon.connections.clients.set('client-1', { clientId: 'client-1', controlSocket, streamSocket, authenticatedPairEstablished: true }) - await daemon.routeRequest('client-1', { + await daemon.requestRouter.route('client-1', { id: 'req-1', type: 'createOrAttach', payload: { sessionId: 'test-session', cols: 80, rows: 24 } @@ -707,13 +703,13 @@ describe('DaemonServer', () => { writableLength: number } - daemon.clients.set('client-1', { + daemon.connections.clients.set('client-1', { clientId: 'client-1', controlSocket, streamSocket, authenticatedPairEstablished: true }) - await daemon.routeRequest('client-1', { + await daemon.requestRouter.route('client-1', { id: 'req-1', type: 'createOrAttach', payload: { sessionId: 'test-session', cols: 80, rows: 24 } @@ -780,11 +776,11 @@ describe('DaemonServer', () => { const control = await connectRawHello('control', 'raw-client') const stream = await connectRawHello('stream', 'raw-client') - expect(daemon.clients.get('raw-client')?.streamSocket).toBeTruthy() + expect(daemon.connections.clients.get('raw-client')?.streamSocket).toBeTruthy() stream.destroy() - await waitFor(() => daemon.clients.get('raw-client')?.streamSocket === null) + await waitFor(() => daemon.connections.clients.get('raw-client')?.streamSocket === null) control.destroy() }) @@ -801,7 +797,7 @@ describe('DaemonServer', () => { const secondStream = await connectRawHello('stream', 'raw-client') await waitFor(() => firstClosed) - expect(daemon.clients.get('raw-client')?.streamSocket).toBeTruthy() + expect(daemon.connections.clients.get('raw-client')?.streamSocket).toBeTruthy() secondStream.destroy() control.destroy() }) @@ -823,8 +819,8 @@ describe('DaemonServer', () => { const secondControl = await connectRawHello('control', 'raw-client') await waitFor(() => firstControlClosed && firstStreamClosed) - expect(daemon.clients.get('raw-client')?.controlSocket).toBeTruthy() - expect(daemon.clients.get('raw-client')?.streamSocket).toBeNull() + expect(daemon.connections.clients.get('raw-client')?.controlSocket).toBeTruthy() + expect(daemon.connections.clients.get('raw-client')?.streamSocket).toBeNull() secondControl.destroy() }) @@ -838,7 +834,7 @@ describe('DaemonServer', () => { }) await waitFor(() => closed || stream.destroyed) - expect(daemon.clients.has('missing-client')).toBe(false) + expect(daemon.connections.clients.has('missing-client')).toBe(false) }) }) @@ -850,7 +846,7 @@ describe('DaemonServer', () => { const daemon = server as unknown as DaemonServerPrivate & { host: { dispose: () => Promise } } - const controlSocket = [...daemon.clients.values()][0].controlSocket + const controlSocket = [...daemon.connections.clients.values()][0].controlSocket const originalWrite = controlSocket.write.bind(controlSocket) let replyFlushed: (() => void) | undefined vi.spyOn(controlSocket, 'write').mockImplementation((( @@ -932,7 +928,7 @@ describe('DaemonServer', () => { const c = await connectClient() await expect(c.request('shutdown', { killSessions: true })).resolves.toEqual({}) - await waitFor(() => daemon.server === null) + await waitFor(() => daemon.lifecycle.server === null) // Why not existsSync: the dead entry remains for the next publisher to replace. expect(await waitForEndpointUnreachable(socketPath)).toBe(true) const late = new DaemonClient({ socketPath, tokenPath }) diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 7a9196d0e0c..21514b0a850 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -1,854 +1,255 @@ -/* eslint-disable max-lines -- Why: one class owns the daemon socket protocol, routing, stream fanout, and session lifecycle. */ -import { createServer, type Server, type Socket } from 'node:net' import { randomUUID } from 'node:crypto' -import { performance } from 'node:perf_hooks' -import { writeFileSync, chmodSync, unlinkSync } from 'node:fs' -import { StringDecoder } from 'node:string_decoder' -import { encodeNdjson, createNdjsonParser } from './ndjson' -import { TerminalHost } from './terminal-host' -import { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' +import type { Socket } from 'node:net' +import { setImmediate as waitForImmediate } from 'node:timers/promises' +import { extractHiddenStartupRendererQueryData } from '../../shared/terminal-reply-query-extraction' import { BackgroundTransientFactRelay, BACKGROUND_STREAM_DROP_ENABLED } from './daemon-background-transient-facts' -import { extractHiddenStartupRendererQueryData } from '../../shared/terminal-reply-query-extraction' -import { - recordDaemonStreamBacklogEvent, - startDaemonStreamBacklogProbe -} from './daemon-stream-backlog-probe' -import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health' -import type { SubprocessHandle } from './session-subprocess-handle' -import { checkPtySpawnHealth } from './pty-subprocess' +import { DaemonClientConnections } from './daemon-client-connections' +import { DaemonEndpointLifecycle } from './daemon-endpoint-lifecycle' import { createNoopDaemonFileLog, type DaemonFileLog } from './daemon-file-log' -import { isTuiAgent } from '../../shared/tui-agent-config' -import { parsePtyStartupIngressIntent } from '../../shared/pty-startup-ingress' -import { unlinkOwnedDaemonPidFile, unlinkOwnedDaemonTokenFile } from './daemon-spawner' -import { - DAEMON_ENDPOINT_LOST_MESSAGE, - DaemonEndpointUnavailableError, - type DaemonEndpointOwnershipState, - getDaemonSocketBindPath, - publishDaemonEndpoint, - readDaemonEndpointOwnershipState, - type DaemonSocketIdentity -} from './daemon-endpoint-ownership' -import { probeSocketConnect } from './daemon-endpoint-probe' +import { DaemonPtySpawnPreparations } from './daemon-pty-spawn-preparations' +import { DaemonRequestRouter } from './daemon-request-router' +import { DaemonServerLifecycle } from './daemon-server-lifecycle' +import type { DaemonServerOptions } from './daemon-server-options' +import { DaemonSessionAttachments } from './daemon-session-attachments' +import { DaemonSessionBackgroundRouting } from './daemon-session-background-routing' +import { startDaemonStreamBacklogProbe } from './daemon-stream-backlog-probe' +import { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' +import { DaemonTerminalAdmission } from './daemon-terminal-admission' +import { checkPtySpawnHealth } from './pty-subprocess' +import { TerminalHistorySeedTransferRegistry } from './terminal-history-seed-transfer-registry' +import { TerminalHost } from './terminal-host' import { CLEAN_DISCONNECT_PROTOCOL_VERSION, - PROTOCOL_VERSION, NOTIFY_PREFIX, - SessionNotFoundError, - TerminalAttachCanceledError, - type HelloMessage, + PROTOCOL_VERSION, type DaemonRequest } from './types' -import { - isAgentSessionExecutionClaim, - isAgentSessionSurfaceBinding -} from '../../shared/agent-session-host-authority' -import { TerminalHistorySeedTransferRegistry } from './terminal-history-seed-transfer-registry' - -export type DaemonServerOptions = { - socketPath: string - tokenPath: string - pidPath?: string - launchNonce?: string - startedAtMs?: number - publishEndpointOwnership?: () => void - /** Reported in the hello so a repaired PID record can carry the real owner's metadata. */ - entryPath?: string - appVersion?: string - spawnerExecPath?: string - /** Direct-construction seam for protocol fixture tests; production never overrides it. */ - protocolVersion?: number - onIdleShutdown?: () => void - onRpcShutdown?: () => void - /** Direct-construction-only controls; production uses the compiled initial-adoption timeout. */ - initialAdoptionTestConfig?: { - timeoutMs: number - clock: { - setTimeout(callback: () => void, delayMs: number): unknown - clearTimeout(handle: unknown): void - now(): number - } - } - ptySpawnHealthCheck?: () => Promise - preparePtySpawn?: () => Promise - // Why: login-session death detection (#7936) probes on PTY-exit bursts and fresh app connections. - onPtySessionExit?: (sessionId: string) => void - onAuthenticatedClientPair?: () => void - log?: DaemonFileLog - spawnSubprocess: (opts: { - sessionId: string - cols: number - rows: number - cwd?: string - env?: Record - command?: string - shellOverride?: string - isCanceled?: () => boolean - // Async production spawns and sync test stubs share this boundary. - }) => SubprocessHandle | Promise -} - -type ConnectedClient = { - clientId: string - controlSocket: Socket - streamSocket: Socket | null - authenticatedPairEstablished: boolean -} - -type PendingPtySpawnPreparation = { - canceled: boolean - // Why: `canceled` is only polled between spawn steps; the signal is what - // actually interrupts an in-progress cwd probe on a dead share. - readonly controller: AbortController - cancelTimer?: ReturnType - // Why: preparations are keyed by sessionId, but a control-socket close must - // cancel only the disconnecting client's preps, not another client's (F4). - clientId: string - requestId: string -} - -function cancelPtySpawnPreparation(preparation: PendingPtySpawnPreparation): void { - preparation.canceled = true - preparation.controller.abort() -} - -type PendingShutdownReply = { - start: () => void -} +import { encodeNdjson } from './ndjson' export class DaemonServer { - // Why: survive long enough to adopt a first client pair, but don't orphan forever if the parent crashes first. private static readonly INITIAL_ADOPTION_TIMEOUT_MS = 2 * 60 * 1000 - private static readonly SHUTDOWN_REPLY_FLUSH_TIMEOUT_MS = 1_000 - private static readonly ENDPOINT_OWNERSHIP_POLL_MS = 30 * 1000 - private static readonly ENDPOINT_OWNERSHIP_LOSS_CONFIRMATIONS = 2 - private server: Server | null = null - private token: string - private host: TerminalHost - private socketPath: string - private tokenPath: string - private pidPath: string | null - private launchNonce: string | null - private startedAtMs: number | null - private publishEndpointOwnership: () => void - private entryPath: string | null - private appVersion: string | null - private spawnerExecPath: string | null - private ownedSocketIdentity: DaemonSocketIdentity | null = null - /** Set once start() has been rejected, so async publication can tell it is no longer wanted. */ - private startupFailure: Error | null = null - private endpointOwnershipTimer: ReturnType | null = null - private endpointOwnershipLossStreak = 0 - /** - * Sticky, and not inferred from `ownedSocketIdentity`: retiring nulls that, so a socket accepted - * before the takeover could finish its hello and reopen sessions on an unreachable daemon. - */ - private endpointOwnershipLost = false - private protocolVersion: number - private onIdleShutdown: () => void - private onRpcShutdown: () => void - private onAuthenticatedClientPair: () => void - private ptySpawnHealthCheck: () => Promise - private preparePtySpawn: () => Promise - private log: DaemonFileLog - private transportSockets = new Set() - private createOrAttachInFlight = 0 - private idleShutdownState: 'running' | 'idle-shutdown-pending' | 'shutting-down' = 'running' - private initialAdoptionTimer: unknown = null - private initialAdoptionDeadlineMs: number | null = null - private retirementRequested = false - private shutdownPromise: Promise | null = null - private ordinaryShutdownServerClose: Promise | null = null - private pendingShutdownReplies = new Map() - private initialAdoptionTimeoutMs: number - private lifecycleClock: NonNullable['clock'] - private clients = new Map() - private streamDataBatcher = new DaemonStreamDataBatcher( - (clientId) => this.clients.get(clientId), - { - isSessionDroppable: (sessionId) => - BACKGROUND_STREAM_DROP_ENABLED && this.transientFactRelay.isBackgrounded(sessionId), - salvageDroppedData: (dropped) => { - if (!dropped.includes('\x1b')) { - return '' - } - const extracted = extractHiddenStartupRendererQueryData(dropped, '') - return ( - extracted.statelessQueryData + extracted.statefulQueryData + extracted.oscColorQueryData - ) - } - } - ) - // Facts ride the stream queue as control entries so they hold byte order (else a fact could arrive after the reveal snapshot). - private transientFactRelay = new BackgroundTransientFactRelay((sessionId, fact) => { - const clientId = this.streamClientIdBySessionId.get(sessionId) - if (clientId) { - this.streamDataBatcher.enqueueControlEvent(clientId, sessionId, { - type: 'event', - event: 'transientFact', - sessionId, - payload: fact - }) - } - }) - private streamClientIdBySessionId = new Map() - private attachTokenBySessionId = new Map() - private lastInputAtBySessionId = new Map() - private pendingPtySpawnPreparations = new Map>() - private historySeedTransfers = new TerminalHistorySeedTransferRegistry() + private readonly log: DaemonFileLog + private readonly host: TerminalHost + private readonly transientFactRelay: BackgroundTransientFactRelay + private readonly streamDataBatcher: DaemonStreamDataBatcher + private readonly attachments: DaemonSessionAttachments + private readonly historySeedTransfers = new TerminalHistorySeedTransferRegistry() + private readonly preparations: DaemonPtySpawnPreparations + private readonly connections: DaemonClientConnections + private readonly endpoint: DaemonEndpointLifecycle + private readonly lifecycle: DaemonServerLifecycle + private readonly admission: DaemonTerminalAdmission + private readonly requestRouter: DaemonRequestRouter private stopStreamBacklogProbe: () => void = () => {} - // Why: bypass batching within this window so keystroke echo/redraws skip the daemon's fixed batch delay. - private static readonly INTERACTIVE_OUTPUT_WINDOW_MS = 100 - private static readonly INTERACTIVE_OUTPUT_MAX_CHARS = 1024 + constructor(options: DaemonServerOptions) { + this.log = options.log ?? createNoopDaemonFileLog() + this.host = new TerminalHost({ + spawnSubprocess: options.spawnSubprocess, + reportReadinessEvent: (event, details) => this.log.log(event, details), + onSessionReaped: (sessionId) => { + this.attachments.release(sessionId) + this.transientFactRelay.onSessionExit(sessionId) + this.streamDataBatcher.refreshSessionDroppability(sessionId) + options.onPtySessionExit?.(sessionId) + this.lifecycle.reevaluateIdleShutdown() + } + }) + this.attachments = new DaemonSessionAttachments(this.host) + this.transientFactRelay = new BackgroundTransientFactRelay((sessionId, fact) => { + const clientId = this.attachments.clientIdForSession(sessionId) + if (clientId) { + this.streamDataBatcher.enqueueControlEvent(clientId, sessionId, { + type: 'event', + event: 'transientFact', + sessionId, + payload: fact + }) + } + }) + this.streamDataBatcher = new DaemonStreamDataBatcher( + (clientId) => this.connections.get(clientId), + { + isSessionDroppable: (sessionId) => + BACKGROUND_STREAM_DROP_ENABLED && this.transientFactRelay.isBackgrounded(sessionId), + salvageDroppedData: (dropped) => { + if (!dropped.includes('\x1b')) { + return '' + } + const extracted = extractHiddenStartupRendererQueryData(dropped, '') + return ( + extracted.statelessQueryData + extracted.statefulQueryData + extracted.oscColorQueryData + ) + } + } + ) + this.preparations = new DaemonPtySpawnPreparations( + options.preparePtySpawn ?? (() => Promise.resolve()) + ) - constructor(opts: DaemonServerOptions) { - this.socketPath = opts.socketPath - this.tokenPath = opts.tokenPath - this.pidPath = opts.pidPath ?? null - this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION - this.launchNonce = - opts.launchNonce ?? - (this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION ? randomUUID() : null) - this.startedAtMs = - opts.startedAtMs ?? - (this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION + const protocolVersion = options.protocolVersion ?? PROTOCOL_VERSION + const launchNonce = + options.launchNonce ?? + (protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION ? randomUUID() : null) + const startedAtMs = + options.startedAtMs ?? + (protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION ? Date.now() - process.uptime() * 1000 : null) - this.publishEndpointOwnership = opts.publishEndpointOwnership ?? (() => {}) - this.entryPath = opts.entryPath ?? null - this.appVersion = opts.appVersion ?? null - this.spawnerExecPath = opts.spawnerExecPath ?? null - this.onIdleShutdown = opts.onIdleShutdown ?? (() => {}) - this.onRpcShutdown = opts.onRpcShutdown ?? (() => {}) - this.initialAdoptionTimeoutMs = - opts.initialAdoptionTestConfig?.timeoutMs ?? DaemonServer.INITIAL_ADOPTION_TIMEOUT_MS - this.lifecycleClock = opts.initialAdoptionTestConfig?.clock ?? { - setTimeout: (callback, delayMs) => { + const token = randomUUID() + + this.connections = new DaemonClientConnections({ + token, + protocolVersion, + identity: { + launchNonce, + startedAtMs, + entryPath: options.entryPath ?? null, + appVersion: options.appVersion ?? null, + spawnerExecPath: options.spawnerExecPath ?? null + }, + log: this.log, + streamDataBatcher: this.streamDataBatcher, + isAcceptingWork: () => this.lifecycle.isAcceptingWork(), + onTransportChanged: () => this.lifecycle.reevaluateIdleShutdown(), + onConnectionAccepted: () => this.lifecycle.onConnectionAccepted(), + onAuthenticatedPair: () => { + options.onAuthenticatedClientPair?.() + this.lifecycle.onAuthenticatedPair() + }, + onLastAuthenticatedClientDisconnected: () => + this.lifecycle.onLastAuthenticatedClientDisconnected(), + onControlRequest: (socket, clientId, request) => + void this.handleRequest(socket, clientId, request), + onControlReplaced: (clientId) => { + this.preparations.cancelForClient(clientId) + this.historySeedTransfers.clearOwner(clientId) + }, + onClientDisconnected: (clientId) => { + this.preparations.cancelForClient(clientId) + this.historySeedTransfers.clearOwner(clientId) + this.streamDataBatcher.clear(clientId) + this.attachments.detachClientSessions(clientId) + }, + onStreamDisconnected: (clientId) => { + this.preparations.cancelForClient(clientId) + this.streamDataBatcher.clear(clientId) + this.attachments.detachClientSessions(clientId) + } + }) + this.endpoint = new DaemonEndpointLifecycle({ + socketPath: options.socketPath, + tokenPath: options.tokenPath, + pidPath: options.pidPath ?? null, + launchNonce, + token, + publishEndpointOwnership: options.publishEndpointOwnership ?? (() => {}), + log: this.log, + isServing: () => this.lifecycle.isAcceptingWork(), + onOwnershipLost: () => this.lifecycle.onEndpointOwnershipLost() + }) + const lifecycleClock = options.initialAdoptionTestConfig?.clock ?? { + setTimeout: (callback: () => void, delayMs: number) => { const timer = setTimeout(callback, delayMs) timer.unref() return timer }, - clearTimeout: (handle) => clearTimeout(handle as ReturnType), + clearTimeout: (handle: unknown) => clearTimeout(handle as ReturnType), now: () => Date.now() } - this.token = randomUUID() - this.onAuthenticatedClientPair = opts.onAuthenticatedClientPair ?? (() => {}) - this.host = new TerminalHost({ - spawnSubprocess: opts.spawnSubprocess, - // Why a closure: this.log is assigned after this constructor call, and the - // callback only fires once a session is live. - reportReadinessEvent: (event, details) => this.log.log(event, details), - // Why host-level and not the attach callback: a session whose client transport already dropped - // has no attachment left to fire exit bookkeeping, and the daemon must still notice it can idle. - onSessionReaped: (sessionId) => { - this.streamClientIdBySessionId.delete(sessionId) - this.attachTokenBySessionId.delete(sessionId) - this.lastInputAtBySessionId.delete(sessionId) - this.transientFactRelay.onSessionExit(sessionId) - this.streamDataBatcher.refreshSessionDroppability(sessionId) - opts.onPtySessionExit?.(sessionId) - this.reevaluateIdleShutdown() - } + this.lifecycle = new DaemonServerLifecycle({ + protocolVersion, + initialAdoptionTimeoutMs: + options.initialAdoptionTestConfig?.timeoutMs ?? DaemonServer.INITIAL_ADOPTION_TIMEOUT_MS, + clock: lifecycleClock, + endpoint: this.endpoint, + log: this.log, + isIdle: () => this.isIdle(), + disposeResources: () => this.disposeResources(), + onIdleShutdown: options.onIdleShutdown ?? (() => {}), + onRpcShutdown: options.onRpcShutdown ?? (() => {}) + }) + this.admission = new DaemonTerminalAdmission({ + host: this.host, + connections: this.connections, + endpoint: this.endpoint, + preparations: this.preparations, + attachments: this.attachments, + historySeedTransfers: this.historySeedTransfers, + transientFactRelay: this.transientFactRelay, + streamDataBatcher: this.streamDataBatcher, + log: this.log, + isAcceptingWork: () => this.lifecycle.isAcceptingWork(), + requestEndpointRetirement: () => this.endpoint.requestRetirementForLoss(), + reevaluateIdleShutdown: () => this.lifecycle.reevaluateIdleShutdown() + }) + this.requestRouter = new DaemonRequestRouter({ + host: this.host, + connections: this.connections, + lifecycle: this.lifecycle, + admission: this.admission, + preparations: this.preparations, + attachments: this.attachments, + historySeedTransfers: this.historySeedTransfers, + sessionBackgroundRouting: new DaemonSessionBackgroundRouting({ + host: this.host, + attachments: this.attachments, + transientFactRelay: this.transientFactRelay, + streamDataBatcher: this.streamDataBatcher + }), + streamDataBatcher: this.streamDataBatcher, + ptySpawnHealthCheck: options.ptySpawnHealthCheck ?? checkPtySpawnHealth, + log: this.log }) - this.ptySpawnHealthCheck = opts.ptySpawnHealthCheck ?? checkPtySpawnHealth - this.preparePtySpawn = opts.preparePtySpawn ?? (() => Promise.resolve()) this.stopStreamBacklogProbe = startDaemonStreamBacklogProbe(() => ({ - clients: Array.from(this.clients.values(), (client) => ({ + clients: Array.from(this.connections.values(), (client) => ({ clientId: client.clientId, socketBufferedBytes: client.streamSocket?.writableLength ?? 0, batcherQueuedChars: this.streamDataBatcher.queuedCharsForClient(client.clientId) })), backgroundedSessionIdSuffixes: this.transientFactRelay.backgroundedSessionIdSuffixes() })) - this.log = opts.log ?? createNoopDaemonFileLog() } - async start(): Promise { - return new Promise((resolve, reject) => { - this.server = createServer((socket) => this.handleConnection(socket)) - // Permanent, not `once`: an unhandled 'error' is an uncaught exception, so a post-startup - // accept failure (EMFILE) would kill a daemon hosting every terminal. - let startupSettled = false - const onServerError = (err: Error): void => { - if (startupSettled) { - // The daemon is serving; an operational error must not read as a startup failure. - this.log.log('server-error', { message: err.message }) - console.warn(`[daemon] Socket server error: ${err.message}`) - return - } - startupSettled = true - // Publishing is async, so the continuation reads this and tears down instead of arming - // a daemon whose caller was already told startup failed. - this.startupFailure = err - reject(err) - } - - this.server.on('error', onServerError) - - // Private bind name, linked into place: libuv's close-time unlink can then only ever - // remove our own name, never a replacement daemon's endpoint. - const bindPath = - process.platform === 'win32' ? this.socketPath : getDaemonSocketBindPath(this.socketPath) - - this.server.listen(bindPath, () => { - try { - // So the endpoint is never briefly reachable with default permissions. - chmodSync(bindPath, 0o600) - } catch { - // Best-effort on platforms that support it - } - const abandonStartup = (error: unknown): void => { - startupSettled = true - const server = this.server - this.server = null - // Settle before close: an accepted connection defers the close callback indefinitely. - reject(error) - server?.close() - if (process.platform !== 'win32') { - try { - unlinkSync(bindPath) - } catch { - // Already consumed by a successful link or rename, or never created. - } - } - } - void this.publishAndArm(bindPath).then(() => { - // The server can fail while publishing awaits its probe; resolving then would strand a - // live daemon behind a caller told startup failed, who would launch a replacement. - if (this.startupFailure) { - this.retireUnstartedDaemon() - abandonStartup(this.startupFailure) - return - } - // Serving from here, so later server errors are logged, not treated as startup failure. - startupSettled = true - resolve() - }, abandonStartup) - }) - }) + start(): Promise { + return this.lifecycle.start((socket) => this.connections.accept(socket)) } - /** - * Takes the canonical endpoint, then makes this listener adoptable — in that order. Never rolled - * back: an aborting daemon just closes, and the next publisher replaces the dead entry. - */ - private async publishAndArm(bindPath: string): Promise { - const outcome = await publishDaemonEndpoint(bindPath, this.socketPath, probeSocketConnect) - if (outcome.status !== 'published') { - // The only point the design declines to serve, so a field regression surfaces here. - this.log.log('endpoint-publish-declined', { reason: outcome.status }) - console.warn(`[daemon] Endpoint unavailable at startup: reason=${outcome.status}`) - throw new DaemonEndpointUnavailableError(outcome.status) + shutdown(): Promise { + return this.lifecycle.shutdown() + } + + private isIdle(): boolean { + if (this.admission.inFlight > 0 || this.host.listSessions().length > 0) { + return false } - this.ownedSocketIdentity = outcome.identity - let publishedOwnership = false - try { - // The PID/nonce record must exist before the token makes this listener adoptable. - this.publishEndpointOwnership() - publishedOwnership = true - writeFileSync(this.tokenPath, this.token, { mode: 0o600 }) - } catch (error) { - // Roll back only a record we wrote; anything else at that path belongs to another daemon. - if (publishedOwnership && this.pidPath && this.launchNonce) { - unlinkOwnedDaemonPidFile(this.pidPath, process.pid, this.launchNonce) - } - this.ownedSocketIdentity = null - throw error - } - if (this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION) { - // A parent crash before the first client pair must not strand an empty daemon forever. - this.armInitialAdoptionTimeout() - } - this.startEndpointOwnershipWatch() - } - - /** - * Stands down a daemon that published after its startup was already reported failed. The - * endpoint stays, as on every exit path — removing it could delete a replacement's name. - */ - private retireUnstartedDaemon(): void { - this.stopEndpointOwnershipWatch() - this.cancelInitialAdoptionTimer() - this.unlinkOwnedEndpointArtifacts() - } - - async shutdown(): Promise { - if (this.shutdownPromise) { - return this.shutdownPromise - } - const serverClose = this.beginOrdinaryShutdownFence() - this.shutdownPromise = this.finishOrdinaryShutdown(serverClose) - return this.shutdownPromise - } - - private beginOrdinaryShutdownFence(): Promise { - this.idleShutdownState = 'shutting-down' - this.cancelInitialAdoptionTimer() - this.ordinaryShutdownServerClose ??= this.beginServerClose() - return this.ordinaryShutdownServerClose - } - - private async finishOrdinaryShutdown(serverClose: Promise): Promise { - this.unlinkOwnedEndpointArtifacts() - await this.disposeDaemonResources() - await serverClose - } - - private async finishRpcShutdown(serverClose: Promise): Promise { - await this.finishOrdinaryShutdown(serverClose) - this.onRpcShutdown() - } - - private unlinkOwnedEndpointArtifacts(): void { - // Ownership-checked so a late replacement's token or PID record is never removed. - unlinkOwnedDaemonTokenFile(this.tokenPath, this.token) - if (this.pidPath && this.launchNonce) { - unlinkOwnedDaemonPidFile(this.pidPath, process.pid, this.launchNonce) - } - // The endpoint is deliberately left: fencing its removal against a replacement that published - // meanwhile was this component's largest source of defects, and a dead entry costs nothing. - this.ownedSocketIdentity = null - } - - private startEndpointOwnershipWatch(): void { - if (process.platform === 'win32' || !this.ownedSocketIdentity) { - return - } - this.endpointOwnershipTimer = setInterval( - () => this.checkEndpointOwnership(), - DaemonServer.ENDPOINT_OWNERSHIP_POLL_MS - ) - // A liveness poll must never be the reason the process cannot exit. - this.endpointOwnershipTimer.unref() - } - - private stopEndpointOwnershipWatch(): void { - if (this.endpointOwnershipTimer === null) { - return - } - clearInterval(this.endpointOwnershipTimer) - this.endpointOwnershipTimer = null - } - - /** - * Retires a daemon that no longer owns the canonical endpoint. - * - * Such a daemon keeps hosting PTYs no client can reach — terminals that take input and never - * run it. Retirement drains rather than kills: sessions finish and the process exits once idle. - */ - private checkEndpointOwnership(): void { - if (process.platform === 'win32' || !this.ownedSocketIdentity || this.shutdownPromise) { - return - } - // An inconclusive stat resets too: EACCES must not retire a daemon that is still serving. - if (this.observeEndpointOwnership() !== 'lost') { - return - } - this.endpointOwnershipLossStreak++ - // Two, though a single rename leaves no gap to misread: this is a backstop, not the detector. - if (this.endpointOwnershipLossStreak < DaemonServer.ENDPOINT_OWNERSHIP_LOSS_CONFIRMATIONS) { - return - } - this.requestRetirementForLostEndpoint() - } - - /** - * Whether the endpoint demonstrably no longer resolves to this daemon. Only positive evidence - * counts — treating an unreadable stat as loss would refuse sessions on a healthy daemon. - */ - private hasLostEndpointOwnership(): boolean { - if (this.endpointOwnershipLost) { + if (this.endpoint.lost) { return true } - return this.observeEndpointOwnership() === 'lost' + return this.connections.transportCount === 0 && this.connections.size === 0 } - /** - * Reads ownership and keeps the watchdog's loss streak honest. Both callers come through here - * because the watchdog retires on *consecutive* losses: a read that skipped the streak let two - * losses separated by an owned observation count as consecutive, poisoning a healthy daemon. - */ - private observeEndpointOwnership(): DaemonEndpointOwnershipState { - // The running check, not just `shutdownPromise`: both shutdown routes close the server before - // assigning it, and in that window the listener is gone while the recorded identity is not. - if ( - process.platform === 'win32' || - !this.ownedSocketIdentity || - this.idleShutdownState !== 'running' - ) { - return 'indeterminate' - } - const state = readDaemonEndpointOwnershipState(this.socketPath, this.ownedSocketIdentity) - if (state !== 'lost') { - this.endpointOwnershipLossStreak = 0 - } - return state - } - - private requestRetirementForLostEndpoint(): void { - // Recorded before any dedup and not gated on retirement: folding the two together left the - // loss unrecorded when already retiring, and a later hello returned the daemon to service. - const alreadyLost = this.endpointOwnershipLost - this.endpointOwnershipLost = true - this.ownedSocketIdentity = null - if (!alreadyLost) { - this.log.log('endpoint-ownership-lost', { socketPath: this.socketPath }) - console.warn( - '[daemon] Endpoint ownership lost to another daemon — retiring once existing sessions end' - ) - } - this.retirementRequested = true - // The identity it compares against was just cleared, so every later tick early-returns. - this.stopEndpointOwnershipWatch() - this.reevaluateIdleShutdown() - } - - private async disposeDaemonResources(): Promise { - this.stopEndpointOwnershipWatch() + private async disposeResources(): Promise { + this.endpoint.stopOwnershipWatch() this.stopStreamBacklogProbe() this.transientFactRelay.dispose() - this.cancelAllPendingPtySpawnPreparations() + this.preparations.cancelAll() try { await this.host.dispose() - } catch (err) { - // Why: an unreapable child must not block daemon exit — post-exit it reparents to init anyway. + } catch (error) { this.log.log('shutdown-dispose-failed', { - error: err instanceof Error ? err.message : String(err) + error: error instanceof Error ? error.message : String(error) }) } this.streamDataBatcher.clear() this.historySeedTransfers.dispose() - this.pendingShutdownReplies.clear() - - for (const [, client] of this.clients) { - client.controlSocket.destroy() - client.streamSocket?.destroy() - } - this.clients.clear() - for (const socket of this.transportSockets) { - socket.destroy() - } - this.transportSockets.clear() - } - - private beginServerClose(): Promise { - const server = this.server - this.server = null - if (!server) { - return Promise.resolve() - } - return new Promise((resolve) => { - // Why: close synchronously before any awaited cleanup so no new transport enters after the empty proof. - server.close(() => { - // Why: libuv unlinks the path this server bound, which is our private bind name and is - // already gone. Nothing removes the canonical endpoint — a departing daemon leaves it - // for the next publisher to replace — so closing late cannot delete a replacement's. - resolve() - }) - }) - } - - private isIdle(): boolean { - if (this.createOrAttachInFlight > 0 || this.host.listSessions().length > 0) { - return false - } - // Why open connections stop counting once the endpoint is lost: they belong to clients that - // reached this daemon before the takeover, and holding them open cannot make it reachable to - // anyone new. Waiting for them turns a drained retirement into an orphan that outlives its - // usefulness — the sessions are gone and nothing can route a new one here. - if (this.endpointOwnershipLost) { - return true - } - return this.transportSockets.size === 0 && this.clients.size === 0 - } - - private reevaluateIdleShutdown(): void { - if (this.idleShutdownState !== 'running') { - return - } - if (this.retirementRequested) { - this.cancelInitialAdoptionTimer() - if (this.isIdle()) { - this.beginIdleShutdown() - } - return - } - if (!this.isIdle() || this.initialAdoptionDeadlineMs === null) { - this.cancelInitialAdoptionTimer() - return - } - if (this.initialAdoptionTimer !== null) { - return - } - const remainingMs = Math.max(0, this.initialAdoptionDeadlineMs - this.lifecycleClock.now()) - if (remainingMs === 0) { - this.initialAdoptionDeadlineMs = null - this.retirementRequested = true - this.beginIdleShutdown() - return - } - this.initialAdoptionTimer = this.lifecycleClock.setTimeout(() => { - this.initialAdoptionTimer = null - this.initialAdoptionDeadlineMs = null - this.retirementRequested = true - this.beginIdleShutdown() - }, remainingMs) - } - - private armInitialAdoptionTimeout(): void { - this.initialAdoptionDeadlineMs = this.lifecycleClock.now() + this.initialAdoptionTimeoutMs - this.reevaluateIdleShutdown() - } - - private cancelInitialAdoptionTimer(): void { - if (this.initialAdoptionTimer === null) { - return - } - this.lifecycleClock.clearTimeout(this.initialAdoptionTimer) - this.initialAdoptionTimer = null - } - - private beginIdleShutdown(): void { - this.initialAdoptionTimer = null - if (this.idleShutdownState !== 'running') { - return - } - this.idleShutdownState = 'idle-shutdown-pending' - if (!this.isIdle()) { - // Why: work admitted before the fence wins; clear pending state to keep it usable. - this.idleShutdownState = 'running' - this.reevaluateIdleShutdown() - return - } - - this.idleShutdownState = 'shutting-down' - // beginServerClose() runs synchronously up to server.close() before any yield to a racing connection. - const serverClose = this.beginServerClose() - this.shutdownPromise = this.finishIdleShutdown(serverClose) - } - - private async finishIdleShutdown(serverClose: Promise): Promise { - this.unlinkOwnedEndpointArtifacts() - await this.disposeDaemonResources() - await serverClose - this.onIdleShutdown() - } - - private handleConnection(socket: Socket): void { - this.cancelInitialAdoptionTimer() - this.transportSockets.add(socket) - const removeTransport = (): void => { - this.transportSockets.delete(socket) - this.reevaluateIdleShutdown() - } - socket.once('close', removeTransport) - socket.on('error', () => socket.destroy()) - - if (this.idleShutdownState !== 'running') { - // Why: a connection accepted just before close() gets an explicit retry signal instead of dying mid-auth. - socket.end( - encodeNdjson({ - type: 'hello', - ok: false, - error: 'Daemon temporarily unavailable; reconnect', - retryable: true - }) - ) - return - } - // Why: keep UTF-8 sequences intact across socket chunks before NDJSON parsing. - const decoder = new StringDecoder('utf8') - const parser = createNdjsonParser( - (msg) => this.handleFirstMessage(socket, msg, parser), - () => { - socket.destroy() - } - ) - - socket.on('data', (chunk) => parser.feed(decoder.write(chunk))) - } - - private handleFirstMessage( - socket: Socket, - msg: unknown, - _parser: ReturnType - ): void { - const hello = msg as HelloMessage - if (hello.type !== 'hello') { - this.log.log('client-hello-rejected', { reason: 'expected-hello' }) - socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Expected hello' })) - socket.destroy() - return - } - - if (hello.version !== this.protocolVersion) { - this.log.log('client-hello-rejected', { - reason: 'protocol-mismatch', - clientVersion: hello.version - }) - socket.write( - encodeNdjson({ - type: 'hello', - ok: false, - error: 'Protocol version mismatch' - }) - ) - socket.destroy() - return - } - - if (hello.token !== this.token) { - this.log.log('client-hello-rejected', { - reason: 'invalid-token', - role: hello.role - }) - socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Invalid token' })) - socket.destroy() - return - } - - this.log.log('client-hello-accepted', { - role: hello.role, - clientId: hello.clientId - }) - socket.write( - encodeNdjson({ - type: 'hello', - ok: true, - ...(this.launchNonce && this.startedAtMs - ? { - daemonIdentity: { - pid: process.pid, - startedAtMs: this.startedAtMs, - launchNonce: this.launchNonce, - ...(this.entryPath ? { entryPath: this.entryPath } : {}), - ...(this.appVersion ? { appVersion: this.appVersion } : {}), - ...(this.spawnerExecPath ? { spawnerExecPath: this.spawnerExecPath } : {}) - } - } - : {}) - }) - ) - - if (hello.role === 'control') { - const previous = this.clients.get(hello.clientId) - const client: ConnectedClient = { - clientId: hello.clientId, - controlSocket: socket, - streamSocket: null, - authenticatedPairEstablished: false - } - this.clients.set(hello.clientId, client) - this.setupControlSocket(socket, hello.clientId) - if (previous) { - // Why: reconnect reuses clientId before stale close fires; cancel the old owner's preflight at handoff. - this.cancelPendingPtySpawnPreparationsForClient(hello.clientId) - this.historySeedTransfers.clearOwner(hello.clientId) - this.recordFullyAuthenticatedDisconnect(previous.authenticatedPairEstablished) - // Why: tear down the old sockets after installing the new owner so a stale close can't delete the replacement. - previous.streamSocket?.destroy() - previous.controlSocket.destroy() - } - } else if (hello.role === 'stream') { - const client = this.clients.get(hello.clientId) - if (!client) { - // Why: a stream socket is meaningless without its control socket; drop the orphan. - socket.destroy() - return - } - this.setupStreamSocket(socket, client) - client.authenticatedPairEstablished = true - // Why: one-shot health probes authenticate only a control socket; they are not fresh app activity. - this.onAuthenticatedClientPair() - // A complete app connection (unlike a probe) re-owns the endpoint and cancels pending - // retirement — but not a retirement caused by losing the endpoint itself. A client that - // connected before the takeover cannot make this daemon reachable again, and treating it - // as re-ownership would reopen session creation on a daemon nothing can find. - this.initialAdoptionDeadlineMs = null - this.cancelInitialAdoptionTimer() - if (!this.endpointOwnershipLost) { - this.retirementRequested = false - } - } - } - - private setupControlSocket(socket: Socket, clientId: string): void { - // Why: decode as a UTF-8 stream so emoji/Unicode split across chunks isn't corrupted. - const decoder = new StringDecoder('utf8') - const parser = createNdjsonParser( - (msg) => this.handleRequest(socket, clientId, msg as DaemonRequest), - () => {} // Ignore parse errors - ) - - // Remove the initial data listener and replace with the RPC parser - socket.removeAllListeners('data') - socket.on('data', (chunk) => parser.feed(decoder.write(chunk))) - - socket.on('close', () => { - const client = this.clients.get(clientId) - if (client?.controlSocket !== socket) { - return - } - // Why: a client that disconnects mid-preflight would otherwise still create - // its daemon PTY, orphaning a durable, unattached session — cancel its preps (F4). - this.cancelPendingPtySpawnPreparationsForClient(clientId) - this.historySeedTransfers.clearOwner(clientId) - const wasFullyAuthenticated = client.authenticatedPairEstablished - this.streamDataBatcher.clear(clientId) - this.detachClientSessions(clientId) - client.streamSocket?.destroy() - this.clients.delete(clientId) - this.recordFullyAuthenticatedDisconnect(wasFullyAuthenticated) - this.reevaluateIdleShutdown() - }) - } - - private recordFullyAuthenticatedDisconnect(wasFullyAuthenticated: boolean): void { - if ( - !wasFullyAuthenticated || - [...this.clients.values()].some((remaining) => remaining.authenticatedPairEstablished) || - this.idleShutdownState !== 'running' - ) { - return - } - // Why: once the last full client is gone, incomplete transports may block retirement but never erase it. - this.retirementRequested = true - } - - private setupStreamSocket(socket: Socket, client: ConnectedClient): void { - const previous = client.streamSocket - socket.removeAllListeners('data') - client.streamSocket = socket - // Why: 'drain' is the wake-up for the batcher's shallow-gate held bulk. - socket.on('drain', () => { - this.streamDataBatcher.flush(client.clientId) - }) - - const cleanup = (): void => { - socket.removeListener('close', cleanup) - socket.removeListener('error', cleanup) - if (this.clients.get(client.clientId) !== client || client.streamSocket !== socket) { - return - } - // Why: a preflight that outlives its output channel would create an unattached daemon PTY. - this.cancelPendingPtySpawnPreparationsForClient(client.clientId) - this.streamDataBatcher.clear(client.clientId) - this.detachClientSessions(client.clientId) - client.streamSocket = null - } - - socket.on('close', cleanup) - socket.on('error', cleanup) - - if (previous && previous !== socket) { - // Why: replacing a stream socket must not leave the old channel alive and untracked. - previous.destroy() - } + // Let canceled in-flight RPCs enqueue their protocol errors before transport destruction. + await waitForImmediate() + this.connections.dispose() } private async handleRequest( @@ -857,683 +258,23 @@ export class DaemonServer { request: DaemonRequest ): Promise { const isNotify = request.id.startsWith(NOTIFY_PREFIX) - try { - const result = await this.routeRequest(clientId, request) + const result = await this.requestRouter.route(clientId, request) if (!isNotify) { - const pendingShutdown = this.pendingShutdownReplies.get( - this.shutdownReplyKey(clientId, request.id) - ) socket.write(encodeNdjson({ id: request.id, ok: true, payload: result }), () => { - pendingShutdown?.start() + this.lifecycle.startPendingShutdownReply(clientId, request.id) }) } - } catch (err) { + } catch (error) { if (!isNotify) { socket.write( encodeNdjson({ id: request.id, ok: false, - error: err instanceof Error ? err.message : String(err) + error: error instanceof Error ? error.message : String(error) }) ) } } } - - private shutdownReplyKey(clientId: string, requestId: string): string { - return `${clientId}\u0000${requestId}` - } - - private deferShutdownUntilReply( - clientId: string, - requestId: string, - socket: Socket, - finish: () => Promise - ): void { - const key = this.shutdownReplyKey(clientId, requestId) - let started = false - let timer: ReturnType - const start = (): void => { - if (started) { - return - } - started = true - clearTimeout(timer) - socket.off('close', start) - socket.off('error', start) - this.pendingShutdownReplies.delete(key) - if (!this.shutdownPromise) { - this.shutdownPromise = finish() - } - } - // Why: a non-reading peer must not pin a fenced daemon by holding its ack behind permanent socket backpressure. - timer = setTimeout(start, DaemonServer.SHUTDOWN_REPLY_FLUSH_TIMEOUT_MS) - timer.unref() - socket.once('close', start) - socket.once('error', start) - this.pendingShutdownReplies.set(key, { start }) - } - - /** - * Registers a cancellable preparation without doing spawn preflight. Every - * createOrAttach registers one, including attach-only: without it the server - * cannot match the client's cancel, and an attach-only request queued behind a - * hung create is bounded by neither side. - */ - private registerPtySpawnPreparation( - sessionId: string, - clientId: string, - requestId: string, - cancelAfterMs: unknown - ): PendingPtySpawnPreparation { - const preparation: PendingPtySpawnPreparation = { - canceled: false, - controller: new AbortController(), - clientId, - requestId - } - if (Number.isSafeInteger(cancelAfterMs) && Number(cancelAfterMs) > 0) { - preparation.cancelTimer = setTimeout( - () => { - cancelPtySpawnPreparation(preparation) - }, - Math.min(Number(cancelAfterMs), 300_000) - ) - preparation.cancelTimer.unref() - } - const pending = this.pendingPtySpawnPreparations.get(sessionId) ?? new Set() - pending.add(preparation) - this.pendingPtySpawnPreparations.set(sessionId, pending) - return preparation - } - - private async preparePtySpawnUnlessCanceled( - sessionId: string, - preparation: PendingPtySpawnPreparation - ): Promise { - // Why: registered before the async probe so a concurrent close can cancel this creation before a subprocess exists. - await this.preparePtySpawn() - if (preparation.canceled) { - throw new TerminalAttachCanceledError(sessionId) - } - } - - private finishPtySpawnPreparation( - sessionId: string, - preparation: PendingPtySpawnPreparation - ): void { - if (preparation.cancelTimer) { - clearTimeout(preparation.cancelTimer) - } - const pending = this.pendingPtySpawnPreparations.get(sessionId) - pending?.delete(preparation) - if (pending?.size === 0) { - this.pendingPtySpawnPreparations.delete(sessionId) - } - } - - private cancelPendingPtySpawnPreparations( - sessionId: string, - request?: { clientId: string; requestId?: string } - ): boolean { - const pending = this.pendingPtySpawnPreparations.get(sessionId) - if (!pending) { - return false - } - let canceled = false - for (const preparation of pending) { - if ( - request && - (preparation.clientId !== request.clientId || - (request.requestId !== undefined && preparation.requestId !== request.requestId)) - ) { - continue - } - cancelPtySpawnPreparation(preparation) - canceled = true - } - return canceled - } - - private cancelAllPendingPtySpawnPreparations(): void { - for (const sessionId of this.pendingPtySpawnPreparations.keys()) { - this.cancelPendingPtySpawnPreparations(sessionId) - } - } - - private cancelPendingPtySpawnPreparationsForClient(clientId: string): void { - for (const pending of this.pendingPtySpawnPreparations.values()) { - for (const preparation of pending) { - if (preparation.clientId === clientId) { - cancelPtySpawnPreparation(preparation) - } - } - } - } - - private detachClientSessions(clientId: string): void { - const attachments: { sessionId: string; token: symbol }[] = [] - for (const [sessionId, attachedClientId] of this.streamClientIdBySessionId) { - if (attachedClientId !== clientId) { - continue - } - const token = this.attachTokenBySessionId.get(sessionId) - if (token) { - attachments.push({ sessionId, token }) - } - this.streamClientIdBySessionId.delete(sessionId) - this.attachTokenBySessionId.delete(sessionId) - } - if (attachments.length > 0) { - this.host.detachClients(attachments) - } - } - - private detachSessionForClient(sessionId: string, clientId: string): void { - if (this.streamClientIdBySessionId.get(sessionId) !== clientId) { - return - } - const token = this.attachTokenBySessionId.get(sessionId) - if (token) { - this.host.detach(sessionId, token) - } - this.streamClientIdBySessionId.delete(sessionId) - this.attachTokenBySessionId.delete(sessionId) - } - - private async routeRequest(clientId: string, request: DaemonRequest): Promise { - const client = this.clients.get(clientId) - - switch (request.type) { - case 'startHistorySeedTransfer': { - if (!client?.authenticatedPairEstablished || client.streamSocket === null) { - throw new Error('Daemon client connection is incomplete; reconnect') - } - const transferId = this.historySeedTransfers.start(clientId, request.payload) - return { transferId } - } - - case 'appendHistorySeedTransfer': - this.historySeedTransfers.append( - clientId, - request.payload.transferId, - request.payload.index, - request.payload.data - ) - return {} - - case 'finishHistorySeedTransfer': - this.historySeedTransfers.finish(clientId, request.payload.transferId) - return {} - - case 'abortHistorySeedTransfer': - this.historySeedTransfers.abort(clientId, request.payload.transferId) - return {} - - case 'createOrAttach': { - if (this.idleShutdownState !== 'running') { - throw new Error('Daemon temporarily unavailable; reconnect') - } - if (!client?.authenticatedPairEstablished || client.streamSocket === null) { - // Why: a control-only replacement can't own terminal admission or erase the prior client's retirement request. - throw new Error('Daemon client connection is incomplete; reconnect') - } - const p = request.payload - const attachOnly = p.attachOnly === true - // Why check here and not only on the watchdog: publishing cannot be made atomic against - // a publisher preempted between proving an entry dead and replacing it, so this daemon - // can lose the endpoint at any moment. The watchdog notices within a poll, which is far - // too late if a session was accepted in between — that session is then reachable by - // nobody, and the user sees a terminal that acknowledges input and never runs it. - // Why creation only: an attach reaches a session this daemon already hosts, over a - // connection that already exists. Refusing that would break the drain a retiring daemon - // depends on, and it strands nothing — the session is already here. - if (!attachOnly && this.hasLostEndpointOwnership()) { - this.requestRetirementForLostEndpoint() - throw new Error(DAEMON_ENDPOINT_LOST_MESSAGE) - } - this.createOrAttachInFlight++ - let routedSessionId = p.sessionId - let result: Awaited> - let spawnPreparation: PendingPtySpawnPreparation | null = null - try { - if ( - p.agentSessionEnsure !== undefined && - (!isAgentSessionExecutionClaim(p.agentSessionEnsure.claim) || - !isAgentSessionSurfaceBinding(p.agentSessionEnsure.surface)) - ) { - throw new Error('agent_session_identity_required') - } - spawnPreparation = this.registerPtySpawnPreparation( - p.sessionId, - clientId, - request.id, - p.cancelAfterMs - ) - if (!attachOnly) { - await this.preparePtySpawnUnlessCanceled(p.sessionId, spawnPreparation) - } - if (p.historySeed !== undefined && p.historySeedTransferId !== undefined) { - throw new Error('Multiple terminal history seed sources') - } - const historySeedChunks = - p.historySeedTransferId !== undefined - ? this.historySeedTransfers.take(clientId, p.historySeedTransferId) - : p.historySeed !== undefined - ? [p.historySeed] - : undefined - result = await this.host.createOrAttach({ - sessionId: p.sessionId, - cols: p.cols, - rows: p.rows, - cwd: p.cwd, - env: p.env, - envToDelete: p.envToDelete, - command: p.command, - startupCommandDelivery: p.startupCommandDelivery, - ...(attachOnly ? { attachOnly: true } : {}), - ...(p.expectedIncarnationId !== undefined - ? { expectedIncarnationId: p.expectedIncarnationId } - : {}), - // Why: RPC payloads are untrusted JSON; persist only the allowlisted routing enum, never arbitrary identity. - ...(isTuiAgent(p.launchAgent) ? { launchAgent: p.launchAgent } : {}), - shellOverride: p.shellOverride, - terminalWindowsWslDistro: p.terminalWindowsWslDistro, - terminalWindowsPowerShellImplementation: p.terminalWindowsPowerShellImplementation, - shellReadySupported: p.shellReadySupported, - historySeedChunks, - startupIngress: parsePtyStartupIngressIntent(p.startupIngress), - ...(p.shellReadyTimeoutMs !== undefined - ? { shellReadyTimeoutMs: p.shellReadyTimeoutMs } - : {}), - ...(p.agentSessionEnsure ? { agentSessionEnsure: p.agentSessionEnsure } : {}), - ...(spawnPreparation - ? { - isCanceled: () => spawnPreparation?.canceled === true, - cancelSignal: spawnPreparation.controller.signal - } - : {}), - onSessionResolved: (sessionId) => { - routedSessionId = sessionId - }, - streamClient: { - onData: (data, rawLength = data.length, transformed = false, seq) => { - // Scan BEFORE enqueue: the batcher may drop this chunk, but its facts must be captured regardless. - this.transientFactRelay.onSessionData(routedSessionId, data) - const lastInputAt = this.lastInputAtBySessionId.get(routedSessionId) - const isInteractiveOutput = - data.length <= DaemonServer.INTERACTIVE_OUTPUT_MAX_CHARS && - lastInputAt !== undefined && - performance.now() - lastInputAt <= DaemonServer.INTERACTIVE_OUTPUT_WINDOW_MS - this.streamDataBatcher.enqueue(clientId, routedSessionId, data, { - flushImmediately: isInteractiveOutput, - flushMaxChars: DaemonServer.INTERACTIVE_OUTPUT_MAX_CHARS, - rawLength, - transformed, - seq - }) - }, - onExit: (code, incarnationId, cause) => { - // Why: exit tears down renderer handlers, so it must ride the ordered queue behind final output. - this.log.log('session-exited', { - sessionId: routedSessionId, - code, - cause: cause?.kind - }) - this.streamDataBatcher.enqueueControlEvent(clientId, routedSessionId, { - type: 'event', - event: 'exit', - sessionId: routedSessionId, - payload: { code, incarnationId, ...(cause ? { cause } : {}) } - }) - this.streamDataBatcher.flush(clientId) - recordDaemonStreamBacklogEvent('sessionExit', { - sessionIdSuffix: routedSessionId.slice(-10) - }) - this.transientFactRelay.onSessionExit(routedSessionId) - this.streamDataBatcher.refreshSessionDroppability(routedSessionId) - this.streamClientIdBySessionId.delete(routedSessionId) - this.attachTokenBySessionId.delete(routedSessionId) - this.lastInputAtBySessionId.delete(routedSessionId) - this.reevaluateIdleShutdown() - } - } - }) - } finally { - if (spawnPreparation) { - this.finishPtySpawnPreparation(p.sessionId, spawnPreparation) - } - this.createOrAttachInFlight-- - this.reevaluateIdleShutdown() - } - routedSessionId = result.agentSessionEnsure?.owner.ptyId ?? p.sessionId - if ( - this.clients.get(clientId) !== client || - !client.authenticatedPairEstablished || - client.streamSocket === null - ) { - this.host.detach(routedSessionId, result.attachToken) - throw new TerminalAttachCanceledError(routedSessionId) - } - this.streamClientIdBySessionId.set(routedSessionId, clientId) - this.attachTokenBySessionId.set(routedSessionId, result.attachToken) - this.streamDataBatcher.refreshSessionDroppability(routedSessionId) - // Why an attach-time marker: background resync can precede this attach, so scan suppression must start at the new stream's head. - if (this.transientFactRelay.isBackgrounded(routedSessionId)) { - this.streamDataBatcher.enqueueControlEvent(clientId, routedSessionId, { - type: 'event', - event: 'sessionBackgroundMarker', - sessionId: routedSessionId, - payload: { background: true } - }) - } - this.log.log(result.isNew ? 'session-created' : 'session-attached', { - sessionId: routedSessionId, - pid: result.pid - }) - return { - isNew: result.isNew, - snapshot: result.snapshot, - pid: result.pid, - shellState: result.shellState, - incarnationId: result.incarnationId, - ...(result.launchAgent ? { launchAgent: result.launchAgent } : {}), - wslDistro: result.wslDistro, - ...(result.historySeeded !== undefined ? { historySeeded: result.historySeeded } : {}), - ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}) - } - } - - case 'cancelCreateOrAttach': - return { - canceled: this.cancelPendingPtySpawnPreparations(request.payload.sessionId, { - clientId, - ...(typeof request.payload.requestId === 'string' - ? { requestId: request.payload.requestId } - : {}) - }) - } - - case 'closeStartupQueryAuthority': - return { - appliedSeq: this.host.closeStartupQueryAuthority(request.payload.sessionId) - } - - case 'write': - try { - this.lastInputAtBySessionId.set(request.payload.sessionId, performance.now()) - this.host.write( - request.payload.sessionId, - request.payload.data, - request.payload.expectedIncarnationId - ) - } catch (err) { - this.lastInputAtBySessionId.delete(request.payload.sessionId) - if (err instanceof SessionNotFoundError) { - this.sendExitEvent( - client, - request.payload.sessionId, - -1, - request.payload.expectedIncarnationId - ) - } - throw err - } - return {} - - case 'resize': - try { - this.host.resize( - request.payload.sessionId, - request.payload.cols, - request.payload.rows, - request.payload.expectedIncarnationId - ) - } catch (err) { - if (err instanceof SessionNotFoundError) { - this.sendExitEvent( - client, - request.payload.sessionId, - -1, - request.payload.expectedIncarnationId - ) - } - throw err - } - return {} - - case 'pausePty': - this.host.pauseProducer(request.payload.sessionId) - return {} - - case 'resumePty': - this.host.resumeProducer(request.payload.sessionId) - return {} - - case 'setSessionBackground': { - const sessionId = request.payload.sessionId - const background = request.payload.background === true - recordDaemonStreamBacklogEvent('setSessionBackground', { - sessionIdSuffix: sessionId.slice(-10), - background - }) - const backgroundChanged = this.transientFactRelay.setSessionBackground( - sessionId, - background - ) - this.streamDataBatcher.refreshSessionDroppability(sessionId) - if (!backgroundChanged) { - return {} - } - if (background) { - // Seed the fresh relay tracker with the emulator's dangling escape so a handoff-split sequence still parses. - this.transientFactRelay.seedSessionScanState( - sessionId, - this.host.getPartialEscapeTailAnsi(sessionId) - ) - } - const streamClientId = this.streamClientIdBySessionId.get(sessionId) - if (!streamClientId) { - // Not attached yet — the attach-time marker covers the handoff. - return {} - } - // Reveal intentionally keeps the queued tail: main needs those bytes, and the normal flush/drain delivers them in order ahead of the marker. - const mode2031State = this.transientFactRelay.getMode2031ReplyScanState(sessionId) - const scanSeedAnsi = background - ? '' - : mode2031State.pendingSubscribe - ? mode2031State.tail - : this.host.getPartialEscapeTailAnsi(sessionId) - this.streamDataBatcher.enqueueControlEvent(streamClientId, sessionId, { - type: 'event', - event: 'sessionBackgroundMarker', - sessionId, - payload: { - background, - ...(scanSeedAnsi.length > 0 ? { scanSeedAnsi } : {}), - ...(mode2031State.pendingSubscribe ? { mode2031PendingSubscribe: true as const } : {}) - } - }) - return {} - } - - case 'kill': { - const canceledPendingSpawn = this.cancelPendingPtySpawnPreparations( - request.payload.sessionId - ) - this.lastInputAtBySessionId.delete(request.payload.sessionId) - const attribution = { - sessionId: request.payload.sessionId, - immediate: request.payload.immediate === true, - // Daemon control identity, not the paired-device bearer credential. - clientId - } - try { - await this.host.kill(request.payload.sessionId, { - immediate: request.payload.immediate - }) - } catch (error) { - // Why: a kill that wins before session registration already canceled the pending spawn, so its intent is done. - if (!(canceledPendingSpawn && error instanceof SessionNotFoundError)) { - this.log.log('session-kill-failed', attribution) - throw error - } - } - this.log.log('session-killed', attribution) - return {} - } - - case 'signal': - this.host.signal(request.payload.sessionId, request.payload.signal) - return {} - - case 'detach': - this.detachSessionForClient(request.payload.sessionId, clientId) - this.log.log('session-detached', { - sessionId: request.payload.sessionId - }) - return {} - - case 'getCwd': - return { cwd: await this.host.getCwd(request.payload.sessionId) } - - case 'getForegroundProcess': - return { - foregroundProcess: this.host.getForegroundProcess(request.payload.sessionId) - } - - case 'inspectProcess': - return this.host.inspectProcess(request.payload.sessionId) - - case 'confirmForegroundProcess': - return { - foregroundProcess: await this.host.confirmForegroundProcess(request.payload.sessionId) - } - - case 'clearScrollback': - this.host.clearScrollback(request.payload.sessionId) - return {} - - case 'listSessions': - return { sessions: this.host.listSessions() } - - case 'shutdownIfIdle': { - const authenticatedClient = this.clients.get(clientId) - const retiring = - authenticatedClient !== undefined && - authenticatedClient.streamSocket !== null && - this.clients.size === 1 && - this.createOrAttachInFlight === 0 && - this.host.listSessions().length === 0 && - [...this.transportSockets].every( - (transport) => - transport === authenticatedClient.controlSocket || - transport === authenticatedClient.streamSocket - ) - if (!retiring) { - return { retiring: false } - } - this.idleShutdownState = 'shutting-down' - this.initialAdoptionDeadlineMs = null - this.retirementRequested = false - this.cancelInitialAdoptionTimer() - // Why: close before acknowledging retirement so no new terminal races between the empty proof and disposal. - const serverClose = this.beginServerClose() - this.deferShutdownUntilReply(clientId, request.id, authenticatedClient.controlSocket, () => - this.finishIdleShutdown(serverClose) - ) - return { retiring: true } - } - - case 'getSnapshot': { - const snapshotStart = performance.now() - const requestedScrollbackRows = request.payload.scrollbackRows - const scrollbackRows = - typeof requestedScrollbackRows === 'number' && Number.isFinite(requestedScrollbackRows) - ? Math.max(0, Math.min(50_000, Math.floor(requestedScrollbackRows))) - : undefined - const snapshot = this.host.getSnapshot(request.payload.sessionId, { - scrollbackRows - }) - const snapshotMs = performance.now() - snapshotStart - if (snapshotMs >= 25) { - // Serialize stalls block the daemon's single thread; surface them to attribute field typing stalls (issue #5096 family). - recordDaemonStreamBacklogEvent('slowGetSnapshot', { - sessionIdSuffix: request.payload.sessionId.slice(-10), - snapshotMs: Math.round(snapshotMs) - }) - } - return { snapshot } - } - - case 'getSize': - return { size: this.host.getAppliedSize(request.payload.sessionId) } - - case 'takePendingOutput': - // Why no await: with includeSnapshot, drain+serialize must share one sync turn or cold restore replays doubled PTY bytes. - return this.host.takePendingOutput( - request.payload.sessionId, - request.payload.includeSnapshot === true, - { teardownSnapshot: request.payload.teardownSnapshot === true } - ) - - case 'ping': - return { pong: true } - - case 'systemResolverHealth': - return { health: await readCurrentProcessMacSystemResolverHealth() } - - case 'ptySpawnHealth': - await this.ptySpawnHealthCheck() - return { healthy: true } - - case 'shutdown': { - this.log.log('shutdown', { - reason: 'rpc', - killSessions: request.payload.killSessions === true - }) - const serverClose = this.beginOrdinaryShutdownFence() - if (request.payload.killSessions) { - try { - await this.host.dispose() - } catch (err) { - // Why: shutdown must always self-terminate; failed owners stay retryable for the follow-up shutdown() below. - this.log.log('shutdown-dispose-failed', { - error: err instanceof Error ? err.message : String(err) - }) - } - } - const controlSocket = this.clients.get(clientId)?.controlSocket - if (controlSocket) { - this.deferShutdownUntilReply(clientId, request.id, controlSocket, () => - this.finishRpcShutdown(serverClose) - ) - } else if (!this.shutdownPromise) { - this.shutdownPromise = this.finishRpcShutdown(serverClose) - } - return {} - } - } - throw new Error(`Unknown request type: ${(request as { type: string }).type}`) - } - - private sendExitEvent( - client: ConnectedClient | undefined, - sessionId: string, - code: number, - incarnationId?: string - ): void { - if (!client?.streamSocket) { - return - } - // Why: write/resize don't wait for replies, so this synthetic exit is the renderer's only signal to clear stale pane bindings. - this.streamDataBatcher.enqueueControlEvent(client.clientId, sessionId, { - type: 'event', - event: 'exit', - sessionId, - payload: { code, ...(incarnationId ? { incarnationId } : {}) } - }) - this.streamDataBatcher.flush(client.clientId) - } } diff --git a/src/main/daemon/daemon-session-attachments.ts b/src/main/daemon/daemon-session-attachments.ts new file mode 100644 index 00000000000..2a012c1c9d4 --- /dev/null +++ b/src/main/daemon/daemon-session-attachments.ts @@ -0,0 +1,67 @@ +import { performance } from 'node:perf_hooks' +import type { TerminalHost } from './terminal-host' + +export class DaemonSessionAttachments { + private readonly clientIdBySessionId = new Map() + private readonly tokenBySessionId = new Map() + private readonly lastInputAtBySessionId = new Map() + + constructor(private readonly host: TerminalHost) {} + + attach(sessionId: string, clientId: string, token: symbol): void { + this.clientIdBySessionId.set(sessionId, clientId) + this.tokenBySessionId.set(sessionId, token) + } + + clientIdForSession(sessionId: string): string | undefined { + return this.clientIdBySessionId.get(sessionId) + } + + recordInput(sessionId: string): void { + this.lastInputAtBySessionId.set(sessionId, performance.now()) + } + + lastInputAt(sessionId: string): number | undefined { + return this.lastInputAtBySessionId.get(sessionId) + } + + clearInput(sessionId: string): void { + this.lastInputAtBySessionId.delete(sessionId) + } + + release(sessionId: string): void { + this.clientIdBySessionId.delete(sessionId) + this.tokenBySessionId.delete(sessionId) + this.lastInputAtBySessionId.delete(sessionId) + } + + detachSessionForClient(sessionId: string, clientId: string): void { + if (this.clientIdBySessionId.get(sessionId) !== clientId) { + return + } + const token = this.tokenBySessionId.get(sessionId) + if (token) { + this.host.detach(sessionId, token) + } + this.clientIdBySessionId.delete(sessionId) + this.tokenBySessionId.delete(sessionId) + } + + detachClientSessions(clientId: string): void { + const attachments: { sessionId: string; token: symbol }[] = [] + for (const [sessionId, attachedClientId] of this.clientIdBySessionId) { + if (attachedClientId !== clientId) { + continue + } + const token = this.tokenBySessionId.get(sessionId) + if (token) { + attachments.push({ sessionId, token }) + } + this.clientIdBySessionId.delete(sessionId) + this.tokenBySessionId.delete(sessionId) + } + if (attachments.length > 0) { + this.host.detachClients(attachments) + } + } +} diff --git a/src/main/daemon/daemon-session-background-routing.ts b/src/main/daemon/daemon-session-background-routing.ts new file mode 100644 index 00000000000..f3e45ba7abc --- /dev/null +++ b/src/main/daemon/daemon-session-background-routing.ts @@ -0,0 +1,55 @@ +import type { BackgroundTransientFactRelay } from './daemon-background-transient-facts' +import type { DaemonSessionAttachments } from './daemon-session-attachments' +import { recordDaemonStreamBacklogEvent } from './daemon-stream-backlog-probe' +import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' +import type { TerminalHost } from './terminal-host' + +type DaemonSessionBackgroundRoutingOptions = { + host: TerminalHost + attachments: DaemonSessionAttachments + transientFactRelay: BackgroundTransientFactRelay + streamDataBatcher: DaemonStreamDataBatcher +} + +export class DaemonSessionBackgroundRouting { + constructor(private readonly options: DaemonSessionBackgroundRoutingOptions) {} + + setBackground(sessionId: string, background: boolean): Record { + recordDaemonStreamBacklogEvent('setSessionBackground', { + sessionIdSuffix: sessionId.slice(-10), + background + }) + const changed = this.options.transientFactRelay.setSessionBackground(sessionId, background) + this.options.streamDataBatcher.refreshSessionDroppability(sessionId) + if (!changed) { + return {} + } + if (background) { + this.options.transientFactRelay.seedSessionScanState( + sessionId, + this.options.host.getPartialEscapeTailAnsi(sessionId) + ) + } + const streamClientId = this.options.attachments.clientIdForSession(sessionId) + if (!streamClientId) { + return {} + } + const mode2031State = this.options.transientFactRelay.getMode2031ReplyScanState(sessionId) + const scanSeedAnsi = background + ? '' + : mode2031State.pendingSubscribe + ? mode2031State.tail + : this.options.host.getPartialEscapeTailAnsi(sessionId) + this.options.streamDataBatcher.enqueueControlEvent(streamClientId, sessionId, { + type: 'event', + event: 'sessionBackgroundMarker', + sessionId, + payload: { + background, + ...(scanSeedAnsi.length > 0 ? { scanSeedAnsi } : {}), + ...(mode2031State.pendingSubscribe ? { mode2031PendingSubscribe: true as const } : {}) + } + }) + return {} + } +} diff --git a/src/main/daemon/daemon-shutdown-final-checkpoint-caller-deadline.test.ts b/src/main/daemon/daemon-shutdown-final-checkpoint-caller-deadline.test.ts index 1f1f0dce8d2..64ae0f77ba0 100644 --- a/src/main/daemon/daemon-shutdown-final-checkpoint-caller-deadline.test.ts +++ b/src/main/daemon/daemon-shutdown-final-checkpoint-caller-deadline.test.ts @@ -2,7 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { DaemonPtyAdapter, FinalCheckpointWaitExpiredError } from './daemon-pty-adapter' +import { DaemonPtyAdapter } from './daemon-pty-adapter' +import { FinalCheckpointWaitExpiredError } from './daemon-pty-lifecycle-errors' import { DaemonServer } from './daemon-server' import { getDaemonSocketPath } from './daemon-spawner' import type { DaemonFileLog } from './daemon-file-log' diff --git a/src/main/daemon/daemon-stream-droppability-lifecycle.test.ts b/src/main/daemon/daemon-stream-droppability-lifecycle.test.ts index f62cec1179c..fda1fe25b98 100644 --- a/src/main/daemon/daemon-stream-droppability-lifecycle.test.ts +++ b/src/main/daemon/daemon-stream-droppability-lifecycle.test.ts @@ -4,6 +4,7 @@ import type { Socket } from 'node:net' import { tmpdir } from 'node:os' import { join } from 'node:path' import { DaemonServer } from './daemon-server' +import type { ConnectedDaemonClient } from './daemon-client-connections' import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' import type { BackgroundTransientFactRelay } from './daemon-background-transient-facts' import type { PendingStreamDataBatch } from './daemon-stream-keep-tail-drop' @@ -16,22 +17,14 @@ type MockSubprocess = SubprocessHandle & { } type DaemonLifecyclePrivate = { - clients: Map< - string, - { - clientId: string - controlSocket: Socket - streamSocket: Socket | null - authenticatedPairEstablished: boolean - } - > - host: { - getPartialEscapeTailAnsi(sessionId: string): string + connections: { clients: Map } + host: { getPartialEscapeTailAnsi(sessionId: string): string } + requestRouter: { + route(clientId: string, request: DaemonRequest): Promise } - routeRequest(clientId: string, request: DaemonRequest): Promise streamDataBatcher: DaemonStreamDataBatcher transientFactRelay: BackgroundTransientFactRelay - streamClientIdBySessionId: Map + attachments: { clientIdBySessionId: Map } } function createMockSubprocess(): MockSubprocess { @@ -95,7 +88,7 @@ function addClient( write: ReturnType writableLength: number } - daemon.clients.set('client-1', { + daemon.connections.clients.set('client-1', { clientId: 'client-1', controlSocket, streamSocket, @@ -134,7 +127,7 @@ describe('daemon stream droppability lifecycle', () => { server = harness.server const { daemon } = harness addClient(daemon) - daemon.streamClientIdBySessionId.set('session-toggle', 'client-1') + daemon.attachments.clientIdBySessionId.set('session-toggle', 'client-1') vi.spyOn(daemon.host, 'getPartialEscapeTailAnsi').mockReturnValue('') const lifecycle: string[] = [] vi.spyOn(daemon.streamDataBatcher, 'refreshSessionDroppability').mockImplementation( @@ -152,7 +145,7 @@ describe('daemon stream droppability lifecycle', () => { } ) - await daemon.routeRequest('client-1', { + await daemon.requestRouter.route('client-1', { id: 'background', type: 'setSessionBackground', payload: { sessionId: 'session-toggle', background: true } @@ -160,7 +153,7 @@ describe('daemon stream droppability lifecycle', () => { expect(lifecycle).toEqual(['refresh:true', 'marker:true']) lifecycle.length = 0 - await daemon.routeRequest('client-1', { + await daemon.requestRouter.route('client-1', { id: 'duplicate-background', type: 'setSessionBackground', payload: { sessionId: 'session-toggle', background: true } @@ -168,7 +161,7 @@ describe('daemon stream droppability lifecycle', () => { expect(lifecycle).toEqual(['refresh:true']) lifecycle.length = 0 - await daemon.routeRequest('client-1', { + await daemon.requestRouter.route('client-1', { id: 'foreground', type: 'setSessionBackground', payload: { sessionId: 'session-toggle', background: false } @@ -181,13 +174,13 @@ describe('daemon stream droppability lifecycle', () => { server = harness.server const { daemon } = harness addClient(daemon) - daemon.streamClientIdBySessionId.set('session-toggle', 'client-1') + daemon.attachments.clientIdBySessionId.set('session-toggle', 'client-1') daemon.transientFactRelay.setSessionBackground('session-toggle', true) daemon.transientFactRelay.onSessionData('session-toggle', '\x1b[?2031h\x1b[?') vi.spyOn(daemon.host, 'getPartialEscapeTailAnsi').mockReturnValue('\x1b[?') const enqueue = vi.spyOn(daemon.streamDataBatcher, 'enqueueControlEvent') - await daemon.routeRequest('client-1', { + await daemon.requestRouter.route('client-1', { id: 'foreground', type: 'setSessionBackground', payload: { sessionId: 'session-toggle', background: false } @@ -216,7 +209,9 @@ describe('daemon stream droppability lifecycle', () => { const lifecycle: string[] = [] vi.spyOn(daemon.streamDataBatcher, 'refreshSessionDroppability').mockImplementation( (sessionId) => { - lifecycle.push(`refresh:${daemon.streamClientIdBySessionId.get(sessionId) ?? 'unrouted'}`) + lifecycle.push( + `refresh:${daemon.attachments.clientIdBySessionId.get(sessionId) ?? 'unrouted'}` + ) } ) vi.spyOn(daemon.streamDataBatcher, 'enqueueControlEvent').mockImplementation( @@ -225,7 +220,7 @@ describe('daemon stream droppability lifecycle', () => { } ) - await daemon.routeRequest('client-1', { + await daemon.requestRouter.route('client-1', { id: 'attach', type: 'createOrAttach', payload: { sessionId: 'session-attach', cols: 80, rows: 24 } @@ -241,7 +236,7 @@ describe('daemon stream droppability lifecycle', () => { addClient(daemon, 128 * 1024) daemon.transientFactRelay.setSessionBackground('session-exit', true) - await daemon.routeRequest('client-1', { + await daemon.requestRouter.route('client-1', { id: 'attach', type: 'createOrAttach', payload: { sessionId: 'session-exit', cols: 80, rows: 24 } @@ -262,6 +257,6 @@ describe('daemon stream droppability lifecycle', () => { payload: { code: 42 } }) expect(daemon.transientFactRelay.isBackgrounded('session-exit')).toBe(false) - expect(daemon.streamClientIdBySessionId.has('session-exit')).toBe(false) + expect(daemon.attachments.clientIdBySessionId.has('session-exit')).toBe(false) }) }) diff --git a/src/main/daemon/daemon-terminal-admission.ts b/src/main/daemon/daemon-terminal-admission.ts new file mode 100644 index 00000000000..873cb2444ee --- /dev/null +++ b/src/main/daemon/daemon-terminal-admission.ts @@ -0,0 +1,216 @@ +import { performance } from 'node:perf_hooks' +import type { BackgroundTransientFactRelay } from './daemon-background-transient-facts' +import type { DaemonClientConnections } from './daemon-client-connections' +import type { DaemonEndpointLifecycle } from './daemon-endpoint-lifecycle' +import { DAEMON_ENDPOINT_LOST_MESSAGE } from './daemon-endpoint-ownership' +import type { DaemonFileLog } from './daemon-file-log' +import type { + DaemonPtySpawnPreparations, + PendingPtySpawnPreparation +} from './daemon-pty-spawn-preparations' +import type { DaemonSessionAttachments } from './daemon-session-attachments' +import { recordDaemonStreamBacklogEvent } from './daemon-stream-backlog-probe' +import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' +import type { TerminalHistorySeedTransferRegistry } from './terminal-history-seed-transfer-registry' +import type { CreateOrAttachOptions, CreateOrAttachResult, TerminalHost } from './terminal-host' +import { isTuiAgent } from '../../shared/tui-agent-config' +import { parsePtyStartupIngressIntent } from '../../shared/pty-startup-ingress' +import { + isAgentSessionExecutionClaim, + isAgentSessionSurfaceBinding +} from '../../shared/agent-session-host-authority' +import { type DaemonRequest, TerminalAttachCanceledError } from './types' + +type CreateOrAttachRequest = Extract + +type DaemonTerminalAdmissionOptions = { + host: TerminalHost + connections: DaemonClientConnections + endpoint: DaemonEndpointLifecycle + preparations: DaemonPtySpawnPreparations + attachments: DaemonSessionAttachments + historySeedTransfers: TerminalHistorySeedTransferRegistry + transientFactRelay: BackgroundTransientFactRelay + streamDataBatcher: DaemonStreamDataBatcher + log: DaemonFileLog + isAcceptingWork: () => boolean + requestEndpointRetirement: () => void + reevaluateIdleShutdown: () => void +} + +export class DaemonTerminalAdmission { + private static readonly INTERACTIVE_OUTPUT_WINDOW_MS = 100 + private static readonly INTERACTIVE_OUTPUT_MAX_CHARS = 1024 + private createOrAttachInFlight = 0 + + constructor(private readonly options: DaemonTerminalAdmissionOptions) {} + + get inFlight(): number { + return this.createOrAttachInFlight + } + + async createOrAttach(clientId: string, request: CreateOrAttachRequest): Promise { + const client = this.options.connections.get(clientId) + if (!this.options.isAcceptingWork()) { + throw new Error('Daemon temporarily unavailable; reconnect') + } + if (!client?.authenticatedPairEstablished || client.streamSocket === null) { + throw new Error('Daemon client connection is incomplete; reconnect') + } + const payload = request.payload + const attachOnly = payload.attachOnly === true + if (!attachOnly && this.options.endpoint.hasLostOwnership()) { + this.options.requestEndpointRetirement() + throw new Error(DAEMON_ENDPOINT_LOST_MESSAGE) + } + this.createOrAttachInFlight++ + let routedSessionId = payload.sessionId + let result: CreateOrAttachResult + let spawnPreparation: PendingPtySpawnPreparation | null = null + try { + if ( + payload.agentSessionEnsure !== undefined && + (!isAgentSessionExecutionClaim(payload.agentSessionEnsure.claim) || + !isAgentSessionSurfaceBinding(payload.agentSessionEnsure.surface)) + ) { + throw new Error('agent_session_identity_required') + } + spawnPreparation = this.options.preparations.register( + payload.sessionId, + clientId, + request.id, + payload.cancelAfterMs + ) + if (!attachOnly) { + await this.options.preparations.prepareUnlessCanceled(payload.sessionId, spawnPreparation) + } + if (payload.historySeed !== undefined && payload.historySeedTransferId !== undefined) { + throw new Error('Multiple terminal history seed sources') + } + const historySeedChunks = + payload.historySeedTransferId !== undefined + ? this.options.historySeedTransfers.take(clientId, payload.historySeedTransferId) + : payload.historySeed !== undefined + ? [payload.historySeed] + : undefined + result = await this.options.host.createOrAttach({ + sessionId: payload.sessionId, + cols: payload.cols, + rows: payload.rows, + cwd: payload.cwd, + env: payload.env, + envToDelete: payload.envToDelete, + command: payload.command, + startupCommandDelivery: payload.startupCommandDelivery, + ...(attachOnly ? { attachOnly: true } : {}), + ...(payload.expectedIncarnationId !== undefined + ? { expectedIncarnationId: payload.expectedIncarnationId } + : {}), + ...(isTuiAgent(payload.launchAgent) ? { launchAgent: payload.launchAgent } : {}), + shellOverride: payload.shellOverride, + terminalWindowsWslDistro: payload.terminalWindowsWslDistro, + terminalWindowsPowerShellImplementation: payload.terminalWindowsPowerShellImplementation, + shellReadySupported: payload.shellReadySupported, + historySeedChunks, + startupIngress: parsePtyStartupIngressIntent(payload.startupIngress), + ...(payload.shellReadyTimeoutMs !== undefined + ? { shellReadyTimeoutMs: payload.shellReadyTimeoutMs } + : {}), + ...(payload.agentSessionEnsure ? { agentSessionEnsure: payload.agentSessionEnsure } : {}), + isCanceled: () => spawnPreparation?.canceled === true, + cancelSignal: spawnPreparation.controller.signal, + onSessionResolved: (sessionId) => { + routedSessionId = sessionId + }, + streamClient: this.createStreamClient(clientId, () => routedSessionId) + }) + } finally { + if (spawnPreparation) { + this.options.preparations.finish(payload.sessionId, spawnPreparation) + } + this.createOrAttachInFlight-- + this.options.reevaluateIdleShutdown() + } + + routedSessionId = result.agentSessionEnsure?.owner.ptyId ?? payload.sessionId + if ( + this.options.connections.get(clientId) !== client || + !client.authenticatedPairEstablished || + client.streamSocket === null + ) { + this.options.host.detach(routedSessionId, result.attachToken) + throw new TerminalAttachCanceledError(routedSessionId) + } + this.options.attachments.attach(routedSessionId, clientId, result.attachToken) + this.options.streamDataBatcher.refreshSessionDroppability(routedSessionId) + if (this.options.transientFactRelay.isBackgrounded(routedSessionId)) { + this.options.streamDataBatcher.enqueueControlEvent(clientId, routedSessionId, { + type: 'event', + event: 'sessionBackgroundMarker', + sessionId: routedSessionId, + payload: { background: true } + }) + } + this.options.log.log(result.isNew ? 'session-created' : 'session-attached', { + sessionId: routedSessionId, + pid: result.pid + }) + return { + isNew: result.isNew, + snapshot: result.snapshot, + pid: result.pid, + shellState: result.shellState, + incarnationId: result.incarnationId, + ...(result.launchAgent ? { launchAgent: result.launchAgent } : {}), + wslDistro: result.wslDistro, + ...(result.historySeeded !== undefined ? { historySeeded: result.historySeeded } : {}), + ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}) + } + } + + private createStreamClient( + clientId: string, + sessionId: () => string + ): CreateOrAttachOptions['streamClient'] { + return { + onData: (data, rawLength = data.length, transformed = false, seq) => { + const routedSessionId = sessionId() + this.options.transientFactRelay.onSessionData(routedSessionId, data) + const lastInputAt = this.options.attachments.lastInputAt(routedSessionId) + const isInteractiveOutput = + data.length <= DaemonTerminalAdmission.INTERACTIVE_OUTPUT_MAX_CHARS && + lastInputAt !== undefined && + performance.now() - lastInputAt <= DaemonTerminalAdmission.INTERACTIVE_OUTPUT_WINDOW_MS + this.options.streamDataBatcher.enqueue(clientId, routedSessionId, data, { + flushImmediately: isInteractiveOutput, + flushMaxChars: DaemonTerminalAdmission.INTERACTIVE_OUTPUT_MAX_CHARS, + rawLength, + transformed, + seq + }) + }, + onExit: (code, incarnationId, cause) => { + const routedSessionId = sessionId() + this.options.log.log('session-exited', { + sessionId: routedSessionId, + code, + cause: cause?.kind + }) + this.options.streamDataBatcher.enqueueControlEvent(clientId, routedSessionId, { + type: 'event', + event: 'exit', + sessionId: routedSessionId, + payload: { code, incarnationId, ...(cause ? { cause } : {}) } + }) + this.options.streamDataBatcher.flush(clientId) + recordDaemonStreamBacklogEvent('sessionExit', { + sessionIdSuffix: routedSessionId.slice(-10) + }) + this.options.transientFactRelay.onSessionExit(routedSessionId) + this.options.streamDataBatcher.refreshSessionDroppability(routedSessionId) + this.options.attachments.release(routedSessionId) + this.options.reevaluateIdleShutdown() + } + } + } +} diff --git a/src/main/daemon/pty-subprocess-test-harness.ts b/src/main/daemon/pty-subprocess-test-harness.ts index 7113af73ef8..623dd20bf63 100644 --- a/src/main/daemon/pty-subprocess-test-harness.ts +++ b/src/main/daemon/pty-subprocess-test-harness.ts @@ -11,7 +11,10 @@ const ORCA_SHELL_WRAPPER_ENV = [ 'ORCA_MIMOCODE_HOME', 'ORCA_PI_CODING_AGENT_DIR', 'ORCA_OMP_CODING_AGENT_DIR', - 'ORCA_CODEX_HOME' + 'ORCA_OMP_STATUS_EXTENSION', + 'ORCA_CODEX_HOME', + 'ORCA_AGENT_TEAMS_SHIM_DIR', + 'ORCA_REMOTE_CLI_BIN_DIR' ] as const export const POWERLEVEL10K_WIZARD_DISABLE_ENV = 'POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD' diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 2f983e4f7c5..8e8d2314783 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -1,128 +1,20 @@ -/* eslint-disable max-lines -- Why: daemon PTY spawning must keep platform launch setup, preflight, and lifecycle guards in one execution path. */ -import * as pty from 'node-pty' -import { statSync } from 'node:fs' -import { release } from 'node:os' -import { delimiter, win32 as pathWin32 } from 'node:path' import type { SubprocessHandle } from './session-subprocess-handle' -import { DaemonProtocolError } from './types' -import { getShellLaunchConfig, resolvePtyShellPath } from './shell-ready' -import { selectShellStartupFeatures } from '../shell-startup-features' -import { isValidPtySize, normalizePtySize } from './daemon-pty-size' -import { - ensureNodePtySpawnHelperExecutable, - getNodePtySpawnHelperCandidates, - resolveUnixShellPath, - validateWorkingDirectoryAsync, - WorkingDirectoryValidationAbortedError -} from '../providers/local-pty-utils' -import { - hostReportsChildExitStatus, - wrapShellSpawnForMacosTccAttribution -} from '../providers/macos-tcc-login-shell' -import { resolveProcessExitCause, type TerminalExitCause } from '../../shared/terminal-exit-cause' -import { signalPosixPtyForegroundGroup } from '../pty/posix-pty-foreground-group' -import { readPtsName } from '../pty/node-pty-pts-name' -import { - ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV, - resolveWindowsShellLaunchArgs -} from '../providers/windows-shell-args' -import { - resolveEffectiveWindowsPowerShell, - shouldProbeWindowsPowerShellAvailability, - type WindowsPowerShellShellFamily -} from '../providers/windows-powershell' -import { - buildWindowsPowerShellSpawnAttempts, - type WindowsShellSpawnAttempt -} from '../providers/windows-shell-fallback-chain' -import { isPwshAvailable } from '../pwsh' -import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' -import { removeInheritedNoColor } from '../pty/terminal-color-env' -import { dropInheritedOrcaFishHistory } from '../fish-history-session' -import { dropInheritedOrcaHistFile } from '../worktree-history-file-path' -import { removeAppImageRuntimeEnv } from '../pty/appimage-terminal-env' -import { stripInheritedBuildModeEnv } from '../pty/build-mode-env' -import { stripLegacyTerminalShimEnv } from '../pty/legacy-terminal-shim-dir' -import { dropIncoherentCondaActivationEnv } from '../pty/conda-activation-env' -import { resolvePathEnvKey } from '../pty/windows-environment-path' -import { parseWslPath } from '../wsl' -import { addWslEnvKeys } from '../wsl-env' -import { - gitCredentialPromptGuardEnv, - mergeGitConfigEnvProtocol -} from '../../shared/git-credential-prompt-env' -import { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } from '../../shared/terminal-git-credential-guard' -import { resolveWslSessionContext } from './wsl-session-context' -import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env' -import { - POWERLEVEL10K_WIZARD_DISABLE_ENV, - seedPowerlevel10kWizardEnv -} from '../pty/powerlevel10k-wizard-env' -import { isWindowsGitBashShellPath, resolveWindowsGitBashShellPath } from '../git-bash' -import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell' -import { resolveAgentForegroundProcessWithAvailability } from '../providers/agent-foreground-process' -import { readWindowsConptyProcessIds } from '../providers/windows-conpty-process-membership' -import { assignHostProcessToKillOnCloseJob, terminatePtyJob } from '../windows/windows-pty-job' -import { - isAgentForegroundWrapperProcess, - recognizeAgentProcess, - recognizeAgentProcessFromCommandLine -} from '../../shared/agent-process-recognition' -import { shouldInspectOuterWrapperForegroundProcess } from '../../shared/foreground-wrapper-agent' -import { - shouldUseShellReadyStartupDelivery, - type StartupCommandDelivery -} from '../../shared/codex-startup-delivery' -import { isShellProcess } from '../../shared/shell-process-detection' +import { normalizePtySize } from './daemon-pty-size' import { TerminalAttachCanceledError } from './daemon-errors' -import { parsePtySessionId } from './pty-session-id' -import { getAgentForegroundContextPaths } from '../providers/agent-foreground-context-paths' -import { assertSafeAgentStartupCwd, resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd' -import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../shared/hermes-startup-query' -import type { TuiAgent } from '../../shared/tui-agent' +import { createDaemonPtyEnvironment } from './pty-subprocess/spawn-environment' +import { createPtyShellLaunchPlan } from './pty-subprocess/shell-launch-plan' +import { spawnNativeDaemonPty, type SpawnedDaemonPty } from './pty-subprocess/native-pty-spawn' import { - expandWindowsEnvironmentVariables, - expandWindowsPathEnvironmentVariables -} from '../../shared/windows-environment-expansion' -import { forceKillPosixPtyProcessGroups } from '../pty/posix-pty-process-groups' -import { readPtySlavePath } from '../../shared/pty-slave-line-discipline-echo' + formatPtySpawnError, + preflightPtySpawn, + preflightPtySpawnHealth, + runPtySpawnHealthProbe +} from './pty-subprocess/spawn-preflight' +import { createDaemonPtySubprocessHandle } from './pty-subprocess/subprocess-handle' +import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' +import type { TuiAgent } from '../../shared/tui-agent' -const PANE_IDENTITY_ENV_KEYS = [ - 'ORCA_PANE_KEY', - 'ORCA_TAB_ID', - 'ORCA_WORKTREE_ID', - 'ORCA_AGENT_LAUNCH_TOKEN' -] as const -const WINDOWS_PATH_ENV_KEY_RE = /^path$/i -const FOREGROUND_AGENT_CACHE_TTL_MS = 1000 -const SHELL_FOREGROUND_REFRESH_RETRY_MS = 5_000 -// Why: a Windows refresh forks a heavy powershell.exe CIM scan (~10-40x POSIX `ps`); idle shells retry slower, output re-arms the fast retry. -const WINDOWS_IDLE_SHELL_FOREGROUND_REFRESH_RETRY_MS = 15_000 -const SHELL_FOREGROUND_OUTPUT_HOT_WINDOW_MS = 10_000 -const STARTUP_AGENT_FOREGROUND_BOOTSTRAP_MS = 5_000 -const PTY_SPAWN_HEALTH_TIMEOUT_MS = 4_000 -// Why: retry once so a transient slow spawn doesn't route every terminal to the local fallback, losing daemon persistence. const PTY_SPAWN_HEALTH_RETRY_ATTEMPTS = 2 -const PENDING_PRE_LISTENER_DATA_MAX_CHARS = 512 * 1024 - -function shouldInspectOuterWrapperFallback(processName: string | null): boolean { - const recognized = recognizeAgentProcess(processName) - return recognized !== null && shouldInspectOuterWrapperForegroundProcess(recognized) -} - -function composeGuardedDaemonGitConfigEnv( - env: Record, - explicitEnv: Record | undefined, - launchAgent: TuiAgent | undefined -): void { - const policy = explicitEnv?.[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV] - delete env[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV] - if (policy !== 'guard' && launchAgent === undefined) { - return - } - // Why: the daemon can outlive Electron, so its process.env is the authoritative inherited config; append only the guard. - Object.assign(env, gitCredentialPromptGuardEnv(env, process.platform)) -} export type PtySubprocessOptions = { sessionId: string @@ -134,9 +26,7 @@ export type PtySubprocessOptions = { command?: string startupCommandDelivery?: StartupCommandDelivery launchAgent?: TuiAgent - /** Explicit shell executable path/basename the renderer asked for. - * Overrides env.COMSPEC / env.SHELL resolution inside the daemon so a user - * who picks "New WSL terminal" from the "+" menu actually gets WSL. */ + /** Explicit shell executable path/basename requested by the renderer. */ shellOverride?: string terminalWindowsWslDistro?: string | null terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' @@ -146,391 +36,21 @@ export type PtySubprocessOptions = { onMacosTccSpawnStrategy?: (strategy: 'wrapped' | 'direct') => void } -function deleteRequestedDaemonEnvKeys( - env: Record, - keys: readonly string[] | undefined -): void { - // Why: the persistent daemon's inherited env can differ from Electron's. - // Compare ownership here so real-home routing neither leaks an Orca overlay - // nor deletes a user-owned CODEX_HOME chosen by the daemon's host context. - const deleteOrcaOwnedCodexHome = - keys?.includes('ORCA_CODEX_HOME') === true && - env.ORCA_CODEX_HOME !== undefined && - env.CODEX_HOME === env.ORCA_CODEX_HOME - for (const key of keys ?? []) { - delete env[key] - } - if (deleteOrcaOwnedCodexHome) { - delete env.CODEX_HOME - } -} - -/** - * Returns a stable default working directory for daemon-spawned PTYs. - */ -function getDefaultCwd(): string { - return resolveSafePtyDefaultCwd() -} - -/** - * Removes pane identity inherited from the daemon parent unless explicitly set. - */ -function removeUnspecifiedPaneIdentityEnv( - env: Record, - explicitEnv: Record | undefined -): void { - for (const key of PANE_IDENTITY_ENV_KEYS) { - if (!explicitEnv || !Object.hasOwn(explicitEnv, key)) { - delete env[key] - } - } -} - -/** Removes the second PATH key only when the daemon's env merge created it. */ -function collapseWindowsPathEnvKeys( - env: Record, - requestedEnv: Record | undefined -): void { - if (process.platform !== 'win32') { - return - } - const pathKeys = Object.keys(env).filter((key) => WINDOWS_PATH_ENV_KEY_RE.test(key)) - if (pathKeys.length < 2) { - return - } - // Why: a one-key main patch is authoritative; zero or two keys came from inherited state. - const requestedKeys = requestedEnv - ? Object.keys(requestedEnv).filter((key) => WINDOWS_PATH_ENV_KEY_RE.test(key)) - : [] - if (requestedKeys.length !== 1) { - return - } - const survivingKey = requestedKeys[0] - if (!survivingKey || env[survivingKey] === undefined) { - return - } - for (const key of pathKeys) { - if (key !== survivingKey) { - delete env[key] - } - } -} - -/** - * Promotes the agent-teams shim path ahead of inherited PATH entries. - */ -function promoteAgentTeamsShimPath( - env: Record, - requestedPath: string | undefined -): void { - if (!env.ORCA_AGENT_TEAMS_TEAM_ID || !requestedPath) { - return - } - const normalizedRequestedPath = - process.platform === 'win32' - ? expandWindowsEnvironmentVariables(requestedPath, env) - : requestedPath - const pathDelimiter = process.platform === 'win32' ? ';' : delimiter - const shimDir = normalizedRequestedPath.split(pathDelimiter)[0] - if (!shimDir) { - return - } - const pathKey = resolvePathEnvKey(env, process.platform) - const currentParts = env[pathKey]?.split(pathDelimiter).filter(Boolean) ?? [] - env[pathKey] = [shimDir, ...currentParts.filter((part) => part !== shimDir)].join(pathDelimiter) -} - -/** - * Removes stale development hook endpoints inherited by daemon children. - */ -function removeInheritedDevAgentHookEndpoint( - env: Record, - explicitEnv: Record | undefined -): void { - if (explicitEnv?.ORCA_AGENT_HOOK_ENV === 'development' && !explicitEnv.ORCA_AGENT_HOOK_ENDPOINT) { - // Why: strip only stale inherited endpoints; a fresh explicit one is needed by hooks that scrub token-like env vars before exec. - delete env.ORCA_AGENT_HOOK_ENDPOINT - } -} - -/** - * Strips Electron's internal run-as-node flag from user shell environments. - */ -function removeInheritedElectronRunAsNode(env: Record): void { - // Why: user shells must not inherit ELECTRON_RUN_AS_NODE or nested Electron commands run as plain Node. - delete env.ELECTRON_RUN_AS_NODE -} - -function daemonEnvironmentDiagSuffix(): string { - const orca = process.env.ORCA_APP_VERSION?.trim() || '0.0.0-dev' - const systemVersion = - (process as NodeJS.Process & { getSystemVersion?: () => string }).getSystemVersion?.() || - release() - const platform = `${process.platform} ${systemVersion}` - return ` (orca: ${orca}, arch: ${process.arch}, platform: ${platform})` -} - -/** - * Formats a daemon preflight failure with the same ENOENT details node-pty exposes. - */ -function formatMissingDaemonPathError(kind: 'helper' | 'cwd', path: string): DaemonProtocolError { - const detailName = kind === 'helper' ? 'helper' : 'cwd' - const step = kind === 'helper' ? 'posix_spawn' : 'daemon_cwd' - const missingTarget = kind === 'helper' ? 'node-pty install' : 'working directory' - const diag = daemonEnvironmentDiagSuffix() - return new DaemonProtocolError( - `Daemon's ${missingTarget} is gone (worktree deleted?). Restart Orca. node-pty: ${step} failed: ENOENT (errno 2, No such file or directory) - ${detailName}='${path}'${diag}` - ) -} - -/** - * Checks whether a path currently exists and is a directory. - */ -function isExistingDirectory(path: string | undefined): path is string { - if (!path) { - return false - } - try { - return statSync(path).isDirectory() - } catch { - return false - } -} - -/** - * Moves the daemon process to a stable cwd after its original cwd disappears. - */ -function repairDaemonCwd(): string | null { - const candidates = [process.env.ORCA_USER_DATA_PATH] - try { - candidates.push(getDefaultCwd()) - } catch { - // Keep daemon cwd repair best-effort even when no user terminal cwd is safe. - } - candidates.push(process.platform === 'win32' ? 'C:\\' : '/') - for (const candidate of candidates) { - if (isExistingDirectory(candidate)) { - try { - process.chdir(candidate) - return candidate - } catch { - // Try the next stable cwd candidate. - } - } - } - return null -} - -/** - * Ensures the daemon cwd is valid before native PTY spawning. - */ -function preflightDaemonCwd(): void { - let daemonCwd = '' - try { - daemonCwd = process.cwd() - if (isExistingDirectory(daemonCwd)) { - return - } - } catch { - // Recover below; process.cwd() throws after the original cwd is deleted. - } - - // Why: if the daemon's launch worktree disappears, node-pty's macOS spawn-helper fails even when the terminal cwd is valid. - if (repairDaemonCwd()) { - return - } - throw formatMissingDaemonPathError('cwd', daemonCwd) -} - -/** - * Validates macOS node-pty helper availability before spawning terminals. - */ -function preflightMacNodePtySpawnEnvironment(): void { - if (process.platform !== 'darwin') { - return - } - - let candidates: string[] - try { - candidates = getNodePtySpawnHelperCandidates() - } catch { - throw formatMissingDaemonPathError('helper', '') - } - - for (const candidate of candidates) { - try { - if (statSync(candidate).isFile()) { - return - } - } catch { - // Try the next node-pty native location. - } - } - - throw formatMissingDaemonPathError('helper', candidates[0] ?? '') -} - -/** - * Ensures POSIX daemon-owned native PTY spawn prerequisites are still valid. - */ -function preflightUnixPtySpawnEnvironment(): void { - if (process.platform === 'win32') { - return - } - - // Why: detached daemons can outlive their launch cwd; repair before every spawn so Linux/macOS don't wait for health recovery. - preflightDaemonCwd() - preflightMacNodePtySpawnEnvironment() -} - -/** - * Detects native Windows paths that should be validated before spawn. - */ -function isNativeWindowsPath(path: string): boolean { - return /^[A-Za-z]:[\\/]/.test(path) || path.startsWith('\\\\') -} - -/** - * Validates explicit native Windows cwd paths before ConPTY launch. - */ -async function preflightWindowsPtySpawnEnvironment(args: { - validationCwd: string - cwdWasExplicit: boolean - signal?: AbortSignal -}): Promise { - if (process.platform !== 'win32' || !args.cwdWasExplicit) { - return - } - - if (!isNativeWindowsPath(args.validationCwd)) { - return - } - - await validateWorkingDirectoryAsync( - args.validationCwd, - args.signal ? { signal: args.signal } : {} - ) -} - -/** - * Validates POSIX spawn cwd before node-pty can fail with an opaque ENOENT. - */ -async function preflightPosixPtySpawnEnvironment( - validationCwd: string, - signal?: AbortSignal -): Promise { - if (process.platform === 'win32') { - return - } - await validateWorkingDirectoryAsync(validationCwd, signal ? { signal } : {}) -} - -/** - * Wraps native PTY spawn failures with shell and cwd context. - */ -function formatPtySpawnError(err: unknown, shellPath: string, spawnCwd: string): Error { - const message = err instanceof Error ? err.message : String(err) - const diag = daemonEnvironmentDiagSuffix() - const formatted = new DaemonProtocolError( - `Daemon failed to spawn shell "${shellPath}" with cwd "${spawnCwd}": ${message}${diag}` - ) - if (err instanceof Error && err.stack) { - formatted.stack = err.stack - } - return formatted -} - -/** - * Runs one short native PTY spawn probe (spawn `/bin/sh -c 'exit 0'`). - */ -function runSinglePtySpawnHealthProbe(): Promise { - const cwd = isExistingDirectory(process.env.ORCA_USER_DATA_PATH) - ? process.env.ORCA_USER_DATA_PATH - : getDefaultCwd() - - let proc: pty.IPty - try { - proc = pty.spawn('/bin/sh', ['-c', 'exit 0'], { - name: 'xterm-256color', - cols: 2, - rows: 1, - cwd, - env: { - ...process.env, - TERM: 'xterm-256color' - } - }) - } catch (err) { - throw formatPtySpawnError(err, '/bin/sh', cwd) - } - - return new Promise((resolve, reject) => { - let settled = false - let exitDisposable: { dispose(): void } | undefined - const finish = (error?: Error, opts?: { kill?: boolean }): void => { - if (settled) { - return - } - settled = true - clearTimeout(timer) - exitDisposable?.dispose() - if (opts?.kill) { - try { - proc.kill() - } catch { - // Best-effort cleanup for a short-lived health probe. - } - } - if (error) { - reject(error) - return - } - resolve() - } - const timer = setTimeout(() => { - finish(new Error(`PTY spawn health check timed out after ${PTY_SPAWN_HEALTH_TIMEOUT_MS}ms`), { - kill: true - }) - }, PTY_SPAWN_HEALTH_TIMEOUT_MS) - - // Why: ping only proves the protocol is alive; a real spawn catches stale node-pty helper paths. - exitDisposable = proc.onExit(({ exitCode }) => { - if (exitCode === 0) { - finish() - return - } - finish(new Error(`PTY spawn health check exited with code ${exitCode}`)) - }) - }) -} - -/** - * Runs a short native PTY spawn probe for daemon health checks, retrying once - * so a transient stall (e.g. a busy machine right after an upgrade) does not - * mis-classify a healthy daemon as unable to spawn PTYs. - */ export async function checkPtySpawnHealth(): Promise { - if (process.platform === 'win32') { + if (!preflightPtySpawnHealth()) { return } - - // Why: a real short-lived spawn catches a deleted cwd or stale native PTY path before panes are routed to a daemon that can't spawn. - if (process.platform === 'darwin') { - ensureNodePtySpawnHelperExecutable() - } - preflightUnixPtySpawnEnvironment() - let lastError: unknown for (let attempt = 1; attempt <= PTY_SPAWN_HEALTH_RETRY_ATTEMPTS; attempt++) { try { - await runSinglePtySpawnHealthProbe() + await runPtySpawnHealthProbe() return - } catch (err) { - lastError = err + } catch (error) { + lastError = error if (attempt < PTY_SPAWN_HEALTH_RETRY_ATTEMPTS) { console.warn( `[daemon] PTY spawn health probe attempt ${attempt} failed; retrying`, - err instanceof Error ? err.message : err + error instanceof Error ? error.message : error ) } } @@ -538,883 +58,57 @@ export async function checkPtySpawnHealth(): Promise { throw lastError instanceof Error ? lastError : new Error(String(lastError)) } -/** - * Normalizes node-pty foreground process strings to executable basenames. - */ -function normalizeForegroundProcessName(processName: string | null | undefined): string | null { - const trimmed = processName?.trim().replace(/^["']|["']$/g, '') ?? '' - if (!trimmed || trimmed === 'xterm-256color') { - return null - } - return trimmed.split(/[\\/]/).pop() || null -} - -/** - * Falls back to the spawned Windows shell when node-pty reports a terminal name. - */ -function resolveFallbackForegroundProcess( - processName: string | null | undefined, - shellPath: string -): string | null { - const normalized = normalizeForegroundProcessName(processName) - if (normalized || process.platform !== 'win32') { - return normalized - } - // Why: Windows node-pty can report the terminal name instead of the shell; fall back to the spawned shell. - return normalizeForegroundProcessName(pathWin32.basename(shellPath)) -} - -/** - * Spawns the daemon PTY, walking the Windows PowerShell -> cmd.exe fallback - * chain when ConPTY rejects the primary shell with ERROR_ACCESS_DENIED. - * - * Why: the daemon has no LocalPtyProvider, so it owns its chain walk; later attempts recompute args so the cmd.exe fallback still gets `chcp 65001`. - */ -function spawnDaemonPtyWithWindowsFallback(args: { - shellPath: string - shellArgs: string[] - spawnCwd: string - env: Record - cols: number - rows: number - windowsFallbackAttempts: WindowsShellSpawnAttempt[] - onMacosTccSpawnStrategy?: PtySubprocessOptions['onMacosTccSpawnStrategy'] -}): { - process: pty.IPty - shellPath: string - spawnCwd: string - startupCommandDeliveredInShellArgs?: boolean - /** False when a wrapper owns the reported status, so no exit code may be read from it. */ - reportsChildExitStatus: boolean -} { - let reportsChildExitStatus = true - const spawnAt = (shellPath: string, shellArgs: string[], cwd: string): pty.IPty => { - const wrapped = wrapShellSpawnForMacosTccAttribution(shellPath, shellArgs, args.env) - // Why here: children inherit job membership, so the host job has to exist - // before the first pty -- but resolving it loads the ConPTY addon, and - // paying that at startup delayed the daemon's endpoint past the boot smoke - // test's readiness check. This path already pays ConPTY cost. Memoised. - if (process.platform === 'win32') { - assignHostProcessToKillOnCloseJob() - } - const proc = pty.spawn(wrapped.file, wrapped.args, { - name: args.env.TERM ?? 'xterm-256color', - cols: args.cols, - rows: args.rows, - cwd, - env: args.env, - // Why: legacy system ConPTY can corrupt full-width TUI rows in scrollback; bundled ConPTY has the wrap-marker behavior xterm expects. - ...(process.platform === 'win32' ? { useConptyDll: true } : {}) - }) - reportsChildExitStatus = hostReportsChildExitStatus(wrapped.file) - args.onMacosTccSpawnStrategy?.(wrapped.file === shellPath ? 'direct' : 'wrapped') - return proc - } - - try { - const process_ = spawnAt(args.shellPath, args.shellArgs, args.spawnCwd) - return { - process: process_, - shellPath: args.shellPath, - spawnCwd: args.spawnCwd, - reportsChildExitStatus - } - } catch (primaryErr) { - if (process.platform !== 'win32') { - throw primaryErr - } - // Skip the first entry: it is the primary that already failed above. - for (const attempt of args.windowsFallbackAttempts.slice(1)) { - try { - const process = spawnAt(attempt.shellPath, attempt.shellArgs, attempt.effectiveCwd) - const message = primaryErr instanceof Error ? primaryErr.message : String(primaryErr) - console.warn( - `[daemon/pty] Primary shell "${args.shellPath}" failed (${message}), fell back to "${attempt.shellPath}"` - ) - return { - process, - shellPath: attempt.shellPath, - spawnCwd: attempt.effectiveCwd, - startupCommandDeliveredInShellArgs: attempt.startupCommandDeliveredInShellArgs, - reportsChildExitStatus - } - } catch { - // This fallback shell also failed -- try the next link in the chain. - } - } - throw primaryErr - } -} - /** * Spawns the daemon-owned PTY subprocess for a terminal session. * - * The returned handle records whether the startup command was already embedded - * in Windows shell args so the daemon host does not write it a second time. + * Launch planning stays ahead of validation so Windows validates the effective + * host or WSL cwd selected by the same fallback chain that native spawn uses. + * The handle then owns all event buffering, foreground identity, and teardown. */ export async function createPtySubprocess(opts: PtySubprocessOptions): Promise { const size = normalizePtySize(opts.cols, opts.rows) - const env: Record = { - ...mergeGitConfigEnvProtocol(stripInheritedBuildModeEnv(process.env), opts.env), - TERM: 'xterm-256color', - COLORTERM: 'truecolor', - TERM_PROGRAM: 'Orca', - // Why: TUIs feature-gate on TERM_PROGRAM_VERSION; ORCA_APP_VERSION is inherited from the forking main process. - TERM_PROGRAM_VERSION: process.env.ORCA_APP_VERSION ?? '0.0.0-dev', - // Why: `supports-hyperlinks` gates OSC 8 on a TERM_PROGRAM allowlist excluding Orca; force it since xterm.js parses OSC 8 for clickable links. - FORCE_HYPERLINK: '1' - } as Record - // Why: an older client may not ask a newly upgraded daemon to delete inherited shim state. - stripLegacyTerminalShimEnv(env, process.platform) - composeGuardedDaemonGitConfigEnv(env, opts.env, opts.launchAgent) - deleteRequestedDaemonEnvKeys(env, opts.envToDelete) - if (opts.env?.TERM) { - env.TERM = opts.env.TERM - } - // Why: the daemon can inherit the pane identity of the terminal that launched `pn dev`; each PTY must opt into its own. - removeUnspecifiedPaneIdentityEnv(env, opts.env) - // Why: the daemon inherits an exported `fish_history` from the fish pane that - // launched Orca; only a session this spawn asked for may reach the shell, or - // panes write into the launching worktree's history file (STA-4682). - if (opts.env?.fish_history === undefined) { - dropInheritedOrcaFishHistory(env) - } - // Why the same for HISTFILE: it is exported too, so a daemon started from an - // Orca pane hands that worktree's history file to every pane this spawn did - // not scope itself — including panes of other worktrees. - if (opts.env?.HISTFILE === undefined) { - dropInheritedOrcaHistFile(env) - } - // Why ORCA_HISTFILE too: the desktop drops an inherited one on both of its - // branches, and the daemon inherits one from the pane that launched it just - // as readily. Left in place it now both WRAPS a pane the client scoped - // nothing for (shell-startup-features selects `history` on its presence) and - // makes the wrapper re-export another worktree's history path (#11146). - if (opts.env?.ORCA_HISTFILE === undefined) { - delete env.ORCA_HISTFILE - } - removeInheritedDevAgentHookEndpoint(env, opts.env) - removeInheritedElectronRunAsNode(env) - removeAppImageRuntimeEnv(env) - removeInheritedNoColor(env) + const env = createDaemonPtyEnvironment(opts) + const launch = createPtyShellLaunchPlan(opts, env) - env.LANG ??= 'en_US.UTF-8' - // Why: shellOverride must win over env.COMSPEC, or Windows always resolves to cmd.exe/PowerShell regardless of the user's pick. - const resolvedWslContext = resolveWslSessionContext(opts) - // Why: older persisted tabs can carry a PowerShell/cmd shellOverride; ignore it so WSL reconnects still enter the distro. - let shellPath = resolvedWslContext ? 'wsl.exe' : opts.shellOverride || resolvePtyShellPath(env) - let shellArgs: string[] - let startupCommandDeliveredInShellArgs = false - let windowsFallbackAttempts: WindowsShellSpawnAttempt[] = [] - const startupAgentRecognition = recognizeAgentProcessFromCommandLine(opts.command) - const isCodexStartupCommand = startupAgentRecognition?.agent === 'codex' - // Why: gate on the effective cwd, not raw opts.cwd — an omitted cwd becomes a safe - // default (mirrors LocalPtyProvider). Guarding first treated undefined as root-like (#9578). - const requestedCwd = opts.cwd || getDefaultCwd() - if (opts.command && startupAgentRecognition) { - assertSafeAgentStartupCwd(requestedCwd, opts.command) - } - let spawnCwd = requestedCwd - let validationCwd = spawnCwd - - if (process.platform === 'win32') { - const normalizedShellFamily = pathWin32.basename(shellPath).toLowerCase() - const resolvedGitBashPath = resolveWindowsGitBashShellPath(shellPath) - // Why: normalize concrete PowerShell paths to the family so the resolver can fall back to powershell.exe when pwsh.exe is unavailable. - const resolvedShellFamily: WindowsPowerShellShellFamily = - normalizedShellFamily === 'powershell.exe' || normalizedShellFamily === 'pwsh.exe' - ? normalizedShellFamily - : normalizedShellFamily === 'cmd.exe' || normalizedShellFamily === 'wsl.exe' - ? normalizedShellFamily - : undefined - const shouldProbePwsh = shouldProbeWindowsPowerShellAvailability({ - shellFamily: resolvedShellFamily, - implementation: opts.terminalWindowsPowerShellImplementation - }) - const shouldResolvePowerShellFamily = - opts.terminalWindowsPowerShellImplementation !== undefined || - pathWin32.basename(shellPath) === shellPath - if (resolvedGitBashPath) { - shellPath = resolvedGitBashPath - } else if (shellPath === WINDOWS_GIT_BASH_SHELL) { - shellPath = 'powershell.exe' - } else { - shellPath = shouldResolvePowerShellFamily - ? (resolveEffectiveWindowsPowerShell({ - shellFamily: resolvedShellFamily, - implementation: opts.terminalWindowsPowerShellImplementation, - pwshAvailable: shouldProbePwsh ? isPwshAvailable() : false - }) ?? shellPath) - : shellPath - } - if ( - pathWin32.basename(shellPath).toLowerCase() === 'cmd.exe' && - env.ORCA_CODEX_LAUNCH_PREFLIGHT - ) { - // Why: node-pty backslash-escapes argv quotes; expand the quote inside cmd.exe instead. - env[ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV] = '"' - } - // Why: a bare `pwsh.exe` resolves to the Store App Execution Alias stub whose launch fails with ERROR_ACCESS_DENIED (5). - windowsFallbackAttempts = buildWindowsPowerShellSpawnAttempts({ - shellPath, - cwd: spawnCwd, - defaultCwd: getDefaultCwd(), - wslContext: resolvedWslContext, - startupCommand: opts.command - }) - const primaryAttempt = windowsFallbackAttempts[0] - if (primaryAttempt) { - shellPath = primaryAttempt.shellPath - shellArgs = primaryAttempt.shellArgs - spawnCwd = primaryAttempt.effectiveCwd - validationCwd = primaryAttempt.validationCwd - startupCommandDeliveredInShellArgs = primaryAttempt.startupCommandDeliveredInShellArgs - } else { - const resolved = resolveWindowsShellLaunchArgs( - shellPath, - spawnCwd, - getDefaultCwd(), - resolvedWslContext, - opts.command, - env.ORCA_CODEX_LAUNCH_PREFLIGHT - ) - shellArgs = resolved.shellArgs - spawnCwd = resolved.effectiveCwd - validationCwd = resolved.validationCwd - startupCommandDeliveredInShellArgs = resolved.startupCommandDeliveredInShellArgs === true - } - if (isWindowsGitBashShellPath(shellPath)) { - // Why: Git for Windows login startup files otherwise cd to $HOME, ignoring node-pty's cwd. - env.CHERE_INVOKING ??= '1' - } - const codexHomeWslInfo = env.CODEX_HOME ? parseWslPath(env.CODEX_HOME) : null - if (pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe') { - if (codexHomeWslInfo) { - const launchWslDistro = resolvedWslContext?.distro - if (launchWslDistro && launchWslDistro !== codexHomeWslInfo.distro) { - delete env.CODEX_HOME - delete env.ORCA_CODEX_HOME - } else { - env.CODEX_HOME = codexHomeWslInfo.linuxPath - env.ORCA_CODEX_HOME = codexHomeWslInfo.linuxPath - // Why: wsl.exe only imports non-default env vars named in WSLENV. - addWslEnvKeys(env, ['CODEX_HOME', 'ORCA_CODEX_HOME']) - if (!launchWslDistro) { - const resolved = resolveWindowsShellLaunchArgs( - shellPath, - requestedCwd, - getDefaultCwd(), - { - distro: codexHomeWslInfo.distro - }, - opts.command, - env.ORCA_CODEX_LAUNCH_PREFLIGHT - ) - shellArgs = resolved.shellArgs - spawnCwd = resolved.effectiveCwd - validationCwd = resolved.validationCwd - startupCommandDeliveredInShellArgs = - resolved.startupCommandDeliveredInShellArgs === true - } - } - } else if (isHostCodexHomeForWsl(env.CODEX_HOME)) { - // Why: host-local Codex home is unusable in WSL; let WSL Codex use its Linux-side ~/.codex. - delete env.CODEX_HOME - delete env.ORCA_CODEX_HOME - } else if (env.CODEX_HOME) { - addWslEnvKeys(env, ['CODEX_HOME', 'ORCA_CODEX_HOME']) - } - if (env.CLAUDE_CONFIG_DIR) { - // Why: non-default env vars need WSLENV import to cross Windows wsl.exe into the Linux side. - addWslEnvKeys(env, ['CLAUDE_CONFIG_DIR']) - } - if (env[ORCA_HERMES_STARTUP_QUERY_ENV] !== undefined) { - // Why: wsl.exe drops custom Windows env vars unless named in WSLENV. - addWslEnvKeys(env, [ORCA_HERMES_STARTUP_QUERY_ENV]) - } - } else if (codexHomeWslInfo || isWslCodexHomeForHost(env.CODEX_HOME)) { - // Why: WSL Codex homes are Linux paths; also drop ORCA_CODEX_HOME since shell-ready restores CODEX_HOME from it. - delete env.CODEX_HOME - delete env.ORCA_CODEX_HOME - } - if (pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe') { - addOrcaWslInteropEnv(env) - } - } else { - // Why: relay-side launch modes can ask for host defaults to stay scrubbed - // even after environment normalization above. - deleteRequestedDaemonEnvKeys(env, opts.envToDelete) - if (opts.env?.TERM) { - env.TERM = opts.env.TERM - } - // Why: set SHELL after the scrub and before launch-config derivation so shell-ready wrappers target the resolved shell. - const preferredShellPath = shellPath - shellPath = resolveUnixShellPath(shellPath) - if (shellPath !== preferredShellPath) { - env.SHELL = shellPath - console.warn( - `[daemon/pty] Preferred shell "${preferredShellPath}" is unavailable, fell back to "${shellPath}"` - ) - } - // Why: OpenCode/Codex path restoration, OMP's typed-command status wrapper, - // and the worktree HISTFILE repair all need shell-ready code that runs after - // the user's own startup files. - // Why: payload-bearing Codex startup text can be dropped by rc-file noise; plain Codex stays markerless for the startup-speed path. - const waitsForShellReady = - Boolean(opts.command) && - (!isCodexStartupCommand || - shouldUseShellReadyStartupDelivery({ - command: opts.command as string, - startupCommandDelivery: opts.startupCommandDelivery - })) - // Why delete: ORCA_SHELL_FEATURES is Orca-owned, and only the launch config - // below may name features for this shell. - delete env.ORCA_SHELL_FEATURES - const shellLaunch = getShellLaunchConfig( - shellPath, - selectShellStartupFeatures({ - shellPath, - env, - hasStartupCommand: Boolean(opts.command), - waitsForShellReady, - // Why identical: the identity marker exists so the readiness handshake - // can bind output to the right shell PID. - emitsStartupIdentity: waitsForShellReady - }) - ) - Object.assign(env, shellLaunch.env) - shellArgs = shellLaunch.args ?? ['-l'] - } - seedPowerlevel10kWizardEnv(env, { envToDelete: opts.envToDelete }) - if ( - env[POWERLEVEL10K_WIZARD_DISABLE_ENV] !== undefined && - process.platform === 'win32' && - pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe' - ) { - addWslEnvKeys(env, [POWERLEVEL10K_WIZARD_DISABLE_ENV]) - } - const requestedEnv = opts.env - expandWindowsPathEnvironmentVariables(env) - // Why: collapse before promoting so the shim lands on the spelling the child actually inherits. - collapseWindowsPathEnvKeys(env, requestedEnv) - const requestedPath = requestedEnv - ? requestedEnv[resolvePathEnvKey(requestedEnv, process.platform)] - : undefined - promoteAgentTeamsShimPath(env, requestedPath) - // Why: raw requested PATH promotion runs after the inherited-env scrub. - stripLegacyTerminalShimEnv(env, process.platform) - // Why after every deletion pass: an envToDelete of CONDA_PREFIX must not leave the sentinel behind. - dropIncoherentCondaActivationEnv(env, process.platform) - - // Why: asar packaging can strip +x from node-pty's spawn-helper; the daemon is a separate forked process from the main-process fix. - ensureNodePtySpawnHelperExecutable() - preflightUnixPtySpawnEnvironment() - try { - await preflightPosixPtySpawnEnvironment(validationCwd, opts.cancelSignal) - await preflightWindowsPtySpawnEnvironment({ - validationCwd, - cwdWasExplicit: opts.cwd !== undefined, - ...(opts.cancelSignal ? { signal: opts.cancelSignal } : {}) - }) - } catch (error) { - // Why: the wire must carry one cancellation identity. Clients key recovery - // off it, and an unrecognized message takes the rollback branch that closes - // a terminal the user still has (#7718). - if (error instanceof WorkingDirectoryValidationAbortedError) { - throw new TerminalAttachCanceledError(opts.sessionId) - } - throw error - } + await preflightPtySpawn({ + validationCwd: launch.validationCwd, + cwdWasExplicit: opts.cwd !== undefined, + sessionId: opts.sessionId, + ...(opts.cancelSignal ? { signal: opts.cancelSignal } : {}) + }) if (opts.isCanceled?.()) { throw new TerminalAttachCanceledError(opts.sessionId) } - let proc: pty.IPty - let reportsChildExitStatus = true + let spawned: SpawnedDaemonPty try { - const spawned = spawnDaemonPtyWithWindowsFallback({ - shellPath, - shellArgs, - spawnCwd, + spawned = spawnNativeDaemonPty({ + shellPath: launch.shellPath, + shellArgs: launch.shellArgs, + spawnCwd: launch.spawnCwd, env, cols: size.cols, rows: size.rows, - windowsFallbackAttempts, + windowsFallbackAttempts: launch.windowsFallbackAttempts, onMacosTccSpawnStrategy: opts.onMacosTccSpawnStrategy }) - proc = spawned.process - // Why: a Windows fallback (e.g. cmd.exe) carries its own argv-embedded startup command; adopt the winning shell's identity + delivery flag. - shellPath = spawned.shellPath - spawnCwd = spawned.spawnCwd - reportsChildExitStatus = spawned.reportsChildExitStatus - if (spawned.startupCommandDeliveredInShellArgs !== undefined) { - startupCommandDeliveredInShellArgs = spawned.startupCommandDeliveredInShellArgs - } - } catch (err) { + } catch (error) { if (process.platform === 'win32') { - throw formatPtySpawnError(err, shellPath, spawnCwd) + throw formatPtySpawnError(error, launch.shellPath, launch.spawnCwd) } - throw err + throw error } - let onDataCb: ((data: string) => void) | null = null - let onExitCb: ((code: number, cause?: TerminalExitCause) => void) | null = null - let pendingPreListenerData: string[] = [] - let pendingPreListenerDataChars = 0 - let pendingPreListenerExitCode: number | null = null - let pendingPreListenerExitCause: TerminalExitCause | null = null - - const bufferPreListenerData = (data: string): void => { - // Why: Windows shell-arg startup commands can print before Session wires this subprocess in; preserve that spawn-time race window. - pendingPreListenerData.push(data) - pendingPreListenerDataChars += data.length - while (pendingPreListenerDataChars > PENDING_PRE_LISTENER_DATA_MAX_CHARS) { - const removed = pendingPreListenerData.shift() - if (removed === undefined) { - pendingPreListenerDataChars = 0 - return - } - pendingPreListenerDataChars -= removed.length - } - } - - const flushPreListenerData = (): void => { - if (!onDataCb || pendingPreListenerData.length === 0) { - return - } - const pending = pendingPreListenerData - pendingPreListenerData = [] - pendingPreListenerDataChars = 0 - for (const data of pending) { - onDataCb(data) - } - } - - let lastOutputAt = 0 - proc.onData((data) => { - if (data.length > 0) { - lastOutputAt = Date.now() - } - if (onDataCb) { - onDataCb(data) - } else { - bufferPreListenerData(data) - } + return createDaemonPtySubprocessHandle({ + process: spawned.process, + shellPath: spawned.shellPath, + spawnCwd: spawned.spawnCwd, + env, + startupCommandDeliveredInShellArgs: + spawned.startupCommandDeliveredInShellArgs ?? launch.startupCommandDeliveredInShellArgs, + reportsChildExitStatus: spawned.reportsChildExitStatus, + requestedCwd: opts.cwd, + sessionId: opts.sessionId, + startupAgentRecognition: launch.startupAgentRecognition }) - proc.onExit(({ exitCode, signal }) => { - // Why: node-pty reports a signalled death as {exitCode: 0, signal: N}, and a - // wrapper spawn reports the wrapper's status — so build the cause here, - // where both facts are still in hand, rather than shipping a bare number. - const cause = resolveProcessExitCause({ - exitCode, - signal, - hostReportsChildExitStatus: reportsChildExitStatus - }) - if (onExitCb) { - flushPreListenerData() - onExitCb(exitCode, cause) - } else { - pendingPreListenerExitCode = exitCode - pendingPreListenerExitCause = cause - } - }) - - // Why: node-pty throws Napi::Error if write/resize/kill hit a closed fd (child-exit vs onExit race); uncaught it std::terminates the daemon. - let dead = false - let disposed = false - let nodePtyKillIssued = false - let cachedAgentForeground: { processName: string; refreshedAt: number } | null = null - const agentForegroundContextPaths = getAgentForegroundContextPaths({ - cwd: opts.cwd, - worktreeId: parsePtySessionId(opts.sessionId).worktreeId - }) - let startupAgentForeground: { processName: string; expiresAt: number } | null = - startupAgentRecognition - ? { - processName: startupAgentRecognition.processName, - expiresAt: Date.now() + STARTUP_AGENT_FOREGROUND_BOOTSTRAP_MS - } - : null - let foregroundRefreshInFlight = false - let lastForegroundRefreshStartedAt = 0 - const getFallbackForegroundProcess = (): string | null => - resolveFallbackForegroundProcess(proc.process, shellPath) - const getActiveStartupAgentForeground = ( - now = Date.now() - ): { processName: string; expiresAt: number } | null => { - if (!startupAgentForeground) { - return null - } - if (now > startupAgentForeground.expiresAt) { - startupAgentForeground = null - return null - } - return startupAgentForeground - } - const shouldInspectFallbackForegroundProcess = (fallbackProcess: string | null): boolean => - fallbackProcess !== null && - (isShellProcess(fallbackProcess) || - isAgentForegroundWrapperProcess(fallbackProcess) || - shouldInspectOuterWrapperFallback(fallbackProcess) || - // Why: agent-spawned helpers can become the PTY foreground child, but the Unix process tree still identifies the parent agent. - process.platform !== 'win32') - const scheduleAgentForegroundRefresh = (fallbackProcess: string | null): void => { - if (dead || !proc.pid) { - return - } - const fallbackIsShell = fallbackProcess !== null && isShellProcess(fallbackProcess) - const fallbackRecognition = recognizeAgentProcess(fallbackProcess) - if ( - !fallbackProcess || - (fallbackRecognition !== null && - !shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)) || - !shouldInspectFallbackForegroundProcess(fallbackProcess) - ) { - return - } - const now = Date.now() - const idleNoEvidenceShell = - fallbackIsShell && !getActiveStartupAgentForeground(now) && !cachedAgentForeground - // Why: on Windows each refresh is a whole-table CIM scan, so only shells with no agent evidence and no recent output relax the retry. - const retryMs = !idleNoEvidenceShell - ? FOREGROUND_AGENT_CACHE_TTL_MS - : process.platform === 'win32' && now - lastOutputAt > SHELL_FOREGROUND_OUTPUT_HOT_WINDOW_MS - ? WINDOWS_IDLE_SHELL_FOREGROUND_REFRESH_RETRY_MS - : SHELL_FOREGROUND_REFRESH_RETRY_MS - if (foregroundRefreshInFlight || now - lastForegroundRefreshStartedAt < retryMs) { - return - } - foregroundRefreshInFlight = true - lastForegroundRefreshStartedAt = now - // Why: daemon foreground reads are sync on the IPC hot path; refresh derived identities in the background and serve from a short cache. - // Why: Windows may need an async membership check before this shell/wrapper retirement policy is safe. - const retireStaleForegroundIdentity = (): void => { - const currentFallbackProcess = getFallbackForegroundProcess() - if ( - fallbackIsShell && - !getActiveStartupAgentForeground() && - currentFallbackProcess !== null && - isShellProcess(currentFallbackProcess) - ) { - cachedAgentForeground = null - startupAgentForeground = null - } else if ( - cachedAgentForeground !== null && - Date.now() - cachedAgentForeground.refreshedAt > FOREGROUND_AGENT_CACHE_TTL_MS && - currentFallbackProcess !== null && - isAgentForegroundWrapperProcess(currentFallbackProcess) - ) { - // Why: an expired wrapper identity must not transfer to an unrelated wrapper (e.g. npm after an agent exit); fresh ones survive scan hiccups. - cachedAgentForeground = null - } - } - void resolveAgentForegroundProcessWithAvailability(proc.pid, fallbackProcess, { - contextPaths: agentForegroundContextPaths - }) - .then(({ processName, available }) => { - if (dead) { - return - } - // Why: a degraded scan isn't exit evidence — retiring would fire false completion while the agent works under CIM load. - if (!available) { - return - } - if (!processName || !recognizeAgentProcess(processName)) { - // Why: a Windows snapshot can omit a live agent; only verified shell-only membership may retire cached identity. - if (process.platform === 'win32' && fallbackIsShell && cachedAgentForeground !== null) { - return readWindowsConptyProcessIds(proc.pid).then((consoleProcessIds) => { - if (dead || consoleProcessIds === null || consoleProcessIds.size > 1) { - return - } - retireStaleForegroundIdentity() - }) - } - retireStaleForegroundIdentity() - return - } - cachedAgentForeground = { processName, refreshedAt: Date.now() } - startupAgentForeground = null - return processName - }) - .catch(() => { - // Best-effort only: foreground enrichment must never affect PTY health. - }) - .finally(() => { - foregroundRefreshInFlight = false - }) - } - proc.onExit(() => { - dead = true - cachedAgentForeground = null - startupAgentForeground = null - // Why: neutralize proc.kill synchronously in onExit so UnixTerminal's async socket-close SIGHUP can't land on a recycled pid. - // Windows excluded: WindowsTerminal.destroy needs kill() to close the ConPTY agent. - if (process.platform !== 'win32') { - ;(proc as unknown as { kill: (sig?: string) => void }).kill = () => {} - } - }) - - const slavePath = readPtySlavePath(proc) - return { - pid: proc.pid, - shellPath, - shellCwd: spawnCwd, - shellPathEnv: env.PATH, - ...(slavePath ? { slavePath } : {}), - ...(startupCommandDeliveredInShellArgs ? { startupCommandDeliveredInShellArgs: true } : {}), - getForegroundProcess: () => { - // Why: node-pty's `.process` reports the live foreground name but reads a recycled pid on a reaped pty, so bail when dead. - if (dead) { - return null - } - try { - const fallbackProcess = getFallbackForegroundProcess() - const fallbackRecognition = recognizeAgentProcess(fallbackProcess) - const inspectOuterWrapper = - fallbackRecognition !== null && - shouldInspectOuterWrapperForegroundProcess(fallbackRecognition) - if (fallbackProcess && fallbackRecognition && !inspectOuterWrapper) { - cachedAgentForeground = { processName: fallbackProcess, refreshedAt: Date.now() } - startupAgentForeground = null - return fallbackProcess - } - scheduleAgentForegroundRefresh(fallbackProcess) - const now = Date.now() - if ( - cachedAgentForeground && - now - cachedAgentForeground.refreshedAt <= FOREGROUND_AGENT_CACHE_TTL_MS - ) { - return cachedAgentForeground.processName - } - // Why: a wrapper (node/python) can't self-identify and readers poll slower than the cache TTL, so serve the last resolved agent. - // On Windows a shell fallback is an unreliable exit signal under ConPTY lag; trust the cache until a console-presence read confirms exit. - if ( - cachedAgentForeground && - fallbackProcess !== null && - (isAgentForegroundWrapperProcess(fallbackProcess) || - inspectOuterWrapper || - (process.platform === 'win32' && isShellProcess(fallbackProcess))) - ) { - return cachedAgentForeground.processName - } - const activeStartupAgentForeground = getActiveStartupAgentForeground(now) - if (fallbackProcess && isShellProcess(fallbackProcess) && activeStartupAgentForeground) { - return activeStartupAgentForeground.processName - } - return fallbackProcess - } catch { - return null - } - }, - confirmForegroundProcess: async () => { - if (dead || !proc.pid) { - return null - } - try { - const fallbackProcess = getFallbackForegroundProcess() - const fallbackRecognition = recognizeAgentProcess(fallbackProcess) - if ( - !fallbackProcess || - (fallbackRecognition !== null && - process.platform !== 'win32' && - !shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)) || - (process.platform !== 'win32' && !shouldInspectFallbackForegroundProcess(fallbackProcess)) - ) { - return fallbackProcess - } - // Why: cached/in-flight scans may predate the OSC command boundary; confirmation needs a snapshot started afterward. - const resolution = await resolveAgentForegroundProcessWithAvailability( - proc.pid, - fallbackProcess, - { - contextPaths: agentForegroundContextPaths, - fresh: true, - ...(process.platform === 'win32' - ? { - forceProcessScan: true, - readWindowsConptyProcessIds: () => readWindowsConptyProcessIds(proc.pid) - } - : {}) - } - ) - if (dead || !resolution.available) { - return null - } - const recognized = recognizeAgentProcess(resolution.processName) - if (recognized) { - cachedAgentForeground = { - processName: recognized.processName, - refreshedAt: Date.now() - } - startupAgentForeground = null - return recognized.processName - } - // Why: a post-boundary scan resolving no agent is the authority that retires stale cached/startup identity. - cachedAgentForeground = null - startupAgentForeground = null - return resolution.processName - } catch { - return null - } - }, - write: (data) => { - if (dead) { - return - } - try { - proc.write(data) - } catch { - dead = true - } - }, - resize: (cols, rows) => { - if (dead) { - return - } - if (!isValidPtySize(cols, rows)) { - return - } - try { - proc.resize(cols, rows) - } catch { - dead = true - } - }, - // Why pause/resume work on Windows too: WindowsTerminal wires _socket to the ConPTY conout pipe, so pausing backpressures the child. - pause: () => { - if (dead) { - return - } - try { - proc.pause() - } catch { - /* native handle already torn down — flow control is best-effort */ - } - }, - resume: () => { - if (dead) { - return - } - try { - proc.resume() - } catch { - /* native handle already torn down — flow control is best-effort */ - } - }, - clear: () => { - if (dead) { - return - } - try { - proc.clear() - } catch { - // Best-effort: a clear on a just-exited PTY must not kill the handle. - } - }, - kill: () => { - if (dead) { - return - } - nodePtyKillIssued = true - try { - proc.kill() - } catch (error) { - // Why: a rejected native kill isn't proof of exit — keep the wrapper live so Session can retry the owner. - nodePtyKillIssued = false - throw error - } - }, - terminateOwnedTree: () => terminatePtyJob(proc), - forceKill: () => { - // Why: after reap/dispose proc.pid is a recycled pid, so SIGKILL would hit an unrelated process (forceKill only signals a live child). - if (dead) { - return - } - // Why: Windows node-pty kill already closed ConPTY; forcing again can double-close the native handle. - // That left forceKill a permanent no-op after any kill(), so a wedged ConPTY -- which - // never fires onExit -- had no escalation at all (#9854). The job is that escalation: - // it terminates the tree without touching the shell handle node-pty owns. - if (process.platform === 'win32' && nodePtyKillIssued) { - terminatePtyJob(proc) - return - } - try { - forceKillPosixPtyProcessGroups(proc.pid, () => { - process.kill(proc.pid, 'SIGKILL') - }) - } catch (signalError) { - try { - proc.kill() - nodePtyKillIssued = true - } catch { - nodePtyKillIssued = false - // Keep the original OS failure so callers can retry the same owner. - throw signalError - } - } - }, - signal: (sig) => { - // Why: same recycled-pid hazard as forceKill — once dead, dropping avoids signalling an unrelated process. - if (dead) { - return - } - const signalRootPid = (): void => { - try { - process.kill(proc.pid, sig) - } catch { - // Process may already be dead - } - } - // Why only SIGWINCH: the kernel delivers a real resize to the tty's foreground - // group, and proc.pid is never in it (the shell setpgid's away, and on macOS - // proc.pid is login(1), which drops SIGWINCH). Destructive signals keep the - // narrower root-pid target. - if (sig === 'SIGWINCH') { - signalPosixPtyForegroundGroup(proc.pid, readPtsName(proc), sig, signalRootPid) - return - } - signalRootPid() - }, - onData: (cb) => { - onDataCb = cb - flushPreListenerData() - }, - onExit: (cb) => { - onExitCb = cb - if (pendingPreListenerExitCode !== null) { - const code = pendingPreListenerExitCode - const cause = pendingPreListenerExitCause ?? resolveProcessExitCause({ exitCode: code }) - pendingPreListenerExitCode = null - pendingPreListenerExitCause = null - flushPreListenerData() - cb(code, cause) - } - }, - dispose: () => { - if (disposed) { - return - } - disposed = true - dead = true - onDataCb = null - onExitCb = null - pendingPreListenerData = [] - pendingPreListenerDataChars = 0 - pendingPreListenerExitCode = null - pendingPreListenerExitCause = null - // Why: UnixTerminal.destroy()'s async socket-close SIGHUP can land on a recycled pid, hitting an unrelated user process; neutralize kill on POSIX. - // Windows destroy() uses kill() to close the ConPTY agent, so the guard is POSIX-only. - if (process.platform !== 'win32') { - ;(proc as unknown as { kill: (sig?: string) => void }).kill = () => {} - } else if (nodePtyKillIssued) { - // Why: WindowsTerminal.destroy() calls kill(); destroying after node-pty's kill() double-closes the ConPTY handle (heap corruption). - return - } - try { - ;(proc as unknown as { destroy?: () => void }).destroy?.() - } catch { - /* swallow — already torn down, or native-side error we can't recover from */ - } - } - } } diff --git a/src/main/daemon/pty-subprocess/foreground-process-tracker.ts b/src/main/daemon/pty-subprocess/foreground-process-tracker.ts new file mode 100644 index 00000000000..ff905e0cdfb --- /dev/null +++ b/src/main/daemon/pty-subprocess/foreground-process-tracker.ts @@ -0,0 +1,275 @@ +import type * as pty from 'node-pty' +import { win32 as pathWin32 } from 'node:path' +import { getAgentForegroundContextPaths } from '../../providers/agent-foreground-context-paths' +import { resolveAgentForegroundProcessWithAvailability } from '../../providers/agent-foreground-process' +import { readWindowsConptyProcessIds } from '../../providers/windows-conpty-process-membership' +import { + isAgentForegroundWrapperProcess, + recognizeAgentProcess, + type RecognizedAgentProcess +} from '../../../shared/agent-process-recognition' +import { shouldInspectOuterWrapperForegroundProcess } from '../../../shared/foreground-wrapper-agent' +import { isShellProcess } from '../../../shared/shell-process-detection' +import { parsePtySessionId } from '../pty-session-id' + +const FOREGROUND_AGENT_CACHE_TTL_MS = 1000 +const SHELL_FOREGROUND_REFRESH_RETRY_MS = 5_000 +const WINDOWS_IDLE_SHELL_FOREGROUND_REFRESH_RETRY_MS = 15_000 +const SHELL_FOREGROUND_OUTPUT_HOT_WINDOW_MS = 10_000 +const STARTUP_AGENT_FOREGROUND_BOOTSTRAP_MS = 5_000 + +function normalizeForegroundProcessName(processName: string | null | undefined): string | null { + const trimmed = processName?.trim().replace(/^["']|["']$/g, '') ?? '' + if (!trimmed || trimmed === 'xterm-256color') { + return null + } + return trimmed.split(/[\\/]/).pop() || null +} + +function resolveFallbackForegroundProcess( + processName: string | null | undefined, + shellPath: string +): string | null { + const normalized = normalizeForegroundProcessName(processName) + if (normalized || process.platform !== 'win32') { + return normalized + } + return normalizeForegroundProcessName(pathWin32.basename(shellPath)) +} + +function shouldInspectOuterWrapperFallback(processName: string | null): boolean { + const recognized = recognizeAgentProcess(processName) + return recognized !== null && shouldInspectOuterWrapperForegroundProcess(recognized) +} + +export type PtyForegroundProcessTracker = { + recordOutput(data: string): void + markDead(): void + getForegroundProcess(): string | null + confirmForegroundProcess(): Promise +} + +export function createPtyForegroundProcessTracker(args: { + process: pty.IPty + shellPath: string + cwd?: string + sessionId: string + startupAgentRecognition: RecognizedAgentProcess | null + isDead: () => boolean +}): PtyForegroundProcessTracker { + const proc = args.process + let lastOutputAt = 0 + let cachedAgentForeground: { processName: string; refreshedAt: number } | null = null + const contextPaths = getAgentForegroundContextPaths({ + cwd: args.cwd, + worktreeId: parsePtySessionId(args.sessionId).worktreeId + }) + let startupAgentForeground: { processName: string; expiresAt: number } | null = + args.startupAgentRecognition + ? { + processName: args.startupAgentRecognition.processName, + expiresAt: Date.now() + STARTUP_AGENT_FOREGROUND_BOOTSTRAP_MS + } + : null + let foregroundRefreshInFlight = false + let lastForegroundRefreshStartedAt = 0 + const getFallbackProcess = (): string | null => + resolveFallbackForegroundProcess(proc.process, args.shellPath) + const getActiveStartupAgent = ( + now = Date.now() + ): { processName: string; expiresAt: number } | null => { + if (!startupAgentForeground) { + return null + } + if (now > startupAgentForeground.expiresAt) { + startupAgentForeground = null + return null + } + return startupAgentForeground + } + const shouldInspectFallback = (fallbackProcess: string | null): boolean => + fallbackProcess !== null && + (isShellProcess(fallbackProcess) || + isAgentForegroundWrapperProcess(fallbackProcess) || + shouldInspectOuterWrapperFallback(fallbackProcess) || + process.platform !== 'win32') + + const scheduleRefresh = (fallbackProcess: string | null): void => { + if (args.isDead() || !proc.pid) { + return + } + const fallbackIsShell = fallbackProcess !== null && isShellProcess(fallbackProcess) + const fallbackRecognition = recognizeAgentProcess(fallbackProcess) + if ( + !fallbackProcess || + (fallbackRecognition !== null && + !shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)) || + !shouldInspectFallback(fallbackProcess) + ) { + return + } + const now = Date.now() + const idleNoEvidenceShell = + fallbackIsShell && !getActiveStartupAgent(now) && !cachedAgentForeground + const retryMs = !idleNoEvidenceShell + ? FOREGROUND_AGENT_CACHE_TTL_MS + : process.platform === 'win32' && now - lastOutputAt > SHELL_FOREGROUND_OUTPUT_HOT_WINDOW_MS + ? WINDOWS_IDLE_SHELL_FOREGROUND_REFRESH_RETRY_MS + : SHELL_FOREGROUND_REFRESH_RETRY_MS + if (foregroundRefreshInFlight || now - lastForegroundRefreshStartedAt < retryMs) { + return + } + foregroundRefreshInFlight = true + lastForegroundRefreshStartedAt = now + const retireStaleForegroundIdentity = (): void => { + const currentFallbackProcess = getFallbackProcess() + if ( + fallbackIsShell && + !getActiveStartupAgent() && + currentFallbackProcess !== null && + isShellProcess(currentFallbackProcess) + ) { + cachedAgentForeground = null + startupAgentForeground = null + } else if ( + cachedAgentForeground !== null && + Date.now() - cachedAgentForeground.refreshedAt > FOREGROUND_AGENT_CACHE_TTL_MS && + currentFallbackProcess !== null && + isAgentForegroundWrapperProcess(currentFallbackProcess) + ) { + cachedAgentForeground = null + } + } + void resolveAgentForegroundProcessWithAvailability(proc.pid, fallbackProcess, { + contextPaths + }) + .then(({ processName, available }) => { + if (args.isDead() || !available) { + return + } + if (!processName || !recognizeAgentProcess(processName)) { + if (process.platform === 'win32' && fallbackIsShell && cachedAgentForeground !== null) { + return readWindowsConptyProcessIds(proc.pid).then((consoleProcessIds) => { + if (args.isDead() || consoleProcessIds === null || consoleProcessIds.size > 1) { + return + } + retireStaleForegroundIdentity() + }) + } + retireStaleForegroundIdentity() + return + } + cachedAgentForeground = { processName, refreshedAt: Date.now() } + startupAgentForeground = null + return processName + }) + .catch(() => { + // Best-effort only: foreground enrichment must never affect PTY health. + }) + .finally(() => { + foregroundRefreshInFlight = false + }) + } + + return { + recordOutput: (data) => { + if (data.length > 0) { + lastOutputAt = Date.now() + } + }, + markDead: () => { + cachedAgentForeground = null + startupAgentForeground = null + }, + getForegroundProcess: () => { + if (args.isDead()) { + return null + } + try { + const fallbackProcess = getFallbackProcess() + const fallbackRecognition = recognizeAgentProcess(fallbackProcess) + const inspectOuterWrapper = + fallbackRecognition !== null && + shouldInspectOuterWrapperForegroundProcess(fallbackRecognition) + if (fallbackProcess && fallbackRecognition && !inspectOuterWrapper) { + cachedAgentForeground = { processName: fallbackProcess, refreshedAt: Date.now() } + startupAgentForeground = null + return fallbackProcess + } + scheduleRefresh(fallbackProcess) + const now = Date.now() + if ( + cachedAgentForeground && + now - cachedAgentForeground.refreshedAt <= FOREGROUND_AGENT_CACHE_TTL_MS + ) { + return cachedAgentForeground.processName + } + if ( + cachedAgentForeground && + fallbackProcess !== null && + (isAgentForegroundWrapperProcess(fallbackProcess) || + inspectOuterWrapper || + (process.platform === 'win32' && isShellProcess(fallbackProcess))) + ) { + return cachedAgentForeground.processName + } + const activeStartupAgentForeground = getActiveStartupAgent(now) + if (fallbackProcess && isShellProcess(fallbackProcess) && activeStartupAgentForeground) { + return activeStartupAgentForeground.processName + } + return fallbackProcess + } catch { + return null + } + }, + confirmForegroundProcess: async () => { + if (args.isDead() || !proc.pid) { + return null + } + try { + const fallbackProcess = getFallbackProcess() + const fallbackRecognition = recognizeAgentProcess(fallbackProcess) + if ( + !fallbackProcess || + (fallbackRecognition !== null && + process.platform !== 'win32' && + !shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)) || + (process.platform !== 'win32' && !shouldInspectFallback(fallbackProcess)) + ) { + return fallbackProcess + } + const resolution = await resolveAgentForegroundProcessWithAvailability( + proc.pid, + fallbackProcess, + { + contextPaths, + fresh: true, + ...(process.platform === 'win32' + ? { + forceProcessScan: true, + readWindowsConptyProcessIds: () => readWindowsConptyProcessIds(proc.pid) + } + : {}) + } + ) + if (args.isDead() || !resolution.available) { + return null + } + const recognized = recognizeAgentProcess(resolution.processName) + if (recognized) { + cachedAgentForeground = { + processName: recognized.processName, + refreshedAt: Date.now() + } + startupAgentForeground = null + return recognized.processName + } + cachedAgentForeground = null + startupAgentForeground = null + return resolution.processName + } catch { + return null + } + } + } +} diff --git a/src/main/daemon/pty-subprocess/native-pty-spawn.ts b/src/main/daemon/pty-subprocess/native-pty-spawn.ts new file mode 100644 index 00000000000..e0332ba9921 --- /dev/null +++ b/src/main/daemon/pty-subprocess/native-pty-spawn.ts @@ -0,0 +1,82 @@ +import * as pty from 'node-pty' +import { + hostReportsChildExitStatus, + wrapShellSpawnForMacosTccAttribution +} from '../../providers/macos-tcc-login-shell' +import type { WindowsShellSpawnAttempt } from '../../providers/windows-shell-fallback-chain' +import { assignHostProcessToKillOnCloseJob } from '../../windows/windows-pty-job' + +export type SpawnedDaemonPty = { + process: pty.IPty + shellPath: string + spawnCwd: string + startupCommandDeliveredInShellArgs?: boolean + /** False when a wrapper owns the reported status, so no exit code may be read from it. */ + reportsChildExitStatus: boolean +} + +/** Walks the Windows PowerShell -> cmd.exe fallback chain when ConPTY rejects the primary shell. */ +export function spawnNativeDaemonPty(args: { + shellPath: string + shellArgs: string[] + spawnCwd: string + env: Record + cols: number + rows: number + windowsFallbackAttempts: WindowsShellSpawnAttempt[] + onMacosTccSpawnStrategy?: (strategy: 'wrapped' | 'direct') => void +}): SpawnedDaemonPty { + let reportsChildExitStatus = true + const spawnAt = (shellPath: string, shellArgs: string[], cwd: string): pty.IPty => { + const wrapped = wrapShellSpawnForMacosTccAttribution(shellPath, shellArgs, args.env) + // Why: children inherit job membership, so the host job must exist before the first Windows PTY. + if (process.platform === 'win32') { + assignHostProcessToKillOnCloseJob() + } + const proc = pty.spawn(wrapped.file, wrapped.args, { + name: args.env.TERM ?? 'xterm-256color', + cols: args.cols, + rows: args.rows, + cwd, + env: args.env, + // Why: bundled ConPTY has the wrap-marker behavior xterm expects. + ...(process.platform === 'win32' ? { useConptyDll: true } : {}) + }) + reportsChildExitStatus = hostReportsChildExitStatus(wrapped.file) + args.onMacosTccSpawnStrategy?.(wrapped.file === shellPath ? 'direct' : 'wrapped') + return proc + } + + try { + const process_ = spawnAt(args.shellPath, args.shellArgs, args.spawnCwd) + return { + process: process_, + shellPath: args.shellPath, + spawnCwd: args.spawnCwd, + reportsChildExitStatus + } + } catch (primaryErr) { + if (process.platform !== 'win32') { + throw primaryErr + } + for (const attempt of args.windowsFallbackAttempts.slice(1)) { + try { + const process = spawnAt(attempt.shellPath, attempt.shellArgs, attempt.effectiveCwd) + const message = primaryErr instanceof Error ? primaryErr.message : String(primaryErr) + console.warn( + `[daemon/pty] Primary shell "${args.shellPath}" failed (${message}), fell back to "${attempt.shellPath}"` + ) + return { + process, + shellPath: attempt.shellPath, + spawnCwd: attempt.effectiveCwd, + startupCommandDeliveredInShellArgs: attempt.startupCommandDeliveredInShellArgs, + reportsChildExitStatus + } + } catch { + // This fallback shell also failed -- try the next link in the chain. + } + } + throw primaryErr + } +} diff --git a/src/main/daemon/pty-subprocess/pre-listener-events.ts b/src/main/daemon/pty-subprocess/pre-listener-events.ts new file mode 100644 index 00000000000..f7eb7216e58 --- /dev/null +++ b/src/main/daemon/pty-subprocess/pre-listener-events.ts @@ -0,0 +1,90 @@ +import { + resolveProcessExitCause, + type TerminalExitCause +} from '../../../shared/terminal-exit-cause' + +const PENDING_PRE_LISTENER_DATA_MAX_CHARS = 512 * 1024 + +type DataListener = (data: string) => void +type ExitListener = (code: number, cause?: TerminalExitCause) => void + +/** Preserves spawn-time output and exit ordering until Session installs its listeners. */ +export class PtyPreListenerEvents { + private onDataCb: DataListener | null = null + private onExitCb: ExitListener | null = null + private pendingData: string[] = [] + private pendingDataChars = 0 + private pendingExitCode: number | null = null + private pendingExitCause: TerminalExitCause | null = null + + acceptData(data: string): void { + if (this.onDataCb) { + this.onDataCb(data) + return + } + this.pendingData.push(data) + this.pendingDataChars += data.length + while (this.pendingDataChars > PENDING_PRE_LISTENER_DATA_MAX_CHARS) { + const removed = this.pendingData.shift() + if (removed === undefined) { + this.pendingDataChars = 0 + return + } + this.pendingDataChars -= removed.length + } + } + + acceptExit(args: { + exitCode: number + signal?: number + hostReportsChildExitStatus: boolean + }): void { + const cause = resolveProcessExitCause(args) + if (this.onExitCb) { + this.flushData() + this.onExitCb(args.exitCode, cause) + } else { + this.pendingExitCode = args.exitCode + this.pendingExitCause = cause + } + } + + onData(cb: DataListener): void { + this.onDataCb = cb + this.flushData() + } + + onExit(cb: ExitListener): void { + this.onExitCb = cb + if (this.pendingExitCode === null) { + return + } + const code = this.pendingExitCode + const cause = this.pendingExitCause ?? resolveProcessExitCause({ exitCode: code }) + this.pendingExitCode = null + this.pendingExitCause = null + this.flushData() + cb(code, cause) + } + + clear(): void { + this.onDataCb = null + this.onExitCb = null + this.pendingData = [] + this.pendingDataChars = 0 + this.pendingExitCode = null + this.pendingExitCause = null + } + + private flushData(): void { + if (!this.onDataCb || this.pendingData.length === 0) { + return + } + const pending = this.pendingData + this.pendingData = [] + this.pendingDataChars = 0 + for (const data of pending) { + this.onDataCb(data) + } + } +} diff --git a/src/main/daemon/pty-subprocess/shell-launch-plan.ts b/src/main/daemon/pty-subprocess/shell-launch-plan.ts new file mode 100644 index 00000000000..ed8cd5e6a22 --- /dev/null +++ b/src/main/daemon/pty-subprocess/shell-launch-plan.ts @@ -0,0 +1,234 @@ +import { win32 as pathWin32 } from 'node:path' +import { isWindowsGitBashShellPath, resolveWindowsGitBashShellPath } from '../../git-bash' +import { isPwshAvailable } from '../../pwsh' +import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../../pty/codex-home-wsl-env' +import { addOrcaWslInteropEnv } from '../../pty/wsl-orca-env' +import { + POWERLEVEL10K_WIZARD_DISABLE_ENV, + seedPowerlevel10kWizardEnv +} from '../../pty/powerlevel10k-wizard-env' +import { + resolveSafePtyDefaultCwd, + assertSafeAgentStartupCwd +} from '../../providers/pty-default-cwd' +import { + resolveEffectiveWindowsPowerShell, + shouldProbeWindowsPowerShellAvailability, + type WindowsPowerShellShellFamily +} from '../../providers/windows-powershell' +import { + buildWindowsPowerShellSpawnAttempts, + type WindowsShellSpawnAttempt +} from '../../providers/windows-shell-fallback-chain' +import { + ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV, + resolveWindowsShellLaunchArgs +} from '../../providers/windows-shell-args' +import { resolveUnixShellPath } from '../../providers/local-pty-utils' +import { selectShellStartupFeatures } from '../../shell-startup-features' +import { parseWslPath } from '../../wsl' +import { addWslEnvKeys } from '../../wsl-env' +import { + recognizeAgentProcessFromCommandLine, + type RecognizedAgentProcess +} from '../../../shared/agent-process-recognition' +import { shouldUseShellReadyStartupDelivery } from '../../../shared/codex-startup-delivery' +import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../../shared/hermes-startup-query' +import { WINDOWS_GIT_BASH_SHELL } from '../../../shared/windows-terminal-shell' +import { getShellLaunchConfig, resolvePtyShellPath } from '../shell-ready' +import { resolveWslSessionContext } from '../wsl-session-context' +import { finalizeDaemonPtyEnvironment, rescrubDaemonPtyEnvironment } from './spawn-environment' +import type { PtySubprocessOptions } from '../pty-subprocess' + +export type PtyShellLaunchPlan = { + shellPath: string + shellArgs: string[] + spawnCwd: string + validationCwd: string + startupCommandDeliveredInShellArgs: boolean + windowsFallbackAttempts: WindowsShellSpawnAttempt[] + startupAgentRecognition: RecognizedAgentProcess | null +} + +export function createPtyShellLaunchPlan( + opts: PtySubprocessOptions, + env: Record +): PtyShellLaunchPlan { + const resolvedWslContext = resolveWslSessionContext(opts) + let shellPath = resolvedWslContext ? 'wsl.exe' : opts.shellOverride || resolvePtyShellPath(env) + let shellArgs: string[] + let startupCommandDeliveredInShellArgs = false + let windowsFallbackAttempts: WindowsShellSpawnAttempt[] = [] + const startupAgentRecognition = recognizeAgentProcessFromCommandLine(opts.command) + const isCodexStartupCommand = startupAgentRecognition?.agent === 'codex' + const requestedCwd = opts.cwd || resolveSafePtyDefaultCwd() + if (opts.command && startupAgentRecognition) { + assertSafeAgentStartupCwd(requestedCwd, opts.command) + } + let spawnCwd = requestedCwd + let validationCwd = spawnCwd + + if (process.platform === 'win32') { + const normalizedShellFamily = pathWin32.basename(shellPath).toLowerCase() + const resolvedGitBashPath = resolveWindowsGitBashShellPath(shellPath) + const resolvedShellFamily: WindowsPowerShellShellFamily = + normalizedShellFamily === 'powershell.exe' || normalizedShellFamily === 'pwsh.exe' + ? normalizedShellFamily + : normalizedShellFamily === 'cmd.exe' || normalizedShellFamily === 'wsl.exe' + ? normalizedShellFamily + : undefined + const shouldProbePwsh = shouldProbeWindowsPowerShellAvailability({ + shellFamily: resolvedShellFamily, + implementation: opts.terminalWindowsPowerShellImplementation + }) + const shouldResolvePowerShellFamily = + opts.terminalWindowsPowerShellImplementation !== undefined || + pathWin32.basename(shellPath) === shellPath + if (resolvedGitBashPath) { + shellPath = resolvedGitBashPath + } else if (shellPath === WINDOWS_GIT_BASH_SHELL) { + shellPath = 'powershell.exe' + } else { + shellPath = shouldResolvePowerShellFamily + ? (resolveEffectiveWindowsPowerShell({ + shellFamily: resolvedShellFamily, + implementation: opts.terminalWindowsPowerShellImplementation, + pwshAvailable: shouldProbePwsh ? isPwshAvailable() : false + }) ?? shellPath) + : shellPath + } + if ( + pathWin32.basename(shellPath).toLowerCase() === 'cmd.exe' && + env.ORCA_CODEX_LAUNCH_PREFLIGHT + ) { + env[ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV] = '"' + } + windowsFallbackAttempts = buildWindowsPowerShellSpawnAttempts({ + shellPath, + cwd: spawnCwd, + defaultCwd: resolveSafePtyDefaultCwd(), + wslContext: resolvedWslContext, + startupCommand: opts.command + }) + const primaryAttempt = windowsFallbackAttempts[0] + if (primaryAttempt) { + shellPath = primaryAttempt.shellPath + shellArgs = primaryAttempt.shellArgs + spawnCwd = primaryAttempt.effectiveCwd + validationCwd = primaryAttempt.validationCwd + startupCommandDeliveredInShellArgs = primaryAttempt.startupCommandDeliveredInShellArgs + } else { + const resolved = resolveWindowsShellLaunchArgs( + shellPath, + spawnCwd, + resolveSafePtyDefaultCwd(), + resolvedWslContext, + opts.command, + env.ORCA_CODEX_LAUNCH_PREFLIGHT + ) + shellArgs = resolved.shellArgs + spawnCwd = resolved.effectiveCwd + validationCwd = resolved.validationCwd + startupCommandDeliveredInShellArgs = resolved.startupCommandDeliveredInShellArgs === true + } + if (isWindowsGitBashShellPath(shellPath)) { + env.CHERE_INVOKING ??= '1' + } + const codexHomeWslInfo = env.CODEX_HOME ? parseWslPath(env.CODEX_HOME) : null + if (pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe') { + if (codexHomeWslInfo) { + const launchWslDistro = resolvedWslContext?.distro + if (launchWslDistro && launchWslDistro !== codexHomeWslInfo.distro) { + delete env.CODEX_HOME + delete env.ORCA_CODEX_HOME + } else { + env.CODEX_HOME = codexHomeWslInfo.linuxPath + env.ORCA_CODEX_HOME = codexHomeWslInfo.linuxPath + addWslEnvKeys(env, ['CODEX_HOME', 'ORCA_CODEX_HOME']) + if (!launchWslDistro) { + const resolved = resolveWindowsShellLaunchArgs( + shellPath, + requestedCwd, + resolveSafePtyDefaultCwd(), + { distro: codexHomeWslInfo.distro }, + opts.command, + env.ORCA_CODEX_LAUNCH_PREFLIGHT + ) + shellArgs = resolved.shellArgs + spawnCwd = resolved.effectiveCwd + validationCwd = resolved.validationCwd + startupCommandDeliveredInShellArgs = + resolved.startupCommandDeliveredInShellArgs === true + } + } + } else if (isHostCodexHomeForWsl(env.CODEX_HOME)) { + delete env.CODEX_HOME + delete env.ORCA_CODEX_HOME + } else if (env.CODEX_HOME) { + addWslEnvKeys(env, ['CODEX_HOME', 'ORCA_CODEX_HOME']) + } + if (env.CLAUDE_CONFIG_DIR) { + addWslEnvKeys(env, ['CLAUDE_CONFIG_DIR']) + } + if (env[ORCA_HERMES_STARTUP_QUERY_ENV] !== undefined) { + addWslEnvKeys(env, [ORCA_HERMES_STARTUP_QUERY_ENV]) + } + } else if (codexHomeWslInfo || isWslCodexHomeForHost(env.CODEX_HOME)) { + delete env.CODEX_HOME + delete env.ORCA_CODEX_HOME + } + if (pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe') { + addOrcaWslInteropEnv(env) + } + } else { + rescrubDaemonPtyEnvironment(env, opts) + const preferredShellPath = shellPath + shellPath = resolveUnixShellPath(shellPath) + if (shellPath !== preferredShellPath) { + env.SHELL = shellPath + console.warn( + `[daemon/pty] Preferred shell "${preferredShellPath}" is unavailable, fell back to "${shellPath}"` + ) + } + const waitsForShellReady = + Boolean(opts.command) && + (!isCodexStartupCommand || + shouldUseShellReadyStartupDelivery({ + command: opts.command as string, + startupCommandDelivery: opts.startupCommandDelivery + })) + delete env.ORCA_SHELL_FEATURES + const shellLaunch = getShellLaunchConfig( + shellPath, + selectShellStartupFeatures({ + shellPath, + env, + hasStartupCommand: Boolean(opts.command), + waitsForShellReady, + emitsStartupIdentity: waitsForShellReady + }) + ) + Object.assign(env, shellLaunch.env) + shellArgs = shellLaunch.args ?? ['-l'] + } + + seedPowerlevel10kWizardEnv(env, { envToDelete: opts.envToDelete }) + if ( + env[POWERLEVEL10K_WIZARD_DISABLE_ENV] !== undefined && + process.platform === 'win32' && + pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe' + ) { + addWslEnvKeys(env, [POWERLEVEL10K_WIZARD_DISABLE_ENV]) + } + finalizeDaemonPtyEnvironment(env, opts.env) + + return { + shellPath, + shellArgs, + spawnCwd, + validationCwd, + startupCommandDeliveredInShellArgs, + windowsFallbackAttempts, + startupAgentRecognition + } +} diff --git a/src/main/daemon/pty-subprocess/spawn-environment.ts b/src/main/daemon/pty-subprocess/spawn-environment.ts new file mode 100644 index 00000000000..cbace940f46 --- /dev/null +++ b/src/main/daemon/pty-subprocess/spawn-environment.ts @@ -0,0 +1,189 @@ +import { delimiter } from 'node:path' +import { dropInheritedOrcaFishHistory } from '../../fish-history-session' +import { removeAppImageRuntimeEnv } from '../../pty/appimage-terminal-env' +import { stripInheritedBuildModeEnv } from '../../pty/build-mode-env' +import { dropIncoherentCondaActivationEnv } from '../../pty/conda-activation-env' +import { stripLegacyTerminalShimEnv } from '../../pty/legacy-terminal-shim-dir' +import { removeInheritedNoColor } from '../../pty/terminal-color-env' +import { resolvePathEnvKey } from '../../pty/windows-environment-path' +import { dropInheritedOrcaHistFile } from '../../worktree-history-file-path' +import { + gitCredentialPromptGuardEnv, + mergeGitConfigEnvProtocol +} from '../../../shared/git-credential-prompt-env' +import { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } from '../../../shared/terminal-git-credential-guard' +import { + expandWindowsEnvironmentVariables, + expandWindowsPathEnvironmentVariables +} from '../../../shared/windows-environment-expansion' +import type { TuiAgent } from '../../../shared/tui-agent' +import type { PtySubprocessOptions } from '../pty-subprocess' + +const PANE_IDENTITY_ENV_KEYS = [ + 'ORCA_PANE_KEY', + 'ORCA_TAB_ID', + 'ORCA_WORKTREE_ID', + 'ORCA_AGENT_LAUNCH_TOKEN' +] as const +const WINDOWS_PATH_ENV_KEY_RE = /^path$/i + +function composeGuardedDaemonGitConfigEnv( + env: Record, + explicitEnv: Record | undefined, + launchAgent: TuiAgent | undefined +): void { + const policy = explicitEnv?.[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV] + delete env[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV] + if (policy !== 'guard' && launchAgent === undefined) { + return + } + // Why: the daemon can outlive Electron, so its process.env is the authoritative inherited config; append only the guard. + Object.assign(env, gitCredentialPromptGuardEnv(env, process.platform)) +} + +function deleteRequestedDaemonEnvKeys( + env: Record, + keys: readonly string[] | undefined +): void { + // Why: persistent daemon state can differ from Electron; delete CODEX_HOME only when its Orca overlay owns it. + const deleteOrcaOwnedCodexHome = + keys?.includes('ORCA_CODEX_HOME') === true && + env.ORCA_CODEX_HOME !== undefined && + env.CODEX_HOME === env.ORCA_CODEX_HOME + for (const key of keys ?? []) { + delete env[key] + } + if (deleteOrcaOwnedCodexHome) { + delete env.CODEX_HOME + } +} + +function removeUnspecifiedPaneIdentityEnv( + env: Record, + explicitEnv: Record | undefined +): void { + for (const key of PANE_IDENTITY_ENV_KEYS) { + if (!explicitEnv || !Object.hasOwn(explicitEnv, key)) { + delete env[key] + } + } +} + +/** Removes the second PATH key only when the daemon's env merge created it. */ +function collapseWindowsPathEnvKeys( + env: Record, + requestedEnv: Record | undefined +): void { + if (process.platform !== 'win32') { + return + } + const pathKeys = Object.keys(env).filter((key) => WINDOWS_PATH_ENV_KEY_RE.test(key)) + if (pathKeys.length < 2) { + return + } + // Why: a one-key main patch is authoritative; zero or two keys came from inherited state. + const requestedKeys = requestedEnv + ? Object.keys(requestedEnv).filter((key) => WINDOWS_PATH_ENV_KEY_RE.test(key)) + : [] + if (requestedKeys.length !== 1) { + return + } + const survivingKey = requestedKeys[0] + if (!survivingKey || env[survivingKey] === undefined) { + return + } + for (const key of pathKeys) { + if (key !== survivingKey) { + delete env[key] + } + } +} + +/** Promotes the agent-teams shim path ahead of inherited PATH entries. */ +function promoteAgentTeamsShimPath( + env: Record, + requestedPath: string | undefined +): void { + if (!env.ORCA_AGENT_TEAMS_TEAM_ID || !requestedPath) { + return + } + const normalizedRequestedPath = + process.platform === 'win32' + ? expandWindowsEnvironmentVariables(requestedPath, env) + : requestedPath + const pathDelimiter = process.platform === 'win32' ? ';' : delimiter + const shimDir = normalizedRequestedPath.split(pathDelimiter)[0] + if (!shimDir) { + return + } + const pathKey = resolvePathEnvKey(env, process.platform) + const currentParts = env[pathKey]?.split(pathDelimiter).filter(Boolean) ?? [] + env[pathKey] = [shimDir, ...currentParts.filter((part) => part !== shimDir)].join(pathDelimiter) +} + +function removeInheritedDevAgentHookEndpoint( + env: Record, + explicitEnv: Record | undefined +): void { + if (explicitEnv?.ORCA_AGENT_HOOK_ENV === 'development' && !explicitEnv.ORCA_AGENT_HOOK_ENDPOINT) { + // Why: strip only stale inherited endpoints; a fresh explicit one is needed by hooks that scrub token-like env vars before exec. + delete env.ORCA_AGENT_HOOK_ENDPOINT + } +} + +export function createDaemonPtyEnvironment(opts: PtySubprocessOptions): Record { + const env: Record = { + ...mergeGitConfigEnvProtocol(stripInheritedBuildModeEnv(process.env), opts.env), + TERM: 'xterm-256color', + COLORTERM: 'truecolor', + TERM_PROGRAM: 'Orca', + TERM_PROGRAM_VERSION: process.env.ORCA_APP_VERSION ?? '0.0.0-dev', + FORCE_HYPERLINK: '1' + } as Record + stripLegacyTerminalShimEnv(env, process.platform) + composeGuardedDaemonGitConfigEnv(env, opts.env, opts.launchAgent) + deleteRequestedDaemonEnvKeys(env, opts.envToDelete) + if (opts.env?.TERM) { + env.TERM = opts.env.TERM + } + removeUnspecifiedPaneIdentityEnv(env, opts.env) + if (opts.env?.fish_history === undefined) { + dropInheritedOrcaFishHistory(env) + } + if (opts.env?.HISTFILE === undefined) { + dropInheritedOrcaHistFile(env) + } + if (opts.env?.ORCA_HISTFILE === undefined) { + delete env.ORCA_HISTFILE + } + removeInheritedDevAgentHookEndpoint(env, opts.env) + delete env.ELECTRON_RUN_AS_NODE + removeAppImageRuntimeEnv(env) + removeInheritedNoColor(env) + env.LANG ??= 'en_US.UTF-8' + return env +} + +export function rescrubDaemonPtyEnvironment( + env: Record, + opts: PtySubprocessOptions +): void { + deleteRequestedDaemonEnvKeys(env, opts.envToDelete) + if (opts.env?.TERM) { + env.TERM = opts.env.TERM + } +} + +export function finalizeDaemonPtyEnvironment( + env: Record, + requestedEnv: Record | undefined +): void { + expandWindowsPathEnvironmentVariables(env) + collapseWindowsPathEnvKeys(env, requestedEnv) + const requestedPath = requestedEnv + ? requestedEnv[resolvePathEnvKey(requestedEnv, process.platform)] + : undefined + promoteAgentTeamsShimPath(env, requestedPath) + stripLegacyTerminalShimEnv(env, process.platform) + dropIncoherentCondaActivationEnv(env, process.platform) +} diff --git a/src/main/daemon/pty-subprocess/spawn-preflight.ts b/src/main/daemon/pty-subprocess/spawn-preflight.ts new file mode 100644 index 00000000000..facaf0a69bd --- /dev/null +++ b/src/main/daemon/pty-subprocess/spawn-preflight.ts @@ -0,0 +1,221 @@ +import * as pty from 'node-pty' +import { statSync } from 'node:fs' +import { release } from 'node:os' +import { + ensureNodePtySpawnHelperExecutable, + getNodePtySpawnHelperCandidates, + validateWorkingDirectoryAsync, + WorkingDirectoryValidationAbortedError +} from '../../providers/local-pty-utils' +import { resolveSafePtyDefaultCwd } from '../../providers/pty-default-cwd' +import { TerminalAttachCanceledError } from '../daemon-errors' +import { DaemonProtocolError } from '../types' + +const PTY_SPAWN_HEALTH_TIMEOUT_MS = 4_000 + +function daemonEnvironmentDiagSuffix(): string { + const orca = process.env.ORCA_APP_VERSION?.trim() || '0.0.0-dev' + const systemVersion = + (process as NodeJS.Process & { getSystemVersion?: () => string }).getSystemVersion?.() || + release() + return ` (orca: ${orca}, arch: ${process.arch}, platform: ${process.platform} ${systemVersion})` +} + +function formatMissingDaemonPathError(kind: 'helper' | 'cwd', path: string): DaemonProtocolError { + const detailName = kind === 'helper' ? 'helper' : 'cwd' + const step = kind === 'helper' ? 'posix_spawn' : 'daemon_cwd' + const missingTarget = kind === 'helper' ? 'node-pty install' : 'working directory' + return new DaemonProtocolError( + `Daemon's ${missingTarget} is gone (worktree deleted?). Restart Orca. node-pty: ${step} failed: ENOENT (errno 2, No such file or directory) - ${detailName}='${path}'${daemonEnvironmentDiagSuffix()}` + ) +} + +function isExistingDirectory(path: string | undefined): path is string { + if (!path) { + return false + } + try { + return statSync(path).isDirectory() + } catch { + return false + } +} + +function repairDaemonCwd(): string | null { + const candidates = [process.env.ORCA_USER_DATA_PATH] + try { + candidates.push(resolveSafePtyDefaultCwd()) + } catch { + // Keep daemon cwd repair best-effort even when no user terminal cwd is safe. + } + candidates.push(process.platform === 'win32' ? 'C:\\' : '/') + for (const candidate of candidates) { + if (isExistingDirectory(candidate)) { + try { + process.chdir(candidate) + return candidate + } catch { + // Try the next stable cwd candidate. + } + } + } + return null +} + +function preflightDaemonCwd(): void { + let daemonCwd = '' + try { + daemonCwd = process.cwd() + if (isExistingDirectory(daemonCwd)) { + return + } + } catch { + // Recover below; process.cwd() throws after the original cwd is deleted. + } + if (repairDaemonCwd()) { + return + } + throw formatMissingDaemonPathError('cwd', daemonCwd) +} + +function preflightMacNodePtySpawnEnvironment(): void { + if (process.platform !== 'darwin') { + return + } + let candidates: string[] + try { + candidates = getNodePtySpawnHelperCandidates() + } catch { + throw formatMissingDaemonPathError('helper', '') + } + for (const candidate of candidates) { + try { + if (statSync(candidate).isFile()) { + return + } + } catch { + // Try the next node-pty native location. + } + } + throw formatMissingDaemonPathError('helper', candidates[0] ?? '') +} + +function preflightUnixPtySpawnEnvironment(): void { + if (process.platform === 'win32') { + return + } + // Why: detached daemons can outlive their launch cwd; repair before every spawn. + preflightDaemonCwd() + preflightMacNodePtySpawnEnvironment() +} + +function isNativeWindowsPath(path: string): boolean { + return /^[A-Za-z]:[\\/]/.test(path) || path.startsWith('\\\\') +} + +export async function preflightPtySpawn(args: { + validationCwd: string + cwdWasExplicit: boolean + sessionId: string + signal?: AbortSignal +}): Promise { + ensureNodePtySpawnHelperExecutable() + preflightUnixPtySpawnEnvironment() + try { + if (process.platform === 'win32') { + if (args.cwdWasExplicit && isNativeWindowsPath(args.validationCwd)) { + await validateWorkingDirectoryAsync( + args.validationCwd, + args.signal ? { signal: args.signal } : {} + ) + } + } else { + await validateWorkingDirectoryAsync( + args.validationCwd, + args.signal ? { signal: args.signal } : {} + ) + } + } catch (error) { + if (error instanceof WorkingDirectoryValidationAbortedError) { + throw new TerminalAttachCanceledError(args.sessionId) + } + throw error + } +} + +export function formatPtySpawnError(err: unknown, shellPath: string, spawnCwd: string): Error { + const message = err instanceof Error ? err.message : String(err) + const formatted = new DaemonProtocolError( + `Daemon failed to spawn shell "${shellPath}" with cwd "${spawnCwd}": ${message}${daemonEnvironmentDiagSuffix()}` + ) + if (err instanceof Error && err.stack) { + formatted.stack = err.stack + } + return formatted +} + +export function runPtySpawnHealthProbe(): Promise { + const cwd = isExistingDirectory(process.env.ORCA_USER_DATA_PATH) + ? process.env.ORCA_USER_DATA_PATH + : resolveSafePtyDefaultCwd() + let proc: pty.IPty + try { + proc = pty.spawn('/bin/sh', ['-c', 'exit 0'], { + name: 'xterm-256color', + cols: 2, + rows: 1, + cwd, + env: { ...process.env, TERM: 'xterm-256color' } + }) + } catch (err) { + throw formatPtySpawnError(err, '/bin/sh', cwd) + } + + return new Promise((resolve, reject) => { + let settled = false + let exitDisposable: { dispose(): void } | undefined + const finish = (error?: Error, opts?: { kill?: boolean }): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + exitDisposable?.dispose() + if (opts?.kill) { + try { + proc.kill() + } catch { + // Best-effort cleanup for a short-lived health probe. + } + } + if (error) { + reject(error) + } else { + resolve() + } + } + const timer = setTimeout(() => { + finish(new Error(`PTY spawn health check timed out after ${PTY_SPAWN_HEALTH_TIMEOUT_MS}ms`), { + kill: true + }) + }, PTY_SPAWN_HEALTH_TIMEOUT_MS) + exitDisposable = proc.onExit(({ exitCode }) => { + if (exitCode === 0) { + finish() + } else { + finish(new Error(`PTY spawn health check exited with code ${exitCode}`)) + } + }) + }) +} + +export function preflightPtySpawnHealth(): boolean { + if (process.platform === 'win32') { + return false + } + if (process.platform === 'darwin') { + ensureNodePtySpawnHelperExecutable() + } + preflightUnixPtySpawnEnvironment() + return true +} diff --git a/src/main/daemon/pty-subprocess/subprocess-handle.ts b/src/main/daemon/pty-subprocess/subprocess-handle.ts new file mode 100644 index 00000000000..a56d0fc3ae2 --- /dev/null +++ b/src/main/daemon/pty-subprocess/subprocess-handle.ts @@ -0,0 +1,202 @@ +import type * as pty from 'node-pty' +import type { RecognizedAgentProcess } from '../../../shared/agent-process-recognition' +import { readPtySlavePath } from '../../../shared/pty-slave-line-discipline-echo' +import { forceKillPosixPtyProcessGroups } from '../../pty/posix-pty-process-groups' +import { signalPosixPtyForegroundGroup } from '../../pty/posix-pty-foreground-group' +import { readPtsName } from '../../pty/node-pty-pts-name' +import { terminatePtyJob } from '../../windows/windows-pty-job' +import { isValidPtySize } from '../daemon-pty-size' +import type { SubprocessHandle } from '../session-subprocess-handle' +import { createPtyForegroundProcessTracker } from './foreground-process-tracker' +import { PtyPreListenerEvents } from './pre-listener-events' + +type DisposableNativePty = pty.IPty & { destroy?: () => void } + +export function createDaemonPtySubprocessHandle(args: { + process: pty.IPty + shellPath: string + spawnCwd: string + env: Record + startupCommandDeliveredInShellArgs: boolean + reportsChildExitStatus: boolean + requestedCwd?: string + sessionId: string + startupAgentRecognition: RecognizedAgentProcess | null +}): SubprocessHandle { + const proc = args.process + // node-pty exposes destroy at runtime but omits it from IPty. + const nativeProc = proc as DisposableNativePty + const events = new PtyPreListenerEvents() + let dead = false + let disposed = false + let nodePtyKillIssued = false + const foreground = createPtyForegroundProcessTracker({ + process: proc, + shellPath: args.shellPath, + cwd: args.requestedCwd, + sessionId: args.sessionId, + startupAgentRecognition: args.startupAgentRecognition, + isDead: () => dead + }) + + proc.onData((data) => { + foreground.recordOutput(data) + events.acceptData(data) + }) + proc.onExit(({ exitCode, signal }) => { + events.acceptExit({ + exitCode, + signal, + hostReportsChildExitStatus: args.reportsChildExitStatus + }) + }) + proc.onExit(() => { + dead = true + foreground.markDead() + // Why: neutralize kill synchronously so a later async socket-close SIGHUP cannot hit a recycled pid. + if (process.platform !== 'win32') { + nativeProc.kill = () => {} + } + }) + + const slavePath = readPtySlavePath(proc) + return { + pid: proc.pid, + shellPath: args.shellPath, + shellCwd: args.spawnCwd, + shellPathEnv: args.env.PATH, + ...(slavePath ? { slavePath } : {}), + ...(args.startupCommandDeliveredInShellArgs + ? { startupCommandDeliveredInShellArgs: true } + : {}), + getForegroundProcess: foreground.getForegroundProcess, + confirmForegroundProcess: foreground.confirmForegroundProcess, + write: (data) => { + if (dead) { + return + } + try { + proc.write(data) + } catch { + dead = true + } + }, + resize: (cols, rows) => { + if (dead || !isValidPtySize(cols, rows)) { + return + } + try { + proc.resize(cols, rows) + } catch { + dead = true + } + }, + // WindowsTerminal also wires _socket to the ConPTY conout pipe, so pausing backpressures the child. + pause: () => { + if (dead) { + return + } + try { + proc.pause() + } catch { + // Native handle already torn down; flow control is best-effort. + } + }, + resume: () => { + if (dead) { + return + } + try { + proc.resume() + } catch { + // Native handle already torn down; flow control is best-effort. + } + }, + clear: () => { + if (dead) { + return + } + try { + proc.clear() + } catch { + // A clear on a just-exited PTY is best-effort. + } + }, + kill: () => { + if (dead) { + return + } + nodePtyKillIssued = true + try { + proc.kill() + } catch (error) { + // A rejected native kill is not proof of exit; keep the wrapper live for a retry. + nodePtyKillIssued = false + throw error + } + }, + terminateOwnedTree: () => terminatePtyJob(proc), + forceKill: () => { + if (dead) { + return + } + // Escalate a ConPTY kill through the job without double-closing node-pty's shell handle. + if (process.platform === 'win32' && nodePtyKillIssued) { + terminatePtyJob(proc) + return + } + try { + forceKillPosixPtyProcessGroups(proc.pid, () => { + process.kill(proc.pid, 'SIGKILL') + }) + } catch (signalError) { + try { + proc.kill() + nodePtyKillIssued = true + } catch { + nodePtyKillIssued = false + throw signalError + } + } + }, + signal: (sig) => { + if (dead) { + return + } + const signalRootPid = (): void => { + try { + process.kill(proc.pid, sig) + } catch { + // Process may already be dead. + } + } + // SIGWINCH belongs to the tty foreground group; destructive signals keep the root-pid target. + if (sig === 'SIGWINCH') { + signalPosixPtyForegroundGroup(proc.pid, readPtsName(proc), sig, signalRootPid) + return + } + signalRootPid() + }, + onData: (cb) => events.onData(cb), + onExit: (cb) => events.onExit(cb), + dispose: () => { + if (disposed) { + return + } + disposed = true + dead = true + events.clear() + // POSIX destroy() can asynchronously signal a recycled pid; Windows needs kill() to close ConPTY. + if (process.platform !== 'win32') { + nativeProc.kill = () => {} + } else if (nodePtyKillIssued) { + return + } + try { + nativeProc.destroy?.() + } catch { + // Native handle was already torn down. + } + } + } +} diff --git a/src/main/github/project-view.ts b/src/main/github/project-view.ts index f9320a3b10e..9a0d9e97f78 100644 --- a/src/main/github/project-view.ts +++ b/src/main/github/project-view.ts @@ -60,20 +60,24 @@ import { githubProjectHost } from '../../shared/github/project-identity' // Re-export the public API so existing `./project-view` call sites keep working; the split is internal-only. export { isValidOwnerSlug, isValidRepoSlug } from './project-view/internals' export { classifyProjectError } from './project-view/project-error-classification' +export { updateIssueBySlug } from './project-view/mutations' export { updateProjectItemFieldValue, - clearProjectItemFieldValue, - updateIssueBySlug, - updatePullRequestBySlug, + clearProjectItemFieldValue +} from './project-view/project-field-mutations' +export { updatePullRequestBySlug } from './project-view/pull-request-mutation' +export { addIssueCommentBySlug, updateIssueCommentBySlug, - deleteIssueCommentBySlug, + deleteIssueCommentBySlug +} from './project-view/issue-comment-mutations' +export { listLabelsBySlug, listAssignableUsersBySlug, - listIssueTypesBySlug, - updateIssueTypeBySlug, - getWorkItemDetailsBySlug -} from './project-view/mutations' + listIssueTypesBySlug +} from './project-view/repository-field-options' +export { updateIssueTypeBySlug } from './project-view/issue-type-mutation' +export { getWorkItemDetailsBySlug } from './project-view/work-item-details' // ─── Constants ───────────────────────────────────────────────────────── diff --git a/src/main/github/project-view/issue-comment-mutations.ts b/src/main/github/project-view/issue-comment-mutations.ts new file mode 100644 index 00000000000..fcf7e056fb6 --- /dev/null +++ b/src/main/github/project-view/issue-comment-mutations.ts @@ -0,0 +1,111 @@ +import { assertPositiveInt, projectGhExecOptions, runRest, validateSlugArgs } from './internals' +import type { PRComment } from '../../../shared/github/comment-types' +import type { + GitHubProjectCommentMutationResult, + GitHubProjectMutationResult +} from '../../../shared/github/project-result-types' +import type { + AddIssueCommentBySlugArgs, + DeleteIssueCommentBySlugArgs, + UpdateIssueCommentBySlugArgs +} from '../../../shared/github/project-request-types' + +type RawIssueCommentResponse = { + id?: number + user?: { login?: string; avatar_url?: string; type?: string } | null + body?: string + created_at?: string + html_url?: string +} + +function mapIssueComment(data: RawIssueCommentResponse, fallbackBody: string): PRComment { + return { + id: data.id ?? Date.now(), + author: data.user?.login ?? 'You', + authorAvatarUrl: data.user?.avatar_url ?? '', + body: data.body ?? fallbackBody, + createdAt: data.created_at ?? new Date().toISOString(), + url: data.html_url ?? '', + isBot: data.user?.type === 'Bot' + } +} + +export async function addIssueCommentBySlug( + args: AddIssueCommentBySlugArgs +): Promise { + const slug = validateSlugArgs(args.owner, args.repo) + if (!slug.ok) { + return slug + } + const number = assertPositiveInt(args.number, 'number') + if (!number.ok) { + return { ok: false, error: number.error } + } + if (typeof args.body !== 'string' || !args.body.trim()) { + return { ok: false, error: { type: 'validation_error', message: 'Comment body required.' } } + } + const result = await runRest( + [ + '-X', + 'POST', + `repos/${args.owner}/${args.repo}/issues/${args.number}/comments`, + '--raw-field', + `body=${args.body}` + ], + undefined, + 'core', + projectGhExecOptions(args.host) + ) + return result.ok + ? { ok: true, comment: mapIssueComment(result.data, args.body) } + : { ok: false, error: result.error } +} + +export async function updateIssueCommentBySlug( + args: UpdateIssueCommentBySlugArgs +): Promise { + const slug = validateSlugArgs(args.owner, args.repo) + if (!slug.ok) { + return slug + } + const number = assertPositiveInt(args.commentId, 'commentId') + if (!number.ok) { + return { ok: false, error: number.error } + } + if (typeof args.body !== 'string' || !args.body.trim()) { + return { ok: false, error: { type: 'validation_error', message: 'Comment body required.' } } + } + const result = await runRest( + [ + '-X', + 'PATCH', + `repos/${args.owner}/${args.repo}/issues/comments/${args.commentId}`, + '--raw-field', + `body=${args.body}` + ], + undefined, + 'core', + projectGhExecOptions(args.host) + ) + return result.ok ? { ok: true } : { ok: false, error: result.error } +} + +export async function deleteIssueCommentBySlug( + args: DeleteIssueCommentBySlugArgs +): Promise { + const slug = validateSlugArgs(args.owner, args.repo) + if (!slug.ok) { + return slug + } + const number = assertPositiveInt(args.commentId, 'commentId') + if (!number.ok) { + return { ok: false, error: number.error } + } + const result = await runRest( + ['-X', 'DELETE', `repos/${args.owner}/${args.repo}/issues/comments/${args.commentId}`], + undefined, + 'core', + { expectEmpty: true, ...projectGhExecOptions(args.host) } + ) + return result.ok ? { ok: true } : { ok: false, error: result.error } +} diff --git a/src/main/github/project-view/issue-type-mutation.ts b/src/main/github/project-view/issue-type-mutation.ts new file mode 100644 index 00000000000..d36cf26955a --- /dev/null +++ b/src/main/github/project-view/issue-type-mutation.ts @@ -0,0 +1,54 @@ +import { + assertPositiveInt, + projectGhExecOptions, + runGraphql, + validateSlugArgs, + type GraphqlVars +} from './internals' +import type { GitHubProjectMutationResult } from '../../../shared/github/project-result-types' +import type { UpdateIssueTypeBySlugArgs } from '../../../shared/github/project-request-types' + +export async function updateIssueTypeBySlug( + args: UpdateIssueTypeBySlugArgs +): Promise { + const validation = validateSlugArgs(args.owner, args.repo) + if (!validation.ok) { + return validation + } + const number = assertPositiveInt(args.number, 'number') + if (!number.ok) { + return { ok: false, error: number.error } + } + const lookup = await runGraphql<{ + repository?: { issue?: { id?: string } | null } | null + }>( + `query($owner:String!, $repo:String!, $num:Int!) { + repository(owner:$owner, name:$repo) { issue(number:$num) { id } } + }`, + { owner: args.owner, repo: args.repo, num: args.number }, + projectGhExecOptions(args.host) + ) + if (!lookup.ok) { + return { ok: false, error: lookup.error } + } + const issueId = lookup.data.repository?.issue?.id + if (!issueId) { + return { ok: false, error: { type: 'not_found', message: 'Issue not found.' } } + } + const query = args.issueTypeId + ? `mutation($issueId:ID!, $issueTypeId:ID!) { + updateIssueIssueType(input: { issueId: $issueId, issueTypeId: $issueTypeId }) { + issue { id } + } + }` + : `mutation($issueId:ID!) { + updateIssueIssueType(input: { issueId: $issueId, issueTypeId: null }) { + issue { id } + } + }` + const vars: GraphqlVars = args.issueTypeId + ? { issueId, issueTypeId: args.issueTypeId } + : { issueId } + const result = await runGraphql(query, vars, projectGhExecOptions(args.host)) + return result.ok ? { ok: true } : { ok: false, error: result.error } +} diff --git a/src/main/github/project-view/mutations.test.ts b/src/main/github/project-view/mutations.test.ts index 30a00a0f110..4876f6da14b 100644 --- a/src/main/github/project-view/mutations.test.ts +++ b/src/main/github/project-view/mutations.test.ts @@ -33,7 +33,7 @@ vi.mock('./internals', () => ({ repositoryRateLimitGuard: repositoryRateLimitGuardMock, noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock, projectHostAuthenticationError: projectHostAuthenticationErrorMock, - projectGhExecOptions: (host?: string) => (host ? { host } : {}), + projectGhExecOptions: (host?: string) => ({ host: host ?? 'github.com' }), classifyProjectError: (stderr: string) => ({ type: 'unknown', message: stderr }), rateLimitedError: () => ({ type: 'rate_limited', message: 'rate limited' }), runGraphql: runGraphqlMock, @@ -46,7 +46,8 @@ vi.mock('./internals', () => ({ : { ok: false, error: { type: 'validation_error', message: `${name} invalid` } } })) -import { getWorkItemDetailsBySlug, updateIssueBySlug } from './mutations' +import { updateIssueBySlug } from './mutations' +import { getWorkItemDetailsBySlug } from './work-item-details' describe('updateIssueBySlug', () => { beforeEach(() => { diff --git a/src/main/github/project-view/mutations.ts b/src/main/github/project-view/mutations.ts index 8e06899b512..65d7eb32ec9 100644 --- a/src/main/github/project-view/mutations.ts +++ b/src/main/github/project-view/mutations.ts @@ -1,202 +1,75 @@ -/* eslint-disable max-lines -- Why: slug-addressed mutations + work-item -details share the validate/runRest/runGraphql plumbing with the read path. -Keeping them together preserves a single review surface for the write side. */ import { acquire, - release, + assertPositiveInt, extractExecError, ghExecFileAsync, - repositoryRateLimitGuard, noteRepositoryRateLimitSpend, - runGraphql, - runRest, - validateSlugArgs, - assertPositiveInt, - projectHostAuthenticationError, projectGhExecOptions, - type GraphqlVars + projectHostAuthenticationError, + release, + repositoryRateLimitGuard, + runRest, + validateSlugArgs } from './internals' import { classifyProjectError, rateLimitedError } from './project-error-classification' -import { githubProjectHost } from '../../../shared/github/project-identity' -import type { PRComment } from '../../../shared/github/comment-types' -import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types' -import type { GitHubWorkItemDetails } from '../../../shared/github/work-item-types' -import type { GitHubProjectFieldMutationValue } from '../../../shared/github/project-types' -import type { - GitHubProjectCommentMutationResult, - GitHubProjectMutationResult, - ListAssignableUsersBySlugResult, - ListIssueTypesBySlugResult, - ListLabelsBySlugResult, - ProjectWorkItemDetailsBySlugResult -} from '../../../shared/github/project-result-types' -import type { - AddIssueCommentBySlugArgs, - ClearProjectItemFieldArgs, - DeleteIssueCommentBySlugArgs, - ListAssignableUsersBySlugArgs, - ListIssueTypesBySlugArgs, - ListLabelsBySlugArgs, - ProjectWorkItemDetailsBySlugArgs, - UpdateIssueBySlugArgs, - UpdateIssueCommentBySlugArgs, - UpdateIssueTypeBySlugArgs, - UpdatePullRequestBySlugArgs, - UpdateProjectItemFieldArgs -} from '../../../shared/github/project-request-types' - -function githubHostExecOptions(args: { host?: string }): { host: string } { - return { host: githubProjectHost(args.host) } -} - -// ─── Project field mutations ────────────────────────────────────────── - -class UnknownFieldMutationKindError extends Error { - constructor(kind: string) { - super(`Unknown project field mutation kind: ${kind}`) - } -} - -function graphqlValueForFieldMutation(value: GitHubProjectFieldMutationValue): string { - // Serialize the value fragment for the GraphQL mutation. We use GraphQL - // variables for every dynamic piece, so here we only pick the variable name - // to reference per value kind. - switch (value.kind) { - case 'single-select': - return 'singleSelectOptionId: $value' - case 'iteration': - return 'iterationId: $value' - case 'text': - return 'text: $value' - case 'number': - return 'number: $value' - case 'date': - return 'date: $value' - } - // Why: keep a runtime guard for malformed IPC payloads while lint enforces - // that every typed mutation kind is handled above. - throw new UnknownFieldMutationKindError((value as { kind: string }).kind) -} - -function mutationValueVar(value: GitHubProjectFieldMutationValue): { - type: string - val: string | number -} { - switch (value.kind) { - case 'single-select': - return { type: 'String!', val: value.optionId } - case 'iteration': - return { type: 'String!', val: value.iterationId } - case 'text': - return { type: 'String!', val: value.text } - case 'number': - return { type: 'Float!', val: value.number } - case 'date': - return { type: 'Date!', val: value.date } - } - // Why: see graphqlValueForFieldMutation — surface unknown kinds loudly - // instead of returning undefined and dispatching an invalid mutation. - throw new UnknownFieldMutationKindError((value as { kind: string }).kind) -} - -export async function updateProjectItemFieldValue( - args: UpdateProjectItemFieldArgs -): Promise { - if (!args.projectId || !args.itemId || !args.fieldId) { - return { ok: false, error: { type: 'validation_error', message: 'Missing ids.' } } - } - let valFrag: string - let valVar: { type: string; val: string | number } - try { - valFrag = graphqlValueForFieldMutation(args.value) - valVar = mutationValueVar(args.value) - } catch (err) { - if (err instanceof UnknownFieldMutationKindError) { - return { ok: false, error: { type: 'validation_error', message: err.message } } - } - throw err - } - const query = ` - mutation($projectId:ID!, $itemId:ID!, $fieldId:ID!, $value:${valVar.type}) { - updateProjectV2ItemFieldValue(input: { - projectId: $projectId - itemId: $itemId - fieldId: $fieldId - value: { ${valFrag} } - }) { projectV2Item { id } } - } - ` - const vars: GraphqlVars = { - projectId: args.projectId, - itemId: args.itemId, - fieldId: args.fieldId, - value: valVar.val - } - const res = await runGraphql(query, vars, projectGhExecOptions(args.host)) - if (!res.ok) { - return { ok: false, error: res.error } - } - return { ok: true } -} - -export async function clearProjectItemFieldValue( - args: ClearProjectItemFieldArgs -): Promise { - if (!args.projectId || !args.itemId || !args.fieldId) { - return { ok: false, error: { type: 'validation_error', message: 'Missing ids.' } } - } - const query = ` - mutation($projectId:ID!, $itemId:ID!, $fieldId:ID!) { - clearProjectV2ItemFieldValue(input: { - projectId: $projectId - itemId: $itemId - fieldId: $fieldId - }) { projectV2Item { id } } - } - ` - const res = await runGraphql( - query, - { - projectId: args.projectId, - itemId: args.itemId, - fieldId: args.fieldId - }, - projectGhExecOptions(args.host) - ) - if (!res.ok) { - return { ok: false, error: res.error } - } - return { ok: true } -} - -// ─── Slug-addressed issue/PR mutations ──────────────────────────────── +import type { GitHubProjectMutationResult } from '../../../shared/github/project-result-types' +import type { UpdateIssueBySlugArgs } from '../../../shared/github/project-request-types' export async function updateIssueBySlug( args: UpdateIssueBySlugArgs ): Promise { - const v = validateSlugArgs(args.owner, args.repo) - if (!v.ok) { - return v + const validation = validateSlugArgs(args.owner, args.repo) + if (!validation.ok) { + return validation } - const n = assertPositiveInt(args.number, 'number') - if (!n.ok) { - return { ok: false, error: n.error } + const number = assertPositiveInt(args.number, 'number') + if (!number.ok) { + return { ok: false, error: number.error } } if (!args.updates || typeof args.updates !== 'object') { return { ok: false, error: { type: 'validation_error', message: 'Updates required.' } } } - const { - title, - body, - state, - stateReason, - duplicateOf, - addLabels, - removeLabels, - addAssignees, - removeAssignees - } = args.updates + const duplicateError = validateDuplicateUpdate(args) + if (duplicateError) { + return duplicateError + } + const authError = await projectHostAuthenticationError(args.host) + if (authError) { + return { ok: false, error: authError } + } + const base = `repos/${args.owner}/${args.repo}/issues/${args.number}` + const stateResult = await updateIssueState(args) + if (!stateResult.ok) { + return stateResult + } + const { title, body } = args.updates + if (title !== undefined || body !== undefined) { + const patchArgs = ['-X', 'PATCH', base] + if (title !== undefined) { + patchArgs.push('--raw-field', `title=${title}`) + } + if (body !== undefined) { + patchArgs.push('--raw-field', `body=${body}`) + } + const result = await runRest( + patchArgs, + undefined, + 'core', + projectGhExecOptions(args.host) + ) + if (!result.ok) { + return { ok: false, error: result.error } + } + } + const labelResult = await updateIssueLabels(args, base) + if (!labelResult.ok) { + return labelResult + } + return updateIssueAssignees(args, base) +} +function validateDuplicateUpdate(args: UpdateIssueBySlugArgs): GitHubProjectMutationResult | null { + const { duplicateOf, state, stateReason } = args.updates if (duplicateOf !== undefined && (state !== 'closed' || stateReason !== 'duplicate')) { return { ok: false, @@ -221,726 +94,137 @@ export async function updateIssueBySlug( return { ok: false, error: duplicate.error } } } - const authError = await projectHostAuthenticationError(args.host) - if (authError) { - return { ok: false, error: authError } + return null +} + +async function updateIssueState(args: UpdateIssueBySlugArgs): Promise { + const { state, stateReason, duplicateOf } = args.updates + if (state === undefined) { + return { ok: true } } - - // Title/body go through PATCH /repos/{owner}/{repo}/issues/{n}. - // State uses gh issue close/reopen so duplicate closes can record a target. - // Labels/assignees go through their dedicated endpoints. - const base = `repos/${args.owner}/${args.repo}/issues/${args.number}` - - if (state !== undefined) { - const guard = repositoryRateLimitGuard(args, 'core') - if (guard.blocked) { - return { ok: false, error: rateLimitedError(guard) } - } - const stateArgs = - state === 'closed' - ? ['issue', 'close', String(args.number), '--repo', `${args.owner}/${args.repo}`] - : ['issue', 'reopen', String(args.number), '--repo', `${args.owner}/${args.repo}`] - if (state === 'closed') { - if (stateReason === 'completed') { - stateArgs.push('--reason', 'completed') - } else if (stateReason === 'not_planned') { - stateArgs.push('--reason', 'not planned') - } else if (stateReason === 'duplicate') { - stateArgs.push('--duplicate-of', String(duplicateOf)) - } - } - await acquire() - noteRepositoryRateLimitSpend(args, 'core') - try { - await ghExecFileAsync(stateArgs, { encoding: 'utf-8', ...githubHostExecOptions(args) }) - } catch (err) { - const { stderr, stdout } = extractExecError(err) - return { ok: false, error: classifyProjectError(stderr, stdout, args.host) } - } finally { - release() + const guard = repositoryRateLimitGuard(args, 'core') + if (guard.blocked) { + return { ok: false, error: rateLimitedError(guard) } + } + const stateArgs = + state === 'closed' + ? ['issue', 'close', String(args.number), '--repo', `${args.owner}/${args.repo}`] + : ['issue', 'reopen', String(args.number), '--repo', `${args.owner}/${args.repo}`] + if (state === 'closed') { + if (stateReason === 'completed') { + stateArgs.push('--reason', 'completed') + } else if (stateReason === 'not_planned') { + stateArgs.push('--reason', 'not planned') + } else if (stateReason === 'duplicate') { + stateArgs.push('--duplicate-of', String(duplicateOf)) } } - - // 1) PATCH body - if (title !== undefined || body !== undefined) { - const patchArgs: string[] = ['-X', 'PATCH', base] - if (title !== undefined) { - patchArgs.push('--raw-field', `title=${title}`) - } - if (body !== undefined) { - patchArgs.push('--raw-field', `body=${body}`) - } - const r = await runRest(patchArgs, undefined, 'core', githubHostExecOptions(args)) - if (!r.ok) { - return { ok: false, error: r.error } - } + await acquire() + noteRepositoryRateLimitSpend(args, 'core') + try { + await ghExecFileAsync(stateArgs, { + encoding: 'utf-8', + ...projectGhExecOptions(args.host) + }) + return { ok: true } + } catch (error) { + const { stderr, stdout } = extractExecError(error) + return { ok: false, error: classifyProjectError(stderr, stdout, args.host) } + } finally { + release() } +} - // 2) Labels — collapse multi-delete fan-out into a single PUT when removing - // >1 label. PUT /labels replaces the entire label set, so we fetch the - // current labels first and compute the resulting set client-side. This - // turns an N-delete + 1-add (=N+1 calls) into 1-fetch + 1-PUT (=2 calls) - // once removeLabels has more than one entry, capping the cost at 2 even - // for a "remove all 20 labels" mutation. - const removeCount = removeLabels?.length ?? 0 - const addCount = addLabels?.length ?? 0 - if (removeCount > 1) { - type RawLabelResp = { name?: string }[] - const fetched = await runRest( +async function updateIssueLabels( + args: UpdateIssueBySlugArgs, + base: string +): Promise { + const { addLabels = [], removeLabels = [] } = args.updates + const options = projectGhExecOptions(args.host) + if (removeLabels.length > 1) { + const fetched = await runRest<{ name?: string }[]>( ['-X', 'GET', `${base}/labels`], undefined, 'core', - githubHostExecOptions(args) + options ) if (!fetched.ok) { return { ok: false, error: fetched.error } } - const currentNames = new Set( - fetched.data.map((l) => l.name).filter((n): n is string => typeof n === 'string') + const names = new Set( + fetched.data.map((label) => label.name).filter((name): name is string => Boolean(name)) ) - for (const l of removeLabels ?? []) { - currentNames.delete(l) + for (const label of removeLabels) { + names.delete(label) } - for (const l of addLabels ?? []) { - currentNames.add(l) + for (const label of addLabels) { + names.add(label) } - if (currentNames.size === 0) { - // Why: `gh api -X PUT` with no `--raw-field` arguments sends an empty - // body — GitHub does NOT interpret that as "clear labels". The - // dedicated DELETE endpoint is the documented way to remove all - // labels in a single call. - const r = await runRest(['-X', 'DELETE', `${base}/labels`], undefined, 'core', { + if (names.size === 0) { + const result = await runRest(['-X', 'DELETE', `${base}/labels`], undefined, 'core', { expectEmpty: true, - ...githubHostExecOptions(args) + ...options }) - if (!r.ok && r.error.type !== 'not_found') { - return { ok: false, error: r.error } - } - } else { - const putArgs = ['-X', 'PUT', `${base}/labels`] - for (const name of currentNames) { - putArgs.push('--raw-field', `labels[]=${name}`) - } - const r = await runRest(putArgs, undefined, 'core', githubHostExecOptions(args)) - if (!r.ok) { - return { ok: false, error: r.error } - } + return result.ok || result.error.type === 'not_found' + ? { ok: true } + : { ok: false, error: result.error } } - } else { - if (addCount > 0) { - const restArgs = ['-X', 'POST', `${base}/labels`] - for (const l of addLabels ?? []) { - restArgs.push('--raw-field', `labels[]=${l}`) - } - const r = await runRest(restArgs, undefined, 'core', githubHostExecOptions(args)) - if (!r.ok) { - return { ok: false, error: r.error } - } + const putArgs = ['-X', 'PUT', `${base}/labels`] + for (const name of names) { + putArgs.push('--raw-field', `labels[]=${name}`) } - if (removeCount === 1) { - const r = await runRest( - ['-X', 'DELETE', `${base}/labels/${encodeURIComponent(removeLabels![0])}`], - undefined, - 'core', - { expectEmpty: true, ...githubHostExecOptions(args) } - ) - if (!r.ok && r.error.type !== 'not_found') { - return { ok: false, error: r.error } - } + const result = await runRest(putArgs, undefined, 'core', options) + return result.ok ? { ok: true } : { ok: false, error: result.error } + } + if (addLabels.length > 0) { + const postArgs = ['-X', 'POST', `${base}/labels`] + for (const label of addLabels) { + postArgs.push('--raw-field', `labels[]=${label}`) + } + const result = await runRest(postArgs, undefined, 'core', options) + if (!result.ok) { + return { ok: false, error: result.error } } } - - // 3) Assignees — POST and DELETE both accept arrays in a single call, so - // add/remove are at most 2 calls regardless of array size. - if (addAssignees && addAssignees.length > 0) { - const restArgs = ['-X', 'POST', `${base}/assignees`] - for (const u of addAssignees) { - restArgs.push('--raw-field', `assignees[]=${u}`) - } - const r = await runRest(restArgs, undefined, 'core', githubHostExecOptions(args)) - if (!r.ok) { - return { ok: false, error: r.error } - } - } - if (removeAssignees && removeAssignees.length > 0) { - const restArgs = ['-X', 'DELETE', `${base}/assignees`] - for (const u of removeAssignees) { - restArgs.push('--raw-field', `assignees[]=${u}`) - } - const r = await runRest(restArgs, undefined, 'core', githubHostExecOptions(args)) - if (!r.ok) { - return { ok: false, error: r.error } - } - } - return { ok: true } -} - -export async function updatePullRequestBySlug( - args: UpdatePullRequestBySlugArgs -): Promise { - const v = validateSlugArgs(args.owner, args.repo) - if (!v.ok) { - return v - } - const n = assertPositiveInt(args.number, 'number') - if (!n.ok) { - return { ok: false, error: n.error } - } - if (!args.updates || typeof args.updates !== 'object') { - return { ok: false, error: { type: 'validation_error', message: 'Updates required.' } } - } - const patchArgs: string[] = [ - '-X', - 'PATCH', - `repos/${args.owner}/${args.repo}/pulls/${args.number}` - ] - // Why: count fields explicitly rather than inferring from patchArgs.length — - // adding a future header/flag arg silently breaks an array-length check. - let fieldCount = 0 - if (args.updates.title !== undefined) { - patchArgs.push('--raw-field', `title=${args.updates.title}`) - fieldCount++ - } - if (args.updates.body !== undefined) { - patchArgs.push('--raw-field', `body=${args.updates.body}`) - fieldCount++ - } - if (args.updates.state !== undefined) { - patchArgs.push('--raw-field', `state=${args.updates.state}`) - fieldCount++ - } - if (fieldCount === 0) { - // No fields to update — nothing to do. - return { ok: true } - } - const r = await runRest(patchArgs, undefined, 'core', githubHostExecOptions(args)) - if (!r.ok) { - return { ok: false, error: r.error } - } - return { ok: true } -} - -type RawIssueCommentResponse = { - id?: number - user?: { login?: string; avatar_url?: string; type?: string } | null - body?: string - created_at?: string - html_url?: string -} - -function mapIssueComment(data: RawIssueCommentResponse, fallbackBody: string): PRComment { - return { - id: data.id ?? Date.now(), - author: data.user?.login ?? 'You', - authorAvatarUrl: data.user?.avatar_url ?? '', - body: data.body ?? fallbackBody, - createdAt: data.created_at ?? new Date().toISOString(), - url: data.html_url ?? '', - isBot: data.user?.type === 'Bot' - } -} - -export async function addIssueCommentBySlug( - args: AddIssueCommentBySlugArgs -): Promise { - const v = validateSlugArgs(args.owner, args.repo) - if (!v.ok) { - return v - } - const n = assertPositiveInt(args.number, 'number') - if (!n.ok) { - return { ok: false, error: n.error } - } - if (typeof args.body !== 'string' || !args.body.trim()) { - return { ok: false, error: { type: 'validation_error', message: 'Comment body required.' } } - } - const r = await runRest( - [ - '-X', - 'POST', - `repos/${args.owner}/${args.repo}/issues/${args.number}/comments`, - '--raw-field', - `body=${args.body}` - ], - undefined, - 'core', - githubHostExecOptions(args) - ) - if (!r.ok) { - return { ok: false, error: r.error } - } - return { ok: true, comment: mapIssueComment(r.data, args.body) } -} - -export async function updateIssueCommentBySlug( - args: UpdateIssueCommentBySlugArgs -): Promise { - const v = validateSlugArgs(args.owner, args.repo) - if (!v.ok) { - return v - } - const n = assertPositiveInt(args.commentId, 'commentId') - if (!n.ok) { - return { ok: false, error: n.error } - } - if (typeof args.body !== 'string' || !args.body.trim()) { - return { ok: false, error: { type: 'validation_error', message: 'Comment body required.' } } - } - const r = await runRest( - [ - '-X', - 'PATCH', - `repos/${args.owner}/${args.repo}/issues/comments/${args.commentId}`, - '--raw-field', - `body=${args.body}` - ], - undefined, - 'core', - githubHostExecOptions(args) - ) - if (!r.ok) { - return { ok: false, error: r.error } - } - return { ok: true } -} - -export async function deleteIssueCommentBySlug( - args: DeleteIssueCommentBySlugArgs -): Promise { - const v = validateSlugArgs(args.owner, args.repo) - if (!v.ok) { - return v - } - const n = assertPositiveInt(args.commentId, 'commentId') - if (!n.ok) { - return { ok: false, error: n.error } - } - const r = await runRest( - ['-X', 'DELETE', `repos/${args.owner}/${args.repo}/issues/comments/${args.commentId}`], - undefined, - 'core', - { expectEmpty: true, ...githubHostExecOptions(args) } - ) - if (!r.ok) { - return { ok: false, error: r.error } - } - return { ok: true } -} - -// ─── Slug-addressed picker sources ──────────────────────────────────── - -export async function listLabelsBySlug( - args: ListLabelsBySlugArgs -): Promise { - const v = validateSlugArgs(args.owner, args.repo) - if (!v.ok) { - return v - } - const authError = await projectHostAuthenticationError(args.host) - if (authError) { - return { ok: false, error: authError } - } - const guard = repositoryRateLimitGuard(args, 'core') - if (guard.blocked) { - return { ok: false, error: rateLimitedError(guard) } - } - await acquire() - // Why: `--paginate` may fan out to multiple pages; we can only reasonably - // estimate a 1-call spend up front. The next probe will reconcile. - noteRepositoryRateLimitSpend(args, 'core') - try { - const { stdout } = await ghExecFileAsync( - ['api', '--paginate', `repos/${args.owner}/${args.repo}/labels`, '--jq', '.[].name'], - { encoding: 'utf-8', ...githubHostExecOptions(args) } + if (removeLabels.length === 1) { + const result = await runRest( + ['-X', 'DELETE', `${base}/labels/${encodeURIComponent(removeLabels[0])}`], + undefined, + 'core', + { expectEmpty: true, ...options } ) - return { - ok: true, - labels: stdout - .trim() - .split('\n') - .filter((l) => l.length > 0) + if (!result.ok && result.error.type !== 'not_found') { + return { ok: false, error: result.error } } - } catch (err) { - const { stderr, stdout: maybeStdout } = extractExecError(err) - return { ok: false, error: classifyProjectError(stderr, maybeStdout, args.host) } - } finally { - release() - } -} - -export async function listAssignableUsersBySlug( - args: ListAssignableUsersBySlugArgs -): Promise { - const v = validateSlugArgs(args.owner, args.repo) - if (!v.ok) { - return v - } - const authError = await projectHostAuthenticationError(args.host) - if (authError) { - return { ok: false, error: authError } - } - // Seed logins merge after the fetch so callers can include currently-visible - // assignees even if the repo participant search is sparse. - const result: GitHubAssignableUser[] = [] - const guard = repositoryRateLimitGuard(args, 'core') - if (guard.blocked) { - return { ok: false, error: rateLimitedError(guard) } - } - await acquire() - noteRepositoryRateLimitSpend(args, 'core') - try { - const { stdout } = await ghExecFileAsync( - [ - 'api', - '--paginate', - `repos/${args.owner}/${args.repo}/assignees`, - '--jq', - '.[] | {login: .login, name: null, avatarUrl: .avatar_url}' - ], - { encoding: 'utf-8', ...githubHostExecOptions(args) } - ) - for (const line of stdout - .trim() - .split('\n') - .filter((l) => l.length > 0)) { - try { - const u = JSON.parse(line) as { login?: string; avatarUrl?: string; name?: string | null } - if (typeof u.login === 'string') { - result.push({ login: u.login, name: u.name ?? null, avatarUrl: u.avatarUrl ?? '' }) - } - } catch { - // skip malformed jq line - } - } - } catch (err) { - const { stderr } = extractExecError(err) - return { ok: false, error: classifyProjectError(stderr, '', args.host) } - } finally { - release() - } - if (args.seedLogins) { - const seen = new Set(result.map((u) => u.login)) - for (const login of args.seedLogins) { - if (typeof login === 'string' && !seen.has(login)) { - result.push({ login, name: null, avatarUrl: '' }) - seen.add(login) - } - } - } - return { ok: true, users: result } -} - -// Why: Issue Types are a repo-level taxonomy (Bug/Feature/Task/etc) only -// available on repos opted into typed-issues. Empty list (or schema_drift on -// older GitHub deployments) is the legitimate "this repo doesn't use issue -// types" signal — callers should treat it as "no editor". -export async function listIssueTypesBySlug( - args: ListIssueTypesBySlugArgs -): Promise { - const v = validateSlugArgs(args.owner, args.repo) - if (!v.ok) { - return v - } - const query = ` - query($owner:String!, $repo:String!) { - repository(owner:$owner, name:$repo) { - issueTypes(first:50) { - nodes { id name color description } - } - } - } - ` - const res = await runGraphql<{ - repository?: { - issueTypes?: { - nodes?: ({ - id?: string - name?: string - color?: string | null - description?: string | null - } | null)[] - } | null - } | null - }>(query, { owner: args.owner, repo: args.repo }, githubHostExecOptions(args)) - if (!res.ok) { - // Why: repos without issue types respond with a GraphQL error claiming the - // `issueTypes` field is unknown. Map that to an empty list so the UI shows - // "no editor" instead of an angry banner. - if (res.error.type === 'schema_drift' || res.error.type === 'validation_error') { - return { ok: true, types: [] } - } - return { ok: false, error: res.error } - } - const nodes = res.data.repository?.issueTypes?.nodes ?? [] - const types = nodes - .filter( - (n): n is NonNullable => - n !== null && typeof n.id === 'string' && typeof n.name === 'string' - ) - .map((n) => ({ - id: n.id as string, - name: n.name as string, - color: typeof n.color === 'string' ? n.color : null, - description: typeof n.description === 'string' ? n.description : null - })) - return { ok: true, types } -} - -export async function updateIssueTypeBySlug( - args: UpdateIssueTypeBySlugArgs -): Promise { - const v = validateSlugArgs(args.owner, args.repo) - if (!v.ok) { - return v - } - const n = assertPositiveInt(args.number, 'number') - if (!n.ok) { - return { ok: false, error: n.error } - } - // Why: `updateIssueIssueType` is the dedicated mutation; passing null for - // `issueTypeId` clears the type. We resolve the issue id via a lightweight - // GraphQL lookup because the REST endpoint doesn't accept issue types. - const lookup = await runGraphql<{ - repository?: { issue?: { id?: string } | null } | null - }>( - `query($owner:String!, $repo:String!, $num:Int!) { - repository(owner:$owner, name:$repo) { issue(number:$num) { id } } - }`, - { owner: args.owner, repo: args.repo, num: args.number }, - githubHostExecOptions(args) - ) - if (!lookup.ok) { - return { ok: false, error: lookup.error } - } - const issueId = lookup.data.repository?.issue?.id - if (!issueId) { - return { ok: false, error: { type: 'not_found', message: 'Issue not found.' } } - } - // Why: build the mutation conditionally so a null clear doesn't have to - // smuggle a null GraphQL variable through `gh api graphql -f`. The - // mutation accepts a literal `null` in the input object directly. - const query = args.issueTypeId - ? ` - mutation($issueId:ID!, $issueTypeId:ID!) { - updateIssueIssueType(input: { issueId: $issueId, issueTypeId: $issueTypeId }) { - issue { id } - } - } - ` - : ` - mutation($issueId:ID!) { - updateIssueIssueType(input: { issueId: $issueId, issueTypeId: null }) { - issue { id } - } - } - ` - const vars: GraphqlVars = args.issueTypeId - ? { issueId, issueTypeId: args.issueTypeId } - : { issueId } - const res = await runGraphql(query, vars, githubHostExecOptions(args)) - if (!res.ok) { - return { ok: false, error: res.error } } return { ok: true } } -// ─── Slug-addressed work-item details ───────────────────────────────── - -type RawUser = { login?: string; name?: string | null; avatarUrl?: string | null } -type RawLabel = { name?: string; color?: string } -type RawWorkItemContent = { - id?: string - number?: number - title?: string - url?: string - state?: string - stateReason?: string | null - isDraft?: boolean - labels?: { nodes?: RawLabel[] } - assignees?: { nodes?: RawUser[] } -} - -export async function getWorkItemDetailsBySlug( - args: ProjectWorkItemDetailsBySlugArgs -): Promise { - const v = validateSlugArgs(args.owner, args.repo) - if (!v.ok) { - return v - } - const n = assertPositiveInt(args.number, 'number') - if (!n.ok) { - return { ok: false, error: n.error } - } - if (args.type !== 'issue' && args.type !== 'pr') { - return { ok: false, error: { type: 'validation_error', message: 'Invalid type.' } } - } - - // Single GraphQL round-trip to fetch the issue/PR summary + comments + labels + assignees. - const contentFrag = - args.type === 'issue' - ? ` - issue(number:$num) { - id number title url state stateReason updatedAt - body - author { login } - labels(first:50) { nodes { name } } - assignees(first:50) { nodes { login } } - participants(first:50) { nodes { login name avatarUrl } } - comments(first:100) { - nodes { - databaseId - author { login avatarUrl __typename } - body createdAt url - } - } - } - ` - : ` - pullRequest(number:$num) { - id number title url state isDraft updatedAt headRefName baseRefName - body - author { login } - labels(first:50) { nodes { name } } - assignees(first:50) { nodes { login } } - participants(first:50) { nodes { login name avatarUrl } } - comments(first:100) { - nodes { - databaseId - author { login avatarUrl __typename } - body createdAt url - } - } - } - ` - const query = ` - query($owner:String!, $repo:String!, $num:Int!) { - repository(owner:$owner, name:$repo) { - ${contentFrag} - } +async function updateIssueAssignees( + args: UpdateIssueBySlugArgs, + base: string +): Promise { + const { addAssignees = [], removeAssignees = [] } = args.updates + const options = projectGhExecOptions(args.host) + if (addAssignees.length > 0) { + const postArgs = ['-X', 'POST', `${base}/assignees`] + for (const login of addAssignees) { + postArgs.push('--raw-field', `assignees[]=${login}`) } - ` - const res = await runGraphql<{ - repository?: { - issue?: - | (RawWorkItemContent & { - updatedAt?: string - body?: string - author?: { login?: string } | null - participants?: { nodes?: RawUser[] } - comments?: { - nodes?: ({ - databaseId?: number - author?: { login?: string; avatarUrl?: string; __typename?: string } | null - body?: string - createdAt?: string - url?: string - } | null)[] - } - }) - | null - pullRequest?: - | (RawWorkItemContent & { - updatedAt?: string - body?: string - headRefName?: string - baseRefName?: string - author?: { login?: string } | null - participants?: { nodes?: RawUser[] } - comments?: { - nodes?: ({ - databaseId?: number - author?: { login?: string; avatarUrl?: string; __typename?: string } | null - body?: string - createdAt?: string - url?: string - } | null)[] - } - }) - | null - } | null - }>(query, { owner: args.owner, repo: args.repo, num: args.number }, githubHostExecOptions(args)) - if (!res.ok) { - return { ok: false, error: res.error } - } - const raw = args.type === 'issue' ? res.data.repository?.issue : res.data.repository?.pullRequest - if (!raw) { - return { ok: false, error: { type: 'not_found', message: 'Item not found.' } } - } - - const labels = (raw.labels?.nodes ?? []) - .map((l) => l?.name) - .filter((n): n is string => typeof n === 'string') - const assignees = (raw.assignees?.nodes ?? []) - .map((a) => a?.login) - .filter((l): l is string => typeof l === 'string') - const comments: PRComment[] = [] - for (const c of raw.comments?.nodes ?? []) { - if (!c || typeof c.body !== 'string') { - continue + const result = await runRest(postArgs, undefined, 'core', options) + if (!result.ok) { + return { ok: false, error: result.error } } - comments.push({ - id: typeof c.databaseId === 'number' ? c.databaseId : Date.now(), - author: c.author?.login ?? '', - authorAvatarUrl: c.author?.avatarUrl ?? '', - body: c.body, - createdAt: typeof c.createdAt === 'string' ? c.createdAt : '', - url: typeof c.url === 'string' ? c.url : '', - isBot: c.author?.__typename === 'Bot' - }) } - const participants: GitHubAssignableUser[] = [] - for (const p of raw.participants?.nodes ?? []) { - if (p && typeof p.login === 'string') { - participants.push({ login: p.login, name: p.name ?? null, avatarUrl: p.avatarUrl ?? '' }) + if (removeAssignees.length > 0) { + const deleteArgs = ['-X', 'DELETE', `${base}/assignees`] + for (const login of removeAssignees) { + deleteArgs.push('--raw-field', `assignees[]=${login}`) + } + const result = await runRest(deleteArgs, undefined, 'core', options) + if (!result.ok) { + return { ok: false, error: result.error } } - } - - const state: 'open' | 'closed' | 'merged' | 'draft' = - args.type === 'pr' - ? raw.isDraft - ? 'draft' - : raw.state === 'MERGED' - ? 'merged' - : raw.state === 'CLOSED' - ? 'closed' - : 'open' - : raw.state === 'CLOSED' - ? 'closed' - : 'open' - - const details: GitHubWorkItemDetails = { - item: { - id: typeof raw.id === 'string' ? raw.id : '', - type: args.type, - number: typeof raw.number === 'number' ? raw.number : args.number, - title: typeof raw.title === 'string' ? raw.title : '', - state, - url: typeof raw.url === 'string' ? raw.url : '', - labels, - updatedAt: - typeof (raw as { updatedAt?: string }).updatedAt === 'string' - ? (raw as { updatedAt: string }).updatedAt - : '', - author: - typeof (raw as { author?: { login?: string } | null }).author?.login === 'string' - ? ((raw as { author: { login: string } }).author.login as string) - : null, - branchName: - args.type === 'pr' && typeof (raw as { headRefName?: string }).headRefName === 'string' - ? ((raw as { headRefName: string }).headRefName as string) - : undefined, - baseRefName: - args.type === 'pr' && typeof (raw as { baseRefName?: string }).baseRefName === 'string' - ? ((raw as { baseRefName: string }).baseRefName as string) - : undefined - }, - body: typeof raw.body === 'string' ? raw.body : '', - comments, - participants, - // Why: PR files/checks/review-thread tabs depend on a local repo path and - // are out of Project-mode slug scope for v1. Omit them here; the dialog - // branches on their absence and hides those tabs. - assignees } - return { ok: true, details } + return { ok: true } } diff --git a/src/main/github/project-view/project-field-mutations.test.ts b/src/main/github/project-view/project-field-mutations.test.ts new file mode 100644 index 00000000000..3242e947a2a --- /dev/null +++ b/src/main/github/project-view/project-field-mutations.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { runGraphqlMock } = vi.hoisted(() => ({ runGraphqlMock: vi.fn() })) + +vi.mock('./internals', () => ({ + projectGhExecOptions: (host?: string) => ({ host: host ?? 'github.com' }), + runGraphql: runGraphqlMock +})) + +import { clearProjectItemFieldValue, updateProjectItemFieldValue } from './project-field-mutations' + +describe('project field mutations', () => { + beforeEach(() => { + runGraphqlMock.mockReset().mockResolvedValue({ ok: true, data: {} }) + }) + + it('maps each field value kind to a host-pinned GraphQL variable', async () => { + await updateProjectItemFieldValue({ + projectId: 'PVT_1', + itemId: 'PVTI_1', + fieldId: 'PVTF_1', + value: { kind: 'number', number: 12.5 }, + host: 'github.corp.example' + }) + + expect(runGraphqlMock).toHaveBeenCalledWith( + expect.stringContaining('$value:Float!'), + { projectId: 'PVT_1', itemId: 'PVTI_1', fieldId: 'PVTF_1', value: 12.5 }, + { host: 'github.corp.example' } + ) + }) + + it('rejects malformed field kinds without dispatching GraphQL', async () => { + const result = await updateProjectItemFieldValue({ + projectId: 'PVT_1', + itemId: 'PVTI_1', + fieldId: 'PVTF_1', + value: { kind: 'future-kind' } as never + }) + + expect(result).toMatchObject({ ok: false, error: { type: 'validation_error' } }) + expect(runGraphqlMock).not.toHaveBeenCalled() + }) + + it('keeps clear mutations host-scoped', async () => { + await clearProjectItemFieldValue({ + projectId: 'PVT_1', + itemId: 'PVTI_1', + fieldId: 'PVTF_1', + host: 'github.corp.example' + }) + + expect(runGraphqlMock).toHaveBeenCalledWith( + expect.stringContaining('clearProjectV2ItemFieldValue'), + { projectId: 'PVT_1', itemId: 'PVTI_1', fieldId: 'PVTF_1' }, + { host: 'github.corp.example' } + ) + }) +}) diff --git a/src/main/github/project-view/project-field-mutations.ts b/src/main/github/project-view/project-field-mutations.ts new file mode 100644 index 00000000000..5fc5edb1985 --- /dev/null +++ b/src/main/github/project-view/project-field-mutations.ts @@ -0,0 +1,103 @@ +import { projectGhExecOptions, runGraphql, type GraphqlVars } from './internals' +import type { GitHubProjectFieldMutationValue } from '../../../shared/github/project-types' +import type { GitHubProjectMutationResult } from '../../../shared/github/project-result-types' +import type { + ClearProjectItemFieldArgs, + UpdateProjectItemFieldArgs +} from '../../../shared/github/project-request-types' + +class UnknownFieldMutationKindError extends Error { + constructor(kind: string) { + super(`Unknown project field mutation kind: ${kind}`) + } +} + +function graphqlValueForFieldMutation(value: GitHubProjectFieldMutationValue): string { + switch (value.kind) { + case 'single-select': + return 'singleSelectOptionId: $value' + case 'iteration': + return 'iterationId: $value' + case 'text': + return 'text: $value' + case 'number': + return 'number: $value' + case 'date': + return 'date: $value' + } + throw new UnknownFieldMutationKindError((value as { kind: string }).kind) +} + +function mutationValueVar(value: GitHubProjectFieldMutationValue): { + type: string + val: string | number +} { + switch (value.kind) { + case 'single-select': + return { type: 'String!', val: value.optionId } + case 'iteration': + return { type: 'String!', val: value.iterationId } + case 'text': + return { type: 'String!', val: value.text } + case 'number': + return { type: 'Float!', val: value.number } + case 'date': + return { type: 'Date!', val: value.date } + } + throw new UnknownFieldMutationKindError((value as { kind: string }).kind) +} + +export async function updateProjectItemFieldValue( + args: UpdateProjectItemFieldArgs +): Promise { + if (!args.projectId || !args.itemId || !args.fieldId) { + return { ok: false, error: { type: 'validation_error', message: 'Missing ids.' } } + } + let valueFragment: string + let valueVariable: { type: string; val: string | number } + try { + valueFragment = graphqlValueForFieldMutation(args.value) + valueVariable = mutationValueVar(args.value) + } catch (error) { + if (error instanceof UnknownFieldMutationKindError) { + return { ok: false, error: { type: 'validation_error', message: error.message } } + } + throw error + } + const query = ` + mutation($projectId:ID!, $itemId:ID!, $fieldId:ID!, $value:${valueVariable.type}) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { ${valueFragment} } + }) { projectV2Item { id } } + } + ` + const vars: GraphqlVars = { + projectId: args.projectId, + itemId: args.itemId, + fieldId: args.fieldId, + value: valueVariable.val + } + const result = await runGraphql(query, vars, projectGhExecOptions(args.host)) + return result.ok ? { ok: true } : { ok: false, error: result.error } +} + +export async function clearProjectItemFieldValue( + args: ClearProjectItemFieldArgs +): Promise { + if (!args.projectId || !args.itemId || !args.fieldId) { + return { ok: false, error: { type: 'validation_error', message: 'Missing ids.' } } + } + const result = await runGraphql( + `mutation($projectId:ID!, $itemId:ID!, $fieldId:ID!) { + clearProjectV2ItemFieldValue(input: { + projectId: $projectId itemId: $itemId fieldId: $fieldId + }) { projectV2Item { id } } + }`, + { projectId: args.projectId, itemId: args.itemId, fieldId: args.fieldId }, + projectGhExecOptions(args.host) + ) + return result.ok ? { ok: true } : { ok: false, error: result.error } +} diff --git a/src/main/github/project-view/pull-request-mutation.ts b/src/main/github/project-view/pull-request-mutation.ts new file mode 100644 index 00000000000..7a4d056dab2 --- /dev/null +++ b/src/main/github/project-view/pull-request-mutation.ts @@ -0,0 +1,47 @@ +import { assertPositiveInt, projectGhExecOptions, runRest, validateSlugArgs } from './internals' +import type { GitHubProjectMutationResult } from '../../../shared/github/project-result-types' +import type { UpdatePullRequestBySlugArgs } from '../../../shared/github/project-request-types' + +export async function updatePullRequestBySlug( + args: UpdatePullRequestBySlugArgs +): Promise { + const slug = validateSlugArgs(args.owner, args.repo) + if (!slug.ok) { + return slug + } + const number = assertPositiveInt(args.number, 'number') + if (!number.ok) { + return { ok: false, error: number.error } + } + if (!args.updates || typeof args.updates !== 'object') { + return { ok: false, error: { type: 'validation_error', message: 'Updates required.' } } + } + const patchArgs: string[] = [ + '-X', + 'PATCH', + `repos/${args.owner}/${args.repo}/pulls/${args.number}` + ] + let fieldCount = 0 + if (args.updates.title !== undefined) { + patchArgs.push('--raw-field', `title=${args.updates.title}`) + fieldCount++ + } + if (args.updates.body !== undefined) { + patchArgs.push('--raw-field', `body=${args.updates.body}`) + fieldCount++ + } + if (args.updates.state !== undefined) { + patchArgs.push('--raw-field', `state=${args.updates.state}`) + fieldCount++ + } + if (fieldCount === 0) { + return { ok: true } + } + const result = await runRest( + patchArgs, + undefined, + 'core', + projectGhExecOptions(args.host) + ) + return result.ok ? { ok: true } : { ok: false, error: result.error } +} diff --git a/src/main/github/project-view/repository-field-options.test.ts b/src/main/github/project-view/repository-field-options.test.ts new file mode 100644 index 00000000000..91123388b49 --- /dev/null +++ b/src/main/github/project-view/repository-field-options.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { acquireMock, ghExecFileAsyncMock, noteRepositoryRateLimitSpendMock, releaseMock } = + vi.hoisted(() => ({ + acquireMock: vi.fn(), + ghExecFileAsyncMock: vi.fn(), + noteRepositoryRateLimitSpendMock: vi.fn(), + releaseMock: vi.fn() + })) + +vi.mock('./internals', () => ({ + acquire: acquireMock, + release: releaseMock, + extractExecError: vi.fn(), + ghExecFileAsync: ghExecFileAsyncMock, + noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock, + projectGhExecOptions: (host?: string) => ({ host: host ?? 'github.com' }), + projectHostAuthenticationError: vi.fn().mockResolvedValue(null), + repositoryRateLimitGuard: vi.fn().mockReturnValue({ blocked: false }), + runGraphql: vi.fn(), + validateSlugArgs: () => ({ ok: true }) +})) + +vi.mock('./project-error-classification', () => ({ + classifyProjectError: vi.fn(), + rateLimitedError: vi.fn() +})) + +import { listAssignableUsersBySlug, listLabelsBySlug } from './repository-field-options' + +describe('repository field options', () => { + beforeEach(() => { + acquireMock.mockReset().mockResolvedValue(undefined) + releaseMock.mockReset() + noteRepositoryRateLimitSpendMock.mockReset() + ghExecFileAsyncMock.mockReset() + }) + + it('keeps label discovery paginated and host-scoped', async () => { + ghExecFileAsyncMock.mockResolvedValue({ stdout: 'bug\nenhancement\n', stderr: '' }) + + await expect( + listLabelsBySlug({ owner: 'acme', repo: 'widgets', host: 'github.corp.example' }) + ).resolves.toEqual({ ok: true, labels: ['bug', 'enhancement'] }) + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + ['api', '--paginate', 'repos/acme/widgets/labels', '--jq', '.[].name'], + { encoding: 'utf-8', host: 'github.corp.example' } + ) + expect(releaseMock).toHaveBeenCalledOnce() + }) + + it('merges seed assignees after paginated provider results', async () => { + ghExecFileAsyncMock.mockResolvedValue({ + stdout: '{"login":"octo","name":null,"avatarUrl":"octo.png"}\n', + stderr: '' + }) + + const result = await listAssignableUsersBySlug({ + owner: 'acme', + repo: 'widgets', + seedLogins: ['octo', 'hubot'] + }) + + expect(result).toEqual({ + ok: true, + users: [ + { login: 'octo', name: null, avatarUrl: 'octo.png' }, + { login: 'hubot', name: null, avatarUrl: '' } + ] + }) + }) +}) diff --git a/src/main/github/project-view/repository-field-options.ts b/src/main/github/project-view/repository-field-options.ts new file mode 100644 index 00000000000..ce1d80cfbf6 --- /dev/null +++ b/src/main/github/project-view/repository-field-options.ts @@ -0,0 +1,165 @@ +import { + acquire, + extractExecError, + ghExecFileAsync, + noteRepositoryRateLimitSpend, + projectGhExecOptions, + projectHostAuthenticationError, + release, + repositoryRateLimitGuard, + runGraphql, + validateSlugArgs +} from './internals' +import { classifyProjectError, rateLimitedError } from './project-error-classification' +import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types' +import type { + ListAssignableUsersBySlugResult, + ListIssueTypesBySlugResult, + ListLabelsBySlugResult +} from '../../../shared/github/project-result-types' +import type { + ListAssignableUsersBySlugArgs, + ListIssueTypesBySlugArgs, + ListLabelsBySlugArgs +} from '../../../shared/github/project-request-types' + +export async function listLabelsBySlug( + args: ListLabelsBySlugArgs +): Promise { + const validation = validateSlugArgs(args.owner, args.repo) + if (!validation.ok) { + return validation + } + const authError = await projectHostAuthenticationError(args.host) + if (authError) { + return { ok: false, error: authError } + } + const guard = repositoryRateLimitGuard(args, 'core') + if (guard.blocked) { + return { ok: false, error: rateLimitedError(guard) } + } + await acquire() + noteRepositoryRateLimitSpend(args, 'core') + try { + const { stdout } = await ghExecFileAsync( + ['api', '--paginate', `repos/${args.owner}/${args.repo}/labels`, '--jq', '.[].name'], + { encoding: 'utf-8', ...projectGhExecOptions(args.host) } + ) + return { ok: true, labels: stdout.trim().split('\n').filter(Boolean) } + } catch (error) { + const { stderr, stdout } = extractExecError(error) + return { ok: false, error: classifyProjectError(stderr, stdout, args.host) } + } finally { + release() + } +} + +export async function listAssignableUsersBySlug( + args: ListAssignableUsersBySlugArgs +): Promise { + const validation = validateSlugArgs(args.owner, args.repo) + if (!validation.ok) { + return validation + } + const authError = await projectHostAuthenticationError(args.host) + if (authError) { + return { ok: false, error: authError } + } + const guard = repositoryRateLimitGuard(args, 'core') + if (guard.blocked) { + return { ok: false, error: rateLimitedError(guard) } + } + const users: GitHubAssignableUser[] = [] + await acquire() + noteRepositoryRateLimitSpend(args, 'core') + try { + const { stdout } = await ghExecFileAsync( + [ + 'api', + '--paginate', + `repos/${args.owner}/${args.repo}/assignees`, + '--jq', + '.[] | {login: .login, name: null, avatarUrl: .avatar_url}' + ], + { encoding: 'utf-8', ...projectGhExecOptions(args.host) } + ) + for (const line of stdout.trim().split('\n').filter(Boolean)) { + try { + const user = JSON.parse(line) as { + login?: string + avatarUrl?: string + name?: string | null + } + if (typeof user.login === 'string') { + users.push({ + login: user.login, + name: user.name ?? null, + avatarUrl: user.avatarUrl ?? '' + }) + } + } catch { + // Skip malformed jq output. + } + } + } catch (error) { + const { stderr } = extractExecError(error) + return { ok: false, error: classifyProjectError(stderr, '', args.host) } + } finally { + release() + } + const seen = new Set(users.map((user) => user.login)) + for (const login of args.seedLogins ?? []) { + if (typeof login === 'string' && !seen.has(login)) { + users.push({ login, name: null, avatarUrl: '' }) + seen.add(login) + } + } + return { ok: true, users } +} + +export async function listIssueTypesBySlug( + args: ListIssueTypesBySlugArgs +): Promise { + const validation = validateSlugArgs(args.owner, args.repo) + if (!validation.ok) { + return validation + } + const result = await runGraphql<{ + repository?: { + issueTypes?: { + nodes?: ({ + id?: string + name?: string + color?: string | null + description?: string | null + } | null)[] + } | null + } | null + }>( + `query($owner:String!, $repo:String!) { + repository(owner:$owner, name:$repo) { + issueTypes(first:50) { nodes { id name color description } } + } + }`, + { owner: args.owner, repo: args.repo }, + projectGhExecOptions(args.host) + ) + if (!result.ok) { + if (result.error.type === 'schema_drift' || result.error.type === 'validation_error') { + return { ok: true, types: [] } + } + return { ok: false, error: result.error } + } + const types = (result.data.repository?.issueTypes?.nodes ?? []) + .filter( + (node): node is NonNullable => + node !== null && typeof node.id === 'string' && typeof node.name === 'string' + ) + .map((node) => ({ + id: node.id as string, + name: node.name as string, + color: typeof node.color === 'string' ? node.color : null, + description: typeof node.description === 'string' ? node.description : null + })) + return { ok: true, types } +} diff --git a/src/main/github/project-view/work-item-details.ts b/src/main/github/project-view/work-item-details.ts new file mode 100644 index 00000000000..ad6d122f707 --- /dev/null +++ b/src/main/github/project-view/work-item-details.ts @@ -0,0 +1,162 @@ +import { assertPositiveInt, projectGhExecOptions, runGraphql, validateSlugArgs } from './internals' +import type { PRComment } from '../../../shared/github/comment-types' +import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types' +import type { GitHubWorkItemDetails } from '../../../shared/github/work-item-types' +import type { ProjectWorkItemDetailsBySlugResult } from '../../../shared/github/project-result-types' +import type { ProjectWorkItemDetailsBySlugArgs } from '../../../shared/github/project-request-types' + +type RawUser = { login?: string; name?: string | null; avatarUrl?: string | null } +type RawLabel = { name?: string; color?: string } +type RawComment = { + databaseId?: number + author?: { login?: string; avatarUrl?: string; __typename?: string } | null + body?: string + createdAt?: string + url?: string +} +type RawWorkItemContent = { + id?: string + number?: number + title?: string + url?: string + state?: string + stateReason?: string | null + isDraft?: boolean + updatedAt?: string + body?: string + headRefName?: string + baseRefName?: string + author?: { login?: string } | null + labels?: { nodes?: RawLabel[] } + assignees?: { nodes?: RawUser[] } + participants?: { nodes?: RawUser[] } + comments?: { nodes?: (RawComment | null)[] } +} + +export async function getWorkItemDetailsBySlug( + args: ProjectWorkItemDetailsBySlugArgs +): Promise { + const validation = validateSlugArgs(args.owner, args.repo) + if (!validation.ok) { + return validation + } + const number = assertPositiveInt(args.number, 'number') + if (!number.ok) { + return { ok: false, error: number.error } + } + if (args.type !== 'issue' && args.type !== 'pr') { + return { ok: false, error: { type: 'validation_error', message: 'Invalid type.' } } + } + const contentFragment = args.type === 'issue' ? ISSUE_FRAGMENT : PULL_REQUEST_FRAGMENT + const result = await runGraphql<{ + repository?: { + issue?: RawWorkItemContent | null + pullRequest?: RawWorkItemContent | null + } | null + }>( + `query($owner:String!, $repo:String!, $num:Int!) { + repository(owner:$owner, name:$repo) { ${contentFragment} } + }`, + { owner: args.owner, repo: args.repo, num: args.number }, + projectGhExecOptions(args.host) + ) + if (!result.ok) { + return { ok: false, error: result.error } + } + const raw = + args.type === 'issue' ? result.data.repository?.issue : result.data.repository?.pullRequest + if (!raw) { + return { ok: false, error: { type: 'not_found', message: 'Item not found.' } } + } + return { ok: true, details: mapWorkItemDetails(raw, args) } +} + +const ISSUE_FRAGMENT = `issue(number:$num) { + id number title url state stateReason updatedAt body author { login } + labels(first:50) { nodes { name } } + assignees(first:50) { nodes { login } } + participants(first:50) { nodes { login name avatarUrl } } + comments(first:100) { + nodes { databaseId author { login avatarUrl __typename } body createdAt url } + } +}` + +const PULL_REQUEST_FRAGMENT = `pullRequest(number:$num) { + id number title url state isDraft updatedAt headRefName baseRefName body author { login } + labels(first:50) { nodes { name } } + assignees(first:50) { nodes { login } } + participants(first:50) { nodes { login name avatarUrl } } + comments(first:100) { + nodes { databaseId author { login avatarUrl __typename } body createdAt url } + } +}` + +function mapWorkItemDetails( + raw: RawWorkItemContent, + args: ProjectWorkItemDetailsBySlugArgs +): GitHubWorkItemDetails { + const labels = (raw.labels?.nodes ?? []) + .map((label) => label?.name) + .filter((name): name is string => typeof name === 'string') + const assignees = (raw.assignees?.nodes ?? []) + .map((assignee) => assignee?.login) + .filter((login): login is string => typeof login === 'string') + const comments: PRComment[] = [] + for (const comment of raw.comments?.nodes ?? []) { + if (!comment || typeof comment.body !== 'string') { + continue + } + comments.push({ + id: typeof comment.databaseId === 'number' ? comment.databaseId : Date.now(), + author: comment.author?.login ?? '', + authorAvatarUrl: comment.author?.avatarUrl ?? '', + body: comment.body, + createdAt: typeof comment.createdAt === 'string' ? comment.createdAt : '', + url: typeof comment.url === 'string' ? comment.url : '', + isBot: comment.author?.__typename === 'Bot' + }) + } + const participants: GitHubAssignableUser[] = [] + for (const participant of raw.participants?.nodes ?? []) { + if (participant && typeof participant.login === 'string') { + participants.push({ + login: participant.login, + name: participant.name ?? null, + avatarUrl: participant.avatarUrl ?? '' + }) + } + } + const state: 'open' | 'closed' | 'merged' | 'draft' = + args.type === 'pr' + ? raw.isDraft + ? 'draft' + : raw.state === 'MERGED' + ? 'merged' + : raw.state === 'CLOSED' + ? 'closed' + : 'open' + : raw.state === 'CLOSED' + ? 'closed' + : 'open' + return { + item: { + id: typeof raw.id === 'string' ? raw.id : '', + type: args.type, + number: typeof raw.number === 'number' ? raw.number : args.number, + title: typeof raw.title === 'string' ? raw.title : '', + state, + url: typeof raw.url === 'string' ? raw.url : '', + labels, + updatedAt: typeof raw.updatedAt === 'string' ? raw.updatedAt : '', + author: typeof raw.author?.login === 'string' ? raw.author.login : null, + branchName: + args.type === 'pr' && typeof raw.headRefName === 'string' ? raw.headRefName : undefined, + baseRefName: + args.type === 'pr' && typeof raw.baseRefName === 'string' ? raw.baseRefName : undefined + }, + body: typeof raw.body === 'string' ? raw.body : '', + comments, + participants, + assignees + } +} diff --git a/src/main/ipc/crash-reporting.test.ts b/src/main/ipc/crash-reporting.test.ts index 86867659302..c7495634db1 100644 --- a/src/main/ipc/crash-reporting.test.ts +++ b/src/main/ipc/crash-reporting.test.ts @@ -138,7 +138,7 @@ describe('registerCrashReportingHandlers', () => { _resetRendererErrorReportDedupeForTests() }) - it('copies the latest pending diagnostic text to the clipboard', async () => { + it('copies the requested captured report to the clipboard', async () => { const latest = report() registerCrashReportingHandlers({ getById: vi.fn(async () => latest), @@ -151,6 +151,7 @@ describe('registerCrashReportingHandlers', () => { } as never) const result = await handlers.get('crashReports:copyLatestDiagnostics')?.(null, { + reportId: latest.id, notes: 'extra /Users/alice/project' }) diff --git a/src/main/ipc/hooks/register-worktree-hook-check-handler.ts b/src/main/ipc/hooks/register-worktree-hook-check-handler.ts new file mode 100644 index 00000000000..6b64dbe6a6f --- /dev/null +++ b/src/main/ipc/hooks/register-worktree-hook-check-handler.ts @@ -0,0 +1,67 @@ +import { ipcMain } from 'electron' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { isFolderRepo } from '../../../shared/repo-kind' +import { getSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' +import { joinWorktreeRelativePath } from '../../runtime/runtime-relative-paths' +import { parseOrcaYaml, hasHooksFile, loadHooks, hasUnrecognizedOrcaYamlKeys } from '../../hooks' +import { isENOENT } from '../filesystem-path-containment' +import { resolveRepoForExecutionHost } from '../worktrees/repo-host-ownership' +import type { WorktreeIpcContext } from '../worktrees/worktree-ipc-context' + +export function registerWorktreeHookCheckHandler(context: WorktreeIpcContext): void { + const { store } = context + + ipcMain.handle( + 'hooks:check', + async (_event, args: { repoId: string; hostId?: ExecutionHostId }) => { + const repo = resolveRepoForExecutionHost(store, args.repoId, args.hostId) + if (!repo) { + const repoIdExists = store.getRepos().some((candidate) => candidate.id === args.repoId) + // Why: callers treat inspection errors as "skip", so a requested/ambiguous host must report error (fail closed), not hook-free. + return { + status: args.hostId || repoIdExists ? 'error' : 'ok', + hasHooks: false, + hooks: null, + mayNeedUpdate: false + } + } + if (isFolderRepo(repo)) { + return { status: 'ok', hasHooks: false, hooks: null, mayNeedUpdate: false } + } + + if (repo.connectionId) { + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + return { status: 'error', hasHooks: false, hooks: null, mayNeedUpdate: false } + } + try { + const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) + return { + status: 'ok', + hasHooks: !result.isBinary, + hooks: result.isBinary ? null : parseOrcaYaml(result.content), + mayNeedUpdate: false + } + } catch (error) { + return { + status: isENOENT(error) ? 'ok' : 'error', + hasHooks: false, + hooks: null, + mayNeedUpdate: false + } + } + } + + const has = hasHooksFile(repo.path) + const hooks = has ? loadHooks(repo.path) : null + // Why: unrecognised top-level keys mean the file is well-formed but from a newer Orca; suggest updating rather than "could not be parsed". + const mayNeedUpdate = has && !hooks && hasUnrecognizedOrcaYamlKeys(repo.path) + return { + status: 'ok', + hasHooks: has, + hooks, + mayNeedUpdate + } + } + ) +} diff --git a/src/main/ipc/hooks/register-worktree-hook-file-handlers.ts b/src/main/ipc/hooks/register-worktree-hook-file-handlers.ts new file mode 100644 index 00000000000..881a5e3bd0a --- /dev/null +++ b/src/main/ipc/hooks/register-worktree-hook-file-handlers.ts @@ -0,0 +1,126 @@ +import { ipcMain } from 'electron' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { isFolderRepo } from '../../../shared/repo-kind' +import { joinWorktreeRelativePath } from '../../runtime/runtime-relative-paths' +import { getSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' +import { isENOENT } from '../filesystem-path-containment' +import { parseOrcaYaml } from '../../hooks' +import { readIssueCommand, writeIssueCommand } from '../../issue-command-file' +import { resolveRepoForExecutionHost } from '../worktrees/repo-host-ownership' +import type { WorktreeIpcContext } from '../worktrees/worktree-ipc-context' + +export function registerWorktreeHookFileHandlers(context: WorktreeIpcContext): void { + const { store } = context + + ipcMain.handle( + 'hooks:readIssueCommand', + async (_event, args: { repoId: string; hostId?: ExecutionHostId }) => { + const repo = resolveRepoForExecutionHost(store, args.repoId, args.hostId) + if (!repo || isFolderRepo(repo)) { + return { + status: 'ok', + localContent: null, + sharedContent: null, + effectiveContent: null, + localFilePath: '', + source: 'none' as const + } + } + if (repo.connectionId) { + const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command') + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + return { + status: 'error', + localContent: null, + sharedContent: null, + effectiveContent: null, + localFilePath: issueCommandPath, + source: 'none' as const + } + } + + let status: 'ok' | 'error' = 'ok' + let localContent: string | null = null + let sharedContent: string | null = null + try { + const result = await fsProvider.readFile(issueCommandPath) + localContent = result.isBinary ? null : result.content.trim() || null + } catch (error) { + if (!isENOENT(error)) { + status = 'error' + } + } + try { + const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) + sharedContent = result.isBinary + ? null + : parseOrcaYaml(result.content)?.issueCommand?.trim() || null + } catch (error) { + if (!isENOENT(error)) { + status = 'error' + } + } + const effectiveContent = localContent ?? sharedContent + return { + status: localContent ? 'ok' : status, + localContent, + sharedContent, + effectiveContent, + localFilePath: issueCommandPath, + source: localContent + ? ('local' as const) + : sharedContent + ? ('shared' as const) + : ('none' as const) + } + } + return readIssueCommand(repo.path) + } + ) + + ipcMain.handle( + 'hooks:writeIssueCommand', + async (_event, args: { repoId: string; content: string; hostId?: ExecutionHostId }) => { + const repo = resolveRepoForExecutionHost(store, args.repoId, args.hostId) + if (!repo || isFolderRepo(repo)) { + return + } + if (repo.connectionId) { + const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command') + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + throw new Error( + 'Remote filesystem unavailable. Reconnect the SSH target before retrying.' + ) + } + const trimmed = args.content.trim() + if (!trimmed) { + await fsProvider.deletePath(issueCommandPath, false).catch((error: unknown) => { + if (!isENOENT(error)) { + throw error + } + }) + return + } + await fsProvider.createDir(joinWorktreeRelativePath(repo.path, '.orca')) + const gitignorePath = joinWorktreeRelativePath(repo.path, '.gitignore') + try { + const result = await fsProvider.readFile(gitignorePath) + if (!result.isBinary && !/^\.orca\/?$/m.test(result.content)) { + const separator = result.content.endsWith('\n') ? '' : '\n' + await fsProvider.writeFile(gitignorePath, `${result.content}${separator}.orca\n`) + } + } catch (error) { + if (!isENOENT(error)) { + throw error + } + await fsProvider.writeFile(gitignorePath, '.orca\n') + } + await fsProvider.writeFile(issueCommandPath, `${trimmed}\n`) + return + } + writeIssueCommand(repo.path, args.content) + } + ) +} diff --git a/src/main/ipc/hooks/register-worktree-hook-inspection-handler.ts b/src/main/ipc/hooks/register-worktree-hook-inspection-handler.ts new file mode 100644 index 00000000000..36dcfbc1288 --- /dev/null +++ b/src/main/ipc/hooks/register-worktree-hook-inspection-handler.ts @@ -0,0 +1,78 @@ +import { ipcMain } from 'electron' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { isFolderRepo } from '../../../shared/repo-kind' +import { inspectSetupScriptImportCandidates } from '../../../shared/setup-script-imports' +import { joinWorktreeRelativePath } from '../../runtime/runtime-relative-paths' +import { getSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' +import { readFile, stat } from 'node:fs/promises' +import { isENOENT } from '../filesystem-path-containment' +import { resolveRepoForExecutionHost } from '../worktrees/repo-host-ownership' +import type { WorktreeIpcContext } from '../worktrees/worktree-ipc-context' + +export function registerWorktreeHookInspectionHandler(context: WorktreeIpcContext): void { + const { store } = context + + ipcMain.handle( + 'hooks:inspectSetupScriptImports', + async (_event, args: { repoId: string; hostId?: ExecutionHostId }) => { + const repo = resolveRepoForExecutionHost(store, args.repoId, args.hostId) + if (!repo || isFolderRepo(repo)) { + return [] + } + + return inspectSetupScriptImportCandidates( + async (relativePath) => { + const filePath = joinWorktreeRelativePath(repo.path, relativePath) + if (repo.connectionId) { + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + return null + } + try { + const result = await fsProvider.readFile(filePath) + return result.isBinary ? null : result.content + } catch { + return null + } + } + + try { + return await readFile(filePath, 'utf-8') + } catch (error) { + if (!isENOENT(error)) { + console.warn('[hooks] Failed to inspect setup script import candidate:', error) + } + return null + } + }, + { + fileExists: async (relativePath) => { + const filePath = joinWorktreeRelativePath(repo.path, relativePath) + if (repo.connectionId) { + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + return false + } + try { + const fileStat = await fsProvider.stat(filePath) + return fileStat.type !== 'directory' + } catch { + return false + } + } + + try { + const fileStat = await stat(filePath) + return !fileStat.isDirectory() + } catch (error) { + if (!isENOENT(error)) { + console.warn('[hooks] Failed to stat setup script import candidate:', error) + } + return false + } + } + } + ) + } + ) +} diff --git a/src/main/ipc/hooks/register-worktree-hook-runner-handler.ts b/src/main/ipc/hooks/register-worktree-hook-runner-handler.ts new file mode 100644 index 00000000000..b6386616bf2 --- /dev/null +++ b/src/main/ipc/hooks/register-worktree-hook-runner-handler.ts @@ -0,0 +1,29 @@ +import { ipcMain } from 'electron' +import { + createIssueCommandRunnerScript, + resolveSetupRunnerShell +} from '../../worktree-runner-script' +import { getLocalProjectWorktreeGitOptions } from '../../project-runtime-git-options' +import type { WorktreeIpcContext } from '../worktrees/worktree-ipc-context' + +export function registerWorktreeHookRunnerHandler(context: WorktreeIpcContext): void { + const { store } = context + + ipcMain.handle( + 'hooks:createIssueCommandRunner', + (_event, args: { repoId: string; worktreePath: string; command: string }) => { + const repo = store.getRepo(args.repoId) + if (!repo) { + throw new Error(`Repo not found: ${args.repoId}`) + } + + return createIssueCommandRunnerScript( + repo, + args.worktreePath, + args.command, + getLocalProjectWorktreeGitOptions(store, repo), + resolveSetupRunnerShell(store.getSettings()) + ) + } + ) +} diff --git a/src/main/ipc/worktrees-create-metadata-persistence.test.ts b/src/main/ipc/worktrees-create-metadata-persistence.test.ts index 798cc443442..7e2ebac49f5 100644 --- a/src/main/ipc/worktrees-create-metadata-persistence.test.ts +++ b/src/main/ipc/worktrees-create-metadata-persistence.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { REVIEW_HEAD_FETCH_TIMEOUT_MS } from '../../shared/review-head-tracking-ref' import { + handleMock, removeHandlerMock, listWorktreesMock, getPullRequestPushTargetMock, @@ -14,6 +15,35 @@ import { } from './worktrees-test-fixtures' import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub' +const WORKTREE_HANDLER_CHANNELS = [ + 'worktrees:listAll', + 'worktrees:list', + 'worktrees:listRetiredNames', + 'worktrees:listDetected', + 'worktrees:listKnownForExecutionHost', + 'worktrees:forgetRemovedForExecutionHost', + 'worktrees:cancelListDetected', + 'worktrees:create', + 'worktrees:adoptProvisionedRoot', + 'worktrees:prefetchCreateBase', + 'worktrees:resolvePrBase', + 'worktrees:resolveMrBase', + 'worktrees:remove', + 'worktrees:forgetLocal', + 'worktrees:forceDeletePreservedBranch', + 'worktrees:updateMeta', + 'worktrees:listLineage', + 'worktrees:listLineageForHost', + 'worktrees:updateLineage', + 'worktrees:persistSortOrder', + 'worktrees:getBranchRenameFailureOutput', + 'hooks:check', + 'hooks:inspectSetupScriptImports', + 'hooks:createIssueCommandRunner', + 'hooks:readIssueCommand', + 'hooks:writeIssueCommand' +] as const + vi.mock('electron', async () => (await import('./worktrees-test-module-mocks')).electronModuleMock() ) @@ -113,6 +143,20 @@ describe('registerWorktreeHandlers', () => { expect(handlers['worktrees:getBranchRenameFailureOutput']).toBeDefined() }) + it('removes exactly the installed channel set before installing the first handler', () => { + const removedChannels = removeHandlerMock.mock.calls.map(([channel]) => channel) + const installedChannels = handleMock.mock.calls.map(([channel]) => channel) + + expect(new Set(removedChannels)).toEqual(new Set(WORKTREE_HANDLER_CHANNELS)) + expect(new Set(removedChannels)).toEqual(new Set(installedChannels)) + expect(new Set(installedChannels)).toEqual(new Set(WORKTREE_HANDLER_CHANNELS)) + expect(removedChannels).toHaveLength(WORKTREE_HANDLER_CHANNELS.length) + expect(installedChannels).toHaveLength(WORKTREE_HANDLER_CHANNELS.length) + expect(Math.max(...removeHandlerMock.mock.invocationCallOrder)).toBeLessThan( + Math.min(...handleMock.mock.invocationCallOrder) + ) + }) + it('persistSortOrder only reorders existing worktrees and never mints meta for a stale id', () => { const liveId = 'repo-1::/workspace/repo' const staleId = 'removed-repo::/workspace/gone' diff --git a/src/main/ipc/worktrees-detected-scan-cache.test.ts b/src/main/ipc/worktrees-detected-scan-cache.test.ts index 6fb798fad3f..3504fd3a0d6 100644 --- a/src/main/ipc/worktrees-detected-scan-cache.test.ts +++ b/src/main/ipc/worktrees-detected-scan-cache.test.ts @@ -3,7 +3,7 @@ import { resolve } from 'node:path' import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types' import { notifyWorktreesChanged } from './worktree-remote' import { resolveRegisteredWorktreePath } from './registered-worktree-roots-cache' -import { __getDetectedWorktreeScanCacheStatsForTests } from './worktrees' +import { __getDetectedWorktreeScanCacheStatsForTests } from './worktrees/listing/detected-worktree-scan-cache' import { setPlatform, listWorktreesMock } from './worktrees-test-module-mocks' import { handlers, mainWindow, setupWorktreeHandlers, store } from './worktrees-test-harness' import { makeWorktreeMeta } from './worktrees-test-fixtures' diff --git a/src/main/ipc/worktrees-forget-local.test.ts b/src/main/ipc/worktrees-forget-local.test.ts index b1616f6eecc..9bace029631 100644 --- a/src/main/ipc/worktrees-forget-local.test.ts +++ b/src/main/ipc/worktrees-forget-local.test.ts @@ -156,6 +156,22 @@ describe('registerWorktreeHandlers', () => { expect(removeWorktreeMock).not.toHaveBeenCalled() }) + it('purges metadata after a rejected best-effort PTY sweep', async () => { + const worktreeId = 'repo-1::/workspace/feature-wt' + killAllProcessesForWorktreeMock.mockRejectedValue(new Error('terminal inventory unavailable')) + + await expect(handlers['worktrees:forgetLocal'](null, { worktreeId })).resolves.toEqual({}) + + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { + repoId: 'repo-1' + }) + expect(store.removeWorktreeMeta.mock.invocationCallOrder[0]).toBeLessThan( + mainWindow.webContents.send.mock.invocationCallOrder[0] + ) + expect(removeWorktreeMock).not.toHaveBeenCalled() + }) + it('sweeps a connected SSH owner through its PTY provider', async () => { const worktreeId = 'repo-gone::/workspace/feature-wt' const sshProvider = {} as never diff --git a/src/main/ipc/worktrees-lineage-hydration.test.ts b/src/main/ipc/worktrees-lineage-hydration.test.ts index cb12b0095f9..e109a13848f 100644 --- a/src/main/ipc/worktrees-lineage-hydration.test.ts +++ b/src/main/ipc/worktrees-lineage-hydration.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Worktree } from '../../shared/worktree/types' import { toSshExecutionHostId } from '../../shared/execution-host' -import { LINEAGE_HYDRATION_TIMEOUT_MS } from './worktrees' +import { LINEAGE_HYDRATION_TIMEOUT_MS } from './worktrees/metadata/host-lineage-listing' import { getSshProviderAuthority, rotateSshProviderAuthority } from '../ssh/ssh-provider-authority' import { listWorktreesMock, getSshGitProviderMock } from './worktrees-test-module-mocks' import { handlers, ipcEvent, setupWorktreeHandlers, store } from './worktrees-test-harness' diff --git a/src/main/ipc/worktrees-removal-recovery.test.ts b/src/main/ipc/worktrees-removal-recovery.test.ts index cc10e1929a1..49dc7b633bd 100644 --- a/src/main/ipc/worktrees-removal-recovery.test.ts +++ b/src/main/ipc/worktrees-removal-recovery.test.ts @@ -137,6 +137,35 @@ describe('registerWorktreeHandlers', () => { ) }) + it('purges only the selected host when a normal worktree id is owned locally and over SSH', async () => { + const worktreeId = 'repo-1::/workspace/feature-wt' + const localRepo = { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'local', + badgeColor: '#000', + addedAt: 0 + } + const sshRepo = { + ...localRepo, + displayName: 'ssh', + connectionId: 'conn-1' + } + store.getRepos.mockReturnValue([localRepo, sshRepo]) + store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ hostId: 'local' })) + mockKnownFeatureWorktree() + removeWorktreeMock.mockResolvedValue({}) + + await handlers['worktrees:remove'](null, { worktreeId, hostId: 'local' }) + + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + expect(advertisedUrlWatcherForgetWorktreeMock).not.toHaveBeenCalled() + expect(deleteWorktreeHistoryDirMock).not.toHaveBeenCalled() + expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { + repoId: 'repo-1' + }) + }) + it('tombstones a cleanup-batch removal without scheduling singular sidecar writes', async () => { mockKnownFeatureWorktree() getEffectiveHooksMock.mockReturnValue(null) @@ -484,6 +513,29 @@ describe('registerWorktreeHandlers', () => { }) }) + it('purges a folder workspace after a rejected best-effort PTY sweep', async () => { + const worktreeId = 'repo-folder::/workspace/folder::workspace:child-1' + store.getRepo.mockReturnValue({ + id: 'repo-folder', + path: '/workspace/folder', + displayName: 'folder', + badgeColor: '#000', + addedAt: 0, + kind: 'folder' + }) + killAllProcessesForWorktreeMock.mockRejectedValue(new Error('terminal inventory unavailable')) + + await handlers['worktrees:remove'](null, { worktreeId }) + + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { + repoId: 'repo-folder' + }) + expect(store.removeWorktreeMeta.mock.invocationCallOrder[0]).toBeLessThan( + mainWindow.webContents.send.mock.invocationCallOrder[0] + ) + }) + // Folder projects can be SSH-backed, and folder workspace ids are `repoId::path::workspace:` // — reusable across hosts — so the sweep must name the owning connection. it('fences an SSH folder workspace PTY sweep to the owning connection', async () => { diff --git a/src/main/ipc/worktrees-ssh-provider-authority.test.ts b/src/main/ipc/worktrees-ssh-provider-authority.test.ts index c801d0498f3..8c2d6b0edd7 100644 --- a/src/main/ipc/worktrees-ssh-provider-authority.test.ts +++ b/src/main/ipc/worktrees-ssh-provider-authority.test.ts @@ -3,7 +3,7 @@ import type { Repo } from '../../shared/repo-types' import type { GitWorktreeInfo } from '../../shared/worktree/types' import type { ProviderRequestId } from '../../shared/detected-worktree-provider-contract' import { toSshExecutionHostId } from '../../shared/execution-host' -import { DETECTED_WORKTREE_PROVIDER_TIMEOUT_MS } from './worktrees' +import { DETECTED_WORKTREE_PROVIDER_TIMEOUT_MS } from './worktrees/listing/register-detected-worktree-handlers' import { getSshProviderAuthority, rotateSshProviderAuthority } from '../ssh/ssh-provider-authority' import { getSshGitProviderMock } from './worktrees-test-module-mocks' import { handlers, ipcEvent, setupWorktreeHandlers, store } from './worktrees-test-harness' diff --git a/src/main/ipc/worktrees-test-harness.ts b/src/main/ipc/worktrees-test-harness.ts index de0d3a3ed5d..4e1c524fa67 100644 --- a/src/main/ipc/worktrees-test-harness.ts +++ b/src/main/ipc/worktrees-test-harness.ts @@ -1,6 +1,7 @@ import { __resetSshWorktreeCreateFetchCacheForTests } from './worktree-remote' import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache' -import { __resetDetectedWorktreeScanCacheForTests, registerWorktreeHandlers } from './worktrees' +import { registerWorktreeHandlers } from './worktrees' +import { __resetDetectedWorktreeScanCacheForTests } from './worktrees/listing/detected-worktree-scan-cache' import { clearConfiguredWorktreeSharedDirectoriesCacheForTests } from '../git/worktree-shared-directories' import { resetRetirementCollisionKeyCacheForTests } from '../worktree-name-retirement' import { resetSshProviderAuthorities } from '../ssh/ssh-provider-authority' diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 4b4d009024f..5e70faa1871 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -1,1848 +1,53 @@ -/* oxlint-disable max-lines */ -import { app, ipcMain, type BrowserWindow } from 'electron' -import { readFile, stat } from 'node:fs/promises' -import { randomUUID } from 'node:crypto' -import type { Store } from '../persistence' -import { pruneLineageForMissingRepoWorktrees } from '../worktree-lineage-pruning' -import { isFolderRepo } from '../../shared/repo-kind' -import { readBranchRenameFailureOutputForDisplay } from '../agent-hooks/branch-rename-failure-output' -import { parseWorkspaceKey } from '../../shared/workspace-scope' -import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-imports' -import { planWorktreeSortOrderUpdates } from '../../shared/worktree/sort-order-update' -import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-projection' -import { TaskSourceContextSchema } from '../../shared/task-source-context-schema' -import { WorkspaceLinkedItemSchema } from '../../shared/workspace-linked-item-schema' -import { isWorkspaceLinkedItemSourceContextMatch } from '../../shared/workspace-linked-item-source-context' -import { getProjectGroupSubtreeIds } from '../../shared/project-groups' -import { projectResolvedWorktreeLineage } from '../../shared/resolved-worktree-lineage' -import { isPathInsideOrEqual, isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' -import { deleteWorktreeHistoryDir } from '../terminal-history-deletion' -import { - pruneWorkspaceCleanupScanSnapshot, - pruneWorkspaceCleanupScanSnapshots -} from '../workspace-cleanup-scan-snapshot' -import { - pruneWorkspaceSpaceAnalysisSnapshot, - pruneWorkspaceSpaceAnalysisSnapshots -} from '../workspace-space-analysis-snapshot' -import { recordWorkspaceCleanupRemovalSnapshotPrune } from '../workspace-cleanup-removal-snapshot-prune' -import type { OrcaHooks } from '../../shared/orca-yaml-hook-types' -import type { Repo } from '../../shared/repo-types' -import type { - AdoptProvisionedRootArgs, - CreateWorktreeArgs, - CreateWorktreeResult, - ForceDeleteWorktreeBranchResult, - RemoveWorktreeResult -} from '../../shared/worktree/create-types' -import type { WorkspaceLineage, WorktreeLineage } from '../../shared/worktree/lineage-types' -import type { WorktreeMeta } from '../../shared/worktree/meta-types' -import type { - AutomationWorkspaceProvenance, - CliWorkspaceProvenance, - DetectedWorktree, - DetectedWorktreeListResult, - GitHubPrStartPoint, - GitPushTarget, - GitWorktreeInfo, - Worktree -} from '../../shared/worktree/types' -import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree/removal' -import { - getRepoExecutionHostId, - LOCAL_EXECUTION_HOST_ID, - parseExecutionHostId, - toSshExecutionHostId, - type ExecutionHostId -} from '../../shared/execution-host' -import { - PROVIDER_REQUEST_ID_MAX_UTF8_BYTES, - type DirectSshDetectedWorktreeRequest, - type ForgetRemovedWorktreesForExecutionHostArgs, - type ForgetRemovedWorktreesForExecutionHostResult, - type HostQualifiedKnownWorktreeResult, - type HostQualifiedDetectedWorktreeResult, - type ListKnownWorktreesForExecutionHostArgs, - type ListDetectedWorktreesArgs, - type ProviderRequestId -} from '../../shared/detected-worktree-provider-contract' -import type { - HostLineageSnapshot, - ListDesktopLineageForHostArgs -} from '../../shared/host-lineage-contract' -import { isAdmissibleDirectSshAuthority } from '../../shared/ssh-retained-payload-admission' -import { - applyMetadataFallbackVisibility, - buildKnownOrcaWorkspaceLayouts, - isLegacyRepoForExternalWorktreeVisibility, - toDetectedWorktree -} from '../../shared/worktree/ownership' -import { - createWorktreeVisibilitySourceMatcher, - resolveCustomWorktreeVisibilitySources -} from '../../shared/worktree/visibility-sources' -import { resolveConfiguredWorktreeBasePaths } from '../../shared/worktree/configured-worktree-base-path' -import { - assertWorktreeCleanForRemoval, - forceDeleteLocalBranch, - listWorktreesStrict as listGitWorktreesStrict, - removeWorktree -} from '../git/worktree' -import { gitExecFileAsync } from '../git/runner' -import { withWorktreeRemoveStageSpan, withWorktreeSpan } from '../observability/instrumentation' -import { resolveGitHubPrStartPoint } from '../github/pr-start-point' -import { - fetchGitHubPullRequestHeadRef, - fetchPrHeadTrackingRef -} from '../github/pr-head-tracking-ref' -import { pruneWorktreePRRefreshAliases } from '../github/pr-refresh-coordinator' -import { resolveGitHubReviewHeadRemote } from '../github/review-head-remote' -import { listRepoWorktrees } from '../repo-worktrees' -import { getSshGitProvider, requireSshGitProvider } from '../providers/ssh-git-dispatch' -import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' -import { - getEffectiveHooks, - loadHooks, - parseOrcaYaml, - runHook, - hasHooksFile, - hasUnrecognizedOrcaYamlKeys -} from '../hooks' -import { createIssueCommandRunnerScript, resolveSetupRunnerShell } from '../worktree-runner-script' -import { getSetupRunnerEnvVars } from '../setup-hook-env-vars' -import { getEffectiveHooksFromConfig } from '../effective-hook-config' -import { readIssueCommand, writeIssueCommand } from '../issue-command-file' -import { - mergeWorktree, - parseWorktreeId, - areWorktreePathsEqual, - formatWorktreeRemovalError, - isOrphanCompatiblePreflightError, - isOrphanedWorktreeError -} from './worktree-logic' -import { getRetiredNameRegistryForRepo } from '../worktree-name-retirement' -import { EMPTY_RETIRED_NAME_REGISTRY } from '../../shared/worktree/retired-name-registry' -import { dedupeWorktreesByPath } from './worktree-path-comparison' -import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths' -import { - createLocalWorktree, - createRemoteWorktree, - cleanupUnusedWorktreePushTargetRemote, - cleanupUnusedWorktreePushTargetRemoteSsh, - notifyWorktreesChanged -} from './worktree-remote' -import { registerWorktreeChangeInvalidator } from './worktree-change-invalidators' -import { isENOENT } from './filesystem-path-containment' -import { - invalidateAuthorizedRootsCache, - registerWorktreeRootsForRepo -} from './registered-worktree-roots-cache' +import { ipcMain, type BrowserWindow } from 'electron' +import type { Store } from '../persistence/loading-store/store' import type { OrcaRuntimeService, RuntimeWorktreeLifecycleEvent } from '../runtime/orca-runtime' -import { killAllProcessesForWorktree } from '../runtime/worktree-teardown' -import { clearProviderPtyState, getLocalPtyProvider, getSshPtyProvider } from './pty' -import { findExistingWorktreeSymlinkPaths, removeWorktreeLinkedPaths } from './worktree-symlinks' -import { getWorktreeSharedLinkPaths } from '../git/worktree-shared-directories' -import { track } from '../telemetry/client' -import { getCohortAtEmit } from '../telemetry/cohort-classifier' -import { workspaceSourceSchema, type WorkspaceSource } from '../../shared/telemetry-events' -import { - finishAutomationWorkspaceProvenanceRequest, - releaseAutomationWorkspaceProvenanceRequest, - resolveAutomationWorkspaceProvenance -} from '../automations/workspace-provenance' -import { shouldEmitBoundedWarning } from './bounded-warning-dedupe' -import { - getSshProviderAuthority, - isCurrentSshProviderAuthority, - registerSshProviderRequestAbort -} from '../ssh/ssh-provider-authority' import { createSenderScopedRequestCancellations } from './sender-scoped-request-cancellation' -import { preservedBranchCleanupScopeKey } from '../../shared/preserved-branch-cleanup' -import { adoptProvisionedRootSshCheckout } from '../provisioned-root-ssh-adoption' - -type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & { - automationProvenance?: AutomationWorkspaceProvenance - cliProvenance?: CliWorkspaceProvenance -} - -type RemoveWorktreeArgs = { - worktreeId: string - hostId?: ExecutionHostId - force?: boolean - /** Explicit Force Delete only — `force` alone is set by the ordinary confirmation (#11960). */ - allowUnverifiedPtyStop?: boolean - skipArchive?: boolean - snapshotPruneBatchId?: string -} - -type DetectedWorktreeRequestArgs = { repoId: string } | ListDetectedWorktreesArgs - -async function stopPtysForDestructiveWorktreeRemoval( - runtime: OrcaRuntimeService, - worktreeId: string, - options: { connectionId?: string; allowUnverifiedStop?: boolean } = {} -): Promise { - const { connectionId, allowUnverifiedStop } = options - const provider = connectionId ? getSshPtyProvider(connectionId) : getLocalPtyProvider() - if (!provider) { - throw new Error(`PTY provider unavailable for worktree deletion: ${worktreeId}`) - } - const teardownResult = await killAllProcessesForWorktree(worktreeId, { - runtime, - // Why: `repoId::path` ids repeat across hosts, so an unfenced sweep stops a same-id - // workspace's terminals on another connection — and the selector lookup this replaces - // throws `selector_ambiguous` the moment two hosts own the id. - resolvedWorktreeId: worktreeId, - ...(connectionId ? { resolvedConnectionId: connectionId } : {}), - localProvider: provider, - onPtyStopped: clearProviderPtyState, - requirePhysicalStop: true, - // Why (#11960): set only by an explicit Force Delete, never by the ordinary - // confirmation — otherwise the gate would be off on the primary delete path. - ...(allowUnverifiedStop ? { allowUnverifiedStop: true } : {}), - ...(connectionId ? { includeLocalRegistry: false } : {}) - }) - const total = - teardownResult.runtimeStopped + teardownResult.providerStopped + teardownResult.registryStopped - if (total > 0) { - console.info( - `[worktree-teardown] ${worktreeId} killed runtime=${teardownResult.runtimeStopped} provider=${teardownResult.providerStopped} registry=${teardownResult.registryStopped}` - ) - } -} - -function getRepoForWorktreeRemoval( - store: Store, - repoId: string, - hostId?: ExecutionHostId -): Repo | undefined { - // Why: deletion must never guess between host owners; legacy unscoped calls work only while the repo id has one unique owner. - const owner = resolveWorktreeRemovalRepoOwner(store, repoId, hostId) - return owner.kind === 'resolved' ? owner.repo : undefined -} -import { - hasWorktreeRemovalRepoOwnerOnOtherHost, - resolveWorktreeRemovalMetadata, - resolveWorktreeRemovalRepoOwner -} from '../worktree-removal-repo-owner' -import { classifyWorkspaceCreateError } from './workspace-create-error-classifier' -import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' -import { localhostWorktreeLabelProxy } from '../localhost-worktree-label-proxy' -import { - assertWorktreeDoesNotContainRegisteredWorktree, - canCleanupUnregisteredOrcaLeftoverDirectory, - canCleanupUnregisteredOrcaWorktreeDirectory, - canSafelyRemoveOrphanedWorktreeDirectory, - findRegisteredDeletableWorktree, - isDangerousWorktreeRemovalPath, - isWorktreePathMissing, - ORPHANED_WORKTREE_DIRECTORY_MESSAGE, - stripOrcaProvenanceMetaUpdates, - UNREGISTERED_MISSING_WORKTREE_MESSAGE -} from '../worktree-removal-safety' -import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' -import { - FOLDER_WORKSPACE_INSTANCE_SEPARATOR, - getRepoIdFromWorktreeId, - getWorktreePathBasenameFromId -} from '../../shared/worktree/id' -import { prefetchWorktreeCreateBase } from '../worktree-create-base-prefetch' -import { - getLocalProjectGitExecOptions, - getLocalProjectWorktreeGitOptions -} from '../project-runtime-git-options' -import { - getLocalWorktreePathAccess, - removeLocalWorktreePath, - toLocalWorktreeRuntimePath -} from '../local-worktree-filesystem' -import { - removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval, - recoverLocalWindowsWorktreeRemoval -} from '../local-worktree-removal-recovery' -import { deleteRemoteWorktreeHistory } from '../remote-worktree-history-cleanup' - -const NullableWorkspaceLinkedItemSchema = WorkspaceLinkedItemSchema.nullable() -const NullableTaskSourceContextSchema = TaskSourceContextSchema.nullable() -const WORKTREE_ARCHIVE_HOOK_TIMEOUT_MS = 120_000 -const WORKTREE_LIST_ALL_CONCURRENCY = 8 - -function normalizeLinkedWorkItemFields< - T extends { - linkedWorkItem?: unknown - linkedTaskSourceContext?: unknown - } ->(input: T): T { - const linkedWorkItem = - input.linkedWorkItem === undefined - ? undefined - : NullableWorkspaceLinkedItemSchema.parse(input.linkedWorkItem) - const linkedTaskSourceContext = - input.linkedTaskSourceContext === undefined - ? undefined - : NullableTaskSourceContextSchema.parse(input.linkedTaskSourceContext) - if ( - linkedWorkItem && - linkedTaskSourceContext && - !isWorkspaceLinkedItemSourceContextMatch(linkedWorkItem, linkedTaskSourceContext) - ) { - throw new Error('Linked work item and source context identities must match') - } - return { - ...input, - ...(linkedWorkItem !== undefined ? { linkedWorkItem } : {}), - ...(linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext } : {}) - } -} - -async function mapWithConcurrency( - items: readonly T[], - limit: number, - fn: (item: T) => Promise -): Promise { - const results: R[] = [] - let nextIndex = 0 - const workerCount = Math.min(limit, items.length) - await Promise.all( - Array.from({ length: workerCount }, async () => { - while (nextIndex < items.length) { - const index = nextIndex - nextIndex += 1 - results[index] = await fn(items[index]) - } - }) - ) - return results -} - -// Why: the worktree's own persisted host outranks the repo fallback; teardown and metadata purge must resolve the same owner -// or the purge lands on the local partition while the SSH/runtime one keeps the workspace's tabs forever. -function resolveWorktreeRemovalOwnerHostId( - store: Store, - worktreeId: string, - repo: Repo | undefined, - fallbackHostId?: ExecutionHostId -): ExecutionHostId | undefined { - return ( - fallbackHostId ?? - (repo ? getRepoExecutionHostId(repo) : store.getWorktreeMeta(worktreeId)?.hostId) - ) -} - -function removeWorktreeMetadataAndTransientState( - store: Store, - worktreeId: string, - hostId?: ExecutionHostId, - snapshotPruneBatchId?: string -): void { - const persistedHostId = store.getWorktreeMeta(worktreeId)?.hostId - const repoId = getRepoIdFromWorktreeId(worktreeId) - const preservesSameIdOwner = Boolean( - hostId && - ((persistedHostId && persistedHostId !== hostId) || - hasWorktreeRemovalRepoOwnerOnOtherHost(store, repoId, hostId)) - ) - // Why: worktree IDs are path-derived and reusable; drop process-local caches before the same ID can map to a new workspace. - if (hostId) { - store.removeWorktreeMeta(worktreeId, hostId) - } else { - store.removeWorktreeMeta(worktreeId) - } - if (!preservesSameIdOwner) { - advertisedUrlWatcher.forgetWorktree(worktreeId) - // Why: drop this worktree's localhost label routes so they don't accumulate in the proxy's route maps all session. - localhostWorktreeLabelProxy.unregisterWorktree(worktreeId) - // Why: schedule async history tree removal — never recursive-rmSync on the delete critical path. - deleteWorktreeHistoryDir(worktreeId) - // Why: release the removed worktree's PR-refresh aliases so coalesced queue entries don't retain it all session (memory creep). - pruneWorktreePRRefreshAliases(worktreeId) - } - // Why: removed workspaces must never resurrect from the persisted cleanup/space scan snapshots. - const snapshotDirectory = store.getProfileStorageDirectory() - if (snapshotPruneBatchId) { - recordWorkspaceCleanupRemovalSnapshotPrune(snapshotDirectory, { - batchId: snapshotPruneBatchId, - worktreeId, - executionHostId: hostId - }) - return - } - void pruneWorkspaceCleanupScanSnapshot(snapshotDirectory, worktreeId, hostId) - void pruneWorkspaceSpaceAnalysisSnapshot(snapshotDirectory, worktreeId, hostId) -} - -function getProjectHostSetupMetaUpdates( - store: Store, - repo: Repo, - existing?: WorktreeMeta -): Partial> { - const ownership = getProjectHostSetupWorktreeMeta(store.getProjectHostSetups(), repo) - const sameSetup = - existing?.projectHostSetupId === undefined || - existing.projectHostSetupId === ownership.projectHostSetupId - return { - // Why: project IDs can upgrade from legacy repo IDs to provider-backed ones; repair ownership on discovery when the host setup matches. - ...(sameSetup && existing?.projectId !== ownership.projectId - ? { projectId: ownership.projectId } - : {}), - ...(sameSetup && existing?.hostId === undefined ? { hostId: ownership.hostId } : {}), - ...(existing?.projectHostSetupId === undefined - ? { projectHostSetupId: ownership.projectHostSetupId } - : {}) - } -} - -// Why: disk-discovered worktrees have no WorktreeMeta, so lastActivityAt=0 sinks them to the bottom of "Recent"; also backfill host-setup ownership here. -function resolveWorktreeMetaWithDiscoveryBackfill( - store: Store, - repo: Repo, - worktreeId: string -): WorktreeMeta { - const existing = store.getWorktreeMeta(worktreeId) - const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, existing) - if (existing) { - const updates = { - ...(!existing.instanceId ? { instanceId: randomUUID() } : {}), - ...ownershipUpdates - } - if (Object.keys(updates).length > 0) { - // Why: pre-lineage profiles already have WorktreeMeta rows; backfill on discovery so upgraded workspaces get lineage and host routing. - return store.setWorktreeMeta(worktreeId, updates) - } - return existing - } - return store.setWorktreeMeta(worktreeId, { - lastActivityAt: Date.now(), - ...ownershipUpdates - }) -} - -async function isAlreadyRemovedWorktreePath( - repo: Repo, - worktreePath: string, - localWorktreeGitOptions: { wslDistro?: string } = {} -): Promise { - if (!repo.connectionId) { - const access = getLocalWorktreePathAccess(localWorktreeGitOptions) - return isWorktreePathMissing( - toLocalWorktreeRuntimePath(worktreePath, localWorktreeGitOptions), - access.statPath - ) - } - - const fsProvider = getSshFilesystemProvider(repo.connectionId) - if (!fsProvider) { - return false - } - return isWorktreePathMissing(worktreePath, (path) => fsProvider.stat(path)) -} - -async function isLocalGitRepository( - runtimeWorktreePath: string, - localWorktreeGitOptions: { wslDistro?: string } = {} -): Promise { - try { - await gitExecFileAsync(['status', '--short'], { - cwd: runtimeWorktreePath, - ...localWorktreeGitOptions - }) - return true - } catch (error) { - return !gitStatusErrorMeansNotRepository(error) - } -} - -function gitStatusErrorMeansNotRepository(error: unknown): boolean { - const message = - error instanceof Error - ? error.message - : error && typeof error === 'object' && 'message' in error - ? String((error as { message: unknown }).message) - : typeof error === 'string' - ? error - : '' - const stderr = - error && typeof error === 'object' && 'stderr' in error - ? String((error as { stderr: unknown }).stderr) - : '' - return /not a git repository/i.test(`${message}\n${stderr}`) -} - -function getWorktreeRemovalOptionsKey(args: { - force?: boolean - allowUnverifiedPtyStop?: boolean - skipArchive?: boolean -}): string { - const forceKey = args.force === true ? 'force' : 'normal' - const archiveKey = args.skipArchive === true ? 'skip-archive' : 'run-archive' - // Why: a Force Delete retry must not coalesce onto the in-flight attempt that - // just failed the PTY gate — it would inherit that failure instead of retrying. - const ptyKey = args.allowUnverifiedPtyStop === true ? 'allow-unverified-pty' : 'require-pty-stop' - return `${forceKey}:${archiveKey}:${ptyKey}` -} - -function getWorktreeRemovalInFlightKey(worktreeId: string, hostId?: ExecutionHostId): string { - return `${hostId ?? ''}\0${worktreeId}` -} - -async function getArchiveHooksForRemoval(repo: Repo): Promise { - if (!repo.connectionId) { - return getEffectiveHooks(repo) - } - - const fsProvider = getSshFilesystemProvider(repo.connectionId) - if (!fsProvider) { - return getEffectiveHooksFromConfig(repo, null) - } - - try { - const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) - const yamlHooks = result.isBinary ? null : parseOrcaYaml(result.content) - return getEffectiveHooksFromConfig(repo, yamlHooks) - } catch { - return getEffectiveHooksFromConfig(repo, null) - } -} - -async function runRemoteArchiveHook( - repo: Repo, - worktreePath: string, - script: string -): Promise<{ success: boolean; output: string }> { - if (!repo.connectionId) { - return { success: true, output: '' } - } - - const provider = requireSshGitProvider(repo.connectionId) - const env = getSetupRunnerEnvVars(repo, worktreePath) - const isWindowsRemote = isWindowsAbsolutePathLike(worktreePath) - const result = await provider - .execNonInteractive( - isWindowsRemote ? 'cmd.exe' : '/bin/bash', - isWindowsRemote ? ['/d', '/s', '/c', script] : ['-lc', script], - worktreePath, - WORKTREE_ARCHIVE_HOOK_TIMEOUT_MS, - undefined, - env - ) - .catch((error) => ({ - stdout: '', - stderr: '', - exitCode: null, - timedOut: false, - spawnError: error instanceof Error ? error.message : String(error) - })) - const output = [ - result.stdout, - result.stderr, - result.spawnError, - result.timedOut ? 'archive hook timed out' : null, - typeof result.exitCode === 'number' && result.exitCode !== 0 - ? `archive hook exited ${result.exitCode}` - : null - ] - .filter((part): part is string => Boolean(part)) - .join('\n') - .trim() - - return { - success: !result.spawnError && !result.timedOut && result.exitCode === 0, - output - } -} - -type WorktreeRemovalInFlight = { - optionsKey: string - promise: Promise -} - -type PreservedBranchCleanupTarget = { - worktreeId: string - hostId: ExecutionHostId - branchName: string - head: string - pushTarget?: GitPushTarget -} - -const preservedBranchCleanupByScope = new Map() - -function rememberPreservedBranchCleanupTarget( - worktreeId: string, - hostId: ExecutionHostId, - result: RemoveWorktreeResult | undefined, - fallbackHead: string | undefined, - pushTarget: GitPushTarget | undefined -): void { - if (result?.preservedBranch) { - const head = result.preservedBranch.head ?? fallbackHead - if (!head) { - throw new Error( - `Cannot safely offer force-delete for preserved branch "${result.preservedBranch.branchName}" without its saved commit.` - ) - } - preservedBranchCleanupByScope.set(preservedBranchCleanupScopeKey({ worktreeId, hostId }), { - worktreeId, - hostId, - branchName: result.preservedBranch.branchName, - head, - ...(pushTarget ? { pushTarget } : {}) - }) - return - } - preservedBranchCleanupByScope.delete(preservedBranchCleanupScopeKey({ worktreeId, hostId })) -} - -function preserveBranchHeadFallback( - result: RemoveWorktreeResult | undefined, - fallbackHead: string | undefined -): RemoveWorktreeResult { - if (!result?.preservedBranch || result.preservedBranch.head || !fallbackHead) { - return result ?? {} - } - return { - ...result, - preservedBranch: { - ...result.preservedBranch, - head: fallbackHead - } - } -} - -function getPreservedBranchCleanupTarget( - worktreeId: string, - branchName: string, - expectedHead: string, - hostId?: ExecutionHostId -): PreservedBranchCleanupTarget { - const exactTarget = hostId - ? preservedBranchCleanupByScope.get(preservedBranchCleanupScopeKey({ worktreeId, hostId })) - : undefined - const legacyMatches = hostId - ? [] - : [...preservedBranchCleanupByScope.values()].filter( - (target) => - target.worktreeId === worktreeId && - target.branchName === branchName && - target.head === expectedHead - ) - const target = exactTarget ?? (legacyMatches.length === 1 ? legacyMatches[0] : undefined) - if (!target || target.branchName !== branchName || target.head !== expectedHead) { - throw new Error(`No preserved branch cleanup is pending for "${branchName}".`) - } - return target -} - -const loggedUnavailableSshGitProviders = new Set() -const loggedWorktreeListFailures = new Set() -const loggedMalformedWorktreeMetaKeys = new Set() -export const DETECTED_WORKTREE_PROVIDER_TIMEOUT_MS = 30_000 -export const LINEAGE_HYDRATION_TIMEOUT_MS = 5_000 -// Why: absorb renderer polling bursts while bounding external worktree-change lag to one short refresh window. -const DETECTED_WORKTREE_SCAN_CACHE_TTL_MS = 5_000 - -type DetectedWorktreeScanCacheEntry = { - expiresAt: number - worktrees: GitWorktreeInfo[] -} - -type DetectedWorktreeScan = { - invalidated: boolean - promise: Promise -} - -type DetectedWorktreeScanResult = { - gitWorktrees: GitWorktreeInfo[] - fresh: boolean -} - -const detectedWorktreeScanCache = new Map() -const detectedWorktreeScanInFlight = new Map() - -function invalidateDetectedWorktreeScanCache(repoId: string): void { - const keyPrefix = `${repoId}\0` - for (const key of new Set([ - ...detectedWorktreeScanCache.keys(), - ...detectedWorktreeScanInFlight.keys() - ])) { - if (!key.startsWith(keyPrefix)) { - continue - } - detectedWorktreeScanCache.delete(key) - const inFlight = detectedWorktreeScanInFlight.get(key) - if (inFlight) { - // Why: the detached scan keeps this token so later scans settle without making an older result fresh again. - inFlight.invalidated = true - detectedWorktreeScanInFlight.delete(key) - } - } -} - -registerWorktreeChangeInvalidator(invalidateDetectedWorktreeScanCache) - -export function __resetDetectedWorktreeScanCacheForTests(): void { - // Why: pending scans across a test reset must not repopulate the cache and leak state into the next test. - for (const scan of detectedWorktreeScanInFlight.values()) { - scan.invalidated = true - } - detectedWorktreeScanCache.clear() - detectedWorktreeScanInFlight.clear() -} - -export function __getDetectedWorktreeScanCacheStatsForTests(): { - cacheSize: number - inFlightSize: number -} { - return { - cacheSize: detectedWorktreeScanCache.size, - inFlightSize: detectedWorktreeScanInFlight.size - } -} - -async function listDetectedGitWorktrees( - store: Store, - repo: Repo -): Promise { - const localWorktreeGitOptions = getLocalProjectWorktreeGitOptions(store, repo) - if (repo.connectionId || isFolderRepo(repo)) { - return { - gitWorktrees: await listRepoWorktrees(repo, localWorktreeGitOptions), - fresh: true - } - } - - const cacheKey = getDetectedWorktreeScanCacheKey(repo.id, localWorktreeGitOptions) - const cached = detectedWorktreeScanCache.get(cacheKey) - if (cached && cached.expiresAt > Date.now()) { - return { gitWorktrees: cached.worktrees, fresh: false } - } - - const inFlight = detectedWorktreeScanInFlight.get(cacheKey) - if (inFlight) { - return { gitWorktrees: await inFlight.promise, fresh: false } - } - - const scan: DetectedWorktreeScan = { - invalidated: false, - promise: listRepoWorktrees(repo, localWorktreeGitOptions) - } - detectedWorktreeScanInFlight.set(cacheKey, scan) - try { - const gitWorktrees = await scan.promise - // Why: a create/remove notification can invalidate mid-scan; don't let that stale scan repopulate the cache afterward. - if (!scan.invalidated) { - detectedWorktreeScanCache.set(cacheKey, { - worktrees: gitWorktrees, - expiresAt: Date.now() + DETECTED_WORKTREE_SCAN_CACHE_TTL_MS - }) - } - return { gitWorktrees, fresh: !scan.invalidated } - } finally { - if (detectedWorktreeScanInFlight.get(cacheKey) === scan) { - detectedWorktreeScanInFlight.delete(cacheKey) - } - } -} - -function getDetectedWorktreeScanCacheKey( - repoId: string, - localWorktreeGitOptions: { wslDistro?: string } = {} -): string { - return `${repoId}\0${localWorktreeGitOptions.wslDistro ?? 'host'}` -} - -function warnOnce(keySet: Set, key: string, message: string, error?: unknown): void { - if (!shouldEmitBoundedWarning(keySet, key)) { - return - } - if (error) { - console.warn(message, error) - } else { - console.warn(message) - } -} - -function rememberLocalWorktreeRoots( - store: Store, - repo: Repo, - gitWorktrees: GitWorktreeInfo[] -): void { - if (repo.connectionId) { - return - } - // Why: reuse the `git worktree list` result so later git/file IPC validation skips a second scan that can trigger macOS folder-permission prompts. - registerWorktreeRootsForRepo(store, repo.id, [ - repo.path, - ...gitWorktrees.map((worktree) => worktree.path) - ]) -} - -type SshWorktreeMetaCandidate = { - id: string - path: string - meta: WorktreeMeta -} - -type SshWorktreeMetaIndex = Map - -function createSshWorktreeMetaIndex(entries: [string, WorktreeMeta][]): SshWorktreeMetaIndex { - const index: SshWorktreeMetaIndex = new Map() - for (const [worktreeId, meta] of entries) { - let parsed: { repoId: string; worktreePath: string } - try { - parsed = parseWorktreeId(worktreeId) - } catch (err) { - warnOnce( - loggedMalformedWorktreeMetaKeys, - worktreeId, - `[worktrees] ignoring malformed persisted worktree metadata key "${worktreeId}"`, - err - ) - continue - } - - const candidates = index.get(parsed.repoId) ?? [] - candidates.push({ id: worktreeId, path: parsed.worktreePath, meta }) - index.set(parsed.repoId, candidates) - } - return index -} - -// Why: scopes parseWorktreeId to one repo's keys. The entry list itself is still materialized for the whole -// store, so this is cheaper per call than the unfiltered index, not free. -function createSshWorktreeMetaIndexForRepo( - allMeta: Record, - repoId: string -): SshWorktreeMetaIndex { - return createSshWorktreeMetaIndex( - Object.entries(allMeta).filter(([worktreeId]) => getRepoIdFromWorktreeId(worktreeId) === repoId) - ) -} - -function synthesizeSshGitWorktree(repo: Repo, path: string, meta: WorktreeMeta): GitWorktreeInfo { - return { - path, - head: '', - branch: '', - isBare: false, - isMainWorktree: areWorktreePathsEqual(path, repo.path), - ...(meta.sparseDirectories !== undefined || - meta.sparseBaseRef !== undefined || - meta.sparsePresetId !== undefined - ? { isSparse: true } - : {}) - } -} - -function listDisconnectedSshWorktrees( - store: Store, - repo: Repo, - metaIndex: SshWorktreeMetaIndex -): ReturnType[] { - const byWorktreeId = new Map>() - const expectedHostId = getRepoExecutionHostId(repo) - const repoOwners = store.getRepos().filter((candidate) => candidate.id === repo.id) - for (const candidate of metaIndex.get(repo.id) ?? []) { - if ( - (candidate.meta.hostId && candidate.meta.hostId !== expectedHostId) || - (!candidate.meta.hostId && repoOwners.length > 1) - ) { - continue - } - const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, candidate.meta) - const meta = - Object.keys(ownershipUpdates).length > 0 - ? { ...candidate.meta, ...ownershipUpdates } - : candidate.meta - if (Object.keys(ownershipUpdates).length > 0) { - store.setWorktreeMeta(candidate.id, ownershipUpdates) - } - // Why: synthesized rows carry no branch, so the title would fall through to the DESKTOP's basename() - // applied to a REMOTE path — a Windows remote then renders its whole C:\... path as the name. Rows must - // stay per-directory (repo.displayName would title every row identically), so use the separator-agnostic - // basename instead. - const worktree = mergeWorktree( - repo.id, - synthesizeSshGitWorktree(repo, candidate.path, meta), - meta, - getWorktreePathBasenameFromId(candidate.id) ?? undefined - ) - byWorktreeId.delete(worktree.id) - byWorktreeId.set(worktree.id, worktree) - } - return [...byWorktreeId.values()] -} - -function buildDetectedGitWorktrees( - store: Store, - repo: Repo, - gitWorktrees: GitWorktreeInfo[] -): DetectedWorktree[] { - const settings = store.getSettings() - const knownOrcaLayouts = buildKnownOrcaWorkspaceLayouts(settings, repo) - const isLegacyRepoForVisibility = isLegacyRepoForExternalWorktreeVisibility(repo) - // Why: a prunable registration has no working directory (issue #8389); only this listing omits it — cleanup flows list separately. - const liveWorktrees = dedupeWorktreesByPath( - gitWorktrees.filter((gitWorktree) => !gitWorktree.prunable) - ) - const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher( - [repo.path, ...liveWorktrees.map((worktree) => worktree.path)], - resolveCustomWorktreeVisibilitySources(repo, settings.worktreeVisibilityDefaults), - resolveConfiguredWorktreeBasePaths(repo) - ) - const detected = liveWorktrees.map((gitWorktree) => { - const worktreeId = `${repo.id}::${gitWorktree.path}` - let meta = store.getWorktreeMeta(worktreeId) - const worktree = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) - const detected = toDetectedWorktree({ - repo, - worktree, - meta, - settings, - knownOrcaLayouts, - isLegacyRepoForVisibility, - worktreeVisibilitySourceMatcher - }) - if (!detected.visible) { - return detected - } - - meta = resolveWorktreeMetaWithDiscoveryBackfill(store, repo, worktreeId) - return toDetectedWorktree({ - repo, - worktree: mergeWorktree(repo.id, gitWorktree, meta, repo.displayName), - meta, - settings, - knownOrcaLayouts, - isLegacyRepoForVisibility, - worktreeVisibilitySourceMatcher - }) - }) - return projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {}) -} - -function stampAndMergeVisibleDetectedWorktree( - store: Store, - repo: Repo, - detected: DetectedWorktree -) { - const meta = resolveWorktreeMetaWithDiscoveryBackfill(store, repo, detected.id) - return mergeWorktree(repo.id, detected, meta, repo.displayName) -} - -function getFolderWorkspaceRootId(repo: Repo): string { - return `${repo.id}::${repo.path}` -} - -function getFolderWorkspaceInstanceId(repo: Repo, instanceId: string): string { - return `${getFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}${instanceId}` -} - -function getFolderWorkspaceInstanceIdentity(repo: Repo, worktreeId: string): string { - const prefix = `${getFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}` - return worktreeId.startsWith(prefix) ? worktreeId.slice(prefix.length) : randomUUID() -} - -function isFolderWorkspaceIdForRepo(repo: Repo, worktreeId: string): boolean { - const rootId = getFolderWorkspaceRootId(repo) - return ( - worktreeId === rootId || - worktreeId.startsWith(`${rootId}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}`) - ) -} - -function mergeFolderWorkspace(repo: Repo, worktreeId: string, meta: WorktreeMeta): Worktree { - return { - id: worktreeId, - ...(meta.instanceId !== undefined ? { instanceId: meta.instanceId } : {}), - repoId: repo.id, - ...(meta.projectId !== undefined ? { projectId: meta.projectId } : {}), - ...(meta.hostId !== undefined ? { hostId: meta.hostId } : {}), - ...(meta.projectHostSetupId !== undefined - ? { projectHostSetupId: meta.projectHostSetupId } - : {}), - path: repo.path, - head: '', - branch: '', - isBare: false, - isMainWorktree: worktreeId === getFolderWorkspaceRootId(repo), - displayName: meta.displayName || repo.displayName, - comment: meta.comment || '', - linkedIssue: meta.linkedIssue ?? null, - linkedPR: meta.linkedPR ?? null, - linkedLinearIssue: meta.linkedLinearIssue ?? null, - linkedLinearIssueWorkspaceId: meta.linkedLinearIssueWorkspaceId ?? null, - linkedLinearIssueOrganizationUrlKey: meta.linkedLinearIssueOrganizationUrlKey ?? null, - linkedGitLabMR: meta.linkedGitLabMR ?? null, - linkedGitLabIssue: meta.linkedGitLabIssue ?? null, - linkedBitbucketPR: meta.linkedBitbucketPR ?? null, - linkedAzureDevOpsPR: meta.linkedAzureDevOpsPR ?? null, - linkedGiteaPR: meta.linkedGiteaPR ?? null, - linkedWorkItem: meta.linkedWorkItem ?? null, - linkedTaskSourceContext: meta.linkedTaskSourceContext ?? null, - isArchived: meta.isArchived ?? false, - isUnread: meta.isUnread ?? false, - isPinned: meta.isPinned ?? false, - sortOrder: meta.sortOrder ?? 0, - ...(meta.manualOrder !== undefined ? { manualOrder: meta.manualOrder } : {}), - lastActivityAt: meta.lastActivityAt ?? 0, - ...(meta.createdAt !== undefined ? { createdAt: meta.createdAt } : {}), - ...(meta.createdWithAgent !== undefined ? { createdWithAgent: meta.createdWithAgent } : {}), - ...(meta.automationProvenance !== undefined - ? { automationProvenance: meta.automationProvenance } - : {}), - ...(meta.cliProvenance !== undefined ? { cliProvenance: meta.cliProvenance } : {}), - ...(meta.priorWorktreeIds !== undefined ? { priorWorktreeIds: meta.priorWorktreeIds } : {}), - workspaceStatus: meta.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID, - diffComments: meta.diffComments, - mobileDiffReview: meta.mobileDiffReview - } -} - -function listFolderWorkspaces(store: Store, repo: Repo): Worktree[] { - const rootId = getFolderWorkspaceRootId(repo) - const allMeta = store.getAllWorktreeMeta() - const ids = Object.keys(allMeta).filter((worktreeId) => - isFolderWorkspaceIdForRepo(repo, worktreeId) - ) - if (!ids.includes(rootId)) { - ids.unshift(rootId) - } - - return ids - .map((worktreeId) => { - const existing = allMeta[worktreeId] - const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, existing) - const meta = - existing?.instanceId && Object.keys(ownershipUpdates).length === 0 - ? existing - : store.setWorktreeMeta(worktreeId, { - instanceId: - existing?.instanceId ?? getFolderWorkspaceInstanceIdentity(repo, worktreeId), - ...ownershipUpdates, - ...(existing ? {} : { displayName: repo.displayName, lastActivityAt: Date.now() }) - }) - return mergeFolderWorkspace(repo, worktreeId, meta) - }) - .sort((a, b) => { - if (a.id === rootId) { - return -1 - } - if (b.id === rootId) { - return 1 - } - return (b.createdAt ?? b.lastActivityAt) - (a.createdAt ?? a.lastActivityAt) - }) -} - -function buildFolderDetectedWorktrees(store: Store, repo: Repo): DetectedWorktree[] { - const settings = store.getSettings() - const worktrees = listFolderWorkspaces(store, repo) - const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher( - [repo.path, ...worktrees.map((worktree) => worktree.path)], - resolveCustomWorktreeVisibilitySources(repo, settings.worktreeVisibilityDefaults), - resolveConfiguredWorktreeBasePaths(repo) - ) - return worktrees.map((worktree) => - toDetectedWorktree({ - repo, - worktree, - meta: store.getWorktreeMeta(worktree.id), - settings, - knownOrcaLayouts: [], - isLegacyRepoForVisibility: true, - worktreeVisibilitySourceMatcher - }) - ) -} - -function listVisibleFolderWorkspaces(store: Store, repo: Repo): Worktree[] { - return buildFolderDetectedWorktrees(store, repo) - .filter((worktree) => worktree.visible) - .map((worktree) => { - const meta = store.getWorktreeMeta(worktree.id) - const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, meta) - const repairedMeta = - meta && Object.keys(ownershipUpdates).length === 0 - ? meta - : store.setWorktreeMeta(worktree.id, ownershipUpdates) - return mergeFolderWorkspace(repo, worktree.id, repairedMeta) - }) -} - -function createFolderWorkspace( - args: CreateWorktreeArgsWithSystemProvenance, - repo: Repo, - store: Store -): CreateWorktreeResult { - const now = Date.now() - const instanceId = randomUUID() - const worktreeId = getFolderWorkspaceInstanceId(repo, instanceId) - const meta = store.setWorktreeMeta(worktreeId, { - instanceId, - ...(store.getProjectHostSetups - ? getProjectHostSetupWorktreeMeta(store.getProjectHostSetups(), repo) - : {}), - displayName: args.displayName || args.name, - lastActivityAt: now, - createdAt: now, - orcaCreatedAt: now, - orcaCreationSource: 'desktop', - creatorProvenance: { kind: 'host' }, - ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), - ...(args.cliProvenance ? { cliProvenance: args.cliProvenance } : {}), - ...(args.createdWithAgent ? { createdWithAgent: args.createdWithAgent } : {}), - ...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}), - ...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}), - ...(args.linkedLinearIssue !== undefined ? { linkedLinearIssue: args.linkedLinearIssue } : {}), - ...(args.linkedLinearIssueWorkspaceId !== undefined - ? { linkedLinearIssueWorkspaceId: args.linkedLinearIssueWorkspaceId } - : {}), - ...(args.linkedLinearIssueOrganizationUrlKey !== undefined - ? { linkedLinearIssueOrganizationUrlKey: args.linkedLinearIssueOrganizationUrlKey } - : {}), - ...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}), - ...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}), - ...(args.linkedGitLabIssue !== undefined ? { linkedGitLabIssue: args.linkedGitLabIssue } : {}), - ...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}), - ...(args.linkedBitbucketPR !== undefined ? { linkedBitbucketPR: args.linkedBitbucketPR } : {}), - ...(args.linkedAzureDevOpsPR !== undefined - ? { linkedAzureDevOpsPR: args.linkedAzureDevOpsPR } - : {}), - ...(args.linkedGiteaPR !== undefined ? { linkedGiteaPR: args.linkedGiteaPR } : {}), - ...(args.linkedWorkItem !== undefined ? { linkedWorkItem: args.linkedWorkItem } : {}), - ...(args.linkedTaskSourceContext !== undefined - ? { linkedTaskSourceContext: args.linkedTaskSourceContext } - : {}) - }) - return { worktree: mergeFolderWorkspace(repo, worktreeId, meta) } -} - -function buildDisconnectedDetectedWorktrees( - store: Store, - repo: Repo, - worktrees: Worktree[] -): DetectedWorktree[] { - const settings = store.getSettings() - const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher( - [repo.path, ...worktrees.map((worktree) => worktree.path)], - resolveCustomWorktreeVisibilitySources(repo, settings.worktreeVisibilityDefaults), - resolveConfiguredWorktreeBasePaths(repo) - ) - const detected = worktrees.map((worktree) => { - const meta = store.getWorktreeMeta(worktree.id) - const detected = toDetectedWorktree({ - repo, - worktree, - meta, - settings, - knownOrcaLayouts: [], - isLegacyRepoForVisibility: true, - worktreeVisibilitySourceMatcher - }) - return applyMetadataFallbackVisibility(detected) - }) - return projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {}) -} - -function hasConflictingStoredWorktreeOwner( - store: Store, - repo: Repo, - worktreeIds: readonly string[] -): boolean { - const expectedHostId = getRepoExecutionHostId(repo) - const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length - return worktreeIds.some((worktreeId) => { - const meta = store.getWorktreeMeta(worktreeId) - return !!meta && (meta.hostId ? meta.hostId !== expectedHostId : repoOwnerCount > 1) - }) -} - -type RepoOwnershipEvidence = - | { status: 'owned'; hostId: ExecutionHostId } - | { status: 'malformed' } - | { status: 'contradictory' } - -function resolveRepoOwnershipEvidence(repo: Repo): RepoOwnershipEvidence { - const hasExplicitHost = repo.executionHostId !== null && repo.executionHostId !== undefined - const explicitHost = hasExplicitHost ? parseExecutionHostId(repo.executionHostId) : null - if (hasExplicitHost && !explicitHost) { - return { status: 'malformed' } - } - const hasConnection = repo.connectionId !== null && repo.connectionId !== undefined - const connectionId = hasConnection ? repo.connectionId?.trim() : null - if (hasConnection && !connectionId) { - return { status: 'malformed' } - } - const connectionHostId = connectionId ? toSshExecutionHostId(connectionId) : null - if (explicitHost && connectionHostId && explicitHost.id !== connectionHostId) { - return { status: 'contradictory' } - } - return { - status: 'owned', - hostId: explicitHost?.id ?? connectionHostId ?? LOCAL_EXECUTION_HOST_ID - } -} - -function findExactRepoOwner( - store: Store, - repoId: string, - executionHostId?: ExecutionHostId -): Repo | undefined { - const candidates = store.getRepos().filter((repo) => repo.id === repoId) - const evidence = candidates.map(resolveRepoOwnershipEvidence) - if (evidence.some((owner) => owner.status !== 'owned')) { - return undefined - } - const matches = candidates.filter((_, index) => { - const owner = evidence[index] - return ( - owner?.status === 'owned' && - (executionHostId === undefined || owner.hostId === executionHostId) - ) - }) - return matches.length === 1 ? matches[0] : undefined -} - -function isCapturedRepoCurrent( - store: Store, - repo: Repo, - executionHostId?: ExecutionHostId -): boolean { - const current = findExactRepoOwner(store, repo.id, executionHostId) - return ( - current !== undefined && - current.path === repo.path && - (current.connectionId ?? null) === (repo.connectionId ?? null) && - (current.executionHostId ?? null) === (repo.executionHostId ?? null) - ) -} - -async function listDetectedWorktreesForCapturedRepo( - store: Store, - repo: Repo, - isCurrent: () => boolean, - capturedProvider = repo.connectionId ? getSshGitProvider(repo.connectionId) : undefined, - providerAbort?: { signal: AbortSignal; status: () => 'canceled' | 'timed-out' } -): Promise { - const abortedResult = () => - providerAbort?.signal.aborted - ? ({ providerAbortStatus: providerAbort.status() } as const) - : undefined - const sshWorktreeMetaIndex = repo.connectionId - ? createSshWorktreeMetaIndex(Object.entries(store.getAllWorktreeMeta())) - : new Map() - - try { - let gitWorktrees: GitWorktreeInfo[] - let freshScan = true - if (isFolderRepo(repo)) { - if (!isCurrent()) { - return null - } - const folderWorkspaceIds = Object.keys(store.getAllWorktreeMeta()).filter((worktreeId) => - isFolderWorkspaceIdForRepo(repo, worktreeId) - ) - if (hasConflictingStoredWorktreeOwner(store, repo, folderWorkspaceIds)) { - return { - repoId: repo.id, - authoritative: false, - source: 'metadata-fallback', - worktrees: [] - } - } - return { - repoId: repo.id, - authoritative: true, - source: 'git', - worktrees: projectResolvedWorktreeLineage( - buildFolderDetectedWorktrees(store, repo), - store.getAllWorktreeLineage?.() ?? {} - ) - } - } - if (repo.connectionId) { - if (!capturedProvider) { - const aborted = abortedResult() - if (aborted) { - return aborted - } - if (!isCurrent()) { - return null - } - const worktrees = listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) - return { - repoId: repo.id, - authoritative: false, - source: 'metadata-fallback', - worktrees: buildDisconnectedDetectedWorktrees(store, repo, worktrees) - } - } - gitWorktrees = await capturedProvider.listWorktrees(repo.path, { - signal: providerAbort?.signal - }) - } else { - const scan = await listDetectedGitWorktrees(store, repo) - gitWorktrees = scan.gitWorktrees - freshScan = scan.fresh - } - const aborted = abortedResult() - if (aborted) { - return aborted - } - if (!isCurrent()) { - return null - } - const listedWorktreeIds = gitWorktrees.map((worktree) => `${repo.id}::${worktree.path}`) - if (hasConflictingStoredWorktreeOwner(store, repo, listedWorktreeIds)) { - return { - repoId: repo.id, - authoritative: false, - source: 'metadata-fallback', - worktrees: [] - } - } - if (freshScan) { - rememberLocalWorktreeRoots(store, repo, gitWorktrees) - pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) - } - loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) - return { - repoId: repo.id, - authoritative: true, - source: 'git', - worktrees: buildDetectedGitWorktrees(store, repo, gitWorktrees) - } - } catch (err) { - const aborted = abortedResult() - if (aborted) { - return aborted - } - if (!isCurrent()) { - return null - } - warnOnce( - loggedWorktreeListFailures, - `${repo.id}:${repo.path}`, - `[worktrees] failed to list detected worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, - err - ) - if (repo.connectionId) { - const worktrees = listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) - return { - repoId: repo.id, - authoritative: false, - source: 'metadata-fallback', - worktrees: buildDisconnectedDetectedWorktrees(store, repo, worktrees) - } - } - return { repoId: repo.id, authoritative: false, source: 'metadata-fallback', worktrees: [] } - } -} - -function hasValidDirectSshAuthority( - args: DirectSshDetectedWorktreeRequest -): args is DirectSshDetectedWorktreeRequest { - return isAdmissibleDirectSshAuthority(args.expectedAuthority) -} - -function hasValidLineageSshAuthority( - args: ListDesktopLineageForHostArgs -): args is Extract { - if (!('expectedAuthority' in args)) { - return false - } - return isAdmissibleDirectSshAuthority(args.expectedAuthority) -} - -type LineageOwner = - | { status: 'owned'; hostId: ExecutionHostId } - | { status: 'ambiguous' | 'contradictory' | 'runtime' } - -type LineageFolder = ReturnType[number] -type LineageGroup = ReturnType[number] - -type LineageResolutionContext = { - store: Store - repos: Repo[] - groups: LineageGroup[] - reposById: Map - foldersById: Map - groupsById: Map - groupSubtreeIdsByRoot: Map> - worktreeOwners: Map - folderOwners: Map - workspaceOwners: Map -} - -function indexLineageEntriesById( - entries: readonly T[] -): Map { - const index = new Map() - for (const entry of entries) { - const matching = index.get(entry.id) ?? [] - matching.push(entry) - index.set(entry.id, matching) - } - return index -} - -function createLineageResolutionContext(store: Store): LineageResolutionContext { - const repos = store.getRepos() - const folders = store.getFolderWorkspaces() - const groups = store.getProjectGroups() - return { - store, - repos, - groups, - reposById: indexLineageEntriesById(repos), - foldersById: indexLineageEntriesById(folders), - groupsById: indexLineageEntriesById(groups), - groupSubtreeIdsByRoot: new Map(), - worktreeOwners: new Map(), - folderOwners: new Map(), - workspaceOwners: new Map() - } -} - -function resolveRepoLineageOwner(repo: Repo): LineageOwner { - const owner = resolveRepoOwnershipEvidence(repo) - if (owner.status === 'malformed') { - return { status: 'ambiguous' } - } - if (owner.status === 'contradictory') { - return { status: 'contradictory' } - } - return parseExecutionHostId(owner.hostId)?.kind === 'runtime' ? { status: 'runtime' } : owner -} - -function resolveWorktreeLineageOwner( - context: LineageResolutionContext, - worktreeId: string -): LineageOwner { - const cached = context.worktreeOwners.get(worktreeId) - if (cached) { - return cached - } - const remember = (owner: LineageOwner): LineageOwner => { - context.worktreeOwners.set(worktreeId, owner) - return owner - } - let repoId: string - try { - repoId = parseWorktreeId(worktreeId).repoId - } catch { - return remember({ status: 'ambiguous' }) - } - const repos = context.reposById.get(repoId) ?? [] - const meta = context.store.getWorktreeMeta(worktreeId) - const runtimeOwnerEnvironmentId = ( - meta as (WorktreeMeta & { runtimeOwnerEnvironmentId?: string }) | undefined - )?.runtimeOwnerEnvironmentId?.trim() - if (runtimeOwnerEnvironmentId) { - return remember({ status: 'runtime' }) - } - if (meta?.hostId) { - const explicitHost = parseExecutionHostId(meta.hostId) - if (!explicitHost) { - return remember({ status: 'ambiguous' }) - } - if (explicitHost.kind === 'runtime') { - return remember({ status: 'runtime' }) - } - const matchingRepos = repos.filter((repo) => { - const owner = resolveRepoLineageOwner(repo) - return owner.status === 'owned' && owner.hostId === explicitHost.id - }) - if (matchingRepos.length === 1) { - return remember({ status: 'owned', hostId: explicitHost.id }) - } - return remember( - matchingRepos.length > 1 - ? { status: 'ambiguous' } - : { status: repos.length > 0 ? 'contradictory' : 'ambiguous' } - ) - } - if (repos.length !== 1) { - return remember({ status: 'ambiguous' }) - } - return remember(resolveRepoLineageOwner(repos[0])) -} - -function getFolderLineageCandidateRepos( - context: LineageResolutionContext, - folder: LineageFolder -): Repo[] { - let groupIds = context.groupSubtreeIdsByRoot.get(folder.projectGroupId) - if (!groupIds) { - groupIds = getProjectGroupSubtreeIds(context.groups, folder.projectGroupId) - context.groupSubtreeIdsByRoot.set(folder.projectGroupId, groupIds) - } - const grouped = context.repos.filter( - (repo) => typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId) - ) - const pathRepos = context.repos.filter( - (repo) => - !(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) && - isPathInsideOrEqual(folder.folderPath, repo.path) - ) - const group = context.groupsById.get(folder.projectGroupId)?.[0] - const connectionId = folder.connectionId ?? group?.connectionId ?? null - return connectionId - ? [...grouped, ...pathRepos.filter((repo) => (repo.connectionId ?? null) === connectionId)] - : grouped.length > 0 - ? [ - ...grouped, - ...pathRepos.filter((repo) => - new Set(grouped.map((candidate) => candidate.connectionId ?? null)).has( - repo.connectionId ?? null - ) - ) - ] - : pathRepos -} - -function resolveFolderLineageOwner( - context: LineageResolutionContext, - folderWorkspaceId: string -): LineageOwner { - const cached = context.folderOwners.get(folderWorkspaceId) - if (cached) { - return cached - } - const remember = (owner: LineageOwner): LineageOwner => { - context.folderOwners.set(folderWorkspaceId, owner) - return owner - } - const folders = context.foldersById.get(folderWorkspaceId) ?? [] - if (folders.length !== 1) { - return remember({ status: 'ambiguous' }) - } - const folder = folders[0] - const groups = context.groupsById.get(folder.projectGroupId) ?? [] - if (groups.length !== 1) { - return remember({ status: 'ambiguous' }) - } - const group = groups[0] - const hosts = new Set() - if (folder.connectionId) { - hosts.add(`ssh:${encodeURIComponent(folder.connectionId)}`) - } - if (group.connectionId) { - hosts.add(`ssh:${encodeURIComponent(group.connectionId)}`) - } - if (group.executionHostId) { - const parsed = parseExecutionHostId(group.executionHostId) - if (!parsed) { - return remember({ status: 'ambiguous' }) - } - hosts.add(parsed.id) - } - for (const repo of getFolderLineageCandidateRepos(context, folder)) { - const owner = resolveRepoLineageOwner(repo) - if (owner.status !== 'owned') { - return remember(owner) - } - hosts.add(owner.hostId) - } - if (hosts.size > 1) { - return remember({ status: 'contradictory' }) - } - const hostId = [...hosts][0] ?? LOCAL_EXECUTION_HOST_ID - return remember( - parseExecutionHostId(hostId)?.kind === 'runtime' - ? { status: 'runtime' } - : { status: 'owned', hostId } - ) -} - -function resolveWorkspaceLineageOwner( - context: LineageResolutionContext, - workspaceKey: string -): LineageOwner { - const cached = context.workspaceOwners.get(workspaceKey) - if (cached) { - return cached - } - const workspace = parseWorkspaceKey(workspaceKey) - const owner = !workspace - ? { status: 'ambiguous' as const } - : workspace.type === 'worktree' - ? resolveWorktreeLineageOwner(context, workspace.worktreeId) - : resolveFolderLineageOwner(context, workspace.folderWorkspaceId) - context.workspaceOwners.set(workspaceKey, owner) - return owner -} - -function filterLineageForHost( - store: Store, - executionHostId: ExecutionHostId -): { - worktreeLineageById: Record - workspaceLineageByChildKey: Record -} | null { - const context = createLineageResolutionContext(store) - const worktreeLineageById: Record = {} - const workspaceLineageByChildKey: Record = {} - for (const [worktreeId, lineage] of Object.entries(store.getAllWorktreeLineage())) { - const child = resolveWorktreeLineageOwner(context, worktreeId) - const parent = resolveWorktreeLineageOwner(context, lineage.parentWorktreeId) - if (child.status === 'ambiguous' || child.status === 'contradictory') { - return null - } - if (parent.status === 'ambiguous' || parent.status === 'contradictory') { - return null - } - if ( - child.status === 'owned' && - parent.status === 'owned' && - child.hostId === executionHostId && - parent.hostId === executionHostId - ) { - worktreeLineageById[worktreeId] = structuredClone(lineage) - } else if ( - child.status === 'owned' && - parent.status === 'owned' && - child.hostId !== parent.hostId - ) { - return null - } - } - for (const [childKey, lineage] of Object.entries(store.getAllWorkspaceLineage())) { - const child = resolveWorkspaceLineageOwner(context, childKey) - const parent = resolveWorkspaceLineageOwner(context, lineage.parentWorkspaceKey) - if (child.status === 'ambiguous' || child.status === 'contradictory') { - return null - } - if (parent.status === 'ambiguous' || parent.status === 'contradictory') { - return null - } - if ( - child.status === 'owned' && - parent.status === 'owned' && - child.hostId === executionHostId && - parent.hostId === executionHostId - ) { - workspaceLineageByChildKey[childKey] = structuredClone(lineage) - } else if ( - child.status === 'owned' && - parent.status === 'owned' && - child.hostId !== parent.hostId - ) { - return null - } - } - return { worktreeLineageById, workspaceLineageByChildKey } -} - -async function hydrateLineageWithinDeadline(runtime: OrcaRuntimeService): Promise { - let timeout: ReturnType | undefined - const hydration = Promise.resolve() - .then(() => runtime.hydrateInferredWorktreeLineage()) - .then( - () => true, - () => false - ) - const deadline = new Promise((resolve) => { - timeout = setTimeout(() => resolve(false), LINEAGE_HYDRATION_TIMEOUT_MS) - }) - try { - return await Promise.race([hydration, deadline]) - } finally { - if (timeout) { - clearTimeout(timeout) - } - } -} - -async function listDesktopLineageForHost( - store: Store, - runtime: OrcaRuntimeService, - args: ListDesktopLineageForHostArgs -): Promise { - const parsedHost = parseExecutionHostId(args?.executionHostId) - const rejected = ( - reason: Extract['reason'] - ): HostLineageSnapshot => ({ - authoritative: false, - executionHostId: args.executionHostId, - reason - }) - if (!parsedHost || parsedHost.kind === 'runtime') { - return rejected('rejected') - } - let provider: ReturnType | undefined - let authority: - | Extract['expectedAuthority'] - | null = null - if (parsedHost.kind === 'local') { - if ('expectedAuthority' in args) { - return rejected('rejected') - } - } else { - if ( - !hasValidLineageSshAuthority(args) || - args.expectedAuthority.targetId !== parsedHost.targetId - ) { - return rejected('rejected') - } - authority = { ...args.expectedAuthority } - if (!isCurrentSshProviderAuthority(authority)) { - return rejected('stale') - } - provider = getSshGitProvider(parsedHost.targetId) - if (!provider) { - return rejected('unavailable') - } - } - if (!(await hydrateLineageWithinDeadline(runtime))) { - return rejected('unavailable') - } - if ( - parsedHost.kind === 'ssh' && - (!authority || - getSshGitProvider(parsedHost.targetId) !== provider || - !isCurrentSshProviderAuthority(authority)) - ) { - return rejected('stale') - } - const lineage = filterLineageForHost(store, parsedHost.id) - if (!lineage) { - return rejected('ambiguous-owner') - } - if (parsedHost.kind === 'local') { - return { - authoritative: true, - authority: { kind: 'local', executionHostId: LOCAL_EXECUTION_HOST_ID }, - ...lineage - } - } - if (!authority) { - return rejected('authority-unknown') - } - return { - authoritative: true, - authority: { - kind: 'direct-ssh', - executionHostId: parsedHost.id, - ...authority - }, - ...lineage - } -} - -async function listHostQualifiedDetectedWorktrees( - store: Store, - args: ListDetectedWorktreesArgs, - providerAbort?: { signal: AbortSignal; status: () => 'canceled' | 'timed-out' } -): Promise { - const parsedHost = parseExecutionHostId(args.executionHostId) - const rejected = (status: 'rejected' | 'stale' | 'ambiguous-owner') => ({ - providerRequestId: args.providerRequestId, - executionHostId: args.executionHostId, - status - }) - if ( - typeof args.providerRequestId !== 'string' || - args.providerRequestId.length === 0 || - Buffer.byteLength(args.providerRequestId, 'utf8') > PROVIDER_REQUEST_ID_MAX_UTF8_BYTES || - !parsedHost || - parsedHost.kind === 'runtime' - ) { - return rejected('rejected') - } - let capturedAuthority: DirectSshDetectedWorktreeRequest['expectedAuthority'] | null = null - if (parsedHost.kind === 'ssh') { - const directArgs = args as DirectSshDetectedWorktreeRequest - if ( - !hasValidDirectSshAuthority(directArgs) || - directArgs.expectedAuthority.targetId !== parsedHost.targetId - ) { - return rejected('rejected') - } - capturedAuthority = { ...directArgs.expectedAuthority } - if (!isCurrentSshProviderAuthority(capturedAuthority)) { - return rejected('stale') - } - } - - const repoCandidates = store.getRepos().filter((candidate) => candidate.id === args.repoId) - if ( - repoCandidates.some((candidate) => resolveRepoOwnershipEvidence(candidate).status !== 'owned') - ) { - return rejected('rejected') - } - const repo = findExactRepoOwner(store, args.repoId, args.executionHostId) - if (!repo) { - return rejected('ambiguous-owner') - } - if ( - (parsedHost.kind === 'local' && repo.connectionId) || - (parsedHost.kind === 'ssh' && repo.connectionId !== parsedHost.targetId) - ) { - return rejected('rejected') - } - const provider = parsedHost.kind === 'ssh' ? getSshGitProvider(parsedHost.targetId) : undefined - const isCurrent = (): boolean => { - if (!isCapturedRepoCurrent(store, repo, args.executionHostId)) { - return false - } - if ( - (parsedHost.kind === 'local' && repo.connectionId) || - (parsedHost.kind === 'ssh' && repo.connectionId !== parsedHost.targetId) - ) { - return false - } - if (parsedHost.kind !== 'ssh') { - return true - } - return ( - capturedAuthority !== null && - getSshGitProvider(parsedHost.targetId) === provider && - isCurrentSshProviderAuthority(capturedAuthority) - ) - } - const result = await listDetectedWorktreesForCapturedRepo( - store, - repo, - isCurrent, - provider, - providerAbort - ) - if (!result) { - return rejected('stale') - } - if ('providerAbortStatus' in result) { - return { - providerRequestId: args.providerRequestId, - executionHostId: args.executionHostId, - status: result.providerAbortStatus - } - } - const status = result.authoritative ? 'complete' : 'non-authoritative' - if (parsedHost.kind === 'local') { - return { - status, - providerRequestId: args.providerRequestId, - repoId: repo.id, - authority: { kind: 'local', executionHostId: LOCAL_EXECUTION_HOST_ID }, - result - } - } - if (!capturedAuthority) { - return rejected('rejected') - } - return { - status, - providerRequestId: args.providerRequestId, - repoId: repo.id, - authority: { - kind: 'direct-ssh', - executionHostId: args.executionHostId as `ssh:${string}`, - ...capturedAuthority - }, - result - } -} +import { registerWorktreeCreateHandlers } from './worktrees/create/register-worktree-create-handlers' +import { registerWorktreePrefetchHandler } from './worktrees/create/register-worktree-prefetch-handler' +import { registerReviewBaseHandlers } from './worktrees/create/register-review-base-handlers' +import { registerWorktreeHookCheckHandler } from './hooks/register-worktree-hook-check-handler' +import { registerWorktreeHookFileHandlers } from './hooks/register-worktree-hook-file-handlers' +import { registerWorktreeHookInspectionHandler } from './hooks/register-worktree-hook-inspection-handler' +import { registerWorktreeHookRunnerHandler } from './hooks/register-worktree-hook-runner-handler' +import { registerDetectedWorktreeHandlers } from './worktrees/listing/register-detected-worktree-handlers' +import { registerHostCatalogHandlers } from './worktrees/listing/register-host-catalog-handlers' +import { registerWorktreeCatalogHandlers } from './worktrees/listing/register-worktree-catalog-handlers' +import { registerDetectedWorktreeScanInvalidation } from './worktrees/listing/register-detected-worktree-scan-invalidation' +import { registerWorktreeMetadataHandlers } from './worktrees/metadata/register-worktree-metadata-handlers' +import { registerWorktreeForgetHandlers } from './worktrees/removal/register-worktree-forget-handlers' +import { registerWorktreeRemovalHandlers } from './worktrees/removal/register-worktree-removal-handlers' +import type { WorktreeIpcContext } from './worktrees/worktree-ipc-context' + +registerDetectedWorktreeScanInvalidation() + +const WORKTREE_HANDLER_CHANNELS = [ + 'worktrees:listAll', + 'worktrees:list', + 'worktrees:listRetiredNames', + 'worktrees:listDetected', + 'worktrees:listKnownForExecutionHost', + 'worktrees:forgetRemovedForExecutionHost', + 'worktrees:cancelListDetected', + 'worktrees:create', + 'worktrees:adoptProvisionedRoot', + 'worktrees:prefetchCreateBase', + 'worktrees:resolvePrBase', + 'worktrees:resolveMrBase', + 'worktrees:remove', + 'worktrees:forgetLocal', + 'worktrees:forceDeletePreservedBranch', + 'worktrees:updateMeta', + 'worktrees:listLineage', + 'worktrees:listLineageForHost', + 'worktrees:updateLineage', + 'worktrees:persistSortOrder', + 'worktrees:getBranchRenameFailureOutput', + 'hooks:check', + 'hooks:inspectSetupScriptImports', + 'hooks:createIssueCommandRunner', + 'hooks:readIssueCommand', + 'hooks:writeIssueCommand' +] as const export function registerWorktreeHandlers( mainWindow: BrowserWindow, @@ -1850,1826 +55,31 @@ export function registerWorktreeHandlers( runtime: OrcaRuntimeService, options?: { onWorktreeLifecycle?: (event: RuntimeWorktreeLifecycleEvent) => void } ): void { - const detectedWorktreeCancellations = createSenderScopedRequestCancellations() - // Remove previously registered handlers so re-register works when macOS re-activates and creates a new window. - ipcMain.removeHandler('worktrees:listAll') - ipcMain.removeHandler('worktrees:list') - ipcMain.removeHandler('worktrees:listRetiredNames') - ipcMain.removeHandler('worktrees:listDetected') - ipcMain.removeHandler('worktrees:listKnownForExecutionHost') - ipcMain.removeHandler('worktrees:forgetRemovedForExecutionHost') - ipcMain.removeHandler('worktrees:cancelListDetected') - ipcMain.removeHandler('worktrees:create') - ipcMain.removeHandler('worktrees:adoptProvisionedRoot') - ipcMain.removeHandler('worktrees:prefetchCreateBase') - ipcMain.removeHandler('worktrees:resolvePrBase') - ipcMain.removeHandler('worktrees:resolveMrBase') - ipcMain.removeHandler('worktrees:remove') - ipcMain.removeHandler('worktrees:forgetLocal') - ipcMain.removeHandler('worktrees:forceDeletePreservedBranch') - ipcMain.removeHandler('worktrees:updateMeta') - ipcMain.removeHandler('worktrees:listLineage') - ipcMain.removeHandler('worktrees:listLineageForHost') - ipcMain.removeHandler('worktrees:updateLineage') - ipcMain.removeHandler('worktrees:persistSortOrder') - ipcMain.removeHandler('worktrees:getBranchRenameFailureOutput') - ipcMain.removeHandler('hooks:check') - ipcMain.removeHandler('hooks:inspectSetupScriptImports') - ipcMain.removeHandler('hooks:createIssueCommandRunner') - ipcMain.removeHandler('hooks:readIssueCommand') - ipcMain.removeHandler('hooks:writeIssueCommand') + const context: WorktreeIpcContext = { + mainWindow, + store, + runtime, + ...(options ? { options } : {}), + detectedWorktreeCancellations: createSenderScopedRequestCancellations(), + worktreeRemovalsInFlight: new Map() + } - ipcMain.handle('worktrees:listAll', async () => { - const repos = store.getRepos() - const sshWorktreeMetaIndex = repos.some((repo) => repo.connectionId) - ? createSshWorktreeMetaIndex(Object.entries(store.getAllWorktreeMeta())) - : new Map() + // Remove all stale registrations before installing any replacement handler. + for (const channel of WORKTREE_HANDLER_CHANNELS) { + ipcMain.removeHandler(channel) + } - // Why: each local repo listing can spawn `git worktree list`; cap fan-out so large fleets don't start unbounded subprocesses. - const results = await mapWithConcurrency(repos, WORKTREE_LIST_ALL_CONCURRENCY, async (repo) => { - try { - let gitWorktrees - let freshScan = true - if (isFolderRepo(repo)) { - return listVisibleFolderWorkspaces(store, repo) - } else if (repo.connectionId) { - const provider = getSshGitProvider(repo.connectionId) - if (!provider) { - warnOnce( - loggedUnavailableSshGitProviders, - `${repo.connectionId}:${repo.id}`, - `[worktrees] SSH git provider unavailable; skipping worktree list for repo "${repo.displayName}" (${repo.id}) at ${repo.path} on connection ${repo.connectionId}` - ) - return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) - } - loggedUnavailableSshGitProviders.delete(`${repo.connectionId}:${repo.id}`) - try { - gitWorktrees = await provider.listWorktrees(repo.path) - } catch (err) { - warnOnce( - loggedWorktreeListFailures, - `${repo.id}:${repo.path}`, - `[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, - err - ) - return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) - } - } else { - const scan = await listDetectedGitWorktrees(store, repo) - gitWorktrees = scan.gitWorktrees - freshScan = scan.fresh - } - if (freshScan) { - rememberLocalWorktreeRoots(store, repo, gitWorktrees) - pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) - } - loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) - return buildDetectedGitWorktrees(store, repo, gitWorktrees) - .filter((worktree) => worktree.visible) - .map((worktree) => stampAndMergeVisibleDetectedWorktree(store, repo, worktree)) - } catch (err) { - warnOnce( - loggedWorktreeListFailures, - `${repo.id}:${repo.path}`, - `[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, - err - ) - // Why: do NOT seed empty success — it flags the repo registered, blocking access to legit linked worktrees until the cache is invalidated. - return [] - } - }) - - return results.flat() - }) - - ipcMain.handle('worktrees:listRetiredNames', async (_event, args: { repoId: string }) => { - const repo = store.getRepo(args.repoId) - if (!repo) { - return EMPTY_RETIRED_NAME_REGISTRY - } - return getRetiredNameRegistryForRepo(store, repo, store.getRepos(), store.getSettings()) - }) - - ipcMain.handle('worktrees:list', async (_event, args: { repoId: string }) => { - const repo = store.getRepo(args.repoId) - if (!repo) { - return [] - } - const sshWorktreeMetaIndex = repo.connectionId - ? createSshWorktreeMetaIndex(Object.entries(store.getAllWorktreeMeta())) - : new Map() - - try { - let gitWorktrees - let freshScan = true - if (isFolderRepo(repo)) { - return listVisibleFolderWorkspaces(store, repo) - } else if (repo.connectionId) { - const provider = getSshGitProvider(repo.connectionId) - if (!provider) { - warnOnce( - loggedUnavailableSshGitProviders, - `${repo.connectionId}:${repo.id}`, - `[worktrees] SSH git provider unavailable; skipping worktree list for repo "${repo.displayName}" (${repo.id}) at ${repo.path} on connection ${repo.connectionId}` - ) - return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) - } - loggedUnavailableSshGitProviders.delete(`${repo.connectionId}:${repo.id}`) - try { - gitWorktrees = await provider.listWorktrees(repo.path) - } catch (err) { - warnOnce( - loggedWorktreeListFailures, - `${repo.id}:${repo.path}`, - `[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, - err - ) - return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) - } - } else { - const scan = await listDetectedGitWorktrees(store, repo) - gitWorktrees = scan.gitWorktrees - freshScan = scan.fresh - } - if (freshScan) { - rememberLocalWorktreeRoots(store, repo, gitWorktrees) - pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) - } - loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) - return buildDetectedGitWorktrees(store, repo, gitWorktrees) - .filter((worktree) => worktree.visible) - .map((worktree) => stampAndMergeVisibleDetectedWorktree(store, repo, worktree)) - } catch (err) { - warnOnce( - loggedWorktreeListFailures, - `${repo.id}:${repo.path}`, - `[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, - err - ) - // Why: see worktrees:listAll catch — seeding an empty-success result would poison the auth cache and block linked worktrees. - return [] - } - }) - - ipcMain.handle( - 'worktrees:listKnownForExecutionHost', - (_event, args: ListKnownWorktreesForExecutionHostArgs): HostQualifiedKnownWorktreeResult => { - // Why: a malformed invoke must fail closed as `rejected`, not throw out of the handler. `ssh:` is inert — - // it owns no repo, so every guard below still rejects it. - const requestedRepoId = args?.repoId ?? '' - const requestedExecutionHostId = args?.executionHostId ?? 'ssh:' - const rejected = (): HostQualifiedKnownWorktreeResult => ({ - status: 'rejected', - repoId: requestedRepoId, - executionHostId: requestedExecutionHostId - }) - const parsedHost = parseExecutionHostId(requestedExecutionHostId) - if (parsedHost?.kind !== 'ssh') { - return rejected() - } - // Why: findExactRepoOwner repeats this same all-candidates-owned check, and getRepos() re-hydrates the - // whole catalog, so a separate pass here is pure cost. - const repo = findExactRepoOwner(store, requestedRepoId, requestedExecutionHostId) - if (!repo || repo.connectionId !== parsedHost.targetId) { - return rejected() - } - const complete = (worktrees: DetectedWorktree[]): HostQualifiedKnownWorktreeResult => ({ - status: 'complete', - repoId: repo.id, - executionHostId: requestedExecutionHostId, - result: { - repoId: repo.id, - authoritative: false, - source: 'metadata-fallback', - worktrees - } - }) - // Why: folder workspace ids carry an instance suffix the git-worktree synthesizer would read as a directory; build them the way every other listing does. - if (isFolderRepo(repo)) { - const folderWorkspaceIds = Object.keys(store.getAllWorktreeMeta()).filter((worktreeId) => - isFolderWorkspaceIdForRepo(repo, worktreeId) - ) - return hasConflictingStoredWorktreeOwner(store, repo, folderWorkspaceIds) - ? rejected() - : complete( - // Why: match the authoritative folder listing; without lineage these rows render flat and then - // reshuffle once the real scan lands. - projectResolvedWorktreeLineage( - buildFolderDetectedWorktrees(store, repo), - store.getAllWorktreeLineage?.() ?? {} - ) - ) - } - const metaIndex = createSshWorktreeMetaIndexForRepo(store.getAllWorktreeMeta(), repo.id) - return complete( - buildDisconnectedDetectedWorktrees( - store, - repo, - listDisconnectedSshWorktrees(store, repo, metaIndex) - ) - ) - } - ) - - // Why: gcStaleWorktreeMeta cannot stat a remote path, so SSH metadata outlives the worktree and the fallback - // above re-lists a worktree deleted outside Orca on every launch. An authoritative scan is the only proof of - // absence, so the renderer reports what it retired here and the row is dropped like a local GC would. - ipcMain.handle( - 'worktrees:forgetRemovedForExecutionHost', - ( - _event, - args: ForgetRemovedWorktreesForExecutionHostArgs - ): ForgetRemovedWorktreesForExecutionHostResult => { - const nothingForgotten: ForgetRemovedWorktreesForExecutionHostResult = { - forgottenWorktreeIds: [] - } - const requestedExecutionHostId = args?.executionHostId ?? 'ssh:' - const worktreeIds = Array.isArray(args?.worktreeIds) ? args.worktreeIds : [] - const parsedHost = parseExecutionHostId(requestedExecutionHostId) - if (parsedHost?.kind !== 'ssh' || worktreeIds.length === 0) { - return nothingForgotten - } - const repo = findExactRepoOwner(store, args?.repoId ?? '', requestedExecutionHostId) - if (!repo || repo.connectionId !== parsedHost.targetId) { - return nothingForgotten - } - // Why: a folder workspace's meta IS the workspace record, not a checkout row — gcStaleWorktreeMeta skips - // those keys for the same reason, and no remote scan can retire one. - if (isFolderRepo(repo)) { - return nothingForgotten - } - const allMeta = store.getAllWorktreeMeta() - const forgottenWorktreeIds: string[] = [] - for (const worktreeId of worktreeIds) { - const meta = typeof worktreeId === 'string' ? allMeta[worktreeId] : undefined - if (!meta || getRepoIdFromWorktreeId(worktreeId) !== repo.id) { - continue - } - // An unhosted meta belongs to this repo's only owner; a foreign hostId needs that host's own scan. - if (meta.hostId && meta.hostId !== requestedExecutionHostId) { - continue - } - store.removeWorktreeMeta(worktreeId, requestedExecutionHostId) - forgottenWorktreeIds.push(worktreeId) - } - if (forgottenWorktreeIds.length > 0) { - const snapshotDirectory = store.getProfileStorageDirectory() - const targets = forgottenWorktreeIds.map((worktreeId) => ({ - worktreeId, - executionHostId: requestedExecutionHostId - })) - void pruneWorkspaceCleanupScanSnapshots(snapshotDirectory, targets) - void pruneWorkspaceSpaceAnalysisSnapshots(snapshotDirectory, targets) - } - return { forgottenWorktreeIds } - } - ) - - ipcMain.handle( - 'worktrees:listDetected', - async ( - event, - args: DetectedWorktreeRequestArgs - ): Promise => { - if ('executionHostId' in args) { - const parsedHost = parseExecutionHostId(args.executionHostId) - const directSshRequest = parsedHost?.kind === 'ssh' - const controller = directSshRequest - ? detectedWorktreeCancellations.begin(event, args.providerRequestId) - : null - const directArgs = args as DirectSshDetectedWorktreeRequest - const removeAuthorityAbort = - controller && - parsedHost?.kind === 'ssh' && - hasValidDirectSshAuthority(directArgs) && - directArgs.expectedAuthority.targetId === parsedHost.targetId - ? registerSshProviderRequestAbort(directArgs.expectedAuthority, controller) - : undefined - let timedOut = false - let removeAbortListener: (() => void) | undefined - const abortedResult = controller - ? new Promise((resolve) => { - const onAbort = (): void => { - resolve({ - providerRequestId: args.providerRequestId, - executionHostId: args.executionHostId, - status: timedOut ? 'timed-out' : 'canceled' - }) - } - controller.signal.addEventListener('abort', onAbort, { once: true }) - removeAbortListener = () => controller.signal.removeEventListener('abort', onAbort) - }) - : undefined - const timeout = controller - ? setTimeout(() => { - timedOut = true - controller.abort() - }, DETECTED_WORKTREE_PROVIDER_TIMEOUT_MS) - : undefined - try { - const providerResult = listHostQualifiedDetectedWorktrees( - store, - args, - controller - ? { - signal: controller.signal, - status: () => (timedOut ? 'timed-out' : 'canceled') - } - : undefined - ) - return abortedResult - ? await Promise.race([providerResult, abortedResult]) - : await providerResult - } finally { - if (timeout) { - clearTimeout(timeout) - } - removeAbortListener?.() - removeAuthorityAbort?.() - detectedWorktreeCancellations.finish(event, args.providerRequestId, controller) - } - } - const repo = findExactRepoOwner(store, args.repoId) - if (!repo) { - return { - repoId: args.repoId, - authoritative: false, - source: 'metadata-fallback', - worktrees: [] - } - } - const provider = repo.connectionId ? getSshGitProvider(repo.connectionId) : undefined - const authority = repo.connectionId - ? { ...getSshProviderAuthority(repo.connectionId) } - : undefined - const result = await listDetectedWorktreesForCapturedRepo( - store, - repo, - () => - isCapturedRepoCurrent(store, repo) && - (!repo.connectionId || - (getSshGitProvider(repo.connectionId) === provider && - authority !== undefined && - isCurrentSshProviderAuthority(authority))), - provider - ) - return result && !('providerAbortStatus' in result) - ? result - : { - repoId: repo.id, - authoritative: false, - source: 'metadata-fallback', - worktrees: [] - } - } - ) - ipcMain.handle( - 'worktrees:cancelListDetected', - (event, args: { providerRequestId: ProviderRequestId }): void => { - detectedWorktreeCancellations.cancel(event, args.providerRequestId) - } - ) - - ipcMain.handle( - 'worktrees:prefetchCreateBase', - async (_event, args: { repoId: string; baseBranch?: string }): Promise => { - const repo = store.getRepo(args.repoId) - if (!repo) { - return - } - try { - await prefetchWorktreeCreateBase({ repo, baseBranch: args.baseBranch, runtime }) - } catch { - // Why: optimistic warm-up; the real create path awaits the same refresh and reports failures there. - } - } - ) - - ipcMain.handle( - 'worktrees:create', - async (_event, rawArgs: CreateWorktreeArgs): Promise => { - const args = normalizeLinkedWorkItemFields(rawArgs) - // Why span here: parent the child git spans for the trace tree; don't attach branch name/remote URL (user content) — repo ID is the safer correlator. - return withWorktreeSpan({ stage: 'create' }, async () => { - const repo = store.getRepo(args.repoId) - if (!repo) { - throw new Error(`Repo not found: ${args.repoId}`) - } - - const sourceParse = workspaceSourceSchema.safeParse(args.telemetrySource) - const source: WorkspaceSource = sourceParse.success ? sourceParse.data : 'unknown' - - const automationProvenance = resolveAutomationWorkspaceProvenance({ - authority: runtime, - repoSelector: args.repoId, - repo, - request: args.automationProvenanceRequest - }) - const createArgs: CreateWorktreeArgsWithSystemProvenance = { - ...args, - automationProvenance - } - - let result: CreateWorktreeResult - try { - // Why: wrap only the helpers; the pre-validation throws above are IPC-shape bugs, not the git/filesystem failures the funnel tracks. - result = isFolderRepo(repo) - ? createFolderWorkspace(createArgs, repo, store) - : repo.connectionId - ? await createRemoteWorktree(createArgs, repo, store, mainWindow) - : await createLocalWorktree(createArgs, repo, store, mainWindow, runtime) - } catch (error) { - releaseAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest) - track('workspace_create_failed', { - source, - error_class: classifyWorkspaceCreateError(error), - ...getCohortAtEmit() - }) - throw error - } - finishAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest) - - // Why: reaching here means create succeeded (helpers throw); skip a separate workspace_initialized (telemetry-plan.md§Deferred); never send the branch name. - track('workspace_created', { - source, - from_existing_branch: - !isFolderRepo(repo) && - typeof args.baseBranch === 'string' && - args.baseBranch.length > 0, - ...getCohortAtEmit() - }) - - if (isFolderRepo(repo)) { - notifyWorktreesChanged(mainWindow, repo.id) - } - - options?.onWorktreeLifecycle?.({ - kind: 'created', - worktreeId: result.worktree.id, - path: result.worktree.path, - branch: result.worktree.branch - }) - - return result - }) - } - ) - - ipcMain.handle( - 'worktrees:adoptProvisionedRoot', - async (_event, rawArgs: AdoptProvisionedRootArgs): Promise => { - const args = normalizeLinkedWorkItemFields(rawArgs) - return withWorktreeSpan({ stage: 'create' }, async () => { - const repo = findExactRepoOwner(store, args.repoId, args.executionHostId) - if (!repo || isFolderRepo(repo)) { - throw new Error('Provisioned-root repository ownership is missing or ambiguous.') - } - const sourceParse = workspaceSourceSchema.safeParse(args.telemetrySource) - const source: WorkspaceSource = sourceParse.success ? sourceParse.data : 'unknown' - const automationProvenance = resolveAutomationWorkspaceProvenance({ - authority: runtime, - repoSelector: args.repoId, - repo, - request: args.automationProvenanceRequest - }) - let result: CreateWorktreeResult - try { - result = await adoptProvisionedRootSshCheckout({ - userDataPath: app.getPath('userData'), - request: { ...args, automationProvenance }, - repo, - store, - isRepoCurrent: () => isCapturedRepoCurrent(store, repo, args.executionHostId) - }) - } catch (error) { - releaseAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest) - track('workspace_create_failed', { - source, - error_class: classifyWorkspaceCreateError(error), - ...getCohortAtEmit() - }) - throw error - } - finishAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest) - track('workspace_created', { - source, - from_existing_branch: false, - ...getCohortAtEmit() - }) - notifyWorktreesChanged(mainWindow, repo.id) - options?.onWorktreeLifecycle?.({ - kind: 'created', - worktreeId: result.worktree.id, - path: result.worktree.path, - branch: result.worktree.branch - }) - return result - }) - } - ) - - ipcMain.handle( - 'worktrees:resolvePrBase', - async ( - _event, - args: { - repoId: string - prNumber: number - headRefName?: string - baseRefName?: string - isCrossRepository?: boolean - } - ): Promise => { - const repo = store.getRepo(args.repoId) - if (!repo) { - return { error: 'Repo not found' } - } - if (isFolderRepo(repo)) { - return { error: 'Folder mode does not support creating worktrees.' } - } - const gitExec = async (args: string[]): Promise<{ stdout: string; stderr: string }> => { - if (!repo.connectionId) { - return gitExecFileAsync(args, getLocalProjectGitExecOptions(store, repo)) - } - const provider = getSshGitProvider(repo.connectionId) - if (!provider) { - throw new Error( - 'SSH Git provider is not available. Reconnect to this target and try again.' - ) - } - return provider.exec(args, repo.path) - } - // Why: SSH review-head fetches require narrow write-capable RPCs. - const fetchRemoteTrackingRef = (remote: string, branch: string): Promise => - fetchPrHeadTrackingRef( - repo, - repo.connectionId ? getSshGitProvider(repo.connectionId) : undefined, - remote, - branch, - { localGitExecOptions: getLocalProjectGitExecOptions(store, repo) } - ) - const fetchPullRequestHeadRef = (remote: string, prNumber: number): Promise => - fetchGitHubPullRequestHeadRef( - repo, - repo.connectionId ? getSshGitProvider(repo.connectionId) : undefined, - remote, - prNumber, - { localGitExecOptions: getLocalProjectGitExecOptions(store, repo) } - ) - - return resolveGitHubPrStartPoint({ - repoPath: repo.path, - prNumber: args.prNumber, - headRefName: args.headRefName, - baseRefName: args.baseRefName, - isCrossRepository: args.isCrossRepository, - issueSourcePreference: repo.issueSourcePreference, - connectionId: repo.connectionId ?? null, - localGitOptions: getLocalProjectWorktreeGitOptions(store, repo), - gitExec, - fetchRemoteTrackingRef, - fetchPullRequestHeadRef, - // Why: one resolver keeps source preference and hosting identity aligned - // across local, WSL, and SSH worktree creation. - resolveRemote: () => - resolveGitHubReviewHeadRemote({ - repoPath: repo.path, - issueSourcePreference: repo.issueSourcePreference, - connectionId: repo.connectionId ?? null, - localGitOptions: getLocalProjectWorktreeGitOptions(store, repo), - gitExec - }) - }) - } - ) - - // Why: keep desktop IPC and mobile/runtime RPC on the same MR-base path so SSH repos don't regress differently per surface. - ipcMain.handle( - 'worktrees:resolveMrBase', - async ( - _event, - args: { - repoId: string - mrIid: number - sourceBranch?: string - targetBranch?: string - isCrossRepository?: boolean - } - ): Promise< - | { baseBranch: string; compareBaseRef?: string; pushTarget?: GitPushTarget } - | { error: string } - > => { - return runtime.resolveManagedMrBase({ - repoSelector: `id:${args.repoId}`, - mrIid: args.mrIid, - sourceBranch: args.sourceBranch, - targetBranch: args.targetBranch, - isCrossRepository: args.isCrossRepository - }) - } - ) - - const worktreeRemovalsInFlight = new Map() - - ipcMain.handle( - 'worktrees:remove', - async (_event, args: RemoveWorktreeArgs): Promise => { - const { repoId, worktreePath } = parseWorktreeId(args.worktreeId) - const repo = getRepoForWorktreeRemoval(store, repoId, args.hostId) - if (!repo) { - throw new Error(`Repo not found: ${repoId}`) - } - // The resolved repo supplies host ownership when legacy callers omit args.hostId. - const removalHostId = getRepoExecutionHostId(repo) - const inFlightKey = getWorktreeRemovalInFlightKey(args.worktreeId, removalHostId) - const optionsKey = getWorktreeRemovalOptionsKey(args) - const inFlightRemoval = worktreeRemovalsInFlight.get(inFlightKey) - if (inFlightRemoval) { - if (inFlightRemoval.optionsKey === optionsKey) { - return inFlightRemoval.promise - } - throw new Error(`Worktree deletion already in progress: ${args.worktreeId}`) - } - - // Why: concurrent stale-toast/double-click/sidebar races can hit the same worktree; share the op so only one path touches Git and disk. - const removal = (async (): Promise => { - // Why: worktree.create is traced; delete freezes were invisible without a matching worktree.remove parent span. - return withWorktreeSpan({ stage: 'remove', path: worktreePath }, async () => { - if (isFolderRepo(repo)) { - if (args.worktreeId === getFolderWorkspaceRootId(repo)) { - throw new Error( - 'Cannot delete the project root workspace. Remove the folder project instead.' - ) - } - const ownerHost = parseExecutionHostId(removalHostId) - const sshPtyProvider = - ownerHost?.kind === 'ssh' ? getSshPtyProvider(ownerHost.targetId) : undefined - // Why: folder workspaces share one root, so there's no Git remove step to close shells; sweep PTYs before dropping metadata. - await withWorktreeRemoveStageSpan('pty_sweep', 'folder', async () => { - // Folder projects can be SSH-backed, so fence the sweep to the owning host exactly - // like the git paths — the local inventory must never reach a remote workspace's id. - // The resolved repo is authoritative here: path-derived metadata is shared by - // same-id host copies and can describe a different owner's workspace. - const externalHost = ownerHost?.kind === 'ssh' || ownerHost?.kind === 'runtime' - await killAllProcessesForWorktree(args.worktreeId, { - runtime, - resolvedWorktreeId: args.worktreeId, - ...(ownerHost?.kind === 'ssh' ? { resolvedConnectionId: ownerHost.targetId } : {}), - ...(ownerHost?.kind === 'runtime' - ? { resolvedRuntimeEnvironmentId: ownerHost.environmentId } - : {}), - localProvider: sshPtyProvider ?? getLocalPtyProvider(), - onPtyStopped: clearProviderPtyState, - ...(externalHost - ? { - includeProviderInventory: - ownerHost?.kind === 'ssh' && Boolean(sshPtyProvider), - includeLocalRegistry: false - } - : {}) - }).catch((err) => { - console.warn(`[worktree-teardown] failed for ${args.worktreeId}:`, err) - }) - }) - await withWorktreeRemoveStageSpan('metadata_purge', 'folder', async () => { - await deleteRemoteWorktreeHistory(sshPtyProvider, args.worktreeId) - removeWorktreeMetadataAndTransientState( - store, - args.worktreeId, - removalHostId, - args.snapshotPruneBatchId - ) - }) - preservedBranchCleanupByScope.delete( - preservedBranchCleanupScopeKey({ worktreeId: args.worktreeId, hostId: removalHostId }) - ) - notifyWorktreesChanged(mainWindow, repoId) - return {} - } - - // Why: renderer-supplied worktreeId embeds a path; re-derive the canonical path from git before any destructive action. - const provider = repo.connectionId ? requireSshGitProvider(repo.connectionId) : null - const localWorktreeGitOptions = repo.connectionId - ? {} - : getLocalProjectWorktreeGitOptions(store, repo) - const hasLocalWorktreeGitOptions = Object.keys(localWorktreeGitOptions).length > 0 - const registeredWorktrees = repo.connectionId - ? await provider!.listWorktrees(repo.path) - : hasLocalWorktreeGitOptions - ? await listGitWorktreesStrict(repo.path, localWorktreeGitOptions) - : await listGitWorktreesStrict(repo.path) - const removedMeta = resolveWorktreeRemovalMetadata( - store, - repoId, - args.worktreeId, - removalHostId - ) - const removedPushTarget = removedMeta?.pushTarget - const registeredWorktree = findRegisteredDeletableWorktree( - repo.path, - worktreePath, - registeredWorktrees - ) - if (!registeredWorktree) { - const fsProvider = repo.connectionId - ? getSshFilesystemProvider(repo.connectionId) - : null - let canCleanOrphanedDirectory = false - if ( - canCleanupUnregisteredOrcaWorktreeDirectory({ - meta: removedMeta - }) - ) { - if (repo.connectionId) { - if (!fsProvider) { - throw new Error('SSH filesystem provider unavailable') - } - if (!fsProvider.lstat) { - throw new Error('SSH filesystem provider lstat unavailable') - } - canCleanOrphanedDirectory = await canSafelyRemoveOrphanedWorktreeDirectory( - worktreePath, - repo.path, - (path) => fsProvider.lstat!(path), - (path) => fsProvider.readFile(path) - ) - } else { - const access = getLocalWorktreePathAccess(localWorktreeGitOptions) - canCleanOrphanedDirectory = - !isDangerousWorktreeRemovalPath(worktreePath, repo.path) && - (await canSafelyRemoveOrphanedWorktreeDirectory( - toLocalWorktreeRuntimePath(worktreePath, localWorktreeGitOptions), - toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), - access.statPath, - access.readPath - )) - } - } - if (canCleanOrphanedDirectory) { - assertWorktreeDoesNotContainRegisteredWorktree(worktreePath, registeredWorktrees) - if (!args.force) { - throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE) - } - if (repo.connectionId) { - const removalGate = await runtime.acquireFileWatcherRemoval( - worktreePath, - repo.connectionId - ) - let removalCompleted = false - try { - await stopPtysForDestructiveWorktreeRemoval(runtime, args.worktreeId, { - connectionId: repo.connectionId, - allowUnverifiedStop: args.allowUnverifiedPtyStop - }) - await fsProvider!.deletePath(worktreePath, true) - removalCompleted = true - } finally { - await removalGate.finish(removalCompleted) - } - // Why history first: the worktree is already gone from git and - // disk by here, so a rejecting push-target cleanup must not be - // able to skip history removal and leave the user's commands on - // the remote host. - await deleteRemoteWorktreeHistory( - getSshPtyProvider(repo.connectionId), - args.worktreeId - ) - await cleanupUnusedWorktreePushTargetRemoteSsh( - provider!, - repo.path, - args.worktreeId, - removedPushTarget, - store - ) - } else { - const removalGate = await runtime.acquireFileWatcherRemoval(worktreePath) - let removalCompleted = false - try { - await stopPtysForDestructiveWorktreeRemoval(runtime, args.worktreeId, { - allowUnverifiedStop: args.allowUnverifiedPtyStop - }) - await removeLocalWorktreePath(worktreePath, localWorktreeGitOptions) - removalCompleted = true - } finally { - await removalGate.finish(removalCompleted) - } - await cleanupUnusedWorktreePushTargetRemote( - repo.path, - args.worktreeId, - removedPushTarget, - store, - localWorktreeGitOptions - ) - invalidateAuthorizedRootsCache() - } - runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState( - store, - args.worktreeId, - removalHostId, - args.snapshotPruneBatchId - ) - preservedBranchCleanupByScope.delete( - preservedBranchCleanupScopeKey({ - worktreeId: args.worktreeId, - hostId: removalHostId - }) - ) - notifyWorktreesChanged(mainWindow, repoId) - return {} - } - if (!repo.connectionId) { - const access = getLocalWorktreePathAccess(localWorktreeGitOptions) - const runtimeWorktreePath = toLocalWorktreeRuntimePath( - worktreePath, - localWorktreeGitOptions - ) - if ( - await canCleanupUnregisteredOrcaLeftoverDirectory({ - meta: removedMeta, - worktreePath, - runtimeWorktreePath, - repo, - runtimeRepoPath: toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), - registeredWorktrees, - statPath: access.statPath, - isGitRepository: (path) => isLocalGitRepository(path, localWorktreeGitOptions) - }) - ) { - if (!args.force) { - throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE) - } - const removalGate = await runtime.acquireFileWatcherRemoval(worktreePath) - let removalCompleted = false - try { - await stopPtysForDestructiveWorktreeRemoval(runtime, args.worktreeId, { - allowUnverifiedStop: args.allowUnverifiedPtyStop - }) - await removeLocalWorktreePath(worktreePath, localWorktreeGitOptions) - removalCompleted = true - } finally { - await removalGate.finish(removalCompleted) - } - await cleanupUnusedWorktreePushTargetRemote( - repo.path, - args.worktreeId, - removedPushTarget, - store, - localWorktreeGitOptions - ) - runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState( - store, - args.worktreeId, - removalHostId, - args.snapshotPruneBatchId - ) - preservedBranchCleanupByScope.delete( - preservedBranchCleanupScopeKey({ - worktreeId: args.worktreeId, - hostId: removalHostId - }) - ) - invalidateAuthorizedRootsCache() - notifyWorktreesChanged(mainWindow, repoId) - return {} - } - } - if (await isAlreadyRemovedWorktreePath(repo, worktreePath, localWorktreeGitOptions)) { - if (!args.force && !removedMeta) { - // Why: without persisted metadata, require the renderer recovery path before deleting Orca-only state for an unregistered path. - throw new Error(UNREGISTERED_MISSING_WORKTREE_MESSAGE) - } - // Why: a manually deleted worktree is already gone; persisted metadata proves it was an Orca-known row, so no force is needed. - if (repo.connectionId) { - // Why history first: the worktree is already gone from git and - // disk by here, so a rejecting push-target cleanup must not be - // able to skip history removal and leave the user's commands on - // the remote host. - await deleteRemoteWorktreeHistory( - getSshPtyProvider(repo.connectionId), - args.worktreeId - ) - await cleanupUnusedWorktreePushTargetRemoteSsh( - provider!, - repo.path, - args.worktreeId, - removedPushTarget, - store - ) - } else { - await cleanupUnusedWorktreePushTargetRemote( - repo.path, - args.worktreeId, - removedPushTarget, - store, - localWorktreeGitOptions - ) - invalidateAuthorizedRootsCache() - } - runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState( - store, - args.worktreeId, - removalHostId, - args.snapshotPruneBatchId - ) - preservedBranchCleanupByScope.delete( - preservedBranchCleanupScopeKey({ - worktreeId: args.worktreeId, - hostId: removalHostId - }) - ) - notifyWorktreesChanged(mainWindow, repoId) - return {} - } - throw new Error(`Refusing to delete unregistered worktree path: ${worktreePath}`) - } - const canonicalWorktreePath = registeredWorktree.path - const deleteBranch = removedMeta?.preserveBranchOnDelete !== true - - // Why: a Git lock must block before archive hooks or linked-path cleanup mutate the workspace; dirty-file force is separate. - try { - assertWorktreeUnlockedForRemoval(registeredWorktree) - } catch (error) { - throw new Error( - formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false) - ) - } - - // Why: a prior forced Windows recovery can delete the dir but leave a stale Git registration; verify before clearing metadata. - if ( - !repo.connectionId && - args.force === true && - process.platform === 'win32' && - (isWindowsAbsolutePathLike(canonicalWorktreePath) || - !!localWorktreeGitOptions.wslDistro) && - removedMeta && - (await isAlreadyRemovedWorktreePath( - repo, - canonicalWorktreePath, - localWorktreeGitOptions - )) - ) { - const removalResult = await removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval({ - canonicalWorktreePath, - repoPath: repo.path, - localWorktreeGitOptions, - registeredWorktree, - deleteBranch - }) - await cleanupUnusedWorktreePushTargetRemote( - repo.path, - args.worktreeId, - removedPushTarget, - store, - localWorktreeGitOptions - ) - rememberPreservedBranchCleanupTarget( - args.worktreeId, - removalHostId, - removalResult, - registeredWorktree.head, - removedPushTarget - ) - runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState( - store, - args.worktreeId, - removalHostId, - args.snapshotPruneBatchId - ) - invalidateAuthorizedRootsCache() - notifyWorktreesChanged(mainWindow, repoId) - return removalResult ?? {} - } - - // Run archive hook before removal so teardown scripts still see the worktree directory. - const hooks = await getArchiveHooksForRemoval(repo) - const archiveScript = hooks?.scripts.archive - if (archiveScript && !args.skipArchive) { - // Why the branch on connectionId: this block is shared by both flows, so a hardcoded - // 'remote' would file every local archive hook under the SSH breakdown. - await withWorktreeRemoveStageSpan( - 'archive_hook', - repo.connectionId ? 'remote' : 'local', - async () => { - const result = repo.connectionId - ? await runRemoteArchiveHook(repo, canonicalWorktreePath, archiveScript) - : await runHook( - 'archive', - canonicalWorktreePath, - repo, - undefined, - localWorktreeGitOptions - ) - if (!result.success) { - console.error( - `[hooks] archive hook failed for ${canonicalWorktreePath}:`, - result.output - ) - } - } - ) - } - - const remoteConnectionId = repo.connectionId ?? undefined - if (remoteConnectionId) { - // Why: SSH deletion mirrors the local flow — hooks run while the directory is intact, then the clean check guards removal. - if (!args.force) { - const { clean, stdout } = await provider!.worktreeIsClean(canonicalWorktreePath) - if (!clean) { - const error = new Error('Worktree has uncommitted or untracked changes.') - ;(error as Error & { stdout?: string }).stdout = stdout - throw error - } - } - - const remoteRemoveOptions = !deleteBranch ? { deleteBranch } : {} - const removalGate = await withWorktreeRemoveStageSpan( - 'watcher_gate', - 'remote', - async () => - runtime.acquireFileWatcherRemoval(canonicalWorktreePath, remoteConnectionId) - ) - let rawRemovalResult: RemoveWorktreeResult | undefined - let removalCompleted = false - try { - await withWorktreeRemoveStageSpan('pty_sweep', 'remote', async () => { - await stopPtysForDestructiveWorktreeRemoval(runtime, args.worktreeId, { - connectionId: remoteConnectionId, - allowUnverifiedStop: args.allowUnverifiedPtyStop - }) - }) - rawRemovalResult = await withWorktreeRemoveStageSpan( - 'git_remove', - 'remote', - async () => - Object.keys(remoteRemoveOptions).length > 0 - ? provider!.removeWorktree( - canonicalWorktreePath, - args.force, - remoteRemoveOptions - ) - : provider!.removeWorktree(canonicalWorktreePath, args.force) - ) - removalCompleted = true - } finally { - await removalGate.finish(removalCompleted) - } - const removalResult = preserveBranchHeadFallback( - rawRemovalResult, - registeredWorktree.head - ) - await cleanupUnusedWorktreePushTargetRemoteSsh( - provider!, - repo.path, - args.worktreeId, - removedPushTarget, - store - ) - await deleteRemoteWorktreeHistory( - getSshPtyProvider(remoteConnectionId), - args.worktreeId - ) - rememberPreservedBranchCleanupTarget( - args.worktreeId, - removalHostId, - removalResult, - registeredWorktree.head, - removedPushTarget - ) - runtime.clearOptimisticReconcileToken(args.worktreeId) - await withWorktreeRemoveStageSpan('metadata_purge', 'remote', async () => { - removeWorktreeMetadataAndTransientState( - store, - args.worktreeId, - removalHostId, - args.snapshotPruneBatchId - ) - }) - notifyWorktreesChanged(mainWindow, repoId) - return removalResult ?? {} - } - - const refreshedWorktrees = hasLocalWorktreeGitOptions - ? await listGitWorktreesStrict(repo.path, localWorktreeGitOptions) - : await listGitWorktreesStrict(repo.path) - const refreshedRegisteredWorktree = findRegisteredDeletableWorktree( - repo.path, - canonicalWorktreePath, - refreshedWorktrees - ) - if (!refreshedRegisteredWorktree) { - throw new Error( - `Worktree registration changed during deletion: ${canonicalWorktreePath}. Retry deletion.` - ) - } - try { - // Why: an archive hook can race another Git client that locks the row; recheck before linked-path/watcher/terminal teardown. - assertWorktreeUnlockedForRemoval(refreshedRegisteredWorktree) - } catch (error) { - throw new Error( - formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false) - ) - } - - // Why: `orca.yaml` shared directories are symlinked in too, and a - // directory-only ignore rule leaves those links untracked, so removal must - // tolerate and unlink them exactly like the per-user shared paths. - const linkedPaths = getWorktreeSharedLinkPaths(repo) - const ignoredLinkedPaths = args.force - ? [] - : await findExistingWorktreeSymlinkPaths(canonicalWorktreePath, linkedPaths) - try { - await (hasLocalWorktreeGitOptions - ? assertWorktreeCleanForRemoval(canonicalWorktreePath, args.force ?? false, { - ...localWorktreeGitOptions, - ...(ignoredLinkedPaths.length > 0 - ? { ignoredUntrackedPaths: ignoredLinkedPaths } - : {}) - }) - : ignoredLinkedPaths.length > 0 - ? assertWorktreeCleanForRemoval(canonicalWorktreePath, args.force ?? false, { - ignoredUntrackedPaths: ignoredLinkedPaths - }) - : assertWorktreeCleanForRemoval(canonicalWorktreePath, args.force ?? false)) - } catch (error) { - if (!isOrphanCompatiblePreflightError(error)) { - throw new Error( - formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false) - ) - } - // Why: Git can still classify this as an orphan after preflight; keep strict PTY teardown before any recursive fallback deletion. - } - - let removalResult: RemoveWorktreeResult | undefined - const removalGate = await withWorktreeRemoveStageSpan('watcher_gate', 'local', async () => - runtime.acquireFileWatcherRemoval(canonicalWorktreePath) - ) - let removalCompleted = false - try { - // Why: hold the watcher/terminal gate through Git and any recursive fallback so no late spawn recreates a native handle. - // Linked-path deletion is destructive too, so PTYs must release every handle before Windows or WSL filesystem cleanup starts. - await withWorktreeRemoveStageSpan('pty_sweep', 'local', async () => { - await stopPtysForDestructiveWorktreeRemoval(runtime, args.worktreeId, { - allowUnverifiedStop: args.allowUnverifiedPtyStop - }) - }) - - // Why: preflight only ignored these paths, not mutated them; keep watcher installs fenced through Git removal. - if (linkedPaths.length > 0) { - await removeWorktreeLinkedPaths(canonicalWorktreePath, linkedPaths) - } - - try { - const removeOptions = { - ...(!deleteBranch ? { deleteBranch } : {}), - // Why: reuse the authoritative worktree list already computed here instead of rescanning siblings on the hot delete path. - knownRemovedWorktree: refreshedRegisteredWorktree, - ...(hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {}) - } - removalResult = preserveBranchHeadFallback( - await withWorktreeRemoveStageSpan('git_remove', 'local', async () => - removeWorktree( - repo.path, - canonicalWorktreePath, - args.force ?? false, - removeOptions - ) - ), - refreshedRegisteredWorktree.head - ) - } catch (error) { - // Why: Git for Windows can deregister a clean worktree before its recursive filesystem deletion fails transiently. - const recoveredRemovalResult = await recoverLocalWindowsWorktreeRemoval({ - error, - force: args.force ?? false, - canonicalWorktreePath, - repoPath: repo.path, - localWorktreeGitOptions, - registeredWorktree: refreshedRegisteredWorktree, - deleteBranch, - closeWatcher: (worktreePath) => runtime.closeFileWatchersForRemoval(worktreePath) - }) - if (recoveredRemovalResult) { - removalResult = recoveredRemovalResult - removalCompleted = true - } else if (isOrphanedWorktreeError(error)) { - // If git no longer tracks this worktree, clean up the directory and metadata - console.warn( - `[worktrees] Orphaned worktree detected at ${canonicalWorktreePath}, cleaning up` - ) - const access = getLocalWorktreePathAccess(localWorktreeGitOptions) - if ( - await canSafelyRemoveOrphanedWorktreeDirectory( - toLocalWorktreeRuntimePath(canonicalWorktreePath, localWorktreeGitOptions), - toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), - access.statPath, - access.readPath - ) - ) { - await runtime.closeFileWatchersForRemoval(canonicalWorktreePath) - await removeLocalWorktreePath( - canonicalWorktreePath, - localWorktreeGitOptions - ).catch(() => {}) - } else { - console.warn( - `[worktrees] Refusing recursive cleanup for unproven worktree directory: ${canonicalWorktreePath}` - ) - } - // Why: remove failed so git still tracks it (.git/worktrees/); prune or the stale entry keeps its branch locked. - await gitExecFileAsync(['worktree', 'prune'], { - cwd: repo.path, - ...localWorktreeGitOptions - }).catch(() => {}) - await cleanupUnusedWorktreePushTargetRemote( - repo.path, - args.worktreeId, - removedPushTarget, - store, - localWorktreeGitOptions - ) - runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState( - store, - args.worktreeId, - removalHostId, - args.snapshotPruneBatchId - ) - preservedBranchCleanupByScope.delete( - preservedBranchCleanupScopeKey({ - worktreeId: args.worktreeId, - hostId: removalHostId - }) - ) - invalidateAuthorizedRootsCache() - notifyWorktreesChanged(mainWindow, repoId) - removalCompleted = true - return {} - } else { - throw new Error( - formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false) - ) - } - } - removalCompleted = true - } finally { - await removalGate.finish(removalCompleted) - } - await cleanupUnusedWorktreePushTargetRemote( - repo.path, - args.worktreeId, - removedPushTarget, - store, - localWorktreeGitOptions - ) - rememberPreservedBranchCleanupTarget( - args.worktreeId, - removalHostId, - removalResult, - refreshedRegisteredWorktree.head, - removedPushTarget - ) - runtime.clearOptimisticReconcileToken(args.worktreeId) - await withWorktreeRemoveStageSpan('metadata_purge', 'local', async () => { - removeWorktreeMetadataAndTransientState( - store, - args.worktreeId, - removalHostId, - args.snapshotPruneBatchId - ) - }) - await withWorktreeRemoveStageSpan('cache_invalidation', 'local', async () => { - invalidateAuthorizedRootsCache() - }) - - notifyWorktreesChanged(mainWindow, repoId) - return removalResult ?? {} - }) - })() - worktreeRemovalsInFlight.set(inFlightKey, { optionsKey, promise: removal }) - try { - const result = await removal - options?.onWorktreeLifecycle?.({ - kind: 'removed', - worktreeId: args.worktreeId, - path: parseWorktreeId(args.worktreeId).worktreePath - }) - return result - } finally { - if (worktreeRemovalsInFlight.get(inFlightKey)?.promise === removal) { - worktreeRemovalsInFlight.delete(inFlightKey) - } - } - } - ) - - // Why: drop a workspace locally with no remote work, so one pinned to a dead SSH target (where worktrees:remove throws) can still be cleared. - ipcMain.handle( - 'worktrees:forgetLocal', - async ( - _event, - args: Pick - ): Promise => { - const { repoId } = parseWorktreeId(args.worktreeId) - const repoOwner = resolveWorktreeRemovalRepoOwner(store, repoId, args.hostId) - if (!args.hostId && repoOwner.kind === 'ambiguous') { - throw new Error( - `Workspace identity is ambiguous across hosts: ${args.worktreeId}. Retry with an explicit host.` - ) - } - const repo = repoOwner.kind === 'resolved' ? repoOwner.repo : undefined - // Repo-first (unlike owner resolution below) so this key matches worktrees:remove's; meta only covers ownerless forgets. - const inFlightKey = getWorktreeRemovalInFlightKey( - args.worktreeId, - repo - ? getRepoExecutionHostId(repo) - : (args.hostId ?? store.getWorktreeMeta(args.worktreeId)?.hostId) - ) - const optionsKey = 'forget-local' - const inFlight = worktreeRemovalsInFlight.get(inFlightKey) - if (inFlight) { - if (inFlight.optionsKey === optionsKey) { - return inFlight.promise - } - throw new Error(`Worktree deletion already in progress: ${args.worktreeId}`) - } - - const forget = (async (): Promise => { - const isFolderRootOf = (candidate: Repo): boolean => - isFolderRepo(candidate) && args.worktreeId === getFolderWorkspaceRootId(candidate) - const fallbackRepos = args.hostId - ? store - .getRepos() - .filter((candidate) => getRepoExecutionHostId(candidate) === args.hostId) - : store.getRepos() - if (repo ? isFolderRootOf(repo) : fallbackRepos.some(isFolderRootOf)) { - throw new Error( - 'Cannot delete the project root workspace. Remove the folder project instead.' - ) - } - - const ownerHostId = resolveWorktreeRemovalOwnerHostId( - store, - args.worktreeId, - repo, - args.hostId - ) - const ownerHost = parseExecutionHostId(ownerHostId) - const sshPtyProvider = - ownerHost?.kind === 'ssh' ? getSshPtyProvider(ownerHost.targetId) : undefined - const externalHost = ownerHost?.kind === 'ssh' || ownerHost?.kind === 'runtime' - // External host inventories must never sweep a same-id local workspace. - await killAllProcessesForWorktree(args.worktreeId, { - runtime, - resolvedWorktreeId: args.worktreeId, - ...(ownerHost?.kind === 'ssh' ? { resolvedConnectionId: ownerHost.targetId } : {}), - ...(ownerHost?.kind === 'runtime' - ? { resolvedRuntimeEnvironmentId: ownerHost.environmentId } - : {}), - localProvider: sshPtyProvider ?? getLocalPtyProvider(), - onPtyStopped: clearProviderPtyState, - ...(externalHost - ? { - includeProviderInventory: ownerHost?.kind === 'ssh' && Boolean(sshPtyProvider), - includeLocalRegistry: false - } - : {}) - }).catch((err) => { - console.warn(`[worktree-teardown] forget-local failed for ${args.worktreeId}:`, err) - }) - - runtime.clearOptimisticReconcileToken(args.worktreeId) - // The resolved owner, not args.hostId: an orphan forget with no hostId still has to purge its SSH/runtime partition. - removeWorktreeMetadataAndTransientState( - store, - args.worktreeId, - ownerHost?.id, - args.snapshotPruneBatchId - ) - // Why: cached roots outlive the forgotten workspace, so an ownerless path stays filesystem-authorized until a rebuild. - invalidateAuthorizedRootsCache() - if (ownerHost?.id) { - preservedBranchCleanupByScope.delete( - preservedBranchCleanupScopeKey({ worktreeId: args.worktreeId, hostId: ownerHost.id }) - ) - } else { - for (const [key, target] of preservedBranchCleanupByScope) { - if (target.worktreeId === args.worktreeId) { - preservedBranchCleanupByScope.delete(key) - } - } - } - notifyWorktreesChanged(mainWindow, repoId) - return {} - })() - worktreeRemovalsInFlight.set(inFlightKey, { optionsKey, promise: forget }) - try { - return await forget - } finally { - if (worktreeRemovalsInFlight.get(inFlightKey)?.promise === forget) { - worktreeRemovalsInFlight.delete(inFlightKey) - } - } - } - ) - - ipcMain.handle( - 'worktrees:forceDeletePreservedBranch', - async ( - _event, - args: { - worktreeId: string - branchName: string - expectedHead: string - hostId?: ExecutionHostId - } - ): Promise => { - const { repoId } = parseWorktreeId(args.worktreeId) - const cleanupTarget = getPreservedBranchCleanupTarget( - args.worktreeId, - args.branchName, - args.expectedHead, - args.hostId - ) - const repo = getRepoForWorktreeRemoval(store, repoId, cleanupTarget.hostId) - if (!repo) { - throw new Error(`Repo not found: ${repoId}`) - } - if (isFolderRepo(repo)) { - throw new Error('Folder workspaces do not have local Git branches.') - } - - if (repo.connectionId) { - const provider = requireSshGitProvider(repo.connectionId) - // Why: SSH needs the write-capable relay RPC; the read-only git.exec allowlist rejects these worktree/update-ref/config writes. - await provider.forceDeletePreservedBranch( - repo.path, - cleanupTarget.branchName, - cleanupTarget.head - ) - await cleanupUnusedWorktreePushTargetRemoteSsh( - provider, - repo.path, - args.worktreeId, - cleanupTarget.pushTarget, - store - ) - } else { - const localWorktreeGitOptions = getLocalProjectWorktreeGitOptions(store, repo) - const hasLocalWorktreeGitOptions = Object.keys(localWorktreeGitOptions).length > 0 - await (hasLocalWorktreeGitOptions - ? forceDeleteLocalBranch( - repo.path, - cleanupTarget.branchName, - cleanupTarget.head, - (argv, cwd) => gitExecFileAsync(argv, { cwd, ...localWorktreeGitOptions }) - ) - : forceDeleteLocalBranch(repo.path, cleanupTarget.branchName, cleanupTarget.head)) - await cleanupUnusedWorktreePushTargetRemote( - repo.path, - args.worktreeId, - cleanupTarget.pushTarget, - store, - localWorktreeGitOptions - ) - } - - preservedBranchCleanupByScope.delete( - preservedBranchCleanupScopeKey({ - worktreeId: args.worktreeId, - hostId: cleanupTarget.hostId - }) - ) - return { deleted: true } - } - ) - - ipcMain.handle( - 'worktrees:updateMeta', - (_event, args: { worktreeId: string; updates: Partial }) => { - const validatedUpdates = normalizeLinkedWorkItemFields(args.updates) - const updates = - validatedUpdates.displayName !== undefined - ? { - ...validatedUpdates, - pendingFirstAgentMessageRename: false, - firstAgentMessageRenameError: null - } - : validatedUpdates - const meta = store.setWorktreeMeta(args.worktreeId, stripOrcaProvenanceMetaUpdates(updates)) - // Do NOT notify here: renderer already applied this optimistically; a notification would re-sort the sidebar (bug PR #209). - if (args.updates.displayName !== undefined) { - // Why: remote clients have no optimistic rename and stopped polling titles, so push a remote-only invalidation; gate on displayName so per-click isUnread updates stay event-free. - runtime.notifyWorktreesChangedForRemoteClients(getRepoIdFromWorktreeId(args.worktreeId)) - } - return meta - } - ) - - ipcMain.handle('worktrees:listLineage', async () => { - await runtime.hydrateInferredWorktreeLineage() - return { - lineage: store.getAllWorktreeLineage(), - workspaceLineage: store.getAllWorkspaceLineage() - } - }) - - ipcMain.handle( - 'worktrees:listLineageForHost', - (_event, args: ListDesktopLineageForHostArgs): Promise => - listDesktopLineageForHost(store, runtime, args) - ) - - ipcMain.handle( - 'worktrees:updateLineage', - async (_event, args: { worktreeId: string; parentWorktreeId?: string; noParent?: boolean }) => { - await runtime.updateManagedWorktreeMeta(args.worktreeId, { - lineage: - args.noParent === true - ? { noParent: true } - : args.parentWorktreeId - ? { parentWorktree: `id:${args.parentWorktreeId}` } - : undefined - }) - notifyWorktreesChanged(mainWindow, parseWorktreeId(args.worktreeId).repoId) - return store.getWorktreeLineage(args.worktreeId) ?? null - } - ) - - // Why: snapshot sidebar order for cold-start restore (ephemeral signals gone); one batch call avoids N updateMeta IPCs. - ipcMain.handle('worktrees:persistSortOrder', (_event, args: { orderedIds: string[] }) => { - if (!Array.isArray(args?.orderedIds) || args.orderedIds.length === 0) { - return - } - const updates = planWorktreeSortOrderUpdates( - args.orderedIds, - (worktreeId) => store.getWorktreeMeta(worktreeId), - Date.now() - ) - for (const update of updates) { - store.setWorktreeMeta(update.worktreeId, { sortOrder: update.sortOrder }) - } - }) - - // Why: full failure output lives only in main memory (not worktree metadata), so the dialog pulls it on demand. - ipcMain.handle( - 'worktrees:getBranchRenameFailureOutput', - (_event, args: { worktreeId: string }) => { - if (typeof args?.worktreeId !== 'string' || args.worktreeId.length === 0) { - return null - } - return readBranchRenameFailureOutputForDisplay(args.worktreeId) - } - ) - - ipcMain.handle( - 'hooks:check', - async (_event, args: { repoId: string; hostId?: ExecutionHostId }) => { - const repo = getRepoForWorktreeRemoval(store, args.repoId, args.hostId) - if (!repo) { - const repoIdExists = store.getRepos().some((candidate) => candidate.id === args.repoId) - // Why: callers treat inspection errors as "skip", so a requested/ambiguous host must report error (fail closed), not hook-free. - return { - status: args.hostId || repoIdExists ? 'error' : 'ok', - hasHooks: false, - hooks: null, - mayNeedUpdate: false - } - } - if (isFolderRepo(repo)) { - return { status: 'ok', hasHooks: false, hooks: null, mayNeedUpdate: false } - } - - if (repo.connectionId) { - const fsProvider = getSshFilesystemProvider(repo.connectionId) - if (!fsProvider) { - return { status: 'error', hasHooks: false, hooks: null, mayNeedUpdate: false } - } - try { - const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) - return { - status: 'ok', - hasHooks: !result.isBinary, - hooks: result.isBinary ? null : parseOrcaYaml(result.content), - mayNeedUpdate: false - } - } catch (error) { - return { - status: isENOENT(error) ? 'ok' : 'error', - hasHooks: false, - hooks: null, - mayNeedUpdate: false - } - } - } - - const has = hasHooksFile(repo.path) - const hooks = has ? loadHooks(repo.path) : null - // Why: unrecognised top-level keys mean the file is well-formed but from a newer Orca; suggest updating rather than "could not be parsed". - const mayNeedUpdate = has && !hooks && hasUnrecognizedOrcaYamlKeys(repo.path) - return { - status: 'ok', - hasHooks: has, - hooks, - mayNeedUpdate - } - } - ) - - ipcMain.handle( - 'hooks:createIssueCommandRunner', - (_event, args: { repoId: string; worktreePath: string; command: string }) => { - const repo = store.getRepo(args.repoId) - if (!repo) { - throw new Error(`Repo not found: ${args.repoId}`) - } - - return createIssueCommandRunnerScript( - repo, - args.worktreePath, - args.command, - getLocalProjectWorktreeGitOptions(store, repo), - resolveSetupRunnerShell(store.getSettings()) - ) - } - ) - - ipcMain.handle( - 'hooks:inspectSetupScriptImports', - async (_event, args: { repoId: string; hostId?: ExecutionHostId }) => { - const repo = getRepoForWorktreeRemoval(store, args.repoId, args.hostId) - if (!repo || isFolderRepo(repo)) { - return [] - } - - return inspectSetupScriptImportCandidates( - async (relativePath) => { - const filePath = joinWorktreeRelativePath(repo.path, relativePath) - if (repo.connectionId) { - const fsProvider = getSshFilesystemProvider(repo.connectionId) - if (!fsProvider) { - return null - } - try { - const result = await fsProvider.readFile(filePath) - return result.isBinary ? null : result.content - } catch { - return null - } - } - - try { - return await readFile(filePath, 'utf-8') - } catch (error) { - if (!isENOENT(error)) { - console.warn('[hooks] Failed to inspect setup script import candidate:', error) - } - return null - } - }, - { - fileExists: async (relativePath) => { - const filePath = joinWorktreeRelativePath(repo.path, relativePath) - if (repo.connectionId) { - const fsProvider = getSshFilesystemProvider(repo.connectionId) - if (!fsProvider) { - return false - } - try { - const fileStat = await fsProvider.stat(filePath) - return fileStat.type !== 'directory' - } catch { - return false - } - } - - try { - const fileStat = await stat(filePath) - return !fileStat.isDirectory() - } catch (error) { - if (!isENOENT(error)) { - console.warn('[hooks] Failed to stat setup script import candidate:', error) - } - return false - } - } - } - ) - } - ) - - ipcMain.handle( - 'hooks:readIssueCommand', - async (_event, args: { repoId: string; hostId?: ExecutionHostId }) => { - const repo = getRepoForWorktreeRemoval(store, args.repoId, args.hostId) - if (!repo || isFolderRepo(repo)) { - return { - status: 'ok', - localContent: null, - sharedContent: null, - effectiveContent: null, - localFilePath: '', - source: 'none' as const - } - } - if (repo.connectionId) { - const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command') - const fsProvider = getSshFilesystemProvider(repo.connectionId) - if (!fsProvider) { - return { - status: 'error', - localContent: null, - sharedContent: null, - effectiveContent: null, - localFilePath: issueCommandPath, - source: 'none' as const - } - } - - let status: 'ok' | 'error' = 'ok' - let localContent: string | null = null - let sharedContent: string | null = null - try { - const result = await fsProvider.readFile(issueCommandPath) - localContent = result.isBinary ? null : result.content.trim() || null - } catch (error) { - if (!isENOENT(error)) { - status = 'error' - } - } - try { - const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) - sharedContent = result.isBinary - ? null - : parseOrcaYaml(result.content)?.issueCommand?.trim() || null - } catch (error) { - if (!isENOENT(error)) { - status = 'error' - } - } - const effectiveContent = localContent ?? sharedContent - return { - status: localContent ? 'ok' : status, - localContent, - sharedContent, - effectiveContent, - localFilePath: issueCommandPath, - source: localContent - ? ('local' as const) - : sharedContent - ? ('shared' as const) - : ('none' as const) - } - } - return readIssueCommand(repo.path) - } - ) - - ipcMain.handle( - 'hooks:writeIssueCommand', - async (_event, args: { repoId: string; content: string; hostId?: ExecutionHostId }) => { - const repo = getRepoForWorktreeRemoval(store, args.repoId, args.hostId) - if (!repo || isFolderRepo(repo)) { - return - } - if (repo.connectionId) { - const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command') - const fsProvider = getSshFilesystemProvider(repo.connectionId) - if (!fsProvider) { - throw new Error( - 'Remote filesystem unavailable. Reconnect the SSH target before retrying.' - ) - } - const trimmed = args.content.trim() - if (!trimmed) { - await fsProvider.deletePath(issueCommandPath, false).catch((error: unknown) => { - if (!isENOENT(error)) { - throw error - } - }) - return - } - await fsProvider.createDir(joinWorktreeRelativePath(repo.path, '.orca')) - const gitignorePath = joinWorktreeRelativePath(repo.path, '.gitignore') - try { - const result = await fsProvider.readFile(gitignorePath) - if (!result.isBinary && !/^\.orca\/?$/m.test(result.content)) { - const separator = result.content.endsWith('\n') ? '' : '\n' - await fsProvider.writeFile(gitignorePath, `${result.content}${separator}.orca\n`) - } - } catch (error) { - if (!isENOENT(error)) { - throw error - } - await fsProvider.writeFile(gitignorePath, '.orca\n') - } - await fsProvider.writeFile(issueCommandPath, `${trimmed}\n`) - return - } - writeIssueCommand(repo.path, args.content) - } - ) + registerWorktreeCatalogHandlers(context) + registerHostCatalogHandlers(context) + registerDetectedWorktreeHandlers(context) + registerWorktreePrefetchHandler(context) + registerWorktreeCreateHandlers(context) + registerReviewBaseHandlers(context) + registerWorktreeRemovalHandlers(context) + registerWorktreeForgetHandlers(context) + registerWorktreeMetadataHandlers(context) + registerWorktreeHookCheckHandler(context) + registerWorktreeHookRunnerHandler(context) + registerWorktreeHookInspectionHandler(context) + registerWorktreeHookFileHandlers(context) } diff --git a/src/main/ipc/worktrees/create/folder-workspace-creation.ts b/src/main/ipc/worktrees/create/folder-workspace-creation.ts new file mode 100644 index 00000000000..9a1bc65fc02 --- /dev/null +++ b/src/main/ipc/worktrees/create/folder-workspace-creation.ts @@ -0,0 +1,55 @@ +import { randomUUID } from 'node:crypto' +import type { Repo } from '../../../../shared/repo-types' +import { getProjectHostSetupWorktreeMeta } from '../../../../shared/project-host-setup-projection' +import type { CreateWorktreeResult } from '../../../../shared/worktree/create-types' +import type { Store } from '../../../persistence/loading-store/store' +import type { CreateWorktreeArgsWithSystemProvenance } from '../ipc-context-schemas' +import { getFolderWorkspaceInstanceId, mergeFolderWorkspace } from '../folder-workspace-model' + +export function createFolderWorkspace( + args: CreateWorktreeArgsWithSystemProvenance, + repo: Repo, + store: Store +): CreateWorktreeResult { + const now = Date.now() + const instanceId = randomUUID() + const worktreeId = getFolderWorkspaceInstanceId(repo, instanceId) + const meta = store.setWorktreeMeta(worktreeId, { + instanceId, + ...(store.getProjectHostSetups + ? getProjectHostSetupWorktreeMeta(store.getProjectHostSetups(), repo) + : {}), + displayName: args.displayName || args.name, + lastActivityAt: now, + createdAt: now, + orcaCreatedAt: now, + orcaCreationSource: 'desktop', + creatorProvenance: { kind: 'host' }, + ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), + ...(args.cliProvenance ? { cliProvenance: args.cliProvenance } : {}), + ...(args.createdWithAgent ? { createdWithAgent: args.createdWithAgent } : {}), + ...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}), + ...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}), + ...(args.linkedLinearIssue !== undefined ? { linkedLinearIssue: args.linkedLinearIssue } : {}), + ...(args.linkedLinearIssueWorkspaceId !== undefined + ? { linkedLinearIssueWorkspaceId: args.linkedLinearIssueWorkspaceId } + : {}), + ...(args.linkedLinearIssueOrganizationUrlKey !== undefined + ? { linkedLinearIssueOrganizationUrlKey: args.linkedLinearIssueOrganizationUrlKey } + : {}), + ...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}), + ...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}), + ...(args.linkedGitLabIssue !== undefined ? { linkedGitLabIssue: args.linkedGitLabIssue } : {}), + ...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}), + ...(args.linkedBitbucketPR !== undefined ? { linkedBitbucketPR: args.linkedBitbucketPR } : {}), + ...(args.linkedAzureDevOpsPR !== undefined + ? { linkedAzureDevOpsPR: args.linkedAzureDevOpsPR } + : {}), + ...(args.linkedGiteaPR !== undefined ? { linkedGiteaPR: args.linkedGiteaPR } : {}), + ...(args.linkedWorkItem !== undefined ? { linkedWorkItem: args.linkedWorkItem } : {}), + ...(args.linkedTaskSourceContext !== undefined + ? { linkedTaskSourceContext: args.linkedTaskSourceContext } + : {}) + }) + return { worktree: mergeFolderWorkspace(repo, worktreeId, meta) } +} diff --git a/src/main/ipc/worktrees/create/register-review-base-handlers.ts b/src/main/ipc/worktrees/create/register-review-base-handlers.ts new file mode 100644 index 00000000000..6ebe8f26fbd --- /dev/null +++ b/src/main/ipc/worktrees/create/register-review-base-handlers.ts @@ -0,0 +1,120 @@ +import { ipcMain } from 'electron' +import type { GitHubPrStartPoint, GitPushTarget } from '../../../../shared/worktree/types' +import { isFolderRepo } from '../../../../shared/repo-kind' +import { gitExecFileAsync } from '../../../git/runner' +import { + getLocalProjectGitExecOptions, + getLocalProjectWorktreeGitOptions +} from '../../../project-runtime-git-options' +import { getSshGitProvider } from '../../../providers/ssh-git-dispatch' +import { + fetchPrHeadTrackingRef, + fetchGitHubPullRequestHeadRef +} from '../../../github/pr-head-tracking-ref' +import { resolveGitHubPrStartPoint } from '../../../github/pr-start-point' +import { resolveGitHubReviewHeadRemote } from '../../../github/review-head-remote' +import type { WorktreeIpcContext } from '../worktree-ipc-context' + +export function registerReviewBaseHandlers(context: WorktreeIpcContext): void { + const { store, runtime } = context + + ipcMain.handle( + 'worktrees:resolvePrBase', + async ( + _event, + args: { + repoId: string + prNumber: number + headRefName?: string + baseRefName?: string + isCrossRepository?: boolean + } + ): Promise => { + const repo = store.getRepo(args.repoId) + if (!repo) { + return { error: 'Repo not found' } + } + if (isFolderRepo(repo)) { + return { error: 'Folder mode does not support creating worktrees.' } + } + const gitExec = async (args: string[]): Promise<{ stdout: string; stderr: string }> => { + if (!repo.connectionId) { + return gitExecFileAsync(args, getLocalProjectGitExecOptions(store, repo)) + } + const provider = getSshGitProvider(repo.connectionId) + if (!provider) { + throw new Error( + 'SSH Git provider is not available. Reconnect to this target and try again.' + ) + } + return provider.exec(args, repo.path) + } + // Why: SSH review-head fetches require narrow write-capable RPCs. + const fetchRemoteTrackingRef = (remote: string, branch: string): Promise => + fetchPrHeadTrackingRef( + repo, + repo.connectionId ? getSshGitProvider(repo.connectionId) : undefined, + remote, + branch, + { localGitExecOptions: getLocalProjectGitExecOptions(store, repo) } + ) + const fetchPullRequestHeadRef = (remote: string, prNumber: number): Promise => + fetchGitHubPullRequestHeadRef( + repo, + repo.connectionId ? getSshGitProvider(repo.connectionId) : undefined, + remote, + prNumber, + { localGitExecOptions: getLocalProjectGitExecOptions(store, repo) } + ) + + return resolveGitHubPrStartPoint({ + repoPath: repo.path, + prNumber: args.prNumber, + headRefName: args.headRefName, + baseRefName: args.baseRefName, + isCrossRepository: args.isCrossRepository, + issueSourcePreference: repo.issueSourcePreference, + connectionId: repo.connectionId ?? null, + localGitOptions: getLocalProjectWorktreeGitOptions(store, repo), + gitExec, + fetchRemoteTrackingRef, + fetchPullRequestHeadRef, + // Why: one resolver keeps source preference and hosting identity aligned + // across local, WSL, and SSH worktree creation. + resolveRemote: () => + resolveGitHubReviewHeadRemote({ + repoPath: repo.path, + issueSourcePreference: repo.issueSourcePreference, + connectionId: repo.connectionId ?? null, + localGitOptions: getLocalProjectWorktreeGitOptions(store, repo), + gitExec + }) + }) + } + ) + + ipcMain.handle( + 'worktrees:resolveMrBase', + async ( + _event, + args: { + repoId: string + mrIid: number + sourceBranch?: string + targetBranch?: string + isCrossRepository?: boolean + } + ): Promise< + | { baseBranch: string; compareBaseRef?: string; pushTarget?: GitPushTarget } + | { error: string } + > => { + return runtime.resolveManagedMrBase({ + repoSelector: `id:${args.repoId}`, + mrIid: args.mrIid, + sourceBranch: args.sourceBranch, + targetBranch: args.targetBranch, + isCrossRepository: args.isCrossRepository + }) + } + ) +} diff --git a/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts b/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts new file mode 100644 index 00000000000..775a0859790 --- /dev/null +++ b/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts @@ -0,0 +1,156 @@ +import { ipcMain, app } from 'electron' +import type { + CreateWorktreeArgs, + CreateWorktreeResult, + AdoptProvisionedRootArgs +} from '../../../../shared/worktree/create-types' +import { withWorktreeSpan } from '../../../observability/instrumentation' +import { workspaceSourceSchema } from '../../../../shared/telemetry-events' +import type { WorkspaceSource } from '../../../../shared/telemetry-events' +import { + resolveAutomationWorkspaceProvenance, + releaseAutomationWorkspaceProvenanceRequest, + finishAutomationWorkspaceProvenanceRequest +} from '../../../automations/workspace-provenance' +import { isFolderRepo } from '../../../../shared/repo-kind' +import { + createRemoteWorktree, + createLocalWorktree, + notifyWorktreesChanged +} from '../../worktree-remote' +import { track } from '../../../telemetry/client' +import { classifyWorkspaceCreateError } from '../../workspace-create-error-classifier' +import { getCohortAtEmit } from '../../../telemetry/cohort-classifier' +import { adoptProvisionedRootSshCheckout } from '../../../provisioned-root-ssh-adoption' +import { normalizeLinkedWorkItemFields } from '../ipc-context-schemas' +import type { CreateWorktreeArgsWithSystemProvenance } from '../ipc-context-schemas' +import { createFolderWorkspace } from './folder-workspace-creation' +import { findExactRepoOwner, isCapturedRepoCurrent } from '../listing/worktree-host-ownership' +import type { WorktreeIpcContext } from '../worktree-ipc-context' + +export function registerWorktreeCreateHandlers(context: WorktreeIpcContext): void { + const { mainWindow, store, runtime, options } = context + + ipcMain.handle( + 'worktrees:create', + async (_event, rawArgs: CreateWorktreeArgs): Promise => { + const args = normalizeLinkedWorkItemFields(rawArgs) + // Why span here: parent the child git spans for the trace tree; don't attach branch name/remote URL (user content) — repo ID is the safer correlator. + return withWorktreeSpan({ stage: 'create' }, async () => { + const repo = store.getRepo(args.repoId) + if (!repo) { + throw new Error(`Repo not found: ${args.repoId}`) + } + + const sourceParse = workspaceSourceSchema.safeParse(args.telemetrySource) + const source: WorkspaceSource = sourceParse.success ? sourceParse.data : 'unknown' + + const automationProvenance = resolveAutomationWorkspaceProvenance({ + authority: runtime, + repoSelector: args.repoId, + repo, + request: args.automationProvenanceRequest + }) + const createArgs: CreateWorktreeArgsWithSystemProvenance = { + ...args, + automationProvenance + } + + let result: CreateWorktreeResult + try { + // Why: wrap only the helpers; the pre-validation throws above are IPC-shape bugs, not the git/filesystem failures the funnel tracks. + result = isFolderRepo(repo) + ? createFolderWorkspace(createArgs, repo, store) + : repo.connectionId + ? await createRemoteWorktree(createArgs, repo, store, mainWindow) + : await createLocalWorktree(createArgs, repo, store, mainWindow, runtime) + } catch (error) { + releaseAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest) + track('workspace_create_failed', { + source, + error_class: classifyWorkspaceCreateError(error), + ...getCohortAtEmit() + }) + throw error + } + finishAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest) + + // Why: reaching here means create succeeded (helpers throw); skip a separate workspace_initialized (telemetry-plan.md§Deferred); never send the branch name. + track('workspace_created', { + source, + from_existing_branch: + !isFolderRepo(repo) && + typeof args.baseBranch === 'string' && + args.baseBranch.length > 0, + ...getCohortAtEmit() + }) + + if (isFolderRepo(repo)) { + notifyWorktreesChanged(mainWindow, repo.id) + } + + options?.onWorktreeLifecycle?.({ + kind: 'created', + worktreeId: result.worktree.id, + path: result.worktree.path, + branch: result.worktree.branch + }) + + return result + }) + } + ) + + ipcMain.handle( + 'worktrees:adoptProvisionedRoot', + async (_event, rawArgs: AdoptProvisionedRootArgs): Promise => { + const args = normalizeLinkedWorkItemFields(rawArgs) + return withWorktreeSpan({ stage: 'create' }, async () => { + const repo = findExactRepoOwner(store, args.repoId, args.executionHostId) + if (!repo || isFolderRepo(repo)) { + throw new Error('Provisioned-root repository ownership is missing or ambiguous.') + } + const sourceParse = workspaceSourceSchema.safeParse(args.telemetrySource) + const source: WorkspaceSource = sourceParse.success ? sourceParse.data : 'unknown' + const automationProvenance = resolveAutomationWorkspaceProvenance({ + authority: runtime, + repoSelector: args.repoId, + repo, + request: args.automationProvenanceRequest + }) + let result: CreateWorktreeResult + try { + result = await adoptProvisionedRootSshCheckout({ + userDataPath: app.getPath('userData'), + request: { ...args, automationProvenance }, + repo, + store, + isRepoCurrent: () => isCapturedRepoCurrent(store, repo, args.executionHostId) + }) + } catch (error) { + releaseAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest) + track('workspace_create_failed', { + source, + error_class: classifyWorkspaceCreateError(error), + ...getCohortAtEmit() + }) + throw error + } + finishAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest) + track('workspace_created', { + source, + from_existing_branch: false, + ...getCohortAtEmit() + }) + notifyWorktreesChanged(mainWindow, repo.id) + options?.onWorktreeLifecycle?.({ + kind: 'created', + worktreeId: result.worktree.id, + path: result.worktree.path, + branch: result.worktree.branch + }) + return result + }) + } + ) +} diff --git a/src/main/ipc/worktrees/create/register-worktree-prefetch-handler.ts b/src/main/ipc/worktrees/create/register-worktree-prefetch-handler.ts new file mode 100644 index 00000000000..19b0516f274 --- /dev/null +++ b/src/main/ipc/worktrees/create/register-worktree-prefetch-handler.ts @@ -0,0 +1,22 @@ +import { ipcMain } from 'electron' +import { prefetchWorktreeCreateBase } from '../../../worktree-create-base-prefetch' +import type { WorktreeIpcContext } from '../worktree-ipc-context' + +export function registerWorktreePrefetchHandler(context: WorktreeIpcContext): void { + const { store, runtime } = context + + ipcMain.handle( + 'worktrees:prefetchCreateBase', + async (_event, args: { repoId: string; baseBranch?: string }): Promise => { + const repo = store.getRepo(args.repoId) + if (!repo) { + return + } + try { + await prefetchWorktreeCreateBase({ repo, baseBranch: args.baseBranch, runtime }) + } catch { + // Why: optimistic warm-up; the real create path awaits the same refresh and reports failures there. + } + } + ) +} diff --git a/src/main/ipc/worktrees/folder-workspace-model.ts b/src/main/ipc/worktrees/folder-workspace-model.ts new file mode 100644 index 00000000000..8f42175fce4 --- /dev/null +++ b/src/main/ipc/worktrees/folder-workspace-model.ts @@ -0,0 +1,75 @@ +import { randomUUID } from 'node:crypto' +import type { Repo } from '../../../shared/repo-types' +import { DEFAULT_WORKSPACE_STATUS_ID } from '../../../shared/workspace-statuses' +import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../../shared/worktree/id' +import type { WorktreeMeta } from '../../../shared/worktree/meta-types' +import type { Worktree } from '../../../shared/worktree/types' + +export function getFolderWorkspaceRootId(repo: Repo): string { + return `${repo.id}::${repo.path}` +} + +export function getFolderWorkspaceInstanceId(repo: Repo, instanceId: string): string { + return `${getFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}${instanceId}` +} + +export function getFolderWorkspaceInstanceIdentity(repo: Repo, worktreeId: string): string { + const prefix = `${getFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}` + return worktreeId.startsWith(prefix) ? worktreeId.slice(prefix.length) : randomUUID() +} + +export function isFolderWorkspaceIdForRepo(repo: Repo, worktreeId: string): boolean { + const rootId = getFolderWorkspaceRootId(repo) + return ( + worktreeId === rootId || + worktreeId.startsWith(`${rootId}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}`) + ) +} + +export function mergeFolderWorkspace(repo: Repo, worktreeId: string, meta: WorktreeMeta): Worktree { + return { + id: worktreeId, + ...(meta.instanceId !== undefined ? { instanceId: meta.instanceId } : {}), + repoId: repo.id, + ...(meta.projectId !== undefined ? { projectId: meta.projectId } : {}), + ...(meta.hostId !== undefined ? { hostId: meta.hostId } : {}), + ...(meta.projectHostSetupId !== undefined + ? { projectHostSetupId: meta.projectHostSetupId } + : {}), + path: repo.path, + head: '', + branch: '', + isBare: false, + isMainWorktree: worktreeId === getFolderWorkspaceRootId(repo), + displayName: meta.displayName || repo.displayName, + comment: meta.comment || '', + linkedIssue: meta.linkedIssue ?? null, + linkedPR: meta.linkedPR ?? null, + linkedLinearIssue: meta.linkedLinearIssue ?? null, + linkedLinearIssueWorkspaceId: meta.linkedLinearIssueWorkspaceId ?? null, + linkedLinearIssueOrganizationUrlKey: meta.linkedLinearIssueOrganizationUrlKey ?? null, + linkedGitLabMR: meta.linkedGitLabMR ?? null, + linkedGitLabIssue: meta.linkedGitLabIssue ?? null, + linkedBitbucketPR: meta.linkedBitbucketPR ?? null, + linkedAzureDevOpsPR: meta.linkedAzureDevOpsPR ?? null, + linkedGiteaPR: meta.linkedGiteaPR ?? null, + linkedWorkItem: meta.linkedWorkItem ?? null, + linkedTaskSourceContext: meta.linkedTaskSourceContext ?? null, + isArchived: meta.isArchived ?? false, + isUnread: meta.isUnread ?? false, + isPinned: meta.isPinned ?? false, + sortOrder: meta.sortOrder ?? 0, + ...(meta.manualOrder !== undefined ? { manualOrder: meta.manualOrder } : {}), + lastActivityAt: meta.lastActivityAt ?? 0, + ...(meta.createdAt !== undefined ? { createdAt: meta.createdAt } : {}), + ...(meta.createdWithAgent !== undefined ? { createdWithAgent: meta.createdWithAgent } : {}), + ...(meta.automationProvenance !== undefined + ? { automationProvenance: meta.automationProvenance } + : {}), + ...(meta.cliProvenance !== undefined ? { cliProvenance: meta.cliProvenance } : {}), + ...(meta.priorWorktreeIds !== undefined ? { priorWorktreeIds: meta.priorWorktreeIds } : {}), + workspaceStatus: meta.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID, + diffComments: meta.diffComments, + mobileDiffReview: meta.mobileDiffReview + } +} diff --git a/src/main/ipc/worktrees/ipc-context-schemas.ts b/src/main/ipc/worktrees/ipc-context-schemas.ts new file mode 100644 index 00000000000..f29f09b39d4 --- /dev/null +++ b/src/main/ipc/worktrees/ipc-context-schemas.ts @@ -0,0 +1,58 @@ +import type { CreateWorktreeArgs } from '../../../shared/worktree/create-types' +import type { + AutomationWorkspaceProvenance, + CliWorkspaceProvenance +} from '../../../shared/worktree/types' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { ListDetectedWorktreesArgs } from '../../../shared/detected-worktree-provider-contract' +import { WorkspaceLinkedItemSchema } from '../../../shared/workspace-linked-item-schema' +import { TaskSourceContextSchema } from '../../../shared/task-source-context-schema' +import { isWorkspaceLinkedItemSourceContextMatch } from '../../../shared/workspace-linked-item-source-context' + +export type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & { + automationProvenance?: AutomationWorkspaceProvenance + cliProvenance?: CliWorkspaceProvenance +} + +export type RemoveWorktreeArgs = { + worktreeId: string + hostId?: ExecutionHostId + force?: boolean + /** Explicit Force Delete only — `force` alone is set by the ordinary confirmation (#11960). */ + allowUnverifiedPtyStop?: boolean + skipArchive?: boolean + snapshotPruneBatchId?: string +} + +export type DetectedWorktreeRequestArgs = { repoId: string } | ListDetectedWorktreesArgs + +export const NullableWorkspaceLinkedItemSchema = WorkspaceLinkedItemSchema.nullable() +export const NullableTaskSourceContextSchema = TaskSourceContextSchema.nullable() + +export function normalizeLinkedWorkItemFields< + T extends { + linkedWorkItem?: unknown + linkedTaskSourceContext?: unknown + } +>(input: T): T { + const linkedWorkItem = + input.linkedWorkItem === undefined + ? undefined + : NullableWorkspaceLinkedItemSchema.parse(input.linkedWorkItem) + const linkedTaskSourceContext = + input.linkedTaskSourceContext === undefined + ? undefined + : NullableTaskSourceContextSchema.parse(input.linkedTaskSourceContext) + if ( + linkedWorkItem && + linkedTaskSourceContext && + !isWorkspaceLinkedItemSourceContextMatch(linkedWorkItem, linkedTaskSourceContext) + ) { + throw new Error('Linked work item and source context identities must match') + } + return { + ...input, + ...(linkedWorkItem !== undefined ? { linkedWorkItem } : {}), + ...(linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext } : {}) + } +} diff --git a/src/main/ipc/worktrees/listing/detected-provider-listing.ts b/src/main/ipc/worktrees/listing/detected-provider-listing.ts new file mode 100644 index 00000000000..c3692d5c926 --- /dev/null +++ b/src/main/ipc/worktrees/listing/detected-provider-listing.ts @@ -0,0 +1,163 @@ +import type { Store } from '../../../persistence/loading-store/store' +import type { Repo } from '../../../../shared/repo-types' +import { getSshGitProvider } from '../../../providers/ssh-git-dispatch' +import type { DetectedWorktreeListResult, GitWorktreeInfo } from '../../../../shared/worktree/types' +import { isFolderRepo } from '../../../../shared/repo-kind' +import { projectResolvedWorktreeLineage } from '../../../../shared/resolved-worktree-lineage' +import { pruneLineageForMissingRepoWorktrees } from '../../../worktree-lineage-pruning' +import type { DirectSshDetectedWorktreeRequest } from '../../../../shared/detected-worktree-provider-contract' +import { isAdmissibleDirectSshAuthority } from '../../../../shared/ssh-retained-payload-admission' +import type { ListDesktopLineageForHostArgs } from '../../../../shared/host-lineage-contract' +import { + buildDetectedGitWorktrees, + createSshWorktreeMetaIndex, + listDisconnectedSshWorktrees +} from './ssh-worktree-fallback' +import { + buildDisconnectedDetectedWorktrees, + buildFolderDetectedWorktrees +} from './folder-workspace-catalog' +import { isFolderWorkspaceIdForRepo } from '../folder-workspace-model' +import { hasConflictingStoredWorktreeOwner } from './worktree-host-ownership' +import { + listDetectedGitWorktrees, + rememberLocalWorktreeRoots +} from './detected-worktree-scan-cache' +import { loggedWorktreeListFailures, warnOnce } from './worktree-listing-diagnostics' + +export async function listDetectedWorktreesForCapturedRepo( + store: Store, + repo: Repo, + isCurrent: () => boolean, + capturedProvider = repo.connectionId ? getSshGitProvider(repo.connectionId) : undefined, + providerAbort?: { signal: AbortSignal; status: () => 'canceled' | 'timed-out' } +): Promise { + const abortedResult = () => + providerAbort?.signal.aborted + ? ({ providerAbortStatus: providerAbort.status() } as const) + : undefined + const sshWorktreeMetaIndex = repo.connectionId + ? createSshWorktreeMetaIndex(Object.entries(store.getAllWorktreeMeta())) + : new Map() + + try { + let gitWorktrees: GitWorktreeInfo[] + let freshScan = true + if (isFolderRepo(repo)) { + if (!isCurrent()) { + return null + } + const folderWorkspaceIds = Object.keys(store.getAllWorktreeMeta()).filter((worktreeId) => + isFolderWorkspaceIdForRepo(repo, worktreeId) + ) + if (hasConflictingStoredWorktreeOwner(store, repo, folderWorkspaceIds)) { + return { + repoId: repo.id, + authoritative: false, + source: 'metadata-fallback', + worktrees: [] + } + } + return { + repoId: repo.id, + authoritative: true, + source: 'git', + worktrees: projectResolvedWorktreeLineage( + buildFolderDetectedWorktrees(store, repo), + store.getAllWorktreeLineage?.() ?? {} + ) + } + } + if (repo.connectionId) { + if (!capturedProvider) { + const aborted = abortedResult() + if (aborted) { + return aborted + } + if (!isCurrent()) { + return null + } + const worktrees = listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) + return { + repoId: repo.id, + authoritative: false, + source: 'metadata-fallback', + worktrees: buildDisconnectedDetectedWorktrees(store, repo, worktrees) + } + } + gitWorktrees = await capturedProvider.listWorktrees(repo.path, { + signal: providerAbort?.signal + }) + } else { + const scan = await listDetectedGitWorktrees(store, repo) + gitWorktrees = scan.gitWorktrees + freshScan = scan.fresh + } + const aborted = abortedResult() + if (aborted) { + return aborted + } + if (!isCurrent()) { + return null + } + const listedWorktreeIds = gitWorktrees.map((worktree) => `${repo.id}::${worktree.path}`) + if (hasConflictingStoredWorktreeOwner(store, repo, listedWorktreeIds)) { + return { + repoId: repo.id, + authoritative: false, + source: 'metadata-fallback', + worktrees: [] + } + } + if (freshScan) { + rememberLocalWorktreeRoots(store, repo, gitWorktrees) + pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) + } + loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) + return { + repoId: repo.id, + authoritative: true, + source: 'git', + worktrees: buildDetectedGitWorktrees(store, repo, gitWorktrees) + } + } catch (err) { + const aborted = abortedResult() + if (aborted) { + return aborted + } + if (!isCurrent()) { + return null + } + warnOnce( + loggedWorktreeListFailures, + `${repo.id}:${repo.path}`, + `[worktrees] failed to list detected worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, + err + ) + if (repo.connectionId) { + const worktrees = listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) + return { + repoId: repo.id, + authoritative: false, + source: 'metadata-fallback', + worktrees: buildDisconnectedDetectedWorktrees(store, repo, worktrees) + } + } + return { repoId: repo.id, authoritative: false, source: 'metadata-fallback', worktrees: [] } + } +} + +export function hasValidDirectSshAuthority( + args: DirectSshDetectedWorktreeRequest +): args is DirectSshDetectedWorktreeRequest { + return isAdmissibleDirectSshAuthority(args.expectedAuthority) +} + +export function hasValidLineageSshAuthority( + args: ListDesktopLineageForHostArgs +): args is Extract { + if (!('expectedAuthority' in args)) { + return false + } + return isAdmissibleDirectSshAuthority(args.expectedAuthority) +} diff --git a/src/main/ipc/worktrees/listing/detected-worktree-scan-cache.ts b/src/main/ipc/worktrees/listing/detected-worktree-scan-cache.ts new file mode 100644 index 00000000000..55b3d75e3ed --- /dev/null +++ b/src/main/ipc/worktrees/listing/detected-worktree-scan-cache.ts @@ -0,0 +1,133 @@ +import type { GitWorktreeInfo } from '../../../../shared/worktree/types' +import type { Store } from '../../../persistence/loading-store/store' +import type { Repo } from '../../../../shared/repo-types' +import { getLocalProjectWorktreeGitOptions } from '../../../project-runtime-git-options' +import { isFolderRepo } from '../../../../shared/repo-kind' +import { listRepoWorktrees } from '../../../repo-worktrees' +import { registerWorktreeRootsForRepo } from '../../registered-worktree-roots-cache' + +// Why: absorb renderer polling bursts while bounding external worktree-change lag to one short refresh window. +export const DETECTED_WORKTREE_SCAN_CACHE_TTL_MS = 5_000 + +export type DetectedWorktreeScanCacheEntry = { + expiresAt: number + worktrees: GitWorktreeInfo[] +} + +export type DetectedWorktreeScan = { + invalidated: boolean + promise: Promise +} + +export type DetectedWorktreeScanResult = { + gitWorktrees: GitWorktreeInfo[] + fresh: boolean +} + +export const detectedWorktreeScanCache = new Map() +export const detectedWorktreeScanInFlight = new Map() + +export function invalidateDetectedWorktreeScanCache(repoId: string): void { + const keyPrefix = `${repoId}\0` + for (const key of new Set([ + ...detectedWorktreeScanCache.keys(), + ...detectedWorktreeScanInFlight.keys() + ])) { + if (!key.startsWith(keyPrefix)) { + continue + } + detectedWorktreeScanCache.delete(key) + const inFlight = detectedWorktreeScanInFlight.get(key) + if (inFlight) { + // Why: the detached scan keeps this token so later scans settle without making an older result fresh again. + inFlight.invalidated = true + detectedWorktreeScanInFlight.delete(key) + } + } +} + +export function __resetDetectedWorktreeScanCacheForTests(): void { + // Why: pending scans across a test reset must not repopulate the cache and leak state into the next test. + for (const scan of detectedWorktreeScanInFlight.values()) { + scan.invalidated = true + } + detectedWorktreeScanCache.clear() + detectedWorktreeScanInFlight.clear() +} + +export function __getDetectedWorktreeScanCacheStatsForTests(): { + cacheSize: number + inFlightSize: number +} { + return { + cacheSize: detectedWorktreeScanCache.size, + inFlightSize: detectedWorktreeScanInFlight.size + } +} + +export async function listDetectedGitWorktrees( + store: Store, + repo: Repo +): Promise { + const localWorktreeGitOptions = getLocalProjectWorktreeGitOptions(store, repo) + if (repo.connectionId || isFolderRepo(repo)) { + return { + gitWorktrees: await listRepoWorktrees(repo, localWorktreeGitOptions), + fresh: true + } + } + + const cacheKey = getDetectedWorktreeScanCacheKey(repo.id, localWorktreeGitOptions) + const cached = detectedWorktreeScanCache.get(cacheKey) + if (cached && cached.expiresAt > Date.now()) { + return { gitWorktrees: cached.worktrees, fresh: false } + } + + const inFlight = detectedWorktreeScanInFlight.get(cacheKey) + if (inFlight) { + return { gitWorktrees: await inFlight.promise, fresh: false } + } + + const scan: DetectedWorktreeScan = { + invalidated: false, + promise: listRepoWorktrees(repo, localWorktreeGitOptions) + } + detectedWorktreeScanInFlight.set(cacheKey, scan) + try { + const gitWorktrees = await scan.promise + // Why: a create/remove notification can invalidate mid-scan; don't let that stale scan repopulate the cache afterward. + if (!scan.invalidated) { + detectedWorktreeScanCache.set(cacheKey, { + worktrees: gitWorktrees, + expiresAt: Date.now() + DETECTED_WORKTREE_SCAN_CACHE_TTL_MS + }) + } + return { gitWorktrees, fresh: !scan.invalidated } + } finally { + if (detectedWorktreeScanInFlight.get(cacheKey) === scan) { + detectedWorktreeScanInFlight.delete(cacheKey) + } + } +} + +export function getDetectedWorktreeScanCacheKey( + repoId: string, + localWorktreeGitOptions: { wslDistro?: string } = {} +): string { + return `${repoId}\0${localWorktreeGitOptions.wslDistro ?? 'host'}` +} + +export function rememberLocalWorktreeRoots( + store: Store, + repo: Repo, + gitWorktrees: GitWorktreeInfo[] +): void { + if (repo.connectionId) { + return + } + // Why: reuse the `git worktree list` result so later git/file IPC validation skips a second scan that can trigger macOS folder-permission prompts. + registerWorktreeRootsForRepo(store, repo.id, [ + repo.path, + ...gitWorktrees.map((worktree) => worktree.path) + ]) +} diff --git a/src/main/ipc/worktrees/listing/folder-workspace-catalog.ts b/src/main/ipc/worktrees/listing/folder-workspace-catalog.ts new file mode 100644 index 00000000000..abaa594bf6e --- /dev/null +++ b/src/main/ipc/worktrees/listing/folder-workspace-catalog.ts @@ -0,0 +1,118 @@ +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree, DetectedWorktree } from '../../../../shared/worktree/types' +import type { Store } from '../../../persistence/loading-store/store' +import { + createWorktreeVisibilitySourceMatcher, + resolveCustomWorktreeVisibilitySources +} from '../../../../shared/worktree/visibility-sources' +import { resolveConfiguredWorktreeBasePaths } from '../../../../shared/worktree/configured-worktree-base-path' +import { + toDetectedWorktree, + applyMetadataFallbackVisibility +} from '../../../../shared/worktree/ownership' +import { projectResolvedWorktreeLineage } from '../../../../shared/resolved-worktree-lineage' +import { getProjectHostSetupMetaUpdates } from './worktree-discovery-metadata' +import { + getFolderWorkspaceInstanceIdentity, + getFolderWorkspaceRootId, + isFolderWorkspaceIdForRepo, + mergeFolderWorkspace +} from '../folder-workspace-model' + +export function listFolderWorkspaces(store: Store, repo: Repo): Worktree[] { + const rootId = getFolderWorkspaceRootId(repo) + const allMeta = store.getAllWorktreeMeta() + const ids = Object.keys(allMeta).filter((worktreeId) => + isFolderWorkspaceIdForRepo(repo, worktreeId) + ) + if (!ids.includes(rootId)) { + ids.unshift(rootId) + } + + return ids + .map((worktreeId) => { + const existing = allMeta[worktreeId] + const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, existing) + const meta = + existing?.instanceId && Object.keys(ownershipUpdates).length === 0 + ? existing + : store.setWorktreeMeta(worktreeId, { + instanceId: + existing?.instanceId ?? getFolderWorkspaceInstanceIdentity(repo, worktreeId), + ...ownershipUpdates, + ...(existing ? {} : { displayName: repo.displayName, lastActivityAt: Date.now() }) + }) + return mergeFolderWorkspace(repo, worktreeId, meta) + }) + .sort((a, b) => { + if (a.id === rootId) { + return -1 + } + if (b.id === rootId) { + return 1 + } + return (b.createdAt ?? b.lastActivityAt) - (a.createdAt ?? a.lastActivityAt) + }) +} + +export function buildFolderDetectedWorktrees(store: Store, repo: Repo): DetectedWorktree[] { + const settings = store.getSettings() + const worktrees = listFolderWorkspaces(store, repo) + const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher( + [repo.path, ...worktrees.map((worktree) => worktree.path)], + resolveCustomWorktreeVisibilitySources(repo, settings.worktreeVisibilityDefaults), + resolveConfiguredWorktreeBasePaths(repo) + ) + return worktrees.map((worktree) => + toDetectedWorktree({ + repo, + worktree, + meta: store.getWorktreeMeta(worktree.id), + settings, + knownOrcaLayouts: [], + isLegacyRepoForVisibility: true, + worktreeVisibilitySourceMatcher + }) + ) +} + +export function listVisibleFolderWorkspaces(store: Store, repo: Repo): Worktree[] { + return buildFolderDetectedWorktrees(store, repo) + .filter((worktree) => worktree.visible) + .map((worktree) => { + const meta = store.getWorktreeMeta(worktree.id) + const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, meta) + const repairedMeta = + meta && Object.keys(ownershipUpdates).length === 0 + ? meta + : store.setWorktreeMeta(worktree.id, ownershipUpdates) + return mergeFolderWorkspace(repo, worktree.id, repairedMeta) + }) +} + +export function buildDisconnectedDetectedWorktrees( + store: Store, + repo: Repo, + worktrees: Worktree[] +): DetectedWorktree[] { + const settings = store.getSettings() + const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher( + [repo.path, ...worktrees.map((worktree) => worktree.path)], + resolveCustomWorktreeVisibilitySources(repo, settings.worktreeVisibilityDefaults), + resolveConfiguredWorktreeBasePaths(repo) + ) + const detected = worktrees.map((worktree) => { + const meta = store.getWorktreeMeta(worktree.id) + const detected = toDetectedWorktree({ + repo, + worktree, + meta, + settings, + knownOrcaLayouts: [], + isLegacyRepoForVisibility: true, + worktreeVisibilitySourceMatcher + }) + return applyMetadataFallbackVisibility(detected) + }) + return projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {}) +} diff --git a/src/main/ipc/worktrees/listing/host-qualified-worktree-listing.ts b/src/main/ipc/worktrees/listing/host-qualified-worktree-listing.ts new file mode 100644 index 00000000000..3026a1b0652 --- /dev/null +++ b/src/main/ipc/worktrees/listing/host-qualified-worktree-listing.ts @@ -0,0 +1,133 @@ +import type { Store } from '../../../persistence/loading-store/store' +import { PROVIDER_REQUEST_ID_MAX_UTF8_BYTES } from '../../../../shared/detected-worktree-provider-contract' +import type { + ListDetectedWorktreesArgs, + HostQualifiedDetectedWorktreeResult, + DirectSshDetectedWorktreeRequest +} from '../../../../shared/detected-worktree-provider-contract' +import { parseExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import { isCurrentSshProviderAuthority } from '../../../ssh/ssh-provider-authority' +import { getSshGitProvider } from '../../../providers/ssh-git-dispatch' +import { + hasValidDirectSshAuthority, + listDetectedWorktreesForCapturedRepo +} from './detected-provider-listing' +import { + findExactRepoOwner, + isCapturedRepoCurrent, + resolveRepoOwnershipEvidence +} from './worktree-host-ownership' + +export async function listHostQualifiedDetectedWorktrees( + store: Store, + args: ListDetectedWorktreesArgs, + providerAbort?: { signal: AbortSignal; status: () => 'canceled' | 'timed-out' } +): Promise { + const parsedHost = parseExecutionHostId(args.executionHostId) + const rejected = (status: 'rejected' | 'stale' | 'ambiguous-owner') => ({ + providerRequestId: args.providerRequestId, + executionHostId: args.executionHostId, + status + }) + if ( + typeof args.providerRequestId !== 'string' || + args.providerRequestId.length === 0 || + Buffer.byteLength(args.providerRequestId, 'utf8') > PROVIDER_REQUEST_ID_MAX_UTF8_BYTES || + !parsedHost || + parsedHost.kind === 'runtime' + ) { + return rejected('rejected') + } + let capturedAuthority: DirectSshDetectedWorktreeRequest['expectedAuthority'] | null = null + if (parsedHost.kind === 'ssh') { + const directArgs = args as DirectSshDetectedWorktreeRequest + if ( + !hasValidDirectSshAuthority(directArgs) || + directArgs.expectedAuthority.targetId !== parsedHost.targetId + ) { + return rejected('rejected') + } + capturedAuthority = { ...directArgs.expectedAuthority } + if (!isCurrentSshProviderAuthority(capturedAuthority)) { + return rejected('stale') + } + } + + const repoCandidates = store.getRepos().filter((candidate) => candidate.id === args.repoId) + if ( + repoCandidates.some((candidate) => resolveRepoOwnershipEvidence(candidate).status !== 'owned') + ) { + return rejected('rejected') + } + const repo = findExactRepoOwner(store, args.repoId, args.executionHostId) + if (!repo) { + return rejected('ambiguous-owner') + } + if ( + (parsedHost.kind === 'local' && repo.connectionId) || + (parsedHost.kind === 'ssh' && repo.connectionId !== parsedHost.targetId) + ) { + return rejected('rejected') + } + const provider = parsedHost.kind === 'ssh' ? getSshGitProvider(parsedHost.targetId) : undefined + const isCurrent = (): boolean => { + if (!isCapturedRepoCurrent(store, repo, args.executionHostId)) { + return false + } + if ( + (parsedHost.kind === 'local' && repo.connectionId) || + (parsedHost.kind === 'ssh' && repo.connectionId !== parsedHost.targetId) + ) { + return false + } + if (parsedHost.kind !== 'ssh') { + return true + } + return ( + capturedAuthority !== null && + getSshGitProvider(parsedHost.targetId) === provider && + isCurrentSshProviderAuthority(capturedAuthority) + ) + } + const result = await listDetectedWorktreesForCapturedRepo( + store, + repo, + isCurrent, + provider, + providerAbort + ) + if (!result) { + return rejected('stale') + } + if ('providerAbortStatus' in result) { + return { + providerRequestId: args.providerRequestId, + executionHostId: args.executionHostId, + status: result.providerAbortStatus + } + } + const status = result.authoritative ? 'complete' : 'non-authoritative' + if (parsedHost.kind === 'local') { + return { + status, + providerRequestId: args.providerRequestId, + repoId: repo.id, + authority: { kind: 'local', executionHostId: LOCAL_EXECUTION_HOST_ID }, + result + } + } + if (!capturedAuthority) { + return rejected('rejected') + } + return { + status, + providerRequestId: args.providerRequestId, + repoId: repo.id, + authority: { + kind: 'direct-ssh', + executionHostId: args.executionHostId as `ssh:${string}`, + ...capturedAuthority + }, + result + } +} diff --git a/src/main/ipc/worktrees/listing/register-detected-worktree-handlers.ts b/src/main/ipc/worktrees/listing/register-detected-worktree-handlers.ts new file mode 100644 index 00000000000..66e8e85a4a5 --- /dev/null +++ b/src/main/ipc/worktrees/listing/register-detected-worktree-handlers.ts @@ -0,0 +1,134 @@ +import { ipcMain } from 'electron' +import type { DetectedWorktreeListResult } from '../../../../shared/worktree/types' +import type { + HostQualifiedDetectedWorktreeResult, + DirectSshDetectedWorktreeRequest, + ProviderRequestId +} from '../../../../shared/detected-worktree-provider-contract' +import { parseExecutionHostId } from '../../../../shared/execution-host' +import { + registerSshProviderRequestAbort, + getSshProviderAuthority, + isCurrentSshProviderAuthority +} from '../../../ssh/ssh-provider-authority' +import { getSshGitProvider } from '../../../providers/ssh-git-dispatch' +import type { DetectedWorktreeRequestArgs } from '../ipc-context-schemas' +import { + hasValidDirectSshAuthority, + listDetectedWorktreesForCapturedRepo +} from './detected-provider-listing' +import { listHostQualifiedDetectedWorktrees } from './host-qualified-worktree-listing' +import { findExactRepoOwner, isCapturedRepoCurrent } from './worktree-host-ownership' +import type { WorktreeIpcContext } from '../worktree-ipc-context' + +export const DETECTED_WORKTREE_PROVIDER_TIMEOUT_MS = 30_000 + +export function registerDetectedWorktreeHandlers(context: WorktreeIpcContext): void { + const { store, detectedWorktreeCancellations } = context + + ipcMain.handle( + 'worktrees:listDetected', + async ( + event, + args: DetectedWorktreeRequestArgs + ): Promise => { + if ('executionHostId' in args) { + const parsedHost = parseExecutionHostId(args.executionHostId) + const directSshRequest = parsedHost?.kind === 'ssh' + const controller = directSshRequest + ? detectedWorktreeCancellations.begin(event, args.providerRequestId) + : null + const directArgs = args as DirectSshDetectedWorktreeRequest + const removeAuthorityAbort = + controller && + parsedHost?.kind === 'ssh' && + hasValidDirectSshAuthority(directArgs) && + directArgs.expectedAuthority.targetId === parsedHost.targetId + ? registerSshProviderRequestAbort(directArgs.expectedAuthority, controller) + : undefined + let timedOut = false + let removeAbortListener: (() => void) | undefined + const abortedResult = controller + ? new Promise((resolve) => { + const onAbort = (): void => { + resolve({ + providerRequestId: args.providerRequestId, + executionHostId: args.executionHostId, + status: timedOut ? 'timed-out' : 'canceled' + }) + } + controller.signal.addEventListener('abort', onAbort, { once: true }) + removeAbortListener = () => controller.signal.removeEventListener('abort', onAbort) + }) + : undefined + const timeout = controller + ? setTimeout(() => { + timedOut = true + controller.abort() + }, DETECTED_WORKTREE_PROVIDER_TIMEOUT_MS) + : undefined + try { + const providerResult = listHostQualifiedDetectedWorktrees( + store, + args, + controller + ? { + signal: controller.signal, + status: () => (timedOut ? 'timed-out' : 'canceled') + } + : undefined + ) + return abortedResult + ? await Promise.race([providerResult, abortedResult]) + : await providerResult + } finally { + if (timeout) { + clearTimeout(timeout) + } + removeAbortListener?.() + removeAuthorityAbort?.() + detectedWorktreeCancellations.finish(event, args.providerRequestId, controller) + } + } + const repo = findExactRepoOwner(store, args.repoId) + if (!repo) { + return { + repoId: args.repoId, + authoritative: false, + source: 'metadata-fallback', + worktrees: [] + } + } + const provider = repo.connectionId ? getSshGitProvider(repo.connectionId) : undefined + const authority = repo.connectionId + ? { ...getSshProviderAuthority(repo.connectionId) } + : undefined + const result = await listDetectedWorktreesForCapturedRepo( + store, + repo, + () => + isCapturedRepoCurrent(store, repo) && + (!repo.connectionId || + (getSshGitProvider(repo.connectionId) === provider && + authority !== undefined && + isCurrentSshProviderAuthority(authority))), + provider + ) + return result && !('providerAbortStatus' in result) + ? result + : { + repoId: repo.id, + authoritative: false, + source: 'metadata-fallback', + worktrees: [] + } + } + ) + + ipcMain.handle( + 'worktrees:cancelListDetected', + (event, args: { providerRequestId: ProviderRequestId }): void => { + detectedWorktreeCancellations.cancel(event, args.providerRequestId) + } + ) +} diff --git a/src/main/ipc/worktrees/listing/register-detected-worktree-scan-invalidation.ts b/src/main/ipc/worktrees/listing/register-detected-worktree-scan-invalidation.ts new file mode 100644 index 00000000000..222ae1bf999 --- /dev/null +++ b/src/main/ipc/worktrees/listing/register-detected-worktree-scan-invalidation.ts @@ -0,0 +1,6 @@ +import { registerWorktreeChangeInvalidator } from '../../worktree-change-invalidators' +import { invalidateDetectedWorktreeScanCache } from './detected-worktree-scan-cache' + +export function registerDetectedWorktreeScanInvalidation(): void { + registerWorktreeChangeInvalidator(invalidateDetectedWorktreeScanCache) +} diff --git a/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts b/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts new file mode 100644 index 00000000000..50ce6082a42 --- /dev/null +++ b/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts @@ -0,0 +1,140 @@ +import { ipcMain } from 'electron' +import type { + ListKnownWorktreesForExecutionHostArgs, + HostQualifiedKnownWorktreeResult, + ForgetRemovedWorktreesForExecutionHostArgs, + ForgetRemovedWorktreesForExecutionHostResult +} from '../../../../shared/detected-worktree-provider-contract' +import { parseExecutionHostId } from '../../../../shared/execution-host' +import type { DetectedWorktree } from '../../../../shared/worktree/types' +import { isFolderRepo } from '../../../../shared/repo-kind' +import { projectResolvedWorktreeLineage } from '../../../../shared/resolved-worktree-lineage' +import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' +import { pruneWorkspaceCleanupScanSnapshots } from '../../../workspace-cleanup-scan-snapshot' +import { pruneWorkspaceSpaceAnalysisSnapshots } from '../../../workspace-space-analysis-snapshot' +import { findExactRepoOwner, hasConflictingStoredWorktreeOwner } from './worktree-host-ownership' +import { + buildDisconnectedDetectedWorktrees, + buildFolderDetectedWorktrees +} from './folder-workspace-catalog' +import { isFolderWorkspaceIdForRepo } from '../folder-workspace-model' +import { + createSshWorktreeMetaIndexForRepo, + listDisconnectedSshWorktrees +} from './ssh-worktree-fallback' +import type { WorktreeIpcContext } from '../worktree-ipc-context' + +export function registerHostCatalogHandlers(context: WorktreeIpcContext): void { + const { store } = context + + ipcMain.handle( + 'worktrees:listKnownForExecutionHost', + (_event, args: ListKnownWorktreesForExecutionHostArgs): HostQualifiedKnownWorktreeResult => { + // Why: a malformed invoke must fail closed as `rejected`, not throw out of the handler. `ssh:` is inert — + // it owns no repo, so every guard below still rejects it. + const requestedRepoId = args?.repoId ?? '' + const requestedExecutionHostId = args?.executionHostId ?? 'ssh:' + const rejected = (): HostQualifiedKnownWorktreeResult => ({ + status: 'rejected', + repoId: requestedRepoId, + executionHostId: requestedExecutionHostId + }) + const parsedHost = parseExecutionHostId(requestedExecutionHostId) + if (parsedHost?.kind !== 'ssh') { + return rejected() + } + // Why: findExactRepoOwner repeats this same all-candidates-owned check, and getRepos() re-hydrates the + // whole catalog, so a separate pass here is pure cost. + const repo = findExactRepoOwner(store, requestedRepoId, requestedExecutionHostId) + if (!repo || repo.connectionId !== parsedHost.targetId) { + return rejected() + } + const complete = (worktrees: DetectedWorktree[]): HostQualifiedKnownWorktreeResult => ({ + status: 'complete', + repoId: repo.id, + executionHostId: requestedExecutionHostId, + result: { + repoId: repo.id, + authoritative: false, + source: 'metadata-fallback', + worktrees + } + }) + // Why: folder workspace ids carry an instance suffix the git-worktree synthesizer would read as a directory; build them the way every other listing does. + if (isFolderRepo(repo)) { + const folderWorkspaceIds = Object.keys(store.getAllWorktreeMeta()).filter((worktreeId) => + isFolderWorkspaceIdForRepo(repo, worktreeId) + ) + return hasConflictingStoredWorktreeOwner(store, repo, folderWorkspaceIds) + ? rejected() + : complete( + // Why: match the authoritative folder listing; without lineage these rows render flat and then + // reshuffle once the real scan lands. + projectResolvedWorktreeLineage( + buildFolderDetectedWorktrees(store, repo), + store.getAllWorktreeLineage?.() ?? {} + ) + ) + } + const metaIndex = createSshWorktreeMetaIndexForRepo(store.getAllWorktreeMeta(), repo.id) + return complete( + buildDisconnectedDetectedWorktrees( + store, + repo, + listDisconnectedSshWorktrees(store, repo, metaIndex) + ) + ) + } + ) + + ipcMain.handle( + 'worktrees:forgetRemovedForExecutionHost', + ( + _event, + args: ForgetRemovedWorktreesForExecutionHostArgs + ): ForgetRemovedWorktreesForExecutionHostResult => { + const nothingForgotten: ForgetRemovedWorktreesForExecutionHostResult = { + forgottenWorktreeIds: [] + } + const requestedExecutionHostId = args?.executionHostId ?? 'ssh:' + const worktreeIds = Array.isArray(args?.worktreeIds) ? args.worktreeIds : [] + const parsedHost = parseExecutionHostId(requestedExecutionHostId) + if (parsedHost?.kind !== 'ssh' || worktreeIds.length === 0) { + return nothingForgotten + } + const repo = findExactRepoOwner(store, args?.repoId ?? '', requestedExecutionHostId) + if (!repo || repo.connectionId !== parsedHost.targetId) { + return nothingForgotten + } + // Why: a folder workspace's meta IS the workspace record, not a checkout row — gcStaleWorktreeMeta skips + // those keys for the same reason, and no remote scan can retire one. + if (isFolderRepo(repo)) { + return nothingForgotten + } + const allMeta = store.getAllWorktreeMeta() + const forgottenWorktreeIds: string[] = [] + for (const worktreeId of worktreeIds) { + const meta = typeof worktreeId === 'string' ? allMeta[worktreeId] : undefined + if (!meta || getRepoIdFromWorktreeId(worktreeId) !== repo.id) { + continue + } + // An unhosted meta belongs to this repo's only owner; a foreign hostId needs that host's own scan. + if (meta.hostId && meta.hostId !== requestedExecutionHostId) { + continue + } + store.removeWorktreeMeta(worktreeId, requestedExecutionHostId) + forgottenWorktreeIds.push(worktreeId) + } + if (forgottenWorktreeIds.length > 0) { + const snapshotDirectory = store.getProfileStorageDirectory() + const targets = forgottenWorktreeIds.map((worktreeId) => ({ + worktreeId, + executionHostId: requestedExecutionHostId + })) + void pruneWorkspaceCleanupScanSnapshots(snapshotDirectory, targets) + void pruneWorkspaceSpaceAnalysisSnapshots(snapshotDirectory, targets) + } + return { forgottenWorktreeIds } + } + ) +} diff --git a/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts b/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts new file mode 100644 index 00000000000..64c28c08495 --- /dev/null +++ b/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts @@ -0,0 +1,181 @@ +import { ipcMain } from 'electron' +import { isFolderRepo } from '../../../../shared/repo-kind' +import { getSshGitProvider } from '../../../providers/ssh-git-dispatch' +import { pruneLineageForMissingRepoWorktrees } from '../../../worktree-lineage-pruning' +import { EMPTY_RETIRED_NAME_REGISTRY } from '../../../../shared/worktree/retired-name-registry' +import { getRetiredNameRegistryForRepo } from '../../../worktree-name-retirement' +import { + buildDetectedGitWorktrees, + createSshWorktreeMetaIndex, + listDisconnectedSshWorktrees, + stampAndMergeVisibleDetectedWorktree +} from './ssh-worktree-fallback' +import { listVisibleFolderWorkspaces } from './folder-workspace-catalog' +import { + listDetectedGitWorktrees, + rememberLocalWorktreeRoots +} from './detected-worktree-scan-cache' +import { + loggedUnavailableSshGitProviders, + loggedWorktreeListFailures, + warnOnce +} from './worktree-listing-diagnostics' +import type { WorktreeIpcContext } from '../worktree-ipc-context' + +const WORKTREE_LIST_ALL_CONCURRENCY = 8 + +async function mapWithConcurrency( + items: readonly T[], + limit: number, + fn: (item: T) => Promise +): Promise { + const results: R[] = [] + let nextIndex = 0 + const workerCount = Math.min(limit, items.length) + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (nextIndex < items.length) { + const index = nextIndex + nextIndex += 1 + results[index] = await fn(items[index]) + } + }) + ) + return results +} + +export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): void { + const { store } = context + + ipcMain.handle('worktrees:listAll', async () => { + const repos = store.getRepos() + const sshWorktreeMetaIndex = repos.some((repo) => repo.connectionId) + ? createSshWorktreeMetaIndex(Object.entries(store.getAllWorktreeMeta())) + : new Map() + + // Why: each local repo listing can spawn `git worktree list`; cap fan-out so large fleets don't start unbounded subprocesses. + const results = await mapWithConcurrency(repos, WORKTREE_LIST_ALL_CONCURRENCY, async (repo) => { + try { + let gitWorktrees + let freshScan = true + if (isFolderRepo(repo)) { + return listVisibleFolderWorkspaces(store, repo) + } else if (repo.connectionId) { + const provider = getSshGitProvider(repo.connectionId) + if (!provider) { + warnOnce( + loggedUnavailableSshGitProviders, + `${repo.connectionId}:${repo.id}`, + `[worktrees] SSH git provider unavailable; skipping worktree list for repo "${repo.displayName}" (${repo.id}) at ${repo.path} on connection ${repo.connectionId}` + ) + return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) + } + loggedUnavailableSshGitProviders.delete(`${repo.connectionId}:${repo.id}`) + try { + gitWorktrees = await provider.listWorktrees(repo.path) + } catch (err) { + warnOnce( + loggedWorktreeListFailures, + `${repo.id}:${repo.path}`, + `[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, + err + ) + return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) + } + } else { + const scan = await listDetectedGitWorktrees(store, repo) + gitWorktrees = scan.gitWorktrees + freshScan = scan.fresh + } + if (freshScan) { + rememberLocalWorktreeRoots(store, repo, gitWorktrees) + pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) + } + loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) + return buildDetectedGitWorktrees(store, repo, gitWorktrees) + .filter((worktree) => worktree.visible) + .map((worktree) => stampAndMergeVisibleDetectedWorktree(store, repo, worktree)) + } catch (err) { + warnOnce( + loggedWorktreeListFailures, + `${repo.id}:${repo.path}`, + `[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, + err + ) + // Why: do NOT seed empty success — it flags the repo registered, blocking access to legit linked worktrees until the cache is invalidated. + return [] + } + }) + + return results.flat() + }) + + ipcMain.handle('worktrees:listRetiredNames', async (_event, args: { repoId: string }) => { + const repo = store.getRepo(args.repoId) + if (!repo) { + return EMPTY_RETIRED_NAME_REGISTRY + } + return getRetiredNameRegistryForRepo(store, repo, store.getRepos(), store.getSettings()) + }) + + ipcMain.handle('worktrees:list', async (_event, args: { repoId: string }) => { + const repo = store.getRepo(args.repoId) + if (!repo) { + return [] + } + const sshWorktreeMetaIndex = repo.connectionId + ? createSshWorktreeMetaIndex(Object.entries(store.getAllWorktreeMeta())) + : new Map() + + try { + let gitWorktrees + let freshScan = true + if (isFolderRepo(repo)) { + return listVisibleFolderWorkspaces(store, repo) + } else if (repo.connectionId) { + const provider = getSshGitProvider(repo.connectionId) + if (!provider) { + warnOnce( + loggedUnavailableSshGitProviders, + `${repo.connectionId}:${repo.id}`, + `[worktrees] SSH git provider unavailable; skipping worktree list for repo "${repo.displayName}" (${repo.id}) at ${repo.path} on connection ${repo.connectionId}` + ) + return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) + } + loggedUnavailableSshGitProviders.delete(`${repo.connectionId}:${repo.id}`) + try { + gitWorktrees = await provider.listWorktrees(repo.path) + } catch (err) { + warnOnce( + loggedWorktreeListFailures, + `${repo.id}:${repo.path}`, + `[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, + err + ) + return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) + } + } else { + const scan = await listDetectedGitWorktrees(store, repo) + gitWorktrees = scan.gitWorktrees + freshScan = scan.fresh + } + if (freshScan) { + rememberLocalWorktreeRoots(store, repo, gitWorktrees) + pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) + } + loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) + return buildDetectedGitWorktrees(store, repo, gitWorktrees) + .filter((worktree) => worktree.visible) + .map((worktree) => stampAndMergeVisibleDetectedWorktree(store, repo, worktree)) + } catch (err) { + warnOnce( + loggedWorktreeListFailures, + `${repo.id}:${repo.path}`, + `[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, + err + ) + // Why: see worktrees:listAll catch — seeding an empty-success result would poison the auth cache and block linked worktrees. + return [] + } + }) +} diff --git a/src/main/ipc/worktrees/listing/ssh-worktree-fallback.ts b/src/main/ipc/worktrees/listing/ssh-worktree-fallback.ts new file mode 100644 index 00000000000..26fb14f2341 --- /dev/null +++ b/src/main/ipc/worktrees/listing/ssh-worktree-fallback.ts @@ -0,0 +1,186 @@ +import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' +import { parseWorktreeId, areWorktreePathsEqual, mergeWorktree } from '../../worktree-logic' +import { + getRepoIdFromWorktreeId, + getWorktreePathBasenameFromId +} from '../../../../shared/worktree/id' +import type { Repo } from '../../../../shared/repo-types' +import type { GitWorktreeInfo, DetectedWorktree, Worktree } from '../../../../shared/worktree/types' +import type { Store } from '../../../persistence/loading-store/store' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import { + buildKnownOrcaWorkspaceLayouts, + isLegacyRepoForExternalWorktreeVisibility, + toDetectedWorktree +} from '../../../../shared/worktree/ownership' +import { dedupeWorktreesByPath } from '../../worktree-path-comparison' +import { + createWorktreeVisibilitySourceMatcher, + resolveCustomWorktreeVisibilitySources +} from '../../../../shared/worktree/visibility-sources' +import { resolveConfiguredWorktreeBasePaths } from '../../../../shared/worktree/configured-worktree-base-path' +import { projectResolvedWorktreeLineage } from '../../../../shared/resolved-worktree-lineage' +import { loggedMalformedWorktreeMetaKeys, warnOnce } from './worktree-listing-diagnostics' +import { + getProjectHostSetupMetaUpdates, + resolveWorktreeMetaWithDiscoveryBackfill +} from './worktree-discovery-metadata' + +export type SshWorktreeMetaCandidate = { + id: string + path: string + meta: WorktreeMeta +} + +export type SshWorktreeMetaIndex = Map + +export function createSshWorktreeMetaIndex( + entries: [string, WorktreeMeta][] +): SshWorktreeMetaIndex { + const index: SshWorktreeMetaIndex = new Map() + for (const [worktreeId, meta] of entries) { + let parsed: { repoId: string; worktreePath: string } + try { + parsed = parseWorktreeId(worktreeId) + } catch (err) { + warnOnce( + loggedMalformedWorktreeMetaKeys, + worktreeId, + `[worktrees] ignoring malformed persisted worktree metadata key "${worktreeId}"`, + err + ) + continue + } + + const candidates = index.get(parsed.repoId) ?? [] + candidates.push({ id: worktreeId, path: parsed.worktreePath, meta }) + index.set(parsed.repoId, candidates) + } + return index +} + +// Why: scopes parseWorktreeId to one repo's keys. The entry list itself is still materialized for the whole +// store, so this is cheaper per call than the unfiltered index, not free. +export function createSshWorktreeMetaIndexForRepo( + allMeta: Record, + repoId: string +): SshWorktreeMetaIndex { + return createSshWorktreeMetaIndex( + Object.entries(allMeta).filter(([worktreeId]) => getRepoIdFromWorktreeId(worktreeId) === repoId) + ) +} + +export function synthesizeSshGitWorktree( + repo: Repo, + path: string, + meta: WorktreeMeta +): GitWorktreeInfo { + return { + path, + head: '', + branch: '', + isBare: false, + isMainWorktree: areWorktreePathsEqual(path, repo.path), + ...(meta.sparseDirectories !== undefined || + meta.sparseBaseRef !== undefined || + meta.sparsePresetId !== undefined + ? { isSparse: true } + : {}) + } +} + +export function listDisconnectedSshWorktrees( + store: Store, + repo: Repo, + metaIndex: SshWorktreeMetaIndex +): Worktree[] { + const byWorktreeId = new Map() + const expectedHostId = getRepoExecutionHostId(repo) + const repoOwners = store.getRepos().filter((candidate) => candidate.id === repo.id) + for (const candidate of metaIndex.get(repo.id) ?? []) { + if ( + (candidate.meta.hostId && candidate.meta.hostId !== expectedHostId) || + (!candidate.meta.hostId && repoOwners.length > 1) + ) { + continue + } + const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, candidate.meta) + const meta = + Object.keys(ownershipUpdates).length > 0 + ? { ...candidate.meta, ...ownershipUpdates } + : candidate.meta + if (Object.keys(ownershipUpdates).length > 0) { + store.setWorktreeMeta(candidate.id, ownershipUpdates) + } + // Why: synthesized rows carry no branch, so the title would fall through to the DESKTOP's basename() + // applied to a REMOTE path — a Windows remote then renders its whole C:\... path as the name. Rows must + // stay per-directory (repo.displayName would title every row identically), so use the separator-agnostic + // basename instead. + const worktree = mergeWorktree( + repo.id, + synthesizeSshGitWorktree(repo, candidate.path, meta), + meta, + getWorktreePathBasenameFromId(candidate.id) ?? undefined + ) + byWorktreeId.delete(worktree.id) + byWorktreeId.set(worktree.id, worktree) + } + return [...byWorktreeId.values()] +} + +export function buildDetectedGitWorktrees( + store: Store, + repo: Repo, + gitWorktrees: GitWorktreeInfo[] +): DetectedWorktree[] { + const settings = store.getSettings() + const knownOrcaLayouts = buildKnownOrcaWorkspaceLayouts(settings, repo) + const isLegacyRepoForVisibility = isLegacyRepoForExternalWorktreeVisibility(repo) + // Why: a prunable registration has no working directory (issue #8389); only this listing omits it — cleanup flows list separately. + const liveWorktrees = dedupeWorktreesByPath( + gitWorktrees.filter((gitWorktree) => !gitWorktree.prunable) + ) + const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher( + [repo.path, ...liveWorktrees.map((worktree) => worktree.path)], + resolveCustomWorktreeVisibilitySources(repo, settings.worktreeVisibilityDefaults), + resolveConfiguredWorktreeBasePaths(repo) + ) + const detected = liveWorktrees.map((gitWorktree) => { + const worktreeId = `${repo.id}::${gitWorktree.path}` + let meta = store.getWorktreeMeta(worktreeId) + const worktree = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) + const detected = toDetectedWorktree({ + repo, + worktree, + meta, + settings, + knownOrcaLayouts, + isLegacyRepoForVisibility, + worktreeVisibilitySourceMatcher + }) + if (!detected.visible) { + return detected + } + + meta = resolveWorktreeMetaWithDiscoveryBackfill(store, repo, worktreeId) + return toDetectedWorktree({ + repo, + worktree: mergeWorktree(repo.id, gitWorktree, meta, repo.displayName), + meta, + settings, + knownOrcaLayouts, + isLegacyRepoForVisibility, + worktreeVisibilitySourceMatcher + }) + }) + return projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {}) +} + +export function stampAndMergeVisibleDetectedWorktree( + store: Store, + repo: Repo, + detected: DetectedWorktree +) { + const meta = resolveWorktreeMetaWithDiscoveryBackfill(store, repo, detected.id) + return mergeWorktree(repo.id, detected, meta, repo.displayName) +} diff --git a/src/main/ipc/worktrees/listing/worktree-discovery-metadata.ts b/src/main/ipc/worktrees/listing/worktree-discovery-metadata.ts new file mode 100644 index 00000000000..c96e8dc2978 --- /dev/null +++ b/src/main/ipc/worktrees/listing/worktree-discovery-metadata.ts @@ -0,0 +1,51 @@ +import type { Store } from '../../../persistence/loading-store/store' +import type { Repo } from '../../../../shared/repo-types' +import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' +import { getProjectHostSetupWorktreeMeta } from '../../../../shared/project-host-setup-projection' +import { randomUUID } from 'node:crypto' + +export function getProjectHostSetupMetaUpdates( + store: Store, + repo: Repo, + existing?: WorktreeMeta +): Partial> { + const ownership = getProjectHostSetupWorktreeMeta(store.getProjectHostSetups(), repo) + const sameSetup = + existing?.projectHostSetupId === undefined || + existing.projectHostSetupId === ownership.projectHostSetupId + return { + // Why: project IDs can upgrade from legacy repo IDs to provider-backed ones; repair ownership on discovery when the host setup matches. + ...(sameSetup && existing?.projectId !== ownership.projectId + ? { projectId: ownership.projectId } + : {}), + ...(sameSetup && existing?.hostId === undefined ? { hostId: ownership.hostId } : {}), + ...(existing?.projectHostSetupId === undefined + ? { projectHostSetupId: ownership.projectHostSetupId } + : {}) + } +} + +// Why: disk-discovered worktrees have no WorktreeMeta, so lastActivityAt=0 sinks them to the bottom of "Recent"; also backfill host-setup ownership here. +export function resolveWorktreeMetaWithDiscoveryBackfill( + store: Store, + repo: Repo, + worktreeId: string +): WorktreeMeta { + const existing = store.getWorktreeMeta(worktreeId) + const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, existing) + if (existing) { + const updates = { + ...(!existing.instanceId ? { instanceId: randomUUID() } : {}), + ...ownershipUpdates + } + if (Object.keys(updates).length > 0) { + // Why: pre-lineage profiles already have WorktreeMeta rows; backfill on discovery so upgraded workspaces get lineage and host routing. + return store.setWorktreeMeta(worktreeId, updates) + } + return existing + } + return store.setWorktreeMeta(worktreeId, { + lastActivityAt: Date.now(), + ...ownershipUpdates + }) +} diff --git a/src/main/ipc/worktrees/listing/worktree-host-ownership.ts b/src/main/ipc/worktrees/listing/worktree-host-ownership.ts new file mode 100644 index 00000000000..1f9eee701d8 --- /dev/null +++ b/src/main/ipc/worktrees/listing/worktree-host-ownership.ts @@ -0,0 +1,82 @@ +import type { Store } from '../../../persistence/loading-store/store' +import type { Repo } from '../../../../shared/repo-types' +import { + getRepoExecutionHostId, + parseExecutionHostId, + toSshExecutionHostId, + LOCAL_EXECUTION_HOST_ID +} from '../../../../shared/execution-host' +import type { ExecutionHostId } from '../../../../shared/execution-host' + +export function hasConflictingStoredWorktreeOwner( + store: Store, + repo: Repo, + worktreeIds: readonly string[] +): boolean { + const expectedHostId = getRepoExecutionHostId(repo) + const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length + return worktreeIds.some((worktreeId) => { + const meta = store.getWorktreeMeta(worktreeId) + return !!meta && (meta.hostId ? meta.hostId !== expectedHostId : repoOwnerCount > 1) + }) +} + +export type RepoOwnershipEvidence = + | { status: 'owned'; hostId: ExecutionHostId } + | { status: 'malformed' } + | { status: 'contradictory' } + +export function resolveRepoOwnershipEvidence(repo: Repo): RepoOwnershipEvidence { + const hasExplicitHost = repo.executionHostId !== null && repo.executionHostId !== undefined + const explicitHost = hasExplicitHost ? parseExecutionHostId(repo.executionHostId) : null + if (hasExplicitHost && !explicitHost) { + return { status: 'malformed' } + } + const hasConnection = repo.connectionId !== null && repo.connectionId !== undefined + const connectionId = hasConnection ? repo.connectionId?.trim() : null + if (hasConnection && !connectionId) { + return { status: 'malformed' } + } + const connectionHostId = connectionId ? toSshExecutionHostId(connectionId) : null + if (explicitHost && connectionHostId && explicitHost.id !== connectionHostId) { + return { status: 'contradictory' } + } + return { + status: 'owned', + hostId: explicitHost?.id ?? connectionHostId ?? LOCAL_EXECUTION_HOST_ID + } +} + +export function findExactRepoOwner( + store: Store, + repoId: string, + executionHostId?: ExecutionHostId +): Repo | undefined { + const candidates = store.getRepos().filter((repo) => repo.id === repoId) + const evidence = candidates.map(resolveRepoOwnershipEvidence) + if (evidence.some((owner) => owner.status !== 'owned')) { + return undefined + } + const matches = candidates.filter((_, index) => { + const owner = evidence[index] + return ( + owner?.status === 'owned' && + (executionHostId === undefined || owner.hostId === executionHostId) + ) + }) + return matches.length === 1 ? matches[0] : undefined +} + +export function isCapturedRepoCurrent( + store: Store, + repo: Repo, + executionHostId?: ExecutionHostId +): boolean { + const current = findExactRepoOwner(store, repo.id, executionHostId) + return ( + current !== undefined && + current.path === repo.path && + (current.connectionId ?? null) === (repo.connectionId ?? null) && + (current.executionHostId ?? null) === (repo.executionHostId ?? null) + ) +} diff --git a/src/main/ipc/worktrees/listing/worktree-listing-diagnostics.ts b/src/main/ipc/worktrees/listing/worktree-listing-diagnostics.ts new file mode 100644 index 00000000000..128bb46dd28 --- /dev/null +++ b/src/main/ipc/worktrees/listing/worktree-listing-diagnostics.ts @@ -0,0 +1,16 @@ +import { shouldEmitBoundedWarning } from '../../bounded-warning-dedupe' + +export const loggedUnavailableSshGitProviders = new Set() +export const loggedWorktreeListFailures = new Set() +export const loggedMalformedWorktreeMetaKeys = new Set() + +export function warnOnce(keySet: Set, key: string, message: string, error?: unknown): void { + if (!shouldEmitBoundedWarning(keySet, key)) { + return + } + if (error) { + console.warn(message, error) + } else { + console.warn(message) + } +} diff --git a/src/main/ipc/worktrees/metadata/host-lineage-listing.ts b/src/main/ipc/worktrees/metadata/host-lineage-listing.ts new file mode 100644 index 00000000000..a2ec052fe68 --- /dev/null +++ b/src/main/ipc/worktrees/metadata/host-lineage-listing.ts @@ -0,0 +1,110 @@ +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import type { Store } from '../../../persistence/loading-store/store' +import type { + ListDesktopLineageForHostArgs, + HostLineageSnapshot +} from '../../../../shared/host-lineage-contract' +import { parseExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import { getSshGitProvider } from '../../../providers/ssh-git-dispatch' +import type { SshGitProvider } from '../../../providers/ssh-git-provider' +import { isCurrentSshProviderAuthority } from '../../../ssh/ssh-provider-authority' +import { hasValidLineageSshAuthority } from '../listing/detected-provider-listing' +import { filterLineageForHost } from './workspace-lineage-filtering' + +export const LINEAGE_HYDRATION_TIMEOUT_MS = 5_000 + +export async function hydrateLineageWithinDeadline(runtime: OrcaRuntimeService): Promise { + let timeout: ReturnType | undefined + const hydration = Promise.resolve() + .then(() => runtime.hydrateInferredWorktreeLineage()) + .then( + () => true, + () => false + ) + const deadline = new Promise((resolve) => { + timeout = setTimeout(() => resolve(false), LINEAGE_HYDRATION_TIMEOUT_MS) + }) + try { + return await Promise.race([hydration, deadline]) + } finally { + if (timeout) { + clearTimeout(timeout) + } + } +} + +export async function listDesktopLineageForHost( + store: Store, + runtime: OrcaRuntimeService, + args: ListDesktopLineageForHostArgs +): Promise { + const parsedHost = parseExecutionHostId(args?.executionHostId) + const rejected = ( + reason: Extract['reason'] + ): HostLineageSnapshot => ({ + authoritative: false, + executionHostId: args.executionHostId, + reason + }) + if (!parsedHost || parsedHost.kind === 'runtime') { + return rejected('rejected') + } + let provider: SshGitProvider | undefined + let authority: + | Extract['expectedAuthority'] + | null = null + if (parsedHost.kind === 'local') { + if ('expectedAuthority' in args) { + return rejected('rejected') + } + } else { + if ( + !hasValidLineageSshAuthority(args) || + args.expectedAuthority.targetId !== parsedHost.targetId + ) { + return rejected('rejected') + } + authority = { ...args.expectedAuthority } + if (!isCurrentSshProviderAuthority(authority)) { + return rejected('stale') + } + provider = getSshGitProvider(parsedHost.targetId) + if (!provider) { + return rejected('unavailable') + } + } + if (!(await hydrateLineageWithinDeadline(runtime))) { + return rejected('unavailable') + } + if ( + parsedHost.kind === 'ssh' && + (!authority || + getSshGitProvider(parsedHost.targetId) !== provider || + !isCurrentSshProviderAuthority(authority)) + ) { + return rejected('stale') + } + const lineage = filterLineageForHost(store, parsedHost.id) + if (!lineage) { + return rejected('ambiguous-owner') + } + if (parsedHost.kind === 'local') { + return { + authoritative: true, + authority: { kind: 'local', executionHostId: LOCAL_EXECUTION_HOST_ID }, + ...lineage + } + } + if (!authority) { + return rejected('authority-unknown') + } + return { + authoritative: true, + authority: { + kind: 'direct-ssh', + executionHostId: parsedHost.id, + ...authority + }, + ...lineage + } +} diff --git a/src/main/ipc/worktrees/metadata/lineage-owner-resolution.ts b/src/main/ipc/worktrees/metadata/lineage-owner-resolution.ts new file mode 100644 index 00000000000..a8a58bf81d1 --- /dev/null +++ b/src/main/ipc/worktrees/metadata/lineage-owner-resolution.ts @@ -0,0 +1,123 @@ +import { parseExecutionHostId } from '../../../../shared/execution-host' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { Store } from '../../../persistence/loading-store/store' +import type { Repo } from '../../../../shared/repo-types' +import { parseWorktreeId } from '../../worktree-logic' +import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import { resolveRepoOwnershipEvidence } from '../listing/worktree-host-ownership' + +export type LineageOwner = + | { status: 'owned'; hostId: ExecutionHostId } + | { status: 'ambiguous' | 'contradictory' | 'runtime' } + +export type LineageFolder = FolderWorkspace +export type LineageGroup = ProjectGroup + +export type LineageResolutionContext = { + store: Store + repos: Repo[] + groups: LineageGroup[] + reposById: Map + foldersById: Map + groupsById: Map + groupSubtreeIdsByRoot: Map> + worktreeOwners: Map + folderOwners: Map + workspaceOwners: Map +} + +export function indexLineageEntriesById( + entries: readonly T[] +): Map { + const index = new Map() + for (const entry of entries) { + const matching = index.get(entry.id) ?? [] + matching.push(entry) + index.set(entry.id, matching) + } + return index +} + +export function createLineageResolutionContext(store: Store): LineageResolutionContext { + const repos = store.getRepos() + const folders = store.getFolderWorkspaces() + const groups = store.getProjectGroups() + return { + store, + repos, + groups, + reposById: indexLineageEntriesById(repos), + foldersById: indexLineageEntriesById(folders), + groupsById: indexLineageEntriesById(groups), + groupSubtreeIdsByRoot: new Map(), + worktreeOwners: new Map(), + folderOwners: new Map(), + workspaceOwners: new Map() + } +} + +export function resolveRepoLineageOwner(repo: Repo): LineageOwner { + const owner = resolveRepoOwnershipEvidence(repo) + if (owner.status === 'malformed') { + return { status: 'ambiguous' } + } + if (owner.status === 'contradictory') { + return { status: 'contradictory' } + } + return parseExecutionHostId(owner.hostId)?.kind === 'runtime' ? { status: 'runtime' } : owner +} + +export function resolveWorktreeLineageOwner( + context: LineageResolutionContext, + worktreeId: string +): LineageOwner { + const cached = context.worktreeOwners.get(worktreeId) + if (cached) { + return cached + } + const remember = (owner: LineageOwner): LineageOwner => { + context.worktreeOwners.set(worktreeId, owner) + return owner + } + let repoId: string + try { + repoId = parseWorktreeId(worktreeId).repoId + } catch { + return remember({ status: 'ambiguous' }) + } + const repos = context.reposById.get(repoId) ?? [] + const meta = context.store.getWorktreeMeta(worktreeId) + const runtimeOwnerEnvironmentId = ( + meta as (WorktreeMeta & { runtimeOwnerEnvironmentId?: string }) | undefined + )?.runtimeOwnerEnvironmentId?.trim() + if (runtimeOwnerEnvironmentId) { + return remember({ status: 'runtime' }) + } + if (meta?.hostId) { + const explicitHost = parseExecutionHostId(meta.hostId) + if (!explicitHost) { + return remember({ status: 'ambiguous' }) + } + if (explicitHost.kind === 'runtime') { + return remember({ status: 'runtime' }) + } + const matchingRepos = repos.filter((repo) => { + const owner = resolveRepoLineageOwner(repo) + return owner.status === 'owned' && owner.hostId === explicitHost.id + }) + if (matchingRepos.length === 1) { + return remember({ status: 'owned', hostId: explicitHost.id }) + } + return remember( + matchingRepos.length > 1 + ? { status: 'ambiguous' } + : { status: repos.length > 0 ? 'contradictory' : 'ambiguous' } + ) + } + if (repos.length !== 1) { + return remember({ status: 'ambiguous' }) + } + return remember(resolveRepoLineageOwner(repos[0])) +} diff --git a/src/main/ipc/worktrees/metadata/register-worktree-metadata-handlers.ts b/src/main/ipc/worktrees/metadata/register-worktree-metadata-handlers.ts new file mode 100644 index 00000000000..97d175a70df --- /dev/null +++ b/src/main/ipc/worktrees/metadata/register-worktree-metadata-handlers.ts @@ -0,0 +1,95 @@ +import { ipcMain } from 'electron' +import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' +import { stripOrcaProvenanceMetaUpdates } from '../../../worktree-removal-safety' +import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' +import type { + ListDesktopLineageForHostArgs, + HostLineageSnapshot +} from '../../../../shared/host-lineage-contract' +import { notifyWorktreesChanged } from '../../worktree-remote' +import { parseWorktreeId } from '../../worktree-logic' +import { planWorktreeSortOrderUpdates } from '../../../../shared/worktree/sort-order-update' +import { readBranchRenameFailureOutputForDisplay } from '../../../agent-hooks/branch-rename-failure-output' +import { normalizeLinkedWorkItemFields } from '../ipc-context-schemas' +import { listDesktopLineageForHost } from './host-lineage-listing' +import type { WorktreeIpcContext } from '../worktree-ipc-context' + +export function registerWorktreeMetadataHandlers(context: WorktreeIpcContext): void { + const { mainWindow, store, runtime } = context + + ipcMain.handle( + 'worktrees:updateMeta', + (_event, args: { worktreeId: string; updates: Partial }) => { + const validatedUpdates = normalizeLinkedWorkItemFields(args.updates) + const updates = + validatedUpdates.displayName !== undefined + ? { + ...validatedUpdates, + pendingFirstAgentMessageRename: false, + firstAgentMessageRenameError: null + } + : validatedUpdates + const meta = store.setWorktreeMeta(args.worktreeId, stripOrcaProvenanceMetaUpdates(updates)) + // Do NOT notify here: renderer already applied this optimistically; a notification would re-sort the sidebar (bug PR #209). + if (args.updates.displayName !== undefined) { + // Why: remote clients have no optimistic rename and stopped polling titles, so push a remote-only invalidation; gate on displayName so per-click isUnread updates stay event-free. + runtime.notifyWorktreesChangedForRemoteClients(getRepoIdFromWorktreeId(args.worktreeId)) + } + return meta + } + ) + + ipcMain.handle('worktrees:listLineage', async () => { + await runtime.hydrateInferredWorktreeLineage() + return { + lineage: store.getAllWorktreeLineage(), + workspaceLineage: store.getAllWorkspaceLineage() + } + }) + + ipcMain.handle( + 'worktrees:listLineageForHost', + (_event, args: ListDesktopLineageForHostArgs): Promise => + listDesktopLineageForHost(store, runtime, args) + ) + + ipcMain.handle( + 'worktrees:updateLineage', + async (_event, args: { worktreeId: string; parentWorktreeId?: string; noParent?: boolean }) => { + await runtime.updateManagedWorktreeMeta(args.worktreeId, { + lineage: + args.noParent === true + ? { noParent: true } + : args.parentWorktreeId + ? { parentWorktree: `id:${args.parentWorktreeId}` } + : undefined + }) + notifyWorktreesChanged(mainWindow, parseWorktreeId(args.worktreeId).repoId) + return store.getWorktreeLineage(args.worktreeId) ?? null + } + ) + + ipcMain.handle('worktrees:persistSortOrder', (_event, args: { orderedIds: string[] }) => { + if (!Array.isArray(args?.orderedIds) || args.orderedIds.length === 0) { + return + } + const updates = planWorktreeSortOrderUpdates( + args.orderedIds, + (worktreeId) => store.getWorktreeMeta(worktreeId), + Date.now() + ) + for (const update of updates) { + store.setWorktreeMeta(update.worktreeId, { sortOrder: update.sortOrder }) + } + }) + + ipcMain.handle( + 'worktrees:getBranchRenameFailureOutput', + (_event, args: { worktreeId: string }) => { + if (typeof args?.worktreeId !== 'string' || args.worktreeId.length === 0) { + return null + } + return readBranchRenameFailureOutputForDisplay(args.worktreeId) + } + ) +} diff --git a/src/main/ipc/worktrees/metadata/workspace-lineage-filtering.ts b/src/main/ipc/worktrees/metadata/workspace-lineage-filtering.ts new file mode 100644 index 00000000000..3106812418c --- /dev/null +++ b/src/main/ipc/worktrees/metadata/workspace-lineage-filtering.ts @@ -0,0 +1,184 @@ +import type { Repo } from '../../../../shared/repo-types' +import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups' +import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path' +import { parseExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import type { Store } from '../../../persistence/loading-store/store' +import type { WorktreeLineage, WorkspaceLineage } from '../../../../shared/worktree/lineage-types' +import { + createLineageResolutionContext, + resolveRepoLineageOwner, + resolveWorktreeLineageOwner +} from './lineage-owner-resolution' +import type { + LineageFolder, + LineageOwner, + LineageResolutionContext +} from './lineage-owner-resolution' + +export function getFolderLineageCandidateRepos( + context: LineageResolutionContext, + folder: LineageFolder +): Repo[] { + let groupIds = context.groupSubtreeIdsByRoot.get(folder.projectGroupId) + if (!groupIds) { + groupIds = getProjectGroupSubtreeIds(context.groups, folder.projectGroupId) + context.groupSubtreeIdsByRoot.set(folder.projectGroupId, groupIds) + } + const grouped = context.repos.filter( + (repo) => typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId) + ) + const pathRepos = context.repos.filter( + (repo) => + !(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) && + isPathInsideOrEqual(folder.folderPath, repo.path) + ) + const group = context.groupsById.get(folder.projectGroupId)?.[0] + const connectionId = folder.connectionId ?? group?.connectionId ?? null + return connectionId + ? [...grouped, ...pathRepos.filter((repo) => (repo.connectionId ?? null) === connectionId)] + : grouped.length > 0 + ? [ + ...grouped, + ...pathRepos.filter((repo) => + new Set(grouped.map((candidate) => candidate.connectionId ?? null)).has( + repo.connectionId ?? null + ) + ) + ] + : pathRepos +} + +export function resolveFolderLineageOwner( + context: LineageResolutionContext, + folderWorkspaceId: string +): LineageOwner { + const cached = context.folderOwners.get(folderWorkspaceId) + if (cached) { + return cached + } + const remember = (owner: LineageOwner): LineageOwner => { + context.folderOwners.set(folderWorkspaceId, owner) + return owner + } + const folders = context.foldersById.get(folderWorkspaceId) ?? [] + if (folders.length !== 1) { + return remember({ status: 'ambiguous' }) + } + const folder = folders[0] + const groups = context.groupsById.get(folder.projectGroupId) ?? [] + if (groups.length !== 1) { + return remember({ status: 'ambiguous' }) + } + const group = groups[0] + const hosts = new Set() + if (folder.connectionId) { + hosts.add(`ssh:${encodeURIComponent(folder.connectionId)}`) + } + if (group.connectionId) { + hosts.add(`ssh:${encodeURIComponent(group.connectionId)}`) + } + if (group.executionHostId) { + const parsed = parseExecutionHostId(group.executionHostId) + if (!parsed) { + return remember({ status: 'ambiguous' }) + } + hosts.add(parsed.id) + } + for (const repo of getFolderLineageCandidateRepos(context, folder)) { + const owner = resolveRepoLineageOwner(repo) + if (owner.status !== 'owned') { + return remember(owner) + } + hosts.add(owner.hostId) + } + if (hosts.size > 1) { + return remember({ status: 'contradictory' }) + } + const hostId = [...hosts][0] ?? LOCAL_EXECUTION_HOST_ID + return remember( + parseExecutionHostId(hostId)?.kind === 'runtime' + ? { status: 'runtime' } + : { status: 'owned', hostId } + ) +} + +export function resolveWorkspaceLineageOwner( + context: LineageResolutionContext, + workspaceKey: string +): LineageOwner { + const cached = context.workspaceOwners.get(workspaceKey) + if (cached) { + return cached + } + const workspace = parseWorkspaceKey(workspaceKey) + const owner = !workspace + ? { status: 'ambiguous' as const } + : workspace.type === 'worktree' + ? resolveWorktreeLineageOwner(context, workspace.worktreeId) + : resolveFolderLineageOwner(context, workspace.folderWorkspaceId) + context.workspaceOwners.set(workspaceKey, owner) + return owner +} + +export function filterLineageForHost( + store: Store, + executionHostId: ExecutionHostId +): { + worktreeLineageById: Record + workspaceLineageByChildKey: Record +} | null { + const context = createLineageResolutionContext(store) + const worktreeLineageById: Record = {} + const workspaceLineageByChildKey: Record = {} + for (const [worktreeId, lineage] of Object.entries(store.getAllWorktreeLineage())) { + const child = resolveWorktreeLineageOwner(context, worktreeId) + const parent = resolveWorktreeLineageOwner(context, lineage.parentWorktreeId) + if (child.status === 'ambiguous' || child.status === 'contradictory') { + return null + } + if (parent.status === 'ambiguous' || parent.status === 'contradictory') { + return null + } + if ( + child.status === 'owned' && + parent.status === 'owned' && + child.hostId === executionHostId && + parent.hostId === executionHostId + ) { + worktreeLineageById[worktreeId] = structuredClone(lineage) + } else if ( + child.status === 'owned' && + parent.status === 'owned' && + child.hostId !== parent.hostId + ) { + return null + } + } + for (const [childKey, lineage] of Object.entries(store.getAllWorkspaceLineage())) { + const child = resolveWorkspaceLineageOwner(context, childKey) + const parent = resolveWorkspaceLineageOwner(context, lineage.parentWorkspaceKey) + if (child.status === 'ambiguous' || child.status === 'contradictory') { + return null + } + if (parent.status === 'ambiguous' || parent.status === 'contradictory') { + return null + } + if ( + child.status === 'owned' && + parent.status === 'owned' && + child.hostId === executionHostId && + parent.hostId === executionHostId + ) { + workspaceLineageByChildKey[childKey] = structuredClone(lineage) + } else if ( + child.status === 'owned' && + parent.status === 'owned' && + child.hostId !== parent.hostId + ) { + return null + } + } + return { worktreeLineageById, workspaceLineageByChildKey } +} diff --git a/src/main/ipc/worktrees/removal/execute-worktree-removal.ts b/src/main/ipc/worktrees/removal/execute-worktree-removal.ts new file mode 100644 index 00000000000..6f0d91ffef5 --- /dev/null +++ b/src/main/ipc/worktrees/removal/execute-worktree-removal.ts @@ -0,0 +1,181 @@ +import type { Repo } from '../../../../shared/repo-types' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-types' +import { isFolderRepo } from '../../../../shared/repo-kind' +import { assertWorktreeUnlockedForRemoval } from '../../../../shared/worktree/removal' +import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path' +import { getLocalProjectWorktreeGitOptions } from '../../../project-runtime-git-options' +import { listWorktreesStrict as listGitWorktreesStrict } from '../../../git/worktree' +import { requireSshGitProvider } from '../../../providers/ssh-git-dispatch' +import { resolveWorktreeRemovalMetadata } from '../../../worktree-removal-repo-owner' +import { findRegisteredDeletableWorktree } from '../../../worktree-removal-safety' +import { removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval } from '../../../local-worktree-removal-recovery' +import { runHook } from '../../../hooks' +import { withWorktreeRemoveStageSpan } from '../../../observability/instrumentation' +import { + cleanupUnusedWorktreePushTargetRemote, + notifyWorktreesChanged +} from '../../worktree-remote' +import { invalidateAuthorizedRootsCache } from '../../registered-worktree-roots-cache' +import { formatWorktreeRemovalError } from '../../worktree-logic' +import type { RemoveWorktreeArgs } from '../ipc-context-schemas' +import type { WorktreeIpcContext } from '../worktree-ipc-context' +import { getArchiveHooksForRemoval, runRemoteArchiveHook } from './worktree-archive-hook' +import { isAlreadyRemovedWorktreePath } from './worktree-removal-filesystem' +import { rememberPreservedBranchCleanupTarget } from './preserved-branch-cleanup' +import { removeWorktreeMetadataAndTransientState } from './worktree-removal-ownership' +import { removeFolderWorkspace } from './remove-folder-workspace' +import { removeUnregisteredWorktree } from './remove-unregistered-worktree' +import { removeRegisteredRemoteWorktree } from './remove-registered-remote-worktree' +import { removeRegisteredLocalWorktree } from './remove-registered-local-worktree' + +export async function executeWorktreeRemoval( + context: WorktreeIpcContext, + args: RemoveWorktreeArgs, + repo: Repo, + repoId: string, + worktreePath: string, + removalHostId: ExecutionHostId +): Promise { + const { mainWindow, store, runtime } = context + if (isFolderRepo(repo)) { + return removeFolderWorkspace(context, args, repo, repoId, removalHostId) + } + const provider = repo.connectionId ? requireSshGitProvider(repo.connectionId) : null + const localWorktreeGitOptions = repo.connectionId + ? {} + : getLocalProjectWorktreeGitOptions(store, repo) + const hasLocalWorktreeGitOptions = Object.keys(localWorktreeGitOptions).length > 0 + const registeredWorktrees = repo.connectionId + ? await provider!.listWorktrees(repo.path) + : hasLocalWorktreeGitOptions + ? await listGitWorktreesStrict(repo.path, localWorktreeGitOptions) + : await listGitWorktreesStrict(repo.path) + const removedMeta = resolveWorktreeRemovalMetadata(store, repoId, args.worktreeId, removalHostId) + const removedPushTarget = removedMeta?.pushTarget + const registeredWorktree = findRegisteredDeletableWorktree( + repo.path, + worktreePath, + registeredWorktrees + ) + if (!registeredWorktree) { + return removeUnregisteredWorktree( + context, + args, + repo, + repoId, + worktreePath, + removalHostId, + registeredWorktrees, + removedMeta, + removedPushTarget, + localWorktreeGitOptions, + provider + ) + } + const canonicalWorktreePath = registeredWorktree.path + + const deleteBranch = removedMeta?.preserveBranchOnDelete !== true + + try { + assertWorktreeUnlockedForRemoval(registeredWorktree) + } catch (error) { + throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false)) + } + + if ( + !repo.connectionId && + args.force === true && + process.platform === 'win32' && + (isWindowsAbsolutePathLike(canonicalWorktreePath) || !!localWorktreeGitOptions.wslDistro) && + removedMeta && + (await isAlreadyRemovedWorktreePath(repo, canonicalWorktreePath, localWorktreeGitOptions)) + ) { + const removalResult = await removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval({ + canonicalWorktreePath, + repoPath: repo.path, + localWorktreeGitOptions, + registeredWorktree, + deleteBranch + }) + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + args.worktreeId, + removedPushTarget, + store, + localWorktreeGitOptions + ) + rememberPreservedBranchCleanupTarget( + args.worktreeId, + removalHostId, + removalResult, + registeredWorktree.head, + removedPushTarget + ) + runtime.clearOptimisticReconcileToken(args.worktreeId) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) + invalidateAuthorizedRootsCache() + notifyWorktreesChanged(mainWindow, repoId) + return removalResult ?? {} + } + + const hooks = await getArchiveHooksForRemoval(repo) + + const archiveScript = hooks?.scripts.archive + + if (archiveScript && !args.skipArchive) { + // Why the branch on connectionId: this block is shared by both flows, so a hardcoded + // 'remote' would file every local archive hook under the SSH breakdown. + await withWorktreeRemoveStageSpan( + 'archive_hook', + repo.connectionId ? 'remote' : 'local', + async () => { + const result = repo.connectionId + ? await runRemoteArchiveHook(repo, canonicalWorktreePath, archiveScript) + : await runHook( + 'archive', + canonicalWorktreePath, + repo, + undefined, + localWorktreeGitOptions + ) + if (!result.success) { + console.error(`[hooks] archive hook failed for ${canonicalWorktreePath}:`, result.output) + } + } + ) + } + + const remoteConnectionId = repo.connectionId ?? undefined + if (remoteConnectionId) { + return removeRegisteredRemoteWorktree( + context, + args, + repo, + repoId, + canonicalWorktreePath, + removalHostId, + registeredWorktree, + removedPushTarget, + provider!, + deleteBranch + ) + } + return removeRegisteredLocalWorktree( + context, + args, + repo, + repoId, + canonicalWorktreePath, + removalHostId, + removedPushTarget, + localWorktreeGitOptions, + hasLocalWorktreeGitOptions, + deleteBranch + ) +} diff --git a/src/main/ipc/worktrees/removal/preserved-branch-cleanup.ts b/src/main/ipc/worktrees/removal/preserved-branch-cleanup.ts new file mode 100644 index 00000000000..948e997ccd0 --- /dev/null +++ b/src/main/ipc/worktrees/removal/preserved-branch-cleanup.ts @@ -0,0 +1,80 @@ +import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-types' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { GitPushTarget } from '../../../../shared/worktree/types' +import { preservedBranchCleanupScopeKey } from '../../../../shared/preserved-branch-cleanup' + +export type PreservedBranchCleanupTarget = { + worktreeId: string + hostId: ExecutionHostId + branchName: string + head: string + pushTarget?: GitPushTarget +} + +export const preservedBranchCleanupByScope = new Map() + +export function rememberPreservedBranchCleanupTarget( + worktreeId: string, + hostId: ExecutionHostId, + result: RemoveWorktreeResult | undefined, + fallbackHead: string | undefined, + pushTarget: GitPushTarget | undefined +): void { + if (result?.preservedBranch) { + const head = result.preservedBranch.head ?? fallbackHead + if (!head) { + throw new Error( + `Cannot safely offer force-delete for preserved branch "${result.preservedBranch.branchName}" without its saved commit.` + ) + } + preservedBranchCleanupByScope.set(preservedBranchCleanupScopeKey({ worktreeId, hostId }), { + worktreeId, + hostId, + branchName: result.preservedBranch.branchName, + head, + ...(pushTarget ? { pushTarget } : {}) + }) + return + } + preservedBranchCleanupByScope.delete(preservedBranchCleanupScopeKey({ worktreeId, hostId })) +} + +export function preserveBranchHeadFallback( + result: RemoveWorktreeResult | undefined, + fallbackHead: string | undefined +): RemoveWorktreeResult { + if (!result?.preservedBranch || result.preservedBranch.head || !fallbackHead) { + return result ?? {} + } + return { + ...result, + preservedBranch: { + ...result.preservedBranch, + head: fallbackHead + } + } +} + +export function getPreservedBranchCleanupTarget( + worktreeId: string, + branchName: string, + expectedHead: string, + hostId?: ExecutionHostId +): PreservedBranchCleanupTarget { + const exactTarget = hostId + ? preservedBranchCleanupByScope.get(preservedBranchCleanupScopeKey({ worktreeId, hostId })) + : undefined + const legacyMatches = hostId + ? [] + : [...preservedBranchCleanupByScope.values()].filter( + (target) => + target.worktreeId === worktreeId && + target.branchName === branchName && + target.head === expectedHead + ) + const target = exactTarget ?? (legacyMatches.length === 1 ? legacyMatches[0] : undefined) + if (!target || target.branchName !== branchName || target.head !== expectedHead) { + throw new Error(`No preserved branch cleanup is pending for "${branchName}".`) + } + return target +} diff --git a/src/main/ipc/worktrees/removal/register-worktree-forget-handlers.ts b/src/main/ipc/worktrees/removal/register-worktree-forget-handlers.ts new file mode 100644 index 00000000000..393ccc1a77b --- /dev/null +++ b/src/main/ipc/worktrees/removal/register-worktree-forget-handlers.ts @@ -0,0 +1,221 @@ +import { ipcMain } from 'electron' +import type { + RemoveWorktreeResult, + ForceDeleteWorktreeBranchResult +} from '../../../../shared/worktree/create-types' +import { parseWorktreeId } from '../../worktree-logic' +import { resolveWorktreeRemovalRepoOwner } from '../../../worktree-removal-repo-owner' +import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { Repo } from '../../../../shared/repo-types' +import { isFolderRepo } from '../../../../shared/repo-kind' +import { getSshPtyProvider, getLocalPtyProvider, clearProviderPtyState } from '../../pty' +import { killAllProcessesForWorktree } from '../../../runtime/worktree-teardown' +import { invalidateAuthorizedRootsCache } from '../../registered-worktree-roots-cache' +import { preservedBranchCleanupScopeKey } from '../../../../shared/preserved-branch-cleanup' +import { + notifyWorktreesChanged, + cleanupUnusedWorktreePushTargetRemoteSsh, + cleanupUnusedWorktreePushTargetRemote +} from '../../worktree-remote' +import { requireSshGitProvider } from '../../../providers/ssh-git-dispatch' +import { getLocalProjectWorktreeGitOptions } from '../../../project-runtime-git-options' +import { forceDeleteLocalBranch } from '../../../git/worktree' +import { gitExecFileAsync } from '../../../git/runner' +import type { RemoveWorktreeArgs } from '../ipc-context-schemas' +import { getWorktreeRemovalInFlightKey } from './worktree-removal-coordinator' +import { getFolderWorkspaceRootId } from '../folder-workspace-model' +import { + removeWorktreeMetadataAndTransientState, + resolveWorktreeRemovalOwnerHostId +} from './worktree-removal-ownership' +import { resolveRepoForExecutionHost } from '../repo-host-ownership' +import { + getPreservedBranchCleanupTarget, + preservedBranchCleanupByScope +} from './preserved-branch-cleanup' +import type { WorktreeIpcContext } from '../worktree-ipc-context' + +export function registerWorktreeForgetHandlers(context: WorktreeIpcContext): void { + const { mainWindow, store, runtime, worktreeRemovalsInFlight } = context + + ipcMain.handle( + 'worktrees:forgetLocal', + async ( + _event, + args: Pick + ): Promise => { + const { repoId } = parseWorktreeId(args.worktreeId) + const repoOwner = resolveWorktreeRemovalRepoOwner(store, repoId, args.hostId) + if (!args.hostId && repoOwner.kind === 'ambiguous') { + throw new Error( + `Workspace identity is ambiguous across hosts: ${args.worktreeId}. Retry with an explicit host.` + ) + } + const repo = repoOwner.kind === 'resolved' ? repoOwner.repo : undefined + // Repo-first (unlike owner resolution below) so this key matches worktrees:remove's; meta only covers ownerless forgets. + const inFlightKey = getWorktreeRemovalInFlightKey( + args.worktreeId, + repo + ? getRepoExecutionHostId(repo) + : (args.hostId ?? store.getWorktreeMeta(args.worktreeId)?.hostId) + ) + const optionsKey = 'forget-local' + const inFlight = worktreeRemovalsInFlight.get(inFlightKey) + if (inFlight) { + if (inFlight.optionsKey === optionsKey) { + return inFlight.promise + } + throw new Error(`Worktree deletion already in progress: ${args.worktreeId}`) + } + + const forget = (async (): Promise => { + const isFolderRootOf = (candidate: Repo): boolean => + isFolderRepo(candidate) && args.worktreeId === getFolderWorkspaceRootId(candidate) + const fallbackRepos = args.hostId + ? store + .getRepos() + .filter((candidate) => getRepoExecutionHostId(candidate) === args.hostId) + : store.getRepos() + if (repo ? isFolderRootOf(repo) : fallbackRepos.some(isFolderRootOf)) { + throw new Error( + 'Cannot delete the project root workspace. Remove the folder project instead.' + ) + } + + const ownerHostId = resolveWorktreeRemovalOwnerHostId( + store, + args.worktreeId, + repo, + args.hostId + ) + const ownerHost = parseExecutionHostId(ownerHostId) + const sshPtyProvider = + ownerHost?.kind === 'ssh' ? getSshPtyProvider(ownerHost.targetId) : undefined + const externalHost = ownerHost?.kind === 'ssh' || ownerHost?.kind === 'runtime' + // External host inventories must never sweep a same-id local workspace. + await killAllProcessesForWorktree(args.worktreeId, { + runtime, + resolvedWorktreeId: args.worktreeId, + ...(ownerHost?.kind === 'ssh' ? { resolvedConnectionId: ownerHost.targetId } : {}), + ...(ownerHost?.kind === 'runtime' + ? { resolvedRuntimeEnvironmentId: ownerHost.environmentId } + : {}), + localProvider: sshPtyProvider ?? getLocalPtyProvider(), + onPtyStopped: clearProviderPtyState, + ...(externalHost + ? { + includeProviderInventory: ownerHost?.kind === 'ssh' && Boolean(sshPtyProvider), + includeLocalRegistry: false + } + : {}) + }).catch((err) => { + console.warn(`[worktree-teardown] forget-local failed for ${args.worktreeId}:`, err) + }) + + runtime.clearOptimisticReconcileToken(args.worktreeId) + // The resolved owner, not args.hostId: an orphan forget with no hostId still has to purge its SSH/runtime partition. + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + ownerHost?.id, + args.snapshotPruneBatchId + ) + // Why: cached roots outlive the forgotten workspace, so an ownerless path stays filesystem-authorized until a rebuild. + invalidateAuthorizedRootsCache() + if (ownerHost?.id) { + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ worktreeId: args.worktreeId, hostId: ownerHost.id }) + ) + } else { + for (const [key, target] of preservedBranchCleanupByScope) { + if (target.worktreeId === args.worktreeId) { + preservedBranchCleanupByScope.delete(key) + } + } + } + notifyWorktreesChanged(mainWindow, repoId) + return {} + })() + worktreeRemovalsInFlight.set(inFlightKey, { optionsKey, promise: forget }) + try { + return await forget + } finally { + if (worktreeRemovalsInFlight.get(inFlightKey)?.promise === forget) { + worktreeRemovalsInFlight.delete(inFlightKey) + } + } + } + ) + + ipcMain.handle( + 'worktrees:forceDeletePreservedBranch', + async ( + _event, + args: { + worktreeId: string + branchName: string + expectedHead: string + hostId?: ExecutionHostId + } + ): Promise => { + const { repoId } = parseWorktreeId(args.worktreeId) + const cleanupTarget = getPreservedBranchCleanupTarget( + args.worktreeId, + args.branchName, + args.expectedHead, + args.hostId + ) + const repo = resolveRepoForExecutionHost(store, repoId, cleanupTarget.hostId) + if (!repo) { + throw new Error(`Repo not found: ${repoId}`) + } + if (isFolderRepo(repo)) { + throw new Error('Folder workspaces do not have local Git branches.') + } + + if (repo.connectionId) { + const provider = requireSshGitProvider(repo.connectionId) + // Why: SSH needs the write-capable relay RPC; the read-only git.exec allowlist rejects these worktree/update-ref/config writes. + await provider.forceDeletePreservedBranch( + repo.path, + cleanupTarget.branchName, + cleanupTarget.head + ) + await cleanupUnusedWorktreePushTargetRemoteSsh( + provider, + repo.path, + args.worktreeId, + cleanupTarget.pushTarget, + store + ) + } else { + const localWorktreeGitOptions = getLocalProjectWorktreeGitOptions(store, repo) + const hasLocalWorktreeGitOptions = Object.keys(localWorktreeGitOptions).length > 0 + await (hasLocalWorktreeGitOptions + ? forceDeleteLocalBranch( + repo.path, + cleanupTarget.branchName, + cleanupTarget.head, + (argv, cwd) => gitExecFileAsync(argv, { cwd, ...localWorktreeGitOptions }) + ) + : forceDeleteLocalBranch(repo.path, cleanupTarget.branchName, cleanupTarget.head)) + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + args.worktreeId, + cleanupTarget.pushTarget, + store, + localWorktreeGitOptions + ) + } + + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ + worktreeId: args.worktreeId, + hostId: cleanupTarget.hostId + }) + ) + return { deleted: true } + } + ) +} diff --git a/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts b/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts new file mode 100644 index 00000000000..5aff34966c8 --- /dev/null +++ b/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts @@ -0,0 +1,58 @@ +import { ipcMain } from 'electron' +import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-types' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import { withWorktreeSpan } from '../../../observability/instrumentation' +import { parseWorktreeId } from '../../worktree-logic' +import type { RemoveWorktreeArgs } from '../ipc-context-schemas' +import type { WorktreeIpcContext } from '../worktree-ipc-context' +import { executeWorktreeRemoval } from './execute-worktree-removal' +import { + getWorktreeRemovalInFlightKey, + getWorktreeRemovalOptionsKey +} from './worktree-removal-coordinator' +import { resolveRepoForExecutionHost } from '../repo-host-ownership' + +export function registerWorktreeRemovalHandlers(context: WorktreeIpcContext): void { + const { store, options, worktreeRemovalsInFlight } = context + + ipcMain.handle( + 'worktrees:remove', + async (_event, args: RemoveWorktreeArgs): Promise => { + const { repoId, worktreePath } = parseWorktreeId(args.worktreeId) + const repo = resolveRepoForExecutionHost(store, repoId, args.hostId) + if (!repo) { + throw new Error(`Repo not found: ${repoId}`) + } + // The resolved repo supplies host ownership when legacy callers omit args.hostId. + const removalHostId = getRepoExecutionHostId(repo) + const inFlightKey = getWorktreeRemovalInFlightKey(args.worktreeId, removalHostId) + const optionsKey = getWorktreeRemovalOptionsKey(args) + const inFlightRemoval = worktreeRemovalsInFlight.get(inFlightKey) + if (inFlightRemoval) { + if (inFlightRemoval.optionsKey === optionsKey) { + return inFlightRemoval.promise + } + throw new Error(`Worktree deletion already in progress: ${args.worktreeId}`) + } + + // Why: concurrent stale-toast/double-click/sidebar races can hit the same worktree; share the op so only one path touches Git and disk. + const removal = withWorktreeSpan({ stage: 'remove', path: worktreePath }, () => + executeWorktreeRemoval(context, args, repo, repoId, worktreePath, removalHostId) + ) + worktreeRemovalsInFlight.set(inFlightKey, { optionsKey, promise: removal }) + try { + const result = await removal + options?.onWorktreeLifecycle?.({ + kind: 'removed', + worktreeId: args.worktreeId, + path: parseWorktreeId(args.worktreeId).worktreePath + }) + return result + } finally { + if (worktreeRemovalsInFlight.get(inFlightKey)?.promise === removal) { + worktreeRemovalsInFlight.delete(inFlightKey) + } + } + } + ) +} diff --git a/src/main/ipc/worktrees/removal/remove-folder-workspace.ts b/src/main/ipc/worktrees/removal/remove-folder-workspace.ts new file mode 100644 index 00000000000..c03b1449136 --- /dev/null +++ b/src/main/ipc/worktrees/removal/remove-folder-workspace.ts @@ -0,0 +1,70 @@ +import type { Repo } from '../../../../shared/repo-types' +import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-types' +import { deleteRemoteWorktreeHistory } from '../../../remote-worktree-history-cleanup' +import { killAllProcessesForWorktree } from '../../../runtime/worktree-teardown' +import { withWorktreeRemoveStageSpan } from '../../../observability/instrumentation' +import { preservedBranchCleanupScopeKey } from '../../../../shared/preserved-branch-cleanup' +import { notifyWorktreesChanged } from '../../worktree-remote' +import { clearProviderPtyState, getLocalPtyProvider, getSshPtyProvider } from '../../pty' +import type { RemoveWorktreeArgs } from '../ipc-context-schemas' +import type { WorktreeIpcContext } from '../worktree-ipc-context' +import { getFolderWorkspaceRootId } from '../folder-workspace-model' +import { preservedBranchCleanupByScope } from './preserved-branch-cleanup' +import { removeWorktreeMetadataAndTransientState } from './worktree-removal-ownership' + +export async function removeFolderWorkspace( + context: WorktreeIpcContext, + args: RemoveWorktreeArgs, + repo: Repo, + repoId: string, + removalHostId: ExecutionHostId +): Promise { + const { mainWindow, store, runtime } = context + if (args.worktreeId === getFolderWorkspaceRootId(repo)) { + throw new Error('Cannot delete the project root workspace. Remove the folder project instead.') + } + const ownerHost = parseExecutionHostId(removalHostId) + const sshPtyProvider = + ownerHost?.kind === 'ssh' ? getSshPtyProvider(ownerHost.targetId) : undefined + // Why: folder workspaces share one root, so there's no Git remove step to close shells; sweep PTYs before dropping metadata. + await withWorktreeRemoveStageSpan('pty_sweep', 'folder', async () => { + // Folder projects can be SSH-backed, so fence the sweep to the owning host exactly + // like the git paths — the local inventory must never reach a remote workspace's id. + // The resolved repo is authoritative here: path-derived metadata is shared by + // same-id host copies and can describe a different owner's workspace. + const externalHost = ownerHost?.kind === 'ssh' || ownerHost?.kind === 'runtime' + await killAllProcessesForWorktree(args.worktreeId, { + runtime, + resolvedWorktreeId: args.worktreeId, + ...(ownerHost?.kind === 'ssh' ? { resolvedConnectionId: ownerHost.targetId } : {}), + ...(ownerHost?.kind === 'runtime' + ? { resolvedRuntimeEnvironmentId: ownerHost.environmentId } + : {}), + localProvider: sshPtyProvider ?? getLocalPtyProvider(), + onPtyStopped: clearProviderPtyState, + ...(externalHost + ? { + includeProviderInventory: ownerHost?.kind === 'ssh' && Boolean(sshPtyProvider), + includeLocalRegistry: false + } + : {}) + }).catch((err) => { + console.warn(`[worktree-teardown] failed for ${args.worktreeId}:`, err) + }) + }) + await withWorktreeRemoveStageSpan('metadata_purge', 'folder', async () => { + await deleteRemoteWorktreeHistory(sshPtyProvider, args.worktreeId) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) + }) + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ worktreeId: args.worktreeId, hostId: removalHostId }) + ) + notifyWorktreesChanged(mainWindow, repoId) + return {} +} diff --git a/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts b/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts new file mode 100644 index 00000000000..2b81078b1dd --- /dev/null +++ b/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts @@ -0,0 +1,248 @@ +import type { Repo } from '../../../../shared/repo-types' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-types' +import type { GitPushTarget } from '../../../../shared/worktree/types' +import { assertWorktreeUnlockedForRemoval } from '../../../../shared/worktree/removal' +import type { LocalProjectWorktreeGitOptions } from '../../../project-runtime-git-options' +import { + assertWorktreeCleanForRemoval, + listWorktreesStrict as listGitWorktreesStrict, + removeWorktree +} from '../../../git/worktree' +import { gitExecFileAsync } from '../../../git/runner' +import { getWorktreeSharedLinkPaths } from '../../../git/worktree-shared-directories' +import { + getLocalWorktreePathAccess, + removeLocalWorktreePath, + toLocalWorktreeRuntimePath +} from '../../../local-worktree-filesystem' +import { recoverLocalWindowsWorktreeRemoval } from '../../../local-worktree-removal-recovery' +import { withWorktreeRemoveStageSpan } from '../../../observability/instrumentation' +import { + canSafelyRemoveOrphanedWorktreeDirectory, + findRegisteredDeletableWorktree +} from '../../../worktree-removal-safety' +import { + cleanupUnusedWorktreePushTargetRemote, + notifyWorktreesChanged +} from '../../worktree-remote' +import { + findExistingWorktreeSymlinkPaths, + removeWorktreeLinkedPaths +} from '../../worktree-symlinks' +import { invalidateAuthorizedRootsCache } from '../../registered-worktree-roots-cache' +import { + formatWorktreeRemovalError, + isOrphanCompatiblePreflightError, + isOrphanedWorktreeError +} from '../../worktree-logic' +import type { RemoveWorktreeArgs } from '../ipc-context-schemas' +import type { WorktreeIpcContext } from '../worktree-ipc-context' +import { + preserveBranchHeadFallback, + preservedBranchCleanupByScope, + rememberPreservedBranchCleanupTarget +} from './preserved-branch-cleanup' +import { + removeWorktreeMetadataAndTransientState, + stopPtysForDestructiveWorktreeRemoval +} from './worktree-removal-ownership' +import { preservedBranchCleanupScopeKey } from '../../../../shared/preserved-branch-cleanup' + +export async function removeRegisteredLocalWorktree( + context: WorktreeIpcContext, + args: RemoveWorktreeArgs, + repo: Repo, + repoId: string, + canonicalWorktreePath: string, + removalHostId: ExecutionHostId, + removedPushTarget: GitPushTarget | undefined, + localWorktreeGitOptions: LocalProjectWorktreeGitOptions, + hasLocalWorktreeGitOptions: boolean, + deleteBranch: boolean +): Promise { + const { mainWindow, store, runtime } = context + const refreshedWorktrees = hasLocalWorktreeGitOptions + ? await listGitWorktreesStrict(repo.path, localWorktreeGitOptions) + : await listGitWorktreesStrict(repo.path) + const refreshedRegisteredWorktree = findRegisteredDeletableWorktree( + repo.path, + canonicalWorktreePath, + refreshedWorktrees + ) + if (!refreshedRegisteredWorktree) { + throw new Error( + `Worktree registration changed during deletion: ${canonicalWorktreePath}. Retry deletion.` + ) + } + try { + // Why: an archive hook can race another Git client that locks the row; recheck before linked-path/watcher/terminal teardown. + assertWorktreeUnlockedForRemoval(refreshedRegisteredWorktree) + } catch (error) { + throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false)) + } + + // Why: `orca.yaml` shared directories are symlinked in too, and a + // directory-only ignore rule leaves those links untracked, so removal must + // tolerate and unlink them exactly like the per-user shared paths. + const linkedPaths = getWorktreeSharedLinkPaths(repo) + const ignoredLinkedPaths = args.force + ? [] + : await findExistingWorktreeSymlinkPaths(canonicalWorktreePath, linkedPaths) + try { + await (hasLocalWorktreeGitOptions + ? assertWorktreeCleanForRemoval(canonicalWorktreePath, args.force ?? false, { + ...localWorktreeGitOptions, + ...(ignoredLinkedPaths.length > 0 ? { ignoredUntrackedPaths: ignoredLinkedPaths } : {}) + }) + : ignoredLinkedPaths.length > 0 + ? assertWorktreeCleanForRemoval(canonicalWorktreePath, args.force ?? false, { + ignoredUntrackedPaths: ignoredLinkedPaths + }) + : assertWorktreeCleanForRemoval(canonicalWorktreePath, args.force ?? false)) + } catch (error) { + if (!isOrphanCompatiblePreflightError(error)) { + throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false)) + } + // Why: Git can still classify this as an orphan after preflight; keep strict PTY teardown before any recursive fallback deletion. + } + + let removalResult: RemoveWorktreeResult | undefined + const removalGate = await withWorktreeRemoveStageSpan('watcher_gate', 'local', async () => + runtime.acquireFileWatcherRemoval(canonicalWorktreePath) + ) + let removalCompleted = false + try { + // Why: hold the watcher/terminal gate through Git and any recursive fallback so no late spawn recreates a native handle. + // Linked-path deletion is destructive too, so PTYs must release every handle before Windows or WSL filesystem cleanup starts. + await withWorktreeRemoveStageSpan('pty_sweep', 'local', async () => { + await stopPtysForDestructiveWorktreeRemoval(runtime, args.worktreeId, { + allowUnverifiedStop: args.allowUnverifiedPtyStop + }) + }) + + // Why: preflight only ignored these paths, not mutated them; keep watcher installs fenced through Git removal. + if (linkedPaths.length > 0) { + await removeWorktreeLinkedPaths(canonicalWorktreePath, linkedPaths) + } + + try { + const removeOptions = { + ...(!deleteBranch ? { deleteBranch } : {}), + // Why: reuse the authoritative worktree list already computed here instead of rescanning siblings on the hot delete path. + knownRemovedWorktree: refreshedRegisteredWorktree, + ...(hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {}) + } + removalResult = preserveBranchHeadFallback( + await withWorktreeRemoveStageSpan('git_remove', 'local', async () => + removeWorktree(repo.path, canonicalWorktreePath, args.force ?? false, removeOptions) + ), + refreshedRegisteredWorktree.head + ) + } catch (error) { + // Why: Git for Windows can deregister a clean worktree before its recursive filesystem deletion fails transiently. + const recoveredRemovalResult = await recoverLocalWindowsWorktreeRemoval({ + error, + force: args.force ?? false, + canonicalWorktreePath, + repoPath: repo.path, + localWorktreeGitOptions, + registeredWorktree: refreshedRegisteredWorktree, + deleteBranch, + closeWatcher: (worktreePath) => runtime.closeFileWatchersForRemoval(worktreePath) + }) + if (recoveredRemovalResult) { + removalResult = recoveredRemovalResult + removalCompleted = true + } else if (isOrphanedWorktreeError(error)) { + // If git no longer tracks this worktree, clean up the directory and metadata + console.warn( + `[worktrees] Orphaned worktree detected at ${canonicalWorktreePath}, cleaning up` + ) + const access = getLocalWorktreePathAccess(localWorktreeGitOptions) + if ( + await canSafelyRemoveOrphanedWorktreeDirectory( + toLocalWorktreeRuntimePath(canonicalWorktreePath, localWorktreeGitOptions), + toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), + access.statPath, + access.readPath + ) + ) { + await runtime.closeFileWatchersForRemoval(canonicalWorktreePath) + await removeLocalWorktreePath(canonicalWorktreePath, localWorktreeGitOptions).catch( + () => {} + ) + } else { + console.warn( + `[worktrees] Refusing recursive cleanup for unproven worktree directory: ${canonicalWorktreePath}` + ) + } + // Why: remove failed so git still tracks it (.git/worktrees/); prune or the stale entry keeps its branch locked. + await gitExecFileAsync(['worktree', 'prune'], { + cwd: repo.path, + ...localWorktreeGitOptions + }).catch(() => {}) + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + args.worktreeId, + removedPushTarget, + store, + localWorktreeGitOptions + ) + runtime.clearOptimisticReconcileToken(args.worktreeId) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ + worktreeId: args.worktreeId, + hostId: removalHostId + }) + ) + invalidateAuthorizedRootsCache() + notifyWorktreesChanged(mainWindow, repoId) + removalCompleted = true + return {} + } else { + throw new Error( + formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false) + ) + } + } + removalCompleted = true + } finally { + await removalGate.finish(removalCompleted) + } + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + args.worktreeId, + removedPushTarget, + store, + localWorktreeGitOptions + ) + rememberPreservedBranchCleanupTarget( + args.worktreeId, + removalHostId, + removalResult, + refreshedRegisteredWorktree.head, + removedPushTarget + ) + runtime.clearOptimisticReconcileToken(args.worktreeId) + await withWorktreeRemoveStageSpan('metadata_purge', 'local', async () => { + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) + }) + await withWorktreeRemoveStageSpan('cache_invalidation', 'local', async () => { + invalidateAuthorizedRootsCache() + }) + + notifyWorktreesChanged(mainWindow, repoId) + return removalResult ?? {} +} diff --git a/src/main/ipc/worktrees/removal/remove-registered-remote-worktree.ts b/src/main/ipc/worktrees/removal/remove-registered-remote-worktree.ts new file mode 100644 index 00000000000..47297eda4d7 --- /dev/null +++ b/src/main/ipc/worktrees/removal/remove-registered-remote-worktree.ts @@ -0,0 +1,97 @@ +import type { Repo } from '../../../../shared/repo-types' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-types' +import type { GitPushTarget, GitWorktreeInfo } from '../../../../shared/worktree/types' +import type { SshGitProvider } from '../../../providers/ssh-git-provider' +import { deleteRemoteWorktreeHistory } from '../../../remote-worktree-history-cleanup' +import { withWorktreeRemoveStageSpan } from '../../../observability/instrumentation' +import { getSshPtyProvider } from '../../pty' +import { + cleanupUnusedWorktreePushTargetRemoteSsh, + notifyWorktreesChanged +} from '../../worktree-remote' +import type { RemoveWorktreeArgs } from '../ipc-context-schemas' +import type { WorktreeIpcContext } from '../worktree-ipc-context' +import { + preserveBranchHeadFallback, + rememberPreservedBranchCleanupTarget +} from './preserved-branch-cleanup' +import { + removeWorktreeMetadataAndTransientState, + stopPtysForDestructiveWorktreeRemoval +} from './worktree-removal-ownership' + +export async function removeRegisteredRemoteWorktree( + context: WorktreeIpcContext, + args: RemoveWorktreeArgs, + repo: Repo, + repoId: string, + canonicalWorktreePath: string, + removalHostId: ExecutionHostId, + registeredWorktree: GitWorktreeInfo, + removedPushTarget: GitPushTarget | undefined, + provider: SshGitProvider, + deleteBranch: boolean +): Promise { + const { mainWindow, store, runtime } = context + const remoteConnectionId = repo.connectionId! + // Why: SSH deletion mirrors the local flow — hooks run while the directory is intact, then the clean check guards removal. + if (!args.force) { + const { clean, stdout } = await provider!.worktreeIsClean(canonicalWorktreePath) + if (!clean) { + const error = new Error('Worktree has uncommitted or untracked changes.') + ;(error as Error & { stdout?: string }).stdout = stdout + throw error + } + } + + const remoteRemoveOptions = !deleteBranch ? { deleteBranch } : {} + const removalGate = await withWorktreeRemoveStageSpan('watcher_gate', 'remote', async () => + runtime.acquireFileWatcherRemoval(canonicalWorktreePath, remoteConnectionId) + ) + let rawRemovalResult: RemoveWorktreeResult | undefined + let removalCompleted = false + try { + await withWorktreeRemoveStageSpan('pty_sweep', 'remote', async () => { + await stopPtysForDestructiveWorktreeRemoval(runtime, args.worktreeId, { + connectionId: remoteConnectionId, + allowUnverifiedStop: args.allowUnverifiedPtyStop + }) + }) + rawRemovalResult = await withWorktreeRemoveStageSpan('git_remove', 'remote', async () => + Object.keys(remoteRemoveOptions).length > 0 + ? provider!.removeWorktree(canonicalWorktreePath, args.force, remoteRemoveOptions) + : provider!.removeWorktree(canonicalWorktreePath, args.force) + ) + removalCompleted = true + } finally { + await removalGate.finish(removalCompleted) + } + const removalResult = preserveBranchHeadFallback(rawRemovalResult, registeredWorktree.head) + await cleanupUnusedWorktreePushTargetRemoteSsh( + provider!, + repo.path, + args.worktreeId, + removedPushTarget, + store + ) + await deleteRemoteWorktreeHistory(getSshPtyProvider(remoteConnectionId), args.worktreeId) + rememberPreservedBranchCleanupTarget( + args.worktreeId, + removalHostId, + removalResult, + registeredWorktree.head, + removedPushTarget + ) + runtime.clearOptimisticReconcileToken(args.worktreeId) + await withWorktreeRemoveStageSpan('metadata_purge', 'remote', async () => { + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) + }) + notifyWorktreesChanged(mainWindow, repoId) + return removalResult ?? {} +} diff --git a/src/main/ipc/worktrees/removal/remove-unregistered-worktree.ts b/src/main/ipc/worktrees/removal/remove-unregistered-worktree.ts new file mode 100644 index 00000000000..6cacdc6d317 --- /dev/null +++ b/src/main/ipc/worktrees/removal/remove-unregistered-worktree.ts @@ -0,0 +1,254 @@ +import type { Repo } from '../../../../shared/repo-types' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-types' +import type { GitPushTarget, GitWorktreeInfo } from '../../../../shared/worktree/types' +import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' +import type { LocalProjectWorktreeGitOptions } from '../../../project-runtime-git-options' +import type { SshGitProvider } from '../../../providers/ssh-git-provider' +import { getSshFilesystemProvider } from '../../../providers/ssh-filesystem-dispatch' +import { deleteRemoteWorktreeHistory } from '../../../remote-worktree-history-cleanup' +import { preservedBranchCleanupScopeKey } from '../../../../shared/preserved-branch-cleanup' +import { + assertWorktreeDoesNotContainRegisteredWorktree, + canCleanupUnregisteredOrcaLeftoverDirectory, + canCleanupUnregisteredOrcaWorktreeDirectory, + canSafelyRemoveOrphanedWorktreeDirectory, + isDangerousWorktreeRemovalPath, + ORPHANED_WORKTREE_DIRECTORY_MESSAGE, + UNREGISTERED_MISSING_WORKTREE_MESSAGE +} from '../../../worktree-removal-safety' +import { + getLocalWorktreePathAccess, + removeLocalWorktreePath, + toLocalWorktreeRuntimePath +} from '../../../local-worktree-filesystem' +import { + cleanupUnusedWorktreePushTargetRemote, + cleanupUnusedWorktreePushTargetRemoteSsh, + notifyWorktreesChanged +} from '../../worktree-remote' +import { getSshPtyProvider } from '../../pty' +import { invalidateAuthorizedRootsCache } from '../../registered-worktree-roots-cache' +import type { RemoveWorktreeArgs } from '../ipc-context-schemas' +import type { WorktreeIpcContext } from '../worktree-ipc-context' +import { preservedBranchCleanupByScope } from './preserved-branch-cleanup' +import { + removeWorktreeMetadataAndTransientState, + stopPtysForDestructiveWorktreeRemoval +} from './worktree-removal-ownership' +import { isAlreadyRemovedWorktreePath, isLocalGitRepository } from './worktree-removal-filesystem' + +export async function removeUnregisteredWorktree( + context: WorktreeIpcContext, + args: RemoveWorktreeArgs, + repo: Repo, + repoId: string, + worktreePath: string, + removalHostId: ExecutionHostId, + registeredWorktrees: GitWorktreeInfo[], + removedMeta: WorktreeMeta | undefined, + removedPushTarget: GitPushTarget | undefined, + localWorktreeGitOptions: LocalProjectWorktreeGitOptions, + provider: SshGitProvider | null +): Promise { + const { mainWindow, store, runtime } = context + const fsProvider = repo.connectionId ? getSshFilesystemProvider(repo.connectionId) : null + let canCleanOrphanedDirectory = false + if ( + canCleanupUnregisteredOrcaWorktreeDirectory({ + meta: removedMeta + }) + ) { + if (repo.connectionId) { + if (!fsProvider) { + throw new Error('SSH filesystem provider unavailable') + } + if (!fsProvider.lstat) { + throw new Error('SSH filesystem provider lstat unavailable') + } + canCleanOrphanedDirectory = await canSafelyRemoveOrphanedWorktreeDirectory( + worktreePath, + repo.path, + (path) => fsProvider.lstat!(path), + (path) => fsProvider.readFile(path) + ) + } else { + const access = getLocalWorktreePathAccess(localWorktreeGitOptions) + canCleanOrphanedDirectory = + !isDangerousWorktreeRemovalPath(worktreePath, repo.path) && + (await canSafelyRemoveOrphanedWorktreeDirectory( + toLocalWorktreeRuntimePath(worktreePath, localWorktreeGitOptions), + toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), + access.statPath, + access.readPath + )) + } + } + if (canCleanOrphanedDirectory) { + assertWorktreeDoesNotContainRegisteredWorktree(worktreePath, registeredWorktrees) + if (!args.force) { + throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE) + } + if (repo.connectionId) { + const removalGate = await runtime.acquireFileWatcherRemoval(worktreePath, repo.connectionId) + let removalCompleted = false + try { + await stopPtysForDestructiveWorktreeRemoval(runtime, args.worktreeId, { + connectionId: repo.connectionId, + allowUnverifiedStop: args.allowUnverifiedPtyStop + }) + await fsProvider!.deletePath(worktreePath, true) + removalCompleted = true + } finally { + await removalGate.finish(removalCompleted) + } + // Why history first: the worktree is already gone from git and + // disk by here, so a rejecting push-target cleanup must not be + // able to skip history removal and leave the user's commands on + // the remote host. + await deleteRemoteWorktreeHistory(getSshPtyProvider(repo.connectionId), args.worktreeId) + await cleanupUnusedWorktreePushTargetRemoteSsh( + provider!, + repo.path, + args.worktreeId, + removedPushTarget, + store + ) + } else { + const removalGate = await runtime.acquireFileWatcherRemoval(worktreePath) + let removalCompleted = false + try { + await stopPtysForDestructiveWorktreeRemoval(runtime, args.worktreeId, { + allowUnverifiedStop: args.allowUnverifiedPtyStop + }) + await removeLocalWorktreePath(worktreePath, localWorktreeGitOptions) + removalCompleted = true + } finally { + await removalGate.finish(removalCompleted) + } + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + args.worktreeId, + removedPushTarget, + store, + localWorktreeGitOptions + ) + invalidateAuthorizedRootsCache() + } + runtime.clearOptimisticReconcileToken(args.worktreeId) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ + worktreeId: args.worktreeId, + hostId: removalHostId + }) + ) + notifyWorktreesChanged(mainWindow, repoId) + return {} + } + if (!repo.connectionId) { + const access = getLocalWorktreePathAccess(localWorktreeGitOptions) + const runtimeWorktreePath = toLocalWorktreeRuntimePath(worktreePath, localWorktreeGitOptions) + if ( + await canCleanupUnregisteredOrcaLeftoverDirectory({ + meta: removedMeta, + worktreePath, + runtimeWorktreePath, + repo, + runtimeRepoPath: toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), + registeredWorktrees, + statPath: access.statPath, + isGitRepository: (path) => isLocalGitRepository(path, localWorktreeGitOptions) + }) + ) { + if (!args.force) { + throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE) + } + const removalGate = await runtime.acquireFileWatcherRemoval(worktreePath) + let removalCompleted = false + try { + await stopPtysForDestructiveWorktreeRemoval(runtime, args.worktreeId, { + allowUnverifiedStop: args.allowUnverifiedPtyStop + }) + await removeLocalWorktreePath(worktreePath, localWorktreeGitOptions) + removalCompleted = true + } finally { + await removalGate.finish(removalCompleted) + } + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + args.worktreeId, + removedPushTarget, + store, + localWorktreeGitOptions + ) + runtime.clearOptimisticReconcileToken(args.worktreeId) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ + worktreeId: args.worktreeId, + hostId: removalHostId + }) + ) + invalidateAuthorizedRootsCache() + notifyWorktreesChanged(mainWindow, repoId) + return {} + } + } + if (await isAlreadyRemovedWorktreePath(repo, worktreePath, localWorktreeGitOptions)) { + if (!args.force && !removedMeta) { + // Why: without persisted metadata, require the renderer recovery path before deleting Orca-only state for an unregistered path. + throw new Error(UNREGISTERED_MISSING_WORKTREE_MESSAGE) + } + // Why: a manually deleted worktree is already gone; persisted metadata proves it was an Orca-known row, so no force is needed. + if (repo.connectionId) { + // Why history first: the worktree is already gone from git and + // disk by here, so a rejecting push-target cleanup must not be + // able to skip history removal and leave the user's commands on + // the remote host. + await deleteRemoteWorktreeHistory(getSshPtyProvider(repo.connectionId), args.worktreeId) + await cleanupUnusedWorktreePushTargetRemoteSsh( + provider!, + repo.path, + args.worktreeId, + removedPushTarget, + store + ) + } else { + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + args.worktreeId, + removedPushTarget, + store, + localWorktreeGitOptions + ) + invalidateAuthorizedRootsCache() + } + runtime.clearOptimisticReconcileToken(args.worktreeId) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ + worktreeId: args.worktreeId, + hostId: removalHostId + }) + ) + notifyWorktreesChanged(mainWindow, repoId) + return {} + } + throw new Error(`Refusing to delete unregistered worktree path: ${worktreePath}`) +} diff --git a/src/main/ipc/worktrees/removal/worktree-archive-hook.ts b/src/main/ipc/worktrees/removal/worktree-archive-hook.ts new file mode 100644 index 00000000000..f59ac4a2ebb --- /dev/null +++ b/src/main/ipc/worktrees/removal/worktree-archive-hook.ts @@ -0,0 +1,77 @@ +import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path' +import type { OrcaHooks } from '../../../../shared/orca-yaml-hook-types' +import type { Repo } from '../../../../shared/repo-types' +import { getEffectiveHooksFromConfig } from '../../../effective-hook-config' +import { getEffectiveHooks, parseOrcaYaml } from '../../../hooks' +import { getSshFilesystemProvider } from '../../../providers/ssh-filesystem-dispatch' +import { requireSshGitProvider } from '../../../providers/ssh-git-dispatch' +import { joinWorktreeRelativePath } from '../../../runtime/runtime-relative-paths' +import { getSetupRunnerEnvVars } from '../../../setup-hook-env-vars' + +const WORKTREE_ARCHIVE_HOOK_TIMEOUT_MS = 120_000 + +export async function getArchiveHooksForRemoval(repo: Repo): Promise { + if (!repo.connectionId) { + return getEffectiveHooks(repo) + } + + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + return getEffectiveHooksFromConfig(repo, null) + } + + try { + const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) + const yamlHooks = result.isBinary ? null : parseOrcaYaml(result.content) + return getEffectiveHooksFromConfig(repo, yamlHooks) + } catch { + return getEffectiveHooksFromConfig(repo, null) + } +} + +export async function runRemoteArchiveHook( + repo: Repo, + worktreePath: string, + script: string +): Promise<{ success: boolean; output: string }> { + if (!repo.connectionId) { + return { success: true, output: '' } + } + + const provider = requireSshGitProvider(repo.connectionId) + const env = getSetupRunnerEnvVars(repo, worktreePath) + const isWindowsRemote = isWindowsAbsolutePathLike(worktreePath) + const result = await provider + .execNonInteractive( + isWindowsRemote ? 'cmd.exe' : '/bin/bash', + isWindowsRemote ? ['/d', '/s', '/c', script] : ['-lc', script], + worktreePath, + WORKTREE_ARCHIVE_HOOK_TIMEOUT_MS, + undefined, + env + ) + .catch((error) => ({ + stdout: '', + stderr: '', + exitCode: null, + timedOut: false, + spawnError: error instanceof Error ? error.message : String(error) + })) + const output = [ + result.stdout, + result.stderr, + result.spawnError, + result.timedOut ? 'archive hook timed out' : null, + typeof result.exitCode === 'number' && result.exitCode !== 0 + ? `archive hook exited ${result.exitCode}` + : null + ] + .filter((part): part is string => Boolean(part)) + .join('\n') + .trim() + + return { + success: !result.spawnError && !result.timedOut && result.exitCode === 0, + output + } +} diff --git a/src/main/ipc/worktrees/removal/worktree-removal-coordinator.ts b/src/main/ipc/worktrees/removal/worktree-removal-coordinator.ts new file mode 100644 index 00000000000..3c52c4cabae --- /dev/null +++ b/src/main/ipc/worktrees/removal/worktree-removal-coordinator.ts @@ -0,0 +1,26 @@ +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-types' +import type { RemoveWorktreeArgs } from '../ipc-context-schemas' + +export type WorktreeRemovalInFlight = { + optionsKey: string + promise: Promise +} + +export function getWorktreeRemovalOptionsKey( + args: Pick +): string { + const forceKey = args.force === true ? 'force' : 'normal' + const archiveKey = args.skipArchive === true ? 'skip-archive' : 'run-archive' + // Why: a Force Delete retry must not coalesce onto the in-flight attempt that + // just failed the PTY gate — it would inherit that failure instead of retrying. + const ptyKey = args.allowUnverifiedPtyStop === true ? 'allow-unverified-pty' : 'require-pty-stop' + return `${forceKey}:${archiveKey}:${ptyKey}` +} + +export function getWorktreeRemovalInFlightKey( + worktreeId: string, + hostId?: ExecutionHostId +): string { + return `${hostId ?? ''}\0${worktreeId}` +} diff --git a/src/main/ipc/worktrees/removal/worktree-removal-filesystem.ts b/src/main/ipc/worktrees/removal/worktree-removal-filesystem.ts new file mode 100644 index 00000000000..2f071f458c5 --- /dev/null +++ b/src/main/ipc/worktrees/removal/worktree-removal-filesystem.ts @@ -0,0 +1,59 @@ +import type { Repo } from '../../../../shared/repo-types' +import { + getLocalWorktreePathAccess, + toLocalWorktreeRuntimePath +} from '../../../local-worktree-filesystem' +import { getSshFilesystemProvider } from '../../../providers/ssh-filesystem-dispatch' +import { gitExecFileAsync } from '../../../git/runner' +import { isWorktreePathMissing } from '../../../worktree-removal-safety' + +export async function isAlreadyRemovedWorktreePath( + repo: Repo, + worktreePath: string, + localWorktreeGitOptions: { wslDistro?: string } = {} +): Promise { + if (!repo.connectionId) { + const access = getLocalWorktreePathAccess(localWorktreeGitOptions) + return isWorktreePathMissing( + toLocalWorktreeRuntimePath(worktreePath, localWorktreeGitOptions), + access.statPath + ) + } + + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + return false + } + return isWorktreePathMissing(worktreePath, (path) => fsProvider.stat(path)) +} + +export async function isLocalGitRepository( + runtimeWorktreePath: string, + localWorktreeGitOptions: { wslDistro?: string } = {} +): Promise { + try { + await gitExecFileAsync(['status', '--short'], { + cwd: runtimeWorktreePath, + ...localWorktreeGitOptions + }) + return true + } catch (error) { + return !gitStatusErrorMeansNotRepository(error) + } +} + +export function gitStatusErrorMeansNotRepository(error: unknown): boolean { + const message = + error instanceof Error + ? error.message + : error && typeof error === 'object' && 'message' in error + ? String((error as { message: unknown }).message) + : typeof error === 'string' + ? error + : '' + const stderr = + error && typeof error === 'object' && 'stderr' in error + ? String((error as { stderr: unknown }).stderr) + : '' + return /not a git repository/i.test(`${message}\n${stderr}`) +} diff --git a/src/main/ipc/worktrees/removal/worktree-removal-ownership.ts b/src/main/ipc/worktrees/removal/worktree-removal-ownership.ts new file mode 100644 index 00000000000..ec2198bcdb0 --- /dev/null +++ b/src/main/ipc/worktrees/removal/worktree-removal-ownership.ts @@ -0,0 +1,106 @@ +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import { getSshPtyProvider, getLocalPtyProvider, clearProviderPtyState } from '../../pty' +import { killAllProcessesForWorktree } from '../../../runtime/worktree-teardown' +import type { Store } from '../../../persistence/loading-store/store' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { Repo } from '../../../../shared/repo-types' +import { hasWorktreeRemovalRepoOwnerOnOtherHost } from '../../../worktree-removal-repo-owner' +import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' +import { advertisedUrlWatcher } from '../../../ports/advertised-url-watcher' +import { localhostWorktreeLabelProxy } from '../../../localhost-worktree-label-proxy' +import { deleteWorktreeHistoryDir } from '../../../terminal-history-deletion' +import { pruneWorktreePRRefreshAliases } from '../../../github/pr-refresh-coordinator' +import { recordWorkspaceCleanupRemovalSnapshotPrune } from '../../../workspace-cleanup-removal-snapshot-prune' +import { pruneWorkspaceCleanupScanSnapshot } from '../../../workspace-cleanup-scan-snapshot' +import { pruneWorkspaceSpaceAnalysisSnapshot } from '../../../workspace-space-analysis-snapshot' + +export async function stopPtysForDestructiveWorktreeRemoval( + runtime: OrcaRuntimeService, + worktreeId: string, + options: { connectionId?: string; allowUnverifiedStop?: boolean } = {} +): Promise { + const { connectionId, allowUnverifiedStop } = options + const provider = connectionId ? getSshPtyProvider(connectionId) : getLocalPtyProvider() + if (!provider) { + throw new Error(`PTY provider unavailable for worktree deletion: ${worktreeId}`) + } + const teardownResult = await killAllProcessesForWorktree(worktreeId, { + runtime, + // Why: `repoId::path` ids repeat across hosts, so an unfenced sweep stops a same-id + // workspace's terminals on another connection — and the selector lookup this replaces + // throws `selector_ambiguous` the moment two hosts own the id. + resolvedWorktreeId: worktreeId, + ...(connectionId ? { resolvedConnectionId: connectionId } : {}), + localProvider: provider, + onPtyStopped: clearProviderPtyState, + requirePhysicalStop: true, + // Why (#11960): set only by an explicit Force Delete, never by the ordinary + // confirmation — otherwise the gate would be off on the primary delete path. + ...(allowUnverifiedStop ? { allowUnverifiedStop: true } : {}), + ...(connectionId ? { includeLocalRegistry: false } : {}) + }) + const total = + teardownResult.runtimeStopped + teardownResult.providerStopped + teardownResult.registryStopped + if (total > 0) { + console.info( + `[worktree-teardown] ${worktreeId} killed runtime=${teardownResult.runtimeStopped} provider=${teardownResult.providerStopped} registry=${teardownResult.registryStopped}` + ) + } +} + +// Why: the worktree's own persisted host outranks the repo fallback; teardown and metadata purge must resolve the same owner +// or the purge lands on the local partition while the SSH/runtime one keeps the workspace's tabs forever. +export function resolveWorktreeRemovalOwnerHostId( + store: Store, + worktreeId: string, + repo: Repo | undefined, + fallbackHostId?: ExecutionHostId +): ExecutionHostId | undefined { + return ( + fallbackHostId ?? + (repo ? getRepoExecutionHostId(repo) : store.getWorktreeMeta(worktreeId)?.hostId) + ) +} + +export function removeWorktreeMetadataAndTransientState( + store: Store, + worktreeId: string, + hostId?: ExecutionHostId, + snapshotPruneBatchId?: string +): void { + const persistedHostId = store.getWorktreeMeta(worktreeId)?.hostId + const repoId = getRepoIdFromWorktreeId(worktreeId) + const preservesSameIdOwner = Boolean( + hostId && + ((persistedHostId && persistedHostId !== hostId) || + hasWorktreeRemovalRepoOwnerOnOtherHost(store, repoId, hostId)) + ) + // Why: worktree IDs are path-derived and reusable; drop process-local caches before the same ID can map to a new workspace. + if (hostId) { + store.removeWorktreeMeta(worktreeId, hostId) + } else { + store.removeWorktreeMeta(worktreeId) + } + if (!preservesSameIdOwner) { + advertisedUrlWatcher.forgetWorktree(worktreeId) + // Why: drop this worktree's localhost label routes so they don't accumulate in the proxy's route maps all session. + localhostWorktreeLabelProxy.unregisterWorktree(worktreeId) + // Why: schedule async history tree removal — never recursive-rmSync on the delete critical path. + deleteWorktreeHistoryDir(worktreeId) + // Why: release the removed worktree's PR-refresh aliases so coalesced queue entries don't retain it all session (memory creep). + pruneWorktreePRRefreshAliases(worktreeId) + } + // Why: removed workspaces must never resurrect from the persisted cleanup/space scan snapshots. + const snapshotDirectory = store.getProfileStorageDirectory() + if (snapshotPruneBatchId) { + recordWorkspaceCleanupRemovalSnapshotPrune(snapshotDirectory, { + batchId: snapshotPruneBatchId, + worktreeId, + executionHostId: hostId + }) + return + } + void pruneWorkspaceCleanupScanSnapshot(snapshotDirectory, worktreeId, hostId) + void pruneWorkspaceSpaceAnalysisSnapshot(snapshotDirectory, worktreeId, hostId) +} diff --git a/src/main/ipc/worktrees/repo-host-ownership.ts b/src/main/ipc/worktrees/repo-host-ownership.ts new file mode 100644 index 00000000000..89f26e871a5 --- /dev/null +++ b/src/main/ipc/worktrees/repo-host-ownership.ts @@ -0,0 +1,14 @@ +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { Repo } from '../../../shared/repo-types' +import type { Store } from '../../persistence/loading-store/store' +import { resolveWorktreeRemovalRepoOwner } from '../../worktree-removal-repo-owner' + +export function resolveRepoForExecutionHost( + store: Store, + repoId: string, + hostId?: ExecutionHostId +): Repo | undefined { + // Why: host-qualified operations must never guess between repo owners; legacy unscoped calls work only for one unique owner. + const owner = resolveWorktreeRemovalRepoOwner(store, repoId, hostId) + return owner.kind === 'resolved' ? owner.repo : undefined +} diff --git a/src/main/ipc/worktrees/worktree-ipc-context.ts b/src/main/ipc/worktrees/worktree-ipc-context.ts new file mode 100644 index 00000000000..5455a71d06e --- /dev/null +++ b/src/main/ipc/worktrees/worktree-ipc-context.ts @@ -0,0 +1,16 @@ +import type { BrowserWindow } from 'electron' +import type { Store } from '../../persistence/loading-store/store' +import type { OrcaRuntimeService, RuntimeWorktreeLifecycleEvent } from '../../runtime/orca-runtime' +import type { SenderScopedRequestCancellations } from '../sender-scoped-request-cancellation' +import type { WorktreeRemovalInFlight } from './removal/worktree-removal-coordinator' + +export type WorktreeIpcContext = { + mainWindow: BrowserWindow + store: Store + runtime: OrcaRuntimeService + options?: { + onWorktreeLifecycle?: (event: RuntimeWorktreeLifecycleEvent) => void + } + detectedWorktreeCancellations: SenderScopedRequestCancellations + worktreeRemovalsInFlight: Map +} diff --git a/src/main/orcad/electron-serve-browser-process.ts b/src/main/orcad/electron-serve-browser-process.ts new file mode 100644 index 00000000000..365618f51c7 --- /dev/null +++ b/src/main/orcad/electron-serve-browser-process.ts @@ -0,0 +1,299 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { createServer, type AddressInfo } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' +import { z } from 'zod' +import { BrowserError } from '../browser/browser-error' +import type { + RuntimeBrowserCommandHost, + RuntimeBrowserCommands +} from '../runtime/orca-runtime-browser' +import type { RuntimeMetadata } from '../../shared/runtime-bootstrap' +import { BROWSER_UNAVAILABLE_ERROR_CODE } from '../../shared/runtime-types' +import { readRuntimeMetadata } from '../runtime/runtime-metadata' +import { spawnProcess, type SpawnedProcess } from '../../shared/child-process/run-process' +import { sendOrcadSidecarRequest } from './orcad-sidecar-runtime-client' +import { + ElectronSidecarTabRegistry, + ElectronSidecarTabSchema, + type ElectronSidecarPage +} from './electron-sidecar-tab-registry' +import { + electronSidecarRuntimeMethodName, + TARGETLESS_BROWSER_METHODS +} from './electron-sidecar-method-routing' + +const START_TIMEOUT_MS = 120_000 +const STOP_TIMEOUT_MS = 5_000 +const BrowserPageResult = z.object({ browserPageId: z.string() }).passthrough() +const BrowserCloseResult = z.object({ closed: z.boolean() }).passthrough() +const BrowserTabListResult = z.object({ tabs: z.array(ElectronSidecarTabSchema) }).passthrough() +const RuntimeStatusResult = z.object({ capabilities: z.array(z.string()).optional() }).passthrough() + +async function reserveLoopbackPort(): Promise { + const server = createServer() + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + server.removeListener('error', reject) + resolve() + }) + }) + const address = server.address() as AddressInfo + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error) + } else { + resolve() + } + }) + }) + return address.port +} +function electronServeEnvironment(): NodeJS.ProcessEnv { + const environment = { ...process.env } + for (const key of [ + 'AGENT_BROWSER_ARGS', + 'AGENT_BROWSER_AUTO_CONNECT', + 'AGENT_BROWSER_CDP', + 'AGENT_BROWSER_ENGINE', + 'AGENT_BROWSER_EXECUTABLE_PATH', + 'AGENT_BROWSER_HEADED', + 'AGENT_BROWSER_PROFILE', + 'AGENT_BROWSER_PROVIDER', + 'AGENT_BROWSER_SESSION', + 'AGENT_BROWSER_SESSION_NAME', + 'AGENT_BROWSER_STATE' + ]) { + delete environment[key] + } + return environment +} + +function signalProcess(pid: number, signal: NodeJS.Signals): void { + try { + process.kill(pid, signal) + } catch { + // The sidecar already exited. + } +} + +function processIsLive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +export class ElectronServeBrowserProcess { + private child: SpawnedProcess | null = null + private metadata: RuntimeMetadata | null = null + private readonly tabs = new ElectronSidecarTabRegistry() + private sidecarDataPath: string | null = null + + constructor(private readonly executablePath: string) {} + + async start(): Promise { + const temporaryRoot = process.platform === 'win32' ? tmpdir() : '/tmp' + const userDataPath = await mkdtemp(join(temporaryRoot, 'orcad-browser-')) + this.sidecarDataPath = userDataPath + const port = await reserveLoopbackPort() + const child = spawnProcess({ + program: this.executablePath, + args: [ + '--serve', + '--serve-port', + String(port), + '--serve-json', + '--serve-no-pairing', + `--user-data-dir=${userDataPath}` + ], + env: electronServeEnvironment() + }) + this.child = child + for (const stream of [child.stdout, child.stderr]) { + stream?.on('error', () => undefined) + stream?.resume() + } + const deadline = Date.now() + START_TIMEOUT_MS + let lastError: unknown = null + while (Date.now() < deadline) { + const metadata = readRuntimeMetadata(userDataPath) + if (metadata) { + try { + const status = RuntimeStatusResult.parse( + await sendOrcadSidecarRequest(metadata, 'status.get', undefined, 5_000) + ) + if (status.capabilities?.includes('browser.headless.v1')) { + this.metadata = metadata + return + } + lastError = new Error('Installed Electron app omitted browser.headless.v1.') + } catch (error) { + lastError = error + } + } + if (child.exitCode !== null || child.signalCode !== null) { + break + } + await delay(100) + } + throw new Error( + `Installed Electron browser provider did not become ready: ${ + lastError instanceof Error ? lastError.message : 'no runtime metadata' + }` + ) + } + + createCommands(host: RuntimeBrowserCommandHost): RuntimeBrowserCommands { + return new Proxy({} as RuntimeBrowserCommands, { + get: (_target, property) => { + if (property === 'then') { + return undefined + } + if (typeof property !== 'string') { + return undefined + } + return (...args: unknown[]) => this.invoke(host, property, args) + } + }) + } + isAvailable(): boolean { + return this.metadata !== null && processIsLive(this.metadata.pid) + } + + async stop(): Promise { + const child = this.child + const sidecarPid = this.metadata?.pid ?? child?.pid ?? null + const sidecarDataPath = this.sidecarDataPath + this.child = null + this.metadata = null + this.sidecarDataPath = null + this.tabs.clear() + if (sidecarPid) { + signalProcess(sidecarPid, 'SIGTERM') + const deadline = Date.now() + STOP_TIMEOUT_MS + while (processIsLive(sidecarPid) && Date.now() < deadline) { + await delay(50) + } + if (processIsLive(sidecarPid)) { + signalProcess(sidecarPid, 'SIGKILL') + } + } + if (sidecarDataPath) { + await rm(sidecarDataPath, { recursive: true, force: true }) + } + } + + private async invoke( + host: RuntimeBrowserCommandHost, + method: string, + args: unknown[] + ): Promise { + if (method === 'browserScreencast') { + throw new BrowserError( + 'browser_screencast_unavailable', + 'The orcad Electron provider does not proxy screencast.' + ) + } + const metadata = this.metadata + if (!metadata) { + throw new BrowserError( + BROWSER_UNAVAILABLE_ERROR_CODE, + 'The Electron browser provider is not running.' + ) + } + const original = (args[0] ?? {}) as Record + const worktreeId = + typeof original.worktree === 'string' + ? (await host.resolveWorktreeSelector(original.worktree)).id + : undefined + const params: Record = { ...original, worktree: undefined } + const requestedPageId = typeof original.page === 'string' ? original.page : undefined + const requestedIndex = typeof original.index === 'number' ? original.index : undefined + let targetPage: ElectronSidecarPage | undefined + let rpcMethod = electronSidecarRuntimeMethodName(method) + if (method === 'browserTabCreate' && requestedPageId) { + const existing = this.tabs.find(requestedPageId) + if (existing) { + this.tabs.require(requestedPageId, worktreeId) + return { browserPageId: requestedPageId } + } + } + + if (method === 'browserTabCurrent' && worktreeId) { + targetPage = this.tabs.active(worktreeId) + rpcMethod = 'browser.tabShow' + params.page = targetPage.sidecarPageId + } else if (method === 'browserTabSwitch' || method === 'browserTabClose') { + targetPage = requestedPageId + ? this.tabs.require(requestedPageId, worktreeId) + : requestedIndex !== undefined + ? this.tabs.pageAt(worktreeId, requestedIndex) + : worktreeId + ? this.tabs.active(worktreeId) + : undefined + if (targetPage) { + params.page = targetPage.sidecarPageId + delete params.index + } + } else if (requestedPageId) { + targetPage = this.tabs.require(requestedPageId, worktreeId) + params.page = targetPage.sidecarPageId + } else if (worktreeId && !TARGETLESS_BROWSER_METHODS[method]) { + targetPage = this.tabs.active(worktreeId) + params.page = targetPage.sidecarPageId + } + + let result: unknown + try { + result = await sendOrcadSidecarRequest(metadata, rpcMethod, params) + } catch (error) { + if ( + targetPage && + error instanceof BrowserError && + (error.code === 'browser_tab_not_found' || error.code === 'browser_tab_closed') + ) { + this.tabs.delete(targetPage) + } + throw error + } + if (method === 'browserTabCreate') { + const created = BrowserPageResult.parse(result) + const page = this.tabs.register(created.browserPageId, requestedPageId, worktreeId) + return { ...created, browserPageId: page.publicPageId } + } + if (method === 'browserTabList') { + const listed = BrowserTabListResult.parse(result) + return { ...listed, tabs: this.tabs.reconcileTabs(listed.tabs, worktreeId) } + } + if (method === 'browserTabSwitch') { + const switched = BrowserPageResult.parse(result) + const page = this.tabs.pageForSidecar(switched.browserPageId) + if (page) { + this.tabs.setActive(page) + } + return { + ...switched, + ...(requestedIndex !== undefined ? { switched: requestedIndex } : {}), + browserPageId: this.tabs.publicPageId(switched.browserPageId) + } + } + if (method === 'browserTabClose' && targetPage) { + const closed = BrowserCloseResult.parse(result) + if (closed.closed) { + this.tabs.delete(targetPage) + } + return closed + } + if (method === 'browserTabProfileClone') { + const cloned = BrowserPageResult.parse(result) + this.tabs.register(cloned.browserPageId, undefined, targetPage?.worktreeId) + } + return this.tabs.rewriteResult(result) + } +} diff --git a/src/main/orcad/electron-sidecar-method-routing.ts b/src/main/orcad/electron-sidecar-method-routing.ts new file mode 100644 index 00000000000..a2dcfec79ef --- /dev/null +++ b/src/main/orcad/electron-sidecar-method-routing.ts @@ -0,0 +1,37 @@ +export const TARGETLESS_BROWSER_METHODS: Record = { + browserProfileClearDefaultCookies: true, + browserProfileCreate: true, + browserProfileDelete: true, + browserProfileDetectBrowsers: true, + browserProfileImportFromBrowser: true, + browserProfileList: true, + browserTabCreate: true, + browserTabList: true +} + +export function electronSidecarRuntimeMethodName(browserMethod: string): string { + const suffix = browserMethod.slice('browser'.length) + if (suffix === 'ProceedCertificate') { + return 'browser.certificate.proceed' + } + if (suffix === 'SetViewport') { + return 'browser.viewport' + } + if (suffix === 'SetGeolocation') { + return 'browser.geolocation' + } + for (const group of ['Cookie', 'Intercept', 'Capture'] as const) { + if (suffix.startsWith(group)) { + const action = suffix.slice(group.length) + return `browser.${group.toLowerCase()}.${action[0].toLowerCase()}${action.slice(1)}` + } + } + for (const storage of ['StorageLocal', 'StorageSession'] as const) { + if (suffix.startsWith(storage)) { + const action = suffix.slice(storage.length) + const area = storage === 'StorageLocal' ? 'local' : 'session' + return `browser.storage.${area}.${action[0].toLowerCase()}${action.slice(1)}` + } + } + return `browser.${suffix[0].toLowerCase()}${suffix.slice(1)}` +} diff --git a/src/main/orcad/electron-sidecar-tab-registry.test.ts b/src/main/orcad/electron-sidecar-tab-registry.test.ts new file mode 100644 index 00000000000..3a53664b031 --- /dev/null +++ b/src/main/orcad/electron-sidecar-tab-registry.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { ElectronSidecarTabRegistry } from './electron-sidecar-tab-registry' + +describe('ElectronSidecarTabRegistry', () => { + it('preserves requested ids and fences explicit worktree scope', () => { + const registry = new ElectronSidecarTabRegistry() + registry.register('sidecar-a', 'public-a', 'worktree-a') + registry.register('sidecar-b', 'public-b', 'worktree-b') + + expect(registry.require('public-a', 'worktree-a').sidecarPageId).toBe('sidecar-a') + expect(() => registry.require('public-a', 'worktree-b')).toThrow( + expect.objectContaining({ code: 'browser_tab_not_found' }) + ) + expect(registry.active('worktree-b').publicPageId).toBe('public-b') + }) + + it('rewrites sidecar ids, worktree-relative indices, and stale pages', () => { + const registry = new ElectronSidecarTabRegistry() + registry.register('sidecar-a', 'public-a', 'worktree-a') + registry.register('sidecar-b', 'public-b', 'worktree-b') + registry.register('sidecar-c', 'public-c', 'worktree-a') + + const tabs = registry.reconcileTabs( + [ + { browserPageId: 'sidecar-a', active: false }, + { browserPageId: 'sidecar-b', active: true }, + { browserPageId: 'sidecar-c', active: true } + ], + 'worktree-a' + ) + expect(tabs).toEqual([ + expect.objectContaining({ browserPageId: 'public-a', index: 0 }), + expect.objectContaining({ browserPageId: 'public-c', index: 1 }) + ]) + expect(registry.active('worktree-a').publicPageId).toBe('public-c') + + registry.reconcileTabs([{ browserPageId: 'sidecar-c' }]) + expect(() => registry.require('public-a')).toThrow( + expect.objectContaining({ code: 'browser_tab_not_found' }) + ) + }) + + it('rewrites page ids in tab and command results', () => { + const registry = new ElectronSidecarTabRegistry() + registry.register('sidecar-a', 'public-a', 'worktree-a') + + expect( + registry.rewriteResult({ + browserPageId: 'sidecar-a', + tab: { + browserPageId: 'sidecar-a', + certificateFailure: { browserPageId: 'sidecar-a', challengeId: 'challenge-a' } + } + }) + ).toEqual({ + browserPageId: 'public-a', + tab: { + browserPageId: 'public-a', + certificateFailure: { browserPageId: 'public-a', challengeId: 'challenge-a' } + } + }) + }) +}) diff --git a/src/main/orcad/electron-sidecar-tab-registry.ts b/src/main/orcad/electron-sidecar-tab-registry.ts new file mode 100644 index 00000000000..8b3b9940b2e --- /dev/null +++ b/src/main/orcad/electron-sidecar-tab-registry.ts @@ -0,0 +1,169 @@ +import { z } from 'zod' +import { BrowserError } from '../browser/browser-error' + +const CertificateFailure = z + .object({ browserPageId: z.string() }) + .passthrough() + .nullable() + .optional() +export const ElectronSidecarTabSchema = z + .object({ + browserPageId: z.string(), + index: z.number().optional(), + active: z.boolean().optional(), + certificateFailure: CertificateFailure + }) + .passthrough() +const ElectronSidecarResult = z + .object({ + browserPageId: z.string().optional(), + sourceBrowserPageId: z.string().optional(), + tab: ElectronSidecarTabSchema.optional() + }) + .passthrough() + +export type ElectronSidecarTab = z.infer + +export type ElectronSidecarPage = { + publicPageId: string + sidecarPageId: string + worktreeId?: string +} + +export class ElectronSidecarTabRegistry { + private readonly pagesByPublicId = new Map() + private readonly pagesBySidecarId = new Map() + private readonly activePageIdByWorktree = new Map() + + clear(): void { + this.pagesByPublicId.clear() + this.pagesBySidecarId.clear() + this.activePageIdByWorktree.clear() + } + + register(sidecarPageId: string, requestedPageId: string | undefined, worktreeId?: string) { + const publicPageId = requestedPageId || sidecarPageId + const page = { publicPageId, sidecarPageId, worktreeId } + this.pagesByPublicId.set(publicPageId, page) + this.pagesBySidecarId.set(sidecarPageId, page) + this.setActive(page) + return page + } + find(publicPageId: string): ElectronSidecarPage | undefined { + return this.pagesByPublicId.get(publicPageId) + } + + require(publicPageId: string, worktreeId?: string): ElectronSidecarPage { + const page = this.pagesByPublicId.get(publicPageId) + if (!page || (worktreeId && page.worktreeId !== worktreeId)) { + throw new BrowserError( + 'browser_tab_not_found', + `Browser page ${publicPageId} was not found in this worktree.` + ) + } + return page + } + + active(worktreeId?: string): ElectronSidecarPage { + const publicPageId = this.activePageIdByWorktree.get(worktreeId ?? '') + const page = publicPageId ? this.pagesByPublicId.get(publicPageId) : undefined + if (!page) { + throw new BrowserError('browser_no_tab', 'No browser tab is active in this worktree.') + } + return page + } + + pageAt(worktreeId: string | undefined, index: number): ElectronSidecarPage { + const page = [...this.pagesByPublicId.values()].filter( + (candidate) => !worktreeId || candidate.worktreeId === worktreeId + )[index] + if (!page) { + throw new BrowserError('browser_tab_not_found', `Browser tab index ${index} was not found.`) + } + return page + } + + pageForSidecar(sidecarPageId: string): ElectronSidecarPage | undefined { + return this.pagesBySidecarId.get(sidecarPageId) + } + + setActive(page: ElectronSidecarPage): void { + this.activePageIdByWorktree.set(page.worktreeId ?? '', page.publicPageId) + } + + delete(page: ElectronSidecarPage): void { + this.pagesByPublicId.delete(page.publicPageId) + this.pagesBySidecarId.delete(page.sidecarPageId) + const key = page.worktreeId ?? '' + if (this.activePageIdByWorktree.get(key) === page.publicPageId) { + const replacement = [...this.pagesByPublicId.values()].find( + (candidate) => candidate.worktreeId === page.worktreeId + ) + if (replacement) { + this.activePageIdByWorktree.set(key, replacement.publicPageId) + } else { + this.activePageIdByWorktree.delete(key) + } + } + } + + publicPageId(sidecarPageId: string): string { + return this.pagesBySidecarId.get(sidecarPageId)?.publicPageId ?? sidecarPageId + } + + reconcileTabs(tabs: ElectronSidecarTab[], worktreeId?: string): ElectronSidecarTab[] { + const liveSidecarPageIds = new Set(tabs.map((tab) => tab.browserPageId)) + for (const page of this.pagesByPublicId.values()) { + if (!liveSidecarPageIds.has(page.sidecarPageId)) { + this.delete(page) + } + } + return tabs + .flatMap((tab) => { + const page = + this.pagesBySidecarId.get(tab.browserPageId) ?? + (worktreeId ? undefined : this.register(tab.browserPageId, undefined, undefined)) + if (!page || (worktreeId && page.worktreeId !== worktreeId)) { + return [] + } + if (tab.active === true) { + this.setActive(page) + } + return [this.rewriteTab(tab)] + }) + .map((tab, index) => ({ ...tab, index })) + } + + rewriteResult(result: unknown): unknown { + const parsed = ElectronSidecarResult.safeParse(result) + if (!parsed.success) { + return result + } + const rewritten = { ...parsed.data } + if (rewritten.browserPageId) { + rewritten.browserPageId = this.publicPageId(rewritten.browserPageId) + } + if (rewritten.sourceBrowserPageId) { + rewritten.sourceBrowserPageId = this.publicPageId(rewritten.sourceBrowserPageId) + } + if (rewritten.tab) { + rewritten.tab = this.rewriteTab(rewritten.tab) + } + return rewritten + } + + private rewriteTab(tab: ElectronSidecarTab): ElectronSidecarTab { + return { + ...tab, + browserPageId: this.publicPageId(tab.browserPageId), + ...(tab.certificateFailure + ? { + certificateFailure: { + ...tab.certificateFailure, + browserPageId: this.publicPageId(tab.certificateFailure.browserPageId) + } + } + : {}) + } + } +} diff --git a/src/main/orcad/external-chromium-browser-process.integration.test.ts b/src/main/orcad/external-chromium-browser-process.integration.test.ts new file mode 100644 index 00000000000..6d20f0c6fd1 --- /dev/null +++ b/src/main/orcad/external-chromium-browser-process.integration.test.ts @@ -0,0 +1,63 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { describe, expect, it } from 'vitest' +import { resolveOrcadBrowserProvider } from './orcad-browser-provider' + +const executablePath = process.env.ORCA_BROWSER_EXECUTABLE + +describe('ExternalChromiumBrowserProcess integration', () => { + it.runIf(Boolean(executablePath))( + 'navigates, evaluates, and screenshots with the operator executable', + async () => { + const root = await mkdtemp(join(tmpdir(), 'orcad-external-browser-')) + const fixturePath = join(root, 'fixture.html') + await writeFile( + fixturePath, + 'External Chromium
external-ready
' + ) + const provider = await resolveOrcadBrowserProvider({ + userDataPath: root, + environment: { ORCA_BROWSER_EXECUTABLE: executablePath }, + resolveInstalledElectronExecutable: async () => null + }) + try { + expect(provider?.kind).toBe('chromium') + if (!provider) { + throw new Error('External Chromium provider did not resolve.') + } + const commands = provider.factory({ + getAgentBrowserBridge: () => null, + resolveWorktreeSelector: async (selector) => ({ id: selector }), + getAuthoritativeWindow: () => { + throw new Error('No renderer') + }, + getAvailableAuthoritativeWindow: () => null, + getOffscreenBrowserBackend: () => null + }) + await expect( + commands.browserTabCreate({ page: 'external-page', url: 'about:blank' }) + ).resolves.toEqual({ browserPageId: 'external-page' }) + await expect( + commands.browserGoto({ + page: 'external-page', + url: pathToFileURL(fixturePath).href + }) + ).resolves.toMatchObject({ title: 'External Chromium' }) + await expect( + commands.browserEval({ + page: 'external-page', + expression: 'document.querySelector("main")?.textContent' + }) + ).resolves.toMatchObject({ result: 'external-ready' }) + await expect( + commands.browserScreenshot({ page: 'external-page', format: 'png' }) + ).resolves.toMatchObject({ data: expect.stringMatching(/\S+/), format: 'png' }) + } finally { + await provider?.stop() + await rm(root, { recursive: true, force: true }) + } + } + ) +}) diff --git a/src/main/orcad/external-chromium-browser-process.ts b/src/main/orcad/external-chromium-browser-process.ts new file mode 100644 index 00000000000..912a44775e7 --- /dev/null +++ b/src/main/orcad/external-chromium-browser-process.ts @@ -0,0 +1,188 @@ +import { z } from 'zod' +import type { + RuntimeBrowserCommandHost, + RuntimeBrowserCommands +} from '../runtime/orca-runtime-browser' +import { BrowserError } from '../browser/browser-error' +import { + ExternalChromiumBrowserSession, + type ExternalChromiumLaunch +} from './external-chromium-browser-session' +export type { ExternalChromiumLaunch } from './external-chromium-browser-session' +import { normalizeBrowserNavigationUrl } from '../../shared/browser-url' +import { + externalChromiumCommandArguments, + normalizeExternalChromiumCommandResult +} from './external-chromium-command-arguments' +import { + externalChromiumSnapshotResult, + type ExternalChromiumPageRecord as PageRecord +} from './external-chromium-tab-projection' +import { ExternalChromiumTabRegistry } from './external-chromium-tab-registry' +const AgentBrowserSnapshot = z.object({ + refs: z + .record(z.string(), z.object({ name: z.string().optional(), role: z.string().optional() })) + .optional(), + snapshot: z.string().optional() +}) + +export class ExternalChromiumBrowserProcess { + private readonly session: ExternalChromiumBrowserSession + private readonly tabs: ExternalChromiumTabRegistry + private queue: Promise = Promise.resolve() + private available = false + + constructor(agentBrowserPath: string, launch: ExternalChromiumLaunch, statePath: string) { + this.session = new ExternalChromiumBrowserSession(agentBrowserPath, launch, statePath) + this.tabs = new ExternalChromiumTabRegistry(this.session) + } + + async start(): Promise { + this.tabs.initialize(await this.session.start()) + this.available = true + } + + createCommands(host: RuntimeBrowserCommandHost): RuntimeBrowserCommands { + return new Proxy({} as RuntimeBrowserCommands, { + get: (_target, property) => { + if (property === 'then') { + return undefined + } + if (typeof property !== 'string') { + return undefined + } + return (...args: unknown[]) => this.enqueue(() => this.invoke(host, property, args)) + } + }) + } + isAvailable(): boolean { + return this.available + } + + async stop(): Promise { + this.available = false + await this.enqueue(async () => { + await this.session.stop() + this.tabs.clear() + }) + } + + private enqueue(operation: () => Promise): Promise { + const result = this.queue.then(operation, operation) + this.queue = result.then( + () => undefined, + () => undefined + ) + return result + } + + private async invoke( + host: RuntimeBrowserCommandHost, + method: string, + args: unknown[] + ): Promise { + const params = (args[0] ?? {}) as Record + if (method === 'browserTabCreate') { + return this.tabs.createTab(host, params) + } + if (method === 'browserTabList') { + return this.tabs.listTabs(host, params) + } + if (method === 'browserTabShow') { + return { tab: await this.tabs.describeTab(host, params) } + } + if (method === 'browserTabCurrent') { + return { tab: await this.tabs.currentTab(host, params) } + } + if (method === 'browserTabSwitch') { + return this.tabs.switchTab(host, params) + } + if (method === 'browserTabClose') { + return this.tabs.closeTab(host, params) + } + if ( + method.startsWith('browserProfile') || + method === 'browserTabSetProfile' || + method === 'browserTabProfileClone' + ) { + throw new BrowserError( + 'browser_profile_unavailable', + 'Browser profile import and switching require the desktop Electron provider.' + ) + } + if (method === 'browserTabProfileShow') { + const tab = await this.tabs.describeTab(host, params) + return { + browserPageId: tab.browserPageId, + worktreeId: tab.worktreeId, + profileId: 'default', + profileLabel: 'Default' + } + } + if (method === 'browserScreencast') { + throw new BrowserError( + 'browser_screencast_unavailable', + 'This browser provider does not offer screencast.' + ) + } + if (method === 'browserProceedCertificate') { + throw new BrowserError( + 'browser_certificate_trust_unavailable', + 'This browser provider cannot override certificate failures.' + ) + } + + const page = await this.tabs.resolveTargetPage(host, params) + await this.session.selectPage(page.agentPageId) + if (method === 'browserSnapshot') { + return this.snapshot(page) + } + if (method === 'browserScreenshot') { + return this.session.screenshot(params, false) + } + if (method === 'browserFullScreenshot') { + return this.session.screenshot(params, true) + } + if (method === 'browserPdf') { + return this.session.pdf() + } + if (method === 'browserGoto') { + const url = normalizeBrowserNavigationUrl(String(params.url ?? '')) + if (!url) { + throw new BrowserError( + 'invalid_argument', + `Unsupported browser URL: ${String(params.url ?? '')}` + ) + } + return this.session.run(['open', url]) + } + if (method === 'browserEval') { + return this.session.run(['eval', String(params.expression ?? '')]) + } + if (method === 'browserSelectAll') { + await this.session.run(['focus', String(params.element ?? '')]) + await this.session.run(['press', 'Control+a']) + return { selected: String(params.element ?? '') } + } + if (method === 'browserBack' || method === 'browserForward' || method === 'browserReload') { + await this.session.run([method.slice('browser'.length).toLowerCase()]) + const tab = await this.tabs.describePage(page) + return { url: tab.url, title: tab.title } + } + + const command = externalChromiumCommandArguments(method, params) + if (!command) { + throw new BrowserError( + 'browser_command_unavailable', + `${method} is not supported by this browser provider.` + ) + } + const result = await this.session.run(command) + return normalizeExternalChromiumCommandResult(method, params, result) + } + + private async snapshot(page: PageRecord): Promise { + const data = AgentBrowserSnapshot.parse(await this.session.run(['snapshot'])) + return externalChromiumSnapshotResult(page, data, await this.tabs.describePage(page)) + } +} diff --git a/src/main/orcad/external-chromium-browser-session.ts b/src/main/orcad/external-chromium-browser-session.ts new file mode 100644 index 00000000000..a8aadac3ccd --- /dev/null +++ b/src/main/orcad/external-chromium-browser-session.ts @@ -0,0 +1,154 @@ +import { createHash, randomUUID } from 'node:crypto' +import { mkdir, readFile, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { z } from 'zod' +import { BrowserError } from '../browser/browser-error' +import { BROWSER_UNAVAILABLE_ERROR_CODE } from '../../shared/runtime-types' +import { runProcess } from '../../shared/child-process/run-process' + +const COMMAND_TIMEOUT_MS = 90_000 +const MAX_OUTPUT_BYTES = 50 * 1024 * 1024 +const CLOSE_TIMEOUT_MS = 5_000 + +export type ExternalChromiumLaunch = { + executablePath: string + browserArgs?: readonly string[] + provider: 'electron' | 'chromium' +} + +const AgentBrowserTab = z.object({ + active: z.boolean(), + tabId: z.string(), + title: z.string(), + url: z.string() +}) +const AgentBrowserTabsResult = z.object({ tabs: z.array(AgentBrowserTab).optional() }) +const AgentBrowserScreenshotResult = z.object({ path: z.string().min(1) }) +const AgentBrowserEnvelope = z.object({ + success: z.boolean(), + data: z.unknown().optional(), + error: z.string().nullable().optional() +}) + +export type AgentBrowserTab = z.infer + +function classifyAgentBrowserError(message: string): string { + if (/unknown ref|ref not found|element not found: @e/i.test(message)) { + return 'browser_stale_ref' + } + if (/evaluation error/i.test(message)) { + return 'browser_eval_error' + } + if (/navigation|net::err/i.test(message)) { + return 'browser_navigation_failed' + } + if (/timed out|timeout/i.test(message)) { + return 'browser_timeout' + } + if (/tab.*not found|unknown tab/i.test(message)) { + return 'browser_tab_not_found' + } + return 'browser_error' +} + +export class ExternalChromiumBrowserSession { + private readonly profilePath: string + private readonly sessionName: string + + constructor( + private readonly agentBrowserPath: string, + private readonly launch: ExternalChromiumLaunch, + statePath: string + ) { + const identity = createHash('sha256') + .update(`${statePath}:${launch.provider}`) + .digest('hex') + .slice(0, 16) + this.sessionName = `orca-orcad-${identity}` + this.profilePath = join(statePath, `browser-${launch.provider}`) + } + + async start(): Promise { + await mkdir(this.profilePath, { recursive: true }) + await this.run(['open', 'about:blank']) + const tabs = await this.readTabs() + const active = tabs.find((tab) => tab.active) ?? tabs[0] + if (!active) { + throw new BrowserError( + BROWSER_UNAVAILABLE_ERROR_CODE, + 'The browser launched without an automation target.' + ) + } + return active.tabId + } + + async stop(): Promise { + try { + await this.run(['close'], CLOSE_TIMEOUT_MS) + } catch { + // Closing an already-dead browser is complete cleanup. + } + } + + async selectPage(agentPageId: string): Promise { + await this.run(['tab', agentPageId]) + } + + async readTabs(): Promise { + return AgentBrowserTabsResult.parse(await this.run(['tab'])).tabs ?? [] + } + + async screenshot(params: Record, full: boolean): Promise { + const format = params.format === 'jpeg' ? 'jpeg' : 'png' + const command = ['screenshot', ...(full ? ['--full'] : []), '--screenshot-format', format] + const result = AgentBrowserScreenshotResult.parse(await this.run(command)) + const data = (await readFile(result.path)).toString('base64') + await rm(result.path, { force: true }).catch(() => undefined) + return { data, format } + } + + async pdf(): Promise { + const outputPath = join(this.profilePath, `capture-${randomUUID()}.pdf`) + await this.run(['pdf', outputPath]) + const data = (await readFile(outputPath)).toString('base64') + await rm(outputPath, { force: true }).catch(() => undefined) + return { data } + } + + async run(command: readonly string[], timeoutMs = COMMAND_TIMEOUT_MS): Promise { + const args = ['--session', this.sessionName, '--profile', this.profilePath] + if (this.launch.browserArgs?.length) { + args.push('--args', this.launch.browserArgs.join('\n')) + } + args.push(...command, '--json') + const env = { + ...process.env, + AGENT_BROWSER_EXECUTABLE_PATH: this.launch.executablePath, + AGENT_BROWSER_PROFILE: this.profilePath, + AGENT_BROWSER_SESSION: this.sessionName, + AGENT_BROWSER_ARGS: this.launch.browserArgs?.join('\n') ?? '' + } + const result = await runProcess({ + program: this.agentBrowserPath, + args, + env, + timeoutMs, + maxOutputBytes: MAX_OUTPUT_BYTES + }) + if (result.timedOut) { + throw new BrowserError('browser_timeout', 'Browser command timed out.') + } + let envelope: z.infer + try { + envelope = AgentBrowserEnvelope.parse(JSON.parse(result.stdout)) + } catch { + const detail = result.stderr.trim() || `exit ${String(result.code)}` + throw new BrowserError('browser_error', `Browser command failed: ${detail.slice(0, 1000)}`) + } + if (!envelope.success) { + const message = envelope.error ?? (result.stderr.trim() || 'Unknown browser error.') + throw new BrowserError(classifyAgentBrowserError(message), message) + } + return envelope.data + } +} diff --git a/src/main/orcad/external-chromium-command-arguments.ts b/src/main/orcad/external-chromium-command-arguments.ts new file mode 100644 index 00000000000..3650d993f58 --- /dev/null +++ b/src/main/orcad/external-chromium-command-arguments.ts @@ -0,0 +1,293 @@ +import { BrowserError } from '../browser/browser-error' +const TARGET_OVERRIDE_FLAGS: Record = { + '--args': true, + '--cdp': true, + '--executable-path': true, + '--profile': true, + '--session': true +} +const TARGET_OVERRIDE_FLAG_PREFIXES = Object.keys(TARGET_OVERRIDE_FLAGS).map((flag) => `${flag}=`) + +function parseExecArguments(input: string): string[] { + const args: string[] = [] + let value = '' + let quote: '"' | "'" | null = null + let escaped = false + for (const char of input.trim()) { + if (escaped) { + value += char + escaped = false + } else if (char === '\\' && quote !== "'") { + escaped = true + } else if (quote) { + if (char === quote) { + quote = null + } else { + value += char + } + } else if (char === '"' || char === "'") { + quote = char + } else if (/\s/.test(char)) { + if (value) { + args.push(value) + value = '' + } + } else { + value += char + } + } + if (escaped) { + value += '\\' + } + if (quote) { + throw new BrowserError('invalid_argument', 'Browser command has an unclosed quote.') + } + if (value) { + args.push(value) + } + return args +} +function stripTargetOverrideArguments(args: string[]): string[] { + const stripped: string[] = [] + for (let index = 0; index < args.length; index++) { + const arg = args[index] + if (TARGET_OVERRIDE_FLAGS[arg]) { + index++ + continue + } + if (TARGET_OVERRIDE_FLAG_PREFIXES.some((prefix) => arg.startsWith(prefix))) { + continue + } + stripped.push(arg) + } + return stripped +} + +function waitArguments(params: Record): string[] { + const args = ['wait'] + if (params.selector) { + args.push(String(params.selector)) + } else if (params.timeout != null && !params.text && !params.url && !params.load && !params.fn) { + args.push(String(params.timeout)) + } + for (const [key, flag] of [ + ['text', '--text'], + ['url', '--url'], + ['load', '--load'], + ['fn', '--fn'], + ['state', '--state'] + ] as const) { + if (params[key]) { + args.push(flag, String(params[key])) + } + } + return args +} + +function cookieSetArguments(params: Record): string[] { + const args = ['cookies', 'set', String(params.name ?? ''), String(params.value ?? '')] + for (const [key, flag] of [ + ['domain', '--domain'], + ['path', '--path'], + ['sameSite', '--sameSite'], + ['expires', '--expires'] + ] as const) { + if (params[key] != null) { + args.push(flag, String(params[key])) + } + } + if (params.secure) { + args.push('--secure') + } + if (params.httpOnly) { + args.push('--httpOnly') + } + return args +} +function cookieDeleteArguments(params: Record): string[] { + const args = ['cookies', 'clear'] + if (params.name) { + args.push('--name', String(params.name)) + } + if (params.domain) { + args.push('--domain', String(params.domain)) + } + return args +} + +export function externalChromiumCommandArguments( + method: string, + params: Record +): string[] | null { + const text = (key: string): string => String(params[key] ?? '') + const optional = (key: string): string[] => (params[key] == null ? [] : [String(params[key])]) + switch (method) { + case 'browserClick': + return ['click', text('element')] + case 'browserDblclick': + return ['dblclick', text('element')] + case 'browserFill': + return ['fill', text('element'), text('value')] + case 'browserType': + return ['keyboard', 'type', text('input')] + case 'browserSelect': + return ['select', text('element'), text('value')] + case 'browserScroll': + return ['scroll', text('direction'), ...optional('amount')] + case 'browserHover': + return ['hover', text('element')] + case 'browserDrag': + return ['drag', text('from'), text('to')] + case 'browserUpload': + return ['upload', text('element'), ...((params.files as string[] | undefined) ?? [])] + case 'browserCheck': + return [params.checked === false ? 'uncheck' : 'check', text('element')] + case 'browserFocus': + return ['focus', text('element')] + case 'browserClear': + return ['fill', text('element'), ''] + case 'browserKeypress': + return ['press', text('key')] + case 'browserScrollIntoView': + return ['scrollintoview', text('element')] + case 'browserGet': + return ['get', text('what'), ...optional('selector')] + case 'browserIs': + return ['is', text('what'), text('selector')] + case 'browserKeyboardInsertText': + return ['keyboard', 'inserttext', text('text')] + case 'browserMouseMove': + return ['mouse', 'move', text('x'), text('y')] + case 'browserMouseDown': + return ['mouse', 'down', ...optional('button')] + case 'browserMouseClick': + if ( + params.radius != null || + (Array.isArray(params.modifiers) && params.modifiers.length > 0) + ) { + return null + } + return ['mouse', 'click', text('x'), text('y'), ...optional('button')] + case 'browserMouseUp': + return ['mouse', 'up', ...optional('button')] + case 'browserMouseWheel': + return ['mouse', 'wheel', text('dy'), ...optional('dx')] + case 'browserFind': + return ['find', text('locator'), text('value'), text('action'), ...optional('text')] + case 'browserSetDevice': + return ['set', 'device', text('name')] + case 'browserSetOffline': + return ['set', 'offline', ...optional('state')] + case 'browserSetHeaders': + return ['set', 'headers', text('headers')] + case 'browserSetCredentials': + return ['set', 'credentials', text('user'), text('pass')] + case 'browserSetMedia': + return ['set', 'media', ...optional('colorScheme'), ...optional('reducedMotion')] + case 'browserClipboardRead': + return ['clipboard', 'read'] + case 'browserClipboardWrite': + return ['clipboard', 'write', text('text')] + case 'browserDialogAccept': + return ['dialog', 'accept', ...optional('text')] + case 'browserDialogDismiss': + return ['dialog', 'dismiss'] + case 'browserStorageLocalGet': + return ['storage', 'local', 'get', text('key')] + case 'browserStorageLocalSet': + return ['storage', 'local', 'set', text('key'), text('value')] + case 'browserStorageLocalClear': + return ['storage', 'local', 'clear'] + case 'browserStorageSessionGet': + return ['storage', 'session', 'get', text('key')] + case 'browserStorageSessionSet': + return ['storage', 'session', 'set', text('key'), text('value')] + case 'browserStorageSessionClear': + return ['storage', 'session', 'clear'] + case 'browserDownload': + return ['download', text('selector'), text('path')] + case 'browserHighlight': + return ['highlight', text('selector')] + case 'browserCookieGet': + return ['cookies', 'get'] + case 'browserCookieDelete': + return cookieDeleteArguments(params) + case 'browserSetGeolocation': + return ['set', 'geo', text('latitude'), text('longitude')] + case 'browserInterceptDisable': + return ['network', 'unroute'] + case 'browserInterceptList': + case 'browserNetworkLog': + return ['network', 'requests'] + case 'browserConsoleLog': + return ['console'] + case 'browserCaptureStart': + return ['network', 'har', 'start'] + case 'browserCaptureStop': + return ['network', 'har', 'stop'] + case 'browserExec': + return stripTargetOverrideArguments(parseExecArguments(text('command'))) + case 'browserSetViewport': + if ( + params.mobile === true || + (params.deviceScaleFactor != null && params.deviceScaleFactor !== 1) + ) { + return null + } + return ['set', 'viewport', text('width'), text('height')] + case 'browserWait': + return waitArguments(params) + case 'browserCookieSet': + return cookieSetArguments(params) + case 'browserInterceptEnable': + return ['network', 'route', (params.patterns as string[] | undefined)?.[0] ?? '**/*'] + default: + return null + } +} + +export function normalizeExternalChromiumCommandResult( + method: string, + params: Record, + result: unknown +): unknown { + switch (method) { + case 'browserClick': + return { clicked: String(params.element) } + case 'browserFill': + return { filled: String(params.element) } + case 'browserType': + return { typed: true } + case 'browserSelect': + return { selected: String(params.value) } + case 'browserScroll': + return { scrolled: params.direction } + case 'browserHover': + return { hovered: String(params.element) } + case 'browserDrag': + return { dragged: { from: params.from, to: params.to } } + case 'browserUpload': + return { uploaded: Array.isArray(params.files) ? params.files.length : 0 } + case 'browserCheck': + return { checked: params.checked !== false } + case 'browserFocus': + return { focused: String(params.element) } + case 'browserClear': + return { cleared: String(params.element) } + case 'browserSelectAll': + return { selected: String(params.element) } + case 'browserKeypress': + return { pressed: String(params.key) } + case 'browserWait': + return { waited: true } + case 'browserSetViewport': + return { + width: params.width, + height: params.height, + deviceScaleFactor: 1, + mobile: false + } + default: + return result + } +} diff --git a/src/main/orcad/external-chromium-tab-projection.ts b/src/main/orcad/external-chromium-tab-projection.ts new file mode 100644 index 00000000000..a1ad300e48c --- /dev/null +++ b/src/main/orcad/external-chromium-tab-projection.ts @@ -0,0 +1,45 @@ +import type { AgentBrowserTab } from './external-chromium-browser-session' + +export type ExternalChromiumPageRecord = { + agentPageId: string + publicPageId: string + worktreeId?: string +} + +export function externalChromiumTabInfo( + page: ExternalChromiumPageRecord, + tab: AgentBrowserTab, + index: number +): Record { + return { + browserPageId: page.publicPageId, + index, + url: tab.url, + title: tab.title, + active: tab.active, + worktreeId: page.worktreeId ?? null, + profileId: 'default', + profileLabel: 'Default' + } +} + +export function externalChromiumSnapshotResult( + page: ExternalChromiumPageRecord, + data: { + refs?: Record + snapshot?: string + }, + tab: Record +): Record { + return { + browserPageId: page.publicPageId, + snapshot: data.snapshot ?? '', + refs: Object.entries(data.refs ?? {}).map(([ref, value]) => ({ + ref, + role: value.role ?? '', + name: value.name ?? '' + })), + url: tab.url, + title: tab.title + } +} diff --git a/src/main/orcad/external-chromium-tab-registry.ts b/src/main/orcad/external-chromium-tab-registry.ts new file mode 100644 index 00000000000..1cd135bd600 --- /dev/null +++ b/src/main/orcad/external-chromium-tab-registry.ts @@ -0,0 +1,248 @@ +import { randomUUID } from 'node:crypto' +import { z } from 'zod' +import type { RuntimeBrowserCommandHost } from '../runtime/orca-runtime-browser' +import { BrowserError } from '../browser/browser-error' +import { normalizeBrowserNavigationUrl } from '../../shared/browser-url' +import type { ExternalChromiumBrowserSession } from './external-chromium-browser-session' +import { + externalChromiumTabInfo, + type ExternalChromiumPageRecord +} from './external-chromium-tab-projection' +const AgentBrowserCreatedTab = z.object({ tabId: z.string().min(1) }) + +export class ExternalChromiumTabRegistry { + private readonly pagesByPublicId = new Map() + private readonly pagesByAgentId = new Map() + private readonly activePageIdByWorktree = new Map() + private initialAgentPageId: string | null = null + + constructor(private readonly session: ExternalChromiumBrowserSession) {} + + initialize(agentPageId: string): void { + this.initialAgentPageId = agentPageId + } + + clear(): void { + this.pagesByPublicId.clear() + this.pagesByAgentId.clear() + this.activePageIdByWorktree.clear() + this.initialAgentPageId = null + } + + async createTab( + host: RuntimeBrowserCommandHost, + params: Record + ): Promise { + if (typeof params.profileId === 'string' && params.profileId !== 'default') { + throw new BrowserError( + 'browser_profile_unavailable', + 'Only the default browser profile is available.' + ) + } + const worktreeId = await this.resolveWorktreeId(host, params.worktree) + const requestedPageId = + typeof params.page === 'string' && params.page.length > 0 ? params.page : undefined + const existing = requestedPageId ? this.pagesByPublicId.get(requestedPageId) : undefined + if (existing) { + if (worktreeId && existing.worktreeId !== worktreeId) { + throw new BrowserError( + 'browser_tab_not_found', + `Browser page ${requestedPageId} was not found in this worktree.` + ) + } + this.setActivePage(existing) + return { browserPageId: existing.publicPageId } + } + const url = normalizeBrowserNavigationUrl(String(params.url ?? 'about:blank')) + if (!url) { + throw new BrowserError( + 'invalid_argument', + `Unsupported browser URL: ${String(params.url ?? '')}` + ) + } + let agentPageId: string + if (this.initialAgentPageId) { + agentPageId = this.initialAgentPageId + this.initialAgentPageId = null + await this.session.selectPage(agentPageId) + await this.session.run(['open', url]) + } else { + agentPageId = AgentBrowserCreatedTab.parse(await this.session.run(['tab', 'new', url])).tabId + } + const publicPageId = requestedPageId ?? randomUUID() + const page = { agentPageId, publicPageId, worktreeId } + this.pagesByPublicId.set(publicPageId, page) + this.pagesByAgentId.set(agentPageId, page) + this.setActivePage(page) + host.markHeadlessBrowserSessionTabActive?.( + worktreeId, + publicPageId, + typeof params.targetGroupId === 'string' ? params.targetGroupId : undefined + ) + if (worktreeId) { + host.notifyHeadlessBrowserSessionTabsChanged?.(worktreeId) + } + return { browserPageId: publicPageId } + } + + async listTabs( + host: RuntimeBrowserCommandHost, + params: Record + ): Promise { + const requestedWorktreeId = await this.resolveWorktreeId(host, params.worktree) + const tabs = await this.session.readTabs() + const liveAgentPageIds = new Set(tabs.map((tab) => tab.tabId)) + for (const page of this.pagesByPublicId.values()) { + if (!liveAgentPageIds.has(page.agentPageId)) { + this.deletePage(page) + } + } + const visibleTabs = tabs.flatMap((tab) => { + const page = this.pagesByAgentId.get(tab.tabId) + if (!page || (requestedWorktreeId && page.worktreeId !== requestedWorktreeId)) { + return [] + } + if (tab.active) { + this.setActivePage(page) + } + return [{ page, tab }] + }) + return { + tabs: visibleTabs.map(({ page, tab }, index) => externalChromiumTabInfo(page, tab, index)) + } + } + + async describeTab( + host: RuntimeBrowserCommandHost, + params: Record + ): Promise> { + return this.describePage(await this.resolveTargetPage(host, params)) + } + + async currentTab( + host: RuntimeBrowserCommandHost, + params: Record + ): Promise { + const worktreeId = await this.resolveWorktreeId(host, params.worktree) + const page = this.findActivePage(worktreeId) + if (!page) { + throw new BrowserError('browser_no_tab', 'No browser tab is active in this worktree.') + } + return this.describePage(page) + } + + async switchTab( + host: RuntimeBrowserCommandHost, + params: Record + ): Promise { + const worktreeId = await this.resolveWorktreeId(host, params.worktree) + const pages = [...this.pagesByPublicId.values()].filter( + (page) => !worktreeId || page.worktreeId === worktreeId + ) + const page = + typeof params.page === 'string' + ? this.pagesByPublicId.get(params.page) + : typeof params.index === 'number' + ? pages[params.index] + : undefined + if (!page || (worktreeId && page.worktreeId !== worktreeId)) { + throw new BrowserError('browser_tab_not_found', 'Browser tab was not found in this worktree.') + } + await this.session.selectPage(page.agentPageId) + this.setActivePage(page) + return { switched: pages.indexOf(page), browserPageId: page.publicPageId } + } + + async closeTab( + host: RuntimeBrowserCommandHost, + params: Record + ): Promise { + const worktreeId = await this.resolveWorktreeId(host, params.worktree) + const pages = [...this.pagesByPublicId.values()].filter( + (page) => !worktreeId || page.worktreeId === worktreeId + ) + const page = + typeof params.page === 'string' + ? this.pagesByPublicId.get(params.page) + : typeof params.index === 'number' + ? pages[params.index] + : this.findActivePage(worktreeId) + if (!page || (worktreeId && page.worktreeId !== worktreeId)) { + if (typeof params.page === 'string') { + throw new BrowserError( + 'browser_tab_not_found', + 'Browser tab was not found in this worktree.' + ) + } + return { closed: false } + } + await this.session.run(['tab', 'close', page.agentPageId]) + this.deletePage(page) + if (page.worktreeId) { + host.notifyHeadlessBrowserSessionTabsChanged?.(page.worktreeId) + } + return { closed: true } + } + + async resolveTargetPage( + host: RuntimeBrowserCommandHost, + params: Record + ): Promise { + const worktreeId = await this.resolveWorktreeId(host, params.worktree) + const page = + typeof params.page === 'string' + ? this.pagesByPublicId.get(params.page) + : this.findActivePage(worktreeId) + if (!page || (worktreeId && page.worktreeId !== worktreeId)) { + throw new BrowserError('browser_no_tab', 'No browser tab is active in this worktree.') + } + return page + } + + async describePage(page: ExternalChromiumPageRecord): Promise> { + const tabs = await this.session.readTabs() + const scopedTabs = tabs.filter( + (tab) => this.pagesByAgentId.get(tab.tabId)?.worktreeId === page.worktreeId + ) + const index = scopedTabs.findIndex((tab) => tab.tabId === page.agentPageId) + if (index === -1) { + this.deletePage(page) + throw new BrowserError('browser_tab_closed', 'Browser tab is no longer available.') + } + return externalChromiumTabInfo(page, scopedTabs[index], index) + } + + private findActivePage(worktreeId?: string): ExternalChromiumPageRecord | undefined { + const key = worktreeId ?? '' + const activePageId = this.activePageIdByWorktree.get(key) + const active = activePageId ? this.pagesByPublicId.get(activePageId) : undefined + if (active) { + return active + } + return [...this.pagesByPublicId.values()].find( + (page) => !worktreeId || page.worktreeId === worktreeId + ) + } + + private setActivePage(page: ExternalChromiumPageRecord): void { + this.activePageIdByWorktree.set(page.worktreeId ?? '', page.publicPageId) + } + + private deletePage(page: ExternalChromiumPageRecord): void { + this.pagesByPublicId.delete(page.publicPageId) + this.pagesByAgentId.delete(page.agentPageId) + const key = page.worktreeId ?? '' + if (this.activePageIdByWorktree.get(key) === page.publicPageId) { + this.activePageIdByWorktree.delete(key) + } + } + + private async resolveWorktreeId( + host: RuntimeBrowserCommandHost, + worktree: unknown + ): Promise { + return typeof worktree === 'string' && worktree + ? (await host.resolveWorktreeSelector(worktree)).id + : undefined + } +} diff --git a/src/main/orcad/orcad-agent-browser-binary.ts b/src/main/orcad/orcad-agent-browser-binary.ts new file mode 100644 index 00000000000..9b7c98666a4 --- /dev/null +++ b/src/main/orcad/orcad-agent-browser-binary.ts @@ -0,0 +1,31 @@ +import { accessSync, constants, existsSync } from 'node:fs' +import { arch, platform } from 'node:os' +import { dirname, join } from 'node:path' + +export function orcadAgentBrowserNativeName( + platformName: NodeJS.Platform, + architecture: string +): string { + const ext = platformName === 'win32' ? '.exe' : '' + return `agent-browser-${platformName}-${architecture}${ext}` +} + +export function resolveOrcadAgentBrowserBinary(): string | null { + const name = orcadAgentBrowserNativeName(platform(), arch()) + const candidates = [ + join(dirname(process.argv[1] ?? __filename), name), + join(process.cwd(), 'node_modules', 'agent-browser', 'bin', name) + ] + for (const candidate of candidates) { + if (!existsSync(candidate)) { + continue + } + try { + accessSync(candidate, process.platform === 'win32' ? constants.F_OK : constants.X_OK) + return candidate + } catch { + // Keep searching; a copied but non-executable binary is not a provider. + } + } + return null +} diff --git a/src/main/orcad/orcad-browser-provider.test.ts b/src/main/orcad/orcad-browser-provider.test.ts new file mode 100644 index 00000000000..43fe9022d8e --- /dev/null +++ b/src/main/orcad/orcad-browser-provider.test.ts @@ -0,0 +1,207 @@ +import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { ExternalChromiumBrowserProcess } from './external-chromium-browser-process' +import { installedElectronCandidates, resolveOrcadBrowserProvider } from './orcad-browser-provider' +import { orcadAgentBrowserNativeName } from './orcad-agent-browser-binary' + +vi.mock('../../shared/child-process/run-process', () => ({ runProcess: vi.fn() })) + +type BrowserState = { + activeTabId: string + tabs: { active: boolean; tabId: string; title: string; url: string }[] +} + +const runProcessMock = vi.mocked(runProcess) +let root: string +let screenshotPath: string +let browserState: BrowserState + +function commandFromArgs(args: readonly string[]): string[] { + let index = 0 + while (['--session', '--profile', '--args'].includes(args[index] ?? '')) { + index += 2 + } + return args.slice(index, -1) +} + +function success(data: unknown) { + return Promise.resolve({ + code: 0, + signal: null, + stdout: JSON.stringify({ success: true, data, error: null }), + stderr: '', + timedOut: false + }) +} + +function installAgentBrowserMock(): void { + runProcessMock.mockImplementation(async (spec) => { + const command = commandFromArgs(spec.args ?? []) + if (command[0] === 'open') { + const active = browserState.tabs.find((tab) => tab.tabId === browserState.activeTabId)! + active.url = command[1] + active.title = command[1] === 'about:blank' ? '' : 'Fixture page' + return success({ url: active.url, title: active.title }) + } + if (command[0] === 'tab' && command.length === 1) { + return success({ + tabs: browserState.tabs.map((tab) => ({ + ...tab, + active: tab.tabId === browserState.activeTabId + })) + }) + } + if (command[0] === 'tab' && command.length === 2) { + browserState.activeTabId = command[1] + return success({ tabId: command[1] }) + } + if (command[0] === 'eval') { + return success({ result: 'Fixture page', origin: 'https://fixture.test/' }) + } + if (command[0] === 'screenshot') { + return success({ path: screenshotPath }) + } + if (command[0] === 'close') { + return success({ closed: true }) + } + throw new Error(`Unexpected agent-browser command: ${command.join(' ')}`) + }) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orcad-browser-provider-')) + screenshotPath = join(root, 'fixture.png') + await writeFile(screenshotPath, Buffer.from('fixture-image')) + browserState = { + activeTabId: 't1', + tabs: [{ active: true, tabId: 't1', title: '', url: 'about:blank' }] + } + runProcessMock.mockReset() + installAgentBrowserMock() +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +describe('ExternalChromiumBrowserProcess', () => { + it('navigates, evaluates, and screenshots through the operator browser', async () => { + const processHandle = new ExternalChromiumBrowserProcess( + '/agent-browser', + { executablePath: '/operator/chromium', provider: 'chromium' }, + root + ) + await processHandle.start() + const commands = processHandle.createCommands({ + getAgentBrowserBridge: () => null, + resolveWorktreeSelector: async (selector) => ({ id: selector }), + getAuthoritativeWindow: () => { + throw new Error('No renderer') + }, + getAvailableAuthoritativeWindow: () => null, + getOffscreenBrowserBackend: () => null + }) + + await expect( + commands.browserTabCreate({ + url: 'https://fixture.test/', + worktree: 'worktree-1', + page: 'page-1' + }) + ).resolves.toEqual({ browserPageId: 'page-1' }) + await expect( + commands.browserGoto({ + url: 'https://fixture.test/next', + worktree: 'worktree-1', + page: 'page-1' + }) + ).resolves.toEqual({ url: 'https://fixture.test/next', title: 'Fixture page' }) + await expect( + commands.browserEval({ + expression: 'document.title', + worktree: 'worktree-1', + page: 'page-1' + }) + ).resolves.toEqual({ result: 'Fixture page', origin: 'https://fixture.test/' }) + await expect( + commands.browserScreenshot({ format: 'png', worktree: 'worktree-1', page: 'page-1' }) + ).resolves.toEqual({ data: Buffer.from('fixture-image').toString('base64'), format: 'png' }) + await expect( + commands.browserExec({ + page: 'page-1', + worktree: 'worktree-1', + command: + '--session stolen --cdp 9222 --profile /tmp/stolen --executable-path /bad --args bad eval document.title' + }) + ).resolves.toEqual({ result: 'Fixture page', origin: 'https://fixture.test/' }) + const execArgs = runProcessMock.mock.calls.at(-1)?.[0].args ?? [] + expect(execArgs).not.toContain('stolen') + expect(execArgs).not.toContain('9222') + expect(execArgs).not.toContain('/tmp/stolen') + expect(execArgs).not.toContain('/bad') + + expect(runProcessMock).toHaveBeenCalledWith( + expect.objectContaining({ + env: expect.objectContaining({ AGENT_BROWSER_EXECUTABLE_PATH: '/operator/chromium' }) + }) + ) + await processHandle.stop() + }) +}) + +describe('cross-platform browser provider paths', () => { + it('resolves installed Electron launchers on macOS, Linux, and Windows', () => { + expect(installedElectronCandidates('darwin', '/Users/test', {})).toContain( + '/Users/test/Applications/Orca.app/Contents/MacOS/Orca' + ) + expect(installedElectronCandidates('linux', '/home/test', {})).toContain( + '/home/test/.local/bin/orca-ide' + ) + expect( + installedElectronCandidates('win32', 'C:\\Users\\test', { + LOCALAPPDATA: 'C:\\Users\\test\\AppData\\Local' + }) + ).toContain('C:\\Users\\test\\AppData\\Local\\Programs\\Orca\\Orca.exe') + }) + + it('uses platform-specific bundled agent-browser names', () => { + expect(orcadAgentBrowserNativeName('darwin', 'arm64')).toBe('agent-browser-darwin-arm64') + expect(orcadAgentBrowserNativeName('linux', 'x64')).toBe('agent-browser-linux-x64') + expect(orcadAgentBrowserNativeName('win32', 'x64')).toBe('agent-browser-win32-x64.exe') + }) +}) + +describe('resolveOrcadBrowserProvider', () => { + it('uses ORCA_BROWSER_EXECUTABLE when Electron is absent', async () => { + const executable = join(root, process.platform === 'win32' ? 'chromium.exe' : 'chromium') + await writeFile(executable, '') + if (process.platform !== 'win32') { + await chmod(executable, 0o755) + } + + const provider = await resolveOrcadBrowserProvider({ + userDataPath: root, + environment: { ORCA_BROWSER_EXECUTABLE: executable }, + resolveInstalledElectronExecutable: async () => null, + resolveAgentBrowserBinary: () => '/agent-browser' + }) + + expect(provider?.kind).toBe('chromium') + await provider?.stop() + }) + + it('returns no provider when neither executable resolves', async () => { + await expect( + resolveOrcadBrowserProvider({ + userDataPath: root, + environment: {}, + resolveInstalledElectronExecutable: async () => null, + resolveAgentBrowserBinary: () => '/agent-browser' + }) + ).resolves.toBeNull() + expect(runProcessMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/orcad/orcad-browser-provider.ts b/src/main/orcad/orcad-browser-provider.ts new file mode 100644 index 00000000000..1a9137d2b3d --- /dev/null +++ b/src/main/orcad/orcad-browser-provider.ts @@ -0,0 +1,148 @@ +import { access, mkdir } from 'node:fs/promises' +import { constants } from 'node:fs' +import { homedir } from 'node:os' +import { posix, win32 } from 'node:path' +import type { RuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory' +import { + ExternalChromiumBrowserProcess, + type ExternalChromiumLaunch +} from './external-chromium-browser-process' +import { resolveOrcadAgentBrowserBinary } from './orcad-agent-browser-binary' +import { ElectronServeBrowserProcess } from './electron-serve-browser-process' + +export type OrcadBrowserProvider = { + kind: 'electron' | 'chromium' + factory: RuntimeBrowserCommandsFactory + isAvailable(): boolean + stop(): Promise +} + +export type OrcadBrowserProviderOptions = { + userDataPath: string + environment?: NodeJS.ProcessEnv + resolveInstalledElectronExecutable?: () => Promise + resolveAgentBrowserBinary?: () => string | null +} + +async function executableExists(path: string): Promise { + try { + await access(path, process.platform === 'win32' ? constants.F_OK : constants.X_OK) + return true + } catch { + return false + } +} + +export function installedElectronCandidates( + platform: NodeJS.Platform, + homePath: string, + environment: NodeJS.ProcessEnv +): string[] { + const joinPath = platform === 'win32' ? win32.join : posix.join + if (platform === 'darwin') { + return [ + '/Applications/Orca.app/Contents/MacOS/Orca', + joinPath(homePath, 'Applications', 'Orca.app', 'Contents', 'MacOS', 'Orca') + ] + } + if (platform === 'win32') { + return [ + ...(environment.LOCALAPPDATA + ? [joinPath(environment.LOCALAPPDATA, 'Programs', 'Orca', 'Orca.exe')] + : []), + ...(environment.ProgramFiles ? [joinPath(environment.ProgramFiles, 'Orca', 'Orca.exe')] : []) + ] + } + return [ + joinPath(homePath, '.local', 'bin', 'orca-ide'), + '/usr/local/bin/orca-ide', + '/usr/bin/orca-ide', + '/opt/Orca/orca-ide' + ] +} + +/** Only paths with stable installer ownership qualify; never guess arbitrary AppImage locations. */ +export async function resolveInstalledElectronExecutable(): Promise { + const candidates = installedElectronCandidates(process.platform, homedir(), process.env) + for (const candidate of candidates) { + if (await executableExists(candidate)) { + return candidate + } + } + return null +} + +async function startProvider( + agentBrowserPath: string, + launch: ExternalChromiumLaunch, + userDataPath: string +): Promise { + const processHandle = new ExternalChromiumBrowserProcess(agentBrowserPath, launch, userDataPath) + try { + await processHandle.start() + } catch (error) { + await processHandle.stop() + throw error + } + return { + kind: launch.provider, + factory: (host) => processHandle.createCommands(host), + isAvailable: () => processHandle.isAvailable(), + stop: () => processHandle.stop() + } +} + +async function startElectronServeProvider(executablePath: string): Promise { + const processHandle = new ElectronServeBrowserProcess(executablePath) + try { + await processHandle.start() + } catch (error) { + await processHandle.stop() + throw error + } + return { + kind: 'electron', + factory: (host) => processHandle.createCommands(host), + isAvailable: () => processHandle.isAvailable(), + stop: () => processHandle.stop() + } +} + +/** Resolve once at startup: Electron first, then the operator-supplied Chromium. */ +export async function resolveOrcadBrowserProvider( + options: OrcadBrowserProviderOptions +): Promise { + const environment = options.environment ?? process.env + await mkdir(options.userDataPath, { recursive: true, mode: 0o700 }) + + const installedElectronExecutable = await ( + options.resolveInstalledElectronExecutable ?? resolveInstalledElectronExecutable + )() + if (installedElectronExecutable) { + try { + return await startElectronServeProvider(installedElectronExecutable) + } catch (error) { + console.warn('[orcad] Installed Electron browser provider unavailable:', error) + } + } + const agentBrowserPath = (options.resolveAgentBrowserBinary ?? resolveOrcadAgentBrowserBinary)() + + if (!agentBrowserPath) { + return null + } + + const chromiumExecutable = environment.ORCA_BROWSER_EXECUTABLE?.trim() + if (!chromiumExecutable || !(await executableExists(chromiumExecutable))) { + return null + } + try { + return await startProvider( + agentBrowserPath, + { executablePath: chromiumExecutable, provider: 'chromium' }, + options.userDataPath + ) + } catch (error) { + console.warn('[orcad] External Chromium browser provider unavailable:', error) + return null + } +} diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 94a7f4267da..d01c2227a40 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -5,11 +5,11 @@ * desktop uses, installs a PTY controller via `registerHeadlessPtyRuntime`, and * serves runtime RPC. See docs/design/node-only-runtime-backend.html. * - * The desktop-only surfaces are deliberately left uninstalled: no notifications, no - * renderer window, no browser panes. Most are declared rather than faked — see - * `runtime-desktop-surface.ts` and `pty-host-bindings.ts`. The renderer window is the - * exception: `registerPtyHandlers` takes a non-null `BrowserWindow`, so the headless - * path still fakes one that reports itself destroyed. + * Desktop UI surfaces stay uninstalled: no notifications, no renderer window. The + * renderer window is faked as a destroyed one because `registerPtyHandlers` takes a + * non-null `BrowserWindow`. Browser automation is different — it is installed through + * the runtime factory, but only when an Electron serve sidecar or an operator-supplied + * Chromium proves available at startup. */ import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' @@ -17,6 +17,8 @@ import process from 'node:process' import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environment' import { setSecretStore, type SecretStore } from '../../shared/secret-store' import type { ServeReadiness } from '../server/serve-readiness' +import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory' +import { resolveOrcadBrowserProvider, type OrcadBrowserProvider } from './orcad-browser-provider' /** XDG-ish data root. `$ORCA_USER_DATA` wins so a smoke test can isolate state. */ function resolveUserDataPath(): string { @@ -27,13 +29,14 @@ function resolveUserDataPath(): string { const xdg = process.env.XDG_DATA_HOME return xdg ? join(xdg, 'Orca') : join(homedir(), '.orca') } +let runOrcadQuitHandlers = (): void => {} function createNodeAppEnvironment(): AppEnvironment { const userData = resolveUserDataPath() const quitHandlers: (() => void)[] = [] - // Why SIGTERM/SIGINT: this is the Node equivalent of electron's will-quit, and the - // runtime's teardown (daemon disconnect, PTY kill, store flush) hangs off it. - const runQuitHandlers = (): void => { + // The main signal handler awaits runtime and browser teardown before process.exit. + // Keep will-quit callbacks synchronous, but never let them pre-empt that async barrier. + runOrcadQuitHandlers = (): void => { for (const handler of quitHandlers.splice(0)) { try { handler() @@ -42,14 +45,6 @@ function createNodeAppEnvironment(): AppEnvironment { } } } - process.once('SIGTERM', () => { - runQuitHandlers() - process.exit(0) - }) - process.once('SIGINT', () => { - runQuitHandlers() - process.exit(0) - }) return { getPath: (name) => (name === 'home' ? homedir() : name === 'temp' ? tmpdir() : userData), getAppPath: () => process.cwd(), @@ -105,7 +100,26 @@ export type OrcadHandle = { */ export async function startOrcad(options: OrcadOptions = {}): Promise { installOrcadHostAdapters() + const userDataPath = resolveUserDataPath() + const browserProvider = await resolveOrcadBrowserProvider({ userDataPath }) + setRuntimeBrowserCommandsFactory(browserProvider?.factory ?? null, { + headless: browserProvider !== null, + ...(browserProvider ? { isAvailable: () => browserProvider.isAvailable() } : {}) + }) + try { + return await startOrcadRuntime(options, browserProvider) + } catch (error) { + await browserProvider?.stop() + setRuntimeBrowserCommandsFactory(null) + runOrcadQuitHandlers() + throw error + } +} +async function startOrcadRuntime( + options: OrcadOptions, + browserProvider: OrcadBrowserProvider | null +): Promise { const { OrcaRuntimeService } = await import('../runtime/orca-runtime') const { OrcaRuntimeRpcServer } = await import('../runtime/runtime-rpc') const { registerHeadlessPtyRuntime, getLocalPtyProvider, getSshPtyProvider } = @@ -118,9 +132,9 @@ export async function startOrcad(options: OrcadOptions = {}): Promise { - await rpc.stop() + try { + await rpc.stop() + } finally { + await browserProvider?.stop() + setRuntimeBrowserCommandsFactory(null) + runOrcadQuitHandlers() + } } } } diff --git a/src/main/orcad/orcad-sidecar-runtime-client.ts b/src/main/orcad/orcad-sidecar-runtime-client.ts new file mode 100644 index 00000000000..5fef23543ae --- /dev/null +++ b/src/main/orcad/orcad-sidecar-runtime-client.ts @@ -0,0 +1,132 @@ +import { randomUUID } from 'node:crypto' +import { createConnection } from 'node:net' +import { z } from 'zod' +import { findTransport, type RuntimeMetadata } from '../../shared/runtime-bootstrap' +import { BROWSER_UNAVAILABLE_ERROR_CODE } from '../../shared/runtime-types' +import { BrowserError } from '../browser/browser-error' +const SIDECAR_MAX_RESPONSE_BYTES = 64 * 1024 * 1024 + +const RuntimeResponse = z.discriminatedUnion('ok', [ + z.object({ id: z.string(), ok: z.literal(true), result: z.unknown() }).passthrough(), + z + .object({ + id: z.string(), + ok: z.literal(false), + error: z.object({ code: z.string(), message: z.string() }).passthrough() + }) + .passthrough() +]) + +export async function sendOrcadSidecarRequest( + metadata: RuntimeMetadata, + method: string, + params: unknown, + timeoutMs = 90_000 +): Promise { + const transport = findTransport(metadata, 'unix', 'named-pipe') + if (!transport) { + throw new BrowserError( + BROWSER_UNAVAILABLE_ERROR_CODE, + 'Electron browser sidecar has no local RPC transport.' + ) + } + return await new Promise((resolve, reject) => { + const socket = createConnection(transport.endpoint) + const requestId = randomUUID() + let buffer = '' + let retainedBytes = 0 + let settled = false + const finish = (error: Error | null, result?: unknown): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + socket.end() + if (error) { + reject(error) + } else { + resolve(result) + } + } + const timer = setTimeout(() => { + socket.destroy() + finish(new BrowserError('browser_timeout', 'Electron browser sidecar request timed out.')) + }, timeoutMs) + timer.unref?.() + socket.setEncoding('utf8') + socket.once('error', () => { + finish( + new BrowserError( + BROWSER_UNAVAILABLE_ERROR_CODE, + 'Could not connect to Electron browser sidecar.' + ) + ) + }) + socket.once('close', () => { + finish( + new BrowserError( + BROWSER_UNAVAILABLE_ERROR_CODE, + 'Electron browser sidecar closed before responding.' + ) + ) + }) + socket.on('data', (chunk: string) => { + buffer += chunk + retainedBytes += Buffer.byteLength(chunk, 'utf8') + if (retainedBytes > SIDECAR_MAX_RESPONSE_BYTES) { + socket.destroy() + finish(new BrowserError('browser_error', 'Electron browser sidecar response is too large.')) + return + } + let newline = buffer.indexOf('\n') + while (newline !== -1 && !settled) { + const line = buffer.slice(0, newline) + buffer = buffer.slice(newline + 1) + retainedBytes = Buffer.byteLength(buffer, 'utf8') + newline = buffer.indexOf('\n') + if (!line.trim()) { + continue + } + let raw: unknown + try { + raw = JSON.parse(line) + } catch { + finish( + new BrowserError('browser_error', 'Electron browser sidecar returned invalid JSON.') + ) + return + } + if (raw && typeof raw === 'object' && '_keepalive' in raw) { + timer.refresh() + continue + } + const parsed = RuntimeResponse.safeParse(raw) + if (!parsed.success || parsed.data.id !== requestId) { + finish( + new BrowserError( + 'browser_error', + 'Electron browser sidecar returned an invalid response.' + ) + ) + return + } + if (!parsed.data.ok) { + finish(new BrowserError(parsed.data.error.code, parsed.data.error.message)) + return + } + finish(null, parsed.data.result) + } + }) + socket.on('connect', () => { + socket.write( + `${JSON.stringify({ + id: requestId, + authToken: metadata.authToken, + method, + params + })}\n` + ) + }) + }) +} diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 243fed1191f..652908b605d 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -2338,6 +2338,27 @@ describe('OrcaRuntimeService', () => { expect(capabilities).toContain('browser.headless.v1') expect(capabilities).toContain('browser.certificate-trust.v1') }) + + it('advertises only while headless browser commands remain live', () => { + let available = true + setRuntimeBrowserCommandsFactory((host) => new RuntimeBrowserCommands(host), { + headless: true, + isAvailable: () => available + }) + const status = createRuntime().getStatus() + + expect(status.capabilities).toContain('browser.headless.v1') + expect(status.capabilities).not.toContain('browser.screencast.v1') + expect(status.capabilities).not.toContain('browser.certificate-trust.v1') + expect(status.degradations).toBeUndefined() + + available = false + const degraded = createRuntime().getStatus() + expect(degraded.capabilities).not.toContain('browser.headless.v1') + expect(degraded.degradations).toEqual([ + expect.objectContaining({ code: 'browser_unavailable' }) + ]) + }) it('surfaces live offscreen load failures in headless browser snapshots', () => { const runtime = createRuntime() runtime.setOffscreenBrowserBackend({ createTab: vi.fn(), closeTab: vi.fn() }) @@ -2465,11 +2486,38 @@ describe('OrcaRuntimeService', () => { expect(runtime.getStatus().capabilities).toContain('browser.certificate-trust.v1') }) - it('does not advertise certificate trust when no browser backend is available', () => { + it('declares browser unavailability when no browser provider resolves', async () => { + setRuntimeBrowserCommandsFactory(null) const runtime = createRuntime() + const status = runtime.getStatus() - expect(runtime.getStatus().capabilities).not.toContain('browser.certificate-trust.v1') - expect(runtime.getStatus().capabilities).not.toContain('browser.screencast.v1') + expect(status.capabilities).not.toContain('browser.headless.v1') + expect(status.capabilities).not.toContain('browser.certificate-trust.v1') + expect(status.capabilities).not.toContain('browser.screencast.v1') + expect(status.degradations).toEqual([ + { + code: 'browser_unavailable', + capability: 'browser.headless.v1', + message: 'Browser automation requires the Electron provider or ORCA_BROWSER_EXECUTABLE.' + } + ]) + const browserCalls = Object.entries(runtime).filter( + ([name, value]) => /^browser[A-Z]/.test(name) && typeof value === 'function' + ) + expect(browserCalls.length).toBeGreaterThan(50) + for (const [name, call] of browserCalls) { + const invoke = + name === 'browserScreencast' + ? () => + (call as CallableFunction)( + { format: 'jpeg' }, + { sendBinary: () => true, emit: () => undefined } + ) + : () => (call as CallableFunction)({}) + await expect(Promise.resolve().then(invoke)).rejects.toMatchObject({ + code: 'browser_unavailable' + }) + } }) it('closes a worktree’s offscreen browser pages when its metadata is removed (leak fix)', () => { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 81558e0dda0..cd65b3df7b4 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -426,6 +426,7 @@ import type { LinearStatusSetResult } from '../../shared/linear/agent-access' import { + BROWSER_UNAVAILABLE_ERROR_CODE, HEADLESS_RUNTIME_WINDOW_ID, type RuntimeDesktopWindowStatus, type RuntimeGraphStatus, @@ -659,7 +660,10 @@ import { import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' import type { AutomationService } from '../automations/service' import type { RuntimeBrowserCommands } from './orca-runtime-browser' -import { createRuntimeBrowserCommands } from './runtime-browser-commands-factory' +import { + createRuntimeBrowserCommands, + runtimeBrowserCommandsFactoryIsHeadless +} from './runtime-browser-commands-factory' import { RemoteRuntimeTerminalCreateIdempotency } from './remote-runtime-terminal-create-idempotency' import { deriveRemoteRuntimeTerminalCreateHandle } from './remote-runtime-terminal-create-identity' import { @@ -6134,6 +6138,7 @@ export class OrcaRuntimeService { // so they must not fall back to a local desktop browser tab. const hasRenderer = Boolean(this.getAvailableAuthoritativeWindow()) const hasOffscreen = !hasRenderer && Boolean(this.offscreenBrowserBackend) + const hasHeadlessCommands = runtimeBrowserCommandsFactoryIsHeadless() const canBrowse = hasRenderer || hasOffscreen const capabilities: RuntimeCapability[] = RUNTIME_CAPABILITIES.filter( (capability) => @@ -6144,7 +6149,7 @@ export class OrcaRuntimeService { (process.env.ORCA_E2E_DISABLE_PAIRED_TERMINAL_PARKING !== '1' || capability !== TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY) ) - if (hasOffscreen) { + if (hasOffscreen || hasHeadlessCommands) { capabilities.push(BROWSER_HEADLESS_RUNTIME_CAPABILITY) } // Why: certificate proceed is owned by the browser-hosting process for both @@ -6153,6 +6158,17 @@ export class OrcaRuntimeService { if (canBrowse) { capabilities.push(BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY) } + const degradations = + canBrowse || hasHeadlessCommands + ? [] + : [ + { + code: BROWSER_UNAVAILABLE_ERROR_CODE, + capability: BROWSER_HEADLESS_RUNTIME_CAPABILITY, + message: + 'Browser automation requires the Electron provider or ORCA_BROWSER_EXECUTABLE.' + } + ] return { runtimeId: this.runtimeId, rendererGraphEpoch: this.rendererGraphEpoch, @@ -6166,6 +6182,7 @@ export class OrcaRuntimeService { // Why: headless orca serve cannot create/stream BrowserViews, so clients // must not treat browser panes as supported just because runtime RPC is up. capabilities, + ...(degradations.length > 0 ? { degradations } : {}), hostPlatform: process.platform, terminalWindowsShell: this.store?.getSettings?.().terminalWindowsShell ?? null, floatingWorkspaceEnabled: this.store?.getSettings?.().floatingTerminalEnabled !== false, diff --git a/src/main/runtime/pty-waiver-source-invariant.test.ts b/src/main/runtime/pty-waiver-source-invariant.test.ts index e0b7122d825..5e4bcf7b4f2 100644 --- a/src/main/runtime/pty-waiver-source-invariant.test.ts +++ b/src/main/runtime/pty-waiver-source-invariant.test.ts @@ -7,7 +7,21 @@ import { describe, expect, it } from 'vitest' // while silently disabling the PTY gate on that path. `force` is set by the // ordinary delete confirmation (to skip the dirty-file prompt) and is NOT user // intent to delete past live terminals — so pin the wiring itself, at every site. -const FILES = [join(__dirname, '..', 'ipc', 'worktrees.ts'), join(__dirname, 'orca-runtime.ts')] +const FILE_GROUPS = [ + { + label: 'extracted worktree removal', + files: [ + join(__dirname, '..', 'ipc', 'worktrees', 'removal', 'worktree-removal-ownership.ts'), + join(__dirname, '..', 'ipc', 'worktrees', 'removal', 'remove-registered-local-worktree.ts'), + join(__dirname, '..', 'ipc', 'worktrees', 'removal', 'remove-registered-remote-worktree.ts'), + join(__dirname, '..', 'ipc', 'worktrees', 'removal', 'remove-unregistered-worktree.ts') + ] + }, + { + label: 'runtime removal', + files: [join(__dirname, 'orca-runtime.ts')] + } +] as const // Why: a comment quoting `allowUnverifiedStop:` would otherwise count as a site — // and this very invariant invites people to write one in the file it guards. @@ -20,8 +34,8 @@ function stripComments(source: string): string { } describe('the PTY-stop waiver is never derived from `force`', () => { - it.each(FILES)('%s passes only an explicit waiver to the teardown', (file) => { - const source = stripComments(readFileSync(file, 'utf8')) + it.each(FILE_GROUPS)('$label paths pass only an explicit waiver to the teardown', ({ files }) => { + const source = stripComments(files.map((file) => readFileSync(file, 'utf8')).join('\n')) // Why: derived, not hardcoded — merging two removal branches is a legitimate // refactor and must not read as a deleted safety wiring, while dropping the diff --git a/src/main/runtime/rpc/methods/browser-screencast.ts b/src/main/runtime/rpc/methods/browser-screencast.ts index 98321949953..b37bfeb1a22 100644 --- a/src/main/runtime/rpc/methods/browser-screencast.ts +++ b/src/main/runtime/rpc/methods/browser-screencast.ts @@ -1,6 +1,9 @@ import { z } from 'zod' import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' import { Screencast } from './browser-schemas' +import { BrowserError } from '../../../browser/browser-error' +import { BROWSER_UNAVAILABLE_ERROR_CODE } from '../../../../shared/runtime-types' +import { runtimeBrowserCommandsFactoryIsAvailable } from '../../runtime-browser-commands-factory' const ScreencastUnsubscribe = z.object({ subscriptionId: z.string().min(1, 'Missing required --subscription-id') @@ -17,6 +20,12 @@ export const BROWSER_SCREENCAST_METHODS: RpcAnyMethod[] = [ name: 'browser.screencast.unsubscribe', params: ScreencastUnsubscribe, handler: async (params, { runtime }) => { + if (!runtimeBrowserCommandsFactoryIsAvailable()) { + throw new BrowserError( + BROWSER_UNAVAILABLE_ERROR_CODE, + 'Browser automation is unavailable on this host.' + ) + } runtime.cleanupSubscription(params.subscriptionId) return { unsubscribed: true } } diff --git a/src/main/runtime/rpc/methods/browser.test.ts b/src/main/runtime/rpc/methods/browser.test.ts index 638483f843d..5ebe2420a55 100644 --- a/src/main/runtime/rpc/methods/browser.test.ts +++ b/src/main/runtime/rpc/methods/browser.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' import type { OrcaRuntimeService } from '../../orca-runtime' +import { setRuntimeBrowserCommandsFactory } from '../../runtime-browser-commands-factory' import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS, CLIPBOARD_TEXT_WRITE_MAX_BYTES, @@ -155,6 +156,32 @@ describe('browser RPC methods', () => { cleanupSubscription: vi.fn() } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: BROWSER_SCREENCAST_METHODS }) + setRuntimeBrowserCommandsFactory(() => ({}) as never) + + try { + const response = await dispatcher.dispatch( + makeRequest('browser.screencast.unsubscribe', { + subscriptionId: 'browser-screencast:page-1:test' + }) + ) + + expect(runtime.cleanupSubscription).toHaveBeenCalledWith('browser-screencast:page-1:test') + expect(response).toMatchObject({ + ok: true, + result: { unsubscribed: true } + }) + } finally { + setRuntimeBrowserCommandsFactory(null) + } + }) + + it('rejects browser screencast unsubscribe when no provider resolved', async () => { + setRuntimeBrowserCommandsFactory(null) + const runtime = { + getRuntimeId: () => 'test-runtime', + cleanupSubscription: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: BROWSER_SCREENCAST_METHODS }) const response = await dispatcher.dispatch( makeRequest('browser.screencast.unsubscribe', { @@ -162,11 +189,11 @@ describe('browser RPC methods', () => { }) ) - expect(runtime.cleanupSubscription).toHaveBeenCalledWith('browser-screencast:page-1:test') expect(response).toMatchObject({ - ok: true, - result: { unsubscribed: true } + ok: false, + error: { code: 'browser_unavailable' } }) + expect(runtime.cleanupSubscription).not.toHaveBeenCalled() }) it('routes browser session and environment controls to the runtime server', async () => { diff --git a/src/main/runtime/runtime-browser-commands-factory.ts b/src/main/runtime/runtime-browser-commands-factory.ts index 99e321aa310..5fbcabcb4dc 100644 --- a/src/main/runtime/runtime-browser-commands-factory.ts +++ b/src/main/runtime/runtime-browser-commands-factory.ts @@ -1,3 +1,5 @@ +import { BrowserError } from '../browser/browser-error' +import { BROWSER_UNAVAILABLE_ERROR_CODE } from '../../shared/runtime-types' import type { RuntimeBrowserCommandHost, RuntimeBrowserCommands } from './orca-runtime-browser' /** @@ -8,9 +10,9 @@ import type { RuntimeBrowserCommandHost, RuntimeBrowserCommands } from './orca-r * 15 modules that a Node host cannot load at all. Importing the class for its *type* * is free; constructing it is what drags the cluster in. * - * The desktop installs the real factory. A Node host installs none and every browser - * RPC rejects with `browser_unavailable`, which the runtime already advertises through - * capability filtering — clients do not offer the affordance. + * The desktop installs the Electron factory. A Node host installs an external provider + * when one resolves; otherwise every browser RPC rejects with the closed + * `browser_unavailable` code and capability filtering hides the affordance. * * Deliberately NOT a stub object with silently-succeeding methods: that is the * "looks fine, returns a lie" shape this codebase rejects. Absent means rejected. @@ -20,12 +22,36 @@ export type RuntimeBrowserCommandsFactory = ( host: RuntimeBrowserCommandHost ) => RuntimeBrowserCommands +export type RuntimeBrowserCommandsFactoryOptions = { + /** The provider owns browser pages without a renderer window. */ + headless?: boolean + /** Live health probe; failure must remove advertised capability. */ + isAvailable?: () => boolean +} + let currentFactory: RuntimeBrowserCommandsFactory | null = null +let currentOptions: RuntimeBrowserCommandsFactoryOptions = {} export function setRuntimeBrowserCommandsFactory( - factory: RuntimeBrowserCommandsFactory | null + factory: RuntimeBrowserCommandsFactory | null, + options: RuntimeBrowserCommandsFactoryOptions = {} ): void { currentFactory = factory + currentOptions = factory ? options : {} +} +export function runtimeBrowserCommandsFactoryIsAvailable(): boolean { + if (!currentFactory) { + return false + } + try { + return currentOptions.isAvailable?.() !== false + } catch { + return false + } +} + +export function runtimeBrowserCommandsFactoryIsHeadless(): boolean { + return runtimeBrowserCommandsFactoryIsAvailable() && currentOptions.headless === true } /** @@ -46,7 +72,10 @@ export function createRuntimeBrowserCommands( return undefined } return () => { - throw new Error(`browser_unavailable: ${String(property)} needs a desktop host`) + throw new BrowserError( + BROWSER_UNAVAILABLE_ERROR_CODE, + 'Browser automation is unavailable on this host.' + ) } } }) diff --git a/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt b/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt index 01dc111ae1b..30494f29502 100644 --- a/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt +++ b/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt @@ -21,7 +21,6 @@ main/codex-accounts/service.ts main/codex/codex-state-db-backfill-recovery.ts main/codex/codex-trust-grant-host.ts main/codex/codex-wsl-hook-install-plan.ts -main/daemon/pty-subprocess.ts main/git/runner.ts main/git/wsl-git-read-environment.ts main/ipc/filesystem-watcher-wsl.ts diff --git a/src/renderer/src/components/crash-report/CrashReportDialogSurface.tsx b/src/renderer/src/components/crash-report/CrashReportDialogSurface.tsx index 94ffb8d342d..05ad8fff24e 100644 --- a/src/renderer/src/components/crash-report/CrashReportDialogSurface.tsx +++ b/src/renderer/src/components/crash-report/CrashReportDialogSurface.tsx @@ -16,6 +16,7 @@ import { useMountedRef } from '@/hooks/useMountedRef' import { formatCrashReportText, isReactErrorBoundaryReport, + MAX_USER_NOTES_LENGTH, type CrashReportDiagnosticBundle, type CrashReportRecord } from '../../../../shared/crash-reporting' @@ -277,13 +278,23 @@ export function CrashReportDialogSurface({ )} )} -