From 913509edeb0ada8e6a8c57f3b2360dd60d424de1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:25:30 -0700 Subject: [PATCH 01/29] fix(orchestration): prevent slow worker-start stalls (#16300) * Extend orchestration agent submission timing budgets * fix(orchestration): preserve mutation recovery identity * fix(orchestration): preserve recovery executable identity * fix(orchestration): keep worker starts and recovery commands safe * test(orchestration): cover federated worker preflight * fix(orchestration): harden mutation recovery * fix(orchestration): redact dispatch recovery credentials * chore: preserve upstream skill dialog formatting * test(orchestration): stabilize agent prompt submit e2e * fix(orchestration): validate federated start receipts * perf(runtime): cache unchanged prompt verification tail * fix(orchestration): reject worker-start timer overflow * fix(orchestration): normalize worker-start timeout defaults * fix(orchestration): normalize worker-start readiness budgets * fix(orchestration): normalize federated readiness timeout * test(runtime): tolerate current-main degradation exports * chore: preserve current-main orcad formatting * chore: drop unrelated formatting carryover --- .../handlers/orchestration-gate-cli.test.ts | 10 + src/cli/index.ts | 5 +- .../orchestration-mutation-recovery.test.ts | 269 ++++++++++++++++++ src/cli/orchestration-mutation-recovery.ts | 166 ++++++++++- src/cli/runtime/client-error-recovery.ts | 31 ++ src/cli/runtime/client-recovery.test.ts | 130 +++++++++ src/cli/runtime/client.ts | 64 +++-- .../orchestration-recovery-command.test.ts | 133 +++++++++ .../runtime/orchestration-recovery-command.ts | 102 +++++++ .../runtime/websocket-transport-error.test.ts | 101 +++++++ src/cli/runtime/websocket-transport.ts | 4 +- ...ent-prompt-submission-verification.test.ts | 31 +- .../agent-prompt-submission-verification.ts | 29 +- src/main/runtime/orca-runtime.ts | 45 ++- ...estration-federated-attach-receipt.test.ts | 36 +++ .../orchestration-federated-attach-receipt.ts | 53 ++++ ...ion-federated-worker-start-receipt.test.ts | 84 ++++++ ...-federated-worker-start-unknown-receipt.ts | 25 ++ .../orchestration-federated-worker-start.ts | 133 ++++----- .../rpc/methods/orchestration-federation.ts | 13 +- .../orchestration-migration-behavior.test.ts | 81 ++++++ ...orchestration-worker-start-budgets.test.ts | 34 +++ .../orchestration-worker-start-budgets.ts | 32 +++ .../rpc/methods/orchestration-workers.ts | 17 +- .../runtime-rpc-long-poll-transport.test.ts | 110 ++++++- src/main/runtime/runtime-rpc.ts | 16 ++ .../ssh-remote-cli-host-passthrough.test.ts | 18 ++ .../ssh/ssh-remote-cli-host-passthrough.ts | 2 + .../orchestration-timing-budgets.test.ts | 51 ++++ src/shared/orchestration-timing-budgets.ts | 50 ++++ src/shared/remote-runtime-client-error.ts | 4 + .../remote-runtime-shared-control-protocol.ts | 6 +- .../terminal-send-agent-prompt-submit.spec.ts | 102 +++++-- tests/tools/repro-terminal-send-submit.mjs | 124 +++++--- 34 files changed, 1912 insertions(+), 199 deletions(-) create mode 100644 src/cli/orchestration-mutation-recovery.test.ts create mode 100644 src/cli/runtime/client-error-recovery.ts create mode 100644 src/cli/runtime/client-recovery.test.ts create mode 100644 src/cli/runtime/orchestration-recovery-command.test.ts create mode 100644 src/cli/runtime/orchestration-recovery-command.ts create mode 100644 src/cli/runtime/websocket-transport-error.test.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-federated-attach-receipt.test.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-federated-attach-receipt.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-federated-worker-start-receipt.test.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-federated-worker-start-unknown-receipt.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-worker-start-budgets.test.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-worker-start-budgets.ts create mode 100644 src/shared/orchestration-timing-budgets.test.ts create mode 100644 src/shared/orchestration-timing-budgets.ts diff --git a/src/cli/handlers/orchestration-gate-cli.test.ts b/src/cli/handlers/orchestration-gate-cli.test.ts index 0bcdddbc98e..b4be315c155 100644 --- a/src/cli/handlers/orchestration-gate-cli.test.ts +++ b/src/cli/handlers/orchestration-gate-cli.test.ts @@ -206,6 +206,16 @@ describe('orchestration gate commands carry caller identity', () => { 'The Orca runtime closed the connection before responding. Restart Orca and try again. Orchestration mutation request ID: mutation_1.', { orchestrationRequestId: 'mutation_1', + originalCommand: [ + 'orca', + 'orchestration', + 'gate-create', + '--task', + 'task_1', + '--question', + 'ship?', + '--json' + ], failedStage: 'dispatch_input', residualResources: [ { kind: 'worktree', id: 'repo::child' }, diff --git a/src/cli/index.ts b/src/cli/index.ts index 26cfb8eea49..2a8921a2cdb 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -18,6 +18,7 @@ import { reportCliError } from './format' import { printHelp } from './help' import type { RuntimeClient } from './runtime-client' import { COMMAND_SPECS } from './specs' +import { resolveOrchestrationCliExecutable } from './runtime/orchestration-recovery-command' export { COMMAND_SPECS } from './specs' export { buildCurrentWorktreeSelector, normalizeWorktreeSelector } from './selectors' @@ -149,7 +150,9 @@ export async function main( undefined, undefined, remotePairingCode, - remoteEnvironment + remoteEnvironment, + resolveOrchestrationCliExecutable(), + argv ) return client }, diff --git a/src/cli/orchestration-mutation-recovery.test.ts b/src/cli/orchestration-mutation-recovery.test.ts new file mode 100644 index 00000000000..7664cc7ec55 --- /dev/null +++ b/src/cli/orchestration-mutation-recovery.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, it } from 'vitest' +import { runProcess } from '../shared/child-process/run-process' +import { + orchestrationMutationRecoveryError, + renderCommand +} from './orchestration-mutation-recovery' +import { RuntimeClientError } from './runtime-client' + +describe('orchestration mutation recovery', () => { + it('queries a known dispatch before issuing the keyed retry', () => { + const result = orchestrationMutationRecoveryError( + new RuntimeClientError('runtime_timeout', 'request timed out', { + orchestrationRequestId: 'request_1', + dispatchId: 'dispatch_1', + originalCommand: ['orca', 'orchestration', 'worker-start', '--task', 'task_1'] + }) + ) as RuntimeClientError + + expect(result.data).toMatchObject({ + recovery: { + orchestrationRequestId: 'request_1', + dispatchId: 'dispatch_1', + queryCommand: [ + 'orca', + 'orchestration', + 'worker-show', + '--dispatch', + 'dispatch_1', + '--json' + ], + retryCommand: [ + 'orca', + 'orchestration', + 'worker-start', + '--task', + 'task_1', + '--retry-request', + 'request_1' + ], + workerDeathInferred: false + } + }) + expect(result.message.indexOf('orca orchestration worker-show')).toBeLessThan( + result.message.indexOf('orca orchestration worker-start') + ) + expect((result.data as { nextSteps?: string[] }).nextSteps).toEqual([ + 'Run orca orchestration worker-show --dispatch dispatch_1 --json before retrying.', + 'Run orca orchestration worker-start --task task_1 --retry-request request_1.' + ]) + }) + + it('does not invent a dispatch for an old-client-shaped error', () => { + const result = orchestrationMutationRecoveryError( + new RuntimeClientError('runtime_timeout', 'request timed out', { + orchestrationRequestId: 'request_2', + originalCommand: ['orca', 'orchestration', 'worker-start', '--task', 'task_2'] + }) + ) as RuntimeClientError + + expect(result.data).toMatchObject({ + recovery: { + orchestrationRequestId: 'request_2', + retryCommand: expect.arrayContaining(['--retry-request', 'request_2']), + workerDeathInferred: false + } + }) + expect((result.data as Record).recovery).not.toHaveProperty('dispatchId') + expect(result.message).not.toContain('worker death') + }) + + it('renders the exact executable and safely quotes original arguments', () => { + const result = orchestrationMutationRecoveryError( + new RuntimeClientError('runtime_timeout', 'request timed out', { + orchestrationRequestId: 'request_3', + dispatchId: 'dispatch_3', + originalCommand: [ + 'orca-dev', + 'orchestration', + 'worker-start', + '--task', + 'task 3', + '--comment', + 'literal $(do-not-run)' + ] + }) + ) as RuntimeClientError + + expect((result.data as { nextSteps?: string[] }).nextSteps).toEqual([ + 'Run orca-dev orchestration worker-show --dispatch dispatch_3 --json before retrying.', + "Run orca-dev orchestration worker-start --task 'task 3' --comment 'literal $(do-not-run)' --retry-request request_3." + ]) + expect(result.message).toContain("'literal $(do-not-run)'") + }) + + it('parses legacy command text without losing quoted arguments', () => { + const result = orchestrationMutationRecoveryError( + new RuntimeClientError('runtime_timeout', 'request timed out', { + orchestrationRequestId: 'request_4', + originalCommand: + 'orca-ide orchestration worker-stop --dispatch dispatch_4 --comment "quoted value"' + }) + ) as RuntimeClientError + + expect( + (result.data as { recovery?: { retryCommand?: string[] } }).recovery?.retryCommand + ).toEqual([ + 'orca-ide', + 'orchestration', + 'worker-stop', + '--dispatch', + 'dispatch_4', + '--comment', + 'quoted value', + '--retry-request', + 'request_4' + ]) + }) + + it.each([ + [ + 'gate-create', + ['orca', 'orchestration', 'gate-create', '--task', 'task_1', '--question', 'ship?'] + ], + [ + 'worker-retain', + ['orca', 'orchestration', 'worker-retain', '--dispatch', 'dispatch_1', '--json'] + ] + ])('replays exact %s argv with the keyed retry', (_name, originalCommand) => { + const result = orchestrationMutationRecoveryError( + new RuntimeClientError('runtime_timeout', 'request timed out', { + orchestrationRequestId: 'request_exact', + originalCommand + }) + ) as RuntimeClientError + + expect( + (result.data as { recovery?: { retryCommand?: string[] } }).recovery?.retryCommand + ).toEqual([...originalCommand, '--retry-request', 'request_exact']) + }) + + it('reuses the request identity without duplicating an existing retry flag', () => { + const result = orchestrationMutationRecoveryError( + new RuntimeClientError('runtime_timeout', 'request timed out', { + orchestrationRequestId: 'request_reused', + originalCommand: [ + 'orca', + 'orchestration', + 'worker-retain', + '--dispatch', + 'dispatch_1', + '--retry-request=request_reused' + ] + }) + ) as RuntimeClientError + + expect( + (result.data as { recovery?: { retryCommand?: string[] } }).recovery?.retryCommand + ).toEqual([ + 'orca', + 'orchestration', + 'worker-retain', + '--dispatch', + 'dispatch_1', + '--retry-request', + 'request_reused' + ]) + }) + + it('renders Windows cmd recovery guidance without quote drift or percent expansion', () => { + expect( + renderCommand( + ['orca', 'orchestration', 'worker-start', '--comment', 'literal "quoted" %PATH% & safe'], + 'win32', + { ComSpec: 'C:\\Windows\\System32\\cmd.exe' } + ) + ).toBe( + '"orca" "orchestration" "worker-start" "--comment" "literal ""quoted"" "^%"PATH"^%" & safe"' + ) + }) + + it('keeps PowerShell and POSIX recovery guidance literal', () => { + expect( + renderCommand(['orca', 'literal "quoted" $HOME'], 'win32', { + ComSpec: 'powershell.exe' + }) + ).toBe("& 'orca' 'literal \\\"quoted\\\" $HOME'") + expect(renderCommand(['orca', 'literal $(do-not-run)'], 'darwin')).toBe( + "orca 'literal $(do-not-run)'" + ) + }) + + it.runIf(process.platform !== 'win32')( + 'round trips POSIX recovery argv through /bin/sh', + async () => { + const values = ['with spaces', "apostrophe's", 'literal $(do-not-run)', 'line one\nline two'] + const command = renderCommand( + [ + process.execPath, + '-e', + 'process.stdout.write(JSON.stringify(process.argv.slice(1)))', + ...values + ], + 'darwin' + ) + + const result = await runProcess({ program: '/bin/sh', args: ['-c', command] }) + + expect(result).toMatchObject({ code: 0, stderr: '', timedOut: false }) + expect(JSON.parse(result.stdout)).toEqual(values) + } + ) + + it.each([ + [ + 'split', + ['orca', 'orchestration', 'send', '--pairing-code', 'split-secret', '--subject', 'status'], + 'split-secret' + ], + [ + 'equals', + ['orca', 'orchestration', 'send', '--pairing-code=equals-secret', '--subject', 'status'], + 'equals-secret' + ], + [ + 'dispatch split', + [ + 'orca', + 'orchestration', + 'send', + '--dispatch-capability', + 'split-dispatch-secret', + '--subject', + 'status' + ], + 'split-dispatch-secret' + ], + [ + 'dispatch equals', + [ + 'orca', + 'orchestration', + 'send', + '--dispatch-capability=equals-dispatch-secret', + '--subject', + 'status' + ], + 'equals-dispatch-secret' + ] + ])('blocks recovery and removes %s credentials', (_name, originalCommand, secret) => { + const result = orchestrationMutationRecoveryError( + new RuntimeClientError('runtime_timeout', 'request timed out', { + orchestrationRequestId: 'request_secret', + originalCommand + }) + ) as RuntimeClientError + const output = JSON.stringify({ message: result.message, data: result.data }) + + expect(output).not.toContain(secret) + expect(result.message).toContain('Recovery is blocked') + expect(result.data).toMatchObject({ + recovery: { + orchestrationRequestId: 'request_secret', + recoveryBlocked: true + } + }) + expect(result.data).not.toHaveProperty('originalCommand') + expect((result.data as { recovery: object }).recovery).not.toHaveProperty('retryCommand') + }) +}) diff --git a/src/cli/orchestration-mutation-recovery.ts b/src/cli/orchestration-mutation-recovery.ts index 8e4a03f911d..63261561caf 100644 --- a/src/cli/orchestration-mutation-recovery.ts +++ b/src/cli/orchestration-mutation-recovery.ts @@ -1,4 +1,12 @@ -import { RuntimeClientError } from './runtime-client' +import { RuntimeClientError, RuntimeRpcFailureError } from './runtime-client' +import { + recoverableOrchestrationArgs, + resolveOrchestrationCliExecutable +} from './runtime/orchestration-recovery-command' +import { quoteWindowsCmdArgument } from '../shared/child-process/windows-command-line' +import { quotePowerShellNativeArgument } from '../shared/powershell-native-argument' +import { resolveWindowsShellStartupFamily } from '../shared/windows-terminal-shell' +import type { AgentStartupShell } from '../shared/tui-agent-startup-shell' export function orchestrationMutationRecoveryError(error: unknown): unknown { if (!(error instanceof RuntimeClientError) || !isUnknownMutationOutcomeCode(error.code)) { @@ -9,16 +17,64 @@ export function orchestrationMutationRecoveryError(error: unknown): unknown { if (typeof requestId !== 'string' || requestId.length === 0) { return error } + const dispatchId = typeof data?.dispatchId === 'string' ? data.dispatchId : undefined + const parsedOriginalCommand = commandParts(data?.originalCommand) + const originalCommand = parsedOriginalCommand + ? recoverableOrchestrationArgs(parsedOriginalCommand) + : undefined + const safeData = { ...data } + delete safeData.originalCommand + const retryCommand = originalCommand + ? [...originalCommand, '--retry-request', requestId] + : undefined + const executable = originalCommand?.[0] ?? resolveOrchestrationCliExecutable() + const queryCommand = dispatchId + ? [executable, 'orchestration', 'worker-show', '--dispatch', dispatchId, '--json'] + : undefined + const recovery = { + orchestrationRequestId: requestId, + ...(dispatchId ? { dispatchId } : {}), + ...(queryCommand ? { queryCommand } : {}), + ...(retryCommand ? { retryCommand } : {}), + recoveryBlocked: !retryCommand, + disposition: 'outcome_unknown', + workerDeathInferred: false + } + const retryStep = retryCommand + ? `Run ${renderCommand(retryCommand)}.` + : 'Recovery is blocked until the exact original command is available; no retry command was emitted.' + const nextSteps = queryCommand + ? [`Run ${renderCommand(queryCommand)} before retrying.`, retryStep] + : [retryStep] const message = [ stripUnsafeRetryAdvice(error.message, requestId), 'The orchestration mutation may already have taken effect; do not assume it failed.', - `Re-issue the same command with --retry-request ${requestId} to recover idempotently. Do not retry this mutation without --retry-request.`, + ...nextSteps, typeof data?.failedStage === 'string' ? `Failed stage: ${data.failedStage}.` : undefined, Array.isArray(data?.residualResources) ? `Residual resources: ${JSON.stringify(data.residualResources)}.` : undefined ].filter((line): line is string => line !== undefined) - return new RuntimeClientError(error.code, message.join('\n'), error.data) + const recoveredData = { + ...safeData, + orchestrationRequestId: requestId, + ...(originalCommand ? { originalCommand } : {}), + recovery, + nextSteps + } + // Preserve the RPC failure envelope so --json callers retain the request id and + // runtime metadata while receiving the structured recovery guidance. + if (error instanceof RuntimeRpcFailureError) { + return new RuntimeRpcFailureError({ + ...error.response, + error: { + ...error.response.error, + message: message.join('\n'), + data: recoveredData + } + }) + } + return new RuntimeClientError(error.code, message.join('\n'), recoveredData) } function isUnknownMutationOutcomeCode(code: string): boolean { @@ -36,6 +92,110 @@ function objectRecord(value: unknown): Record | undefined { : undefined } +function commandParts(value: unknown): string[] | undefined { + if (Array.isArray(value) && value.every((part) => typeof part === 'string')) { + return [...value] + } + if (typeof value === 'string' && value.length > 0) { + return parseCommandLine(value) + } + return undefined +} + +function parseCommandLine(value: string): string[] | undefined { + const parts: string[] = [] + let part = '' + let quote: "'" | '"' | undefined + let tokenStarted = false + for (let index = 0; index < value.length; index += 1) { + const character = value[index] + if (quote === "'") { + if (character === "'") { + quote = undefined + } else { + part += character + } + tokenStarted = true + continue + } + if (quote === '"') { + if (character === '"') { + quote = undefined + } else if (character === '\\' && ['"', '\\'].includes(value[index + 1] ?? '')) { + part += value[++index] + } else { + part += character + } + tokenStarted = true + continue + } + if (character === "'" || character === '"') { + quote = character + tokenStarted = true + } else if (/\s/.test(character)) { + if (tokenStarted) { + parts.push(part) + part = '' + tokenStarted = false + } + } else if (character === '\\' && [' ', '\\', "'", '"'].includes(value[index + 1] ?? '')) { + part += value[++index] + tokenStarted = true + } else { + part += character + tokenStarted = true + } + } + if (quote !== undefined) { + return undefined + } + if (tokenStarted) { + parts.push(part) + } + return parts.length > 0 ? parts : undefined +} + +export function renderCommand( + command: readonly string[], + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env +): string { + const shell = resolveRecoveryShell(platform, env) + const rendered = command.map((value) => quoteRecoveryArgument(value, shell)).join(' ') + return shell === 'powershell' && rendered ? `& ${rendered}` : rendered +} + +function resolveRecoveryShell( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv +): AgentStartupShell { + if (platform !== 'win32') { + return 'posix' + } + return resolveWindowsShellStartupFamily( + env.ORCA_TERMINAL_WINDOWS_SHELL ?? env.ORCA_WINDOWS_SHELL ?? env.ComSpec ?? env.COMSPEC + ) +} + +function quoteRecoveryArgument(value: string, shell: AgentStartupShell): string { + if (shell === 'cmd') { + // cmd has no single-quote syntax and expands %VAR% inside double quotes; + // use the argv encoder that keeps quote parity and escapes percent pairs. + return quoteWindowsCmdArgument(value) + } + if (shell === 'powershell') { + return quotePowerShellNativeArgument(value) + } + return shellQuote(value) +} + +function shellQuote(value: string): string { + if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value)) { + return value + } + return `'${value.replaceAll("'", `'"'"'`)}'` +} + function stripUnsafeRetryAdvice(message: string, requestId: string): string { return message .replace(' Restart Orca and try again.', '') diff --git a/src/cli/runtime/client-error-recovery.ts b/src/cli/runtime/client-error-recovery.ts new file mode 100644 index 00000000000..89ff4fa884f --- /dev/null +++ b/src/cli/runtime/client-error-recovery.ts @@ -0,0 +1,31 @@ +import { RuntimeClientError, RuntimeRpcFailureError } from './types' + +export function attachMutationRecovery( + error: unknown, + requestId: string | undefined, + originalCommand?: string[] +): unknown { + if (!requestId || !(error instanceof RuntimeClientError)) { + return error + } + const data = { + ...(error.data && typeof error.data === 'object' ? error.data : {}), + orchestrationRequestId: requestId, + ...(originalCommand ? { originalCommand } : {}) + } + if (error instanceof RuntimeRpcFailureError) { + return new RuntimeRpcFailureError({ + ...error.response, + error: { + ...error.response.error, + message: `${error.message} Orchestration mutation request ID: ${requestId}.`, + data + } + }) + } + return new RuntimeClientError( + error.code, + `${error.message} Orchestration mutation request ID: ${requestId}.`, + data + ) +} diff --git a/src/cli/runtime/client-recovery.test.ts b/src/cli/runtime/client-recovery.test.ts new file mode 100644 index 00000000000..3da40edd5f0 --- /dev/null +++ b/src/cli/runtime/client-recovery.test.ts @@ -0,0 +1,130 @@ +import { createServer, type Server } from 'node:net' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import { ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS } from '../../shared/orchestration-timing-budgets' +import { MAX_TIMER_DELAY_MS } from '../../shared/timer-delay' +import { orchestrationMutationRecoveryError } from '../orchestration-mutation-recovery' +import { RuntimeClient, RuntimeRpcFailureError } from '../runtime-client' + +const servers = new Set() + +afterEach(async () => { + await Promise.all( + [...servers].map( + (server) => + new Promise((resolve) => { + server.close(() => resolve()) + }) + ) + ) + servers.clear() +}) + +describe('RuntimeClient orchestration recovery identity', () => { + it('rejects a worker-start timeout whose client grace would overflow timers', () => { + const client = new RuntimeClient(undefined, 60_000, null, null, 'orca') + const resolve = ( + client as unknown as { + resolveMethodTimeoutMs: (method: string, params?: unknown) => number + } + ).resolveMethodTimeoutMs.bind(client) + const maxValid = MAX_TIMER_DELAY_MS - ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS + expect(resolve('orchestration.workerStart', { timeoutMs: maxValid })).toBe(MAX_TIMER_DELAY_MS) + expect(() => resolve('orchestration.workerStart', { timeoutMs: maxValid + 1 })).toThrow( + 'derived timeout must be' + ) + }) + + it('attaches the request and exact retry identity to a real RPC failure response', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-recovery-')) + const endpoint = join(userDataPath, 'runtime.sock') + const server = createServer((socket) => { + let buffer = '' + socket.setEncoding('utf8') + socket.on('data', (chunk: string) => { + buffer += chunk + const newline = buffer.indexOf('\n') + if (newline === -1) { + return + } + const request = JSON.parse(buffer.slice(0, newline)) as { id: string; method: string } + const response = + request.method === 'status.get' + ? { + id: request.id, + ok: true, + result: { capabilities: [ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY] }, + _meta: { runtimeId: 'runtime-1' } + } + : { + id: request.id, + ok: false, + error: { + code: 'runtime_timeout', + message: 'request timed out', + data: { requestId: 'request_1', dispatchId: 'dispatch_1' } + }, + _meta: { runtimeId: 'runtime-1' } + } + socket.end(`${JSON.stringify(response)}\n`) + }) + }) + servers.add(server) + await new Promise((resolve) => server.listen(endpoint, resolve)) + writeFileSync( + join(userDataPath, 'orca-runtime.json'), + JSON.stringify({ + runtimeId: 'runtime-1', + pid: 1, + transports: [{ kind: 'unix', endpoint }], + authToken: 'token', + startedAt: 1 + }) + ) + + const client = new RuntimeClient(userDataPath, 500, null, null, 'orca') + try { + await client.call('orchestration.workerStart', { task: 'task_1' }) + throw new Error('expected worker-start failure') + } catch (error) { + expect(error).toBeInstanceOf(RuntimeRpcFailureError) + const recovered = orchestrationMutationRecoveryError(error) as { + data?: Record + response?: { id?: string; _meta?: { runtimeId?: string } } + } + expect(recovered).toBeInstanceOf(RuntimeRpcFailureError) + expect(recovered.response).toMatchObject({ + id: expect.any(String), + _meta: { runtimeId: 'runtime-1' } + }) + expect(recovered.data).toMatchObject({ + orchestrationRequestId: expect.any(String), + dispatchId: 'dispatch_1', + originalCommand: ['orca', 'orchestration', 'worker-start', '--task', 'task_1'], + recovery: { + queryCommand: [ + 'orca', + 'orchestration', + 'worker-show', + '--dispatch', + 'dispatch_1', + '--json' + ], + retryCommand: [ + 'orca', + 'orchestration', + 'worker-start', + '--task', + 'task_1', + '--retry-request', + expect.any(String) + ], + recoveryBlocked: false + } + }) + } + }) +}) diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts index 31c5b94e5c9..f9a855ae929 100644 --- a/src/cli/runtime/client.ts +++ b/src/cli/runtime/client.ts @@ -11,6 +11,7 @@ import { getDefaultUserDataPath, readMetadata } from './metadata' import { getCliStatus, projectRemoteAppStatus } from './status' import { sendRequest } from './transport' import { RuntimeClientError, RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types' +import { attachMutationRecovery } from './client-error-recovery' import { markEnvironmentUsed, resolveEnvironmentPairingOffer } from './environments' import { ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY, @@ -19,6 +20,16 @@ import { import { RemoteRuntimeCompatGate } from './remote-runtime-compat-gate' import { createOrchestrationCompatibilityEnvelope } from './orchestration-compatibility-envelope' import { getTimeoutMsParam, isWaitingCheck } from './runtime-request-timeout' +import { + isWorkerStartTimeoutWithinTimerLimit, + resolveWorkerStartClientTimeoutMs, + resolveWorkerStartReadinessTimeoutMs +} from '../../shared/orchestration-timing-budgets' +import { MAX_TIMER_DELAY_MS } from '../../shared/timer-delay' +import { + buildOrchestrationRecoveryCommand, + resolveOrchestrationCliExecutable +} from './orchestration-recovery-command' // Why: for long-poll methods the caller's method-level // `params.timeoutMs` is the inner waiter budget; we extend the client-side @@ -40,6 +51,8 @@ export class RuntimeClient { private readonly requestTimeoutMs: number private readonly remotePairing: PairingOffer | null private readonly environmentSelector: string | null + private readonly cliExecutable: string + private readonly originalArgs: readonly string[] | undefined private readonly remoteCompat: RemoteRuntimeCompatGate private orchestrationContractCheck: Promise | null = null private readonly orchestrationCompatibility = createOrchestrationCompatibilityEnvelope( @@ -53,11 +66,15 @@ export class RuntimeClient { userDataPath = getDefaultUserDataPath(), requestTimeoutMs = 60_000, remotePairingCode = process.env.ORCA_PAIRING_CODE ?? process.env.ORCA_REMOTE_PAIRING ?? null, - environmentSelector = process.env.ORCA_ENVIRONMENT ?? null + environmentSelector = process.env.ORCA_ENVIRONMENT ?? null, + cliExecutable = resolveOrchestrationCliExecutable(), + originalArgs?: readonly string[] ) { this.userDataPath = userDataPath this.requestTimeoutMs = requestTimeoutMs this.environmentSelector = environmentSelector + this.cliExecutable = cliExecutable + this.originalArgs = originalArgs ? [...originalArgs] : undefined this.remotePairing = resolveRemotePairing(userDataPath, remotePairingCode, environmentSelector) this.remoteCompat = new RemoteRuntimeCompatGate(userDataPath, environmentSelector) } @@ -79,6 +96,9 @@ export class RuntimeClient { const orchestrationRequestId = orchestrationMutation ? (options?.orchestrationRequestId ?? randomUUID()) : undefined + const originalCommand = orchestrationMutation + ? buildOrchestrationRecoveryCommand(method, params, this.cliExecutable, this.originalArgs) + : undefined const compatibilityEnvelope = method.startsWith('orchestration.') ? { ...this.orchestrationCompatibility, @@ -107,10 +127,14 @@ export class RuntimeClient { envelope }) } catch (error) { - throw attachMutationRecovery(error, orchestrationRequestId) + throw attachMutationRecovery(error, orchestrationRequestId, originalCommand) } if (response.ok === false) { - throw new RuntimeRpcFailureError(response) + throw attachMutationRecovery( + new RuntimeRpcFailureError(response), + orchestrationRequestId, + originalCommand + ) } if (this.environmentSelector) { markEnvironmentUsed(this.userDataPath, this.environmentSelector, { @@ -124,10 +148,14 @@ export class RuntimeClient { try { response = await sendRequest(metadata, method, params, effectiveTimeoutMs, envelope) } catch (error) { - throw attachMutationRecovery(error, orchestrationRequestId) + throw attachMutationRecovery(error, orchestrationRequestId, originalCommand) } if (response.ok === false) { - throw new RuntimeRpcFailureError(response) + throw attachMutationRecovery( + new RuntimeRpcFailureError(response), + orchestrationRequestId, + originalCommand + ) } return response } @@ -138,6 +166,18 @@ export class RuntimeClient { // to resolve. Without this, a 5 min wait would still die at the 60 s default. // See design doc ยง3.1. private resolveMethodTimeoutMs(method: string, params?: unknown): number { + if (method === 'orchestration.workerStart') { + const requestedValue = getTimeoutMsParam(params) + const requested = typeof requestedValue === 'number' ? requestedValue : Number(requestedValue) + if (!isWorkerStartTimeoutWithinTimerLimit(requested)) { + throw new RuntimeClientError( + 'invalid_argument', + `--timeout-ms is too large for worker-start transport grace; the derived timeout must be <= ${MAX_TIMER_DELAY_MS}ms.` + ) + } + const readiness = resolveWorkerStartReadinessTimeoutMs(requested) + return Math.max(resolveWorkerStartClientTimeoutMs(readiness), this.requestTimeoutMs) + } if ( (method === 'orchestration.check' && isWaitingCheck(params)) || method === 'terminal.wait' @@ -241,20 +281,6 @@ export class RuntimeClient { } } -function attachMutationRecovery(error: unknown, requestId: string | undefined): unknown { - if (!requestId || !(error instanceof RuntimeClientError)) { - return error - } - return new RuntimeClientError( - error.code, - `${error.message} Orchestration mutation request ID: ${requestId}.`, - { - ...(error.data && typeof error.data === 'object' ? error.data : {}), - orchestrationRequestId: requestId - } - ) -} - function throwDesktopActivationBlocked(): never { throw new RuntimeClientError( 'desktop_activation_blocked', diff --git a/src/cli/runtime/orchestration-recovery-command.test.ts b/src/cli/runtime/orchestration-recovery-command.test.ts new file mode 100644 index 00000000000..68c0a72c507 --- /dev/null +++ b/src/cli/runtime/orchestration-recovery-command.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest' +import { + buildOrchestrationRecoveryCommand, + resolveOrchestrationCliExecutable +} from './orchestration-recovery-command' + +describe('orchestration recovery command identity', () => { + it.each([ + ['configured dev', { ORCA_CLI_COMMAND: 'orca-dev' }, 'darwin', 'orca-dev'], + ['configured WSL', { ORCA_CLI_COMMAND: 'orca-ide' }, 'linux', 'orca-ide'], + ['dev checkout', { ORCA_DEV_REPO_ROOT: '/repo' }, 'darwin', 'orca-dev'], + ['packaged Linux', {}, 'linux', 'orca-ide'], + ['local macOS', {}, 'darwin', 'orca'], + ['local Windows', {}, 'win32', 'orca'] + ] as const)('resolves the %s CLI identity', (_name, env, platform, expected) => { + expect(resolveOrchestrationCliExecutable(env, platform)).toBe(expected) + }) + + it('reconstructs the keyed worker-start command from its RPC params', () => { + expect( + buildOrchestrationRecoveryCommand( + 'orchestration.workerStart', + { + task: 'task_1', + on: 'windows', + worktree: 'new-top-level', + timeoutMs: 90_000, + devMode: false + }, + 'orca' + ) + ).toEqual([ + 'orca', + 'orchestration', + 'worker-start', + '--task', + 'task_1', + '--on', + 'windows', + '--worktree', + 'new-top-level', + '--timeout-ms', + '90000' + ]) + }) + + it('preserves the selected executable and raw command arguments', () => { + expect( + buildOrchestrationRecoveryCommand( + 'orchestration.workerStart', + { task: 'task_1' }, + 'orca-dev', + [ + 'orchestration', + 'worker-start', + '--task', + 'task_1', + '--comment', + 'literal $(do-not-run) "quoted"', + '--json' + ] + ) + ).toEqual([ + 'orca-dev', + 'orchestration', + 'worker-start', + '--task', + 'task_1', + '--comment', + 'literal $(do-not-run) "quoted"', + '--json' + ]) + }) + + it.each([ + [ + 'gate-create', + 'orchestration.gateCreate', + ['orchestration', 'gate-create', '--task', 'task_1', '--question', 'ship?'] + ], + [ + 'worker-retain', + 'orchestration.workerRetain', + ['orchestration', 'worker-retain', '--dispatch', 'dispatch_1', '--json'] + ] + ] as const)('preserves exact raw argv for %s recovery', (_name, method, args) => { + expect(buildOrchestrationRecoveryCommand(method, {}, 'orca', args)).toEqual(['orca', ...args]) + }) + + it.each([ + ['split', ['orchestration', 'send', '--pairing-code', 'secret', '--subject', 'status']], + ['equals', ['orchestration', 'send', '--pairing-code=secret', '--subject', 'status']], + [ + 'dispatch split', + ['orchestration', 'send', '--dispatch-capability', 'secret', '--subject', 'status'] + ], + [ + 'dispatch equals', + ['orchestration', 'send', '--dispatch-capability=secret', '--subject', 'status'] + ] + ])('blocks %s credential argv instead of exposing it', (_name, args) => { + expect( + buildOrchestrationRecoveryCommand('orchestration.send', {}, 'orca', args) + ).toBeUndefined() + }) + + it('supports the explicit executable-first form', () => { + expect( + buildOrchestrationRecoveryCommand('orca-ide', 'orchestration.workerStop', { + dispatch: 'dispatch_1' + }) + ).toEqual(['orca-ide', 'orchestration', 'worker-stop', '--dispatch', 'dispatch_1']) + }) + + it('reconstructs worker-retain when raw argv is unavailable', () => { + expect( + buildOrchestrationRecoveryCommand('orchestration.workerRetain', { + dispatch: 'dispatch_1' + }) + ).toEqual([ + resolveOrchestrationCliExecutable(), + 'orchestration', + 'worker-retain', + '--dispatch', + 'dispatch_1' + ]) + }) + + it('does not fabricate identity for unsupported mutations or malformed params', () => { + expect(buildOrchestrationRecoveryCommand('orchestration.send', {})).toBeUndefined() + expect(buildOrchestrationRecoveryCommand('orchestration.workerStart', null)).toBeUndefined() + }) +}) diff --git a/src/cli/runtime/orchestration-recovery-command.ts b/src/cli/runtime/orchestration-recovery-command.ts new file mode 100644 index 00000000000..706b1656ac6 --- /dev/null +++ b/src/cli/runtime/orchestration-recovery-command.ts @@ -0,0 +1,102 @@ +export function resolveOrchestrationCliExecutable( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform +): string { + const configured = env.ORCA_CLI_COMMAND?.trim() + if (configured) { + return configured + } + if (env.ORCA_DEV_REPO_ROOT) { + return 'orca-dev' + } + return platform === 'linux' ? 'orca-ide' : 'orca' +} + +export function buildOrchestrationRecoveryCommand( + method: string, + params: unknown, + executable?: string, + originalArgs?: readonly string[] +): string[] | undefined +export function buildOrchestrationRecoveryCommand( + executable: string, + method: string, + params: unknown, + originalArgs?: readonly string[] +): string[] | undefined +export function buildOrchestrationRecoveryCommand( + methodOrExecutable: string, + paramsOrMethod: unknown, + executableOrParams?: unknown, + originalArgs?: readonly string[] +): string[] | undefined { + const legacyOrder = typeof paramsOrMethod === 'string' && executableOrParams !== undefined + const method = (legacyOrder ? paramsOrMethod : methodOrExecutable) as string + const params = legacyOrder ? executableOrParams : paramsOrMethod + const executable = legacyOrder + ? methodOrExecutable + : typeof executableOrParams === 'string' + ? executableOrParams + : resolveOrchestrationCliExecutable() + if (originalArgs && originalArgs.length > 0) { + const recoverableArgs = recoverableOrchestrationArgs(originalArgs) + return recoverableArgs ? [executable, ...recoverableArgs] : undefined + } + const command = + method === 'orchestration.workerStart' + ? 'worker-start' + : method === 'orchestration.workerStop' + ? 'worker-stop' + : method === 'orchestration.workerAbandon' + ? 'worker-abandon' + : method === 'orchestration.workerRelease' + ? 'worker-release' + : method === 'orchestration.workerRetain' + ? 'worker-retain' + : undefined + if (!command || params === null || typeof params !== 'object') { + return undefined + } + const record = params as Record + const requiredKey = command === 'worker-start' ? 'task' : 'dispatch' + if (typeof record[requiredKey] !== 'string' || record[requiredKey].length === 0) { + return undefined + } + const args = [executable, 'orchestration', command] + for (const [key, value] of Object.entries(record)) { + if (value === undefined || value === null || value === false) { + continue + } + const flag = `--${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}` + if (value === true) { + args.push(flag) + } else if (typeof value === 'string' || typeof value === 'number') { + args.push(flag, String(value)) + } + } + return args +} + +export function recoverableOrchestrationArgs(args: readonly string[]): string[] | undefined { + const containsCredential = args.some((arg) => + ['--pairing-code', '--dispatch-capability'].some( + (flag) => arg === flag || arg.startsWith(`${flag}=`) + ) + ) + if (containsCredential) { + return undefined + } + const result: string[] = [] + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] + if (arg === '--retry-request') { + index += 1 + continue + } + if (arg.startsWith('--retry-request=')) { + continue + } + result.push(arg) + } + return result +} diff --git a/src/cli/runtime/websocket-transport-error.test.ts b/src/cli/runtime/websocket-transport-error.test.ts new file mode 100644 index 00000000000..ab3df967f03 --- /dev/null +++ b/src/cli/runtime/websocket-transport-error.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest' +import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' +import { orchestrationMutationRecoveryError } from '../orchestration-mutation-recovery' + +const { sendRemoteRuntimeRequest, sendRemoteRuntimeRequestWithStatusPreflight } = vi.hoisted( + () => ({ + sendRemoteRuntimeRequest: vi.fn(), + sendRemoteRuntimeRequestWithStatusPreflight: vi.fn() + }) +) + +vi.mock('../../shared/remote-runtime-client', () => ({ + RemoteRuntimeClientError, + sendRemoteRuntimeRequest, + sendRemoteRuntimeRequestWithStatusPreflight +})) + +import { + sendWebSocketRequest, + sendWebSocketRequestWithStatusPreflight +} from './websocket-transport' + +const pairing = { + v: 2, + endpoint: 'ws://127.0.0.1:1', + deviceToken: 'token', + publicKeyB64: 'key' +} as never + +describe('CLI WebSocket transport error identity', () => { + it('preserves recovery data from one-shot transport errors', async () => { + sendRemoteRuntimeRequest.mockRejectedValueOnce( + new RemoteRuntimeClientError('runtime_timeout', 'timed out', { + data: { + orchestrationRequestId: 'request_1', + dispatchId: 'dispatch_1', + originalCommand: ['orca', 'orchestration', 'worker-start', '--task', 'task_1'] + } + }) + ) + + try { + await sendWebSocketRequest(pairing, 'orchestration.workerStart', {}, 100) + throw new Error('expected transport failure') + } catch (error) { + expect(error).toMatchObject({ + code: 'runtime_timeout', + data: { + orchestrationRequestId: 'request_1', + dispatchId: 'dispatch_1', + originalCommand: ['orca', 'orchestration', 'worker-start', '--task', 'task_1'] + } + }) + expect(orchestrationMutationRecoveryError(error)).toMatchObject({ + data: { + recovery: { + dispatchId: 'dispatch_1', + queryCommand: [ + 'orca', + 'orchestration', + 'worker-show', + '--dispatch', + 'dispatch_1', + '--json' + ], + retryCommand: [ + 'orca', + 'orchestration', + 'worker-start', + '--task', + 'task_1', + '--retry-request', + 'request_1' + ] + } + } + }) + } + }) + + it('preserves recovery data through status-preflight transport errors', async () => { + sendRemoteRuntimeRequestWithStatusPreflight.mockRejectedValueOnce( + new RemoteRuntimeClientError('runtime_timeout', 'timed out', { + data: { orchestrationRequestId: 'request_2' } + }) + ) + + await expect( + sendWebSocketRequestWithStatusPreflight( + pairing, + 'orchestration.workerStart', + {}, + 100, + () => {} + ) + ).rejects.toMatchObject({ + code: 'runtime_timeout', + data: { orchestrationRequestId: 'request_2' } + }) + }) +}) diff --git a/src/cli/runtime/websocket-transport.ts b/src/cli/runtime/websocket-transport.ts index d71c1cbd671..8c7696d9f7d 100644 --- a/src/cli/runtime/websocket-transport.ts +++ b/src/cli/runtime/websocket-transport.ts @@ -19,7 +19,7 @@ export async function sendWebSocketRequest( return await sendRemoteRuntimeRequest(pairing, method, params, timeoutMs, envelope) } catch (error) { if (error instanceof RemoteRuntimeClientError) { - throw new RuntimeClientError(error.code, error.message) + throw new RuntimeClientError(error.code, error.message, error.data) } throw error } @@ -44,7 +44,7 @@ export async function sendWebSocketRequestWithStatusPreflight( ) } catch (error) { if (error instanceof RemoteRuntimeClientError) { - throw new RuntimeClientError(error.code, error.message) + throw new RuntimeClientError(error.code, error.message, error.data) } throw error } diff --git a/src/main/runtime/agent-prompt-submission-verification.test.ts b/src/main/runtime/agent-prompt-submission-verification.test.ts index 4a802df8d33..ec7c2b85378 100644 --- a/src/main/runtime/agent-prompt-submission-verification.test.ts +++ b/src/main/runtime/agent-prompt-submission-verification.test.ts @@ -4,6 +4,7 @@ import { AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS, type AgentPromptActivity, isAgentPromptStalledError, + readAgentPromptWaitText, resolveAgentPromptEffectTimeoutMs, verifyAgentPromptSubmission } from './agent-prompt-submission-verification' @@ -23,6 +24,17 @@ function activity(overrides: Partial = {}): AgentPromptActi describe('agent prompt submission verification', () => { afterEach(() => vi.useRealTimers()) + it('reuses wait text while the PTY output sequence is unchanged', () => { + const cache: { outputSequence?: number; waitText?: string } = {} + const readWaitText = vi.fn(() => 'retained terminal tail') + + expect(readAgentPromptWaitText(cache, 7, readWaitText)).toBe('retained terminal tail') + expect(readAgentPromptWaitText(cache, 7, readWaitText)).toBe('retained terminal tail') + expect(readAgentPromptWaitText(cache, 8, readWaitText)).toBe('retained terminal tail') + + expect(readWaitText).toHaveBeenCalledTimes(2) + }) + it('accepts an observed working transition', async () => { vi.useFakeTimers() let current = activity() @@ -80,6 +92,21 @@ describe('agent prompt submission verification', () => { await rejected }) + it('accepts a working transition after the former five-second deadline', async () => { + vi.useFakeTimers() + let current = activity() + const verification = verifyAgentPromptSubmission({ + baseline: current, + readActivity: () => current + }) + + await vi.advanceTimersByTimeAsync(5_000) + current = activity({ workingSequence: 5, status: 'working' }) + await vi.advanceTimersByTimeAsync(50) + + await expect(verification).resolves.toBeUndefined() + }) + it('blocks when permission appears after submit', async () => { vi.useFakeTimers() let current = activity() @@ -207,7 +234,7 @@ describe('agent prompt submission verification', () => { await rejected }) - it('holds the longer hook window open past the default timeout', async () => { + it('holds the extended hook window open past the former hook timeout', async () => { vi.useFakeTimers() let current = activity() const verification = verifyAgentPromptSubmission({ @@ -216,7 +243,7 @@ describe('agent prompt submission verification', () => { timeoutMs: AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS }) - await vi.advanceTimersByTimeAsync(AGENT_PROMPT_EFFECT_TIMEOUT_MS + 1_000) + await vi.advanceTimersByTimeAsync(15_000 + 1_000) current = activity({ explicitWorkingStartedAt: 9_000, status: 'working' }) await vi.advanceTimersByTimeAsync(50) diff --git a/src/main/runtime/agent-prompt-submission-verification.ts b/src/main/runtime/agent-prompt-submission-verification.ts index e8373bbaf1f..5bd1a622321 100644 --- a/src/main/runtime/agent-prompt-submission-verification.ts +++ b/src/main/runtime/agent-prompt-submission-verification.ts @@ -1,12 +1,8 @@ +export { AGENT_PROMPT_EFFECT_TIMEOUT_MS } from '../../shared/orchestration-timing-budgets' +import { AGENT_PROMPT_EFFECT_TIMEOUT_MS } from '../../shared/orchestration-timing-budgets' import type { TuiAgent } from '../../shared/tui-agent' -export const AGENT_PROMPT_EFFECT_TIMEOUT_MS = 5_000 -// Why: these panes prove a turn start only through the out-of-process hook โ€” kimi has no synthetic -// title profile and codex suppresses the hook-driven working frame (synthesizeWorkingTitle: false), -// so the first proof lags Enter by agent startup, not by one TUI repaint. Capped so the worst case -// (8s render gate + this wait + chunked paste) still fits RELAY_TO_CLIENT_REQUEST_TIMEOUT_MS -// (30s, src/relay/dispatcher.ts), the budget a paired client's submission runs under. -export const AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS = 15_000 +export const AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS = AGENT_PROMPT_EFFECT_TIMEOUT_MS const AGENT_PROMPT_EFFECT_POLL_MS = 50 const HOOK_OBSERVED_TURN_START_AGENTS = new Set(['codex', 'kimi']) @@ -26,6 +22,11 @@ export type AgentPromptActivity = Readonly<{ status: 'working' | 'permission' | 'idle' | null }> +export type AgentPromptWaitTextCache = { + outputSequence?: number + waitText?: string +} + type AgentPromptVerificationOptions = { baseline: AgentPromptActivity readActivity: () => AgentPromptActivity @@ -51,6 +52,20 @@ export function isAgentPromptStalledError(error: unknown): boolean { ) } +export function readAgentPromptWaitText( + cache: AgentPromptWaitTextCache, + outputSequence: number, + readWaitText: () => string +): string { + if (cache.outputSequence === outputSequence && cache.waitText !== undefined) { + return cache.waitText + } + const waitText = readWaitText() + cache.outputSequence = outputSequence + cache.waitText = waitText + return waitText +} + export async function verifyAgentPromptSubmission( options: AgentPromptVerificationOptions ): Promise { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 495045821da..6afbdfda612 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -106,6 +106,8 @@ import { } from '../../shared/agent-prompt-injection' import { type AgentPromptActivity, + type AgentPromptWaitTextCache, + readAgentPromptWaitText, resolveAgentPromptEffectTimeoutMs, verifyAgentPromptSubmission } from './agent-prompt-submission-verification' @@ -6097,7 +6099,8 @@ export class OrcaRuntimeService { method: string, params: unknown, timeoutMs?: number, - envelope?: RuntimeOrchestrationEnvelope + envelope?: RuntimeOrchestrationEnvelope, + internal?: { contractVerified?: boolean } ): Promise { if (!this.orchestrationEnvironmentTransport) { throw new OrchestrationError( @@ -6105,7 +6108,7 @@ export class OrcaRuntimeService { 'Connected-server orchestration is unavailable in this runtime.' ) } - if (isOrchestrationMutation(method, params)) { + if (isOrchestrationMutation(method, params) && !internal?.contractVerified) { const statusResponse = await this.orchestrationEnvironmentTransport.call( selector, 'status.get', @@ -19530,7 +19533,8 @@ export class OrcaRuntimeService { private getTerminalAgentStatusSnapshot( handle: string, - expectedPtyId: string + expectedPtyId: string, + waitTextOverride?: string ): TerminalAgentStatusSnapshot { const pty = this.getLivePtyForHandle(handle) if (pty) { @@ -19550,11 +19554,9 @@ export class OrcaRuntimeService { { title: pty.pty.title, updatedAt: pty.pty.titleUpdatedAt }, { title: pty.pty.lastOscTitle, updatedAt: pty.pty.lastOscTitleAt } ) - const waitText = buildTerminalWaitText( - pty.pty.tailBuffer, - pty.pty.tailPartialLine, - pty.pty.preview - ) + const waitText = + waitTextOverride ?? + buildTerminalWaitText(pty.pty.tailBuffer, pty.pty.tailPartialLine, pty.pty.preview) return { waitText, waitBlockedAt: pty.pty.waitBlockedAt, @@ -19582,7 +19584,9 @@ export class OrcaRuntimeService { { title: this.tabs.get(leaf.tabId)?.title, updatedAt: 0 } ) return { - waitText: buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview), + waitText: + waitTextOverride ?? + buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview), waitBlockedAt: leaf.waitBlockedAt, title: title?.title ?? null, titleStatus: title ? detectAgentStatusFromTitle(title.title) : leaf.lastAgentStatus, @@ -20241,7 +20245,8 @@ export class OrcaRuntimeService { } assertAgentPromptRequestActive(options.signal) this.assertAgentPromptGeneration(ptyId, generation) - const baseline = this.getAgentPromptActivity(handle, ptyId) + const waitTextCache: AgentPromptWaitTextCache = {} + const baseline = this.getAgentPromptActivity(handle, ptyId, waitTextCache) this.assertAgentPromptPermissionSafe(permissionBaseline, baseline) const suffixWrote = this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT) ?? false if (!suffixWrote) { @@ -20249,7 +20254,7 @@ export class OrcaRuntimeService { } await verifyAgentPromptSubmission({ baseline, - readActivity: () => this.getAgentPromptActivity(handle, ptyId), + readActivity: () => this.getAgentPromptActivity(handle, ptyId, waitTextCache), timeoutMs: resolveAgentPromptEffectTimeoutMs(this.getPtyAgent(ptyId)), signal: options.signal }) @@ -20278,8 +20283,13 @@ export class OrcaRuntimeService { } } - private getAgentPromptActivity(handle: string, ptyId: string): AgentPromptActivity { + private getAgentPromptActivity( + handle: string, + ptyId: string, + waitTextCache?: AgentPromptWaitTextCache + ): AgentPromptActivity { this.assertLiveTerminalHandleTargetsPty(handle, ptyId) + const outputSequence = this.getPtyOutputSequence(ptyId) const explicitCandidate = this.getFreshExplicitAgentStatusForHandle(handle) const explicitFloor = this.agentPromptExplicitStatusFloorByPtyId.get(ptyId) const explicit = @@ -20297,7 +20307,14 @@ export class OrcaRuntimeService { (!explicit || lifecycle.updatedAt > explicit.updatedAt || (lifecycle.updatedAt === explicit.updatedAt && lifecycle.status === 'permission')) - const terminal = this.getTerminalAgentStatusSnapshot(handle, ptyId) + const waitText = waitTextCache + ? readAgentPromptWaitText( + waitTextCache, + outputSequence, + () => this.getTerminalAgentStatusSnapshot(handle, ptyId).waitText + ) + : undefined + const terminal = this.getTerminalAgentStatusSnapshot(handle, ptyId, waitText) const status = this.hasAuthoritativeTerminalWaitPermission(terminal, explicit, lifecycle) ? 'permission' : lifecycleIsNewer @@ -20312,7 +20329,7 @@ export class OrcaRuntimeService { // stateStartedAt, not updatedAt โ€” same-state tool/prompt pings refresh updatedAt and would // otherwise pass off an in-progress turn as a new one. explicitWorkingStartedAt: explicit?.status === 'working' ? explicit.stateStartedAt : null, - outputSequence: this.getPtyOutputSequence(ptyId), + outputSequence, status } } diff --git a/src/main/runtime/rpc/methods/orchestration-federated-attach-receipt.test.ts b/src/main/runtime/rpc/methods/orchestration-federated-attach-receipt.test.ts new file mode 100644 index 00000000000..400232fdffb --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-federated-attach-receipt.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { parseRemoteFederatedWorkerStartReceipt } from './orchestration-federated-attach-receipt' + +describe('remote federated worker start receipt', () => { + it.each([ + { name: 'missing runtime epoch', receipt: {} }, + { name: 'non-string runtime epoch', receipt: { runtimeEpoch: 42 } }, + { name: 'missing worktree id', receipt: { runtimeEpoch: 'epoch_remote' } }, + { + name: 'missing terminal handle', + receipt: { + runtimeEpoch: 'epoch_remote', + worktreeId: 'worktree_remote', + terminalHandle: undefined + } + } + ])('rejects a ready receipt with $name', ({ receipt }) => { + expect(() => + parseRemoteFederatedWorkerStartReceipt({ + dispatchId: 'ctx_remote', + state: 'ready', + terminalHandle: 'term_remote', + ...receipt + }) + ).toThrow('invalid ready receipt') + }) + + it('accepts a non-ready receipt without ready-only resources', () => { + expect( + parseRemoteFederatedWorkerStartReceipt({ + dispatchId: 'ctx_remote', + state: 'outcome_unknown' + }) + ).toMatchObject({ dispatchId: 'ctx_remote', state: 'outcome_unknown' }) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-federated-attach-receipt.ts b/src/main/runtime/rpc/methods/orchestration-federated-attach-receipt.ts new file mode 100644 index 00000000000..fc62a31788b --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-federated-attach-receipt.ts @@ -0,0 +1,53 @@ +import type { OrchestrationWorkerLaunchReceipt } from './orchestration-worker-launch-preferences' + +export type RemoteFederatedWorkerStartReceipt = { + dispatchId: string + state: string + runtimeEpoch?: string + worktreeId?: string + terminalHandle?: string + setup?: { state: string } + launch?: OrchestrationWorkerLaunchReceipt + effects?: unknown[] + residualResources?: unknown[] + failedStage?: string + lastError?: string +} + +export type ReadyRemoteFederatedWorkerStartReceipt = RemoteFederatedWorkerStartReceipt & { + state: 'ready' + runtimeEpoch: string + worktreeId: string + terminalHandle: string +} + +export function parseRemoteFederatedWorkerStartReceipt( + value: unknown +): RemoteFederatedWorkerStartReceipt { + if (!isRecord(value) || !isNonEmptyString(value.dispatchId) || !isNonEmptyString(value.state)) { + throw new Error('The worker server returned an invalid attachment receipt.') + } + if ( + value.state === 'ready' && + (!isNonEmptyString(value.runtimeEpoch) || + !isNonEmptyString(value.worktreeId) || + !isNonEmptyString(value.terminalHandle)) + ) { + throw new Error('The worker server returned an invalid ready receipt.') + } + return value as RemoteFederatedWorkerStartReceipt +} + +export function isReadyRemoteFederatedWorkerStartReceipt( + receipt: RemoteFederatedWorkerStartReceipt +): receipt is ReadyRemoteFederatedWorkerStartReceipt { + return receipt.state === 'ready' +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 +} diff --git a/src/main/runtime/rpc/methods/orchestration-federated-worker-start-receipt.test.ts b/src/main/runtime/rpc/methods/orchestration-federated-worker-start-receipt.test.ts new file mode 100644 index 00000000000..270c5f6a322 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-federated-worker-start-receipt.test.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY, + ORCHESTRATION_FEDERATION_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationDb } from '../../orchestration/db' +import { startFederatedWorker } from './orchestration-federated-worker-start' + +describe('federated worker start receipt validation', () => { + const databases: OrchestrationDb[] = [] + + afterEach(() => { + for (const database of databases.splice(0)) { + database.close() + } + }) + + it('marks a malformed ready receipt outcome unknown without persisting resources', async () => { + const db = new OrchestrationDb(':memory:') + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + databases.push(db) + const run = db.createRun({ + objective: 'federated worker', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: 'tab_coord:leaf_coord' + }) + const task = db.createTask({ spec: 'remote work', runId: run.id }) + vi.spyOn(runtime, 'resolveOrchestrationWorkerServer').mockReturnValue({ + environmentId: 'environment_remote', + name: 'remote', + peerFingerprint: 'remote_peer' + }) + vi.spyOn(runtime, 'callOrchestrationWorkerServer').mockImplementation( + async (_environmentId, method, params) => { + if (method === 'status.get') { + return { + capabilities: [ + ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY, + ORCHESTRATION_FEDERATION_RUNTIME_CAPABILITY + ] + } + } + return { + dispatchId: (params as { dispatchId: string }).dispatchId, + state: 'ready', + worktreeId: 'worktree_remote', + terminalHandle: 'term_remote' + } + } + ) + + const result = (await startFederatedWorker({ + params: { + task: task.id, + from: 'term_coord', + on: 'remote', + worktree: 'id:worktree_remote', + terminal: 'term_remote' + }, + runtime, + db, + runId: run.id, + task, + orchestrationMutation: { + callerFingerprint: 'caller', + requestId: 'remote_start', + method: 'orchestration.workerStart', + payloadHash: 'payload' + } + })) as { dispatchId: string; state: string; lastError?: string } + + expect(result).toMatchObject({ + state: 'outcome_unknown', + lastError: 'The worker server returned an invalid ready receipt.' + }) + expect(db.getFederatedDispatch(result.dispatchId)).toMatchObject({ + remote_runtime_epoch: null, + remote_worktree_id: null, + remote_terminal_handle: null + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-federated-worker-start-unknown-receipt.ts b/src/main/runtime/rpc/methods/orchestration-federated-worker-start-unknown-receipt.ts new file mode 100644 index 00000000000..f0c7354c3e5 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-federated-worker-start-unknown-receipt.ts @@ -0,0 +1,25 @@ +import type { OrchestrationWorkerLaunchReceipt } from './orchestration-worker-launch-preferences' + +export function federatedUnknownReceipt( + worker: { dispatch_id: string; state: string; stage: string; last_error: string | null }, + taskId: string, + serverName: string, + launch: OrchestrationWorkerLaunchReceipt +): unknown { + return { + taskId, + dispatchId: worker.dispatch_id, + state: 'outcome_unknown', + stage: worker.stage, + server: { name: serverName }, + launch, + failedStage: worker.stage, + lastError: worker.last_error, + effects: [], + residualResources: [], + nextCommands: [ + `orca orchestration worker-show --dispatch ${worker.dispatch_id} --json`, + `orca orchestration worker-abandon --dispatch ${worker.dispatch_id} --json` + ] + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-federated-worker-start.ts b/src/main/runtime/rpc/methods/orchestration-federated-worker-start.ts index 28c6530d2a0..0f6bd44f67f 100644 --- a/src/main/runtime/rpc/methods/orchestration-federated-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration-federated-worker-start.ts @@ -17,11 +17,17 @@ import { assertWorkerLaunchPreferencesRuntimeSupported, assertWorkerLaunchPreferencesCreateTerminal, createPendingWorkerLaunchReceipt, - resolveFederatedWorkerLaunchReceipt, - type OrchestrationWorkerLaunchReceipt + resolveFederatedWorkerLaunchReceipt } from './orchestration-worker-launch-preferences' import { validateFederatedWorkerStartPlacement } from './orchestration-worker-start-validation' +import { resolveFederatedWorkerStartBudgets } from './orchestration-worker-start-budgets' import { resolveDispatchCreator } from './orchestration-dispatch-creator' +import { + isReadyRemoteFederatedWorkerStartReceipt, + parseRemoteFederatedWorkerStartReceipt +} from './orchestration-federated-attach-receipt' +import { isWorkerStartTimeoutWithinTimerLimit } from '../../../../shared/orchestration-timing-budgets' +import { federatedUnknownReceipt } from './orchestration-federated-worker-start-unknown-receipt' export async function startFederatedWorker(args: { params: WorkerStartInput @@ -37,6 +43,12 @@ export async function startFederatedWorker(args: { } }): Promise { const { params, runtime, db, task, runId, orchestrationMutation } = args + if (!isWorkerStartTimeoutWithinTimerLimit(params.timeoutMs)) { + throw new OrchestrationError( + 'invalid_argument', + '--timeout-ms is too large for worker-start transport grace; the derived timeout must fit within the timer limit.' + ) + } if (!orchestrationMutation) { throw new OrchestrationError( 'invalid_argument', @@ -58,13 +70,13 @@ export async function startFederatedWorker(args: { model: params.model, effort: params.effort }) - const server = runtime.resolveOrchestrationWorkerServer(params.on as string) + const budgets = resolveFederatedWorkerStartBudgets(params.timeoutMs) const status = (await runtime.callOrchestrationWorkerServer( server.environmentId, 'status.get', undefined, - params.timeoutMs + budgets.preflightTimeoutMs )) as RuntimeStatus if (!status.capabilities?.includes(ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY)) { throw new OrchestrationError( @@ -112,7 +124,7 @@ export async function startFederatedWorker(args: { terminal: params.terminal ?? null, agent: params.agent ?? null, launch: requestedLaunch, - timeoutMs: params.timeoutMs ?? 60_000, + timeoutMs: budgets.readinessTimeoutMs, setup: setupDecision, setupSource: createsWorktree ? params.setup @@ -131,39 +143,42 @@ export async function startFederatedWorker(args: { }) db.recordWorkerStage({ dispatchId: started.dispatch.id, stage: 'remote_attach_requested' }) try { - const remote = (await runtime.callOrchestrationWorkerServer( - server.environmentId, - 'orchestration.federationAttachStart', - { - dispatchId: started.dispatch.id, - taskId: task.id, - taskSpec: task.spec, - // Carry the home dispatch depth across the federation boundary so a - // remote worker cannot be mistaken for a root when it dispatches again. - depth: started.dispatch.depth, - protocolVersion: federationProtocolVersion, - worktree, - name: params.name, - repo: params.repo, - baseBranch: params.baseBranch, - displayName: params.displayName, - comment: params.comment, - setup: createsWorktree ? (params.setup ?? 'run') : undefined, - setupSource: createsWorktree - ? params.setup - ? 'explicit_request' - : 'orchestration_default' - : undefined, - terminal: params.terminal, - agent: params.agent, - model: params.model, - effort: params.effort, - timeoutMs: params.timeoutMs, - devMode: params.devMode - }, - (params.timeoutMs ?? 60_000) + 15_000, - { orchestrationRequestId: orchestrationMutation.requestId } - )) as RemoteStartReceipt + const remote = parseRemoteFederatedWorkerStartReceipt( + await runtime.callOrchestrationWorkerServer( + server.environmentId, + 'orchestration.federationAttachStart', + { + dispatchId: started.dispatch.id, + taskId: task.id, + taskSpec: task.spec, + // Carry the home dispatch depth across the federation boundary so a + // remote worker cannot be mistaken for a root when it dispatches again. + depth: started.dispatch.depth, + protocolVersion: federationProtocolVersion, + worktree, + name: params.name, + repo: params.repo, + baseBranch: params.baseBranch, + displayName: params.displayName, + comment: params.comment, + setup: createsWorktree ? (params.setup ?? 'run') : undefined, + setupSource: createsWorktree + ? params.setup + ? 'explicit_request' + : 'orchestration_default' + : undefined, + terminal: params.terminal, + agent: params.agent, + model: params.model, + effort: params.effort, + timeoutMs: budgets.readinessTimeoutMs, + devMode: params.devMode + }, + budgets.attachDeadlineMs, + { orchestrationRequestId: orchestrationMutation.requestId }, + { contractVerified: true } + ) + ) if (remote.dispatchId !== started.dispatch.id) { throw new OrchestrationError( 'resource_server_mismatch', @@ -175,7 +190,7 @@ export async function startFederatedWorker(args: { requestedLaunch, remote.state === 'ready' ) - if (remote.state === 'ready' && remote.worktreeId && remote.terminalHandle) { + if (isReadyRemoteFederatedWorkerStartReceipt(remote)) { db.updateFederatedDispatchResources({ dispatchId: started.dispatch.id, remoteRuntimeEpoch: remote.runtimeEpoch, @@ -202,7 +217,7 @@ export async function startFederatedWorker(args: { server: { environmentId: server.environmentId, name: server.name }, setup: remote.setup, launch, - timeoutMs: params.timeoutMs ?? 60_000, + timeoutMs: budgets.readinessTimeoutMs, effects: remote.effects ?? [], residualResources: remote.residualResources ?? [] } @@ -257,20 +272,6 @@ export async function startFederatedWorker(args: { } } -type RemoteStartReceipt = { - dispatchId: string - state: string - runtimeEpoch: string - worktreeId?: string - terminalHandle?: string - setup?: { state: string } - launch?: OrchestrationWorkerLaunchReceipt - effects?: unknown[] - residualResources?: unknown[] - failedStage?: string - lastError?: string -} - function isKnownRemoteStartFailure(code: string): boolean { return [ 'invalid_argument', @@ -280,27 +281,3 @@ function isKnownRemoteStartFailure(code: string): boolean { 'capability_unsupported' ].includes(code) } - -function federatedUnknownReceipt( - worker: { dispatch_id: string; state: string; stage: string; last_error: string | null }, - taskId: string, - serverName: string, - launch: OrchestrationWorkerLaunchReceipt -): unknown { - return { - taskId, - dispatchId: worker.dispatch_id, - state: 'outcome_unknown', - stage: worker.stage, - server: { name: serverName }, - launch, - failedStage: worker.stage, - lastError: worker.last_error, - effects: [], - residualResources: [], - nextCommands: [ - `orca orchestration worker-show --dispatch ${worker.dispatch_id} --json`, - `orca orchestration worker-abandon --dispatch ${worker.dispatch_id} --json` - ] - } -} diff --git a/src/main/runtime/rpc/methods/orchestration-federation.ts b/src/main/runtime/rpc/methods/orchestration-federation.ts index 4b48632c39a..421bf51586e 100644 --- a/src/main/runtime/rpc/methods/orchestration-federation.ts +++ b/src/main/runtime/rpc/methods/orchestration-federation.ts @@ -18,6 +18,10 @@ import { import { FederationAttachStartParams } from './orchestration-federation-start-schema' import { failFederatedAttachmentWithReceipt } from './orchestration-federation-start-receipt' import { prepareFederationAttachmentWorkerStart } from './orchestration-worker-start-validation' +import { + isWorkerStartTimeoutWithinTimerLimit, + resolveWorkerStartReadinessTimeoutMs +} from '../../../../shared/orchestration-timing-budgets' export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [ defineMethod({ @@ -30,6 +34,13 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [ 'Federated worker attachment requires a durable retry request.' ) } + if (!isWorkerStartTimeoutWithinTimerLimit(params.timeoutMs)) { + throw new OrchestrationError( + 'invalid_argument', + '--timeout-ms is too large for worker-start transport grace; the derived timeout must fit within the timer limit.' + ) + } + const readinessTimeoutMs = resolveWorkerStartReadinessTimeoutMs(params.timeoutMs) if (params.worktree === 'current' || params.worktree === 'new-child') { throw new OrchestrationError( 'invalid_argument', @@ -198,7 +209,7 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [ failedStage = 'agent_readiness' const wait = await runtime.waitForTerminal(terminalHandle, { condition: 'tui-idle', - timeoutMs: params.timeoutMs ?? 60_000 + timeoutMs: readinessTimeoutMs }) persistFederatedSetupWaitOutcome({ ...setupStage, wait }) if (!wait.satisfied) { diff --git a/src/main/runtime/rpc/methods/orchestration-migration-behavior.test.ts b/src/main/runtime/rpc/methods/orchestration-migration-behavior.test.ts index 1d562e1ee5a..4e516e32fd3 100644 --- a/src/main/runtime/rpc/methods/orchestration-migration-behavior.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-migration-behavior.test.ts @@ -1,10 +1,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' import { ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY, ORCHESTRATION_FEDERATION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { OrcaRuntimeService } from '../../orca-runtime' import { OrchestrationDb } from '../../orchestration/db' +import type { OrchestrationEnvironmentTransport } from '../../orchestration/environment-transport' import { RpcDispatcher } from '../dispatcher' import { ORCHESTRATION_METHODS } from './orchestration' import { startFederatedWorker } from './orchestration-federated-worker-start' @@ -246,4 +248,83 @@ describe('orchestration migration behavior', () => { expect(db.getTask(task.id)?.status).toBe('ready') expect(db.getDispatchContext(task.id)).toBeUndefined() }) + + it('runs one real contract preflight before federated attach', async () => { + const db = new OrchestrationDb(':memory:') + const run = db.createRun({ + objective: 'federated worker', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: 'tab_coord:tab' + }) + const task = db.createTask({ spec: 'remote work', runId: run.id }) + databases.push(db) + + const calls: { method: string; params: unknown }[] = [] + const runtime = new OrcaRuntimeService(null, undefined, { + orchestrationEnvironmentTransport: { + resolve: () => ({ + environmentId: 'environment_windows', + name: 'windows', + peerFingerprint: 'windows_peer' + }), + call: async (_selector, method, params) => { + calls.push({ method, params }) + if (method === 'status.get') { + return { + id: 'status', + ok: true, + result: { + ...runtime.getStatus(), + capabilities: [ + ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY, + ORCHESTRATION_FEDERATION_RUNTIME_CAPABILITY + ] + }, + _meta: { runtimeId: runtime.getRuntimeId() } + } satisfies RuntimeRpcResponse + } + return { + id: 'attach', + ok: true, + result: { + dispatchId: (params as { dispatchId: string }).dispatchId, + state: 'outcome_unknown', + runtimeEpoch: 'worker-runtime' + }, + _meta: { runtimeId: runtime.getRuntimeId() } + } satisfies RuntimeRpcResponse + } + } satisfies OrchestrationEnvironmentTransport + }) + runtime.setOrchestrationDb(db) + + const result = await startFederatedWorker({ + params: { + task: task.id, + from: 'term_coord', + on: 'windows', + worktree: 'new-top-level', + repo: 'id:windows-repo', + name: 'remote-work', + agent: 'codex' + }, + runtime, + db, + runId: run.id, + task, + orchestrationMutation: { + callerFingerprint: 'caller', + requestId: 'remote_start', + method: 'orchestration.workerStart', + payloadHash: 'payload' + } + }) + + expect(result).toMatchObject({ state: 'outcome_unknown' }) + expect(calls.map(({ method }) => method)).toEqual([ + 'status.get', + 'orchestration.federationAttachStart' + ]) + expect(calls.filter(({ method }) => method === 'status.get')).toHaveLength(1) + }) }) diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-budgets.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-budgets.test.ts new file mode 100644 index 00000000000..6e600dd5177 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-budgets.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { MAX_TIMER_DELAY_MS } from '../../../../shared/timer-delay' +import { + ORCHESTRATION_READINESS_TIMEOUT_MS, + ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS +} from '../../../../shared/orchestration-timing-budgets' +import { resolveFederatedWorkerStartBudgets } from './orchestration-worker-start-budgets' + +describe('worker-start transport budgets', () => { + it('keeps the exact maximum derived timeout representable', () => { + const timeoutMs = MAX_TIMER_DELAY_MS - ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS + const budgets = resolveFederatedWorkerStartBudgets(timeoutMs, 1_000) + expect(budgets.outerDeadlineMs).toBe(1_000 + MAX_TIMER_DELAY_MS) + }) + + it('does not silently shorten an overflowing request', () => { + const timeoutMs = MAX_TIMER_DELAY_MS - ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS + 1 + expect(() => resolveFederatedWorkerStartBudgets(timeoutMs, 1_000)).toThrow( + 'derived timeout must fit' + ) + }) + + it('normalizes non-positive requests to the ordinary readiness default', () => { + const expectedOuterDeadline = + 1_000 + ORCHESTRATION_READINESS_TIMEOUT_MS + ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS + expect(resolveFederatedWorkerStartBudgets(0, 1_000).readinessTimeoutMs).toBe( + ORCHESTRATION_READINESS_TIMEOUT_MS + ) + expect(resolveFederatedWorkerStartBudgets(0, 1_000).outerDeadlineMs).toBe(expectedOuterDeadline) + expect(resolveFederatedWorkerStartBudgets(-1, 1_000).outerDeadlineMs).toBe( + expectedOuterDeadline + ) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-budgets.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-budgets.ts new file mode 100644 index 00000000000..de5f3db40d9 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-budgets.ts @@ -0,0 +1,32 @@ +import { + ORCHESTRATION_CONTRACT_PREFLIGHT_TIMEOUT_MS, + isWorkerStartTimeoutWithinTimerLimit, + resolveFederationAttachDeadlineMs, + resolveWorkerStartReadinessTimeoutMs, + resolveWorkerStartClientTimeoutMs +} from '../../../../shared/orchestration-timing-budgets' + +export function resolveFederatedWorkerStartBudgets( + timeoutMs: number | undefined, + nowMs = Date.now() +) { + if (!isWorkerStartTimeoutWithinTimerLimit(timeoutMs)) { + throw new Error( + '--timeout-ms is too large for worker-start transport grace; the derived timeout must fit within the timer limit.' + ) + } + // Normalize non-positive/invalid values exactly as the client does so local + // and federated transports share the ordinary default contract. + const readinessTimeoutMs = resolveWorkerStartReadinessTimeoutMs(timeoutMs) + const outerDeadlineMs = nowMs + resolveWorkerStartClientTimeoutMs(readinessTimeoutMs) + return { + readinessTimeoutMs, + outerDeadlineMs, + preflightTimeoutMs: ORCHESTRATION_CONTRACT_PREFLIGHT_TIMEOUT_MS, + attachDeadlineMs: resolveFederationAttachDeadlineMs({ + readinessTimeoutMs, + outerDeadlineMs, + nowMs + }) + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-workers.ts b/src/main/runtime/rpc/methods/orchestration-workers.ts index 0b8891481cd..61271525939 100644 --- a/src/main/runtime/rpc/methods/orchestration-workers.ts +++ b/src/main/runtime/rpc/methods/orchestration-workers.ts @@ -22,6 +22,10 @@ import { failWorkerStartWithReceipt } from './orchestration-worker-start-receipt import { prepareLocalWorkerStart } from './orchestration-worker-start-validation' import { resolveDispatchCreator } from './orchestration-dispatch-creator' import { resolveOrchestrationCaller } from './orchestration-run-scope' +import { + isWorkerStartTimeoutWithinTimerLimit, + resolveWorkerStartReadinessTimeoutMs +} from '../../../../shared/orchestration-timing-budgets' export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ defineMethod({ @@ -31,6 +35,13 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ params, { runtime, orchestrationMutation, orchestrationCompatibilityEvidence } ) => { + if (!isWorkerStartTimeoutWithinTimerLimit(params.timeoutMs)) { + throw new OrchestrationError( + 'invalid_argument', + `--timeout-ms is too large for worker-start transport grace; the derived timeout must fit within the timer limit.` + ) + } + const readinessTimeoutMs = resolveWorkerStartReadinessTimeoutMs(params.timeoutMs) const db = runtime.getOrchestrationDb() // Why: worker-start was the only Run-scoped verb that skipped this, so a // declared --from could name someone else's pane and inherit their depth. @@ -111,7 +122,7 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ terminal: params.terminal ?? null, agent: agent ?? null, launch: launch.receipt, - timeoutMs: params.timeoutMs ?? 60_000, + timeoutMs: readinessTimeoutMs, setup: createsWorktree ? (params.setup ?? 'run') : 'not_applicable', setupSource: createsWorktree ? params.setup @@ -208,7 +219,7 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ failedStage = 'agent_readiness' const wait = await runtime.waitForTerminal(terminalHandle, { condition: 'tui-idle', - timeoutMs: params.timeoutMs ?? 60_000 + timeoutMs: readinessTimeoutMs }) persistWorkerSetupWaitOutcome({ ...setupStage, wait }) if (!wait.satisfied) { @@ -268,7 +279,7 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ stage: worker.stage, setup: setupReceipt, launch: launch.receipt, - timeoutMs: params.timeoutMs ?? 60_000, + timeoutMs: readinessTimeoutMs, effects, residualResources: [], ...(terminalRevealWarning ? { warning: terminalRevealWarning } : {}) diff --git a/src/main/runtime/runtime-rpc-long-poll-transport.test.ts b/src/main/runtime/runtime-rpc-long-poll-transport.test.ts index 43279ea7931..74dbe9e0cde 100644 --- a/src/main/runtime/runtime-rpc-long-poll-transport.test.ts +++ b/src/main/runtime/runtime-rpc-long-poll-transport.test.ts @@ -6,7 +6,7 @@ import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from './orca-runtime' import { OrchestrationDb } from './orchestration/db' import { readRuntimeMetadata } from './runtime-metadata' -import { OrcaRuntimeRpcServer } from './runtime-rpc' +import { classifyRuntimeLongPoll, OrcaRuntimeRpcServer } from './runtime-rpc' import { sendRequest, openFramedSession, @@ -33,6 +33,36 @@ vi.mock('../git/worktree', () => { }) describe('OrcaRuntimeRpcServer', () => { + it('classifies worker-start as a keepalive-backed long poll', () => { + expect( + classifyRuntimeLongPoll({ + id: 'req_worker_start', + authToken: 'token', + method: 'orchestration.workerStart', + params: { task: 'task_1', timeoutMs: 60_000 } + }) + ).toBe('wait') + }) + + it('keeps agent-prompt submission sockets alive during verification', () => { + expect( + classifyRuntimeLongPoll({ + id: 'req_prompt', + authToken: 'token', + method: 'terminal.send', + params: { agentPrompt: true } + }) + ).toBe('wait') + expect( + classifyRuntimeLongPoll({ + id: 'req_direct', + authToken: 'token', + method: 'terminal.send', + params: { agentPrompt: false } + }) + ).toBeNull() + }) + it('rejects oversized RPC frames instead of buffering them indefinitely', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) const runtime = new OrcaRuntimeService() @@ -74,6 +104,45 @@ describe('OrcaRuntimeRpcServer', () => { // Exercise the real socket (not a mock) so we catch buffer/flush regressions // that a unit-level test would miss. describe('long-poll transport (ยง3.1)', () => { + it('emits keepalives while orchestration.workerStart blocks', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + keepaliveIntervalMs: 30 + }) + const dispatch = server['dispatcher'] + vi.spyOn(dispatch, 'dispatch').mockImplementation(async (request) => { + await sleep(120) + return { + id: request.id, + ok: true, + result: { dispatch: { id: 'dispatch_1' } }, + _meta: { runtimeId: runtime.getRuntimeId() } + } + }) + await server.start() + + try { + const metadata = readRuntimeMetadata(userDataPath) + const session = openFramedSession(metadata!.transports[0]!.endpoint, { + id: 'req_worker_start', + authToken: metadata!.authToken, + method: 'orchestration.workerStart', + params: { task: 'task_1', timeoutMs: 60_000 } + }) + await session.done + + expect( + session.frames.filter((frame) => frame._keepalive === true).length + ).toBeGreaterThanOrEqual(2) + expect(session.frames.filter((frame) => frame.ok !== undefined)).toHaveLength(1) + } finally { + await server.stop() + } + }) + it('emits keepalive frames while a check --wait handler blocks', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) const runtime = new OrcaRuntimeService() @@ -242,6 +311,45 @@ describe('OrcaRuntimeRpcServer', () => { } }) + it('emits keepalive frames while agent-prompt verification blocks', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + keepaliveIntervalMs: 30 + }) + const dispatch = server['dispatcher'] + vi.spyOn(dispatch, 'dispatch').mockImplementation(async (request) => { + await sleep(120) + return { + id: request.id, + ok: true, + result: { send: { accepted: true } }, + _meta: { runtimeId: runtime.getRuntimeId() } + } + }) + await server.start() + + try { + const metadata = readRuntimeMetadata(userDataPath) + const session = openFramedSession(metadata!.transports[0]!.endpoint, { + id: 'req_prompt', + authToken: metadata!.authToken, + method: 'terminal.send', + params: { agentPrompt: true } + }) + await session.done + + expect( + session.frames.filter((frame) => frame._keepalive === true).length + ).toBeGreaterThanOrEqual(2) + expect(session.frames.filter((frame) => frame.ok !== undefined)).toHaveLength(1) + } finally { + await server.stop() + } + }) + it('releases terminal.wait long-poll slot when the client closes mid-wait', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) const runtime = new OrcaRuntimeService() diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index b28619b2eed..a6a2d91763c 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -466,12 +466,28 @@ export type RuntimeLongPollClass = 'ask' | 'browser-host' | 'wait' // Why: single classifier for long-poll requests (handlers that block on an external event), shared by counter/abort/keepalive. See ยง3.1. export function classifyRuntimeLongPoll(request: RpcRequest): RuntimeLongPollClass | null { + // Worker start waits for readiness and then verifies the submitted prompt; + // the complete operation can run for 90โ€“110s. Keep every local transport + // (Unix sockets and Windows named pipes) alive for that long poll. + if (request.method === 'orchestration.workerStart') { + return 'wait' + } if (request.method === 'browser.clientHost.attach') { return 'browser-host' } if (request.method === 'terminal.wait') { return 'wait' } + // Agent-prompt submission waits for the PTY's lifecycle transition (up to + // the verification budget); keep the local socket alive for that wait. + if ( + request.method === 'terminal.send' && + typeof request.params === 'object' && + request.params !== null && + (request.params as { agentPrompt?: unknown }).agentPrompt === true + ) { + return 'wait' + } // Why: orchestration.ask blocks unconditionally (default 600 s) holding the // RPC open until a reply lands or the deadline passes, so it needs the same // keepalive as check --wait or the 30 s socket idle timer tears it down. It diff --git a/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts b/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts index b4d7ca07fef..e66cbd77507 100644 --- a/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts +++ b/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts @@ -106,11 +106,29 @@ describe('buildHostCliEnv', () => { expect(env.PATH).toBe('/host/bin') expect(env.ORCA_USER_DATA_PATH).toBe('/host/user-data') expect(env.ORCA_CLI_CWD).toBe('/home/alice/wt/sub') + expect(env.ORCA_CLI_COMMAND).toBe('orca') expect(env.ELECTRON_RUN_AS_NODE).toBe('1') expect(env.NODE_OPTIONS).toBeUndefined() expect(env.ORCA_NODE_OPTIONS).toBe('--inspect') }) + it.each([ + ['dev host', { ORCA_DEV_REPO_ROOT: '/repo', ORCA_CLI_COMMAND: 'orca-dev' }], + ['packaged Linux host', { ORCA_CLI_COMMAND: 'orca-ide' }], + ['local host', {}], + ['WSL host', { WSL_DISTRO_NAME: 'Ubuntu', ORCA_CLI_COMMAND: 'orca-ide' }], + ['Windows host', { ComSpec: 'C:\\Windows\\System32\\cmd.exe' }] + ])('pins %s recovery to the remote shim', (_name, hostEnv) => { + const env = buildHostCliEnv({ + hostEnv, + remoteEnv: { ORCA_CLI_COMMAND: 'untrusted-remote-command' }, + userDataPath: '/host/user-data', + remoteCwd: '/srv/repo' + }) + + expect(env.ORCA_CLI_COMMAND).toBe('orca') + }) + it('namespaces identical remote artifact paths by stable SSH target', () => { const artifactInput = { sourceKey: '/srv/repo/report.html', diff --git a/src/main/ssh/ssh-remote-cli-host-passthrough.ts b/src/main/ssh/ssh-remote-cli-host-passthrough.ts index 7749584a61a..05a785c8482 100644 --- a/src/main/ssh/ssh-remote-cli-host-passthrough.ts +++ b/src/main/ssh/ssh-remote-cli-host-passthrough.ts @@ -159,6 +159,8 @@ export function buildHostCliEnv(args: { // subprocess cwd cannot be chdir'd there; ORCA_CLI_CWD carries it for // cwd-based selectors like `--worktree active`. env.ORCA_CLI_CWD = args.remoteCwd + // Why: recovery commands run on the SSH execution host through its relay shim. + env.ORCA_CLI_COMMAND = 'orca' // Why: same node-mode hygiene as the shipped CLI launchers โ€” stash and clear // NODE_OPTIONS so Electron's node bootstrap does not inherit them. env.ORCA_NODE_OPTIONS = args.hostEnv.NODE_OPTIONS ?? '' diff --git a/src/shared/orchestration-timing-budgets.test.ts b/src/shared/orchestration-timing-budgets.test.ts new file mode 100644 index 00000000000..c7cdea5221c --- /dev/null +++ b/src/shared/orchestration-timing-budgets.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_PROMPT_EFFECT_TIMEOUT_MS, + ORCHESTRATION_CONTRACT_PREFLIGHT_TIMEOUT_MS, + ORCHESTRATION_FEDERATION_ATTACH_GRACE_MS, + ORCHESTRATION_READINESS_TIMEOUT_MS, + ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS, + isWorkerStartTimeoutWithinTimerLimit, + resolveFederationAttachDeadlineMs, + resolveFederationAttachTimeoutMs, + resolveWorkerStartClientTimeoutMs +} from './orchestration-timing-budgets' +import { MAX_TIMER_DELAY_MS } from './timer-delay' + +describe('orchestration timing budgets', () => { + it('keeps the worker-start budget strictly nested', () => { + const readiness = ORCHESTRATION_READINESS_TIMEOUT_MS + const verify = readiness + AGENT_PROMPT_EFFECT_TIMEOUT_MS + const federated = resolveFederationAttachTimeoutMs(readiness) + const outer = resolveWorkerStartClientTimeoutMs(readiness) + + expect(ORCHESTRATION_CONTRACT_PREFLIGHT_TIMEOUT_MS + federated).toBeLessThan(outer) + expect(verify).toBeLessThan(federated) + expect(federated).toBeLessThan(outer) + expect(ORCHESTRATION_FEDERATION_ATTACH_GRACE_MS).toBeGreaterThan(AGENT_PROMPT_EFFECT_TIMEOUT_MS) + expect(ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS).toBeGreaterThan( + ORCHESTRATION_FEDERATION_ATTACH_GRACE_MS + ) + }) + + it('caps attach at the outer deadline after preflight', () => { + expect( + resolveFederationAttachDeadlineMs({ + readinessTimeoutMs: 60_000, + outerDeadlineMs: 105_000, + nowMs: 10_000 + }) + ).toBe(95_000) + }) + + it('accepts the exact maximum readiness timeout and rejects the first overflow', () => { + const maxValid = MAX_TIMER_DELAY_MS - ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS + expect(isWorkerStartTimeoutWithinTimerLimit(maxValid)).toBe(true) + expect(isWorkerStartTimeoutWithinTimerLimit(maxValid + 1)).toBe(false) + }) + + it('keeps ordinary defaults within the timer limit', () => { + expect(isWorkerStartTimeoutWithinTimerLimit(undefined)).toBe(true) + expect(isWorkerStartTimeoutWithinTimerLimit(0)).toBe(true) + }) +}) diff --git a/src/shared/orchestration-timing-budgets.ts b/src/shared/orchestration-timing-budgets.ts new file mode 100644 index 00000000000..cc6890bf0f0 --- /dev/null +++ b/src/shared/orchestration-timing-budgets.ts @@ -0,0 +1,50 @@ +/** Shared timing contracts for agent submission and worker-start transports. */ +import { MAX_TIMER_DELAY_MS } from './timer-delay' + +export const AGENT_PROMPT_EFFECT_TIMEOUT_MS = 30_000 +export const ORCHESTRATION_CONTRACT_PREFLIGHT_TIMEOUT_MS = 5_000 +export const ORCHESTRATION_READINESS_TIMEOUT_MS = 60_000 +export const ORCHESTRATION_FEDERATION_ATTACH_GRACE_MS = AGENT_PROMPT_EFFECT_TIMEOUT_MS + 10_000 +export const ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS = AGENT_PROMPT_EFFECT_TIMEOUT_MS + 20_000 +export const SWALLOWED_ENTER_FIXTURE_TIMEOUT_MS = AGENT_PROMPT_EFFECT_TIMEOUT_MS + 30_000 + +export function resolveWorkerStartReadinessTimeoutMs(timeoutMs: number | undefined): number { + return typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) && timeoutMs > 0 + ? timeoutMs + : ORCHESTRATION_READINESS_TIMEOUT_MS +} + +export function resolveFederationAttachTimeoutMs( + readinessTimeoutMs = ORCHESTRATION_READINESS_TIMEOUT_MS +): number { + return readinessTimeoutMs + ORCHESTRATION_FEDERATION_ATTACH_GRACE_MS +} + +export function resolveWorkerStartClientTimeoutMs( + readinessTimeoutMs = ORCHESTRATION_READINESS_TIMEOUT_MS +): number { + return readinessTimeoutMs + ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS +} + +export function isWorkerStartTimeoutWithinTimerLimit(timeoutMs: number | undefined): boolean { + const readinessTimeoutMs = resolveWorkerStartReadinessTimeoutMs(timeoutMs) + return ( + Number.isSafeInteger(readinessTimeoutMs) && + resolveWorkerStartClientTimeoutMs(readinessTimeoutMs) <= MAX_TIMER_DELAY_MS + ) +} + +export function resolveFederationAttachDeadlineMs(args: { + readinessTimeoutMs?: number + outerDeadlineMs: number + nowMs?: number +}): number { + const nowMs = args.nowMs ?? Date.now() + return Math.max( + 1, + Math.min( + resolveFederationAttachTimeoutMs(args.readinessTimeoutMs), + args.outerDeadlineMs - nowMs + ) + ) +} diff --git a/src/shared/remote-runtime-client-error.ts b/src/shared/remote-runtime-client-error.ts index 487a1108b87..ec76b55af64 100644 --- a/src/shared/remote-runtime-client-error.ts +++ b/src/shared/remote-runtime-client-error.ts @@ -9,6 +9,8 @@ export type RemoteRuntimePairingStage = 'connect' | 'host-identity' | 'access-gr */ export class RemoteRuntimeClientError extends Error { readonly code: string + /** Structured recovery data must survive the WebSocket adapter unchanged. */ + readonly data?: unknown readonly pairingStage?: RemoteRuntimePairingStage readonly closeCode?: number @@ -16,6 +18,7 @@ export class RemoteRuntimeClientError extends Error { code: string, message: string, details?: { + data?: unknown pairingStage?: RemoteRuntimePairingStage closeCode?: number } @@ -23,6 +26,7 @@ export class RemoteRuntimeClientError extends Error { super(message) this.name = 'RemoteRuntimeClientError' this.code = code + this.data = details?.data this.pairingStage = details?.pairingStage this.closeCode = details?.closeCode } diff --git a/src/shared/remote-runtime-shared-control-protocol.ts b/src/shared/remote-runtime-shared-control-protocol.ts index 4daae215989..e7857a08d69 100644 --- a/src/shared/remote-runtime-shared-control-protocol.ts +++ b/src/shared/remote-runtime-shared-control-protocol.ts @@ -157,7 +157,11 @@ export function toRemoteRuntimeClientError(error: unknown): RemoteRuntimeClientE return error } if (error instanceof Error) { - return new RemoteRuntimeClientError('runtime_error', error.message) + const data = + typeof error === 'object' && error !== null && 'data' in error + ? (error as { data?: unknown }).data + : undefined + return new RemoteRuntimeClientError('runtime_error', error.message, { data }) } return new RemoteRuntimeClientError('runtime_error', String(error)) } diff --git a/tests/e2e/terminal-send-agent-prompt-submit.spec.ts b/tests/e2e/terminal-send-agent-prompt-submit.spec.ts index 1ae525d29da..3567cb1d8a2 100644 --- a/tests/e2e/terminal-send-agent-prompt-submit.spec.ts +++ b/tests/e2e/terminal-send-agent-prompt-submit.spec.ts @@ -1,52 +1,97 @@ import { execFile } from 'node:child_process' -import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' import { promisify } from 'node:util' import { expect, test } from './helpers/orca-app' +import { buildFakeAgentCommandOverride } from './helpers/fake-agent-command-override' import { waitForSessionReady } from './helpers/store' +import { RuntimeClient } from '../../src/cli/runtime-client' +import { recognizeAgentProcess } from '../../src/shared/agent-process-recognition' +import { SWALLOWED_ENTER_FIXTURE_TIMEOUT_MS } from '../../src/shared/orchestration-timing-budgets' const execFileAsync = promisify(execFile) const fixtureRoot = mkdtempSync(path.join(os.tmpdir(), 'orca-terminal-send-agent-prompt-')) -const fixtureBin = path.join(fixtureRoot, 'bin') const fixtureReport = path.join(fixtureRoot, 'report.json') const fixtureMarker = `ORCA_TERMINAL_SEND_E2E_${process.pid}` const fixtureScript = path.join(process.cwd(), 'tests', 'tools', 'repro-terminal-send-submit.mjs') -const fakeCodex = path.join(fixtureBin, process.platform === 'win32' ? 'codex.cmd' : 'codex') +const fakeCodex = path.join(fixtureRoot, process.platform === 'win32' ? 'codex.cmd' : 'codex') +const fakeCodexCommand = buildFakeAgentCommandOverride(fakeCodex) +const swallowedEnterFixtureTimeoutMs = SWALLOWED_ENTER_FIXTURE_TIMEOUT_MS -mkdirSync(fixtureBin) writeFileSync( fakeCodex, process.platform === 'win32' ? `@echo off\r\n"${process.execPath}" "${fixtureScript}" --fake-agent --report "%ORCA_FAKE_AGENT_REPORT%" --marker "%ORCA_FAKE_AGENT_MARKER%" --allow-unframed-paste %*\r\n` - : `#!/usr/bin/env sh\nexec "${process.execPath}" "${fixtureScript}" --fake-agent --report "$ORCA_FAKE_AGENT_REPORT" --marker "$ORCA_FAKE_AGENT_MARKER" "$@"\n`, + : `#!/usr/bin/env sh\n"${process.execPath}" "${fixtureScript}" --fake-agent --report "$ORCA_FAKE_AGENT_REPORT" --marker "$ORCA_FAKE_AGENT_MARKER" "$@"\n`, 'utf8' ) if (process.platform !== 'win32') { chmodSync(fakeCodex, 0o755) } -test.use({ - seedTestRepo: false, - orcaAppExtraEnv: { - PATH: `${fixtureBin}${path.delimiter}${process.env.PATH ?? ''}`, - ORCA_FAKE_AGENT_REPORT: fixtureReport, - ORCA_FAKE_AGENT_MARKER: fixtureMarker - } -}) - test.afterAll(() => { rmSync(fixtureRoot, { recursive: true, force: true }) }) +async function createFakeCodexTerminal( + userDataDir: string, + testRepoPath: string, + args: string[] = [] +): Promise { + const client = new RuntimeClient(userDataDir, 30_000, null, null) + const expectedPath = path.resolve(testRepoPath) + const findWorktree = async (): Promise<{ id: string } | undefined> => { + const listed = await client.call<{ worktrees: { id: string; path: string }[] }>( + 'worktree.list', + {} + ) + return listed.result.worktrees.find((worktree) => { + const candidatePath = path.resolve(worktree.path) + return process.platform === 'win32' + ? candidatePath.toLowerCase() === expectedPath.toLowerCase() + : candidatePath === expectedPath + }) + } + await expect.poll(async () => Boolean(await findWorktree()), { timeout: 60_000 }).toBe(true) + const worktree = await findWorktree() + if (!worktree) { + throw new Error(`runtime did not register ${testRepoPath}`) + } + const created = await client.call<{ terminal: { handle: string } }>('terminal.create', { + worktree: `id:${worktree.id}`, + command: [fakeCodexCommand, ...args].join(' '), + launchAgent: 'codex', + env: { + ORCA_FAKE_AGENT_REPORT: fixtureReport, + ORCA_FAKE_AGENT_MARKER: fixtureMarker + }, + title: 'terminal send submit repro' + }) + const handle = created.result.terminal.handle + await expect + .poll( + async () => { + const inspected = await client.call<{ + process: { foregroundProcess: string | null } + }>('terminal.inspectProcess', { terminal: handle }) + return recognizeAgentProcess(inspected.result.process.foregroundProcess)?.agent ?? null + }, + { timeout: 30_000 } + ) + .toBe('codex') + return handle +} + test('CLI text plus Enter waits for a slow agent composer before submitting', async ({ electronApp, orcaPage, testRepoPath }) => { - test.setTimeout(90_000) + test.setTimeout(110_000) await waitForSessionReady(orcaPage) const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData')) + const terminal = await createFakeCodexTerminal(userDataDir, testRepoPath) const repoRoot = process.cwd() let stdout = '' try { @@ -58,8 +103,8 @@ test('CLI text plus Enter waits for a slow agent composer before submitting', as path.join(repoRoot, 'config', 'scripts', 'orca-dev.mjs'), '--worktree', testRepoPath, - '--agent-command', - 'codex', + '--terminal', + terminal, '--report', fixtureReport, '--marker', @@ -94,9 +139,14 @@ test('CLI reports a swallowed Enter without submitting a second Enter', async ({ orcaPage, testRepoPath }) => { - test.setTimeout(90_000) + test.setTimeout(110_000) await waitForSessionReady(orcaPage) const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData')) + const terminal = await createFakeCodexTerminal(userDataDir, testRepoPath, [ + '--swallow-first-enter', + '--timeout-ms', + String(swallowedEnterFixtureTimeoutMs) + ]) const repoRoot = process.cwd() let stdout = '' try { @@ -108,8 +158,10 @@ test('CLI reports a swallowed Enter without submitting a second Enter', async ({ path.join(repoRoot, 'config', 'scripts', 'orca-dev.mjs'), '--worktree', testRepoPath, - '--agent-command', - 'codex --swallow-first-enter', + '--terminal', + terminal, + '--timeout-ms', + String(swallowedEnterFixtureTimeoutMs), '--expect-stalled', '--report', fixtureReport, @@ -120,7 +172,7 @@ test('CLI reports a swallowed Enter without submitting a second Enter', async ({ { cwd: repoRoot, env: { ...process.env, ORCA_DEV_USER_DATA_PATH: userDataDir }, - timeout: 60_000 + timeout: 90_000 } ) stdout = result.stdout @@ -137,6 +189,7 @@ test('CLI reports a swallowed Enter without submitting a second Enter', async ({ prematureEnters: 0, receivedEnters: 1, swallowedEnters: 1, + configuredTimeoutMs: swallowedEnterFixtureTimeoutMs, markerReceived: true }) }) @@ -146,9 +199,12 @@ test('CLI does not write prompt bytes into an active permission dialog', async ( orcaPage, testRepoPath }) => { - test.setTimeout(90_000) + test.setTimeout(110_000) await waitForSessionReady(orcaPage) const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData')) + const terminal = await createFakeCodexTerminal(userDataDir, testRepoPath, [ + '--permission-before-send' + ]) const repoRoot = process.cwd() let stdout = '' try { @@ -160,6 +216,8 @@ test('CLI does not write prompt bytes into an active permission dialog', async ( path.join(repoRoot, 'config', 'scripts', 'orca-dev.mjs'), '--worktree', testRepoPath, + '--terminal', + terminal, '--expect-blocked', '--report', fixtureReport, diff --git a/tests/tools/repro-terminal-send-submit.mjs b/tests/tools/repro-terminal-send-submit.mjs index 201e51c9364..7e1c0152012 100644 --- a/tests/tools/repro-terminal-send-submit.mjs +++ b/tests/tools/repro-terminal-send-submit.mjs @@ -1,8 +1,9 @@ #!/usr/bin/env node import { spawn } from 'node:child_process' -import { mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' +import { pathToFileURL } from 'node:url' const SCRIPT_PATH = import.meta.filename const BEGIN = '\x1b[200~' @@ -118,6 +119,20 @@ async function closeTerminal(cli, handle, cwd) { } } +async function waitForPermissionPrompt(cli, handle, cwd) { + const deadline = Date.now() + 10_000 + let lastTerminal = null + while (Date.now() < deadline) { + const shown = await callOrca(cli, ['terminal', 'show', '--terminal', handle], cwd) + lastTerminal = shown.terminal ?? null + if (lastTerminal?.agentWait) { + return + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + throw new Error(`terminal permission prompt was not observed: ${JSON.stringify(lastTerminal)}`) +} + async function waitForWorktreeSelector(cli, repoId, cwd) { const deadline = Date.now() + 10_000 const expectedPath = path.resolve(cwd) @@ -138,18 +153,25 @@ async function waitForWorktreeSelector(cli, repoId, cwd) { } async function createFakeCodexCommand(tempDir, args) { + const launcherPath = path.join(tempDir, 'codex') + const runnerPath = path.join(tempDir, 'fake-agent.mjs') + await writeFile(runnerPath, `import ${JSON.stringify(pathToFileURL(SCRIPT_PATH).href)}\n`, 'utf8') if (process.platform === 'win32') { - const launcherPath = path.join(tempDir, 'codex.cmd') + const commandPath = path.join(tempDir, 'codex.cmd') await writeFile( - launcherPath, - `@echo off\r\n"${process.execPath}" "${SCRIPT_PATH}" %*\r\n`, + commandPath, + `@echo off\r\n"${process.execPath}" "%~dp0fake-agent.mjs" %*\r\n`, 'utf8' ) - return [shellQuote(launcherPath), ...args].join(' ') + return [shellQuote(commandPath), ...args].join(' ') } - const launcherPath = path.join(tempDir, 'codex') - await symlink(process.execPath, launcherPath) - return [shellQuote(launcherPath), shellQuote(SCRIPT_PATH), ...args].join(' ') + await writeFile( + launcherPath, + `#!/usr/bin/env sh\n"${process.execPath}" "${runnerPath}" "$@"\n`, + 'utf8' + ) + await chmod(launcherPath, 0o755) + return [shellQuote(launcherPath), ...args].join(' ') } async function parentMain() { @@ -162,43 +184,48 @@ async function parentMain() { const prompt = `${marker} ${'slow composer payload '.repeat(24)}` const expectStalled = hasFlag('expect-stalled') const expectBlocked = hasFlag('expect-blocked') + const providedHandle = argValue('terminal') await mkdir(tempDir, { recursive: true }) await rm(reportPath, { force: true }) - const command = - argValue('agent-command') ?? - (await createFakeCodexCommand(tempDir, [ - '--fake-agent', - '--report', - shellQuote(reportPath), - '--marker', - shellQuote(marker), - '--timeout-ms', - String(timeoutMs), - ...(expectBlocked ? ['--permission-before-send'] : []), - ...(process.platform === 'win32' ? ['--allow-unframed-paste'] : []) - ])) - const added = await callOrca(cli, ['repo', 'add', '--path', cwd], cwd) - const repoId = added.repo?.id - if (!repoId) { - throw new Error('repo add returned no id') + let handle = providedHandle + if (!handle) { + const command = + argValue('agent-command') ?? + (await createFakeCodexCommand(tempDir, [ + '--fake-agent', + '--report', + shellQuote(reportPath), + '--marker', + shellQuote(marker), + '--timeout-ms', + String(timeoutMs), + ...(expectStalled ? ['--swallow-first-enter'] : []), + ...(expectBlocked ? ['--permission-before-send'] : []), + ...(process.platform === 'win32' ? ['--allow-unframed-paste'] : []) + ])) + const added = await callOrca(cli, ['repo', 'add', '--path', cwd], cwd) + const repoId = added.repo?.id + if (!repoId) { + throw new Error('repo add returned no id') + } + const worktreeSelector = await waitForWorktreeSelector(cli, repoId, cwd) + const created = await callOrca( + cli, + [ + 'terminal', + 'create', + '--worktree', + worktreeSelector, + '--title', + 'terminal send submit repro', + '--command', + command + ], + cwd + ) + handle = created.terminal?.handle } - const worktreeSelector = await waitForWorktreeSelector(cli, repoId, cwd) - const created = await callOrca( - cli, - [ - 'terminal', - 'create', - '--worktree', - worktreeSelector, - '--title', - 'terminal send submit repro', - '--command', - command - ], - cwd - ) - const handle = created.terminal?.handle if (!handle) { throw new Error('terminal create returned no handle') } @@ -209,6 +236,7 @@ async function parentMain() { if (!setupReport) { throw new Error('terminal permission prompt did not materialize') } + await waitForPermissionPrompt(cli, handle, cwd) } else { await callOrca( cli, @@ -278,6 +306,7 @@ async function parentMain() { } async function fakeAgentMain() { + process.title = 'codex' const reportPath = argValue('report') const marker = argValue('marker') const timeoutMs = parsePositiveInteger('timeout-ms', DEFAULT_TIMEOUT_MS) @@ -291,10 +320,14 @@ async function fakeAgentMain() { process.stdin.setRawMode(true) } process.stdin.resume() - process.stdout.write('OpenAI Codex\nmodel: fake\ndirectory: fixture\n> ') - if (permissionBeforeSend) { - process.stdout.write('\nPermission required\nAllow once\nAllow always\nReject\n') - } + process.stdout.write('\x1b]0;Codex working\x07') + setTimeout(() => { + const title = permissionBeforeSend ? 'Codex permission' : 'Codex Ready' + process.stdout.write(`\x1b]0;${title}\x07OpenAI Codex\nmodel: fake\ndirectory: fixture\n> `) + if (permissionBeforeSend) { + process.stdout.write('\nPermission required\nAllow once\nAllow always\nReject\n') + } + }, 100) let input = '' let countedCarriages = 0 @@ -308,6 +341,7 @@ async function fakeAgentMain() { const writeReport = async (submitted) => { const hasBracketedPasteFrame = input.includes(BEGIN) && input.includes(END) const report = { + configuredTimeoutMs: timeoutMs, contractOk: prematureEnters === 0 && (permissionBeforeSend || !pasteFramingRequired || hasBracketedPasteFrame), From bc3911a3a64e38d1d7a5af68a02a4e3182ee95c2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:26:39 -0700 Subject: [PATCH 02/29] fix(native-chat): separate image attachment paths from following prompt text (STA-4993) (#15820) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(native-chat): separate image paths from following prompt text (STA-4993) Native Chat image send wrote a framed path and then the prompt with no separator, so after the TUI unwrapped the paste the two glued together (`โ€ฆpngdescribe`). Put a trailing space after the frame when text follows, share that rule with clipboard image paste and terminal drops, and split the image send path out so the runtime send file stays under the line cap. * refactor(native-chat): keep image separator fix focused * fix(native-chat): keep consecutive image frames bare * test(runtime): update export parity for terminal degradation * test(native-chat): pin image frame separator contract --- .../native-chat/NativeChatComposer.test.tsx | 4 +- .../native-chat/NativeChatComposer.tsx | 2 +- .../native-chat-runtime-image-send.ts | 76 +++++++++++++++++++ ...ive-chat-runtime-send-launch-draft.test.ts | 8 +- .../native-chat-runtime-send.test.ts | 44 +++++++---- .../native-chat/native-chat-runtime-send.ts | 65 +--------------- .../terminal-drop-path-writer.ts | 6 +- src/shared/image-paste-following-text.test.ts | 31 ++++++++ src/shared/image-paste-following-text.ts | 25 ++++++ 9 files changed, 179 insertions(+), 82 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts create mode 100644 src/shared/image-paste-following-text.test.ts create mode 100644 src/shared/image-paste-following-text.ts diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index e7a6269f533..2d90ac30126 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -70,9 +70,11 @@ vi.mock('./native-chat-runtime-send', () => ({ sendNativeChatMessageVerified: (...args: unknown[]) => mocks.sendNativeChatMessageVerified(...args), typeNativeChatCommand: (...args: unknown[]) => mocks.typeNativeChatCommand(...args), - sendNativeChatMessageWithImageAttachments: vi.fn(), submitNativeChatPrompt: vi.fn() })) +vi.mock('./native-chat-runtime-image-send', () => ({ + sendNativeChatMessageWithImageAttachments: vi.fn() +})) vi.mock('./claude-model-switch-confirmation', () => ({ createClaudeModelSwitchConfirmationObserver: (...args: unknown[]) => mocks.createClaudeModelSwitchConfirmationObserver(...args) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 7ec609f8aa1..a0fec999d8a 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -5,10 +5,10 @@ import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' import { sendNativeChatMessage, sendNativeChatTypedCommand, - sendNativeChatMessageWithImageAttachments, submitNativeChatPrompt } from './native-chat-runtime-send' import type { NativeChatSendHandle } from './native-chat-runtime-send' +import { sendNativeChatMessageWithImageAttachments } from './native-chat-runtime-image-send' import { resolveNativeChatLaunchDraftSend } from './native-chat-launch-draft-send' import { getVerifiedNativeChatCommands } from '../../../../shared/native-chat-agent-profiles' import { isSlashCommandDraft } from '../../../../shared/native-chat-slash-commands' diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts new file mode 100644 index 00000000000..5dd62b5ef98 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts @@ -0,0 +1,76 @@ +import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' +import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' +import { imagePasteWritesFollowedByText } from '../../../../shared/image-paste-following-text' +import { NATIVE_CHAT_SUBMIT_DELAY_MS } from '../../../../shared/native-chat-answer-stepping' +import { + buildNativeChatImagePasteBytes, + buildNativeChatPasteBytes, + NATIVE_CHAT_SUBMIT +} from './native-chat-send' +import { enqueueNativeChatPtySend } from './native-chat-pty-send-queue' +import { + clearConfirmDurationMs, + clearThenWrite, + clearUnsubmittedAgentInput, + sendNativeChatMessage, + type NativeChatSendHandle, + type NativeChatSendOptions +} from './native-chat-runtime-send' + +export const NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS = 300 + +type RuntimeSettings = ReturnType + +export function sendNativeChatMessageWithImageAttachments( + settings: RuntimeSettings, + ptyId: string, + text: string, + imagePaths: readonly string[], + options?: NativeChatSendOptions +): NativeChatSendHandle { + if (imagePaths.length === 0) { + return sendNativeChatMessage(settings, ptyId, text, options) + } + const trimmedText = text.trim() + const durationMs = + (trimmedText.length > 0 + ? NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS + : NATIVE_CHAT_SUBMIT_DELAY_MS) + clearConfirmDurationMs(options) + return enqueueNativeChatPtySend( + ptyId, + durationMs, + ({ isCancelled, delay, markSubmitted }) => { + if (isCancelled()) { + return + } + clearThenWrite(settings, ptyId, options, delay, () => { + if (isCancelled()) { + return + } + for (const payload of imagePasteWritesFollowedByText( + imagePaths.map(buildNativeChatImagePasteBytes), + trimmedText.length > 0 + )) { + sendRuntimePtyInput(settings, ptyId, payload) + } + if (trimmedText.length > 0) { + delay(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, () => { + sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text)) + delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => { + sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) + markSubmitted() + }) + }) + return + } + delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => { + sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) + markSubmitted() + }) + }) + }, + { + onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId, options) + } + ) +} diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts index 32f4947c3d6..35d8fc9444d 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts @@ -13,12 +13,14 @@ vi.mock('@/runtime/runtime-terminal-inspection', () => ({ import { NATIVE_CHAT_CLEAR_CONFIRM_MS, NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, - NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, NATIVE_CHAT_SUBMIT_DELAY_MS, resetNativeChatPtySendQueuesForTests, - sendNativeChatMessage, - sendNativeChatMessageWithImageAttachments + sendNativeChatMessage } from './native-chat-runtime-send' +import { + NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, + sendNativeChatMessageWithImageAttachments +} from './native-chat-runtime-image-send' import { buildNativeChatPasteBytes, NATIVE_CHAT_SUBMIT } from './native-chat-send' import { AGENT_TUI_CLEAR_INPUT_MAX, diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts index 9a16199d52a..dc7fa05e830 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts @@ -14,21 +14,19 @@ import { sendNativeChatTypedCommand, sendNativeChatMessageVerified, typeNativeChatCommand, - sendNativeChatMessageWithImageAttachments, submitNativeChatPrompt, sendNativeChatAskAnswer, resetNativeChatPtySendQueuesForTests, - NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, NATIVE_CHAT_SUBMIT_DELAY_MS, NATIVE_CHAT_QUESTION_STEP_MS, NATIVE_CHAT_ADVANCE_BUFFER_MS, NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT } from './native-chat-runtime-send' import { - buildNativeChatImagePasteBytes, - buildNativeChatPasteBytes, - NATIVE_CHAT_SUBMIT -} from './native-chat-send' + NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, + sendNativeChatMessageWithImageAttachments +} from './native-chat-runtime-image-send' +import { buildNativeChatPasteBytes, NATIVE_CHAT_SUBMIT } from './native-chat-send' const SETTINGS = {} as Parameters[0] const PTY = 'pty-1' @@ -337,24 +335,21 @@ describe('sendNativeChatMessageWithImageAttachments', () => { NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS ) + const framedImageWithSeparator = '\x1b[200~/tmp/orca-paste-image.png\x1b[201~ ' expectWriteOrder(sendRuntimePtyInput.mock.calls, [ NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, - buildNativeChatImagePasteBytes('/tmp/orca-paste-image.png') + framedImageWithSeparator ]) vi.advanceTimersByTime(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS) - expect(sendRuntimePtyInput).toHaveBeenLastCalledWith( - SETTINGS, - PTY, - buildNativeChatPasteBytes('what do you see?') - ) + expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, 'what do you see?') vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS) expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) expect(sendRuntimePtyInput).toHaveBeenCalledTimes(4) }) - it('waits the normal submit gap for an attachment-only send', () => { + it('does not append a trailing separator on an attachment-only send', () => { const handle = sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, '', [ '/tmp/orca-paste-image.png' ]) @@ -363,7 +358,7 @@ describe('sendNativeChatMessageWithImageAttachments', () => { expectWriteOrder(sendRuntimePtyInput.mock.calls, [ NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, - buildNativeChatImagePasteBytes('/tmp/orca-paste-image.png') + '\x1b[200~/tmp/orca-paste-image.png\x1b[201~' ]) vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS - 1) @@ -374,6 +369,25 @@ describe('sendNativeChatMessageWithImageAttachments', () => { expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) }) + it('treats whitespace-only prompt input as attachment-only', () => { + sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, ' ', ['/tmp/orca-paste-image.png']) + + expectWriteOrder(sendRuntimePtyInput.mock.calls, [ + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + '\x1b[200~/tmp/orca-paste-image.png\x1b[201~' + ]) + }) + + it('keeps multiple image frames bare and separates only the final frame from prompt text', () => { + sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, 'hello', ['/tmp/a.png', '/tmp/b.png']) + + expectWriteOrder(sendRuntimePtyInput.mock.calls, [ + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + '\x1b[200~/tmp/a.png\x1b[201~', + '\x1b[200~/tmp/b.png\x1b[201~ ' + ]) + }) + it('cancels deferred prompt and Enter writes after the attachment path', () => { const handle = sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, 'describe', [ '/tmp/orca-paste-image.png' @@ -384,7 +398,7 @@ describe('sendNativeChatMessageWithImageAttachments', () => { // Pre-clear + image body + cancel clear; no Enter. expectWriteOrder(sendRuntimePtyInput.mock.calls, [ NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, - buildNativeChatImagePasteBytes('/tmp/orca-paste-image.png'), + '\x1b[200~/tmp/orca-paste-image.png\x1b[201~ ', NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT ]) expect(sendRuntimePtyInput.mock.calls.some((call) => call[2] === NATIVE_CHAT_SUBMIT)).toBe( diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts index a3b34b4f716..a845fe10d16 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts @@ -14,11 +14,7 @@ import { NATIVE_CHAT_QUESTION_STEP_MS, NATIVE_CHAT_SUBMIT_DELAY_MS } from '../../../../shared/native-chat-answer-stepping' -import { - buildNativeChatImagePasteBytes, - buildNativeChatPasteBytes, - NATIVE_CHAT_SUBMIT -} from './native-chat-send' +import { buildNativeChatPasteBytes, NATIVE_CHAT_SUBMIT } from './native-chat-send' import { AGENT_TUI_COMMAND_KEY_INTERVAL_MS, typeAgentTuiCommand @@ -33,8 +29,6 @@ import { export { NATIVE_CHAT_ADVANCE_BUFFER_MS, NATIVE_CHAT_QUESTION_STEP_MS, NATIVE_CHAT_SUBMIT_DELAY_MS } export { resetNativeChatPtySendQueuesForTests } -export const NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS = 300 - // Why: agent TUI composers treat Ctrl+U as kill-to-start-of-line. Chat sends // start from an empty line so a prior cancelled paste cannot glue onto the next // prompt. Not used on verified option commands โ€” model-switch confirmation @@ -73,7 +67,7 @@ export type NativeChatSendHandle = { type RuntimeSettings = ReturnType -function clearUnsubmittedAgentInput( +export function clearUnsubmittedAgentInput( settings: RuntimeSettings, ptyId: string, options?: NativeChatSendOptions @@ -89,7 +83,7 @@ function clearUnsubmittedAgentInput( * draft is still visible โ€” the injected line count is only a lower bound on what * the buffer holds, since the user can type into the TUI directly. */ -function clearThenWrite( +export function clearThenWrite( settings: RuntimeSettings, ptyId: string, options: NativeChatSendOptions | undefined, @@ -117,7 +111,7 @@ function clearThenWrite( } /** Extra time a send needs when it stops to confirm the clear before the body. */ -function clearConfirmDurationMs(options?: NativeChatSendOptions): number { +export function clearConfirmDurationMs(options?: NativeChatSendOptions): number { return options?.confirmCleared ? NATIVE_CHAT_CLEAR_CONFIRM_MS : 0 } @@ -272,57 +266,6 @@ export function sendNativeChatTypedCommand( ) } -export function sendNativeChatMessageWithImageAttachments( - settings: RuntimeSettings, - ptyId: string, - text: string, - imagePaths: readonly string[], - options?: NativeChatSendOptions -): NativeChatSendHandle { - if (imagePaths.length === 0) { - return sendNativeChatMessage(settings, ptyId, text, options) - } - const trimmedText = text.trim() - const durationMs = - (trimmedText.length > 0 - ? NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS - : NATIVE_CHAT_SUBMIT_DELAY_MS) + clearConfirmDurationMs(options) - return enqueueNativeChatPtySend( - ptyId, - durationMs, - ({ isCancelled, delay, markSubmitted }) => { - if (isCancelled()) { - return - } - clearThenWrite(settings, ptyId, options, delay, () => { - if (isCancelled()) { - return - } - for (const imagePath of imagePaths) { - sendRuntimePtyInput(settings, ptyId, buildNativeChatImagePasteBytes(imagePath)) - } - if (trimmedText.length > 0) { - delay(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, () => { - sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text)) - delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => { - sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) - markSubmitted() - }) - }) - return - } - delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => { - sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) - markSubmitted() - }) - }) - }, - { - onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId, options) - } - ) -} - /** Submit a TUI prompt with no body (Enter only) โ€” e.g. a plain submit when the * composer is empty. */ export function submitNativeChatPrompt(settings: RuntimeSettings, ptyId: string): void { diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.ts b/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.ts index d97bca17a63..f8ed3766251 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.ts @@ -1,4 +1,5 @@ import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import { separateImagePasteFromFollowingText } from '../../../../shared/image-paste-following-text' import { shellEscapePath } from './pane-helpers' import type { PtyTransport } from './pty-transport' import { wrapTerminalBracketedPasteText } from './terminal-bracketed-paste' @@ -65,7 +66,10 @@ export async function writeTerminalDropPathsToCapturedTarget({ canPasteImageDropPathRaw(nextPath, targetShell) const needsSeparatorAfterImage = nextPath !== undefined && !nextPathIsRawPasteImage const payload = pathIsRawPasteImage - ? `${wrapTerminalBracketedPasteText(path)}${needsSeparatorAfterImage ? ' ' : ''}` + ? separateImagePasteFromFollowingText( + wrapTerminalBracketedPasteText(path), + needsSeparatorAfterImage + ) : `${shellEscapePath(path, targetShell)} ` const writeResult = await runTerminalPasteOperationWithTimeout( () => writeTerminalPastePtyInput(liveTransport, payload), diff --git a/src/shared/image-paste-following-text.test.ts b/src/shared/image-paste-following-text.test.ts new file mode 100644 index 00000000000..ef46c4c7a9e --- /dev/null +++ b/src/shared/image-paste-following-text.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { + IMAGE_PASTE_FOLLOWING_TEXT_SEPARATOR, + imagePasteWritesFollowedByText +} from './image-paste-following-text' + +const IMAGE_A = '\x1b[200~/tmp/orca-paste-a.png\x1b[201~' +const IMAGE_B = '\x1b[200~/tmp/orca-paste-b.png\x1b[201~' + +describe('imagePasteWritesFollowedByText', () => { + it('separates an attachment path from following prompt text by a single space', () => { + expect(imagePasteWritesFollowedByText([IMAGE_A], true)).toEqual([`${IMAGE_A} `]) + expect(IMAGE_PASTE_FOLLOWING_TEXT_SEPARATOR).toBe(' ') + }) + + it('keeps an attachment-only send as the framed path with no trailing separator', () => { + expect(imagePasteWritesFollowedByText([IMAGE_A], false)).toEqual([IMAGE_A]) + }) + + it('returns no writes when there are no image pastes', () => { + expect(imagePasteWritesFollowedByText([], true)).toEqual([]) + }) + + it('keeps back-to-back image frames bare and separates only the final frame from prompt text', () => { + expect(imagePasteWritesFollowedByText([IMAGE_A, IMAGE_B], true)).toEqual([ + IMAGE_A, + `${IMAGE_B} ` + ]) + expect(imagePasteWritesFollowedByText([IMAGE_A, IMAGE_B], false)).toEqual([IMAGE_A, IMAGE_B]) + }) +}) diff --git a/src/shared/image-paste-following-text.ts b/src/shared/image-paste-following-text.ts new file mode 100644 index 00000000000..4efd7363427 --- /dev/null +++ b/src/shared/image-paste-following-text.ts @@ -0,0 +1,25 @@ +// One space after a bracketed image paste so later typed/sent text cannot glue +// onto the path (`โ€ฆpngadd` โ†’ `โ€ฆpng add`). Desktop terminal drops already do this +// when an image is followed by a non-image path. +export const IMAGE_PASTE_FOLLOWING_TEXT_SEPARATOR = ' ' + +export function separateImagePasteFromFollowingText( + framedImagePaste: string, + followedByNonImageInput: boolean +): string { + return followedByNonImageInput + ? `${framedImagePaste}${IMAGE_PASTE_FOLLOWING_TEXT_SEPARATOR}` + : framedImagePaste +} + +export function imagePasteWritesFollowedByText( + framedImagePastes: readonly string[], + followedByText: boolean +): string[] { + return framedImagePastes.map((paste, index) => + separateImagePasteFromFollowingText( + paste, + index === framedImagePastes.length - 1 && followedByText + ) + ) +} From 419e3b449685fa10490d07e64e3e3daf01574360 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:28:09 -0700 Subject: [PATCH 03/29] Fix terminal reads that flatten composer drafts into output (#16711) * fix(terminal): separate composer drafts from read output Rendered screen reads treated cursor-line suggestion overlays as PTY output. Detect composer-owned text from cell attributes and cursor context, remove it from tail, and expose it as structured draft metadata. * fix(terminal): handle wrapped composer overlays * fix(terminal): preserve draft wrapping and tail alignment * fix(terminal): preserve composer wrap boundaries * fix(terminal): preserve draft continuations with middle dots * fix(terminal): recognize configurable Codex status lines --- src/cli/specs/core.ts | 1 + src/cli/terminal-format-draft.test.ts | 21 + src/cli/terminal-format.ts | 1 + src/main/daemon/headless-emulator.ts | 16 + .../terminal-cursor-line-context.test.ts | 71 ++++ .../daemon/terminal-cursor-line-context.ts | 99 +++++ src/main/runtime/orca-runtime.test.ts | 91 +++++ src/main/runtime/orca-runtime.ts | 138 +++++-- .../orchestration/worker-output-archive.ts | 11 +- .../orchestration-worker-archive-read.ts | 1 + .../orchestration-worker-output.test.ts | 25 ++ .../methods/orchestration-worker-output.ts | 13 +- .../orchestration-worker-release.test.ts | 12 +- src/main/runtime/terminal-projection.test.ts | 59 +++ src/shared/runtime-terminal-contracts.ts | 2 + src/shared/terminal-composer-draft.test.ts | 369 ++++++++++++++++++ src/shared/terminal-composer-draft.ts | 173 ++++++++ 17 files changed, 1056 insertions(+), 47 deletions(-) create mode 100644 src/cli/terminal-format-draft.test.ts create mode 100644 src/main/daemon/terminal-cursor-line-context.test.ts create mode 100644 src/main/daemon/terminal-cursor-line-context.ts create mode 100644 src/main/runtime/terminal-projection.test.ts create mode 100644 src/shared/terminal-composer-draft.test.ts create mode 100644 src/shared/terminal-composer-draft.ts diff --git a/src/cli/specs/core.ts b/src/cli/specs/core.ts index d4422943f67..aaf94bb0d64 100644 --- a/src/cli/specs/core.ts +++ b/src/cli/specs/core.ts @@ -208,6 +208,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ 'By default this returns accumulated terminal output with escape sequences stripped, not the rendered screen. Any program that repaints a line โ€” shells, progress bars, TUIs โ€” comes back as stacked fragments, so one `clear` keystroke by keystroke reads as `cclclecleaclear`, and spaces a prompt draws by moving the cursor are absent.', 'Use --screen to read what the terminal actually renders. Prefer it whenever the answer depends on how output looks rather than what was emitted over time; the default is unsuitable for verifying rendered output.', 'The result reports source: stream when it is accumulated output, screen when it is the rendered screen, and screen-unavailable when a screen was asked for but none could be rendered and the accumulated output is being returned instead. An absent source means the host predates the field.', + 'When present, draft is UI-only composer text excluded from tail; never treat it as terminal output or a submitted instruction.', '--screen and --cursor are mutually exclusive: a screen read is the current frame and has no history to page.', 'Use --cursor with the nextCursor value from a previous read to get only new output since that read.', 'Use --limit to request more retained lines for long agent responses; output reports oldestCursor when older lines were dropped.', diff --git a/src/cli/terminal-format-draft.test.ts b/src/cli/terminal-format-draft.test.ts new file mode 100644 index 00000000000..3f38e48dd21 --- /dev/null +++ b/src/cli/terminal-format-draft.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { formatTerminalRead } from './terminal-format' + +describe('formatTerminalRead draft separation', () => { + it('labels composer drafts outside terminal output', () => { + const output = formatTerminalRead({ + terminal: { + handle: 'term_1', + status: 'running', + tail: ['Build passed', 'โฏ'], + truncated: false, + nextCursor: null, + source: 'screen', + draft: 'proceed with the release' + } + }) + + expect(output).toContain('draft: "proceed with the release"') + expect(output).toContain('\n\nBuild passed\nโฏ') + }) +}) diff --git a/src/cli/terminal-format.ts b/src/cli/terminal-format.ts index 806a39cb588..4d57e7693a1 100644 --- a/src/cli/terminal-format.ts +++ b/src/cli/terminal-format.ts @@ -141,6 +141,7 @@ export function formatTerminalRead(result: { terminal: RuntimeTerminalRead }): s `handle: ${terminal.handle}`, `status: ${terminal.status}`, ...(terminal.source ? [`source: ${terminal.source}`] : []), + ...(terminal.draft ? [`draft: ${JSON.stringify(terminal.draft)}`] : []), ...(terminal.nextCursor !== null ? [`cursor: ${terminal.nextCursor}`] : []), ...oldestCursor, ...latestCursor, diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index e26ce0d3094..18a9b25e6d3 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -23,6 +23,8 @@ import { import { installDeviceAttributesResponder } from './startup-device-attributes-responder' import type { TerminalSnapshot, TerminalModes } from './types' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' +import type { TerminalCursorContext } from '../../shared/terminal-composer-draft' +import { readTerminalCursorLineContext } from './terminal-cursor-line-context' export type HeadlessEmulatorOptions = { cols: number @@ -302,6 +304,20 @@ export class HeadlessEmulator { return lines } + getVisibleBufferRange(): { start: number; endExclusive: number; totalLength: number } { + const buffer = this.terminal.buffer.active + const start = buffer.viewportY + return { + start, + endExclusive: Math.min(buffer.length, start + this.terminal.rows), + totalLength: buffer.length + } + } + + getCursorLineContext(rowsAbove = this.terminal.rows): TerminalCursorContext | null { + return readTerminalCursorLineContext(this.terminal, rowsAbove) + } + getBufferTailLines(limit: number): string[] { const buffer = this.terminal.buffer.active const start = Math.max(0, buffer.length - Math.max(0, Math.floor(limit))) diff --git a/src/main/daemon/terminal-cursor-line-context.test.ts b/src/main/daemon/terminal-cursor-line-context.test.ts new file mode 100644 index 00000000000..fcf5fa2b492 --- /dev/null +++ b/src/main/daemon/terminal-cursor-line-context.test.ts @@ -0,0 +1,71 @@ +import { Terminal } from '@xterm/headless' +import { describe, expect, it } from 'vitest' +import { detectTerminalComposerDraft } from '../../shared/terminal-composer-draft' +import { HeadlessEmulator } from './headless-emulator' +import { readTerminalCursorLineContext } from './terminal-cursor-line-context' + +function writeSync(terminal: Terminal, data: string): void { + const core = (terminal as unknown as { _core: { writeSync(data: string): void } })._core + core.writeSync(data) +} + +describe('readTerminalCursorLineContext', () => { + it.each([ + { cols: 19, cursorRowTail: 'proceed with the ', continuation: 'release' }, + { cols: 18, cursorRowTail: 'proceed with the', continuation: ' release' } + ])( + 'preserves a space at a dimmed soft-wrap boundary with $cols columns', + ({ cols, cursorRowTail, continuation }) => { + const terminal = new Terminal({ cols, rows: 6, allowProposedApi: true }) + writeSync( + terminal, + `${'โ”€'.repeat(cols)}\r\nโฏ \x1b7\x1b[2mproceed with the release\x1b[22m\x1b8` + ) + + const context = readTerminalCursorLineContext(terminal, 16) + + expect(context?.rawAfterCursor).toBe(cursorRowTail) + expect(context?.rowsBelow).toEqual([continuation, '', '', '']) + expect(context?.rowsBelowWrapped).toEqual([true, false, false, false]) + expect(detectTerminalComposerDraft(context)?.text).toBe('proceed with the release') + terminal.dispose() + } + ) + + it('preserves a typed space before the cursor moves onto a wrapped row', () => { + const terminal = new Terminal({ cols: 19, rows: 6, allowProposedApi: true }) + writeSync(terminal, 'โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\r\nโฏ proceed with the release') + + const context = readTerminalCursorLineContext(terminal, 16) + + expect(context?.typedRows).toEqual(['โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€', 'โฏ proceed with the ', 'release']) + expect(context?.rowsWrapped).toEqual([false, false, true]) + expect(detectTerminalComposerDraft(context)?.text).toBe('proceed with the release') + terminal.dispose() + }) + + it('recognizes a colored context-only Codex status footer', () => { + const terminal = new Terminal({ cols: 80, rows: 8, allowProposedApi: true }) + writeSync( + terminal, + '\x1b[1mโ€บ\x1b[22m \x1b7review the change\r\n \r\n\x1b[38;2;242;181;144mContext 0% used\x1b[0m\x1b8' + ) + + const context = readTerminalCursorLineContext(terminal, 16) + + expect(detectTerminalComposerDraft(context)?.text).toBe('review the change') + terminal.dispose() + }) + + it('finds a composer prompt more than 16 wrapped rows above the cursor', async () => { + const draft = `proceed ${'with '.repeat(65)}release` + const emulator = new HeadlessEmulator({ cols: 19, rows: 30 }) + await emulator.write(`${'โ”€'.repeat(19)}\r\nโฏ ${draft}`) + + const context = emulator.getCursorLineContext() + + expect(context?.rows.length).toBeGreaterThan(17) + expect(detectTerminalComposerDraft(context)?.text).toBe(draft) + emulator.dispose() + }) +}) diff --git a/src/main/daemon/terminal-cursor-line-context.ts b/src/main/daemon/terminal-cursor-line-context.ts new file mode 100644 index 00000000000..cf9593e13e8 --- /dev/null +++ b/src/main/daemon/terminal-cursor-line-context.ts @@ -0,0 +1,99 @@ +import type { IBufferLine, Terminal } from '@xterm/headless' +import type { TerminalCursorContext } from '../../shared/terminal-composer-draft' + +function undimmedText(line: IBufferLine, fromX = 0, trimRight = true): string { + let text = '' + for (let x = fromX; x < line.length; x += 1) { + const cell = line.getCell(x) + if (!cell || cell.isDim() || cell.getWidth() === 0) { + continue + } + text += cell.getChars() || ' ' + } + return trimRight ? text.trimEnd() : text +} + +function firstVisibleCellIsBold(line: IBufferLine): boolean { + for (let x = 0; x < line.length; x += 1) { + const cell = line.getCell(x) + if (!cell || cell.getWidth() === 0 || !cell.getChars().trim()) { + continue + } + return Boolean(cell.isBold()) + } + return false +} + +function firstVisibleCellHasCustomForeground(line: IBufferLine): boolean { + for (let x = 0; x < line.length; x += 1) { + const cell = line.getCell(x) + if (!cell || cell.getWidth() === 0 || !cell.getChars().trim()) { + continue + } + return !cell.isFgDefault() + } + return false +} + +export function readTerminalCursorLineContext( + terminal: Terminal, + rowsAbove: number +): TerminalCursorContext | null { + const buffer = terminal.buffer.active + const cursorRow = buffer.baseY + buffer.cursorY + const cursorLine = buffer.getLine(cursorRow) + if (!cursorLine) { + return null + } + const rows: string[] = [] + const typedRows: string[] = [] + const promptGlyphBoldRows: boolean[] = [] + const rowsWrapped: boolean[] = [] + const start = Math.max(buffer.viewportY, cursorRow - Math.max(0, Math.floor(rowsAbove))) + for (let row = start; row <= cursorRow; row += 1) { + const line = buffer.getLine(row) + const nextLineIsWrapped = buffer.getLine(row + 1)?.isWrapped ?? false + rows.push(line?.translateToString(!nextLineIsWrapped) ?? '') + typedRows.push(line ? undimmedText(line, 0, !nextLineIsWrapped) : '') + promptGlyphBoldRows.push(line ? firstVisibleCellIsBold(line) : false) + rowsWrapped.push(line?.isWrapped ?? false) + } + const rowsBelow: string[] = [] + const typedRowsBelow: string[] = [] + const rowsBelowWrapped: boolean[] = [] + const rowsBelowCustomForeground: boolean[] = [] + const end = Math.min( + buffer.viewportY + terminal.rows - 1, + cursorRow + Math.max(0, Math.floor(rowsAbove)) + ) + for (let row = cursorRow + 1; row <= end; row += 1) { + const line = buffer.getLine(row) + const nextLineIsWrapped = buffer.getLine(row + 1)?.isWrapped ?? false + rowsBelow.push(line?.translateToString(!nextLineIsWrapped) ?? '') + typedRowsBelow.push(line ? undimmedText(line, 0, !nextLineIsWrapped) : '') + rowsBelowWrapped.push(line?.isWrapped ?? false) + rowsBelowCustomForeground.push(line ? firstVisibleCellHasCustomForeground(line) : false) + } + return { + rows, + typedRows, + promptGlyphBoldRows, + rowsWrapped, + rowsBelow, + typedRowsBelow, + rowsBelowWrapped, + rowsBelowCustomForeground, + beforeCursor: cursorLine.translateToString(true, 0, buffer.cursorX), + afterCursor: undimmedText( + cursorLine, + buffer.cursorX, + !(buffer.getLine(cursorRow + 1)?.isWrapped ?? false) + ), + rawAfterCursor: cursorLine.translateToString( + !(buffer.getLine(cursorRow + 1)?.isWrapped ?? false), + buffer.cursorX + ), + cursorHidden: !terminal.modes.showCursor, + cursorViewportRow: cursorRow - buffer.viewportY + } +} diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 40c9eafd621..3d507c5da88 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -19354,6 +19354,97 @@ describe('OrcaRuntimeService', () => { }) }) + it('separates composer draft text from rendered terminal output', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + hasRendererSerializer: () => false + }) + syncSinglePty(runtime) + runtime.onPtyData( + 'pty-1', + '\x1b[?1049hBuild passed\r\nโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\r\nโฏ \x1b[2mproceed with the release\r\n and close the pull request\x1b[22m\x1b[1A\x1b[3G', + 100 + ) + const [terminal] = (await runtime.listTerminals()).terminals + + const read = await runtime.readTerminal(terminal.handle) + + expect(read).toMatchObject({ + source: 'screen', + tail: ['Build passed', 'โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€', 'โฏ'], + draft: 'proceed with the release\nand close the pull request' + }) + }) + + it('keeps renderer-fallback composer drafts separate from terminal output', async () => { + const serializeBuffer = vi.fn().mockResolvedValue({ + data: '\x1b[?1049hBuild passed\r\nโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\r\nโฏ \x1b[2mproceed with the release\x1b[22m\x1b[3G', + cols: 80, + rows: 24 + }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + hasRendererSerializer: () => true, + serializeBuffer + }) + syncSinglePty(runtime) + runtime.onPtyData('pty-1', `${Array.from({ length: 3000 }, () => '').join('\n')}\n`, 100) + const [terminal] = (await runtime.listTerminals()).terminals + + const read = await runtime.readTerminal(terminal.handle) + + expect(read).toMatchObject({ + source: 'screen', + tail: ['Build passed', 'โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€', 'โฏ'], + draft: 'proceed with the release' + }) + expect(serializeBuffer).toHaveBeenCalledWith('pty-1', { + scrollbackRows: 0, + altScreenForcesZeroRows: false + }) + }) + + it('separates composer drafts from provider-owned terminal screens', async () => { + const serializeProviderBuffer = vi.fn().mockResolvedValue({ + data: '\x1b[?1049hBuild passed\r\nโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\r\nโฏ \x1b[2mproceed with the release\x1b[22m\x1b[3G', + cols: 80, + rows: 24, + seq: 900, + source: 'headless', + alternateScreen: true + }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeProviderBuffer, + hasRendererSerializer: () => false + }) + syncSinglePty(runtime) + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-1', + { value: 900, generation: 'continued' }, + 0 + ) + const [terminal] = (await runtime.listTerminals()).terminals + + const read = await runtime.readTerminal(terminal.handle) + + expect(read).toMatchObject({ + source: 'screen', + tail: ['Build passed', 'โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€', 'โฏ'], + draft: 'proceed with the release' + }) + expect(serializeProviderBuffer).toHaveBeenCalledOnce() + }) + it('does not use renderer visible-screen fallback for cursor transcript reads', async () => { const serializeBuffer = vi.fn().mockResolvedValue({ data: 'Visible TUI\n', diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 6afbdfda612..b57e44aacf3 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -27,6 +27,7 @@ import type { AgentStatus } from '../../shared/agent-detection' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' import type { TerminalOscColorQueryReplyColors } from '../../shared/terminal-osc-color-reply' import type { TerminalOutputSourceRange } from '../../shared/terminal-output-source-range' +import { detectTerminalComposerDraft } from '../../shared/terminal-composer-draft' import type { RemoteTerminalSourceRangeConsumerHooks, RemoteTerminalSourceRangeReplacementPublication, @@ -1896,11 +1897,17 @@ export type RuntimeTerminalDataMeta = Readonly<{ type RuntimeVisibleTerminalState = { lines: string[] + draft?: string isAlternateScreen: boolean sequence: number generation: number } +type RuntimeTerminalProjection = { + lines: string[] + draft?: string +} + type ProviderBufferAcquisition = { generation: number scrollbackRows: number @@ -14053,13 +14060,18 @@ export class OrcaRuntimeService { !recoveredWorkerFallback && this.isKnownUnattachedLocalDaemonPty(ptyId) if (recoveredWorkerFallback || neverAttachedProviderFallback) { - const providerLines = await this.readProviderTerminalTailLines( + const providerProjection = await this.readProviderTerminalTailLines( ptyId, opts.limit, providerSnapshot ) - if (providerLines.length > 0) { - return buildVisibleSnapshotReadFallback(read, providerLines, opts.limit) + if (providerProjection.lines.length > 0) { + return buildVisibleSnapshotReadFallback( + read, + providerProjection.lines, + opts.limit, + providerProjection.draft + ) } } const knownAlternateScreen = this.isTerminalAlternateScreen(ptyId) @@ -14083,21 +14095,21 @@ export class OrcaRuntimeService { ) { return read } - let lines = visibleState?.lines ?? [] - if (lines.length === 0) { - lines = await this.readRendererVisibleSnapshotLines(ptyId) + let projection: RuntimeTerminalProjection = visibleState ?? { lines: [] } + if (projection.lines.length === 0) { + projection = await this.readRendererVisibleSnapshotLines(ptyId) } - if (lines.length === 0) { + if (projection.lines.length === 0) { return read } - return buildVisibleSnapshotReadFallback(read, lines, opts.limit) + return buildVisibleSnapshotReadFallback(read, projection.lines, opts.limit, projection.draft) } private async readProviderTerminalTailLines( ptyId: string, limit: number | undefined, snapshotOptions: ProviderSnapshotReadOptions = {} - ): Promise { + ): Promise { const generation = this.getPtyLifecycleGeneration(ptyId) const lineLimit = terminalReadLimit(limit, DEFAULT_TERMINAL_READ_LIMIT) const snapshot = await this.serializeProviderTerminalBuffer( @@ -14106,21 +14118,21 @@ export class OrcaRuntimeService { snapshotOptions ) if (!snapshot) { - return [] + return { lines: [] } } // Why: a cached acquisition can carry scrollback this caller did not ask for, // so visible-only reads parse the grid itself rather than trusting the request. if (snapshotOptions.visibleScreenOnly) { - const lines = await this.parseVisibleSnapshotLines(snapshot) + const projection = await this.parseVisibleSnapshot(snapshot) // Live bytes ordered after the provider frame make that frame stale. return this.getPtyLifecycleGeneration(ptyId) === generation && this.getPtyOutputSequence(ptyId) <= snapshot.seq - ? lines - : [] + ? projection + : { lines: [] } } const data = `${snapshot.scrollbackAnsi ?? ''}${snapshot.data}` if (data.length === 0) { - return [] + return { lines: [] } } const emulator = new HeadlessEmulator({ cols: snapshot.cols, @@ -14129,11 +14141,11 @@ export class OrcaRuntimeService { }) try { await emulator.write(data) - const lines = visibleNonBlankTerminalLines(emulator.getBufferTailLines(lineLimit)) + const projection = projectTerminalTailLines(emulator, lineLimit) return this.getPtyLifecycleGeneration(ptyId) === generation && this.getPtyOutputSequence(ptyId) <= snapshot.seq - ? lines - : [] + ? projection + : { lines: [] } } finally { emulator.dispose() } @@ -14150,11 +14162,11 @@ export class OrcaRuntimeService { if (!knownAlternateScreen && !visibleState?.isAlternateScreen) { return preview } - let lines = visibleState?.lines ?? [] - if (lines.length === 0) { - lines = await this.readRendererVisibleSnapshotLines(ptyId) + let projection: RuntimeTerminalProjection = visibleState ?? { lines: [] } + if (projection.lines.length === 0) { + projection = await this.readRendererVisibleSnapshotLines(ptyId) } - return lines.length > 0 ? buildPreview(lines, '') : preview + return projection.lines.length > 0 ? buildPreview(projection.lines, '') : preview } private async readVisibleTerminalState( @@ -14208,7 +14220,7 @@ export class OrcaRuntimeService { return liveState } } - const lines = await this.parseVisibleSnapshotLines(snapshot) + const projection = await this.parseVisibleSnapshot(snapshot) if ( this.getPtyLifecycleGeneration(ptyId) !== generation || this.getPtyOutputSequence(ptyId) > snapshot.seq @@ -14216,7 +14228,8 @@ export class OrcaRuntimeService { return null } const visibleState: RuntimeVisibleTerminalState = { - lines, + lines: projection.lines, + ...(projection.draft ? { draft: projection.draft } : {}), isAlternateScreen: snapshot.alternateScreen ?? false, sequence: snapshot.seq, generation @@ -14240,21 +14253,23 @@ export class OrcaRuntimeService { ) { return null } + const projection = projectVisibleTerminalLines(state.emulator) return { - lines: visibleNonBlankTerminalLines(state.emulator.getVisibleLines()), + lines: projection.lines, + ...(projection.draft ? { draft: projection.draft } : {}), isAlternateScreen: state.emulator.isAlternateScreen, sequence: state.outputSequence, generation } } - private async parseVisibleSnapshotLines(snapshot: { + private async parseVisibleSnapshot(snapshot: { data: string cols: number rows: number - }): Promise { + }): Promise<{ lines: string[]; draft?: string }> { if (snapshot.data.length === 0) { - return [] + return { lines: [] } } const emulator = new HeadlessEmulator({ cols: snapshot.cols, @@ -14263,19 +14278,21 @@ export class OrcaRuntimeService { }) try { await emulator.write(`\x1b[2J\x1b[3J\x1b[H${snapshot.data}`) - return visibleNonBlankTerminalLines(emulator.getVisibleLines()) + return projectVisibleTerminalLines(emulator) } finally { emulator.dispose() } } - private async readRendererVisibleSnapshotLines(ptyId: string): Promise { + private async readRendererVisibleSnapshotLines( + ptyId: string + ): Promise { const controller = this.ptyController if (!controller?.serializeBuffer) { - return [] + return { lines: [] } } if (controller.hasRendererSerializer && !controller.hasRendererSerializer(ptyId)) { - return [] + return { lines: [] } } try { // Why: raw PTY tails can be whitespace-only while a full-screen TUI is @@ -14290,11 +14307,11 @@ export class OrcaRuntimeService { null ) if (!snapshot || snapshot.data.length === 0) { - return [] + return { lines: [] } } - return this.parseVisibleSnapshotLines(snapshot) + return this.parseVisibleSnapshot(snapshot) } catch { - return [] + return { lines: [] } } } @@ -19275,12 +19292,11 @@ export class OrcaRuntimeService { opts: { limit?: number } = {} ): Promise { const visibleState = await this.readVisibleTerminalState(ptyId) - const lines = - visibleState?.lines ?? (await this.readProviderTerminalTailLines(ptyId, opts.limit)) - if (lines.length === 0) { + const projection = visibleState ?? (await this.readProviderTerminalTailLines(ptyId, opts.limit)) + if (projection.lines.length === 0) { return { ...read, source: 'screen-unavailable' } } - return buildVisibleSnapshotReadFallback(read, lines, opts.limit) + return buildVisibleSnapshotReadFallback(read, projection.lines, opts.limit, projection.draft) } // Why a cache: leaf-branch sends may arrive per keystroke; one proven-absent @@ -40973,6 +40989,46 @@ function visibleNonBlankTerminalLines(lines: string[]): string[] { return lines.map((line) => line.trimEnd()).filter((line) => line.trim().length > 0) } +function projectVisibleTerminalLines(emulator: HeadlessEmulator): { + lines: string[] + draft?: string +} { + const lines = emulator.getVisibleLines() + const draft = detectTerminalComposerDraft(emulator.getCursorLineContext()) + if (draft) { + lines[draft.promptRow] = draft.promptGlyph + for (let row = draft.promptRow + 1; row <= draft.endRow; row += 1) { + lines[row] = '' + } + } + return { + lines: visibleNonBlankTerminalLines(lines), + ...(draft ? { draft: draft.text } : {}) + } +} + +export function projectTerminalTailLines( + emulator: HeadlessEmulator, + limit: number +): RuntimeTerminalProjection { + const tail = emulator.getBufferTailLines(limit) + const visible = emulator.getVisibleLines() + const visibleRange = emulator.getVisibleBufferRange() + const draft = detectTerminalComposerDraft(emulator.getCursorLineContext()) + if (draft && visibleRange.endExclusive === visibleRange.totalLength) { + visible[draft.promptRow] = draft.promptGlyph + for (let row = draft.promptRow + 1; row <= draft.endRow; row += 1) { + visible[row] = '' + } + const scrollbackTail = tail.slice(0, Math.max(0, tail.length - visible.length)) + tail.splice(0, tail.length, ...scrollbackTail, ...visibleNonBlankTerminalLines(visible)) + } + return { + lines: visibleNonBlankTerminalLines(tail).slice(-limit), + ...(draft ? { draft: draft.text } : {}) + } +} + // Why: every read carries its source, so a caller that asked for a screen and got a response // with no source at all knows it reached a host that predates screen reads โ€” rather than // mistaking the stream for the screen. Rendered lines only ever enter a read through @@ -40985,7 +41041,8 @@ function labelTerminalReadSource(resolved: RuntimeTerminalRead): RuntimeTerminal function buildVisibleSnapshotReadFallback( read: RuntimeTerminalRead, visibleLines: string[], - limit: number | undefined + limit: number | undefined, + draft?: string ): RuntimeTerminalRead { const lineLimit = terminalReadLimit(limit, DEFAULT_TERMINAL_READ_LIMIT) const lineBoundedTail = visibleLines.slice(-lineLimit) @@ -40999,7 +41056,8 @@ function buildVisibleSnapshotReadFallback( limited: read.limited || lineBoundedTail.length < visibleLines.length || charBoundedTail.limited, returnedLineCount: charBoundedTail.tail.length, - source: 'screen' + source: 'screen', + ...(draft ? { draft } : {}) } } diff --git a/src/main/runtime/orchestration/worker-output-archive.ts b/src/main/runtime/orchestration/worker-output-archive.ts index c6fe9970feb..f6f2b52ecb1 100644 --- a/src/main/runtime/orchestration/worker-output-archive.ts +++ b/src/main/runtime/orchestration/worker-output-archive.ts @@ -31,6 +31,7 @@ export type WorkerTranscriptSnapshotArchive = { export type WorkerTerminalTailArchive = { lines: string[] + draft?: string truncated: boolean terminalStatus: string warnings: string[] @@ -85,8 +86,13 @@ export async function captureWorkerOutputArchive(args: { `Output could not be preserved for Dispatch ${args.dispatchId}; the terminal was retained. ${error instanceof Error ? error.message : String(error)}` ) } - const redacted = redactWorkerTerminalLines(terminal.tail) + const redacted = redactWorkerTerminalLines([ + ...terminal.tail, + ...(terminal.draft ? [terminal.draft] : []) + ]) const bounded = boundArchiveLines(redacted.lines) + const draft = terminal.draft ? bounded.lines.at(-1) : undefined + const lines = terminal.draft ? bounded.lines.slice(0, -1) : bounded.lines // Why: an exited PTY zeroes its tail immediately, so an empty capture is a distinct receipt, // not silent success โ€” worker-read must be able to say why nothing is there. const empty = bounded.lines.every((line) => line.trim() === '') @@ -94,7 +100,8 @@ export async function captureWorkerOutputArchive(args: { kind: 'terminal_tail', status: empty ? 'empty' : 'captured', content: { - lines: bounded.lines, + lines, + ...(draft ? { draft } : {}), truncated: terminal.truncated || bounded.truncated, terminalStatus: terminal.status, warnings: empty diff --git a/src/main/runtime/rpc/methods/orchestration-worker-archive-read.ts b/src/main/runtime/rpc/methods/orchestration-worker-archive-read.ts index 29e01e45558..1830944c98c 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-archive-read.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-archive-read.ts @@ -206,6 +206,7 @@ function readArchivedTerminalTail( handle: args.resource.terminal_handle, status: 'exited', tail, + ...(!cursor && content.draft ? { draft: content.draft } : {}), truncated: content.truncated, nextCursor, returnedLineCount: tail.length diff --git a/src/main/runtime/rpc/methods/orchestration-worker-output.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-output.test.ts index 0e24a6bced8..d1349c92454 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-output.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-output.test.ts @@ -157,6 +157,31 @@ describe('exact orchestration worker output', () => { expect(JSON.stringify(result)).not.toContain(capability) }) + it('redacts dispatch capabilities from terminal composer drafts', async () => { + const capability = `dcap_${'A'.repeat(43)}` + readTerminal.mockResolvedValue({ + handle: 'term_worker', + status: 'running', + tail: ['safe output'], + draft: `send --dispatch-capability ${capability}`, + truncated: false, + nextCursor: '9' + }) + providerSession = null + + const result = await read() + + expect(result).toMatchObject({ + source: 'terminal', + terminal: { + tail: ['safe output'], + draft: 'send --dispatch-capability [dispatch capability redacted]' + }, + warnings: ['Dispatch capability tokens were redacted from terminal output.'] + }) + expect(JSON.stringify(result)).not.toContain(capability) + }) + it('rejects an old cursor after the exact provider session changes', async () => { const initial = await read() if (initial.source !== 'transcript') { diff --git a/src/main/runtime/rpc/methods/orchestration-worker-output.ts b/src/main/runtime/rpc/methods/orchestration-worker-output.ts index bd63da46546..b559b4cae6a 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-output.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-output.ts @@ -135,7 +135,12 @@ async function readTerminalOutput( cursor: cursor?.source === 'terminal' ? cursor.position : undefined, limit: args.limit }) - const redactedTerminal = redactWorkerTerminalLines(terminal.tail) + const redactedTerminal = redactWorkerTerminalLines([ + ...terminal.tail, + ...(terminal.draft ? [terminal.draft] : []) + ]) + const redactedTail = redactedTerminal.lines.slice(0, terminal.tail.length) + const redactedDraft = terminal.draft ? redactedTerminal.lines.at(-1) : undefined const position = terminal.nextCursor !== null && /^\d+$/.test(terminal.nextCursor) ? Number.parseInt(terminal.nextCursor, 10) @@ -148,7 +153,11 @@ async function readTerminalOutput( dispatchId: args.dispatchId, source: 'terminal', sourceIdentity, - terminal: { ...terminal, tail: redactedTerminal.lines }, + terminal: { + ...terminal, + tail: redactedTail, + ...(redactedDraft ? { draft: redactedDraft } : {}) + }, cursor: nextCursor, status: { worker: args.workerState, diff --git a/src/main/runtime/rpc/methods/orchestration-worker-release.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-release.test.ts index 9563a848880..b32fb7756bd 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-release.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-release.test.ts @@ -587,6 +587,7 @@ describe('orchestration worker release', () => { handle: 'term_worker', status: 'running', tail: ['first line', `capability dcap_${'a'.repeat(24)} leaked`, 'last line'], + draft: `send --dispatch-capability dcap_${'b'.repeat(24)}`, truncated: false, nextCursor: '3' }) @@ -596,19 +597,24 @@ describe('orchestration worker release', () => { const page1 = (await call('orchestration.workerRead', { dispatch: dispatchId, limit: 2 - })) as { archived?: boolean; terminal: { tail: string[] }; cursor: string | null } - expect(page1.archived).toBe(true) + })) as { + archived?: boolean + terminal: { tail: string[]; draft?: string } + cursor: string | null + } expect(page1.terminal.tail).toEqual([ 'first line', 'capability [dispatch capability redacted] leaked' ]) + expect(page1.terminal.draft).toBe('send --dispatch-capability [dispatch capability redacted]') expect(page1.cursor).not.toBeNull() const page2 = (await call('orchestration.workerRead', { dispatch: dispatchId, cursor: page1.cursor as string - })) as { terminal: { tail: string[] }; cursor: string | null } + })) as { terminal: { tail: string[]; draft?: string }; cursor: string | null } expect(page2.terminal.tail).toEqual(['last line']) + expect(page2.terminal.draft).toBeUndefined() expect(page2.cursor).toBeNull() // The live terminal is never consulted after release. expect(runtime.readTerminal).not.toHaveBeenCalled() diff --git a/src/main/runtime/terminal-projection.test.ts b/src/main/runtime/terminal-projection.test.ts new file mode 100644 index 00000000000..7dff087e74c --- /dev/null +++ b/src/main/runtime/terminal-projection.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import type { TerminalCursorContext } from '../../shared/terminal-composer-draft' +import type { HeadlessEmulator } from '../daemon/headless-emulator' +import { projectTerminalTailLines } from './orca-runtime' + +describe('projectTerminalTailLines', () => { + it('does not splice a scrolled viewport into unrelated tail rows', () => { + const context: TerminalCursorContext = { + rows: ['โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€', 'โฏ proceed'], + typedRows: ['โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€', 'โฏ'], + promptGlyphBoldRows: [false, false], + rowsBelow: [], + typedRowsBelow: [], + beforeCursor: 'โฏ ', + afterCursor: '', + rawAfterCursor: 'proceed', + cursorHidden: false, + cursorViewportRow: 1 + } + const emulator = { + getBufferTailLines: () => ['old output', 'tail output'], + getVisibleLines: () => ['โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€', 'โฏ proceed'], + getVisibleBufferRange: () => ({ start: 0, endExclusive: 2, totalLength: 10 }), + getCursorLineContext: () => context + } as unknown as HeadlessEmulator + + expect(projectTerminalTailLines(emulator, 2)).toEqual({ + lines: ['old output', 'tail output'], + draft: 'proceed' + }) + }) + + it('backfills the prompt row when a small limit contains only draft continuations', () => { + const context: TerminalCursorContext = { + rows: ['โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€', 'โฏ proceed'], + typedRows: ['โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€', 'โฏ'], + promptGlyphBoldRows: [false, false], + rowsBelow: [' with', ' release'], + typedRowsBelow: ['', ''], + rowsBelowWrapped: [true, true], + beforeCursor: 'โฏ ', + afterCursor: '', + rawAfterCursor: 'proceed', + cursorHidden: false, + cursorViewportRow: 2 + } + const emulator = { + getBufferTailLines: () => [' with', ' release'], + getVisibleLines: () => ['Build passed', 'โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€', 'โฏ proceed', ' with', ' release'], + getVisibleBufferRange: () => ({ start: 0, endExclusive: 5, totalLength: 5 }), + getCursorLineContext: () => context + } as unknown as HeadlessEmulator + + expect(projectTerminalTailLines(emulator, 2)).toEqual({ + lines: ['โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€', 'โฏ'], + draft: 'proceed with release' + }) + }) +}) diff --git a/src/shared/runtime-terminal-contracts.ts b/src/shared/runtime-terminal-contracts.ts index a1692e5f754..02eb5df9ebd 100644 --- a/src/shared/runtime-terminal-contracts.ts +++ b/src/shared/runtime-terminal-contracts.ts @@ -185,6 +185,8 @@ export type RuntimeTerminalRead = { latestCursor?: string returnedLineCount?: number source?: 'stream' | 'screen' | 'screen-unavailable' + /** UI-only composer text, excluded from `tail`. */ + draft?: string } export type RuntimeTerminalRename = { diff --git a/src/shared/terminal-composer-draft.test.ts b/src/shared/terminal-composer-draft.test.ts new file mode 100644 index 00000000000..9e61796c4f9 --- /dev/null +++ b/src/shared/terminal-composer-draft.test.ts @@ -0,0 +1,369 @@ +import { describe, expect, it } from 'vitest' +import { detectTerminalComposerDraft } from './terminal-composer-draft' + +describe('detectTerminalComposerDraft', () => { + it('separates a cursor-right suggestion from the composer line', () => { + expect( + detectTerminalComposerDraft({ + rows: ['โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€', 'โฏ proceed with the release'], + typedRows: ['โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€', 'โฏ'], + promptGlyphBoldRows: [false, false], + rowsBelow: [], + typedRowsBelow: [], + beforeCursor: 'โฏ ', + afterCursor: '', + rawAfterCursor: 'proceed with the release', + cursorHidden: false, + cursorViewportRow: 8 + }) + ).toEqual({ + text: 'proceed with the release', + promptRow: 8, + cursorRow: 8, + endRow: 8, + promptGlyph: 'โฏ' + }) + }) + + it('keeps stock dim placeholders out of draft metadata', () => { + expect( + detectTerminalComposerDraft({ + rows: ['โ€บ Ask Codex to do anything'], + typedRows: ['โ€บ'], + promptGlyphBoldRows: [true], + rowsBelow: [], + typedRowsBelow: [], + beforeCursor: 'โ€บ ', + afterCursor: '', + rawAfterCursor: 'Ask Codex to do anything', + cursorHidden: false, + cursorViewportRow: 4 + }) + ).toBeNull() + }) + + it('does not duplicate real typed text left of the cursor', () => { + expect( + detectTerminalComposerDraft({ + rows: ['โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€', 'โฏ review the change'], + typedRows: ['โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€', 'โฏ review the change'], + promptGlyphBoldRows: [false, false], + rowsBelow: [], + typedRowsBelow: [], + beforeCursor: 'โฏ review the change', + afterCursor: '', + rawAfterCursor: '', + cursorHidden: false, + cursorViewportRow: 2 + })?.text + ).toBe('review the change') + }) + + it('preserves a shell prompt that happens to use the Claude glyph', () => { + expect( + detectTerminalComposerDraft({ + rows: ['last command output', 'โฏ git status'], + typedRows: ['last command output', 'โฏ git status'], + promptGlyphBoldRows: [false, false], + rowsBelow: [], + typedRowsBelow: [], + beforeCursor: 'โฏ git status', + afterCursor: '', + rawAfterCursor: '', + cursorHidden: false, + cursorViewportRow: 7 + }) + ).toBeNull() + }) + + it('rejects a hidden-cursor dialog even when its selected row uses a composer glyph', () => { + expect( + detectTerminalComposerDraft({ + rows: ['โ€บ 1. Yes, continue', ' Press enter to continue'], + typedRows: ['โ€บ 1. Yes, continue', ' Press enter to continue'], + promptGlyphBoldRows: [true, false], + rowsBelow: [], + typedRowsBelow: [], + beforeCursor: ' Press enter to continue', + afterCursor: '', + rawAfterCursor: '', + cursorHidden: true, + cursorViewportRow: 5 + }) + ).toBeNull() + }) + + it('separates dimmed suggestion rows below the restored cursor', () => { + expect( + detectTerminalComposerDraft({ + rows: ['โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€', 'โฏ proceed with the release'], + typedRows: ['โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€', 'โฏ'], + promptGlyphBoldRows: [false, false], + rowsBelow: [' and close the pull request', 'โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€'], + typedRowsBelow: ['', 'โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€'], + beforeCursor: 'โฏ ', + afterCursor: '', + rawAfterCursor: 'proceed with the release', + cursorHidden: false, + cursorViewportRow: 8 + }) + ).toEqual({ + text: 'proceed with the release\nand close the pull request', + promptRow: 8, + cursorRow: 8, + endRow: 9, + promptGlyph: 'โฏ' + }) + }) + + it('recognizes the bold Codex ultra composer glyph', () => { + expect( + detectTerminalComposerDraft({ + rows: ['ยป review the change'], + typedRows: ['ยป review the change'], + promptGlyphBoldRows: [true], + rowsBelow: ['gpt-5.6 ยท ~/repo'], + typedRowsBelow: ['gpt-5.6 ยท ~/repo'], + beforeCursor: 'ยป review the change', + afterCursor: '', + rawAfterCursor: '', + cursorHidden: false, + cursorViewportRow: 4 + }) + ).toMatchObject({ text: 'review the change', promptGlyph: 'ยป' }) + }) + + it('recognizes the bold Codex composer when its footer is visible', () => { + expect( + detectTerminalComposerDraft({ + rows: ['โ€บ review the change'], + typedRows: ['โ€บ review the change'], + promptGlyphBoldRows: [true], + rowsBelow: ['gpt-5.6 ยท ~/repo'], + typedRowsBelow: ['gpt-5.6 ยท ~/repo'], + beforeCursor: 'โ€บ review the change', + afterCursor: '', + rawAfterCursor: '', + cursorHidden: false, + cursorViewportRow: 4 + }) + ).toMatchObject({ text: 'review the change', promptGlyph: 'โ€บ' }) + }) + + it('recognizes a Codex composer with a context-only status footer', () => { + expect( + detectTerminalComposerDraft({ + rows: ['โ€บ review the change'], + typedRows: ['โ€บ review the change'], + promptGlyphBoldRows: [true], + rowsBelow: ['', ' Context 12% used'], + typedRowsBelow: ['', ''], + rowsBelowWrapped: [false, false], + beforeCursor: 'โ€บ review the change', + afterCursor: '', + rawAfterCursor: '', + cursorHidden: false, + cursorViewportRow: 4 + }) + ).toMatchObject({ text: 'review the change', promptGlyph: 'โ€บ' }) + }) + + it('recognizes a Codex composer with an arbitrary dimmed thread-title footer', () => { + expect( + detectTerminalComposerDraft({ + rows: ['โ€บ review the change'], + typedRows: ['โ€บ review the change'], + promptGlyphBoldRows: [true], + rowsBelow: ['', ' Release train'], + typedRowsBelow: ['', ''], + rowsBelowWrapped: [false, false], + beforeCursor: 'โ€บ review the change', + afterCursor: '', + rawAfterCursor: '', + cursorHidden: false, + cursorViewportRow: 4 + }) + ).toMatchObject({ text: 'review the change', promptGlyph: 'โ€บ' }) + }) + + it('preserves an ordinary shell prompt that uses the Codex glyph', () => { + expect( + detectTerminalComposerDraft({ + rows: ['last command output', 'โ€บ git status'], + typedRows: ['last command output', 'โ€บ git status'], + promptGlyphBoldRows: [false, true], + rowsBelow: [], + typedRowsBelow: [], + beforeCursor: 'โ€บ git status', + afterCursor: '', + rawAfterCursor: '', + cursorHidden: false, + cursorViewportRow: 7 + }) + ).toBeNull() + }) + + it('keeps the side-thread placeholder out of draft metadata', () => { + expect( + detectTerminalComposerDraft({ + rows: ['โ€บ Ask a follow-up question'], + typedRows: ['โ€บ'], + promptGlyphBoldRows: [true], + rowsBelow: [], + typedRowsBelow: [], + beforeCursor: 'โ€บ ', + afterCursor: '', + rawAfterCursor: 'Ask a follow-up question', + cursorHidden: false, + cursorViewportRow: 4 + }) + ).toBeNull() + }) + + it('keeps a wrapped stock placeholder out of draft metadata', () => { + expect( + detectTerminalComposerDraft({ + rows: ['โ€บ Ask Codex to do'], + typedRows: ['โ€บ'], + promptGlyphBoldRows: [true], + rowsBelow: [' anything'], + typedRowsBelow: [''], + beforeCursor: 'โ€บ ', + afterCursor: '', + rawAfterCursor: 'Ask Codex to do', + cursorHidden: false, + cursorViewportRow: 4 + }) + ).toBeNull() + }) + + it('joins soft-wrapped continuation rows without inserting a newline', () => { + expect( + detectTerminalComposerDraft({ + rows: ['โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€', 'โฏ proceed with the'], + typedRows: ['โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€', 'โฏ'], + promptGlyphBoldRows: [false, false], + rowsWrapped: [false, false], + rowsBelow: ['release'], + typedRowsBelow: [''], + rowsBelowWrapped: [true], + beforeCursor: 'โฏ ', + afterCursor: '', + rawAfterCursor: 'proceed with the ', + cursorHidden: false, + cursorViewportRow: 8 + })?.text + ).toBe('proceed with the release') + }) + + it('keeps a draft continuation containing a middle dot', () => { + expect( + detectTerminalComposerDraft({ + rows: ['โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€', 'โฏ proceed '], + typedRows: ['โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€', 'โฏ'], + promptGlyphBoldRows: [false, false], + rowsWrapped: [false, false], + rowsBelow: ['deploy ยท verify', 'โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€'], + typedRowsBelow: ['', 'โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€'], + rowsBelowWrapped: [true, false], + beforeCursor: 'โฏ ', + afterCursor: 'proceed ', + rawAfterCursor: 'proceed ', + cursorHidden: false, + cursorViewportRow: 8 + })?.text + ).toBe('proceed deploy ยท verify') + }) + + it('keeps a model-like continuation before the Codex status footer', () => { + expect( + detectTerminalComposerDraft({ + rows: ['โ€บ compare '], + typedRows: ['โ€บ compare '], + promptGlyphBoldRows: [true], + rowsBelow: ['gpt-5 ยท verify', 'gpt-5.6 ยท Context 12% used'], + typedRowsBelow: ['gpt-5 ยท verify', 'gpt-5.6 ยท Context 12% used'], + rowsBelowWrapped: [false, false], + beforeCursor: 'โ€บ compare ', + afterCursor: '', + rawAfterCursor: '', + cursorHidden: false, + cursorViewportRow: 4 + })?.text + ).toBe('compare\ngpt-5 ยท verify') + }) + + it('keeps typed soft-wrapped rows below a restored cursor in the draft', () => { + expect( + detectTerminalComposerDraft({ + rows: ['โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€', 'โฏ proceed with the '], + typedRows: ['โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€', 'โฏ proceed with the '], + promptGlyphBoldRows: [false, false], + rowsWrapped: [false, false], + rowsBelow: ['release'], + typedRowsBelow: ['release'], + rowsBelowWrapped: [true], + beforeCursor: 'โฏ proceed', + afterCursor: ' with the ', + rawAfterCursor: ' with the ', + cursorHidden: false, + cursorViewportRow: 8 + })?.text + ).toBe('proceed with the release') + }) + + it('keeps typed hard-newline rows below a restored cursor in the draft', () => { + expect( + detectTerminalComposerDraft({ + rows: ['โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€', 'โฏ first line'], + typedRows: ['โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€', 'โฏ first line'], + promptGlyphBoldRows: [false, false], + rowsWrapped: [false, false], + rowsBelow: [' second line', 'โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€'], + typedRowsBelow: [' second line', 'โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€'], + rowsBelowWrapped: [false, false], + beforeCursor: 'โฏ first', + afterCursor: ' line', + rawAfterCursor: ' line', + cursorHidden: false, + cursorViewportRow: 8 + })?.text + ).toBe('first line\nsecond line') + }) + + it('keeps typed continuation rows after an intentional blank draft line', () => { + expect( + detectTerminalComposerDraft({ + rows: ['โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€', 'โฏ first line'], + typedRows: ['โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€', 'โฏ first line'], + promptGlyphBoldRows: [false, false], + rowsWrapped: [false, false], + rowsBelow: ['', ' second line', 'โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€'], + typedRowsBelow: ['', ' second line', 'โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€'], + rowsBelowWrapped: [false, false, false], + beforeCursor: 'โฏ first', + afterCursor: ' line', + rawAfterCursor: ' line', + cursorHidden: false, + cursorViewportRow: 8 + }) + ).toMatchObject({ text: 'first line\n\nsecond line', endRow: 10 }) + }) + + it('stops before a blank row that only precedes the composer frame', () => { + expect( + detectTerminalComposerDraft({ + rows: ['โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€', 'โฏ first line'], + typedRows: ['โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€', 'โฏ first line'], + promptGlyphBoldRows: [false, false], + rowsBelow: ['', 'โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€'], + typedRowsBelow: ['', 'โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€'], + beforeCursor: 'โฏ first line', + afterCursor: '', + rawAfterCursor: '', + cursorHidden: false, + cursorViewportRow: 8 + }) + ).toMatchObject({ text: 'first line', endRow: 8 }) + }) +}) diff --git a/src/shared/terminal-composer-draft.ts b/src/shared/terminal-composer-draft.ts new file mode 100644 index 00000000000..8acb0fc3b72 --- /dev/null +++ b/src/shared/terminal-composer-draft.ts @@ -0,0 +1,173 @@ +export type TerminalCursorContext = { + rows: string[] + typedRows: string[] + promptGlyphBoldRows: boolean[] + rowsWrapped?: boolean[] + rowsBelow: string[] + typedRowsBelow: string[] + rowsBelowWrapped?: boolean[] + rowsBelowCustomForeground?: boolean[] + beforeCursor: string + afterCursor: string + rawAfterCursor: string + cursorHidden: boolean + cursorViewportRow: number +} + +export type TerminalComposerDraft = { + text: string + promptRow: number + cursorRow: number + endRow: number + promptGlyph: 'โฏ' | 'โ€บ' | 'ยป' +} + +const COMPOSER_FRAME_LINE = /^[โ”€โ”-]{8,}\s*$/ +const CODEX_FOOTER_LINE = /^\s*(?:gpt-\S+|o\d\S*)\s+[ยทโ€ข]\s+\S.*$/i + +function composerContinuationRows( + context: TerminalCursorContext, + afterCursor: string, + codexFooterIndex: number +): { text: string; wrapped: boolean }[] { + if (!afterCursor.trim() && !context.typedRowsBelow.some((row) => row.trim())) { + return [] + } + const continuation: { text: string; wrapped: boolean }[] = [] + const hasTypedContinuationAfter = context.rowsBelow.map(() => false) + let hasFollowingTyped = false + for (let index = context.rowsBelow.length - 1; index >= 0; index -= 1) { + const raw = context.rowsBelow[index] ?? '' + if (COMPOSER_FRAME_LINE.test(raw) || index === codexFooterIndex) { + hasFollowingTyped = false + continue + } + hasTypedContinuationAfter[index] = hasFollowingTyped + if ((context.typedRowsBelow[index] ?? '').trim()) { + hasFollowingTyped = true + } + } + for (let index = 0; index < context.rowsBelow.length; index += 1) { + const raw = context.rowsBelow[index] ?? '' + if (COMPOSER_FRAME_LINE.test(raw) || index === codexFooterIndex) { + break + } + if (!raw.trim() && !hasTypedContinuationAfter[index]) { + break + } + const typed = context.typedRowsBelow[index] ?? '' + continuation.push({ + text: typed.trim() ? typed : raw, + wrapped: context.rowsBelowWrapped?.[index] ?? false + }) + } + return continuation +} + +function findCodexFooterIndex(context: TerminalCursorContext): number { + for (let index = context.rowsBelow.length - 1; index >= 0; index -= 1) { + const row = context.rowsBelow[index] ?? '' + if (!row.trim()) { + continue + } + const undimmed = context.typedRowsBelow[index] ?? '' + const hasFooterGap = index > 0 && !(context.rowsBelow[index - 1] ?? '').trim() + const isDimmedFooter = + hasFooterGap && !undimmed.trim() && context.rowsBelowWrapped?.[index] === false + const isColoredFooter = + hasFooterGap && + context.rowsBelowCustomForeground?.[index] === true && + context.rowsBelowWrapped?.[index] === false + return isDimmedFooter || isColoredFooter || CODEX_FOOTER_LINE.test(row) ? index : -1 + } + return -1 +} + +function isStockPlaceholder( + afterCursor: string, + continuationRows: { text: string; wrapped: boolean }[] +): boolean { + const text = [afterCursor, ...continuationRows.map((row) => row.text)] + .join(' ') + .replace(/\s+/g, ' ') + .trim() + return ( + /^Try\s+["โ€œ]/.test(text) || + text === 'Ask Codex to do anything' || + text === 'Ask a follow-up question' + ) +} + +export function detectTerminalComposerDraft( + context: TerminalCursorContext | null | undefined +): TerminalComposerDraft | null { + if (!context || context.cursorHidden || context.rows.length === 0) { + return null + } + const cursorIndex = context.rows.length - 1 + const codexFooterIndex = findCodexFooterIndex(context) + let afterCursor = context.afterCursor || context.rawAfterCursor + let continuationRows = composerContinuationRows(context, afterCursor, codexFooterIndex) + if (isStockPlaceholder(afterCursor, continuationRows)) { + afterCursor = '' + continuationRows = [] + } + const cursorText = `${context.beforeCursor}${afterCursor}` + for (let index = cursorIndex; index >= 0; index -= 1) { + const row = context.rows[index] ?? '' + const glyph = row.match(/^\s*([โฏโ€บยป])/)?.[1] as 'โฏ' | 'โ€บ' | 'ยป' | undefined + if (glyph) { + if (glyph === 'โฏ' && !COMPOSER_FRAME_LINE.test(context.rows[index - 1] ?? '')) { + return null + } + if ( + (glyph === 'โ€บ' || glyph === 'ยป') && + (context.promptGlyphBoldRows[index] !== true || codexFooterIndex === -1) + ) { + return null + } + const lines: { text: string; wrapped: boolean }[] = + index === cursorIndex + ? [{ text: cursorText.replace(/^\s*[โฏโ€บยป]\s?/, ''), wrapped: false }, ...continuationRows] + : [ + { + text: (context.typedRows[index] ?? row).replace(/^\s*[โฏโ€บยป]\s?/, ''), + wrapped: false + }, + ...context.typedRows.slice(index + 1, cursorIndex).map((text, offset) => ({ + text, + wrapped: context.rowsWrapped?.[index + 1 + offset] ?? false + })), + { + text: cursorText, + wrapped: context.rowsWrapped?.[cursorIndex] ?? false + }, + ...continuationRows + ] + const text = lines + .map((line, lineIndex) => { + const continuesPrevious = lineIndex > 0 && line.wrapped + const continuesNext = lines[lineIndex + 1]?.wrapped ?? false + const start = continuesPrevious ? '' : lineIndex > 0 ? '\n' : '' + const content = continuesPrevious ? line.text : line.text.trimStart() + return `${start}${continuesNext ? content : content.trimEnd()}` + }) + .join('') + .trim() + if (!text) { + return null + } + return { + text, + promptRow: context.cursorViewportRow - (cursorIndex - index), + cursorRow: context.cursorViewportRow, + endRow: context.cursorViewportRow + continuationRows.length, + promptGlyph: glyph + } + } + if (row.length > 0 && context.rowsWrapped?.[index] !== true && !/^\s/.test(row)) { + return null + } + } + return null +} From efb4050f4da3a3ffb30c6bfa02a9b9be6f9d5592 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:33:26 -0700 Subject: [PATCH 04/29] fix(ai-vault): index Cline sessions (#16814) * fix(ai-vault): index Cline sessions * fix(ai-vault): constrain Cline session discovery --- .../remote-session-scanner-cline-source.ts | 42 +++++++ .../remote-session-scanner-discovery.ts | 53 ++++++-- .../remote-session-scanner-sources.ts | 2 + .../ai-vault/remote-session-scanner-types.ts | 1 + .../ai-vault/remote-session-scanner.test.ts | 92 ++++++++++++++ .../ai-vault/session-delete-target.test.ts | 31 +++++ src/main/ai-vault/session-delete-target.ts | 6 +- .../ai-vault/session-scanner-agent-parser.ts | 3 + .../ai-vault/session-scanner-agent-sources.ts | 21 ++++ .../session-scanner-cline-parser.test.ts | 117 ++++++++++++++++++ .../ai-vault/session-scanner-cline-parser.ts | 116 +++++++++++++++++ .../ai-vault/session-scanner-discovery.ts | 26 +++- .../ai-vault/session-scanner-parse-cache.ts | 1 + .../session-scanner-service-env.test.ts | 2 + .../ai-vault/session-scanner-service-env.ts | 1 + .../session-scanner-source-discovery.ts | 1 + .../ai-vault/session-scanner-test-fixtures.ts | 1 + src/main/ai-vault/session-scanner-types.ts | 1 + src/main/ai-vault/session-scanner.test.ts | 21 ++++ src/shared/ai-vault-resume-command.ts | 2 + src/shared/ai-vault-session-deletion.ts | 3 +- src/shared/ai-vault-types.ts | 2 + 22 files changed, 528 insertions(+), 17 deletions(-) create mode 100644 src/main/ai-vault/remote-session-scanner-cline-source.ts create mode 100644 src/main/ai-vault/session-scanner-cline-parser.test.ts create mode 100644 src/main/ai-vault/session-scanner-cline-parser.ts diff --git a/src/main/ai-vault/remote-session-scanner-cline-source.ts b/src/main/ai-vault/remote-session-scanner-cline-source.ts new file mode 100644 index 00000000000..f2f79089f9a --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-cline-source.ts @@ -0,0 +1,42 @@ +import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { joinRemotePath } from '../ssh/ssh-remote-platform' +import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' +import { isMissingRemoteSessionPathError } from './remote-session-file-stat' +import type { RemoteSessionSource } from './remote-session-scanner-types' +import { + clineMessagesPathForMetadata, + isClineSessionMetadataPath, + parseClineSessionContent +} from './session-scanner-cline-parser' + +export function remoteClineSource( + remoteHome: string, + hostPlatform: RemoteHostPlatform +): RemoteSessionSource { + return { + agent: 'cline', + rootDir: joinRemotePath(hostPlatform, remoteHome, '.cline', 'data', 'sessions'), + extensions: ['.json'], + filePredicate: isClineSessionMetadataPath, + contentDependencyPath: clineMessagesPathForMetadata, + directoryPredicate: (_name, depth) => depth === 0, + parse: async (file, content, context) => { + let messagesContent: string | null = null + try { + throwIfAiVaultScanCancelled(context.signal) + const read = await context.provider.readFile(clineMessagesPathForMetadata(file.path)) + throwIfAiVaultScanCancelled(context.signal) + messagesContent = read.isBinary ? null : read.content + } catch (error) { + throwIfAiVaultScanCancelled(context.signal) + if (!isMissingRemoteSessionPathError(error)) { + throw error + } + } + return parseClineSessionContent(file, content, messagesContent, context.hostPlatform.os, { + executionHostId: context.executionHostId, + executionHostPlatform: context.hostPlatform.os + }) + } + } +} diff --git a/src/main/ai-vault/remote-session-scanner-discovery.ts b/src/main/ai-vault/remote-session-scanner-discovery.ts index 329bdb2e584..159bf049da6 100644 --- a/src/main/ai-vault/remote-session-scanner-discovery.ts +++ b/src/main/ai-vault/remote-session-scanner-discovery.ts @@ -29,18 +29,7 @@ export async function discoverRemoteSourceCandidates(args: { const files = await mapRemoteScanBatches( paths, REMOTE_DISCOVERY_CONCURRENCY, - (path) => - statRemoteSessionFile( - args.context.provider, - path, - args.source.agent, - args.context.executionHostId, - args.issues, - { - missingIsExpected: Boolean(args.source.fixedChildFileSegments), - signal: args.context.signal - } - ), + (path) => statRemoteCandidateFile(path, args.source, args.context, args.issues), args.context.signal ) return files @@ -52,6 +41,46 @@ export async function discoverRemoteSourceCandidates(args: { })) } +async function statRemoteCandidateFile( + path: string, + source: RemoteSessionSource, + context: RemoteScannerContext, + issues: AiVaultScanIssue[] +): Promise { + const file = await statRemoteSessionFile( + context.provider, + path, + source.agent, + context.executionHostId, + issues, + { + missingIsExpected: Boolean(source.fixedChildFileSegments), + signal: context.signal + } + ) + if (!file || !source.contentDependencyPath) { + return file + } + const dependency = await statRemoteSessionFile( + context.provider, + source.contentDependencyPath(path), + source.agent, + context.executionHostId, + issues, + { missingIsExpected: true, signal: context.signal } + ) + if (!dependency) { + return file + } + const mtimeMs = Math.max(file.mtimeMs, dependency.mtimeMs) + return { + ...file, + mtimeMs, + modifiedAt: new Date(mtimeMs).toISOString(), + sizeBytes: (file.sizeBytes ?? 0) + (dependency.sizeBytes ?? 0) + } +} + async function listRemoteFixedChildFiles( source: RemoteSessionSource, context: RemoteScannerContext, diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts index 8bd9fdaf61d..1fd0ca30a61 100644 --- a/src/main/ai-vault/remote-session-scanner-sources.ts +++ b/src/main/ai-vault/remote-session-scanner-sources.ts @@ -17,6 +17,7 @@ import { partitionOmpSubagentTranscriptPaths } from './session-scanner-omp-subag import type { FileWithMtime } from './session-scanner-types' import { normalizeAgentSessionsDir } from './session-scanner-values' import { remoteCodexIndexTitles } from './remote-session-scanner-codex-index' +import { remoteClineSource } from './remote-session-scanner-cline-source' import type { RemoteParserOptions, RemoteScannerContext, @@ -77,6 +78,7 @@ export function remoteSessionSources( parseCursorSessionContent, (path) => remotePathSegments(path).includes('agent-transcripts') ), + remoteClineSource(remoteHome, hostPlatform), source( 'hermes', remoteHome, diff --git a/src/main/ai-vault/remote-session-scanner-types.ts b/src/main/ai-vault/remote-session-scanner-types.ts index 8c9774abe14..405ea7f4626 100644 --- a/src/main/ai-vault/remote-session-scanner-types.ts +++ b/src/main/ai-vault/remote-session-scanner-types.ts @@ -33,6 +33,7 @@ export type RemoteSessionSource = { codexHome?: string extensions: readonly string[] filePredicate?: (path: string) => boolean + contentDependencyPath?: (path: string) => string // Depth 0 denotes a direct child of rootDir. directoryPredicate?: (name: string, depth: number) => boolean // A canonical file directly beneath every top-level session directory. diff --git a/src/main/ai-vault/remote-session-scanner.test.ts b/src/main/ai-vault/remote-session-scanner.test.ts index 7884fa9461d..c1c99bcbf12 100644 --- a/src/main/ai-vault/remote-session-scanner.test.ts +++ b/src/main/ai-vault/remote-session-scanner.test.ts @@ -5,6 +5,98 @@ import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-f import { primeAgentFixture } from './session-scanner-prime-agent-fixtures' describe('scanRemoteAiVaultSessions', () => { + it('indexes Cline manifests on the SSH-owned disk without messages-file phantoms', async () => { + const provider = new MemoryRemoteProvider() + const sessionId = '1786466194549_xrzrl' + const sessionDir = `/home/ada/.cline/data/sessions/${sessionId}` + provider.addFile( + `${sessionDir}/${sessionId}.json`, + JSON.stringify({ + version: 1, + session_id: sessionId, + started_at: '2026-08-11T16:36:34.551Z', + provider: 'deepseek', + model: 'deepseek-v4-flash', + cwd: '/home/ada/repo' + }), + 10 + ) + provider.addFile( + `${sessionDir}/${sessionId}.messages.json`, + JSON.stringify({ + version: 1, + updated_at: '2026-08-11T16:38:00.000Z', + sessionId, + messages: [ + { + role: 'user', + content: [{ type: 'text', text: 'Fix remote Cline history' }] + } + ] + }), + 11 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + agent: 'cline', + sessionId, + title: 'Fix remote Cline history', + cwd: '/home/ada/repo', + executionHostId: 'ssh:dev-box', + executionHostPlatform: 'linux', + filePath: `${sessionDir}/${sessionId}.json` + }) + }) + + it('indexes and resumes Cline sessions from a Windows SSH host', async () => { + const provider = new MemoryRemoteProvider() + const sessionId = '1786466194549_xrzrl' + const sessionDir = `C:/Users/Ada/.cline/data/sessions/${sessionId}` + provider.addFile( + `${sessionDir}/${sessionId}.json`, + JSON.stringify({ + session_id: sessionId, + started_at: '2026-08-11T16:36:34.551Z', + cwd: 'C:/repo/app' + }), + 10 + ) + provider.addFile( + `${sessionDir}/${sessionId}.messages.json`, + JSON.stringify({ + updated_at: '2026-08-11T16:38:00.000Z', + messages: [{ role: 'user', content: [{ type: 'text', text: 'Fix Windows history' }] }] + }), + 11 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:win-box', + remoteHome: 'C:/Users/Ada', + hostPlatform: getRemoteHostPlatform('win32-x64') + }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + agent: 'cline', + sessionId, + executionHostPlatform: 'win32', + filePath: `${sessionDir}/${sessionId}.json`, + resumeCommand: `cmd /d /s /c "cd /d ""C:/repo/app"" && cline --id ""${sessionId}"""` + }) + }) + it('parses remote default and Orca-managed Codex homes with SSH host ids', async () => { const provider = new MemoryRemoteProvider() provider.addFile( diff --git a/src/main/ai-vault/session-delete-target.test.ts b/src/main/ai-vault/session-delete-target.test.ts index 39c95f4f5f6..cf51fecdad9 100644 --- a/src/main/ai-vault/session-delete-target.test.ts +++ b/src/main/ai-vault/session-delete-target.test.ts @@ -19,6 +19,37 @@ const ROVO_ROOT = join(HOME, '.rovodev', 'sessions') const GROK_ROOT = join(HOME, '.grok', 'sessions') describe('validateAiVaultSessionDeleteTarget', () => { + it('allows a canonical Cline manifest and removes its whole session directory', () => { + const root = join('/tmp', 'cline-sessions') + const sessionId = '1786466194549_xrzrl' + const sessionDir = join(root, sessionId) + const result = validateAiVaultSessionDeleteTarget({ + agent: 'cline', + filePath: join(sessionDir, `${sessionId}.json`), + executionHostId: 'local', + rootOptions: { clineSessionsDir: root } + }) + + expect(result).toMatchObject({ + allowed: true, + agent: 'cline', + removals: [{ path: sessionDir, kind: 'directory' }] + }) + }) + + it('rejects a Cline messages companion as an undiscoverable delete target', () => { + const root = join('/tmp', 'cline-sessions') + const sessionId = '1786466194549_xrzrl' + const result = validateAiVaultSessionDeleteTarget({ + agent: 'cline', + filePath: join(root, sessionId, `${sessionId}.messages.json`), + executionHostId: 'local', + rootOptions: { clineSessionsDir: root } + }) + + expect(result).toEqual({ allowed: false, agent: 'cline', reason: 'undiscoverable-path' }) + }) + it('allows a supported agent whose file resolves inside its known root', () => { const result = validateAiVaultSessionDeleteTarget({ agent: 'gemini', diff --git a/src/main/ai-vault/session-delete-target.ts b/src/main/ai-vault/session-delete-target.ts index 11ef38039dc..13320ce39e9 100644 --- a/src/main/ai-vault/session-delete-target.ts +++ b/src/main/ai-vault/session-delete-target.ts @@ -21,7 +21,11 @@ import type { AiVaultScanOptions } from './session-scanner-types' // Agents whose session IS the directory holding the scanned file: everything // beside it belongs to the same session (rovo's session_context.json, grok's // chat_history.jsonl), so the directory is the only complete delete unit. -const AI_VAULT_DIRECTORY_SHAPED_DELETE_AGENTS = new Set(['rovo', 'grok']) +const AI_VAULT_DIRECTORY_SHAPED_DELETE_AGENTS = new Set([ + 'rovo', + 'grok', + 'cline' +]) export type ValidateAiVaultSessionDeleteTargetArgs = { agent: AiVaultAgent diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts index 61554f663fe..83f23943241 100644 --- a/src/main/ai-vault/session-scanner-agent-parser.ts +++ b/src/main/ai-vault/session-scanner-agent-parser.ts @@ -2,6 +2,7 @@ import type { AiVaultSession } from '../../shared/ai-vault-types' import { parseDevinSessionFile } from './session-scanner-devin-parser' import { parseAntigravitySessionFile } from './session-scanner-antigravity-parser' import { parseDroidSessionFile } from './session-scanner-droid-parser' +import { parseClineSessionFile } from './session-scanner-cline-parser' import { parseGrokSessionFile } from './session-scanner-grok-parser' import { parseMessageGraphSessionFile, parseRovoSessionFile } from './session-scanner-graph-parsers' import { parseKimiSessionFile } from './session-scanner-kimi-parser' @@ -72,6 +73,8 @@ export async function parseAgentSessionFile( return parseMessageGraphSessionFile('prime-agent', candidate.file, platform) case 'droid': return parseDroidSessionFile(candidate.file, platform) + case 'cline': + return parseClineSessionFile(candidate.file, platform) case 'devin': return parseDevinSessionFile(candidate.file, platform) case 'kimi': diff --git a/src/main/ai-vault/session-scanner-agent-sources.ts b/src/main/ai-vault/session-scanner-agent-sources.ts index 803bf6a3f07..8b2e66ea5b0 100644 --- a/src/main/ai-vault/session-scanner-agent-sources.ts +++ b/src/main/ai-vault/session-scanner-agent-sources.ts @@ -4,6 +4,10 @@ import type { AiVaultAgent } from '../../shared/ai-vault-types' import type { AiVaultDeletableAgent } from '../../shared/ai-vault-session-deletion' import { resolveGrokSessionsDir } from '../../shared/grok-session-paths' import { uniqueCodexSessionsDirs } from './session-scanner-codex-paths' +import { + clineMessagesPathForMetadata, + isClineSessionMetadataPath +} from './session-scanner-cline-parser' import { resolveKimiSessionsDir } from './session-scanner-kimi-paths' import { OMP_SESSION_ARTIFACT_DIR_PATTERN } from './session-scanner-omp-subagent-transcripts' import { claudeProjectsRootDirs, OMP_SESSIONS_DIR, sessionRootDirs } from './session-scanner-roots' @@ -40,6 +44,8 @@ const DEVIN_TRANSCRIPTS_DIR = join( ) const DROID_SESSIONS_DIR = join(homedir(), '.factory', 'sessions') const DROID_PROJECTS_DIR = join(homedir(), '.factory', 'projects') +const CLINE_SESSIONS_DIR = + process.env.CLINE_SESSION_DATA_DIR?.trim() || join(homedir(), '.cline', 'data', 'sessions') /** * Where one agent's session files live and which of them count as sessions. @@ -55,6 +61,8 @@ export type AiVaultAgentSource = { rootDirs: (options: AiVaultScanOptions, wslHomeDirs: readonly string[]) => string[] extensions: readonly string[] filePredicate?: (filePath: string) => boolean + // A sibling whose stat participates in candidate freshness and recency. + contentDependencyPath?: (filePath: string) => string // Return false to skip a directory; depth 0 is a child of the root. directoryPredicate?: (name: string, depth: number) => boolean // Roots that are alternates for one install rather than distinct locations, @@ -221,6 +229,19 @@ export const AI_VAULT_AGENT_SOURCES: AiVaultAgentSourceTable = { ], extensions: ['.jsonl'] }, + cline: { + rootDirs: (options, wslHomeDirs) => + sessionRootDirs(options.clineSessionsDir ?? CLINE_SESSIONS_DIR, wslHomeDirs, [ + '.cline', + 'data', + 'sessions' + ]), + extensions: ['.json'], + filePredicate: isClineSessionMetadataPath, + contentDependencyPath: clineMessagesPathForMetadata, + // Cline stores one manifest directly beneath each session directory. + directoryPredicate: (_name, depth) => depth === 0 + }, kimi: { rootDirs: (options, wslHomeDirs) => sessionRootDirs(resolveKimiSessionsDir(options.kimiSessionsDir), wslHomeDirs, [ diff --git a/src/main/ai-vault/session-scanner-cline-parser.test.ts b/src/main/ai-vault/session-scanner-cline-parser.test.ts new file mode 100644 index 00000000000..6d96bcb114f --- /dev/null +++ b/src/main/ai-vault/session-scanner-cline-parser.test.ts @@ -0,0 +1,117 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { scanAiVaultSessions } from './session-scanner' +import { isolatedScanRoots } from './session-scanner-test-fixtures' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +describe('Cline AI Vault sessions', () => { + it('indexes one manifest-backed session without surfacing its messages file as a phantom', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-cline-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const sessionId = '1786466194549_xrzrl' + const sessionDir = join(roots.clineSessionsDir, sessionId) + await mkdir(sessionDir, { recursive: true }) + await writeFile( + join(sessionDir, `${sessionId}.json`), + JSON.stringify({ + version: 1, + session_id: sessionId, + source: 'cli', + started_at: '2026-08-11T16:36:34.551Z', + status: 'idle', + interactive: true, + provider: 'deepseek', + model: 'deepseek-v4-flash', + cwd: '/repo/cline', + workspace_root: '/repo/cline' + }) + ) + await writeFile( + join(sessionDir, `${sessionId}.messages.json`), + JSON.stringify({ + version: 1, + updated_at: '2026-08-11T16:38:00.000Z', + agent: 'lead', + sessionId, + messages: [ + { + id: 'user-1', + role: 'user', + content: [{ type: 'text', text: 'Fix the Cline vault scanner' }] + }, + { + id: 'assistant-1', + role: 'assistant', + content: [{ type: 'text', text: 'I will inspect the scanner.' }], + ts: 1_786_466_280_000, + modelInfo: { id: 'deepseek-v4-flash', provider: 'deepseek' } + } + ] + }) + ) + + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + + expect(result.issues).toEqual([]) + expect(result.sessions.filter((session) => session.agent === 'cline')).toHaveLength(1) + expect(result.sessions.find((session) => session.agent === 'cline')).toMatchObject({ + sessionId, + title: 'Fix the Cline vault scanner', + cwd: '/repo/cline', + model: 'deepseek-v4-flash', + messageCount: 2, + resumeCommand: `cd '/repo/cline' && cline --id '${sessionId}'`, + filePath: join(sessionDir, `${sessionId}.json`) + }) + + await writeFile( + join(sessionDir, `${sessionId}.messages.json`), + JSON.stringify({ + version: 1, + updated_at: '2026-08-11T16:39:00.000Z', + sessionId, + messages: [ + { role: 'user', content: [{ type: 'text', text: 'Fix the Cline vault scanner' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'I inspected the scanner.' }] }, + { role: 'user', content: [{ type: 'text', text: 'Add the regression test too' }] } + ] + }) + ) + + const rescanned = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + expect(rescanned.sessions.find((session) => session.agent === 'cline')).toMatchObject({ + messageCount: 3, + updatedAt: '2026-08-11T16:39:00.000Z' + }) + }) + + it('only indexes manifests directly beneath a session directory', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-cline-nested-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const sessionId = 'direct-session' + const nestedId = 'nested-session' + await mkdir(join(roots.clineSessionsDir, sessionId), { recursive: true }) + await mkdir(join(roots.clineSessionsDir, 'workspace', nestedId), { recursive: true }) + const metadata = JSON.stringify({ session_id: sessionId, cwd: '/repo' }) + await writeFile(join(roots.clineSessionsDir, sessionId, `${sessionId}.json`), metadata) + await writeFile( + join(roots.clineSessionsDir, 'workspace', nestedId, `${nestedId}.json`), + JSON.stringify({ session_id: nestedId, cwd: '/unexpected' }) + ) + + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + + expect(result.sessions.filter((session) => session.agent === 'cline')).toHaveLength(1) + expect(result.sessions.find((session) => session.agent === 'cline')?.sessionId).toBe(sessionId) + }) +}) diff --git a/src/main/ai-vault/session-scanner-cline-parser.ts b/src/main/ai-vault/session-scanner-cline-parser.ts new file mode 100644 index 00000000000..21a21664205 --- /dev/null +++ b/src/main/ai-vault/session-scanner-cline-parser.ts @@ -0,0 +1,116 @@ +import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access' +import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import { + addPreviewContent, + createAccumulator, + finalizeSession, + updateTimeline +} from './session-scanner-accumulator' +import type { FileWithMtime } from './session-scanner-types' +import { + arrayValue, + asRecord, + extractContentText, + extractString, + normalizeTitleText +} from './session-scanner-values' + +type ParserSessionOptions = { + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null +} + +export function isClineSessionMetadataPath(filePath: string): boolean { + const segments = filePath.replace(/\\/g, '/').split('/').filter(Boolean) + const fileName = segments.pop() + const sessionId = segments.pop() + return Boolean(fileName && sessionId && fileName === `${sessionId}.json`) +} + +export function clineMessagesPathForMetadata(filePath: string): string { + return filePath.endsWith('.json') ? `${filePath.slice(0, -'.json'.length)}.messages.json` : '' +} + +export async function parseClineSessionFile( + file: FileWithMtime, + platform: NodeJS.Platform = process.platform +): Promise { + const metadataContent = await wslGatedReadFile(file.path, 'utf-8', 'scan') + let messagesContent: string | null = null + try { + messagesContent = await wslGatedReadFile( + clineMessagesPathForMetadata(file.path), + 'utf-8', + 'scan' + ) + } catch (error) { + // The manifest is written before the first turn; a missing messages file is + // a valid metadata-only session, while a WSL gate refusal must stay visible. + if (error instanceof WslTranscriptFsError || !isMissingSessionPathError(error)) { + throw error + } + } + return parseClineSessionContent(file, metadataContent, messagesContent, platform) +} + +function isMissingSessionPathError(error: unknown): boolean { + const code = + error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' + ? error.code + : null + return code === 'ENOENT' || code === 'ENOTDIR' +} + +export function parseClineSessionContent( + file: FileWithMtime, + metadataContent: string, + messagesContent: string | null, + platform: NodeJS.Platform = process.platform, + options: ParserSessionOptions = {} +): AiVaultSession | null { + const metadata = parseJsonRecord(metadataContent) + if (!metadata) { + return null + } + const pathSegments = file.path.replace(/\\/g, '/').split('/').filter(Boolean) + const sessionId = extractString(metadata.session_id) ?? pathSegments.at(-2) ?? '' + const accumulator = createAccumulator({ agent: 'cline', file, sessionId }) + accumulator.cwd = extractString(metadata.cwd) ?? extractString(metadata.workspace_root) + accumulator.model = extractString(metadata.model) + updateTimeline(accumulator, metadata.started_at) + + const messages = messagesContent ? parseJsonRecord(messagesContent) : null + if (messages) { + updateTimeline(accumulator, messages.updated_at) + for (const value of arrayValue(messages.messages)) { + const message = asRecord(value) + const role = message?.role + if (!message || (role !== 'user' && role !== 'assistant')) { + continue + } + accumulator.messageCount++ + updateTimeline(accumulator, message.ts) + const content = message.content + if (role === 'user' && !accumulator.fallbackTitle) { + accumulator.fallbackTitle = normalizeTitleText(extractContentText(content) ?? '') + } + if (role === 'assistant' && !accumulator.model) { + accumulator.model = extractString(asRecord(message.modelInfo)?.id) + } + addPreviewContent(accumulator, role, content, message.ts) + } + } + accumulator.fallbackTitle ??= normalizeTitleText(extractString(metadata.prompt) ?? '') + + return finalizeSession(accumulator, platform, options) +} + +function parseJsonRecord(content: string): Record | null { + try { + return asRecord(JSON.parse(content) as unknown) + } catch { + return null + } +} diff --git a/src/main/ai-vault/session-scanner-discovery.ts b/src/main/ai-vault/session-scanner-discovery.ts index 9ec569bc547..a5cf386043e 100644 --- a/src/main/ai-vault/session-scanner-discovery.ts +++ b/src/main/ai-vault/session-scanner-discovery.ts @@ -14,6 +14,7 @@ export async function discoverFiles(args: { issues: AiVaultScanIssue[] extensions: string[] filePredicate?: (path: string) => boolean + contentDependencyPath?: (path: string) => string directoryPredicate?: (name: string, depth: number) => boolean }): Promise { let paths: string[] @@ -41,11 +42,13 @@ export async function discoverFiles(args: { for (const path of paths) { try { const fileStat = await wslGatedStat(path, 'scan') + const dependencyStat = await optionalContentDependencyStat(args.contentDependencyPath?.(path)) + const mtimeMs = Math.max(fileStat.mtimeMs, dependencyStat?.mtimeMs ?? 0) files.push({ path, - mtimeMs: fileStat.mtimeMs, - modifiedAt: fileStat.mtime.toISOString(), - sizeBytes: fileStat.size, + mtimeMs, + modifiedAt: new Date(mtimeMs).toISOString(), + sizeBytes: fileStat.size + (dependencyStat?.size ?? 0), dev: fileStat.dev, ino: fileStat.ino, nlink: fileStat.nlink @@ -65,6 +68,23 @@ export async function discoverFiles(args: { } } +async function optionalContentDependencyStat( + filePath: string | undefined +): Promise<{ mtimeMs: number; size: number } | null> { + if (!filePath) { + return null + } + try { + const fileStat = await wslGatedStat(filePath, 'scan') + return { mtimeMs: fileStat.mtimeMs, size: fileStat.size } + } catch (error) { + if (error instanceof WslTranscriptFsError) { + throw error + } + return null + } +} + export async function walkSessionFiles( dirPath: string, agent: AiVaultAgent, diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index 563055ffa39..aaabaf76d6e 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -76,6 +76,7 @@ function resumableStateFactoryFor( case 'devin': case 'grok': case 'hermes': + case 'cline': case 'kimi': case 'opencode': case 'rovo': diff --git a/src/main/ai-vault/session-scanner-service-env.test.ts b/src/main/ai-vault/session-scanner-service-env.test.ts index 5d06c0508e8..b56b849270b 100644 --- a/src/main/ai-vault/session-scanner-service-env.test.ts +++ b/src/main/ai-vault/session-scanner-service-env.test.ts @@ -33,6 +33,7 @@ describe('buildAiVaultServiceEnv', () => { const env = buildAiVaultServiceEnv( { CODEX_HOME: '/home/dev/.codex', + CLINE_SESSION_DATA_DIR: '/home/dev/cline-sessions', COPILOT_HOME: '/home/dev/.copilot', DEVIN_HOME: '/home/dev/.devin', GROK_HOME: '/home/dev/.grok', @@ -49,6 +50,7 @@ describe('buildAiVaultServiceEnv', () => { expect(env).toEqual({ CODEX_HOME: '/home/dev/.codex', + CLINE_SESSION_DATA_DIR: '/home/dev/cline-sessions', COPILOT_HOME: '/home/dev/.copilot', DEVIN_HOME: '/home/dev/.devin', GROK_HOME: '/home/dev/.grok', diff --git a/src/main/ai-vault/session-scanner-service-env.ts b/src/main/ai-vault/session-scanner-service-env.ts index e2f5309e70e..a29dd9d2108 100644 --- a/src/main/ai-vault/session-scanner-service-env.ts +++ b/src/main/ai-vault/session-scanner-service-env.ts @@ -34,6 +34,7 @@ export const RUNTIME_ENV_ALLOWLIST = [ // dropping one hides every session of a user who relocated that agent's home. const AGENT_ROOT_ENV_ALLOWLIST = [ 'CODEX_HOME', + 'CLINE_SESSION_DATA_DIR', 'COPILOT_HOME', 'DEVIN_HOME', 'GROK_HOME', diff --git a/src/main/ai-vault/session-scanner-source-discovery.ts b/src/main/ai-vault/session-scanner-source-discovery.ts index fd368f6c172..fef37ebcda0 100644 --- a/src/main/ai-vault/session-scanner-source-discovery.ts +++ b/src/main/ai-vault/session-scanner-source-discovery.ts @@ -55,6 +55,7 @@ function agentDiscoveries( issues, extensions: [...source.extensions], filePredicate: source.filePredicate, + contentDependencyPath: source.contentDependencyPath, directoryPredicate: source.directoryPredicate }) return source.mergeRootDiscoveries diff --git a/src/main/ai-vault/session-scanner-test-fixtures.ts b/src/main/ai-vault/session-scanner-test-fixtures.ts index 6da538c6a5f..e7dd54196f5 100644 --- a/src/main/ai-vault/session-scanner-test-fixtures.ts +++ b/src/main/ai-vault/session-scanner-test-fixtures.ts @@ -24,6 +24,7 @@ export function isolatedScanRoots(root: string) { primeAgentSessionsDir: join(root, 'prime-agent-sessions'), droidSessionsDir: join(root, 'droid-sessions'), droidProjectsDir: join(root, 'droid-projects'), + clineSessionsDir: join(root, 'cline-sessions'), kimiSessionsDir: join(root, 'kimi-sessions') } } diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index 9021b1f7c02..ab553b63568 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -33,6 +33,7 @@ export type AiVaultScanOptions = { primeAgentSessionsDir?: string droidSessionsDir?: string droidProjectsDir?: string + clineSessionsDir?: string kimiSessionsDir?: string limit?: number unlimited?: boolean diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts index feff1b19f89..14f37d566b8 100644 --- a/src/main/ai-vault/session-scanner.test.ts +++ b/src/main/ai-vault/session-scanner.test.ts @@ -647,6 +647,26 @@ describe('scanAiVaultSessions', () => { ]) ) + const clineSessionId = 'cline-session' + const clineSessionDir = join(roots.clineSessionsDir, clineSessionId) + await mkdir(clineSessionDir, { recursive: true }) + await writeFile( + join(clineSessionDir, `${clineSessionId}.json`), + JSON.stringify({ + session_id: clineSessionId, + started_at: '2026-05-01T10:10:30.000Z', + model: 'cline-model', + cwd: '/tmp/cline' + }) + ) + await writeFile( + join(clineSessionDir, `${clineSessionId}.messages.json`), + JSON.stringify({ + updated_at: '2026-05-01T10:10:31.000Z', + messages: [{ role: 'user', content: [{ type: 'text', text: 'Cline vault title' }] }] + }) + ) + // Kimi: /wd_*/session_*/state.json + sibling agents/main/wire.jsonl, // with the work dir resolved from the top-level session_index.jsonl. const kimiSessionDir = join(roots.kimiSessionsDir, 'wd_app_abc', 'session_kimi-session') @@ -738,6 +758,7 @@ describe('scanAiVaultSessions', () => { expect(commandByAgent.get('prime-agent')).toBe( `cd '/tmp/prime-agent' && prime-agent --resume '${primeAgentSessionFile}'` ) + expect(commandByAgent.get('cline')).toBe("cd '/tmp/cline' && cline --id 'cline-session'") expect(commandByAgent.get('devin')).toBe("cd '/tmp/devin' && devin --resume 'devin-session'") expect(commandByAgent.get('droid')).toBe("cd '/tmp/droid' && droid --resume 'droid-session'") expect(commandByAgent.get('kimi')).toBe( diff --git a/src/shared/ai-vault-resume-command.ts b/src/shared/ai-vault-resume-command.ts index 26c5b637e82..7ec417c7ef3 100644 --- a/src/shared/ai-vault-resume-command.ts +++ b/src/shared/ai-vault-resume-command.ts @@ -210,6 +210,8 @@ function buildAgentResumeInvocation( return `${baseCommand} --session ${sessionArg}` case 'copilot': return `${baseCommand} --resume=${sessionArg}` + case 'cline': + return `${baseCommand} --id ${sessionArg}` case 'claude': case 'cursor': case 'gemini': diff --git a/src/shared/ai-vault-session-deletion.ts b/src/shared/ai-vault-session-deletion.ts index 9e25f632abe..d3c2211ab84 100644 --- a/src/shared/ai-vault-session-deletion.ts +++ b/src/shared/ai-vault-session-deletion.ts @@ -41,7 +41,8 @@ export const AI_VAULT_DELETABLE_AGENTS = [ 'omp', 'claude', 'rovo', - 'grok' + 'grok', + 'cline' ] as const satisfies readonly AiVaultAgent[] export type AiVaultDeletableAgent = (typeof AI_VAULT_DELETABLE_AGENTS)[number] diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index a6dff1b59a9..00f25510922 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -18,6 +18,7 @@ export const AI_VAULT_AGENTS = [ 'openclaw', 'devin', 'droid', + 'cline', 'kimi' ] as const satisfies readonly TuiAgent[] @@ -59,6 +60,7 @@ export const AI_VAULT_AGENT_LABELS = { openclaw: 'OpenClaw', devin: 'Devin', droid: 'Droid', + cline: 'Cline', kimi: 'Kimi' } as const satisfies Record From fe714878951d3e301e01d862f1ec5ce5b9fb3717 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:33:53 -0700 Subject: [PATCH 05/29] test(main): isolate deferred worktree watcher setup (#16836) --- .../attach-main-window-services.test.ts | 48 +++++++++++-------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index 51777682474..8a340d840ec 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -17,6 +17,8 @@ const { registerWorktreeHandlersMock, registerPtyHandlersMock, hydrateLocalPtyRegistryAtBootMock, + setWorktreeBaseDirectoryWatcherSyncContextMock, + scheduleWorktreeBaseDirectoryWatcherSyncMock, setupAutoUpdaterMock, browserManagerUnregisterAllMock, runWorktreeChangeInvalidatorsMock, @@ -40,6 +42,8 @@ const { registerWorktreeHandlersMock: vi.fn(), registerPtyHandlersMock: vi.fn(), hydrateLocalPtyRegistryAtBootMock: vi.fn(), + setWorktreeBaseDirectoryWatcherSyncContextMock: vi.fn(), + scheduleWorktreeBaseDirectoryWatcherSyncMock: vi.fn(), setupAutoUpdaterMock: vi.fn(), browserManagerUnregisterAllMock: vi.fn(), runWorktreeChangeInvalidatorsMock: vi.fn(), @@ -98,6 +102,11 @@ vi.mock('../memory/hydrate-local-pty-registry', () => ({ hydrateLocalPtyRegistryAtBoot: hydrateLocalPtyRegistryAtBootMock })) +vi.mock('../ipc/worktree-base-directory-watcher', () => ({ + setWorktreeBaseDirectoryWatcherSyncContext: setWorktreeBaseDirectoryWatcherSyncContextMock, + scheduleWorktreeBaseDirectoryWatcherSync: scheduleWorktreeBaseDirectoryWatcherSyncMock +})) + vi.mock('../browser/browser-manager', () => ({ browserManager: { unregisterAll: browserManagerUnregisterAllMock @@ -222,27 +231,7 @@ async function fireReadyToShow(mainWindow: MainWindowStub): Promise { describe('attachMainWindowServices', () => { beforeEach(() => { - onMock.mockReset() - removeAllListenersMock.mockReset() - removeListenerMock.mockReset() - handleMock.mockReset() - removeHandlerMock.mockReset() - setPermissionRequestHandlerMock.mockReset() - setPermissionCheckHandlerMock.mockReset() - systemPreferencesAskForMediaAccessMock.mockReset() - systemPreferencesGetMediaAccessStatusMock.mockReset() - registerRepoHandlersMock.mockReset() - setRepoRemoteClientNotifierMock.mockReset() - setWorktreeCatalogRemoteClientNotifierMock.mockReset() - registerWorktreeHandlersMock.mockReset() - registerPtyHandlersMock.mockReset() - hydrateLocalPtyRegistryAtBootMock.mockReset() - setupAutoUpdaterMock.mockReset() - browserManagerUnregisterAllMock.mockReset() - acknowledgePendingTccPromptNoticeMock.mockReset() - consumePendingTccPromptNoticeMock.mockReset() - dismissTccPromptNoticeMock.mockReset() - releasePendingTccPromptNoticeMock.mockReset() + vi.resetAllMocks() systemPreferencesAskForMediaAccessMock.mockResolvedValue(true) systemPreferencesGetMediaAccessStatusMock.mockReturnValue('granted') }) @@ -945,4 +934,21 @@ describe('attachMainWindowServices', () => { identity }) }) + + it('keeps deferred worktree watcher setup inside the service boundary', async () => { + const mainWindow = createMainWindow() + const store = createStore() + + vi.useFakeTimers() + try { + attachMainWindowServices(mainWindow as never, store, createRuntime() as never) + + expect(setWorktreeBaseDirectoryWatcherSyncContextMock).toHaveBeenCalledWith(store, mainWindow) + expect(scheduleWorktreeBaseDirectoryWatcherSyncMock).toHaveBeenCalledWith(store, mainWindow) + + await vi.advanceTimersByTimeAsync(100) + } finally { + vi.useRealTimers() + } + }) }) From 6cdae26e1c8a5c91c98d20d8bd1e790c0dd51905 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:36:15 -0700 Subject: [PATCH 06/29] fix(source-control): allow an empty AI-generated PR description in Create PR (#16873) --- ...urce-control-create-pr-intent-flow.test.ts | 33 +++++++++++++++---- .../review/create-pr-intent-flow.ts | 6 ++-- .../review/use-create-pr-intent-review.ts | 7 +--- src/renderer/src/i18n/locales/en.json | 1 - src/renderer/src/i18n/locales/es.json | 1 - src/renderer/src/i18n/locales/ja.json | 1 - src/renderer/src/i18n/locales/ko.json | 1 - src/renderer/src/i18n/locales/zh.json | 1 - src/shared/pull-request-generation.test.ts | 14 ++++++++ 9 files changed, 44 insertions(+), 21 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts index 3072fd76642..f89236426cb 100644 --- a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts @@ -342,12 +342,6 @@ describe('source-control Create PR intent flow helpers', () => { error: 'Agent timed out.' }) ).toEqual({ ok: false, error: 'Agent timed out.' }) - expect( - resolveCreatePrIntentGeneratedReviewFields(current, { - success: true, - fields: { ...current, title: 'Generated title', body: ' ' } - }) - ).toEqual({ ok: false, error: null }) expect( resolveCreatePrIntentGeneratedReviewFields(current, { success: true, @@ -369,6 +363,33 @@ describe('source-control Create PR intent flow helpers', () => { }) }) + it('accepts generated review fields with an empty description', () => { + expect( + resolveCreatePrIntentGeneratedReviewFields( + { base: 'main', title: 'Feature branch', body: '', draft: false }, + { + success: true, + fields: { base: 'main', title: 'chore: add new app config', body: '', draft: false } + } + ) + ).toEqual({ + ok: true, + fields: { base: 'main', title: 'chore: add new app config', body: '', draft: false } + }) + }) + + it('falls back to the current title when the generated title is blank and the body is empty', () => { + expect( + resolveCreatePrIntentGeneratedReviewFields( + { base: 'main', title: 'Feature branch', body: '', draft: false }, + { success: true, fields: { base: 'main', title: ' ', body: ' ', draft: true } } + ) + ).toEqual({ + ok: true, + fields: { base: 'main', title: 'Feature branch', body: ' ', draft: true } + }) + }) + it('surfaces the commit failure summary in the Create PR intent notice', () => { expect( getCreatePrIntentCommitFailureNoticeMessage( diff --git a/src/renderer/src/components/right-sidebar/source-control/review/create-pr-intent-flow.ts b/src/renderer/src/components/right-sidebar/source-control/review/create-pr-intent-flow.ts index 669f7b3142a..d72d6dcc86e 100644 --- a/src/renderer/src/components/right-sidebar/source-control/review/create-pr-intent-flow.ts +++ b/src/renderer/src/components/right-sidebar/source-control/review/create-pr-intent-flow.ts @@ -53,7 +53,7 @@ type CreatePrIntentReviewGeneration = export type CreatePrIntentGeneratedReviewFields = | { ok: true; fields: CreatePrIntentReviewFields } - | { ok: false; error: string | null } + | { ok: false; error: string } export function createCreatePrIntentRunToken(input: Omit) { return { ...input, startedAt: Date.now() } @@ -208,15 +208,13 @@ export function resolveCreatePrIntentGeneratedReviewFields( if (!generated.success) { return { ok: false, error: generated.error } } - if (!generated.fields.body.trim()) { - return { ok: false, error: null } - } return { ok: true, fields: { // Why: intent auto-submits, so generated details must not retarget the review without confirmation. base: current.base, title: generated.fields.title.trim() || current.title, + // Why: a description is optional everywhere else (composer, GitHub/GitLab), so an intentionally empty generated body is a valid result, not a failure. body: generated.fields.body, draft: generated.fields.draft } diff --git a/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-review.ts b/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-review.ts index e6b9b2f6b38..373148e4fc2 100644 --- a/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-review.ts +++ b/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-review.ts @@ -132,12 +132,7 @@ export function useSourceControlCreatePrIntentReview({ if (!resolved.ok) { setCreatePrIntentNoticeForWorktree(token.worktreeId, { tone: 'destructive', - message: - resolved.error ?? - translate( - 'auto.components.right.sidebar.SourceControl.createPrIntentEmptyGeneratedBody', - 'Generated review details did not include a description. Retry Create PR.' - ) + message: resolved.error }) return false } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 3cfec9b3695..599c1a5d6d0 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -12000,7 +12000,6 @@ "a4e93c21d7": "Current branch: {{value0}}", "c7d4e2f801": "Change base ref: {{value0}}", "f3a1b8c204": "upstream", - "createPrIntentEmptyGeneratedBody": "Generated review details did not include a description. Retry Create PR.", "createPrIntentGenerateDetailsFailed": "Could not generate review details. Retry Create PR." }, "SourceControlAgentActionDialog": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 5142116a66b..8b014a386da 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -10779,7 +10779,6 @@ "a4e93c21d7": "Rama actual: {{value0}}", "c7d4e2f801": "Cambiar ref base: {{value0}}", "f3a1b8c204": "upstream", - "createPrIntentEmptyGeneratedBody": "Los detalles de revisiรณn generados no incluyeron una descripciรณn. Reintentar Crear PR.", "createPrIntentGenerateDetailsFailed": "No se pudieron generar los detalles de revisiรณn. Reintentar Crear PR." }, "SourceControlAgentActionDialog": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 1c72acfa367..0e5e5fd0e9d 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -10770,7 +10770,6 @@ "a4e93c21d7": "็พๅœจใฎใƒ–ใƒฉใƒณใƒ: {{value0}}", "c7d4e2f801": "ใƒ™ใƒผใ‚น ref ใ‚’ๅค‰ๆ›ด: {{value0}}", "f3a1b8c204": "upstream", - "createPrIntentEmptyGeneratedBody": "็”Ÿๆˆใ•ใ‚ŒใŸใƒฌใƒ“ใƒฅใƒผใฎ่ฉณ็ดฐใซ่ชฌๆ˜ŽใŒๅซใพใ‚Œใฆใ„ใพใ›ใ‚“ใงใ—ใŸใ€‚PR ใฎไฝœๆˆใ‚’ๅ†่ฉฆ่กŒใ—ใฆใใ ใ•ใ„ใ€‚", "createPrIntentGenerateDetailsFailed": "ใƒฌใƒ“ใƒฅใƒผใฎ่ฉณ็ดฐใ‚’็”Ÿๆˆใงใใพใ›ใ‚“ใงใ—ใŸใ€‚PR ใฎไฝœๆˆใ‚’ๅ†่ฉฆ่กŒใ—ใฆใใ ใ•ใ„ใ€‚" }, "SourceControlAgentActionDialog": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 5b062ebeeee..36700af527d 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -10791,7 +10791,6 @@ "a4e93c21d7": "ํ˜„์žฌ ๋ธŒ๋žœ์น˜: {{value0}}", "c7d4e2f801": "๋ฒ ์ด์Šค ref ๋ณ€๊ฒฝ: {{value0}}", "f3a1b8c204": "upstream", - "createPrIntentEmptyGeneratedBody": "์ƒ์„ฑ๋œ ๊ฒ€ํ†  ์„ธ๋ถ€ ์ •๋ณด์— ์„ค๋ช…์ด ํฌํ•จ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. PR ์ƒ์„ฑ์„ ๋‹ค์‹œ ์‹œ๋„ํ•˜์„ธ์š”.", "createPrIntentGenerateDetailsFailed": "๊ฒ€ํ†  ์„ธ๋ถ€ ์ •๋ณด๋ฅผ ์ƒ์„ฑํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. PR ์ƒ์„ฑ์„ ๋‹ค์‹œ ์‹œ๋„ํ•˜์„ธ์š”." }, "SourceControlAgentActionDialog": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 5a680d43a8b..099a734060a 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -10826,7 +10826,6 @@ "a4e93c21d7": "ๅฝ“ๅ‰ๅˆ†ๆ”ฏ๏ผš{{value0}}", "c7d4e2f801": "ๆ›ดๆ”นๅŸบๅผ•็”จ๏ผš{{value0}}", "f3a1b8c204": "upstream", - "createPrIntentEmptyGeneratedBody": "็”Ÿๆˆ็š„่ฏ„ๅฎก่ฏฆๆƒ…ๆœชๅŒ…ๅซๆ่ฟฐใ€‚่ฏท้‡่ฏ•ๅˆ›ๅปบ PRใ€‚", "createPrIntentGenerateDetailsFailed": "ๆ— ๆณ•็”Ÿๆˆ่ฏ„ๅฎก่ฏฆๆƒ…ใ€‚่ฏท้‡่ฏ•ๅˆ›ๅปบ PRใ€‚" }, "SourceControlAgentActionDialog": { diff --git a/src/shared/pull-request-generation.test.ts b/src/shared/pull-request-generation.test.ts index be18305e3f0..0bb21502bec 100644 --- a/src/shared/pull-request-generation.test.ts +++ b/src/shared/pull-request-generation.test.ts @@ -173,6 +173,20 @@ describe('parseGeneratedPullRequestFields', () => { }) }) + it('keeps an explicitly empty body instead of falling back', () => { + const fields = parseGeneratedPullRequestFields( + '{"base":"main","title":"chore: add new app config","body":"","draft":false}', + context + ) + + expect(fields).toEqual({ + base: 'main', + title: 'chore: add new app config', + body: '', + draft: false + }) + }) + it('rejects excessive nesting before JSON.parse', () => { const parseSpy = vi.spyOn(JSON, 'parse') const depth = GENERATED_PULL_REQUEST_JSON_STRUCTURE_LIMITS.nestingDepth + 1 From 0665c758d139669efd4d6657cd4f527163539d47 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:49:52 -0700 Subject: [PATCH 07/29] feat(settings): name the agents the Chat UI supports (#16830) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Chat UI setting described itself in terms of "supported agent terminal panes" without naming them, and an unsupported agent falls back to the terminal silently โ€” no toast, no toggle, no explanation. A user on OpenCode reported this as a bug in Discord. Adds a "Supported agents:" icon row under the toggle, matching the existing StatusBarUsageEmptyCta legend pattern, driven by the same list the availability predicate uses so it cannot drift from actual support. Icons carry role="img" plus a tooltip for identification. Also adds the missing openclaude/omp settings-search keywords. --- .../native-chat-availability.test.ts | 23 ++--- .../NativeChatExperimentalSetting.tsx | 2 + .../NativeChatSupportedAgents.test.tsx | 85 +++++++++++++++++++ .../settings/NativeChatSupportedAgents.tsx | 35 ++++++++ ...ive-chat-experimental-search-entry.test.ts | 9 ++ .../native-chat-experimental-search-entry.ts | 8 ++ src/renderer/src/i18n/locales/en.json | 3 + .../lib/native-chat-initial-view-mode.test.ts | 21 +++-- src/shared/native-chat-agent-support.ts | 13 ++- .../pane-agent-identity-inventory.test.ts | 1 + 10 files changed, 178 insertions(+), 22 deletions(-) create mode 100644 src/renderer/src/components/settings/NativeChatSupportedAgents.test.tsx create mode 100644 src/renderer/src/components/settings/NativeChatSupportedAgents.tsx create mode 100644 src/renderer/src/components/settings/native-chat-experimental-search-entry.test.ts diff --git a/src/renderer/src/components/native-chat/native-chat-availability.test.ts b/src/renderer/src/components/native-chat/native-chat-availability.test.ts index 9c77e05992a..a08bc476d2f 100644 --- a/src/renderer/src/components/native-chat/native-chat-availability.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-availability.test.ts @@ -117,16 +117,19 @@ describe('canToggleNativeChat', () => { ).toBe(true) }) - it('rejects an unsupported agent detected live (Gemini)', () => { - expect( - canToggleNativeChat({ - experimentalNativeChatEnabled: true, - contentType: 'terminal', - launchAgent: null, - detectedAgent: 'gemini' - }) - ).toBe(false) - }) + it.each(['gemini', 'opencode'] as const)( + 'rejects unsupported agent %s detected live', + (agent) => { + expect( + canToggleNativeChat({ + experimentalNativeChatEnabled: true, + contentType: 'terminal', + launchAgent: null, + detectedAgent: agent + }) + ).toBe(false) + } + ) it('accepts Grok when resolved from the title', () => { expect( diff --git a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx index d7ea704c553..6dac8022c8d 100644 --- a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx +++ b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx @@ -2,6 +2,7 @@ import type { GlobalSettings } from '../../../../shared/global-settings-types' import { translate } from '@/i18n/i18n' import { Label } from '../ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' +import { NativeChatSupportedAgents } from './NativeChatSupportedAgents' import { SearchableSetting } from './SearchableSetting' import { SettingsSwitch } from './SettingsFormControls' import { getExperimentalSearchEntry } from './experimental-search' @@ -43,6 +44,7 @@ export function NativeChatExperimentalSetting({ 'Adds a Chat UI view you can switch to from supported agent terminal panes. Experimental while we tune transcript fidelity, streaming, and terminal parity.' )}

+ ) + const container = document.createElement('div') + container.innerHTML = markup + return Array.from(container.querySelectorAll('[data-agent]')).map((node) => ({ + agent: node.getAttribute('data-agent') ?? '', + label: node.getAttribute('aria-label') ?? '', + role: node.getAttribute('role') ?? '' + })) +} + +describe('NativeChatSupportedAgents', () => { + afterEach(async () => { + await i18n.changeLanguage('en') + }) + + it('keeps the advertised list and support predicate on the independent contract', () => { + expect(NATIVE_CHAT_SUPPORTED_AGENT_LIST).toEqual(EXPECTED_SUPPORTED_AGENTS) + for (const entry of getAgentCatalog()) { + expect(isNativeChatSupportedAgent(entry.id), entry.id).toBe( + EXPECTED_SUPPORTED_AGENTS.includes(entry.id as (typeof EXPECTED_SUPPORTED_AGENTS)[number]) + ) + } + }) + + it('renders exactly one chip for each supported agent', () => { + expect(getRenderedChips().map((chip) => chip.agent)).toEqual(EXPECTED_SUPPORTED_AGENTS) + }) + + it('gives each icon an accessible catalog agent name', () => { + for (const chip of getRenderedChips()) { + const entry = getAgentCatalog().find((candidate) => candidate.id === chip.agent) + expect(chip.label).toBe(entry?.label) + expect(chip.role).toBe('img') + } + }) + + it('omits agents native chat cannot render, including OpenCode', () => { + const rendered = getRenderedChips().map((chip) => chip.agent) + + for (const entry of getAgentCatalog()) { + if (!isNativeChatSupportedAgent(entry.id)) { + expect(rendered).not.toContain(entry.id) + } + } + expect(rendered).not.toContain('opencode') + }) + + it('keeps the label in the English catalog', () => { + expect(i18n.getResource('en', 'translation', SUPPORTED_AGENTS_LABEL_KEY)).toBe( + 'Supported agents:' + ) + }) + + it('renders the English fallback when the active locale lacks the label key', async () => { + await i18n.changeLanguage('es') + expect(i18n.getResource('es', 'translation', SUPPORTED_AGENTS_LABEL_KEY)).toBeUndefined() + + const markup = renderToStaticMarkup() + + expect(markup).toContain('Supported agents:') + }) +}) diff --git a/src/renderer/src/components/settings/NativeChatSupportedAgents.tsx b/src/renderer/src/components/settings/NativeChatSupportedAgents.tsx new file mode 100644 index 00000000000..52001a573ca --- /dev/null +++ b/src/renderer/src/components/settings/NativeChatSupportedAgents.tsx @@ -0,0 +1,35 @@ +import { NATIVE_CHAT_SUPPORTED_AGENT_LIST } from '../../../../shared/native-chat-agent-support' +import { AgentIcon, getAgentLabel } from '@/lib/agent-catalog' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' + +/** Names the agents so unsupported-agent terminal fallback does not look broken. */ +export function NativeChatSupportedAgents(): React.JSX.Element { + return ( + +
+ + {translate( + 'auto.components.settings.NativeChatSupportedAgents.label', + 'Supported agents:' + )} + + {NATIVE_CHAT_SUPPORTED_AGENT_LIST.map((agent) => { + const label = getAgentLabel(agent) + return ( + + + + + + + + {label} + + + ) + })} +
+
+ ) +} diff --git a/src/renderer/src/components/settings/native-chat-experimental-search-entry.test.ts b/src/renderer/src/components/settings/native-chat-experimental-search-entry.test.ts new file mode 100644 index 00000000000..502c407056a --- /dev/null +++ b/src/renderer/src/components/settings/native-chat-experimental-search-entry.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest' +import { getNativeChatExperimentalSearchEntry } from './native-chat-experimental-search-entry' +import { matchesSettingsSearch } from './settings-search' + +describe('native chat experimental search entry', () => { + it.each(['openclaude', 'omp'])('matches the supported-agent keyword %s', (query) => { + expect(matchesSettingsSearch(query, getNativeChatExperimentalSearchEntry())).toBe(true) + }) +}) diff --git a/src/renderer/src/components/settings/native-chat-experimental-search-entry.ts b/src/renderer/src/components/settings/native-chat-experimental-search-entry.ts index 75026ea6ce1..87427d81e8a 100644 --- a/src/renderer/src/components/settings/native-chat-experimental-search-entry.ts +++ b/src/renderer/src/components/settings/native-chat-experimental-search-entry.ts @@ -30,10 +30,18 @@ export function getNativeChatExperimentalSearchEntry(): SettingsSearchEntry { 'auto.components.settings.experimental.search.nativeChat.codex', 'codex' ), + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.nativeChat.openclaude', + 'openclaude' + ), ...translateSearchKeyword( 'auto.components.settings.experimental.search.nativeChat.grok', 'grok' ), + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.nativeChat.omp', + 'omp' + ), ...translateSearchKeyword( 'auto.components.settings.experimental.search.nativeChat.terminal', 'terminal' diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 599c1a5d6d0..5a0bad3575b 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -11484,6 +11484,9 @@ }, "shortcutDefinitionCatalog": { "missionControlConflict": "Blocked by Mission Control. Remap here or change it in System Settings." + }, + "NativeChatSupportedAgents": { + "label": "Supported agents:" } }, "right": { diff --git a/src/renderer/src/lib/native-chat-initial-view-mode.test.ts b/src/renderer/src/lib/native-chat-initial-view-mode.test.ts index e3a7a5b7aad..957e9b62ce2 100644 --- a/src/renderer/src/lib/native-chat-initial-view-mode.test.ts +++ b/src/renderer/src/lib/native-chat-initial-view-mode.test.ts @@ -47,15 +47,18 @@ describe('decideInitialAgentTabViewMode', () => { ).toBeUndefined() }) - it('returns undefined for unsupported agents', () => { - expect( - decideInitialAgentTabViewMode({ - experimentalNativeChat: true, - openAgentTabsInChatByDefault: true, - agent: 'gemini' - }) - ).toBeUndefined() - }) + it.each(['gemini', 'opencode'] as const)( + 'keeps unsupported agent %s in terminal view', + (agent) => { + expect( + decideInitialAgentTabViewMode({ + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true, + agent + }) + ).toBeUndefined() + } + ) it.each([ ['local', null], diff --git a/src/shared/native-chat-agent-support.ts b/src/shared/native-chat-agent-support.ts index 8210e907e0a..b5bd27c0016 100644 --- a/src/shared/native-chat-agent-support.ts +++ b/src/shared/native-chat-agent-support.ts @@ -1,13 +1,20 @@ +import type { TuiAgent } from './tui-agent' + export type NativeChatTranscriptAgent = 'claude' | 'codex' | 'grok' | 'omp' -/** Agents whose transcripts the native chat view can parse and render. */ -export const NATIVE_CHAT_SUPPORTED_AGENTS: ReadonlySet = new Set([ +/** Agents whose transcripts the native chat view can parse and render, in the + * order the settings pane advertises them. */ +export const NATIVE_CHAT_SUPPORTED_AGENT_LIST: readonly TuiAgent[] = [ 'claude', 'openclaude', 'codex', 'grok', 'omp' -]) +] + +export const NATIVE_CHAT_SUPPORTED_AGENTS: ReadonlySet = new Set( + NATIVE_CHAT_SUPPORTED_AGENT_LIST +) export function isNativeChatSupportedAgent(agent: string | null | undefined): boolean { return agent != null && NATIVE_CHAT_SUPPORTED_AGENTS.has(agent) diff --git a/src/shared/pane-agent-identity-inventory.test.ts b/src/shared/pane-agent-identity-inventory.test.ts index 730c6b0ce05..d1fd17b3a4c 100644 --- a/src/shared/pane-agent-identity-inventory.test.ts +++ b/src/shared/pane-agent-identity-inventory.test.ts @@ -55,6 +55,7 @@ const INVENTORY: readonly InventoryGroup[] = [ ['src/renderer/src/components/automations/automation-list-search-rows.ts', 2], ['src/renderer/src/components/dashboard-popout/AgentMapSnapshotWorkspaceMenu.tsx', 2], ['src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx', 2], + ['src/renderer/src/components/settings/NativeChatSupportedAgents.tsx', 2], ['src/renderer/src/components/settings/QuickCommandsList.tsx', 2], ['src/renderer/src/components/tab-bar/TabBarQuickCommandItem.tsx', 2], ['src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.tsx', 2], From 6ba6d58cd2d3d6abd4a08bfd8eaed8ae64195c08 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:53:05 -0700 Subject: [PATCH 08/29] fix(orchestration): route @agent messages by resolved identity, not terminal title (#16237) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(agent-status): add the pane agent identity resolver Four ladders answer "which agent is in this pane" independently โ€” the tab icon, the open-tab/search occupant, the sidebar title rows, and the sidebar hook-row fallback โ€” and they disagree. Two consult the terminal title before the launch record, so a string Orca parsed outranks a fact Orca owns. resolvePaneAgentIdentity is the single ranked answer. Two rules, one of which is not an ordering: 1. Evidence is ranked by how directly it observes the process; a display title is last. 2. Each observation carries the runId of the agent run it describes. Evidence from a superseded run is INELIGIBLE, not merely outranked. Rule 2 is the part reordering could never supply. A completed hook naming A plus a title naming B is either a bug (hook right, title stale) or a legitimate pane reclaim (title right) โ€” identical signals, opposite correct answers. Run ids make them different facts: in the bug both belong to the current run; in the reclaim the hook belongs to a previous one. That pair ships as a test asserting the two produce opposite answers from the same evidence. Missing run ids are treated as eligible. Absence means "this peer does not publish them", not "this is stale", so an old host's rows are never blanked. Sibling evidence is opt-in so pane-scoped consumers cannot inherit another pane's agent. No consumer imports this yet; each migrates separately with its own evidence. Verified non-vacuous: reversing the authority order fails 10 of 18 assertions and removing the run filter fails 3. * fix(agent-status): close three resolver contract holes found in review **Duplicate evidence of one source resolved by array order.** `eligible.find(...)` returned the first match, so two live hooks naming different agents were settled by input position โ€” the exact property this resolver exists to remove. The original order-independence test only used DISTINCT sources, so it never exercised it. Conflicting same-class evidence now returns null with `ambiguousAt`, and does NOT fall through to a weaker source: letting a title answer whenever two hooks disagree is worse than saying nothing. **A bare numeric runId collided across authority restarts.** `incarnation` is a total order only within one `authorityId` (agent-status-observation.ts states this), and the id is regenerated per authority instance, so a restarted host counting from its own floor would report `1` and match an unrelated live run 1. The run key now carries its authority, and evidence from a DIFFERENT authority is treated as incomparable โ€” kept, like an absent key โ€” rather than as stale. **Title stayed reachable by consumers that authorize writes.** Ranking it last makes misuse unlikely; `minimumSource` makes it impossible. An action consumer passes `'launch'` and weaker evidence is dropped before ranking, so routing or delivery cannot name a target from a parsed string even by reordering its inputs. Display surfaces omit it and are unaffected. Also restores the generic agent-vocabulary parameter, which lives on the routing branch and was lost when this branch was rebased. Each fix is mutation-verified: first-match restored fails 3, ignoring authority fails 1, dropping the floor fails 2. The authority test was itself vacuous on the first attempt โ€” both sides used `incarnation: 1`, so a resolver ignoring authority still passed on the numeric compare. It now uses differing incarnations. The remaining review finding, that `process > launch` has no freshness bound, is NOT fixed here: it needs an observation timestamp the evidence type does not yet carry. Recorded rather than silently dropped. * fix(orchestration): route @agent messages by resolved identity, not terminal title `@claude` picked its recipients with `buildAgentNameRe('claude').test(title)`, so any pane whose TITLE contained the word received Claude's messages. Terminal titles carry task text, and people describe agent work in them, so this is the ordinary case rather than a contrived one: the recorded title "Switch Claude and Codex off the load balancerโ€ฆ - grok" is a Grok pane that received both @claude and @codex. Misdelivered instructions, not a cosmetic slip. The cause is that `RuntimeTerminalSummary` carried no identity at all โ€” `title` was the only identity-ish field on it, so routing by title was the only option available. Fix the input: - `RuntimeTerminalSummary.agentIdentity?: TuiAgent` โ€” optional, host-resolved from launch and foreground-process evidence the host owns, with the title ranked last and contributing only when the evidence parser finds an unambiguous name. A title that merely mentions an agent yields no evidence, which is the whole point. - `resolvePublishedPaneAgentIdentity` in `src/shared` rather than inside the runtime class, so the decision is testable without a runtime and so routing, delivery and the UI cannot drift. - Groups match `agentIdentity`; the title matcher and its bespoke Cursor predicate are deleted. Unknown fails closed. `agentIdentity` is absent when the host predates the field or had no evidence beyond the title, and delivery is an action: not delivering is visible and recoverable (the sender sees no recipients), while delivering to the wrong agent is neither. The optional field is additive, so an old client simply ignores it (wire rule 1). This is also the first real caller of the evidence parser and the identity resolver. Tests: 27 in groups, 8 for the publisher, 3 RPC fan-out cases updated to the new contract. The `@cursor`-must-not-match-"text cursor blink" hazard is now excluded structurally instead of by a per-agent predicate. Verified non-vacuous by mutation: swapping the process/title ranks fails 2 publisher assertions, and reverting groups to title matching fails 15 of 27. One earlier mutation silently failed to apply after formatting reflowed the block โ€” the file was checked before trusting the result. * perf(runtime): reuse terminal title during summary build * fix(orchestration): refuse title evidence when publishing identity for routing Rebuilt on current main so this carries the hardened parser from #16148 and the corrected resolver from #16157 (authority-scoped run keys, no order-dependent duplicate resolution). Applies the resolver's new `minimumSource` floor at the publisher. What this publishes authorizes an action โ€” routing decides which real agent pane receives a message โ€” so ranking title last is not enough; the floor removes it from consideration entirely, and no amount of reordering by a caller can bring it back. The trade, stated because it is a real capability loss: a hook-less agent over SSH that Orca did not launch, and whose foreground process the host cannot read, is no longer addressable by @agent. Accepted because a message delivered into the wrong agent's prompt is unrecoverable while an undelivered one is visible โ€” the sender sees zero recipients. Whether real panes actually carry launch/foreground evidence is the open question, and is what live validation must answer. * fix(pty): preserve agent identity on daemon reattach * fix(runtime): retire stale pane agent identity * chore: normalize runtime types formatting * fix(agent-status): identify a pane from its own hook, not from how it was started Two defects, one cause: identity was inferred from the outside instead of read from the agent. **Hook evidence was never plumbed in.** The publisher considered `process`, `launch` and `title` and contained zero hook references โ€” while the resolver ranks `live-hook` first. The top rung of the ladder was never connected. That made identity depend on Orca having launched the agent. Most agents are started by typing `claude` or `codex` at a shell, which leaves no launch record. On macOS the foreground process still names them, so the gap was invisible. On WSL the Windows host reads the foreground process as `wsl.exe` โ€” the distro wrapper, not the agent inside it โ€” so those panes had no signal at all and became unaddressable by `@agent`. A hook is the agent reporting itself, so it survives both: no launch record needed, and no dependency on reading a process across the WSL boundary. **`launch` outranked `completed-hook`.** Ranking is now by TENSE rather than by how authoritative a source sounds: present: live-hook > process past: completed-hook > launch > sleeping-session > sibling > title A launch record is an event, not a state โ€” it stays true after the agent exits, which is why a pane reused after closing its agent kept reading as the old one. A completed hook at least proves the agent actually ran in that pane; a launch record only proves Orca tried to start one. Neither rank was covered: all 392 existing tests passed unchanged after reordering. Mutation now fails 2 on the old order and 4 with hook evidence removed. Known remaining gap, deliberately not papered over: a hand-started WSL agent with no managed hooks has no identity signal at all. Restoring a title guess there would reinstate the misdelivery this PR exists to prevent. * fix(orchestration): restore title as the last resort, not a forbidden source An earlier revision passed `minimumSource: 'launch'` so routing could not see a title at any rank, reasoning that a display string must never authorize a write. That conflated the evidence parser with the raw substring match it replaced. `buildAgentNameRe('claude').test(title)` was the misdelivery. `collectAgentTitleEvidence` returns null on exactly those shapes: "Review the Claude session-history fix" on a Codex pane yields nothing, and "Switch Claude and Codex off the load balancerโ€ฆ - grok" yields grok from its owner suffix. Ranking title last is therefore sufficient; refusing it is not necessary. Refusing it had a real cost. An agent a user starts by hand inside an Orca WSL terminal has no launch record, no readable foreground process (the Windows host sees `wsl.exe`, not the agent in the distro), and โ€” until managed Codex hooks install there โ€” no hook either. An unambiguous title was the only thing left, and dropping it made that pane unaddressable by @agent where the previous code could reach it. That is a regression, and most agents are started that way. End-to-end coverage added at the routing layer with title allowed: @claude still does not reach a Codex pane whose task text names Claude, @codex still does not reach a Grok pane whose task text names Codex, and a pane identified only by an unambiguous title is reachable again. * revert(agent-status): keep launch above completed-hook until run keys exist Reverts the tense-based reorder from this branch. The reasoning behind it was sound as far as it went โ€” a launch record is a past event, not an observation, which is why a reused pane kept reading as its previous agent โ€” but it fixed one staleness by opening a worse one. A completed hook is past tense too, and without an agent-run key it never expires at all. Ranking it above `launch` lets a stale hook from a previous agent outrank the launch record Orca stamped for the process running NOW. pane-agent-owner.ts already says this in its own comment: "Ranking launch/live-hook above the completed/sleeping records keeps a genuine pane on its real agent and stops a stale record from hijacking it." The reorder belongs with authority-scoped run generation, which is what makes any past-tense evidence expire. It is staged in the migration plan rather than shipped here. What this branch keeps: hook evidence feeding pane identity (so an agent a user starts by hand is identified from its own report rather than needing a launch record), and title restored as a genuine last resort behind the evidence parser. * fix(runtime): guard the pane key so terminal.list survives a non-UUID leaf `makePaneKey` throws on a leaf id that is not a UUID. The hook-evidence lookup called it unguarded inside `buildTerminalSummary`, so a single such leaf took down `terminal.list` for the whole list rather than degrading that one pane โ€” 136 tests across 5 files, and the native code-quality gate tripped separately on a duplicate test title. Both were mine, and both were caught by CI rather than by me: I ran the focused suites before pushing instead of the affected directories. * fix(runtime): declare published terminal agent identity * fix(runtime): demote completed hook identity evidence --- .../ipc/pty-daemon-spawn-wsl-runtime.test.ts | 52 ++++ src/main/ipc/pty-ipc-mock-registry.ts | 1 + .../pty-spawn-runtime-handle-binding.test.ts | 39 ++- src/main/ipc/pty/ipc/spawn-commit.ts | 13 +- .../ipc/pty/pane/launch-authority.test.ts | 40 +++ src/main/ipc/pty/pane/launch-authority.ts | 15 + src/main/ipc/pty/runtime/spawn-commit.ts | 8 +- ...ntime-hook-agent-status-projection.test.ts | 19 +- ...-provider-reattach-launch-identity.test.ts | 100 +++++++ src/main/runtime/orca-runtime.test.ts | 7 + src/main/runtime/orca-runtime.ts | 72 ++++- src/main/runtime/orchestration/groups.test.ts | 274 ++++++------------ src/main/runtime/orchestration/groups.ts | 54 ++-- .../rpc/methods/orchestration-send.test.ts | 32 +- .../pane-agent-identity-inventory.test.ts | 5 - .../published-pane-agent-identity.test.ts | 123 ++++++++ src/shared/published-pane-agent-identity.ts | 61 ++++ src/shared/runtime-terminal-contracts.ts | 2 + 18 files changed, 678 insertions(+), 239 deletions(-) create mode 100644 src/main/ipc/pty/pane/launch-authority.test.ts create mode 100644 src/main/runtime/orca-runtime-provider-reattach-launch-identity.test.ts create mode 100644 src/shared/published-pane-agent-identity.test.ts create mode 100644 src/shared/published-pane-agent-identity.ts diff --git a/src/main/ipc/pty-daemon-spawn-wsl-runtime.test.ts b/src/main/ipc/pty-daemon-spawn-wsl-runtime.test.ts index ad2b29955db..cbeddb333cd 100644 --- a/src/main/ipc/pty-daemon-spawn-wsl-runtime.test.ts +++ b/src/main/ipc/pty-daemon-spawn-wsl-runtime.test.ts @@ -148,6 +148,58 @@ describe('registerPtyHandlers', () => { false ) }) + it('restores daemon launch identity for a runtime-created reattach', async () => { + const incarnationId = 'runtime-reattach-incarnation' + const leafId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + const daemonSpawn = setupDaemonAdapter() + daemonSpawn.mockResolvedValueOnce({ + id: 'runtime-reattach', + incarnationId, + isReattach: true, + launchAgent: 'codex' + } as never) + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as { + spawn(args: { + cols: number + rows: number + worktreeId: string + tabId: string + leafId: string + sessionId: string + }): Promise<{ id: string }> + } + + await controller.spawn({ + cols: 80, + rows: 24, + worktreeId: 'wt-runtime-reattach', + tabId: 'tab-runtime-reattach', + leafId, + sessionId: 'runtime-reattach' + }) + + expect(runtime.registerPty).toHaveBeenCalledWith( + 'runtime-reattach', + 'wt-runtime-reattach', + null, + { + tabId: 'tab-runtime-reattach', + leafId, + incarnationId, + providerReattachLaunchIdentity: { incarnationId, launchAgent: 'codex' } + }, + false + ) + }) it('uses the owning project WSL runtime for runtime-created daemon PTYs', async () => { await withWin32Platform(async () => { _setWslCachesForTests({ available: true, distros: ['Ubuntu'] }) diff --git a/src/main/ipc/pty-ipc-mock-registry.ts b/src/main/ipc/pty-ipc-mock-registry.ts index 24dc333e6d1..bf3ec10dc27 100644 --- a/src/main/ipc/pty-ipc-mock-registry.ts +++ b/src/main/ipc/pty-ipc-mock-registry.ts @@ -87,6 +87,7 @@ export const fsModuleMock = () => ({ chmodSync: chmodSyncMock, renameSync: renameSyncMock, rmSync: rmSyncMock, + mkdtempSync: () => '/tmp/orca-watcher-canary-test', constants: { X_OK: 1, R_OK: 4 diff --git a/src/main/ipc/pty-spawn-runtime-handle-binding.test.ts b/src/main/ipc/pty-spawn-runtime-handle-binding.test.ts index 6e5e57f3ad6..73c6e906d5e 100644 --- a/src/main/ipc/pty-spawn-runtime-handle-binding.test.ts +++ b/src/main/ipc/pty-spawn-runtime-handle-binding.test.ts @@ -416,8 +416,14 @@ describe('registerPtyHandlers', () => { baseEnv: expect.any(Object) }) }) - it('does not echo launch config for provider reattach results', async () => { - const spawn = vi.fn(async () => ({ id: 'ssh-reattach', isReattach: true })) + it('restores daemon launch identity without minting renderer authority on reattach', async () => { + const incarnationId = 'ssh-reattach-incarnation' + const spawn = vi.fn(async () => ({ + id: 'ssh-reattach', + incarnationId, + isReattach: true as const, + launchAgent: 'codex' as const + })) registerSshPtyProvider('ssh-reattach-1', { spawn, write: vi.fn(), @@ -439,17 +445,30 @@ describe('registerPtyHandlers', () => { getProfiles: vi.fn(), acknowledgeDataEvent: vi.fn() } as never) + const registerPty = vi.fn() const runtime = { setPtyController: vi.fn(), createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'), - registerPreAllocatedHandleForPty: vi.fn() + registerPreAllocatedHandleForPty: vi.fn(), + registerPty } + const tabId = 'tab-reattach' + const leafId = '77777777-7777-4777-8777-777777777777' + const worktreeId = 'repo-1::/tmp/reattach' registerPtyHandlers(mainWindow as never, runtime as never) - const result = (await handlers.get('pty:spawn')!(null, { + const result = (await handlers.get('pty:spawn')!(mainWindowIpcEvent, { cols: 80, rows: 24, connectionId: 'ssh-reattach-1', + worktreeId, + tabId, + leafId, + env: { + ORCA_PANE_KEY: makePaneKey(tabId, leafId), + ORCA_TAB_ID: tabId, + ORCA_WORKTREE_ID: worktreeId + }, launchConfig: { agentCommand: 'codex --model gpt-5', agentArgs: '--model gpt-5', @@ -459,6 +478,18 @@ describe('registerPtyHandlers', () => { expect(result).toMatchObject({ id: 'ssh-reattach', isReattach: true }) expect(result.launchConfig).toBeUndefined() + expect(registerPty).toHaveBeenCalledWith( + 'ssh-reattach', + worktreeId, + 'ssh-reattach-1', + expect.objectContaining({ + tabId, + leafId, + incarnationId, + providerReattachLaunchIdentity: { incarnationId, launchAgent: 'codex' } + }), + undefined + ) }) it('reuses the runtime background handle in local PTY spawn env', async () => { type RuntimeSpawnController = { diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index 09e6fa8b8c8..7793ccf17ee 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -18,7 +18,10 @@ import { import { rememberPaneKeyForPty } from '../pane/key-state' import { resolvePaneSpawnReservation } from '../pane/spawn-reservation' import { seedTerminalRestoreRecordsFromSpawnResult } from '../pane/agent-session-owners' -import { admitRendererAgentLaunchAuthority } from '../pane/launch-authority' +import { + admitProviderReattachLaunchIdentity, + admitRendererAgentLaunchAuthority +} from '../pane/launch-authority' import type { PtyIpcSpawnState } from './spawn-state' import { persistPtyIpcSpawnCommit } from './spawn-commit-persist' @@ -82,6 +85,11 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { + it('binds provider launch identity to a valid reattach incarnation', () => { + expect( + admitProviderReattachLaunchIdentity({ + isReattach: true, + launchAgent: 'codex', + incarnationId: 'provider-incarnation' + }) + ).toEqual({ launchAgent: 'codex', incarnationId: 'provider-incarnation' }) + }) + + it.each([ + { label: 'fresh spawn', isReattach: false, launchAgent: 'codex', incarnationId: 'incarnation' }, + { + label: 'invalid agent', + isReattach: true, + launchAgent: 'unknown', + incarnationId: 'incarnation' + }, + { + label: 'missing incarnation', + isReattach: true, + launchAgent: 'codex', + incarnationId: undefined + }, + { + label: 'oversized incarnation', + isReattach: true, + launchAgent: 'codex', + incarnationId: 'x'.repeat(129) + } + ])('rejects $label metadata', ({ isReattach, launchAgent, incarnationId }) => { + expect( + admitProviderReattachLaunchIdentity({ isReattach, launchAgent, incarnationId }) + ).toBeNull() + }) +}) diff --git a/src/main/ipc/pty/pane/launch-authority.ts b/src/main/ipc/pty/pane/launch-authority.ts index 93f3061f0b3..ab00d29b4f5 100644 --- a/src/main/ipc/pty/pane/launch-authority.ts +++ b/src/main/ipc/pty/pane/launch-authority.ts @@ -30,6 +30,21 @@ export function admitRendererAgentLaunchAuthority(args: { return { launchToken: args.launchToken, launchAgent: args.launchAgent } } +export function admitProviderReattachLaunchIdentity(args: { + isReattach?: boolean + launchAgent?: unknown + incarnationId?: unknown +}): { incarnationId: string; launchAgent: TuiAgent } | null { + if ( + !args.isReattach || + !isTuiAgent(args.launchAgent) || + !isPtyIncarnationId(args.incarnationId) + ) { + return null + } + return { incarnationId: args.incarnationId, launchAgent: args.launchAgent } +} + export function shouldRefreshNativeClaudeAgentTeamsEnv(args: { command?: string launchConfig?: SleepingAgentLaunchConfig diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index ecf4f4ebcd4..38f9ced106e 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -33,10 +33,12 @@ import { toSshExecutionHostId } from '../../../../shared/execution-host' import { createTerminalSessionStateSaveFailureMessage } from '../../../../shared/terminal-session-state-save-failure' import { clearProviderPtyState } from '../provider/state-cleanup' import { resolvePaneSpawnReservation } from '../pane/spawn-reservation' +import { admitProviderReattachLaunchIdentity } from '../pane/launch-authority' import type { RuntimePtySpawnState } from './spawn-state' export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { const args = ctx.args + const providerReattachLaunchIdentity = admitProviderReattachLaunchIdentity(ctx.result) try { ctx.stablePaneBindingPersisted = persistAdmittedStablePaneBinding({ store: ctx.hostSessionBinding?.store, @@ -69,7 +71,8 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { { tabId: owner.surface.tabId, leafId: owner.surface.leafId, - ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}) + ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}), + ...(providerReattachLaunchIdentity ? { providerReattachLaunchIdentity } : {}) } ) if (!args.connectionId) { @@ -204,7 +207,8 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { ? { tabId: args.tabId, leafId: ctx.metadataLeafId, - ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}) + ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}), + ...(providerReattachLaunchIdentity ? { providerReattachLaunchIdentity } : {}) } : undefined, !args.connectionId diff --git a/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts b/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts index 11b9fc97afd..f8171862728 100644 --- a/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts +++ b/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts @@ -61,7 +61,8 @@ async function createRuntimeWithHookRows( rows: AgentStatusIpcPayload[] ): Promise { const runtime = new OrcaRuntimeService(null, undefined, { - getAgentStatusSnapshot: () => rows + getAgentStatusSnapshot: () => rows, + getAgentProviderSessionRowsForPane: () => rows }) const internals = runtime as unknown as { resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise @@ -115,6 +116,22 @@ function lastOscTitleEpochMs(runtime: OrcaRuntimeService): number { } describe('headless hook agent-status projection (#11761)', () => { + it('ranks a completed hook below present launch evidence in terminal identity', async () => { + const runtime = await createRuntimeWithHookRows([ + hookRow({ + agentType: 'codex', + state: 'done', + prompt: '', + toolName: undefined, + interactivePrompt: undefined + }) + ]) + + expect((await runtime.listTerminals()).terminals).toEqual([ + expect.objectContaining({ agentIdentity: 'claude' }) + ]) + }) + it('carries the hook state, tool and interactivePrompt to paired clients', async () => { const agentStatus = await projectAgentStatus([hookRow()]) diff --git a/src/main/runtime/orca-runtime-provider-reattach-launch-identity.test.ts b/src/main/runtime/orca-runtime-provider-reattach-launch-identity.test.ts new file mode 100644 index 00000000000..a463dd75cf4 --- /dev/null +++ b/src/main/runtime/orca-runtime-provider-reattach-launch-identity.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from 'vitest' +import { makePaneKey } from '../../shared/stable-pane-id' +import { OrcaRuntimeService } from './orca-runtime' + +vi.mock('electron', () => ({ + app: { getPath: vi.fn(() => '/tmp'), isPackaged: false }, + BrowserWindow: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + webContents: { fromId: vi.fn(() => null) } +})) + +const TAB_ID = '11111111-1111-4111-8111-111111111111' +const LEAF_ID = '22222222-2222-4222-8222-222222222222' +const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID) +const INCARNATION_ID = 'provider-reattach-incarnation' +const WORKTREE_ID = 'repo-1::/tmp/provider-reattach' + +type RuntimePtyLaunchIdentity = { + incarnationId: string | null + paneKey: string | null + launchAgent: string | null + launchToken: string | null + launchIncarnationId: string | null +} + +function getPty(runtime: OrcaRuntimeService, ptyId: string): RuntimePtyLaunchIdentity | undefined { + return (runtime as unknown as { ptysById: Map }).ptysById.get( + ptyId + ) +} + +describe('provider reattach launch identity', () => { + it('restores daemon-owned agent identity without minting renderer authority', () => { + const runtime = new OrcaRuntimeService(null) + + runtime.registerPty('pty-reattach', WORKTREE_ID, null, { + tabId: TAB_ID, + leafId: LEAF_ID, + incarnationId: INCARNATION_ID, + providerReattachLaunchIdentity: { + incarnationId: INCARNATION_ID, + launchAgent: 'codex' + } + }) + + expect(getPty(runtime, 'pty-reattach')).toMatchObject({ + incarnationId: INCARNATION_ID, + paneKey: PANE_KEY, + launchAgent: 'codex', + launchToken: null, + launchIncarnationId: null + }) + }) + + it('rejects provider identity from a different process incarnation', () => { + const runtime = new OrcaRuntimeService(null) + + runtime.registerPty('pty-mismatched-reattach', WORKTREE_ID, null, { + tabId: TAB_ID, + leafId: LEAF_ID, + incarnationId: INCARNATION_ID, + providerReattachLaunchIdentity: { + incarnationId: 'stale-provider-incarnation', + launchAgent: 'codex' + } + }) + + expect(getPty(runtime, 'pty-mismatched-reattach')).toMatchObject({ + incarnationId: INCARNATION_ID, + paneKey: PANE_KEY, + launchAgent: null, + launchToken: null, + launchIncarnationId: null + }) + }) + + it('retires daemon launch identity when the agent command finishes', () => { + const runtime = new OrcaRuntimeService(null) + + runtime.registerPty('pty-finished-reattach', WORKTREE_ID, null, { + tabId: TAB_ID, + leafId: LEAF_ID, + incarnationId: INCARNATION_ID, + providerReattachLaunchIdentity: { + incarnationId: INCARNATION_ID, + launchAgent: 'codex' + } + }) + runtime.emitDaemonPtyTransientFact('pty-finished-reattach', { + kind: 'command-finished', + exitCode: 0 + }) + + expect(getPty(runtime, 'pty-finished-reattach')).toMatchObject({ + launchAgent: null, + launchToken: null, + launchIncarnationId: null + }) + }) +}) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 3d507c5da88..b8b12bf2b1a 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -13769,6 +13769,7 @@ describe('OrcaRuntimeService', () => { const terminal = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { command: 'codex', + launchAgent: 'codex', launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} } }) const spawnEnv = @@ -13785,6 +13786,9 @@ describe('OrcaRuntimeService', () => { launchToken: spawnEnv.ORCA_AGENT_LAUNCH_TOKEN }) ).toBeDefined() + expect((await runtime.listTerminals()).terminals).toEqual([ + expect.objectContaining({ handle: terminal.handle, agentIdentity: 'codex' }) + ]) runtime.onPtyData('pty-authority', '\x1b]133;D;0\x07', 100) @@ -13795,6 +13799,9 @@ describe('OrcaRuntimeService', () => { launchToken: spawnEnv.ORCA_AGENT_LAUNCH_TOKEN }) ).toBeUndefined() + expect((await runtime.listTerminals()).terminals).toEqual([ + expect.not.objectContaining({ agentIdentity: expect.anything() }) + ]) }) it('retires only receipted restored PTY authority on command completion and exit', () => { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index b57e44aacf3..f7807df3f16 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -347,6 +347,7 @@ import type { TerminalPaneLayoutNode, TerminalTab } from '../../shared/terminal-tab-types' +import { resolvePublishedPaneAgentIdentity } from '../../shared/published-pane-agent-identity' import type { TuiAgent } from '../../shared/tui-agent' import type { BranchPrefixStrategy } from '../../shared/ui-chrome-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' @@ -11456,6 +11457,10 @@ export class OrcaRuntimeService { leafId: string incarnationId?: PtyIncarnationId agentLaunchAuthority?: { launchToken: string; launchAgent: TuiAgent } + providerReattachLaunchIdentity?: { + incarnationId: PtyIncarnationId + launchAgent: TuiAgent + } }, isWsl?: boolean ): void { @@ -11494,6 +11499,18 @@ export class OrcaRuntimeService { pty.launchIncarnationId = binding.incarnationId pty.launchAgent = agentLaunchAuthority.launchAgent } + const providerReattachLaunchIdentity = binding?.providerReattachLaunchIdentity + if ( + providerReattachLaunchIdentity && + paneKey && + binding.incarnationId === providerReattachLaunchIdentity.incarnationId && + pty.incarnationId === providerReattachLaunchIdentity.incarnationId && + pty.paneKey === paneKey && + isTuiAgent(providerReattachLaunchIdentity.launchAgent) + ) { + // Why: daemon metadata owns the surviving process; its incarnation fence restores identity without minting renderer launch authority. + pty.launchAgent = providerReattachLaunchIdentity.launchAgent + } const pendingIncarnation = this.pendingPtyRegistrationIncarnations.get(ptyId) if ( pendingIncarnation === null || @@ -14689,13 +14706,14 @@ export class OrcaRuntimeService { return } const receipt = this.restoredOrchestrationAuthorityByPtyId.get(ptyId) - if (!pty.launchToken && !receipt) { + if (!pty.launchToken && !receipt && !pty.launchAgent) { return } const paneKeys = this.collectPaneKeysForPty(ptyId) this.restoredOrchestrationAuthorityByPtyId.delete(ptyId) pty.launchToken = null pty.launchIncarnationId = null + pty.launchAgent = null for (const paneKey of paneKeys) { this.retireAgentHookCompatibilityAuthorityFn?.(paneKey) } @@ -33984,6 +34002,30 @@ export class OrcaRuntimeService { return null } + /** Thin adapter so the summary builders stay declarative; the decision lives in `src/shared`. */ + private resolvePaneAgentIdentityField( + launchAgent: TuiAgent | null | undefined, + foregroundAgent: TuiAgent | null | undefined, + title: string | null, + paneKey: string | null + ): { agentIdentity?: TuiAgent } { + // Why hooks here: an agent the USER started from a shell has no launch record, and on WSL the + // Windows host reads its foreground process as `wsl.exe` rather than the agent inside the + // distro. The hook is the only signal that survives both, because the agent reports itself. + const hookRow = paneKey + ? this.getHookAgentRowForPane(this.getAgentProviderSessionRowsForPaneFn?.(paneKey) ?? []) + : null + const hookAgent = isTuiAgent(hookRow?.agentType) ? hookRow.agentType : null + const agentIdentity = resolvePublishedPaneAgentIdentity({ + hookAgent, + hookIsLive: hookRow?.agentIsLive, + launchAgent, + foregroundAgent, + title + }) + return agentIdentity ? { agentIdentity } : {} + } + private buildTerminalSummary( leaf: RuntimeLeafRecord, worktreesById: Map, @@ -33993,6 +34035,7 @@ export class OrcaRuntimeService { const tab = this.tabs.get(leaf.tabId) ?? null const pty = leaf.ptyId ? this.ptysById.get(leaf.ptyId) : undefined + const title = getLatestLeafTitle(leaf, tab?.title ?? null) // Why: leaf.connected mirrors the renderer graph (`ptyId !== null`), so a // restored surface whose PTY died with a prior run still reads connected. // Demote only on a controller-proven absence, and only for locally-scoped @@ -34018,13 +34061,21 @@ export class OrcaRuntimeService { branch: worktree?.branch ?? '', tabId: leaf.tabId, leafId: leaf.leafId, - title: getLatestLeafTitle(leaf, tab?.title ?? null), + title, connected: provenAbsent ? false : leaf.connected, writable: provenAbsent ? false : leaf.writable, lastOutputAt: leaf.lastOutputAt, preview: leaf.preview, ...(leaf.lastExitCause ? { exitCause: leaf.lastExitCause } : {}), - ...this.terminalExecutionHostField(leaf.ptyId, leaf.worktreeId) + ...this.terminalExecutionHostField(leaf.ptyId, leaf.worktreeId), + ...this.resolvePaneAgentIdentityField( + pty?.launchAgent, + pty?.foregroundAgent, + title, + // Why guarded: makePaneKey THROWS on a non-UUID leaf id, and an unguarded call here took + // down terminal.list for every pane in the list, not just the odd one. + isTerminalLeafId(leaf.leafId) ? makePaneKey(leaf.tabId, leaf.leafId) : null + ) } } @@ -35225,6 +35276,7 @@ export class OrcaRuntimeService { providerSessionAgentType: string | null providerSessionReceivedAt: number | null agentType: string | null + agentIsLive: boolean live: HookLiveAgentRow | null } { let session: AgentStatusIpcPayload | null = null @@ -35265,6 +35317,9 @@ export class OrcaRuntimeService { providerSessionAgentType: session?.agentType ?? null, providerSessionReceivedAt: session?.receivedAt ?? null, agentType: agent?.agentType ?? null, + // A fresh completed row still projects its terminal `done` status, but it is + // past-tense identity evidence and must not outrank process or launch facts. + agentIsLive: agent != null && agent.state !== 'done', live: live ? { payload: pickParsedAgentStatusPayload(live), @@ -36048,6 +36103,7 @@ export class OrcaRuntimeService { worktreesById: Map ): RuntimeTerminalSummary { const worktree = worktreesById.get(pty.worktreeId) + const title = getLatestPtyTitle(pty) const pane = parsePaneKey(pty.paneKey ?? '') const orphaned = !pty.tabId || !pane || pane.tabId !== pty.tabId @@ -36061,13 +36117,19 @@ export class OrcaRuntimeService { branch: worktree?.branch ?? '', tabId: orphaned ? `pty:${pty.ptyId}` : pty.tabId!, leafId: orphaned ? `pty:${pty.ptyId}` : pane.leafId, - title: getLatestPtyTitle(pty), + title, connected: pty.connected, writable: pty.connected, lastOutputAt: pty.lastOutputAt, preview: pty.preview, ...(pty.lastExitCause ? { exitCause: pty.lastExitCause } : {}), - ...this.terminalExecutionHostField(pty.ptyId, pty.worktreeId) + ...this.terminalExecutionHostField(pty.ptyId, pty.worktreeId), + ...this.resolvePaneAgentIdentityField( + pty.launchAgent, + pty.foregroundAgent, + title, + pty.paneKey ?? null + ) } } diff --git a/src/main/runtime/orchestration/groups.test.ts b/src/main/runtime/orchestration/groups.test.ts index 81c2345c8b2..7fb30f733f0 100644 --- a/src/main/runtime/orchestration/groups.test.ts +++ b/src/main/runtime/orchestration/groups.test.ts @@ -18,7 +18,10 @@ function makeSummary( connected: opts.connected ?? true, writable: opts.writable ?? true, lastOutputAt: opts.lastOutputAt ?? null, - preview: opts.preview ?? '' + preview: opts.preview ?? '', + // Why spread and not a default: `agentIdentity` absent is meaningful (unknown), so the + // helper must be able to produce a summary that genuinely lacks the field. + ...(opts.agentIdentity ? { agentIdentity: opts.agentIdentity } : {}) } } @@ -97,209 +100,108 @@ describe('resolveGroupAddress', () => { }) describe('agent name groups', () => { - it('matches @claude by terminal title', () => { + // Why identity and not title: these groups used to match `@` against the terminal + // title, so any pane whose task text mentioned an agent received that agent's messages. + // Routing now reads the identity the host resolved from launch/process evidence it owns. + + it('routes to every pane the host resolved as that agent', () => { const terminals = [ - makeSummary('term_a', { title: 'Claude Code' }), - makeSummary('term_b', { title: 'Claude Code' }), - makeSummary('term_c', { title: 'Codex CLI' }) + makeSummary('term_a', { agentIdentity: 'claude' }), + makeSummary('term_b', { agentIdentity: 'claude' }), + makeSummary('term_c', { agentIdentity: 'codex' }) ] - const result = resolveGroupAddress('@claude', 'term_a', terminals, noStatus) - expect(result).toEqual(['term_b']) + expect(resolveGroupAddress('@claude', 'term_a', terminals, noStatus)).toEqual(['term_b']) }) - it('matches @mimo by terminal title', () => { + it.each([ + ['@codex', 'codex'], + ['@openclaude', 'openclaude'], + ['@mimo', 'mimo-code'], + ['@gemini', 'gemini'], + ['@droid', 'droid'], + ['@grok', 'grok'], + ['@cursor', 'cursor'], + ['@opencode', 'opencode'] + ])('routes %s to its agent id', (group, agentIdentity) => { const terminals = [ - makeSummary('term_a', { title: 'mimo' }), - makeSummary('term_b', { title: 'MiMo Code session' }), - makeSummary('term_c', { title: 'OpenCode' }) + makeSummary('sender'), + makeSummary('target', { agentIdentity: agentIdentity as never }), + makeSummary('other', { agentIdentity: 'claude' }) ] - const result = resolveGroupAddress('@mimo', 'term_a', terminals, noStatus) - expect(result).toEqual(['term_b']) + const expected = agentIdentity === 'claude' ? ['target', 'other'] : ['target'] + expect(resolveGroupAddress(group, 'sender', terminals, noStatus)).toEqual(expected) }) - it('matches @openclaude by terminal title', () => { - const terminals = [ - makeSummary('term_a', { title: 'OpenClaude' }), - makeSummary('term_b', { title: 'OpenClaude running' }), - makeSummary('term_c', { title: 'Claude Code' }) - ] - const result = resolveGroupAddress('@openclaude', 'term_a', terminals, noStatus) - expect(result).toEqual(['term_b']) + it('is case-insensitive for the group address', () => { + const terminals = [makeSummary('sender'), makeSummary('target', { agentIdentity: 'claude' })] + expect(resolveGroupAddress('@CLAUDE', 'sender', terminals, noStatus)).toEqual(['target']) }) - it('does not match OpenClaude titles through @claude', () => { + it('excludes the sender even when the sender is that agent', () => { const terminals = [ - makeSummary('term_a', { title: 'Claude Code' }), - makeSummary('term_b', { title: 'OpenClaude running' }) + makeSummary('sender', { agentIdentity: 'grok' }), + makeSummary('target', { agentIdentity: 'grok' }) ] - const result = resolveGroupAddress('@claude', 'term_a', terminals, noStatus) - expect(result).toEqual([]) + expect(resolveGroupAddress('@grok', 'sender', terminals, noStatus)).toEqual(['target']) }) - it('matches @codex by terminal title', () => { - const terminals = [ - makeSummary('term_a', { title: 'Codex CLI' }), - makeSummary('term_b', { title: 'Codex CLI' }) - ] - const result = resolveGroupAddress('@codex', 'term_a', terminals, noStatus) - expect(result).toEqual(['term_b']) + describe('a task title can no longer redirect a message', () => { + // The bug. Recorded titles of this exact shape exist: a Grok pane named + // "Switch Claude and Codex off the load balancerโ€ฆ - grok" received both @claude and @codex. + it('does not route @claude to a Codex pane whose task text names Claude', () => { + const terminals = [ + makeSummary('sender'), + makeSummary('codex_pane', { + agentIdentity: 'codex', + title: 'Review the Claude session-history fix' + }) + ] + expect(resolveGroupAddress('@claude', 'sender', terminals, noStatus)).toEqual([]) + }) + + it('does not route @codex to a Grok pane whose task text names Codex', () => { + const terminals = [ + makeSummary('sender'), + makeSummary('grok_pane', { + agentIdentity: 'grok', + title: 'Switch Claude and Codex off the load balancerโ€ฆ - grok' + }) + ] + expect(resolveGroupAddress('@codex', 'sender', terminals, noStatus)).toEqual([]) + expect(resolveGroupAddress('@grok', 'sender', terminals, noStatus)).toEqual(['grok_pane']) + }) + + it('does not route @cursor to a pane merely discussing a text cursor', () => { + const terminals = [ + makeSummary('sender'), + makeSummary('claude_pane', { + agentIdentity: 'claude', + title: 'fix the text cursor blink' + }) + ] + expect(resolveGroupAddress('@cursor', 'sender', terminals, noStatus)).toEqual([]) + }) }) - it('matches @droid by terminal title and excludes sender', () => { - const terminals = [ - makeSummary('term_a', { title: 'Droid ready' }), - makeSummary('term_b', { title: 'Droid ready' }), - makeSummary('term_c', { title: 'Droid - action required' }) - ] - const result = resolveGroupAddress('@droid', 'term_a', terminals, noStatus) - expect(result).toEqual(['term_b', 'term_c']) + describe('unknown identity fails closed', () => { + // Why: `agentIdentity` is absent when the host predates the field or had no evidence + // beyond the title. Delivery is an action, so unknown must not deliver. The sender sees + // no recipients, which is visible and recoverable; a message in the wrong agent's prompt + // is neither. + it('does not route to a pane with no resolved identity, whatever its title says', () => { + const terminals = [makeSummary('sender'), makeSummary('unknown', { title: 'Claude Code' })] + expect(resolveGroupAddress('@claude', 'sender', terminals, noStatus)).toEqual([]) + }) + + it('still routes the identity-free groups, which do not depend on the field', () => { + const terminals = [makeSummary('sender'), makeSummary('other', { title: 'Claude Code' })] + expect(resolveGroupAddress('@all', 'sender', terminals, noStatus)).toEqual(['other']) + }) }) - it('does not match Android, path, or hyphenated tokens through @droid', () => { - const terminals = [ - makeSummary('term_a', { title: 'Codex CLI' }), - makeSummary('term_b', { title: 'Android build' }), - makeSummary('term_c', { title: '/tmp/android' }), - makeSummary('term_d', { title: 'my-droid-worker' }) - ] - const result = resolveGroupAddress('@droid', 'term_a', terminals, noStatus) - expect(result).toEqual([]) - }) - - it('is case-insensitive for group address', () => { - const terminals = [makeSummary('term_a'), makeSummary('term_b', { title: 'Claude Code' })] - const result = resolveGroupAddress('@Claude', 'term_a', terminals, noStatus) - expect(result).toEqual(['term_b']) - }) - - it('matches @grok as a standalone title token and excludes sender', () => { - const terminals = [ - makeSummary('term_a', { title: 'Grok' }), - makeSummary('term_b', { title: 'GROK CLI' }), - makeSummary('term_c', { title: 'โ ‹ Grok' }), - makeSummary('term_d', { title: 'ngrok' }), - makeSummary('term_e', { title: '/tmp/grok' }), - makeSummary('term_f', { title: 'my-grok-worker' }), - makeSummary('term_g', { title: 'Codex CLI' }) - ] - - const result = resolveGroupAddress('@GrOk', 'term_a', terminals, noStatus) - - expect(result).toEqual(['term_b', 'term_c']) - }) - - // Why: the resolver sees the raw OSC title, and Grok CLI's real working/session - // titles carry a trailing " - grok" identity or a spinner-collapsed "โ ‹ grok" - // (see terminal-title-agent-type.ts). Prove those production shapes resolve. - it('matches real Grok OSC working and session titles', () => { - const terminals = [ - makeSummary('coordinator', { title: 'Coordinator' }), - makeSummary('term_rotating', { title: 'โ ‹ - fix the flaky suite - grok' }), - makeSummary('term_collapsed', { title: 'โ ‹ grok' }), - makeSummary('term_session', { title: 'Fix the auth bug - grok' }) - ] - - const result = resolveGroupAddress('@grok', 'coordinator', terminals, noStatus) - - expect(result).toEqual(['term_rotating', 'term_collapsed', 'term_session']) - }) - - // Why: Windows agent titles can surface the launcher process name (`grok.exe`); - // the shared matcher accepts .exe/.cmd/.bat/.ps1 suffixes but still rejects - // arbitrary dotted fragments like `grok.py`. - it('matches Windows launcher-suffix titles but not arbitrary dotted tokens', () => { - const terminals = [ - makeSummary('coordinator', { title: 'Coordinator' }), - makeSummary('term_exe', { title: 'grok.exe' }), - makeSummary('term_cmd', { title: 'grok.cmd running' }), - makeSummary('term_dotted', { title: 'grok.py' }) - ] - - const result = resolveGroupAddress('@grok', 'coordinator', terminals, noStatus) - - expect(result).toEqual(['term_exe', 'term_cmd']) - }) - - // Why: these are the titles cursor-agent natively emits and the ones Orca synthesizes - // from Cursor hooks, so each must resolve. - it('matches native and Orca-synthesized Cursor titles', () => { - const terminals = [ - makeSummary('coordinator', { title: 'Coordinator' }), - makeSummary('term_native', { title: 'Cursor Agent' }), - makeSummary('term_working', { title: 'โ ‹ Cursor Agent' }), - makeSummary('term_idle', { title: 'Cursor ready' }), - makeSummary('term_permission', { title: 'Cursor - action required' }) - ] - - const result = resolveGroupAddress('@cursor', 'coordinator', terminals, noStatus) - - expect(result).toEqual(['term_native', 'term_working', 'term_idle', 'term_permission']) - }) - - it('is case-insensitive for @cursor', () => { - const terminals = [ - makeSummary('coordinator', { title: 'Coordinator' }), - makeSummary('term_b', { title: 'CURSOR READY' }) - ] - - const result = resolveGroupAddress('@CuRsOr', 'coordinator', terminals, noStatus) - - expect(result).toEqual(['term_b']) - }) - - // Why: this is the false positive that a whole-token matcher cannot catch. Claude - // and Codex put their task summary in the OSC title, and none of Claude's status - // prefixes carries its own name, so "cursor" as ordinary editor vocabulary is the - // only agent token such a title has. Routing @cursor there would write into a live - // non-Cursor agent's prompt. - it('does not match another agent whose task title mentions a text cursor', () => { - const terminals = [ - makeSummary('coordinator', { title: 'Coordinator' }), - makeSummary('term_claude_working', { - title: 'โ ‹ preserve cursor visibility across replays' - }), - makeSummary('term_claude_idle', { title: 'โœณ Fix the text cursor blink' }), - makeSummary('term_claude_dot', { title: '. fix cursor position' }), - makeSummary('term_claude_star', { title: '* cursor rendering done' }), - makeSummary('term_codex', { title: 'โ ‹ Codex: fix cursor offsets' }), - makeSummary('term_grok', { title: 'โ ‹ - restoring cursor state - grok' }), - makeSummary('term_shell', { title: 'Terminal Cursor and Orca slows down' }) - ] - - const result = resolveGroupAddress('@cursor', 'coordinator', terminals, noStatus) - - expect(result).toEqual([]) - }) - - // Why: pin the deliberate tradeoff. A renamed Cursor pane outranks its OSC title, so it - // is group-unaddressable and resolves to nothing instead of resolving loosely. A silent - // miss (address it by handle) is preferred over delivering into another agent's prompt. - it('does not match a renamed Cursor terminal or the bare process name', () => { - const terminals = [ - makeSummary('coordinator', { title: 'Coordinator' }), - makeSummary('term_renamed', { title: 'Cursor - reviewer' }), - makeSummary('term_worker', { title: 'cursor worker 2' }), - makeSummary('term_process', { title: 'cursor-agent' }) - ] - - const result = resolveGroupAddress('@cursor', 'coordinator', terminals, noStatus) - - expect(result).toEqual([]) - }) - - it('does not match cursor paths, hyphenated compounds, or dotted tokens', () => { - const terminals = [ - makeSummary('coordinator', { title: 'Coordinator' }), - makeSummary('term_rules', { title: '~/cursor-rules' }), - makeSummary('term_path', { title: '/tmp/cursor' }), - makeSummary('term_worker', { title: 'my-cursor-worker' }), - makeSummary('term_file', { title: 'render-cursor-after-bracketed-paste' }), - makeSummary('term_dotted', { title: 'cursor.ts' }) - ] - - const result = resolveGroupAddress('@cursor', 'coordinator', terminals, noStatus) - - expect(result).toEqual([]) + it('returns no recipients for an unknown group', () => { + const terminals = [makeSummary('sender'), makeSummary('target', { agentIdentity: 'claude' })] + expect(resolveGroupAddress('@nonsense', 'sender', terminals, noStatus)).toEqual([]) }) }) diff --git a/src/main/runtime/orchestration/groups.ts b/src/main/runtime/orchestration/groups.ts index 6f31f5df261..64c0900bfb9 100644 --- a/src/main/runtime/orchestration/groups.ts +++ b/src/main/runtime/orchestration/groups.ts @@ -1,6 +1,5 @@ -import { isCursorAgentTitle } from '../../../shared/agent-title-core' -import { buildAgentNameRe } from '../../../shared/agent-name-token-match' import type { RuntimeTerminalSummary } from '../../../shared/runtime-types' +import type { TuiAgent } from '../../../shared/tui-agent' // Why: group addresses enable broadcast messaging to logical groups of agents. // Resolution is done at send-time: one message record per recipient, same thread_id, @@ -24,23 +23,36 @@ export function isGroupAddress(to: string): boolean { return to.startsWith('@') } -// Why: a name token identifies an agent only when the name is a coined word. `cursor` is -// also ordinary vocabulary in another agent's task-summary title ("fix the text cursor -// blink"), so token-matching it would route @cursor into a live Claude/Codex prompt. Names -// with that ambiguity register the identity predicate delivery already applies to them. -const GROUP_TITLE_MATCHERS: Partial boolean>> = { - cursor: isCursorAgentTitle +/** Group name to the agent id the host publishes for a pane. */ +const GROUP_AGENT_IDS: Record = { + claude: 'claude', + openclaude: 'openclaude', + codex: 'codex', + opencode: 'opencode', + mimo: 'mimo-code', + gemini: 'gemini', + droid: 'droid', + grok: 'grok', + cursor: 'cursor' } -function titleMatchesAgentNameGroup(title: string, agentName: string): boolean { - const identityMatcher = GROUP_TITLE_MATCHERS[agentName as AgentNameGroup] - if (identityMatcher) { - return identityMatcher(title) - } - // Why: reuse the shared whole-token matcher so orchestration groups honor the - // same Windows launcher-suffix rule (e.g. `grok.exe`) as the rest of Orca's - // agent-title detection, instead of maintaining a divergent regex here. - return buildAgentNameRe(agentName).test(title) +/** + * Whether this terminal IS the addressed agent. + * + * Why the host's resolved identity and not the title: a terminal title is a decoration channel + * that routinely contains other agents' names, because people describe agent work in their task + * titles. Matching `@claude` against the title delivered the message to any pane whose task text + * happened to say "claude" โ€” a Codex pane reviewing a Claude PR received Claude's instructions. + * Recorded titles like "Switch Claude and Codex off the load balancerโ€ฆ - grok" are the ordinary + * case, not a contrived one. + * + * Why an absent identity means NO: `agentIdentity` is absent when the host predates the field or + * had no evidence beyond the title. Delivery is an action, so unknown fails closed. Not + * delivering is visible and recoverable โ€” the sender sees no recipients; delivering to the wrong + * agent is neither. + */ +function terminalIsAgent(terminal: RuntimeTerminalSummary, agentName: AgentNameGroup): boolean { + return terminal.agentIdentity === GROUP_AGENT_IDS[agentName] } export function resolveGroupAddress( @@ -76,9 +88,9 @@ export function resolveGroupAddress( .map((t) => t.handle) } - // Why: agent-name groups (@claude, @droid, etc.) match by terminal title so - // the sender can address all instances of a particular agent type without - // knowing their handles. + // Why: agent-name groups (@claude, @droid, etc.) resolve against the identity the HOST + // published for each pane, so the sender can address every instance of an agent without + // knowing their handles โ€” and without a task title being able to redirect the message. const agentName = group.slice(1) // remove @ if ((AGENT_NAME_GROUPS as readonly string[]).includes(agentName)) { return terminals @@ -86,7 +98,7 @@ export function resolveGroupAddress( if (t.handle === senderHandle) { return false } - return titleMatchesAgentNameGroup(t.title ?? '', agentName) + return terminalIsAgent(t, agentName as AgentNameGroup) }) .map((t) => t.handle) } diff --git a/src/main/runtime/rpc/methods/orchestration-send.test.ts b/src/main/runtime/rpc/methods/orchestration-send.test.ts index 36eaaa8b0a9..e4312c34a3a 100644 --- a/src/main/runtime/rpc/methods/orchestration-send.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-send.test.ts @@ -591,7 +591,10 @@ describe('orchestration RPC methods', () => { connected: opts.connected ?? true, writable: opts.writable ?? true, lastOutputAt: opts.lastOutputAt ?? null, - preview: opts.preview ?? '' + preview: opts.preview ?? '', + // Why spread: absent `agentIdentity` means unknown, so the helper must be able to + // produce a summary that genuinely lacks the field. + ...(opts.agentIdentity ? { agentIdentity: opts.agentIdentity } : {}) } } @@ -726,11 +729,11 @@ describe('orchestration RPC methods', () => { expect(result.messages[0].to_handle).toBe('term_b') }) - it('fans out agent name group (@claude) by title match', async () => { + it('fans out an agent name group by host-resolved identity', async () => { setupWithTerminals([ - makeSummary('term_a', { title: 'Claude Code' }), - makeSummary('term_b', { title: 'Claude Code' }), - makeSummary('term_c', { title: 'Codex' }) + makeSummary('term_a', { agentIdentity: 'claude' }), + makeSummary('term_b', { agentIdentity: 'claude' }), + makeSummary('term_c', { agentIdentity: 'codex' }) ]) const result = (await call('orchestration.send', { @@ -743,11 +746,13 @@ describe('orchestration RPC methods', () => { expect(result.messages[0].to_handle).toBe('term_b') }) - it('fans out @droid by title match', async () => { + it('fans out @droid without claiming a pane whose title merely contains the word', async () => { setupWithTerminals([ - makeSummary('term_a', { title: 'Codex' }), - makeSummary('term_b', { title: 'Droid ready' }), - makeSummary('term_c', { title: 'Android build' }) + makeSummary('term_a', { agentIdentity: 'codex' }), + makeSummary('term_b', { agentIdentity: 'droid' }), + // Why kept: "Android build" contains `droid` as a substring. It was excluded before by + // whole-token matching and is excluded now because its identity is not droid. + makeSummary('term_c', { agentIdentity: 'claude', title: 'Android build' }) ]) const result = (await call('orchestration.send', { @@ -760,11 +765,12 @@ describe('orchestration RPC methods', () => { expect(result.messages[0].to_handle).toBe('term_b') }) - it('fans out @cursor by title match without claiming a cursor-mentioning title', async () => { + it('fans out @cursor without claiming a Claude pane discussing a text cursor', async () => { setupWithTerminals([ - makeSummary('term_a', { title: 'Codex' }), - makeSummary('term_b', { title: 'Cursor ready' }), - makeSummary('term_c', { title: 'โœณ Fix the text cursor blink' }) + makeSummary('term_a', { agentIdentity: 'codex' }), + makeSummary('term_b', { agentIdentity: 'cursor' }), + // The original hazard, now excluded structurally rather than by a bespoke predicate. + makeSummary('term_c', { agentIdentity: 'claude', title: 'โœณ Fix the text cursor blink' }) ]) const result = (await call('orchestration.send', { diff --git a/src/shared/pane-agent-identity-inventory.test.ts b/src/shared/pane-agent-identity-inventory.test.ts index d1fd17b3a4c..a0297e5c33e 100644 --- a/src/shared/pane-agent-identity-inventory.test.ts +++ b/src/shared/pane-agent-identity-inventory.test.ts @@ -124,11 +124,6 @@ const INVENTORY: readonly InventoryGroup[] = [ classification: 'evidence-producer', paths: [['src/renderer/src/hooks/ipc-events/agent-status-routing.ts', 2]] }, - { - helper: 'buildAgentNameRe', - classification: 'action-consumer', - paths: [['src/main/runtime/orchestration/groups.ts', 2]] - }, { helper: 'buildAgentNameRe', classification: 'parser-implementation', diff --git a/src/shared/published-pane-agent-identity.test.ts b/src/shared/published-pane-agent-identity.test.ts new file mode 100644 index 00000000000..6a338e891c6 --- /dev/null +++ b/src/shared/published-pane-agent-identity.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest' +import { resolvePublishedPaneAgentIdentity } from './published-pane-agent-identity' + +const resolve = resolvePublishedPaneAgentIdentity + +describe('resolvePublishedPaneAgentIdentity', () => { + describe('a task title cannot name the pane', () => { + // Minimized from real recorded titles. Each is a pane of one agent whose task text names + // another; before this, `@` routing delivered to them. + it.each([ + ['Switch Claude and Codex off the load balancerโ€ฆ - grok', 'grok'], + ['Review the Claude session-history fix', 'codex'], + ['โœณ Fix the text cursor blink', 'claude'] + ])('keeps %j as its launched agent', (title, launchAgent) => { + expect(resolve({ launchAgent: launchAgent as never, title })).toBe(launchAgent) + }) + }) + + it('keeps the launch record when an unambiguous title names a different agent', () => { + // The ranking assertion. Every other case here either yields no title evidence or agrees with + // the launch record, so without this one the suite passes even with title ranked FIRST โ€” + // verified by mutation. `โœณ Claude Code` is a parseable, unambiguous Claude title, and it still + // must not rename a pane Orca launched as Codex. + expect(resolve({ launchAgent: 'codex', title: 'โœณ Claude Code' })).toBe('codex') + }) + + it('prefers the live foreground process to the launch record', () => { + // The pane was launched as Claude and the user then started Codex in it. The process is the + // more direct observation, so it wins. + expect(resolve({ launchAgent: 'claude', foregroundAgent: 'codex' })).toBe('codex') + }) + + describe('title is the last resort, and the parser is what makes that safe', () => { + // The misdelivery this PR exists to stop, re-checked with title ALLOWED at the bottom. The old + // code matched `buildAgentNameRe('claude').test(title)`; the parser is categorically stricter + // and yields nothing for a name that only appears in task text. + it('does not name a Codex pane Claude from its task text', () => { + expect(resolve({ title: 'Review the Claude session-history fix' })).toBeUndefined() + }) + + it('reads the owner suffix, not the agents named in the task text', () => { + expect(resolve({ title: 'Switch Claude and Codex off the load balancerโ€ฆ - grok' })).toBe( + 'grok' + ) + }) + + it('identifies a pane whose title unambiguously names its agent', () => { + // The WSL case: no launch record, no readable foreground process, no hooks installed. An + // unambiguous title is the only thing left, and refusing it made the pane unaddressable. + expect(resolve({ title: 'โœณ Claude Code' })).toBe('claude') + }) + + it('still declines a bare worktree name that merely contains an agent word', () => { + expect(resolve({ title: 'review-14600-codex' })).toBeUndefined() + }) + + it('lets every stronger source outrank an unambiguous title', () => { + expect(resolve({ title: 'โœณ Claude Code', hookAgent: 'codex', hookIsLive: true })).toBe( + 'codex' + ) + expect(resolve({ title: 'โœณ Claude Code', foregroundAgent: 'codex' })).toBe('codex') + expect(resolve({ title: 'โœณ Claude Code', launchAgent: 'codex' })).toBe('codex') + }) + }) + + it('publishes nothing when the title names no agent unambiguously', () => { + // Absence is meaningful: it tells a caller to fail closed rather than guess. + expect(resolve({ title: 'โ— Rebase PR #14624 onto main' })).toBeUndefined() + expect(resolve({ title: 'Fix the codex bug' })).toBeUndefined() + expect(resolve({})).toBeUndefined() + }) + + it('publishes nothing for a hyphenated worktree name that contains an agent word', () => { + expect(resolve({ title: 'review-14600-codex' })).toBeUndefined() + }) + + describe('identity must not depend on how the agent was started', () => { + // Most agents are started by typing `claude` / `codex` at a shell, not through Orca's agent + // launcher. Those panes have no launch record at all, so anything that leans on one works for + // roughly half of real usage. + it('identifies a shell-started agent from its own hook report', () => { + expect(resolve({ hookAgent: 'codex', hookIsLive: true })).toBe('codex') + }) + + it('identifies a shell-started agent on WSL, where the process signal is useless', () => { + // The Windows host reads the foreground process of a WSL pane as `wsl.exe`, not the agent + // running inside the distro โ€” so `foregroundAgent` cannot name it and there is no launch + // record. Without hook evidence this pane is unaddressable. + expect(resolve({ hookAgent: 'codex', hookIsLive: false })).toBe('codex') + }) + + it('still resolves when only a launch record exists', () => { + expect(resolve({ launchAgent: 'claude' })).toBe('claude') + }) + }) + + describe('a launch record is outranked by live observation, not by other records', () => { + // NOTE: `launch` deliberately still ranks ABOVE `completed-hook`, matching + // pane-agent-owner.ts. Demoting it looked right โ€” a launch record is past tense โ€” but a + // completed hook is past tense too, and without run keys it never expires. Ranking it higher + // lets a stale hook from a previous agent hijack a pane from the fresh launch record of the + // agent running now. The reorder belongs with run generation, not before it. + + // Field report: launch an agent, close it, reuse the terminal โ€” the pane kept reading as the + // old agent. A launch record does not stop being true when the thing it describes ends. + it('keeps the launch record when only a completed hook competes', () => { + // Both are records, and neither expires without run keys. The launch record is the one Orca + // stamped for the CURRENT process, so promoting the hook above it lets a stale hook from a + // previous agent hijack the pane. Matches pane-agent-owner.ts. + expect(resolve({ launchAgent: 'claude', hookAgent: 'codex', hookIsLive: false })).toBe( + 'claude' + ) + }) + + it('lets a live hook outrank a stale launch record', () => { + expect(resolve({ launchAgent: 'claude', hookAgent: 'codex', hookIsLive: true })).toBe('codex') + }) + + it('lets the live foreground process outrank a stale launch record', () => { + expect(resolve({ launchAgent: 'claude', foregroundAgent: 'codex' })).toBe('codex') + }) + }) +}) diff --git a/src/shared/published-pane-agent-identity.ts b/src/shared/published-pane-agent-identity.ts new file mode 100644 index 00000000000..a00864a3b3a --- /dev/null +++ b/src/shared/published-pane-agent-identity.ts @@ -0,0 +1,61 @@ +import { collectAgentTitleEvidence } from './agent-title-evidence' +import { resolvePaneAgentIdentity } from './pane-agent-identity-resolver' +import type { TuiAgent } from './tui-agent' + +/** + * The agent a host publishes for a pane, for consumers that ACT on identity. + * + * Kept out of the runtime class so it can be tested without one, and so routing, delivery and the + * UI all read the same decision instead of each re-deriving it. + * + * Hook evidence ranks first because the agent reports its own identity regardless of how it was + * started โ€” Orca's launcher, a shell prompt, or a resumed session โ€” and regardless of host. + * + * Title ranks LAST and is a genuine last resort, not forbidden. An earlier revision refused it + * outright for action consumers, reasoning that a title must never authorize a write. That + * conflated the parser with the raw substring match it replaced: `collectAgentTitleEvidence` + * returns null on exactly the shapes that caused misdelivery โ€” "Review the Claude session-history + * fix" on a Codex pane yields nothing, and "Switch Claude and Codex off the load balancerโ€ฆ - grok" + * yields grok from its owner suffix. Refusing it instead cost real panes their identity: an agent + * a user started by hand inside an Orca WSL terminal has no launch record, no readable foreground + * process (the Windows host sees `wsl.exe`), and โ€” until managed Codex hooks install there โ€” no + * hook either, leaving a title that names it unambiguously as the only thing left. + * + * Returns undefined when nothing is known, and absence is published as absence. A caller that + * authorizes an action must fail closed on it rather than falling back to parsing the title. + */ +export function resolvePublishedPaneAgentIdentity(args: { + /** + * The agent a provider hook reported for this pane. The ONLY signal that does not depend on how + * the agent was started: a user who types `claude` at a shell still posts hooks. It is also the + * only one that survives WSL, where the Windows host reads the foreground process as `wsl.exe` + * rather than the agent running inside the distro. + */ + hookAgent?: TuiAgent | null + /** Whether that hook belongs to a turn in progress, as opposed to one that finished. */ + hookIsLive?: boolean + launchAgent?: TuiAgent | null + foregroundAgent?: TuiAgent | null + title?: string | null +}): TuiAgent | undefined { + const titleAgent = args.title ? collectAgentTitleEvidence(args.title).agent : null + return ( + resolvePaneAgentIdentity({ + evidence: [ + ...(args.hookAgent + ? [ + { + source: args.hookIsLive ? ('live-hook' as const) : ('completed-hook' as const), + agent: args.hookAgent + } + ] + : []), + ...(args.foregroundAgent + ? [{ source: 'process' as const, agent: args.foregroundAgent }] + : []), + ...(args.launchAgent ? [{ source: 'launch' as const, agent: args.launchAgent }] : []), + ...(titleAgent ? [{ source: 'title' as const, agent: titleAgent }] : []) + ] + }).agent ?? undefined + ) +} diff --git a/src/shared/runtime-terminal-contracts.ts b/src/shared/runtime-terminal-contracts.ts index 02eb5df9ebd..8e889129949 100644 --- a/src/shared/runtime-terminal-contracts.ts +++ b/src/shared/runtime-terminal-contracts.ts @@ -26,6 +26,8 @@ export type RuntimeTerminalSummary = { writable: boolean lastOutputAt: number | null preview: string + /** Host-resolved agent identity for action consumers; absent when unknown or unsupported. */ + agentIdentity?: TuiAgent /** Absent while running or when the host predates the field; never infer a clean finish. */ exitCause?: TerminalExitCause /** Absent when the host predates the field or could not name the execution host. */ From 81ae98e10d88484470ca0f1257c8eeddb133df6a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:02:38 -0700 Subject: [PATCH 09/29] fix(mobile): honor host worktree create retention (#16342) * fix(mobile): honor host worktree create retention * fix(mobile): cover malformed worktree retention policy * fix(mobile): fail closed on malformed retention policy * fix(mobile): fail closed on missing dedupe ttl --- .../use-new-workspace-create-submit.ts | 7 +- .../src/tasks/blank-workspace-create.test.ts | 17 ++- mobile/src/tasks/blank-workspace-create.ts | 5 +- .../src/tasks/source-workspace-create.test.ts | 6 +- mobile/src/tasks/source-workspace-create.ts | 17 +-- .../tasks/worktree-create-capability.test.ts | 132 +++++++++++++++-- .../src/tasks/worktree-create-capability.ts | 36 +++-- .../worktree-create-idempotency-policy.ts | 40 ++++++ .../src/tasks/worktree-create-retry.test.ts | 136 +++++++++++++----- mobile/src/tasks/worktree-create-retry.ts | 45 +++--- src/main/runtime/orca-runtime.test.ts | 1 + src/main/runtime/orca-runtime.ts | 10 +- .../worktree-create-retry-policy.ts | 7 - src/shared/protocol-version.ts | 3 +- src/shared/runtime-session-contracts.ts | 4 + 15 files changed, 356 insertions(+), 110 deletions(-) create mode 100644 mobile/src/tasks/worktree-create-idempotency-policy.ts diff --git a/mobile/src/components/use-new-workspace-create-submit.ts b/mobile/src/components/use-new-workspace-create-submit.ts index 14d9f9bfae0..1ab3ac5d5c4 100644 --- a/mobile/src/components/use-new-workspace-create-submit.ts +++ b/mobile/src/components/use-new-workspace-create-submit.ts @@ -16,6 +16,7 @@ import { normalizeWorkspaceAgent } from '../tasks/workspace-agent-selection' import type { WorkspaceCreateSetupDecision } from '../tasks/workspace-create-params' import type { WorkspaceSshGate } from '../tasks/workspace-ssh-gate' import type { useMobileComposerSource } from '../tasks/use-mobile-composer-source' +import type { WorktreeCreateIdempotencySupport } from '../tasks/worktree-create-idempotency-policy' import { pickPreferredNewWorktreeAgent, type NewWorktreeAgentOption, @@ -54,7 +55,7 @@ export function useNewWorkspaceCreateSubmit(args: { runSetup: boolean trustedOrcaHooks: PersistedTrustedOrcaHooks setTrustedOrcaHooks: (trust: PersistedTrustedOrcaHooks) => void - getWorktreeCreateCutoverSupport: () => Promise + getWorktreeCreateCutoverSupport: () => Promise transitionDrawer: (view: Exclude) => void setError: Dispatch> onCreated: (worktreeId: string, name: string) => void @@ -163,7 +164,7 @@ export function useNewWorkspaceCreateSubmit(args: { workspaceName: trimmedName || undefined, note: trimmedNote, nameIsAutoManaged: args.composer.isNameAutoManaged, - supportsIdempotentCutoverRetry: args.getWorktreeCreateCutoverSupport() + worktreeCreateIdempotency: args.getWorktreeCreateCutoverSupport() }) : await createBlankWorkspace({ client, @@ -173,7 +174,7 @@ export function useNewWorkspaceCreateSubmit(args: { createdWithAgentId, comment: trimmedNote, setupDecision, - supportsIdempotentCutoverRetry: args.getWorktreeCreateCutoverSupport() + worktreeCreateIdempotency: args.getWorktreeCreateCutoverSupport() }) if ('error' in result) { args.setError(result.error) diff --git a/mobile/src/tasks/blank-workspace-create.test.ts b/mobile/src/tasks/blank-workspace-create.test.ts index 32e9e162ef4..1d3cf2c15eb 100644 --- a/mobile/src/tasks/blank-workspace-create.test.ts +++ b/mobile/src/tasks/blank-workspace-create.test.ts @@ -1,9 +1,14 @@ import { describe, expect, it } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import { createBlankWorkspace } from './blank-workspace-create' +import { WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS } from './worktree-create-idempotency-policy' type Call = { method: string; params: unknown } +const IDEMPOTENT_CREATE_SUPPORT = { + dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS +} + function fakeClient(script: (method: string, call: number) => unknown, calls: Call[]): RpcClient { return { sendRequest: async (method: string, params?: unknown) => { @@ -35,7 +40,7 @@ describe('createBlankWorkspace', () => { comment: undefined, setupDecision: 'inherit', nameWasGenerated: false, - supportsIdempotentCutoverRetry: true + worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT }) expect(result).toEqual({ worktreeId: 'wt-1', name: 'octopus' }) @@ -70,7 +75,7 @@ describe('createBlankWorkspace', () => { comment: undefined, setupDecision: 'inherit', nameWasGenerated: true, - supportsIdempotentCutoverRetry: true + worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT }) expect(calls[0]?.params).toMatchObject({ nameWasGenerated: true }) @@ -90,7 +95,7 @@ describe('createBlankWorkspace', () => { comment: 'spike', setupDecision: 'run', nameWasGenerated: false, - supportsIdempotentCutoverRetry: true + worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT }) const params = calls[0]?.params as Record @@ -122,7 +127,7 @@ describe('createBlankWorkspace', () => { comment: undefined, setupDecision: 'inherit', nameWasGenerated: false, - supportsIdempotentCutoverRetry: true + worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT }) expect(result).toEqual({ worktreeId: 'wt-3', name: 'octopus-2' }) @@ -148,7 +153,7 @@ describe('createBlankWorkspace', () => { comment: undefined, setupDecision: 'inherit', nameWasGenerated: false, - supportsIdempotentCutoverRetry: true + worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT }) expect(result).toEqual({ worktreeId: 'wt-4', name: 'octopus-2' }) @@ -167,7 +172,7 @@ describe('createBlankWorkspace', () => { comment: undefined, setupDecision: 'skip', nameWasGenerated: false, - supportsIdempotentCutoverRetry: true + worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT }) expect(result).toEqual({ error: 'SSH connection is not available' }) diff --git a/mobile/src/tasks/blank-workspace-create.ts b/mobile/src/tasks/blank-workspace-create.ts index d81d75d5a69..77d0dccc4d3 100644 --- a/mobile/src/tasks/blank-workspace-create.ts +++ b/mobile/src/tasks/blank-workspace-create.ts @@ -1,6 +1,7 @@ import type { TuiAgent } from '../../../src/shared/tui-agent' import type { RpcClient } from '../transport/rpc-client' import { createWorktreeWithNameRetry, type WorktreeCreateResult } from './worktree-create-retry' +import type { WorktreeCreateIdempotencyProbe } from './worktree-create-idempotency-policy' import { agentLaunchCreateFields, type WorkspaceCreateSetupDecision @@ -19,13 +20,13 @@ export async function createBlankWorkspace(args: { /** True when `baseName` is a generated creature name rather than one the user typed; only then * may the host retire it. */ nameWasGenerated: boolean - supportsIdempotentCutoverRetry: boolean | Promise + worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe }): Promise { return createWorktreeWithNameRetry({ client: args.client, baseName: args.baseName, nameWasGenerated: args.nameWasGenerated, - supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry, + worktreeCreateIdempotency: args.worktreeCreateIdempotency, buildParams: (name) => { const params: Record = { repo: `id:${args.repoId}`, diff --git a/mobile/src/tasks/source-workspace-create.test.ts b/mobile/src/tasks/source-workspace-create.test.ts index ba1ddc4b300..66974f63443 100644 --- a/mobile/src/tasks/source-workspace-create.test.ts +++ b/mobile/src/tasks/source-workspace-create.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import { createWorkspaceFromComposerSource } from './source-workspace-create' import type { MobileComposerCreateSelection } from './mobile-composer-source-types' +import { WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS } from './worktree-create-idempotency-policy' type Call = { method: string; params: Record } @@ -24,6 +25,9 @@ function fakeClient(handle: (method: string, call: number) => unknown, calls: Ca } const agent = { choice: 'blank' as const } +const IDEMPOTENT_CREATE_SUPPORT = { + dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS +} const baseArgs = { targetRepoId: 'repo-1', @@ -31,7 +35,7 @@ const baseArgs = { agent, workspaceName: undefined, note: undefined, - supportsIdempotentCutoverRetry: true + worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT } describe('createWorkspaceFromComposerSource', () => { diff --git a/mobile/src/tasks/source-workspace-create.ts b/mobile/src/tasks/source-workspace-create.ts index 53b6a2bd03f..53e13e68ea3 100644 --- a/mobile/src/tasks/source-workspace-create.ts +++ b/mobile/src/tasks/source-workspace-create.ts @@ -13,6 +13,7 @@ import { type WorkspaceCreateTaskItem } from './workspace-create-params' import { createWorktreeWithNameRetry, type WorktreeCreateResult } from './worktree-create-retry' +import type { WorktreeCreateIdempotencyProbe } from './worktree-create-idempotency-policy' // The agent bundle the modal resolved: `choice` drives launch resolution โ€” the // host applies the agent's launch args (permission flags) and shell quoting. @@ -29,7 +30,7 @@ export type CreateWorkspaceFromComposerArgs = { workspaceName: string | undefined note: string | undefined nameIsAutoManaged?: boolean - supportsIdempotentCutoverRetry: boolean | Promise + worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe } export async function createWorkspaceFromComposerSource( @@ -92,7 +93,7 @@ async function createWorkItemWorkspace(args: { workspaceName: string | undefined note: string | undefined nameIsAutoManaged?: boolean - supportsIdempotentCutoverRetry: boolean | Promise + worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe }): Promise { const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args const item = selection.item @@ -137,7 +138,7 @@ async function createWorkItemWorkspace(args: { return createWorktreeWithNameRetry({ client, baseName, - supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry, + worktreeCreateIdempotency: args.worktreeCreateIdempotency, buildParams: (name) => ({ ...params, name }) }) } @@ -150,7 +151,7 @@ async function createBranchWorkspace(args: { agent: WorkspaceCreateAgentBundle workspaceName: string | undefined note: string | undefined - supportsIdempotentCutoverRetry: boolean | Promise + worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe }): Promise { const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice @@ -174,7 +175,7 @@ async function createBranchWorkspace(args: { return createWorktreeWithNameRetry({ client, baseName, - supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry, + worktreeCreateIdempotency: args.worktreeCreateIdempotency, maxAttempts: 1, buildParams: (name) => applyCommon({ @@ -196,7 +197,7 @@ async function createBranchWorkspace(args: { return createWorktreeWithNameRetry({ client, baseName, - supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry, + worktreeCreateIdempotency: args.worktreeCreateIdempotency, buildParams: (candidate) => { const params: Record = { repo: `id:${targetRepoId}`, @@ -220,7 +221,7 @@ async function createNewBranchWorkspace(args: { agent: WorkspaceCreateAgentBundle workspaceName: string | undefined note: string | undefined - supportsIdempotentCutoverRetry: boolean | Promise + worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe }): Promise { const { client, selection, targetRepoId, setupDecision, agent, note } = args const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice @@ -232,7 +233,7 @@ async function createNewBranchWorkspace(args: { return createWorktreeWithNameRetry({ client, baseName: selection.branchName, - supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry, + worktreeCreateIdempotency: args.worktreeCreateIdempotency, buildParams: (candidate) => { const params: Record = { repo: `id:${targetRepoId}`, diff --git a/mobile/src/tasks/worktree-create-capability.test.ts b/mobile/src/tasks/worktree-create-capability.test.ts index 8b1e55efcae..cad108e29d7 100644 --- a/mobile/src/tasks/worktree-create-capability.test.ts +++ b/mobile/src/tasks/worktree-create-capability.test.ts @@ -2,8 +2,21 @@ import { describe, expect, it } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client' import { readNewWorktreeRuntimeCapabilities } from './worktree-create-capability' +import { + WORKTREE_CREATE_DEDUPE_TTL_CLIENT_CEILING_MS, + WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS +} from './worktree-create-idempotency-policy' -function statusClient(outcomes: Array<'cutover' | 'error' | string[]>): RpcClient { +type StatusOutcome = + | 'cutover' + | 'error' + | string[] + | { + capabilities?: string[] + worktreeCreateIdempotency?: unknown + } + +function statusClient(outcomes: StatusOutcome[]): RpcClient { let call = 0 return { sendRequest: async () => { @@ -15,10 +28,13 @@ function statusClient(outcomes: Array<'cutover' | 'error' | string[]>): RpcClien if (outcome === 'error') { throw new Error('offline') } + const result = Array.isArray(outcome) + ? { capabilities: outcome, hostPlatform: 'darwin' } + : { ...outcome, hostPlatform: 'darwin' } return { id: '1', ok: true, - result: { capabilities: outcome, hostPlatform: 'darwin' }, + result, _meta: { runtimeId: 'r' } } } @@ -29,23 +45,123 @@ describe('readNewWorktreeRuntimeCapabilities', () => { it('reads task and idempotent-create support from status.get', async () => { await expect( readNewWorktreeRuntimeCapabilities( - statusClient([['mobile.tasks.v1', 'worktree.create-idempotency.v1']]) + statusClient([ + { + capabilities: ['mobile.tasks.v1', 'worktree.create-idempotency.v1'], + worktreeCreateIdempotency: { dedupeTtlMs: 20_000 } + } + ]) ) ).resolves.toEqual({ tasksSupported: true, - idempotentWorktreeCreateSupported: true, + worktreeCreateIdempotency: { dedupeTtlMs: 20_000 }, hostPlatform: 'darwin' }) }) - it('retries the safe status probe after a connection cutover', async () => { + it('uses the bounded fallback for an old idempotent host without an advertisement', async () => { + await expect( + readNewWorktreeRuntimeCapabilities(statusClient([['worktree.create-idempotency.v1']])) + ).resolves.toEqual({ + tasksSupported: false, + worktreeCreateIdempotency: { dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_CLIENT_CEILING_MS }, + hostPlatform: 'darwin' + }) + }) + + it('fails closed when a valid idempotency container omits dedupeTtlMs', async () => { await expect( readNewWorktreeRuntimeCapabilities( - statusClient(['cutover', ['worktree.create-idempotency.v1']]) + statusClient([ + { + capabilities: ['worktree.create-idempotency.v1'], + worktreeCreateIdempotency: {} + } + ]) ) ).resolves.toEqual({ tasksSupported: false, - idempotentWorktreeCreateSupported: true, + worktreeCreateIdempotency: { dedupeTtlMs: 0 }, + hostPlatform: 'darwin' + }) + }) + + it.each([ + { label: 'null', value: null }, + { label: 'string', value: 'nonsense' }, + { label: 'number', value: 20_000 }, + { label: 'empty array', value: [] } + ])( + 'fails closed for a malformed idempotency container ($label)', + async ({ value: worktreeCreateIdempotency }) => { + await expect( + readNewWorktreeRuntimeCapabilities( + statusClient([ + { + capabilities: ['worktree.create-idempotency.v1'], + worktreeCreateIdempotency + } + ]) + ) + ).resolves.toEqual({ + tasksSupported: false, + worktreeCreateIdempotency: { dedupeTtlMs: 0 }, + hostPlatform: 'darwin' + }) + } + ) + + it('clamps an over-large host advertisement to the client fallback ceiling', async () => { + await expect( + readNewWorktreeRuntimeCapabilities( + statusClient([ + { + capabilities: ['worktree.create-idempotency.v1'], + worktreeCreateIdempotency: { dedupeTtlMs: 600_000 } + } + ]) + ) + ).resolves.toEqual({ + tasksSupported: false, + worktreeCreateIdempotency: { dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS }, + hostPlatform: 'darwin' + }) + }) + + it.each(['60000', -1, Number.NaN, 60_000.5, {}])( + 'fails closed for malformed host advertisement %j', + async (advertisedDedupeTtlMs) => { + await expect( + readNewWorktreeRuntimeCapabilities( + statusClient([ + { + capabilities: ['worktree.create-idempotency.v1'], + worktreeCreateIdempotency: { dedupeTtlMs: advertisedDedupeTtlMs } + } + ]) + ) + ).resolves.toEqual({ + tasksSupported: false, + worktreeCreateIdempotency: { dedupeTtlMs: 0 }, + hostPlatform: 'darwin' + }) + } + ) + + it('retries the safe status probe after a connection cutover', async () => { + await expect( + readNewWorktreeRuntimeCapabilities( + statusClient([ + 'cutover', + { + capabilities: ['worktree.create-idempotency.v1'], + worktreeCreateIdempotency: { dedupeTtlMs: 30_000 } + } + ]) + ) + ).resolves.toEqual({ + tasksSupported: false, + worktreeCreateIdempotency: { dedupeTtlMs: 30_000 }, hostPlatform: 'darwin' }) }) @@ -53,7 +169,7 @@ describe('readNewWorktreeRuntimeCapabilities', () => { it('fails closed when capability detection is unavailable', async () => { await expect(readNewWorktreeRuntimeCapabilities(statusClient(['error']))).resolves.toEqual({ tasksSupported: false, - idempotentWorktreeCreateSupported: false, + worktreeCreateIdempotency: false, hostPlatform: null }) }) diff --git a/mobile/src/tasks/worktree-create-capability.ts b/mobile/src/tasks/worktree-create-capability.ts index 6083120c7a4..c4ca8170312 100644 --- a/mobile/src/tasks/worktree-create-capability.ts +++ b/mobile/src/tasks/worktree-create-capability.ts @@ -4,6 +4,11 @@ import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-cli import type { RpcSuccess } from '../transport/types' import { readMobileRuntimeHostPlatform } from '../transport/mobile-runtime-host-platform' import { MOBILE_TASKS_CAPABILITY } from './mobile-tasks-capability' +import { + WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS, + resolveWorktreeCreateIdempotencySupport, + type WorktreeCreateIdempotencySupport +} from './worktree-create-idempotency-policy' // Why: older hosts strip worktree.create's clientMutationId, so mobile must not // replay an ambiguous create unless the host advertises idempotency support. @@ -14,13 +19,13 @@ const STATUS_CUTOVER_MAX_RETRIES = 5 export type NewWorktreeRuntimeCapabilities = { tasksSupported: boolean - idempotentWorktreeCreateSupported: boolean + worktreeCreateIdempotency: WorktreeCreateIdempotencySupport | false hostPlatform: NodeJS.Platform | null } const UNSUPPORTED_CAPABILITIES: NewWorktreeRuntimeCapabilities = { tasksSupported: false, - idempotentWorktreeCreateSupported: false, + worktreeCreateIdempotency: false, hostPlatform: null } @@ -35,13 +40,28 @@ export async function readNewWorktreeRuntimeCapabilities( if (!response.ok) { return UNSUPPORTED_CAPABILITIES } - const result = (response as RpcSuccess).result as { capabilities?: string[] } + const result = (response as RpcSuccess).result as { + capabilities?: string[] + worktreeCreateIdempotency?: unknown + } const capabilities = result.capabilities ?? [] + const supportsIdempotency = capabilities.includes( + MOBILE_WORKTREE_CREATE_IDEMPOTENCY_CAPABILITY + ) + const advertisedIdempotency = result.worktreeCreateIdempotency return { tasksSupported: capabilities.includes(MOBILE_TASKS_CAPABILITY), - idempotentWorktreeCreateSupported: capabilities.includes( - MOBILE_WORKTREE_CREATE_IDEMPOTENCY_CAPABILITY - ), + worktreeCreateIdempotency: supportsIdempotency + ? advertisedIdempotency === undefined + ? { dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS } + : advertisedIdempotency !== null && + typeof advertisedIdempotency === 'object' && + !Array.isArray(advertisedIdempotency) + ? resolveWorktreeCreateIdempotencySupport( + (advertisedIdempotency as { dedupeTtlMs?: unknown }).dedupeTtlMs + ) + : { dedupeTtlMs: 0 } + : false, hostPlatform: readMobileRuntimeHostPlatform(result) } } catch (error) { @@ -58,7 +78,7 @@ export function useNewWorktreeRuntimeCapabilities( ): { tasksSupported: boolean hostPlatform: NodeJS.Platform | null - getWorktreeCreateCutoverSupport: () => Promise + getWorktreeCreateCutoverSupport: () => Promise } { const [tasksSupported, setTasksSupported] = useState(false) const [hostPlatform, setHostPlatform] = useState(null) @@ -97,7 +117,7 @@ export function useNewWorktreeRuntimeCapabilities( }, [client, enabled, getCapabilities, setTasksSupported]) const getWorktreeCreateCutoverSupport = useCallback( - () => getCapabilities().then((capabilities) => capabilities.idempotentWorktreeCreateSupported), + () => getCapabilities().then((capabilities) => capabilities.worktreeCreateIdempotency), [getCapabilities] ) return { tasksSupported, hostPlatform, getWorktreeCreateCutoverSupport } diff --git a/mobile/src/tasks/worktree-create-idempotency-policy.ts b/mobile/src/tasks/worktree-create-idempotency-policy.ts new file mode 100644 index 00000000000..797b62305e0 --- /dev/null +++ b/mobile/src/tasks/worktree-create-idempotency-policy.ts @@ -0,0 +1,40 @@ +// Why: old idempotent hosts omit policy, so preserve today's effective replay window. +export const WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS = 60_000 + +// Why: a client must not trust a host advertisement beyond its tested ceiling. +export const WORKTREE_CREATE_DEDUPE_TTL_CLIENT_CEILING_MS = 60_000 + +// Why: the replay still has to reach the host before its dedupe record expires. +const WORKTREE_CREATE_REPLAY_FLIGHT_MARGIN_MS = 10_000 + +export type WorktreeCreateIdempotencySupport = { + dedupeTtlMs: number +} + +export type WorktreeCreateIdempotencyProbe = + | WorktreeCreateIdempotencySupport + | false + | Promise + +export function resolveWorktreeCreateIdempotencySupport( + advertisedDedupeTtlMs: unknown +): WorktreeCreateIdempotencySupport { + if (advertisedDedupeTtlMs === undefined) { + return { dedupeTtlMs: 0 } + } + if ( + typeof advertisedDedupeTtlMs !== 'number' || + !Number.isSafeInteger(advertisedDedupeTtlMs) || + advertisedDedupeTtlMs < 0 + ) { + // Truthy support preserves immediate cutover retries while disabling ambiguous replay. + return { dedupeTtlMs: 0 } + } + return { + dedupeTtlMs: Math.min(advertisedDedupeTtlMs, WORKTREE_CREATE_DEDUPE_TTL_CLIENT_CEILING_MS) + } +} + +export function getWorktreeCreateReplayWindowMs(support: WorktreeCreateIdempotencySupport): number { + return Math.max(0, support.dedupeTtlMs - WORKTREE_CREATE_REPLAY_FLIGHT_MARGIN_MS) +} diff --git a/mobile/src/tasks/worktree-create-retry.test.ts b/mobile/src/tasks/worktree-create-retry.test.ts index 52d6d7b9782..b4463207ebe 100644 --- a/mobile/src/tasks/worktree-create-retry.test.ts +++ b/mobile/src/tasks/worktree-create-retry.test.ts @@ -3,16 +3,24 @@ import type { RpcClient } from '../transport/rpc-client' import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client' import type { ConnectionState } from '../transport/types' -import { WORKTREE_CREATE_DEDUPE_TTL_MS } from '../../../src/shared/new-workspace/worktree-create-retry-policy' +import { + getWorktreeCreateReplayWindowMs, + WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS, + type WorktreeCreateIdempotencySupport +} from './worktree-create-idempotency-policy' import { createWorktreeWithNameRetry, - WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS, - WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS + WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS } from './worktree-create-retry' import { WORKTREE_CREATE_TIMEOUT_MS } from './workspace-create-timeout' type Attempt = { method: string; params: Record } +const IDEMPOTENT_CREATE_SUPPORT = { + dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS +} +const LEGACY_HOST_REPLAY_WINDOW_MS = getWorktreeCreateReplayWindowMs(IDEMPOTENT_CREATE_SUPPORT) + // Drives the transport state a replay has to wait on. Production couples the two: // a socket close rejects the pending frame AND drops the client off 'connected'. function connectionController(): { @@ -106,21 +114,21 @@ describe('createWorktreeWithNameRetry', () => { it('waits for capability detection before sending a create', async () => { const attempts: Attempt[] = [] const client = scriptedClient([{ id: 'wt-ready' }], attempts) - let resolveSupport!: (supported: boolean) => void - const support = new Promise((resolve) => { + let resolveSupport!: (supported: WorktreeCreateIdempotencySupport) => void + const support = new Promise((resolve) => { resolveSupport = resolve }) const pending = createWorktreeWithNameRetry({ client, baseName: 'puffin', buildParams: (name) => ({ repo: 'id:r', name }), - supportsIdempotentCutoverRetry: support, + worktreeCreateIdempotency: support, mintMutationId: () => 'key-ready' }) await Promise.resolve() expect(attempts).toHaveLength(0) - resolveSupport(true) + resolveSupport(IDEMPOTENT_CREATE_SUPPORT) await expect(pending).resolves.toEqual({ worktreeId: 'wt-ready', name: 'puffin' }) expect(attempts).toHaveLength(1) @@ -134,7 +142,7 @@ describe('createWorktreeWithNameRetry', () => { client, baseName: 'otter', buildParams: (name) => ({ repo: 'id:r', name }), - supportsIdempotentCutoverRetry: true, + worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT, mintMutationId: () => 'key-1' }) expect(result).toEqual({ worktreeId: 'wt-1', name: 'otter' }) @@ -152,7 +160,7 @@ describe('createWorktreeWithNameRetry', () => { client, baseName: 'seal', buildParams: (name) => ({ repo: 'id:r', name }), - supportsIdempotentCutoverRetry: true, + worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT, mintMutationId: () => 'key-mig' }) expect(result).toEqual({ worktreeId: 'wt-2', name: 'seal' }) @@ -172,7 +180,7 @@ describe('createWorktreeWithNameRetry', () => { client, baseName: 'crab', buildParams: (name) => ({ repo: 'id:r', name }), - supportsIdempotentCutoverRetry: true, + worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT, mintMutationId: () => 'key-x' }) ).rejects.toBeInstanceOf(LogicalClientCutoverError) @@ -190,7 +198,7 @@ describe('createWorktreeWithNameRetry', () => { client, baseName: 'eel', buildParams: (name) => ({ repo: 'id:r', name }), - supportsIdempotentCutoverRetry: true, + worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT, mintMutationId: () => 'key-t' }) ).rejects.toThrow('Request timed out') @@ -208,7 +216,7 @@ describe('createWorktreeWithNameRetry', () => { client, baseName: 'topic', buildParams: (name) => ({ repo: 'id:r', name }), - supportsIdempotentCutoverRetry: true, + worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT, mintMutationId: () => `key-${(n += 1)}` }) expect(result).toEqual({ worktreeId: 'wt-3', name: 'topic-2' }) @@ -231,7 +239,7 @@ describe('createWorktreeWithNameRetry', () => { baseName: 'nautilus-2', nameWasGenerated: true, buildParams: (name) => ({ repo: 'id:r', name, nameWasGenerated: true }), - supportsIdempotentCutoverRetry: false + worktreeCreateIdempotency: false }) expect(result).toEqual({ worktreeId: 'wt-generated', name: 'nautilus-3' }) @@ -246,7 +254,7 @@ describe('createWorktreeWithNameRetry', () => { client, baseName: 'ray', buildParams: (name) => ({ repo: 'id:r', name }), - supportsIdempotentCutoverRetry: false, + worktreeCreateIdempotency: false, mintMutationId: () => 'must-not-be-used' }) ).rejects.toBeInstanceOf(LogicalClientCutoverError) @@ -273,7 +281,7 @@ describe('createWorktreeWithNameRetry', () => { client, baseName: 'urchin', buildParams: (name) => ({ repo: 'id:r', name }), - supportsIdempotentCutoverRetry: true, + worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT, mintMutationId: () => 'key-drop' }) @@ -301,7 +309,7 @@ describe('createWorktreeWithNameRetry', () => { client, baseName: 'limpet', buildParams: (name) => ({ repo: 'id:r', name }), - supportsIdempotentCutoverRetry: false, + worktreeCreateIdempotency: false, mintMutationId: () => 'must-not-be-used' }) ).rejects.toThrow('Connection interrupted') @@ -309,6 +317,32 @@ describe('createWorktreeWithNameRetry', () => { expect(attempts[0]!.params.clientMutationId).toBeUndefined() }) + it('disables ambiguous replay for a malformed advertisement', async () => { + const attempts: Attempt[] = [] + const connection = connectionController() + const client = scriptedClient( + [ + { + throws: markRpcDeliveryUnknown(new Error('Connection interrupted')), + dropsConnection: true + } + ], + attempts, + connection + ) + await expect( + createWorktreeWithNameRetry({ + client, + baseName: 'limpet', + buildParams: (name) => ({ repo: 'id:r', name }), + worktreeCreateIdempotency: { dedupeTtlMs: 0 }, + mintMutationId: () => 'key-malformed' + }) + ).rejects.toThrow('Connection interrupted') + expect(attempts).toHaveLength(1) + expect(attempts[0]!.params.clientMutationId).toBe('key-malformed') + }) + it('gives up after the delivery-ambiguity replay budget, still inside the dedupe TTL', async () => { vi.useFakeTimers() try { @@ -331,21 +365,21 @@ describe('createWorktreeWithNameRetry', () => { client, baseName: 'barnacle', buildParams: (name) => ({ repo: 'id:r', name }), - supportsIdempotentCutoverRetry: true, + worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT, mintMutationId: () => 'key-budget' }).catch((error: unknown) => { settledAt = Date.now() throw error }) const settled = expect(pending).rejects.toThrow('Connection interrupted') - await vi.advanceTimersByTimeAsync(WORKTREE_CREATE_DEDUPE_TTL_MS) + await vi.advanceTimersByTimeAsync(WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS) await settled // Initial attempt + 2 replays. expect(attempts).toHaveLength(3) // The budget is a count, but what keeps a replay reconciling instead of building // a second worktree is wall clock: every attempt has to land while the host still // holds the record, with the watchdog's detection latency already deducted. - expect(settledAt - startedAt).toBeLessThanOrEqual(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS) + expect(settledAt - startedAt).toBeLessThanOrEqual(LEGACY_HOST_REPLAY_WINDOW_MS) } finally { vi.useRealTimers() } @@ -373,7 +407,7 @@ describe('createWorktreeWithNameRetry', () => { client, baseName: 'anemone', buildParams: (name) => ({ repo: 'id:r', name }), - supportsIdempotentCutoverRetry: true, + worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT, mintMutationId: () => 'key-stuck' }) const settled = expect(pending).rejects.toThrow('Connection interrupted') @@ -388,6 +422,41 @@ describe('createWorktreeWithNameRetry', () => { // so vitest's default 5s real-time budget is reachable on a loaded runner. }, 30_000) + it('stops replaying when the host-advertised dedupe record has expired', async () => { + vi.useFakeTimers() + try { + const attempts: Attempt[] = [] + const connection = connectionController() + const client = scriptedClient( + [ + { + throws: markRpcDeliveryUnknown(new Error('Connection interrupted')), + dropsConnection: true, + takesMs: 12_000, + reconnectsAfterMs: 1_000 + }, + { id: 'wt-duplicate' } + ], + attempts, + connection + ) + + const pending = createWorktreeWithNameRetry({ + client, + baseName: 'mussel', + buildParams: (name) => ({ repo: 'id:r', name }), + worktreeCreateIdempotency: { dedupeTtlMs: 20_000 }, + mintMutationId: () => 'key-short-ttl' + }) + const settled = expect(pending).rejects.toThrow('Connection interrupted') + await vi.advanceTimersByTimeAsync(30_000) + await settled + expect(attempts).toHaveLength(1) + } finally { + vi.useRealTimers() + } + }) + it('does not replay an ambiguity that surfaced while the transport stayed connected', async () => { vi.useFakeTimers() try { @@ -415,7 +484,7 @@ describe('createWorktreeWithNameRetry', () => { client, baseName: 'cuttlefish', buildParams: (name) => ({ repo: 'id:r', name }), - supportsIdempotentCutoverRetry: true, + worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT, mintMutationId: () => 'key-expired' }) const settled = expect(pending).rejects.toThrow('Request timed out') @@ -464,19 +533,19 @@ describe('createWorktreeWithNameRetry', () => { client, baseName: 'nautilus', buildParams: (name) => ({ repo: 'id:r', name }), - supportsIdempotentCutoverRetry: true, + worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT, mintMutationId: () => 'key-clamped' }).catch((error: unknown) => { settledAt = Date.now() throw error }) const settled = expect(pending).rejects.toThrow('Connection interrupted') - await vi.advanceTimersByTimeAsync(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS) + await vi.advanceTimersByTimeAsync(LEGACY_HOST_REPLAY_WINDOW_MS) await settled expect(attempts).toHaveLength(2) // The window is anchored at the FIRST ambiguity, so the second wait gets only the // remainder โ€” it must not restart a full wait and carry the replay past the record. - expect(settledAt - startedAt).toBe(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS) + expect(settledAt - startedAt).toBe(LEGACY_HOST_REPLAY_WINDOW_MS) } finally { vi.useRealTimers() } @@ -509,7 +578,7 @@ describe('createWorktreeWithNameRetry', () => { client, baseName: 'limpet', buildParams: (name) => ({ repo: 'id:r', name }), - supportsIdempotentCutoverRetry: true, + worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT, mintMutationId: () => 'key-stale' }) const settled = expect(pending).rejects.toThrow('Connection interrupted') @@ -551,7 +620,7 @@ describe('createWorktreeWithNameRetry', () => { client, baseName: 'kelp', buildParams: (name) => ({ repo: 'id:r', name }), - supportsIdempotentCutoverRetry: true, + worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT, mintMutationId: () => 'key-long' }) await vi.advanceTimersByTimeAsync(250_000) @@ -606,7 +675,7 @@ describe('createWorktreeWithNameRetry', () => { client, baseName: 'urchin', buildParams: (name) => ({ repo: 'id:r', name }), - supportsIdempotentCutoverRetry: true, + worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT, mintMutationId: () => 'key-anchored' }) const settled = expect(pending).rejects.toThrow('Connection lost') @@ -638,7 +707,7 @@ describe('createWorktreeWithNameRetry', () => { client, baseName: 'urchin', buildParams: (name) => ({ repo: 'id:r', name }), - supportsIdempotentCutoverRetry: true, + worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT, mintMutationId: () => 'key-unmarked' }) ).rejects.toThrow('Socket closed before send') @@ -649,11 +718,10 @@ describe('createWorktreeWithNameRetry', () => { // The window is measured from a lower bound on when the host could have resolved, so // it has to leave the record room for the replay to still be in flight. Widening it // to the whole TTL would let the last replay land on a record that just expired. - expect(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS).toBeGreaterThan(0) - expect(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS).toBeLessThan(WORKTREE_CREATE_DEDUPE_TTL_MS) - // A single wait must not be able to consume the window on its own. - expect(WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS).toBeLessThan( - WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS - ) + expect(LEGACY_HOST_REPLAY_WINDOW_MS).toBeGreaterThan(0) + expect(LEGACY_HOST_REPLAY_WINDOW_MS).toBeLessThan(WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS) + // A reconnect wait is clamped when a short advertisement leaves less time. + const shortReplayWindowMs = getWorktreeCreateReplayWindowMs({ dedupeTtlMs: 20_000 }) + expect(Math.min(WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS, shortReplayWindowMs)).toBe(10_000) }) }) diff --git a/mobile/src/tasks/worktree-create-retry.ts b/mobile/src/tasks/worktree-create-retry.ts index 7bacc95aa72..971dfa759cf 100644 --- a/mobile/src/tasks/worktree-create-retry.ts +++ b/mobile/src/tasks/worktree-create-retry.ts @@ -7,10 +7,14 @@ import { CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS, getClientWorktreeCreateCandidate, getGeneratedWorktreeCreateRetryCandidate, - isRetryableWorktreeCreateConflict, - WORKTREE_CREATE_DEDUPE_TTL_MS + isRetryableWorktreeCreateConflict } from '../../../src/shared/new-workspace/worktree-create-retry-policy' import { WORKTREE_CREATE_TIMEOUT_MS } from './workspace-create-timeout' +import { + getWorktreeCreateReplayWindowMs, + type WorktreeCreateIdempotencyProbe, + type WorktreeCreateIdempotencySupport +} from './worktree-create-idempotency-policy' // Why: server-side collision checks (branch already exists locally / on a remote // / already has PR #N) can fire even after a pre-flight basename dedupe โ€” @@ -36,17 +40,6 @@ const WORKTREE_CREATE_CUTOVER_MAX_RETRIES = 5 // finished the worktree. Replay on the same clientMutationId instead. const WORKTREE_CREATE_AMBIGUOUS_MAX_RETRIES = 2 -// Why: the host keeps a settled create's dedupe record for WORKTREE_CREATE_DEDUPE_TTL_MS -// after it resolves; a replay that lands later misses it and the host's suffix loop -// builds a SECOND worktree โ€” and for folder workspaces a second one with the SAME name -// and no collision check at all. So a replay is bounded by how stale our knowledge of the -// host is, and that has to be measured in WALL CLOCK: iOS/Android suspend JS timers while -// the app is backgrounded, so any ceiling derived from timer intervals โ€” a watchdog probe -// budget, a request timeout โ€” can be arbitrarily wrong across a background cycle, which is -// exactly when a create sits ambiguous for minutes. -const WORKTREE_CREATE_REPLAY_FLIGHT_MARGIN_MS = 10_000 -export const WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS = - WORKTREE_CREATE_DEDUPE_TTL_MS - WORKTREE_CREATE_REPLAY_FLIGHT_MARGIN_MS // Bounded so the Create spinner doesn't sit for the whole window; the deadline still caps it. export const WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS = 20_000 @@ -55,7 +48,7 @@ export type CreateWorktreeWithNameRetryArgs = { baseName: string nameWasGenerated?: boolean buildParams: (name: string) => Record - supportsIdempotentCutoverRetry: boolean | Promise + worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe maxAttempts?: number // Injected in tests; production mints a fresh idempotency key per candidate. mintMutationId?: () => string @@ -72,7 +65,7 @@ export async function createWorktreeWithNameRetry( const { client, baseName, buildParams } = args // Why: creating before status.get settles would silently disable safe replay // during the exact slow-network window this path is meant to recover from. - const supportsIdempotentCutoverRetry = await args.supportsIdempotentCutoverRetry + const worktreeCreateIdempotency = await args.worktreeCreateIdempotency const maxAttempts = args.maxAttempts ?? CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS const mintMutationId = args.mintMutationId ?? defaultWorktreeCreateMutationId let lastError: string | null = null @@ -84,14 +77,10 @@ export async function createWorktreeWithNameRetry( // Why: older hosts strip unknown fields, so only stamp and replay when the // host advertises idempotency. One key per candidate makes cutover retries // safe while a name-collision bump remains a genuinely new create. - const params = supportsIdempotentCutoverRetry + const params = worktreeCreateIdempotency ? { ...candidateParams, clientMutationId: mintMutationId() } : candidateParams - const response = await sendWorktreeCreateResilient( - client, - params, - supportsIdempotentCutoverRetry - ) + const response = await sendWorktreeCreateResilient(client, params, worktreeCreateIdempotency) if (response.ok) { const result = (response as RpcSuccess).result as { worktree: { id: string } } return { worktreeId: result.worktree.id, name: candidateName } @@ -112,7 +101,7 @@ export async function createWorktreeWithNameRetry( async function sendWorktreeCreateResilient( client: RpcClient, params: Record, - supportsIdempotentCutoverRetry: boolean + worktreeCreateIdempotency: WorktreeCreateIdempotencySupport | false ): Promise { let migrationRetry = 0 let ambiguousRetry = 0 @@ -124,7 +113,7 @@ async function sendWorktreeCreateResilient( timeoutMs: WORKTREE_CREATE_TIMEOUT_MS }) } catch (error) { - if (!supportsIdempotentCutoverRetry) { + if (!worktreeCreateIdempotency) { throw error } if (isLogicalClientCutoverError(error)) { @@ -152,7 +141,7 @@ async function sendWorktreeCreateResilient( } // Computed once: a later ambiguity reads a fresher lastInboundAt from the // replacement session, which would push the deadline past the record it respects. - replayDeadlineAt ??= resolveReplayDeadline(client, firstSentAt) + replayDeadlineAt ??= resolveReplayDeadline(client, firstSentAt, worktreeCreateIdempotency) const remainingWindowMs = replayDeadlineAt - Date.now() if (remainingWindowMs <= 0) { throw error @@ -179,10 +168,14 @@ async function sendWorktreeCreateResilient( // so it stays honest across a suspension in a way a timer budget cannot; the send is the // fallback, and a sound floor either way, because the host cannot resolve a create it has // not yet received. -function resolveReplayDeadline(client: RpcClient, firstSentAt: number): number { +function resolveReplayDeadline( + client: RpcClient, + firstSentAt: number, + support: WorktreeCreateIdempotencySupport +): number { const lastInboundAt = client.getLastInboundAt?.() ?? null const anchor = lastInboundAt !== null && lastInboundAt > firstSentAt ? lastInboundAt : firstSentAt - return anchor + WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS + return anchor + getWorktreeCreateReplayWindowMs(support) } function defaultWorktreeCreateMutationId(): string { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index b8b12bf2b1a..7747797de4f 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -2098,6 +2098,7 @@ describe('OrcaRuntimeService', () => { expect(status.capabilities).toContain('mobile.tasks.v1') expect(status.capabilities).toContain('terminal.quick-commands.v1') expect(status.capabilities).toContain('worktree.create-idempotency.v1') + expect(status.worktreeCreateIdempotency).toEqual({ dedupeTtlMs: 60_000 }) expect(status.capabilities).toContain('files.mutation-ownership.v1') expect(status.capabilities).toContain('project-host-setup.v1') expect(status.capabilities).toContain('linear.issue-attribute-filter.v1') diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index f7807df3f16..e5cf999e4e4 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1148,7 +1148,6 @@ import { isGeneratedWorktreeCreateName, WORKTREE_CREATE_MAX_SUFFIX_ATTEMPTS } from '../worktree-create-candidates' -import { WORKTREE_CREATE_DEDUPE_TTL_MS } from '../../shared/new-workspace/worktree-create-retry-policy' import { failedWorktreeCreationNeedsRetirement, getRetiredNameRegistryForRepo, @@ -2133,11 +2132,9 @@ function getAgentLaunchPlatformForRepo( // Why: long enough for a phone to reconnect and retry a create whose response // was lost, short enough that an intentional later re-resume forks fresh. const MOBILE_TERMINAL_CREATE_RESULT_TTL_MS = 60_000 -// Why: same idempotency window for worktree.create โ€” a phone whose create was -// interrupted by a connection migration retries with the same clientMutationId -// and reuses the just-created worktree instead of spawning a duplicate. Shared with -// the mobile client so its replay budget is sized against the real window. -const WORKTREE_CREATE_RESULT_TTL_MS = WORKTREE_CREATE_DEDUPE_TTL_MS +// Why: a phone whose create was interrupted retries with the same clientMutationId +// and reuses the just-created worktree instead of spawning a duplicate. +const WORKTREE_CREATE_RESULT_TTL_MS = 60_000 const FOREGROUND_AGENT_WRAPPER_RETRY_INTERVAL_MS = 150 const FOREGROUND_AGENT_WRAPPER_RETRY_TIMEOUT_MS = 6_500 const BRACKETED_PASTE_BEGIN = '\x1b[200~' @@ -6517,6 +6514,7 @@ export class OrcaRuntimeService { // must not treat browser panes as supported just because runtime RPC is up. capabilities, ...(degradations.length > 0 ? { degradations } : {}), + worktreeCreateIdempotency: { dedupeTtlMs: WORKTREE_CREATE_RESULT_TTL_MS }, hostPlatform: process.platform, terminalWindowsShell: this.store?.getSettings?.().terminalWindowsShell ?? null, floatingWorkspaceEnabled: this.store?.getSettings?.().floatingTerminalEnabled !== false, diff --git a/src/shared/new-workspace/worktree-create-retry-policy.ts b/src/shared/new-workspace/worktree-create-retry-policy.ts index 2d3438b0218..e45da0d5530 100644 --- a/src/shared/new-workspace/worktree-create-retry-policy.ts +++ b/src/shared/new-workspace/worktree-create-retry-policy.ts @@ -65,10 +65,3 @@ export function getGeneratedWorktreeCreateRetryCandidate( export function isRetryableWorktreeCreateConflict(message: string): boolean { return RETRYABLE_WORKTREE_CREATE_CONFLICT_PATTERNS.some((pattern) => pattern.test(message)) } - -// Why: the host drops a *settled* worktree.create dedupe record this long after it -// resolves, so a client replaying an idempotent create must land inside the window -// or the replay misses the record and the host's suffix loop builds a second -// worktree. Shared so the client's replay budget can be asserted against it instead -// of assuming a value that lives in another process. -export const WORKTREE_CREATE_DEDUPE_TTL_MS = 60_000 diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index bfd58f181e1..8f10558d7e5 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -97,7 +97,8 @@ export const TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY = 'terminal.paired-parki // terminal creation, so mobile must hide Quick Commands unless both are present. export const TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY = 'terminal.quick-commands.v1' as const // Why: older hosts strip worktree.create's clientMutationId, so mobile must only -// replay ambiguous cutovers when the host advertises idempotent create support. +// replay ambiguous cutovers when the host advertises idempotent create support; +// status.worktreeCreateIdempotency carries the optional host retention policy. export const WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY = 'worktree.create-idempotency.v1' as const export const CODEX_RESET_CREDIT_RUNTIME_CAPABILITY = 'accounts.codex-reset-credit.v1' as const diff --git a/src/shared/runtime-session-contracts.ts b/src/shared/runtime-session-contracts.ts index 9d94980fcae..8bf41a9cdc5 100644 --- a/src/shared/runtime-session-contracts.ts +++ b/src/shared/runtime-session-contracts.ts @@ -70,6 +70,10 @@ export type RuntimeStatus = { runtimeProtocolVersion?: number minCompatibleRuntimeClientVersion?: number capabilities?: RuntimeCapability[] + /** Optional policy for clients that negotiated worktree.create-idempotency.v1. */ + worktreeCreateIdempotency?: { + dedupeTtlMs: number + } /** * Optional for mixed-version peers. Absence means the host predates structured * degradation reporting, not that the host proved every optional feature available. From 64f537f442aa2276a7bc605baafe6e01f6b13cf8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:02:56 -0700 Subject: [PATCH 10/29] test(terminal): pin that Hangul is two cells under every unicode provider (#15477) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(terminal): pin that Hangul is two cells under every unicode provider #15192 turned out to be an upstream Antigravity CLI defect, but the investigation re-litigated Orca's Hangul cell width three separate times before ruling it out. This makes that negative result durable. The first test closes a real gap rather than restating the others. Nothing verified which provider actually ends up active in production: pane-lifecycle.test.ts asserts activeVersion=11, but its terminal mock has no _core, so activateOrcaTerminalUnicodeProvider can only ever take the fallback branch there. This asserts on a real terminal, in pane-lifecycle's order, that the Orca provider is reached. The sweep matters because it is what makes the width theory unavailable rather than merely unproven: all 11,172 precomposed syllables budget two cells under v6, v11 and the Orca provider, against both wcwidth and the packed charProperties bits. Even total activation failure leaves them wide. Also pins the wide-cell test oracle's disagreement with xterm on conjoining jamo U+1160..U+11FF. Only decomposed Korean reaches them and no fixture writes NFD today, so nothing mis-asserts now - but a repaint test would silently assert against a wrong oracle if anyone added NFD text. Refs #15192 * test(terminal): drop the version-sensitivity list as redundant Its output is a strict subset of the oracle-divergence test's, measured a different way, for about one bit of information across twenty lines. Refs #15192 * test(terminal): assert both widths in the Hangul oracle divergence, drop the pane-lifecycle-order claim The oracle divergence only asserted that xterm and the fixture disagree, not how: widening the fixture's jamo range from one cell to two left the expected list byte-identical, so the tripwire it exists to be would not have fired. Record both widths in the run key. Test 1 claimed to pin pane-lifecycle's activation order, but it mirrors that order rather than importing it โ€” deleting the call at pane-lifecycle.ts:88 or moving it before loadAddon leaves it green (pane-lifecycle.test.ts covers both). Retitled to what it does pin: that xterm's live _core shape still reaches the non-fallback branch. Also oxfmt. --- .../hangul-cell-width-agreement.test.ts | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 src/main/daemon/hangul-cell-width-agreement.test.ts diff --git a/src/main/daemon/hangul-cell-width-agreement.test.ts b/src/main/daemon/hangul-cell-width-agreement.test.ts new file mode 100644 index 00000000000..f8235068faa --- /dev/null +++ b/src/main/daemon/hangul-cell-width-agreement.test.ts @@ -0,0 +1,152 @@ +/** + * #15192, activation-order hypothesis: "the Orca unicode provider is not reached + * in production, so Hangul lays out at the wrong width." + * + * These pin the measurements that close it. Every precomposed Hangul syllable is + * two cells under xterm's Unicode 6 tables, its Unicode 11 tables, and Orca's + * provider alike, so no activation order โ€” provider, v11 fallback, or the + * untouched v6 default โ€” can change how a syllable is budgeted. Whatever moves + * Korean text off its cells is not the unicode version. + * + * The version-sensitive and oracle-sensitive code points are pinned too, as the + * complete list of Hangul-block characters where a width disagreement is even + * available. None of them occur in modern Korean prose. + */ +import { describe, expect, it } from 'vitest' +import { Terminal } from '@xterm/headless' +import { Unicode11Addon } from '@xterm/addon-unicode11' +import { activateOrcaTerminalUnicodeProvider } from '../../shared/terminal-unicode-provider' +import { isWideGlyph } from './__fixtures__/terminal-wide-cell-grid' + +const ORCA_UNICODE_VERSION = 'orca-11-zwj' +const HANGUL_SYLLABLES_FIRST = 0xac00 +const HANGUL_SYLLABLES_LAST = 0xd7a3 + +/** Jamo, Compatibility Jamo, Jamo Extended-A, Syllables + Extended-B, halfwidth jamo. */ +const HANGUL_BLOCKS: [number, number][] = [ + [0x1100, 0x11ff], + [0x3130, 0x318f], + [0xa960, 0xa97f], + [0xac00, 0xd7ff], + [0xffa0, 0xffdc] +] + +function* hangulCodePoints(): Generator { + for (const [first, last] of HANGUL_BLOCKS) { + for (let cp = first; cp <= last; cp += 1) { + yield cp + } + } +} + +type UnicodeServiceInternals = { + versions: string[] + activeVersion: string + wcwidth(codepoint: number): number + charProperties(codepoint: number, preceding: number): number +} + +/** + * Addon first, activation after โ€” the order every call site uses. This mirrors that + * order rather than importing it, so it does not guard the call sites themselves; + * `pane-lifecycle.test.ts` covers that the activation runs and runs after the addon. + * What is unguarded, and what these cover, is that the real `_core.unicodeService` + * shape still lets the activation reach its non-fallback branch on a live terminal. + */ +function openWithUnicode11AddonLoaded(): { + terminal: Terminal + unicode: UnicodeServiceInternals +} { + const terminal = new Terminal({ cols: 40, rows: 10, allowProposedApi: true }) + terminal.loadAddon(new Unicode11Addon()) + activateOrcaTerminalUnicodeProvider(terminal as never) + const unicode = ( + terminal as unknown as { + _core: { unicodeService: UnicodeServiceInternals } + } + )._core.unicodeService + return { terminal, unicode } +} + +/** Width bits of xterm's packed char properties โ€” what the buffer actually budgets. */ +function propertyWidth(properties: number): number { + return (properties >> 1) & 3 +} + +/** + * Contiguous code points carrying the same detail collapse into one line. The detail + * is part of the run key so a changed width re-partitions the output instead of + * silently reusing a range that only ever asserted "these two disagree somehow". + */ +function summarize(entries: { codepoint: number; detail: string }[]): string[] { + const runs: { start: number; end: number; detail: string }[] = [] + for (const { codepoint, detail } of entries) { + const last = runs.at(-1) + if (last && last.end === codepoint - 1 && last.detail === detail) { + last.end = codepoint + continue + } + runs.push({ start: codepoint, end: codepoint, detail }) + } + const hex = (value: number): string => `U+${value.toString(16).toUpperCase().padStart(4, '0')}` + return runs.map( + ({ start, end, detail }) => + `${start === end ? hex(start) : `${hex(start)}..${hex(end)}`} ${detail}` + ) +} + +describe('Hangul cell width agreement (#15192)', () => { + it('reaches the Orca provider, not the v11 fallback, on a live terminal', () => { + const { terminal, unicode } = openWithUnicode11AddonLoaded() + expect(unicode.versions).toContain(ORCA_UNICODE_VERSION) + expect(unicode.activeVersion).toBe(ORCA_UNICODE_VERSION) + terminal.dispose() + }) + + it('budgets every precomposed syllable at two cells under v6, v11 and Orca', () => { + const { terminal, unicode } = openWithUnicode11AddonLoaded() + const disagreeing: { codepoint: number; detail: string }[] = [] + for (const version of ['6', '11', ORCA_UNICODE_VERSION]) { + unicode.activeVersion = version + for (let cp = HANGUL_SYLLABLES_FIRST; cp <= HANGUL_SYLLABLES_LAST; cp += 1) { + const wcwidth = unicode.wcwidth(cp) + const packed = propertyWidth(unicode.charProperties(cp, 0)) + if (wcwidth !== 2 || packed !== 2) { + disagreeing.push({ + codepoint: cp, + detail: `v${version} wcwidth=${wcwidth} packed=${packed}` + }) + } + } + } + expect(summarize(disagreeing)).toEqual([]) + terminal.dispose() + }) + + it('records where the wide-cell oracle diverges from xterm on conjoining jamo', () => { + const { terminal, unicode } = openWithUnicode11AddonLoaded() + const divergent: { codepoint: number; detail: string }[] = [] + for (const cp of hangulCodePoints()) { + const oracle = isWideGlyph(String.fromCodePoint(cp)) ? 2 : 1 + const xterm = unicode.wcwidth(cp) + if (xterm !== oracle) { + divergent.push({ codepoint: cp, detail: `oracle=${oracle} xterm=${xterm}` }) + } + } + // Why pinned rather than fixed: xterm treats medial/final jamo as zero-width + // combining marks, the oracle as one cell โ€” and `isWideGlyph` returns a boolean, + // so it cannot express zero-width without changing shape. Only decomposed (NFD) + // Korean reaches them and no fixture writes NFD today, so nothing mis-asserts; + // this is the tripwire for the fixture that adds it. U+3130/U+318F are unassigned + // Compatibility Jamo edges. U+A960..U+A97C is assigned Jamo Extended-A (Old Korean + // initials) that xterm makes wide and the oracle narrow โ€” the dangerous direction, + // but no fixture uses it. Both widths are asserted so changing either trips this. + expect(summarize(divergent)).toEqual([ + 'U+1160..U+11FF oracle=1 xterm=0', + 'U+3130 oracle=2 xterm=1', + 'U+318F oracle=2 xterm=1', + 'U+A960..U+A97C oracle=1 xterm=2' + ]) + terminal.dispose() + }) +}) From 074339478a41c514819316b4f3575476908e0085 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:06:43 -0700 Subject: [PATCH 11/29] fix(terminal): compose iPadOS Hangul by holding the syllable in the renderer (#13345) (#15480) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(terminal): compose iPadOS Hangul by holding the syllable in the renderer (#13345) Korean typed on an iPad with a hardware keyboard reached the PTY as separate jamo: `ํ•œ๊ธ€๊นจ์ง` arrived as `ใ…Žใ…ใ„ดใ„ฑใ…กใ„น...`. iPadOS fires no composition events for it โ€” each jamo is a plain keydown while the IME rewrites the syllable in place in the helper textarea โ€” so xterm consumes the keydown, sends the raw jamo from `_keyPress`, and drops the composed `insertText` because `_inputEvent` admits a composed insert only when no key is down. The jamo keydown is handed to the system by a new bypass rule, and the syllable it builds is held in the renderer until the IME proves it final. The PTY sees one write per syllable and nothing is ever sent then retracted, so raw-mode TUIs never receive DEL bytes they need not read as "erase one cell" and SSH and relay sessions pay no round trip for them. `ํ•œ๊ธ€๊นจ์ง` is four writes and zero DELs. Sitting upstream of `xterm-bypass-policy.ts` rather than inside xterm is what makes this work for Shift-typed double consonants: Orca's own Shift rule already hides `ใ„ฒ ใ„ธ ใ…ƒ ใ…† ใ…‰` keydowns from xterm, so a fix living in CompositionHelper never sees them and every syllable starting with one โ€” ๊นจ ๊ฟˆ ๋”ธ ๋นต ์“ฐ๋‹ค ์งœ๋‹ค โ€” stays broken. That placement call is dvpaa's, from #13346. Composition sessions are left alone entirely, so Chinese pinyin on the same device keeps working; that state is derived from the existing composition tracker rather than latched, so a session that never ends cannot disable the pane. The bypass claims jamo only โ€” a Cyrillic or kana key would lose its keydown, keypress and `input` alike and reach the PTY as nothing. Co-authored-by: dvpaa <82706622+dvpaa@users.noreply.github.com> * refactor(terminal): drop the dead session check from the iOS preedit input guard `isCompositionOwnedInput(event) || options.isCompositionActive()` could never take its second branch. The composition tracker's own `input` listener runs first on the same element and clears its active flag for every input except `insertCompositionText` โ€” which is exactly the first disjunct. Verified by construction: instrumented to throw on the combination, nothing in the renderer suite (24k tests) reached it, and five deliberate attempts to build one, via a resumed preedit and a post-compositionend insert, all failed to. Reading composition ownership off the event alone also makes the decision independent of listener registration order, which the previous comment at the call site claimed to depend on. It does not: swapping the tracker and the preedit controller leaves every test passing. The comment now states the one coupling that is real โ€” the controller stops propagation on `input` while a syllable is held โ€” without asserting a behavioral dependency that does not exist. `isCompositionActive` remains the gate on opening a hold, where it is pinned. * fix(terminal): settle iPad Hangul by diffing the field, not assuming it grows The hold released a syllable only when the textarea tail grew past it and still started with it. Korean batchim migration breaks that: a device capture on iPadOS 26 shows `๊นจ` + `ใ…ˆ` rewritten to `๊นพ` โ€” a different codepoint, not an extension โ€” and only becoming `๊นจ์ฃผ` once the next vowel decides where the `ใ…ˆ` belongs. The prefix check failed there, the hold stopped advancing, and `๊นจ์ฅ ` reached the PTY as one chunk on blur. Locate the IME's edit with a prefix diff of the two field states instead. Everything before where it began rewriting is settled: the batchim question for those syllables is already answered. Still hold-and-commit, so no DEL ever reaches the pty. The capture is now the fixture, replayed both verbatim and as keystrokes. * fix(terminal): keep the iPad Hangul hold open across the IME's erase rewrite Backspace can decompose a held syllable as deleteContentBackward then a replacing insertText, the same shape the IME uses to grow one. The hold closed on the emptied half, so the replacement landed with nothing held: typing ํ•œ, Backspace, ใ„น put a bare `ใ„น` on the wire and dropped ํ•˜. The empty field now collapses the hold instead of closing it, and the Backspace that finds nothing held is the one that reaches the PTY. An `imeWrote` flag keeps an erased hold from resurrecting its opening jamo. Also diff the field as NFC. Decomposed Hangul grows by appending jamo, which the common-prefix diff reads as the previous syllable settling, so an NFD source emitted one bare jamo per keystroke. The recorded device trace is NFC, where normalization is a no-op. * test(terminal): cover Japanese, Hanja and Greek on the iPad Hangul path The coexistence suite proved pinyin and a short list of non-Hangul keys. Widen it: a kana-to-kanji session, a Hanja lookup over a live preedit, a digit that ends a held syllable as literal text, and Greek, halfwidth kana and accented Latin among the keys the bypass must not claim. --------- Co-authored-by: dvpaa <82706622+dvpaa@users.noreply.github.com> --- ...e-2set-hangul-batchim-migration-trace.json | 286 ++++++++++++ .../terminal-pane/hangul-jamo-key.ts | 9 + ...nal-ios-hangul-preedit-coexistence.test.ts | 234 ++++++++++ ...al-ios-hangul-preedit-device-trace.test.ts | 169 +++++++ .../terminal-ios-hangul-preedit-fixture.ts | 341 ++++++++++++++ .../terminal-ios-hangul-preedit-overlay.ts | 53 +++ .../terminal-ios-hangul-preedit.test.ts | 436 ++++++++++++++++++ .../terminal-ios-hangul-preedit.ts | 316 +++++++++++++ .../use-terminal-pane-lifecycle.test.ts | 37 -- .../use-terminal-pane-lifecycle.ts | 31 +- .../xterm-bypass-policy-ios.test.ts | 120 +++++ .../terminal-pane/xterm-bypass-policy.ts | 38 ++ src/renderer/src/lib/ios-web-platform.test.ts | 49 ++ src/renderer/src/lib/ios-web-platform.ts | 28 ++ 14 files changed, 2100 insertions(+), 47 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/__fixtures__/ipados-hardware-2set-hangul-batchim-migration-trace.json create mode 100644 src/renderer/src/components/terminal-pane/hangul-jamo-key.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-coexistence.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-device-trace.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-fixture.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-overlay.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit.ts create mode 100644 src/renderer/src/components/terminal-pane/xterm-bypass-policy-ios.test.ts create mode 100644 src/renderer/src/lib/ios-web-platform.test.ts create mode 100644 src/renderer/src/lib/ios-web-platform.ts diff --git a/src/renderer/src/components/terminal-pane/__fixtures__/ipados-hardware-2set-hangul-batchim-migration-trace.json b/src/renderer/src/components/terminal-pane/__fixtures__/ipados-hardware-2set-hangul-batchim-migration-trace.json new file mode 100644 index 00000000000..832e08ed997 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/__fixtures__/ipados-hardware-2set-hangul-batchim-migration-trace.json @@ -0,0 +1,286 @@ +{ + "source": "iPadOS 26 hardware keyboard, Korean 2-set, xterm helper textarea", + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Safari/605.1.15", + "maxTouchPoints": 5, + "typed": "gksrmfRownla", + "expected": "ํ•œ๊ธ€๊นจ์ฅ ", + "observedSent": [ + "ํ•œ", + "๊ธ€", + "๊นจ์ฅ " + ], + "events": [ + { + "type": "keydown", + "key": "ใ…Ž", + "keyCode": 71, + "inputType": null, + "data": "", + "value": "" + }, + { + "type": "input", + "key": "", + "keyCode": null, + "inputType": "insertText", + "data": "ใ…Ž", + "value": "ใ…Ž" + }, + { + "type": "keydown", + "key": "ใ…", + "keyCode": 75, + "inputType": null, + "data": "", + "value": "ใ…Ž" + }, + { + "type": "input", + "key": "", + "keyCode": null, + "inputType": "deleteContentBackward", + "data": "", + "value": "" + }, + { + "type": "input", + "key": "", + "keyCode": null, + "inputType": "insertText", + "data": "ํ•˜", + "value": "ํ•˜" + }, + { + "type": "keydown", + "key": "ใ„ด", + "keyCode": 83, + "inputType": null, + "data": "", + "value": "ํ•˜" + }, + { + "type": "input", + "key": "", + "keyCode": null, + "inputType": "deleteContentBackward", + "data": "", + "value": "" + }, + { + "type": "input", + "key": "", + "keyCode": null, + "inputType": "insertText", + "data": "ํ•œ", + "value": "ํ•œ" + }, + { + "type": "keydown", + "key": "ใ„ฑ", + "keyCode": 82, + "inputType": null, + "data": "", + "value": "ํ•œ" + }, + { + "type": "input", + "key": "", + "keyCode": null, + "inputType": "insertText", + "data": "ใ„ฑ", + "value": "ํ•œใ„ฑ" + }, + { + "type": "keydown", + "key": "ใ…ก", + "keyCode": 77, + "inputType": null, + "data": "", + "value": "ํ•œใ„ฑ" + }, + { + "type": "input", + "key": "", + "keyCode": null, + "inputType": "deleteContentBackward", + "data": "", + "value": "ํ•œ" + }, + { + "type": "input", + "key": "", + "keyCode": null, + "inputType": "insertText", + "data": "๊ทธ", + "value": "ํ•œ๊ทธ" + }, + { + "type": "keydown", + "key": "ใ„น", + "keyCode": 70, + "inputType": null, + "data": "", + "value": "ํ•œ๊ทธ" + }, + { + "type": "input", + "key": "", + "keyCode": null, + "inputType": "deleteContentBackward", + "data": "", + "value": "ํ•œ" + }, + { + "type": "input", + "key": "", + "keyCode": null, + "inputType": "insertText", + "data": "๊ธ€", + "value": "ํ•œ๊ธ€" + }, + { + "type": "keydown", + "key": "Shift", + "keyCode": 16, + "inputType": null, + "data": "", + "value": "ํ•œ๊ธ€" + }, + { + "type": "keydown", + "key": "ใ„ฒ", + "keyCode": 82, + "inputType": null, + "data": "", + "value": "ํ•œ๊ธ€" + }, + { + "type": "input", + "key": "", + "keyCode": null, + "inputType": "insertText", + "data": "ใ„ฒ", + "value": "ํ•œ๊ธ€ใ„ฒ" + }, + { + "type": "keydown", + "key": "ใ…", + "keyCode": 79, + "inputType": null, + "data": "", + "value": "ํ•œ๊ธ€ใ„ฒ" + }, + { + "type": "input", + "key": "", + "keyCode": null, + "inputType": "deleteContentBackward", + "data": "", + "value": "ํ•œ๊ธ€" + }, + { + "type": "input", + "key": "", + "keyCode": null, + "inputType": "insertText", + "data": "๊นจ", + "value": "ํ•œ๊ธ€๊นจ" + }, + { + "type": "keydown", + "key": "ใ…ˆ", + "keyCode": 87, + "inputType": null, + "data": "", + "value": "ํ•œ๊ธ€๊นจ" + }, + { + "type": "input", + "key": "", + "keyCode": null, + "inputType": "deleteContentBackward", + "data": "", + "value": "ํ•œ๊ธ€" + }, + { + "type": "input", + "key": "", + "keyCode": null, + "inputType": "insertText", + "data": "๊นพ", + "value": "ํ•œ๊ธ€๊นพ" + }, + { + "type": "keydown", + "key": "ใ…œ", + "keyCode": 78, + "inputType": null, + "data": "", + "value": "ํ•œ๊ธ€๊นพ" + }, + { + "type": "input", + "key": "", + "keyCode": null, + "inputType": "deleteContentBackward", + "data": "", + "value": "ํ•œ๊ธ€" + }, + { + "type": "input", + "key": "", + "keyCode": null, + "inputType": "insertText", + "data": "๊นจ์ฃผ", + "value": "ํ•œ๊ธ€๊นจ์ฃผ" + }, + { + "type": "keydown", + "key": "ใ…ฃ", + "keyCode": 76, + "inputType": null, + "data": "", + "value": "ํ•œ๊ธ€๊นจ์ฃผ" + }, + { + "type": "input", + "key": "", + "keyCode": null, + "inputType": "deleteContentBackward", + "data": "", + "value": "ํ•œ๊ธ€๊นจ" + }, + { + "type": "input", + "key": "", + "keyCode": null, + "inputType": "insertText", + "data": "์ฅ", + "value": "ํ•œ๊ธ€๊นจ์ฅ" + }, + { + "type": "keydown", + "key": "ใ…", + "keyCode": 65, + "inputType": null, + "data": "", + "value": "ํ•œ๊ธ€๊นจ์ฅ" + }, + { + "type": "input", + "key": "", + "keyCode": null, + "inputType": "deleteContentBackward", + "data": "", + "value": "ํ•œ๊ธ€๊นจ" + }, + { + "type": "input", + "key": "", + "keyCode": null, + "inputType": "insertText", + "data": "์ฅ ", + "value": "ํ•œ๊ธ€๊นจ์ฅ " + } + ] +} \ No newline at end of file diff --git a/src/renderer/src/components/terminal-pane/hangul-jamo-key.ts b/src/renderer/src/components/terminal-pane/hangul-jamo-key.ts new file mode 100644 index 00000000000..16b6eb2e853 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/hangul-jamo-key.ts @@ -0,0 +1,9 @@ +/** Jamo a hardware keyboard reports directly in `KeyboardEvent.key`: conjoining, + * compatibility, extended and halfwidth forms. Shift-typed doubles (ใ„ฒ ใ„ธ ใ…ƒ ใ…† ใ…‰) + * live in the compatibility block alongside the singles. */ +const HANGUL_JAMO_KEY = /^[แ„€-แ‡ฟใ„ฐ-ใ†๊ฅ -๊ฅฟํžฐ-ํŸฟ๏พ -๏ฟœ]$/ + +/** Returns whether `key` is a single Hangul jamo. */ +export function isHangulJamoKeyText(key: string): boolean { + return HANGUL_JAMO_KEY.test(key) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-coexistence.test.ts b/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-coexistence.test.ts new file mode 100644 index 00000000000..567ef3fbf5e --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-coexistence.test.ts @@ -0,0 +1,234 @@ +// @vitest-environment happy-dom +/** + * What the iPadOS Hangul path must not disturb: the composition sessions other + * input sources on the same device do run, non-Hangul scripts, desktop + * platforms, and anything else that writes to the PTY. + */ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { writeTerminalPastePtyInput } from './terminal-pty-paste-writer' +import { + disposeOpenTerminals, + dispatchComposition, + dispatchInput, + dispatchKey, + nextEventLoop, + openIosTerminal, + pretendIosWeb, + typeHangeul, + typeJamo, + typePrintable, + type IosHangulRig +} from './terminal-ios-hangul-preedit-fixture' + +/** Chinese pinyin, which fires real composition events on the very same iPad. */ +async function typePinyin(rig: IosHangulRig, spelling: string, committed: string): Promise { + const base = rig.textarea.value + dispatchComposition(rig, 'compositionstart', '') + for (let index = 1; index <= spelling.length; index += 1) { + const preedit = spelling.slice(0, index) + dispatchKey(rig, 'keydown', { + key: spelling[index - 1], + keyCode: 229, + isComposing: true + }) + dispatchComposition(rig, 'compositionupdate', preedit) + rig.textarea.value = base + preedit + dispatchInput(rig, 'insertCompositionText', preedit, { isComposing: true }) + } + rig.textarea.value = base + committed + dispatchComposition(rig, 'compositionend', committed) + dispatchInput(rig, 'insertCompositionText', committed) + await nextEventLoop() + await nextEventLoop() +} + +describe('the iPadOS Hangul path alongside everything else', () => { + let originalUserAgent: PropertyDescriptor | undefined + let originalMaxTouchPoints: PropertyDescriptor | undefined + + beforeEach(() => { + originalUserAgent = Object.getOwnPropertyDescriptor(navigator, 'userAgent') + originalMaxTouchPoints = Object.getOwnPropertyDescriptor(navigator, 'maxTouchPoints') + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + measureText: () => ({ width: 10 }) + } as unknown as CanvasRenderingContext2D) + }) + + afterEach(() => { + disposeOpenTerminals() + if (originalUserAgent) { + Object.defineProperty(navigator, 'userAgent', originalUserAgent) + } + if (originalMaxTouchPoints) { + Object.defineProperty(navigator, 'maxTouchPoints', originalMaxTouchPoints) + } + vi.restoreAllMocks() + document.body.replaceChildren() + }) + + describe('Chinese pinyin on the same device', () => { + it('commits once, not twice', async () => { + pretendIosWeb() + const rig = openIosTerminal() + await typePinyin(rig, 'ni', 'ไฝ ') + + expect(rig.emitted).toEqual(['ไฝ ']) + }) + + it('commits two compositions in a row', async () => { + pretendIosWeb() + const rig = openIosTerminal() + await typePinyin(rig, 'ni', 'ไฝ ') + await typePinyin(rig, 'hao', 'ๅฅฝ') + + expect(rig.emitted).toEqual(['ไฝ ', 'ๅฅฝ']) + }) + + it('still works after Hangul was typed first', async () => { + pretendIosWeb() + const rig = openIosTerminal() + await typeHangeul(rig) + await typePinyin(rig, 'ni', 'ไฝ ') + + // The open syllable is committed by the composition that supersedes it. + expect(rig.emitted).toEqual(['ํ•œ', '๊ธ€', 'ไฝ ']) + }) + + it('leaves Hangul working after a composition finished', async () => { + pretendIosWeb() + const rig = openIosTerminal() + await typePinyin(rig, 'ni', 'ไฝ ') + await typeHangeul(rig) + dispatchKey(rig, 'keydown', { key: 'Enter', code: 'Enter', keyCode: 13 }) + + expect(rig.emitted).toEqual(['ไฝ ', 'ํ•œ', '๊ธ€', '\r']) + }) + }) + + it('composes Hangul after a composition that never ended, then a blur', async () => { + // Why this case: a `composing` flag latched on compositionstart and cleared + // only on compositionend stays true forever when the session is abandoned โ€” + // blur mid-composition, pane teardown, a cancelled IME โ€” and silently kills + // every claimed key for the rest of the pane's life. The state is derived + // from the composition tracker instead, which a blur clears. + pretendIosWeb() + const rig = openIosTerminal() + dispatchComposition(rig, 'compositionstart', '') + dispatchComposition(rig, 'compositionupdate', 'n') + rig.textarea.dispatchEvent(new FocusEvent('blur', { bubbles: false })) + rig.textarea.value = '' + await nextEventLoop() + + await typeHangeul(rig) + dispatchKey(rig, 'keydown', { key: 'Enter', code: 'Enter', keyCode: 13 }) + + expect(rig.emitted.join('')).toContain('ํ•œ๊ธ€') + }) + + it('commits a Japanese kana-to-kanji session once, like pinyin', async () => { + pretendIosWeb() + const rig = openIosTerminal() + await typePinyin(rig, 'ka', '่šŠ') + + expect(rig.emitted).toEqual(['่šŠ']) + }) + + it('leaves a Hanja lookup to the session that owns its preedit', async () => { + // Why: ibus-hangul indexes its Hanja table by digit, but only over a live + // composition โ€” which xterm's CompositionHelper already commits. + pretendIosWeb() + const rig = openIosTerminal() + await typePinyin(rig, 'han', '้Ÿ“') + + expect(rig.emitted).toEqual(['้Ÿ“']) + }) + + it('sends a digit after a held syllable as the literal text that ends it', async () => { + pretendIosWeb() + const rig = openIosTerminal() + await typeJamo(rig, 'ใ…Ž', 'ใ…Ž', { replaces: false }) + await typeJamo(rig, 'ใ…', 'ํ•˜', { replaces: true }) + await typePrintable(rig, { key: '1', keyCode: 49, written: '1', replaces: false }) + + expect(rig.emitted).toEqual(['ํ•˜', '1']) + }) + + describe('desktop platforms', () => { + it('is inert on a Mac reporting no touch points', async () => { + pretendIosWeb(0) + const rig = openIosTerminal() + await typeJamo(rig, 'ใ…Ž', 'ใ…Ž', { replaces: false }) + + expect(rig.emitted).toEqual(['ใ…Ž']) + expect(rig.compositionView.classList.contains('active')).toBe(false) + }) + + it('is inert on a Mac whose touch peripheral reports a single point', async () => { + pretendIosWeb(1) + const rig = openIosTerminal() + await typeJamo(rig, 'ใ…Ž', 'ใ…Ž', { replaces: false }) + + expect(rig.emitted).toEqual(['ใ…Ž']) + expect(rig.compositionView.classList.contains('active')).toBe(false) + }) + + it('is inert on Windows and Linux', async () => { + for (const userAgent of [ + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/126.0.0.0', + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/126.0.0.0' + ]) { + pretendIosWeb(5, userAgent) + const rig = openIosTerminal() + await typeJamo(rig, 'ใ…Ž', 'ใ…Ž', { replaces: false }) + expect(rig.emitted).toEqual(['ใ…Ž']) + } + }) + + it('reads the platform once, so no second gate can drift from it', () => { + const lifecycle = readFileSync( + resolve( + process.cwd(), + 'src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts' + ), + 'utf8' + ) + expect(lifecycle.match(/const isIosWeb =/g)).toHaveLength(1) + expect(lifecycle.match(/isCurrentPlatformIosWeb\(/g)).toHaveLength(1) + // And that the one gate is what installs the controller. + expect(lifecycle).toMatch( + /const iosHangulPreedit = isIosWeb\s*\?\s*installTerminalIosHangulPreedit\(/ + ) + }) + }) + + it('composes Hangul after a paste written straight to the transport', async () => { + // Why: `writeTerminalPastePtyInput` bypasses xterm entirely, so it fires no + // `onData` a mirror could resync on. A hold reads the field only when it + // opens, so there is no state for the paste to make stale. + pretendIosWeb() + const rig = openIosTerminal() + const sent: string[] = [] + writeTerminalPastePtyInput({ sendInput: (data) => (sent.push(data), true) }, 'echo ') + expect(sent).toEqual(['echo ']) + + await typeHangeul(rig) + dispatchKey(rig, 'keydown', { key: 'Enter', code: 'Enter', keyCode: 13 }) + + expect(rig.emitted).toEqual(['ํ•œ', '๊ธ€', '\r']) + }) + + it('leaves non-Hangul scripts on the same device reaching the PTY', async () => { + // Why: the bypass claims a key that nothing downstream re-sends, so it must + // claim only jamo. A Cyrillic or kana key would otherwise lose its keydown, + // its keypress and its `input` alike and arrive as nothing. + pretendIosWeb() + const rig = openIosTerminal() + for (const key of ['ะฟ', 'ั€', 'ฮฑ', 'ฯ‰', 'ใ‚', 'ใ‚ซ', 'รฉ', 'รผ', 'โ‚ฌ']) { + await typePrintable(rig, { key, keyCode: 71, written: key, replaces: false }) + } + + expect(rig.emitted).toEqual(['ะฟ', 'ั€', 'ฮฑ', 'ฯ‰', 'ใ‚', 'ใ‚ซ', 'รฉ', 'รผ', 'โ‚ฌ']) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-device-trace.test.ts b/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-device-trace.test.ts new file mode 100644 index 00000000000..210697ab308 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-device-trace.test.ts @@ -0,0 +1,169 @@ +// @vitest-environment happy-dom +/** + * `gksrmfRownla` โ†’ `ํ•œ๊ธ€๊นจ์ฅ `, replayed from a capture taken on an iPad running + * iPadOS 26 with a hardware Korean 2-set keyboard (#13345). The recording is + * the fixture: an earlier round of this work passed against a hand-written + * shape the device never produces, and shipped a bug the device shows in event + * 23 of this file. + * + * What the device does that no reconstruction guessed โ€” batchim migration: + * + * keydown 'ใ…ˆ' โ†’ deleteContentBackward, insertText '๊นพ' value 'ํ•œ๊ธ€๊นพ' + * keydown 'ใ…œ' โ†’ deleteContentBackward, insertText '๊นจ์ฃผ' value 'ํ•œ๊ธ€๊นจ์ฃผ' + * + * The `ใ…ˆ` first attaches to `๊นจ` as a final consonant, making a different + * codepoint entirely, and only migrates forward once `ใ…œ` says it starts the + * next syllable. A syllable is not final until the following syllable's vowel + * decides, so `๊นจ` must not be committed when the `ใ…ˆ` arrives. + * + * Two properties are pinned here: + * + * - `๊นจ` opens on `ใ„ฒ`, a Shift-typed double consonant. Orca's own Shift rule + * (`xterm-bypass-policy.ts`) already hides those keydowns from xterm, so a fix + * living inside xterm's CompositionHelper never sees them and every syllable + * starting with one โ€” ๊นจ ๊ฟˆ ๋”ธ ๋นต ์“ฐ๋‹ค ์งœ๋‹ค โ€” stays broken. Sitting upstream of + * the bypass policy is what makes the source of the keydown irrelevant. + * - Nothing is sent and then retracted. A field-diffing mirror emits one write + * per edit and erases the superseded syllable with DEL, which raw-mode TUIs + * need not read as "erase one cell" and which costs a round trip each over + * SSH or the relay. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + deviceTraceKeystrokes, + disposeOpenTerminals, + dispatchKey, + loadIosDeviceTrace, + openIosTerminal, + pretendIosWeb, + replayIosDeviceTrace, + typeJamo, + typePrintable, + type IosHangulRig +} from './terminal-ios-hangul-preedit-fixture' + +const TRACE = loadIosDeviceTrace('ipados-hardware-2set-hangul-batchim-migration-trace.json') +const KEYSTROKES = deviceTraceKeystrokes(TRACE) + +function pretendRecordedDevice(): void { + pretendIosWeb(TRACE.maxTouchPoints, TRACE.userAgent) +} + +/** The capture as keystrokes, which adds the keypress the recorder did not log. */ +async function replayKeystrokes(rig: IosHangulRig, upTo = KEYSTROKES.length): Promise { + for (const step of KEYSTROKES.slice(0, upTo)) { + await typePrintable(rig, step) + } +} + +describe('the recorded iPad device trace for ํ•œ๊ธ€๊นจ์ฅ ', () => { + beforeEach(() => { + // happy-dom has no 2d context, which the DOM renderer's WidthCache requires. + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + measureText: () => ({ width: 10 }) + } as unknown as CanvasRenderingContext2D) + }) + + afterEach(() => { + disposeOpenTerminals() + vi.restoreAllMocks() + document.body.replaceChildren() + }) + + describe('replayed event for event, exactly as recorded', () => { + it('commits per syllable instead of hoarding the batchim migration', async () => { + pretendRecordedDevice() + const rig = openIosTerminal() + const drift = await replayIosDeviceTrace(rig, TRACE) + + // The device build reached the wire as ['ํ•œ', '๊ธ€', '๊นจ์ฅ '] โ€” correct text, + // but everything from `๊นจ` on arrived in one chunk at blur, because the + // hold stalled the moment `๊นจ` became `๊นพ`. + expect(TRACE.observedSent).toEqual(['ํ•œ', '๊ธ€', '๊นจ์ฅ ']) + expect(rig.emitted).toEqual(['ํ•œ', '๊ธ€', '๊นจ']) + expect(rig.preedit.heldText()).toBe('์ฅ ') + expect(drift).toEqual([]) + }) + + it('holds ๊นจ while the ใ…ˆ could still belong to it', async () => { + pretendRecordedDevice() + const rig = openIosTerminal() + // Up to and including the `๊นพ` write, which is where the device build stopped. + await replayIosDeviceTrace(rig, { ...TRACE, events: TRACE.events.slice(0, 25) }) + + expect(rig.emitted).toEqual(['ํ•œ', '๊ธ€']) + expect(rig.preedit.heldText()).toBe('๊นพ') + }) + + it('reaches the PTY as ํ•œ๊ธ€๊นจ์ฅ ', async () => { + pretendRecordedDevice() + const rig = openIosTerminal() + await replayIosDeviceTrace(rig, TRACE) + dispatchKey(rig, 'keydown', { key: 'Enter', code: 'Enter', keyCode: 13 }) + + expect(rig.emitted.join('')).toBe(`${TRACE.expected}\r`) + }) + + it('puts no DEL or backspace byte on the wire', async () => { + pretendRecordedDevice() + const rig = openIosTerminal() + await replayIosDeviceTrace(rig, TRACE) + dispatchKey(rig, 'keydown', { key: 'Enter', code: 'Enter', keyCode: 13 }) + + const wire = rig.emitted.join('') + expect(wire).not.toContain('\x7f') + expect(wire).not.toContain('\b') + // One write per syllable plus the Enter: no retraction, and no per-edit churn. + expect(rig.emitted).toHaveLength(5) + }) + }) + + describe('replayed as keystrokes, with the keypress a browser also fires', () => { + it('sends the composed syllables and never the raw jamo', async () => { + pretendRecordedDevice() + const rig = openIosTerminal() + await replayKeystrokes(rig) + dispatchKey(rig, 'keydown', { key: 'Enter', code: 'Enter', keyCode: 13 }) + + expect(rig.emitted.join('')).toBe(`${TRACE.expected}\r`) + expect(rig.emitted).toEqual(['ํ•œ', '๊ธ€', '๊นจ', '์ฅ ', '\r']) + }) + + it('composes a mid-word Shift+jamo after a committed syllable', async () => { + pretendRecordedDevice() + const rig = openIosTerminal() + // Through `๊นจ`, the eighth keystroke, whose initial is a Shift-typed ใ„ฒ. + await replayKeystrokes(rig, 8) + dispatchKey(rig, 'keydown', { key: 'Enter', code: 'Enter', keyCode: 13 }) + + expect(rig.emitted.join('')).toBe('ํ•œ๊ธ€๊นจ\r') + }) + }) + + it('composes a syllable-initial Shift+jamo with nothing before it', async () => { + pretendRecordedDevice() + const rig = openIosTerminal() + await typeJamo(rig, 'ใ„ฒ', 'ใ„ฒ', { replaces: false, shiftKey: true }) + await typeJamo(rig, 'ใ…', '๊นจ', { replaces: true }) + dispatchKey(rig, 'keydown', { key: 'Enter', code: 'Enter', keyCode: 13 }) + + expect(rig.emitted).toEqual(['๊นจ', '\r']) + }) + + it('migrates a batchim with nothing committed before it', async () => { + // The same rewrite as the trace's `๊นจ` โ†’ `๊นพ` โ†’ `๊นจ์ฃผ`, but opening the line: + // the settled prefix is empty, so nothing can be released early by accident. + pretendRecordedDevice() + const rig = openIosTerminal() + await typeJamo(rig, 'ใ„ฒ', 'ใ„ฒ', { replaces: false, shiftKey: true }) + await typeJamo(rig, 'ใ…', '๊นจ', { replaces: true }) + await typeJamo(rig, 'ใ…ˆ', '๊นพ', { replaces: true }) + expect(rig.emitted).toEqual([]) + + await typeJamo(rig, 'ใ…œ', '๊นจ์ฃผ', { replaces: true }) + await typeJamo(rig, 'ใ…ฃ', '์ฅ', { replaces: true }) + + expect(rig.emitted).toEqual(['๊นจ']) + expect(rig.preedit.heldText()).toBe('์ฅ') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-fixture.ts b/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-fixture.ts new file mode 100644 index 00000000000..7bf337c196b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-fixture.ts @@ -0,0 +1,341 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { Terminal } from '@xterm/xterm' +import { isCurrentPlatformIosWeb } from '../../lib/ios-web-platform' +import { installTerminalImeCompositionTracker } from './terminal-ime-composition-tracker' +import { createTerminalIosHangulPreeditRenderer } from './terminal-ios-hangul-preedit-overlay' +import { + installTerminalIosHangulPreedit, + type TerminalIosHangulPreedit +} from './terminal-ios-hangul-preedit' +import { + shouldBypassXtermKeyboardEvent, + shouldSuppressTerminalImeKeyboardEvent +} from './xterm-bypass-policy' + +/** + * Shared rig for the iPadOS Hangul suites: a real xterm `Terminal` wired to the + * same tracker, bypass policy and preedit controller the pane lifecycle + * installs, so the tests exercise the whole path rather than a mock of it. + */ + +export const IPAD_DESKTOP_MODE_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15' + +export type IosHangulRig = { + compositionView: HTMLElement + /** Every chunk the terminal handed to the PTY, in order. */ + emitted: string[] + preedit: TerminalIosHangulPreedit + terminal: Terminal + textarea: HTMLTextAreaElement +} + +const openTerminals: Terminal[] = [] + +export function nextEventLoop(): Promise { + return new Promise((resolve) => window.setTimeout(resolve, 0)) +} + +/** iPadOS reports a Mac user agent, so the touch-point count is what separates it from a desktop. */ +export function pretendIosWeb(maxTouchPoints = 5, userAgent = IPAD_DESKTOP_MODE_UA): void { + Object.defineProperty(navigator, 'userAgent', { value: userAgent, configurable: true }) + Object.defineProperty(navigator, 'maxTouchPoints', { + value: maxTouchPoints, + configurable: true + }) +} + +export function disposeOpenTerminals(): void { + for (const terminal of openTerminals.splice(0)) { + terminal.dispose() + } +} + +export function openIosTerminal( + options: { + isIosWeb?: boolean + /** Overrides the tracker, to isolate what the controller reads it for. */ + isCompositionActive?: () => boolean + screenReaderMode?: boolean + } = {} +): IosHangulRig { + const container = document.createElement('div') + document.body.appendChild(container) + const terminal = new Terminal({ + cols: 40, + rows: 8, + screenReaderMode: options.screenReaderMode + }) + openTerminals.push(terminal) + terminal.open(container) + const textarea = terminal.textarea + const compositionView = container.querySelector('.composition-view') + if (!textarea || !compositionView) { + throw new Error('xterm did not create the helper textarea and composition view') + } + + // Mirrors the lifecycle: the platform decides, so a desktop rig exercises the + // real gate rather than a flag the test set. + const isIosWeb = options.isIosWeb ?? isCurrentPlatformIosWeb() + const tracker = installTerminalImeCompositionTracker(terminal.element) + const preedit = isIosWeb + ? installTerminalIosHangulPreedit({ + terminalElement: terminal.element, + isCompositionActive: options.isCompositionActive ?? (() => tracker.isActive()), + isScreenReaderMode: () => terminal.options.screenReaderMode === true, + sendInput: (data) => terminal.input(data), + renderPreedit: createTerminalIosHangulPreeditRenderer(terminal) + }) + : { heldText: () => '', dispose: () => undefined } + + terminal.attachCustomKeyEventHandler((event) => { + if ( + shouldSuppressTerminalImeKeyboardEvent(event, { + compositionActive: tracker.isActive(), + candidateKeyGuardActive: tracker.isCandidateKeyGuardActive(), + pendingCandidateKeyReleaseActive: false, + hangulPreedit: tracker.isHangulPreedit(), + isMac: true, + isLinux: false + }) + ) { + return false + } + return !shouldBypassXtermKeyboardEvent(event, { + isMac: true, + isIosWeb, + hasSelection: false + }) + }) + + const emitted: string[] = [] + terminal.onData((data) => emitted.push(data)) + return { compositionView, emitted, preedit, terminal, textarea } +} + +export function dispatchKey( + { textarea }: IosHangulRig, + type: 'keydown' | 'keypress' | 'keyup', + init: { + key: string + code?: string + keyCode?: number + charCode?: number + shiftKey?: boolean + isComposing?: boolean + } +): boolean { + const event = new KeyboardEvent(type, { + key: init.key, + code: init.code ?? 'KeyQ', + shiftKey: init.shiftKey ?? false, + bubbles: true, + cancelable: true + }) + // happy-dom drops the legacy numeric fields from KeyboardEventInit; xterm's key paths read them. + Object.defineProperty(event, 'keyCode', { value: init.keyCode ?? init.key.charCodeAt(0) }) + Object.defineProperty(event, 'charCode', { value: init.charCode ?? 0 }) + Object.defineProperty(event, 'isComposing', { value: init.isComposing ?? false }) + textarea.dispatchEvent(event) + return event.defaultPrevented +} + +export function dispatchInput( + { textarea }: IosHangulRig, + inputType: string, + data: string | null, + init: { isComposing?: boolean } = {} +): void { + const event = new InputEvent('input', { bubbles: true }) + Object.defineProperty(event, 'inputType', { value: inputType }) + Object.defineProperty(event, 'data', { value: data }) + Object.defineProperty(event, 'isComposing', { value: init.isComposing ?? false }) + // Trusted user events are always composed; that is the flag that makes xterm drop the commit. + Object.defineProperty(event, 'composed', { value: true }) + textarea.dispatchEvent(event) +} + +export function dispatchComposition( + { textarea }: IosHangulRig, + type: 'compositionstart' | 'compositionupdate' | 'compositionend', + data?: string +): void { + const event = new CompositionEvent(type, { bubbles: true }) + if (data !== undefined) { + Object.defineProperty(event, 'data', { value: data }) + } + textarea.dispatchEvent(event) +} + +/** + * One printable keystroke as a browser produces it: the key, then โ€” only if + * xterm did not consume the keydown โ€” the keypress and the text the IME writes. + */ +export async function typePrintable( + rig: IosHangulRig, + { + key, + keyCode, + written, + replaces, + shiftKey + }: { + key: string + keyCode: number + written: string + replaces: boolean + shiftKey?: boolean + } +): Promise { + if (dispatchKey(rig, 'keydown', { key, keyCode, shiftKey })) { + await nextEventLoop() + return + } + dispatchKey(rig, 'keypress', { key, keyCode, charCode: key.charCodeAt(0), shiftKey }) + if (replaces) { + rig.textarea.value = rig.textarea.value.slice(0, -1) + dispatchInput(rig, 'deleteContentBackward', null) + } + rig.textarea.value += written + dispatchInput(rig, 'insertText', written) + await nextEventLoop() +} + +/** + * A jamo keystroke. `replaces` is the delete-then-insert the IME uses while a + * syllable is still growing; without it the syllable is finished and the jamo + * simply appends. + */ +export function typeJamo( + rig: IosHangulRig, + key: string, + written: string, + options: { replaces: boolean; shiftKey?: boolean } +): Promise { + return typePrintable(rig, { + key, + keyCode: key.charCodeAt(0), + written, + replaces: options.replaces, + shiftKey: options.shiftKey + }) +} + +/** `ํ•œ๊ธ€`, keystroke for keystroke, as captured from the iPad. */ +export async function typeHangeul(rig: IosHangulRig): Promise { + await typeJamo(rig, 'ใ…Ž', 'ใ…Ž', { replaces: false }) + await typeJamo(rig, 'ใ…', 'ํ•˜', { replaces: true }) + await typeJamo(rig, 'ใ„ด', 'ํ•œ', { replaces: true }) + await typeJamo(rig, 'ใ„ฑ', 'ใ„ฑ', { replaces: false }) + await typeJamo(rig, 'ใ…ก', '๊ทธ', { replaces: true }) + await typeJamo(rig, 'ใ„น', '๊ธ€', { replaces: true }) +} + +/** One DOM event exactly as the on-device recorder saw it. */ +export type IosDeviceTraceEvent = { + type: 'keydown' | 'input' + key: string + keyCode: number | null + inputType: string | null + data: string + /** `textarea.value` at the moment the event fired. */ + value: string +} + +export type IosDeviceTrace = { + source: string + userAgent: string + maxTouchPoints: number + typed: string + expected: string + /** What the device build actually put on the wire, bug included. */ + observedSent: string[] + events: IosDeviceTraceEvent[] +} + +export function loadIosDeviceTrace(fileName: string): IosDeviceTrace { + const path = resolve( + process.cwd(), + 'src/renderer/src/components/terminal-pane/__fixtures__', + fileName + ) + return JSON.parse(readFileSync(path, 'utf8')) as IosDeviceTrace +} + +/** A recorded keydown whose field value no longer matches on replay. */ +export type FieldDrift = { index: number; recorded: string; actual: string } + +/** + * Replays a capture verbatim โ€” the recorded events only, with the field set to + * the value the device held at each one. No keypress: the recorder captured + * keydown and input, so the browser's keypress is not replayed here and the + * hand-driven suites keep that coverage. + * + * Returns every keydown at which the field had drifted from the recording, + * which is how the controller having disturbed the IME's own state shows up. + */ +export async function replayIosDeviceTrace( + rig: IosHangulRig, + trace: IosDeviceTrace +): Promise { + const drift: FieldDrift[] = [] + for (const [index, event] of trace.events.entries()) { + if (event.type === 'keydown') { + if (rig.textarea.value !== event.value) { + drift.push({ index, recorded: event.value, actual: rig.textarea.value }) + } + dispatchKey(rig, 'keydown', { key: event.key, keyCode: event.keyCode ?? 0 }) + } else { + rig.textarea.value = event.value + dispatchInput(rig, event.inputType ?? 'insertText', event.data || null) + } + await nextEventLoop() + } + return drift +} + +/** + * The same capture as keystrokes, so the shared `typeJamo` path replays it with + * the keypress a browser fires. `replaces` is read off the recording: a jamo + * that first attaches to the previous syllable arrives as delete-then-insert. + */ +export type DeviceTraceKeystroke = { + key: string + keyCode: number + written: string + replaces: boolean + shiftKey: boolean +} + +export function deviceTraceKeystrokes(trace: IosDeviceTrace): DeviceTraceKeystroke[] { + const steps: DeviceTraceKeystroke[] = [] + let shiftKey = false + for (const event of trace.events) { + if (event.type === 'keydown') { + if (event.key === 'Shift') { + shiftKey = true + continue + } + steps.push({ + key: event.key, + keyCode: event.keyCode ?? 0, + written: '', + replaces: false, + shiftKey + }) + shiftKey = false + continue + } + const step = steps.at(-1) + if (!step) { + throw new Error('trace opens with an input event, which no keystroke owns') + } + if (event.inputType?.startsWith('delete')) { + step.replaces = true + } else { + step.written = event.data + } + } + return steps +} diff --git a/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-overlay.ts b/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-overlay.ts new file mode 100644 index 00000000000..ec7d11d0a92 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-overlay.ts @@ -0,0 +1,53 @@ +import type { Terminal } from '@xterm/xterm' + +// Why: xterm draws an open syllable from its own CompositionHelper, which iPad +// Hangul never reaches โ€” it fires no composition events. Korean users expect to +// watch `ํ•˜` become `ํ•œ`, so Orca drives xterm's `.composition-view` itself. +// +// Cell metrics come from the public `.xterm-screen` bounds (xterm sizes that +// element to cols x rows cells) rather than `_core._renderService.dimensions`, +// matching `terminal-ime-candidate-anchor.ts` โ€” an xterm bump cannot silently +// move the overlay off the cursor. + +/** + * Returns a renderer for the held Hangul syllable, or a no-op when the terminal + * has not opened its DOM. Called with '' to hide the overlay. + */ +export function createTerminalIosHangulPreeditRenderer(terminal: Terminal): (text: string) => void { + const view = terminal.element?.querySelector('.composition-view') + const screen = terminal.element?.querySelector('.xterm-screen') + if (!view || !screen) { + return () => undefined + } + + return (text: string): void => { + if (!text) { + view.classList.remove('active') + view.textContent = '' + return + } + view.textContent = text + view.classList.add('active') + + const rect = screen.getBoundingClientRect() + const cellWidth = rect.width / terminal.cols + const cellHeight = rect.height / terminal.rows + if (!(cellWidth > 0) || !(cellHeight > 0)) { + return + } + const buffer = terminal.buffer.active + const left = Math.min(buffer.cursorX, terminal.cols - 1) * cellWidth + view.style.left = `${left}px` + view.style.top = `${buffer.cursorY * cellHeight}px` + view.style.height = `${cellHeight}px` + view.style.lineHeight = `${cellHeight}px` + view.style.fontFamily = terminal.options.fontFamily ?? '' + view.style.fontSize = `${terminal.options.fontSize ?? 0}px` + // Clip at the terminal's right edge instead of overflowing the pane. + view.style.maxWidth = `${terminal.cols * cellWidth - left}px` + view.style.overflow = 'hidden' + // Themed rather than stock #000/#FFF, so light themes keep contrast. + view.style.background = terminal.options.theme?.background ?? '#000' + view.style.color = terminal.options.theme?.foreground ?? '#FFF' + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit.test.ts b/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit.test.ts new file mode 100644 index 00000000000..2ced09f36a8 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit.test.ts @@ -0,0 +1,436 @@ +// @vitest-environment happy-dom +/** + * iPadOS Hangul with a hardware keyboard fires no composition events at all + * (#13345). Captured on the device, each jamo is a plain keydown whose `key` is + * the jamo, followed by the IME rewriting the composing syllable in place: + * + * keydown 'ใ…Ž' โ†’ insertText 'ใ…Ž' value 'ใ…Ž' + * keydown 'ใ…' โ†’ deleteContentBackward, insertText 'ํ•˜' value 'ํ•˜' + * keydown 'ใ„ด' โ†’ deleteContentBackward, insertText 'ํ•œ' value 'ํ•œ' + * keydown 'ใ„ฑ' โ†’ insertText 'ใ„ฑ' value 'ํ•œใ„ฑ' + * + * Unpatched xterm sends the jamo from `_keyPress` and drops the composed + * `insertText`, because `_inputEvent` admits a composed insert only when no key + * is down โ€” and one is down for every one of them. The PTY sees `ใ…Žใ…ใ„ดใ„ฑใ…กใ„น`. + * + * These suites drive a real xterm `Terminal` through the same tracker, bypass + * policy and preedit controller the pane lifecycle installs. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + disposeOpenTerminals, + dispatchComposition, + dispatchInput, + dispatchKey, + nextEventLoop, + openIosTerminal, + pretendIosWeb, + typeHangeul, + typeJamo, + typePrintable +} from './terminal-ios-hangul-preedit-fixture' + +describe('iPadOS Hangul typed as bare keydowns', () => { + let originalUserAgent: PropertyDescriptor | undefined + let originalMaxTouchPoints: PropertyDescriptor | undefined + + beforeEach(() => { + originalUserAgent = Object.getOwnPropertyDescriptor(navigator, 'userAgent') + originalMaxTouchPoints = Object.getOwnPropertyDescriptor(navigator, 'maxTouchPoints') + // happy-dom has no 2d context, which the DOM renderer's WidthCache requires. + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + measureText: () => ({ width: 10 }) + } as unknown as CanvasRenderingContext2D) + }) + + afterEach(() => { + disposeOpenTerminals() + if (originalUserAgent) { + Object.defineProperty(navigator, 'userAgent', originalUserAgent) + } + if (originalMaxTouchPoints) { + Object.defineProperty(navigator, 'maxTouchPoints', originalMaxTouchPoints) + } + vi.restoreAllMocks() + document.body.replaceChildren() + }) + + it('sends composed syllables, not the jamo the keydowns carry', async () => { + pretendIosWeb() + const rig = openIosTerminal() + await typeHangeul(rig) + dispatchKey(rig, 'keydown', { key: 'Enter', code: 'Enter', keyCode: 13 }) + + expect(rig.emitted).toEqual(['ํ•œ', '๊ธ€', '\r']) + }) + + it('commits a syllable once, with no intermediate state to retract', async () => { + pretendIosWeb() + const rig = openIosTerminal() + await typeHangeul(rig) + + // Only the syllable the IME finished has been sent; `๊ธ€` is still held. + expect(rig.emitted).toEqual(['ํ•œ']) + expect(rig.preedit.heldText()).toBe('๊ธ€') + }) + + it('shows the open syllable in the preedit overlay as it grows', async () => { + pretendIosWeb() + const rig = openIosTerminal() + await typeJamo(rig, 'ใ…Ž', 'ใ…Ž', { replaces: false }) + expect(rig.compositionView.classList.contains('active')).toBe(true) + expect(rig.compositionView.textContent).toBe('ใ…Ž') + + await typeJamo(rig, 'ใ…', 'ํ•˜', { replaces: true }) + expect(rig.compositionView.textContent).toBe('ํ•˜') + expect(rig.emitted).toEqual([]) + }) + + it('hides the overlay once the syllable is committed', async () => { + pretendIosWeb() + const rig = openIosTerminal() + await typeJamo(rig, 'ใ…Ž', 'ใ…Ž', { replaces: false }) + dispatchKey(rig, 'keydown', { key: 'Enter', code: 'Enter', keyCode: 13 }) + + expect(rig.compositionView.classList.contains('active')).toBe(false) + expect(rig.compositionView.textContent).toBe('') + expect(rig.emitted).toEqual(['ใ…Ž', '\r']) + }) + + it('composes after text already sitting on the line', async () => { + pretendIosWeb() + const rig = openIosTerminal() + for (const [key, keyCode] of [ + ['e', 69], + ['c', 67], + ['h', 72], + ['o', 79], + [' ', 32] + ] as const) { + await typePrintable(rig, { key, keyCode, written: key, replaces: false }) + } + await typeHangeul(rig) + dispatchKey(rig, 'keydown', { key: 'Enter', code: 'Enter', keyCode: 13 }) + + expect(rig.emitted.join('')).toBe('echo ํ•œ๊ธ€\r') + }) + + it('leaves ASCII typing on the same device untouched', async () => { + pretendIosWeb() + const rig = openIosTerminal() + await typePrintable(rig, { key: 'l', keyCode: 76, written: 'l', replaces: false }) + await typePrintable(rig, { key: 's', keyCode: 83, written: 's', replaces: false }) + + expect(rig.emitted).toEqual(['l', 's']) + expect(rig.compositionView.classList.contains('active')).toBe(false) + }) + + it('commits the open syllable before the space that follows it', async () => { + pretendIosWeb() + const rig = openIosTerminal() + await typeHangeul(rig) + await typePrintable(rig, { key: ' ', keyCode: 32, written: ' ', replaces: false }) + + expect(rig.emitted).toEqual(['ํ•œ', '๊ธ€', ' ']) + }) + + it('commits the open syllable when the textarea loses focus', async () => { + pretendIosWeb() + const rig = openIosTerminal() + await typeHangeul(rig) + rig.textarea.dispatchEvent(new FocusEvent('blur', { bubbles: false })) + await nextEventLoop() + + expect(rig.emitted).toEqual(['ํ•œ', '๊ธ€']) + expect(rig.compositionView.classList.contains('active')).toBe(false) + }) + + it('lets Backspace decompose the open syllable without reaching the PTY', async () => { + pretendIosWeb() + const rig = openIosTerminal() + await typeJamo(rig, 'ใ…Ž', 'ใ…Ž', { replaces: false }) + await typeJamo(rig, 'ใ…', 'ํ•˜', { replaces: true }) + + // The IME walks the syllable back a jamo at a time by rewriting the field. + dispatchKey(rig, 'keydown', { key: 'Backspace', code: 'Backspace', keyCode: 8 }) + rig.textarea.value = 'ใ…Ž' + dispatchInput(rig, 'deleteContentBackward', null) + await nextEventLoop() + + expect(rig.emitted).toEqual([]) + expect(rig.compositionView.textContent).toBe('ใ…Ž') + }) + + it('sends Backspace to the PTY once no syllable is open', async () => { + pretendIosWeb() + const rig = openIosTerminal() + await typeJamo(rig, 'ใ…Ž', 'ใ…Ž', { replaces: false }) + + dispatchKey(rig, 'keydown', { key: 'Backspace', code: 'Backspace', keyCode: 8 }) + rig.textarea.value = '' + dispatchInput(rig, 'deleteContentBackward', null) + await nextEventLoop() + expect(rig.emitted).toEqual([]) + + dispatchKey(rig, 'keydown', { key: 'Backspace', code: 'Backspace', keyCode: 8 }) + await nextEventLoop() + expect(rig.emitted).toEqual(['\x7f']) + }) + + it('keeps the syllable painted between the delete and insert that replace it', async () => { + // Why: the IME empties the field for one event in the middle of growing a + // syllable, and reading it there would blank the overlay every keystroke. + pretendIosWeb() + const rig = openIosTerminal() + await typeJamo(rig, '\u314E', '\u314E', { replaces: false }) + + dispatchKey(rig, 'keydown', { key: '\u3153', keyCode: '\u3153'.charCodeAt(0) }) + rig.textarea.value = '' + dispatchInput(rig, 'deleteContentBackward', null) + expect(rig.compositionView.textContent).toBe('\u314E') + + rig.textarea.value = '\uD5E4' + dispatchInput(rig, 'insertText', '\uD5E4') + await nextEventLoop() + expect(rig.compositionView.textContent).toBe('\uD5E4') + }) + + it('holds the syllable when Backspace decomposes it as delete-then-insert', async () => { + // Why: the IME rewrites a growing syllable as deleteContentBackward then a + // replacing insertText, and there is no reason a shrinking one differs. A + // hold that closed on the emptied half sent the next jamo raw. + pretendIosWeb() + const rig = openIosTerminal() + await typeJamo(rig, '\u314E', '\uD558', { replaces: false }) + await typeJamo(rig, '\u3153', '\uD5E4', { replaces: true }) + await typeJamo(rig, '\u3134', '\uD5E8', { replaces: true }) + + dispatchKey(rig, 'keydown', { key: 'Backspace', code: 'Backspace', keyCode: 8 }) + rig.textarea.value = '' + dispatchInput(rig, 'deleteContentBackward', null) + rig.textarea.value = '\uD5E4' + dispatchInput(rig, 'insertText', '\uD5E4') + await nextEventLoop() + + expect(rig.emitted).toEqual([]) + expect(rig.preedit.heldText()).toBe('\uD5E4') + + await typeJamo(rig, '\u3139', '\uD5EC', { replaces: true }) + dispatchKey(rig, 'keydown', { key: ' ', code: 'Space', keyCode: 32 }) + await nextEventLoop() + expect(rig.emitted.join('')).toContain('\uD5EC') + }) + + it('does not resurrect the opening jamo after Backspace emptied the field', async () => { + // Why: the openKey is owed to the PTY only while the IME has written nothing. + pretendIosWeb() + const rig = openIosTerminal() + await typeJamo(rig, '\u314E', '\u314E', { replaces: false }) + + dispatchKey(rig, 'keydown', { key: 'Backspace', code: 'Backspace', keyCode: 8 }) + rig.textarea.value = '' + dispatchInput(rig, 'deleteContentBackward', null) + await nextEventLoop() + + dispatchKey(rig, 'keydown', { key: ' ', code: 'Space', keyCode: 32 }) + await nextEventLoop() + expect(rig.emitted.join('')).not.toContain('\u314E') + }) + + it('composes one syllable from a field the IME leaves decomposed', async () => { + // Why: NFD grows the field by appending jamo, and an append is what the diff + // reads as the previous syllable settling โ€” each jamo would go out alone. + pretendIosWeb() + const rig = openIosTerminal() + const steps: [string, string][] = [ + ['\u314E', '\u1112'], + ['\u3153', '\u1112\u1165'], + ['\u3134', '\u1112\u1165\u11AB'] + ] + for (const [key, field] of steps) { + dispatchKey(rig, 'keydown', { key, keyCode: key.charCodeAt(0) }) + rig.textarea.value = field + dispatchInput(rig, 'insertText', field.slice(-1)) + await nextEventLoop() + expect(rig.emitted).toEqual([]) + } + + dispatchKey(rig, 'keydown', { key: ' ', code: 'Space', keyCode: 32 }) + await nextEventLoop() + expect(rig.emitted[0]).toBe('\uD5CC') + }) + + it('clears the overlay when the pane disposes the controller', async () => { + // Why: the pane disposes the controller before xterm tears its DOM down, so + // a held syllable would otherwise stay painted over a dead pane. + pretendIosWeb() + const rig = openIosTerminal() + await typeJamo(rig, '\u314E', '\u314E', { replaces: false }) + expect(rig.compositionView.classList.contains('active')).toBe(true) + + rig.preedit.dispose() + + expect(rig.compositionView.classList.contains('active')).toBe(false) + expect(rig.compositionView.textContent).toBe('') + }) + + it('discards the open syllable on Escape, as a composition does', async () => { + pretendIosWeb() + const rig = openIosTerminal() + await typeJamo(rig, 'ใ…Ž', 'ใ…Ž', { replaces: false }) + dispatchKey(rig, 'keydown', { key: 'Escape', code: 'Escape', keyCode: 27 }) + await nextEventLoop() + + expect(rig.emitted).toEqual([]) + expect(rig.textarea.value).toBe('') + expect(rig.compositionView.classList.contains('active')).toBe(false) + }) + + it('sends a jamo the IME never composed, once the next keydown settles it', async () => { + pretendIosWeb() + const rig = openIosTerminal() + dispatchKey(rig, 'keydown', { key: 'ใ…Ž', keyCode: 'ใ…Ž'.charCodeAt(0) }) + await nextEventLoop() + + // Nothing is sent on a timer: the device delivers its write later than a + // macrotask, so a deferred read that concluded "nothing was composed" would + // fire first and send every jamo raw. The hold stays open instead. + expect(rig.emitted).toEqual([]) + + dispatchKey(rig, 'keydown', { key: 'Enter', code: 'Enter', keyCode: 13 }) + expect(rig.emitted).toEqual(['ใ…Ž', '\r']) + }) + + it('holds the jamo when the write lands after a full event loop turn', async () => { + pretendIosWeb() + const rig = openIosTerminal() + dispatchKey(rig, 'keydown', { key: 'ใ…Ž', keyCode: 'ใ…Ž'.charCodeAt(0) }) + await nextEventLoop() + + // The shape the device produces: the field is written only after the turn. + rig.textarea.value = 'ใ…Ž' + dispatchInput(rig, 'insertText', 'ใ…Ž') + await nextEventLoop() + + expect(rig.emitted).toEqual([]) + expect(rig.compositionView.textContent).toBe('ใ…Ž') + }) + + it('keeps a screen reader on its existing path, so input cannot vanish', async () => { + pretendIosWeb() + const rig = openIosTerminal({ screenReaderMode: true }) + await typeJamo(rig, 'ใ…Ž', 'ใ…Ž', { replaces: false }) + + expect(rig.preedit.heldText()).toBe('') + expect(rig.compositionView.classList.contains('active')).toBe(false) + }) + + it('leaves the syllable held when an input event changes nothing', async () => { + pretendIosWeb() + const rig = openIosTerminal() + await typeJamo(rig, 'ใ…Ž', 'ใ…Ž', { replaces: false }) + await typeJamo(rig, 'ใ…', 'ํ•˜', { replaces: true }) + + // A rewrite that lands on the same text is not the IME finishing `ํ•˜`. + dispatchInput(rig, 'insertText', 'ํ•˜') + await nextEventLoop() + + expect(rig.emitted).toEqual([]) + expect(rig.preedit.heldText()).toBe('ํ•˜') + }) + + it('does not commit a held syllable the field has stopped showing', async () => { + pretendIosWeb() + const rig = openIosTerminal() + await typeJamo(rig, 'ใ…Ž', 'ใ…Ž', { replaces: false }) + + // Longer than what is held, but not an extension of it: the IME respelled + // the syllable rather than finishing it, so `ใ…Ž` is no longer owed. + rig.textarea.value = '๊ฐ€๋‚˜' + dispatchInput(rig, 'insertText', '๊ฐ€๋‚˜') + await nextEventLoop() + + expect(rig.emitted).toEqual([]) + expect(rig.preedit.heldText()).toBe('๊ฐ€๋‚˜') + }) + + it('commits the held syllable when the field is rewritten out from under it', async () => { + pretendIosWeb() + const rig = openIosTerminal() + await typeHangeul(rig) + + // Not the IME rewriting its own syllable: the text the hold measures against + // is gone entirely, so `๊ธ€` is still owed to the PTY rather than droppable. + rig.textarea.value = 'ls' + dispatchInput(rig, 'insertText', 's') + await nextEventLoop() + + expect(rig.emitted).toEqual(['ํ•œ', '๊ธ€']) + expect(rig.preedit.heldText()).toBe('') + }) + + it('sends a jamo composed inside a session once, not twice', async () => { + // The iPad on-screen keyboard does run a composition for Hangul, unlike the + // hardware one. A hold opened over that session would commit the raw jamo + // alongside xterm's own commit. `isComposing` is unreliable on a session's + // keydowns โ€” the reason xterm has a 229 path at all โ€” so the session state + // is what has to gate the hold. + pretendIosWeb() + const rig = openIosTerminal() + dispatchComposition(rig, 'compositionstart', '') + dispatchKey(rig, 'keydown', { key: 'ใ…Ž', keyCode: 'ใ…Ž'.charCodeAt(0) }) + dispatchComposition(rig, 'compositionupdate', 'ใ…Ž') + rig.textarea.value = 'ใ…Ž' + dispatchInput(rig, 'insertCompositionText', 'ใ…Ž', { isComposing: true }) + dispatchKey(rig, 'keydown', { key: 'ใ…', keyCode: 'ใ…'.charCodeAt(0) }) + dispatchComposition(rig, 'compositionupdate', 'ํ•˜') + rig.textarea.value = 'ํ•˜' + dispatchInput(rig, 'insertCompositionText', 'ํ•˜', { isComposing: true }) + dispatchComposition(rig, 'compositionend', 'ํ•˜') + dispatchInput(rig, 'insertCompositionText', 'ํ•˜') + await nextEventLoop() + await nextEventLoop() + + expect(rig.emitted).toEqual(['ํ•˜']) + }) + + it('claims the field write even once the key has been released', async () => { + // xterm drops a composed `insertText` only while a key is down. When the + // device delivers the write after the keyup, nothing but the claim keeps + // xterm from sending the syllable a second time. + pretendIosWeb() + const rig = openIosTerminal() + dispatchKey(rig, 'keydown', { key: 'ใ…Ž', keyCode: 'ใ…Ž'.charCodeAt(0) }) + dispatchKey(rig, 'keyup', { key: 'ใ…Ž', keyCode: 'ใ…Ž'.charCodeAt(0) }) + rig.textarea.value = 'ใ…Ž' + dispatchInput(rig, 'insertText', 'ใ…Ž') + await nextEventLoop() + + expect(rig.emitted).toEqual([]) + expect(rig.preedit.heldText()).toBe('ใ…Ž') + }) + + it('stands aside for composition-owned input without consulting session state', async () => { + // Standing aside is read off the event, not off the tracker, so it cannot + // depend on which `input` listener on the pane element runs first. + pretendIosWeb() + const rig = openIosTerminal({ isCompositionActive: () => false }) + await typeJamo(rig, 'ใ…Ž', 'ใ…Ž', { replaces: false }) + rig.textarea.value = 'ใ…Žํ•˜' + dispatchInput(rig, 'insertCompositionText', 'ํ•˜') + await nextEventLoop() + + expect(rig.emitted).toEqual(['ใ…Ž']) + expect(rig.preedit.heldText()).toBe('') + }) + + it('stands aside for a composition that starts over an open syllable', async () => { + pretendIosWeb() + const rig = openIosTerminal() + await typeJamo(rig, 'ใ…Ž', 'ใ…Ž', { replaces: false }) + dispatchComposition(rig, 'compositionstart', '') + + expect(rig.emitted).toEqual(['ใ…Ž']) + expect(rig.preedit.heldText()).toBe('') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit.ts b/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit.ts new file mode 100644 index 00000000000..baa0c27d516 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit.ts @@ -0,0 +1,316 @@ +import type { IDisposable } from '@xterm/xterm' +import { isHangulJamoKeyText } from './hangul-jamo-key' + +// Why: iPadOS composes Hangul from a hardware keyboard with no composition +// events at all (#13345). Each jamo arrives as a plain keydown while the IME +// rewrites the syllable in place in the helper textarea โ€” +// `deleteContentBackward` then a replacing `insertText`. xterm consumes the +// keydown, so the raw compatibility jamo reaches the PTY and the composed +// syllable is dropped: `ํ•œ๊ธ€` arrives as `ใ…Žใ…ใ„ดใ„ฑใ…กใ„น`. +// +// The keydown is handed to the system by `shouldBypassXtermForIosTextEdit`, and +// the syllable it builds is held here until the IME proves it is final โ€” +// nothing is ever sent and then retracted, so raw-mode TUIs never see DEL bytes +// they would have to interpret as "erase one cell". +// +// Everything is driven from `input`. iPadOS delivers it later than a macrotask, +// so any timer-based read of the field loses the race and concludes, wrongly, +// that nothing was composed. + +type OpenPreedit = { + /** Field text preceding the syllable; everything after it is uncommitted. */ + baseValue: string + /** Held back: shown, not sent. The open syllable, plus the one before it + * while a migrating batchim could still pull back into it. */ + heldText: string + /** The jamo that opened the hold, owed to the PTY if the IME never writes. */ + openKey: string + /** Set once the IME has written to the field, which retires `openKey`. */ + imeWrote: boolean + /** What the last keydown asked the IME to do to the syllable. */ + editKind: 'compose' | 'erase' +} + +export type TerminalIosHangulPreedit = IDisposable & { + /** The text currently held back from the PTY, or '' when none is. */ + heldText: () => string +} + +export type TerminalIosHangulPreeditOptions = { + terminalElement: HTMLElement | null | undefined + /** Whether a composition session is under way, which bars a hold from opening + * over it โ€” Chinese pinyin on the same device does run one, and xterm's + * CompositionHelper already commits it. Must be derived and expiring, not + * latched: a session that never ends would otherwise disable the pane. */ + isCompositionActive: () => boolean + /** Screen reader mode writes the textarea itself, so a field diff would not + * be the IME's alone. */ + isScreenReaderMode: () => boolean + sendInput: (data: string) => void + /** Draws the open syllable; called with '' when there is none. */ + renderPreedit?: (text: string) => void +} + +const NO_OP_PREEDIT: TerminalIosHangulPreedit = { + heldText: () => '', + dispose: () => undefined +} + +function asHelperTextarea(target: EventTarget | null): HTMLTextAreaElement | null { + if (!(target instanceof HTMLTextAreaElement)) { + return null + } + return target.classList.contains('xterm-helper-textarea') ? target : null +} + +function isUnmodified(event: KeyboardEvent): boolean { + return !event.ctrlKey && !event.altKey && !event.metaKey +} + +function isJamoKey(event: KeyboardEvent): boolean { + return isHangulJamoKeyText(event.key) && isUnmodified(event) +} + +function isDeletion(event: Event): boolean { + return event instanceof InputEvent && event.inputType.startsWith('delete') +} + +/** One field rewrite, located: where the IME began and what it put there. */ +type FieldEdit = { + /** Index of the first character it changed. */ + start: number + /** What it wrote from `start` on; empty when the rewrite wrote nothing. */ + text: string +} + +/** + * Why NFC: a decomposed field grows `แ„’` -> `แ„’แ…ก` -> `แ„’แ…กแ†ซ` by appending, and an + * append is exactly what the diff reads as the previous syllable settling โ€” so + * an NFD source would put one bare jamo on the wire per keystroke. Composed, + * the same three keystrokes rewrite one character in place, as the device trace + * does. Idempotent on the NFC text iPadOS was recorded producing. + */ +function normalizeFieldText(value: string): string { + return value.normalize('NFC') +} + +/** + * Diffs two field states, taking the common prefix as where the IME began. + * + * Why not "did the tail grow past what is held": Korean batchim migration + * rewrites in place. `๊นจ` + `ใ…ˆ` becomes `๊นพ` โ€” a different codepoint, not an + * extension โ€” and only becomes `๊นจ์ฃผ` once the next vowel decides where the `ใ…ˆ` + * belongs. Any monotonic-growth assumption stalls there. + * + * No common-suffix half, unlike the usual textarea-IME diff: the IME edits at + * the caret and the caret is the end of the helper textarea, so there is never + * an untouched tail to discount. + */ +function diffFieldEdit(prev: string, next: string): FieldEdit { + const limit = Math.min(prev.length, next.length) + let start = 0 + while (start < limit && prev[start] === next[start]) { + start += 1 + } + return { start, text: next.slice(start) } +} + +/** True for input the browser attributes to a composition session, whichever + * order it interleaves those events in. */ +function isCompositionOwnedInput(event: Event): boolean { + return ( + event instanceof InputEvent && + (event.isComposing === true || event.inputType === 'insertCompositionText') + ) +} + +/** + * Holds the Hangul syllable iPadOS is still building and commits it only once + * the IME proves it final, so the PTY sees one write per syllable. + */ +export function installTerminalIosHangulPreedit( + options: TerminalIosHangulPreeditOptions +): TerminalIosHangulPreedit { + const root = options.terminalElement + if (!root) { + return NO_OP_PREEDIT + } + + let preedit: OpenPreedit | null = null + + const render = (text: string): void => options.renderPreedit?.(text) + + const close = (): void => { + preedit = null + render('') + } + + const commit = (): void => { + const open = preedit + if (!open) { + return + } + // A hold the IME never wrote to still owes the keystroke it swallowed. + const text = + open.heldText || (!open.imeWrote && isHangulJamoKeyText(open.openKey) ? open.openKey : '') + close() + if (text) { + options.sendInput(text) + } + } + + const discard = (textarea: HTMLTextAreaElement): void => { + const open = preedit + if (!open) { + return + } + // The cancelled syllable never reached the PTY, so it must not survive in + // the field either. + textarea.value = open.baseValue + close() + } + + /** + * Re-derives the hold from the field, releasing whatever the IME has stopped + * rewriting. + * + * A syllable is settled once the IME's own edit point has moved past it: up + * to that index the batchim question โ€” does this consonant end this syllable + * or start the next โ€” is already answered, and nothing there can change + * without a keystroke that ends the hold anyway. + */ + const sync = (textarea: HTMLTextAreaElement): void => { + const open = preedit + if (!open) { + return + } + const value = normalizeFieldText(textarea.value) + if (!value.startsWith(open.baseValue)) { + // The field was rewritten out from under the hold; commit rather than + // measure against text that is gone. + commit() + return + } + const tail = value.slice(open.baseValue.length) + if (tail.length === 0) { + // Backspace can decompose as delete-then-insert, so an emptied field is + // not proof the syllable is gone โ€” only that this half of the rewrite + // wrote nothing. The hold stays open, empty; the Backspace that finds it + // empty is the one the PTY gets. Nothing was sent, so nothing needs + // undoing either way. + open.heldText = '' + render('') + return + } + const edit = diffFieldEdit(open.heldText, tail) + // An edit that wrote nothing settles nothing: a rewrite landing on the same + // text, or a Backspace shortening the tail, is not the IME deciding. + if (edit.start > 0 && edit.text.length > 0) { + const settled = tail.slice(0, edit.start) + open.baseValue += settled + options.sendInput(settled) + } + open.heldText = value.slice(open.baseValue.length) + render(open.heldText) + } + + const handleKeyDown = (event: Event): void => { + if (!(event instanceof KeyboardEvent)) { + return + } + const textarea = root.querySelector('.xterm-helper-textarea') + if (!textarea) { + return + } + if (preedit) { + preedit.editKind = 'compose' + if (event.key === 'Escape' && isUnmodified(event)) { + // Escape cancels the syllable the way it cancels a composition. Stopped + // here so xterm cannot also send it; the default action still lets the + // IME clear its own state. + event.stopImmediatePropagation() + discard(textarea) + return + } + if (event.key === 'Backspace' && isUnmodified(event)) { + if (!preedit.heldText) { + // Nothing left to decompose: this erase is the PTY's, and xterm sends it. + close() + return + } + // Backspace decomposes the held syllable in the field rather than + // erasing a written cell, so it must not reach the PTY as DEL. + preedit.editKind = 'erase' + event.stopImmediatePropagation() + return + } + if (isJamoKey(event)) { + return + } + // Anything else ends the syllable, and runs before xterm sends the key. + commit() + return + } + if ( + isJamoKey(event) && + event.isComposing !== true && + !options.isCompositionActive() && + !options.isScreenReaderMode() + ) { + preedit = { + baseValue: normalizeFieldText(textarea.value), + heldText: '', + openKey: event.key, + imeWrote: false, + editKind: 'compose' + } + } + } + + const handleInput = (event: Event): void => { + if (!preedit) { + return + } + const textarea = asHelperTextarea(event.target) + if (!textarea) { + return + } + if (isCompositionOwnedInput(event)) { + // A session took the field over; it owns the commit from here. Read off + // the event rather than the session state, so this cannot depend on which + // `input` listener on the pane element happens to run first. + commit() + return + } + // Why: the field keeps the syllable so the IME can rewrite it, and xterm + // must not read that as fresh input. + event.stopImmediatePropagation() + preedit.imeWrote = true + if (preedit.editKind === 'compose' && isDeletion(event)) { + // Half of the delete-then-insert the IME uses to replace a growing + // syllable. Reading the field between the two would see it emptied. + return + } + sync(textarea) + } + + // A real session owns the field from here, so anything held is final. + const handleCompositionStart = (): void => commit() + const handleBlur = (): void => commit() + + root.addEventListener('keydown', handleKeyDown, true) + root.addEventListener('input', handleInput, true) + root.addEventListener('compositionstart', handleCompositionStart, true) + root.addEventListener('blur', handleBlur, true) + + return { + heldText: () => preedit?.heldText ?? '', + dispose: () => { + root.removeEventListener('keydown', handleKeyDown, true) + root.removeEventListener('input', handleInput, true) + root.removeEventListener('compositionstart', handleCompositionStart, true) + root.removeEventListener('blur', handleBlur, true) + close() + } + } +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts index 053a22405d5..a8b26cd6341 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts @@ -6,7 +6,6 @@ import { createQueuedStartupConsumer, getPreviousVisibleForTerminalPane, isTerminalPaneVisibilityResume, - isTouchIOSUserAgent, mapRestoredPaneTitlesByPaneId, paneOwnsQueuedStartup, resolvePaneLinkCwd, @@ -674,39 +673,3 @@ describe('terminal pane visibility resume tracking', () => { expect(isTerminalPaneVisibilityResume({ previousIsVisible: false, isVisible: true })).toBe(true) }) }) - -describe('isTouchIOSUserAgent', () => { - it('is false for a real Mac (Macintosh UA, no touch points)', () => { - expect( - isTouchIOSUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15', 0) - ).toBe(false) - }) - - it('is true for iPadOS desktop-mode Safari (Macintosh UA plus touch points)', () => { - expect( - isTouchIOSUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15', 5) - ).toBe(true) - }) - - it('is true for iPhone Safari ("like Mac OS X" UA plus touch points)', () => { - expect( - isTouchIOSUserAgent( - 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15', - 5 - ) - ).toBe(true) - }) - - it('is false for non-Mac UAs regardless of touch points', () => { - expect(isTouchIOSUserAgent('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36', 5)).toBe(false) - expect( - isTouchIOSUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 5) - ).toBe(false) - }) - - it('keeps the forwarder on a Mac whose touch peripheral reports a single point', () => { - expect( - isTouchIOSUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15', 1) - ).toBe(false) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 52a348b627d..0f033c1c3c8 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -95,6 +95,7 @@ import { resolveNonLatinControlChordInput } from './terminal-non-latin-control-chord' import { installTerminalImeCompositionTracker } from './terminal-ime-composition-tracker' +import { isCurrentPlatformIosWeb } from '@/lib/ios-web-platform' import { installTerminalImeLinuxCandidateState } from './terminal-ime-linux-candidate-state' import { armTerminalImePendingCandidateKeyRelease, @@ -103,6 +104,8 @@ import { shouldApplyTerminalImePendingCandidateKeyRelease } from './terminal-ime-candidate-key-release-guard' import { installTerminalImeNativeTextForwarder } from './terminal-ime-native-text-forwarder' +import { installTerminalIosHangulPreedit } from './terminal-ios-hangul-preedit' +import { createTerminalIosHangulPreeditRenderer } from './terminal-ios-hangul-preedit-overlay' import { shouldBypassXtermKeyboardEvent, shouldHandleTerminalInterruptKeyboardEvent, @@ -453,13 +456,6 @@ export function resolvePaneSeedCwd(splitPaneCwd: string | undefined, fallbackCwd return splitPaneCwd ?? fallbackCwd } -// Why > 1, matching isIOSWebView in mobile/src/terminal/terminal-webview-html.ts: a Mac with a -// touch peripheral can report exactly 1, and it must keep the forwarder. Real iPads report 5. -// Why UA rather than that helper's platform check: an iPhone reports platform "iPhone", not "MacIntel". -export function isTouchIOSUserAgent(userAgent: string, maxTouchPoints: number): boolean { - return userAgent.includes('Mac') && maxTouchPoints > 1 -} - type SplitStartupPayload = { command: string; env?: Record } type SplitWithStartupDeps = { @@ -1061,22 +1057,36 @@ export function useTerminalPaneLifecycle({ !isMac && navigator.userAgent.includes('Linux') && !/Android|CrOS/.test(navigator.userAgent) - // Why: gates the forwarder only โ€” isMac stays as-is for the Ctrl+C, clipboard, JIS-yen and 229 policies. - const isTouchIOS = isTouchIOSUserAgent(navigator.userAgent, navigator.maxTouchPoints) + // Why: gates the iOS text-edit paths only โ€” isMac stays as-is for the Ctrl+C, clipboard, JIS-yen and 229 policies. + const isIosWeb = isCurrentPlatformIosWeb() const linuxImeCandidateState = isLinux ? installTerminalImeLinuxCandidateState(pane.terminal.element) : null const imeCompositionTracker = installTerminalImeCompositionTracker(pane.terminal.element) + // Why after the tracker: the preedit stops propagation on `input` while + // a syllable is held, so anything on this element that needs those + // events has to be registered ahead of it. Nothing does today โ€” the + // preedit reads composition ownership off the event itself. + const iosHangulPreedit = isIosWeb + ? installTerminalIosHangulPreedit({ + terminalElement: pane.terminal.element, + isCompositionActive: () => imeCompositionTracker.isActive(), + isScreenReaderMode: () => pane.terminal.options.screenReaderMode === true, + sendInput: (data) => pane.terminal.input(data), + renderPreedit: createTerminalIosHangulPreeditRenderer(pane.terminal) + }) + : null imeCompositionDisposablesRef.current.set(pane.id, { dispose: () => { imeCompositionTracker.dispose() linuxImeCandidateState?.dispose() + iosHangulPreedit?.dispose() } }) // Why: macOS commits an input source's substituted text through the input event alone, so printable keydowns must not reach xterm's encoder. // Not on touch iOS/iPadOS: the forwarder stands aside for IME input via composition events, which iPad Hangul appears not to fire (#13345). const imeNativeTextForwarder = - isMac && !isTouchIOS + isMac && !isIosWeb ? installTerminalImeNativeTextForwarder({ terminalElement: pane.terminal.element, isComposing: () => imeCompositionTracker.isActive(), @@ -1216,6 +1226,7 @@ export function useTerminalPaneLifecycle({ const shouldBypass = shouldBypassXtermKeyboardEvent(e, { isMac, + isIosWeb, hasSelection: pane.terminal.hasSelection(), kittyKeyboardFlags: paneKittyKeyboardModesRef.current.get(pane.id)?.flags ?? 0 }) diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy-ios.test.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy-ios.test.ts new file mode 100644 index 00000000000..9c8d7ccff79 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy-ios.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest' +import { + shouldBypassXtermForIosTextEdit, + shouldBypassXtermKeyboardEvent +} from './xterm-bypass-policy' +import { event } from './xterm-bypass-event-fixture' + +// iPadOS reports a real physical key for every jamo โ€” `key: 'ใ…Ž'`, `code: 'KeyG'`, +// `keyCode: 71`, `isComposing: false` โ€” and runs no composition session. Only by +// leaving those keys to the default handler does the system compose them. +const HANGUL_JAMO_KEYDOWN = { key: 'ใ…Ž', code: 'KeyG', keyCode: 71 } + +describe('shouldBypassXtermForIosTextEdit', () => { + it('claims an unmodified jamo keydown on iOS web', () => { + expect(shouldBypassXtermForIosTextEdit(event(HANGUL_JAMO_KEYDOWN), true)).toBe(true) + }) + + it('claims the matching keyup and keypress so xterm cannot re-send the glyph', () => { + for (const type of ['keyup', 'keypress']) { + expect(shouldBypassXtermForIosTextEdit(event({ ...HANGUL_JAMO_KEYDOWN, type }), true)).toBe( + true + ) + } + }) + + it('leaves ASCII alone so English typing keeps the normal xterm path', () => { + expect( + shouldBypassXtermForIosTextEdit(event({ key: 'a', code: 'KeyA', keyCode: 65 }), true) + ).toBe(false) + }) + + it('leaves other non-ASCII scripts alone, since nothing downstream re-sends them', () => { + // Why: this claims the keydown, the keypress and (through the preedit + // controller) the `input` too. Only Hangul has a handler on the other side; + // a Cyrillic or kana key would reach the PTY as nothing at all. + for (const key of ['ะฟ', 'ใ‚', 'รฉ', 'ไธญ']) { + expect(shouldBypassXtermForIosTextEdit(event({ key }), true)).toBe(false) + } + }) + + it('claims Shift-typed double consonants, which start ๊นจ ๊ฟˆ ๋”ธ ๋นต ์“ฐ๋‹ค ์งœ๋‹ค', () => { + for (const key of ['ใ„ฒ', 'ใ„ธ', 'ใ…ƒ', 'ใ…†', 'ใ…‰']) { + expect(shouldBypassXtermForIosTextEdit(event({ key, shiftKey: true }), true)).toBe(true) + // Orca's own Shift rule already hides these keydowns from xterm, so only + // the keypress claim keeps `_keyPress` from sending the raw jamo. + expect( + shouldBypassXtermForIosTextEdit(event({ key, shiftKey: true, type: 'keypress' }), true) + ).toBe(true) + } + }) + + it('leaves named keys alone so Enter and arrows still reach the shell', () => { + for (const key of ['Enter', 'Backspace', 'ArrowLeft', 'Escape']) { + expect(shouldBypassXtermForIosTextEdit(event({ key }), true)).toBe(false) + } + }) + + it('leaves modifier chords alone so shortcuts are not swallowed', () => { + expect( + shouldBypassXtermForIosTextEdit(event({ ...HANGUL_JAMO_KEYDOWN, ctrlKey: true }), true) + ).toBe(false) + expect( + shouldBypassXtermForIosTextEdit(event({ ...HANGUL_JAMO_KEYDOWN, metaKey: true }), true) + ).toBe(false) + expect( + shouldBypassXtermForIosTextEdit(event({ ...HANGUL_JAMO_KEYDOWN, altKey: true }), true) + ).toBe(false) + }) + + it('leaves composing keystrokes to xterm, for iOS sources that do compose', () => { + // Why: the on-screen keyboard and the Japanese/Chinese IMEs run a real + // composition session, which xterm's CompositionHelper already commits. + expect( + shouldBypassXtermForIosTextEdit(event({ ...HANGUL_JAMO_KEYDOWN, isComposing: true }), true) + ).toBe(false) + }) + + it('is inert off iOS web, leaving desktop IME handling untouched', () => { + expect(shouldBypassXtermForIosTextEdit(event(HANGUL_JAMO_KEYDOWN), false)).toBe(false) + }) +}) + +describe('shouldBypassXtermKeyboardEvent โ€” iOS web', () => { + const iosOptions = { isMac: true, isIosWeb: true, hasSelection: false } + const macOptions = { isMac: true, hasSelection: false } + + it('bypasses a bare jamo keydown', () => { + expect(shouldBypassXtermKeyboardEvent(event(HANGUL_JAMO_KEYDOWN), iosOptions)).toBe(true) + }) + + it('does not bypass the same key on a Mac desktop browser', () => { + // Why: iPadOS reports `Macintosh` in its default desktop mode, so `isMac` + // alone must not turn the iOS path on for real Macs. + expect(shouldBypassXtermKeyboardEvent(event(HANGUL_JAMO_KEYDOWN), macOptions)).toBe(false) + }) + + it('still bypasses Shift+jamo without the iOS flag, as it did before', () => { + expect( + shouldBypassXtermKeyboardEvent( + event({ key: 'ใ„ฒ', code: 'KeyR', keyCode: 82, shiftKey: true }), + macOptions + ) + ).toBe(true) + }) + + it('keeps Cmd+C bubbling on iOS web', () => { + expect( + shouldBypassXtermKeyboardEvent(event({ key: 'c', code: 'KeyC', metaKey: true }), iosOptions) + ).toBe(true) + }) + + it('keeps Enter on xterm so the command is submitted', () => { + expect( + shouldBypassXtermKeyboardEvent( + event({ key: 'Enter', code: 'Enter', keyCode: 13 }), + iosOptions + ) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts index 6e8c2753802..402d452e056 100644 --- a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts @@ -1,4 +1,5 @@ import { keybindingMatchesInput } from '../../../../shared/keybindings' +import { isHangulJamoKeyText } from './hangul-jamo-key' import { getLayoutBaseCharacterForCode } from '../../lib/keyboard-layout/layout-base-character' import { isTerminalImeCandidateDigitKeyEvent, @@ -38,6 +39,10 @@ export type XtermBypassOptions = { * Windows/Linux should only bubble to clipboard when something is selected, * otherwise it must reach the shell as SIGINT. */ hasSelection: boolean + /** True when the renderer runs inside iOS/iPadOS WebKit, where the system + * composes CJK text by rewriting the field instead of running a composition + * session. See `terminal-ios-hangul-preedit.ts`. */ + isIosWeb?: boolean } export type XtermImeKeyboardOptions = { @@ -94,6 +99,36 @@ function isXtermHandledKeyEvent(type: string): boolean { return type === 'keydown' || type === 'keyup' } +/** + * Why: iOS/iPadOS composes CJK text by rewriting the field through + * `beforeinput`/`input`, with no composition session, and that only runs when + * the printable keydown reaches the default handler. xterm has to stay out of + * the way for those keys so `terminal-ios-hangul-preedit.ts` can read the + * resulting edits. `keypress` is included because `_keyPress` would otherwise + * send the glyph a second time alongside the preedit commit. + */ +export function shouldBypassXtermForIosTextEdit( + event: XtermBypassEvent, + isIosWeb: boolean +): boolean { + if (!isIosWeb || event.ctrlKey || event.metaKey || event.altKey) { + return false + } + if (event.isComposing === true) { + // Why: input sources that do run a composition session stay with xterm's + // CompositionHelper, which already commits them correctly. + return false + } + if (!isXtermHandledKeyEvent(event.type) && event.type !== 'keypress') { + return false + } + // Why jamo and not every non-ASCII key: nothing downstream re-sends a key + // this claims. A Cyrillic or kana key would lose its keydown, its keypress, + // and then its `input` too โ€” xterm drops a composed insert while a key is + // down โ€” and reach the PTY as nothing at all. + return isHangulJamoKeyText(event.key) +} + /** Returns whether the Linux orphan-keyup window may claim this digit. */ function claimsOrphanCandidateDigit( event: XtermBypassEvent, @@ -256,6 +291,9 @@ export function shouldBypassXtermKeyboardEvent( event: XtermBypassEvent, options: XtermBypassOptions ): boolean { + if (shouldBypassXtermForIosTextEdit(event, options.isIosWeb === true)) { + return true + } if (!isXtermHandledKeyEvent(event.type)) { return false } diff --git a/src/renderer/src/lib/ios-web-platform.test.ts b/src/renderer/src/lib/ios-web-platform.test.ts new file mode 100644 index 00000000000..e5381b312ce --- /dev/null +++ b/src/renderer/src/lib/ios-web-platform.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { isIosWebPlatform } from './ios-web-platform' + +const IPAD_DESKTOP_MODE_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15' +const IPAD_MOBILE_MODE_UA = + 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120.0.0.0 Mobile/15E148 Safari/604.1' +const IPHONE_UA = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1' +const IPOD_UA = + 'Mozilla/5.0 (iPod touch; CPU iPhone OS 14_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Mobile/15E148 Safari/604.1' +const MAC_DESKTOP_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36' +const MAC_ELECTRON_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) orca/1.0.0 Chrome/126.0.0.0 Electron/31.0.0 Safari/537.36' +const WINDOWS_UA = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36' +const ANDROID_UA = + 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36' + +describe('isIosWebPlatform', () => { + it('detects iPadOS desktop mode, which reports Macintosh and omits iPad', () => { + expect(isIosWebPlatform(IPAD_DESKTOP_MODE_UA, 5)).toBe(true) + }) + + it('detects iPad, iPhone and iPod mobile-mode user agents', () => { + expect(isIosWebPlatform(IPAD_MOBILE_MODE_UA, 5)).toBe(true) + expect(isIosWebPlatform(IPHONE_UA, 5)).toBe(true) + expect(isIosWebPlatform(IPOD_UA, 5)).toBe(true) + }) + + it('detects a mobile-mode iPad even when the touch count is unavailable', () => { + expect(isIosWebPlatform(IPAD_MOBILE_MODE_UA, 0)).toBe(true) + }) + + it('does not claim a Mac browser or the Electron app', () => { + expect(isIosWebPlatform(MAC_DESKTOP_UA, 0)).toBe(false) + expect(isIosWebPlatform(MAC_ELECTRON_UA, 0)).toBe(false) + }) + + it('does not claim a Mac whose touch peripheral reports a single point', () => { + expect(isIosWebPlatform(MAC_DESKTOP_UA, 1)).toBe(false) + }) + + it('does not claim Windows, Linux or Android', () => { + expect(isIosWebPlatform(WINDOWS_UA, 0)).toBe(false) + expect(isIosWebPlatform(ANDROID_UA, 5)).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/ios-web-platform.ts b/src/renderer/src/lib/ios-web-platform.ts new file mode 100644 index 00000000000..7451cff8768 --- /dev/null +++ b/src/renderer/src/lib/ios-web-platform.ts @@ -0,0 +1,28 @@ +/** + * True when the renderer runs inside iOS/iPadOS WebKit โ€” the web client opened + * in a browser on an iPhone or iPad. + * + * Every iOS and iPadOS user agent contains `Mac`: iPad desktop mode (the + * default since iPadOS 13) reports `Macintosh; Intel Mac OS X`, and mobile mode + * reports `like Mac OS X`. Desktop mode omits `iPad`, so the touch-point count + * is the only signal separating an iPad from a Mac. Both browsers on the + * platform are WebKit โ€” iOS bars third-party engines โ€” so this covers Safari, + * Chrome and the rest alike. + */ +export function isIosWebPlatform(userAgent: string, maxTouchPoints: number): boolean { + if (/iPad|iPhone|iPod/.test(userAgent)) { + return true + } + return userAgent.includes('Mac') && maxTouchPoints > 1 +} + +/** + * Returns true if the current environment is an iOS or iPadOS web client browser. + * Safe to invoke in SSR or non-DOM environments where navigator is undefined. + */ +export function isCurrentPlatformIosWeb(): boolean { + if (typeof navigator === 'undefined') { + return false + } + return isIosWebPlatform(navigator.userAgent, navigator.maxTouchPoints) +} From 350423b7cbb640cf47e46c55187d5d3b85eee511 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:09:07 -0700 Subject: [PATCH 12/29] Speed up PR CI with path skips, native caches, and fewer shards (#16863) * Speed up PR CI with per-job path skips and native caches Skip git-compat, xterm, packaging, and shell jobs when their inputs are unchanged, reuse the composite install action (including Windows node-pty cache), skip compiling the Windows CLI launcher on a cache hit, and cut the test matrix from 16x2 to 8x2 shards without dropping coverage. * Widen PR job skip prefixes for orcad browser and live shells Chrome session/tab modules and zsh/fish wrapper templates are inputs to required jobs the classifier previously skipped. Include that implementation graph so those jobs still run when the files they load change. * Fix CI cache safety and required gates * Build scriptless Windows addons explicitly * Preserve node-pty Windows support prebuild * Remove duplicated Windows launcher unit lane --- .../install-node-dependencies/action.yml | 52 +++- .github/workflows/computer-e2e.yml | 27 +- .github/workflows/e2e.yml | 113 +------- .github/workflows/pr.yml | 184 +++++++------ config/scripts/build-windows-cli-launcher.mjs | 93 ++++--- .../build-windows-cli-launcher.test.mjs | 30 +++ config/scripts/computer-e2e-workflow.test.mjs | 5 +- config/scripts/ensure-native-runtime.mjs | 58 ++-- config/scripts/ensure-native-runtime.test.mjs | 97 ++++++- config/scripts/pr-code-change-scope.mjs | 254 +++++++++++++++++- config/scripts/pr-code-change-scope.test.mjs | 213 +++++++++++++-- config/scripts/pr-e2e-gate-contract.test.mjs | 26 +- .../scripts/pr-workflow-parallelism.test.mjs | 57 ++-- config/scripts/rebuild-native-deps.mjs | 26 +- config/scripts/rebuild-native-deps.test.mjs | 7 + .../skills-cli-package-workflow.test.mjs | 1 + ...ws-pty-native-capability-workflow.test.mjs | 21 +- docs/reference/windows-process-enumeration.md | 8 +- ...pty-subprocess-foreground-identity.test.ts | 1 + ...subprocess-foreground-scan-cadence.test.ts | 9 + .../pty-subprocess-handle-lifecycle.test.ts | 1 + 21 files changed, 961 insertions(+), 322 deletions(-) diff --git a/.github/actions/install-node-dependencies/action.yml b/.github/actions/install-node-dependencies/action.yml index 39cb1465852..e6d6f8f15e3 100644 --- a/.github/actions/install-node-dependencies/action.yml +++ b/.github/actions/install-node-dependencies/action.yml @@ -1,5 +1,5 @@ name: Install Node dependencies -description: Installs the Node toolchain and repository dependencies for Linux CI jobs. +description: Installs the Node toolchain and repository dependencies for CI jobs. inputs: native-runtime: @@ -10,6 +10,18 @@ inputs: description: Node.js version override; defaults to the version declared in package.json. required: false default: '' + persist-native-cache: + description: Save restored native modules at job end. Set false when a later step overwrites the same path with a different ABI. + required: false + default: 'true' + +outputs: + node-version: + description: Resolved Node.js version used for the install. + value: ${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }} + native-cache-scope: + description: Operating-system image scope used by the native module cache. + value: ${{ steps.native-cache-scope.outputs.scope }} runs: using: composite @@ -51,7 +63,7 @@ runs: # pnpm's bundled gyp_main.py is not executable on fresh Linux runners. - name: Use external node-gyp - if: inputs.native-runtime != 'none' + if: runner.os == 'Linux' && inputs.native-runtime != 'none' shell: bash run: | npm install -g node-gyp@11.5.0 @@ -80,12 +92,42 @@ runs: # ensure-native-runtime node-gyp-compiles it in every job that asks for a runtime. # The artifacts are ABI-bound, so the key carries the target runtime, the resolved # Node version, and the patch whose contents the build has to match. - - name: Restore compiled native modules + # Windows extra globs are empty on Linux. No restore-keys: a partial-match key is + # an ABI-mismatched build, and ensure-native-runtime would recompile it anyway. + # Native addons built on a newer Linux image can require glibc symbols + # missing from an older runner/container. ImageOS distinguishes hosted + # Windows/macOS images; /etc/os-release also distinguishes Linux containers. + - name: Resolve native cache scope + id: native-cache-scope if: inputs.native-runtime != 'none' + shell: bash + run: | + scope="${ImageOS:-$RUNNER_OS}" + if [ -r /etc/os-release ]; then + . /etc/os-release + scope="${ID:-linux}-${VERSION_ID:-unknown}" + fi + echo "scope=$scope" >> "$GITHUB_OUTPUT" + + - name: Restore compiled native modules + if: inputs.native-runtime != 'none' && inputs.persist-native-cache != 'false' uses: actions/cache@v5 with: - path: node_modules/.pnpm/node-pty@*/node_modules/node-pty/build - key: native-modules-${{ runner.os }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.default-node.outputs.node-version }}${{ steps.requested-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch') }} + path: | + node_modules/.pnpm/node-pty@*/node_modules/node-pty/build + node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build + node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build + key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.default-node.outputs.node-version }}${{ steps.requested-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }} + + - name: Restore compiled native modules without saving + if: inputs.native-runtime != 'none' && inputs.persist-native-cache == 'false' + uses: actions/cache/restore@v5 + with: + path: | + node_modules/.pnpm/node-pty@*/node_modules/node-pty/build + node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build + node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build + key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.default-node.outputs.node-version }}${{ steps.requested-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }} - name: Prepare native runtime if: inputs.native-runtime != 'none' diff --git a/.github/workflows/computer-e2e.yml b/.github/workflows/computer-e2e.yml index ab1c6ce687b..b24c8a52f2c 100644 --- a/.github/workflows/computer-e2e.yml +++ b/.github/workflows/computer-e2e.yml @@ -71,30 +71,17 @@ jobs: - uses: actions/checkout@v6 with: persist-credentials: false - - uses: actions/setup-node@v6 - with: - node-version-file: package.json - - uses: pnpm/action-setup@v6 - with: - run_install: false - if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y python3 python3-gi gir1.2-atspi-2.0 at-spi2-core gedit xvfb xclip xdotool - # Why: pnpm's bundled node-gyp can ship gyp_main.py without execute - # permission on Linux runners; node-pty's install fallback then fails - # before this smoke job can exercise the native package. - - name: Use external node-gyp to avoid pnpm's bundled copy (Linux only) - if: runner.os == 'Linux' - run: | - npm install -g node-gyp@11.5.0 - echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV" - - run: pnpm install --frozen-lockfile + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node # Why: without --config, bare vitest ignores config/vitest.config.ts (there # is no root config) and falls back to the 5s default timeout with no # Windows worker cap, so the real csc.exe launcher-compile tests time out # on hosted Windows. Use the shared config so this job matches pnpm test. - run: >- pnpm vitest run --config config/vitest.config.ts - config/scripts/build-windows-cli-launcher.test.mjs src/main/ssh/ssh-remote-cli-launcher.test.ts config/scripts/computer-e2e-workflow.test.mjs config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs @@ -159,13 +146,9 @@ jobs: - uses: actions/checkout@v6 with: persist-credentials: false - - uses: actions/setup-node@v6 + - uses: ./.github/actions/install-node-dependencies with: - node-version-file: package.json - - uses: pnpm/action-setup@v6 - with: - run_install: false - - run: pnpm install --frozen-lockfile + native-runtime: electron - name: Owner-loss benchmark process cleanup run: >- pnpm vitest run diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 24c0ffa0e5b..989137b8831 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -40,36 +40,9 @@ jobs: with: ref: ${{ inputs.ref || github.ref }} - # Why: the E2E build compiles native modules via node-gyp. Mirrors the - # install step in pr.yml's verify job so E2E doesn't hit missing-toolchain - # errors. - - name: Install native build tools - run: sudo apt-get update && sudo apt-get install -y build-essential python3 - - # Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches. - # Without that cache every E2E job re-downloaded the whole dependency set. - - name: Setup pnpm - uses: pnpm/action-setup@v6 - with: - run_install: false - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version-file: package.json - cache: pnpm - - # Why: this job runs the same pnpm install path as pr.yml's verify - # job, so it needs the same pinned node-gyp override to avoid pnpm's - # broken bundled gyp_main.py on Linux. - - name: Use external node-gyp to avoid pnpm's bundled copy (Linux only) - if: runner.os == 'Linux' - run: | - npm install -g node-gyp@11.5.0 - echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV" - - - name: Install dependencies - run: pnpm install --frozen-lockfile + # Why no native-runtime: this job only produces JS bundles. Native modules + # are prepared in the consumer shards that actually launch Electron. + - uses: ./.github/actions/install-node-dependencies # Why: building here avoids parallel builds inside Playwright globalSetup; # paired-browser specs also need the standalone web bundle. @@ -130,11 +103,11 @@ jobs: with: ref: ${{ inputs.ref || github.ref }} - # Why: pnpm install rebuilds native modules, and those postinstall - # scripts still need the Linux toolchain even though this shard reuses - # the prebuilt Electron output. + # Why: pnpm install used to rebuild native modules here; the composite + # action restores them from cache and only compiles on a miss. The + # toolchain is still required for that miss path, and for paired Quick + # Open coverage which exercises the resource-bounded host search. - name: Install native build tools - # Why: paired Quick Open coverage exercises the resource-bounded host search. run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk python3 ripgrep zsh # Why: Electron on Linux needs an X display even when the app @@ -143,33 +116,9 @@ jobs: - name: Install xvfb run: sudo apt-get install -y xvfb - # Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches. - # Without that cache every E2E job re-downloaded the whole dependency set. - - name: Setup pnpm - uses: pnpm/action-setup@v6 + - uses: ./.github/actions/install-node-dependencies with: - run_install: false - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version-file: package.json - cache: pnpm - - # Why: this job runs the same pnpm install path as pr.yml's verify - # job, so it needs the same pinned node-gyp override to avoid pnpm's - # broken bundled gyp_main.py on Linux. Gate on runner.os matches - # release.yml so the invariant "this workaround is Linux-only" is - # consistent across all three workflows, even though this job - # currently pins runs-on: ubuntu-latest. - - name: Use external node-gyp to avoid pnpm's bundled copy (Linux only) - if: runner.os == 'Linux' - run: | - npm install -g node-gyp@11.5.0 - echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV" - - - name: Install dependencies - run: pnpm install --frozen-lockfile + native-runtime: electron - name: Download E2E build output uses: actions/download-artifact@v8 @@ -222,25 +171,9 @@ jobs: # lane now receives those specs from pr.yml's SSH source mapping. run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh - # Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches. - # Without that cache every E2E job re-downloaded the whole dependency set. - - name: Setup pnpm - uses: pnpm/action-setup@v6 + - uses: ./.github/actions/install-node-dependencies with: - run_install: false - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version-file: package.json - cache: pnpm - - - name: Use external node-gyp to avoid pnpm's bundled copy - run: | - npm install -g node-gyp@11.5.0 - echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV" - - - name: Install dependencies - run: pnpm install --frozen-lockfile + native-runtime: electron - name: Download E2E build output uses: actions/download-artifact@v8 @@ -305,29 +238,9 @@ jobs: - name: Install native build and headless UI tools run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 xvfb zsh - # Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches. - # Without that cache every E2E job re-downloaded the whole dependency set. - - name: Setup pnpm - uses: pnpm/action-setup@v6 + - uses: ./.github/actions/install-node-dependencies with: - run_install: false - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version-file: package.json - cache: pnpm - - # Why: same Linux-only node-gyp pin as build/e2e jobs so the workaround - # stays consistent across workflows even while this job is ubuntu-latest. - - name: Use external node-gyp to avoid pnpm's bundled copy (Linux only) - if: runner.os == 'Linux' - run: | - npm install -g node-gyp@11.5.0 - echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV" - - - name: Install dependencies - run: pnpm install --frozen-lockfile + native-runtime: electron - name: Download E2E build output uses: actions/download-artifact@v8 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index a256da5112c..5eadeb9be81 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -16,15 +16,28 @@ permissions: contents: read jobs: - # Why: a README/docs-only PR used to start the full matrix (32 test shards, + # Why: a README/docs-only PR used to start the full matrix (test shards, # two package jobs, typecheck, git compat, xterm, shell contracts). Path # filters on `on.pull_request` would drop the `verify` check entirely; this # detector keeps verify as the required aggregate and skips the expensive jobs. + # Per-job outputs also skip git-compat/xterm/packaging/shell when those + # inputs are unchanged; empty diffs fail closed and run everything. code_paths: name: detect code-relevant changes runs-on: ubuntu-latest outputs: should_run: ${{ steps.filter.outputs.should_run }} + static_analysis: ${{ steps.filter.outputs.static_analysis }} + typecheck: ${{ steps.filter.outputs.typecheck }} + git_compatibility: ${{ steps.filter.outputs.git_compatibility }} + xterm_patch_sync: ${{ steps.filter.outputs.xterm_patch_sync }} + shell_contracts: ${{ steps.filter.outputs.shell_contracts }} + test: ${{ steps.filter.outputs.test }} + orcad_browser: ${{ steps.filter.outputs.orcad_browser }} + cross-version-wire: ${{ steps.filter.outputs.cross-version-wire }} + managed_hook_node18: ${{ steps.filter.outputs.managed_hook_node18 }} + package: ${{ steps.filter.outputs.package }} + package_windows: ${{ steps.filter.outputs.package_windows }} steps: - name: Checkout uses: actions/checkout@v6 @@ -48,14 +61,12 @@ jobs: CHANGED="$(git diff --name-only --no-renames --diff-filter=ACDMR --merge-base "$BASE_SHA" "$HEAD_SHA")" echo "Changed paths:" printf '%s\n' "$CHANGED" - SHOULD_RUN="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-code-change-scope.mjs)" - echo "should_run=$SHOULD_RUN" >> "$GITHUB_OUTPUT" - echo "should_run=$SHOULD_RUN" + printf '%s\n' "$CHANGED" | node config/scripts/pr-code-change-scope.mjs | tee -a "$GITHUB_OUTPUT" static_analysis: name: static analysis needs: [code_paths] - if: needs.code_paths.outputs.should_run == 'true' + if: needs.code_paths.outputs.static_analysis == 'true' runs-on: ubuntu-latest steps: @@ -70,6 +81,8 @@ jobs: persist-credentials: false - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node - name: Lint run: pnpm exec oxlint --format github @@ -188,7 +201,7 @@ jobs: typecheck: needs: [code_paths] - if: needs.code_paths.outputs.should_run == 'true' + if: needs.code_paths.outputs.typecheck == 'true' runs-on: ubuntu-latest steps: @@ -216,7 +229,7 @@ jobs: git_compatibility: name: Git compatibility needs: [code_paths] - if: needs.code_paths.outputs.should_run == 'true' + if: needs.code_paths.outputs.git_compatibility == 'true' runs-on: ubuntu-latest steps: @@ -283,7 +296,7 @@ jobs: xterm_patch_sync: name: xterm patch sync needs: [code_paths] - if: needs.code_paths.outputs.should_run == 'true' + if: needs.code_paths.outputs.xterm_patch_sync == 'true' runs-on: ubuntu-latest steps: @@ -313,7 +326,7 @@ jobs: shell_contracts: name: shell contracts needs: [code_paths] - if: needs.code_paths.outputs.should_run == 'true' + if: needs.code_paths.outputs.shell_contracts == 'true' runs-on: ubuntu-latest # Why: this job's cost is almost entirely package download, and a stalled mirror has # no wall-clock bound of its own. A successful run finishes in ~4.5 minutes, so this @@ -432,14 +445,14 @@ jobs: test: name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }} needs: [code_paths] - if: needs.code_paths.outputs.should_run == 'true' + if: needs.code_paths.outputs.test == 'true' runs-on: ubuntu-latest strategy: fail-fast: false matrix: node: ['24', '26'] - shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16] - shard_total: [16] + shard: [1, 2, 3, 4, 5, 6, 7, 8] + shard_total: [8] steps: - name: Checkout @@ -477,12 +490,12 @@ jobs: --exclude=tests/e2e/cross-version-wire/** \ --shard=${{ matrix.shard }}/${{ matrix.shard_total }} - # Why a separate job: the test needs a real Chrome, and the 32-way `test` matrix - # would pay for it 32 times to run one file in whichever shard it landed in. + # Why a separate job: the test needs a real Chrome, and the sharded `test` matrix + # would pay for it on every shard to run one file in whichever shard it landed in. orcad_browser: name: orcad browser provider needs: [code_paths] - if: needs.code_paths.outputs.should_run == 'true' + if: needs.code_paths.outputs.orcad_browser == 'true' runs-on: ubuntu-latest steps: @@ -518,7 +531,7 @@ jobs: cross-version-wire: name: cross-version wire compatibility needs: [code_paths] - if: needs.code_paths.outputs.should_run == 'true' + if: needs.code_paths.outputs.cross-version-wire == 'true' runs-on: ubuntu-latest steps: @@ -550,7 +563,7 @@ jobs: managed_hook_node18: name: managed hooks on Node 18 needs: [code_paths] - if: needs.code_paths.outputs.should_run == 'true' + if: needs.code_paths.outputs.managed_hook_node18 == 'true' runs-on: ubuntu-latest steps: @@ -575,7 +588,7 @@ jobs: package: name: package needs: [code_paths] - if: needs.code_paths.outputs.should_run == 'true' + if: needs.code_paths.outputs.package == 'true' runs-on: ubuntu-latest steps: @@ -647,7 +660,7 @@ jobs: package_windows: name: package (windows) needs: [code_paths] - if: needs.code_paths.outputs.should_run == 'true' + if: needs.code_paths.outputs.package_windows == 'true' runs-on: windows-2022 timeout-minutes: 30 @@ -657,17 +670,6 @@ jobs: with: persist-credentials: false - - name: Setup pnpm - uses: pnpm/action-setup@v6 - with: - run_install: false - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version-file: package.json - cache: pnpm - - name: Cache electron-builder downloads uses: actions/cache@v5 with: @@ -678,18 +680,22 @@ jobs: restore-keys: | electron-builder-windows- - - name: Install dependencies - run: pnpm install --frozen-lockfile + # Why persist-native-cache false: this job later rebuilds the same path for + # Electron. A post-job save would store the Electron ABI under the Node key. + - uses: ./.github/actions/install-node-dependencies + id: deps + with: + native-runtime: node + persist-native-cache: 'false' - # Why: node-pty prefers its upstream prebuild, which does not contain - # Orca's Windows patch, so the job-object exports would be absent and the - # suite below would test an unpatched binary. build_from_source removes - # the prebuild, and the package's postinstall restores the ConPTY runtime - # files that a bare node-gyp rebuild would miss. - - name: Rebuild node-pty from patched source - env: - npm_config_build_from_source: 'true' - run: pnpm rebuild node-pty + - name: Save compiled Node native modules + uses: actions/cache/save@v5 + with: + path: | + node_modules/.pnpm/node-pty@*/node_modules/node-pty/build + node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build + node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build + key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-node-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }} - name: Test Windows-specific boundaries run: >- @@ -724,9 +730,26 @@ jobs: # Why the :parallel variant: identical to build:release except the three # electron-vite targets overlap instead of running back to back. The Linux package # job already packages and smoke-tests an AppImage built that way. + - name: Cache Windows CLI launcher + uses: actions/cache@v5 + with: + path: native/windows-cli-launcher/.build + key: windows-cli-launcher-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/windows-cli-launcher/**', 'config/scripts/build-windows-cli-launcher.mjs') }} + - name: Build package inputs + env: + ORCA_REUSE_WINDOWS_CLI_LAUNCHER: '1' run: pnpm run build:release:parallel + - name: Restore compiled Electron native modules + uses: actions/cache@v5 + with: + path: | + node_modules/.pnpm/node-pty@*/node_modules/node-pty/build + node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build + node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build + key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-electron-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }} + - name: Prepare Electron native runtime run: node config/scripts/ensure-native-runtime.mjs --runtime=electron @@ -807,6 +830,7 @@ jobs: - shell_contracts - test - orcad_browser + - cross-version-wire - managed_hook_node18 - package - package_windows @@ -826,57 +850,63 @@ jobs: CODE_PATHS: ${{ needs.code_paths.result }} SHOULD_RUN: ${{ needs.code_paths.outputs.should_run }} STATIC_ANALYSIS: ${{ needs.static_analysis.result }} + STATIC_ANALYSIS_SHOULD_RUN: ${{ needs.code_paths.outputs.static_analysis }} ROOT_DIRECTORY_GUARD: ${{ needs.root_directory_guard.result }} TYPECHECK: ${{ needs.typecheck.result }} + TYPECHECK_SHOULD_RUN: ${{ needs.code_paths.outputs.typecheck }} GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }} + GIT_COMPATIBILITY_SHOULD_RUN: ${{ needs.code_paths.outputs.git_compatibility }} XTERM_PATCH_SYNC: ${{ needs.xterm_patch_sync.result }} + XTERM_PATCH_SYNC_SHOULD_RUN: ${{ needs.code_paths.outputs.xterm_patch_sync }} SHELL_CONTRACTS: ${{ needs.shell_contracts.result }} + SHELL_CONTRACTS_SHOULD_RUN: ${{ needs.code_paths.outputs.shell_contracts }} TEST: ${{ needs.test.result }} + TEST_SHOULD_RUN: ${{ needs.code_paths.outputs.test }} ORCAD_BROWSER: ${{ needs.orcad_browser.result }} + ORCAD_BROWSER_SHOULD_RUN: ${{ needs.code_paths.outputs.orcad_browser }} + CROSS_VERSION_WIRE: ${{ needs.cross-version-wire.result }} + CROSS_VERSION_WIRE_SHOULD_RUN: ${{ needs.code_paths.outputs.cross-version-wire }} MANAGED_HOOK_NODE18: ${{ needs.managed_hook_node18.result }} + MANAGED_HOOK_NODE18_SHOULD_RUN: ${{ needs.code_paths.outputs.managed_hook_node18 }} PACKAGE: ${{ needs.package.result }} + PACKAGE_SHOULD_RUN: ${{ needs.code_paths.outputs.package }} PACKAGE_WINDOWS: ${{ needs.package_windows.result }} + PACKAGE_WINDOWS_SHOULD_RUN: ${{ needs.code_paths.outputs.package_windows }} run: | if [ "$CODE_PATHS" != "success" ]; then exit 1 fi + if [ "$ROOT_DIRECTORY_GUARD" != "success" ]; then + exit 1 + fi if [ "$SHOULD_RUN" != "true" ]; then echo "Docs-only change; expensive PR checks skipped." - if [ "$ROOT_DIRECTORY_GUARD" != "success" ]; then - exit 1 - fi - for result in \ - "$STATIC_ANALYSIS" \ - "$TYPECHECK" \ - "$GIT_COMPATIBILITY" \ - "$XTERM_PATCH_SYNC" \ - "$SHELL_CONTRACTS" \ - "$TEST" \ - "$ORCAD_BROWSER" \ - "$MANAGED_HOOK_NODE18" \ - "$PACKAGE" \ - "$PACKAGE_WINDOWS"; do - if [ "$result" != "skipped" ]; then - exit 1 - fi - done - exit 0 fi - # Require success when the PR has code-relevant changes - for result in \ - "$CODE_PATHS" \ - "$STATIC_ANALYSIS" \ - "$ROOT_DIRECTORY_GUARD" \ - "$TYPECHECK" \ - "$GIT_COMPATIBILITY" \ - "$XTERM_PATCH_SYNC" \ - "$SHELL_CONTRACTS" \ - "$TEST" \ - "$ORCAD_BROWSER" \ - "$MANAGED_HOOK_NODE18" \ - "$PACKAGE" \ - "$PACKAGE_WINDOWS"; do - if [ "$result" != "success" ]; then - exit 1 + failed=0 + check_job() { + local name="$1" result="$2" should="$3" + if [ "$should" = "true" ]; then + if [ "$result" != "success" ]; then + echo "$name: expected success, got $result" + failed=1 + fi + else + if [ "$result" != "skipped" ]; then + echo "$name: expected skipped, got $result" + failed=1 + fi fi - done + } + # Require success when the PR has code-relevant changes + check_job static_analysis "$STATIC_ANALYSIS" "$STATIC_ANALYSIS_SHOULD_RUN" + check_job typecheck "$TYPECHECK" "$TYPECHECK_SHOULD_RUN" + check_job git_compatibility "$GIT_COMPATIBILITY" "$GIT_COMPATIBILITY_SHOULD_RUN" + check_job xterm_patch_sync "$XTERM_PATCH_SYNC" "$XTERM_PATCH_SYNC_SHOULD_RUN" + check_job shell_contracts "$SHELL_CONTRACTS" "$SHELL_CONTRACTS_SHOULD_RUN" + check_job test "$TEST" "$TEST_SHOULD_RUN" + check_job orcad_browser "$ORCAD_BROWSER" "$ORCAD_BROWSER_SHOULD_RUN" + check_job cross-version-wire "$CROSS_VERSION_WIRE" "$CROSS_VERSION_WIRE_SHOULD_RUN" + check_job managed_hook_node18 "$MANAGED_HOOK_NODE18" "$MANAGED_HOOK_NODE18_SHOULD_RUN" + check_job package "$PACKAGE" "$PACKAGE_SHOULD_RUN" + check_job package_windows "$PACKAGE_WINDOWS" "$PACKAGE_WINDOWS_SHOULD_RUN" + exit "$failed" diff --git a/config/scripts/build-windows-cli-launcher.mjs b/config/scripts/build-windows-cli-launcher.mjs index 27c17b3d15c..9d3c9020706 100644 --- a/config/scripts/build-windows-cli-launcher.mjs +++ b/config/scripts/build-windows-cli-launcher.mjs @@ -1,41 +1,24 @@ #!/usr/bin/env node import { spawnSync } from 'node:child_process' -import { existsSync, mkdirSync } from 'node:fs' +import { existsSync, mkdirSync, statSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' -if (process.platform !== 'win32') { - // Why: electron-builder treats a skipped native build like success and can - // continue toward a Windows package whose declared orca.exe does not exist. - throw new Error( - 'Windows CLI launcher compilation requires a Windows host; refusing to package without it.' - ) -} - -const repoRoot = resolve(import.meta.dirname, '../..') -const sourcePath = join(repoRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs') -const outputPath = readArg('--output') ?? defaultOutputPath(repoRoot) -const compilerPath = findFrameworkCompiler(process.env) - -if (!compilerPath) { - throw new Error('Unable to find the .NET Framework C# compiler required for orca.exe.') -} - -mkdirSync(dirname(outputPath), { recursive: true }) -const result = spawnSync( - compilerPath, - ['/nologo', '/target:exe', '/optimize+', '/warnaserror+', `/out:${outputPath}`, sourcePath], - { cwd: repoRoot, stdio: 'inherit' } -) - -if (result.signal) { - process.kill(process.pid, result.signal) -} -if (result.error) { - throw result.error -} -if (result.status !== 0) { - process.exit(result.status ?? 1) +export function shouldReuseCompiledWindowsCliLauncher( + outputPath, + sourcePath, + { reuseCached = false } = {} +) { + if (!existsSync(outputPath)) { + return false + } + // Why reuseCached: Actions cache keys already hash the C# source, but restore + // does not preserve mtimes, so a hit would look stale and recompile anyway. + if (reuseCached) { + return true + } + return statSync(outputPath).mtimeMs >= statSync(sourcePath).mtimeMs } function defaultOutputPath(projectRoot) { @@ -58,3 +41,47 @@ function readArg(name) { const index = process.argv.indexOf(name) return index !== -1 ? process.argv[index + 1] : undefined } + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + if (process.platform !== 'win32') { + // Why: electron-builder treats a skipped native build like success and can + // continue toward a Windows package whose declared orca.exe does not exist. + throw new Error( + 'Windows CLI launcher compilation requires a Windows host; refusing to package without it.' + ) + } + + const repoRoot = resolve(import.meta.dirname, '../..') + const sourcePath = join(repoRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs') + const outputPath = readArg('--output') ?? defaultOutputPath(repoRoot) + const compilerPath = findFrameworkCompiler(process.env) + + if (!compilerPath) { + throw new Error('Unable to find the .NET Framework C# compiler required for orca.exe.') + } + + mkdirSync(dirname(outputPath), { recursive: true }) + if ( + shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath, { + reuseCached: process.env.ORCA_REUSE_WINDOWS_CLI_LAUNCHER === '1' + }) + ) { + console.log(`[native-build] reusing Windows CLI launcher at ${outputPath}`) + process.exit(0) + } + const result = spawnSync( + compilerPath, + ['/nologo', '/target:exe', '/optimize+', '/warnaserror+', `/out:${outputPath}`, sourcePath], + { cwd: repoRoot, stdio: 'inherit' } + ) + + if (result.signal) { + process.kill(process.pid, result.signal) + } + if (result.error) { + throw result.error + } + if (result.status !== 0) { + process.exit(result.status ?? 1) + } +} diff --git a/config/scripts/build-windows-cli-launcher.test.mjs b/config/scripts/build-windows-cli-launcher.test.mjs index 4bb219a5687..82332897715 100644 --- a/config/scripts/build-windows-cli-launcher.test.mjs +++ b/config/scripts/build-windows-cli-launcher.test.mjs @@ -5,12 +5,15 @@ import { mkdtempSync, readFileSync, rmSync, + statSync, + utimesSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { spawnSync } from 'node:child_process' import { describe, expect, it } from 'vitest' +import { shouldReuseCompiledWindowsCliLauncher } from './build-windows-cli-launcher.mjs' const itCrossHost = process.platform === 'win32' ? it.skip : it const projectRoot = resolve(import.meta.dirname, '../..') @@ -37,6 +40,33 @@ function itWindows(name, test) { } describe('Windows CLI launcher', () => { + it('reuses a compiled launcher that is at least as new as the C# source', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-cli-launcher-reuse-')) + try { + const sourcePath = join(root, 'OrcaCliLauncher.cs') + const outputPath = join(root, '.build', 'orca.exe') + mkdirSync(join(root, '.build')) + writeFileSync(sourcePath, 'source\n') + writeFileSync(outputPath, 'binary\n') + const later = new Date(statSync(sourcePath).mtimeMs + 1_000) + utimesSync(outputPath, later, later) + + expect(shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath)).toBe(true) + writeFileSync(sourcePath, 'changed\n') + const sourceLater = new Date(statSync(outputPath).mtimeMs + 1_000) + utimesSync(sourcePath, sourceLater, sourceLater) + expect(shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath)).toBe(false) + expect( + shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath, { reuseCached: true }) + ).toBe(true) + expect(shouldReuseCompiledWindowsCliLauncher(join(root, 'missing.exe'), sourcePath)).toBe( + false + ) + } finally { + removeFixtureTree(root) + } + }) + itCrossHost('fails closed when the Windows launcher cannot be compiled on this host', () => { const outputRoot = mkdtempSync(join(tmpdir(), 'orca cross-host launcher ')) try { diff --git a/config/scripts/computer-e2e-workflow.test.mjs b/config/scripts/computer-e2e-workflow.test.mjs index 11eae65d996..d0489db64cd 100644 --- a/config/scripts/computer-e2e-workflow.test.mjs +++ b/config/scripts/computer-e2e-workflow.test.mjs @@ -76,7 +76,6 @@ describe('computer-use e2e workflow', () => { ) const regressionRun = nativeSmokeRuns.find((run) => run.includes('pnpm vitest run')) const expectedRegressionFiles = [ - 'config/scripts/computer-e2e-workflow.test.mjs', 'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs', 'config/scripts/macos-computer-helper-owner-loss-processes.test.mjs', 'config/scripts/computer-use-modifier-safety.test.mjs', @@ -126,10 +125,14 @@ describe('computer-use e2e workflow', () => { const job = workflow.jobs['mac-native-owner-smoke'] const runs = job.steps.map((step) => step.run).filter((run) => typeof run === 'string') const checkout = job.steps.find((step) => step.uses === 'actions/checkout@v6') + const install = job.steps.find( + (step) => step.uses === './.github/actions/install-node-dependencies' + ) expect(job.if).toBe("github.event_name == 'pull_request'") expect(job['runs-on']).toBe('macos-15') expect(checkout.with['persist-credentials']).toBe(false) + expect(install.with['native-runtime']).toBe('electron') expect(runs).toContain('pnpm bench:macos-computer-helper-owner-loss --expect reaped --trials 1') const cleanupRun = runs.find((run) => run.includes('config/scripts/macos-computer-helper-owner-loss-processes.test.mjs') diff --git a/config/scripts/ensure-native-runtime.mjs b/config/scripts/ensure-native-runtime.mjs index b570ac0e7ae..051ea445413 100644 --- a/config/scripts/ensure-native-runtime.mjs +++ b/config/scripts/ensure-native-runtime.mjs @@ -4,7 +4,7 @@ import { spawnSync } from 'node:child_process' import { createRequire } from 'node:module' import { existsSync, readFileSync } from 'node:fs' import { release } from 'node:os' -import { basename, resolve } from 'node:path' +import { basename, dirname, resolve } from 'node:path' const require = createRequire(import.meta.url) const { assertNodePtyJobOwnership } = require('./node-pty-job-ownership.cjs') @@ -67,7 +67,12 @@ function ensureNodeRuntime() { if (!initial.ok) { printCheckError(initial) } - runPnpm(['rebuild', 'node-pty']) + const failedModules = initial.failures.map((failure) => failure.moduleName) + const rebuildModules = [ + 'node-pty', + ...failedModules.filter((moduleName) => moduleName !== 'node-pty') + ] + rebuildNodeRuntimeModules(rebuildModules) verifyNodeRuntimeAfterRebuild() return } @@ -77,7 +82,7 @@ function ensureNodeRuntime() { `[native-runtime] ${formatRuntimeLabel('node')} cannot load native modules; rebuilding ${failedModules.join(', ')} for Node.` ) printCheckError(initial) - runPnpm(['rebuild', ...failedModules]) + rebuildNodeRuntimeModules(failedModules) verifyNodeRuntimeAfterRebuild() } @@ -312,14 +317,10 @@ function getPatchedNodePtyRebuildReason() { return null } - // Why: a loadable upstream node-pty prebuild is not enough; Orca's Unix - // patch only lands in the source-built build/Release artifacts. + // Why: a loadable upstream node-pty prebuild is not enough; Orca's Unix and + // Windows patches only land in the source-built build/Release artifacts. const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty') - const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')] - // Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node. - if (process.platform === 'darwin') { - artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper')) - } + const artifactPaths = patchedNodePtyArtifactPaths(nodePtyDir) const missingArtifact = artifactPaths.find((artifactPath) => !existsSync(artifactPath)) if (!missingArtifact) { @@ -329,11 +330,24 @@ function getPatchedNodePtyRebuildReason() { return 'Patched node-pty build artifacts are missing; rebuilding native deps.' } -function requiresPatchedNodePtySourceBuild() { +function patchedNodePtyArtifactPaths(nodePtyDir) { if (process.platform === 'win32') { - return false + const releaseDir = resolve(nodePtyDir, 'build', 'Release') + return [ + resolve(releaseDir, 'conpty.node'), + ...NODE_PTY_CONPTY_RUNTIME_FILES.map((filename) => resolve(releaseDir, 'conpty', filename)) + ] } + const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')] + // Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node. + if (process.platform === 'darwin') { + artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper')) + } + return artifactPaths +} + +function requiresPatchedNodePtySourceBuild() { const nodePtyPatchPath = resolve(projectDir, 'config', 'patches', 'node-pty@1.1.0.patch') if (!existsSync(nodePtyPatchPath)) { return false @@ -351,16 +365,28 @@ function getWindowsBuildNumber() { return match && match.length === 4 ? Number.parseInt(match[3], 10) : 0 } -function runPnpm(args) { +function rebuildNodeRuntimeModules(moduleNames) { + for (const moduleName of moduleNames) { + const moduleDir = dirname(require.resolve(`${moduleName}/package.json`)) + console.warn(`[native-runtime] Rebuilding ${moduleName} with node-gyp.`) + runPnpm(['exec', 'node-gyp', 'rebuild'], { cwd: moduleDir }) + if (moduleName === 'node-pty' && process.platform === 'win32') { + runNodeScript([resolve(moduleDir, 'scripts', 'post-install.js')]) + } + } +} + +function runPnpm(args, { cwd = projectDir } = {}) { const command = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' const result = spawnSync(command, args, { - cwd: projectDir, + cwd, stdio: 'inherit', - shell: process.platform === 'win32' + shell: process.platform === 'win32', + env: process.env }) if (result.error || result.status !== 0) { - console.error(`[native-runtime] ${command} ${args.join(' ')} failed.`) + console.error(`[native-runtime] ${command} ${args.join(' ')} failed in ${cwd}.`) if (result.error) { console.error(formatError(result.error)) } diff --git a/config/scripts/ensure-native-runtime.test.mjs b/config/scripts/ensure-native-runtime.test.mjs index d1ea104b863..2086aad2a54 100644 --- a/config/scripts/ensure-native-runtime.test.mjs +++ b/config/scripts/ensure-native-runtime.test.mjs @@ -42,7 +42,8 @@ describe('ensure-native-runtime', () => { expect(result.status, result.stderr).toBe(0) const log = readFileSync(logPath, 'utf8') - expect(log).toContain('pnpm rebuild node-pty\n') + expect(log).toContain('pnpm exec node-gyp rebuild\n') + expect(log).toContain(join('node_modules', 'node-pty')) expect(log.split('\n').filter((line) => line.startsWith('node-pty child '))).toEqual([ expect.stringMatching(/^node-pty child (?:conpty|pty) marker=false$/), expect.stringMatching(/^node-pty child (?:conpty|pty) marker=true$/) @@ -52,6 +53,41 @@ describe('ensure-native-runtime', () => { } }) + it.skipIf(process.platform !== 'win32')( + 'rebuilds other failed Windows addons with patched node-pty', + () => { + const projectDir = mkTempProject() + + try { + const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs') + const logPath = join(projectDir, 'native-runtime.log') + const markerPath = join(projectDir, 'rebuilt.marker') + const binDir = join(projectDir, 'bin') + copyFileSync(sourceScriptPath, scriptPath) + writeFakeNativeModules(projectDir, { windowsRegistryRequiresMarker: true }) + writeNodePtyPatchFile(projectDir) + writeFakePnpm(binDir) + + const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], { + cwd: projectDir, + encoding: 'utf8', + env: envWithPrependedPath(binDir, { + ORCA_NATIVE_TEST_LOG: logPath, + ORCA_NATIVE_TEST_MARKER: markerPath + }) + }) + + expect(result.status, result.stderr).toBe(0) + const log = readFileSync(logPath, 'utf8') + expect(log.match(/pnpm exec node-gyp rebuild\n/g)).toHaveLength(2) + expect(log).toContain(join('node_modules', 'node-pty')) + expect(log).toContain(join('node_modules', 'windows-native-registry')) + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + } + ) + it.skipIf(process.platform === 'win32')( 'rebuilds patched node-pty artifacts even when the Node load check passes', () => { @@ -80,7 +116,7 @@ describe('ensure-native-runtime', () => { expect(result.stderr).toContain( 'Patched node-pty build artifacts are missing; rebuilding native deps.' ) - expect(readFileSync(logPath, 'utf8')).toContain('pnpm rebuild node-pty\n') + expect(readFileSync(logPath, 'utf8')).toContain('pnpm exec node-gyp rebuild\n') } finally { rmSync(projectDir, { recursive: true, force: true }) } @@ -114,7 +150,7 @@ describe('ensure-native-runtime', () => { expect(result.status, result.stderr).toBe(0) expect(result.stderr).toContain("expected build/Release so Orca's node-pty patch is active") - expect(readFileSync(logPath, 'utf8')).toContain('pnpm rebuild node-pty\n') + expect(readFileSync(logPath, 'utf8')).toContain('pnpm exec node-gyp rebuild\n') } finally { rmSync(projectDir, { recursive: true, force: true }) } @@ -148,7 +184,7 @@ describe('ensure-native-runtime', () => { expect(result.status, result.stderr).toBe(0) expect(result.stderr).not.toContain('Patched node-pty build artifacts are missing') - expect(readFileSync(logPath, 'utf8')).not.toContain('pnpm rebuild node-pty') + expect(readFileSync(logPath, 'utf8')).not.toContain('pnpm exec node-gyp rebuild') } finally { rmSync(projectDir, { recursive: true, force: true }) } @@ -178,9 +214,15 @@ function envWithPrependedPath(binDir, extraEnv) { } } -function writeFakeNativeModules(projectDir) { +function writeFakeNativeModules(projectDir, { windowsRegistryRequiresMarker = false } = {}) { const nodePtyDir = join(projectDir, 'node_modules', 'node-pty') mkdirSync(join(nodePtyDir, 'lib'), { recursive: true }) + writeFileSync( + join(nodePtyDir, 'package.json'), + '{"name":"node-pty","version":"1.1.0","main":"index.js"}\n' + ) + mkdirSync(join(nodePtyDir, 'scripts'), { recursive: true }) + writeFileSync(join(nodePtyDir, 'scripts', 'post-install.js'), '') writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n') writeFileSync( @@ -200,12 +242,18 @@ exports.loadNativeModule = function loadNativeModule(nativeName) { } ` ) - writeFakeWindowsRegistry(projectDir) + writeFakeWindowsRegistry(projectDir, { requiresMarker: windowsRegistryRequiresMarker }) } function writeLoadableNativeModules(projectDir, { nativeDir = null } = {}) { const nodePtyDir = join(projectDir, 'node_modules', 'node-pty') mkdirSync(join(nodePtyDir, 'lib'), { recursive: true }) + writeFileSync( + join(nodePtyDir, 'package.json'), + '{"name":"node-pty","version":"1.1.0","main":"index.js"}\n' + ) + mkdirSync(join(nodePtyDir, 'scripts'), { recursive: true }) + writeFileSync(join(nodePtyDir, 'scripts', 'post-install.js'), '') writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n') writeFileSync( @@ -218,23 +266,40 @@ exports.loadNativeModule = function loadNativeModule(nativeName) { const dir = ${JSON.stringify(nativeDir)} ?? (rebuilt ? '../build/Release/' : '../prebuilds/' + process.platform + '-' + process.arch + '/') appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`node-pty load \${nativeName} dir=\${dir}\\n\`) - return { dir, module: {} } + return { + dir, + module: { + listJobProcessIds: () => [], + terminateJob: () => true, + assignCurrentProcessToJob: () => true + } + } } ` ) writeFakeWindowsRegistry(projectDir) } -function writeFakeWindowsRegistry(projectDir) { +function writeFakeWindowsRegistry(projectDir, { requiresMarker = false } = {}) { if (process.platform !== 'win32') { return } const registryDir = join(projectDir, 'node_modules', 'windows-native-registry') mkdirSync(registryDir, { recursive: true }) writeFileSync( - join(registryDir, 'index.js'), - 'exports.HK = { CU: 0x80000001 }; exports.getRegistryKey = () => ({})\n' + join(registryDir, 'package.json'), + '{"name":"windows-native-registry","version":"3.2.2","main":"index.js"}\n' ) + const markerGate = requiresMarker + ? `if (!require('node:fs').existsSync(process.env.ORCA_NATIVE_TEST_MARKER)) { throw new Error('registry ABI mismatch sentinel') }` + : '' + writeFileSync( + join(registryDir, 'index.js'), + `exports.HK = { CU: 0x80000001 }; exports.getRegistryKey = () => { ${markerGate}; return {} }\n` + ) + const processTreeDir = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree') + mkdirSync(processTreeDir, { recursive: true }) + writeFileSync(join(processTreeDir, 'index.js'), 'module.exports = {}\n') } function writeNodePtyPatchFile(projectDir) { @@ -245,6 +310,13 @@ function writeNodePtyPatchFile(projectDir) { function writePatchedNodePtyBuildArtifacts(projectDir) { const buildDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release') mkdirSync(buildDir, { recursive: true }) + if (process.platform === 'win32') { + writeFileSync(join(buildDir, 'conpty.node'), '') + mkdirSync(join(buildDir, 'conpty'), { recursive: true }) + writeFileSync(join(buildDir, 'conpty', 'conpty.dll'), '') + writeFileSync(join(buildDir, 'conpty', 'OpenConsole.exe'), '') + return + } writeFileSync(join(buildDir, 'pty.node'), '') if (process.platform === 'darwin') { writeFileSync(join(buildDir, 'spawn-helper'), '') @@ -260,6 +332,11 @@ function writeFakePnpm(binDir) { const { appendFileSync, writeFileSync } = require('node:fs') appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`pnpm \${process.argv.slice(2).join(' ')}\\n\`) +appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`cwd=\${process.cwd()}\\n\`) +appendFileSync( + process.env.ORCA_NATIVE_TEST_LOG, + \`npm_config_build_from_source=\${process.env.npm_config_build_from_source || ''}\\n\` +) writeFileSync(process.env.ORCA_NATIVE_TEST_MARKER, 'rebuilt') ` ) diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index d19eee52454..8ccc864aabb 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -16,6 +16,183 @@ const DOCS_ONLY_FILES = new Set([ const DOCS_ONLY_PREFIXES = ['docs/', '.github/ISSUE_TEMPLATE/'] +export const PR_CHECK_JOBS = [ + 'static_analysis', + 'typecheck', + 'git_compatibility', + 'xterm_patch_sync', + 'shell_contracts', + 'test', + 'orcad_browser', + 'cross-version-wire', + 'managed_hook_node18', + 'package', + 'package_windows' +] + +const ALWAYS_ON_CODE_JOBS = new Set(['static_analysis', 'typecheck', 'test']) + +const GLOBAL_FORCE_PREFIXES = [ + '.github/workflows/pr.yml', + '.github/actions/install-node-dependencies/', + 'config/scripts/pr-code-change-scope' +] + +const GLOBAL_FORCE_FILES = new Set(['package.json', 'pnpm-lock.yaml']) + +const GIT_COMPAT_PREFIXES = [ + 'src/shared/git-', + 'src/shared/review-head-tracking-ref', + 'src/main/git/', + 'src/relay/git-', + 'config/scripts/git-binary-compatibility' +] + +const XTERM_PREFIXES = [ + 'config/patches/xterm-upstream.json', + 'config/patches/@xterm', + 'config/patches/xterm-src/', + 'config/scripts/regenerate-xterm-patches' +] + +const SHELL_PREFIXES = [ + 'src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec', + 'src/main/daemon/shell-ready', + 'src/main/daemon/daemon-bash-shell-ready', + 'src/main/daemon/daemon-shell-ready-wrapper', + 'src/main/daemon/node-pty-fd-leak', + 'src/main/providers/local-pty-shell-ready', + 'src/main/providers/__tests__/shell-ready-framework-example', + 'src/main/pty/', + 'src/main/shell-templates', + 'src/main/shell-startup-', + 'src/main/shell-wrapper-', + 'src/main/terminal-history-fish', + 'src/main/zsh-', + 'src/renderer/src/components/terminal-pane/fish-color-scheme', + 'src/shared/fish-', + 'src/shared/pty-reply-echo-shapes', + 'src/shared/startup-shell-portability', + 'src/shared/posix-command-path-lookup', + 'config/patches/node-pty@', + 'config/scripts/ensure-native-runtime', + 'config/scripts/node-pty-job-ownership' +] + +const ORCAD_BROWSER_PREFIXES = [ + 'src/main/orcad/external-chromium-', + 'src/main/orcad/orcad-browser-provider', + 'src/main/orcad/orcad-agent-browser-binary', + 'src/main/orcad/electron-serve-browser-process' +] + +const CROSS_VERSION_WIRE_PREFIXES = [ + 'tests/e2e/cross-version-wire/', + 'src/shared/protocol-version', + 'src/shared/terminal-stream-protocol', + 'src/shared/browser-client-host-protocol', + 'src/shared/browser-network-tunnel-protocol', + 'src/shared/browser-client-host-placement', + 'src/main/runtime/rpc/dispatcher', + 'src/main/runtime/rpc/methods/browser-tab-create-schema', + 'src/main/runtime/rpc/methods/terminal', + 'src/renderer/src/runtime/remote-runtime-terminal-multiplexer' +] + +const MANAGED_HOOK_PREFIXES = [ + 'config/scripts/smoke-managed-hook-runtime-node18', + 'config/scripts/build-relay', + 'src/relay/', + 'src/shared/agent-hook', + 'src/main/agent-hooks/' +] + +const NATIVE_RUNTIME_PREFIXES = [ + 'config/scripts/ensure-native-runtime', + 'config/scripts/rebuild-native-deps', + 'config/scripts/node-pty-job-ownership', + 'config/scripts/electron-builder-native-rebuild', + 'config/patches/node-pty@', + 'config/patches/@vscode__windows-process-tree' +] + +const SHARED_PACKAGE_PREFIXES = [ + 'electron.vite.config.ts', + 'config/electron-builder', + 'config/packaged-runtime', + 'config/build-plugins/', + 'config/scripts/build-', + 'config/scripts/smoke-packaged', + 'config/scripts/install-electron-package-binary', + 'config/scripts/verify-packaged', + 'config/scripts/verify-linux-glibc', + 'config/scripts/run-electron-vite', + 'skills/', + 'skill-guides/', + 'resources/build/', + 'resources/onboarding/', + 'resources/plugins/', + 'resources/skills/', + ...NATIVE_RUNTIME_PREFIXES +] + +const LINUX_PACKAGE_PREFIXES = [ + ...SHARED_PACKAGE_PREFIXES, + 'native/computer-use-linux/', + 'resources/linux/', + 'config/scripts/run-headless-serve' +] + +const WINDOWS_PACKAGE_PREFIXES = [ + ...SHARED_PACKAGE_PREFIXES, + 'native/windows-cli-launcher/', + 'native/computer-use-windows/', + 'resources/win32/', + 'config/scripts/build-windows-cli-launcher', + 'config/scripts/windows-pty-native-capability', + 'tests/tools/windows-pty-native-capability-smoke/' +] + +const LINUX_PACKAGE_TESTS = [ + 'src/main/browser/browser-client-page-renderer-lifecycle.electron.test.ts', + 'src/main/browser/browser-route-tcp-egress.electron.test.ts', + 'src/main/browser/browser-route-webrtc-egress.electron.test.ts', + 'src/main/browser/browser-route-h3-egress.electron.test.ts', + 'src/main/browser/browser-route-dns-prefetch.electron.test.ts' +] + +const WINDOWS_PACKAGE_TESTS = [ + ...LINUX_PACKAGE_TESTS, + 'config/scripts/rebuild-native-deps.test.mjs', + 'src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts', + 'src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts', + 'src/shared/child-process/windows-command-line.win32.test.ts', + 'src/main/agent-hooks/windows-hook-payload-delivery.test.ts', + 'src/main/windows/windows-pty-job.win32.test.ts', + 'src/main/windows/windows-host-job.win32.test.ts', + 'src/main/wsl/wsl-runner.test.ts', + 'src/main/wsl/wsl-guest-environment.test.ts', + 'src/main/wsl/wsl-invocation-boundary.test.ts', + 'src/main/wsl/wsl-executable-path.win32.test.ts', + 'src/main/wsl/wsl-w1-w3-contract.test.ts', + 'src/shared/source-scan/source-tree-scan.test.ts', + 'src/main/cli/wsl-cli-powershell-boundary.test.ts', + 'src/main/cursor/hook-service.test.ts', + 'src/main/orca-profiles/profile-index-store.test.ts', + 'src/main/runtime/repo-worktree-admin-fingerprint.test.ts', + 'src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts', + 'src/shared/secure-file-fsync-flags.test.ts', + 'src/main/ipc/pty-codex-account-attribution.test.ts', + 'src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts' +] + +const DESKTOP_IRRELEVANT_PREFIXES = [ + 'mobile/', + '.github/workflows/mobile.yml', + '.github/workflows/mobile-ios-release.yml', + '.github/workflows/mobile-android-release.yml' +] + export function isDocsOnlyPath(file) { if (DOCS_ONLY_FILES.has(file)) { return true @@ -32,10 +209,83 @@ export function shouldRunPrChecks(changedFiles) { if (changedFiles.length === 0) { return true } - return changedFiles.some((file) => !isDocsOnlyPath(file)) + return changedFiles.some((file) => !isDocsOnlyPath(file) && !isDesktopIrrelevantPath(file)) +} + +export function classifyPrJobs(changedFiles) { + const emptyDiff = changedFiles.length === 0 + const shouldRun = shouldRunPrChecks(changedFiles) + const forceAll = emptyDiff || changedFiles.some(isGlobalForcePath) + const jobs = Object.fromEntries( + PR_CHECK_JOBS.map((job) => [ + job, + shouldRun && (forceAll || ALWAYS_ON_CODE_JOBS.has(job) || jobDetector(job)(changedFiles)) + ]) + ) + return { should_run: shouldRun, ...jobs } +} + +function jobDetector(job) { + switch (job) { + case 'git_compatibility': + return (files) => files.some((file) => matchesPrefix(file, GIT_COMPAT_PREFIXES)) + case 'xterm_patch_sync': + return (files) => files.some((file) => matchesPrefix(file, XTERM_PREFIXES)) + case 'shell_contracts': + return (files) => files.some((file) => matchesPrefix(file, SHELL_PREFIXES)) + case 'orcad_browser': + return (files) => files.some((file) => matchesPrefix(file, ORCAD_BROWSER_PREFIXES)) + case 'cross-version-wire': + return (files) => files.some((file) => matchesPrefix(file, CROSS_VERSION_WIRE_PREFIXES)) + case 'managed_hook_node18': + return (files) => files.some((file) => matchesPrefix(file, MANAGED_HOOK_PREFIXES)) + case 'package': + return (files) => files.some(isLinuxPackagePath) + case 'package_windows': + return (files) => files.some(isWindowsPackagePath) + default: + return () => true + } +} + +function isLinuxPackagePath(file) { + return LINUX_PACKAGE_TESTS.includes(file) || isProductBundlePath(file, LINUX_PACKAGE_PREFIXES) +} + +function isWindowsPackagePath(file) { + return WINDOWS_PACKAGE_TESTS.includes(file) || isProductBundlePath(file, WINDOWS_PACKAGE_PREFIXES) +} + +function isProductBundlePath(file, extraPrefixes) { + if (isTestFile(file)) { + return false + } + if (file.startsWith('src/')) { + return true + } + return matchesPrefix(file, extraPrefixes) +} + +function isTestFile(file) { + return /\.(?:test|spec)\.(?:js|cjs|mjs|ts|tsx)$/.test(file) || file.includes('/__tests__/') +} + +function isDesktopIrrelevantPath(file) { + return matchesPrefix(file, DESKTOP_IRRELEVANT_PREFIXES) +} + +function isGlobalForcePath(file) { + return GLOBAL_FORCE_FILES.has(file) || matchesPrefix(file, GLOBAL_FORCE_PREFIXES) +} + +function matchesPrefix(file, prefixes) { + return prefixes.some((prefix) => file === prefix || file.startsWith(prefix)) } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { const files = readFileSync(0, 'utf8').split('\n').filter(Boolean) - process.stdout.write(shouldRunPrChecks(files) ? 'true\n' : 'false\n') + const classification = classifyPrJobs(files) + for (const [name, value] of Object.entries(classification)) { + process.stdout.write(`${name}=${value ? 'true' : 'false'}\n`) + } } diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index f71d6c42934..0ce697d1511 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -1,13 +1,18 @@ +import { spawnSync } from 'node:child_process' import { readFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { describe, expect, it } from 'vitest' import { parse } from 'yaml' -import { isDocsOnlyPath, shouldRunPrChecks } from './pr-code-change-scope.mjs' +import { + classifyPrJobs, + isDocsOnlyPath, + PR_CHECK_JOBS, + shouldRunPrChecks +} from './pr-code-change-scope.mjs' const projectDir = resolve(import.meta.dirname, '../..') const prWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')) -const gatedIf = "needs.code_paths.outputs.should_run == 'true'" const expensiveJobs = [ 'static_analysis', 'typecheck', @@ -15,12 +20,34 @@ const expensiveJobs = [ 'xterm_patch_sync', 'shell_contracts', 'test', + 'orcad_browser', 'cross-version-wire', 'managed_hook_node18', 'package', 'package_windows' ] +const ALWAYS_ON = ['static_analysis', 'typecheck', 'test'] + +function expectedJobs(overrides, { alwaysOn = true } = {}) { + return Object.fromEntries( + PR_CHECK_JOBS.map((job) => [ + job, + (alwaysOn && ALWAYS_ON.includes(job)) || Boolean(overrides[job]) + ]) + ) +} + +function expectClassification(files, overrides) { + const result = classifyPrJobs(files) + const shouldRun = shouldRunPrChecks(files) + expect(result.should_run).toBe(shouldRun) + expect(result).toMatchObject({ + should_run: shouldRun, + ...expectedJobs(overrides, { alwaysOn: shouldRun }) + }) +} + describe('docs-only path classification', () => { it('treats the WeChat README PR files as docs-only', () => { expect( @@ -54,9 +81,154 @@ describe('docs-only path classification', () => { it('runs PR Checks when the diff is empty rather than skipping by accident', () => { expect(shouldRunPrChecks([])).toBe(true) }) + + it('does not start desktop PR Checks for mobile-only diffs', () => { + expect(shouldRunPrChecks(['mobile/src/App.tsx', 'mobile/package.json'])).toBe(false) + }) }) -describe('PR Checks docs-only skip wiring', () => { +describe('per-job path classification', () => { + it('runs every expensive job on an empty diff rather than skipping by accident', () => { + const result = classifyPrJobs([]) + expect(result.should_run).toBe(true) + for (const job of PR_CHECK_JOBS) { + expect(result[job], job).toBe(true) + } + }) + + it('skips every expensive job for docs-only diffs', () => { + expectClassification(['README.md', 'docs/readme/README.zh-CN.md'], {}) + }) + + it('runs packaging and always-on jobs for product source, not git/xterm/shell lanes', () => { + expectClassification(['src/renderer/src/components/tab-bar/TabBar.tsx'], { + package: true, + package_windows: true + }) + }) + + it('runs Git compatibility only when git capability inputs change', () => { + expectClassification(['src/shared/git-capability-cache.ts'], { + git_compatibility: true, + package: true, + package_windows: true + }) + expectClassification(['src/shared/git-binary-compatibility.test.ts'], { + git_compatibility: true + }) + }) + + it('runs xterm patch sync only when xterm inputs change', () => { + expectClassification(['config/patches/xterm-upstream.json'], { + xterm_patch_sync: true + }) + expectClassification(['config/patches/@xterm__xterm@6.1.0-beta.287.patch'], { + xterm_patch_sync: true + }) + }) + + it('runs native package jobs only for the platform that ships the changed native', () => { + expectClassification(['native/windows-cli-launcher/OrcaCliLauncher.cs'], { + package_windows: true + }) + expectClassification(['native/computer-use-linux/runtime.py'], { + package: true + }) + expectClassification(['native/computer-use-macos/Package.swift'], {}) + }) + + it('runs shell contracts when live-shell inputs change', () => { + expectClassification(['src/main/daemon/shell-ready.ts'], { + shell_contracts: true, + package: true, + package_windows: true + }) + }) + + it('runs shell contracts when wrapper templates or live-shell fixtures change', () => { + expectClassification(['src/main/shell-templates.ts'], { + shell_contracts: true, + package: true, + package_windows: true + }) + expectClassification(['src/main/shell-startup-launch-intent-fixtures.ts'], { + shell_contracts: true, + package: true, + package_windows: true + }) + }) + + it('runs orcad browser when Chrome launch, session, or tab modules change', () => { + for (const file of [ + 'src/main/orcad/external-chromium-browser-session.ts', + 'src/main/orcad/external-chromium-command-arguments.ts', + 'src/main/orcad/external-chromium-tab-registry.ts', + 'src/main/orcad/external-chromium-tab-projection.ts' + ]) { + expectClassification([file], { + orcad_browser: true, + package: true, + package_windows: true + }) + } + expectClassification(['src/main/orcad/orcad-native-preflight.ts'], { + package: true, + package_windows: true + }) + }) + + it('runs cross-version wire checks for every working-tree wire module', () => { + for (const file of [ + 'src/shared/protocol-version.ts', + 'src/shared/terminal-stream-protocol.ts', + 'src/main/runtime/rpc/dispatcher.ts', + 'src/main/runtime/rpc/methods/browser-tab-create-schema.ts', + 'src/main/runtime/rpc/methods/terminal.ts', + 'src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts' + ]) { + expectClassification([file], { + 'cross-version-wire': true, + package: true, + package_windows: true + }) + } + expectClassification( + ['tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts'], + { 'cross-version-wire': true } + ) + }) + + it('runs workflow-self-change and lockfile diffs as force-all', () => { + const result = classifyPrJobs(['.github/workflows/pr.yml']) + expect(result.should_run).toBe(true) + for (const job of PR_CHECK_JOBS) { + expect(result[job], job).toBe(true) + } + expect(classifyPrJobs(['pnpm-lock.yaml']).git_compatibility).toBe(true) + }) + + it('keeps unit-test-only diffs out of packaging', () => { + expectClassification(['src/main/git/git-status.test.ts'], { + git_compatibility: true + }) + }) + + it('emits GitHub output pairs from the shipped CLI', () => { + const result = spawnSync(process.execPath, ['config/scripts/pr-code-change-scope.mjs'], { + cwd: projectDir, + encoding: 'utf8', + input: 'config/patches/xterm-upstream.json\n' + }) + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toContain('should_run=true\n') + expect(result.stdout).toContain('xterm_patch_sync=true\n') + expect(result.stdout).toContain('git_compatibility=false\n') + expect(result.stdout).toContain('package=false\n') + expect(result.stdout).toContain('test=true\n') + }) +}) + +describe('PR Checks skip wiring', () => { it('classifies the PR range with a tested script and expands renames', () => { const classify = prWorkflow.jobs.code_paths.steps.find( (step) => step.name === 'Classify changed paths' @@ -65,9 +237,12 @@ describe('PR Checks docs-only skip wiring', () => { expect(classify.run).toContain('--no-renames') expect(classify.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"') expect(classify.run).toContain('node config/scripts/pr-code-change-scope.mjs') - expect(prWorkflow.jobs.code_paths.outputs.should_run).toBe( - '${{ steps.filter.outputs.should_run }}' - ) + expect(classify.run).toContain('tee -a "$GITHUB_OUTPUT"') + for (const jobName of ['should_run', ...expensiveJobs]) { + expect(prWorkflow.jobs.code_paths.outputs[jobName], jobName).toBe( + `\${{ steps.filter.outputs.${jobName} }}` + ) + } }) it('keeps the cheap root-directory guard on docs-only PRs', () => { @@ -75,10 +250,12 @@ describe('PR Checks docs-only skip wiring', () => { expect(prWorkflow.jobs.root_directory_guard.needs).toBeUndefined() }) - it('skips expensive jobs unless the detector says the PR has code', () => { + it('gates each expensive job on its own classifier output', () => { for (const jobName of expensiveJobs) { expect(prWorkflow.jobs[jobName].needs, jobName).toEqual(['code_paths']) - expect(prWorkflow.jobs[jobName].if, jobName).toBe(gatedIf) + expect(prWorkflow.jobs[jobName].if, jobName).toBe( + `needs.code_paths.outputs.${jobName} == 'true'` + ) } }) @@ -89,19 +266,23 @@ describe('PR Checks docs-only skip wiring', () => { ) }) - it('lets verify pass when expensive jobs are skipped for docs-only PRs', () => { + it('lets verify pass skipped jobs the classifier turned off', () => { const verifyStep = prWorkflow.jobs.verify.steps.find( (step) => step.name === 'Require successful checks' ) expect(prWorkflow.jobs.verify.needs[0]).toBe('code_paths') expect(verifyStep.env.SHOULD_RUN).toBe('${{ needs.code_paths.outputs.should_run }}') - expect(verifyStep.run).toContain('"$SHOULD_RUN" != "true"') - const docsOnlyBranch = verifyStep.run.slice( - 0, - verifyStep.run.indexOf('# Require success when the PR has code-relevant changes') - ) - expect(docsOnlyBranch).toContain('if [ "$result" != "skipped" ]') - expect(docsOnlyBranch).not.toContain('"$result" != "success"') expect(verifyStep.run).toContain('"$ROOT_DIRECTORY_GUARD" != "success"') + expect(verifyStep.run).toContain('# Require success when the PR has code-relevant changes') + expect(verifyStep.run).toContain('expected skipped') + expect(verifyStep.run).toContain('expected success') + for (const job of prWorkflow.jobs.verify.needs) { + if (job === 'code_paths' || job === 'root_directory_guard') { + continue + } + const envVar = `${job.replaceAll('-', '_').toUpperCase()}_SHOULD_RUN` + expect(verifyStep.env[envVar]).toBe(`\${{ needs.code_paths.outputs.${job} }}`) + expect(verifyStep.run).toContain(`"$${envVar}"`) + } }) }) diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs index 2c1243d04fa..6aec44fca87 100644 --- a/config/scripts/pr-e2e-gate-contract.test.mjs +++ b/config/scripts/pr-e2e-gate-contract.test.mjs @@ -100,17 +100,19 @@ describe('PR E2E gate contract', () => { // job without adding it to the strict loop fails here instead of silently // leaving that job unenforced. This is what caught GIT_COMPATIBILITY and // SHELL_CONTRACTS being absent from an earlier hardcoded list. - // Why lastIndexOf: the docs-only branch has its own loop that allows skipped. const successMarker = '# Require success when the PR has code-relevant changes' - const successLoop = verifyStep.run.slice( - verifyStep.run.indexOf(successMarker), - verifyStep.run.lastIndexOf('done') - ) + const successLoop = verifyStep.run.slice(verifyStep.run.indexOf(successMarker)) expect(successLoop.length).toBeGreaterThan(0) + expect(verifyStep.run).toContain('"$CODE_PATHS" != "success"') + expect(verifyStep.run).toContain('"$ROOT_DIRECTORY_GUARD" != "success"') for (const job of prWorkflow.jobs.verify.needs) { - const envVar = job.toUpperCase() + const envVar = job.replaceAll('-', '_').toUpperCase() expect(verifyStep.env[envVar]).toBe(`\${{ needs.${job}.result }}`) + if (job === 'code_paths' || job === 'root_directory_guard') { + continue + } expect(successLoop).toContain(`"$${envVar}"`) + expect(verifyStep.env[`${envVar}_SHOULD_RUN`]).toBe(`\${{ needs.code_paths.outputs.${job} }}`) } }) @@ -157,6 +159,18 @@ describe('PR E2E gate contract', () => { expect(sshDockerRunner).toContain("'electron-headful'") }) + it('reuses the composite install action instead of duplicating pnpm setup', () => { + const installFor = (jobName) => + e2eWorkflow.jobs[jobName].steps.find( + (step) => step.uses === './.github/actions/install-node-dependencies' + ) + + expect(installFor('build').with).toBeUndefined() + for (const jobName of ['e2e', 'changed-e2e', 'ssh-docker-watcher-isolation']) { + expect(installFor(jobName).with['native-runtime'], jobName).toBe('electron') + } + }) + it('installs zsh in every Linux lane that can run paired startup readiness', () => { for (const jobName of ['e2e', 'changed-e2e', 'ssh-docker-watcher-isolation']) { const installStep = e2eWorkflow.jobs[jobName].steps.find((step) => diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 42540f6bcb4..a61244d42d3 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -50,9 +50,9 @@ describe('PR workflow parallelism', () => { it('shards the general test suite across Node 24 and Node 26', () => { expect(workflow.jobs.test.strategy.matrix.node).toEqual(['24', '26']) expect(workflow.jobs.test.strategy.matrix.shard).toEqual( - Array.from({ length: 16 }, (_, index) => index + 1) + Array.from({ length: 8 }, (_, index) => index + 1) ) - expect(workflow.jobs.test.strategy.matrix.shard_total).toEqual([16]) + expect(workflow.jobs.test.strategy.matrix.shard_total).toEqual([8]) const testStep = workflow.jobs.test.steps.find((step) => step.name === 'Test shard') const installStep = workflow.jobs.test.steps.find( (step) => step.uses === './.github/actions/install-node-dependencies' @@ -233,21 +233,20 @@ describe('PR workflow parallelism', () => { (step) => step.uses === './.github/actions/install-node-dependencies' ) - for (const jobName of [ - 'static_analysis', - 'typecheck', - 'git_compatibility', - 'xterm_patch_sync' - ]) { + for (const jobName of ['typecheck', 'git_compatibility', 'xterm_patch_sync']) { expect(installFor(jobName).with, jobName).toBeUndefined() } + expect(installFor('static_analysis').with['native-runtime']).toBe('node') expect(installFor('shell_contracts').with['native-runtime']).toBe('node') expect(installFor('test').with['native-runtime']).toBe('node') expect(installFor('package').with['native-runtime']).toBe('electron') + expect(installFor('package_windows').with['native-runtime']).toBe('node') + expect(installFor('package_windows').with['persist-native-cache']).toBe('false') + expect(dependencyAction.inputs['persist-native-cache'].default).toBe('true') expect( dependencyAction.runs.steps.find((step) => step.name === 'Use external node-gyp').if - ).toBe("inputs.native-runtime != 'none'") + ).toBe("runner.os == 'Linux' && inputs.native-runtime != 'none'") const dependencyInstall = dependencyAction.runs.steps.find( (step) => step.name === 'Install dependencies' ) @@ -295,14 +294,39 @@ describe('PR workflow parallelism', () => { // overwritten and one after the rebuild would never save a hit. expect(installIndex).toBeLessThan(cacheIndex) expect(cacheIndex).toBeLessThan(prepareIndex) - expect(steps[cacheIndex].if).toBe("inputs.native-runtime != 'none'") + expect(steps[cacheIndex].if).toBe( + "inputs.native-runtime != 'none' && inputs.persist-native-cache != 'false'" + ) + const restoreOnly = steps.find( + (step) => step.name === 'Restore compiled native modules without saving' + ) + expect(restoreOnly.if).toBe( + "inputs.native-runtime != 'none' && inputs.persist-native-cache == 'false'" + ) + expect(restoreOnly.uses).toBe('actions/cache/restore@v5') // Native artifacts are ABI-bound: a key missing either dimension serves a build // that cannot load, and ensure-native-runtime would recompile it anyway. - expect(steps[cacheIndex].with.key).toContain('${{ inputs.native-runtime }}') - expect(steps[cacheIndex].with.key).toContain('steps.requested-node.outputs.node-version') - expect(steps[cacheIndex].with.key).toContain('config/patches/node-pty@1.1.0.patch') - // No restore-keys: a partial-match key is exactly the ABI-mismatched build above. - expect(steps[cacheIndex].with['restore-keys']).toBeUndefined() + for (const cacheStep of [steps[cacheIndex], restoreOnly]) { + expect(cacheStep.with.key).toContain('${{ inputs.native-runtime }}') + expect(cacheStep.with.key).toContain('${{ runner.os }}') + expect(cacheStep.with.key).toContain('${{ runner.arch }}') + expect(cacheStep.with.key).toContain('steps.requested-node.outputs.node-version') + expect(cacheStep.with.key).toContain('steps.native-cache-scope.outputs.scope') + expect(cacheStep.with.key).toContain('config/patches/node-pty@1.1.0.patch') + expect(cacheStep.with.key).toContain( + 'config/patches/@vscode__windows-process-tree@0.8.0.patch' + ) + expect(cacheStep.with.path).toContain('node-pty@*/node_modules/node-pty/build') + expect(cacheStep.with.path).toContain('windows-native-registry@') + expect(cacheStep.with.path).toContain('@vscode+windows-process-tree@') + expect(cacheStep.with['restore-keys']).toBeUndefined() + } + const cacheScope = steps.find((step) => step.name === 'Resolve native cache scope') + expect(cacheScope.if).toBe("inputs.native-runtime != 'none'") + expect(cacheScope.run).toContain('/etc/os-release') + expect(dependencyAction.outputs['native-cache-scope'].value).toBe( + '${{ steps.native-cache-scope.outputs.scope }}' + ) }) it('reuses TypeScript incremental state across typecheck runs', () => { @@ -346,6 +370,7 @@ describe('PR workflow parallelism', () => { 'shell_contracts', 'test', 'orcad_browser', + 'cross-version-wire', 'managed_hook_node18', 'package', 'package_windows' @@ -359,5 +384,7 @@ describe('PR workflow parallelism', () => { // ORCA_BROWSER_EXECUTABLE, so it only guards anything if verify actually reads it. expect(verifyStep.env.ORCAD_BROWSER).toBe('${{ needs.orcad_browser.result }}') expect(verifyStep.run).toContain('"$ORCAD_BROWSER"') + expect(verifyStep.env.CROSS_VERSION_WIRE).toBe('${{ needs.cross-version-wire.result }}') + expect(verifyStep.run).toContain('"$CROSS_VERSION_WIRE"') }) }) diff --git a/config/scripts/rebuild-native-deps.mjs b/config/scripts/rebuild-native-deps.mjs index 78e25d3634c..4eca4bac33a 100644 --- a/config/scripts/rebuild-native-deps.mjs +++ b/config/scripts/rebuild-native-deps.mjs @@ -401,14 +401,23 @@ function getPatchedNodePtyRebuildReason() { return null } - // Why: Orca patches node-pty's native Unix spawn path; upstream prebuilds can - // load successfully in Electron while missing the patched fd/error handling. + // Why: Orca patches node-pty's native Unix spawn path and Windows job-object + // exports; upstream prebuilds can load while missing those patches. const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty') - const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')] - // Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node. - if (process.platform === 'darwin') { - artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper')) - } + const artifactPaths = + rebuildPlatform === 'win32' + ? [ + resolve(nodePtyDir, 'build', 'Release', 'conpty.node'), + ...NODE_PTY_CONPTY_RUNTIME_FILES.map((filename) => + resolve(nodePtyDir, 'build', 'Release', 'conpty', filename) + ) + ] + : [ + resolve(nodePtyDir, 'build', 'Release', 'pty.node'), + ...(osPlatform() === 'darwin' + ? [resolve(nodePtyDir, 'build', 'Release', 'spawn-helper')] + : []) + ] const missingArtifact = artifactPaths.find((artifactPath) => !existsSync(artifactPath)) if (!missingArtifact) { @@ -422,9 +431,6 @@ function requiresPatchedNodePtySourceBuild() { if (!onlyModules.includes('node-pty')) { return false } - if (rebuildPlatform === 'win32') { - return false - } if (rebuildPlatform !== osPlatform() || rebuildArch !== process.arch) { return false } diff --git a/config/scripts/rebuild-native-deps.test.mjs b/config/scripts/rebuild-native-deps.test.mjs index b158ee57f21..5ebc39dfcaa 100644 --- a/config/scripts/rebuild-native-deps.test.mjs +++ b/config/scripts/rebuild-native-deps.test.mjs @@ -586,6 +586,13 @@ function writeNodePtyPatchFile(projectDir) { function writePatchedNodePtyBuildArtifacts(projectDir) { const buildDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release') mkdirSync(buildDir, { recursive: true }) + if (process.platform === 'win32') { + writeFileSync(join(buildDir, 'conpty.node'), '') + mkdirSync(join(buildDir, 'conpty'), { recursive: true }) + writeFileSync(join(buildDir, 'conpty', 'conpty.dll'), '') + writeFileSync(join(buildDir, 'conpty', 'OpenConsole.exe'), '') + return + } writeFileSync(join(buildDir, 'pty.node'), '') if (process.platform === 'darwin') { writeFileSync(join(buildDir, 'spawn-helper'), '') diff --git a/config/scripts/skills-cli-package-workflow.test.mjs b/config/scripts/skills-cli-package-workflow.test.mjs index 489437dd61f..07f7beb46ac 100644 --- a/config/scripts/skills-cli-package-workflow.test.mjs +++ b/config/scripts/skills-cli-package-workflow.test.mjs @@ -14,6 +14,7 @@ describe('packaged skills CLI PR gates', () => { expect(job['runs-on']).toBe('windows-2022') expect(buildStep.run).toBe('pnpm run build:release:parallel') + expect(buildStep.env.ORCA_REUSE_WINDOWS_CLI_LAUNCHER).toBe('1') expect(prepareStep.run).toBe('node config/scripts/ensure-native-runtime.mjs --runtime=electron') expect(packageStep.run).toContain('electron-builder') expect(packageStep.run).toContain('--dir') diff --git a/config/scripts/windows-pty-native-capability-workflow.test.mjs b/config/scripts/windows-pty-native-capability-workflow.test.mjs index 9a6b8b285cf..8d4b7d568fd 100644 --- a/config/scripts/windows-pty-native-capability-workflow.test.mjs +++ b/config/scripts/windows-pty-native-capability-workflow.test.mjs @@ -28,8 +28,14 @@ describe('packaged Windows PTY native capability routing', () => { it('keeps patched source rebuild, release build, runtime reuse, and aggregate routing intact', () => { const job = workflow.jobs.package_windows - const sourceRebuild = job.steps.find( - (step) => step.name === 'Rebuild node-pty from patched source' + const install = job.steps.find( + (step) => step.uses === './.github/actions/install-node-dependencies' + ) + const nodeCacheSave = job.steps.find( + (step) => step.name === 'Save compiled Node native modules' + ) + const electronCache = job.steps.find( + (step) => step.name === 'Restore compiled Electron native modules' ) const build = job.steps.find((step) => step.name === 'Build package inputs') const prepare = job.steps.find((step) => step.name === 'Prepare Electron native runtime') @@ -37,10 +43,17 @@ describe('packaged Windows PTY native capability routing', () => { const verify = workflow.jobs.verify.steps.find( (step) => step.name === 'Require successful checks' ) + const ensureNativeRuntime = readFileSync('config/scripts/ensure-native-runtime.mjs', 'utf8') - expect(sourceRebuild.env.npm_config_build_from_source).toBe('true') - expect(sourceRebuild.run).toBe('pnpm rebuild node-pty') + expect(install.with['native-runtime']).toBe('node') + expect(install.with['persist-native-cache']).toBe('false') + expect(nodeCacheSave.uses).toBe('actions/cache/save@v5') + expect(nodeCacheSave.with.key).toContain('-node-node') + expect(electronCache.with.key).toContain('-electron-node') + expect(ensureNativeRuntime).toContain("runPnpm(['exec', 'node-gyp', 'rebuild']") + expect(ensureNativeRuntime).toContain("resolve(moduleDir, 'scripts', 'post-install.js')") expect(build.run).toBe('pnpm run build:release:parallel') + expect(build.env.ORCA_REUSE_WINDOWS_CLI_LAUNCHER).toBe('1') expect(prepare.run).toBe('node config/scripts/ensure-native-runtime.mjs --runtime=electron') expect(packageStep.env.ORCA_REUSE_PREPARED_NATIVE_RUNTIME).toBe('1') expect(workflow.jobs.verify.needs).toContain('package_windows') diff --git a/docs/reference/windows-process-enumeration.md b/docs/reference/windows-process-enumeration.md index e4c91329c46..11e39b35b7e 100644 --- a/docs/reference/windows-process-enumeration.md +++ b/docs/reference/windows-process-enumeration.md @@ -326,8 +326,6 @@ the Windows CI job rebuilds from source before running the win32 suites. it is true before asserting anything else, so an unpatched binary fails loudly instead of passing every case vacuously. That guard is what caught this. -`requiresPatchedNodePtySourceBuild()` in `ensure-native-runtime.mjs` still -exempts win32, on the premise that the patch is Unix-only. That premise is now -false, but lifting the exemption also needs `pnpm rebuild` to force a source -build โ€” otherwise the assertion fires and the remedy does not fix it. Left as a -follow-up rather than changed blind. +`requiresPatchedNodePtySourceBuild()` in `ensure-native-runtime.mjs` now covers +win32 as well, and `pnpm rebuild node-pty` sets `npm_config_build_from_source` +so the patched source build actually replaces the upstream prebuild. diff --git a/src/main/daemon/pty-subprocess-foreground-identity.test.ts b/src/main/daemon/pty-subprocess-foreground-identity.test.ts index ff739e32d0c..76793cdbab2 100644 --- a/src/main/daemon/pty-subprocess-foreground-identity.test.ts +++ b/src/main/daemon/pty-subprocess-foreground-identity.test.ts @@ -50,6 +50,7 @@ vi.mock('../providers/local-pty-utils', async (importOriginal) => { const actual = await importOriginal() return { ...actual, + getNodePtySpawnHelperCandidates: () => [import.meta.filename], resolveUnixShellPath: resolveUnixShellPathMock, validateWorkingDirectory: validateWorkingDirectoryMock, validateWorkingDirectoryAsync: validateWorkingDirectoryMock diff --git a/src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts b/src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts index 7d23ffc0a49..47513702b7a 100644 --- a/src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts +++ b/src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts @@ -10,6 +10,7 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import type * as LocalPtyUtils from '../providers/local-pty-utils' const { spawnMock, isPwshAvailableMock, resolveAgentForegroundProcessMock } = vi.hoisted(() => ({ spawnMock: vi.fn(), isPwshAvailableMock: vi.fn(), @@ -39,6 +40,14 @@ vi.mock('../providers/windows-powershell-executable', () => ({ getWindowsCmdPath: () => CMD_ABS })) +vi.mock('../providers/local-pty-utils', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getNodePtySpawnHelperCandidates: () => [import.meta.filename] + } +}) + vi.mock('../providers/agent-foreground-process', () => ({ resolveAgentForegroundProcessWithAvailability: async (...args: unknown[]) => { const value = await resolveAgentForegroundProcessMock(...args) diff --git a/src/main/daemon/pty-subprocess-handle-lifecycle.test.ts b/src/main/daemon/pty-subprocess-handle-lifecycle.test.ts index 6467d020821..6ab32840010 100644 --- a/src/main/daemon/pty-subprocess-handle-lifecycle.test.ts +++ b/src/main/daemon/pty-subprocess-handle-lifecycle.test.ts @@ -50,6 +50,7 @@ vi.mock('../providers/local-pty-utils', async (importOriginal) => { const actual = await importOriginal() return { ...actual, + getNodePtySpawnHelperCandidates: () => [import.meta.filename], resolveUnixShellPath: resolveUnixShellPathMock, validateWorkingDirectory: validateWorkingDirectoryMock, validateWorkingDirectoryAsync: validateWorkingDirectoryMock From bb77a2b1595ebbb65de29c73b98d68feba2dd71e Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:09:43 -0700 Subject: [PATCH 13/29] Add coverage for skill lock release and fix WebRTC test flakiness (#16846) * test: add coverage for skill lock release and simplify WebRTC test - Add test for cleanupReleasedSkillInstallLock handling rmdir races - Improve error handling to cover all documented directory removal error codes - Simplify WebRTC egress test to use localhost addresses consistently * test: use network interface address for WebRTC egress probe - Discover the first non-internal IPv4 address instead of hardcoding localhost, allowing the test to work in CI and varied environments - Update proxy rules to use loopback designation for clarity - Bind UDP socket to all interfaces (0.0.0.0) to receive on the discovered address --- ...untime-rpc-mobile-method-allowlist.test.ts | 1 - .../skills/skill-install-lock-release.test.ts | 36 +++++++++++++++++++ src/main/skills/skill-install-lock-release.ts | 6 ++-- 3 files changed, 39 insertions(+), 4 deletions(-) create mode 100644 src/main/skills/skill-install-lock-release.test.ts diff --git a/src/main/runtime/runtime-rpc-mobile-method-allowlist.test.ts b/src/main/runtime/runtime-rpc-mobile-method-allowlist.test.ts index b0d5b18b84c..9c590994c41 100644 --- a/src/main/runtime/runtime-rpc-mobile-method-allowlist.test.ts +++ b/src/main/runtime/runtime-rpc-mobile-method-allowlist.test.ts @@ -479,7 +479,6 @@ describe('OrcaRuntimeRpcServer', () => { deviceToken: mobile.token, params: { worktree: 'id:wt-1', page: 'page-1' } }) - expect(replies).toContainEqual( expect.objectContaining({ id: 'req_forbidden', diff --git a/src/main/skills/skill-install-lock-release.test.ts b/src/main/skills/skill-install-lock-release.test.ts new file mode 100644 index 00000000000..6811e0d08f9 --- /dev/null +++ b/src/main/skills/skill-install-lock-release.test.ts @@ -0,0 +1,36 @@ +import { mkdtemp, mkdir, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { cleanupReleasedSkillInstallLock } from './skill-install-lock-release' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('cleanupReleasedSkillInstallLock', () => { + it('keeps a released lock recoverable when directory removal races', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skill-lock-release-test-')) + roots.push(root) + const releasePath = join(root, 'released') + const token = '11111111-1111-4111-8111-111111111111' + await mkdir(releasePath) + await Promise.all([ + writeFile(join(releasePath, `${token}.owner`), 'owner'), + writeFile(join(releasePath, `${token}.released`), '') + ]) + + const removeDirectory = async (): Promise => { + const error = new Error('injected-rmdir-race') as NodeJS.ErrnoException + error.code = 'ENOTEMPTY' + throw error + } + + await expect( + cleanupReleasedSkillInstallLock(releasePath, token, removeDirectory) + ).resolves.toBeUndefined() + await expect(readdir(releasePath)).resolves.toEqual([]) + }) +}) diff --git a/src/main/skills/skill-install-lock-release.ts b/src/main/skills/skill-install-lock-release.ts index 0b56ffcc9a1..b3e02bf869b 100644 --- a/src/main/skills/skill-install-lock-release.ts +++ b/src/main/skills/skill-install-lock-release.ts @@ -2,7 +2,7 @@ import { readdir, rmdir, unlink } from 'node:fs/promises' import { join } from 'node:path' const RELEASE_ENTRY_NAME = /^[a-f0-9-]{36}\.(?:owner|released)$/ -const RECOVERY_RMDIR_IGNORED_CODES = new Set(['ENOENT', 'ENOTEMPTY', 'EEXIST', 'EBUSY']) +const LOCK_DIRECTORY_RMDIR_IGNORED_CODES = new Set(['ENOENT', 'ENOTEMPTY', 'EEXIST', 'EBUSY']) async function unlinkIfPresent(path: string): Promise { await unlink(path).catch((error) => { @@ -17,7 +17,7 @@ async function removeDirectoryIfPresent( removeDirectory: (path: string) => Promise ): Promise { await removeDirectory(path).catch((error) => { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + if (!LOCK_DIRECTORY_RMDIR_IGNORED_CODES.has((error as NodeJS.ErrnoException).code ?? '')) { throw error } }) @@ -46,7 +46,7 @@ export async function reclaimReleasedSkillInstallLock(path: string): Promise unlinkIfPresent(join(path, entry.name))) ) await rmdir(path).catch((error) => { - if (!RECOVERY_RMDIR_IGNORED_CODES.has((error as NodeJS.ErrnoException).code ?? '')) { + if (!LOCK_DIRECTORY_RMDIR_IGNORED_CODES.has((error as NodeJS.ErrnoException).code ?? '')) { throw error } }) From 7ee8b5e1a601562ef1fd68282762dd35b41de8db Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:10:51 -0700 Subject: [PATCH 14/29] Refactor lower max-lines modules (#16760) --- config/max-lines-baseline.txt | 22 - mobile/.oxlintrc.json | 36 - mobile/app/pair-scan.tsx | 187 +---- mobile/scripts/repro-terminal-color-output.ts | 60 ++ mobile/scripts/repro-terminal-colors.ts | 76 +- .../scripts/repro-worktree-startup-output.ts | 75 ++ .../scripts/repro-worktree-startup-stream.ts | 79 +- .../src/components/CustomKeyModal.styles.ts | 276 +++++++ mobile/src/components/CustomKeyModal.tsx | 279 +------- ...obile-rich-markdown-editor-body-primary.ts | 279 ++++++++ ...ile-rich-markdown-editor-body-secondary.ts | 278 +++++++ ...le-rich-markdown-editor-document-suffix.ts | 93 +++ .../mobile-rich-markdown-editor-html.ts | 649 +---------------- mobile/src/pair-scan-styles.ts | 185 +++++ .../src/terminal/terminal-accessory-keys.ts | 148 +--- .../src/terminal/terminal-key-definitions.ts | 143 ++++ src/cli/handlers/automation-handler-flags.ts | 308 ++++++++ src/cli/handlers/automations.ts | 309 +------- src/cli/help.ts | 367 +--------- src/cli/root-help-text-primary.ts | 184 +++++ src/cli/root-help-text-secondary.ts | 183 +++++ src/main/cli/wsl-cli-installer.ts | 215 +----- src/main/cli/wsl-cli-registration-command.ts | 70 ++ src/main/cli/wsl-cli-status.ts | 163 +++++ .../keybindings/keybinding-file-parser.ts | 241 +++++++ src/main/keybindings/keybinding-file.ts | 247 +------ src/main/memory/collector.ts | 214 ++---- src/main/memory/memory-snapshot-buckets.ts | 101 +++ src/main/memory/memory-snapshot-values.ts | 49 ++ .../automation-definition-operations.ts | 2 +- .../automation-schedule-operations.ts | 2 +- .../project-groups/nested-repo-discovery.ts | 191 +---- .../project-groups/nested-repo-scan-rules.ts | 174 +++++ .../rate-limits/claude-pty-stop-markers.ts | 14 + .../rate-limits/claude-pty-usage-parser.ts | 166 +++++ src/main/rate-limits/claude-pty.ts | 216 +----- src/main/rate-limits/codex-fetcher.ts | 9 +- .../rate-limits/hidden-rate-limit-shell.ts | 3 + .../runtime/rpc/methods/automation-schemas.ts | 2 +- .../src/components/LinearIssueTextEditor.tsx | 2 +- .../src/components/LinearItemDrawer.tsx | 2 +- .../automations/AutomationCustomCronPanel.tsx | 6 +- .../AutomationEditorSettingsSidebar.tsx | 2 +- .../AutomationSchedulePicker.render.test.tsx | 2 +- .../AutomationSchedulePicker.test.ts | 2 +- .../automations/AutomationSchedulePicker.tsx | 6 +- .../automations/AutomationsPage.tsx | 4 +- .../automations/HermesCronOutputView.tsx | 2 +- .../automations/automation-draft-model.ts | 2 +- .../automations/automation-edit-draft.ts | 2 +- .../external-automation-schedule-display.ts | 2 +- .../linear-issue-project-selector.test.tsx | 7 +- .../linear-issue-project-selector.tsx | 3 +- .../components/linear-issue-sub-issues.tsx | 2 +- ...near-issue-workspace-detail-state.test.tsx | 2 +- .../linear-issue-workspace-detail-state.ts | 2 +- .../FolderWorkspacePrChecksPanel.test.tsx | 2 +- .../FolderWorkspacePrChecksPanel.tsx | 2 +- .../FolderWorkspacePrChecksRow.test.tsx | 2 +- .../FolderWorkspacePrChecksRow.tsx | 2 +- ...-checks-panel-check-and-review-actions.tsx | 2 +- .../use-checks-panel-context-state.tsx | 2 +- .../use-checks-panel-create-review.tsx | 2 +- ...-panel-entry-refresh-and-title-actions.tsx | 2 +- .../use-checks-panel-manual-refresh.test.tsx | 6 +- .../use-checks-panel-manual-refresh.tsx | 2 +- ...rent-pr-checks-projection-selector.test.ts | 2 +- .../right-sidebar/parent-pr-checks-refresh.ts | 6 +- .../parent-pr-checks-rows.test.ts | 6 +- .../right-sidebar/parent-pr-checks-rows.ts | 14 +- .../listing/use-worktree-context.ts | 2 +- .../folder-workspace-card-pr-display.ts | 6 +- .../use-workspace-kanban-area-selection.ts | 60 +- .../use-worktree-card-review-details.ts | 2 +- .../workspace-board-task-status-sync.ts | 10 +- .../workspace-kanban-area-selection-state.ts | 50 ++ .../workspace-kanban-area-selection.test.ts | 2 +- .../status-bar/WorkspaceSpaceManagerPanel.tsx | 2 +- .../use-task-page-create-linear-submits.tsx | 7 +- .../hooks/use-task-page-linear-board.tsx | 3 +- .../use-task-page-linear-create-dialogs.ts | 2 +- .../task-page/linear/linear-state-cell.tsx | 2 +- .../workspace-cleanup-presentation.test.ts | 2 +- .../workspace-cleanup-presentation.ts | 2 +- .../src/hooks/useIssueMetadata.test.tsx | 2 +- src/renderer/src/hooks/useIssueMetadata.ts | 6 +- .../src/runtime/runtime-linear-client.test.ts | 20 +- .../src/runtime/runtime-linear-client.ts | 366 +--------- .../runtime/runtime-linear-issue-mutations.ts | 113 +++ .../runtime/runtime-linear-project-client.ts | 266 +++++++ .../store/slices/diff-comment-persistence.ts | 322 +++++++++ src/renderer/src/store/slices/diffComments.ts | 354 +-------- .../slices/hosted-review-cache-race.test.ts | 3 +- .../store/slices/hosted-review-cache-state.ts | 207 ++++++ .../store/slices/hosted-review-cache.test.ts | 8 +- .../slices/hosted-review-card-refresh.ts | 38 + .../store/slices/hosted-review-pr-cache.ts | 39 + .../slices/hosted-review-request-state.ts | 23 + .../src/store/slices/hosted-review.test.ts | 10 +- .../src/store/slices/hosted-review.ts | 315 +------- .../linear-credential-error-recovery.test.ts | 32 +- .../store/slices/linear-invalidation.test.ts | 17 +- .../linear-scoped-collection-cache.test.ts | 32 +- .../linear-source-context-cache-scope.test.ts | 32 +- src/renderer/src/store/slices/linear.ts | 20 +- .../worktrees-metadata-persistence.test.ts | 2 +- .../metadata/update-worktree-meta.ts | 2 +- .../session/worktree-unread-activity.ts | 2 +- src/shared/automation-cron-occurrence.ts | 48 ++ src/shared/automation-schedule-occurrences.ts | 157 ++++ src/shared/automation-schedule-parsing.ts | 317 ++++++++ src/shared/automation-schedules.test.ts | 18 +- src/shared/automation-schedules.ts | 512 +------------ src/shared/commit-message-agent-spec.test.ts | 10 +- src/shared/commit-message-agent-spec.ts | 676 +----------------- .../commit-message-agent-specs-primary.ts | 223 ++++++ .../commit-message-agent-specs-secondary.ts | 227 ++++++ src/shared/commit-message-model-parsers.ts | 248 +++++++ src/shared/constants.ts | 240 +------ src/shared/default-global-settings.ts | 257 +++++++ src/shared/marine-creature-names-primary.ts | 278 +++++++ src/shared/marine-creature-names-secondary.ts | 278 +++++++ src/shared/marine-creatures.ts | 560 +-------------- src/shared/text-search-glob-patterns.ts | 39 + src/shared/text-search-match-accumulator.ts | 78 ++ src/shared/text-search-paths.ts | 20 + src/shared/text-search.test.ts | 7 +- src/shared/text-search.ts | 205 ++---- 128 files changed, 6818 insertions(+), 6360 deletions(-) create mode 100644 mobile/scripts/repro-terminal-color-output.ts create mode 100644 mobile/scripts/repro-worktree-startup-output.ts create mode 100644 mobile/src/components/CustomKeyModal.styles.ts create mode 100644 mobile/src/components/mobile-rich-markdown-editor-body-primary.ts create mode 100644 mobile/src/components/mobile-rich-markdown-editor-body-secondary.ts create mode 100644 mobile/src/components/mobile-rich-markdown-editor-document-suffix.ts create mode 100644 mobile/src/pair-scan-styles.ts create mode 100644 mobile/src/terminal/terminal-key-definitions.ts create mode 100644 src/cli/handlers/automation-handler-flags.ts create mode 100644 src/cli/root-help-text-primary.ts create mode 100644 src/cli/root-help-text-secondary.ts create mode 100644 src/main/cli/wsl-cli-registration-command.ts create mode 100644 src/main/cli/wsl-cli-status.ts create mode 100644 src/main/keybindings/keybinding-file-parser.ts create mode 100644 src/main/memory/memory-snapshot-buckets.ts create mode 100644 src/main/memory/memory-snapshot-values.ts create mode 100644 src/main/project-groups/nested-repo-scan-rules.ts create mode 100644 src/main/rate-limits/claude-pty-stop-markers.ts create mode 100644 src/main/rate-limits/claude-pty-usage-parser.ts create mode 100644 src/main/rate-limits/hidden-rate-limit-shell.ts create mode 100644 src/renderer/src/components/sidebar/workspace-kanban-area-selection-state.ts create mode 100644 src/renderer/src/runtime/runtime-linear-issue-mutations.ts create mode 100644 src/renderer/src/runtime/runtime-linear-project-client.ts create mode 100644 src/renderer/src/store/slices/diff-comment-persistence.ts create mode 100644 src/renderer/src/store/slices/hosted-review-cache-state.ts create mode 100644 src/renderer/src/store/slices/hosted-review-card-refresh.ts create mode 100644 src/renderer/src/store/slices/hosted-review-pr-cache.ts create mode 100644 src/renderer/src/store/slices/hosted-review-request-state.ts create mode 100644 src/shared/automation-cron-occurrence.ts create mode 100644 src/shared/automation-schedule-occurrences.ts create mode 100644 src/shared/automation-schedule-parsing.ts create mode 100644 src/shared/commit-message-agent-specs-primary.ts create mode 100644 src/shared/commit-message-agent-specs-secondary.ts create mode 100644 src/shared/commit-message-model-parsers.ts create mode 100644 src/shared/default-global-settings.ts create mode 100644 src/shared/marine-creature-names-primary.ts create mode 100644 src/shared/marine-creature-names-secondary.ts create mode 100644 src/shared/text-search-glob-patterns.ts create mode 100644 src/shared/text-search-match-accumulator.ts create mode 100644 src/shared/text-search-paths.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 664bb4c28b3..e29a7426371 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -2,8 +2,6 @@ # This is a RATCHET: the list may only SHRINK. Do NOT add entries to get CI green โ€” # split the oversized file instead (AGENTS.md โ†’ "Do Not Disable Max Lines"). # Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only) -inline src/cli/handlers/automations.ts -inline src/cli/help.ts inline src/main/agent-hooks/server.ts inline src/main/automations/external-manager.ts inline src/main/automations/hermes-cron-output.ts @@ -12,7 +10,6 @@ inline src/main/browser/browser-cookie-import.ts inline src/main/browser/browser-manager.ts inline src/main/browser/cdp-bridge.ts inline src/main/claude-accounts/runtime-auth-service.ts -inline src/main/cli/wsl-cli-installer.ts inline src/main/codex-accounts/runtime-home-service.ts inline src/main/codex-accounts/service.ts inline src/main/codex/hook-service.ts @@ -23,14 +20,10 @@ inline src/main/index.ts inline src/main/ipc/filesystem-watcher.ts inline src/main/ipc/filesystem.ts inline src/main/ipc/worktree-remote.ts -inline src/main/keybindings/keybinding-file.ts inline src/main/linear/issues.ts -inline src/main/memory/collector.ts inline src/main/ports/advertised-url-watcher.ts inline src/main/ports/local-workspace-port-scanner.ts -inline src/main/project-groups/nested-repo-discovery.ts inline src/main/providers/local-pty-provider.ts -inline src/main/rate-limits/claude-pty.ts inline src/main/rate-limits/service.ts inline src/main/runtime/orca-runtime-browser.ts inline src/main/runtime/orca-runtime-files.ts @@ -69,7 +62,6 @@ inline src/renderer/src/components/right-sidebar/PortsPanel.tsx inline src/renderer/src/components/settings/AccountsPane.tsx inline src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx inline src/renderer/src/components/settings/Settings.tsx -inline src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts inline src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx inline src/renderer/src/components/status-bar/StatusBar.tsx inline src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx @@ -82,24 +74,16 @@ inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts inline src/renderer/src/lib/pane-manager/pane-tree-ops.ts inline src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts inline src/renderer/src/runtime/runtime-file-client.ts -inline src/renderer/src/runtime/runtime-linear-client.ts inline src/renderer/src/runtime/sync-runtime-graph.ts inline src/renderer/src/runtime/web-runtime-session.ts inline src/renderer/src/runtime/web-session-tabs-sync.ts inline src/renderer/src/store/slices/agent-status.ts inline src/renderer/src/store/slices/browser.ts -inline src/renderer/src/store/slices/diffComments.ts -inline src/renderer/src/store/slices/hosted-review.ts inline src/renderer/src/store/slices/linear.ts inline src/renderer/src/store/slices/tabs.ts inline src/renderer/src/store/slices/ui.ts -inline src/shared/automation-schedules.ts -inline src/shared/commit-message-agent-spec.ts -inline src/shared/constants.ts inline src/shared/keybindings.ts -inline src/shared/marine-creatures.ts inline src/shared/telemetry-events.ts -inline src/shared/text-search.ts inline tests/e2e/helpers/terminal.ts mobile-config app/h/*/files/*.tsx mobile-config app/h/*/index.tsx @@ -107,15 +91,9 @@ mobile-config app/h/*/session/*.tsx mobile-config app/h/*/source-control/*.tsx mobile-config app/h/*/tasks.tsx mobile-config app/index.tsx -mobile-config app/pair-scan.tsx mobile-config app/troubleshoot.tsx mobile-config scripts/mock-server.ts -mobile-config scripts/repro-terminal-colors.ts -mobile-config scripts/repro-worktree-startup-stream.ts mobile-config src/browser/MobileBrowserPane.tsx -mobile-config src/components/CustomKeyModal.tsx mobile-config src/components/NewWorktreeModal.tsx -mobile-config src/components/mobile-rich-markdown-editor-html.ts -mobile-config src/terminal/terminal-accessory-keys.ts mobile-config src/terminal/terminal-webview-html.ts mobile-config src/transport/rpc-client.ts diff --git a/mobile/.oxlintrc.json b/mobile/.oxlintrc.json index 877a6d7e123..f3338fc3616 100644 --- a/mobile/.oxlintrc.json +++ b/mobile/.oxlintrc.json @@ -75,54 +75,18 @@ "max-lines": ["error", { "max": 1074, "skipBlankLines": true, "skipComments": true }] } }, - { - "files": ["src/components/mobile-rich-markdown-editor-html.ts"], - "rules": { - "max-lines": ["error", { "max": 648, "skipBlankLines": true, "skipComments": true }] - } - }, - { - "files": ["src/components/CustomKeyModal.tsx"], - "rules": { - "max-lines": ["error", { "max": 645, "skipBlankLines": true, "skipComments": true }] - } - }, - { - "files": ["app/pair-scan.tsx"], - "rules": { - "max-lines": ["error", { "max": 531, "skipBlankLines": true, "skipComments": true }] - } - }, { "files": ["scripts/mock-server.ts"], "rules": { "max-lines": ["error", { "max": 407, "skipBlankLines": true, "skipComments": true }] } }, - { - "files": ["src/terminal/terminal-accessory-keys.ts"], - "rules": { - "max-lines": ["error", { "max": 388, "skipBlankLines": true, "skipComments": true }] - } - }, { "files": ["app/troubleshoot.tsx"], "rules": { "max-lines": ["error", { "max": 436, "skipBlankLines": true, "skipComments": true }] } }, - { - "files": ["scripts/repro-worktree-startup-stream.ts"], - "rules": { - "max-lines": ["error", { "max": 326, "skipBlankLines": true, "skipComments": true }] - } - }, - { - "files": ["scripts/repro-terminal-colors.ts"], - "rules": { - "max-lines": ["error", { "max": 319, "skipBlankLines": true, "skipComments": true }] - } - }, { "files": ["app/h/*/files/*.tsx"], "rules": { diff --git a/mobile/app/pair-scan.tsx b/mobile/app/pair-scan.tsx index c78d7a907df..61e99f19f2a 100644 --- a/mobile/app/pair-scan.tsx +++ b/mobile/app/pair-scan.tsx @@ -2,7 +2,6 @@ import { useState, useRef, useCallback } from 'react' import { View, Text, - StyleSheet, Pressable, ActivityIndicator, Linking, @@ -19,13 +18,14 @@ import { } from '../src/transport/pre-profile-pairing-coordinator' import type { ConnectionLogEntry, PairingOffer } from '../src/transport/types' import { useRefreshHostClient } from '../src/transport/client-context' -import { colors, spacing, radii, typography } from '../src/theme/mobile-theme' +import { colors, spacing } from '../src/theme/mobile-theme' import { TextInputModal } from '../src/components/TextInputModal' import { ConnectionLog } from '../src/components/ConnectionLog' import { loadMobileOnboardingSteps, mobileOnboardingDestination } from '../src/onboarding/mobile-onboarding-plan' +import { pairScanStyles as styles } from '../src/pair-scan-styles' // Why: see pair-confirm.tsx โ€” cap initial-pair "Connectingโ€ฆ" so a broken // route surfaces as a real error with the log visible instead of a @@ -356,186 +356,3 @@ export default function PairScanScreen() { ) } - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.bgBase, - padding: spacing.lg - }, - backButton: { - width: 36, - height: 36, - borderRadius: 18, - alignItems: 'center', - justifyContent: 'center', - marginBottom: spacing.sm - }, - steps: { - gap: spacing.sm, - marginBottom: spacing.lg, - marginLeft: 7 - }, - step: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm - }, - stepBadge: { - width: 22, - height: 22, - borderRadius: 11, - backgroundColor: colors.bgRaised, - alignItems: 'center', - justifyContent: 'center' - }, - stepNumber: { - fontSize: 12, - fontWeight: '700', - color: colors.textSecondary - }, - stepText: { - fontSize: typography.bodySize, - color: colors.textSecondary - }, - cameraWrap: { - flex: 1, - borderRadius: radii.camera, - overflow: 'hidden' - }, - // Why: holds the layout slot while the camera is unmounted during - // paste, so the bottom action button doesn't snap up to fill the - // empty space. - cameraPlaceholder: { - flex: 1, - backgroundColor: colors.bgPanel, - borderRadius: radii.camera - }, - camera: { - ...StyleSheet.absoluteFillObject - }, - reticle: { - ...StyleSheet.absoluteFillObject, - alignItems: 'center', - justifyContent: 'center' - }, - reticleFrame: { - position: 'relative' - }, - corner: { - position: 'absolute', - width: 28, - height: 28, - borderColor: 'rgba(255,255,255,0.7)' - }, - cornerTL: { - top: 0, - left: 0, - borderTopWidth: 2.5, - borderLeftWidth: 2.5, - borderTopLeftRadius: 6 - }, - cornerTR: { - top: 0, - right: 0, - borderTopWidth: 2.5, - borderRightWidth: 2.5, - borderTopRightRadius: 6 - }, - cornerBL: { - bottom: 0, - left: 0, - borderBottomWidth: 2.5, - borderLeftWidth: 2.5, - borderBottomLeftRadius: 6 - }, - cornerBR: { - bottom: 0, - right: 0, - borderBottomWidth: 2.5, - borderRightWidth: 2.5, - borderBottomRightRadius: 6 - }, - centered: { - flex: 1, - alignItems: 'center', - justifyContent: 'center' - }, - title: { - fontSize: typography.titleSize, - fontWeight: '600', - color: colors.textPrimary, - marginBottom: spacing.sm - }, - subtitle: { - maxWidth: 310, - fontSize: typography.bodySize, - color: colors.textSecondary, - textAlign: 'center', - marginBottom: spacing.xl, - lineHeight: 20 - }, - connectingText: { - color: colors.textSecondary, - fontSize: typography.bodySize, - marginTop: spacing.lg - }, - logSlot: { - width: '100%', - marginTop: spacing.lg, - paddingHorizontal: spacing.sm - }, - errorText: { - color: colors.statusRed, - fontSize: typography.bodySize, - textAlign: 'center', - marginBottom: spacing.xl, - lineHeight: 20 - }, - primaryButton: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: spacing.xs, - backgroundColor: colors.textPrimary, - paddingHorizontal: spacing.xl, - paddingVertical: spacing.sm + 2, - borderRadius: radii.button - }, - primaryButtonText: { - color: colors.bgBase, - fontSize: typography.bodySize, - fontWeight: '600' - }, - pasteButton: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: spacing.xs, - marginTop: spacing.md, - paddingVertical: spacing.sm, - borderRadius: radii.button - }, - pasteButtonPressed: { - opacity: 0.6 - }, - pasteButtonText: { - color: colors.textSecondary, - fontSize: typography.bodySize, - fontWeight: '500' - }, - errorActions: { - alignItems: 'center', - gap: spacing.sm - }, - secondaryButton: { - paddingHorizontal: spacing.xl, - paddingVertical: spacing.sm, - borderRadius: radii.button - }, - secondaryButtonText: { - color: colors.textSecondary, - fontSize: typography.bodySize, - fontWeight: '500' - } -}) diff --git a/mobile/scripts/repro-terminal-color-output.ts b/mobile/scripts/repro-terminal-color-output.ts new file mode 100644 index 00000000000..50e4e153b54 --- /dev/null +++ b/mobile/scripts/repro-terminal-color-output.ts @@ -0,0 +1,60 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +const ESC = String.fromCharCode(27) + +export type TerminalColorSnapshot = { + label: string + handle: string + cols: number | null + rows: number | null + serialized: string + lines: string +} + +function countMatches(value: string, pattern: RegExp): number { + return value.match(pattern)?.length ?? 0 +} + +export function summarizeTerminalColorSnapshot( + snapshot: TerminalColorSnapshot +): Record { + const data = snapshot.serialized + return { + label: snapshot.label, + handle: snapshot.handle, + cols: snapshot.cols, + rows: snapshot.rows, + serializedBytes: Buffer.byteLength(data), + sgrTotal: countMatches(data, new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')), + sgrColor: countMatches( + data, + new RegExp( + `${ESC}\\[(?:[0-9;:]*[;:])?(?:3[0-7]|4[0-7]|9[0-7]|10[0-7]|38[;:]|48[;:])[0-9;:]*m`, + 'g' + ) + ), + sgrReset: countMatches(data, new RegExp(`${ESC}\\[(?:0|39|49|0;39;49)m`, 'g')), + altScreen: data.includes(`${ESC}[?1049h`) ? 'yes' : 'no', + containsTruecolor: data.includes('38;2') || data.includes('38:2') ? 'yes' : 'no', + containsPaletteColor: data.includes('38;5') || data.includes('38:5') ? 'yes' : 'no' + } +} + +export function saveTerminalColorSnapshots(snapshots: TerminalColorSnapshot[]): string { + const dir = join(process.cwd(), 'terminal-color-repro') + mkdirSync(dir, { recursive: true }) + for (const snapshot of snapshots) { + const base = snapshot.label.replace(/[^a-z0-9_-]/gi, '-') + writeFileSync(join(dir, `${base}.ansi`), snapshot.serialized || snapshot.lines) + writeFileSync( + join(dir, `${base}.escaped.txt`), + JSON.stringify(snapshot.serialized || snapshot.lines) + ) + } + writeFileSync( + join(dir, 'summary.json'), + JSON.stringify(snapshots.map(summarizeTerminalColorSnapshot), null, 2) + ) + return dir +} diff --git a/mobile/scripts/repro-terminal-colors.ts b/mobile/scripts/repro-terminal-colors.ts index 3c21df64176..e6c302652a0 100644 --- a/mobile/scripts/repro-terminal-colors.ts +++ b/mobile/scripts/repro-terminal-colors.ts @@ -10,10 +10,13 @@ * ORCA_MOBILE_WS_URL=ws://127.0.0.1:6768 \ * pnpm exec tsx scripts/repro-terminal-colors.ts [handleA] [handleB] */ -import { mkdirSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' import nacl from 'tweetnacl' import WebSocket from 'ws' +import { + saveTerminalColorSnapshots, + summarizeTerminalColorSnapshot, + type TerminalColorSnapshot +} from './repro-terminal-color-output' const WS_URL = process.env.ORCA_MOBILE_WS_URL ?? 'ws://127.0.0.1:6768' const token = process.argv[2] @@ -21,7 +24,6 @@ const serverPublicKeyB64 = process.argv[3] const worktreeSelector = process.argv[4] const explicitHandleA = process.argv[5] const explicitHandleB = process.argv[6] -const ESC = String.fromCharCode(27) type RpcResponse = { id: string @@ -37,15 +39,6 @@ type PendingRequest = { reject: (error: Error) => void } -type Snapshot = { - label: string - handle: string - cols: number | null - rows: number | null - serialized: string - lines: string -} - if (!token || !serverPublicKeyB64 || !worktreeSelector) { console.error( 'Usage: pnpm exec tsx scripts/repro-terminal-colors.ts [handleA] [handleB]' @@ -158,9 +151,13 @@ async function ensureSecondHandle(ws: WebSocket, handleA: string): Promise { +async function captureSnapshot( + ws: WebSocket, + label: string, + handle: string +): Promise { const id = nextId() - const snapshot = await new Promise((resolve, reject) => { + const snapshot = await new Promise((resolve, reject) => { const timeout = setTimeout(() => { pending.delete(id) streamListeners.delete(id) @@ -207,48 +204,6 @@ async function captureSnapshot(ws: WebSocket, label: string, handle: string): Pr return snapshot } -function countMatches(value: string, pattern: RegExp): number { - return value.match(pattern)?.length ?? 0 -} - -function summarize(snapshot: Snapshot): Record { - const data = snapshot.serialized - return { - label: snapshot.label, - handle: snapshot.handle, - cols: snapshot.cols, - rows: snapshot.rows, - serializedBytes: Buffer.byteLength(data), - sgrTotal: countMatches(data, new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')), - sgrColor: countMatches( - data, - new RegExp( - `${ESC}\\[(?:[0-9;:]*[;:])?(?:3[0-7]|4[0-7]|9[0-7]|10[0-7]|38[;:]|48[;:])[0-9;:]*m`, - 'g' - ) - ), - sgrReset: countMatches(data, new RegExp(`${ESC}\\[(?:0|39|49|0;39;49)m`, 'g')), - altScreen: data.includes(`${ESC}[?1049h`) ? 'yes' : 'no', - containsTruecolor: data.includes('38;2') || data.includes('38:2') ? 'yes' : 'no', - containsPaletteColor: data.includes('38;5') || data.includes('38:5') ? 'yes' : 'no' - } -} - -function saveSnapshots(snapshots: Snapshot[]): string { - const dir = join(process.cwd(), 'terminal-color-repro') - mkdirSync(dir, { recursive: true }) - for (const snapshot of snapshots) { - const base = snapshot.label.replace(/[^a-z0-9_-]/gi, '-') - writeFileSync(join(dir, `${base}.ansi`), snapshot.serialized || snapshot.lines) - writeFileSync( - join(dir, `${base}.escaped.txt`), - JSON.stringify(snapshot.serialized || snapshot.lines) - ) - } - writeFileSync(join(dir, 'summary.json'), JSON.stringify(snapshots.map(summarize), null, 2)) - return dir -} - async function run(ws: WebSocket): Promise { ws.send( JSON.stringify({ @@ -303,11 +258,14 @@ async function run(ws: WebSocket): Promise { const firstB = await captureSnapshot(ws, 'b-during-switch', handleB) const secondA = await captureSnapshot(ws, 'a-after-switch', handleA) const snapshots = [firstA, firstB, secondA] - const dir = saveSnapshots(snapshots) + const dir = saveTerminalColorSnapshots(snapshots) - console.table(snapshots.map(summarize)) + console.table(snapshots.map(summarizeTerminalColorSnapshot)) console.log(`saved: ${dir}`) - if (summarize(firstA).sgrColor !== summarize(secondA).sgrColor) { + if ( + summarizeTerminalColorSnapshot(firstA).sgrColor !== + summarizeTerminalColorSnapshot(secondA).sgrColor + ) { console.log( 'color SGR count changed between A snapshots: serialization/state changed on desktop' ) diff --git a/mobile/scripts/repro-worktree-startup-output.ts b/mobile/scripts/repro-worktree-startup-output.ts new file mode 100644 index 00000000000..83e127a3fa6 --- /dev/null +++ b/mobile/scripts/repro-worktree-startup-output.ts @@ -0,0 +1,75 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +const ESC = String.fromCharCode(27) + +export type StartupTerminalCapture = { + handle: string + title: string | null + scrollback: Record | null + chunks: string[] +} + +function stripAnsi(value: string): string { + return ( + value + // eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries + .replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '') + // eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries + .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '') + .replace(/\r/g, '\n') + ) +} + +function normalizePreview(value: string): string { + return stripAnsi(value) + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .slice(0, 8) + .join('\n') +} + +export function summarizeStartupCapture(capture: StartupTerminalCapture): Record { + const serialized = + typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : '' + const live = capture.chunks.join('') + const serializedPreview = normalizePreview(serialized) + const livePreview = normalizePreview(live) + return { + handle: capture.handle, + title: capture.title, + cols: capture.scrollback?.cols ?? null, + rows: capture.scrollback?.rows ?? null, + serializedBytes: Buffer.byteLength(serialized), + liveBytes: Buffer.byteLength(live), + liveChunks: capture.chunks.length, + serializedSgr: (serialized.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length, + liveSgr: (live.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length, + livePreviewContainedInSerialized: + livePreview.length > 0 && stripAnsi(serialized).includes(livePreview.split('\n')[0] ?? ''), + serializedPreview, + livePreview + } +} + +export function saveStartupCaptures( + captures: StartupTerminalCapture[], + worktreeName: string +): string { + const dir = join(process.cwd(), 'terminal-startup-repro', worktreeName) + mkdirSync(dir, { recursive: true }) + for (const capture of captures) { + const base = capture.handle.replace(/[^a-z0-9_-]/gi, '-') + const serialized = + typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : '' + writeFileSync(join(dir, `${base}.serialized.ansi`), serialized) + writeFileSync(join(dir, `${base}.live.ansi`), capture.chunks.join('')) + writeFileSync(join(dir, `${base}.json`), JSON.stringify(capture, null, 2)) + } + writeFileSync( + join(dir, 'summary.json'), + JSON.stringify(captures.map(summarizeStartupCapture), null, 2) + ) + return dir +} diff --git a/mobile/scripts/repro-worktree-startup-stream.ts b/mobile/scripts/repro-worktree-startup-stream.ts index e4db6182f00..f3bbdc0d5d0 100644 --- a/mobile/scripts/repro-worktree-startup-stream.ts +++ b/mobile/scripts/repro-worktree-startup-stream.ts @@ -4,10 +4,15 @@ * Usage: * pnpm exec tsx mobile/scripts/repro-worktree-startup-stream.ts [startupCommand] */ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' import nacl from 'tweetnacl' import WebSocket from 'ws' +import { + saveStartupCaptures, + summarizeStartupCapture, + type StartupTerminalCapture +} from './repro-worktree-startup-output' const WS_URL = process.env.ORCA_MOBILE_WS_URL ?? 'ws://127.0.0.1:6768' const USER_DATA = @@ -15,7 +20,6 @@ const USER_DATA = const repoSelector = process.argv[2] const worktreeName = process.argv[3] const startupCommand = process.argv[4] || 'claude' -const ESC = String.fromCharCode(27) type RpcResponse = { id: string @@ -36,13 +40,6 @@ type TerminalInfo = { title: string | null } -type Capture = { - handle: string - title: string | null - scrollback: Record | null - chunks: string[] -} - if (!repoSelector || !worktreeName) { console.error( 'Usage: pnpm exec tsx mobile/scripts/repro-worktree-startup-stream.ts [startupCommand]' @@ -141,26 +138,6 @@ function formatError(response: RpcResponse): string { return JSON.stringify(response.error ?? response.result ?? response).slice(0, 1000) } -function stripAnsi(value: string): string { - return ( - value - // eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries - .replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '') - // eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries - .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '') - .replace(/\r/g, '\n') - ) -} - -function normalizePreview(value: string): string { - return stripAnsi(value) - .split('\n') - .map((line) => line.trim()) - .filter(Boolean) - .slice(0, 8) - .join('\n') -} - async function waitForTerminals(ws: WebSocket, worktreeId: string): Promise { const deadline = Date.now() + 60_000 while (Date.now() < deadline) { @@ -185,7 +162,7 @@ async function waitForTerminals(ws: WebSocket, worktreeId: string): Promise { +async function subscribe(ws: WebSocket, capture: StartupTerminalCapture): Promise { const id = nextId() streamListeners.set(id, (result) => { if (result.type === 'scrollback') { @@ -204,44 +181,6 @@ async function subscribe(ws: WebSocket, capture: Capture): Promise { }) } -function summarize(capture: Capture): Record { - const serialized = - typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : '' - const live = capture.chunks.join('') - const serializedPreview = normalizePreview(serialized) - const livePreview = normalizePreview(live) - return { - handle: capture.handle, - title: capture.title, - cols: capture.scrollback?.cols ?? null, - rows: capture.scrollback?.rows ?? null, - serializedBytes: Buffer.byteLength(serialized), - liveBytes: Buffer.byteLength(live), - liveChunks: capture.chunks.length, - serializedSgr: (serialized.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length, - liveSgr: (live.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length, - livePreviewContainedInSerialized: - livePreview.length > 0 && stripAnsi(serialized).includes(livePreview.split('\n')[0] ?? ''), - serializedPreview, - livePreview - } -} - -function save(captures: Capture[], worktreeName: string): string { - const dir = join(process.cwd(), 'terminal-startup-repro', worktreeName) - mkdirSync(dir, { recursive: true }) - for (const capture of captures) { - const base = capture.handle.replace(/[^a-z0-9_-]/gi, '-') - const serialized = - typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : '' - writeFileSync(join(dir, `${base}.serialized.ansi`), serialized) - writeFileSync(join(dir, `${base}.live.ansi`), capture.chunks.join('')) - writeFileSync(join(dir, `${base}.json`), JSON.stringify(capture, null, 2)) - } - writeFileSync(join(dir, 'summary.json'), JSON.stringify(captures.map(summarize), null, 2)) - return dir -} - async function run(ws: WebSocket): Promise { ws.send( JSON.stringify({ @@ -312,8 +251,8 @@ async function run(ws: WebSocket): Promise { for (const capture of captures) { await send(ws, 'terminal.unsubscribe', { subscriptionId: capture.handle }).catch(() => null) } - const dir = save(captures, worktreeName) - console.table(captures.map(summarize)) + const dir = saveStartupCaptures(captures, worktreeName) + console.table(captures.map(summarizeStartupCapture)) console.log(`saved: ${dir}`) } diff --git a/mobile/src/components/CustomKeyModal.styles.ts b/mobile/src/components/CustomKeyModal.styles.ts new file mode 100644 index 00000000000..991e557441f --- /dev/null +++ b/mobile/src/components/CustomKeyModal.styles.ts @@ -0,0 +1,276 @@ +import { StyleSheet } from 'react-native' +import { colors, spacing, radii, typography } from '../theme/mobile-theme' + +export const customKeyModalStyles = StyleSheet.create({ + header: { + flexDirection: 'row', + alignItems: 'center', + paddingBottom: spacing.sm + }, + backButton: { + width: 30, + height: 30, + borderRadius: 15, + alignItems: 'center', + justifyContent: 'center' + }, + backButtonPressed: { + backgroundColor: colors.bgRaised + }, + backSpacer: { + width: 30 + }, + title: { + flex: 1, + fontSize: 15, + fontWeight: '600', + color: colors.textPrimary, + textAlign: 'center' + }, + group: { + backgroundColor: colors.bgPanel, + borderRadius: 12, + overflow: 'hidden' + }, + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + }, + row: { + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + rowPressed: { + backgroundColor: colors.bgRaised + }, + rowLabel: { + fontSize: typography.bodySize, + fontWeight: '500', + color: colors.textPrimary, + marginBottom: 1 + }, + rowHint: { + fontSize: 12, + color: colors.textMuted + }, + shortcutForm: { + paddingTop: spacing.sm + }, + preview: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + paddingVertical: spacing.lg + spacing.xs, + flexWrap: 'wrap' + }, + previewKeycapRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + previewPlus: { + color: colors.textMuted, + fontSize: 16 + }, + keycap: { + minWidth: 48, + height: 48, + paddingHorizontal: spacing.md, + borderRadius: 10, + backgroundColor: colors.bgPanel, + borderWidth: 1, + borderColor: colors.borderSubtle, + alignItems: 'center', + justifyContent: 'center' + }, + keycapModifier: { + minWidth: 0 + }, + keycapWarn: { + borderColor: colors.statusAmber + }, + keycapText: { + color: colors.textPrimary, + fontFamily: typography.monoFamily, + fontSize: 17, + fontWeight: '600' + }, + keycapTextWarn: { + color: colors.statusAmber + }, + keycapModifierText: { + color: colors.textSecondary, + fontFamily: typography.monoFamily, + fontSize: 14, + fontWeight: '600' + }, + section: { + marginTop: spacing.md + }, + sectionLabel: { + fontSize: 11, + color: colors.textMuted, + textTransform: 'uppercase', + letterSpacing: 0.8, + marginBottom: spacing.sm, + paddingLeft: 2 + }, + mods: { + flexDirection: 'row', + gap: spacing.sm + }, + chip: { + flex: 1, + height: 40, + borderRadius: 8, + backgroundColor: colors.bgPanel, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 4 + }, + chipSelected: { + backgroundColor: colors.textPrimary + }, + chipPressed: { + backgroundColor: colors.bgRaised + }, + chipText: { + color: colors.textSecondary, + fontSize: 14, + fontWeight: '500' + }, + chipTextSelected: { + color: colors.bgBase + }, + chipGlyph: { + color: colors.textMuted, + fontSize: 13, + fontFamily: typography.monoFamily + }, + chipGlyphSelected: { + color: 'rgba(10,10,10,0.5)' + }, + keyInput: { + width: '100%', + height: 56, + borderRadius: 10, + backgroundColor: colors.bgPanel, + borderWidth: 1, + borderColor: colors.borderSubtle, + color: colors.textPrimary, + fontFamily: typography.monoFamily, + fontSize: 22, + fontWeight: '600', + textAlign: 'center' + }, + moreLink: { + paddingVertical: spacing.sm, + alignItems: 'center' + }, + moreLinkPressed: { + opacity: 0.6 + }, + moreLinkText: { + color: colors.textSecondary, + fontSize: 13, + textDecorationLine: 'underline' + }, + specialKeysForm: { + paddingTop: spacing.xs, + paddingBottom: spacing.md, + gap: spacing.md + }, + specialGroup: { + gap: spacing.xs + }, + specialGroupTitle: { + fontSize: 11, + color: colors.textMuted, + textTransform: 'uppercase', + letterSpacing: 0.8, + paddingLeft: 2, + marginBottom: spacing.xs + }, + keyGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + marginHorizontal: -spacing.xs / 2 + }, + keyCellWrap: { + paddingHorizontal: spacing.xs / 2, + paddingVertical: spacing.xs / 2 + }, + keyCell: { + height: 40, + borderRadius: 8, + backgroundColor: colors.bgPanel, + alignItems: 'center', + justifyContent: 'center' + }, + keyCellPressed: { + backgroundColor: colors.bgRaised + }, + keyCellSelected: { + backgroundColor: colors.textPrimary + }, + keyCellText: { + fontSize: 13, + fontWeight: '600', + color: colors.textPrimary, + fontFamily: typography.monoFamily + }, + keyCellTextSelected: { + color: colors.bgBase + }, + macroForm: { + padding: spacing.md, + gap: spacing.sm + }, + fieldLabel: { + fontSize: 13, + fontWeight: '500', + color: colors.textSecondary + }, + fieldInput: { + backgroundColor: colors.bgBase, + color: colors.textPrimary, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + fontSize: 14, + fontFamily: typography.monoFamily, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + switchRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: spacing.xs + }, + switchLabel: { + fontSize: typography.bodySize, + color: colors.textPrimary + }, + saveButton: { + marginTop: spacing.md, + backgroundColor: colors.textPrimary, + paddingVertical: spacing.md, + borderRadius: 10, + alignItems: 'center' + }, + saveButtonDisabled: { + backgroundColor: colors.bgRaised + }, + saveButtonText: { + color: colors.bgBase, + fontSize: 15, + fontWeight: '600' + }, + saveButtonTextDisabled: { + color: colors.textMuted + } +}) diff --git a/mobile/src/components/CustomKeyModal.tsx b/mobile/src/components/CustomKeyModal.tsx index 2777e310dc8..0cc24f4c030 100644 --- a/mobile/src/components/CustomKeyModal.tsx +++ b/mobile/src/components/CustomKeyModal.tsx @@ -1,8 +1,8 @@ import { useCallback, useMemo, useState } from 'react' -import { View, Text, Pressable, TextInput, StyleSheet, Switch } from 'react-native' +import { View, Text, Pressable, TextInput, Switch } from 'react-native' import { ChevronLeft } from 'lucide-react-native' import AsyncStorage from '@react-native-async-storage/async-storage' -import { colors, spacing, radii, typography } from '../theme/mobile-theme' +import { colors } from '../theme/mobile-theme' import { BottomDrawer } from './BottomDrawer' import { buildTerminalShortcutKey, @@ -11,6 +11,7 @@ import { type TerminalShortcutModifier, type TerminalShortcutSpecialKey } from '../terminal/terminal-accessory-keys' +import { customKeyModalStyles as styles } from './CustomKeyModal.styles' const CUSTOM_ACCESSORY_KEYS_STORAGE_KEY = 'orca:custom-accessory-keys' @@ -409,277 +410,3 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc ) } - -const styles = StyleSheet.create({ - header: { - flexDirection: 'row', - alignItems: 'center', - paddingBottom: spacing.sm - }, - backButton: { - width: 30, - height: 30, - borderRadius: 15, - alignItems: 'center', - justifyContent: 'center' - }, - backButtonPressed: { - backgroundColor: colors.bgRaised - }, - backSpacer: { - width: 30 - }, - title: { - flex: 1, - fontSize: 15, - fontWeight: '600', - color: colors.textPrimary, - textAlign: 'center' - }, - group: { - backgroundColor: colors.bgPanel, - borderRadius: 12, - overflow: 'hidden' - }, - separator: { - height: StyleSheet.hairlineWidth, - backgroundColor: colors.borderSubtle, - marginHorizontal: spacing.md - }, - row: { - paddingVertical: spacing.md, - paddingHorizontal: spacing.md + 2 - }, - rowPressed: { - backgroundColor: colors.bgRaised - }, - rowLabel: { - fontSize: typography.bodySize, - fontWeight: '500', - color: colors.textPrimary, - marginBottom: 1 - }, - rowHint: { - fontSize: 12, - color: colors.textMuted - }, - shortcutForm: { - paddingTop: spacing.sm - }, - preview: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: spacing.sm, - paddingVertical: spacing.lg + spacing.xs, - flexWrap: 'wrap' - }, - previewKeycapRow: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm - }, - previewPlus: { - color: colors.textMuted, - fontSize: 16 - }, - keycap: { - minWidth: 48, - height: 48, - paddingHorizontal: spacing.md, - borderRadius: 10, - backgroundColor: colors.bgPanel, - borderWidth: 1, - borderColor: colors.borderSubtle, - alignItems: 'center', - justifyContent: 'center' - }, - keycapModifier: { - minWidth: 0 - }, - keycapWarn: { - borderColor: colors.statusAmber - }, - keycapText: { - color: colors.textPrimary, - fontFamily: typography.monoFamily, - fontSize: 17, - fontWeight: '600' - }, - keycapTextWarn: { - color: colors.statusAmber - }, - keycapModifierText: { - color: colors.textSecondary, - fontFamily: typography.monoFamily, - fontSize: 14, - fontWeight: '600' - }, - section: { - marginTop: spacing.md - }, - sectionLabel: { - fontSize: 11, - color: colors.textMuted, - textTransform: 'uppercase', - letterSpacing: 0.8, - marginBottom: spacing.sm, - paddingLeft: 2 - }, - mods: { - flexDirection: 'row', - gap: spacing.sm - }, - chip: { - flex: 1, - height: 40, - borderRadius: 8, - backgroundColor: colors.bgPanel, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 4 - }, - chipSelected: { - backgroundColor: colors.textPrimary - }, - chipPressed: { - backgroundColor: colors.bgRaised - }, - chipText: { - color: colors.textSecondary, - fontSize: 14, - fontWeight: '500' - }, - chipTextSelected: { - color: colors.bgBase - }, - chipGlyph: { - color: colors.textMuted, - fontSize: 13, - fontFamily: typography.monoFamily - }, - chipGlyphSelected: { - color: 'rgba(10,10,10,0.5)' - }, - keyInput: { - width: '100%', - height: 56, - borderRadius: 10, - backgroundColor: colors.bgPanel, - borderWidth: 1, - borderColor: colors.borderSubtle, - color: colors.textPrimary, - fontFamily: typography.monoFamily, - fontSize: 22, - fontWeight: '600', - textAlign: 'center' - }, - moreLink: { - paddingVertical: spacing.sm, - alignItems: 'center' - }, - moreLinkPressed: { - opacity: 0.6 - }, - moreLinkText: { - color: colors.textSecondary, - fontSize: 13, - textDecorationLine: 'underline' - }, - specialKeysForm: { - paddingTop: spacing.xs, - paddingBottom: spacing.md, - gap: spacing.md - }, - specialGroup: { - gap: spacing.xs - }, - specialGroupTitle: { - fontSize: 11, - color: colors.textMuted, - textTransform: 'uppercase', - letterSpacing: 0.8, - paddingLeft: 2, - marginBottom: spacing.xs - }, - keyGrid: { - flexDirection: 'row', - flexWrap: 'wrap', - marginHorizontal: -spacing.xs / 2 - }, - keyCellWrap: { - paddingHorizontal: spacing.xs / 2, - paddingVertical: spacing.xs / 2 - }, - keyCell: { - height: 40, - borderRadius: 8, - backgroundColor: colors.bgPanel, - alignItems: 'center', - justifyContent: 'center' - }, - keyCellPressed: { - backgroundColor: colors.bgRaised - }, - keyCellSelected: { - backgroundColor: colors.textPrimary - }, - keyCellText: { - fontSize: 13, - fontWeight: '600', - color: colors.textPrimary, - fontFamily: typography.monoFamily - }, - keyCellTextSelected: { - color: colors.bgBase - }, - macroForm: { - padding: spacing.md, - gap: spacing.sm - }, - fieldLabel: { - fontSize: 13, - fontWeight: '500', - color: colors.textSecondary - }, - fieldInput: { - backgroundColor: colors.bgBase, - color: colors.textPrimary, - borderRadius: radii.input, - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - fontSize: 14, - fontFamily: typography.monoFamily, - borderWidth: 1, - borderColor: colors.borderSubtle - }, - switchRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingVertical: spacing.xs - }, - switchLabel: { - fontSize: typography.bodySize, - color: colors.textPrimary - }, - saveButton: { - marginTop: spacing.md, - backgroundColor: colors.textPrimary, - paddingVertical: spacing.md, - borderRadius: 10, - alignItems: 'center' - }, - saveButtonDisabled: { - backgroundColor: colors.bgRaised - }, - saveButtonText: { - color: colors.bgBase, - fontSize: 15, - fontWeight: '600' - }, - saveButtonTextDisabled: { - color: colors.textMuted - } -}) diff --git a/mobile/src/components/mobile-rich-markdown-editor-body-primary.ts b/mobile/src/components/mobile-rich-markdown-editor-body-primary.ts new file mode 100644 index 00000000000..c4348ad15a2 --- /dev/null +++ b/mobile/src/components/mobile-rich-markdown-editor-body-primary.ts @@ -0,0 +1,279 @@ +export const MOBILE_RICH_MARKDOWN_EDITOR_BODY_PRIMARY = [ + ';', + ' --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;', + ' --font-sans: Geist, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;', + ' }', + ' * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }', + ' html, body {', + ' width: 100%;', + ' min-height: 100%;', + ' margin: 0;', + ' background: var(--editor-surface);', + ' color: var(--foreground);', + ' font-family: var(--font-sans);', + ' overscroll-behavior: contain;', + ' }', + ' body { overflow: auto; }', + ' #editor {', + ' min-height: 100vh;', + ' padding: 18px 16px 112px;', + ' outline: none;', + ' font-size: 14px;', + ' line-height: 1.7;', + ' word-wrap: break-word;', + ' overflow-wrap: anywhere;', + ' caret-color: var(--foreground);', + ' }', + ' #editor[contenteditable="false"] {', + ' opacity: 0.78;', + ' }', + ' #editor:empty::before,', + ' #editor p.is-empty:first-child::before {', + ' content: attr(data-placeholder);', + ' color: var(--muted-foreground);', + ' pointer-events: none;', + ' }', + ' #editor > :first-child { margin-top: 0; }', + ' h1, h2, h3, h4, h5, h6 {', + ' margin: 1.5em 0 0.5em;', + ' font-weight: 600;', + ' line-height: 1.3;', + ' letter-spacing: 0;', + ' }', + ' h1 { font-size: 1.85em; font-weight: 700; }', + ' h2 { font-size: 1.4em; }', + ' h3 { font-size: 1.15em; }', + ' p, ul, ol, blockquote { margin: 0.75em 0; }', + ' ul, ol { padding-left: 1.5em; }', + ' ul { list-style: disc; }', + ' ol { list-style: decimal; }', + ' li { margin: 0.15em 0; }', + ' li > p { margin: 0; }', + ' ul[data-type="taskList"] {', + ' padding-left: 0;', + ' list-style: none;', + ' }', + ' ul[data-type="taskList"] > li {', + ' display: flex;', + ' align-items: flex-start;', + ' gap: 6px;', + ' }', + ' ul[data-type="taskList"] > li > label {', + ' flex-shrink: 0;', + ' display: flex;', + ' align-items: center;', + ' height: 1.55em;', + ' user-select: none;', + ' }', + ' ul[data-type="taskList"] input[type="checkbox"] {', + ' appearance: none;', + ' width: 16px;', + ' height: 16px;', + ' margin: 0;', + ' border: 1.5px solid color-mix(in srgb, var(--foreground) 55%, transparent);', + ' border-radius: 4px;', + ' background: transparent;', + ' position: relative;', + ' }', + ' ul[data-type="taskList"] input[type="checkbox"]:checked {', + ' background: var(--primary);', + ' border-color: var(--primary);', + ' }', + ' ul[data-type="taskList"] input[type="checkbox"]:checked::after {', + ' content: "";', + ' position: absolute;', + ' left: 4px;', + ' top: 1px;', + ' width: 5px;', + ' height: 9px;', + ' border: solid var(--primary-foreground);', + ' border-width: 0 2px 2px 0;', + ' transform: rotate(45deg);', + ' }', + ' ul[data-type="taskList"] input[type="checkbox"]:disabled {', + ' opacity: 0.65;', + ' }', + ' ul[data-type="taskList"] > li > div {', + ' flex: 1;', + ' min-width: 0;', + ' }', + ' ul[data-type="taskList"] > li[data-checked="true"] > div {', + ' text-decoration: line-through;', + ' color: var(--muted-foreground);', + ' }', + ' blockquote {', + ' padding: 0.5em 1em;', + ' border-left: 3px solid var(--border);', + ' border-radius: 0 6px 6px 0;', + ' color: var(--muted-foreground);', + ' background: color-mix(in srgb, var(--foreground) 2%, transparent);', + ' }', + ' table {', + ' width: 100%;', + ' margin: 1em 0;', + ' border-collapse: collapse;', + ' font-size: 0.95em;', + ' }', + ' th, td {', + ' padding: 8px 14px;', + ' border: 1px solid var(--border);', + ' text-align: left;', + ' vertical-align: top;', + ' }', + ' th {', + ' font-weight: 600;', + ' background: color-mix(in srgb, var(--foreground) 4%, transparent);', + ' }', + ' tr:nth-child(odd) td {', + ' background: color-mix(in srgb, var(--foreground) 1.5%, transparent);', + ' }', + ' code {', + ' padding: 0.2em 0.4em;', + ' border-radius: 5px;', + ' background: color-mix(in srgb, var(--foreground) 8%, transparent);', + ' font-size: 0.88em;', + ' font-family: var(--font-mono);', + ' }', + ' pre {', + ' margin: 0.75em 0;', + ' padding: 14px 18px;', + ' border-radius: 8px;', + ' border: 1px solid color-mix(in srgb, var(--foreground) 6%, transparent);', + ' overflow-x: auto;', + ' line-height: 1.55;', + ' background: color-mix(in srgb, var(--foreground) 6%, transparent);', + ' font-family: var(--font-mono);', + ' white-space: pre-wrap;', + ' }', + ' pre::before {', + ' content: attr(data-language);', + ' display: block;', + ' min-height: 13px;', + ' margin-bottom: 4px;', + ' color: var(--muted-foreground);', + ' font-size: 11px;', + ' text-transform: uppercase;', + ' }', + ' pre code {', + ' padding: 0;', + ' border-radius: 0;', + ' background: transparent;', + ' font-size: 0.92em;', + ' white-space: pre-wrap;', + ' }', + ' hr {', + ' margin: 1.5em 0;', + ' border: none;', + ' border-top: 1px solid var(--border);', + ' }', + ' a {', + ' color: var(--accent-link);', + ' text-decoration: underline;', + ' text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);', + ' text-underline-offset: 2px;', + ' }', + ' img {', + ' display: block;', + ' max-width: 100%;', + ' margin: 0.75em 0;', + ' border-radius: 8px;', + ' }', + ' ', + '', + '', + '
', + ' ', + '', + '' +].join('\n') diff --git a/mobile/src/components/mobile-rich-markdown-editor-html.ts b/mobile/src/components/mobile-rich-markdown-editor-html.ts index b7ff1649075..493fea3dd02 100644 --- a/mobile/src/components/mobile-rich-markdown-editor-html.ts +++ b/mobile/src/components/mobile-rich-markdown-editor-html.ts @@ -2,6 +2,12 @@ import { colors } from '../theme/mobile-theme' import { MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT } from './mobile-rich-markdown-keyboard-dismiss-script' import { MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT } from './mobile-rich-markdown-editor-keyboard-inset-script' import { MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT } from './mobile-rich-markdown-selection-script' +import { MOBILE_RICH_MARKDOWN_EDITOR_BODY_PRIMARY } from './mobile-rich-markdown-editor-body-primary' +import { MOBILE_RICH_MARKDOWN_EDITOR_BODY_SECONDARY } from './mobile-rich-markdown-editor-body-secondary' +import { + MOBILE_RICH_MARKDOWN_EDITOR_AFTER_KEYBOARD_DISMISS, + MOBILE_RICH_MARKDOWN_EDITOR_DOCUMENT_END +} from './mobile-rich-markdown-editor-document-suffix' export function escapeInjectedJavaScriptString(value: string): string { return JSON.stringify(value).replace(/<\/script/gi, '<\\/script') @@ -24,644 +30,7 @@ export function buildMobileRichMarkdownEditorHtml(): string { --border: ${colors.borderSubtle}; --primary: ${colors.textPrimary}; --primary-foreground: ${colors.bgBase}; - --accent-link: ${colors.accentBlue}; - --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; - --font-sans: Geist, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - } - * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; } - html, body { - width: 100%; - min-height: 100%; - margin: 0; - background: var(--editor-surface); - color: var(--foreground); - font-family: var(--font-sans); - overscroll-behavior: contain; - } - body { overflow: auto; } - #editor { - min-height: 100vh; - padding: 18px 16px 112px; - outline: none; - font-size: 14px; - line-height: 1.7; - word-wrap: break-word; - overflow-wrap: anywhere; - caret-color: var(--foreground); - } - #editor[contenteditable="false"] { - opacity: 0.78; - } - #editor:empty::before, - #editor p.is-empty:first-child::before { - content: attr(data-placeholder); - color: var(--muted-foreground); - pointer-events: none; - } - #editor > :first-child { margin-top: 0; } - h1, h2, h3, h4, h5, h6 { - margin: 1.5em 0 0.5em; - font-weight: 600; - line-height: 1.3; - letter-spacing: 0; - } - h1 { font-size: 1.85em; font-weight: 700; } - h2 { font-size: 1.4em; } - h3 { font-size: 1.15em; } - p, ul, ol, blockquote { margin: 0.75em 0; } - ul, ol { padding-left: 1.5em; } - ul { list-style: disc; } - ol { list-style: decimal; } - li { margin: 0.15em 0; } - li > p { margin: 0; } - ul[data-type="taskList"] { - padding-left: 0; - list-style: none; - } - ul[data-type="taskList"] > li { - display: flex; - align-items: flex-start; - gap: 6px; - } - ul[data-type="taskList"] > li > label { - flex-shrink: 0; - display: flex; - align-items: center; - height: 1.55em; - user-select: none; - } - ul[data-type="taskList"] input[type="checkbox"] { - appearance: none; - width: 16px; - height: 16px; - margin: 0; - border: 1.5px solid color-mix(in srgb, var(--foreground) 55%, transparent); - border-radius: 4px; - background: transparent; - position: relative; - } - ul[data-type="taskList"] input[type="checkbox"]:checked { - background: var(--primary); - border-color: var(--primary); - } - ul[data-type="taskList"] input[type="checkbox"]:checked::after { - content: ""; - position: absolute; - left: 4px; - top: 1px; - width: 5px; - height: 9px; - border: solid var(--primary-foreground); - border-width: 0 2px 2px 0; - transform: rotate(45deg); - } - ul[data-type="taskList"] input[type="checkbox"]:disabled { - opacity: 0.65; - } - ul[data-type="taskList"] > li > div { - flex: 1; - min-width: 0; - } - ul[data-type="taskList"] > li[data-checked="true"] > div { - text-decoration: line-through; - color: var(--muted-foreground); - } - blockquote { - padding: 0.5em 1em; - border-left: 3px solid var(--border); - border-radius: 0 6px 6px 0; - color: var(--muted-foreground); - background: color-mix(in srgb, var(--foreground) 2%, transparent); - } - table { - width: 100%; - margin: 1em 0; - border-collapse: collapse; - font-size: 0.95em; - } - th, td { - padding: 8px 14px; - border: 1px solid var(--border); - text-align: left; - vertical-align: top; - } - th { - font-weight: 600; - background: color-mix(in srgb, var(--foreground) 4%, transparent); - } - tr:nth-child(odd) td { - background: color-mix(in srgb, var(--foreground) 1.5%, transparent); - } - code { - padding: 0.2em 0.4em; - border-radius: 5px; - background: color-mix(in srgb, var(--foreground) 8%, transparent); - font-size: 0.88em; - font-family: var(--font-mono); - } - pre { - margin: 0.75em 0; - padding: 14px 18px; - border-radius: 8px; - border: 1px solid color-mix(in srgb, var(--foreground) 6%, transparent); - overflow-x: auto; - line-height: 1.55; - background: color-mix(in srgb, var(--foreground) 6%, transparent); - font-family: var(--font-mono); - white-space: pre-wrap; - } - pre::before { - content: attr(data-language); - display: block; - min-height: 13px; - margin-bottom: 4px; - color: var(--muted-foreground); - font-size: 11px; - text-transform: uppercase; - } - pre code { - padding: 0; - border-radius: 0; - background: transparent; - font-size: 0.92em; - white-space: pre-wrap; - } - hr { - margin: 1.5em 0; - border: none; - border-top: 1px solid var(--border); - } - a { - color: var(--accent-link); - text-decoration: underline; - text-decoration-color: color-mix(in srgb, currentColor 40%, transparent); - text-underline-offset: 2px; - } - img { - display: block; - max-width: 100%; - margin: 0.75em 0; - border-radius: 8px; - } - - - -
- - -` + --accent-link: ${colors.accentBlue}${MOBILE_RICH_MARKDOWN_EDITOR_BODY_PRIMARY} +${MOBILE_RICH_MARKDOWN_EDITOR_BODY_SECONDARY}${MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT} +${MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT}${MOBILE_RICH_MARKDOWN_EDITOR_AFTER_KEYBOARD_DISMISS}${MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT}${MOBILE_RICH_MARKDOWN_EDITOR_DOCUMENT_END}` } diff --git a/mobile/src/pair-scan-styles.ts b/mobile/src/pair-scan-styles.ts new file mode 100644 index 00000000000..af1b936fb54 --- /dev/null +++ b/mobile/src/pair-scan-styles.ts @@ -0,0 +1,185 @@ +import { StyleSheet } from 'react-native' +import { colors, spacing, radii, typography } from '../src/theme/mobile-theme' + +export const pairScanStyles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase, + padding: spacing.lg + }, + backButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + marginBottom: spacing.sm + }, + steps: { + gap: spacing.sm, + marginBottom: spacing.lg, + marginLeft: 7 + }, + step: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + stepBadge: { + width: 22, + height: 22, + borderRadius: 11, + backgroundColor: colors.bgRaised, + alignItems: 'center', + justifyContent: 'center' + }, + stepNumber: { + fontSize: 12, + fontWeight: '700', + color: colors.textSecondary + }, + stepText: { + fontSize: typography.bodySize, + color: colors.textSecondary + }, + cameraWrap: { + flex: 1, + borderRadius: radii.camera, + overflow: 'hidden' + }, + // Why: holds the layout slot while the camera is unmounted during + // paste, so the bottom action button doesn't snap up to fill the + // empty space. + cameraPlaceholder: { + flex: 1, + backgroundColor: colors.bgPanel, + borderRadius: radii.camera + }, + camera: { + ...StyleSheet.absoluteFillObject + }, + reticle: { + ...StyleSheet.absoluteFillObject, + alignItems: 'center', + justifyContent: 'center' + }, + reticleFrame: { + position: 'relative' + }, + corner: { + position: 'absolute', + width: 28, + height: 28, + borderColor: 'rgba(255,255,255,0.7)' + }, + cornerTL: { + top: 0, + left: 0, + borderTopWidth: 2.5, + borderLeftWidth: 2.5, + borderTopLeftRadius: 6 + }, + cornerTR: { + top: 0, + right: 0, + borderTopWidth: 2.5, + borderRightWidth: 2.5, + borderTopRightRadius: 6 + }, + cornerBL: { + bottom: 0, + left: 0, + borderBottomWidth: 2.5, + borderLeftWidth: 2.5, + borderBottomLeftRadius: 6 + }, + cornerBR: { + bottom: 0, + right: 0, + borderBottomWidth: 2.5, + borderRightWidth: 2.5, + borderBottomRightRadius: 6 + }, + centered: { + flex: 1, + alignItems: 'center', + justifyContent: 'center' + }, + title: { + fontSize: typography.titleSize, + fontWeight: '600', + color: colors.textPrimary, + marginBottom: spacing.sm + }, + subtitle: { + maxWidth: 310, + fontSize: typography.bodySize, + color: colors.textSecondary, + textAlign: 'center', + marginBottom: spacing.xl, + lineHeight: 20 + }, + connectingText: { + color: colors.textSecondary, + fontSize: typography.bodySize, + marginTop: spacing.lg + }, + logSlot: { + width: '100%', + marginTop: spacing.lg, + paddingHorizontal: spacing.sm + }, + errorText: { + color: colors.statusRed, + fontSize: typography.bodySize, + textAlign: 'center', + marginBottom: spacing.xl, + lineHeight: 20 + }, + primaryButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.xs, + backgroundColor: colors.textPrimary, + paddingHorizontal: spacing.xl, + paddingVertical: spacing.sm + 2, + borderRadius: radii.button + }, + primaryButtonText: { + color: colors.bgBase, + fontSize: typography.bodySize, + fontWeight: '600' + }, + pasteButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.xs, + marginTop: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radii.button + }, + pasteButtonPressed: { + opacity: 0.6 + }, + pasteButtonText: { + color: colors.textSecondary, + fontSize: typography.bodySize, + fontWeight: '500' + }, + errorActions: { + alignItems: 'center', + gap: spacing.sm + }, + secondaryButton: { + paddingHorizontal: spacing.xl, + paddingVertical: spacing.sm, + borderRadius: radii.button + }, + secondaryButtonText: { + color: colors.textSecondary, + fontSize: typography.bodySize, + fontWeight: '500' + } +}) diff --git a/mobile/src/terminal/terminal-accessory-keys.ts b/mobile/src/terminal/terminal-accessory-keys.ts index 49aaba134d5..c02f265fba4 100644 --- a/mobile/src/terminal/terminal-accessory-keys.ts +++ b/mobile/src/terminal/terminal-accessory-keys.ts @@ -1,3 +1,9 @@ +import { + SPECIAL_KEY_LABELS, + TERMINAL_ACCESSORY_KEY_DEFINITIONS, + TERMINAL_SHORTCUT_SPECIAL_KEY_DEFINITIONS +} from './terminal-key-definitions' + export type TerminalAccessoryKey = { id: string label: string @@ -75,66 +81,6 @@ const CTRL_PRINTABLE_BYTES: Record = { '?': '\x7f' } -const SPECIAL_KEY_LABELS: Record = { - escape: 'Esc', - tab: 'Tab', - enter: 'Enter', - backspace: 'โŒซ', - delete: 'Del', - insert: 'Ins', - arrowUp: 'โ†‘', - arrowDown: 'โ†“', - arrowLeft: 'โ†', - arrowRight: 'โ†’', - home: 'Home', - end: 'End', - pageUp: 'PgUp', - pageDown: 'PgDn', - space: 'Space', - f1: 'F1', - f2: 'F2', - f3: 'F3', - f4: 'F4', - f5: 'F5', - f6: 'F6', - f7: 'F7', - f8: 'F8', - f9: 'F9', - f10: 'F10', - f11: 'F11', - f12: 'F12' -} - -const SPECIAL_KEY_ACCESSIBILITY_LABELS: Record = { - escape: 'Escape', - tab: 'Tab', - enter: 'Enter', - backspace: 'Backspace', - delete: 'Forward delete', - insert: 'Insert', - arrowUp: 'Arrow up', - arrowDown: 'Arrow down', - arrowLeft: 'Arrow left', - arrowRight: 'Arrow right', - home: 'Home', - end: 'End', - pageUp: 'Page up', - pageDown: 'Page down', - space: 'Space', - f1: 'F1', - f2: 'F2', - f3: 'F3', - f4: 'F4', - f5: 'F5', - f6: 'F6', - f7: 'F7', - f8: 'F8', - f9: 'F9', - f10: 'F10', - f11: 'F11', - f12: 'F12' -} - const CSI_FINAL_SPECIAL_KEYS: Record = { arrowUp: 'A', arrowDown: 'B', @@ -165,87 +111,9 @@ const CSI_TILDE_SPECIAL_KEYS: Record = { f12: 24 } -export const TERMINAL_SHORTCUT_SPECIAL_KEYS: TerminalShortcutSpecialKey[] = [ - 'escape', - 'tab', - 'enter', - 'backspace', - 'delete', - 'insert', - 'arrowUp', - 'arrowDown', - 'arrowLeft', - 'arrowRight', - 'home', - 'end', - 'pageUp', - 'pageDown', - 'space', - 'f1', - 'f2', - 'f3', - 'f4', - 'f5', - 'f6', - 'f7', - 'f8', - 'f9', - 'f10', - 'f11', - 'f12' -].map((id) => ({ - id, - label: SPECIAL_KEY_LABELS[id]!, - accessibilityLabel: SPECIAL_KEY_ACCESSIBILITY_LABELS[id]! -})) +export const TERMINAL_SHORTCUT_SPECIAL_KEYS = TERMINAL_SHORTCUT_SPECIAL_KEY_DEFINITIONS -export const TERMINAL_ACCESSORY_KEYS: TerminalAccessoryKey[] = [ - { id: 'escape', label: 'Esc', bytes: '\x1b', accessibilityLabel: 'Escape' }, - { id: 'tab', label: 'Tab', bytes: '\t', accessibilityLabel: 'Tab' }, - { id: 'enter', label: 'Enter', bytes: '\r', accessibilityLabel: 'Enter' }, - // Why: terminal apps recognize ESC [ Z as the reverse-tab sequence. - { id: 'shiftTab', label: 'Shift+Tab', bytes: '\x1b[Z', accessibilityLabel: 'Shift Tab' }, - { id: 'space', label: 'Space', bytes: ' ', accessibilityLabel: 'Space' }, - { id: 'backspace', label: 'โŒซ', bytes: '\x7f', accessibilityLabel: 'Backspace', repeatable: true }, - { - id: 'delete', - label: 'Del', - bytes: '\x1b[3~', - accessibilityLabel: 'Forward delete', - repeatable: true - }, - { id: 'arrowUp', label: 'โ†‘', bytes: '\x1b[A', accessibilityLabel: 'Arrow Up', repeatable: true }, - { - id: 'arrowDown', - label: 'โ†“', - bytes: '\x1b[B', - accessibilityLabel: 'Arrow Down', - repeatable: true - }, - { - id: 'arrowLeft', - label: 'โ†', - bytes: '\x1b[D', - accessibilityLabel: 'Arrow Left', - repeatable: true - }, - { - id: 'arrowRight', - label: 'โ†’', - bytes: '\x1b[C', - accessibilityLabel: 'Arrow Right', - repeatable: true - }, - { id: 'ctrlC', label: 'Ctrl+C', bytes: '\x03', accessibilityLabel: 'Interrupt terminal' }, - { id: 'ctrlD', label: 'Ctrl+D', bytes: '\x04', accessibilityLabel: 'Send EOF' }, - { id: 'ctrlL', label: 'Ctrl+L', bytes: '\x0c', accessibilityLabel: 'Clear screen' }, - { id: 'ctrlZ', label: 'Ctrl+Z', bytes: '\x1a', accessibilityLabel: 'Suspend process' }, - { id: 'ctrlR', label: 'Ctrl+R', bytes: '\x12', accessibilityLabel: 'Reverse search' }, - { id: 'ctrlA', label: 'Ctrl+A', bytes: '\x01', accessibilityLabel: 'Start of line' }, - { id: 'ctrlE', label: 'Ctrl+E', bytes: '\x05', accessibilityLabel: 'End of line' }, - { id: 'ctrlW', label: 'Ctrl+W', bytes: '\x17', accessibilityLabel: 'Delete word backward' }, - { id: 'ctrlU', label: 'Ctrl+U', bytes: '\x15', accessibilityLabel: 'Clear line before cursor' } -] +export const TERMINAL_ACCESSORY_KEYS = TERMINAL_ACCESSORY_KEY_DEFINITIONS export function buildTerminalShortcutKey( binding: TerminalShortcutBinding diff --git a/mobile/src/terminal/terminal-key-definitions.ts b/mobile/src/terminal/terminal-key-definitions.ts new file mode 100644 index 00000000000..e6aeb63d45c --- /dev/null +++ b/mobile/src/terminal/terminal-key-definitions.ts @@ -0,0 +1,143 @@ +import type { TerminalAccessoryKey, TerminalShortcutSpecialKey } from './terminal-accessory-keys' + +export const SPECIAL_KEY_LABELS: Record = { + escape: 'Esc', + tab: 'Tab', + enter: 'Enter', + backspace: 'โŒซ', + delete: 'Del', + insert: 'Ins', + arrowUp: 'โ†‘', + arrowDown: 'โ†“', + arrowLeft: 'โ†', + arrowRight: 'โ†’', + home: 'Home', + end: 'End', + pageUp: 'PgUp', + pageDown: 'PgDn', + space: 'Space', + f1: 'F1', + f2: 'F2', + f3: 'F3', + f4: 'F4', + f5: 'F5', + f6: 'F6', + f7: 'F7', + f8: 'F8', + f9: 'F9', + f10: 'F10', + f11: 'F11', + f12: 'F12' +} + +const SPECIAL_KEY_ACCESSIBILITY_LABELS: Record = { + escape: 'Escape', + tab: 'Tab', + enter: 'Enter', + backspace: 'Backspace', + delete: 'Forward delete', + insert: 'Insert', + arrowUp: 'Arrow up', + arrowDown: 'Arrow down', + arrowLeft: 'Arrow left', + arrowRight: 'Arrow right', + home: 'Home', + end: 'End', + pageUp: 'Page up', + pageDown: 'Page down', + space: 'Space', + f1: 'F1', + f2: 'F2', + f3: 'F3', + f4: 'F4', + f5: 'F5', + f6: 'F6', + f7: 'F7', + f8: 'F8', + f9: 'F9', + f10: 'F10', + f11: 'F11', + f12: 'F12' +} + +export const TERMINAL_SHORTCUT_SPECIAL_KEY_DEFINITIONS: TerminalShortcutSpecialKey[] = [ + 'escape', + 'tab', + 'enter', + 'backspace', + 'delete', + 'insert', + 'arrowUp', + 'arrowDown', + 'arrowLeft', + 'arrowRight', + 'home', + 'end', + 'pageUp', + 'pageDown', + 'space', + 'f1', + 'f2', + 'f3', + 'f4', + 'f5', + 'f6', + 'f7', + 'f8', + 'f9', + 'f10', + 'f11', + 'f12' +].map((id) => ({ + id, + label: SPECIAL_KEY_LABELS[id]!, + accessibilityLabel: SPECIAL_KEY_ACCESSIBILITY_LABELS[id]! +})) + +export const TERMINAL_ACCESSORY_KEY_DEFINITIONS: TerminalAccessoryKey[] = [ + { id: 'escape', label: 'Esc', bytes: '\x1b', accessibilityLabel: 'Escape' }, + { id: 'tab', label: 'Tab', bytes: '\t', accessibilityLabel: 'Tab' }, + { id: 'enter', label: 'Enter', bytes: '\r', accessibilityLabel: 'Enter' }, + // Why: terminal apps recognize ESC [ Z as the reverse-tab sequence. + { id: 'shiftTab', label: 'Shift+Tab', bytes: '\x1b[Z', accessibilityLabel: 'Shift Tab' }, + { id: 'space', label: 'Space', bytes: ' ', accessibilityLabel: 'Space' }, + { id: 'backspace', label: 'โŒซ', bytes: '\x7f', accessibilityLabel: 'Backspace', repeatable: true }, + { + id: 'delete', + label: 'Del', + bytes: '\x1b[3~', + accessibilityLabel: 'Forward delete', + repeatable: true + }, + { id: 'arrowUp', label: 'โ†‘', bytes: '\x1b[A', accessibilityLabel: 'Arrow Up', repeatable: true }, + { + id: 'arrowDown', + label: 'โ†“', + bytes: '\x1b[B', + accessibilityLabel: 'Arrow Down', + repeatable: true + }, + { + id: 'arrowLeft', + label: 'โ†', + bytes: '\x1b[D', + accessibilityLabel: 'Arrow Left', + repeatable: true + }, + { + id: 'arrowRight', + label: 'โ†’', + bytes: '\x1b[C', + accessibilityLabel: 'Arrow Right', + repeatable: true + }, + { id: 'ctrlC', label: 'Ctrl+C', bytes: '\x03', accessibilityLabel: 'Interrupt terminal' }, + { id: 'ctrlD', label: 'Ctrl+D', bytes: '\x04', accessibilityLabel: 'Send EOF' }, + { id: 'ctrlL', label: 'Ctrl+L', bytes: '\x0c', accessibilityLabel: 'Clear screen' }, + { id: 'ctrlZ', label: 'Ctrl+Z', bytes: '\x1a', accessibilityLabel: 'Suspend process' }, + { id: 'ctrlR', label: 'Ctrl+R', bytes: '\x12', accessibilityLabel: 'Reverse search' }, + { id: 'ctrlA', label: 'Ctrl+A', bytes: '\x01', accessibilityLabel: 'Start of line' }, + { id: 'ctrlE', label: 'Ctrl+E', bytes: '\x05', accessibilityLabel: 'End of line' }, + { id: 'ctrlW', label: 'Ctrl+W', bytes: '\x17', accessibilityLabel: 'Delete word backward' }, + { id: 'ctrlU', label: 'Ctrl+U', bytes: '\x15', accessibilityLabel: 'Clear line before cursor' } +] diff --git a/src/cli/handlers/automation-handler-flags.ts b/src/cli/handlers/automation-handler-flags.ts new file mode 100644 index 00000000000..c67f592ab7c --- /dev/null +++ b/src/cli/handlers/automation-handler-flags.ts @@ -0,0 +1,308 @@ +import type { AutomationPrecheck, AutomationSchedulePreset } from '../../shared/automations-types' +import { + normalizeTaskSourceContext, + type TaskSourceContext +} from '../../shared/task-source-context' +import type { TuiAgent } from '../../shared/tui-agent' +import { + DEFAULT_AUTOMATION_PRECHECK_TIMEOUT_SECONDS, + MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS +} from '../../shared/automation-precheck' +import { buildAutomationRrule } from '../../shared/automation-schedule-occurrences' +import { isValidAutomationSchedule } from '../../shared/automation-schedule-parsing' +import { isTuiAgent } from '../../shared/tui-agent-config' +import { + getOptionalPositiveIntegerFlag, + getOptionalStringFlag, + getRequiredStringFlag +} from '../flags' +import { RuntimeClientError } from '../runtime-client' + +const PRESET_TRIGGERS = new Set(['hourly', 'daily', 'weekdays', 'weekly']) +const SCHEDULE_MODIFIER_FLAGS = ['day', 'time'] as const + +function getScheduleModifierFlag(flags: Map): string | undefined { + return SCHEDULE_MODIFIER_FLAGS.find((flag) => flags.has(flag)) +} + +function validateScheduleModifierApplicability( + flags: Map, + raw: string +): void { + const isPreset = PRESET_TRIGGERS.has(raw as AutomationSchedulePreset) + if (!isPreset) { + if (flags.has('time')) { + throw new RuntimeClientError( + 'invalid_argument', + '--time can only be used with preset automation triggers' + ) + } + if (flags.has('day')) { + throw new RuntimeClientError( + 'invalid_argument', + '--day can only be used with the weekly automation preset' + ) + } + return + } + if (raw === 'hourly' && flags.has('time')) { + throw new RuntimeClientError( + 'invalid_argument', + '--time cannot be used with the hourly automation preset; use a cron trigger such as "30 * * * *" to choose the minute' + ) + } + if (raw !== 'weekly' && flags.has('day')) { + throw new RuntimeClientError( + 'invalid_argument', + '--day can only be used with the weekly automation preset' + ) + } +} + +function getOptionalDayFlag(flags: Map): number | undefined { + const raw = flags.get('day') + if (raw === undefined) { + return undefined + } + const day = typeof raw === 'string' && /^\d+$/.test(raw) ? Number(raw) : Number.NaN + if (!Number.isInteger(day) || day < 0 || day > 6) { + throw new RuntimeClientError('invalid_argument', '--day must be an integer from 0 to 6') + } + return day +} + +export function getProviderFlag(flags: Map): TuiAgent { + const provider = getRequiredStringFlag(flags, 'provider') + if (!isTuiAgent(provider)) { + throw new RuntimeClientError('invalid_argument', `Unknown provider: ${provider}`) + } + return provider +} + +export function getOptionalProviderFlag( + flags: Map +): TuiAgent | undefined { + const provider = getOptionalStringFlag(flags, 'provider') + if (provider === undefined) { + return undefined + } + if (!isTuiAgent(provider)) { + throw new RuntimeClientError('invalid_argument', `Unknown provider: ${provider}`) + } + return provider +} + +function getTimeFlag(flags: Map): { hour: number; minute: number } { + const value = flags.get('time') + if (value === undefined) { + return { hour: 9, minute: 0 } + } + if (typeof value !== 'string' || value.length === 0) { + throw new RuntimeClientError('invalid_argument', '--time must use HH:MM format') + } + return parseTimeFlag(value) +} + +function parseTimeFlag(value: string): { hour: number; minute: number } { + const match = /^(\d{1,2}):(\d{2})$/.exec(value) + if (!match) { + throw new RuntimeClientError('invalid_argument', '--time must use HH:MM format') + } + const hour = Number(match[1]) + const minute = Number(match[2]) + if (!Number.isInteger(hour) || hour < 0 || hour > 23 || minute < 0 || minute > 59) { + throw new RuntimeClientError('invalid_argument', '--time must be a valid 24-hour time') + } + return { hour, minute } +} + +export function getScheduleFlag( + flags: Map, + required: boolean +): { rrule: string; dtstart: number } | undefined { + const trigger = getOptionalStringFlag(flags, 'trigger') + const schedule = getOptionalStringFlag(flags, 'schedule') + if (trigger && schedule) { + throw new RuntimeClientError( + 'invalid_argument', + 'Use either --trigger or --schedule, not both.' + ) + } + const raw = trigger ?? schedule + if (!raw) { + const modifier = getScheduleModifierFlag(flags) + if (modifier) { + throw new RuntimeClientError( + 'invalid_argument', + `--${modifier} requires --trigger or --schedule` + ) + } + if (required) { + throw new RuntimeClientError('invalid_argument', 'Missing required --trigger') + } + return undefined + } + if (raw === 'manual') { + throw new RuntimeClientError( + 'invalid_argument', + 'Manual-only automations are not supported yet. Create a scheduled automation with --disabled and run it with `orca automations run ` when needed.' + ) + } + validateScheduleModifierApplicability(flags, raw) + const { hour, minute } = raw === 'hourly' ? { hour: 0, minute: 0 } : getTimeFlag(flags) + const dayOfWeek = raw === 'weekly' ? (getOptionalDayFlag(flags) ?? 1) : 1 + const rrule = PRESET_TRIGGERS.has(raw as AutomationSchedulePreset) + ? buildAutomationRrule({ + preset: raw as Exclude, + hour, + minute, + dayOfWeek + }) + : raw + if (!isValidAutomationSchedule(rrule)) { + throw new RuntimeClientError('invalid_argument', `Invalid automation trigger: ${raw}`) + } + return { rrule, dtstart: Date.now() } +} + +export function getEnabledFlag(flags: Map): boolean | undefined { + const enabledFlag = flags.get('enabled') + const disabledFlag = flags.get('disabled') + if (typeof enabledFlag === 'string') { + throw new RuntimeClientError('invalid_argument', '--enabled does not take a value') + } + if (typeof disabledFlag === 'string') { + throw new RuntimeClientError('invalid_argument', '--disabled does not take a value') + } + const enabled = enabledFlag === true + const disabled = disabledFlag === true + if (enabled && disabled) { + throw new RuntimeClientError( + 'invalid_argument', + 'Use either --enabled or --disabled, not both.' + ) + } + if (enabled) { + return true + } + if (disabled) { + return false + } + return undefined +} + +export function getReuseSessionFlag(flags: Map): boolean | undefined { + const reuseFlag = flags.get('reuse-session') + const freshFlag = flags.get('fresh-session') + if (typeof reuseFlag === 'string') { + throw new RuntimeClientError('invalid_argument', '--reuse-session does not take a value') + } + if (typeof freshFlag === 'string') { + throw new RuntimeClientError('invalid_argument', '--fresh-session does not take a value') + } + const reuse = reuseFlag === true + const fresh = freshFlag === true + if (reuse && fresh) { + throw new RuntimeClientError( + 'invalid_argument', + 'Use either --reuse-session or --fresh-session, not both.' + ) + } + if (reuse) { + return true + } + if (fresh) { + return false + } + return undefined +} + +export function getPrecheckFlag( + flags: Map +): AutomationPrecheck | null | undefined { + const hasPrecheck = flags.has('precheck') + const timeoutSeconds = getOptionalPositiveIntegerFlag(flags, 'precheck-timeout') + if (!hasPrecheck) { + if (timeoutSeconds !== undefined) { + throw new RuntimeClientError('invalid_argument', '--precheck-timeout requires --precheck') + } + return undefined + } + const value = flags.get('precheck') + if (typeof value !== 'string') { + throw new RuntimeClientError('invalid_argument', '--precheck requires a command') + } + const command = value.trim() + if (!command) { + return null + } + if (timeoutSeconds !== undefined && timeoutSeconds > MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS) { + throw new RuntimeClientError( + 'invalid_argument', + `--precheck-timeout must be at most ${MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS} seconds` + ) + } + return { + command, + timeoutSeconds: timeoutSeconds ?? DEFAULT_AUTOMATION_PRECHECK_TIMEOUT_SECONDS + } +} + +export function getSourceContextFlag( + flags: Map +): TaskSourceContext | null | undefined { + if (!flags.has('source-context')) { + return undefined + } + const value = flags.get('source-context') + if (typeof value !== 'string') { + throw new RuntimeClientError( + 'invalid_argument', + '--source-context requires a JSON TaskSourceContext or null' + ) + } + let parsed: unknown + try { + parsed = JSON.parse(value) + } catch { + throw new RuntimeClientError('invalid_argument', '--source-context must be valid JSON') + } + if (parsed === null) { + return null + } + if (!parsed || typeof parsed !== 'object') { + throw new RuntimeClientError( + 'invalid_argument', + '--source-context must be a JSON TaskSourceContext or null' + ) + } + const sourceContext = normalizeTaskSourceContext( + parsed as Parameters[0] + ) + if (!sourceContext) { + throw new RuntimeClientError( + 'invalid_argument', + '--source-context is not a valid TaskSourceContext' + ) + } + return sourceContext +} + +export function getWorkspaceModeFlag( + flags: Map +): 'existing' | 'new_per_run' | undefined { + const value = getOptionalStringFlag(flags, 'workspace-mode') + if (value === undefined) { + return undefined + } + if (value === 'existing') { + return 'existing' + } + if (value === 'new-per-run' || value === 'new_per_run') { + return 'new_per_run' + } + throw new RuntimeClientError( + 'invalid_argument', + '--workspace-mode must be existing or new-per-run' + ) +} diff --git a/src/cli/handlers/automations.ts b/src/cli/handlers/automations.ts index d413b8a2a79..a9a14cc7d78 100644 --- a/src/cli/handlers/automations.ts +++ b/src/cli/handlers/automations.ts @@ -1,16 +1,11 @@ -/* eslint-disable max-lines -- Why: automation handlers share schedule parsing, target resolution, and RPC payload shaping for one command family. */ import type { Automation, AutomationCreateInput, - AutomationPrecheck, AutomationRun, - AutomationSchedulePreset, AutomationUpdateInput } from '../../shared/automations-types' import { buildWorkspaceRunContext, - normalizeTaskSourceContext, - type TaskSourceContext, type WorkspaceRunContext } from '../../shared/task-source-context' import type { @@ -18,13 +13,6 @@ import type { AutomationOwnerPrecondition } from '../../shared/automation-owner-precondition' import type { ProjectHostSetup } from '../../shared/project-types' -import type { TuiAgent } from '../../shared/tui-agent' -import { - DEFAULT_AUTOMATION_PRECHECK_TIMEOUT_SECONDS, - MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS -} from '../../shared/automation-precheck' -import { buildAutomationRrule, isValidAutomationSchedule } from '../../shared/automation-schedules' -import { isTuiAgent } from '../../shared/tui-agent-config' import type { CommandHandler } from '../dispatch' import { formatAutomationList, @@ -48,6 +36,16 @@ import { hasWorkspaceProjectTarget, resolveProjectCreateTarget } from '../worktree-project-target' +import { + getEnabledFlag, + getOptionalProviderFlag, + getPrecheckFlag, + getProviderFlag, + getReuseSessionFlag, + getScheduleFlag, + getSourceContextFlag, + getWorkspaceModeFlag +} from './automation-handler-flags' type AutomationCreateParams = Omit & { destination?: AutomationDestination @@ -61,293 +59,6 @@ type AutomationUpdateParams = AutomationUpdateInput & { workspace?: string } -const PRESET_TRIGGERS = new Set(['hourly', 'daily', 'weekdays', 'weekly']) -const SCHEDULE_MODIFIER_FLAGS = ['day', 'time'] as const - -function getScheduleModifierFlag(flags: Map): string | undefined { - return SCHEDULE_MODIFIER_FLAGS.find((flag) => flags.has(flag)) -} - -function validateScheduleModifierApplicability( - flags: Map, - raw: string -): void { - const isPreset = PRESET_TRIGGERS.has(raw as AutomationSchedulePreset) - if (!isPreset) { - if (flags.has('time')) { - throw new RuntimeClientError( - 'invalid_argument', - '--time can only be used with preset automation triggers' - ) - } - if (flags.has('day')) { - throw new RuntimeClientError( - 'invalid_argument', - '--day can only be used with the weekly automation preset' - ) - } - return - } - if (raw === 'hourly' && flags.has('time')) { - throw new RuntimeClientError( - 'invalid_argument', - '--time cannot be used with the hourly automation preset; use a cron trigger such as "30 * * * *" to choose the minute' - ) - } - if (raw !== 'weekly' && flags.has('day')) { - throw new RuntimeClientError( - 'invalid_argument', - '--day can only be used with the weekly automation preset' - ) - } -} - -function getOptionalDayFlag(flags: Map): number | undefined { - const raw = flags.get('day') - if (raw === undefined) { - return undefined - } - const day = typeof raw === 'string' && /^\d+$/.test(raw) ? Number(raw) : Number.NaN - if (!Number.isInteger(day) || day < 0 || day > 6) { - throw new RuntimeClientError('invalid_argument', '--day must be an integer from 0 to 6') - } - return day -} - -function getProviderFlag(flags: Map): TuiAgent { - const provider = getRequiredStringFlag(flags, 'provider') - if (!isTuiAgent(provider)) { - throw new RuntimeClientError('invalid_argument', `Unknown provider: ${provider}`) - } - return provider -} - -function getOptionalProviderFlag(flags: Map): TuiAgent | undefined { - const provider = getOptionalStringFlag(flags, 'provider') - if (provider === undefined) { - return undefined - } - if (!isTuiAgent(provider)) { - throw new RuntimeClientError('invalid_argument', `Unknown provider: ${provider}`) - } - return provider -} - -function getTimeFlag(flags: Map): { hour: number; minute: number } { - const value = flags.get('time') - if (value === undefined) { - return { hour: 9, minute: 0 } - } - if (typeof value !== 'string' || value.length === 0) { - throw new RuntimeClientError('invalid_argument', '--time must use HH:MM format') - } - return parseTimeFlag(value) -} - -function parseTimeFlag(value: string): { hour: number; minute: number } { - const match = /^(\d{1,2}):(\d{2})$/.exec(value) - if (!match) { - throw new RuntimeClientError('invalid_argument', '--time must use HH:MM format') - } - const hour = Number(match[1]) - const minute = Number(match[2]) - if (!Number.isInteger(hour) || hour < 0 || hour > 23 || minute < 0 || minute > 59) { - throw new RuntimeClientError('invalid_argument', '--time must be a valid 24-hour time') - } - return { hour, minute } -} - -function getScheduleFlag( - flags: Map, - required: boolean -): { rrule: string; dtstart: number } | undefined { - const trigger = getOptionalStringFlag(flags, 'trigger') - const schedule = getOptionalStringFlag(flags, 'schedule') - if (trigger && schedule) { - throw new RuntimeClientError( - 'invalid_argument', - 'Use either --trigger or --schedule, not both.' - ) - } - const raw = trigger ?? schedule - if (!raw) { - const modifier = getScheduleModifierFlag(flags) - if (modifier) { - throw new RuntimeClientError( - 'invalid_argument', - `--${modifier} requires --trigger or --schedule` - ) - } - if (required) { - throw new RuntimeClientError('invalid_argument', 'Missing required --trigger') - } - return undefined - } - if (raw === 'manual') { - throw new RuntimeClientError( - 'invalid_argument', - 'Manual-only automations are not supported yet. Create a scheduled automation with --disabled and run it with `orca automations run ` when needed.' - ) - } - validateScheduleModifierApplicability(flags, raw) - const { hour, minute } = raw === 'hourly' ? { hour: 0, minute: 0 } : getTimeFlag(flags) - const dayOfWeek = raw === 'weekly' ? (getOptionalDayFlag(flags) ?? 1) : 1 - const rrule = PRESET_TRIGGERS.has(raw as AutomationSchedulePreset) - ? buildAutomationRrule({ - preset: raw as Exclude, - hour, - minute, - dayOfWeek - }) - : raw - if (!isValidAutomationSchedule(rrule)) { - throw new RuntimeClientError('invalid_argument', `Invalid automation trigger: ${raw}`) - } - return { rrule, dtstart: Date.now() } -} - -function getEnabledFlag(flags: Map): boolean | undefined { - const enabledFlag = flags.get('enabled') - const disabledFlag = flags.get('disabled') - if (typeof enabledFlag === 'string') { - throw new RuntimeClientError('invalid_argument', '--enabled does not take a value') - } - if (typeof disabledFlag === 'string') { - throw new RuntimeClientError('invalid_argument', '--disabled does not take a value') - } - const enabled = enabledFlag === true - const disabled = disabledFlag === true - if (enabled && disabled) { - throw new RuntimeClientError( - 'invalid_argument', - 'Use either --enabled or --disabled, not both.' - ) - } - if (enabled) { - return true - } - if (disabled) { - return false - } - return undefined -} - -function getReuseSessionFlag(flags: Map): boolean | undefined { - const reuseFlag = flags.get('reuse-session') - const freshFlag = flags.get('fresh-session') - if (typeof reuseFlag === 'string') { - throw new RuntimeClientError('invalid_argument', '--reuse-session does not take a value') - } - if (typeof freshFlag === 'string') { - throw new RuntimeClientError('invalid_argument', '--fresh-session does not take a value') - } - const reuse = reuseFlag === true - const fresh = freshFlag === true - if (reuse && fresh) { - throw new RuntimeClientError( - 'invalid_argument', - 'Use either --reuse-session or --fresh-session, not both.' - ) - } - if (reuse) { - return true - } - if (fresh) { - return false - } - return undefined -} - -function getPrecheckFlag( - flags: Map -): AutomationPrecheck | null | undefined { - const hasPrecheck = flags.has('precheck') - const timeoutSeconds = getOptionalPositiveIntegerFlag(flags, 'precheck-timeout') - if (!hasPrecheck) { - if (timeoutSeconds !== undefined) { - throw new RuntimeClientError('invalid_argument', '--precheck-timeout requires --precheck') - } - return undefined - } - const value = flags.get('precheck') - if (typeof value !== 'string') { - throw new RuntimeClientError('invalid_argument', '--precheck requires a command') - } - const command = value.trim() - if (!command) { - return null - } - if (timeoutSeconds !== undefined && timeoutSeconds > MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS) { - throw new RuntimeClientError( - 'invalid_argument', - `--precheck-timeout must be at most ${MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS} seconds` - ) - } - return { - command, - timeoutSeconds: timeoutSeconds ?? DEFAULT_AUTOMATION_PRECHECK_TIMEOUT_SECONDS - } -} - -function getSourceContextFlag( - flags: Map -): TaskSourceContext | null | undefined { - if (!flags.has('source-context')) { - return undefined - } - const value = flags.get('source-context') - if (typeof value !== 'string') { - throw new RuntimeClientError( - 'invalid_argument', - '--source-context requires a JSON TaskSourceContext or null' - ) - } - let parsed: unknown - try { - parsed = JSON.parse(value) - } catch { - throw new RuntimeClientError('invalid_argument', '--source-context must be valid JSON') - } - if (parsed === null) { - return null - } - if (!parsed || typeof parsed !== 'object') { - throw new RuntimeClientError( - 'invalid_argument', - '--source-context must be a JSON TaskSourceContext or null' - ) - } - const sourceContext = normalizeTaskSourceContext( - parsed as Parameters[0] - ) - if (!sourceContext) { - throw new RuntimeClientError( - 'invalid_argument', - '--source-context is not a valid TaskSourceContext' - ) - } - return sourceContext -} - -function getWorkspaceModeFlag( - flags: Map -): 'existing' | 'new_per_run' | undefined { - const value = getOptionalStringFlag(flags, 'workspace-mode') - if (value === undefined) { - return undefined - } - if (value === 'existing') { - return 'existing' - } - if (value === 'new-per-run' || value === 'new_per_run') { - return 'new_per_run' - } - throw new RuntimeClientError( - 'invalid_argument', - '--workspace-mode must be existing or new-per-run' - ) -} - async function resolveDefaultTarget( flags: Map, cwd: string, diff --git a/src/cli/help.ts b/src/cli/help.ts index 22272a2c28d..7a6b3686123 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -1,371 +1,10 @@ -/* eslint-disable max-lines -- Why: root and generated command help text live together so CLI discovery stays greppable. */ import type { CommandSpec } from './args' import { findCommandSpec, isCommandGroup, supportsBrowserPageFlag } from './args' import { unknownCommandData } from './command-suggestion' +import { ROOT_HELP_TEXT_PRIMARY } from './root-help-text-primary' +import { ROOT_HELP_TEXT_SECONDARY } from './root-help-text-secondary' -const ROOT_HELP_TEXT = `orca - -Usage: orca [options] - -Startup: - open Launch Orca and wait for the runtime to be reachable - serve Start a headless Orca runtime server - status Show app/runtime/graph readiness - -Diagnostics: - diagnostics memory Collect a memory snapshot for Orca and managed terminals - -Agent Discovery: - agent-context Print the machine-readable command schema for agents - -Accounts: - account add Add a managed Claude or Codex account on this Orca host - account list List managed Claude and Codex accounts on this Orca host - -Skills: - skills installed List installed skill selectors - skills share Publish selected skills behind one unlisted link - skills list List version-matched skill guides bundled with this Orca CLI - skills get Print a version-matched skill guide as Markdown - skills install Install bundled Orca skills globally via the community skills CLI - skills update Update already-installed Orca skills via the community skills CLI - -Hosts: - host list List targetable machines and how to name each one - -Environments: - environment add Save a remote Orca runtime from a pairing code - environment list List saved remote Orca runtimes - environment show Show one saved remote Orca runtime - environment rm Remove a saved remote Orca runtime - -Environment Recipes: - vm recipe doctor Validate a per-workspace environment recipe - -Automations: - automations list List scheduled Orca automations - automations show Show one Orca automation - automations create Create a scheduled Orca automation - automations edit Edit an Orca automation - automations remove Remove an Orca automation and its run history - automations run Run an Orca automation now - automations runs List automation run history - -Projects: - project list List durable projects known to Orca - project setups List project host setups - project setup-existing-folder Make a project available on a host by importing an existing folder - project setup-clone Make a project available on a host by cloning a repository - project setup-create Create independent project host setup metadata - project setup-update Update project host setup metadata - project setup-delete Remove a project host setup - -Repos: - repo list List repos registered in Orca - repo add Add a project to Orca by filesystem path - repo show Show one registered repo - repo set-base-ref Set the repo's default base ref for future worktrees - repo search-refs Search branch/tag refs within a repo - -Worktrees: - worktree list List Orca-managed worktrees - worktree show Show one worktree - worktree current Show the Orca-managed worktree for the current directory - worktree create Create a new Orca-managed worktree - worktree set Update Orca metadata for a worktree - worktree rm Remove a worktree from Orca and git - worktree ps Show a compact orchestration summary across worktrees - -Files: - file open Open a workspace file in the Orca editor - file diff Open a workspace file diff in the Orca editor - file open-changed Open all git-changed files for a workspace - -Terminals: - terminal list List live Orca-managed terminals - terminal show Show terminal metadata and preview - terminal read Read bounded terminal output - terminal send Send input to a live terminal - terminal wait Wait for a terminal condition (exit, tui-idle) - terminal stop Stop terminals for a worktree - terminal create Create a terminal session in a worktree - terminal rename Set or clear the title of a terminal tab - terminal split Split an existing terminal pane - terminal switch Bring a terminal tab to the foreground - terminal focus Alias for terminal switch - terminal close Close a terminal pane/session, or its whole tab with --tab - -Orchestration: - orchestration run-create Create and bind a lightweight orchestration Run - orchestration run-use Bind this coordinator terminal to an existing Run - orchestration run-current Show this terminal's bound Run - orchestration run-list List lightweight orchestration Runs - orchestration run-show Show one lightweight orchestration Run - orchestration send Send an inter-agent message - orchestration check Check the bound Run mailbox - orchestration ask Ask the coordinator a blocking question - orchestration reply Reply to a message - orchestration inbox Show all messages across recipients - orchestration task-create Create an orchestration task - orchestration task-list List orchestration tasks - orchestration task-update Update a task status - orchestration dispatch Dispatch a task to a terminal - orchestration dispatch-show Show dispatch context for a task - orchestration worker-start Start a supervised worker locally or on a connected Orca server - orchestration worker-show Inspect one supervised worker - orchestration worker-read Read bounded output from one supervised worker - orchestration worker-stop Fence one Dispatch; stop only its supervised worker - orchestration worker-abandon Fence an uncertain worker without claiming it stopped - orchestration worker-release Release a settled worker's terminal after archiving its output - orchestration worker-retain Keep a worker terminal live for debugging - orchestration worker-list Report worker terminal resource accounting - orchestration coordinator-start Start the legacy automatic coordinator loop - orchestration coordinator-stop Stop the legacy automatic coordinator loop - orchestration gate-create Create a decision gate blocking a task - orchestration gate-resolve Resolve a pending decision gate - orchestration gate-list List decision gates - orchestration reset Reset orchestration state - -Computer Use: - computer capabilities Show computer-use provider capabilities - computer permissions Show or open computer-use permission setup - computer list-apps List running apps available to computer-use - computer list-windows List visible windows for a target app - computer get-app-state Capture a compact accessibility snapshot of an app - computer click Click an app element or window coordinate - computer perform-secondary-action Run an advertised accessibility action - computer scroll Scroll an app element - computer drag Drag between app elements or window coordinates - computer type-text Type literal text at the current app focus - computer press-key Press a single key such as Return or Escape - computer hotkey Press a shortcut combination such as CmdOrCtrl+A - computer paste-text Paste text through the native clipboard path - computer set-value Set the value of a settable app element - -Linear: - linear Read Linear ticket context for agents - -Mobile Emulator (iOS Simulator): - emulator list List available/running emulators (Orca-managed + raw serve-sim) - emulator attach Attach/start helper and make active for the worktree - emulator tap Tap at normalized 0..1 coords (preferred for single taps) - emulator type Type text (US ASCII only) - emulator gesture Send begin/move/end touch points - emulator button Hardware button (home, side_button, etc.) - emulator rotate Rotate device (portrait|landscape_left|...) - emulator exec --command Raw serve-sim subcommand passthrough (no "serve-sim" prefix) - emulator kill Stop helper for device - -Browser Automation: - tab create Create a new browser tab (navigates to --url) - tab list List open browser tabs - tab show Show one browser tab by page id - tab current Show the current browser tab - tab profile list List browser session profiles - tab profile create Create a browser session profile - tab profile delete Delete a browser session profile - tab profile set Switch a browser tab to a different profile - tab profile show Show the profile bound to a browser tab - tab profile use-default Switch a browser tab back to the default profile - tab profile clone Clone a browser tab into another profile - tab switch Switch the active browser tab by --index or --page - tab close Close a browser tab by --index/--page or the current tab - snapshot Accessibility snapshot with element refs (e.g. @e1, @e2) - goto Navigate the active tab to --url - click Click element by --element ref - fill Clear and fill input by --element ref with --value - type Type --input text at the current focus (no element needed) - select Select dropdown option by --element ref and --value - hover Hover element by --element ref - keypress Press a key (e.g. --key Enter, --key Tab) - scroll Scroll --direction (up/down) by --amount pixels - back Navigate back in browser history - reload Reload the active browser tab - screenshot Capture viewport screenshot (--format png|jpeg) - eval Evaluate --expression JavaScript in the page context - wait Wait for page idle or --timeout ms - check Check a checkbox by --element ref - uncheck Uncheck a checkbox by --element ref - focus Focus an element by --element ref - clear Clear an input by --element ref - drag Drag --from ref to --to ref - upload Upload --files to a file input by --element ref - dblclick Double-click element by --element ref - forward Navigate forward in browser history - scrollintoview Scroll --element into view - get Get element property (--what: text, html, value, url, title) - is Check element state (--what: visible, enabled, checked) - inserttext Insert text without key events - mouse move Move mouse to --x --y coordinates - mouse down Press mouse button - mouse up Release mouse button - mouse wheel Scroll wheel --dy [--dx] - find Find element by locator (--locator role|text|label --value ) - set device Emulate device (--name "iPhone 12") - set offline Toggle offline mode (--state on|off) - set headers Set HTTP headers (--headers '{"key":"val"}') - set credentials Set HTTP auth (--user --pass

) - set media Set color scheme (--color-scheme dark|light) - clipboard read Read clipboard contents - clipboard write Write --text to clipboard - dialog accept Accept browser dialog (--text for prompt response) - dialog dismiss Dismiss browser dialog - storage local get Get localStorage value by --key - storage local set Set localStorage --key --value - storage local clear Clear localStorage - storage session get Get sessionStorage value by --key - storage session set Set sessionStorage --key --value - storage session clear Clear sessionStorage - download Download file via --selector to --path - highlight Highlight --selector on page - exec Run any agent-browser command (--command "...") - -Common Commands: - orca open [--json] - orca serve [--port ] [--pairing-address ] [--mobile-pairing] [--no-pairing] [--project-root ] [--recipe-json] [--json] - orca status [--json] - orca diagnostics memory [--json] - orca agent-context [--json] - orca account add [--agent claude|codex] [--json] - orca account list [--json] - orca host list [--json] - orca environment add --name --pairing-code [--json] - orca environment list [--json] - orca environment show --environment [--json] - orca environment rm --environment [--json] - orca worktree list [--repo ] [--limit ] [--json] - orca worktree create --name [--repo |--project [--host ]|--project-host-setup ] [--agent ] [--prompt ] [--setup run|skip|inherit] [--base-branch ] [--issue ] [--linear-issue ] [--comment ] [--parent-worktree ] [--no-parent] [--run-hooks] [--activate] [--json] - orca worktree show --worktree [--json] - orca worktree current [--json] - orca worktree set --worktree [--display-name ] [--issue ] [--linear-issue ] [--comment ] [--workspace-status ] [--parent-worktree |--no-parent] [--json] - orca worktree rm --worktree [--force] [--run-hooks] [--json] - orca worktree ps [--limit ] [--json] - orca file open [--worktree ] [--json] - orca file diff [--staged] [--worktree ] [--json] - orca file open-changed [--mode edit|diff|both] [--worktree ] [--json] - orca terminal list [--worktree ] [--limit ] [--include-visual-layouts] [--json] - orca terminal show [--terminal ] [--json] - orca terminal read [--terminal ] [--cursor ] [--limit ] [--json] - orca terminal send [--terminal ] [--text ] [--enter] [--interrupt] [--json] - orca terminal wait [--terminal ] --for exit|tui-idle [--timeout-ms ] [--json] - orca terminal stop --worktree [--json] - orca terminal create [--worktree ] [--title ] [--command ] [--focus] [--json] - orca terminal split [--terminal ] [--direction horizontal|vertical] [--json] - orca terminal switch [--terminal ] [--json] - orca terminal close [--terminal ] [--tab] [--json] - orca project list [--json] - orca project setups [--project ] [--host ] [--json] - orca project setup-existing-folder --project --host --path [--kind git|folder] [--display-name ] [--json] - orca project setup-clone --project --host --url --destination [--display-name ] [--json] - orca project setup-create --project --host [--setup-id ] [--path ] [--kind git|folder] [--display-name ] [--worktree-base-path ] [--git-username ] [--state ready|not-set-up|setting-up|error|unsupported] [--method imported-existing-folder|cloned|provisioned] [--json] - orca project setup-update --setup [--display-name ] [--path ] [--worktree-base-path ] [--git-username ] [--kind git|folder] [--state ready|not-set-up|setting-up|error|unsupported] [--method legacy-repo|imported-existing-folder|cloned|provisioned] [--json] - orca project setup-delete --setup [--json] - orca repo list [--json] - orca repo add --path [--json] - orca repo show --repo [--json] - orca repo set-base-ref --repo --ref [--json] - orca repo search-refs --repo --query [--limit ] [--json] - -Selectors: - --repo Registered repo selector such as id:, name:, or path: - --worktree Worktree selector such as identity:, id:::, name:, branch:, issue:, path:, or active/current - --terminal Runtime-issued terminal handle returned by \`orca terminal list --json\` - --parent-worktree Parent worktree selector such as identity:, id:::, branch:, issue:, path:, or active/current - --no-parent Force no parent lineage for unrelated worktree creation/update - -Terminal Send Options: - --text Text to send to the terminal - --enter Append Enter after sending text - --interrupt Send as an interrupt-style input when supported - -Terminal List Options: - --include-visual-layouts Include tab and pane topology in JSON output - -Wait Options: - --for exit Wait until the target terminal exits - --timeout-ms Maximum wait time before timing out - -Output Options: - --json Emit machine-readable JSON instead of human text - --pairing-code Connect to a remote Orca runtime using an orca://pair?... code - --environment Connect using a saved environment id or name - --help Show this help message - -Behavior: - Most commands require a running Orca runtime. If Orca is not open yet, run \`orca open\` first. - Remote runtime access can also be supplied with ORCA_PAIRING_CODE or ORCA_ENVIRONMENT. - Use selectors for discovery and handles for repeated live terminal operations. - -Agent Sessions And Worktrees: - \`worktree create --agent\` creates a new checkout with an agent. - To start a fresh agent in the current worktree, use: - orca terminal create --worktree active --command "codex" - -Browser Workflow: - 1. Create or navigate: orca tab create --url https://example.com - orca goto --url https://example.com - 2. Inspect the page: orca snapshot - (Returns an accessibility tree with element refs like e1, e2, e3) - For concurrent workflows, prefer: orca tab list --json - then reuse tabs[].browserPageId with --page on later commands. - 3. Interact: orca click --element e2 - orca fill --element e5 --value "search query" - orca keypress --key Enter - 4. Re-inspect: orca snapshot - (Element refs change after navigation โ€” always re-snapshot before interacting) - -Browser Options: - --element Element ref from snapshot (e.g. @e3) - --url URL to navigate to - --value Value to fill or select - --input Text to type at current focus (no element needed) - --expression JavaScript expression to evaluate - --key Key to press (Enter, Tab, Escape, Control+a, etc.) - --direction

Scroll direction: up or down - --amount Scroll distance in pixels (default: viewport height) - --index Tab index (from \`tab list\`) - --page Stable browser page id (preferred for concurrent workflows) - --profile Browser profile id - --show-profile Include the tab's browser profile in text output - --no-ua-spoof Keep Electron's native user agent for a new profile - --format Screenshot image format - --from Drag source element ref - --to Drag target element ref - --files Comma-separated file paths for upload - --timeout Wait timeout in milliseconds - --worktree Scope commands to a specific worktree's browser tabs - -Examples: - $ orca open - $ orca status --json - $ orca diagnostics memory --json - $ orca repo list - $ orca worktree create --name agent-task --agent codex --prompt "hi" - $ orca worktree create --repo name:orca --name cli-test-1 --issue 273 - $ orca worktree create --repo name:orca --name linear-task --linear-issue https://linear.app/stably/issue/STA-335/test-issue - $ orca worktree create --name linear-task --linear-issue STA-335 - $ orca worktree show --worktree branch:Jinwoo-H/cli - $ orca worktree current - $ orca worktree set --worktree active --comment "waiting on review" - $ orca worktree set --worktree active --linear-issue null - $ orca worktree ps --limit 10 - $ orca file open-changed --mode diff - $ orca file open src/App.tsx - $ orca terminal create --worktree active --command "codex" - $ orca terminal list --worktree path:/Users/me/orca/workspaces/orca/cli-test-1 --json - $ orca terminal send --terminal term_123 --text "hi" --enter - $ orca terminal wait --terminal term_123 --for exit --timeout-ms 60000 --json - $ orca tab current --json - $ orca tab show --page page_123 --json - $ orca tab create --url https://example.com --profile work - $ orca tab profile clone --page page_123 --profile work --json - $ orca snapshot - $ orca click --element e3 - $ orca fill --element e5 --value "hello" - $ orca goto --url https://example.com/login - $ orca keypress --key Enter - $ orca eval --expression "document.title" - $ orca tab list --json` +const ROOT_HELP_TEXT = [ROOT_HELP_TEXT_PRIMARY, ROOT_HELP_TEXT_SECONDARY].join('\n') export function printHelp(specs: CommandSpec[], commandPath: string[] = []): void { const exactSpec = findCommandSpec(specs, commandPath) diff --git a/src/cli/root-help-text-primary.ts b/src/cli/root-help-text-primary.ts new file mode 100644 index 00000000000..6f282c884fd --- /dev/null +++ b/src/cli/root-help-text-primary.ts @@ -0,0 +1,184 @@ +export const ROOT_HELP_TEXT_PRIMARY = [ + 'orca', + '', + 'Usage: orca [options]', + '', + 'Startup:', + ' open Launch Orca and wait for the runtime to be reachable', + ' serve Start a headless Orca runtime server', + ' status Show app/runtime/graph readiness', + '', + 'Diagnostics:', + ' diagnostics memory Collect a memory snapshot for Orca and managed terminals', + '', + 'Agent Discovery:', + ' agent-context Print the machine-readable command schema for agents', + '', + 'Accounts:', + ' account add Add a managed Claude or Codex account on this Orca host', + ' account list List managed Claude and Codex accounts on this Orca host', + '', + 'Skills:', + ' skills installed List installed skill selectors', + ' skills share Publish selected skills behind one unlisted link', + ' skills list List version-matched skill guides bundled with this Orca CLI', + ' skills get Print a version-matched skill guide as Markdown', + ' skills install Install bundled Orca skills globally via the community skills CLI', + ' skills update Update already-installed Orca skills via the community skills CLI', + '', + 'Hosts:', + ' host list List targetable machines and how to name each one', + '', + 'Environments:', + ' environment add Save a remote Orca runtime from a pairing code', + ' environment list List saved remote Orca runtimes', + ' environment show Show one saved remote Orca runtime', + ' environment rm Remove a saved remote Orca runtime', + '', + 'Environment Recipes:', + ' vm recipe doctor Validate a per-workspace environment recipe', + '', + 'Automations:', + ' automations list List scheduled Orca automations', + ' automations show Show one Orca automation', + ' automations create Create a scheduled Orca automation', + ' automations edit Edit an Orca automation', + ' automations remove Remove an Orca automation and its run history', + ' automations run Run an Orca automation now', + ' automations runs List automation run history', + '', + 'Projects:', + ' project list List durable projects known to Orca', + ' project setups List project host setups', + ' project setup-existing-folder Make a project available on a host by importing an existing folder', + ' project setup-clone Make a project available on a host by cloning a repository', + ' project setup-create Create independent project host setup metadata', + ' project setup-update Update project host setup metadata', + ' project setup-delete Remove a project host setup', + '', + 'Repos:', + ' repo list List repos registered in Orca', + ' repo add Add a project to Orca by filesystem path', + ' repo show Show one registered repo', + " repo set-base-ref Set the repo's default base ref for future worktrees", + ' repo search-refs Search branch/tag refs within a repo', + '', + 'Worktrees:', + ' worktree list List Orca-managed worktrees', + ' worktree show Show one worktree', + ' worktree current Show the Orca-managed worktree for the current directory', + ' worktree create Create a new Orca-managed worktree', + ' worktree set Update Orca metadata for a worktree', + ' worktree rm Remove a worktree from Orca and git', + ' worktree ps Show a compact orchestration summary across worktrees', + '', + 'Files:', + ' file open Open a workspace file in the Orca editor', + ' file diff Open a workspace file diff in the Orca editor', + ' file open-changed Open all git-changed files for a workspace', + '', + 'Terminals:', + ' terminal list List live Orca-managed terminals', + ' terminal show Show terminal metadata and preview', + ' terminal read Read bounded terminal output', + ' terminal send Send input to a live terminal', + ' terminal wait Wait for a terminal condition (exit, tui-idle)', + ' terminal stop Stop terminals for a worktree', + ' terminal create Create a terminal session in a worktree', + ' terminal rename Set or clear the title of a terminal tab', + ' terminal split Split an existing terminal pane', + ' terminal switch Bring a terminal tab to the foreground', + ' terminal focus Alias for terminal switch', + ' terminal close Close a terminal pane/session, or its whole tab with --tab', + '', + 'Orchestration:', + ' orchestration run-create Create and bind a lightweight orchestration Run', + ' orchestration run-use Bind this coordinator terminal to an existing Run', + " orchestration run-current Show this terminal's bound Run", + ' orchestration run-list List lightweight orchestration Runs', + ' orchestration run-show Show one lightweight orchestration Run', + ' orchestration send Send an inter-agent message', + ' orchestration check Check the bound Run mailbox', + ' orchestration ask Ask the coordinator a blocking question', + ' orchestration reply Reply to a message', + ' orchestration inbox Show all messages across recipients', + ' orchestration task-create Create an orchestration task', + ' orchestration task-list List orchestration tasks', + ' orchestration task-update Update a task status', + ' orchestration dispatch Dispatch a task to a terminal', + ' orchestration dispatch-show Show dispatch context for a task', + ' orchestration worker-start Start a supervised worker locally or on a connected Orca server', + ' orchestration worker-show Inspect one supervised worker', + ' orchestration worker-read Read bounded output from one supervised worker', + ' orchestration worker-stop Fence one Dispatch; stop only its supervised worker', + ' orchestration worker-abandon Fence an uncertain worker without claiming it stopped', + " orchestration worker-release Release a settled worker's terminal after archiving its output", + ' orchestration worker-retain Keep a worker terminal live for debugging', + ' orchestration worker-list Report worker terminal resource accounting', + ' orchestration coordinator-start Start the legacy automatic coordinator loop', + ' orchestration coordinator-stop Stop the legacy automatic coordinator loop', + ' orchestration gate-create Create a decision gate blocking a task', + ' orchestration gate-resolve Resolve a pending decision gate', + ' orchestration gate-list List decision gates', + ' orchestration reset Reset orchestration state', + '', + 'Computer Use:', + ' computer capabilities Show computer-use provider capabilities', + ' computer permissions Show or open computer-use permission setup', + ' computer list-apps List running apps available to computer-use', + ' computer list-windows List visible windows for a target app', + ' computer get-app-state Capture a compact accessibility snapshot of an app', + ' computer click Click an app element or window coordinate', + ' computer perform-secondary-action Run an advertised accessibility action', + ' computer scroll Scroll an app element', + ' computer drag Drag between app elements or window coordinates', + ' computer type-text Type literal text at the current app focus', + ' computer press-key Press a single key such as Return or Escape', + ' computer hotkey Press a shortcut combination such as CmdOrCtrl+A', + ' computer paste-text Paste text through the native clipboard path', + ' computer set-value Set the value of a settable app element', + '', + 'Linear:', + ' linear Read Linear ticket context for agents', + '', + 'Mobile Emulator (iOS Simulator):', + ' emulator list List available/running emulators (Orca-managed + raw serve-sim)', + ' emulator attach Attach/start helper and make active for the worktree', + ' emulator tap Tap at normalized 0..1 coords (preferred for single taps)', + ' emulator type Type text (US ASCII only)', + ' emulator gesture Send begin/move/end touch points', + ' emulator button Hardware button (home, side_button, etc.)', + ' emulator rotate Rotate device (portrait|landscape_left|...)', + ' emulator exec --command Raw serve-sim subcommand passthrough (no "serve-sim" prefix)', + ' emulator kill Stop helper for device', + '', + 'Browser Automation:', + ' tab create Create a new browser tab (navigates to --url)', + ' tab list List open browser tabs', + ' tab show Show one browser tab by page id', + ' tab current Show the current browser tab', + ' tab profile list List browser session profiles', + ' tab profile create Create a browser session profile', + ' tab profile delete Delete a browser session profile', + ' tab profile set Switch a browser tab to a different profile', + ' tab profile show Show the profile bound to a browser tab', + ' tab profile use-default Switch a browser tab back to the default profile', + ' tab profile clone Clone a browser tab into another profile', + ' tab switch Switch the active browser tab by --index or --page', + ' tab close Close a browser tab by --index/--page or the current tab', + ' snapshot Accessibility snapshot with element refs (e.g. @e1, @e2)', + ' goto Navigate the active tab to --url', + ' click Click element by --element ref', + ' fill Clear and fill input by --element ref with --value', + ' type Type --input text at the current focus (no element needed)', + ' select Select dropdown option by --element ref and --value', + ' hover Hover element by --element ref', + ' keypress Press a key (e.g. --key Enter, --key Tab)', + ' scroll Scroll --direction (up/down) by --amount pixels', + ' back Navigate back in browser history', + ' reload Reload the active browser tab', + ' screenshot Capture viewport screenshot (--format png|jpeg)', + ' eval Evaluate --expression JavaScript in the page context', + ' wait Wait for page idle or --timeout ms', + ' check Check a checkbox by --element ref' +].join('\n') diff --git a/src/cli/root-help-text-secondary.ts b/src/cli/root-help-text-secondary.ts new file mode 100644 index 00000000000..79c2f7d21f1 --- /dev/null +++ b/src/cli/root-help-text-secondary.ts @@ -0,0 +1,183 @@ +export const ROOT_HELP_TEXT_SECONDARY = [ + ' uncheck Uncheck a checkbox by --element ref', + ' focus Focus an element by --element ref', + ' clear Clear an input by --element ref', + ' drag Drag --from ref to --to ref', + ' upload Upload --files to a file input by --element ref', + ' dblclick Double-click element by --element ref', + ' forward Navigate forward in browser history', + ' scrollintoview Scroll --element into view', + ' get Get element property (--what: text, html, value, url, title)', + ' is Check element state (--what: visible, enabled, checked)', + ' inserttext Insert text without key events', + ' mouse move Move mouse to --x --y coordinates', + ' mouse down Press mouse button', + ' mouse up Release mouse button', + ' mouse wheel Scroll wheel --dy [--dx]', + ' find Find element by locator (--locator role|text|label --value )', + ' set device Emulate device (--name "iPhone 12")', + ' set offline Toggle offline mode (--state on|off)', + ' set headers Set HTTP headers (--headers \'{"key":"val"}\')', + ' set credentials Set HTTP auth (--user --pass

)', + ' set media Set color scheme (--color-scheme dark|light)', + ' clipboard read Read clipboard contents', + ' clipboard write Write --text to clipboard', + ' dialog accept Accept browser dialog (--text for prompt response)', + ' dialog dismiss Dismiss browser dialog', + ' storage local get Get localStorage value by --key', + ' storage local set Set localStorage --key --value', + ' storage local clear Clear localStorage', + ' storage session get Get sessionStorage value by --key', + ' storage session set Set sessionStorage --key --value', + ' storage session clear Clear sessionStorage', + ' download Download file via --selector to --path', + ' highlight Highlight --selector on page', + ' exec Run any agent-browser command (--command "...")', + '', + 'Common Commands:', + ' orca open [--json]', + ' orca serve [--port ] [--pairing-address ] [--mobile-pairing] [--no-pairing] [--project-root ] [--recipe-json] [--json]', + ' orca status [--json]', + ' orca diagnostics memory [--json]', + ' orca agent-context [--json]', + ' orca account add [--agent claude|codex] [--json]', + ' orca account list [--json]', + ' orca host list [--json]', + ' orca environment add --name --pairing-code [--json]', + ' orca environment list [--json]', + ' orca environment show --environment [--json]', + ' orca environment rm --environment [--json]', + ' orca worktree list [--repo ] [--limit ] [--json]', + ' orca worktree create --name [--repo |--project [--host ]|--project-host-setup ] [--agent ] [--prompt ] [--setup run|skip|inherit] [--base-branch ] [--issue ] [--linear-issue ] [--comment ] [--parent-worktree ] [--no-parent] [--run-hooks] [--activate] [--json]', + ' orca worktree show --worktree [--json]', + ' orca worktree current [--json]', + ' orca worktree set --worktree [--display-name ] [--issue ] [--linear-issue ] [--comment ] [--workspace-status ] [--parent-worktree |--no-parent] [--json]', + ' orca worktree rm --worktree [--force] [--run-hooks] [--json]', + ' orca worktree ps [--limit ] [--json]', + ' orca file open [--worktree ] [--json]', + ' orca file diff [--staged] [--worktree ] [--json]', + ' orca file open-changed [--mode edit|diff|both] [--worktree ] [--json]', + ' orca terminal list [--worktree ] [--limit ] [--include-visual-layouts] [--json]', + ' orca terminal show [--terminal ] [--json]', + ' orca terminal read [--terminal ] [--cursor ] [--limit ] [--json]', + ' orca terminal send [--terminal ] [--text ] [--enter] [--interrupt] [--json]', + ' orca terminal wait [--terminal ] --for exit|tui-idle [--timeout-ms ] [--json]', + ' orca terminal stop --worktree [--json]', + ' orca terminal create [--worktree ] [--title ] [--command ] [--focus] [--json]', + ' orca terminal split [--terminal ] [--direction horizontal|vertical] [--json]', + ' orca terminal switch [--terminal ] [--json]', + ' orca terminal close [--terminal ] [--tab] [--json]', + ' orca project list [--json]', + ' orca project setups [--project ] [--host ] [--json]', + ' orca project setup-existing-folder --project --host --path [--kind git|folder] [--display-name ] [--json]', + ' orca project setup-clone --project --host --url --destination [--display-name ] [--json]', + ' orca project setup-create --project --host [--setup-id ] [--path ] [--kind git|folder] [--display-name ] [--worktree-base-path ] [--git-username ] [--state ready|not-set-up|setting-up|error|unsupported] [--method imported-existing-folder|cloned|provisioned] [--json]', + ' orca project setup-update --setup [--display-name ] [--path ] [--worktree-base-path ] [--git-username ] [--kind git|folder] [--state ready|not-set-up|setting-up|error|unsupported] [--method legacy-repo|imported-existing-folder|cloned|provisioned] [--json]', + ' orca project setup-delete --setup [--json]', + ' orca repo list [--json]', + ' orca repo add --path [--json]', + ' orca repo show --repo [--json]', + ' orca repo set-base-ref --repo --ref [--json]', + ' orca repo search-refs --repo --query [--limit ] [--json]', + '', + 'Selectors:', + ' --repo Registered repo selector such as id:, name:, or path:', + ' --worktree Worktree selector such as identity:, id:::, name:, branch:, issue:, path:, or active/current', + ' --terminal Runtime-issued terminal handle returned by `orca terminal list --json`', + ' --parent-worktree Parent worktree selector such as identity:, id:::, branch:, issue:, path:, or active/current', + ' --no-parent Force no parent lineage for unrelated worktree creation/update', + '', + 'Terminal Send Options:', + ' --text Text to send to the terminal', + ' --enter Append Enter after sending text', + ' --interrupt Send as an interrupt-style input when supported', + '', + 'Terminal List Options:', + ' --include-visual-layouts Include tab and pane topology in JSON output', + '', + 'Wait Options:', + ' --for exit Wait until the target terminal exits', + ' --timeout-ms Maximum wait time before timing out', + '', + 'Output Options:', + ' --json Emit machine-readable JSON instead of human text', + ' --pairing-code Connect to a remote Orca runtime using an orca://pair?... code', + ' --environment Connect using a saved environment id or name', + ' --help Show this help message', + '', + 'Behavior:', + ' Most commands require a running Orca runtime. If Orca is not open yet, run `orca open` first.', + ' Remote runtime access can also be supplied with ORCA_PAIRING_CODE or ORCA_ENVIRONMENT.', + ' Use selectors for discovery and handles for repeated live terminal operations.', + '', + 'Agent Sessions And Worktrees:', + ' `worktree create --agent` creates a new checkout with an agent.', + ' To start a fresh agent in the current worktree, use:', + ' orca terminal create --worktree active --command "codex"', + '', + 'Browser Workflow:', + ' 1. Create or navigate: orca tab create --url https://example.com', + ' orca goto --url https://example.com', + ' 2. Inspect the page: orca snapshot', + ' (Returns an accessibility tree with element refs like e1, e2, e3)', + ' For concurrent workflows, prefer: orca tab list --json', + ' then reuse tabs[].browserPageId with --page on later commands.', + ' 3. Interact: orca click --element e2', + ' orca fill --element e5 --value "search query"', + ' orca keypress --key Enter', + ' 4. Re-inspect: orca snapshot', + ' (Element refs change after navigation โ€” always re-snapshot before interacting)', + '', + 'Browser Options:', + ' --element Element ref from snapshot (e.g. @e3)', + ' --url URL to navigate to', + ' --value Value to fill or select', + ' --input Text to type at current focus (no element needed)', + ' --expression JavaScript expression to evaluate', + ' --key Key to press (Enter, Tab, Escape, Control+a, etc.)', + ' --direction

Scroll direction: up or down', + ' --amount Scroll distance in pixels (default: viewport height)', + ' --index Tab index (from `tab list`)', + ' --page Stable browser page id (preferred for concurrent workflows)', + ' --profile Browser profile id', + " --show-profile Include the tab's browser profile in text output", + " --no-ua-spoof Keep Electron's native user agent for a new profile", + ' --format Screenshot image format', + ' --from Drag source element ref', + ' --to Drag target element ref', + ' --files Comma-separated file paths for upload', + ' --timeout Wait timeout in milliseconds', + " --worktree Scope commands to a specific worktree's browser tabs", + '', + 'Examples:', + ' $ orca open', + ' $ orca status --json', + ' $ orca diagnostics memory --json', + ' $ orca repo list', + ' $ orca worktree create --name agent-task --agent codex --prompt "hi"', + ' $ orca worktree create --repo name:orca --name cli-test-1 --issue 273', + ' $ orca worktree create --repo name:orca --name linear-task --linear-issue https://linear.app/stably/issue/STA-335/test-issue', + ' $ orca worktree create --name linear-task --linear-issue STA-335', + ' $ orca worktree show --worktree branch:Jinwoo-H/cli', + ' $ orca worktree current', + ' $ orca worktree set --worktree active --comment "waiting on review"', + ' $ orca worktree set --worktree active --linear-issue null', + ' $ orca worktree ps --limit 10', + ' $ orca file open-changed --mode diff', + ' $ orca file open src/App.tsx', + ' $ orca terminal create --worktree active --command "codex"', + ' $ orca terminal list --worktree path:/Users/me/orca/workspaces/orca/cli-test-1 --json', + ' $ orca terminal send --terminal term_123 --text "hi" --enter', + ' $ orca terminal wait --terminal term_123 --for exit --timeout-ms 60000 --json', + ' $ orca tab current --json', + ' $ orca tab show --page page_123 --json', + ' $ orca tab create --url https://example.com --profile work', + ' $ orca tab profile clone --page page_123 --profile work --json', + ' $ orca snapshot', + ' $ orca click --element e3', + ' $ orca fill --element e5 --value "hello"', + ' $ orca goto --url https://example.com/login', + ' $ orca keypress --key Enter', + ' $ orca eval --expression "document.title"', + ' $ orca tab list --json' +].join('\n') diff --git a/src/main/cli/wsl-cli-installer.ts b/src/main/cli/wsl-cli-installer.ts index 7c95be6431c..a3c102e1dfc 100644 --- a/src/main/cli/wsl-cli-installer.ts +++ b/src/main/cli/wsl-cli-installer.ts @@ -1,14 +1,10 @@ -/* eslint-disable max-lines -- Why: WSL CLI status/install/remove share one state machine; - splitting the installer would separate conflict checks from the operations they guard. */ import type { CliInstallStatus } from '../../shared/cli-install-types' import { getDefaultWslDistro } from '../wsl' import { runWslProcess } from '../wsl/wsl-runner' import { CliInstaller } from './cli-installer' import { buildManagedLegacyRemoveCommand, - buildRegistrationLockPrelude, buildSafeRemoveCommand, - buildSafeReplaceGuard, buildWslBridgeScript, buildWslLauncher, getBridgePathFromCommandPath, @@ -18,10 +14,11 @@ import { parseManagedLauncherTarget, quoteShell } from './wsl-cli-scripts' +import { buildWslCliInstallCommand } from './wsl-cli-registration-command' +import { buildWslCliStatus, readWslCliCommandFile, resolveReadyWslCliState } from './wsl-cli-status' const MANAGED_MARKER = getWslLauncherMarker() const BRIDGE_MANAGED_MARKER = getWslBridgeMarker() -const WSL_COMMAND_NAME = 'orca-ide' const LEGACY_WSL_COMMAND_NAME = 'orca' const WSL_COMMAND_TIMEOUT_MS = 10_000 @@ -60,7 +57,12 @@ export class WslCliInstaller { } async getStatus(): Promise { - const ready = await this.resolveReadyState() + const ready = await resolveReadyWslCliState({ + platform: this.platform, + distro: this.distro, + getHostStatus: () => this.hostInstaller.getStatus(), + run: (distro, command) => this.run(distro, command) + }) if ('status' in ready) { return ready.status } @@ -208,67 +210,13 @@ export class WslCliInstaller { throw new Error(`Refusing to replace non-Orca command at ${status.commandPath}.`) } - // Why: the launcher and PowerShell bridge are one registration; the - // command replacement stays a single atomic rename (never missing for a - // concurrent shell) while a bridge copy enables rollback of the pair. await this.run( this.distro as string, - [ - // Why -eu not -euo pipefail: transported via runWslProcess's `sh -s`, - // and no pipe here needs pipefail -- dash on Ubuntu 20.04 lacks the option. - 'set -eu', - `mkdir -p ${quoteShell(status.pathDirectory as string)}`, - `mkdir -p ${quoteShell(getPosixDirname(getBridgePathFromCommandPath(status.commandPath)))}`, - buildRegistrationLockPrelude(status.commandPath), - `command_tmp=${quoteShell(`${status.commandPath}.tmp`)}.$$`, - `bridge_path=${quoteShell(getBridgePathFromCommandPath(status.commandPath))}`, - `legacy_command_path=${quoteShell( - `${getPosixDirname(status.commandPath)}/${LEGACY_WSL_COMMAND_NAME}` - )}`, - 'bridge_tmp="${bridge_path}.tmp.$$"', - 'bridge_backup="${bridge_tmp}.backup"', - 'bridge_had_original=0', - 'bridge_touched=0', - 'committed=0', - 'rollback() {', - ' result=$?', - ' set +e', - ' if [ "$committed" -ne 1 ]; then', - ` if [ "$bridge_had_original" -eq 1 ]; then mv -f "$bridge_backup" ${quoteShell(getBridgePathFromCommandPath(status.commandPath))}; elif [ "$bridge_touched" -eq 1 ]; then rm -f ${quoteShell(getBridgePathFromCommandPath(status.commandPath))}; fi`, - ' fi', - ' rm -f "$command_tmp" "$bridge_tmp" "$bridge_backup"', - ' exit "$result"', - '}', - 'trap rollback EXIT', - buildSafeReplaceGuard(status.commandPath, MANAGED_MARKER), - buildSafeReplaceGuard( - getBridgePathFromCommandPath(status.commandPath), - BRIDGE_MANAGED_MARKER - ), - `cat > "$command_tmp" <<'ORCA_WSL_CLI'`, - buildWslLauncher(status.launcherPath, getBridgePathFromCommandPath(status.commandPath)), - 'ORCA_WSL_CLI', - `cat > "$bridge_tmp" <<'ORCA_WSL_BRIDGE'`, - buildWslBridgeScript(), - 'ORCA_WSL_BRIDGE', - 'chmod 755 "$command_tmp"', - 'chmod 644 "$bridge_tmp"', - buildSafeReplaceGuard(status.commandPath, MANAGED_MARKER), - buildSafeReplaceGuard( - getBridgePathFromCommandPath(status.commandPath), - BRIDGE_MANAGED_MARKER - ), - `if [ -f ${quoteShell(getBridgePathFromCommandPath(status.commandPath))} ]; then cp -p ${quoteShell(getBridgePathFromCommandPath(status.commandPath))} "$bridge_backup"; bridge_had_original=1; fi`, - `mv -f "$bridge_tmp" ${quoteShell(getBridgePathFromCommandPath(status.commandPath))}`, - 'bridge_touched=1', - `mv -f "$command_tmp" ${quoteShell(status.commandPath)}`, - 'committed=1', - 'rm -f "$bridge_backup"', - // Why: the command was renamed to avoid GNOME Orca; remove only the - // old Orca-managed WSL wrapper after the replacement has committed. - buildManagedLegacyRemoveCommand('"$legacy_command_path"'), - 'trap - EXIT' - ].join('\n') + buildWslCliInstallCommand({ + ...status, + commandPath: status.commandPath, + launcherPath: status.launcherPath + }) ) return this.getStatus() } @@ -299,108 +247,15 @@ export class WslCliInstaller { return this.getStatus() } - private async resolveReadyState(): Promise< - | { status: CliInstallStatus } - | { - distro: string - commandPath: string - bridgePath: string - launcherPath: string - pathConfigured: boolean - } - > { - if (this.platform !== 'win32') { - return { - status: this.unsupported( - 'platform_not_supported', - 'WSL CLI registration is only available on Windows.' - ) - } - } - if (!this.distro) { - return { - status: this.unsupported('platform_not_supported', 'No WSL distribution is available.') - } - } - - const hostStatus = await this.hostInstaller.getStatus() - if (!hostStatus.launcherPath) { - return { - status: this.unsupported( - hostStatus.unsupportedReason ?? 'launcher_missing', - hostStatus.detail ?? 'The Windows Orca CLI launcher is missing.' - ) - } - } - - const home = (await this.run(this.distro, 'printf %s "$HOME"')).trim() - if (!home.startsWith('/')) { - return { - status: this.unsupported('launcher_missing', 'Unable to resolve the WSL home directory.') - } - } - - const interopReady = - ( - await this.run( - this.distro, - '{ command -v powershell.exe >/dev/null 2>&1 || [ -x /mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe ]; } && command -v wslpath >/dev/null 2>&1 && printf yes || printf no' - ) - ).trim() === 'yes' - if (!interopReady) { - return { - status: this.unsupported( - 'launcher_missing', - 'WSL Windows interop is unavailable; Orca cannot launch the Windows CLI from WSL.' - ) - } - } - - const pathDirectory = `${home}/.local/bin` - // Why: matches the Linux CLI rename to `orca-ide` (avoids GNOME Orca conflict). - const commandPath = `${pathDirectory}/${WSL_COMMAND_NAME}` - const pathConfigured = - ( - await this.run( - this.distro, - `case ":$PATH:" in *:${quoteShell(pathDirectory)}:*) printf yes ;; *) printf no ;; esac` - ) - ).trim() === 'yes' - - return { - distro: this.distro, - commandPath, - bridgePath: getBridgePathFromCommandPath(commandPath), - launcherPath: hostStatus.launcherPath, - pathConfigured - } - } - private async readCommandFile( distro: string, commandPath: string ): Promise<(string & {}) | 'not_file' | null> { - const output = await this.run( + return readWslCliCommandFile( + (targetDistro, command) => this.run(targetDistro, command), distro, - [ - `if [ -L ${quoteShell(commandPath)} ]; then`, - ' printf __ORCA_NOT_FILE__', - `elif [ ! -e ${quoteShell(commandPath)} ]; then`, - ' printf __ORCA_MISSING__', - `elif [ ! -f ${quoteShell(commandPath)} ]; then`, - ' printf __ORCA_NOT_FILE__', - 'else', - ` cat ${quoteShell(commandPath)}`, - 'fi' - ].join('\n') + commandPath ) - if (output === '__ORCA_MISSING__') { - return null - } - if (output === '__ORCA_NOT_FILE__') { - return 'not_file' - } - return output } private buildStatus(args: { @@ -412,43 +267,7 @@ export class WslCliInstaller { pathConfigured: boolean detail: string }): CliInstallStatus { - return { - platform: 'linux', - commandName: WSL_COMMAND_NAME, - commandPath: args.commandPath, - pathDirectory: getPosixDirname(args.commandPath), - pathConfigured: args.pathConfigured, - launcherPath: args.launcherPath, - installMethod: 'wrapper', - supported: true, - state: args.state, - currentTarget: args.currentTarget, - unsupportedReason: null, - detail: - args.state === 'installed' && !args.pathConfigured - ? `${args.commandPath} is registered, but ${getPosixDirname(args.commandPath)} is not on PATH in ${args.distro}.` - : args.detail - } - } - - private unsupported( - unsupportedReason: NonNullable, - detail: string - ): CliInstallStatus { - return { - platform: 'linux', - commandName: WSL_COMMAND_NAME, - commandPath: null, - pathDirectory: null, - pathConfigured: false, - launcherPath: null, - installMethod: null, - supported: false, - state: 'unsupported', - currentTarget: null, - unsupportedReason, - detail - } + return buildWslCliStatus(args) } private async run(distro: string, command: string): Promise { diff --git a/src/main/cli/wsl-cli-registration-command.ts b/src/main/cli/wsl-cli-registration-command.ts new file mode 100644 index 00000000000..53d39eaec86 --- /dev/null +++ b/src/main/cli/wsl-cli-registration-command.ts @@ -0,0 +1,70 @@ +import type { CliInstallStatus } from '../../shared/cli-install-types' +import { + buildManagedLegacyRemoveCommand, + buildRegistrationLockPrelude, + buildSafeReplaceGuard, + buildWslBridgeScript, + buildWslLauncher, + getBridgePathFromCommandPath, + getPosixDirname, + getWslBridgeMarker, + getWslLauncherMarker, + quoteShell +} from './wsl-cli-scripts' + +const MANAGED_MARKER = getWslLauncherMarker() +const BRIDGE_MANAGED_MARKER = getWslBridgeMarker() +const LEGACY_WSL_COMMAND_NAME = 'orca' + +export function buildWslCliInstallCommand( + status: CliInstallStatus & { commandPath: string; launcherPath: string } +): string { + const bridgePath = getBridgePathFromCommandPath(status.commandPath) + const legacyCommandPath = `${getPosixDirname(status.commandPath)}/${LEGACY_WSL_COMMAND_NAME}` + return [ + // Why -eu not -euo pipefail: transported via runWslProcess's `sh -s`, + // and no pipe here needs pipefail -- dash on Ubuntu 20.04 lacks the option. + 'set -eu', + `mkdir -p ${quoteShell(status.pathDirectory as string)}`, + `mkdir -p ${quoteShell(getPosixDirname(bridgePath))}`, + buildRegistrationLockPrelude(status.commandPath), + `command_tmp=${quoteShell(`${status.commandPath}.tmp`)}.$$`, + `bridge_path=${quoteShell(bridgePath)}`, + `legacy_command_path=${quoteShell(legacyCommandPath)}`, + 'bridge_tmp="${bridge_path}.tmp.$$"', + 'bridge_backup="${bridge_tmp}.backup"', + 'bridge_had_original=0', + 'bridge_touched=0', + 'committed=0', + 'rollback() {', + ' result=$?', + ' set +e', + ' if [ "$committed" -ne 1 ]; then', + ` if [ "$bridge_had_original" -eq 1 ]; then mv -f "$bridge_backup" ${quoteShell(bridgePath)}; elif [ "$bridge_touched" -eq 1 ]; then rm -f ${quoteShell(bridgePath)}; fi`, + ' fi', + ' rm -f "$command_tmp" "$bridge_tmp" "$bridge_backup"', + ' exit "$result"', + '}', + 'trap rollback EXIT', + buildSafeReplaceGuard(status.commandPath, MANAGED_MARKER), + buildSafeReplaceGuard(bridgePath, BRIDGE_MANAGED_MARKER), + `cat > "$command_tmp" <<'ORCA_WSL_CLI'`, + buildWslLauncher(status.launcherPath, bridgePath), + 'ORCA_WSL_CLI', + `cat > "$bridge_tmp" <<'ORCA_WSL_BRIDGE'`, + buildWslBridgeScript(), + 'ORCA_WSL_BRIDGE', + 'chmod 755 "$command_tmp"', + 'chmod 644 "$bridge_tmp"', + buildSafeReplaceGuard(status.commandPath, MANAGED_MARKER), + buildSafeReplaceGuard(bridgePath, BRIDGE_MANAGED_MARKER), + `if [ -f ${quoteShell(bridgePath)} ]; then cp -p ${quoteShell(bridgePath)} "$bridge_backup"; bridge_had_original=1; fi`, + `mv -f "$bridge_tmp" ${quoteShell(bridgePath)}`, + 'bridge_touched=1', + `mv -f "$command_tmp" ${quoteShell(status.commandPath)}`, + 'committed=1', + 'rm -f "$bridge_backup"', + buildManagedLegacyRemoveCommand('"$legacy_command_path"'), + 'trap - EXIT' + ].join('\n') +} diff --git a/src/main/cli/wsl-cli-status.ts b/src/main/cli/wsl-cli-status.ts new file mode 100644 index 00000000000..7661f402a45 --- /dev/null +++ b/src/main/cli/wsl-cli-status.ts @@ -0,0 +1,163 @@ +import type { CliInstallStatus } from '../../shared/cli-install-types' +import { getBridgePathFromCommandPath, getPosixDirname, quoteShell } from './wsl-cli-scripts' + +const WSL_COMMAND_NAME = 'orca-ide' + +export type ReadyWslCliState = { + distro: string + commandPath: string + bridgePath: string + launcherPath: string + pathConfigured: boolean +} + +export async function readWslCliCommandFile( + run: (distro: string, command: string) => Promise, + distro: string, + commandPath: string +): Promise<(string & {}) | 'not_file' | null> { + const output = await run( + distro, + [ + `if [ -L ${quoteShell(commandPath)} ]; then`, + ' printf __ORCA_NOT_FILE__', + `elif [ ! -e ${quoteShell(commandPath)} ]; then`, + ' printf __ORCA_MISSING__', + `elif [ ! -f ${quoteShell(commandPath)} ]; then`, + ' printf __ORCA_NOT_FILE__', + 'else', + ` cat ${quoteShell(commandPath)}`, + 'fi' + ].join('\n') + ) + if (output === '__ORCA_MISSING__') { + return null + } + if (output === '__ORCA_NOT_FILE__') { + return 'not_file' + } + return output +} + +export function buildWslCliStatus(args: { + distro: string + commandPath: string + launcherPath: string + state: CliInstallStatus['state'] + currentTarget: string | null + pathConfigured: boolean + detail: string +}): CliInstallStatus { + return { + platform: 'linux', + commandName: WSL_COMMAND_NAME, + commandPath: args.commandPath, + pathDirectory: getPosixDirname(args.commandPath), + pathConfigured: args.pathConfigured, + launcherPath: args.launcherPath, + installMethod: 'wrapper', + supported: true, + state: args.state, + currentTarget: args.currentTarget, + unsupportedReason: null, + detail: + args.state === 'installed' && !args.pathConfigured + ? `${args.commandPath} is registered, but ${getPosixDirname(args.commandPath)} is not on PATH in ${args.distro}.` + : args.detail + } +} + +export function unsupportedWslCliStatus( + unsupportedReason: NonNullable, + detail: string +): CliInstallStatus { + return { + platform: 'linux', + commandName: WSL_COMMAND_NAME, + commandPath: null, + pathDirectory: null, + pathConfigured: false, + launcherPath: null, + installMethod: null, + supported: false, + state: 'unsupported', + currentTarget: null, + unsupportedReason, + detail + } +} + +export async function resolveReadyWslCliState(args: { + platform: NodeJS.Platform + distro: string | null + getHostStatus: () => Promise + run: (distro: string, command: string) => Promise +}): Promise<{ status: CliInstallStatus } | ReadyWslCliState> { + if (args.platform !== 'win32') { + return { + status: unsupportedWslCliStatus( + 'platform_not_supported', + 'WSL CLI registration is only available on Windows.' + ) + } + } + if (!args.distro) { + return { + status: unsupportedWslCliStatus('platform_not_supported', 'No WSL distribution is available.') + } + } + + const hostStatus = await args.getHostStatus() + if (!hostStatus.launcherPath) { + return { + status: unsupportedWslCliStatus( + hostStatus.unsupportedReason ?? 'launcher_missing', + hostStatus.detail ?? 'The Windows Orca CLI launcher is missing.' + ) + } + } + + const home = (await args.run(args.distro, 'printf %s "$HOME"')).trim() + if (!home.startsWith('/')) { + return { + status: unsupportedWslCliStatus( + 'launcher_missing', + 'Unable to resolve the WSL home directory.' + ) + } + } + + const interopReady = + ( + await args.run( + args.distro, + '{ command -v powershell.exe >/dev/null 2>&1 || [ -x /mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe ]; } && command -v wslpath >/dev/null 2>&1 && printf yes || printf no' + ) + ).trim() === 'yes' + if (!interopReady) { + return { + status: unsupportedWslCliStatus( + 'launcher_missing', + 'WSL Windows interop is unavailable; Orca cannot launch the Windows CLI from WSL.' + ) + } + } + + const pathDirectory = `${home}/.local/bin` + const commandPath = `${pathDirectory}/${WSL_COMMAND_NAME}` + const pathConfigured = + ( + await args.run( + args.distro, + `case ":$PATH:" in *:${quoteShell(pathDirectory)}:*) printf yes ;; *) printf no ;; esac` + ) + ).trim() === 'yes' + + return { + distro: args.distro, + commandPath, + bridgePath: getBridgePathFromCommandPath(commandPath), + launcherPath: hostStatus.launcherPath, + pathConfigured + } +} diff --git a/src/main/keybindings/keybinding-file-parser.ts b/src/main/keybindings/keybinding-file-parser.ts new file mode 100644 index 00000000000..9343a43d1af --- /dev/null +++ b/src/main/keybindings/keybinding-file-parser.ts @@ -0,0 +1,241 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' +import { + findKeybindingConflicts, + getKeybindingDefinition, + isKeybindingActionId, + normalizeKeybindingArrayForAction, + normalizeKeybindingListForAction, + type KeybindingActionId, + type KeybindingFileDiagnostic, + type KeybindingOverrides, + type KeybindingPlatform +} from '../../shared/keybindings' + +export type JsonObject = Record + +export const FILE_VERSION = 1 +const PLATFORM_KEYS: readonly KeybindingPlatform[] = ['darwin', 'linux', 'win32'] +const ROOT_KEYS = new Set(['$schema', 'version', 'keybindings', 'platforms']) + +export function isJsonObject(value: unknown): value is JsonObject { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +export function createEmptyDocument(): JsonObject { + return { + version: FILE_VERSION, + keybindings: {}, + platforms: { + darwin: {}, + linux: {}, + win32: {} + } + } +} + +export function readJsonDocument(path: string): { + exists: boolean + document: JsonObject | null + error?: string +} { + if (!existsSync(path)) { + return { exists: false, document: createEmptyDocument() } + } + try { + const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown + if (!isJsonObject(parsed)) { + return { exists: true, document: null, error: 'Keybindings file must contain a JSON object.' } + } + return { exists: true, document: parsed } + } catch (error) { + return { + exists: true, + document: null, + error: error instanceof Error ? error.message : String(error) + } + } +} + +export function writeJsonDocument(path: string, document: JsonObject): void { + mkdirSync(dirname(path), { recursive: true }) + const tempPath = `${path}.tmp` + try { + writeFileSync(tempPath, `${JSON.stringify(document, null, 2)}\n`, 'utf8') + renameSync(tempPath, path) + } catch (error) { + try { + if (existsSync(tempPath)) { + unlinkSync(tempPath) + } + } catch { + // Ignore cleanup failure; the original write error is more actionable. + } + throw error + } +} + +function normalizeBindingValue( + actionId: KeybindingActionId, + value: unknown +): { ok: true; value: string[] } | { ok: false; error: string } { + if (value === null || value === false) { + return { ok: true, value: [] } + } + if (typeof value === 'string') { + const normalized = normalizeKeybindingListForAction(actionId, value) + return Array.isArray(normalized) + ? { ok: true, value: normalized } + : normalized.ok + ? { ok: true, value: [normalized.value] } + : normalized + } + if (Array.isArray(value)) { + if (!value.every((item) => typeof item === 'string')) { + return { ok: false, error: 'Use a string, string array, null, or false.' } + } + const normalized = normalizeKeybindingArrayForAction(actionId, value) + return Array.isArray(normalized) + ? { ok: true, value: normalized } + : normalized.ok + ? { ok: true, value: [normalized.value] } + : normalized + } + return { ok: false, error: 'Use a string, string array, null, or false.' } +} + +export function normalizeWriteBindingValue( + actionId: KeybindingActionId, + value: unknown +): string[] | null { + if (value === null) { + return null + } + if (!Array.isArray(value) || !value.every((binding) => typeof binding === 'string')) { + throw new Error('Use a string array or null.') + } + const normalized = normalizeKeybindingArrayForAction(actionId, value) + if (!Array.isArray(normalized)) { + throw new Error(normalized.ok ? 'Unable to parse shortcut.' : normalized.error) + } + return normalized +} + +export function parseBindingSection( + value: unknown, + section: string, + diagnostics: KeybindingFileDiagnostic[], + options: { skipRootKeys?: boolean } = {} +): KeybindingOverrides { + if (value === undefined) { + return {} + } + if (!isJsonObject(value)) { + diagnostics.push({ + severity: 'error', + section, + message: `${section} must be an object.` + }) + return {} + } + + const overrides: KeybindingOverrides = {} + for (const [actionId, rawBinding] of Object.entries(value)) { + if (options.skipRootKeys && ROOT_KEYS.has(actionId)) { + continue + } + if (!isKeybindingActionId(actionId)) { + diagnostics.push({ + severity: 'warning', + section, + actionId, + message: `Unknown keybinding action "${actionId}" was ignored.` + }) + continue + } + const normalized = normalizeBindingValue(actionId, rawBinding) + if (!normalized.ok) { + diagnostics.push({ + severity: 'error', + section, + actionId, + message: `Shortcut for "${actionId}" was ignored: ${normalized.error}` + }) + continue + } + overrides[actionId] = normalized.value + } + return overrides +} + +export function parsePlatformOverrides( + document: JsonObject, + diagnostics: KeybindingFileDiagnostic[] +): Partial> { + const rawPlatforms = document.platforms + if (rawPlatforms === undefined) { + return {} + } + if (!isJsonObject(rawPlatforms)) { + diagnostics.push({ + severity: 'error', + section: 'platforms', + message: 'platforms must be an object with darwin, linux, or win32 sections.' + }) + return {} + } + + const result: Partial> = {} + for (const [platform, value] of Object.entries(rawPlatforms)) { + if (!PLATFORM_KEYS.includes(platform as KeybindingPlatform)) { + diagnostics.push({ + severity: 'warning', + section: `platforms.${platform}`, + message: `Unknown platform "${platform}" was ignored.` + }) + continue + } + result[platform as KeybindingPlatform] = parseBindingSection( + value, + `platforms.${platform}`, + diagnostics + ) + } + return result +} + +export function removeConflictingOverrides( + platform: KeybindingPlatform, + overrides: KeybindingOverrides, + diagnostics: KeybindingFileDiagnostic[] +): KeybindingOverrides { + let next = { ...overrides } + for (let attempt = 0; attempt < 20; attempt++) { + const conflicts = findKeybindingConflicts(platform, next) + const conflictingOverrides = new Set() + + for (const conflict of conflicts) { + for (const actionId of conflict.actionIds) { + if (Object.hasOwn(next, actionId)) { + conflictingOverrides.add(actionId) + } + } + } + + if (conflictingOverrides.size === 0) { + return next + } + + for (const actionId of conflictingOverrides) { + delete next[actionId] + } + + diagnostics.push({ + severity: 'error', + message: `Conflicting custom shortcuts were ignored: ${Array.from(conflictingOverrides) + .map((actionId) => getKeybindingDefinition(actionId)?.title ?? actionId) + .join(', ')}.` + }) + } + return next +} diff --git a/src/main/keybindings/keybinding-file.ts b/src/main/keybindings/keybinding-file.ts index 7a357010406..4ee6bfeb2ce 100644 --- a/src/main/keybindings/keybinding-file.ts +++ b/src/main/keybindings/keybinding-file.ts @@ -1,250 +1,33 @@ -/* eslint-disable max-lines -- Why: parsing, sanitizing, migrating, and writing the keybindings file must stay together so file-format edge cases share one validation path. */ -import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' -import { dirname, join } from 'node:path' +import { existsSync } from 'node:fs' +import { join } from 'node:path' import { findKeybindingConflicts, formatKeybindingList, - getKeybindingDefinition, getKeybindingPlatform, isKeybindingActionId, normalizeKeybindingArrayForAction, - normalizeKeybindingListForAction, type KeybindingActionId, type KeybindingFileDiagnostic, type KeybindingFileSnapshot, - type KeybindingOverrides, - type KeybindingPlatform + type KeybindingOverrides } from '../../shared/keybindings' - -type JsonObject = Record - -const FILE_VERSION = 1 -const PLATFORM_KEYS: readonly KeybindingPlatform[] = ['darwin', 'linux', 'win32'] -const ROOT_KEYS = new Set(['$schema', 'version', 'keybindings', 'platforms']) +import { + createEmptyDocument, + FILE_VERSION, + isJsonObject, + normalizeWriteBindingValue, + parseBindingSection, + parsePlatformOverrides, + readJsonDocument, + removeConflictingOverrides, + writeJsonDocument, + type JsonObject +} from './keybinding-file-parser' export function getUserKeybindingsPath(homePath: string): string { return join(homePath, '.orca', 'keybindings.json') } -function isJsonObject(value: unknown): value is JsonObject { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value) -} - -function createEmptyDocument(): JsonObject { - return { - version: FILE_VERSION, - keybindings: {}, - platforms: { - darwin: {}, - linux: {}, - win32: {} - } - } -} - -function readJsonDocument(path: string): { - exists: boolean - document: JsonObject | null - error?: string -} { - if (!existsSync(path)) { - return { exists: false, document: createEmptyDocument() } - } - try { - const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown - if (!isJsonObject(parsed)) { - return { exists: true, document: null, error: 'Keybindings file must contain a JSON object.' } - } - return { exists: true, document: parsed } - } catch (error) { - return { - exists: true, - document: null, - error: error instanceof Error ? error.message : String(error) - } - } -} - -function writeJsonDocument(path: string, document: JsonObject): void { - mkdirSync(dirname(path), { recursive: true }) - const tempPath = `${path}.tmp` - try { - writeFileSync(tempPath, `${JSON.stringify(document, null, 2)}\n`, 'utf8') - renameSync(tempPath, path) - } catch (error) { - try { - if (existsSync(tempPath)) { - unlinkSync(tempPath) - } - } catch { - // Ignore cleanup failure; the original write error is more actionable. - } - throw error - } -} - -function normalizeBindingValue( - actionId: KeybindingActionId, - value: unknown -): { ok: true; value: string[] } | { ok: false; error: string } { - if (value === null || value === false) { - return { ok: true, value: [] } - } - if (typeof value === 'string') { - const normalized = normalizeKeybindingListForAction(actionId, value) - return Array.isArray(normalized) - ? { ok: true, value: normalized } - : normalized.ok - ? { ok: true, value: [normalized.value] } - : normalized - } - if (Array.isArray(value)) { - if (!value.every((item) => typeof item === 'string')) { - return { ok: false, error: 'Use a string, string array, null, or false.' } - } - const normalized = normalizeKeybindingArrayForAction(actionId, value) - return Array.isArray(normalized) - ? { ok: true, value: normalized } - : normalized.ok - ? { ok: true, value: [normalized.value] } - : normalized - } - return { ok: false, error: 'Use a string, string array, null, or false.' } -} - -function normalizeWriteBindingValue(actionId: KeybindingActionId, value: unknown): string[] | null { - if (value === null) { - return null - } - if (!Array.isArray(value) || !value.every((binding) => typeof binding === 'string')) { - throw new Error('Use a string array or null.') - } - const normalized = normalizeKeybindingArrayForAction(actionId, value) - if (!Array.isArray(normalized)) { - throw new Error(normalized.ok ? 'Unable to parse shortcut.' : normalized.error) - } - return normalized -} - -function parseBindingSection( - value: unknown, - section: string, - diagnostics: KeybindingFileDiagnostic[], - options: { skipRootKeys?: boolean } = {} -): KeybindingOverrides { - if (value === undefined) { - return {} - } - if (!isJsonObject(value)) { - diagnostics.push({ - severity: 'error', - section, - message: `${section} must be an object.` - }) - return {} - } - - const overrides: KeybindingOverrides = {} - for (const [actionId, rawBinding] of Object.entries(value)) { - if (options.skipRootKeys && ROOT_KEYS.has(actionId)) { - continue - } - if (!isKeybindingActionId(actionId)) { - diagnostics.push({ - severity: 'warning', - section, - actionId, - message: `Unknown keybinding action "${actionId}" was ignored.` - }) - continue - } - const normalized = normalizeBindingValue(actionId, rawBinding) - if (!normalized.ok) { - diagnostics.push({ - severity: 'error', - section, - actionId, - message: `Shortcut for "${actionId}" was ignored: ${normalized.error}` - }) - continue - } - overrides[actionId] = normalized.value - } - return overrides -} - -function parsePlatformOverrides( - document: JsonObject, - diagnostics: KeybindingFileDiagnostic[] -): Partial> { - const rawPlatforms = document.platforms - if (rawPlatforms === undefined) { - return {} - } - if (!isJsonObject(rawPlatforms)) { - diagnostics.push({ - severity: 'error', - section: 'platforms', - message: 'platforms must be an object with darwin, linux, or win32 sections.' - }) - return {} - } - - const result: Partial> = {} - for (const [platform, value] of Object.entries(rawPlatforms)) { - if (!PLATFORM_KEYS.includes(platform as KeybindingPlatform)) { - diagnostics.push({ - severity: 'warning', - section: `platforms.${platform}`, - message: `Unknown platform "${platform}" was ignored.` - }) - continue - } - result[platform as KeybindingPlatform] = parseBindingSection( - value, - `platforms.${platform}`, - diagnostics - ) - } - return result -} - -function removeConflictingOverrides( - platform: KeybindingPlatform, - overrides: KeybindingOverrides, - diagnostics: KeybindingFileDiagnostic[] -): KeybindingOverrides { - let next = { ...overrides } - for (let attempt = 0; attempt < 20; attempt++) { - const conflicts = findKeybindingConflicts(platform, next) - const conflictingOverrides = new Set() - - for (const conflict of conflicts) { - for (const actionId of conflict.actionIds) { - if (Object.hasOwn(next, actionId)) { - conflictingOverrides.add(actionId) - } - } - } - - if (conflictingOverrides.size === 0) { - return next - } - - for (const actionId of conflictingOverrides) { - delete next[actionId] - } - - diagnostics.push({ - severity: 'error', - message: `Conflicting custom shortcuts were ignored: ${Array.from(conflictingOverrides) - .map((actionId) => getKeybindingDefinition(actionId)?.title ?? actionId) - .join(', ')}.` - }) - } - return next -} - export function readKeybindingFile( path: string, platform: NodeJS.Platform = process.platform diff --git a/src/main/memory/collector.ts b/src/main/memory/collector.ts index c0e77513004..ff2089e7eb7 100644 --- a/src/main/memory/collector.ts +++ b/src/main/memory/collector.ts @@ -1,6 +1,3 @@ -/* eslint-disable max-lines -- Why: the collector's host-process index, - history ring buffer, Electron bucketing, and worktree attribution live - together to keep one snapshot's orchestration in one place. */ /** * Memory dashboard collector. * @@ -17,11 +14,9 @@ * renderer polls never produces overlapping child processes. */ -import { basename } from 'node:path' import { exec } from 'node:child_process' import { promisify } from 'node:util' import os from 'node:os' -import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' import { getProcessOutputFields, iterateProcessOutputLines @@ -30,7 +25,6 @@ import { getAppEnvironment, type AppEnvironment } from '../../shared/app-environ import type { AppMemory, MemorySnapshot, - ProcessCommitMetric, SessionMemory, UsageValues, WorktreeMemory @@ -39,8 +33,24 @@ import type { Store } from '../persistence' import { ORPHAN_WORKTREE_ID } from '../../shared/constants' import { listRegisteredPtys } from './pty-registry' import { enumerateWindowsProcessResources } from './windows-process-resource-collector' -import { collectHostMemory, fallbackHostMemory } from './host-memory' +import { collectHostMemory } from './host-memory' import { getProcessMemoryMetric } from './process-memory-metric' +import { + createEmptyWorktreeMemoryBucket, + pushAppMemoryHistory, + pushMemoryHistorySample, + readAppMemoryHistory, + readMemoryHistory, + resolveWorktreeMemoryNames, + sweepStaleMemoryHistory, + type WorktreeMemoryBucket +} from './memory-snapshot-buckets' +import { + clampMemoryMetric, + emptyMemorySnapshot, + optionalCommitField, + snapshotCommitFields +} from './memory-snapshot-values' export type MemorySnapshotStore = Pick @@ -61,7 +71,7 @@ export async function collectMemorySnapshot(store: MemorySnapshotStore): Promise inflight = runSnapshot(store) .catch((err) => { console.warn('[memory] snapshot failed; returning empty', err) - return emptySnapshot() + return emptyMemorySnapshot() }) .finally(() => { inflight = null @@ -99,88 +109,6 @@ type ProcIndex = { hasPrivateMemory: boolean } -const PROCESS_COMMIT_METRIC: ProcessCommitMetric = 'private-bytes' - -/** - * The one rule for every committed-bytes key: present only when the sweep could - * measure it, because a 0 would read as "these processes commit nothing". - */ -function commitField(hasPrivateMemory: boolean, privateMemory: number): { privateMemory?: number } { - return hasPrivateMemory ? { privateMemory: clampNumber(privateMemory) } : {} -} - -/** The snapshot-level pair, which names the unit alongside the total. */ -function snapshotCommitFields( - hasPrivateMemory: boolean, - totalPrivateMemory: number -): Pick { - return hasPrivateMemory - ? { - processCommitMetric: PROCESS_COMMIT_METRIC, - totalPrivateMemory: clampNumber(totalPrivateMemory) - } - : {} -} - -function clampNumber(value: unknown): number { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return 0 - } - return Math.max(0, value) -} - -function emptySnapshot(): MemorySnapshot { - const zero = { cpu: 0, memory: 0 } - return { - app: { ...zero, main: zero, renderer: zero, other: zero, history: [] }, - worktrees: [], - host: fallbackHostMemory(), - processMemoryMetric: getProcessMemoryMetric(), - totalCpu: 0, - totalMemory: 0, - collectedAt: Date.now() - } -} - -// โ”€โ”€โ”€ History ring buffers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -const APP_HISTORY_KEY = '__app__' -const HISTORY_CAPACITY = 60 -const HISTORY_STALE_MS = 10 * 60 * 1000 - -type HistoryRing = { - samples: number[] - touchedAt: number -} - -const historyByKey = new Map() - -function pushHistorySample(key: string, memoryBytes: number, now: number): void { - let ring = historyByKey.get(key) - if (!ring) { - ring = { samples: [], touchedAt: now } - historyByKey.set(key, ring) - } - ring.samples.push(memoryBytes) - if (ring.samples.length > HISTORY_CAPACITY) { - ring.samples.shift() - } - ring.touchedAt = now -} - -function readHistory(key: string): number[] { - const ring = historyByKey.get(key) - return ring ? [...ring.samples] : [] -} - -function sweepStaleHistory(now: number): void { - for (const [key, ring] of historyByKey) { - if (now - ring.touchedAt > HISTORY_STALE_MS) { - historyByKey.delete(key) - } - } -} - // โ”€โ”€โ”€ Host process enumeration โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ async function enumerateProcesses(): Promise { @@ -292,7 +220,7 @@ function electronMetricMemoryBytes( // Why: on macOS, getAppEnvironment().getAppMetrics().workingSetSize can include large shared // Chromium/Electron mappings. Prefer the host RSS sweep used elsewhere, but // keep workingSetSize as a fallback when the process disappears mid-snapshot. - return clampNumber(proc.memory?.workingSetSize) * 1024 + return clampMemoryMetric(proc.memory?.workingSetSize) * 1024 } function bucketElectronMetrics(processIndex: ProcIndex): AppBucketsRaw { @@ -301,11 +229,11 @@ function bucketElectronMetrics(processIndex: ProcIndex): AppBucketsRaw { const other = { cpu: 0, memory: 0, privateMemory: 0 } for (const proc of getAppEnvironment().getAppMetrics()) { - const cpu = clampNumber(proc.cpu?.percentCPUUsage) + const cpu = clampMemoryMetric(proc.cpu?.percentCPUUsage) const memoryBytes = electronMetricMemoryBytes(proc, processIndex) // Why the host row rather than Electron's own metric: getAppMetrics has no // commit figure for helper processes, and the sweep already indexed them. - const privateBytes = clampNumber(processIndex.byPid.get(proc.pid)?.privateMemory) + const privateBytes = clampMemoryMetric(processIndex.byPid.get(proc.pid)?.privateMemory) // Why: lowercase once so future Electron versions emitting different // casing ('browser' vs 'Browser') still bucket correctly. @@ -325,7 +253,7 @@ function bucketElectronMetrics(processIndex: ProcIndex): AppBucketsRaw { const usage = (bucket: typeof main): UsageValues => ({ cpu: bucket.cpu, memory: bucket.memory, - ...commitField(processIndex.hasPrivateMemory, bucket.privateMemory) + ...optionalCommitField(processIndex.hasPrivateMemory, bucket.privateMemory) }) return { @@ -340,61 +268,6 @@ function bucketElectronMetrics(processIndex: ProcIndex): AppBucketsRaw { } } -// โ”€โ”€โ”€ Worktree attribution โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -type WorktreeBucket = { - worktreeId: string - worktreeName: string - repoId: string - repoName: string - cpu: number - memory: number - privateMemory: number - sessions: SessionMemory[] -} - -function resolveWorktreeNames( - worktreeId: string, - store: MemorySnapshotStore -): { - worktreeName: string - repoId: string - repoName: string -} { - // Orca worktree ids look like `${repoId}::${absolutePath}`. - const parsed = splitWorktreeIdForFilesystem(worktreeId) - const repoId = parsed?.repoId ?? worktreeId - const worktreePath = parsed?.worktreePath ?? '' - const fallbackName = worktreePath ? basename(worktreePath) : worktreeId - - const meta = store.getWorktreeMeta(worktreeId) - const repo = store.getRepo(repoId) - - return { - worktreeName: meta?.displayName?.trim() || fallbackName, - repoId, - repoName: repo?.displayName?.trim() || repoId || 'Unknown Repo' - } -} - -function makeEmptyBucket( - worktreeId: string, - worktreeName: string, - repoId: string, - repoName: string -): WorktreeBucket { - return { - worktreeId, - worktreeName, - repoId, - repoName, - cpu: 0, - memory: 0, - privateMemory: 0, - sessions: [] - } -} - // โ”€โ”€โ”€ Main collection path โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ async function runSnapshot(store: MemorySnapshotStore): Promise { @@ -408,13 +281,13 @@ async function runSnapshot(store: MemorySnapshotStore): Promise // attribute to the first PTY (registration order) to see each pid. const claimed = new Set() - const orphan = makeEmptyBucket( + const orphan = createEmptyWorktreeMemoryBucket( ORPHAN_WORKTREE_ID, 'Unattributed terminals', ORPHAN_WORKTREE_ID, 'Other' ) - const worktreeBuckets = new Map() + const worktreeBuckets = new Map() for (const pty of ptys) { let sessionCpu = 0 @@ -435,7 +308,7 @@ async function runSnapshot(store: MemorySnapshotStore): Promise sessionMemory += row.memory // Why the whole subtree: an agent's committed bytes live in the // children it spawned (codex.exe, MCP servers), not in the shell. - sessionPrivateMemory += clampNumber(row.privateMemory) + sessionPrivateMemory += clampMemoryMetric(row.privateMemory) } } @@ -443,19 +316,24 @@ async function runSnapshot(store: MemorySnapshotStore): Promise sessionId: pty.sessionId ?? pty.ptyId, paneKey: pty.paneKey, pid: pty.pid ?? 0, - cpu: clampNumber(sessionCpu), - memory: clampNumber(sessionMemory), - ...commitField(processIndex.hasPrivateMemory, sessionPrivateMemory) + cpu: clampMemoryMetric(sessionCpu), + memory: clampMemoryMetric(sessionMemory), + ...optionalCommitField(processIndex.hasPrivateMemory, sessionPrivateMemory) } - let bucket: WorktreeBucket + let bucket: WorktreeMemoryBucket if (pty.worktreeId) { const existing = worktreeBuckets.get(pty.worktreeId) if (existing) { bucket = existing } else { - const names = resolveWorktreeNames(pty.worktreeId, store) - bucket = makeEmptyBucket(pty.worktreeId, names.worktreeName, names.repoId, names.repoName) + const names = resolveWorktreeMemoryNames(pty.worktreeId, store) + bucket = createEmptyWorktreeMemoryBucket( + pty.worktreeId, + names.worktreeName, + names.repoId, + names.repoName + ) worktreeBuckets.set(pty.worktreeId, bucket) } } else { @@ -464,11 +342,11 @@ async function runSnapshot(store: MemorySnapshotStore): Promise bucket.cpu += session.cpu bucket.memory += session.memory - bucket.privateMemory += clampNumber(session.privateMemory) + bucket.privateMemory += clampMemoryMetric(session.privateMemory) bucket.sessions.push(session) } - const bucketList: WorktreeBucket[] = [...worktreeBuckets.values()] + const bucketList: WorktreeMemoryBucket[] = [...worktreeBuckets.values()] if (orphan.sessions.length > 0) { bucketList.push(orphan) } @@ -477,16 +355,16 @@ async function runSnapshot(store: MemorySnapshotStore): Promise // returned arrays end with the freshly-collected value. Each write also // acts as a keep-alive so active worktrees survive the staleness sweep. const now = Date.now() - pushHistorySample(APP_HISTORY_KEY, appBuckets.memory, now) + pushAppMemoryHistory(appBuckets.memory, now) for (const bucket of bucketList) { - pushHistorySample(bucket.worktreeId, bucket.memory, now) + pushMemoryHistorySample(bucket.worktreeId, bucket.memory, now) } - sweepStaleHistory(now) + sweepStaleMemoryHistory(now) const worktrees: WorktreeMemory[] = bucketList.map(({ privateMemory, ...b }) => ({ ...b, - ...commitField(processIndex.hasPrivateMemory, privateMemory), - history: readHistory(b.worktreeId) + ...optionalCommitField(processIndex.hasPrivateMemory, privateMemory), + history: readMemoryHistory(b.worktreeId) })) let sessionCpuTotal = 0 @@ -495,17 +373,17 @@ async function runSnapshot(store: MemorySnapshotStore): Promise for (const wt of worktrees) { sessionCpuTotal += wt.cpu sessionMemoryTotal += wt.memory - sessionPrivateTotal += clampNumber(wt.privateMemory) + sessionPrivateTotal += clampMemoryMetric(wt.privateMemory) } return { - app: { ...appBuckets, history: readHistory(APP_HISTORY_KEY) }, + app: { ...appBuckets, history: readAppMemoryHistory() }, worktrees, host, processMemoryMetric: getProcessMemoryMetric(), ...snapshotCommitFields( processIndex.hasPrivateMemory, - clampNumber(appBuckets.privateMemory) + sessionPrivateTotal + clampMemoryMetric(appBuckets.privateMemory) + sessionPrivateTotal ), totalCpu: appBuckets.cpu + sessionCpuTotal, totalMemory: appBuckets.memory + sessionMemoryTotal, diff --git a/src/main/memory/memory-snapshot-buckets.ts b/src/main/memory/memory-snapshot-buckets.ts new file mode 100644 index 00000000000..f2a45e22190 --- /dev/null +++ b/src/main/memory/memory-snapshot-buckets.ts @@ -0,0 +1,101 @@ +import { basename } from 'node:path' +import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' +import type { SessionMemory } from '../../shared/process-stats-types' +import type { MemorySnapshotStore } from './collector' + +const APP_HISTORY_KEY = '__app__' +const HISTORY_CAPACITY = 60 +const HISTORY_STALE_MS = 10 * 60 * 1000 + +type HistoryRing = { + samples: number[] + touchedAt: number +} + +const historyByKey = new Map() + +export function pushMemoryHistorySample(key: string, memoryBytes: number, now: number): void { + let ring = historyByKey.get(key) + if (!ring) { + ring = { samples: [], touchedAt: now } + historyByKey.set(key, ring) + } + ring.samples.push(memoryBytes) + if (ring.samples.length > HISTORY_CAPACITY) { + ring.samples.shift() + } + ring.touchedAt = now +} + +export function readMemoryHistory(key: string): number[] { + const ring = historyByKey.get(key) + return ring ? [...ring.samples] : [] +} + +export function sweepStaleMemoryHistory(now: number): void { + for (const [key, ring] of historyByKey) { + if (now - ring.touchedAt > HISTORY_STALE_MS) { + historyByKey.delete(key) + } + } +} + +export function pushAppMemoryHistory(memoryBytes: number, now: number): void { + pushMemoryHistorySample(APP_HISTORY_KEY, memoryBytes, now) +} + +export function readAppMemoryHistory(): number[] { + return readMemoryHistory(APP_HISTORY_KEY) +} + +export type WorktreeMemoryBucket = { + worktreeId: string + worktreeName: string + repoId: string + repoName: string + cpu: number + memory: number + privateMemory: number + sessions: SessionMemory[] +} + +export function resolveWorktreeMemoryNames( + worktreeId: string, + store: MemorySnapshotStore +): { + worktreeName: string + repoId: string + repoName: string +} { + // Orca worktree ids look like `${repoId}::${absolutePath}`. + const parsed = splitWorktreeIdForFilesystem(worktreeId) + const repoId = parsed?.repoId ?? worktreeId + const worktreePath = parsed?.worktreePath ?? '' + const fallbackName = worktreePath ? basename(worktreePath) : worktreeId + const meta = store.getWorktreeMeta(worktreeId) + const repo = store.getRepo(repoId) + + return { + worktreeName: meta?.displayName?.trim() || fallbackName, + repoId, + repoName: repo?.displayName?.trim() || repoId || 'Unknown Repo' + } +} + +export function createEmptyWorktreeMemoryBucket( + worktreeId: string, + worktreeName: string, + repoId: string, + repoName: string +): WorktreeMemoryBucket { + return { + worktreeId, + worktreeName, + repoId, + repoName, + cpu: 0, + memory: 0, + privateMemory: 0, + sessions: [] + } +} diff --git a/src/main/memory/memory-snapshot-values.ts b/src/main/memory/memory-snapshot-values.ts new file mode 100644 index 00000000000..4fea737c6f6 --- /dev/null +++ b/src/main/memory/memory-snapshot-values.ts @@ -0,0 +1,49 @@ +import type { MemorySnapshot, ProcessCommitMetric } from '../../shared/process-stats-types' +import { fallbackHostMemory } from './host-memory' +import { getProcessMemoryMetric } from './process-memory-metric' + +const PROCESS_COMMIT_METRIC: ProcessCommitMetric = 'private-bytes' + +export function clampMemoryMetric(value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return 0 + } + return Math.max(0, value) +} + +/** + * The one rule for every committed-bytes key: present only when the sweep could + * measure it, because a 0 would read as "these processes commit nothing". + */ +export function optionalCommitField( + hasPrivateMemory: boolean, + privateMemory: number +): { privateMemory?: number } { + return hasPrivateMemory ? { privateMemory: clampMemoryMetric(privateMemory) } : {} +} + +/** The snapshot-level pair, which names the unit alongside the total. */ +export function snapshotCommitFields( + hasPrivateMemory: boolean, + totalPrivateMemory: number +): Pick { + return hasPrivateMemory + ? { + processCommitMetric: PROCESS_COMMIT_METRIC, + totalPrivateMemory: clampMemoryMetric(totalPrivateMemory) + } + : {} +} + +export function emptyMemorySnapshot(): MemorySnapshot { + const zero = { cpu: 0, memory: 0 } + return { + app: { ...zero, main: zero, renderer: zero, other: zero, history: [] }, + worktrees: [], + host: fallbackHostMemory(), + processMemoryMetric: getProcessMemoryMetric(), + totalCpu: 0, + totalMemory: 0, + collectedAt: Date.now() + } +} diff --git a/src/main/persistence/scheduling-automations/automation-definition-operations.ts b/src/main/persistence/scheduling-automations/automation-definition-operations.ts index 3b06ad7dce9..3107ae37a5a 100644 --- a/src/main/persistence/scheduling-automations/automation-definition-operations.ts +++ b/src/main/persistence/scheduling-automations/automation-definition-operations.ts @@ -6,7 +6,7 @@ import type { } from '../../../shared/automations-types' import type { PersistedState } from '../../../shared/persisted-state-types' import { normalizeAutomationPrecheck } from '../../../shared/automation-precheck' -import { nextAutomationOccurrenceAfter } from '../../../shared/automation-schedules' +import { nextAutomationOccurrenceAfter } from '../../../shared/automation-schedule-occurrences' import { applyAutomationExecutionTarget, deriveAutomationExecutionTargetForCreate, diff --git a/src/main/persistence/scheduling-automations/automation-schedule-operations.ts b/src/main/persistence/scheduling-automations/automation-schedule-operations.ts index d1142eb771e..2097efb4a81 100644 --- a/src/main/persistence/scheduling-automations/automation-schedule-operations.ts +++ b/src/main/persistence/scheduling-automations/automation-schedule-operations.ts @@ -3,7 +3,7 @@ import type { PersistedState } from '../../../shared/persisted-state-types' import { latestAutomationOccurrenceAtOrBefore, nextAutomationOccurrenceAfter -} from '../../../shared/automation-schedules' +} from '../../../shared/automation-schedule-occurrences' export function advanceAutomationNextRun( state: PersistedState, diff --git a/src/main/project-groups/nested-repo-discovery.ts b/src/main/project-groups/nested-repo-discovery.ts index d2df48e7970..84fddfd1167 100644 --- a/src/main/project-groups/nested-repo-discovery.ts +++ b/src/main/project-groups/nested-repo-discovery.ts @@ -1,182 +1,15 @@ -/* eslint-disable max-lines -- Why: scanner traversal, ignore matching, and filesystem -abstraction stay together so local, SSH, and runtime scans cannot drift. */ import { readFile, readdir, stat } from 'node:fs/promises' import { basename, join } from 'node:path' -import type { - NestedRepoCandidate, - NestedRepoScanOptions, - NestedRepoScanResult -} from '../../shared/project-group-types' +import type { NestedRepoCandidate, NestedRepoScanResult } from '../../shared/project-group-types' import { isGitRepo } from '../git/repo' - -type NestedRepoDirectoryEntry = { - name: string - isDirectory: boolean - isSymlink?: boolean -} - -type NestedRepoScanFilesystem = { - readDirectory: (dirPath: string) => Promise - readTextFile?: (filePath: string) => Promise - joinPath: (parentPath: string, childName: string) => string - basename: (path: string) => string - hasGitMarker: (path: string) => Promise | boolean - isSelectedPathGitRepo: (path: string) => Promise | boolean -} - -type IgnoreRule = { - pattern: string - negate: boolean - basenameOnly: boolean - baseSegments: string[] -} - -type TraversalFolder = { - path: string - depth: number - segments: string[] - ignoreRules: IgnoreRule[] -} - -type NormalizedNestedRepoScanOptions = { - maxDepth: number - maxRepos: number - timeoutMs: number | null -} - -const DEFAULT_MAX_DEPTH = 3 -const DEFAULT_MAX_REPOS = 100 - -const SKIPPED_DIRS = new Set([ - 'node_modules', - '.next', - 'dist', - 'build', - '.cache', - 'vendor', - '__pycache__', - '.turbo', - '.parcel-cache' -]) - -const VCS_METADATA_DIRS = new Set(['.git', '.svn', '.hg', '.jj', '.sl', '.repo', 'CVS']) - -function normalizeScanOptions(options: unknown): NormalizedNestedRepoScanOptions { - const raw = options && typeof options === 'object' ? (options as NestedRepoScanOptions) : {} - return { - maxDepth: - typeof raw.maxDepth === 'number' && Number.isFinite(raw.maxDepth) - ? Math.max(1, Math.min(8, Math.floor(raw.maxDepth))) - : DEFAULT_MAX_DEPTH, - maxRepos: - typeof raw.maxRepos === 'number' && Number.isFinite(raw.maxRepos) - ? Math.max(1, Math.min(500, Math.floor(raw.maxRepos))) - : DEFAULT_MAX_REPOS, - timeoutMs: - raw.timeoutMs === null - ? null - : typeof raw.timeoutMs === 'number' && Number.isFinite(raw.timeoutMs) - ? Math.max(500, Math.min(30_000, Math.floor(raw.timeoutMs))) - : null - } -} - -function shouldSkipDirectory(name: string, depth: number): boolean { - if (VCS_METADATA_DIRS.has(name)) { - return true - } - if (SKIPPED_DIRS.has(name)) { - return true - } - return depth > 0 && name.startsWith('.') -} - -function globSegmentMatches(pattern: string, value: string): boolean { - if (!pattern.includes('*') && !pattern.includes('?')) { - return pattern === value - } - const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&') - const regex = new RegExp(`^${escaped.replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]')}$`) - return regex.test(value) -} - -function pathSegmentsMatch(patternSegments: string[], candidateSegments: string[]): boolean { - const matchFrom = (patternIndex: number, candidateIndex: number): boolean => { - if (patternIndex >= patternSegments.length) { - return candidateIndex >= candidateSegments.length - } - const pattern = patternSegments[patternIndex] - if (pattern === '**') { - return ( - matchFrom(patternIndex + 1, candidateIndex) || - (candidateIndex < candidateSegments.length && matchFrom(patternIndex, candidateIndex + 1)) - ) - } - return ( - candidateIndex < candidateSegments.length && - globSegmentMatches(pattern, candidateSegments[candidateIndex] ?? '') && - matchFrom(patternIndex + 1, candidateIndex + 1) - ) - } - return matchFrom(0, 0) -} - -function parseGitignoreRules(content: string, baseSegments: string[]): IgnoreRule[] { - return content - .split(/\r?\n/) - .map((rawLine) => rawLine.trim()) - .filter((line) => line.length > 0 && !line.startsWith('#')) - .map((line) => { - const negate = line.startsWith('!') - const unprefixed = negate ? line.slice(1) : line - const anchored = unprefixed.startsWith('/') - const pattern = unprefixed.replace(/^\/+/, '').replace(/\/+$/, '') - return { - pattern, - negate, - basenameOnly: !anchored && !pattern.includes('/'), - baseSegments - } - }) - .filter((rule) => rule.pattern.length > 0) -} - -function isIgnoredByRules(name: string, segments: string[], rules: IgnoreRule[]): boolean { - let ignored = false - for (const rule of rules) { - if (segments.length <= rule.baseSegments.length) { - continue - } - const relativeSegments = segments.slice(rule.baseSegments.length) - const patternSegments = rule.pattern.split('/') - const matches = rule.basenameOnly - ? relativeSegments.some((segment) => globSegmentMatches(rule.pattern, segment)) - : pathSegmentsMatch(patternSegments, relativeSegments) - if (matches) { - ignored = !rule.negate - } - } - return ignored || shouldSkipDirectory(name, segments.length - 1) -} - -async function readGitignoreRules(args: { - folderPath: string - entries: NestedRepoDirectoryEntry[] - filesystem: NestedRepoScanFilesystem - baseSegments: string[] -}): Promise { - if (!args.filesystem.readTextFile || !args.entries.some((entry) => entry.name === '.gitignore')) { - return [] - } - try { - const content = await args.filesystem.readTextFile( - args.filesystem.joinPath(args.folderPath, '.gitignore') - ) - return parseGitignoreRules(content, args.baseSegments) - } catch { - return [] - } -} +import { + isIgnoredNestedRepoDirectory, + normalizeNestedRepoScanOptions, + readNestedRepoGitignoreRules, + type NestedRepoDirectoryEntry, + type NestedRepoScanFilesystem, + type TraversalFolder +} from './nested-repo-scan-rules' async function hasGitMarker(dirPath: string): Promise { try { @@ -214,7 +47,7 @@ export async function scanNestedRepos(args: { onProgress?: (scan: NestedRepoScanResult) => void }): Promise { const startedAt = Date.now() - const options = normalizeScanOptions(args.options) + const options = normalizeNestedRepoScanOptions(args.options) const repos: NestedRepoCandidate[] = [] let truncated = false let timedOut = false @@ -290,7 +123,7 @@ export async function scanNestedRepos(args: { } const currentIgnoreRules = [ ...currentFolder.ignoreRules, - ...(await readGitignoreRules({ + ...(await readNestedRepoGitignoreRules({ folderPath: currentFolder.path, entries, filesystem, @@ -315,7 +148,7 @@ export async function scanNestedRepos(args: { break } const childSegments = [...currentFolder.segments, name] - if (isIgnoredByRules(name, childSegments, currentIgnoreRules)) { + if (isIgnoredNestedRepoDirectory(name, childSegments, currentIgnoreRules)) { continue } const childPath = filesystem.joinPath(currentFolder.path, name) diff --git a/src/main/project-groups/nested-repo-scan-rules.ts b/src/main/project-groups/nested-repo-scan-rules.ts new file mode 100644 index 00000000000..32d6881e15b --- /dev/null +++ b/src/main/project-groups/nested-repo-scan-rules.ts @@ -0,0 +1,174 @@ +import type { NestedRepoScanOptions } from '../../shared/project-group-types' + +export type NestedRepoDirectoryEntry = { + name: string + isDirectory: boolean + isSymlink?: boolean +} + +export type NestedRepoScanFilesystem = { + readDirectory: (dirPath: string) => Promise + readTextFile?: (filePath: string) => Promise + joinPath: (parentPath: string, childName: string) => string + basename: (path: string) => string + hasGitMarker: (path: string) => Promise | boolean + isSelectedPathGitRepo: (path: string) => Promise | boolean +} + +type IgnoreRule = { + pattern: string + negate: boolean + basenameOnly: boolean + baseSegments: string[] +} + +export type TraversalFolder = { + path: string + depth: number + segments: string[] + ignoreRules: IgnoreRule[] +} + +export type NormalizedNestedRepoScanOptions = { + maxDepth: number + maxRepos: number + timeoutMs: number | null +} + +const DEFAULT_MAX_DEPTH = 3 +const DEFAULT_MAX_REPOS = 100 + +const SKIPPED_DIRS = new Set([ + 'node_modules', + '.next', + 'dist', + 'build', + '.cache', + 'vendor', + '__pycache__', + '.turbo', + '.parcel-cache' +]) + +const VCS_METADATA_DIRS = new Set(['.git', '.svn', '.hg', '.jj', '.sl', '.repo', 'CVS']) + +export function normalizeNestedRepoScanOptions(options: unknown): NormalizedNestedRepoScanOptions { + const raw = options && typeof options === 'object' ? (options as NestedRepoScanOptions) : {} + return { + maxDepth: + typeof raw.maxDepth === 'number' && Number.isFinite(raw.maxDepth) + ? Math.max(1, Math.min(8, Math.floor(raw.maxDepth))) + : DEFAULT_MAX_DEPTH, + maxRepos: + typeof raw.maxRepos === 'number' && Number.isFinite(raw.maxRepos) + ? Math.max(1, Math.min(500, Math.floor(raw.maxRepos))) + : DEFAULT_MAX_REPOS, + timeoutMs: + raw.timeoutMs === null + ? null + : typeof raw.timeoutMs === 'number' && Number.isFinite(raw.timeoutMs) + ? Math.max(500, Math.min(30_000, Math.floor(raw.timeoutMs))) + : null + } +} + +function shouldSkipDirectory(name: string, depth: number): boolean { + if (VCS_METADATA_DIRS.has(name)) { + return true + } + if (SKIPPED_DIRS.has(name)) { + return true + } + return depth > 0 && name.startsWith('.') +} + +function globSegmentMatches(pattern: string, value: string): boolean { + if (!pattern.includes('*') && !pattern.includes('?')) { + return pattern === value + } + const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&') + const regex = new RegExp(`^${escaped.replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]')}$`) + return regex.test(value) +} + +function pathSegmentsMatch(patternSegments: string[], candidateSegments: string[]): boolean { + const matchFrom = (patternIndex: number, candidateIndex: number): boolean => { + if (patternIndex >= patternSegments.length) { + return candidateIndex >= candidateSegments.length + } + const pattern = patternSegments[patternIndex] + if (pattern === '**') { + return ( + matchFrom(patternIndex + 1, candidateIndex) || + (candidateIndex < candidateSegments.length && matchFrom(patternIndex, candidateIndex + 1)) + ) + } + return ( + candidateIndex < candidateSegments.length && + globSegmentMatches(pattern, candidateSegments[candidateIndex] ?? '') && + matchFrom(patternIndex + 1, candidateIndex + 1) + ) + } + return matchFrom(0, 0) +} + +function parseGitignoreRules(content: string, baseSegments: string[]): IgnoreRule[] { + return content + .split(/\r?\n/) + .map((rawLine) => rawLine.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#')) + .map((line) => { + const negate = line.startsWith('!') + const unprefixed = negate ? line.slice(1) : line + const anchored = unprefixed.startsWith('/') + const pattern = unprefixed.replace(/^\/+/, '').replace(/\/+$/, '') + return { + pattern, + negate, + basenameOnly: !anchored && !pattern.includes('/'), + baseSegments + } + }) + .filter((rule) => rule.pattern.length > 0) +} + +export function isIgnoredNestedRepoDirectory( + name: string, + segments: string[], + rules: IgnoreRule[] +): boolean { + let ignored = false + for (const rule of rules) { + if (segments.length <= rule.baseSegments.length) { + continue + } + const relativeSegments = segments.slice(rule.baseSegments.length) + const patternSegments = rule.pattern.split('/') + const matches = rule.basenameOnly + ? relativeSegments.some((segment) => globSegmentMatches(rule.pattern, segment)) + : pathSegmentsMatch(patternSegments, relativeSegments) + if (matches) { + ignored = !rule.negate + } + } + return ignored || shouldSkipDirectory(name, segments.length - 1) +} + +export async function readNestedRepoGitignoreRules(args: { + folderPath: string + entries: NestedRepoDirectoryEntry[] + filesystem: NestedRepoScanFilesystem + baseSegments: string[] +}): Promise { + if (!args.filesystem.readTextFile || !args.entries.some((entry) => entry.name === '.gitignore')) { + return [] + } + try { + const content = await args.filesystem.readTextFile( + args.filesystem.joinPath(args.folderPath, '.gitignore') + ) + return parseGitignoreRules(content, args.baseSegments) + } catch { + return [] + } +} diff --git a/src/main/rate-limits/claude-pty-stop-markers.ts b/src/main/rate-limits/claude-pty-stop-markers.ts new file mode 100644 index 00000000000..2bba56c6911 --- /dev/null +++ b/src/main/rate-limits/claude-pty-stop-markers.ts @@ -0,0 +1,14 @@ +// Why: these substrings indicate the /usage TUI panel has finished rendering. +export const CLAUDE_USAGE_STOP_SUBSTRINGS = [ + 'Current week (all models)', + 'Current week (Opus)', + 'Current week (Sonnet only)', + 'Current week (Sonnet)', + 'Weekly limits', + 'Weekly limit', + 'Weekly usage', + '7-day', + 'Current session', + 'Failed to load usage data', + 'failed to load usage data' +] diff --git a/src/main/rate-limits/claude-pty-usage-parser.ts b/src/main/rate-limits/claude-pty-usage-parser.ts new file mode 100644 index 00000000000..3328d947160 --- /dev/null +++ b/src/main/rate-limits/claude-pty-usage-parser.ts @@ -0,0 +1,166 @@ +import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' +import { extractClaudePtyResetMetadata } from './claude-pty-reset-parser' + +// Why: these patterns match the Claude CLI's /usage TUI panel output. +// "Current session" shows a percent like "62% used" or "62% left". +// Weekly labels have varied between "Current week" and "Weekly limits". +const SESSION_RE = /current\s*session/i +const WEEKLY_RE = /(?:current\s*week|weekly\s*(?:limits?|usage|rate\s*limits?)|7\s*[- ]?\s*day)/i +const FABLE_WORD_RE = /\bfable\b/i +const FABLE_LABEL_RE = /^\s*fable\s*$/i +// Why: derive from WEEKLY_RE so a future weekly-wording change stays in one place +// instead of silently reopening the Fable-weekly parsing gap this fix closed. +const FABLE_WEEKLY_LABEL_RE = new RegExp( + `${WEEKLY_RE.source}\\s*(?:\\([^)]*\\bfable\\b[^)]*\\)|[-:]?\\s*\\bfable\\b)`, + 'i' +) +const PERCENT_RE = /(\d{1,3})(?:\.\d+)?\s*%\s*(used|consumed|left|remaining|available)/i +const ESC = String.fromCharCode(27) +const BEL = String.fromCharCode(7) +const OSC_SEQUENCE_RE = new RegExp(`${ESC}\\][^${BEL}]*(?:${BEL}|${ESC}\\\\)`, 'g') +const CSI_SEQUENCE_RE = new RegExp(`${ESC}\\[[0-9;?]*[ -/]*[@-~]`, 'g') + +export function stripTerminalControlSequences(output: string): string { + return output.replace(OSC_SEQUENCE_RE, '').replace(CSI_SEQUENCE_RE, '') +} + +/** + * Extract percent-left from lines following a label match. + * Scans up to 12 lines after the label to find the associated percent. + */ +function matchesWeeklyLabel(line: string): boolean { + return WEEKLY_RE.test(line) && !FABLE_WORD_RE.test(line) +} + +function matchesFableBoundary(line: string): boolean { + return FABLE_LABEL_RE.test(line) || (FABLE_WORD_RE.test(line) && WEEKLY_RE.test(line)) +} + +function matchesFableUsageLabel(line: string): boolean { + // Why: broad Fable-weekly copy should stop nearby scans, but only a real + // Fable usage heading should produce the distinct Fable meter. + return FABLE_LABEL_RE.test(line) || FABLE_WEEKLY_LABEL_RE.test(line) +} + +function isSectionLabel(line: string): boolean { + return SESSION_RE.test(line) || matchesWeeklyLabel(line) || matchesFableBoundary(line) +} + +function extractPercentAfterLabel( + lines: string[], + matchesLabel: (line: string) => boolean +): number | null { + for (let i = 0; i < lines.length; i++) { + if (!matchesLabel(lines[i])) { + continue + } + // Scan next 12 lines for a percent + for (let j = i; j < Math.min(i + 12, lines.length); j++) { + if (j > i && isSectionLabel(lines[j])) { + break + } + const m = PERCENT_RE.exec(lines[j]) + if (m) { + const pct = Number.parseFloat(m[1]) + const word = m[2].toLowerCase() + const isUsed = word === 'used' || word === 'consumed' + return isUsed ? pct : 100 - pct + } + } + } + return null +} + +export function parseClaudePtyUsage(output: string): { + session: RateLimitWindow | null + weekly: RateLimitWindow | null + fableWeekly: RateLimitWindow | null +} { + const lines = output.split(/\r\n|\n|\r/) + + const sessionPct = extractPercentAfterLabel(lines, (line) => SESSION_RE.test(line)) + const weeklyPct = extractPercentAfterLabel(lines, matchesWeeklyLabel) + const fableWeeklyPct = extractPercentAfterLabel(lines, matchesFableUsageLabel) + const sessionReset = extractClaudePtyResetMetadata( + lines, + (line) => SESSION_RE.test(line), + isSectionLabel + ) + const weeklyReset = extractClaudePtyResetMetadata(lines, matchesWeeklyLabel, isSectionLabel) + const fableWeeklyReset = extractClaudePtyResetMetadata( + lines, + matchesFableUsageLabel, + isSectionLabel + ) + + const session: RateLimitWindow | null = + sessionPct !== null + ? { + usedPercent: Math.min(100, Math.max(0, sessionPct)), + windowMinutes: 300, + resetsAt: sessionReset.resetsAt, + resetDescription: sessionReset.resetDescription + } + : null + + const weekly: RateLimitWindow | null = + weeklyPct !== null + ? { + usedPercent: Math.min(100, Math.max(0, weeklyPct)), + windowMinutes: 10080, + resetsAt: weeklyReset.resetsAt, + resetDescription: weeklyReset.resetDescription + } + : null + + const fableWeekly: RateLimitWindow | null = + fableWeeklyPct !== null + ? { + usedPercent: Math.min(100, Math.max(0, fableWeeklyPct)), + windowMinutes: 10080, + resetsAt: fableWeeklyReset.resetsAt, + resetDescription: fableWeeklyReset.resetDescription + } + : null + + return { session, weekly, fableWeekly } +} + +const RATE_LIMITED_RE = /rate limited\.?\s+please try again later/i +const LOAD_FAILED_RE = /failed to load usage data/i +const CLAUDE_21_USAGE_TABS_RE = /settings?\s+status?\s+config\s+usage\s+stats/i +const CLAUDE_21_SESSION_STATS_RE = /total\s*cost|total\s*duration|usage:\s*\d+\s*input/i + +export function isClaude21UsagePanel(output: string): boolean { + return CLAUDE_21_USAGE_TABS_RE.test(output) || CLAUDE_21_SESSION_STATS_RE.test(output) +} + +export function describeClaudeUsageFailure(output: string): string { + if (RATE_LIMITED_RE.test(output)) { + return 'Claude usage is rate limited right now.' + } + + if (LOAD_FAILED_RE.test(output)) { + return 'Claude usage is unavailable right now.' + } + + if (CLAUDE_21_USAGE_TABS_RE.test(output) || CLAUDE_21_SESSION_STATS_RE.test(output)) { + return 'Claude plan usage is unavailable for this Claude CLI session.' + } + + // Why: parser failures are an implementation detail of Orca's PTY fallback. + // The UI should explain the user-visible outcome, not leak internal parsing + // mechanics that the user cannot act on. + return 'Claude usage is unavailable right now.' +} + +export function abortedClaudeUsageResult(): ProviderRateLimits { + return { + provider: 'claude', + session: null, + weekly: null, + updatedAt: Date.now(), + error: 'Rate-limit fetch aborted', + status: 'error' + } +} diff --git a/src/main/rate-limits/claude-pty.ts b/src/main/rate-limits/claude-pty.ts index 210f4778ac9..cbaedbd17f9 100644 --- a/src/main/rate-limits/claude-pty.ts +++ b/src/main/rate-limits/claude-pty.ts @@ -1,7 +1,4 @@ -/* eslint-disable max-lines -- Why: Claude PTY usage scraping keeps prompt -driving, parser, timers, and teardown in one state machine; splitting it would -make the lifecycle harder to audit. */ -import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' +import type { ProviderRateLimits } from '../../shared/rate-limit-types' import { buildConfiguredProxyEnv, type NetworkProxySettings } from '../../shared/network-proxy' import { resolveClaudeCommand } from '../codex-cli/command' // Why: import from the shared module, not the codex-cli re-export, so a test that @@ -11,11 +8,19 @@ import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-au import { applyClaudeEnvPatch } from '../claude-accounts/environment' import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' import { cleanupHiddenRateLimitPty, registerHiddenRateLimitPty } from './hidden-pty-cleanup' -import { extractClaudePtyResetMetadata } from './claude-pty-reset-parser' import { getHiddenRateLimitWslCwdSetupCommands, resolveHiddenRateLimitPtyCwd } from './hidden-rate-limit-pty-cwd' +import { + abortedClaudeUsageResult, + describeClaudeUsageFailure, + isClaude21UsagePanel, + parseClaudePtyUsage, + stripTerminalControlSequences +} from './claude-pty-usage-parser' +import { quoteHiddenRateLimitShellValue } from './hidden-rate-limit-shell' +import { CLAUDE_USAGE_STOP_SUBSTRINGS } from './claude-pty-stop-markers' const PTY_TIMEOUT_MS = 25_000 const MAX_OUTPUT_LENGTH = 100_000 // 100KB buffer limit @@ -24,148 +29,6 @@ const MAX_OUTPUT_LENGTH = 100_000 // 100KB buffer limit // PTY fallback โ€” spawn interactive `claude`, send `/usage`, parse the TUI // --------------------------------------------------------------------------- -// Why: these patterns match the Claude CLI's /usage TUI panel output. -// "Current session" shows a percent like "62% used" or "62% left". -// Weekly labels have varied between "Current week" and "Weekly limits". -const SESSION_RE = /current\s*session/i -const WEEKLY_RE = /(?:current\s*week|weekly\s*(?:limits?|usage|rate\s*limits?)|7\s*[- ]?\s*day)/i -const FABLE_WORD_RE = /\bfable\b/i -const FABLE_LABEL_RE = /^\s*fable\s*$/i -// Why: derive from WEEKLY_RE so a future weekly-wording change stays in one place -// instead of silently reopening the Fable-weekly parsing gap this fix closed. -const FABLE_WEEKLY_LABEL_RE = new RegExp( - `${WEEKLY_RE.source}\\s*(?:\\([^)]*\\bfable\\b[^)]*\\)|[-:]?\\s*\\bfable\\b)`, - 'i' -) -const PERCENT_RE = /(\d{1,3})(?:\.\d+)?\s*%\s*(used|consumed|left|remaining|available)/i -const ESC = String.fromCharCode(27) -const BEL = String.fromCharCode(7) -const OSC_SEQUENCE_RE = new RegExp(`${ESC}\\][^${BEL}]*(?:${BEL}|${ESC}\\\\)`, 'g') -const CSI_SEQUENCE_RE = new RegExp(`${ESC}\\[[0-9;?]*[ -/]*[@-~]`, 'g') - -function stripTerminalControlSequences(output: string): string { - return output.replace(OSC_SEQUENCE_RE, '').replace(CSI_SEQUENCE_RE, '') -} - -/** - * Extract percent-left from lines following a label match. - * Scans up to 12 lines after the label to find the associated percent. - */ -function matchesWeeklyLabel(line: string): boolean { - return WEEKLY_RE.test(line) && !FABLE_WORD_RE.test(line) -} - -function matchesFableBoundary(line: string): boolean { - return FABLE_LABEL_RE.test(line) || (FABLE_WORD_RE.test(line) && WEEKLY_RE.test(line)) -} - -function matchesFableUsageLabel(line: string): boolean { - // Why: broad Fable-weekly copy should stop nearby scans, but only a real - // Fable usage heading should produce the distinct Fable meter. - return FABLE_LABEL_RE.test(line) || FABLE_WEEKLY_LABEL_RE.test(line) -} - -function isSectionLabel(line: string): boolean { - return SESSION_RE.test(line) || matchesWeeklyLabel(line) || matchesFableBoundary(line) -} - -function extractPercentAfterLabel( - lines: string[], - matchesLabel: (line: string) => boolean -): number | null { - for (let i = 0; i < lines.length; i++) { - if (!matchesLabel(lines[i])) { - continue - } - // Scan next 12 lines for a percent - for (let j = i; j < Math.min(i + 12, lines.length); j++) { - if (j > i && isSectionLabel(lines[j])) { - break - } - const m = PERCENT_RE.exec(lines[j]) - if (m) { - const pct = Number.parseFloat(m[1]) - const word = m[2].toLowerCase() - const isUsed = word === 'used' || word === 'consumed' - return isUsed ? pct : 100 - pct - } - } - } - return null -} - -function parsePtyUsage(output: string): { - session: RateLimitWindow | null - weekly: RateLimitWindow | null - fableWeekly: RateLimitWindow | null -} { - const lines = output.split(/\r\n|\n|\r/) - - const sessionPct = extractPercentAfterLabel(lines, (line) => SESSION_RE.test(line)) - const weeklyPct = extractPercentAfterLabel(lines, matchesWeeklyLabel) - const fableWeeklyPct = extractPercentAfterLabel(lines, matchesFableUsageLabel) - const sessionReset = extractClaudePtyResetMetadata( - lines, - (line) => SESSION_RE.test(line), - isSectionLabel - ) - const weeklyReset = extractClaudePtyResetMetadata(lines, matchesWeeklyLabel, isSectionLabel) - const fableWeeklyReset = extractClaudePtyResetMetadata( - lines, - matchesFableUsageLabel, - isSectionLabel - ) - - const session: RateLimitWindow | null = - sessionPct !== null - ? { - usedPercent: Math.min(100, Math.max(0, sessionPct)), - windowMinutes: 300, - resetsAt: sessionReset.resetsAt, - resetDescription: sessionReset.resetDescription - } - : null - - const weekly: RateLimitWindow | null = - weeklyPct !== null - ? { - usedPercent: Math.min(100, Math.max(0, weeklyPct)), - windowMinutes: 10080, - resetsAt: weeklyReset.resetsAt, - resetDescription: weeklyReset.resetDescription - } - : null - - const fableWeekly: RateLimitWindow | null = - fableWeeklyPct !== null - ? { - usedPercent: Math.min(100, Math.max(0, fableWeeklyPct)), - windowMinutes: 10080, - resetsAt: fableWeeklyReset.resetsAt, - resetDescription: fableWeeklyReset.resetDescription - } - : null - - return { session, weekly, fableWeekly } -} - -// Why: these substrings indicate the /usage TUI panel has finished -// rendering. We stop collecting output once one appears, then allow -// a settle period for the rest of the content to flush. -const STOP_SUBSTRINGS = [ - 'Current week (all models)', - 'Current week (Opus)', - 'Current week (Sonnet only)', - 'Current week (Sonnet)', - 'Weekly limits', - 'Weekly limit', - 'Weekly usage', - '7-day', - 'Current session', - 'Failed to load usage data', - 'failed to load usage data' -] - // Why: prompt detection is unreliable because the Claude CLI v2.x renders // a status bar and TUI elements that push the `โฏ` prompt out of any // reasonable detection window. Instead we wait a fixed 2s after spawning @@ -173,48 +36,10 @@ const STOP_SUBSTRINGS = [ // palette prompts ("Show plan usage limits") are auto-confirmed with Enter. const COMMAND_PALETTE_RE = /show plan|usage limits/i const TRUST_PROMPT_RE = /do you trust|trust the files|safety check/i -const RATE_LIMITED_RE = /rate limited\.?\s+please try again later/i -const LOAD_FAILED_RE = /failed to load usage data/i -const CLAUDE_21_USAGE_TABS_RE = /settings?\s+status?\s+config\s+usage\s+stats/i -const CLAUDE_21_SESSION_STATS_RE = /total\s*cost|total\s*duration|usage:\s*\d+\s*input/i const STARTUP_DELAY_MS = 2_000 const SETTLE_AFTER_STOP_MS = 2_000 const SETTLE_AFTER_CLAUDE_21_USAGE_MS = 8_000 -function shellQuote(value: string): string { - return `'${value.replace(/'/g, "'\\''")}'` -} - -function describeClaudeUsageFailure(output: string): string { - if (RATE_LIMITED_RE.test(output)) { - return 'Claude usage is rate limited right now.' - } - - if (LOAD_FAILED_RE.test(output)) { - return 'Claude usage is unavailable right now.' - } - - if (CLAUDE_21_USAGE_TABS_RE.test(output) || CLAUDE_21_SESSION_STATS_RE.test(output)) { - return 'Claude plan usage is unavailable for this Claude CLI session.' - } - - // Why: parser failures are an implementation detail of Orca's PTY fallback. - // The UI should explain the user-visible outcome, not leak internal parsing - // mechanics that the user cannot act on. - return 'Claude usage is unavailable right now.' -} - -function abortedClaudeUsageResult(): ProviderRateLimits { - return { - provider: 'claude', - session: null, - weekly: null, - updatedAt: Date.now(), - error: 'Rate-limit fetch aborted', - status: 'error' - } -} - export async function fetchViaPty(options?: { authPreparation?: ClaudeRuntimeAuthPreparation networkProxySettings?: NetworkProxySettings @@ -279,8 +104,10 @@ export async function fetchViaPty(options?: { // Why: hidden usage probes must not inherit a root-like WSL cwd; // keep Claude discovery bounded to a tiny temp directory. ...getHiddenRateLimitWslCwdSetupCommands(), - `export CLAUDE_CONFIG_DIR=${shellQuote(wslConfig.linuxConfigDir)}`, - ...Object.entries(proxyEnv).map(([key, value]) => `export ${key}=${shellQuote(value)}`), + `export CLAUDE_CONFIG_DIR=${quoteHiddenRateLimitShellValue(wslConfig.linuxConfigDir)}`, + ...Object.entries(proxyEnv).map( + ([key, value]) => `export ${key}=${quoteHiddenRateLimitShellValue(value)}` + ), 'exec claude' ].join(' && ') ] @@ -352,7 +179,7 @@ export async function fetchViaPty(options?: { cleanupHiddenRateLimitPty(term, termDisposables, { kill: true }) // Even on timeout, try to parse whatever we collected const clean = stripTerminalControlSequences(output) - const { session, weekly, fableWeekly } = parsePtyUsage(clean) + const { session, weekly, fableWeekly } = parseClaudePtyUsage(clean) if (session || weekly || fableWeekly) { resolve({ provider: 'claude', @@ -370,7 +197,7 @@ export async function fetchViaPty(options?: { weekly: null, updatedAt: Date.now(), error: withMacTailscaleDnsHint( - CLAUDE_21_USAGE_TABS_RE.test(clean) || CLAUDE_21_SESSION_STATS_RE.test(clean) + isClaude21UsagePanel(clean) ? describeClaudeUsageFailure(clean) : 'PTY timeout โ€” /usage panel did not render', clean @@ -407,7 +234,7 @@ export async function fetchViaPty(options?: { cleanupHiddenRateLimitPty(term, termDisposables, { kill: true }) const clean = stripTerminalControlSequences(output) - const { session, weekly, fableWeekly } = parsePtyUsage(clean) + const { session, weekly, fableWeekly } = parseClaudePtyUsage(clean) if (!session && !weekly && !fableWeekly) { resolve({ @@ -469,10 +296,7 @@ export async function fetchViaPty(options?: { // Check if we've hit a stop substring indicating the panel rendered if (sentUsage && !stopDetected) { const clean = stripTerminalControlSequences(output) - if ( - !claude21UsageDetected && - (CLAUDE_21_USAGE_TABS_RE.test(clean) || CLAUDE_21_SESSION_STATS_RE.test(clean)) - ) { + if (!claude21UsageDetected && isClaude21UsagePanel(clean)) { claude21UsageDetected = true if (enterInterval) { clearInterval(enterInterval) @@ -483,7 +307,7 @@ export async function fetchViaPty(options?: { // with a user-facing unavailable state instead of a false PTY timeout. claude21UsageSettleTimer = setTimeout(finalize, SETTLE_AFTER_CLAUDE_21_USAGE_MS) } - for (const sub of STOP_SUBSTRINGS) { + for (const sub of CLAUDE_USAGE_STOP_SUBSTRINGS) { if (clean.includes(sub)) { stopDetected = true // Why: 2.0s settle time after detecting the stop substring @@ -508,7 +332,7 @@ export async function fetchViaPty(options?: { timeout = null } const clean = stripTerminalControlSequences(output) - const { session, weekly, fableWeekly } = parsePtyUsage(clean) + const { session, weekly, fableWeekly } = parseClaudePtyUsage(clean) resolve({ provider: 'claude', session, diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index f3f3b078224..da2904d524e 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -37,6 +37,7 @@ import { getHiddenRateLimitWslCwdSetupCommands, resolveHiddenRateLimitPtyCwd } from './hidden-rate-limit-pty-cwd' +import { quoteHiddenRateLimitShellValue } from './hidden-rate-limit-shell' const RPC_TIMEOUT_MS = 10_000 const WSL_RPC_TIMEOUT_MS = 25_000 @@ -45,10 +46,6 @@ const WSL_RPC_INIT_TIMEOUT_MS = 40_000 export type FetchCodexRateLimitsOptions = CodexRateLimitFetchOptions -function shellQuote(value: string): string { - return `'${value.replace(/'/g, "'\\''")}'` -} - function buildWslCodexCommand( codexHomePath: string, args: string[], @@ -60,9 +57,9 @@ function buildWslCodexCommand( } const setupCommands = [ ...getHiddenRateLimitWslCwdSetupCommands(), - `export CODEX_HOME=${shellQuote(wslInfo.linuxPath)}` + `export CODEX_HOME=${quoteHiddenRateLimitShellValue(wslInfo.linuxPath)}` ].join(' && ') - const execSuffix = `${args.map(shellQuote).join(' ')}${ + const execSuffix = `${args.map(quoteHiddenRateLimitShellValue).join(' ')}${ isolateRpcStdio ? ' <&3 >&4 3<&- 4>&-' : '' }` const loginShellCommand = buildWslLoginShellCommand( diff --git a/src/main/rate-limits/hidden-rate-limit-shell.ts b/src/main/rate-limits/hidden-rate-limit-shell.ts new file mode 100644 index 00000000000..261f4519926 --- /dev/null +++ b/src/main/rate-limits/hidden-rate-limit-shell.ts @@ -0,0 +1,3 @@ +export function quoteHiddenRateLimitShellValue(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'` +} diff --git a/src/main/runtime/rpc/methods/automation-schemas.ts b/src/main/runtime/rpc/methods/automation-schemas.ts index 919a251d26d..4d4f43a2350 100644 --- a/src/main/runtime/rpc/methods/automation-schemas.ts +++ b/src/main/runtime/rpc/methods/automation-schemas.ts @@ -1,6 +1,6 @@ // Why: the automation method table stays readable only if its field-level validation lives beside it rather than inside it. import { z } from 'zod' -import { isValidAutomationSchedule } from '../../../../shared/automation-schedules' +import { isValidAutomationSchedule } from '../../../../shared/automation-schedule-parsing' import { MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS, normalizeAutomationPrecheckTimeoutSeconds diff --git a/src/renderer/src/components/LinearIssueTextEditor.tsx b/src/renderer/src/components/LinearIssueTextEditor.tsx index 42db6a5c4c8..4301174ceb5 100644 --- a/src/renderer/src/components/LinearIssueTextEditor.tsx +++ b/src/renderer/src/components/LinearIssueTextEditor.tsx @@ -7,7 +7,7 @@ import { useMountedRef } from '@/hooks/useMountedRef' import { cn } from '@/lib/utils' import { useAppStore } from '@/store' import { getScreenSubmitShortcutLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' -import { linearUpdateIssue } from '@/runtime/runtime-linear-client' +import { linearUpdateIssue } from '@/runtime/runtime-linear-issue-mutations' import type { LinearIssue } from '../../../shared/linear/issue-types' import type { TaskSourceContext } from '../../../shared/task-source-context' import { diff --git a/src/renderer/src/components/LinearItemDrawer.tsx b/src/renderer/src/components/LinearItemDrawer.tsx index 04b4c613c4f..d544b58eebe 100644 --- a/src/renderer/src/components/LinearItemDrawer.tsx +++ b/src/renderer/src/components/LinearItemDrawer.tsx @@ -62,7 +62,7 @@ import { linearGetIssue, linearIssueComments, linearUpdateIssue -} from '@/runtime/runtime-linear-client' +} from '@/runtime/runtime-linear-issue-mutations' import { translate } from '@/i18n/i18n' import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format' diff --git a/src/renderer/src/components/automations/AutomationCustomCronPanel.tsx b/src/renderer/src/components/automations/AutomationCustomCronPanel.tsx index ead3ae34f4a..a2a7c8cca1b 100644 --- a/src/renderer/src/components/automations/AutomationCustomCronPanel.tsx +++ b/src/renderer/src/components/automations/AutomationCustomCronPanel.tsx @@ -2,10 +2,8 @@ import React from 'react' import { CheckCircle2, CircleAlert } from 'lucide-react' import { Input } from '@/components/ui/input' import { cn } from '@/lib/utils' -import { - describeAutomationSchedule, - getAutomationCronExpressionFields -} from '../../../../shared/automation-schedules' +import { describeAutomationSchedule } from '../../../../shared/automation-schedules' +import { getAutomationCronExpressionFields } from '../../../../shared/automation-schedule-parsing' import { formatUiAutomationScheduleDescriptor } from './automation-schedule-label' import type { AutomationDraft } from './AutomationEditorDialog' import { Field } from './automation-page-parts' diff --git a/src/renderer/src/components/automations/AutomationEditorSettingsSidebar.tsx b/src/renderer/src/components/automations/AutomationEditorSettingsSidebar.tsx index 45bbfd0f226..7d1f48cea29 100644 --- a/src/renderer/src/components/automations/AutomationEditorSettingsSidebar.tsx +++ b/src/renderer/src/components/automations/AutomationEditorSettingsSidebar.tsx @@ -4,7 +4,7 @@ import { translate } from '@/i18n/i18n' import { isValidAutomationCronSchedule, isValidAutomationSchedule -} from '../../../../shared/automation-schedules' +} from '../../../../shared/automation-schedule-parsing' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { OrcaHooks } from '../../../../shared/orca-yaml-hook-types' import type { ProjectHostSetup } from '../../../../shared/project-types' diff --git a/src/renderer/src/components/automations/AutomationSchedulePicker.render.test.tsx b/src/renderer/src/components/automations/AutomationSchedulePicker.render.test.tsx index e65261b8820..f4ba0b46e5b 100644 --- a/src/renderer/src/components/automations/AutomationSchedulePicker.render.test.tsx +++ b/src/renderer/src/components/automations/AutomationSchedulePicker.render.test.tsx @@ -5,7 +5,7 @@ import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AutomationDraft } from './AutomationEditorDialog' import { AutomationSchedulePicker } from './AutomationSchedulePicker' -import { isValidAutomationCronSchedule } from '../../../../shared/automation-schedules' +import { isValidAutomationCronSchedule } from '../../../../shared/automation-schedule-parsing' import { useTranslation } from 'react-i18next' import { i18n } from '@/i18n/i18n' diff --git a/src/renderer/src/components/automations/AutomationSchedulePicker.test.ts b/src/renderer/src/components/automations/AutomationSchedulePicker.test.ts index 5ee1f22a4f8..9202dd58774 100644 --- a/src/renderer/src/components/automations/AutomationSchedulePicker.test.ts +++ b/src/renderer/src/components/automations/AutomationSchedulePicker.test.ts @@ -14,7 +14,7 @@ import { getSchedulePresetDraft } from './AutomationSchedulePicker' import { formatUiAutomationSchedule } from './automation-schedule-label' -import { isValidAutomationCronSchedule } from '../../../../shared/automation-schedules' +import { isValidAutomationCronSchedule } from '../../../../shared/automation-schedule-parsing' import { SelectItem } from '@/components/ui/select' import { i18n } from '@/i18n/i18n' diff --git a/src/renderer/src/components/automations/AutomationSchedulePicker.tsx b/src/renderer/src/components/automations/AutomationSchedulePicker.tsx index 94399a0aab1..3dfe438cee6 100644 --- a/src/renderer/src/components/automations/AutomationSchedulePicker.tsx +++ b/src/renderer/src/components/automations/AutomationSchedulePicker.tsx @@ -7,10 +7,8 @@ import { SelectValue } from '@/components/ui/select' import type { AutomationSchedulePreset } from '../../../../shared/automations-types' -import { - buildAutomationCronSchedule, - isValidAutomationSchedule -} from '../../../../shared/automation-schedules' +import { buildAutomationCronSchedule } from '../../../../shared/automation-schedule-occurrences' +import { isValidAutomationSchedule } from '../../../../shared/automation-schedule-parsing' import type { AutomationDraft } from './AutomationEditorDialog' import { AutomationCustomCronPanel } from './AutomationCustomCronPanel' import { AutomationTimeField, parseAutomationTime } from './AutomationTimeField' diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index 7c908d41af1..7eef4ac6f57 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -33,11 +33,11 @@ import type { OrcaHooks } from '../../../../shared/orca-yaml-hook-types' import type { Repo } from '../../../../shared/repo-types' import { getWorktreePathBasenameFromId } from '../../../../shared/worktree/id' import type { Worktree } from '../../../../shared/worktree/types' +import { buildAutomationRrule } from '../../../../shared/automation-schedule-occurrences' import { - buildAutomationRrule, isValidAutomationCronSchedule, isValidAutomationSchedule -} from '../../../../shared/automation-schedules' +} from '../../../../shared/automation-schedule-parsing' import { canRerunAutomationRun, getAutomationRunViewState, diff --git a/src/renderer/src/components/automations/HermesCronOutputView.tsx b/src/renderer/src/components/automations/HermesCronOutputView.tsx index e11452976d0..6a6fc6e350e 100644 --- a/src/renderer/src/components/automations/HermesCronOutputView.tsx +++ b/src/renderer/src/components/automations/HermesCronOutputView.tsx @@ -14,7 +14,7 @@ import { import type { LucideIcon } from 'lucide-react' import CommentMarkdown from '@/components/sidebar/CommentMarkdown' import { cn } from '@/lib/utils' -import { isValidAutomationSchedule } from '../../../../shared/automation-schedules' +import { isValidAutomationSchedule } from '../../../../shared/automation-schedule-parsing' import { formatUiAutomationSchedule } from './automation-schedule-label' import { translate } from '@/i18n/i18n' import { parseHermesOutput, type ParsedHermesSection } from './hermes-cron-output-parse' diff --git a/src/renderer/src/components/automations/automation-draft-model.ts b/src/renderer/src/components/automations/automation-draft-model.ts index 827f84543a4..271fb6d9748 100644 --- a/src/renderer/src/components/automations/automation-draft-model.ts +++ b/src/renderer/src/components/automations/automation-draft-model.ts @@ -1,6 +1,6 @@ import { getAgentCatalog } from '@/lib/agent-catalog' import type { AutomationPrecheck } from '../../../../shared/automations-types' -import { buildAutomationCronSchedule } from '../../../../shared/automation-schedules' +import { buildAutomationCronSchedule } from '../../../../shared/automation-schedule-occurrences' import type { Worktree } from '../../../../shared/worktree/types' import type { AutomationDraft } from './AutomationEditorDialog' diff --git a/src/renderer/src/components/automations/automation-edit-draft.ts b/src/renderer/src/components/automations/automation-edit-draft.ts index f4f15bff013..2e9ad08cc26 100644 --- a/src/renderer/src/components/automations/automation-edit-draft.ts +++ b/src/renderer/src/components/automations/automation-edit-draft.ts @@ -12,7 +12,7 @@ import { isValidAutomationCronSchedule, isValidAutomationSchedule, tryParseAutomationRrule -} from '../../../../shared/automation-schedules' +} from '../../../../shared/automation-schedule-parsing' import type { AutomationDraft } from './AutomationEditorDialog' import { AUTOMATION_DEFAULT_TIME, formatTimeInput } from './automation-draft-model' import { getAutomationSetupDecisionDraftValue } from './automation-setup-decision' diff --git a/src/renderer/src/components/automations/external-automation-schedule-display.ts b/src/renderer/src/components/automations/external-automation-schedule-display.ts index 39b19b1bbc6..1b21f1d475a 100644 --- a/src/renderer/src/components/automations/external-automation-schedule-display.ts +++ b/src/renderer/src/components/automations/external-automation-schedule-display.ts @@ -2,7 +2,7 @@ import type { ExternalAutomationJob, ExternalAutomationManager } from '../../../../shared/automations-types' -import { isValidAutomationCronSchedule } from '../../../../shared/automation-schedules' +import { isValidAutomationCronSchedule } from '../../../../shared/automation-schedule-parsing' import { formatUiAutomationSchedule } from './automation-schedule-label' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/linear-issue-project-selector.test.tsx b/src/renderer/src/components/linear-issue-project-selector.test.tsx index c0c16484106..eb0446e9896 100644 --- a/src/renderer/src/components/linear-issue-project-selector.test.tsx +++ b/src/renderer/src/components/linear-issue-project-selector.test.tsx @@ -20,7 +20,12 @@ const storeMocks = vi.hoisted(() => ({ } })) -vi.mock('@/runtime/runtime-linear-client', () => runtimeMocks) +vi.mock('@/runtime/runtime-linear-project-client', () => ({ + linearListProjects: runtimeMocks.linearListProjects +})) +vi.mock('@/runtime/runtime-linear-issue-mutations', () => ({ + linearUpdateIssue: runtimeMocks.linearUpdateIssue +})) vi.mock('@/store', () => ({ useAppStore: (selector: (state: typeof storeMocks.state) => unknown) => selector(storeMocks.state) })) diff --git a/src/renderer/src/components/linear-issue-project-selector.tsx b/src/renderer/src/components/linear-issue-project-selector.tsx index 118c5d1c956..4bdf2de9927 100644 --- a/src/renderer/src/components/linear-issue-project-selector.tsx +++ b/src/renderer/src/components/linear-issue-project-selector.tsx @@ -6,7 +6,8 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import { useMountedRef } from '@/hooks/useMountedRef' import { translate } from '@/i18n/i18n' import { getLinearProjectSearchRequestQuery } from '@/components/linear-project-search-query' -import { linearListProjects, linearUpdateIssue } from '@/runtime/runtime-linear-client' +import { linearUpdateIssue } from '@/runtime/runtime-linear-issue-mutations' +import { linearListProjects } from '@/runtime/runtime-linear-project-client' import { useAppStore } from '@/store' import type { LinearIssue } from '../../../shared/linear/issue-types' import type { LinearProjectSummary } from '../../../shared/linear/project-types' diff --git a/src/renderer/src/components/linear-issue-sub-issues.tsx b/src/renderer/src/components/linear-issue-sub-issues.tsx index adf88992c32..970f7997bfc 100644 --- a/src/renderer/src/components/linear-issue-sub-issues.tsx +++ b/src/renderer/src/components/linear-issue-sub-issues.tsx @@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { useMountedRef } from '@/hooks/useMountedRef' import { translate } from '@/i18n/i18n' -import { linearCreateSubIssue } from '@/runtime/runtime-linear-client' +import { linearCreateSubIssue } from '@/runtime/runtime-linear-issue-mutations' import { useAppStore } from '@/store' import type { LinearIssue, LinearIssueChildSummary } from '../../../shared/linear/issue-types' import type { TaskSourceContext } from '../../../shared/task-source-context' diff --git a/src/renderer/src/components/linear-issue-workspace-detail-state.test.tsx b/src/renderer/src/components/linear-issue-workspace-detail-state.test.tsx index c22b11d93b6..3ee30fc9ae0 100644 --- a/src/renderer/src/components/linear-issue-workspace-detail-state.test.tsx +++ b/src/renderer/src/components/linear-issue-workspace-detail-state.test.tsx @@ -20,7 +20,7 @@ const runtimeMocks = vi.hoisted(() => ({ linearIssueComments: vi.fn() })) -vi.mock('@/runtime/runtime-linear-client', () => runtimeMocks) +vi.mock('@/runtime/runtime-linear-issue-mutations', () => runtimeMocks) vi.mock('@/components/LinearItemDrawer', () => ({ initLinearIssueEditState: (selected: LinearIssue) => ({ state: selected.state, diff --git a/src/renderer/src/components/linear-issue-workspace-detail-state.ts b/src/renderer/src/components/linear-issue-workspace-detail-state.ts index c2082042509..5d69d855be9 100644 --- a/src/renderer/src/components/linear-issue-workspace-detail-state.ts +++ b/src/renderer/src/components/linear-issue-workspace-detail-state.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { createBrowserUuid } from '@/lib/browser-uuid' import { useMountedRef } from '@/hooks/useMountedRef' -import { linearGetIssue, linearIssueComments } from '@/runtime/runtime-linear-client' +import { linearGetIssue, linearIssueComments } from '@/runtime/runtime-linear-issue-mutations' import type { RuntimeLinearSettings } from '@/runtime/runtime-linear-client' import { initLinearIssueEditState, diff --git a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.test.tsx b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.test.tsx index 869509cbf4a..9a634120ca0 100644 --- a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.test.tsx +++ b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.test.tsx @@ -10,7 +10,7 @@ import type { WorkspaceLineage } from '../../../../shared/worktree/lineage-types import type { Worktree } from '../../../../shared/worktree/types' import type { HostedReviewInfo } from '../../../../shared/hosted-review' import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope' -import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' +import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity' import { getGitHubRepoCacheKey } from '@/store/slices/github-cache-key' import { prChecksCacheSuffix } from '@/store/github/cache-identity' diff --git a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.tsx b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.tsx index 4c03a998d79..5693de11724 100644 --- a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.tsx @@ -8,7 +8,7 @@ import { translate } from '@/i18n/i18n' import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/github/check-types' import { getAttachedWorktreesForFolderWorkspace } from './folder-workspace-attached-worktrees' import { FolderWorkspacePrChecksRow } from './FolderWorkspacePrChecksRow' -import type { ParentPrChecksRefreshOutcome, ParentPrChecksRow } from './parent-pr-checks-rows' +import type { ParentPrChecksRefreshOutcome, ParentPrChecksRow } from './parent-pr-checks-row-types' import { getParentPrChecksRefreshCandidates, runLimitedParentPrChecksRefreshes diff --git a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.test.tsx b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.test.tsx index 58dc1398984..36bb9d7527d 100644 --- a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.test.tsx +++ b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.test.tsx @@ -6,7 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { CheckStatus } from '../../../../shared/github/pull-request-types' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' -import type { ParentPrChecksRow, ParentPrChecksRowStatus } from './parent-pr-checks-rows' +import type { ParentPrChecksRow, ParentPrChecksRowStatus } from './parent-pr-checks-row-types' vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string, values?: Record) => diff --git a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.tsx b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.tsx index ba9c3c6a174..a0f77223186 100644 --- a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.tsx +++ b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.tsx @@ -11,7 +11,7 @@ import { PullRequestIcon } from './checks-panel/check-presentation' import { ChecksList } from './checks-panel/checks-list' -import type { ParentPrChecksRow } from './parent-pr-checks-rows' +import type { ParentPrChecksRow } from './parent-pr-checks-row-types' type FolderWorkspacePrChecksRowProps = { row: ParentPrChecksRow diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.tsx index e423c3e8797..1a7a34fa0fc 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.tsx @@ -1,6 +1,6 @@ import React, { useCallback } from 'react' import { toast } from 'sonner' -import { refreshHostedReviewCard } from '@/store/slices/hosted-review' +import { refreshHostedReviewCard } from '@/store/slices/hosted-review-card-refresh' import { checksPanelAsyncResultKey } from '../checks-panel-async-result-key' import { buildFixBrokenChecksPrompt, diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-context-state.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-context-state.tsx index 451ba042d7e..8c0a40931e6 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-context-state.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-context-state.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react' import { useAppStore } from '@/store' import { isFolderRepo } from '../../../../../shared/repo-kind' import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key' -import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' +import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity' import { selectReviewCacheEntry } from '../review-cache-entry-selection' import { selectChecksPanelReview, type ChecksPanelReview } from '../checks-panel-review' import { isGitLabChecksPanelReview } from './gitlab-review-client' diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-create-review.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-create-review.tsx index 8e34f253633..2ccb1188de7 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-create-review.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-create-review.tsx @@ -1,6 +1,6 @@ import { useCallback } from 'react' import { toast } from 'sonner' -import { refreshHostedReviewCard } from '@/store/slices/hosted-review' +import { refreshHostedReviewCard } from '@/store/slices/hosted-review-card-refresh' import { openHttpLink } from '@/lib/http-link-routing' import { resolveCreatedHostedReviewLink } from '../source-control-created-review-link' import { formatCreateError } from '../create-pull-request-review-copy' diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-entry-refresh-and-title-actions.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-entry-refresh-and-title-actions.tsx index efbc59e7a3b..f841968a182 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-entry-refresh-and-title-actions.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-entry-refresh-and-title-actions.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useRef } from 'react' import { ENTRY_REFRESH_GRACE_MS, shouldEntryRefresh } from '../checks-entry-refresh' -import { refreshHostedReviewCard } from '@/store/slices/hosted-review' +import { refreshHostedReviewCard } from '@/store/slices/hosted-review-card-refresh' import { toast } from 'sonner' import type { ChecksPanelContextState } from './use-checks-panel-context-state' diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.test.tsx index 90b264b8fd9..959ea4468ec 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.test.tsx @@ -2,12 +2,12 @@ import { act, cleanup, renderHook } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import type * as HostedReviewStore from '@/store/slices/hosted-review' +import type * as HostedReviewCardRefresh from '@/store/slices/hosted-review-card-refresh' const refresh = vi.hoisted(() => ({ calls: [] as string[] })) -vi.mock('@/store/slices/hosted-review', async (importOriginal) => { - const original = await importOriginal() +vi.mock('@/store/slices/hosted-review-card-refresh', async (importOriginal) => { + const original = await importOriginal() return { ...original, refreshHostedReviewCard: vi.fn(async () => { diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.tsx index 870c75e3a15..25ab151a19a 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.tsx @@ -1,7 +1,7 @@ import { useCallback } from 'react' import { useAppStore } from '@/store' import { buildGitHubPRRefreshStateClearToken } from '@/store/github/pr-refresh-state' -import { refreshHostedReviewCard } from '@/store/slices/hosted-review' +import { refreshHostedReviewCard } from '@/store/slices/hosted-review-card-refresh' import { getRuntimeGitStatus, getRuntimeGitUpstreamStatus } from '@/runtime/runtime-git-client' import { hasChecksPanelGitStatusBranchChanged, diff --git a/src/renderer/src/components/right-sidebar/parent-pr-checks-projection-selector.test.ts b/src/renderer/src/components/right-sidebar/parent-pr-checks-projection-selector.test.ts index 025817b1160..0616bb510e9 100644 --- a/src/renderer/src/components/right-sidebar/parent-pr-checks-projection-selector.test.ts +++ b/src/renderer/src/components/right-sidebar/parent-pr-checks-projection-selector.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import type { HostedReviewInfo } from '../../../../shared/hosted-review' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' -import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' +import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity' import { buildParentPrChecksProjection } from './parent-pr-checks-rows' import { createParentPrChecksProjectionSelector } from './parent-pr-checks-projection-selector' diff --git a/src/renderer/src/components/right-sidebar/parent-pr-checks-refresh.ts b/src/renderer/src/components/right-sidebar/parent-pr-checks-refresh.ts index edde3fcd750..c385d03b5bc 100644 --- a/src/renderer/src/components/right-sidebar/parent-pr-checks-refresh.ts +++ b/src/renderer/src/components/right-sidebar/parent-pr-checks-refresh.ts @@ -6,10 +6,8 @@ import type { HostedReviewInfo } from '../../../../shared/hosted-review' import { isFolderRepo } from '../../../../shared/repo-kind' import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display' import { compareWorktreeDisplayName } from '@/lib/worktree-display-name-order' -import { - getParentPrChecksRefreshIdentity, - type ParentPrChecksRefreshOutcome -} from './parent-pr-checks-rows' +import { getParentPrChecksRefreshIdentity } from './parent-pr-checks-rows' +import type { ParentPrChecksRefreshOutcome } from './parent-pr-checks-row-types' type FetchHostedReview = ( repoPath: string, diff --git a/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.test.ts b/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.test.ts index f44e7dca663..256eaf584a9 100644 --- a/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.test.ts +++ b/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.test.ts @@ -4,7 +4,7 @@ import type { PRInfo } from '../../../../shared/github/pull-request-types' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' import type { HostedReviewInfo } from '../../../../shared/hosted-review' -import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' +import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity' import { getGitHubPRCacheKey, getGitHubRepoCacheKey, @@ -13,9 +13,9 @@ import { import { prChecksCacheSuffix } from '@/store/github/cache-identity' import { buildParentPrChecksProjection, - getParentPrChecksRefreshIdentity, - type ParentPrChecksRefreshOutcome + getParentPrChecksRefreshIdentity } from './parent-pr-checks-rows' +import type { ParentPrChecksRefreshOutcome } from './parent-pr-checks-row-types' const settings = null as never diff --git a/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.ts b/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.ts index d3c2480514e..72bec30b52c 100644 --- a/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.ts +++ b/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.ts @@ -7,7 +7,10 @@ import { isFolderRepo } from '../../../../shared/repo-kind' import { getWorktreeCardPrDisplay } from '@/components/sidebar/worktree-card-pr-display' import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display' import { getGitHubRepoCacheKey } from '@/store/slices/github-cache-key' -import { getHostedReviewCacheKey, linkedReviewHintKey } from '@/store/slices/hosted-review' +import { + getHostedReviewCacheKey, + linkedReviewHintKey +} from '@/store/slices/hosted-review-cache-identity' import { prChecksCacheSuffix } from '@/store/github/cache-identity' import { PARENT_PR_CHECKS_GROUP_LABELS, @@ -33,15 +36,6 @@ import { canUseParentPrChecksHostedReviewCacheEntry } from './parent-pr-checks-h type ParentPrChecksRowSourceArgs = Omit -export type { - ParentPrChecksGroupKey, - ParentPrChecksProjection, - ParentPrChecksRefreshOutcome, - ParentPrChecksRow, - ParentPrChecksRowStatus, - ParentPrChecksSummary -} from './parent-pr-checks-row-types' - export function buildParentPrChecksProjection( args: BuildParentPrChecksRowsArgs ): ParentPrChecksProjection { diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/use-worktree-context.ts b/src/renderer/src/components/right-sidebar/source-control/listing/use-worktree-context.ts index befca9f4cb9..94fc5879f85 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/use-worktree-context.ts +++ b/src/renderer/src/components/right-sidebar/source-control/listing/use-worktree-context.ts @@ -7,7 +7,7 @@ import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-displ import { useAppStore } from '@/store' import { useActiveWorktree, useRepoById, useWorktreeMap } from '@/store/selectors' import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key' -import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' +import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity' import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types' import type { GitStatusEntry } from '../../../../../../shared/git-status-types' import { isFolderRepo } from '../../../../../../shared/repo-kind' diff --git a/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.ts b/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.ts index 5914520603e..06e67d75381 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.ts @@ -3,10 +3,8 @@ import type { Repo } from '../../../../shared/repo-types' import type { WorkspaceLineage, WorktreeLineage } from '../../../../shared/worktree/lineage-types' import type { Worktree } from '../../../../shared/worktree/types' import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope' -import { - buildParentPrChecksRows, - type ParentPrChecksRow -} from '@/components/right-sidebar/parent-pr-checks-rows' +import { buildParentPrChecksRows } from '@/components/right-sidebar/parent-pr-checks-rows' +import type { ParentPrChecksRow } from '@/components/right-sidebar/parent-pr-checks-row-types' import type { WorktreeCardPrDisplay } from './worktree-card-pr-display' import { getProjectedWorktreeLineageChildrenByParentId } from './worktree-lineage-projection' diff --git a/src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts b/src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts index 36f4d5af69c..fd45c5167a3 100644 --- a/src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts +++ b/src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts @@ -1,9 +1,5 @@ -/* eslint-disable max-lines -- Why: marquee selection coordinates pointer capture, lane-scroll refresh, auto-scroll, preview cleanup, and final commit against one drag state. Splitting those phases would make the interaction easier to desynchronize. */ import React, { useCallback, useEffect, useRef } from 'react' -import { - getAreaSelectionCardRects, - type AreaSelectionCardRect -} from './workspace-kanban-area-selection-card-rects' +import { getAreaSelectionCardRects } from './workspace-kanban-area-selection-card-rects' import { clearPreviewSelection, getAreaSelectionAutoScrollDelta, @@ -16,54 +12,12 @@ import { shouldIgnoreAreaSelectionStart, updatePreviewSelection } from './workspace-kanban-area-selection-dom' - -type AreaSelectionDragState = { - startX: number - startY: number - currentX: number - currentY: number - additive: boolean - baseSelectedIds: Set - baseAnchorId: string | null - boardRect: DOMRect - cardRects: readonly AreaSelectionCardRect[] - scrollStartContentYByElement: ReadonlyMap - previewIds: Set - finalAreaIds: string[] - started: boolean - frameId: number | null - scrollFrameId: number | null -} - -type UpdateSelectionForArea = ( - areaIds: readonly string[], - additive: boolean, - baseSelectedIds?: ReadonlySet, - baseAnchorId?: string | null -) => void - -type UseWorkspaceKanbanAreaSelectionParams = { - open: boolean - boardRef: React.RefObject - overlayRef: React.RefObject - selectedWorktreeIds: ReadonlySet - selectionAnchorId: string | null - updateSelectionForArea: UpdateSelectionForArea -} - -const AREA_SELECTION_DRAG_THRESHOLD = 4 - -export function shouldCommitWorkspaceKanbanAreaSelection({ - additive, - started -}: { - additive: boolean - started: boolean -}): boolean { - // Why: a plain click on empty board space is the user's "click off" gesture; - // modifier-clicking empty space should not accidentally drop a selected batch. - return started || !additive -} +import { + AREA_SELECTION_DRAG_THRESHOLD, + shouldCommitWorkspaceKanbanAreaSelection, + type AreaSelectionDragState, + type UseWorkspaceKanbanAreaSelectionParams +} from './workspace-kanban-area-selection-state' export function useWorkspaceKanbanAreaSelection({ open, diff --git a/src/renderer/src/components/sidebar/use-worktree-card-review-details.ts b/src/renderer/src/components/sidebar/use-worktree-card-review-details.ts index 05bee14ee80..277bff4b89e 100644 --- a/src/renderer/src/components/sidebar/use-worktree-card-review-details.ts +++ b/src/renderer/src/components/sidebar/use-worktree-card-review-details.ts @@ -2,7 +2,7 @@ import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-displ import { useAppStore } from '@/store' import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key' import { issueCacheKey as getIssueCacheKey } from '@/store/github/cache-identity' -import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' +import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity' import { hostedReviewInfoFromGitHubPRInfo } from '../../../../shared/hosted-review-github' import type { HostedReviewInfo } from '../../../../shared/hosted-review' import { isFolderRepo } from '../../../../shared/repo-kind' diff --git a/src/renderer/src/components/sidebar/workspace-board-task-status-sync.ts b/src/renderer/src/components/sidebar/workspace-board-task-status-sync.ts index 505459a99fd..b4e76ca0a17 100644 --- a/src/renderer/src/components/sidebar/workspace-board-task-status-sync.ts +++ b/src/renderer/src/components/sidebar/workspace-board-task-status-sync.ts @@ -1,10 +1,6 @@ -import { - linearGetIssue, - linearTeamStates, - linearUpdateIssue, - type LinearMutationResult, - type RuntimeLinearSettings -} from '@/runtime/runtime-linear-client' +import { linearGetIssue, linearUpdateIssue } from '@/runtime/runtime-linear-issue-mutations' +import { linearTeamStates } from '@/runtime/runtime-linear-project-client' +import type { LinearMutationResult, RuntimeLinearSettings } from '@/runtime/runtime-linear-client' import type { LinearIssue } from '../../../../shared/linear/issue-types' import type { LinearWorkflowState } from '../../../../shared/linear/workspace-types' import type { diff --git a/src/renderer/src/components/sidebar/workspace-kanban-area-selection-state.ts b/src/renderer/src/components/sidebar/workspace-kanban-area-selection-state.ts new file mode 100644 index 00000000000..6782b8e3122 --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-kanban-area-selection-state.ts @@ -0,0 +1,50 @@ +import type React from 'react' +import type { AreaSelectionCardRect } from './workspace-kanban-area-selection-card-rects' + +export type AreaSelectionDragState = { + startX: number + startY: number + currentX: number + currentY: number + additive: boolean + baseSelectedIds: Set + baseAnchorId: string | null + boardRect: DOMRect + cardRects: readonly AreaSelectionCardRect[] + scrollStartContentYByElement: ReadonlyMap + previewIds: Set + finalAreaIds: string[] + started: boolean + frameId: number | null + scrollFrameId: number | null +} + +type UpdateSelectionForArea = ( + areaIds: readonly string[], + additive: boolean, + baseSelectedIds?: ReadonlySet, + baseAnchorId?: string | null +) => void + +export type UseWorkspaceKanbanAreaSelectionParams = { + open: boolean + boardRef: React.RefObject + overlayRef: React.RefObject + selectedWorktreeIds: ReadonlySet + selectionAnchorId: string | null + updateSelectionForArea: UpdateSelectionForArea +} + +export const AREA_SELECTION_DRAG_THRESHOLD = 4 + +export function shouldCommitWorkspaceKanbanAreaSelection({ + additive, + started +}: { + additive: boolean + started: boolean +}): boolean { + // Why: a plain click on empty board space is the user's "click off" gesture; + // modifier-clicking empty space should not accidentally drop a selected batch. + return started || !additive +} diff --git a/src/renderer/src/components/sidebar/workspace-kanban-area-selection.test.ts b/src/renderer/src/components/sidebar/workspace-kanban-area-selection.test.ts index 86a51e1e8a2..558922293fe 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-area-selection.test.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-area-selection.test.ts @@ -6,7 +6,7 @@ import { getAreaSelectionCardIds, updatePreviewSelection } from './workspace-kanban-area-selection-dom' -import { shouldCommitWorkspaceKanbanAreaSelection } from './use-workspace-kanban-area-selection' +import { shouldCommitWorkspaceKanbanAreaSelection } from './workspace-kanban-area-selection-state' describe('workspace kanban area selection finish', () => { it('commits an empty non-additive surface click so selection clears', () => { diff --git a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx index c4d8b080843..af74109cae9 100644 --- a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx +++ b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx @@ -54,7 +54,7 @@ import { getWorktreeMapFromState, getWorktreeOnHostFromState } from '../../store/selectors' -import { getHostedReviewCacheKey } from '../../store/slices/hosted-review' +import { getHostedReviewCacheKey } from '../../store/slices/hosted-review-cache-identity' import { issueCacheKey as getIssueCacheKey } from '../../store/github/cache-identity' import { findRepoForHost } from '@/store/slices/repo-host-identity' import { diff --git a/src/renderer/src/components/task-page/hooks/use-task-page-create-linear-submits.tsx b/src/renderer/src/components/task-page/hooks/use-task-page-create-linear-submits.tsx index 70e82bd02b4..224ae67d846 100644 --- a/src/renderer/src/components/task-page/hooks/use-task-page-create-linear-submits.tsx +++ b/src/renderer/src/components/task-page/hooks/use-task-page-create-linear-submits.tsx @@ -3,11 +3,8 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' -import { - linearCreateIssue, - linearCreateProject, - linearGetIssue -} from '@/runtime/runtime-linear-client' +import { linearCreateIssue, linearGetIssue } from '@/runtime/runtime-linear-issue-mutations' +import { linearCreateProject } from '@/runtime/runtime-linear-project-client' import type { GlobalSettings } from '../../../../../shared/global-settings-types' import type { LinearIssue } from '../../../../../shared/linear/issue-types' import type { diff --git a/src/renderer/src/components/task-page/hooks/use-task-page-linear-board.tsx b/src/renderer/src/components/task-page/hooks/use-task-page-linear-board.tsx index e1db9aacfee..2682ac33cb8 100644 --- a/src/renderer/src/components/task-page/hooks/use-task-page-linear-board.tsx +++ b/src/renderer/src/components/task-page/hooks/use-task-page-linear-board.tsx @@ -7,7 +7,8 @@ import { readLinearBoardIssueDragData, writeLinearBoardIssueDragData } from '@/lib/linear-board-drag-payload' -import { linearTeamStates, linearUpdateIssue } from '@/runtime/runtime-linear-client' +import { linearUpdateIssue } from '@/runtime/runtime-linear-issue-mutations' +import { linearTeamStates } from '@/runtime/runtime-linear-project-client' import type { GlobalSettings } from '../../../../../shared/global-settings-types' import type { LinearIssue } from '../../../../../shared/linear/issue-types' import type { LinearTeam } from '../../../../../shared/linear/workspace-types' diff --git a/src/renderer/src/components/task-page/hooks/use-task-page-linear-create-dialogs.ts b/src/renderer/src/components/task-page/hooks/use-task-page-linear-create-dialogs.ts index 7403c12d4b9..8d202c57bac 100644 --- a/src/renderer/src/components/task-page/hooks/use-task-page-linear-create-dialogs.ts +++ b/src/renderer/src/components/task-page/hooks/use-task-page-linear-create-dialogs.ts @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState, type SetStateAction } from 'react' import { useTaskCreationDraftRetention } from '@/components/use-task-creation-draft-retention' import { useTeamLabels, useTeamMembers, useTeamStates } from '@/hooks/useIssueMetadata' -import { linearListProjects } from '@/runtime/runtime-linear-client' +import { linearListProjects } from '@/runtime/runtime-linear-project-client' import { writeNewLinearIssueDraft, writeNewLinearProjectDraft diff --git a/src/renderer/src/components/task-page/linear/linear-state-cell.tsx b/src/renderer/src/components/task-page/linear/linear-state-cell.tsx index 03f3d4aa5d4..28da17989a8 100644 --- a/src/renderer/src/components/task-page/linear/linear-state-cell.tsx +++ b/src/renderer/src/components/task-page/linear/linear-state-cell.tsx @@ -9,7 +9,7 @@ import { getLinearStatePillStyle } from '@/components/linear-state-pill-style' import { useTeamStates } from '@/hooks/useIssueMetadata' -import { linearUpdateIssue } from '@/runtime/runtime-linear-client' +import { linearUpdateIssue } from '@/runtime/runtime-linear-issue-mutations' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' import type { LinearIssue } from '../../../../../shared/linear/issue-types' diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.test.ts index 2f24f47fb0c..f0640d42889 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.test.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' +import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity' import { filterWorkspaceCleanupCandidates, getWorkspaceCleanupGitLabel, diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.ts index f3506b07e20..17b3da019ba 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.ts @@ -1,4 +1,4 @@ -import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' +import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity' import type { AppState } from '@/store/types' import { translate } from '@/i18n/i18n' import { getRepoExecutionHostId } from '../../../../shared/execution-host' diff --git a/src/renderer/src/hooks/useIssueMetadata.test.tsx b/src/renderer/src/hooks/useIssueMetadata.test.tsx index c633d543daa..97f6b3d6b6c 100644 --- a/src/renderer/src/hooks/useIssueMetadata.test.tsx +++ b/src/renderer/src/hooks/useIssueMetadata.test.tsx @@ -21,7 +21,7 @@ const linearMocks = vi.hoisted(() => ({ const runtimeMocks = vi.hoisted(() => ({ callRuntimeRpc: vi.fn() })) const githubMocks = vi.hoisted(() => ({ listLabels: vi.fn() })) -vi.mock('@/runtime/runtime-linear-client', () => ({ +vi.mock('@/runtime/runtime-linear-project-client', () => ({ linearTeamStates: linearMocks.linearTeamStates, linearTeamLabels: linearMocks.linearTeamLabels, linearTeamMembers: linearMocks.linearTeamMembers diff --git a/src/renderer/src/hooks/useIssueMetadata.ts b/src/renderer/src/hooks/useIssueMetadata.ts index 3dc10686323..a7e55b80700 100644 --- a/src/renderer/src/hooks/useIssueMetadata.ts +++ b/src/renderer/src/hooks/useIssueMetadata.ts @@ -3,9 +3,9 @@ import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-cl import { linearTeamLabels, linearTeamMembers, - linearTeamStates, - type RuntimeLinearSettings -} from '@/runtime/runtime-linear-client' + linearTeamStates +} from '@/runtime/runtime-linear-project-client' +import type { RuntimeLinearSettings } from '@/runtime/runtime-linear-client' import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types' import type { LinearLabel, diff --git a/src/renderer/src/runtime/runtime-linear-client.test.ts b/src/renderer/src/runtime/runtime-linear-client.test.ts index 8ebdfffc55e..77e4bbbf957 100644 --- a/src/renderer/src/runtime/runtime-linear-client.test.ts +++ b/src/renderer/src/runtime/runtime-linear-client.test.ts @@ -1,8 +1,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + linearListIssues, + linearSearchIssues, + linearSelectWorkspace, + linearStatus +} from './runtime-linear-client' import { linearCreateIssue, - linearCreateProject, linearCreateSubIssue, + linearUpdateIssue +} from './runtime-linear-issue-mutations' +import { + linearCreateProject, linearGetCustomView, linearGetProject, linearListCustomViewIssues, @@ -10,13 +19,8 @@ import { linearListCustomViews, linearListProjectIssues, linearListProjects, - linearListTeams, - linearListIssues, - linearSearchIssues, - linearSelectWorkspace, - linearStatus, - linearUpdateIssue -} from './runtime-linear-client' + linearListTeams +} from './runtime-linear-project-client' import { createCompatibleRuntimeStatusResponse, createCompatibleRuntimeStatusResponseIfNeeded, diff --git a/src/renderer/src/runtime/runtime-linear-client.ts b/src/renderer/src/runtime/runtime-linear-client.ts index d960923075e..745a4d35b3a 100644 --- a/src/renderer/src/runtime/runtime-linear-client.ts +++ b/src/renderer/src/runtime/runtime-linear-client.ts @@ -1,23 +1,10 @@ -/* eslint-disable max-lines -- Why: the renderer Linear client mirrors the - preload/RPC Linear namespace so local and remote runtime routing stays in - one auditable boundary. */ import type { GlobalSettings } from '../../../shared/global-settings-types' -import type { LinearIssueUpdate } from '../../../shared/issue-mutation-types' -import type { LinearComment, LinearIssue } from '../../../shared/linear/issue-types' -import type { - LinearCustomViewModel, - LinearCustomViewSummary, - LinearProjectDetail, - LinearProjectSummary -} from '../../../shared/linear/project-types' +import type { LinearIssue } from '../../../shared/linear/issue-types' +import type { LinearProjectDetail } from '../../../shared/linear/project-types' import type { LinearCollectionResult, LinearConnectionStatus, - LinearLabel, - LinearMember, - LinearTeam, LinearViewer, - LinearWorkflowState, LinearWorkspaceSelection } from '../../../shared/linear/workspace-types' import { @@ -71,7 +58,7 @@ export type LinearMutationResult = { ok: true } | { ok: false; error: string } export type LinearCommentResult = { ok: true; id: string } | { ok: false; error: string } export type LinearReadOptions = { force?: boolean } -function linearReadForce(options?: LinearReadOptions): { force: true } | {} { +export function linearReadForce(options?: LinearReadOptions): { force: true } | {} { return options?.force ? { force: true } : {} } @@ -81,7 +68,7 @@ function isTaskSourceRuntimeSettings( return settings !== null && settings !== undefined && 'kind' in settings } -function getLinearRuntimeTarget( +export function getLinearRuntimeTarget( settings: RuntimeLinearSettings ): ReturnType { // Why: task source context makes provider ownership explicit; legacy callers @@ -251,348 +238,3 @@ export async function linearListIssues( : await window.api.linear.listIssues(payload) return normalizeLinearIssueCollectionResult(result) } - -export async function linearCreateIssue( - settings: RuntimeLinearSettings, - args: { - teamId: string - title: string - description?: string - workspaceId?: string - parentIssueId?: string - projectId?: string | null - stateId?: string - priority?: number - assigneeId?: string | null - labelIds?: string[] - } -): Promise { - const target = getLinearRuntimeTarget(settings) - return target.kind === 'environment' - ? callRuntimeRpc(target, 'linear.createIssue', args, { - timeoutMs: 30_000 - }) - : window.api.linear.createIssue(args) -} - -export async function linearCreateSubIssue( - settings: RuntimeLinearSettings, - args: { - parentIssueId: string - teamId: string - title: string - description?: string - workspaceId?: string - projectId?: string | null - } -): Promise { - return linearCreateIssue(settings, args) -} - -export async function linearGetIssue( - settings: RuntimeLinearSettings, - id: string, - workspaceId?: string | null -): Promise { - const target = getLinearRuntimeTarget(settings) - return target.kind === 'environment' - ? callRuntimeRpc( - target, - 'linear.getIssue', - { id, workspaceId: workspaceId ?? undefined }, - { timeoutMs: 30_000 } - ) - : window.api.linear.getIssue({ id, workspaceId: workspaceId ?? undefined }) -} - -export async function linearUpdateIssue( - settings: RuntimeLinearSettings, - id: string, - updates: LinearIssueUpdate, - workspaceId?: string | null -): Promise { - const target = getLinearRuntimeTarget(settings) - return target.kind === 'environment' - ? callRuntimeRpc( - target, - 'linear.updateIssue', - { id, updates, workspaceId: workspaceId ?? undefined }, - { timeoutMs: 30_000 } - ) - : window.api.linear.updateIssue({ id, updates, workspaceId: workspaceId ?? undefined }) -} - -export async function linearAddIssueComment( - settings: RuntimeLinearSettings, - issueId: string, - body: string, - workspaceId?: string | null -): Promise { - const target = getLinearRuntimeTarget(settings) - return target.kind === 'environment' - ? callRuntimeRpc( - target, - 'linear.addIssueComment', - { issueId, body, workspaceId: workspaceId ?? undefined }, - { timeoutMs: 30_000 } - ) - : window.api.linear.addIssueComment({ issueId, body, workspaceId: workspaceId ?? undefined }) -} - -export async function linearIssueComments( - settings: RuntimeLinearSettings, - issueId: string, - workspaceId?: string | null -): Promise { - const target = getLinearRuntimeTarget(settings) - return target.kind === 'environment' - ? callRuntimeRpc( - target, - 'linear.issueComments', - { issueId, workspaceId: workspaceId ?? undefined }, - { timeoutMs: 30_000 } - ) - : window.api.linear.issueComments({ issueId, workspaceId: workspaceId ?? undefined }) -} - -export async function linearListTeams( - settings: RuntimeLinearSettings, - workspaceId?: LinearWorkspaceSelection | null -): Promise { - const target = getLinearRuntimeTarget(settings) - return target.kind === 'environment' - ? callRuntimeRpc( - target, - 'linear.listTeams', - workspaceId ? { workspaceId } : undefined, - { timeoutMs: 30_000 } - ) - : window.api.linear.listTeams(workspaceId ? { workspaceId } : undefined) -} - -export async function linearListProjects( - settings: RuntimeLinearSettings, - query?: string, - limit?: number, - workspaceId?: LinearWorkspaceSelection | null, - options?: LinearReadOptions -): Promise> { - if (!isRuntimeProviderSearchQueryWithinLimit(query)) { - return { items: [] } - } - const target = getLinearRuntimeTarget(settings) - return target.kind === 'environment' - ? callRuntimeRpc>( - target, - 'linear.listProjects', - { query, limit, workspaceId: workspaceId ?? undefined, ...linearReadForce(options) }, - { timeoutMs: 30_000 } - ) - : typeof window.api.linear.listProjects === 'function' - ? window.api.linear.listProjects({ - query, - limit, - workspaceId: workspaceId ?? undefined, - ...linearReadForce(options) - }) - : { items: [] } -} - -export async function linearCreateProject( - settings: RuntimeLinearSettings, - args: { - name: string - description?: string - content?: string - teamIds: string[] - workspaceId?: string - leadId?: string | null - memberIds?: string[] - labelIds?: string[] - priority?: number - startDate?: string - targetDate?: string - } -): Promise { - const target = getLinearRuntimeTarget(settings) - return target.kind === 'environment' - ? callRuntimeRpc(target, 'linear.createProject', args, { - timeoutMs: 30_000 - }) - : window.api.linear.createProject(args) -} - -export async function linearGetProject( - settings: RuntimeLinearSettings, - id: string, - workspaceId: string, - options?: LinearReadOptions -): Promise { - const target = getLinearRuntimeTarget(settings) - return target.kind === 'environment' - ? callRuntimeRpc( - target, - 'linear.getProject', - { id, workspaceId, ...linearReadForce(options) }, - { timeoutMs: 30_000 } - ) - : window.api.linear.getProject({ id, workspaceId, ...linearReadForce(options) }) -} - -export async function linearListProjectIssues( - settings: RuntimeLinearSettings, - projectId: string, - limit: number | undefined, - workspaceId: string, - options?: LinearReadOptions -): Promise> { - const target = getLinearRuntimeTarget(settings) - return target.kind === 'environment' - ? callRuntimeRpc>( - target, - 'linear.listProjectIssues', - { projectId, limit, workspaceId, ...linearReadForce(options) }, - { timeoutMs: 30_000 } - ) - : window.api.linear.listProjectIssues({ - projectId, - limit, - workspaceId, - ...linearReadForce(options) - }) -} - -export async function linearListCustomViews( - settings: RuntimeLinearSettings, - model: LinearCustomViewModel, - limit?: number, - workspaceId?: LinearWorkspaceSelection | null, - options?: LinearReadOptions -): Promise> { - const target = getLinearRuntimeTarget(settings) - return target.kind === 'environment' - ? callRuntimeRpc>( - target, - 'linear.listCustomViews', - { model, limit, workspaceId: workspaceId ?? undefined, ...linearReadForce(options) }, - { timeoutMs: 30_000 } - ) - : window.api.linear.listCustomViews({ - model, - limit, - workspaceId: workspaceId ?? undefined, - ...linearReadForce(options) - }) -} - -export async function linearGetCustomView( - settings: RuntimeLinearSettings, - viewId: string, - model: LinearCustomViewModel, - workspaceId: string, - options?: LinearReadOptions -): Promise { - const target = getLinearRuntimeTarget(settings) - return target.kind === 'environment' - ? callRuntimeRpc( - target, - 'linear.getCustomView', - { viewId, model, workspaceId, ...linearReadForce(options) }, - { timeoutMs: 30_000 } - ) - : window.api.linear.getCustomView({ viewId, model, workspaceId, ...linearReadForce(options) }) -} - -export async function linearListCustomViewIssues( - settings: RuntimeLinearSettings, - viewId: string, - limit: number | undefined, - workspaceId: string, - options?: LinearReadOptions -): Promise> { - const target = getLinearRuntimeTarget(settings) - return target.kind === 'environment' - ? callRuntimeRpc>( - target, - 'linear.listCustomViewIssues', - { viewId, limit, workspaceId, ...linearReadForce(options) }, - { timeoutMs: 30_000 } - ) - : window.api.linear.listCustomViewIssues({ - viewId, - limit, - workspaceId, - ...linearReadForce(options) - }) -} - -export async function linearListCustomViewProjects( - settings: RuntimeLinearSettings, - viewId: string, - limit: number | undefined, - workspaceId: string, - options?: LinearReadOptions -): Promise> { - const target = getLinearRuntimeTarget(settings) - return target.kind === 'environment' - ? callRuntimeRpc>( - target, - 'linear.listCustomViewProjects', - { viewId, limit, workspaceId, ...linearReadForce(options) }, - { timeoutMs: 30_000 } - ) - : window.api.linear.listCustomViewProjects({ - viewId, - limit, - workspaceId, - ...linearReadForce(options) - }) -} - -export async function linearTeamStates( - settings: RuntimeLinearSettings, - teamId: string, - workspaceId?: string | null -): Promise { - const target = getLinearRuntimeTarget(settings) - return target.kind === 'environment' - ? callRuntimeRpc( - target, - 'linear.teamStates', - { teamId, workspaceId: workspaceId ?? undefined }, - { timeoutMs: 30_000 } - ) - : window.api.linear.teamStates({ teamId, workspaceId: workspaceId ?? undefined }) -} - -export async function linearTeamLabels( - settings: RuntimeLinearSettings, - teamId: string, - workspaceId?: string | null -): Promise { - const target = getLinearRuntimeTarget(settings) - return target.kind === 'environment' - ? callRuntimeRpc( - target, - 'linear.teamLabels', - { teamId, workspaceId: workspaceId ?? undefined }, - { timeoutMs: 30_000 } - ) - : window.api.linear.teamLabels({ teamId, workspaceId: workspaceId ?? undefined }) -} - -export async function linearTeamMembers( - settings: RuntimeLinearSettings, - teamId: string, - workspaceId?: string | null -): Promise { - const target = getLinearRuntimeTarget(settings) - return target.kind === 'environment' - ? callRuntimeRpc( - target, - 'linear.teamMembers', - { teamId, workspaceId: workspaceId ?? undefined }, - { timeoutMs: 30_000 } - ) - : window.api.linear.teamMembers({ teamId, workspaceId: workspaceId ?? undefined }) -} diff --git a/src/renderer/src/runtime/runtime-linear-issue-mutations.ts b/src/renderer/src/runtime/runtime-linear-issue-mutations.ts new file mode 100644 index 00000000000..12b197b399f --- /dev/null +++ b/src/renderer/src/runtime/runtime-linear-issue-mutations.ts @@ -0,0 +1,113 @@ +import type { LinearIssueUpdate } from '../../../shared/issue-mutation-types' +import type { LinearComment, LinearIssue } from '../../../shared/linear/issue-types' +import { callRuntimeRpc } from './runtime-rpc-client' +import { + getLinearRuntimeTarget, + type LinearCommentResult, + type LinearCreateIssueResult, + type LinearMutationResult, + type RuntimeLinearSettings +} from './runtime-linear-client' + +export async function linearCreateIssue( + settings: RuntimeLinearSettings, + args: { + teamId: string + title: string + description?: string + workspaceId?: string + parentIssueId?: string + projectId?: string | null + stateId?: string + priority?: number + assigneeId?: string | null + labelIds?: string[] + } +): Promise { + const target = getLinearRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc(target, 'linear.createIssue', args, { + timeoutMs: 30_000 + }) + : window.api.linear.createIssue(args) +} + +export async function linearCreateSubIssue( + settings: RuntimeLinearSettings, + args: { + parentIssueId: string + teamId: string + title: string + description?: string + workspaceId?: string + projectId?: string | null + } +): Promise { + return linearCreateIssue(settings, args) +} + +export async function linearGetIssue( + settings: RuntimeLinearSettings, + id: string, + workspaceId?: string | null +): Promise { + const target = getLinearRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc( + target, + 'linear.getIssue', + { id, workspaceId: workspaceId ?? undefined }, + { timeoutMs: 30_000 } + ) + : window.api.linear.getIssue({ id, workspaceId: workspaceId ?? undefined }) +} + +export async function linearUpdateIssue( + settings: RuntimeLinearSettings, + id: string, + updates: LinearIssueUpdate, + workspaceId?: string | null +): Promise { + const target = getLinearRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc( + target, + 'linear.updateIssue', + { id, updates, workspaceId: workspaceId ?? undefined }, + { timeoutMs: 30_000 } + ) + : window.api.linear.updateIssue({ id, updates, workspaceId: workspaceId ?? undefined }) +} + +export async function linearAddIssueComment( + settings: RuntimeLinearSettings, + issueId: string, + body: string, + workspaceId?: string | null +): Promise { + const target = getLinearRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc( + target, + 'linear.addIssueComment', + { issueId, body, workspaceId: workspaceId ?? undefined }, + { timeoutMs: 30_000 } + ) + : window.api.linear.addIssueComment({ issueId, body, workspaceId: workspaceId ?? undefined }) +} + +export async function linearIssueComments( + settings: RuntimeLinearSettings, + issueId: string, + workspaceId?: string | null +): Promise { + const target = getLinearRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc( + target, + 'linear.issueComments', + { issueId, workspaceId: workspaceId ?? undefined }, + { timeoutMs: 30_000 } + ) + : window.api.linear.issueComments({ issueId, workspaceId: workspaceId ?? undefined }) +} diff --git a/src/renderer/src/runtime/runtime-linear-project-client.ts b/src/renderer/src/runtime/runtime-linear-project-client.ts new file mode 100644 index 00000000000..c15d615c674 --- /dev/null +++ b/src/renderer/src/runtime/runtime-linear-project-client.ts @@ -0,0 +1,266 @@ +import type { LinearIssue } from '../../../shared/linear/issue-types' +import type { + LinearCustomViewModel, + LinearCustomViewSummary, + LinearProjectDetail, + LinearProjectSummary +} from '../../../shared/linear/project-types' +import type { + LinearCollectionResult, + LinearLabel, + LinearMember, + LinearTeam, + LinearWorkflowState, + LinearWorkspaceSelection +} from '../../../shared/linear/workspace-types' +import { isRuntimeProviderSearchQueryWithinLimit } from './runtime-provider-search-bounds' +import { callRuntimeRpc } from './runtime-rpc-client' +import { + getLinearRuntimeTarget, + linearReadForce, + type LinearCreateProjectResult, + type LinearReadOptions, + type RuntimeLinearSettings +} from './runtime-linear-client' + +export async function linearListTeams( + settings: RuntimeLinearSettings, + workspaceId?: LinearWorkspaceSelection | null +): Promise { + const target = getLinearRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc( + target, + 'linear.listTeams', + workspaceId ? { workspaceId } : undefined, + { timeoutMs: 30_000 } + ) + : window.api.linear.listTeams(workspaceId ? { workspaceId } : undefined) +} + +export async function linearListProjects( + settings: RuntimeLinearSettings, + query?: string, + limit?: number, + workspaceId?: LinearWorkspaceSelection | null, + options?: LinearReadOptions +): Promise> { + if (!isRuntimeProviderSearchQueryWithinLimit(query)) { + return { items: [] } + } + const target = getLinearRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc>( + target, + 'linear.listProjects', + { query, limit, workspaceId: workspaceId ?? undefined, ...linearReadForce(options) }, + { timeoutMs: 30_000 } + ) + : typeof window.api.linear.listProjects === 'function' + ? window.api.linear.listProjects({ + query, + limit, + workspaceId: workspaceId ?? undefined, + ...linearReadForce(options) + }) + : { items: [] } +} + +export async function linearCreateProject( + settings: RuntimeLinearSettings, + args: { + name: string + description?: string + content?: string + teamIds: string[] + workspaceId?: string + leadId?: string | null + memberIds?: string[] + labelIds?: string[] + priority?: number + startDate?: string + targetDate?: string + } +): Promise { + const target = getLinearRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc(target, 'linear.createProject', args, { + timeoutMs: 30_000 + }) + : window.api.linear.createProject(args) +} + +export async function linearGetProject( + settings: RuntimeLinearSettings, + id: string, + workspaceId: string, + options?: LinearReadOptions +): Promise { + const target = getLinearRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc( + target, + 'linear.getProject', + { id, workspaceId, ...linearReadForce(options) }, + { timeoutMs: 30_000 } + ) + : window.api.linear.getProject({ id, workspaceId, ...linearReadForce(options) }) +} + +export async function linearListProjectIssues( + settings: RuntimeLinearSettings, + projectId: string, + limit: number | undefined, + workspaceId: string, + options?: LinearReadOptions +): Promise> { + const target = getLinearRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc>( + target, + 'linear.listProjectIssues', + { projectId, limit, workspaceId, ...linearReadForce(options) }, + { timeoutMs: 30_000 } + ) + : window.api.linear.listProjectIssues({ + projectId, + limit, + workspaceId, + ...linearReadForce(options) + }) +} + +export async function linearListCustomViews( + settings: RuntimeLinearSettings, + model: LinearCustomViewModel, + limit?: number, + workspaceId?: LinearWorkspaceSelection | null, + options?: LinearReadOptions +): Promise> { + const target = getLinearRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc>( + target, + 'linear.listCustomViews', + { model, limit, workspaceId: workspaceId ?? undefined, ...linearReadForce(options) }, + { timeoutMs: 30_000 } + ) + : window.api.linear.listCustomViews({ + model, + limit, + workspaceId: workspaceId ?? undefined, + ...linearReadForce(options) + }) +} + +export async function linearGetCustomView( + settings: RuntimeLinearSettings, + viewId: string, + model: LinearCustomViewModel, + workspaceId: string, + options?: LinearReadOptions +): Promise { + const target = getLinearRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc( + target, + 'linear.getCustomView', + { viewId, model, workspaceId, ...linearReadForce(options) }, + { timeoutMs: 30_000 } + ) + : window.api.linear.getCustomView({ viewId, model, workspaceId, ...linearReadForce(options) }) +} + +export async function linearListCustomViewIssues( + settings: RuntimeLinearSettings, + viewId: string, + limit: number | undefined, + workspaceId: string, + options?: LinearReadOptions +): Promise> { + const target = getLinearRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc>( + target, + 'linear.listCustomViewIssues', + { viewId, limit, workspaceId, ...linearReadForce(options) }, + { timeoutMs: 30_000 } + ) + : window.api.linear.listCustomViewIssues({ + viewId, + limit, + workspaceId, + ...linearReadForce(options) + }) +} + +export async function linearListCustomViewProjects( + settings: RuntimeLinearSettings, + viewId: string, + limit: number | undefined, + workspaceId: string, + options?: LinearReadOptions +): Promise> { + const target = getLinearRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc>( + target, + 'linear.listCustomViewProjects', + { viewId, limit, workspaceId, ...linearReadForce(options) }, + { timeoutMs: 30_000 } + ) + : window.api.linear.listCustomViewProjects({ + viewId, + limit, + workspaceId, + ...linearReadForce(options) + }) +} + +export async function linearTeamStates( + settings: RuntimeLinearSettings, + teamId: string, + workspaceId?: string | null +): Promise { + const target = getLinearRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc( + target, + 'linear.teamStates', + { teamId, workspaceId: workspaceId ?? undefined }, + { timeoutMs: 30_000 } + ) + : window.api.linear.teamStates({ teamId, workspaceId: workspaceId ?? undefined }) +} + +export async function linearTeamLabels( + settings: RuntimeLinearSettings, + teamId: string, + workspaceId?: string | null +): Promise { + const target = getLinearRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc( + target, + 'linear.teamLabels', + { teamId, workspaceId: workspaceId ?? undefined }, + { timeoutMs: 30_000 } + ) + : window.api.linear.teamLabels({ teamId, workspaceId: workspaceId ?? undefined }) +} + +export async function linearTeamMembers( + settings: RuntimeLinearSettings, + teamId: string, + workspaceId?: string | null +): Promise { + const target = getLinearRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc( + target, + 'linear.teamMembers', + { teamId, workspaceId: workspaceId ?? undefined }, + { timeoutMs: 30_000 } + ) + : window.api.linear.teamMembers({ teamId, workspaceId: workspaceId ?? undefined }) +} diff --git a/src/renderer/src/store/slices/diff-comment-persistence.ts b/src/renderer/src/store/slices/diff-comment-persistence.ts new file mode 100644 index 00000000000..92e1eb9ea9f --- /dev/null +++ b/src/renderer/src/store/slices/diff-comment-persistence.ts @@ -0,0 +1,322 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { DiffComment } from '../../../../shared/diff-comment-types' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { Worktree } from '../../../../shared/worktree/types' +import { getRepoIdFromWorktreeId } from './worktree-helpers' +import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { + findFolderWorkspaceOwner, + getExecutionHostIdForFolderWorkspace, + getRuntimeEnvironmentIdForFolderWorkspace +} from '@/lib/folder-workspace-runtime-owner' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' + +export function normalizeDiffComment(comment: DiffComment): DiffComment { + const rawSource = (comment as { source?: unknown }).source + const source = rawSource === 'markdown' || rawSource === 'diff' ? rawSource : undefined + const rawStartLine = (comment as { startLine?: unknown }).startLine + const startLine = + Number.isInteger(rawStartLine) && + typeof rawStartLine === 'number' && + rawStartLine >= 1 && + rawStartLine <= comment.lineNumber + ? rawStartLine + : undefined + const rawSelectedText = (comment as { selectedText?: unknown }).selectedText + const selectedText = + typeof rawSelectedText === 'string' && rawSelectedText.trim().length > 0 + ? rawSelectedText.trim() + : undefined + const rawSentAt = (comment as { sentAt?: unknown }).sentAt + const sentAt = + typeof rawSentAt === 'number' && Number.isFinite(rawSentAt) && rawSentAt > 0 + ? rawSentAt + : undefined + + return { + ...comment, + ...(source !== undefined ? { source } : {}), + ...(source === undefined ? { source: undefined } : {}), + ...(selectedText !== undefined ? { selectedText } : {}), + ...(selectedText === undefined ? { selectedText: undefined } : {}), + ...(startLine !== undefined ? { startLine } : {}), + ...(startLine === undefined ? { startLine: undefined } : {}), + ...(sentAt !== undefined ? { sentAt } : {}), + ...(sentAt === undefined ? { sentAt: undefined } : {}) + } +} + +async function persist( + state: AppState, + settings: AppState['settings'], + worktreeId: string, + diffComments: DiffComment[], + folderExecutionHostId?: ReturnType +): Promise { + const scope = parseWorkspaceKey(worktreeId) + if (scope?.type === 'folder') { + const executionHostId = + folderExecutionHostId ?? getExecutionHostIdForFolderWorkspace(state, scope.folderWorkspaceId) + const runtimeEnvironmentId = getRuntimeEnvironmentIdForFolderWorkspace( + state, + scope.folderWorkspaceId, + executionHostId + ) + const target = getActiveRuntimeTarget({ activeRuntimeEnvironmentId: runtimeEnvironmentId }) + const updated = + target.kind === 'local' + ? await window.api.folderWorkspaces.update({ + folderWorkspaceId: scope.folderWorkspaceId, + updates: { diffComments } + }) + : ( + await callRuntimeRpc<{ folderWorkspace: FolderWorkspace | null }>( + target, + 'folderWorkspace.update', + { folderWorkspaceId: scope.folderWorkspaceId, updates: { diffComments } }, + { timeoutMs: 15_000 } + ) + ).folderWorkspace + if (!updated?.diffComments) { + throw new Error('Failed to persist folder workspace review notes') + } + return + } + const target = getActiveRuntimeTarget(settings) + if (target.kind === 'local') { + await window.api.worktrees.updateMeta({ + worktreeId, + updates: { diffComments } + }) + return + } + await callRuntimeRpc( + target, + 'worktree.set', + { worktree: toRuntimeWorktreeSelector(worktreeId), diffComments }, + { timeoutMs: 15_000 } + ) +} + +function settingsForWorktreeOwner(state: AppState, worktreeId: string): AppState['settings'] { + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId) + return state.settings + ? { ...state.settings, activeRuntimeEnvironmentId: runtimeEnvironmentId } + : ({ activeRuntimeEnvironmentId: runtimeEnvironmentId } as AppState['settings']) +} + +// Why: IPC writes aren't ordered, so serialize per worktree to stop an older snapshot from overwriting a newer one on disk. +const persistQueueByWorktree = new Map>() + +// Why: rollback must converge to what actually reached disk; in a burst of failed writes each mutation's own predecessor is stale. +const lastPersistedByQueue = new Map() + +// Why: the floor is only valid across an unbroken mutation chain; this is how an out-of-band replacement mid-burst is detected. +const lastMutationNextByQueue = new Map() + +// Why: bumped on every re-seed, so an in-flight write can tell whether the floor it captured is still the current one. +const floorSeedEpochByQueue = new Map() + +export type DiffCommentMutation = { + previous: DiffComment[] | undefined + next: DiffComment[] + folderExecutionHostId?: ReturnType +} + +// Why: one key for the queue and both bookkeeping maps, so they can never drift apart. +function persistQueueKey( + worktreeId: string, + folderExecutionHostId?: ReturnType +): string { + return folderExecutionHostId ? `${folderExecutionHostId}\0${worktreeId}` : worktreeId +} + +// Why: chain each write onto the prior promise so writes land in call order; both then handlers keep the chain alive past a failure. +// Why: queued work reads the latest list at dequeue time, and the returned promise settles for THIS write. +// Why: this promise rejects only after this write's rollback has been applied โ€” callers must not roll back themselves. +export function enqueueDiffCommentPersist( + set: Parameters>[0], + worktreeId: string, + get: () => AppState, + mutation: DiffCommentMutation +): Promise { + const folderExecutionHostId = mutation.folderExecutionHostId + const queueKey = persistQueueKey(worktreeId, folderExecutionHostId) + const prior = persistQueueByWorktree.get(queueKey) ?? Promise.resolve() + // Why: an idle queue means disk still holds the pre-mutation list, so that list is this burst's rollback floor. + // Why: a `previous` that isn't the last mutation's `next` means something outside the mutators replaced the list + // (hydration, folderWorkspaces refresh, remote push), so the old floor predates that state and must be re-seeded. + // Why: seed before the queue entry below, or `has` is always true and the floor is never seeded. + const chainBroken = lastMutationNextByQueue.get(queueKey) !== mutation.previous + if (!persistQueueByWorktree.has(queueKey) || chainBroken) { + lastPersistedByQueue.set(queueKey, mutation.previous) + floorSeedEpochByQueue.set(queueKey, (floorSeedEpochByQueue.get(queueKey) ?? 0) + 1) + } + lastMutationNextByQueue.set(queueKey, mutation.next) + const run = async (): Promise => { + // Why: capture at dequeue time, alongside `stateList` โ€” an epoch read at enqueue time would bar every write + // that straddles a chain break from recording a floor its coalesced payload already carries. + const seedEpoch = floorSeedEpochByQueue.get(queueKey) + // Why: the state-side array, not the normalized copy sent to disk โ€” restoring the same instance keeps + // `getDiffComments` identity stable instead of churning selectors. + let stateList: DiffComment[] | undefined + try { + const scope = parseWorkspaceKey(worktreeId) + if (scope?.type === 'folder') { + const state = get() + const folderWorkspace = findFolderWorkspaceOwner( + state, + scope.folderWorkspaceId, + folderExecutionHostId + ) + stateList = folderWorkspace?.diffComments + await persist( + state, + state.settings, + worktreeId, + (stateList ?? []).map(normalizeDiffComment), + folderExecutionHostId + ) + } else { + const repoId = getRepoIdFromWorktreeId(worktreeId) + const target = get().worktreesByRepo[repoId]?.find((w) => w.id === worktreeId) + stateList = target?.diffComments + const state = get() + await persist( + state, + settingsForWorktreeOwner(state, worktreeId), + worktreeId, + (stateList ?? []).map(normalizeDiffComment) + ) + } + } catch (err) { + // Why: converge to what actually landed; rollback's identity guard no-ops when a later mutation owns the array. + // Why: `has()`, not `get() ?? โ€ฆ` โ€” an absent entry and a legitimately-`undefined` floor are different things. + const floor = lastPersistedByQueue.has(queueKey) + ? lastPersistedByQueue.get(queueKey) + : mutation.previous + rollback(set, worktreeId, floor, mutation.next, folderExecutionHostId) + throw err + } + // Why: a chain break re-seeded the floor to an out-of-band replacement while this write was awaiting, so the + // list captured before the await predates it and must not be reinstated as the floor. + if (floorSeedEpochByQueue.get(queueKey) === seedEpoch) { + lastPersistedByQueue.set(queueKey, stateList) + } + } + const next = prior.then(run, run) + persistQueueByWorktree.set(queueKey, next) + // Why: clear the queue entry only if still the tail, so later enqueues chain onto the real in-flight promise. + // Why: then(cleanup, cleanup) not finally, so a rejection is consumed here rather than re-thrown as unhandledRejection. + const cleanup = (): void => { + if (persistQueueByWorktree.get(queueKey) === next) { + persistQueueByWorktree.delete(queueKey) + // Why: tail-guarded only; a mid-burst delete would strand the remaining writes of the burst without a floor. + lastPersistedByQueue.delete(queueKey) + lastMutationNextByQueue.delete(queueKey) + floorSeedEpochByQueue.delete(queueKey) + } + } + next.then(cleanup, cleanup) + return next +} + +// Why: derive the next list inside the `set` updater so concurrent writes can't clobber each other via a stale closure. +export function mutateDiffComments( + set: Parameters>[0], + worktreeId: string, + mutate: (existing: DiffComment[]) => DiffComment[] | null +): DiffCommentMutation | null { + const repoId = getRepoIdFromWorktreeId(worktreeId) + let previous: DiffComment[] | undefined + let next: DiffComment[] | null = null + let folderExecutionHostId: ReturnType | undefined + set((s) => { + const scope = parseWorkspaceKey(worktreeId) + if (scope?.type === 'folder') { + const target = findFolderWorkspaceOwner(s, scope.folderWorkspaceId) + if (!target) { + return {} + } + folderExecutionHostId = getExecutionHostIdForFolderWorkspace(s, scope.folderWorkspaceId) + previous = target.diffComments + const computed = mutate(previous ?? []) + if (computed === null) { + return {} + } + next = computed + return { + folderWorkspaces: s.folderWorkspaces.map((workspace) => + workspace === target ? { ...workspace, diffComments: computed } : workspace + ) + } + } + const repoList = s.worktreesByRepo[repoId] + if (!repoList) { + return {} + } + const target = repoList.find((w) => w.id === worktreeId) + if (!target) { + return {} + } + previous = target.diffComments + const computed = mutate(previous ?? []) + if (computed === null) { + return {} + } + next = computed + const nextList: Worktree[] = repoList.map((w) => + w.id === worktreeId ? { ...w, diffComments: computed } : w + ) + return { worktreesByRepo: { ...s.worktreesByRepo, [repoId]: nextList } } + }) + if (next === null) { + return null + } + return { previous, next, folderExecutionHostId } +} + +// Why: on IPC-write failure, roll back optimistic state so the renderer matches disk (identity-guarded below). +function rollback( + set: Parameters>[0], + worktreeId: string, + previous: DiffComment[] | undefined, + expectedCurrent: DiffComment[], + folderExecutionHostId?: ReturnType +): void { + const repoId = getRepoIdFromWorktreeId(worktreeId) + set((s) => { + const scope = parseWorkspaceKey(worktreeId) + if (scope?.type === 'folder') { + const target = findFolderWorkspaceOwner(s, scope.folderWorkspaceId, folderExecutionHostId) + if (!target || target.diffComments !== expectedCurrent) { + return {} + } + return { + folderWorkspaces: s.folderWorkspaces.map((workspace) => + workspace === target ? { ...workspace, diffComments: previous } : workspace + ) + } + } + const repoList = s.worktreesByRepo[repoId] + if (!repoList) { + return {} + } + const target = repoList.find((w) => w.id === worktreeId) + // Why: worktree gone since the mutation; bail before remapping so we don't allocate a new array identity and fire spurious notifications. + if (!target) { + return {} + } + // Why: only roll back if no later mutation replaced the array, else our stale `previous` would erase newer state. + if (target.diffComments !== expectedCurrent) { + return {} + } + const nextList: Worktree[] = repoList.map((w) => + w.id === worktreeId ? { ...w, diffComments: previous } : w + ) + return { worktreesByRepo: { ...s.worktreesByRepo, [repoId]: nextList } } + }) +} diff --git a/src/renderer/src/store/slices/diffComments.ts b/src/renderer/src/store/slices/diffComments.ts index fb4a1cb2e9e..b11615be936 100644 --- a/src/renderer/src/store/slices/diffComments.ts +++ b/src/renderer/src/store/slices/diffComments.ts @@ -1,20 +1,15 @@ -/* eslint-disable max-lines -- Why: keeps note mutation, rollback, persistence ordering, and sent-state transitions under shared queue/rollback invariants. */ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { DiffComment } from '../../../../shared/diff-comment-types' -import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' -import type { Worktree } from '../../../../shared/worktree/types' -import { findWorktreeById, getRepoIdFromWorktreeId } from './worktree-helpers' -import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' -import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector' +import { findWorktreeById } from './worktree-helpers' import { createBrowserUuid } from '@/lib/browser-uuid' -import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' -import { - findFolderWorkspaceOwner, - getExecutionHostIdForFolderWorkspace, - getRuntimeEnvironmentIdForFolderWorkspace -} from '@/lib/folder-workspace-runtime-owner' +import { findFolderWorkspaceOwner } from '@/lib/folder-workspace-runtime-owner' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { + enqueueDiffCommentPersist, + mutateDiffComments, + normalizeDiffComment +} from './diff-comment-persistence' export type DiffCommentsSlice = { getDiffComments: (worktreeId: string | null | undefined) => DiffComment[] @@ -43,41 +38,6 @@ function generateId(): string { return createBrowserUuid() } -function normalizeDiffComment(comment: DiffComment): DiffComment { - const rawSource = (comment as { source?: unknown }).source - const source = rawSource === 'markdown' || rawSource === 'diff' ? rawSource : undefined - const rawStartLine = (comment as { startLine?: unknown }).startLine - const startLine = - Number.isInteger(rawStartLine) && - typeof rawStartLine === 'number' && - rawStartLine >= 1 && - rawStartLine <= comment.lineNumber - ? rawStartLine - : undefined - const rawSelectedText = (comment as { selectedText?: unknown }).selectedText - const selectedText = - typeof rawSelectedText === 'string' && rawSelectedText.trim().length > 0 - ? rawSelectedText.trim() - : undefined - const rawSentAt = (comment as { sentAt?: unknown }).sentAt - const sentAt = - typeof rawSentAt === 'number' && Number.isFinite(rawSentAt) && rawSentAt > 0 - ? rawSentAt - : undefined - - return { - ...comment, - ...(source !== undefined ? { source } : {}), - ...(source === undefined ? { source: undefined } : {}), - ...(selectedText !== undefined ? { selectedText } : {}), - ...(selectedText === undefined ? { selectedText: undefined } : {}), - ...(startLine !== undefined ? { startLine } : {}), - ...(startLine === undefined ? { startLine: undefined } : {}), - ...(sentAt !== undefined ? { sentAt } : {}), - ...(sentAt === undefined ? { sentAt: undefined } : {}) - } -} - function deliverySnapshotMatches( comment: DiffComment, snapshot: DiffCommentDeliverySnapshot @@ -96,181 +56,6 @@ function deliverySnapshotMatches( // Why: a frozen shared sentinel avoids selector re-renders and mutation. const EMPTY_COMMENTS: readonly DiffComment[] = Object.freeze([]) -async function persist( - state: AppState, - settings: AppState['settings'], - worktreeId: string, - diffComments: DiffComment[], - folderExecutionHostId?: ReturnType -): Promise { - const scope = parseWorkspaceKey(worktreeId) - if (scope?.type === 'folder') { - const executionHostId = - folderExecutionHostId ?? getExecutionHostIdForFolderWorkspace(state, scope.folderWorkspaceId) - const runtimeEnvironmentId = getRuntimeEnvironmentIdForFolderWorkspace( - state, - scope.folderWorkspaceId, - executionHostId - ) - const target = getActiveRuntimeTarget({ activeRuntimeEnvironmentId: runtimeEnvironmentId }) - const updated = - target.kind === 'local' - ? await window.api.folderWorkspaces.update({ - folderWorkspaceId: scope.folderWorkspaceId, - updates: { diffComments } - }) - : ( - await callRuntimeRpc<{ folderWorkspace: FolderWorkspace | null }>( - target, - 'folderWorkspace.update', - { folderWorkspaceId: scope.folderWorkspaceId, updates: { diffComments } }, - { timeoutMs: 15_000 } - ) - ).folderWorkspace - if (!updated?.diffComments) { - throw new Error('Failed to persist folder workspace review notes') - } - return - } - const target = getActiveRuntimeTarget(settings) - if (target.kind === 'local') { - await window.api.worktrees.updateMeta({ - worktreeId, - updates: { diffComments } - }) - return - } - await callRuntimeRpc( - target, - 'worktree.set', - { worktree: toRuntimeWorktreeSelector(worktreeId), diffComments }, - { timeoutMs: 15_000 } - ) -} - -function settingsForWorktreeOwner(state: AppState, worktreeId: string): AppState['settings'] { - const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId) - return state.settings - ? { ...state.settings, activeRuntimeEnvironmentId: runtimeEnvironmentId } - : ({ activeRuntimeEnvironmentId: runtimeEnvironmentId } as AppState['settings']) -} - -// Why: IPC writes aren't ordered, so serialize per worktree to stop an older snapshot from overwriting a newer one on disk. -const persistQueueByWorktree = new Map>() - -// Why: rollback must converge to what actually reached disk; in a burst of failed writes each mutation's own predecessor is stale. -const lastPersistedByQueue = new Map() - -// Why: the floor is only valid across an unbroken mutation chain; this is how an out-of-band replacement mid-burst is detected. -const lastMutationNextByQueue = new Map() - -// Why: bumped on every re-seed, so an in-flight write can tell whether the floor it captured is still the current one. -const floorSeedEpochByQueue = new Map() - -type CommentMutation = { - previous: DiffComment[] | undefined - next: DiffComment[] - folderExecutionHostId?: ReturnType -} - -// Why: one key for the queue and both bookkeeping maps, so they can never drift apart. -function persistQueueKey( - worktreeId: string, - folderExecutionHostId?: ReturnType -): string { - return folderExecutionHostId ? `${folderExecutionHostId}\0${worktreeId}` : worktreeId -} - -// Why: chain each write onto the prior promise so writes land in call order; both then handlers keep the chain alive past a failure. -// Why: queued work reads the latest list at dequeue time, and the returned promise settles for THIS write. -// Why: this promise rejects only after this write's rollback has been applied โ€” callers must not roll back themselves. -function enqueuePersist( - set: Parameters>[0], - worktreeId: string, - get: () => AppState, - mutation: CommentMutation -): Promise { - const folderExecutionHostId = mutation.folderExecutionHostId - const queueKey = persistQueueKey(worktreeId, folderExecutionHostId) - const prior = persistQueueByWorktree.get(queueKey) ?? Promise.resolve() - // Why: an idle queue means disk still holds the pre-mutation list, so that list is this burst's rollback floor. - // Why: a `previous` that isn't the last mutation's `next` means something outside the mutators replaced the list - // (hydration, folderWorkspaces refresh, remote push), so the old floor predates that state and must be re-seeded. - // Why: seed before the queue entry below, or `has` is always true and the floor is never seeded. - const chainBroken = lastMutationNextByQueue.get(queueKey) !== mutation.previous - if (!persistQueueByWorktree.has(queueKey) || chainBroken) { - lastPersistedByQueue.set(queueKey, mutation.previous) - floorSeedEpochByQueue.set(queueKey, (floorSeedEpochByQueue.get(queueKey) ?? 0) + 1) - } - lastMutationNextByQueue.set(queueKey, mutation.next) - const run = async (): Promise => { - // Why: capture at dequeue time, alongside `stateList` โ€” an epoch read at enqueue time would bar every write - // that straddles a chain break from recording a floor its coalesced payload already carries. - const seedEpoch = floorSeedEpochByQueue.get(queueKey) - // Why: the state-side array, not the normalized copy sent to disk โ€” restoring the same instance keeps - // `getDiffComments` identity stable instead of churning selectors. - let stateList: DiffComment[] | undefined - try { - const scope = parseWorkspaceKey(worktreeId) - if (scope?.type === 'folder') { - const state = get() - const folderWorkspace = findFolderWorkspaceOwner( - state, - scope.folderWorkspaceId, - folderExecutionHostId - ) - stateList = folderWorkspace?.diffComments - await persist( - state, - state.settings, - worktreeId, - (stateList ?? []).map(normalizeDiffComment), - folderExecutionHostId - ) - } else { - const repoId = getRepoIdFromWorktreeId(worktreeId) - const target = get().worktreesByRepo[repoId]?.find((w) => w.id === worktreeId) - stateList = target?.diffComments - const state = get() - await persist( - state, - settingsForWorktreeOwner(state, worktreeId), - worktreeId, - (stateList ?? []).map(normalizeDiffComment) - ) - } - } catch (err) { - // Why: converge to what actually landed; rollback's identity guard no-ops when a later mutation owns the array. - // Why: `has()`, not `get() ?? โ€ฆ` โ€” an absent entry and a legitimately-`undefined` floor are different things. - const floor = lastPersistedByQueue.has(queueKey) - ? lastPersistedByQueue.get(queueKey) - : mutation.previous - rollback(set, worktreeId, floor, mutation.next, folderExecutionHostId) - throw err - } - // Why: a chain break re-seeded the floor to an out-of-band replacement while this write was awaiting, so the - // list captured before the await predates it and must not be reinstated as the floor. - if (floorSeedEpochByQueue.get(queueKey) === seedEpoch) { - lastPersistedByQueue.set(queueKey, stateList) - } - } - const next = prior.then(run, run) - persistQueueByWorktree.set(queueKey, next) - // Why: clear the queue entry only if still the tail, so later enqueues chain onto the real in-flight promise. - // Why: then(cleanup, cleanup) not finally, so a rejection is consumed here rather than re-thrown as unhandledRejection. - const cleanup = (): void => { - if (persistQueueByWorktree.get(queueKey) === next) { - persistQueueByWorktree.delete(queueKey) - // Why: tail-guarded only; a mid-burst delete would strand the remaining writes of the burst without a floor. - lastPersistedByQueue.delete(queueKey) - lastMutationNextByQueue.delete(queueKey) - floorSeedEpochByQueue.delete(queueKey) - } - } - next.then(cleanup, cleanup) - return next -} - // Why: best-effort telemetry runs only after the note is on disk, so a throw here must not report a failed save. function recordReviewNoteInteraction(get: () => AppState): void { try { @@ -280,103 +65,6 @@ function recordReviewNoteInteraction(get: () => AppState): void { } } -// Why: derive the next list inside the `set` updater so concurrent writes can't clobber each other via a stale closure. -function mutateComments( - set: Parameters>[0], - worktreeId: string, - mutate: (existing: DiffComment[]) => DiffComment[] | null -): CommentMutation | null { - const repoId = getRepoIdFromWorktreeId(worktreeId) - let previous: DiffComment[] | undefined - let next: DiffComment[] | null = null - let folderExecutionHostId: ReturnType | undefined - set((s) => { - const scope = parseWorkspaceKey(worktreeId) - if (scope?.type === 'folder') { - const target = findFolderWorkspaceOwner(s, scope.folderWorkspaceId) - if (!target) { - return {} - } - folderExecutionHostId = getExecutionHostIdForFolderWorkspace(s, scope.folderWorkspaceId) - previous = target.diffComments - const computed = mutate(previous ?? []) - if (computed === null) { - return {} - } - next = computed - return { - folderWorkspaces: s.folderWorkspaces.map((workspace) => - workspace === target ? { ...workspace, diffComments: computed } : workspace - ) - } - } - const repoList = s.worktreesByRepo[repoId] - if (!repoList) { - return {} - } - const target = repoList.find((w) => w.id === worktreeId) - if (!target) { - return {} - } - previous = target.diffComments - const computed = mutate(previous ?? []) - if (computed === null) { - return {} - } - next = computed - const nextList: Worktree[] = repoList.map((w) => - w.id === worktreeId ? { ...w, diffComments: computed } : w - ) - return { worktreesByRepo: { ...s.worktreesByRepo, [repoId]: nextList } } - }) - if (next === null) { - return null - } - return { previous, next, folderExecutionHostId } -} - -// Why: on IPC-write failure, roll back optimistic state so the renderer matches disk (identity-guarded below). -function rollback( - set: Parameters>[0], - worktreeId: string, - previous: DiffComment[] | undefined, - expectedCurrent: DiffComment[], - folderExecutionHostId?: ReturnType -): void { - const repoId = getRepoIdFromWorktreeId(worktreeId) - set((s) => { - const scope = parseWorkspaceKey(worktreeId) - if (scope?.type === 'folder') { - const target = findFolderWorkspaceOwner(s, scope.folderWorkspaceId, folderExecutionHostId) - if (!target || target.diffComments !== expectedCurrent) { - return {} - } - return { - folderWorkspaces: s.folderWorkspaces.map((workspace) => - workspace === target ? { ...workspace, diffComments: previous } : workspace - ) - } - } - const repoList = s.worktreesByRepo[repoId] - if (!repoList) { - return {} - } - const target = repoList.find((w) => w.id === worktreeId) - // Why: worktree gone since the mutation; bail before remapping so we don't allocate a new array identity and fire spurious notifications. - if (!target) { - return {} - } - // Why: only roll back if no later mutation replaced the array, else our stale `previous` would erase newer state. - if (target.diffComments !== expectedCurrent) { - return {} - } - const nextList: Worktree[] = repoList.map((w) => - w.id === worktreeId ? { ...w, diffComments: previous } : w - ) - return { worktreesByRepo: { ...s.worktreesByRepo, [repoId]: nextList } } - }) -} - export const createDiffCommentsSlice: StateCreator = ( set, get @@ -404,13 +92,13 @@ export const createDiffCommentsSlice: StateCreator [...existing, comment]) + const result = mutateDiffComments(set, input.worktreeId, (existing) => [...existing, comment]) if (!result) { return null } try { // Why: serialize through the per-worktree queue so concurrent writes can't land on disk out of call order. - await enqueuePersist(set, input.worktreeId, get, result) + await enqueueDiffCommentPersist(set, input.worktreeId, get, result) } catch (err) { console.error('Failed to persist diff comments:', err) return null @@ -436,7 +124,7 @@ export const createDiffCommentsSlice: StateCreator { + const result = mutateDiffComments(set, worktreeId, (current) => { const idx = current.findIndex((c) => c.id === commentId) if (idx === -1) { return null @@ -454,7 +142,7 @@ export const createDiffCommentsSlice: StateCreator [comment.id, comment])) - const result = mutateComments(set, worktreeId, (existing) => { + const result = mutateDiffComments(set, worktreeId, (existing) => { const next = existing.filter((comment) => { const snapshot = snapshotsById.get(comment.id) // Why: delivery is async; a note edited after its snapshot was sent is a fresh pending note that must stay visible. @@ -479,7 +167,7 @@ export const createDiffCommentsSlice: StateCreator { + const result = mutateDiffComments(set, worktreeId, (existing) => { let changed = false const next = existing.map((comment) => { if (!ids.has(comment.id) || comment.sentAt === sentAt) { @@ -508,7 +196,7 @@ export const createDiffCommentsSlice: StateCreator { - const result = mutateComments(set, worktreeId, (existing) => { + const result = mutateDiffComments(set, worktreeId, (existing) => { const next = existing.filter((c) => c.id !== commentId) return next.length === existing.length ? null : next }) @@ -527,21 +215,21 @@ export const createDiffCommentsSlice: StateCreator { - const result = mutateComments(set, worktreeId, (existing) => + const result = mutateDiffComments(set, worktreeId, (existing) => existing.length === 0 ? null : [] ) if (!result) { return true } try { - await enqueuePersist(set, worktreeId, get, result) + await enqueueDiffCommentPersist(set, worktreeId, get, result) return true } catch (err) { console.error('Failed to persist diff comments:', err) @@ -550,7 +238,7 @@ export const createDiffCommentsSlice: StateCreator { - const result = mutateComments(set, worktreeId, (existing) => { + const result = mutateDiffComments(set, worktreeId, (existing) => { const next = existing.filter((c) => c.filePath !== filePath) return next.length === existing.length ? null : next }) @@ -558,7 +246,7 @@ export const createDiffCommentsSlice: StateCreator ({ diff --git a/src/renderer/src/store/slices/hosted-review-cache-state.ts b/src/renderer/src/store/slices/hosted-review-cache-state.ts new file mode 100644 index 00000000000..1a5153c6021 --- /dev/null +++ b/src/renderer/src/store/slices/hosted-review-cache-state.ts @@ -0,0 +1,207 @@ +import type { + CreateHostedReviewInput, + CreateStackedHostedReviewInput, + HostedReviewCreationEligibility, + HostedReviewInfo +} from '../../../../shared/hosted-review' +import type { Repo } from '../../../../shared/repo-types' +import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' +import type { AppState } from '../types' + +export type HostedReviewCacheEntry = { + data: T | null + fetchedAt: number + linkedReviewHintKey?: string + branchLookupGitHubPRNumber?: number +} +export type HostedReviewCache = Record> + +export type HostedReviewFetchOptions = { + force?: boolean + repoId?: string + staleWhileRevalidate?: boolean + currentHeadOid?: string | null + /** + * Pass from surfaces that only render the selected worktree. The host re-checks + * that branch per minute and paces the O(N) card list far slower (#11532). + */ + active?: boolean +} +export type CreateHostedReviewStoreInput = CreateHostedReviewInput & { repoId?: string | null } +export type CreateStackedHostedReviewStoreInput = CreateStackedHostedReviewInput & { + repoId?: string | null +} + +const CACHE_TTL_MS = 60_000 +const HOSTED_REVIEW_CACHE_MAX = 500 +// Why: the runtime path is bounded by callRuntimeRpc's own timeout; the local +// Electron path had none, so a hung git/gh subprocess (e.g. a stalled Windows +// credential probe) could leave the Create PR header stuck in its "Checkingโ€ฆ" +// loading state forever. Mirror the runtime bound so a never-settling probe +// rejects and the UI can fall back to an actionable/retryable state. +const CREATION_ELIGIBILITY_TIMEOUT_MS = 30_000 + +export class HostedReviewCreationEligibilityTimeoutError extends Error { + constructor(timeoutMs: number) { + super(`Timed out checking pull request creation eligibility after ${timeoutMs}ms`) + this.name = 'HostedReviewCreationEligibilityTimeoutError' + } +} + +export function withCreationEligibilityTimeout( + promise: Promise, + timeoutMs = CREATION_ELIGIBILITY_TIMEOUT_MS +): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new HostedReviewCreationEligibilityTimeoutError(timeoutMs)) + }, timeoutMs) + promise.then( + (value) => { + clearTimeout(timer) + resolve(value) + }, + (error) => { + clearTimeout(timer) + reject(error) + } + ) + }) +} + +export function isFreshHostedReview( + entry: HostedReviewCacheEntry | undefined +): entry is HostedReviewCacheEntry { + return entry !== undefined && Date.now() - entry.fetchedAt < CACHE_TTL_MS +} + +export function findHostedReviewRepoByPath( + repos: readonly Repo[] | undefined, + repoPath: string, + repoId?: string | null +): Repo | undefined { + return repos?.find((candidate) => + repoId ? candidate.id === repoId : candidate.path === repoPath + ) +} + +export function shouldRefetchForLinkedHint( + cached: HostedReviewCacheEntry | undefined, + hintKey: string +): boolean { + return cached !== undefined && hintKey !== '' && (cached.linkedReviewHintKey ?? '') !== hintKey +} + +function isGitHubLinkedReviewHintKey(hintKey: string | undefined): boolean { + return hintKey?.split('|').some((key) => key.startsWith('github:')) ?? false +} + +export function shouldRefetchGitHubScopedResultForNoHint( + cached: HostedReviewCacheEntry | undefined, + hintKey: string +): boolean { + // Why: a GitHub-scoped result does not prove the branch's publishing remote + // has no GitLab/other review for neutral lookup. + return ( + cached !== undefined && + hintKey === '' && + isGitHubLinkedReviewHintKey(cached.linkedReviewHintKey) + ) +} + +export function canReuseInflightHint(inflightHintKey: string, nextHintKey: string): boolean { + return inflightHintKey === nextHintKey +} + +export function isStaleMergedGitHubReviewForHead( + cached: HostedReviewCacheEntry | undefined, + currentHeadOid: string | null | undefined +): boolean { + // Why: a merged GitHub PR is only shown when the worktree sits on its head + // or on a commit confirmed to be part of the PR. The cache key is + // branch-scoped, so a worktree that advanced off the merged line of work + // must not reuse (or, on failure, preserve) the now-stale merged review. + const head = typeof currentHeadOid === 'string' ? currentHeadOid.trim() : '' + if (head.length === 0) { + return false + } + const data = cached?.data + return ( + data?.provider === 'github' && + data.state === 'merged' && + typeof data.headSha === 'string' && + data.headSha.length > 0 && + data.headSha !== head && + data.confirmedContainedHeadOid !== head + ) +} + +export function hasNewerHostedReviewCacheEntry( + cache: HostedReviewCache, + cacheKey: string, + requestStartedAt: number, + requestStartedEntry: HostedReviewCacheEntry | undefined +): boolean { + // Why: GitHub refresh events can update this shared cache while a branch + // lookup is in flight; older lookups must not resurrect stale results. + const entry = cache[cacheKey] + return ( + entry !== undefined && + (entry.fetchedAt > requestStartedAt || + (entry.fetchedAt === requestStartedAt && entry !== requestStartedEntry)) + ) +} + +export function withHostedReviewCacheEntry( + cache: HostedReviewCache, + cacheKey: string, + entry: HostedReviewCacheEntry +): HostedReviewCache { + const next = { ...cache, [cacheKey]: entry } + const keys = Object.keys(next) + if (keys.length <= HOSTED_REVIEW_CACHE_MAX) { + return next + } + const keep = new Set( + keys + .map((key) => ({ key, fetchedAt: next[key].fetchedAt })) + .sort((a, b) => b.fetchedAt - a.fetchedAt) + .slice(0, HOSTED_REVIEW_CACHE_MAX) + .map((item) => item.key) + ) + const pruned: HostedReviewCache = {} + for (const key of keep) { + pruned[key] = next[key] + } + return pruned +} + +export function settingsForHostedReviewRepoOwner( + settings: AppState['settings'], + repo: Pick | undefined +): AppState['settings'] { + if (!repo) { + return settings + } + const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) + if (parsed?.kind === 'runtime') { + return settings + ? { ...settings, activeRuntimeEnvironmentId: parsed.environmentId } + : ({ activeRuntimeEnvironmentId: parsed.environmentId } as AppState['settings']) + } + // Why: local and SSH-owned reviews are served by the desktop client's local + // IPC path, even when the sidebar is focused on a runtime host. + return settings + ? { ...settings, activeRuntimeEnvironmentId: null } + : ({ activeRuntimeEnvironmentId: null } as AppState['settings']) +} + +export function settingsForHostedReviewActionOwner( + settings: AppState['settings'], + repo: Pick | undefined +): AppState['settings'] { + if (!repo?.executionHostId && !repo?.connectionId) { + return settings + } + return settingsForHostedReviewRepoOwner(settings, repo) +} diff --git a/src/renderer/src/store/slices/hosted-review-cache.test.ts b/src/renderer/src/store/slices/hosted-review-cache.test.ts index 163dfbd3332..473ac657441 100644 --- a/src/renderer/src/store/slices/hosted-review-cache.test.ts +++ b/src/renderer/src/store/slices/hosted-review-cache.test.ts @@ -1,12 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { create } from 'zustand' import type { AppState } from '../types' +import { createHostedReviewSlice } from './hosted-review' import { _clearHostedReviewRequestGenerationsForTest, - _getHostedReviewRequestGenerationCountForTest, - createHostedReviewSlice, - getHostedReviewCacheKey -} from './hosted-review' + _getHostedReviewRequestGenerationCountForTest +} from './hosted-review-request-state' +import { getHostedReviewCacheKey } from './hosted-review-cache-identity' import type { HostedReviewInfo } from '../../../../shared/hosted-review' const runtimeRpc = vi.hoisted(() => ({ diff --git a/src/renderer/src/store/slices/hosted-review-card-refresh.ts b/src/renderer/src/store/slices/hosted-review-card-refresh.ts new file mode 100644 index 00000000000..3ad33722f4a --- /dev/null +++ b/src/renderer/src/store/slices/hosted-review-card-refresh.ts @@ -0,0 +1,38 @@ +import type { HostedReviewInfo } from '../../../../shared/hosted-review' +import type { LinkedReviewHints } from './hosted-review-cache-identity' +import type { HostedReviewFetchOptions } from './hosted-review-cache-state' + +type FetchHostedReviewForBranch = ( + repoPath: string, + branch: string, + options?: HostedReviewFetchOptions & LinkedReviewHints +) => Promise + +type RefreshHostedReviewCardArgs = { + repoPath: string + repoId: string + branch: string + linkedGitHubPR?: number | null + fallbackGitHubPR?: number | null + linkedGitLabMR?: number | null + linkedBitbucketPR?: number | null + linkedAzureDevOpsPR?: number | null + linkedGiteaPR?: number | null +} + +export function refreshHostedReviewCard( + fetchHostedReviewForBranch: FetchHostedReviewForBranch, + args: RefreshHostedReviewCardArgs +): Promise { + const fallbackGitHubPR = args.linkedGitHubPR == null ? (args.fallbackGitHubPR ?? null) : null + return fetchHostedReviewForBranch(args.repoPath, args.branch, { + force: true, + repoId: args.repoId, + linkedGitHubPR: args.linkedGitHubPR ?? null, + ...(fallbackGitHubPR !== null ? { fallbackGitHubPR } : {}), + linkedGitLabMR: args.linkedGitLabMR ?? null, + linkedBitbucketPR: args.linkedBitbucketPR ?? null, + linkedAzureDevOpsPR: args.linkedAzureDevOpsPR ?? null, + linkedGiteaPR: args.linkedGiteaPR ?? null + }) +} diff --git a/src/renderer/src/store/slices/hosted-review-pr-cache.ts b/src/renderer/src/store/slices/hosted-review-pr-cache.ts new file mode 100644 index 00000000000..b58d3e01994 --- /dev/null +++ b/src/renderer/src/store/slices/hosted-review-pr-cache.ts @@ -0,0 +1,39 @@ +import type { HostedReviewInfo } from '../../../../shared/hosted-review' +import type { Repo } from '../../../../shared/repo-types' +import type { AppState } from '../types' +import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from './github-cache-key' + +export function clearHostedReviewConflictingPrCache(args: { + cache: NonNullable + review: HostedReviewInfo | null + repoPath: string + repoId: string | undefined + branch: string + settings: AppState['settings'] + repo: Repo | undefined +}): NonNullable { + if (!args.review || args.review.provider === 'github') { + return args.cache + } + const keys = [ + getGitHubPRCacheKey( + args.repoPath, + args.repoId, + args.branch, + args.settings, + args.repo?.connectionId, + args.repo?.executionHostId, + args.repo !== undefined + ), + getLegacyGitHubPRCacheKey(args.repoPath, args.repoId, args.branch), + getLegacyGitHubPRCacheKey(args.repoPath, undefined, args.branch) + ] + if (!keys.some((key) => args.cache[key])) { + return args.cache + } + const next = { ...args.cache } + for (const key of keys) { + delete next[key] + } + return next +} diff --git a/src/renderer/src/store/slices/hosted-review-request-state.ts b/src/renderer/src/store/slices/hosted-review-request-state.ts new file mode 100644 index 00000000000..5660bb6918c --- /dev/null +++ b/src/renderer/src/store/slices/hosted-review-request-state.ts @@ -0,0 +1,23 @@ +import type { HostedReviewInfo } from '../../../../shared/hosted-review' + +export const inflightHostedReviewRequests = new Map< + string, + { + promise: Promise + force: boolean + generation: number + linkedReviewHintKey: string + } +>() + +export const hostedReviewRequestGenerations = new Map() + +/** @internal - exposed for leak-regression tests only */ +export function _getHostedReviewRequestGenerationCountForTest(): number { + return hostedReviewRequestGenerations.size +} + +/** @internal - exposed for leak-regression tests only */ +export function _clearHostedReviewRequestGenerationsForTest(): void { + hostedReviewRequestGenerations.clear() +} diff --git a/src/renderer/src/store/slices/hosted-review.test.ts b/src/renderer/src/store/slices/hosted-review.test.ts index c438c2e8ebc..17d8cb20e2e 100644 --- a/src/renderer/src/store/slices/hosted-review.test.ts +++ b/src/renderer/src/store/slices/hosted-review.test.ts @@ -1,12 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { create } from 'zustand' import type { AppState } from '../types' -import { - createHostedReviewSlice, - getHostedReviewCacheKey, - HostedReviewCreationEligibilityTimeoutError, - refreshHostedReviewCard -} from './hosted-review' +import { createHostedReviewSlice } from './hosted-review' +import { refreshHostedReviewCard } from './hosted-review-card-refresh' +import { getHostedReviewCacheKey } from './hosted-review-cache-identity' +import { HostedReviewCreationEligibilityTimeoutError } from './hosted-review-cache-state' import type { HostedReviewInfo } from '../../../../shared/hosted-review' const runtimeRpc = vi.hoisted(() => ({ diff --git a/src/renderer/src/store/slices/hosted-review.ts b/src/renderer/src/store/slices/hosted-review.ts index 3c3940b6acc..8b26f2c9362 100644 --- a/src/renderer/src/store/slices/hosted-review.ts +++ b/src/renderer/src/store/slices/hosted-review.ts @@ -1,16 +1,11 @@ -/* eslint-disable max-lines -- Why: hosted-review cache identity, runtime dispatch, -and race protection are kept together so branch review lookup invariants stay testable. */ import type { StateCreator } from 'zustand' import type { - CreateHostedReviewInput, CreateHostedReviewResult, - CreateStackedHostedReviewInput, CreateStackedHostedReviewResult, HostedReviewCreationEligibility, HostedReviewCreationEligibilityArgs, HostedReviewInfo } from '../../../../shared/hosted-review' -import type { Repo } from '../../../../shared/repo-types' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import type { AppState } from '../types' import { @@ -18,228 +13,31 @@ import { linkedReviewHintKey, type LinkedReviewHints } from './hosted-review-cache-identity' -import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from './github-cache-key' -import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' - -export { getHostedReviewCacheKey, linkedReviewHintKey } from './hosted-review-cache-identity' - -type CacheEntry = { - data: T | null - fetchedAt: number - linkedReviewHintKey?: string - branchLookupGitHubPRNumber?: number -} -type FetchOptions = { - force?: boolean - repoId?: string - staleWhileRevalidate?: boolean - currentHeadOid?: string | null - /** - * Pass from surfaces that only render the selected worktree. The host re-checks - * that branch per minute and paces the O(N) card list far slower (#11532). - */ - active?: boolean -} -type CreateHostedReviewStoreInput = CreateHostedReviewInput & { repoId?: string | null } -type CreateStackedHostedReviewStoreInput = CreateStackedHostedReviewInput & { - repoId?: string | null -} - -const CACHE_TTL_MS = 60_000 -const HOSTED_REVIEW_CACHE_MAX = 500 -// Why: the runtime path is bounded by callRuntimeRpc's own timeout; the local -// Electron path had none, so a hung git/gh subprocess (e.g. a stalled Windows -// credential probe) could leave the Create PR header stuck in its "Checkingโ€ฆ" -// loading state forever. Mirror the runtime bound so a never-settling probe -// rejects and the UI can fall back to an actionable/retryable state. -const CREATION_ELIGIBILITY_TIMEOUT_MS = 30_000 - -export class HostedReviewCreationEligibilityTimeoutError extends Error { - constructor(timeoutMs: number) { - super(`Timed out checking pull request creation eligibility after ${timeoutMs}ms`) - this.name = 'HostedReviewCreationEligibilityTimeoutError' - } -} - -function withCreationEligibilityTimeout( - promise: Promise, - timeoutMs = CREATION_ELIGIBILITY_TIMEOUT_MS -): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject(new HostedReviewCreationEligibilityTimeoutError(timeoutMs)) - }, timeoutMs) - promise.then( - (value) => { - clearTimeout(timer) - resolve(value) - }, - (error) => { - clearTimeout(timer) - reject(error) - } - ) - }) -} - -const inflightHostedReviewRequests = new Map< - string, - { - promise: Promise - force: boolean - generation: number - linkedReviewHintKey: string - } ->() -const requestGenerations = new Map() - -/** @internal - exposed for leak-regression tests only */ -export function _getHostedReviewRequestGenerationCountForTest(): number { - return requestGenerations.size -} - -/** @internal - exposed for leak-regression tests only */ -export function _clearHostedReviewRequestGenerationsForTest(): void { - requestGenerations.clear() -} - -function isFresh(entry: CacheEntry | undefined): entry is CacheEntry { - return entry !== undefined && Date.now() - entry.fetchedAt < CACHE_TTL_MS -} - -function findHostedReviewRepoByPath( - repos: readonly Repo[] | undefined, - repoPath: string, - repoId?: string | null -): Repo | undefined { - return repos?.find((candidate) => - repoId ? candidate.id === repoId : candidate.path === repoPath - ) -} - -function shouldRefetchForLinkedHint( - cached: CacheEntry | undefined, - hintKey: string -): boolean { - return cached !== undefined && hintKey !== '' && (cached.linkedReviewHintKey ?? '') !== hintKey -} - -function isGitHubLinkedReviewHintKey(hintKey: string | undefined): boolean { - return hintKey?.split('|').some((key) => key.startsWith('github:')) ?? false -} - -function shouldRefetchGitHubScopedResultForNoHint( - cached: CacheEntry | undefined, - hintKey: string -): boolean { - // Why: a GitHub-scoped result does not prove the branch's publishing remote - // has no GitLab/other review for neutral lookup. - return ( - cached !== undefined && - hintKey === '' && - isGitHubLinkedReviewHintKey(cached.linkedReviewHintKey) - ) -} - -function canReuseInflightHint(inflightHintKey: string, nextHintKey: string): boolean { - return inflightHintKey === nextHintKey -} - -function isStaleMergedGitHubReviewForHead( - cached: CacheEntry | undefined, - currentHeadOid: string | null | undefined -): boolean { - // Why: a merged GitHub PR is only shown when the worktree sits on its head - // or on a commit confirmed to be part of the PR. The cache key is - // branch-scoped, so a worktree that advanced off the merged line of work - // must not reuse (or, on failure, preserve) the now-stale merged review. - const head = typeof currentHeadOid === 'string' ? currentHeadOid.trim() : '' - if (head.length === 0) { - return false - } - const data = cached?.data - return ( - data?.provider === 'github' && - data.state === 'merged' && - typeof data.headSha === 'string' && - data.headSha.length > 0 && - data.headSha !== head && - data.confirmedContainedHeadOid !== head - ) -} - -function hasNewerHostedReviewCacheEntry( - cache: HostedReviewSlice['hostedReviewCache'], - cacheKey: string, - requestStartedAt: number, - requestStartedEntry: CacheEntry | undefined -): boolean { - // Why: GitHub refresh events can update this shared cache while a branch - // lookup is in flight; older lookups must not resurrect stale results. - const entry = cache[cacheKey] - return ( - entry !== undefined && - (entry.fetchedAt > requestStartedAt || - (entry.fetchedAt === requestStartedAt && entry !== requestStartedEntry)) - ) -} - -function withHostedReviewCacheEntry( - cache: HostedReviewSlice['hostedReviewCache'], - cacheKey: string, - entry: CacheEntry -): HostedReviewSlice['hostedReviewCache'] { - const next = { ...cache, [cacheKey]: entry } - const keys = Object.keys(next) - if (keys.length <= HOSTED_REVIEW_CACHE_MAX) { - return next - } - const keep = new Set( - keys - .map((key) => ({ key, fetchedAt: next[key].fetchedAt })) - .sort((a, b) => b.fetchedAt - a.fetchedAt) - .slice(0, HOSTED_REVIEW_CACHE_MAX) - .map((item) => item.key) - ) - const pruned: HostedReviewSlice['hostedReviewCache'] = {} - for (const key of keep) { - pruned[key] = next[key] - } - return pruned -} - -function settingsForHostedReviewRepoOwner( - settings: AppState['settings'], - repo: Pick | undefined -): AppState['settings'] { - if (!repo) { - return settings - } - const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) - if (parsed?.kind === 'runtime') { - return settings - ? { ...settings, activeRuntimeEnvironmentId: parsed.environmentId } - : ({ activeRuntimeEnvironmentId: parsed.environmentId } as AppState['settings']) - } - // Why: local and SSH-owned reviews are served by the desktop client's local - // IPC path, even when the sidebar is focused on a runtime host. - return settings - ? { ...settings, activeRuntimeEnvironmentId: null } - : ({ activeRuntimeEnvironmentId: null } as AppState['settings']) -} - -function settingsForHostedReviewActionOwner( - settings: AppState['settings'], - repo: Pick | undefined -): AppState['settings'] { - if (!repo?.executionHostId && !repo?.connectionId) { - return settings - } - return settingsForHostedReviewRepoOwner(settings, repo) -} +import { + canReuseInflightHint, + findHostedReviewRepoByPath, + hasNewerHostedReviewCacheEntry, + isFreshHostedReview, + isStaleMergedGitHubReviewForHead, + settingsForHostedReviewActionOwner, + settingsForHostedReviewRepoOwner, + shouldRefetchForLinkedHint, + shouldRefetchGitHubScopedResultForNoHint, + withCreationEligibilityTimeout, + withHostedReviewCacheEntry, + type CreateHostedReviewStoreInput, + type CreateStackedHostedReviewStoreInput, + type HostedReviewCacheEntry, + type HostedReviewFetchOptions +} from './hosted-review-cache-state' +import { clearHostedReviewConflictingPrCache } from './hosted-review-pr-cache' +import { + hostedReviewRequestGenerations as requestGenerations, + inflightHostedReviewRequests +} from './hosted-review-request-state' export type HostedReviewSlice = { - hostedReviewCache: Record> + hostedReviewCache: Record> getHostedReviewCreationEligibility: ( args: HostedReviewCreationEligibilityArgs ) => Promise @@ -254,39 +52,10 @@ export type HostedReviewSlice = { fetchHostedReviewForBranch: ( repoPath: string, branch: string, - options?: FetchOptions & LinkedReviewHints + options?: HostedReviewFetchOptions & LinkedReviewHints ) => Promise } -type RefreshHostedReviewCardArgs = { - repoPath: string - repoId: string - branch: string - linkedGitHubPR?: number | null - fallbackGitHubPR?: number | null - linkedGitLabMR?: number | null - linkedBitbucketPR?: number | null - linkedAzureDevOpsPR?: number | null - linkedGiteaPR?: number | null -} - -export function refreshHostedReviewCard( - fetchHostedReviewForBranch: HostedReviewSlice['fetchHostedReviewForBranch'], - args: RefreshHostedReviewCardArgs -): Promise { - const fallbackGitHubPR = args.linkedGitHubPR == null ? (args.fallbackGitHubPR ?? null) : null - return fetchHostedReviewForBranch(args.repoPath, args.branch, { - force: true, - repoId: args.repoId, - linkedGitHubPR: args.linkedGitHubPR ?? null, - ...(fallbackGitHubPR !== null ? { fallbackGitHubPR } : {}), - linkedGitLabMR: args.linkedGitLabMR ?? null, - linkedBitbucketPR: args.linkedBitbucketPR ?? null, - linkedAzureDevOpsPR: args.linkedAzureDevOpsPR ?? null, - linkedGiteaPR: args.linkedGiteaPR ?? null - }) -} - export const createHostedReviewSlice: StateCreator = ( set, get @@ -406,7 +175,7 @@ export const createHostedReviewSlice: StateCreator currentPRCache[key]) - ? (() => { - const next = { ...currentPRCache } - for (const key of prCacheKeys) { - delete next[key] - } - return next - })() - : currentPRCache + const prCache = clearHostedReviewConflictingPrCache({ + cache: currentPRCache, + review, + repoPath, + repoId, + branch, + settings: ownerSettings, + repo + }) return { ...(prCache === currentPRCache ? {} : { prCache }), hostedReviewCache: withHostedReviewCacheEntry(state.hostedReviewCache, cacheKey, { diff --git a/src/renderer/src/store/slices/linear-credential-error-recovery.test.ts b/src/renderer/src/store/slices/linear-credential-error-recovery.test.ts index 64dac946c00..1644e42361a 100644 --- a/src/renderer/src/store/slices/linear-credential-error-recovery.test.ts +++ b/src/renderer/src/store/slices/linear-credential-error-recovery.test.ts @@ -25,16 +25,7 @@ vi.mock('@/runtime/runtime-linear-client', async (importOriginal) => { linearConnect: (...args: unknown[]) => linearConnect(...args), linearDisconnect: (...args: unknown[]) => linearDisconnect(...args), linearDisconnectWorkspace: vi.fn(), - linearGetCustomView: (...args: unknown[]) => linearGetCustomView(...args), - linearGetProject: (...args: unknown[]) => linearGetProject(...args), - linearGetIssue: (...args: unknown[]) => linearGetIssue(...args), - linearListCustomViewIssues: (...args: unknown[]) => linearListCustomViewIssues(...args), - linearListCustomViewProjects: (...args: unknown[]) => linearListCustomViewProjects(...args), - linearListCustomViews: (...args: unknown[]) => linearListCustomViews(...args), linearListIssues: (...args: unknown[]) => linearListIssues(...args), - linearListProjectIssues: (...args: unknown[]) => linearListProjectIssues(...args), - linearListProjects: (...args: unknown[]) => linearListProjects(...args), - linearListTeams: (...args: unknown[]) => linearListTeams(...args), linearSearchIssues: (...args: unknown[]) => linearSearchIssues(...args), linearSelectWorkspace: vi.fn(), linearStatus: (...args: unknown[]) => linearStatus(...args), @@ -42,6 +33,29 @@ vi.mock('@/runtime/runtime-linear-client', async (importOriginal) => { } }) +vi.mock('@/runtime/runtime-linear-issue-mutations', async (importOriginal) => { + const actual = await importOriginal>() + return { + ...actual, + linearGetIssue: (...args: unknown[]) => linearGetIssue(...args) + } +}) + +vi.mock('@/runtime/runtime-linear-project-client', async (importOriginal) => { + const actual = await importOriginal>() + return { + ...actual, + linearGetCustomView: (...args: unknown[]) => linearGetCustomView(...args), + linearGetProject: (...args: unknown[]) => linearGetProject(...args), + linearListCustomViewIssues: (...args: unknown[]) => linearListCustomViewIssues(...args), + linearListCustomViewProjects: (...args: unknown[]) => linearListCustomViewProjects(...args), + linearListCustomViews: (...args: unknown[]) => linearListCustomViews(...args), + linearListProjectIssues: (...args: unknown[]) => linearListProjectIssues(...args), + linearListProjects: (...args: unknown[]) => linearListProjects(...args), + linearListTeams: (...args: unknown[]) => linearListTeams(...args) + } +}) + vi.mock('../../hooks/useIssueMetadata', () => ({ clearLinearMetadataCache: vi.fn() })) diff --git a/src/renderer/src/store/slices/linear-invalidation.test.ts b/src/renderer/src/store/slices/linear-invalidation.test.ts index 03c917187fa..febf6bc222f 100644 --- a/src/renderer/src/store/slices/linear-invalidation.test.ts +++ b/src/renderer/src/store/slices/linear-invalidation.test.ts @@ -16,16 +16,29 @@ vi.mock('@/runtime/runtime-linear-client', () => ({ linearConnect: (...args: unknown[]) => linearConnect(...args), linearDisconnect: vi.fn(), linearDisconnectWorkspace: vi.fn(), - linearGetIssue: (...args: unknown[]) => linearGetIssue(...args), isLinearIssueAttributeFilterUnsupportedError: () => false, linearListIssues: (...args: unknown[]) => linearListIssues(...args), - linearListTeams: (...args: unknown[]) => linearListTeams(...args), linearSearchIssues: (...args: unknown[]) => linearSearchIssues(...args), linearSelectWorkspace: vi.fn(), linearStatus: (...args: unknown[]) => linearStatus(...args), linearTestConnection: vi.fn() })) +vi.mock('@/runtime/runtime-linear-issue-mutations', () => ({ + linearGetIssue: (...args: unknown[]) => linearGetIssue(...args) +})) + +vi.mock('@/runtime/runtime-linear-project-client', () => ({ + linearGetCustomView: vi.fn(), + linearGetProject: vi.fn(), + linearListCustomViewIssues: vi.fn(), + linearListCustomViewProjects: vi.fn(), + linearListCustomViews: vi.fn(), + linearListProjectIssues: vi.fn(), + linearListProjects: vi.fn(), + linearListTeams: (...args: unknown[]) => linearListTeams(...args) +})) + vi.mock('../../hooks/useIssueMetadata', () => ({ clearLinearMetadataCache: vi.fn() })) diff --git a/src/renderer/src/store/slices/linear-scoped-collection-cache.test.ts b/src/renderer/src/store/slices/linear-scoped-collection-cache.test.ts index 0f457817bd9..46bb849b2de 100644 --- a/src/renderer/src/store/slices/linear-scoped-collection-cache.test.ts +++ b/src/renderer/src/store/slices/linear-scoped-collection-cache.test.ts @@ -26,16 +26,7 @@ vi.mock('@/runtime/runtime-linear-client', async (importOriginal) => { linearConnect: (...args: unknown[]) => linearConnect(...args), linearDisconnect: (...args: unknown[]) => linearDisconnect(...args), linearDisconnectWorkspace: vi.fn(), - linearGetCustomView: (...args: unknown[]) => linearGetCustomView(...args), - linearGetProject: (...args: unknown[]) => linearGetProject(...args), - linearGetIssue: (...args: unknown[]) => linearGetIssue(...args), - linearListCustomViewIssues: (...args: unknown[]) => linearListCustomViewIssues(...args), - linearListCustomViewProjects: (...args: unknown[]) => linearListCustomViewProjects(...args), - linearListCustomViews: (...args: unknown[]) => linearListCustomViews(...args), linearListIssues: (...args: unknown[]) => linearListIssues(...args), - linearListProjectIssues: (...args: unknown[]) => linearListProjectIssues(...args), - linearListProjects: (...args: unknown[]) => linearListProjects(...args), - linearListTeams: (...args: unknown[]) => linearListTeams(...args), linearSearchIssues: (...args: unknown[]) => linearSearchIssues(...args), linearSelectWorkspace: vi.fn(), linearStatus: (...args: unknown[]) => linearStatus(...args), @@ -43,6 +34,29 @@ vi.mock('@/runtime/runtime-linear-client', async (importOriginal) => { } }) +vi.mock('@/runtime/runtime-linear-issue-mutations', async (importOriginal) => { + const actual = await importOriginal>() + return { + ...actual, + linearGetIssue: (...args: unknown[]) => linearGetIssue(...args) + } +}) + +vi.mock('@/runtime/runtime-linear-project-client', async (importOriginal) => { + const actual = await importOriginal>() + return { + ...actual, + linearGetCustomView: (...args: unknown[]) => linearGetCustomView(...args), + linearGetProject: (...args: unknown[]) => linearGetProject(...args), + linearListCustomViewIssues: (...args: unknown[]) => linearListCustomViewIssues(...args), + linearListCustomViewProjects: (...args: unknown[]) => linearListCustomViewProjects(...args), + linearListCustomViews: (...args: unknown[]) => linearListCustomViews(...args), + linearListProjectIssues: (...args: unknown[]) => linearListProjectIssues(...args), + linearListProjects: (...args: unknown[]) => linearListProjects(...args), + linearListTeams: (...args: unknown[]) => linearListTeams(...args) + } +}) + vi.mock('../../hooks/useIssueMetadata', () => ({ clearLinearMetadataCache: vi.fn() })) diff --git a/src/renderer/src/store/slices/linear-source-context-cache-scope.test.ts b/src/renderer/src/store/slices/linear-source-context-cache-scope.test.ts index e4f0f7c3de3..08ed1248b19 100644 --- a/src/renderer/src/store/slices/linear-source-context-cache-scope.test.ts +++ b/src/renderer/src/store/slices/linear-source-context-cache-scope.test.ts @@ -30,16 +30,7 @@ vi.mock('@/runtime/runtime-linear-client', async (importOriginal) => { linearConnect: (...args: unknown[]) => linearConnect(...args), linearDisconnect: (...args: unknown[]) => linearDisconnect(...args), linearDisconnectWorkspace: vi.fn(), - linearGetCustomView: (...args: unknown[]) => linearGetCustomView(...args), - linearGetProject: (...args: unknown[]) => linearGetProject(...args), - linearGetIssue: (...args: unknown[]) => linearGetIssue(...args), - linearListCustomViewIssues: (...args: unknown[]) => linearListCustomViewIssues(...args), - linearListCustomViewProjects: (...args: unknown[]) => linearListCustomViewProjects(...args), - linearListCustomViews: (...args: unknown[]) => linearListCustomViews(...args), linearListIssues: (...args: unknown[]) => linearListIssues(...args), - linearListProjectIssues: (...args: unknown[]) => linearListProjectIssues(...args), - linearListProjects: (...args: unknown[]) => linearListProjects(...args), - linearListTeams: (...args: unknown[]) => linearListTeams(...args), linearSearchIssues: (...args: unknown[]) => linearSearchIssues(...args), linearSelectWorkspace: vi.fn(), linearStatus: (...args: unknown[]) => linearStatus(...args), @@ -47,6 +38,29 @@ vi.mock('@/runtime/runtime-linear-client', async (importOriginal) => { } }) +vi.mock('@/runtime/runtime-linear-issue-mutations', async (importOriginal) => { + const actual = await importOriginal>() + return { + ...actual, + linearGetIssue: (...args: unknown[]) => linearGetIssue(...args) + } +}) + +vi.mock('@/runtime/runtime-linear-project-client', async (importOriginal) => { + const actual = await importOriginal>() + return { + ...actual, + linearGetCustomView: (...args: unknown[]) => linearGetCustomView(...args), + linearGetProject: (...args: unknown[]) => linearGetProject(...args), + linearListCustomViewIssues: (...args: unknown[]) => linearListCustomViewIssues(...args), + linearListCustomViewProjects: (...args: unknown[]) => linearListCustomViewProjects(...args), + linearListCustomViews: (...args: unknown[]) => linearListCustomViews(...args), + linearListProjectIssues: (...args: unknown[]) => linearListProjectIssues(...args), + linearListProjects: (...args: unknown[]) => linearListProjects(...args), + linearListTeams: (...args: unknown[]) => linearListTeams(...args) + } +}) + vi.mock('../../hooks/useIssueMetadata', () => ({ clearLinearMetadataCache: vi.fn() })) diff --git a/src/renderer/src/store/slices/linear.ts b/src/renderer/src/store/slices/linear.ts index 588c6845f3a..0f86363b6ef 100644 --- a/src/renderer/src/store/slices/linear.ts +++ b/src/renderer/src/store/slices/linear.ts @@ -28,21 +28,23 @@ import { linearConnect, linearDisconnect, linearDisconnectWorkspace, - linearGetCustomView, - linearGetProject, - linearGetIssue, - linearListCustomViewIssues, - linearListCustomViewProjects, - linearListCustomViews, linearListIssues, - linearListProjectIssues, - linearListProjects, - linearListTeams, linearSearchIssues, linearSelectWorkspace, linearStatus, linearTestConnection } from '@/runtime/runtime-linear-client' +import { linearGetIssue } from '@/runtime/runtime-linear-issue-mutations' +import { + linearGetCustomView, + linearGetProject, + linearListCustomViewIssues, + linearListCustomViewProjects, + linearListCustomViews, + linearListProjectIssues, + linearListProjects, + linearListTeams +} from '@/runtime/runtime-linear-project-client' import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' import { translate } from '@/i18n/i18n' import { diff --git a/src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts b/src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts index fb7b28c01e8..bdbfabf1373 100644 --- a/src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts +++ b/src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AppState } from '../types' import { toast } from 'sonner' import type { RuntimeEnvironmentCallRequest } from '../../runtime/runtime-compatibility-test-fixture' -import { getHostedReviewCacheKey } from './hosted-review' +import { getHostedReviewCacheKey } from './hosted-review-cache-identity' import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from './github-cache-key' import { makeWorktree } from './worktrees-slice-test-fixtures' import { diff --git a/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts b/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts index 3107d89ab15..a00f76b30a5 100644 --- a/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts +++ b/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts @@ -4,7 +4,7 @@ import { translate } from '@/i18n/i18n' import { isPositiveHostedReviewNumber } from '../../../../../../shared/hosted-review' import { parseWorkspaceKey } from '../../../../../../shared/workspace-scope' import { applyWorktreeUpdates, getRepoIdFromWorktreeId } from '../../worktree-helpers' -import { getHostedReviewCacheKey } from '../../hosted-review' +import { getHostedReviewCacheKey } from '../../hosted-review-cache-identity' import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from '../../github-cache-key' import { applyDetectedWorktreeUpdates, diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-unread-activity.ts b/src/renderer/src/store/slices/worktrees/session/worktree-unread-activity.ts index fa05be0e2b9..fb7e9d4b500 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-unread-activity.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-unread-activity.ts @@ -3,7 +3,7 @@ import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-sli import { parseWorkspaceKey } from '../../../../../../shared/workspace-scope' import { applyWorktreeUpdates, getRepoIdFromWorktreeId } from '../../worktree-helpers' import { branchName } from '@/lib/git-utils' -import { refreshHostedReviewCard } from '../../hosted-review' +import { refreshHostedReviewCard } from '../../hosted-review-card-refresh' import { applyDetectedWorktreeUpdates, findKnownWorktreeById diff --git a/src/shared/automation-cron-occurrence.ts b/src/shared/automation-cron-occurrence.ts new file mode 100644 index 00000000000..6ad88468e16 --- /dev/null +++ b/src/shared/automation-cron-occurrence.ts @@ -0,0 +1,48 @@ +import type { ParsedCron } from './automation-schedule-parsing' + +const DAY_MS = 24 * 60 * 60 * 1000 +const CRON_SCAN_DAYS = 9 * 366 + +export function startOfLocalDay(timestamp: number): number { + const date = new Date(timestamp) + date.setHours(0, 0, 0, 0) + return date.getTime() +} + +export function floorToMinute(timestamp: number): number { + const date = new Date(timestamp) + date.setSeconds(0, 0) + return date.getTime() +} + +export function cronMatches(rule: ParsedCron, timestamp: number): boolean { + if (!cronDateMatches(rule, timestamp)) { + return false + } + const date = new Date(timestamp) + return rule.hours.has(date.getHours()) && rule.minutes.has(date.getMinutes()) +} + +export function cronDateMatches(rule: ParsedCron, timestamp: number): boolean { + const date = new Date(timestamp) + if (!rule.months.has(date.getMonth() + 1)) { + return false + } + const dayOfMonthMatches = rule.daysOfMonth.has(date.getDate()) + const dayOfWeekMatches = rule.daysOfWeek.has(date.getDay()) + if (rule.dayOfMonthRestricted && rule.dayOfWeekRestricted) { + return dayOfMonthMatches || dayOfWeekMatches + } + return dayOfMonthMatches && dayOfWeekMatches +} + +export function cronHasPossibleOccurrence(rule: ParsedCron, anchor: number): boolean { + let day = startOfLocalDay(anchor) + for (let i = 0; i < CRON_SCAN_DAYS; i += 1) { + if (cronDateMatches(rule, day)) { + return true + } + day += DAY_MS + } + return false +} diff --git a/src/shared/automation-schedule-occurrences.ts b/src/shared/automation-schedule-occurrences.ts new file mode 100644 index 00000000000..57e67743d42 --- /dev/null +++ b/src/shared/automation-schedule-occurrences.ts @@ -0,0 +1,157 @@ +import type { AutomationSchedulePreset } from './automations-types' +import { parseSchedule, type ParsedRrule } from './automation-schedule-parsing' +import { cronMatches, floorToMinute, startOfLocalDay } from './automation-cron-occurrence' + +const DAY_MS = 24 * 60 * 60 * 1000 +const HOUR_MS = 60 * 60 * 1000 +const MINUTE_MS = 60 * 1000 +// Why: valid cron expressions like Feb 29 can have an 8-year gap across non-leap centuries. +const CRON_SCAN_DAYS = 9 * 366 +const CRON_SCAN_MINUTES = CRON_SCAN_DAYS * 24 * 60 +const DAY_CODES = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'] as const +function atLocalTime(dayMs: number, hour: number, minute: number): number { + const date = new Date(dayMs) + date.setHours(hour, minute, 0, 0) + return date.getTime() +} + +function dayMatches(rule: ParsedRrule, timestamp: number): boolean { + if (rule.freq === 'DAILY') { + return true + } + const code = DAY_CODES[new Date(timestamp).getDay()] + return rule.byDay.includes(code) +} + +function scanDayCandidates(rule: ParsedRrule, anchor: number, direction: 1 | -1): number | null { + let day = startOfLocalDay(anchor) + for (let i = 0; i < 370; i += 1) { + const candidate = atLocalTime(day, rule.byHour, rule.byMinute) + if (dayMatches(rule, candidate)) { + if (direction === 1 && candidate > anchor) { + return candidate + } + if (direction === -1 && candidate <= anchor) { + return candidate + } + } + day += direction * DAY_MS + } + return null +} + +export function buildAutomationRrule(args: { + preset: Exclude + hour: number + minute: number + dayOfWeek?: number +}): string { + const hour = Math.max(0, Math.min(23, Math.floor(args.hour))) + const minute = Math.max(0, Math.min(59, Math.floor(args.minute))) + if (args.preset === 'hourly') { + return `FREQ=HOURLY;BYMINUTE=${minute}` + } + if (args.preset === 'weekdays') { + return `FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYHOUR=${hour};BYMINUTE=${minute}` + } + if (args.preset === 'weekly') { + const day = DAY_CODES[Math.max(0, Math.min(6, Math.floor(args.dayOfWeek ?? 1)))] + return `FREQ=WEEKLY;BYDAY=${day};BYHOUR=${hour};BYMINUTE=${minute}` + } + return `FREQ=DAILY;BYHOUR=${hour};BYMINUTE=${minute}` +} + +export function buildAutomationCronSchedule(args: { + preset: Exclude + hour: number + minute: number + dayOfWeek?: number +}): string { + const hour = Math.max(0, Math.min(23, Math.floor(args.hour))) + const minute = Math.max(0, Math.min(59, Math.floor(args.minute))) + if (args.preset === 'hourly') { + return `${minute} * * * *` + } + if (args.preset === 'weekdays') { + return `${minute} ${hour} * * 1-5` + } + if (args.preset === 'weekly') { + const day = Math.max(0, Math.min(6, Math.floor(args.dayOfWeek ?? 1))) + return `${minute} ${hour} * * ${day}` + } + return `${minute} ${hour} * * *` +} + +export function nextAutomationOccurrenceAfter( + rrule: string, + dtstart: number, + after: number +): number { + const rule = parseSchedule(rrule) + if (rule.kind === 'cron') { + let candidate = floorToMinute(Math.max(dtstart, after)) + if (candidate <= after) { + candidate += MINUTE_MS + } + if (candidate < dtstart) { + candidate = floorToMinute(dtstart) + if (candidate < dtstart) { + candidate += MINUTE_MS + } + } + for (let i = 0; i < CRON_SCAN_MINUTES; i += 1) { + if (cronMatches(rule, candidate)) { + return candidate + } + candidate += MINUTE_MS + } + throw new Error('Unable to compute next automation run.') + } + if (rule.freq === 'HOURLY') { + const start = Math.max(dtstart, after) + const base = new Date(start) + base.setMinutes(rule.byMinute, 0, 0) + let candidate = base.getTime() + if (candidate <= after || candidate < dtstart) { + candidate += HOUR_MS + } + return candidate + } + const candidate = scanDayCandidates(rule, Math.max(dtstart - 1, after), 1) + if (candidate === null) { + throw new Error('Unable to compute next automation run.') + } + return candidate +} + +export function latestAutomationOccurrenceAtOrBefore( + rrule: string, + dtstart: number, + now: number +): number | null { + if (now < dtstart) { + return null + } + const rule = parseSchedule(rrule) + if (rule.kind === 'cron') { + let candidate = floorToMinute(now) + for (let i = 0; i < CRON_SCAN_MINUTES && candidate >= dtstart; i += 1) { + if (cronMatches(rule, candidate)) { + return candidate + } + candidate -= MINUTE_MS + } + return null + } + if (rule.freq === 'HOURLY') { + const base = new Date(now) + base.setMinutes(rule.byMinute, 0, 0) + let candidate = base.getTime() + if (candidate > now) { + candidate -= HOUR_MS + } + return candidate >= dtstart ? candidate : null + } + const candidate = scanDayCandidates(rule, now, -1) + return candidate !== null && candidate >= dtstart ? candidate : null +} diff --git a/src/shared/automation-schedule-parsing.ts b/src/shared/automation-schedule-parsing.ts new file mode 100644 index 00000000000..746bb434de2 --- /dev/null +++ b/src/shared/automation-schedule-parsing.ts @@ -0,0 +1,317 @@ +import type { AutomationSchedulePreset } from './automations-types' +import { cronHasPossibleOccurrence } from './automation-cron-occurrence' + +import { isClipboardTextByteLengthOverLimit } from './clipboard-text' + +export const AUTOMATION_CRON_EXPRESSION_MAX_BYTES = 2 * 1024 +export type ParsedRrule = { + kind: 'rrule' + freq: 'HOURLY' | 'DAILY' | 'WEEKLY' + byDay: string[] + byHour: number + byMinute: number +} + +export type ParsedCron = { + kind: 'cron' + minutes: Set + hours: Set + daysOfMonth: Set + months: Set + daysOfWeek: Set + dayOfMonthRestricted: boolean + dayOfWeekRestricted: boolean +} + +export type ParsedSchedule = ParsedRrule | ParsedCron +const DAY_CODES = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'] as const +const WEEKDAY_CODES = ['MO', 'TU', 'WE', 'TH', 'FR'] as const +const MONTH_NAMES: Record = { + JAN: 1, + FEB: 2, + MAR: 3, + APR: 4, + MAY: 5, + JUN: 6, + JUL: 7, + AUG: 8, + SEP: 9, + OCT: 10, + NOV: 11, + DEC: 12 +} +const DAY_NAMES: Record = { + SU: 0, + MO: 1, + TU: 2, + WE: 3, + TH: 4, + FR: 5, + SA: 6, + SUN: 0, + MON: 1, + TUE: 2, + WED: 3, + THU: 4, + FRI: 5, + SAT: 6 +} + +function parseRrule(rrule: string): ParsedRrule { + const entries = new Map() + for (const part of rrule.split(';')) { + const [key, value] = part.split('=') + if (key && value) { + entries.set(key.toUpperCase(), value) + } + } + const freq = entries.get('FREQ') + if (freq !== 'HOURLY' && freq !== 'DAILY' && freq !== 'WEEKLY') { + throw new Error('Unsupported automation recurrence.') + } + const byHour = Number(entries.get('BYHOUR') ?? '9') + const byMinute = Number(entries.get('BYMINUTE') ?? '0') + if (!Number.isInteger(byHour) || byHour < 0 || byHour > 23) { + throw new Error('Invalid recurrence hour.') + } + if (!Number.isInteger(byMinute) || byMinute < 0 || byMinute > 59) { + throw new Error('Invalid recurrence minute.') + } + const byDay = (entries.get('BYDAY') ?? '').split(',').filter(Boolean) + if ( + freq === 'WEEKLY' && + (byDay.length === 0 || + byDay.some((day) => !DAY_CODES.includes(day as (typeof DAY_CODES)[number]))) + ) { + throw new Error('Invalid recurrence day.') + } + return { kind: 'rrule', freq, byDay, byHour, byMinute } +} + +function parseCronNumber( + value: string, + names: Record | null, + field: string +): number { + const normalized = value.toUpperCase() + const named = names?.[normalized] + const parsed = named ?? Number(normalized) + if (!Number.isInteger(parsed)) { + throw new Error(`Invalid cron ${field}.`) + } + return parsed +} + +function parseCronField(args: { + value: string + min: number + max: number + field: string + names?: Record + normalize?: (value: number) => number +}): Set { + const result = new Set() + for (const rawPart of args.value.split(',')) { + const part = rawPart.trim() + if (!part) { + throw new Error(`Invalid cron ${args.field}.`) + } + const stepParts = part.split('/') + if (stepParts.length > 2) { + throw new Error(`Invalid cron ${args.field}.`) + } + const [rangePart, stepPart] = stepParts + if (!rangePart) { + throw new Error(`Invalid cron ${args.field}.`) + } + const step = stepPart === undefined ? 1 : Number(stepPart) + if (!Number.isInteger(step) || step < 1) { + throw new Error(`Invalid cron ${args.field}.`) + } + + let start: number + let end: number + if (rangePart === '*') { + start = args.min + end = args.max + } else if (rangePart.includes('-')) { + const rangeParts = rangePart.split('-') + if (rangeParts.length !== 2 || !rangeParts[0] || !rangeParts[1]) { + throw new Error(`Invalid cron ${args.field}.`) + } + const [startPart, endPart] = rangeParts + start = parseCronNumber(startPart, args.names ?? null, args.field) + end = parseCronNumber(endPart, args.names ?? null, args.field) + } else { + start = parseCronNumber(rangePart, args.names ?? null, args.field) + end = start + } + + const normalizedStart = args.normalize?.(start) ?? start + const normalizedEnd = args.normalize?.(end) ?? end + if ( + start < args.min || + start > args.max || + end < args.min || + end > args.max || + normalizedStart < args.min || + normalizedStart > args.max || + normalizedEnd < args.min || + normalizedEnd > args.max || + start > end + ) { + throw new Error(`Invalid cron ${args.field}.`) + } + for (let value = start; value <= end; value += step) { + result.add(args.normalize?.(value) ?? value) + } + } + if (result.size === 0) { + throw new Error(`Invalid cron ${args.field}.`) + } + return result +} + +export function parseCronExpression(expression: string): ParsedCron { + const parts = getAutomationCronExpressionFields(expression, 6) + if (parts.length !== 5) { + throw new Error('Cron schedule must have five fields.') + } + const [minute, hour, dayOfMonth, month, dayOfWeek] = parts + const daysOfMonth = parseCronField({ + value: dayOfMonth, + min: 1, + max: 31, + field: 'day of month' + }) + const daysOfWeek = parseCronField({ + value: dayOfWeek, + min: 0, + max: 7, + field: 'day of week', + names: DAY_NAMES, + normalize: (value) => (value === 7 ? 0 : value) + }) + return { + kind: 'cron', + minutes: parseCronField({ value: minute, min: 0, max: 59, field: 'minute' }), + hours: parseCronField({ value: hour, min: 0, max: 23, field: 'hour' }), + daysOfMonth, + months: parseCronField({ value: month, min: 1, max: 12, field: 'month', names: MONTH_NAMES }), + daysOfWeek, + dayOfMonthRestricted: daysOfMonth.size !== 31, + dayOfWeekRestricted: daysOfWeek.size !== 7 + } +} + +export function getAutomationCronExpressionFields(expression: string, maxFields = 5): string[] { + if (isClipboardTextByteLengthOverLimit(expression, AUTOMATION_CRON_EXPRESSION_MAX_BYTES)) { + return [] + } + const fields: string[] = [] + let tokenStart = -1 + for (let index = 0; index <= expression.length; index += 1) { + const isEnd = index === expression.length + if (!isEnd && !isAutomationCronFieldWhitespace(expression.charCodeAt(index))) { + if (tokenStart === -1) { + tokenStart = index + } + continue + } + if (tokenStart !== -1) { + fields.push(expression.slice(tokenStart, index)) + tokenStart = -1 + if (fields.length >= maxFields) { + break + } + } + } + return fields +} + +function isAutomationCronFieldWhitespace(code: number): boolean { + return ( + code === 32 || + (code >= 9 && code <= 13) || + code === 160 || + code === 5760 || + (code >= 8192 && code <= 8202) || + code === 8232 || + code === 8233 || + code === 8239 || + code === 8287 || + code === 12288 || + code === 65279 + ) +} + +export function parseSchedule(schedule: string): ParsedSchedule { + const trimmed = schedule.trim() + if (trimmed.includes('=')) { + return parseRrule(trimmed) + } + return parseCronExpression(trimmed) +} + +export function isValidAutomationSchedule(schedule: string): boolean { + try { + const parsed = parseSchedule(schedule) + if (parsed.kind === 'cron' && !cronHasPossibleOccurrence(parsed, Date.now())) { + throw new Error('Cron schedule has no possible run.') + } + return true + } catch { + return false + } +} + +export function isValidAutomationCronSchedule(schedule: string): boolean { + try { + const parsed = parseCronExpression(schedule.trim()) + return cronHasPossibleOccurrence(parsed, Date.now()) + } catch { + return false + } +} + +export function parseAutomationRrule(rrule: string): { + preset: AutomationSchedulePreset + hour: number + minute: number + dayOfWeek: number +} { + const rule = parseRrule(rrule) + if (rule.freq === 'HOURLY') { + return { preset: 'hourly', hour: rule.byHour, minute: rule.byMinute, dayOfWeek: 1 } + } + if (rule.freq === 'DAILY') { + return { preset: 'daily', hour: rule.byHour, minute: rule.byMinute, dayOfWeek: 1 } + } + if (rule.byDay.join(',') === WEEKDAY_CODES.join(',')) { + return { preset: 'weekdays', hour: rule.byHour, minute: rule.byMinute, dayOfWeek: 1 } + } + if (rule.byDay.length !== 1) { + throw new Error('Invalid recurrence day.') + } + const dayCode = rule.byDay[0] + const dayOfWeek = DAY_CODES.indexOf(dayCode as (typeof DAY_CODES)[number]) + if (dayOfWeek === -1) { + throw new Error('Invalid recurrence day.') + } + return { + preset: 'weekly', + hour: rule.byHour, + minute: rule.byMinute, + dayOfWeek + } +} + +export function tryParseAutomationRrule( + rrule: string +): ReturnType | null { + try { + return parseAutomationRrule(rrule) + } catch { + return null + } +} diff --git a/src/shared/automation-schedules.test.ts b/src/shared/automation-schedules.test.ts index 37f051366c1..7005b7d59e0 100644 --- a/src/shared/automation-schedules.test.ts +++ b/src/shared/automation-schedules.test.ts @@ -1,19 +1,23 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { - AUTOMATION_CRON_EXPRESSION_MAX_BYTES, - buildAutomationCronSchedule, - buildAutomationRrule, classifyAutomationCronSchedule, describeAutomationSchedule, - formatAutomationSchedule, + formatAutomationSchedule +} from './automation-schedules' +import { + buildAutomationCronSchedule, + buildAutomationRrule, + latestAutomationOccurrenceAtOrBefore, + nextAutomationOccurrenceAfter +} from './automation-schedule-occurrences' +import { + AUTOMATION_CRON_EXPRESSION_MAX_BYTES, getAutomationCronExpressionFields, isValidAutomationCronSchedule, isValidAutomationSchedule, - latestAutomationOccurrenceAtOrBefore, - nextAutomationOccurrenceAfter, parseAutomationRrule, tryParseAutomationRrule -} from './automation-schedules' +} from './automation-schedule-parsing' function formatTimeForTest(hour: number, minute: number): string { const date = new Date() diff --git a/src/shared/automation-schedules.ts b/src/shared/automation-schedules.ts index c53c11c7148..6d5a0053595 100644 --- a/src/shared/automation-schedules.ts +++ b/src/shared/automation-schedules.ts @@ -1,36 +1,10 @@ -/* eslint-disable max-lines -- Why: automation scheduling needs RRULE presets and - * custom cron parsing to share one execution path for main/renderer parity. */ -import type { AutomationSchedulePreset } from './automations-types' -import { isClipboardTextByteLengthOverLimit } from './clipboard-text' - -const DAY_MS = 24 * 60 * 60 * 1000 -const HOUR_MS = 60 * 60 * 1000 -const MINUTE_MS = 60 * 1000 -// Why: valid cron expressions like Feb 29 can have an 8-year gap across non-leap centuries. -const CRON_SCAN_DAYS = 9 * 366 -const CRON_SCAN_MINUTES = CRON_SCAN_DAYS * 24 * 60 -export const AUTOMATION_CRON_EXPRESSION_MAX_BYTES = 2 * 1024 - -type ParsedRrule = { - kind: 'rrule' - freq: 'HOURLY' | 'DAILY' | 'WEEKLY' - byDay: string[] - byHour: number - byMinute: number -} - -type ParsedCron = { - kind: 'cron' - minutes: Set - hours: Set - daysOfMonth: Set - months: Set - daysOfWeek: Set - dayOfMonthRestricted: boolean - dayOfWeekRestricted: boolean -} - -type ParsedSchedule = ParsedRrule | ParsedCron +import { + parseAutomationRrule, + parseCronExpression, + parseSchedule, + type ParsedCron +} from './automation-schedule-parsing' +import { cronHasPossibleOccurrence } from './automation-cron-occurrence' export type AutomationCronScheduleClassification = | { kind: 'hourly'; minute: number; label: string } @@ -49,8 +23,6 @@ export type AutomationScheduleDescriptor = | { kind: 'custom' } | { kind: 'invalid' } -const DAY_CODES = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'] as const -const WEEKDAY_CODES = ['MO', 'TU', 'WE', 'TH', 'FR'] as const // Why: shared labels feed the CLI, which must stay English regardless of OS or UI locale. const EN_DAY_NAMES = [ 'Sunday', @@ -61,285 +33,6 @@ const EN_DAY_NAMES = [ 'Friday', 'Saturday' ] as const -const MONTH_NAMES = new Map([ - ['JAN', 1], - ['FEB', 2], - ['MAR', 3], - ['APR', 4], - ['MAY', 5], - ['JUN', 6], - ['JUL', 7], - ['AUG', 8], - ['SEP', 9], - ['OCT', 10], - ['NOV', 11], - ['DEC', 12] -]) -const DAY_NAMES = new Map([ - ...DAY_CODES.map((code, index) => [code, index] as const), - ['SUN', 0], - ['MON', 1], - ['TUE', 2], - ['WED', 3], - ['THU', 4], - ['FRI', 5], - ['SAT', 6] -]) - -function parseRrule(rrule: string): ParsedRrule { - const entries = new Map() - for (const part of rrule.split(';')) { - const [key, value] = part.split('=') - if (key && value) { - entries.set(key.toUpperCase(), value) - } - } - const freq = entries.get('FREQ') - if (freq !== 'HOURLY' && freq !== 'DAILY' && freq !== 'WEEKLY') { - throw new Error('Unsupported automation recurrence.') - } - const byHour = Number(entries.get('BYHOUR') ?? '9') - const byMinute = Number(entries.get('BYMINUTE') ?? '0') - if (!Number.isInteger(byHour) || byHour < 0 || byHour > 23) { - throw new Error('Invalid recurrence hour.') - } - if (!Number.isInteger(byMinute) || byMinute < 0 || byMinute > 59) { - throw new Error('Invalid recurrence minute.') - } - const byDay = (entries.get('BYDAY') ?? '').split(',').filter(Boolean) - if ( - freq === 'WEEKLY' && - (byDay.length === 0 || - byDay.some((day) => !DAY_CODES.includes(day as (typeof DAY_CODES)[number]))) - ) { - throw new Error('Invalid recurrence day.') - } - return { kind: 'rrule', freq, byDay, byHour, byMinute } -} - -function parseCronNumber(value: string, names: Map | null, field: string): number { - const normalized = value.toUpperCase() - const named = names?.get(normalized) - const parsed = named ?? Number(normalized) - if (!Number.isInteger(parsed)) { - throw new Error(`Invalid cron ${field}.`) - } - return parsed -} - -function parseCronField(args: { - value: string - min: number - max: number - field: string - names?: Map - normalize?: (value: number) => number -}): Set { - const result = new Set() - for (const rawPart of args.value.split(',')) { - const part = rawPart.trim() - if (!part) { - throw new Error(`Invalid cron ${args.field}.`) - } - const stepParts = part.split('/') - if (stepParts.length > 2) { - throw new Error(`Invalid cron ${args.field}.`) - } - const [rangePart, stepPart] = stepParts - if (!rangePart) { - throw new Error(`Invalid cron ${args.field}.`) - } - const step = stepPart === undefined ? 1 : Number(stepPart) - if (!Number.isInteger(step) || step < 1) { - throw new Error(`Invalid cron ${args.field}.`) - } - - let start: number - let end: number - if (rangePart === '*') { - start = args.min - end = args.max - } else if (rangePart.includes('-')) { - const rangeParts = rangePart.split('-') - if (rangeParts.length !== 2 || !rangeParts[0] || !rangeParts[1]) { - throw new Error(`Invalid cron ${args.field}.`) - } - const [startPart, endPart] = rangeParts - start = parseCronNumber(startPart, args.names ?? null, args.field) - end = parseCronNumber(endPart, args.names ?? null, args.field) - } else { - start = parseCronNumber(rangePart, args.names ?? null, args.field) - end = start - } - - const normalizedStart = args.normalize?.(start) ?? start - const normalizedEnd = args.normalize?.(end) ?? end - if ( - start < args.min || - start > args.max || - end < args.min || - end > args.max || - normalizedStart < args.min || - normalizedStart > args.max || - normalizedEnd < args.min || - normalizedEnd > args.max || - start > end - ) { - throw new Error(`Invalid cron ${args.field}.`) - } - for (let value = start; value <= end; value += step) { - result.add(args.normalize?.(value) ?? value) - } - } - if (result.size === 0) { - throw new Error(`Invalid cron ${args.field}.`) - } - return result -} - -function parseCronExpression(expression: string): ParsedCron { - const parts = getAutomationCronExpressionFields(expression, 6) - if (parts.length !== 5) { - throw new Error('Cron schedule must have five fields.') - } - const [minute, hour, dayOfMonth, month, dayOfWeek] = parts - const daysOfMonth = parseCronField({ - value: dayOfMonth, - min: 1, - max: 31, - field: 'day of month' - }) - const daysOfWeek = parseCronField({ - value: dayOfWeek, - min: 0, - max: 7, - field: 'day of week', - names: DAY_NAMES, - normalize: (value) => (value === 7 ? 0 : value) - }) - return { - kind: 'cron', - minutes: parseCronField({ value: minute, min: 0, max: 59, field: 'minute' }), - hours: parseCronField({ value: hour, min: 0, max: 23, field: 'hour' }), - daysOfMonth, - months: parseCronField({ value: month, min: 1, max: 12, field: 'month', names: MONTH_NAMES }), - daysOfWeek, - dayOfMonthRestricted: daysOfMonth.size !== 31, - dayOfWeekRestricted: daysOfWeek.size !== 7 - } -} - -export function getAutomationCronExpressionFields(expression: string, maxFields = 5): string[] { - if (isClipboardTextByteLengthOverLimit(expression, AUTOMATION_CRON_EXPRESSION_MAX_BYTES)) { - return [] - } - const fields: string[] = [] - let tokenStart = -1 - for (let index = 0; index <= expression.length; index += 1) { - const isEnd = index === expression.length - if (!isEnd && !isAutomationCronFieldWhitespace(expression.charCodeAt(index))) { - if (tokenStart === -1) { - tokenStart = index - } - continue - } - if (tokenStart !== -1) { - fields.push(expression.slice(tokenStart, index)) - tokenStart = -1 - if (fields.length >= maxFields) { - break - } - } - } - return fields -} - -function isAutomationCronFieldWhitespace(code: number): boolean { - return ( - code === 32 || - (code >= 9 && code <= 13) || - code === 160 || - code === 5760 || - (code >= 8192 && code <= 8202) || - code === 8232 || - code === 8233 || - code === 8239 || - code === 8287 || - code === 12288 || - code === 65279 - ) -} - -function parseSchedule(schedule: string): ParsedSchedule { - const trimmed = schedule.trim() - if (trimmed.includes('=')) { - return parseRrule(trimmed) - } - return parseCronExpression(trimmed) -} - -export function isValidAutomationSchedule(schedule: string): boolean { - try { - const parsed = parseSchedule(schedule) - if (parsed.kind === 'cron' && !cronHasPossibleOccurrence(parsed, Date.now())) { - throw new Error('Cron schedule has no possible run.') - } - return true - } catch { - return false - } -} - -export function isValidAutomationCronSchedule(schedule: string): boolean { - try { - const parsed = parseCronExpression(schedule.trim()) - return cronHasPossibleOccurrence(parsed, Date.now()) - } catch { - return false - } -} - -export function parseAutomationRrule(rrule: string): { - preset: AutomationSchedulePreset - hour: number - minute: number - dayOfWeek: number -} { - const rule = parseRrule(rrule) - if (rule.freq === 'HOURLY') { - return { preset: 'hourly', hour: rule.byHour, minute: rule.byMinute, dayOfWeek: 1 } - } - if (rule.freq === 'DAILY') { - return { preset: 'daily', hour: rule.byHour, minute: rule.byMinute, dayOfWeek: 1 } - } - if (rule.byDay.join(',') === WEEKDAY_CODES.join(',')) { - return { preset: 'weekdays', hour: rule.byHour, minute: rule.byMinute, dayOfWeek: 1 } - } - if (rule.byDay.length !== 1) { - throw new Error('Invalid recurrence day.') - } - const dayCode = rule.byDay[0] - const dayOfWeek = DAY_CODES.indexOf(dayCode as (typeof DAY_CODES)[number]) - if (dayOfWeek === -1) { - throw new Error('Invalid recurrence day.') - } - return { - preset: 'weekly', - hour: rule.byHour, - minute: rule.byMinute, - dayOfWeek - } -} - -export function tryParseAutomationRrule( - rrule: string -): ReturnType | null { - try { - return parseAutomationRrule(rrule) - } catch { - return null - } -} /** Clock-time portion of a schedule label; numeric shape follows the OS region by convention. */ export function formatAutomationScheduleTime(hour: number, minute: number): string { @@ -502,194 +195,3 @@ export function describeAutomationSchedule( return { kind: 'invalid' } } } - -function atLocalTime(dayMs: number, hour: number, minute: number): number { - const date = new Date(dayMs) - date.setHours(hour, minute, 0, 0) - return date.getTime() -} - -function startOfLocalDay(timestamp: number): number { - const date = new Date(timestamp) - date.setHours(0, 0, 0, 0) - return date.getTime() -} - -function dayMatches(rule: ParsedRrule, timestamp: number): boolean { - if (rule.freq === 'DAILY') { - return true - } - const code = DAY_CODES[new Date(timestamp).getDay()] - return rule.byDay.includes(code) -} - -function scanDayCandidates(rule: ParsedRrule, anchor: number, direction: 1 | -1): number | null { - let day = startOfLocalDay(anchor) - for (let i = 0; i < 370; i += 1) { - const candidate = atLocalTime(day, rule.byHour, rule.byMinute) - if (dayMatches(rule, candidate)) { - if (direction === 1 && candidate > anchor) { - return candidate - } - if (direction === -1 && candidate <= anchor) { - return candidate - } - } - day += direction * DAY_MS - } - return null -} - -function floorToMinute(timestamp: number): number { - const date = new Date(timestamp) - date.setSeconds(0, 0) - return date.getTime() -} - -function cronMatches(rule: ParsedCron, timestamp: number): boolean { - if (!cronDateMatches(rule, timestamp)) { - return false - } - const date = new Date(timestamp) - return rule.hours.has(date.getHours()) && rule.minutes.has(date.getMinutes()) -} - -function cronDateMatches(rule: ParsedCron, timestamp: number): boolean { - const date = new Date(timestamp) - if (!rule.months.has(date.getMonth() + 1)) { - return false - } - const dayOfMonthMatches = rule.daysOfMonth.has(date.getDate()) - const dayOfWeekMatches = rule.daysOfWeek.has(date.getDay()) - if (rule.dayOfMonthRestricted && rule.dayOfWeekRestricted) { - return dayOfMonthMatches || dayOfWeekMatches - } - return dayOfMonthMatches && dayOfWeekMatches -} - -function cronHasPossibleOccurrence(rule: ParsedCron, anchor: number): boolean { - let day = startOfLocalDay(anchor) - for (let i = 0; i < CRON_SCAN_DAYS; i += 1) { - if (cronDateMatches(rule, day)) { - return true - } - day += DAY_MS - } - return false -} - -export function buildAutomationRrule(args: { - preset: Exclude - hour: number - minute: number - dayOfWeek?: number -}): string { - const hour = Math.max(0, Math.min(23, Math.floor(args.hour))) - const minute = Math.max(0, Math.min(59, Math.floor(args.minute))) - if (args.preset === 'hourly') { - return `FREQ=HOURLY;BYMINUTE=${minute}` - } - if (args.preset === 'weekdays') { - return `FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYHOUR=${hour};BYMINUTE=${minute}` - } - if (args.preset === 'weekly') { - const day = DAY_CODES[Math.max(0, Math.min(6, Math.floor(args.dayOfWeek ?? 1)))] - return `FREQ=WEEKLY;BYDAY=${day};BYHOUR=${hour};BYMINUTE=${minute}` - } - return `FREQ=DAILY;BYHOUR=${hour};BYMINUTE=${minute}` -} - -export function buildAutomationCronSchedule(args: { - preset: Exclude - hour: number - minute: number - dayOfWeek?: number -}): string { - const hour = Math.max(0, Math.min(23, Math.floor(args.hour))) - const minute = Math.max(0, Math.min(59, Math.floor(args.minute))) - if (args.preset === 'hourly') { - return `${minute} * * * *` - } - if (args.preset === 'weekdays') { - return `${minute} ${hour} * * 1-5` - } - if (args.preset === 'weekly') { - const day = Math.max(0, Math.min(6, Math.floor(args.dayOfWeek ?? 1))) - return `${minute} ${hour} * * ${day}` - } - return `${minute} ${hour} * * *` -} - -export function nextAutomationOccurrenceAfter( - rrule: string, - dtstart: number, - after: number -): number { - const rule = parseSchedule(rrule) - if (rule.kind === 'cron') { - let candidate = floorToMinute(Math.max(dtstart, after)) - if (candidate <= after) { - candidate += MINUTE_MS - } - if (candidate < dtstart) { - candidate = floorToMinute(dtstart) - if (candidate < dtstart) { - candidate += MINUTE_MS - } - } - for (let i = 0; i < CRON_SCAN_MINUTES; i += 1) { - if (cronMatches(rule, candidate)) { - return candidate - } - candidate += MINUTE_MS - } - throw new Error('Unable to compute next automation run.') - } - if (rule.freq === 'HOURLY') { - const start = Math.max(dtstart, after) - const base = new Date(start) - base.setMinutes(rule.byMinute, 0, 0) - let candidate = base.getTime() - if (candidate <= after || candidate < dtstart) { - candidate += HOUR_MS - } - return candidate - } - const candidate = scanDayCandidates(rule, Math.max(dtstart - 1, after), 1) - if (candidate === null) { - throw new Error('Unable to compute next automation run.') - } - return candidate -} - -export function latestAutomationOccurrenceAtOrBefore( - rrule: string, - dtstart: number, - now: number -): number | null { - if (now < dtstart) { - return null - } - const rule = parseSchedule(rrule) - if (rule.kind === 'cron') { - let candidate = floorToMinute(now) - for (let i = 0; i < CRON_SCAN_MINUTES && candidate >= dtstart; i += 1) { - if (cronMatches(rule, candidate)) { - return candidate - } - candidate -= MINUTE_MS - } - return null - } - if (rule.freq === 'HOURLY') { - const base = new Date(now) - base.setMinutes(rule.byMinute, 0, 0) - let candidate = base.getTime() - if (candidate > now) { - candidate -= HOUR_MS - } - return candidate >= dtstart ? candidate : null - } - const candidate = scanDayCandidates(rule, now, -1) - return candidate !== null && candidate >= dtstart ? candidate : null -} diff --git a/src/shared/commit-message-agent-spec.test.ts b/src/shared/commit-message-agent-spec.test.ts index ce7052d43a9..c9bfb8eb789 100644 --- a/src/shared/commit-message-agent-spec.test.ts +++ b/src/shared/commit-message-agent-spec.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { COMMIT_MESSAGE_AGENT_SPECS, - COMMIT_MESSAGE_MODEL_JSON_STRUCTURE_LIMITS, CUSTOM_AGENT_ID, DEFAULT_COMMIT_MESSAGE_AGENT_ID, getCommitMessageAgentCapability, @@ -11,14 +10,17 @@ import { isCustomAgentId, listCommitMessageAgentCapabilities, listCommitMessageAgentIds, + resolveCommitMessageAgentChoice +} from './commit-message-agent-spec' +import { + COMMIT_MESSAGE_MODEL_JSON_STRUCTURE_LIMITS, parseAntigravityModels, parseClaudeModels, parseCodexModels, parseCursorModels, parseLineModels, - parsePiModels, - resolveCommitMessageAgentChoice -} from './commit-message-agent-spec' + parsePiModels +} from './commit-message-model-parsers' afterEach(() => { vi.restoreAllMocks() diff --git a/src/shared/commit-message-agent-spec.ts b/src/shared/commit-message-agent-spec.ts index c65850ba08a..6223b968b67 100644 --- a/src/shared/commit-message-agent-spec.ts +++ b/src/shared/commit-message-agent-spec.ts @@ -1,14 +1,20 @@ import type { TuiAgent } from './tui-agent' import { isTuiAgentEnabled } from './tui-agent-selection' -import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' -import { - CLAUDE_MODEL_LIST_ARGS, - CLAUDE_MODEL_LIST_STDIN, - parseClaudeModelList -} from './claude-model-list-probe' import { labelFromModelId } from './model-id-label' - -/* eslint-disable max-lines -- Why: this is the single registry for non-interactive commit-message agents, their model discovery parsers, and UI capabilities. */ +import { buildPrimaryCommitMessageAgentSpecs } from './commit-message-agent-specs-primary' +import { buildSecondaryCommitMessageAgentSpecs } from './commit-message-agent-specs-secondary' +import { + BASIC_THINKING_LEVELS, + CLAUDE_THINKING_LEVELS, + OPENAI_THINKING_LEVELS, + parseAntigravityModels, + parseClaudeModels, + parseCodexModels, + parseCursorModels, + parseLineModels, + parsePiModels, + withOpenAiThinking +} from './commit-message-model-parsers' // Why: this file is the source of truth for non-interactive agent invocation // (commit-message generation). It is intentionally separate from @@ -81,646 +87,22 @@ export type CommitMessageAgentCapability = { defaultModelId: string } -export const COMMIT_MESSAGE_MODEL_JSON_STRUCTURE_LIMITS = { - structuralTokens: 64 * 1024, - nestingDepth: 16 -} as const - -const BASIC_THINKING_LEVELS: ThinkingLevel[] = [ - { id: 'low', label: 'Low' }, - { id: 'medium', label: 'Medium' }, - { id: 'high', label: 'High' } -] - -const OPENAI_THINKING_LEVELS: ThinkingLevel[] = [ - { id: 'low', label: 'Low' }, - { id: 'medium', label: 'Medium' }, - { id: 'high', label: 'High' }, - { id: 'xhigh', label: 'Extra High' } -] - -const CLAUDE_THINKING_LEVELS: ThinkingLevel[] = [ - { id: 'low', label: 'Low' }, - { id: 'medium', label: 'Medium' }, - { id: 'high', label: 'High' }, - { id: 'xhigh', label: 'Extra High' }, - { id: 'max', label: 'Max' } -] - -function uniqueModels(models: CommitMessageModel[]): CommitMessageModel[] { - const seen = new Set() - return models.filter((model) => { - if (!model.id || seen.has(model.id)) { - return false - } - seen.add(model.id) - return true - }) -} - -function* iterateModelOutputLines(output: string): Generator { - let lineStart = 0 - - for (let index = 0; index < output.length; index++) { - const code = output.charCodeAt(index) - if (code !== 10 && code !== 13) { - continue - } - - yield output.slice(lineStart, index) - if (code === 13 && output.charCodeAt(index + 1) === 10) { - index++ - } - lineStart = index + 1 - } - - if (lineStart <= output.length) { - yield output.slice(lineStart) - } -} - -function withOpenAiThinking( - id: string -): Pick { - return /(?:gpt-5|codex)/i.test(id) - ? { thinkingLevels: OPENAI_THINKING_LEVELS, defaultThinkingLevel: 'low' } - : {} -} - -export function parseClaudeModels(stdout: string): CommitMessageModel[] { - return uniqueModels( - parseClaudeModelList(stdout).map((model) => { - const thinkingLevels = CLAUDE_THINKING_LEVELS.filter((level) => - model.effortLevels.includes(level.id) - ) - return { - id: model.id, - label: model.label, - ...(model.description ? { description: model.description } : {}), - ...(thinkingLevels.length > 0 - ? { - thinkingLevels, - defaultThinkingLevel: thinkingLevels.some((level) => level.id === 'low') - ? 'low' - : thinkingLevels[0].id - } - : {}), - ...(model.supportsFastMode ? { supportsFastMode: true } : {}) - } - }) - ) -} - -export function parseCodexModels(stdout: string): CommitMessageModel[] { - try { - assertJsonTextStructureWithinLimits(stdout, COMMIT_MESSAGE_MODEL_JSON_STRUCTURE_LIMITS) - const parsed = JSON.parse(stdout) as { - models?: { - slug?: string - display_name?: string - supported_reasoning_levels?: { effort?: string }[] - default_reasoning_level?: string - }[] - } - return uniqueModels( - (parsed.models ?? []) - .filter((model) => model.slug && model.display_name) - .map((model) => ({ - id: model.slug!, - label: model.display_name!, - ...(model.supported_reasoning_levels?.length - ? { - thinkingLevels: model.supported_reasoning_levels - .map((level) => level.effort) - .filter((effort): effort is string => Boolean(effort)) - .map((effort) => ({ - id: effort, - label: effort === 'xhigh' ? 'Extra High' : labelFromModelId(effort) - })), - defaultThinkingLevel: model.default_reasoning_level ?? 'low' - } - : {}) - })) - ) - } catch { - return [] - } -} - -export function parseLineModels(stdout: string): CommitMessageModel[] { - const models: CommitMessageModel[] = [] - for (const rawLine of iterateModelOutputLines(stdout)) { - const id = rawLine.trim() - if (id.length === 0 || id.includes(' ')) { - continue - } - models.push({ - id, - label: labelFromModelId(id), - ...withOpenAiThinking(id) - }) - } - return uniqueModels(models) -} - -export function parsePiModels(stdout: string): CommitMessageModel[] { - const models: CommitMessageModel[] = [] - for (const rawLine of iterateModelOutputLines(stdout)) { - const parts = getPiModelTableFields(rawLine, 6) - if (parts.length < 6 || parts[0] === 'provider') { - continue - } - - const [provider, model, , , thinking] = parts - const id = `${provider}/${model}` - models.push({ - id, - label: `${labelFromModelId(provider)} ${labelFromModelId(model)}`, - ...(thinking === 'yes' - ? { - thinkingLevels: [ - { id: 'off', label: 'Off' }, - { id: 'low', label: 'Low' }, - { id: 'medium', label: 'Medium' }, - { id: 'high', label: 'High' }, - { id: 'xhigh', label: 'Extra High' } - ], - defaultThinkingLevel: 'low' - } - : {}) - }) - } - return uniqueModels(models) -} - -// Why: model discovery output can include paste-sized noisy lines; only the first fields matter. -function getPiModelTableFields(line: string, maxFields: number): string[] { - const fields: string[] = [] - let tokenStart = -1 - - for (let index = 0; index <= line.length; index += 1) { - const isEnd = index === line.length - if (!isEnd && !isPiModelTableWhitespace(line.charCodeAt(index))) { - if (tokenStart === -1) { - tokenStart = index - } - continue - } - if (tokenStart !== -1) { - fields.push(line.slice(tokenStart, index)) - tokenStart = -1 - if (fields.length >= maxFields) { - break - } - } - } - - return fields -} - -function isPiModelTableWhitespace(code: number): boolean { - return ( - code === 32 || - (code >= 9 && code <= 13) || - code === 160 || - code === 5760 || - (code >= 8192 && code <= 8202) || - code === 8232 || - code === 8233 || - code === 8239 || - code === 8287 || - code === 12288 || - code === 65279 - ) -} - -export function parseCursorModels(stdout: string): CommitMessageModel[] { - const models: CommitMessageModel[] = [] - for (const rawLine of iterateModelOutputLines(stdout)) { - const match = /^([^\s]+)\s+-\s+(.+)$/.exec(rawLine.trim()) - if (!match) { - continue - } - models.push({ - id: match[1], - label: match[2].replace(/\s+\((?:default|current)\)$/i, ''), - ...withOpenAiThinking(match[1]) - }) - } - return uniqueModels(models) -} - -export function parseAntigravityModels(stdout: string): CommitMessageModel[] { - const models: CommitMessageModel[] = [] - for (const rawLine of iterateModelOutputLines(stdout)) { - const id = rawLine.trim() - if (id.length === 0) { - continue - } - models.push({ - id, - label: id - }) - } - return uniqueModels(models) -} - export const COMMIT_MESSAGE_AGENT_SPECS: Partial> = { - claude: { - id: 'claude', - label: 'Claude', - binary: 'claude', - // Why: diffs can be large and `claude -p` reads from stdin natively when no - // positional prompt is provided. - promptDelivery: 'stdin', - buildArgs: ({ model, thinkingLevel }) => [ - '-p', - '--output-format', - 'text', - '--model', - model, - '--permission-mode', - 'plan', - ...(thinkingLevel ? ['--effort', thinkingLevel] : []) - ], - modelSource: 'dynamic', - // Why: the Claude CLI has no listing subcommand; one list_models control - // request over --print stream-json returns the /model picker catalog. - // Older CLIs answer with a control error and exit 0, keeping the fallback. - modelDiscovery: { - binary: 'claude', - args: [...CLAUDE_MODEL_LIST_ARGS], - stdinPayload: CLAUDE_MODEL_LIST_STDIN, - parse: parseClaudeModels - }, - models: [ - { - // Why: Claude Code aliases track the account/provider's supported - // model IDs; hardcoded version IDs can be rejected by Bedrock/Vertex. - id: 'haiku', - label: 'Haiku' - }, - { - id: 'sonnet', - label: 'Sonnet', - thinkingLevels: CLAUDE_THINKING_LEVELS, - defaultThinkingLevel: 'low' - }, - { - id: 'opus', - label: 'Opus', - thinkingLevels: CLAUDE_THINKING_LEVELS, - defaultThinkingLevel: 'low' - } - ], - defaultModelId: 'sonnet' - }, - codex: { - id: 'codex', - label: 'Codex', - binary: 'codex', - // Why: `codex exec` reads stdin when no prompt arg is supplied. Commit - // prompts include large staged diffs, so argv would exceed Windows and - // some SSH/POSIX command-line limits. - promptDelivery: 'stdin', - buildArgs: ({ model, thinkingLevel }) => [ - 'exec', - // Why: commit-message generation needs text only, not a persisted agent - // session or workspace writes. Match the safe git-text mode used by - // local-first coding agents. - '--ephemeral', - '--skip-git-repo-check', - '-s', - 'read-only', - '--model', - model, - ...(thinkingLevel ? ['-c', `model_reasoning_effort=${thinkingLevel}`] : []) - ], - // `-c` is intentionally absent: Codex accepts repeated overrides. - singletonOptions: [['--model', '-m']], - modelSource: 'dynamic', - modelDiscovery: { - binary: 'codex', - args: ['debug', 'models'], - parse: parseCodexModels - }, - // Why: ordered to match the official `codex` model picker โ€” descending - // by version so the frontier model lands on top and legacy models trail. - models: [ - { - id: 'gpt-5.5', - label: 'GPT-5.5', - thinkingLevels: OPENAI_THINKING_LEVELS, - defaultThinkingLevel: 'low' - }, - { - id: 'gpt-5.4', - label: 'GPT-5.4', - thinkingLevels: OPENAI_THINKING_LEVELS, - defaultThinkingLevel: 'low' - }, - { - id: 'gpt-5.4-mini', - label: 'GPT-5.4 Mini', - thinkingLevels: OPENAI_THINKING_LEVELS, - defaultThinkingLevel: 'low' - }, - { - id: 'gpt-5.3-codex', - label: 'GPT-5.3 Codex', - thinkingLevels: OPENAI_THINKING_LEVELS, - defaultThinkingLevel: 'low' - }, - { - // Why: Codex's Spark variant accepts `model_reasoning_effort` (the - // CLI banner reports "reasoning effort: medium" by default); the - // gating that surfaces "model not supported" is on the account - // tier, not the effort flag. - id: 'gpt-5.3-codex-spark', - label: 'GPT-5.3 Codex Spark', - thinkingLevels: OPENAI_THINKING_LEVELS, - defaultThinkingLevel: 'low' - }, - { - id: 'gpt-5.2', - label: 'GPT-5.2', - thinkingLevels: OPENAI_THINKING_LEVELS, - defaultThinkingLevel: 'low' - } - ], - defaultModelId: 'gpt-5.5' - }, - opencode: { - id: 'opencode', - label: 'OpenCode', - binary: 'opencode', - // Why: Source Control AI prompts can include large staged diffs; OpenCode - // accepts the prompt on stdin, which avoids cross-platform argv limits. - promptDelivery: 'stdin', - buildArgs: ({ model, thinkingLevel }) => [ - 'run', - '--model', - model, - '--agent', - 'build', - '--format', - 'default', - ...(thinkingLevel ? ['--variant', thinkingLevel] : []) - ], - singletonOptions: [['--model', '-m'], ['--agent'], ['--format'], ['--variant']], - modelSource: 'dynamic', - modelDiscovery: { binary: 'opencode', args: ['models'], parse: parseLineModels }, - models: [ - { - // Why: OpenCode's hosted GPT models can require workspace billing even - // when `opencode models` lists them. This free model is available in - // discovery and works as a usable out-of-the-box default. - id: 'opencode/deepseek-v4-flash-free', - label: 'OpenCode DeepSeek V4 Flash Free' - }, - { - id: 'opencode/gpt-5.4-mini', - label: 'OpenCode GPT 5.4 Mini', - ...withOpenAiThinking('gpt-5.4-mini') - } - ], - defaultModelId: 'opencode/deepseek-v4-flash-free' - }, - pi: { - id: 'pi', - label: 'Pi', - binary: 'pi', - promptDelivery: 'stdin', - buildArgs: ({ model, thinkingLevel }) => [ - '--print', - '--no-session', - '--no-tools', - '--no-extensions', - '--no-skills', - '--no-context-files', - '--mode', - 'text', - '--model', - model, - ...(thinkingLevel ? ['--thinking', thinkingLevel] : []) - ], - modelSource: 'dynamic', - modelDiscovery: { binary: 'pi', args: ['--list-models'], parse: parsePiModels }, - models: [ - { - // Why: Pi commonly authenticates through GitHub Copilot locally; using - // that provider avoids selecting a raw OpenAI model when no key exists. - id: 'github-copilot/gpt-5.4-mini', - label: 'Github Copilot GPT 5.4 Mini', - ...withOpenAiThinking('gpt-5.4-mini') - } - ], - defaultModelId: 'github-copilot/gpt-5.4-mini' - }, - amp: { - id: 'amp', - label: 'Amp', - binary: 'amp', - promptDelivery: 'stdin', - buildArgs: ({ model, thinkingLevel }) => [ - '--execute', - '--no-notifications', - '--no-ide', - '--no-jetbrains', - '--mode', - model, - ...(thinkingLevel ? ['--effort', thinkingLevel] : []) - ], - // Amp selects the model with `--mode`, not `--model`. - singletonOptions: [['--mode']], - modelSource: 'static', - models: [ - { id: 'smart', label: 'Smart' }, - { id: 'rush', label: 'Rush' }, - { - id: 'large', - label: 'Large', - thinkingLevels: BASIC_THINKING_LEVELS, - defaultThinkingLevel: 'low' - }, - { - id: 'deep', - label: 'Deep', - thinkingLevels: BASIC_THINKING_LEVELS, - defaultThinkingLevel: 'low' - } - ], - defaultModelId: 'smart' - }, - cursor: { - id: 'cursor', - label: 'Cursor', - binary: 'cursor-agent', - promptDelivery: 'argv', - buildArgs: ({ prompt, model }) => [ - '--print', - '--mode', - 'ask', - '--trust', - '--output-format', - 'text', - '--model', - model, - prompt - ], - modelSource: 'dynamic', - modelDiscovery: { binary: 'cursor-agent', args: ['--list-models'], parse: parseCursorModels }, - models: [{ id: 'auto', label: 'Auto' }], - defaultModelId: 'auto' - }, - kimi: { - id: 'kimi', - label: 'Kimi', - binary: 'kimi', - // Why: kimi-code accepts the generation prompt only via --prompt/-p (Claude's - // --print is rejected). Deliver on argv so --prompt receives the text (#11669). - promptDelivery: 'argv', - buildArgs: ({ prompt, model, thinkingLevel }) => [ - '--prompt', - prompt, - '--quiet', - ...(model && model !== 'default' ? ['--model', model] : []), - ...(thinkingLevel === 'on' - ? ['--thinking'] - : thinkingLevel === 'off' - ? ['--no-thinking'] - : []) - ], - modelSource: 'static', - models: [ - { id: 'default', label: 'Config default' }, - { - // Why: Kimi resolves its managed model by provider/model; bare model - // names are rejected by the CLI with "LLM not set". - id: 'kimi-code/kimi-for-coding', - label: 'Kimi K2.6', - thinkingLevels: [ - { id: 'on', label: 'On' }, - { id: 'off', label: 'Off' } - ], - defaultThinkingLevel: 'on' - } - ], - defaultModelId: 'default' - }, - copilot: { - id: 'copilot', - label: 'GitHub Copilot', - binary: 'copilot', - promptDelivery: 'argv', - buildArgs: ({ prompt, model, thinkingLevel }) => [ - '--prompt', - prompt, - '--silent', - '--stream', - 'off', - '--no-custom-instructions', - '--model', - model, - ...(thinkingLevel ? ['--effort', thinkingLevel] : []) - ], - modelSource: 'static', - // Why: Copilot CLI's picker is policy-filtered per account/org. Keep the - // full hosted CLI catalog here so users can select models enabled for them. - models: [ - { id: 'auto', label: 'Auto' }, - { - id: 'claude-haiku-4.5', - label: 'Claude Haiku 4.5' - }, - { - id: 'claude-sonnet-4.5', - label: 'Claude Sonnet 4.5' - }, - { - id: 'claude-sonnet-4.6', - label: 'Claude Sonnet 4.6' - }, - { - id: 'claude-opus-4.5', - label: 'Claude Opus 4.5' - }, - { - id: 'claude-opus-4.6', - label: 'Claude Opus 4.6' - }, - { - id: 'claude-opus-4.6-fast', - label: 'Claude Opus 4.6 Fast' - }, - { - id: 'claude-opus-4.7', - label: 'Claude Opus 4.7' - }, - { - id: 'gpt-4.1', - label: 'GPT-4.1' - }, - { - id: 'gpt-5-mini', - label: 'GPT-5 Mini', - thinkingLevels: OPENAI_THINKING_LEVELS, - defaultThinkingLevel: 'low' - }, - { - id: 'gpt-5.2', - label: 'GPT-5.2', - thinkingLevels: OPENAI_THINKING_LEVELS, - defaultThinkingLevel: 'low' - }, - { - id: 'gpt-5.2-codex', - label: 'GPT-5.2 Codex', - thinkingLevels: OPENAI_THINKING_LEVELS, - defaultThinkingLevel: 'low' - }, - { - id: 'gpt-5.3-codex', - label: 'GPT-5.3 Codex', - thinkingLevels: OPENAI_THINKING_LEVELS, - defaultThinkingLevel: 'low' - }, - { - id: 'gpt-5.4', - label: 'GPT-5.4', - thinkingLevels: OPENAI_THINKING_LEVELS, - defaultThinkingLevel: 'low' - }, - { - id: 'gpt-5.4-mini', - label: 'GPT-5.4 Mini', - thinkingLevels: OPENAI_THINKING_LEVELS, - defaultThinkingLevel: 'low' - }, - { - id: 'gpt-5.5', - label: 'GPT-5.5', - thinkingLevels: OPENAI_THINKING_LEVELS, - defaultThinkingLevel: 'low' - } - ], - defaultModelId: 'gpt-5.4' - }, - antigravity: { - id: 'antigravity', - label: 'Antigravity', - binary: 'agy', - promptDelivery: 'stdin', - buildArgs: ({ model }) => ['--print', '--sandbox', '--model', model], - modelSource: 'dynamic', - modelDiscovery: { binary: 'agy', args: ['models'], parse: parseAntigravityModels }, - models: [ - { id: 'Gemini 3.5 Flash (Medium)', label: 'Gemini 3.5 Flash (Medium)' }, - { id: 'Gemini 3.5 Flash (High)', label: 'Gemini 3.5 Flash (High)' }, - { id: 'Gemini 3.5 Flash (Low)', label: 'Gemini 3.5 Flash (Low)' } - ], - defaultModelId: 'Gemini 3.5 Flash (Medium)' - } + ...buildPrimaryCommitMessageAgentSpecs({ + CLAUDE_THINKING_LEVELS, + OPENAI_THINKING_LEVELS, + parseClaudeModels, + parseCodexModels, + parseLineModels, + parsePiModels, + withOpenAiThinking + }), + ...buildSecondaryCommitMessageAgentSpecs({ + BASIC_THINKING_LEVELS, + OPENAI_THINKING_LEVELS, + parseCursorModels, + parseAntigravityModels + }) } export const DEFAULT_COMMIT_MESSAGE_AGENT_ID: TuiAgent = 'claude' diff --git a/src/shared/commit-message-agent-specs-primary.ts b/src/shared/commit-message-agent-specs-primary.ts new file mode 100644 index 00000000000..e6ba42f775b --- /dev/null +++ b/src/shared/commit-message-agent-specs-primary.ts @@ -0,0 +1,223 @@ +import type { TuiAgent } from './tui-agent' +import type { + CommitMessageAgentSpec, + CommitMessageModel, + ThinkingLevel +} from './commit-message-agent-spec' +import { CLAUDE_MODEL_LIST_ARGS, CLAUDE_MODEL_LIST_STDIN } from './claude-model-list-probe' + +type PrimaryAgentSpecDeps = { + CLAUDE_THINKING_LEVELS: ThinkingLevel[] + OPENAI_THINKING_LEVELS: ThinkingLevel[] + parseClaudeModels: (stdout: string) => CommitMessageModel[] + parseCodexModels: (stdout: string) => CommitMessageModel[] + parseLineModels: (stdout: string) => CommitMessageModel[] + parsePiModels: (stdout: string) => CommitMessageModel[] + withOpenAiThinking: ( + id: string + ) => Pick +} + +export function buildPrimaryCommitMessageAgentSpecs({ + CLAUDE_THINKING_LEVELS, + OPENAI_THINKING_LEVELS, + parseClaudeModels, + parseCodexModels, + parseLineModels, + parsePiModels, + withOpenAiThinking +}: PrimaryAgentSpecDeps): Partial> { + return { + claude: { + id: 'claude', + label: 'Claude', + binary: 'claude', + // Why: diffs can be large and `claude -p` reads from stdin natively when no + // positional prompt is provided. + promptDelivery: 'stdin', + buildArgs: ({ model, thinkingLevel }) => [ + '-p', + '--output-format', + 'text', + '--model', + model, + '--permission-mode', + 'plan', + ...(thinkingLevel ? ['--effort', thinkingLevel] : []) + ], + modelSource: 'dynamic', + // Why: the Claude CLI has no listing subcommand; one list_models control + // request over --print stream-json returns the /model picker catalog. + // Older CLIs answer with a control error and exit 0, keeping the fallback. + modelDiscovery: { + binary: 'claude', + args: [...CLAUDE_MODEL_LIST_ARGS], + stdinPayload: CLAUDE_MODEL_LIST_STDIN, + parse: parseClaudeModels + }, + models: [ + { + // Why: Claude Code aliases track the account/provider's supported + // model IDs; hardcoded version IDs can be rejected by Bedrock/Vertex. + id: 'haiku', + label: 'Haiku' + }, + { + id: 'sonnet', + label: 'Sonnet', + thinkingLevels: CLAUDE_THINKING_LEVELS, + defaultThinkingLevel: 'low' + }, + { + id: 'opus', + label: 'Opus', + thinkingLevels: CLAUDE_THINKING_LEVELS, + defaultThinkingLevel: 'low' + } + ], + defaultModelId: 'sonnet' + }, + codex: { + id: 'codex', + label: 'Codex', + binary: 'codex', + // Why: `codex exec` reads stdin when no prompt arg is supplied. Commit + // prompts include large staged diffs, so argv would exceed Windows and + // some SSH/POSIX command-line limits. + promptDelivery: 'stdin', + buildArgs: ({ model, thinkingLevel }) => [ + 'exec', + // Why: commit-message generation needs text only, not a persisted agent + // session or workspace writes. Match the safe git-text mode used by + // local-first coding agents. + '--ephemeral', + '--skip-git-repo-check', + '-s', + 'read-only', + '--model', + model, + ...(thinkingLevel ? ['-c', `model_reasoning_effort=${thinkingLevel}`] : []) + ], + // `-c` is intentionally absent: Codex accepts repeated overrides. + singletonOptions: [['--model', '-m']], + modelSource: 'dynamic', + modelDiscovery: { + binary: 'codex', + args: ['debug', 'models'], + parse: parseCodexModels + }, + // Why: ordered to match the official `codex` model picker โ€” descending + // by version so the frontier model lands on top and legacy models trail. + models: [ + { + id: 'gpt-5.5', + label: 'GPT-5.5', + thinkingLevels: OPENAI_THINKING_LEVELS, + defaultThinkingLevel: 'low' + }, + { + id: 'gpt-5.4', + label: 'GPT-5.4', + thinkingLevels: OPENAI_THINKING_LEVELS, + defaultThinkingLevel: 'low' + }, + { + id: 'gpt-5.4-mini', + label: 'GPT-5.4 Mini', + thinkingLevels: OPENAI_THINKING_LEVELS, + defaultThinkingLevel: 'low' + }, + { + id: 'gpt-5.3-codex', + label: 'GPT-5.3 Codex', + thinkingLevels: OPENAI_THINKING_LEVELS, + defaultThinkingLevel: 'low' + }, + { + // Why: Codex's Spark variant accepts `model_reasoning_effort` (the + // CLI banner reports "reasoning effort: medium" by default); the + // gating that surfaces "model not supported" is on the account + // tier, not the effort flag. + id: 'gpt-5.3-codex-spark', + label: 'GPT-5.3 Codex Spark', + thinkingLevels: OPENAI_THINKING_LEVELS, + defaultThinkingLevel: 'low' + }, + { + id: 'gpt-5.2', + label: 'GPT-5.2', + thinkingLevels: OPENAI_THINKING_LEVELS, + defaultThinkingLevel: 'low' + } + ], + defaultModelId: 'gpt-5.5' + }, + opencode: { + id: 'opencode', + label: 'OpenCode', + binary: 'opencode', + // Why: Source Control AI prompts can include large staged diffs; OpenCode + // accepts the prompt on stdin, which avoids cross-platform argv limits. + promptDelivery: 'stdin', + buildArgs: ({ model, thinkingLevel }) => [ + 'run', + '--model', + model, + '--agent', + 'build', + '--format', + 'default', + ...(thinkingLevel ? ['--variant', thinkingLevel] : []) + ], + singletonOptions: [['--model', '-m'], ['--agent'], ['--format'], ['--variant']], + modelSource: 'dynamic', + modelDiscovery: { binary: 'opencode', args: ['models'], parse: parseLineModels }, + models: [ + { + // Why: OpenCode's hosted GPT models can require workspace billing even + // when `opencode models` lists them. This free model is available in + // discovery and works as a usable out-of-the-box default. + id: 'opencode/deepseek-v4-flash-free', + label: 'OpenCode DeepSeek V4 Flash Free' + }, + { + id: 'opencode/gpt-5.4-mini', + label: 'OpenCode GPT 5.4 Mini', + ...withOpenAiThinking('gpt-5.4-mini') + } + ], + defaultModelId: 'opencode/deepseek-v4-flash-free' + }, + pi: { + id: 'pi', + label: 'Pi', + binary: 'pi', + promptDelivery: 'stdin', + buildArgs: ({ model, thinkingLevel }) => [ + '--print', + '--no-session', + '--no-tools', + '--no-extensions', + '--no-skills', + '--no-context-files', + '--mode', + 'text', + '--model', + model, + ...(thinkingLevel ? ['--thinking', thinkingLevel] : []) + ], + modelSource: 'dynamic', + modelDiscovery: { binary: 'pi', args: ['--list-models'], parse: parsePiModels }, + models: [ + { + // Why: Pi commonly authenticates through GitHub Copilot locally; using + // that provider avoids selecting a raw OpenAI model when no key exists. + id: 'github-copilot/gpt-5.4-mini', + label: 'Github Copilot GPT 5.4 Mini', + ...withOpenAiThinking('gpt-5.4-mini') + } + ], + defaultModelId: 'github-copilot/gpt-5.4-mini' + } + } +} diff --git a/src/shared/commit-message-agent-specs-secondary.ts b/src/shared/commit-message-agent-specs-secondary.ts new file mode 100644 index 00000000000..e22fce018f8 --- /dev/null +++ b/src/shared/commit-message-agent-specs-secondary.ts @@ -0,0 +1,227 @@ +import type { TuiAgent } from './tui-agent' +import type { + CommitMessageAgentSpec, + CommitMessageModel, + ThinkingLevel +} from './commit-message-agent-spec' + +type SecondaryAgentSpecDeps = { + BASIC_THINKING_LEVELS: ThinkingLevel[] + OPENAI_THINKING_LEVELS: ThinkingLevel[] + parseCursorModels: (stdout: string) => CommitMessageModel[] + parseAntigravityModels: (stdout: string) => CommitMessageModel[] +} + +export function buildSecondaryCommitMessageAgentSpecs({ + BASIC_THINKING_LEVELS, + OPENAI_THINKING_LEVELS, + parseCursorModels, + parseAntigravityModels +}: SecondaryAgentSpecDeps): Partial> { + return { + amp: { + id: 'amp', + label: 'Amp', + binary: 'amp', + promptDelivery: 'stdin', + buildArgs: ({ model, thinkingLevel }) => [ + '--execute', + '--no-notifications', + '--no-ide', + '--no-jetbrains', + '--mode', + model, + ...(thinkingLevel ? ['--effort', thinkingLevel] : []) + ], + // Amp selects the model with `--mode`, not `--model`. + singletonOptions: [['--mode']], + modelSource: 'static', + models: [ + { id: 'smart', label: 'Smart' }, + { id: 'rush', label: 'Rush' }, + { + id: 'large', + label: 'Large', + thinkingLevels: BASIC_THINKING_LEVELS, + defaultThinkingLevel: 'low' + }, + { + id: 'deep', + label: 'Deep', + thinkingLevels: BASIC_THINKING_LEVELS, + defaultThinkingLevel: 'low' + } + ], + defaultModelId: 'smart' + }, + cursor: { + id: 'cursor', + label: 'Cursor', + binary: 'cursor-agent', + promptDelivery: 'argv', + buildArgs: ({ prompt, model }) => [ + '--print', + '--mode', + 'ask', + '--trust', + '--output-format', + 'text', + '--model', + model, + prompt + ], + modelSource: 'dynamic', + modelDiscovery: { binary: 'cursor-agent', args: ['--list-models'], parse: parseCursorModels }, + models: [{ id: 'auto', label: 'Auto' }], + defaultModelId: 'auto' + }, + kimi: { + id: 'kimi', + label: 'Kimi', + binary: 'kimi', + // Why: kimi-code accepts the generation prompt only via --prompt/-p (Claude's + // --print is rejected). Deliver on argv so --prompt receives the text (#11669). + promptDelivery: 'argv', + buildArgs: ({ prompt, model, thinkingLevel }) => [ + '--prompt', + prompt, + '--quiet', + ...(model && model !== 'default' ? ['--model', model] : []), + ...(thinkingLevel === 'on' + ? ['--thinking'] + : thinkingLevel === 'off' + ? ['--no-thinking'] + : []) + ], + modelSource: 'static', + models: [ + { id: 'default', label: 'Config default' }, + { + // Why: Kimi resolves its managed model by provider/model; bare model + // names are rejected by the CLI with "LLM not set". + id: 'kimi-code/kimi-for-coding', + label: 'Kimi K2.6', + thinkingLevels: [ + { id: 'on', label: 'On' }, + { id: 'off', label: 'Off' } + ], + defaultThinkingLevel: 'on' + } + ], + defaultModelId: 'default' + }, + copilot: { + id: 'copilot', + label: 'GitHub Copilot', + binary: 'copilot', + promptDelivery: 'argv', + buildArgs: ({ prompt, model, thinkingLevel }) => [ + '--prompt', + prompt, + '--silent', + '--stream', + 'off', + '--no-custom-instructions', + '--model', + model, + ...(thinkingLevel ? ['--effort', thinkingLevel] : []) + ], + modelSource: 'static', + // Why: Copilot CLI's picker is policy-filtered per account/org. Keep the + // full hosted CLI catalog here so users can select models enabled for them. + models: [ + { id: 'auto', label: 'Auto' }, + { + id: 'claude-haiku-4.5', + label: 'Claude Haiku 4.5' + }, + { + id: 'claude-sonnet-4.5', + label: 'Claude Sonnet 4.5' + }, + { + id: 'claude-sonnet-4.6', + label: 'Claude Sonnet 4.6' + }, + { + id: 'claude-opus-4.5', + label: 'Claude Opus 4.5' + }, + { + id: 'claude-opus-4.6', + label: 'Claude Opus 4.6' + }, + { + id: 'claude-opus-4.6-fast', + label: 'Claude Opus 4.6 Fast' + }, + { + id: 'claude-opus-4.7', + label: 'Claude Opus 4.7' + }, + { + id: 'gpt-4.1', + label: 'GPT-4.1' + }, + { + id: 'gpt-5-mini', + label: 'GPT-5 Mini', + thinkingLevels: OPENAI_THINKING_LEVELS, + defaultThinkingLevel: 'low' + }, + { + id: 'gpt-5.2', + label: 'GPT-5.2', + thinkingLevels: OPENAI_THINKING_LEVELS, + defaultThinkingLevel: 'low' + }, + { + id: 'gpt-5.2-codex', + label: 'GPT-5.2 Codex', + thinkingLevels: OPENAI_THINKING_LEVELS, + defaultThinkingLevel: 'low' + }, + { + id: 'gpt-5.3-codex', + label: 'GPT-5.3 Codex', + thinkingLevels: OPENAI_THINKING_LEVELS, + defaultThinkingLevel: 'low' + }, + { + id: 'gpt-5.4', + label: 'GPT-5.4', + thinkingLevels: OPENAI_THINKING_LEVELS, + defaultThinkingLevel: 'low' + }, + { + id: 'gpt-5.4-mini', + label: 'GPT-5.4 Mini', + thinkingLevels: OPENAI_THINKING_LEVELS, + defaultThinkingLevel: 'low' + }, + { + id: 'gpt-5.5', + label: 'GPT-5.5', + thinkingLevels: OPENAI_THINKING_LEVELS, + defaultThinkingLevel: 'low' + } + ], + defaultModelId: 'gpt-5.4' + }, + antigravity: { + id: 'antigravity', + label: 'Antigravity', + binary: 'agy', + promptDelivery: 'stdin', + buildArgs: ({ model }) => ['--print', '--sandbox', '--model', model], + modelSource: 'dynamic', + modelDiscovery: { binary: 'agy', args: ['models'], parse: parseAntigravityModels }, + models: [ + { id: 'Gemini 3.5 Flash (Medium)', label: 'Gemini 3.5 Flash (Medium)' }, + { id: 'Gemini 3.5 Flash (High)', label: 'Gemini 3.5 Flash (High)' }, + { id: 'Gemini 3.5 Flash (Low)', label: 'Gemini 3.5 Flash (Low)' } + ], + defaultModelId: 'Gemini 3.5 Flash (Medium)' + } + } +} diff --git a/src/shared/commit-message-model-parsers.ts b/src/shared/commit-message-model-parsers.ts new file mode 100644 index 00000000000..036557d5b89 --- /dev/null +++ b/src/shared/commit-message-model-parsers.ts @@ -0,0 +1,248 @@ +import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' +import { parseClaudeModelList } from './claude-model-list-probe' +import { labelFromModelId } from './model-id-label' +import type { CommitMessageModel, ThinkingLevel } from './commit-message-agent-spec' + +export const COMMIT_MESSAGE_MODEL_JSON_STRUCTURE_LIMITS = { + structuralTokens: 64 * 1024, + nestingDepth: 16 +} as const + +export const BASIC_THINKING_LEVELS: ThinkingLevel[] = [ + { id: 'low', label: 'Low' }, + { id: 'medium', label: 'Medium' }, + { id: 'high', label: 'High' } +] + +export const OPENAI_THINKING_LEVELS: ThinkingLevel[] = [ + { id: 'low', label: 'Low' }, + { id: 'medium', label: 'Medium' }, + { id: 'high', label: 'High' }, + { id: 'xhigh', label: 'Extra High' } +] + +export const CLAUDE_THINKING_LEVELS: ThinkingLevel[] = [ + { id: 'low', label: 'Low' }, + { id: 'medium', label: 'Medium' }, + { id: 'high', label: 'High' }, + { id: 'xhigh', label: 'Extra High' }, + { id: 'max', label: 'Max' } +] + +function uniqueModels(models: CommitMessageModel[]): CommitMessageModel[] { + const seen = new Set() + return models.filter((model) => { + if (!model.id || seen.has(model.id)) { + return false + } + seen.add(model.id) + return true + }) +} + +function* iterateModelOutputLines(output: string): Generator { + let lineStart = 0 + + for (let index = 0; index < output.length; index++) { + const code = output.charCodeAt(index) + if (code !== 10 && code !== 13) { + continue + } + + yield output.slice(lineStart, index) + if (code === 13 && output.charCodeAt(index + 1) === 10) { + index++ + } + lineStart = index + 1 + } + + if (lineStart <= output.length) { + yield output.slice(lineStart) + } +} + +export function withOpenAiThinking( + id: string +): Pick { + return /(?:gpt-5|codex)/i.test(id) + ? { thinkingLevels: OPENAI_THINKING_LEVELS, defaultThinkingLevel: 'low' } + : {} +} + +export function parseClaudeModels(stdout: string): CommitMessageModel[] { + return uniqueModels( + parseClaudeModelList(stdout).map((model) => { + const thinkingLevels = CLAUDE_THINKING_LEVELS.filter((level) => + model.effortLevels.includes(level.id) + ) + return { + id: model.id, + label: model.label, + ...(model.description ? { description: model.description } : {}), + ...(thinkingLevels.length > 0 + ? { + thinkingLevels, + defaultThinkingLevel: thinkingLevels.some((level) => level.id === 'low') + ? 'low' + : thinkingLevels[0].id + } + : {}), + ...(model.supportsFastMode ? { supportsFastMode: true } : {}) + } + }) + ) +} + +export function parseCodexModels(stdout: string): CommitMessageModel[] { + try { + assertJsonTextStructureWithinLimits(stdout, COMMIT_MESSAGE_MODEL_JSON_STRUCTURE_LIMITS) + const parsed = JSON.parse(stdout) as { + models?: { + slug?: string + display_name?: string + supported_reasoning_levels?: { effort?: string }[] + default_reasoning_level?: string + }[] + } + return uniqueModels( + (parsed.models ?? []) + .filter((model) => model.slug && model.display_name) + .map((model) => ({ + id: model.slug!, + label: model.display_name!, + ...(model.supported_reasoning_levels?.length + ? { + thinkingLevels: model.supported_reasoning_levels + .map((level) => level.effort) + .filter((effort): effort is string => Boolean(effort)) + .map((effort) => ({ + id: effort, + label: effort === 'xhigh' ? 'Extra High' : labelFromModelId(effort) + })), + defaultThinkingLevel: model.default_reasoning_level ?? 'low' + } + : {}) + })) + ) + } catch { + return [] + } +} + +export function parseLineModels(stdout: string): CommitMessageModel[] { + const models: CommitMessageModel[] = [] + for (const rawLine of iterateModelOutputLines(stdout)) { + const id = rawLine.trim() + if (id.length === 0 || id.includes(' ')) { + continue + } + models.push({ + id, + label: labelFromModelId(id), + ...withOpenAiThinking(id) + }) + } + return uniqueModels(models) +} + +export function parsePiModels(stdout: string): CommitMessageModel[] { + const models: CommitMessageModel[] = [] + for (const rawLine of iterateModelOutputLines(stdout)) { + const parts = getPiModelTableFields(rawLine, 6) + if (parts.length < 6 || parts[0] === 'provider') { + continue + } + + const [provider, model, , , thinking] = parts + const id = `${provider}/${model}` + models.push({ + id, + label: `${labelFromModelId(provider)} ${labelFromModelId(model)}`, + ...(thinking === 'yes' + ? { + thinkingLevels: [ + { id: 'off', label: 'Off' }, + { id: 'low', label: 'Low' }, + { id: 'medium', label: 'Medium' }, + { id: 'high', label: 'High' }, + { id: 'xhigh', label: 'Extra High' } + ], + defaultThinkingLevel: 'low' + } + : {}) + }) + } + return uniqueModels(models) +} + +// Why: model discovery output can include paste-sized noisy lines; only the first fields matter. +function getPiModelTableFields(line: string, maxFields: number): string[] { + const fields: string[] = [] + let tokenStart = -1 + + for (let index = 0; index <= line.length; index += 1) { + const isEnd = index === line.length + if (!isEnd && !isPiModelTableWhitespace(line.charCodeAt(index))) { + if (tokenStart === -1) { + tokenStart = index + } + continue + } + if (tokenStart !== -1) { + fields.push(line.slice(tokenStart, index)) + tokenStart = -1 + if (fields.length >= maxFields) { + break + } + } + } + + return fields +} + +function isPiModelTableWhitespace(code: number): boolean { + return ( + code === 32 || + (code >= 9 && code <= 13) || + code === 160 || + code === 5760 || + (code >= 8192 && code <= 8202) || + code === 8232 || + code === 8233 || + code === 8239 || + code === 8287 || + code === 12288 || + code === 65279 + ) +} + +export function parseCursorModels(stdout: string): CommitMessageModel[] { + const models: CommitMessageModel[] = [] + for (const rawLine of iterateModelOutputLines(stdout)) { + const match = /^([^\s]+)\s+-\s+(.+)$/.exec(rawLine.trim()) + if (!match) { + continue + } + models.push({ + id: match[1], + label: match[2].replace(/\s+\((?:default|current)\)$/i, ''), + ...withOpenAiThinking(match[1]) + }) + } + return uniqueModels(models) +} + +export function parseAntigravityModels(stdout: string): CommitMessageModel[] { + const models: CommitMessageModel[] = [] + for (const rawLine of iterateModelOutputLines(stdout)) { + const id = rawLine.trim() + if (id.length === 0) { + continue + } + models.push({ + id, + label: id + }) + } + return uniqueModels(models) +} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 928e774eaa3..f19041b9167 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: default persisted settings live in one schema-shaped object so migrations and tests compare against one source of truth. */ import type { GlobalSettings } from './global-settings-types' import type { NotificationSettings } from './notification-settings-types' import type { OnboardingChecklistState, OnboardingState } from './onboarding-state-types' @@ -9,28 +8,14 @@ import type { AgentActivityDisplayMode } from './ui-chrome-types' import type { WorkspaceSessionState } from './workspace-session-state-types' import { EMPTY_CODEX_RESET_CREDIT_ATTEMPT_LEDGER } from './codex-reset-credit-attempt-ledger' import { DEFAULT_STATUS_BAR_ITEMS } from './status-bar-defaults' -import { DEFAULT_TERMINAL_FONT_WEIGHT, DEFAULT_TERMINAL_FONT_WEIGHT_BOLD } from './terminal-fonts' -import { getDefaultTerminalQuickCommands } from './terminal-quick-commands' import type { VoiceSettings } from './speech-types' import { cloneDefaultWorkspaceStatuses } from './workspace-statuses' -import { TASK_PROVIDERS } from './task-providers' import { DEFAULT_WORKTREE_CARD_PROPERTIES } from './worktree/card-properties' -import { getDefaultSourceControlAiSettings } from './source-control-ai' -import { DEFAULT_APP_ICON_ID } from './app-icon' -import { DEFAULT_OPEN_IN_APPLICATIONS } from './open-in-applications' -import { DEFAULT_BROWSER_PAGE_ZOOM_LEVEL } from './browser-page-zoom' -import { DEFAULT_DISABLED_TUI_AGENTS } from './tui-agent-selection' -import { DEFAULT_TUI_AGENT_ARGS, DEFAULT_TUI_AGENT_ENV } from './tui-agent-launch-defaults' -import { UI_LANGUAGE_SYSTEM } from './ui-language' -import { - DEFAULT_LEFT_SIDEBAR_TINT_COLOR, - DEFAULT_LEFT_SIDEBAR_TINT_OPACITY -} from './left-sidebar-appearance' -import { DEFAULT_SOURCE_CONTROL_GROUP_ORDER } from './source-control-group-order' -import { DEFAULT_SETUP_AGENT_STARTUP_POLICY } from './setup-agent-startup-policy' -import { DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT } from './terminal-scrollback-policy' import { DEFAULT_USAGE_PERCENTAGE_DISPLAY } from './usage-percentage-display' import { DEFAULT_STATUS_BAR_USAGE_MODE } from './status-bar-usage-mode' +import { buildDefaultSettings } from './default-global-settings' +import { DEFAULT_SETUP_AGENT_STARTUP_POLICY } from './setup-agent-startup-policy' +import { DEFAULT_BROWSER_PAGE_ZOOM_LEVEL } from './browser-page-zoom' export { DEFAULT_STATUS_BAR_ITEMS } from './status-bar-defaults' export { @@ -171,232 +156,19 @@ export function getDefaultWorkspaceDir(homeDir: string): string { } export function getDefaultSettings(homedir: string): GlobalSettings { - return { + return buildDefaultSettings({ workspaceDir: getDefaultWorkspaceDir(homedir), - worktreeVisibilityDefaults: { external: 'hide' }, - nestWorkspaces: true, - workspaceDirHistory: [], - refreshLocalBaseRefOnWorktreeCreate: false, - localBaseRefSuggestionDismissed: false, - autoRenameBranchFromWork: true, - autoRenameBranchFromWorkDefaultedOn: true, - branchPrefix: 'git-username', - branchPrefixCustom: '', - theme: 'system', - leftSidebarAppearanceMode: 'default', - leftSidebarTintColor: DEFAULT_LEFT_SIDEBAR_TINT_COLOR, - leftSidebarTintOpacity: DEFAULT_LEFT_SIDEBAR_TINT_OPACITY, - uiLanguage: UI_LANGUAGE_SYSTEM, - appIcon: DEFAULT_APP_ICON_ID, appFontFamily: DEFAULT_APP_FONT_FAMILY, - editorAutoSave: false, editorAutoSaveDelayMs: DEFAULT_EDITOR_AUTO_SAVE_DELAY_MS, - editorMinimapEnabled: false, - // Why empty: the editor keeps following the terminal font unless the user opts in. - editorFontFamily: '', - editorWordWrap: true, - richMarkdownSpellcheckEnabled: true, - markdownReviewToolsEnabled: true, primarySelectionMiddleClickPaste: getDefaultPrimarySelectionMiddleClickPaste(), - primarySelectionMiddleClickPasteDefaultedForLinux: + primarySelectionDefaultedForLinux: typeof process !== 'undefined' && process.platform === 'linux', - primarySelectionMiddleClickPasteDefaultedForTerminalDefaults: - getDefaultPrimarySelectionMiddleClickPaste(), - terminalFontSize: 14, terminalFontFamily: defaultTerminalFontFamily(), - terminalFontWeight: DEFAULT_TERMINAL_FONT_WEIGHT, - terminalFontWeightBold: DEFAULT_TERMINAL_FONT_WEIGHT_BOLD, - terminalLineHeight: 1, - terminalScrollSensitivity: 1.15, - terminalFastScrollSensitivity: 5, - terminalTuiScrollSensitivity: 1, - terminalTuiScrollSensitivityDefaultedToOne: true, - // Why: "auto" uses WebGL when supported, falling back to DOM on renderer failure or software/unknown GPU. - terminalGpuAcceleration: 'auto', - // Why 'auto': enable ligatures only for known ligature fonts, never forced. Resolver in shared/terminal-ligatures.ts. - terminalLigatures: 'auto', - terminalCursorStyle: 'block', - terminalCursorStyleDefaultedToBlock: true, - terminalCursorBlink: true, - terminalThemeDark: 'Ghostty Default Style Dark', - terminalDividerColorDark: '#3f3f46', - terminalUseSeparateLightTheme: true, - terminalThemeLight: 'Builtin Tango Light', - terminalCustomThemes: [], - terminalDividerColorLight: '#d4d4d8', terminalInactivePaneOpacity: DEFAULT_TERMINAL_INACTIVE_PANE_OPACITY, - terminalActivePaneOpacity: 1, - terminalPaneOpacityTransitionMs: 140, - terminalDividerThicknessPx: 3, - // Why: Windows paste-on-right-click matches native convention; macOS/Linux keep right-click for the context menu. terminalRightClickToPaste: getDefaultTerminalRightClickToPaste(), - terminalRightClickToPasteDefaultedForPlatform: true, - terminalWindowsShell: 'powershell.exe', - terminalWindowsWslDistro: null, - localAccountRuntime: 'auto', - localAccountRuntimeDefaultedToAutoForAllUsers: true, - localAccountWslDistro: null, - localWindowsRuntimeDefault: { kind: 'windows-host' }, - // Why: prefer modern PowerShell when installed, falling back to inbox Windows PowerShell. - terminalWindowsPowerShellImplementation: 'auto', - terminalMouseHideWhileTyping: false, - terminalQuickCommands: getDefaultTerminalQuickCommands(), - // Why: opt-in only, matching Ghostty's default (upgrades never enable it unexpectedly). - terminalFocusFollowsMouse: false, - windowBackgroundBlur: false, - minimizeToTrayOnClose: false, - // Why: default-on everywhere so it round-trips across platforms; only darwin acts on it. - showMenuBarIcon: true, - terminalClipboardOnSelect: false, - // Why: default on so Zellij/tmux/nvim copy works out of the box. Query - // replies stay disabled and payload size is capped in the OSC 52 handler. - // This default only covers new profiles; existing ones persisted `false` - // and are flipped once by the stamp below (shared/osc52-clipboard-settings.ts, - // applied by both the Electron store and the web client's localStorage store). - terminalAllowOsc52Clipboard: true, - terminalAllowOsc52ClipboardDefaultedOnForAllUsers: true, - claudeAgentTeamsMode: 'off', - setupScriptLaunchMode: 'new-tab', - terminalScrollbackRows: DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT, - httpProxyUrl: '', - httpProxyBypassRules: '', - electronHttp1CompatibilityMode: false, - openLinksInApp: false, - localhostWorktreeLabelsEnabled: false, - openLinksInAppPreferencePrompted: false, - openLinksInAppModifierInverts: false, - terminalLinkActionPopoverEnabled: true, - openAgentTabsInChatByDefault: false, - experimentalNativeChat: false, - nativeChatSessionOptions: {}, - openInApplications: [...DEFAULT_OPEN_IN_APPLICATIONS], - rightSidebarOpenByDefault: true, - showGitIgnoredFiles: true, - sourceControlViewMode: 'list', - sourceControlGroupOrder: DEFAULT_SOURCE_CONTROL_GROUP_ORDER, - sourceControlCompareAgainstUpstream: false, - showTitlebarAppName: true, - showTasksButton: true, - showAutomationsButton: true, - artifactsEnabled: true, - artifactSharingEnabled: false, - agentSkillSharingEnabled: false, - nestedWorkerMaxDepth: 1, - showArtifactsButton: false, - showSkillsButton: false, - showMobileButton: true, - showPinnedWorktreesInGroups: false, - ctrlTabOrderMode: 'mru', - // Why: Orca-first keeps core shortcuts working from a focused terminal; TUI-ownership users opt in. - terminalShortcutPolicy: 'orca-first', - floatingTerminalEnabled: true, - browserClientHostedRemoteEnabled: true, - floatingTerminalDefaultedForAllUsers: true, - floatingTerminalCwd: '~', - floatingTerminalTrustedCwds: [], - floatingTerminalCwdMigratedToAppWorkspace: true, - floatingTerminalTriggerLocation: 'floating-button', notifications: getDefaultNotificationSettings(), - diffDefaultView: 'inline', - diffWordWrap: false, - combinedDiffFileTreeVisibleByDefault: false, - prBotAuthorOverrides: [], - promptCacheTimerEnabled: false, - promptCacheTtlMs: 300_000, - codexManagedAccounts: [], - activeCodexManagedAccountId: null, - activeCodexManagedAccountIdsByRuntime: { host: null, wsl: {} }, - claudeManagedAccounts: [], - activeClaudeManagedAccountId: null, - terminalScopeHistoryByWorktree: true, - terminalHiddenViewParking: true, - // C1 kill switches โ€” runtime reads stay `!== false` so older persisted - // settings objects (which omit them) keep the default-on behavior. - terminalSshViewParking: true, - terminalHiddenWorktreeRetentionBudget: true, - browserGuestWorktreeRetentionBudget: true, - terminalMainSideEffectAuthority: true, - terminalHiddenDeliveryGate: true, - terminalModelQueryAuthority: true, - defaultTuiAgent: null, - disabledTuiAgents: [...DEFAULT_DISABLED_TUI_AGENTS], - pluginSystemEnabled: false, - disabledPlugins: [], - pluginConsents: {}, - devPluginPaths: [], - claudeAgentTeamsDefaultDisabledMigrated: true, - skipDeleteWorktreeConfirm: false, - skipCloseTerminalWithRunningProcessConfirm: false, - skipDeleteAutomationConfirm: false, - skipDeleteArtifactConfirm: false, - skipCodexRateLimitResetConfirm: false, - defaultTaskViewPreset: 'all', - defaultTaskSource: 'github', - visibleTaskProviders: [...TASK_PROVIDERS], - visibleTaskProvidersDefaultedForJira: true, - defaultRepoSelection: null, - defaultLinearTeamSelection: null, - opencodeSessionCookie: '', - opencodeWorkspaceId: '', - minimaxGroupId: '', - minimaxUsageModels: 'general', - geminiCliOAuthEnabled: false, - agentCmdOverrides: {}, - agentDefaultArgs: { ...DEFAULT_TUI_AGENT_ARGS }, - agentDefaultEnv: { ...DEFAULT_TUI_AGENT_ENV }, - agentYoloDefaultsMigrated: true, - agentStatusHooksEnabled: true, - tabAutoGenerateTitle: false, - confirmClosePinnedTab: true, - keepComputerAwakeWhileAgentsRun: false, - // Why: 'auto' probes keyboard layout so non-US users can type Option chars like @/โ‚ฌ/[ out of the box (issue #903). See src/renderer/src/lib/keyboard-layout/*. - terminalMacOptionAsAlt: 'auto', - terminalMacOptionAsAltMigrated: false, - terminalJISYenToBackslash: false, - experimentalMobile: false, - mobileEmulatorEnabled: true, - mobileEmulatorDefaultDeviceUdid: null, - androidSdkPath: null, - // Why: indefinite hold โ€” the "Restore" banner is the explicit return action, no wall-clock guess. See docs/mobile-fit-hold.md. - mobileAutoRestoreFitMs: null, - // Why: Anywhere (Relay + local) is the default; local-only is written only on explicit same-network choice. - mobilePairingConnectionMode: 'automatic', - mobilePairingCustomAddress: null, - mobilePairingCustomAddresses: [], - // Why: off keeps the cosmetic overlay unmounted for users who never opt in. - experimentalPet: false, - experimentalActivity: false, - experimentalActivityDefaultedOffForAllUsers: true, - experimentalTerminalAttention: false, - experimentalAgentHibernation: false, - agentHibernationIdleMs: 30 * 60 * 1000, - experimentalNewWorktreeCardStyle: false, - experimentalEphemeralVms: false, - compactWorktreeCards: false, - // Why: local desktop stays the default until the user picks a saved runtime environment. - activeRuntimeEnvironmentId: null, - // Why: hydrate a stable empty shape so renderer optional-chained reads never hit undefined. - githubProjects: { - pinned: [], - recent: [], - lastViewByProject: {}, - activeProject: null - }, - // Why: keep agent/model maps empty so first use follows the default agent's model, not a frozen stale choice. - commitMessageAi: { - enabled: true, - agentId: null, - selectedModelByAgent: {}, - discoveredModelsByAgent: {}, - selectedModelByAgentByHost: {}, - discoveredModelsByAgentByHost: {}, - selectedThinkingByModel: {}, - customPrompt: '', - customAgentCommand: '' - }, - sourceControlAi: getDefaultSourceControlAiSettings(), voice: getDefaultVoiceSettings() - } + }) } export function getDefaultVoiceSettings(): VoiceSettings { diff --git a/src/shared/default-global-settings.ts b/src/shared/default-global-settings.ts new file mode 100644 index 00000000000..a501dfd6ec6 --- /dev/null +++ b/src/shared/default-global-settings.ts @@ -0,0 +1,257 @@ +import type { GlobalSettings } from './global-settings-types' +import type { NotificationSettings } from './notification-settings-types' +import type { VoiceSettings } from './speech-types' +import { DEFAULT_TERMINAL_FONT_WEIGHT, DEFAULT_TERMINAL_FONT_WEIGHT_BOLD } from './terminal-fonts' +import { getDefaultTerminalQuickCommands } from './terminal-quick-commands' +import { TASK_PROVIDERS } from './task-providers' +import { getDefaultSourceControlAiSettings } from './source-control-ai' +import { DEFAULT_APP_ICON_ID } from './app-icon' +import { DEFAULT_OPEN_IN_APPLICATIONS } from './open-in-applications' +import { DEFAULT_DISABLED_TUI_AGENTS } from './tui-agent-selection' +import { DEFAULT_TUI_AGENT_ARGS, DEFAULT_TUI_AGENT_ENV } from './tui-agent-launch-defaults' +import { UI_LANGUAGE_SYSTEM } from './ui-language' +import { + DEFAULT_LEFT_SIDEBAR_TINT_COLOR, + DEFAULT_LEFT_SIDEBAR_TINT_OPACITY +} from './left-sidebar-appearance' +import { DEFAULT_SOURCE_CONTROL_GROUP_ORDER } from './source-control-group-order' +import { DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT } from './terminal-scrollback-policy' + +export function buildDefaultSettings(args: { + workspaceDir: string + appFontFamily: string + editorAutoSaveDelayMs: number + primarySelectionMiddleClickPaste: boolean + primarySelectionDefaultedForLinux: boolean + terminalFontFamily: string + terminalInactivePaneOpacity: number + terminalRightClickToPaste: boolean + notifications: NotificationSettings + voice: VoiceSettings +}): GlobalSettings { + return { + workspaceDir: args.workspaceDir, + worktreeVisibilityDefaults: { external: 'hide' }, + nestWorkspaces: true, + workspaceDirHistory: [], + refreshLocalBaseRefOnWorktreeCreate: false, + localBaseRefSuggestionDismissed: false, + autoRenameBranchFromWork: true, + autoRenameBranchFromWorkDefaultedOn: true, + branchPrefix: 'git-username', + branchPrefixCustom: '', + theme: 'system', + leftSidebarAppearanceMode: 'default', + leftSidebarTintColor: DEFAULT_LEFT_SIDEBAR_TINT_COLOR, + leftSidebarTintOpacity: DEFAULT_LEFT_SIDEBAR_TINT_OPACITY, + uiLanguage: UI_LANGUAGE_SYSTEM, + appIcon: DEFAULT_APP_ICON_ID, + appFontFamily: args.appFontFamily, + editorAutoSave: false, + editorAutoSaveDelayMs: args.editorAutoSaveDelayMs, + editorMinimapEnabled: false, + // Why empty: the editor keeps following the terminal font unless the user opts in. + editorFontFamily: '', + editorWordWrap: true, + richMarkdownSpellcheckEnabled: true, + markdownReviewToolsEnabled: true, + primarySelectionMiddleClickPaste: args.primarySelectionMiddleClickPaste, + primarySelectionMiddleClickPasteDefaultedForLinux: args.primarySelectionDefaultedForLinux, + primarySelectionMiddleClickPasteDefaultedForTerminalDefaults: + args.primarySelectionMiddleClickPaste, + terminalFontSize: 14, + terminalFontFamily: args.terminalFontFamily, + terminalFontWeight: DEFAULT_TERMINAL_FONT_WEIGHT, + terminalFontWeightBold: DEFAULT_TERMINAL_FONT_WEIGHT_BOLD, + terminalLineHeight: 1, + terminalScrollSensitivity: 1.15, + terminalFastScrollSensitivity: 5, + terminalTuiScrollSensitivity: 1, + terminalTuiScrollSensitivityDefaultedToOne: true, + // Why: "auto" uses WebGL when supported, falling back to DOM on renderer failure or software/unknown GPU. + terminalGpuAcceleration: 'auto', + // Why 'auto': enable ligatures only for known ligature fonts, never forced. Resolver in shared/terminal-ligatures.ts. + terminalLigatures: 'auto', + terminalCursorStyle: 'block', + terminalCursorStyleDefaultedToBlock: true, + terminalCursorBlink: true, + terminalThemeDark: 'Ghostty Default Style Dark', + terminalDividerColorDark: '#3f3f46', + terminalUseSeparateLightTheme: true, + terminalThemeLight: 'Builtin Tango Light', + terminalCustomThemes: [], + terminalDividerColorLight: '#d4d4d8', + terminalInactivePaneOpacity: args.terminalInactivePaneOpacity, + terminalActivePaneOpacity: 1, + terminalPaneOpacityTransitionMs: 140, + terminalDividerThicknessPx: 3, + // Why: Windows paste-on-right-click matches native convention; macOS/Linux keep right-click for the context menu. + terminalRightClickToPaste: args.terminalRightClickToPaste, + terminalRightClickToPasteDefaultedForPlatform: true, + terminalWindowsShell: 'powershell.exe', + terminalWindowsWslDistro: null, + localAccountRuntime: 'auto', + localAccountRuntimeDefaultedToAutoForAllUsers: true, + localAccountWslDistro: null, + localWindowsRuntimeDefault: { kind: 'windows-host' }, + // Why: prefer modern PowerShell when installed, falling back to inbox Windows PowerShell. + terminalWindowsPowerShellImplementation: 'auto', + terminalMouseHideWhileTyping: false, + terminalQuickCommands: getDefaultTerminalQuickCommands(), + // Why: opt-in only, matching Ghostty's default (upgrades never enable it unexpectedly). + terminalFocusFollowsMouse: false, + windowBackgroundBlur: false, + minimizeToTrayOnClose: false, + // Why: default-on everywhere so it round-trips across platforms; only darwin acts on it. + showMenuBarIcon: true, + terminalClipboardOnSelect: false, + // Why: default on so Zellij/tmux/nvim copy works out of the box. Query + // replies stay disabled and payload size is capped in the OSC 52 handler. + // This default only covers new profiles; existing ones persisted `false` + // and are flipped once by the stamp below (shared/osc52-clipboard-settings.ts, + // applied by both the Electron store and the web client's localStorage store). + terminalAllowOsc52Clipboard: true, + terminalAllowOsc52ClipboardDefaultedOnForAllUsers: true, + claudeAgentTeamsMode: 'off', + setupScriptLaunchMode: 'new-tab', + terminalScrollbackRows: DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT, + httpProxyUrl: '', + httpProxyBypassRules: '', + electronHttp1CompatibilityMode: false, + openLinksInApp: false, + localhostWorktreeLabelsEnabled: false, + openLinksInAppPreferencePrompted: false, + openLinksInAppModifierInverts: false, + terminalLinkActionPopoverEnabled: true, + openAgentTabsInChatByDefault: false, + experimentalNativeChat: false, + nativeChatSessionOptions: {}, + openInApplications: [...DEFAULT_OPEN_IN_APPLICATIONS], + rightSidebarOpenByDefault: true, + showGitIgnoredFiles: true, + sourceControlViewMode: 'list', + sourceControlGroupOrder: DEFAULT_SOURCE_CONTROL_GROUP_ORDER, + sourceControlCompareAgainstUpstream: false, + showTitlebarAppName: true, + showTasksButton: true, + showAutomationsButton: true, + artifactsEnabled: true, + artifactSharingEnabled: false, + agentSkillSharingEnabled: false, + nestedWorkerMaxDepth: 1, + showArtifactsButton: false, + showSkillsButton: false, + showMobileButton: true, + showPinnedWorktreesInGroups: false, + ctrlTabOrderMode: 'mru', + // Why: Orca-first keeps core shortcuts working from a focused terminal; TUI-ownership users opt in. + terminalShortcutPolicy: 'orca-first', + floatingTerminalEnabled: true, + browserClientHostedRemoteEnabled: true, + floatingTerminalDefaultedForAllUsers: true, + floatingTerminalCwd: '~', + floatingTerminalTrustedCwds: [], + floatingTerminalCwdMigratedToAppWorkspace: true, + floatingTerminalTriggerLocation: 'floating-button', + notifications: args.notifications, + diffDefaultView: 'inline', + diffWordWrap: false, + combinedDiffFileTreeVisibleByDefault: false, + prBotAuthorOverrides: [], + promptCacheTimerEnabled: false, + promptCacheTtlMs: 300_000, + codexManagedAccounts: [], + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: {} }, + claudeManagedAccounts: [], + activeClaudeManagedAccountId: null, + terminalScopeHistoryByWorktree: true, + terminalHiddenViewParking: true, + // C1 kill switches โ€” runtime reads stay `!== false` so older persisted + // settings objects (which omit them) keep the default-on behavior. + terminalSshViewParking: true, + terminalHiddenWorktreeRetentionBudget: true, + browserGuestWorktreeRetentionBudget: true, + terminalMainSideEffectAuthority: true, + terminalHiddenDeliveryGate: true, + terminalModelQueryAuthority: true, + defaultTuiAgent: null, + disabledTuiAgents: [...DEFAULT_DISABLED_TUI_AGENTS], + pluginSystemEnabled: false, + disabledPlugins: [], + pluginConsents: {}, + devPluginPaths: [], + claudeAgentTeamsDefaultDisabledMigrated: true, + skipDeleteWorktreeConfirm: false, + skipCloseTerminalWithRunningProcessConfirm: false, + skipDeleteAutomationConfirm: false, + skipDeleteArtifactConfirm: false, + skipCodexRateLimitResetConfirm: false, + defaultTaskViewPreset: 'all', + defaultTaskSource: 'github', + visibleTaskProviders: [...TASK_PROVIDERS], + visibleTaskProvidersDefaultedForJira: true, + defaultRepoSelection: null, + defaultLinearTeamSelection: null, + opencodeSessionCookie: '', + opencodeWorkspaceId: '', + minimaxGroupId: '', + minimaxUsageModels: 'general', + geminiCliOAuthEnabled: false, + agentCmdOverrides: {}, + agentDefaultArgs: { ...DEFAULT_TUI_AGENT_ARGS }, + agentDefaultEnv: { ...DEFAULT_TUI_AGENT_ENV }, + agentYoloDefaultsMigrated: true, + agentStatusHooksEnabled: true, + tabAutoGenerateTitle: false, + confirmClosePinnedTab: true, + keepComputerAwakeWhileAgentsRun: false, + // Why: 'auto' probes keyboard layout so non-US users can type Option chars like @/โ‚ฌ/[ out of the box (issue #903). See src/renderer/src/lib/keyboard-layout/*. + terminalMacOptionAsAlt: 'auto', + terminalMacOptionAsAltMigrated: false, + terminalJISYenToBackslash: false, + experimentalMobile: false, + mobileEmulatorEnabled: true, + mobileEmulatorDefaultDeviceUdid: null, + androidSdkPath: null, + // Why: indefinite hold โ€” the "Restore" banner is the explicit return action, no wall-clock guess. See docs/mobile-fit-hold.md. + mobileAutoRestoreFitMs: null, + // Why: Anywhere (Relay + local) is the default; local-only is written only on explicit same-network choice. + mobilePairingConnectionMode: 'automatic', + mobilePairingCustomAddress: null, + mobilePairingCustomAddresses: [], + // Why: off keeps the cosmetic overlay unmounted for users who never opt in. + experimentalPet: false, + experimentalActivity: false, + experimentalActivityDefaultedOffForAllUsers: true, + experimentalTerminalAttention: false, + experimentalAgentHibernation: false, + agentHibernationIdleMs: 30 * 60 * 1000, + experimentalNewWorktreeCardStyle: false, + experimentalEphemeralVms: false, + compactWorktreeCards: false, + // Why: local desktop stays the default until the user picks a saved runtime environment. + activeRuntimeEnvironmentId: null, + // Why: hydrate a stable empty shape so renderer optional-chained reads never hit undefined. + githubProjects: { + pinned: [], + recent: [], + lastViewByProject: {}, + activeProject: null + }, + // Why: keep agent/model maps empty so first use follows the default agent's model, not a frozen stale choice. + commitMessageAi: { + enabled: true, + agentId: null, + selectedModelByAgent: {}, + discoveredModelsByAgent: {}, + selectedModelByAgentByHost: {}, + discoveredModelsByAgentByHost: {}, + selectedThinkingByModel: {}, + customPrompt: '', + customAgentCommand: '' + }, + sourceControlAi: getDefaultSourceControlAiSettings(), + voice: args.voice + } +} diff --git a/src/shared/marine-creature-names-primary.ts b/src/shared/marine-creature-names-primary.ts new file mode 100644 index 00000000000..f23cb1267a4 --- /dev/null +++ b/src/shared/marine-creature-names-primary.ts @@ -0,0 +1,278 @@ +export const MARINE_CREATURE_NAMES_PRIMARY = [ + 'Nautilus', + 'Seahorse', + 'Starfish', + 'Coral', + 'Narwhal', + 'Jellyfish', + 'Octopus', + 'Manta', + 'Dolphin', + 'Manatee', + 'Cuttlefish', + 'Anemone', + 'Urchin', + 'Triton', + 'Nudibranch', + 'Coelacanth', + 'Oarfish', + 'Beluga', + 'Dugong', + 'Porpoise', + 'Stingray', + 'Hammerhead', + 'Wobbegong', + 'Sawfish', + 'Chimaera', + 'Anglerfish', + 'Viperfish', + 'Dragonfish', + 'Axolotl', + 'Otter', + 'Seadragon', + 'Seahare', + 'Squid', + 'Argonaut', + 'Sponge', + 'Barnacle', + 'Cowrie', + 'Guitarfish', + 'Lamprey', + 'Isopod', + 'Lionfish', + 'Clownfish', + 'Angelfish', + 'Butterflyfish', + 'Parrotfish', + 'Pufferfish', + 'Moonfish', + 'Firefish', + 'Unicornfish', + 'Rainbowfish', + 'Betta', + 'Discus', + 'Arowana', + 'Koi', + 'Piranha', + 'Barracuda', + 'Moray', + 'Sunfish', + 'Lanternfish', + 'Archerfish', + 'Mudskipper', + 'Hatchetfish', + 'Knifefish', + 'Leaffish', + 'Glassfish', + 'Ropefish', + 'Bichir', + 'Tigerfish', + 'Cardinalfish', + 'Lungfish', + 'Opah', + 'Frogfish', + 'Stonefish', + 'Cutlassfish', + 'Paddlefish', + 'Arapaima', + 'Mandarin', + 'Blobfish', + 'Thresher', + 'Vaquita', + 'Pipefish', + 'Guppy', + 'Tetra', + 'Danio', + 'Cichlid', + 'Oscar', + 'Gourami', + 'Killifish', + 'Rasbora', + 'Pleco', + 'Goldfish', + 'Molly', + 'Platy', + 'Barb', + 'Loach', + 'Medaka', + 'Pacu', + 'Filefish', + 'Boxfish', + 'Cowfish', + 'Surgeonfish', + 'Damselfish', + 'Wrasse', + 'Goby', + 'Blenny', + 'Conger', + 'Sculpin', + 'Darter', + 'Remora', + 'Pilotfish', + 'Trumpetfish', + 'Cornetfish', + 'Jawfish', + 'Toadfish', + 'Pearlfish', + 'Driftfish', + 'Lumpfish', + 'Snailfish', + 'Stickleback', + 'Halfbeak', + 'Snipefish', + 'Pencilfish', + 'Snakehead', + 'Tripletail', + 'Lookdown', + 'Sweetlips', + 'Squirrelfish', + 'Soldierfish', + 'Rabbitfish', + 'Hawkfish', + 'Bannerfish', + 'Batfish', + 'Chromis', + 'Anthias', + 'Fusilier', + 'Sweeper', + 'Emperor', + 'Ponyfish', + 'Kelpfish', + 'Weever', + 'Pearlside', + 'Opaleye', + 'Ballyhoo', + 'Needlefish', + 'Triggerfish', + 'Gar', + 'Tarpon', + 'Sailfish', + 'Conch', + 'Walrus', + 'Seal', + 'Penguin', + 'Hagfish', + 'Gulper', + 'Hydra', + 'Krill', + 'Salp', + 'Tunicate', + 'Crinoid', + 'Polyp', + 'Limpet', + 'Whelk', + 'Bowfin', + 'Minnow', + 'Gudgeon', + 'Goldeye', + 'Mooneye', + 'Soapfish', + 'Leatherjacket', + 'Rudderfish', + 'Sabrefish', + 'Puffin', + 'Albatross', + 'Osprey', + 'Pelican', + 'Petrel', + 'Gannet', + 'Tern', + 'Cormorant', + 'Leatherback', + 'Hawksbill', + 'Loggerhead', + 'Dottyback', + 'Basslet', + 'Gramma', + 'Hamlet', + 'Hogfish', + 'Dartfish', + 'Foxface', + 'Shrimpfish', + 'Pyrosome', + 'Seapen', + 'Medusa', + 'Candlefish', + 'Silverside', + 'Shiner', + 'Eelpout', + 'Madtom', + 'Shovelnose', + 'Sandlance', + 'Tubesnout', + 'Marlin', + 'Bonefish', + 'Dace', + 'Rudd', + 'Spirula', + 'Permit', + 'Grunt', + 'Trevally', + 'Croaker', + 'Drum', + 'Ladyfish', + 'Bullhead', + 'Torpedo', + 'Skate', + 'Hermit', + 'Fiddler', + 'Fireworm', + 'Tubeworm', + 'Amphipod', + 'Copepod', + 'Pteropod', + 'Seaslug', + 'Swordtail', + 'Bluegill', + 'Pickerel', + 'Redhorse', + 'Logperch', + 'Goblin', + 'Catshark', + 'Harlequin', + 'Murex', + 'Volute', + 'Wentletrap', + 'Nerite', + 'Stargazer', + 'Handfish', + 'Flyingfish', + 'Waspfish', + 'Skua', + 'Shearwater', + 'Guillemot', + 'Razorbill', + 'Auk', + 'Fulmar', + 'Murre', + 'Velvetfish', + 'Prowfish', + 'Sandperch', + 'Bobtail', + 'Chiton', + 'Featherstar', + 'Nereid', + 'Siren', + 'Kraken', + 'Selkie', + 'Leviathan', + 'Wolffish', + 'Wreckfish', + 'Horseshoe', + 'Orca', + 'Cachalot', + 'Rorqual', + 'Grampus', + 'Humpback', + 'Bowhead', + 'Finback', + 'Sealion', + 'Pinniped', + 'Cetacean', + 'Mako', + 'Porbeagle', + 'Dogfish', + 'Spurdog', + 'Tope', + 'Bonnethead' +] as const diff --git a/src/shared/marine-creature-names-secondary.ts b/src/shared/marine-creature-names-secondary.ts new file mode 100644 index 00000000000..7c33ccfbab6 --- /dev/null +++ b/src/shared/marine-creature-names-secondary.ts @@ -0,0 +1,278 @@ +export const MARINE_CREATURE_NAMES_SECONDARY = [ + 'Sixgill', + 'Sevengill', + 'Angelshark', + 'Houndshark', + 'Megamouth', + 'Cookiecutter', + 'Bramble', + 'Ghostshark', + 'Sandtiger', + 'Galeocerdo', + 'Nursehound', + 'Cownose', + 'Devilray', + 'Eagleray', + 'Butterflyray', + 'Numbfish', + 'Stingaree', + 'Mantaray', + 'Tuna', + 'Albacore', + 'Bonito', + 'Skipjack', + 'Wahoo', + 'Kingfish', + 'Mackerel', + 'Cero', + 'Dorado', + 'Mahimahi', + 'Escolar', + 'Pomfret', + 'Butterfish', + 'Cod', + 'Haddock', + 'Pollock', + 'Whiting', + 'Hake', + 'Ling', + 'Cusk', + 'Burbot', + 'Saithe', + 'Sablefish', + 'Lingcod', + 'Greenling', + 'Rockling', + 'Halibut', + 'Flounder', + 'Sole', + 'Plaice', + 'Turbot', + 'Brill', + 'Dab', + 'Fluke', + 'Megrim', + 'Sanddab', + 'Herring', + 'Sardine', + 'Anchovy', + 'Sprat', + 'Pilchard', + 'Menhaden', + 'Shad', + 'Alewife', + 'Salmon', + 'Trout', + 'Char', + 'Grayling', + 'Steelhead', + 'Kokanee', + 'Chinook', + 'Coho', + 'Sockeye', + 'Taimen', + 'Huchen', + 'Cisco', + 'Inconnu', + 'Vendace', + 'Whitefish', + 'Perch', + 'Bass', + 'Snapper', + 'Grouper', + 'Seabass', + 'Tilefish', + 'Bream', + 'Porgy', + 'Sheepshead', + 'Pinfish', + 'Scup', + 'Tautog', + 'Sander', + 'Zander', + 'Walleye', + 'Sauger', + 'Ruffe', + 'Comber', + 'Hind', + 'Coney', + 'Graysby', + 'Margate', + 'Pompano', + 'Amberjack', + 'Yellowtail', + 'Scad', + 'Runner', + 'Mullet', + 'Goatfish', + 'Threadfin', + 'Snook', + 'Barramundi', + 'Milkfish', + 'Mojarra', + 'Weakfish', + 'Corbina', + 'Queenfish', + 'Kahawai', + 'Tilapia', + 'Cunner', + 'Tuskfish', + 'Razorfish', + 'Tang', + 'Porcupinefish', + 'Burrfish', + 'Eel', + 'Wolfeel', + 'Gardeneel', + 'Ribboneel', + 'Cuskeel', + 'Catfish', + 'Cory', + 'Ratfish', + 'Elephantfish', + 'Goosefish', + 'Monkfish', + 'Sargassum', + 'Coffinfish', + 'Seadevil', + 'Scorpionfish', + 'Rockfish', + 'Cabezon', + 'Rosefish', + 'Redfish', + 'Bocaccio', + 'Thornyhead', + 'Gurnard', + 'Searobin', + 'Fangtooth', + 'Bristlemouth', + 'Barreleye', + 'Spookfish', + 'Telescopefish', + 'Lancetfish', + 'Tripodfish', + 'Spiderfish', + 'Daggertooth', + 'Pearleye', + 'Ridgehead', + 'Snaggletooth', + 'Sturgeon', + 'Sterlet', + 'Kaluga', + 'Cockle', + 'Mussel', + 'Clam', + 'Oyster', + 'Scallop', + 'Abalone', + 'Periwinkle', + 'Cerith', + 'Turban', + 'Tellin', + 'Geoduck', + 'Quahog', + 'Piddock', + 'Shipworm', + 'Lobster', + 'Crayfish', + 'Shrimp', + 'Prawn', + 'Langostino', + 'Langouste', + 'Yabby', + 'Marron', + 'Dungeness', + 'Seafan', + 'Staghorn', + 'Elkhorn', + 'Hydroid', + 'Zoanthid', + 'Gorgonian', + 'Seanettle', + 'Moonjelly', + 'Sanddollar', + 'Brittlestar', + 'Basketstar', + 'Sunstar', + 'Seacucumber', + 'Seabiscuit', + 'Hearturchin', + 'Bristleworm', + 'Featherduster', + 'Lugworm', + 'Sandworm', + 'Palolo', + 'Arrowworm', + 'Acornworm', + 'Flatworm', + 'Ribbonworm', + 'Booby', + 'Frigatebird', + 'Tropicbird', + 'Noddy', + 'Kittiwake', + 'Dovekie', + 'Shag', + 'Anhinga', + 'Skimmer', + 'Jaeger', + 'Oystercatcher', + 'Eider', + 'Scoter', + 'Merganser', + 'Brant', + 'Ridley', + 'Flatback', + 'Terrapin', + 'Seasnake', + 'Diatom', + 'Plankton', + 'Larvacean', + 'Doliolid', + 'Kelp', + 'Seagrass', + 'Eelgrass', + 'Dulse', + 'Bladderwrack', + 'Mola', + 'Scylla', + 'Charybdis', + 'Cetus', + 'Proteus', + 'Glaucus', + 'Hippocamp', + 'Jormungandr', + 'Hafgufa', + 'Kelpie', + 'Merrow', + 'Nuckelavee', + 'Afanc', + 'Rusalka', + 'Vodyanoy', + 'Umibozu', + 'Isonade', + 'Ningyo', + 'Mizuchi', + 'Naga', + 'Makara', + 'Bunyip', + 'Taniwha', + 'Marakihau', + 'Lusca', + 'Undine', + 'Nixie', + 'Melusine', + 'Ondine', + 'Tiamat', + 'Dagon', + 'Aspidochelone', + 'Capricorn', + 'Merfolk', + 'Merman', + 'Mermaid', + 'Timingila', + 'Bakekujira', + 'Jiaolong', + 'Encantado', + 'Hraesvelg' +] as const diff --git a/src/shared/marine-creatures.ts b/src/shared/marine-creatures.ts index 3402be6694d..305afb82469 100644 --- a/src/shared/marine-creatures.ts +++ b/src/shared/marine-creatures.ts @@ -1,560 +1,10 @@ -/* eslint-disable max-lines -- Curated marine-creature name corpus; one entry per line is the readable format for a flat data list. */ +import { MARINE_CREATURE_NAMES_PRIMARY } from './marine-creature-names-primary' +import { MARINE_CREATURE_NAMES_SECONDARY } from './marine-creature-names-secondary' + // Why: the auto-generated workspace name pool lives in shared (not renderer) // so the main process can recognize an Orca-generated branch name when deciding // whether auto-rename-from-work is allowed to overwrite it. export const MARINE_CREATURES = [ - 'Nautilus', - 'Seahorse', - 'Starfish', - 'Coral', - 'Narwhal', - 'Jellyfish', - 'Octopus', - 'Manta', - 'Dolphin', - 'Manatee', - 'Cuttlefish', - 'Anemone', - 'Urchin', - 'Triton', - 'Nudibranch', - 'Coelacanth', - 'Oarfish', - 'Beluga', - 'Dugong', - 'Porpoise', - 'Stingray', - 'Hammerhead', - 'Wobbegong', - 'Sawfish', - 'Chimaera', - 'Anglerfish', - 'Viperfish', - 'Dragonfish', - 'Axolotl', - 'Otter', - 'Seadragon', - 'Seahare', - 'Squid', - 'Argonaut', - 'Sponge', - 'Barnacle', - 'Cowrie', - 'Guitarfish', - 'Lamprey', - 'Isopod', - 'Lionfish', - 'Clownfish', - 'Angelfish', - 'Butterflyfish', - 'Parrotfish', - 'Pufferfish', - 'Moonfish', - 'Firefish', - 'Unicornfish', - 'Rainbowfish', - 'Betta', - 'Discus', - 'Arowana', - 'Koi', - 'Piranha', - 'Barracuda', - 'Moray', - 'Sunfish', - 'Lanternfish', - 'Archerfish', - 'Mudskipper', - 'Hatchetfish', - 'Knifefish', - 'Leaffish', - 'Glassfish', - 'Ropefish', - 'Bichir', - 'Tigerfish', - 'Cardinalfish', - 'Lungfish', - 'Opah', - 'Frogfish', - 'Stonefish', - 'Cutlassfish', - 'Paddlefish', - 'Arapaima', - 'Mandarin', - 'Blobfish', - 'Thresher', - 'Vaquita', - 'Pipefish', - 'Guppy', - 'Tetra', - 'Danio', - 'Cichlid', - 'Oscar', - 'Gourami', - 'Killifish', - 'Rasbora', - 'Pleco', - 'Goldfish', - 'Molly', - 'Platy', - 'Barb', - 'Loach', - 'Medaka', - 'Pacu', - 'Filefish', - 'Boxfish', - 'Cowfish', - 'Surgeonfish', - 'Damselfish', - 'Wrasse', - 'Goby', - 'Blenny', - 'Conger', - 'Sculpin', - 'Darter', - 'Remora', - 'Pilotfish', - 'Trumpetfish', - 'Cornetfish', - 'Jawfish', - 'Toadfish', - 'Pearlfish', - 'Driftfish', - 'Lumpfish', - 'Snailfish', - 'Stickleback', - 'Halfbeak', - 'Snipefish', - 'Pencilfish', - 'Snakehead', - 'Tripletail', - 'Lookdown', - 'Sweetlips', - 'Squirrelfish', - 'Soldierfish', - 'Rabbitfish', - 'Hawkfish', - 'Bannerfish', - 'Batfish', - 'Chromis', - 'Anthias', - 'Fusilier', - 'Sweeper', - 'Emperor', - 'Ponyfish', - 'Kelpfish', - 'Weever', - 'Pearlside', - 'Opaleye', - 'Ballyhoo', - 'Needlefish', - 'Triggerfish', - 'Gar', - 'Tarpon', - 'Sailfish', - 'Conch', - 'Walrus', - 'Seal', - 'Penguin', - 'Hagfish', - 'Gulper', - 'Hydra', - 'Krill', - 'Salp', - 'Tunicate', - 'Crinoid', - 'Polyp', - 'Limpet', - 'Whelk', - 'Bowfin', - 'Minnow', - 'Gudgeon', - 'Goldeye', - 'Mooneye', - 'Soapfish', - 'Leatherjacket', - 'Rudderfish', - 'Sabrefish', - 'Puffin', - 'Albatross', - 'Osprey', - 'Pelican', - 'Petrel', - 'Gannet', - 'Tern', - 'Cormorant', - 'Leatherback', - 'Hawksbill', - 'Loggerhead', - 'Dottyback', - 'Basslet', - 'Gramma', - 'Hamlet', - 'Hogfish', - 'Dartfish', - 'Foxface', - 'Shrimpfish', - 'Pyrosome', - 'Seapen', - 'Medusa', - 'Candlefish', - 'Silverside', - 'Shiner', - 'Eelpout', - 'Madtom', - 'Shovelnose', - 'Sandlance', - 'Tubesnout', - 'Marlin', - 'Bonefish', - 'Dace', - 'Rudd', - 'Spirula', - 'Permit', - 'Grunt', - 'Trevally', - 'Croaker', - 'Drum', - 'Ladyfish', - 'Bullhead', - 'Torpedo', - 'Skate', - 'Hermit', - 'Fiddler', - 'Fireworm', - 'Tubeworm', - 'Amphipod', - 'Copepod', - 'Pteropod', - 'Seaslug', - 'Swordtail', - 'Bluegill', - 'Pickerel', - 'Redhorse', - 'Logperch', - 'Goblin', - 'Catshark', - 'Harlequin', - 'Murex', - 'Volute', - 'Wentletrap', - 'Nerite', - 'Stargazer', - 'Handfish', - 'Flyingfish', - 'Waspfish', - 'Skua', - 'Shearwater', - 'Guillemot', - 'Razorbill', - 'Auk', - 'Fulmar', - 'Murre', - 'Velvetfish', - 'Prowfish', - 'Sandperch', - 'Bobtail', - 'Chiton', - 'Featherstar', - 'Nereid', - 'Siren', - 'Kraken', - 'Selkie', - 'Leviathan', - 'Wolffish', - 'Wreckfish', - 'Horseshoe', - 'Orca', - 'Cachalot', - 'Rorqual', - 'Grampus', - 'Humpback', - 'Bowhead', - 'Finback', - 'Sealion', - 'Pinniped', - 'Cetacean', - 'Mako', - 'Porbeagle', - 'Dogfish', - 'Spurdog', - 'Tope', - 'Bonnethead', - 'Sixgill', - 'Sevengill', - 'Angelshark', - 'Houndshark', - 'Megamouth', - 'Cookiecutter', - 'Bramble', - 'Ghostshark', - 'Sandtiger', - 'Galeocerdo', - 'Nursehound', - 'Cownose', - 'Devilray', - 'Eagleray', - 'Butterflyray', - 'Numbfish', - 'Stingaree', - 'Mantaray', - 'Tuna', - 'Albacore', - 'Bonito', - 'Skipjack', - 'Wahoo', - 'Kingfish', - 'Mackerel', - 'Cero', - 'Dorado', - 'Mahimahi', - 'Escolar', - 'Pomfret', - 'Butterfish', - 'Cod', - 'Haddock', - 'Pollock', - 'Whiting', - 'Hake', - 'Ling', - 'Cusk', - 'Burbot', - 'Saithe', - 'Sablefish', - 'Lingcod', - 'Greenling', - 'Rockling', - 'Halibut', - 'Flounder', - 'Sole', - 'Plaice', - 'Turbot', - 'Brill', - 'Dab', - 'Fluke', - 'Megrim', - 'Sanddab', - 'Herring', - 'Sardine', - 'Anchovy', - 'Sprat', - 'Pilchard', - 'Menhaden', - 'Shad', - 'Alewife', - 'Salmon', - 'Trout', - 'Char', - 'Grayling', - 'Steelhead', - 'Kokanee', - 'Chinook', - 'Coho', - 'Sockeye', - 'Taimen', - 'Huchen', - 'Cisco', - 'Inconnu', - 'Vendace', - 'Whitefish', - 'Perch', - 'Bass', - 'Snapper', - 'Grouper', - 'Seabass', - 'Tilefish', - 'Bream', - 'Porgy', - 'Sheepshead', - 'Pinfish', - 'Scup', - 'Tautog', - 'Sander', - 'Zander', - 'Walleye', - 'Sauger', - 'Ruffe', - 'Comber', - 'Hind', - 'Coney', - 'Graysby', - 'Margate', - 'Pompano', - 'Amberjack', - 'Yellowtail', - 'Scad', - 'Runner', - 'Mullet', - 'Goatfish', - 'Threadfin', - 'Snook', - 'Barramundi', - 'Milkfish', - 'Mojarra', - 'Weakfish', - 'Corbina', - 'Queenfish', - 'Kahawai', - 'Tilapia', - 'Cunner', - 'Tuskfish', - 'Razorfish', - 'Tang', - 'Porcupinefish', - 'Burrfish', - 'Eel', - 'Wolfeel', - 'Gardeneel', - 'Ribboneel', - 'Cuskeel', - 'Catfish', - 'Cory', - 'Ratfish', - 'Elephantfish', - 'Goosefish', - 'Monkfish', - 'Sargassum', - 'Coffinfish', - 'Seadevil', - 'Scorpionfish', - 'Rockfish', - 'Cabezon', - 'Rosefish', - 'Redfish', - 'Bocaccio', - 'Thornyhead', - 'Gurnard', - 'Searobin', - 'Fangtooth', - 'Bristlemouth', - 'Barreleye', - 'Spookfish', - 'Telescopefish', - 'Lancetfish', - 'Tripodfish', - 'Spiderfish', - 'Daggertooth', - 'Pearleye', - 'Ridgehead', - 'Snaggletooth', - 'Sturgeon', - 'Sterlet', - 'Kaluga', - 'Cockle', - 'Mussel', - 'Clam', - 'Oyster', - 'Scallop', - 'Abalone', - 'Periwinkle', - 'Cerith', - 'Turban', - 'Tellin', - 'Geoduck', - 'Quahog', - 'Piddock', - 'Shipworm', - 'Lobster', - 'Crayfish', - 'Shrimp', - 'Prawn', - 'Langostino', - 'Langouste', - 'Yabby', - 'Marron', - 'Dungeness', - 'Seafan', - 'Staghorn', - 'Elkhorn', - 'Hydroid', - 'Zoanthid', - 'Gorgonian', - 'Seanettle', - 'Moonjelly', - 'Sanddollar', - 'Brittlestar', - 'Basketstar', - 'Sunstar', - 'Seacucumber', - 'Seabiscuit', - 'Hearturchin', - 'Bristleworm', - 'Featherduster', - 'Lugworm', - 'Sandworm', - 'Palolo', - 'Arrowworm', - 'Acornworm', - 'Flatworm', - 'Ribbonworm', - 'Booby', - 'Frigatebird', - 'Tropicbird', - 'Noddy', - 'Kittiwake', - 'Dovekie', - 'Shag', - 'Anhinga', - 'Skimmer', - 'Jaeger', - 'Oystercatcher', - 'Eider', - 'Scoter', - 'Merganser', - 'Brant', - 'Ridley', - 'Flatback', - 'Terrapin', - 'Seasnake', - 'Diatom', - 'Plankton', - 'Larvacean', - 'Doliolid', - 'Kelp', - 'Seagrass', - 'Eelgrass', - 'Dulse', - 'Bladderwrack', - 'Mola', - // Mythological sea & water creatures from public-domain folklore โ€” joining - // Kraken, Leviathan, Siren, Triton, Hydra, Medusa, Nereid, and Selkie above. - 'Scylla', - 'Charybdis', - 'Cetus', - 'Proteus', - 'Glaucus', - 'Hippocamp', - 'Jormungandr', - 'Hafgufa', - 'Kelpie', - 'Merrow', - 'Nuckelavee', - 'Afanc', - 'Rusalka', - 'Vodyanoy', - 'Umibozu', - 'Isonade', - 'Ningyo', - 'Mizuchi', - 'Naga', - 'Makara', - 'Bunyip', - 'Taniwha', - 'Marakihau', - 'Lusca', - 'Undine', - 'Nixie', - 'Melusine', - 'Ondine', - 'Tiamat', - 'Dagon', - 'Aspidochelone', - 'Capricorn', - 'Merfolk', - 'Merman', - 'Mermaid', - 'Timingila', - 'Bakekujira', - 'Jiaolong', - 'Encantado', - 'Hraesvelg' + ...MARINE_CREATURE_NAMES_PRIMARY, + ...MARINE_CREATURE_NAMES_SECONDARY ] as const diff --git a/src/shared/text-search-glob-patterns.ts b/src/shared/text-search-glob-patterns.ts new file mode 100644 index 00000000000..a677f39e8d8 --- /dev/null +++ b/src/shared/text-search-glob-patterns.ts @@ -0,0 +1,39 @@ +export function splitSearchGlobPatterns(patterns: string): string[] { + const out: string[] = [] + let current = '' + let escaping = false + for (const ch of patterns) { + if (escaping) { + current += `\\${ch}` + escaping = false + continue + } + if (ch === '\\') { + escaping = true + continue + } + if (ch === ',') { + const trimmed = current.trim() + if (trimmed) { + out.push(trimmed) + } + current = '' + continue + } + current += ch + } + if (escaping) { + current += '\\' + } + const trimmed = current.trim() + if (trimmed) { + out.push(trimmed) + } + return out +} + +export function toGitGlobPathspec(glob: string, exclude?: boolean): string { + const needsRecursive = !glob.includes('/') + const pattern = needsRecursive ? `**/${glob}` : glob + return exclude ? `:(exclude,glob)${pattern}` : `:(glob)${pattern}` +} diff --git a/src/shared/text-search-match-accumulator.ts b/src/shared/text-search-match-accumulator.ts new file mode 100644 index 00000000000..addad7ee707 --- /dev/null +++ b/src/shared/text-search-match-accumulator.ts @@ -0,0 +1,78 @@ +import type { SearchFileResult, SearchMatch } from './code-search-types' +import type { SearchAccumulator } from './text-search' + +const MAX_LINE_CONTENT_LENGTH = 500 +const TRUNCATION_MARKER = 'โ€ฆ' + +type ClampedLineContext = { + lineContent: string + column: number + matchLength: number + displayColumn?: number + displayMatchLength?: number +} + +function clampLineContext( + text: string, + matchStart: number, + matchLength: number +): ClampedLineContext { + if (text.length <= MAX_LINE_CONTENT_LENGTH) { + return { lineContent: text, column: matchStart + 1, matchLength } + } + const clampedMatchLength = Math.min(matchLength, MAX_LINE_CONTENT_LENGTH) + const remaining = MAX_LINE_CONTENT_LENGTH - clampedMatchLength + const leftBudget = Math.floor(remaining / 2) + let windowStart = Math.max(0, matchStart - leftBudget) + const windowEnd = Math.min(text.length, windowStart + MAX_LINE_CONTENT_LENGTH) + windowStart = Math.max(0, windowEnd - MAX_LINE_CONTENT_LENGTH) + + let snippet = text.slice(windowStart, windowEnd) + let column = matchStart - windowStart + 1 + if (windowStart > 0) { + snippet = TRUNCATION_MARKER + snippet + column += TRUNCATION_MARKER.length + } + if (windowEnd < text.length) { + snippet += TRUNCATION_MARKER + } + return { + lineContent: snippet, + column: matchStart + 1, + matchLength, + displayColumn: column, + displayMatchLength: clampedMatchLength + } +} + +export function pushSearchMatch(args: { + fileResult: SearchFileResult + accumulator: SearchAccumulator + lineContent: string + matchStart: number + matchLength: number + lineNumber: number + maxResults: number +}): 'continue' | 'stop' { + const clamped = clampLineContext(args.lineContent, args.matchStart, args.matchLength) + const match: SearchMatch = { + line: args.lineNumber, + column: clamped.column, + matchLength: clamped.matchLength, + lineContent: clamped.lineContent + } + if (clamped.displayColumn !== undefined) { + match.displayColumn = clamped.displayColumn + } + if (clamped.displayMatchLength !== undefined) { + match.displayMatchLength = clamped.displayMatchLength + } + args.fileResult.matches.push(match) + args.fileResult.matchCount = (args.fileResult.matchCount ?? 0) + 1 + args.accumulator.totalMatches++ + if (args.accumulator.totalMatches >= args.maxResults) { + args.accumulator.truncated = true + return 'stop' + } + return 'continue' +} diff --git a/src/shared/text-search-paths.ts b/src/shared/text-search-paths.ts new file mode 100644 index 00000000000..c556a10dedf --- /dev/null +++ b/src/shared/text-search-paths.ts @@ -0,0 +1,20 @@ +import { posix, win32 } from 'node:path' + +function pathFlavor(rootPath: string): typeof posix | typeof win32 { + if (/^[a-zA-Z]:[\\/]/.test(rootPath) || rootPath.startsWith('\\\\')) { + return win32 + } + return posix +} + +export function normalizeRelativePath(path: string): string { + return path.replace(/[\\/]+/g, '/').replace(/^\/+/, '') +} + +export function relativeToSearchRoot(rootPath: string, absolutePath: string): string { + return pathFlavor(rootPath).relative(rootPath, absolutePath) +} + +export function joinSearchRoot(rootPath: string, relativePath: string): string { + return pathFlavor(rootPath).join(rootPath, relativePath) +} diff --git a/src/shared/text-search.test.ts b/src/shared/text-search.test.ts index 108d07a76c5..2ecbc53d8b4 100644 --- a/src/shared/text-search.test.ts +++ b/src/shared/text-search.test.ts @@ -12,11 +12,10 @@ import { ingestGitGrepLine, ingestRgJsonLine, MAX_LINE_CONTENT_LENGTH, - normalizeRelativePath, - SEARCH_JSON_STRUCTURE_LIMITS, - splitSearchGlobPatterns, - toGitGlobPathspec + SEARCH_JSON_STRUCTURE_LIMITS } from './text-search' +import { splitSearchGlobPatterns, toGitGlobPathspec } from './text-search-glob-patterns' +import { normalizeRelativePath } from './text-search-paths' describe('normalizeRelativePath', () => { it('collapses mixed separators and strips leading slashes', () => { diff --git a/src/shared/text-search.ts b/src/shared/text-search.ts index 7919e50cb47..289c5820397 100644 --- a/src/shared/text-search.ts +++ b/src/shared/text-search.ts @@ -1,6 +1,3 @@ -/* oxlint-disable max-lines -- Why: single source of truth for rg/git-grep arg - * construction and --json/submatch parsing shared by main process and SSH relay; - * re-splitting would re-introduce the maxBuffer divergence the design doc calls out. */ /** * Shared, pure text-search helpers used by both the local main process and the * SSH relay. No Electron, child_process, or fs โ€” the caller owns process @@ -10,16 +7,13 @@ * can't re-diverge (notably the relay's old execFile maxBuffer that dropped matches). * Design doc: docs/design/share-text-search.md. */ -import { posix, win32 } from 'node:path' import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' import { normalizeSearchResult } from './search-match-count' import { escapeRegex } from './string-utils' -import type { - SearchFileResult, - SearchMatch, - SearchOptions, - SearchResult -} from './code-search-types' +import type { SearchFileResult, SearchOptions, SearchResult } from './code-search-types' +import { pushSearchMatch } from './text-search-match-accumulator' +import { splitSearchGlobPatterns, toGitGlobPathspec } from './text-search-glob-patterns' +import { joinSearchRoot, normalizeRelativePath, relativeToSearchRoot } from './text-search-paths' export type SearchAccumulator = { fileMap: Map @@ -31,30 +25,6 @@ export function createAccumulator(): SearchAccumulator { return { fileMap: new Map(), totalMatches: 0, truncated: false } } -function acceptMatch(fileResult: SearchFileResult): void { - fileResult.matchCount = (fileResult.matchCount ?? 0) + 1 -} - -// Why: normalize separators and strip leading `/` so results are cross-platform stable and don't break callers' `join(rootPath, relPath)`. -export function normalizeRelativePath(path: string): string { - return path.replace(/[\\/]+/g, '/').replace(/^\/+/, '') -} - -function pathFlavor(rootPath: string): typeof posix | typeof win32 { - if (/^[a-zA-Z]:[\\/]/.test(rootPath) || rootPath.startsWith('\\\\')) { - return win32 - } - return posix -} - -function relativeToSearchRoot(rootPath: string, absPath: string): string { - return pathFlavor(rootPath).relative(rootPath, absPath) -} - -function joinSearchRoot(rootPath: string, relPath: string): string { - return pathFlavor(rootPath).join(rootPath, relPath) -} - // โ”€โ”€โ”€ Constants shared by both callers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ export const MAX_MATCHES_PER_FILE = 100 @@ -70,78 +40,6 @@ const SEARCH_MAX_FILE_SIZE = 5 * 1024 * 1024 // Why: mega-byte lines (minified/generated files) ร— 2000-match caps blow past the 16MB SSH relay MAX_MESSAGE_SIZE; clamp each match's context. export const MAX_LINE_CONTENT_LENGTH = 500 -const TRUNCATION_MARKER = 'โ€ฆ' - -function clampLineContext( - text: string, - matchStart: number, - matchLength: number -): { - lineContent: string - column: number - matchLength: number - displayColumn?: number - displayMatchLength?: number -} { - if (text.length <= MAX_LINE_CONTENT_LENGTH) { - return { lineContent: text, column: matchStart + 1, matchLength } - } - // Clamp the match first so a pathological multi-MB regex hit can't defeat the windowing below. - const clampedMatchLength = Math.min(matchLength, MAX_LINE_CONTENT_LENGTH) - const remaining = MAX_LINE_CONTENT_LENGTH - clampedMatchLength - const leftBudget = Math.floor(remaining / 2) - let windowStart = Math.max(0, matchStart - leftBudget) - let windowEnd = Math.min(text.length, windowStart + MAX_LINE_CONTENT_LENGTH) - windowStart = Math.max(0, windowEnd - MAX_LINE_CONTENT_LENGTH) - - let snippet = text.slice(windowStart, windowEnd) - let column = matchStart - windowStart + 1 - if (windowStart > 0) { - snippet = TRUNCATION_MARKER + snippet - column += TRUNCATION_MARKER.length - } - if (windowEnd < text.length) { - snippet = snippet + TRUNCATION_MARKER - } - return { - lineContent: snippet, - column: matchStart + 1, - matchLength, - displayColumn: column, - displayMatchLength: clampedMatchLength - } -} - -// Why: shared by rg and git-grep to preserve the synchronous truncation ordering callers require. -function pushMatch( - fileResult: SearchFileResult, - acc: SearchAccumulator, - clamped: ReturnType, - lineNumber: number, - maxResults: number -): 'continue' | 'stop' { - // Why: direct assignment avoids conditional-spread allocations on the per-match hot path. - const match: SearchMatch = { - line: lineNumber, - column: clamped.column, - matchLength: clamped.matchLength, - lineContent: clamped.lineContent - } - if (clamped.displayColumn !== undefined) { - match.displayColumn = clamped.displayColumn - } - if (clamped.displayMatchLength !== undefined) { - match.displayMatchLength = clamped.displayMatchLength - } - fileResult.matches.push(match) - acceptMatch(fileResult) - acc.totalMatches++ - if (acc.totalMatches >= maxResults) { - acc.truncated = true - return 'stop' - } - return 'continue' -} // โ”€โ”€โ”€ rg โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -150,40 +48,6 @@ export type SearchOptionsLike = Pick< 'caseSensitive' | 'wholeWord' | 'useRegex' | 'includePattern' | 'excludePattern' > -export function splitSearchGlobPatterns(patterns: string): string[] { - const out: string[] = [] - let current = '' - let escaping = false - for (const ch of patterns) { - if (escaping) { - current += `\\${ch}` - escaping = false - continue - } - if (ch === '\\') { - escaping = true - continue - } - if (ch === ',') { - const trimmed = current.trim() - if (trimmed) { - out.push(trimmed) - } - current = '' - continue - } - current += ch - } - if (escaping) { - current += '\\' - } - const trimmed = current.trim() - if (trimmed) { - out.push(trimmed) - } - return out -} - /** * Build the complete rg argv (flags + `--` + query + target) for both callers to spawn as-is. * @@ -284,8 +148,17 @@ export function ingestRgJsonLine( fileResult = { filePath: absPath, relativePath: relPath, matches: [], matchCount: 0 } acc.fileMap.set(absPath, fileResult) } - const clamped = clampLineContext(lineContent, sub.start, sub.end - sub.start) - if (pushMatch(fileResult, acc, clamped, lineNumber, maxResults) === 'stop') { + if ( + pushSearchMatch({ + fileResult, + accumulator: acc, + lineContent, + matchStart: sub.start, + matchLength: sub.end - sub.start, + lineNumber, + maxResults + }) === 'stop' + ) { return 'stop' } } @@ -294,17 +167,6 @@ export function ingestRgJsonLine( // โ”€โ”€โ”€ git grep โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -/** - * Convert a user-facing glob pattern into a git pathspec. - * - * Why: bare git pathspecs only match the repo root, so wrap with `:(glob)` and prepend `**\/` to replicate rg's recursive-by-default globbing. - */ -export function toGitGlobPathspec(glob: string, exclude?: boolean): string { - const needsRecursive = !glob.includes('/') - const pattern = needsRecursive ? `**/${glob}` : glob - return exclude ? `:(exclude,glob)${pattern}` : `:(glob)${pattern}` -} - export function buildGitGrepArgs(query: string, opts: SearchOptionsLike): string[] { // Why: --no-recurse-submodules avoids failing when submodule.recurse=true conflicts with --untracked; --null disambiguates colon-containing filenames. const gitArgs: string[] = [ @@ -426,19 +288,35 @@ export function ingestGitGrepLine( // Why: no JS-side submatch regex (git accepts patterns JS RegExp rejects); fall back to whole-line highlight so the hit still shows. if (submatchRegex === null) { - const clamped = clampLineContext(lineContent, 0, lineContent.length) const fileResult = getFileResult() - return pushMatch(fileResult, acc, clamped, lineNum, maxResults) + return pushSearchMatch({ + fileResult, + accumulator: acc, + lineContent, + matchStart: 0, + matchLength: lineContent.length, + lineNumber: lineNum, + maxResults + }) } submatchRegex.lastIndex = 0 let m: RegExpExecArray | null let acceptedLineMatch = false while ((m = submatchRegex.exec(lineContent)) !== null) { - const clamped = clampLineContext(lineContent, m.index, m[0].length) const fileResult = getFileResult() acceptedLineMatch = true - if (pushMatch(fileResult, acc, clamped, lineNum, maxResults) === 'stop') { + if ( + pushSearchMatch({ + fileResult, + accumulator: acc, + lineContent, + matchStart: m.index, + matchLength: m[0].length, + lineNumber: lineNum, + maxResults + }) === 'stop' + ) { return 'stop' } // Prevent infinite loop on zero-length regex matches. @@ -448,9 +326,18 @@ export function ingestGitGrepLine( } // Why: git grep confirmed the line but JS regex found no occurrence; keep it navigable, don't drop a git-confirmed hit. if (!acceptedLineMatch) { - const clamped = clampLineContext(lineContent, 0, lineContent.length) const fileResult = getFileResult() - if (pushMatch(fileResult, acc, clamped, lineNum, maxResults) === 'stop') { + if ( + pushSearchMatch({ + fileResult, + accumulator: acc, + lineContent, + matchStart: 0, + matchLength: lineContent.length, + lineNumber: lineNum, + maxResults + }) === 'stop' + ) { return 'stop' } } From 6c0de76ced3292576390771a98016bd97ace18c2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:29:05 -0700 Subject: [PATCH 15/29] Split port scanning and terminal helpers (#16765) --- config/max-lines-baseline.txt | 3 - src/main/ports/advertised-url-cache-update.ts | 95 +++ src/main/ports/advertised-url-parsing.ts | 283 +++++++ .../ports/advertised-url-reconciliation.ts | 87 +++ src/main/ports/advertised-url-watcher.test.ts | 8 +- src/main/ports/advertised-url-watcher.ts | 475 ++---------- .../local-workspace-platform-port-scanner.ts | 322 ++++++++ .../ports/local-workspace-port-address.ts | 71 ++ .../ports/local-workspace-port-attribution.ts | 210 +++++ .../ports/local-workspace-port-scan-state.ts | 122 +++ .../local-workspace-port-scanner.test.ts | 11 +- .../ports/local-workspace-port-scanner.ts | 727 +----------------- src/main/ports/workspace-port-ownership.ts | 3 +- tests/e2e/helpers/terminal-active-pane.ts | 159 ++++ tests/e2e/helpers/terminal-pane-identity.ts | 113 +++ tests/e2e/helpers/terminal-pane-operations.ts | 226 ++++++ tests/e2e/helpers/terminal.ts | 512 ++---------- 17 files changed, 1838 insertions(+), 1589 deletions(-) create mode 100644 src/main/ports/advertised-url-cache-update.ts create mode 100644 src/main/ports/advertised-url-parsing.ts create mode 100644 src/main/ports/advertised-url-reconciliation.ts create mode 100644 src/main/ports/local-workspace-platform-port-scanner.ts create mode 100644 src/main/ports/local-workspace-port-address.ts create mode 100644 src/main/ports/local-workspace-port-attribution.ts create mode 100644 src/main/ports/local-workspace-port-scan-state.ts create mode 100644 tests/e2e/helpers/terminal-active-pane.ts create mode 100644 tests/e2e/helpers/terminal-pane-identity.ts create mode 100644 tests/e2e/helpers/terminal-pane-operations.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index e29a7426371..8485d21a245 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -21,8 +21,6 @@ inline src/main/ipc/filesystem-watcher.ts inline src/main/ipc/filesystem.ts inline src/main/ipc/worktree-remote.ts inline src/main/linear/issues.ts -inline src/main/ports/advertised-url-watcher.ts -inline src/main/ports/local-workspace-port-scanner.ts inline src/main/providers/local-pty-provider.ts inline src/main/rate-limits/service.ts inline src/main/runtime/orca-runtime-browser.ts @@ -84,7 +82,6 @@ inline src/renderer/src/store/slices/tabs.ts inline src/renderer/src/store/slices/ui.ts inline src/shared/keybindings.ts inline src/shared/telemetry-events.ts -inline tests/e2e/helpers/terminal.ts mobile-config app/h/*/files/*.tsx mobile-config app/h/*/index.tsx mobile-config app/h/*/session/*.tsx diff --git a/src/main/ports/advertised-url-cache-update.ts b/src/main/ports/advertised-url-cache-update.ts new file mode 100644 index 00000000000..210cd0c1966 --- /dev/null +++ b/src/main/ports/advertised-url-cache-update.ts @@ -0,0 +1,95 @@ +import { + cacheKey, + classifyHost, + dedupeChangeEvents, + formatHostForOrigin, + isDefaultPort, + isUnspecifiedHost, + shouldReplace, + worktreeIdFromCacheKey, + type CacheKey, + type ListenerScanState +} from './advertised-url-parsing' +import type { AdvertisedUrl, AdvertisedUrlChangeEvent } from './advertised-url-watcher' + +export function considerAdvertisedUrl(args: { + url: URL + ptyId: string + worktreeId: string + timestamp: number + cache: Map + validationBaselines: Map + startupAbsentAllowances: Set + currentScanState: ListenerScanState | undefined + maxCacheEntries: number +}): AdvertisedUrlChangeEvent[] { + const protocol = args.url.protocol === 'https:' ? 'https' : 'http' + const port = args.url.port ? Number(args.url.port) : protocol === 'https' ? 443 : 80 + if (!Number.isFinite(port) || port <= 0 || port > 65535) { + return [] + } + const hostname = args.url.hostname + if (isUnspecifiedHost(hostname)) { + return [] + } + const candidate: AdvertisedUrl = { + origin: `${protocol}://${formatHostForOrigin(args.url)}${isDefaultPort(protocol, port) ? '' : `:${port}`}`, + host: hostname, + hostKind: classifyHost(hostname), + protocol, + port, + ptyId: args.ptyId, + lastSeenAt: args.timestamp + } + const key = cacheKey(args.worktreeId, port) + const existing = args.cache.get(key) + if (existing && !shouldReplace(existing, candidate)) { + existing.lastSeenAt = args.timestamp + return [] + } + + args.cache.set(key, candidate) + if (args.currentScanState) { + args.validationBaselines.set(key, args.currentScanState) + if (args.currentScanState.kind === 'absent') { + args.startupAbsentAllowances.add(key) + } else { + args.startupAbsentAllowances.delete(key) + } + } else { + args.validationBaselines.delete(key) + args.startupAbsentAllowances.add(key) + } + const changedEvents = enforceAdvertisedUrlCacheLimit(args) + if (!existing || existing.origin !== candidate.origin) { + changedEvents.push({ worktreeId: args.worktreeId, port }) + } + return dedupeChangeEvents(changedEvents) +} + +function enforceAdvertisedUrlCacheLimit(args: { + cache: Map + validationBaselines: Map + startupAbsentAllowances: Set + maxCacheEntries: number +}): AdvertisedUrlChangeEvent[] { + if (args.cache.size <= args.maxCacheEntries) { + return [] + } + const entries = Array.from(args.cache.entries()).sort( + (left, right) => left[1].lastSeenAt - right[1].lastSeenAt + ) + const overflow = args.cache.size - args.maxCacheEntries + const removedEvents: AdvertisedUrlChangeEvent[] = [] + for (let index = 0; index < overflow; index++) { + const [key, entry] = entries[index] + args.cache.delete(key) + args.validationBaselines.delete(key) + args.startupAbsentAllowances.delete(key) + removedEvents.push({ + worktreeId: worktreeIdFromCacheKey(key, entry.port), + port: entry.port + }) + } + return removedEvents +} diff --git a/src/main/ports/advertised-url-parsing.ts b/src/main/ports/advertised-url-parsing.ts new file mode 100644 index 00000000000..1d342e8e03b --- /dev/null +++ b/src/main/ports/advertised-url-parsing.ts @@ -0,0 +1,283 @@ +/* eslint-disable no-control-regex -- Terminal control-sequence parsing intentionally matches raw control bytes. */ +import type { + AdvertisedUrl, + AdvertisedUrlChangeEvent, + AdvertisedUrlListenerObservation, + HostKind +} from './advertised-url-watcher' + +const PER_PTY_BUFFER_LIMIT = 4096 +export const PENDING_PRE_BIND_LIMIT = 16 * 1024 +/** Cap on distinct never-bound PTY IDs; spawn-failure paths never bindPty, so without a bound they'd leak one entry each. */ +export const MAX_PENDING_ENTRIES = 32 +export const MAX_CACHE_ENTRIES = 256 +const URL_CANDIDATE_LIMIT = 2048 + +// ANSI/OSC strippers mirror the runtime normalizer in src/main/runtime/orca-runtime.ts, plus URL-specific cursor-move handling to avoid fusing skipped text. +const OSC_PATTERN = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g +// Why: cursor moves in differential redraws skip on-screen cells; a URL-invalid guard drops the damaged candidate. +const CURSOR_MOVE_PATTERN = /\x1b\[[0-?]*[ -/]*[CDGHf]/g +const CURSOR_MOVE_URL_GUARD = '[' +const CSI_PATTERN = /\x1b\[[0-?]*[ -/]*[@-~]/g +const SINGLE_ESC_PATTERN = /\x1b[@-_]/g +const CONTROL_PATTERN = /[\x00-\x08\x0b-\x1f\x7f]/g + +// Permissive matcher (real validation is `new URL()` below); stops at non-URL chars so terminal punctuation isn't absorbed. +const URL_CANDIDATE_PATTERN = /\bhttps?:\/\/[^\s<>"'`]+/gi + +export type CacheKey = string +export type ListenerScanState = { kind: 'absent' } | { kind: 'present'; pid?: number } + +export function cacheKey(worktreeId: string, port: number): CacheKey { + return `${worktreeId}::${port}` +} + +export function worktreeIdFromCacheKey(key: CacheKey, port: number): string { + const suffix = `::${port}` + return key.endsWith(suffix) ? key.slice(0, -suffix.length) : key +} + +export class PtyBuffer { + private raw = '' + + /** Append a chunk; return cleaned text up to the last newline. The tail stays buffered so a URL or ANSI sequence split across chunks survives. */ + ingest(chunk: string): string { + const chunkHasLineBreak = chunk.includes('\n') || chunk.includes('\r') + // Keep the suffix directly so oversized chunks never materialize a throwaway full concatenation. + if (chunk.length >= PER_PTY_BUFFER_LIMIT) { + this.raw = chunk.slice(-PER_PTY_BUFFER_LIMIT) + } else if (this.raw.length + chunk.length > PER_PTY_BUFFER_LIMIT) { + this.raw = `${this.raw.slice(-(PER_PTY_BUFFER_LIMIT - chunk.length))}${chunk}` + } else { + this.raw += chunk + } + if (!chunkHasLineBreak) { + return '' + } + const lastNewline = lastLineBreak(this.raw) + if (lastNewline === -1) { + return '' + } + const finalized = this.raw.slice(0, lastNewline + 1) + this.raw = this.raw.slice(lastNewline + 1) + return mayContainHttpUrl(finalized) ? stripTerminalControls(finalized) : '' + } +} + +function mayContainHttpUrl(text: string): boolean { + // Control stripping cannot create any character required by an HTTP scheme. + return ( + (text.includes('h') || text.includes('H')) && + (text.includes('t') || text.includes('T')) && + (text.includes('p') || text.includes('P')) && + text.includes(':') && + text.includes('/') + ) +} + +function lastLineBreak(text: string): number { + // Accept either \n or \r as a finalize point (\r\n is normalized later in stripTerminalControls). + for (let i = text.length - 1; i >= 0; i--) { + const ch = text.charCodeAt(i) + if (ch === 0x0a || ch === 0x0d) { + return i + } + } + return -1 +} + +export function stripTerminalControls(text: string): string { + return text + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n') + .replace(OSC_PATTERN, '') + .replace(CURSOR_MOVE_PATTERN, CURSOR_MOVE_URL_GUARD) + .replace(CSI_PATTERN, '') + .replace(SINGLE_ESC_PATTERN, '') + .replace(CONTROL_PATTERN, '') +} + +export function extractUrlCandidates(cleaned: string): URL[] { + const results: URL[] = [] + for (const match of cleaned.matchAll(URL_CANDIDATE_PATTERN)) { + let candidate = match[0] + if (candidate.length > URL_CANDIDATE_LIMIT) { + continue + } + // Strip common trailing punctuation that cannot end a real URL. + while (candidate.length > 0 && /[.,;:!?)\]}>'"`]/.test(candidate.slice(-1))) { + candidate = candidate.slice(0, -1) + } + const url = parseUrl(candidate) + if (url) { + results.push(url) + } + } + return results +} + +function parseUrl(candidate: string): URL | null { + try { + const url = new URL(candidate) + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return null + } + if (!url.hostname) { + return null + } + return url + } catch { + return null + } +} + +export function classifyHost(hostname: string): HostKind { + // Why: strip IPv6 brackets so this public API accepts both "[::1]" (Node's form) and bare literals. + const lower = hostname.toLowerCase().replace(/^\[|\]$/g, '') + if (lower === 'localhost' || lower === '127.0.0.1' || lower === '::1') { + return 'loopback' + } + if (isIpv4(lower)) { + if (isPrivateIpv4(lower)) { + return 'private-ip' + } + return 'public-ip' + } + if (isIpv6(lower)) { + if (isPrivateIpv6(lower)) { + return 'private-ip' + } + return 'public-ip' + } + // Anything else is a DNS name โ€” that's what we prefer for dev servers. + return 'custom' +} + +function isIpv4(value: string): boolean { + const parts = value.split('.') + if (parts.length !== 4) { + return false + } + return parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255) +} + +function isPrivateIpv4(value: string): boolean { + const [a, b] = value.split('.').map((n) => Number(n)) + // 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16 (link-local) + if (a === 10) { + return true + } + if (a === 172 && b >= 16 && b <= 31) { + return true + } + if (a === 192 && b === 168) { + return true + } + if (a === 169 && b === 254) { + return true + } + return false +} + +export function isUnspecifiedHost(hostname: string): boolean { + const stripped = hostname.toLowerCase().replace(/^\[|\]$/g, '') + return stripped === '0.0.0.0' || stripped === '::' || stripped === '*' +} + +function isIpv6(value: string): boolean { + // url.hostname for IPv6 returns lowercase without brackets โ€” quick sniff. + return value.includes(':') && /^[0-9a-f:]+$/.test(value) +} + +function isPrivateIpv6(value: string): boolean { + // fc00::/7 (ULA) and fe80::/10 (link-local) + if (value.startsWith('fc') || value.startsWith('fd')) { + return true + } + const firstHextet = Number.parseInt(value.split(':', 1)[0], 16) + return Number.isFinite(firstHextet) && (firstHextet & 0xffc0) === 0xfe80 +} + +function hostKindScore(kind: HostKind): number { + // Prefer custom DNS > loopback > private IP > public IP: loopback beats LAN for cert/cookie reasons on one machine. + switch (kind) { + case 'custom': + return 3 + case 'loopback': + return 2 + case 'private-ip': + return 1 + case 'public-ip': + return 0 + } +} + +export function shouldReplace(existing: AdvertisedUrl, candidate: AdvertisedUrl): boolean { + const oldScore = hostKindScore(existing.hostKind) + const newScore = hostKindScore(candidate.hostKind) + if (newScore !== oldScore) { + return newScore > oldScore + } + if (existing.protocol !== candidate.protocol) { + return candidate.protocol === 'https' + } + return candidate.lastSeenAt >= existing.lastSeenAt +} + +export function isDefaultPort(protocol: 'http' | 'https', port: number): boolean { + return (protocol === 'http' && port === 80) || (protocol === 'https' && port === 443) +} + +export function formatHostForOrigin(url: URL): string { + // Why: some JS runtimes strip the IPv6 brackets Node adds; re-bracket a bare IPv6 literal. + const h = url.hostname + if (h.startsWith('[') && h.endsWith(']')) { + return h + } + if (h.includes(':')) { + return `[${h}]` + } + return h +} + +export function observedListenersByPort( + observations: readonly AdvertisedUrlListenerObservation[] +): Map { + const observed = new Map() + for (const observation of observations) { + const existing = observed.get(observation.port) + if (!observed.has(observation.port)) { + observed.set(observation.port, observation.pid) + } else if (existing !== observation.pid) { + // Multiple host-specific listeners on one port make PID attribution ambiguous; keep presence only. + observed.set(observation.port, undefined) + } + } + return observed +} + +export function scanStateChanged(previous: ListenerScanState, current: ListenerScanState): boolean { + if (previous.kind !== current.kind) { + return true + } + if (previous.kind === 'absent' || current.kind === 'absent') { + return false + } + return previous.pid !== undefined && current.pid !== undefined && previous.pid !== current.pid +} + +export function dedupeChangeEvents( + events: readonly AdvertisedUrlChangeEvent[] +): AdvertisedUrlChangeEvent[] { + const seen = new Set() + const deduped: AdvertisedUrlChangeEvent[] = [] + for (const event of events) { + const key = cacheKey(event.worktreeId, event.port) + if (seen.has(key)) { + continue + } + seen.add(key) + deduped.push(event) + } + return deduped +} diff --git a/src/main/ports/advertised-url-reconciliation.ts b/src/main/ports/advertised-url-reconciliation.ts new file mode 100644 index 00000000000..e5552b858f8 --- /dev/null +++ b/src/main/ports/advertised-url-reconciliation.ts @@ -0,0 +1,87 @@ +import { + cacheKey, + scanStateChanged, + shouldReplace, + type CacheKey, + type ListenerScanState +} from './advertised-url-parsing' +import type { AdvertisedUrl } from './advertised-url-watcher' + +export function shouldEvictAdvertisedUrlAfterScan(args: { + key: CacheKey + entry: AdvertisedUrl + current: ListenerScanState + validationBaselines: Map + startupAbsentAllowances: Set +}): boolean { + const baseline = args.validationBaselines.get(args.key) + if (args.current.kind === 'absent') { + if ( + args.entry.validatedListenerPid === undefined && + baseline?.kind !== 'present' && + args.startupAbsentAllowances.delete(args.key) + ) { + return false + } + return true + } + if ( + args.entry.validatedListenerPid !== undefined && + args.current.pid !== undefined && + args.entry.validatedListenerPid !== args.current.pid + ) { + return true + } + if (baseline?.kind === 'absent') { + args.startupAbsentAllowances.delete(args.key) + return false + } + return ( + args.entry.validatedListenerPid === undefined && + baseline !== undefined && + scanStateChanged(baseline, args.current) + ) +} + +export function lookupBestAdvertisedUrl(args: { + worktreeIds: readonly string[] + port: number + currentListenerPid?: number + cache: Map + validationBaselines: Map + startupAbsentAllowances: Set + onEvict: (worktreeId: string) => void +}): AdvertisedUrl | undefined { + let best: { worktreeId: string; entry: AdvertisedUrl } | undefined + for (const worktreeId of args.worktreeIds) { + const key = cacheKey(worktreeId, args.port) + const candidate = args.cache.get(key) + if (!candidate) { + continue + } + if ( + args.currentListenerPid !== undefined && + candidate.validatedListenerPid !== undefined && + candidate.validatedListenerPid !== args.currentListenerPid + ) { + args.cache.delete(key) + args.validationBaselines.delete(key) + args.startupAbsentAllowances.delete(key) + args.onEvict(worktreeId) + continue + } + if (!best || shouldReplace(best.entry, candidate)) { + best = { worktreeId, entry: candidate } + } + } + if ( + best && + args.currentListenerPid !== undefined && + best.entry.validatedListenerPid === undefined + ) { + best.entry.validatedListenerPid = args.currentListenerPid + args.validationBaselines.delete(cacheKey(best.worktreeId, args.port)) + args.startupAbsentAllowances.delete(cacheKey(best.worktreeId, args.port)) + } + return best?.entry +} diff --git a/src/main/ports/advertised-url-watcher.test.ts b/src/main/ports/advertised-url-watcher.test.ts index 17fcc5803bb..5fe46ef181a 100644 --- a/src/main/ports/advertised-url-watcher.test.ts +++ b/src/main/ports/advertised-url-watcher.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import { - AdvertisedUrlWatcher, - classifyHost, - extractUrlCandidates, - stripTerminalControls -} from './advertised-url-watcher' +import { AdvertisedUrlWatcher } from './advertised-url-watcher' +import { classifyHost, extractUrlCandidates, stripTerminalControls } from './advertised-url-parsing' const WORKTREE = 'repo::/repo' const PTY = 'pty-1' diff --git a/src/main/ports/advertised-url-watcher.ts b/src/main/ports/advertised-url-watcher.ts index 3f8ca60bcce..ebbbecdf25f 100644 --- a/src/main/ports/advertised-url-watcher.ts +++ b/src/main/ports/advertised-url-watcher.ts @@ -1,28 +1,25 @@ -/* eslint-disable no-control-regex, max-lines -- control-sequence regexes need raw matching; URL parsing, host classification, cache lifecycle, and cross-worktree lookup stay in one file to keep the rules in lockstep. */ // Watches PTY output for HTTP(S) URLs dev servers print on startup, caching the // advertised origin per {worktreeId, port} for the ports panel (vs the kernel bind). // Why a separate stateful buffer per PTY: ANSI sequences and URLs can straddle PTY // write boundaries, so we accumulate raw bytes and strip-and-scan only at newlines. - -const PER_PTY_BUFFER_LIMIT = 4096 -const PENDING_PRE_BIND_LIMIT = 16 * 1024 -/** Cap on distinct never-bound PTY IDs; spawn-failure paths never bindPty, so without a bound they'd leak one entry each. */ -const MAX_PENDING_ENTRIES = 32 -const MAX_CACHE_ENTRIES = 256 -const URL_CANDIDATE_LIMIT = 2048 - -// ANSI/OSC strippers mirror the runtime normalizer in src/main/runtime/orca-runtime.ts, plus URL-specific cursor-move handling to avoid fusing skipped text. -const OSC_PATTERN = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g -// Why: cursor moves in differential redraws skip on-screen cells; a URL-invalid guard drops the damaged candidate. -const CURSOR_MOVE_PATTERN = /\x1b\[[0-?]*[ -/]*[CDGHf]/g -const CURSOR_MOVE_URL_GUARD = '[' -const CSI_PATTERN = /\x1b\[[0-?]*[ -/]*[@-~]/g -const SINGLE_ESC_PATTERN = /\x1b[@-_]/g -const CONTROL_PATTERN = /[\x00-\x08\x0b-\x1f\x7f]/g - -// Permissive matcher (real validation is `new URL()` below); stops at non-URL chars so terminal punctuation isn't absorbed. -const URL_CANDIDATE_PATTERN = /\bhttps?:\/\/[^\s<>"'`]+/gi - +import { + MAX_CACHE_ENTRIES, + MAX_PENDING_ENTRIES, + PENDING_PRE_BIND_LIMIT, + PtyBuffer, + cacheKey, + dedupeChangeEvents, + extractUrlCandidates, + observedListenersByPort, + worktreeIdFromCacheKey, + type CacheKey, + type ListenerScanState +} from './advertised-url-parsing' +import { considerAdvertisedUrl } from './advertised-url-cache-update' +import { + lookupBestAdvertisedUrl, + shouldEvictAdvertisedUrlAfterScan +} from './advertised-url-reconciliation' export type HostKind = 'custom' | 'loopback' | 'private-ip' | 'public-ip' export type AdvertisedUrl = { @@ -33,8 +30,6 @@ export type AdvertisedUrl = { port: number ptyId: string lastSeenAt: number - /** Listener PID this URL was validated against on a prior scan; a later mismatch evicts the entry. - * Captured on first scan (not at capture time) because the PTY shell PID isn't the listener PID. */ validatedListenerPid?: number } @@ -48,209 +43,8 @@ export type AdvertisedUrlListenerObservation = { pid?: number } -type CacheKey = string -type ListenerScanState = { kind: 'absent' } | { kind: 'present'; pid?: number } - -function cacheKey(worktreeId: string, port: number): CacheKey { - return `${worktreeId}::${port}` -} - -function worktreeIdFromCacheKey(key: CacheKey, port: number): string { - const suffix = `::${port}` - return key.endsWith(suffix) ? key.slice(0, -suffix.length) : key -} - -class PtyBuffer { - private raw = '' - - /** Append a chunk; return cleaned text up to the last newline. The tail stays buffered so a URL or ANSI sequence split across chunks survives. */ - ingest(chunk: string): string { - const chunkHasLineBreak = chunk.includes('\n') || chunk.includes('\r') - // Keep the suffix directly so oversized chunks never materialize a throwaway full concatenation. - if (chunk.length >= PER_PTY_BUFFER_LIMIT) { - this.raw = chunk.slice(-PER_PTY_BUFFER_LIMIT) - } else if (this.raw.length + chunk.length > PER_PTY_BUFFER_LIMIT) { - this.raw = `${this.raw.slice(-(PER_PTY_BUFFER_LIMIT - chunk.length))}${chunk}` - } else { - this.raw += chunk - } - if (!chunkHasLineBreak) { - return '' - } - const lastNewline = lastLineBreak(this.raw) - if (lastNewline === -1) { - return '' - } - const finalized = this.raw.slice(0, lastNewline + 1) - this.raw = this.raw.slice(lastNewline + 1) - return mayContainHttpUrl(finalized) ? stripTerminalControls(finalized) : '' - } -} - -function mayContainHttpUrl(text: string): boolean { - // Control stripping cannot create any character required by an HTTP scheme. - return ( - (text.includes('h') || text.includes('H')) && - (text.includes('t') || text.includes('T')) && - (text.includes('p') || text.includes('P')) && - text.includes(':') && - text.includes('/') - ) -} - -function lastLineBreak(text: string): number { - // Accept either \n or \r as a finalize point (\r\n is normalized later in stripTerminalControls). - for (let i = text.length - 1; i >= 0; i--) { - const ch = text.charCodeAt(i) - if (ch === 0x0a || ch === 0x0d) { - return i - } - } - return -1 -} - -export function stripTerminalControls(text: string): string { - return text - .replace(/\r\n/g, '\n') - .replace(/\r/g, '\n') - .replace(OSC_PATTERN, '') - .replace(CURSOR_MOVE_PATTERN, CURSOR_MOVE_URL_GUARD) - .replace(CSI_PATTERN, '') - .replace(SINGLE_ESC_PATTERN, '') - .replace(CONTROL_PATTERN, '') -} - -export function extractUrlCandidates(cleaned: string): URL[] { - const results: URL[] = [] - for (const match of cleaned.matchAll(URL_CANDIDATE_PATTERN)) { - let candidate = match[0] - if (candidate.length > URL_CANDIDATE_LIMIT) { - continue - } - // Strip common trailing punctuation that cannot end a real URL. - while (candidate.length > 0 && /[.,;:!?)\]}>'"`]/.test(candidate.slice(-1))) { - candidate = candidate.slice(0, -1) - } - const url = parseUrl(candidate) - if (url) { - results.push(url) - } - } - return results -} - -function parseUrl(candidate: string): URL | null { - try { - const url = new URL(candidate) - if (url.protocol !== 'http:' && url.protocol !== 'https:') { - return null - } - if (!url.hostname) { - return null - } - return url - } catch { - return null - } -} - -export function classifyHost(hostname: string): HostKind { - // Why: strip IPv6 brackets so this public API accepts both "[::1]" (Node's form) and bare literals. - const lower = hostname.toLowerCase().replace(/^\[|\]$/g, '') - if (lower === 'localhost' || lower === '127.0.0.1' || lower === '::1') { - return 'loopback' - } - if (isIpv4(lower)) { - if (isPrivateIpv4(lower)) { - return 'private-ip' - } - return 'public-ip' - } - if (isIpv6(lower)) { - if (isPrivateIpv6(lower)) { - return 'private-ip' - } - return 'public-ip' - } - // Anything else is a DNS name โ€” that's what we prefer for dev servers. - return 'custom' -} - -function isIpv4(value: string): boolean { - const parts = value.split('.') - if (parts.length !== 4) { - return false - } - return parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255) -} - -function isPrivateIpv4(value: string): boolean { - const [a, b] = value.split('.').map((n) => Number(n)) - // 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16 (link-local) - if (a === 10) { - return true - } - if (a === 172 && b >= 16 && b <= 31) { - return true - } - if (a === 192 && b === 168) { - return true - } - if (a === 169 && b === 254) { - return true - } - return false -} - -function isUnspecifiedHost(hostname: string): boolean { - const stripped = hostname.toLowerCase().replace(/^\[|\]$/g, '') - return stripped === '0.0.0.0' || stripped === '::' || stripped === '*' -} - -function isIpv6(value: string): boolean { - // url.hostname for IPv6 returns lowercase without brackets โ€” quick sniff. - return value.includes(':') && /^[0-9a-f:]+$/.test(value) -} - -function isPrivateIpv6(value: string): boolean { - // fc00::/7 (ULA) and fe80::/10 (link-local) - if (value.startsWith('fc') || value.startsWith('fd')) { - return true - } - const firstHextet = Number.parseInt(value.split(':', 1)[0], 16) - return Number.isFinite(firstHextet) && (firstHextet & 0xffc0) === 0xfe80 -} - -function hostKindScore(kind: HostKind): number { - // Prefer custom DNS > loopback > private IP > public IP: loopback beats LAN for cert/cookie reasons on one machine. - switch (kind) { - case 'custom': - return 3 - case 'loopback': - return 2 - case 'private-ip': - return 1 - case 'public-ip': - return 0 - } -} - -function shouldReplace(existing: AdvertisedUrl, candidate: AdvertisedUrl): boolean { - const oldScore = hostKindScore(existing.hostKind) - const newScore = hostKindScore(candidate.hostKind) - if (newScore !== oldScore) { - return newScore > oldScore - } - if (existing.protocol !== candidate.protocol) { - return candidate.protocol === 'https' - } - return candidate.lastSeenAt >= existing.lastSeenAt -} - export type AdvertisedUrlWatcherOptions = { - /** Override the clock; useful for tests. */ now?: () => number - /** Override the max cache entries (default 256). */ maxCacheEntries?: number } @@ -370,63 +164,23 @@ export class AdvertisedUrlWatcher { } const timestamp = now ?? this.now() for (const url of extractUrlCandidates(finalized)) { - this.consider(url, ptyId, worktreeId, timestamp) - } - } - - private consider(url: URL, ptyId: string, worktreeId: string, timestamp: number): void { - const protocol = url.protocol === 'https:' ? 'https' : 'http' - const port = url.port ? Number(url.port) : protocol === 'https' ? 443 : 80 - if (!Number.isFinite(port) || port <= 0 || port > 65535) { - return - } - const hostname = url.hostname - // Why: wildcard bind hosts (0.0.0.0, ::) can't be opened in a browser; keep the scanner's localhost default instead. - if (isUnspecifiedHost(hostname)) { - return - } - const hostKind = classifyHost(hostname) - // Why: store origin only (no path/query/fragment/userinfo) so an OAuth callback or token can't leak to the panel. - const origin = `${protocol}://${formatHostForOrigin(url)}${ - isDefaultPort(protocol, port) ? '' : `:${port}` - }` - const candidate: AdvertisedUrl = { - origin, - host: hostname, - hostKind, - protocol, - port, - ptyId, - lastSeenAt: timestamp - } - const key = cacheKey(worktreeId, port) - const existing = this.cache.get(key) - if (!existing || shouldReplace(existing, candidate)) { - this.cache.set(key, candidate) - const baseline = this.currentScanStateFor(worktreeId, port) - if (baseline) { - this.validationBaselines.set(key, baseline) - if (baseline.kind === 'absent') { - // Why: the URL can arrive between the banner print and the scanner seeing the listener; allow one settling scan. - this.startupAbsentAllowances.add(key) - } else { - this.startupAbsentAllowances.delete(key) - } - } else { - this.validationBaselines.delete(key) - // Why: PTY output can arrive before any scan snapshot exists; grant the same one-scan absent allowance. - this.startupAbsentAllowances.add(key) - } - const changedEvents = this.enforceCacheLimit() - if (!existing || existing.origin !== candidate.origin) { - changedEvents.push({ worktreeId, port }) - } - for (const event of dedupeChangeEvents(changedEvents)) { + const events = considerAdvertisedUrl({ + url, + ptyId, + worktreeId, + timestamp, + cache: this.cache, + validationBaselines: this.validationBaselines, + startupAbsentAllowances: this.startupAbsentAllowances, + currentScanState: this.currentScanStateFor( + worktreeId, + url.port ? Number(url.port) : url.protocol === 'https:' ? 443 : 80 + ), + maxCacheEntries: this.maxCacheEntries + }) + for (const event of events) { this.emitChange(event) } - } else { - // Refresh recency on the existing entry so it isn't evicted by LRU. - existing.lastSeenAt = timestamp } } @@ -440,26 +194,6 @@ export class AdvertisedUrlWatcher { } } - private enforceCacheLimit(): AdvertisedUrlChangeEvent[] { - if (this.cache.size <= this.maxCacheEntries) { - return [] - } - // Drop oldest by lastSeenAt until we are back at the cap. - const entries = Array.from(this.cache.entries()).sort( - (a, b) => a[1].lastSeenAt - b[1].lastSeenAt - ) - const overflow = this.cache.size - this.maxCacheEntries - const removedEvents: AdvertisedUrlChangeEvent[] = [] - for (let i = 0; i < overflow; i++) { - const [key, entry] = entries[i] - this.cache.delete(key) - this.validationBaselines.delete(key) - this.startupAbsentAllowances.delete(key) - removedEvents.push({ worktreeId: worktreeIdFromCacheKey(key, entry.port), port: entry.port }) - } - return removedEvents - } - lookup(worktreeId: string, port: number, currentListenerPid?: number): AdvertisedUrl | undefined { const key = cacheKey(worktreeId, port) const entry = this.cache.get(key) @@ -510,7 +244,15 @@ export class AdvertisedUrlWatcher { ? ({ kind: 'present', pid: observedByPort.get(entry.port) } as const) : ({ kind: 'absent' } as const) - if (this.shouldEvictAfterScan(key, entry, current)) { + if ( + shouldEvictAdvertisedUrlAfterScan({ + key, + entry, + current, + validationBaselines: this.validationBaselines, + startupAbsentAllowances: this.startupAbsentAllowances + }) + ) { this.cache.delete(key) this.validationBaselines.delete(key) this.startupAbsentAllowances.delete(key) @@ -528,41 +270,6 @@ export class AdvertisedUrlWatcher { } } - private shouldEvictAfterScan( - key: CacheKey, - entry: AdvertisedUrl, - current: ListenerScanState - ): boolean { - const baseline = this.validationBaselines.get(key) - if (current.kind === 'absent') { - if ( - entry.validatedListenerPid === undefined && - baseline?.kind !== 'present' && - this.startupAbsentAllowances.delete(key) - ) { - return false - } - return true - } - if ( - entry.validatedListenerPid !== undefined && - current.pid !== undefined && - entry.validatedListenerPid !== current.pid - ) { - return true - } - if (baseline?.kind === 'absent' && current.kind === 'present') { - this.startupAbsentAllowances.delete(key) - // Why: dev servers print their URL before the listener scan sees the port; let this first present scan validate it. - return false - } - return ( - entry.validatedListenerPid === undefined && - baseline !== undefined && - scanStateChanged(baseline, current) - ) - } - /** Find the best advertised URL for `port` across worktrees, scored via `shouldReplace`. * Scans all worktrees on the connection because an SSH port scanner reports ports for the * whole connection, not per-worktree. With `currentListenerPid`, mismatched pinned entries @@ -572,35 +279,15 @@ export class AdvertisedUrlWatcher { port: number, currentListenerPid?: number ): AdvertisedUrl | undefined { - let best: { worktreeId: string; entry: AdvertisedUrl } | undefined - for (const worktreeId of worktreeIds) { - const key = cacheKey(worktreeId, port) - const candidate = this.cache.get(key) - if (!candidate) { - continue - } - if (currentListenerPid !== undefined) { - if ( - candidate.validatedListenerPid !== undefined && - candidate.validatedListenerPid !== currentListenerPid - ) { - this.cache.delete(key) - this.validationBaselines.delete(key) - this.startupAbsentAllowances.delete(key) - this.emitChange({ worktreeId, port }) - continue - } - } - if (!best || shouldReplace(best.entry, candidate)) { - best = { worktreeId, entry: candidate } - } - } - if (best && currentListenerPid !== undefined && best.entry.validatedListenerPid === undefined) { - best.entry.validatedListenerPid = currentListenerPid - this.validationBaselines.delete(cacheKey(best.worktreeId, port)) - this.startupAbsentAllowances.delete(cacheKey(best.worktreeId, port)) - } - return best?.entry + return lookupBestAdvertisedUrl({ + worktreeIds, + port, + currentListenerPid, + cache: this.cache, + validationBaselines: this.validationBaselines, + startupAbsentAllowances: this.startupAbsentAllowances, + onEvict: (worktreeId) => this.emitChange({ worktreeId, port }) + }) } clear(): void { @@ -622,63 +309,5 @@ export class AdvertisedUrlWatcher { } } -function isDefaultPort(protocol: 'http' | 'https', port: number): boolean { - return (protocol === 'http' && port === 80) || (protocol === 'https' && port === 443) -} - -/** Process-wide singleton fed by the runtime and read by scanner enrichment. Tests should instantiate their own instead. */ +/** Process-wide singleton fed by the runtime and read by scanner enrichment. */ export const advertisedUrlWatcher = new AdvertisedUrlWatcher() - -function formatHostForOrigin(url: URL): string { - // Why: some JS runtimes strip the IPv6 brackets Node adds; re-bracket a bare IPv6 literal. - const h = url.hostname - if (h.startsWith('[') && h.endsWith(']')) { - return h - } - if (h.includes(':')) { - return `[${h}]` - } - return h -} - -function observedListenersByPort( - observations: readonly AdvertisedUrlListenerObservation[] -): Map { - const observed = new Map() - for (const observation of observations) { - const existing = observed.get(observation.port) - if (!observed.has(observation.port)) { - observed.set(observation.port, observation.pid) - } else if (existing !== observation.pid) { - // Multiple host-specific listeners on one port make PID attribution ambiguous; keep presence only. - observed.set(observation.port, undefined) - } - } - return observed -} - -function scanStateChanged(previous: ListenerScanState, current: ListenerScanState): boolean { - if (previous.kind !== current.kind) { - return true - } - if (previous.kind === 'absent' || current.kind === 'absent') { - return false - } - return previous.pid !== undefined && current.pid !== undefined && previous.pid !== current.pid -} - -function dedupeChangeEvents( - events: readonly AdvertisedUrlChangeEvent[] -): AdvertisedUrlChangeEvent[] { - const seen = new Set() - const deduped: AdvertisedUrlChangeEvent[] = [] - for (const event of events) { - const key = cacheKey(event.worktreeId, event.port) - if (seen.has(key)) { - continue - } - seen.add(key) - deduped.push(event) - } - return deduped -} diff --git a/src/main/ports/local-workspace-platform-port-scanner.ts b/src/main/ports/local-workspace-platform-port-scanner.ts new file mode 100644 index 00000000000..7e3b9941617 --- /dev/null +++ b/src/main/ports/local-workspace-platform-port-scanner.ts @@ -0,0 +1,322 @@ +import { readFile, readdir, readlink } from 'node:fs/promises' +import { getProcessOutputFields } from '../../shared/process-output-field-scanner' +import { readWindowsProcessTable } from '../windows/windows-process-table' +import { runPortScanCommand } from './port-scan-command-client' +import { + recallListenerMetadata, + rememberListenerMetadata, + shouldSkipMetadataCommands, + type PlatformListeningPortScan, + type ProcessMetadata, + type RawListeningPort, + type WorkspacePortScanOptions +} from './local-workspace-port-scan-state' +import { + dedupeRawPorts, + parseAddressWithPort, + parseProcAddress +} from './local-workspace-port-address' + +export function parseLsofListeningOutput(output: string): RawListeningPort[] { + const ports: RawListeningPort[] = [] + let currentPid: number | undefined + let currentProcessName: string | undefined + + for (const line of output.split('\n')) { + if (!line) { + continue + } + const tag = line[0] + const value = line.slice(1) + if (tag === 'p') { + const pid = Number.parseInt(value, 10) + currentPid = Number.isFinite(pid) ? pid : undefined + currentProcessName = undefined + } else if (tag === 'c') { + currentProcessName = value + } else if (tag === 'n') { + const parsed = parseAddressWithPort(value) + if (parsed) { + ports.push({ pid: currentPid, processName: currentProcessName, ...parsed }) + } + } + } + + return dedupeRawPorts(ports) +} + +export function parseNetstatListeningOutput(output: string): RawListeningPort[] { + const ports: RawListeningPort[] = [] + for (const line of output.split('\n')) { + const fields = getProcessOutputFields(line, 6) + if (fields[0]?.toUpperCase() !== 'TCP') { + continue + } + const stateIndex = fields.findIndex((field) => field.toUpperCase() === 'LISTENING') + if (stateIndex < 2) { + continue + } + const parsed = parseAddressWithPort(fields[1]) + const pid = Number.parseInt(fields[stateIndex + 1] ?? '', 10) + if (!parsed) { + continue + } + ports.push({ ...parsed, pid: Number.isFinite(pid) ? pid : undefined }) + } + return dedupeRawPorts(ports) +} + +export function parseProcNetTcp(content: string): { host: string; port: number; inode: number }[] { + const results: { host: string; port: number; inode: number }[] = [] + const lines = content.split('\n') + for (let i = 1; i < lines.length; i++) { + const fields = getProcessOutputFields(lines[i], 10) + if (fields.length < 10 || fields[3] !== '0A') { + continue + } + const parsed = parseProcAddress(fields[1]) + const inode = Number.parseInt(fields[9], 10) + if (!parsed || !Number.isFinite(inode) || inode === 0) { + continue + } + results.push({ ...parsed, inode }) + } + return results +} + +export async function scanPlatformListeningPorts( + options: WorkspacePortScanOptions +): Promise { + const scan = await dispatchPlatformListeningPortScan(options) + if (scan.metadataAvailable) { + rememberListenerMetadata(scan.ports) + return scan + } + return { ...scan, ports: scan.ports.map(recallListenerMetadata) } +} + +async function dispatchPlatformListeningPortScan( + options: WorkspacePortScanOptions +): Promise { + if (process.platform === 'linux') { + return scanLinuxProcPorts() + } + if (process.platform === 'darwin') { + return scanDarwinLsofPorts(options) + } + if (process.platform === 'win32') { + return scanWindowsNetstatPorts(options) + } + throw new Error(`Port scanning is not supported on ${process.platform}`) +} + +async function scanDarwinLsofPorts( + options: WorkspacePortScanOptions +): Promise { + const { stdout, spawnMs } = await runPortScanCommand('lsof', [ + '-nP', + '-iTCP', + '-sTCP:LISTEN', + '-F', + 'pcn' + ]) + const ports = parseLsofListeningOutput(stdout) + if (shouldSkipMetadataCommands(spawnMs, options)) { + return { ports, metadataAvailable: false } + } + const metadata = await loadDarwinProcessMetadata( + new Set(ports.flatMap((p) => (p.pid ? [p.pid] : []))) + ) + return { + ports: ports.map((port) => ({ ...metadata.get(port.pid ?? -1), ...port })), + metadataAvailable: true + } +} + +async function scanWindowsNetstatPorts( + options: WorkspacePortScanOptions +): Promise { + const { stdout, spawnMs } = await runPortScanCommand('netstat', ['-ano', '-p', 'tcp']) + const ports = parseNetstatListeningOutput(stdout) + if (shouldSkipMetadataCommands(spawnMs, options)) { + return { ports, metadataAvailable: false } + } + const metadata = await loadWindowsProcessMetadata( + new Set(ports.flatMap((p) => (p.pid ? [p.pid] : []))) + ) + return { + ports: ports.map((port) => ({ ...metadata.get(port.pid ?? -1), ...port })), + metadataAvailable: true + } +} + +async function scanLinuxProcPorts(): Promise { + const [tcp4, tcp6] = await Promise.all([ + readProcNet('/proc/net/tcp'), + readProcNet('/proc/net/tcp6') + ]) + const sockets = [...tcp4, ...tcp6] + const inodeToPid = await mapLinuxInodesToPids(new Set(sockets.map((socket) => socket.inode))) + const metadata = new Map() + const rawPorts: RawListeningPort[] = [] + + for (const socket of sockets) { + const pid = inodeToPid.get(socket.inode) + if (pid != null && !metadata.has(pid)) { + metadata.set(pid, await loadLinuxProcessMetadata(pid)) + } + rawPorts.push({ + host: socket.host, + port: socket.port, + pid, + ...metadata.get(pid ?? -1) + }) + } + + return { ports: dedupeRawPorts(rawPorts), metadataAvailable: true } +} + +async function readProcNet( + filePath: string +): Promise<{ host: string; port: number; inode: number }[]> { + try { + return parseProcNetTcp(await readFile(filePath, 'utf-8')) + } catch { + return [] + } +} + +async function mapLinuxInodesToPids(inodes: Set): Promise> { + const result = new Map() + if (inodes.size === 0) { + return result + } + let pids: string[] + try { + pids = (await readdir('/proc')).filter((entry) => /^\d+$/.test(entry)) + } catch { + return result + } + + for (const pidText of pids) { + let fds: string[] + try { + fds = await readdir(`/proc/${pidText}/fd`) + } catch { + continue + } + const pid = Number.parseInt(pidText, 10) + for (const fd of fds) { + let link: string + try { + link = await readlink(`/proc/${pidText}/fd/${fd}`) + } catch { + continue + } + const match = link.match(/^socket:\[(\d+)\]$/) + if (!match) { + continue + } + const inode = Number.parseInt(match[1], 10) + if (inodes.has(inode)) { + result.set(inode, pid) + } + } + } + return result +} + +async function loadLinuxProcessMetadata(pid: number): Promise { + const [comm, cmdline, cwd] = await Promise.all([ + readTextIfAvailable(`/proc/${pid}/comm`), + readTextIfAvailable(`/proc/${pid}/cmdline`), + readlink(`/proc/${pid}/cwd`).catch(() => undefined) + ]) + return { + processName: comm?.trim() || undefined, + commandLine: cmdline?.split('\u0000').join(' ').trim() || undefined, + cwd + } +} + +async function loadDarwinProcessMetadata(pids: Set): Promise> { + const result = new Map() + const pidList = Array.from(pids).join(',') + if (!pidList) { + return result + } + + // Why (#11161): sequential, not Promise.all โ€” the probe worker dispatches one + // command at a time, so issuing both at once would only queue the second. + const cwdOutput = await runPortScanCommand('lsof', [ + '-a', + '-p', + pidList, + '-d', + 'cwd', + '-Fn' + ]).catch(() => null) + const commandOutput = await runPortScanCommand('ps', [ + '-p', + pidList, + '-o', + 'pid=', + '-o', + 'command=' + ]).catch(() => null) + + let currentPid: number | null = null + for (const line of cwdOutput?.stdout.split('\n') ?? []) { + if (line.startsWith('p')) { + const pid = Number.parseInt(line.slice(1), 10) + currentPid = Number.isFinite(pid) ? pid : null + } else if (line.startsWith('n') && currentPid != null) { + result.set(currentPid, { ...result.get(currentPid), cwd: line.slice(1) || undefined }) + } + } + + for (const line of commandOutput?.stdout.split('\n') ?? []) { + const match = line.match(/^\s*(\d+)\s+(.+)$/) + if (!match) { + continue + } + const pid = Number.parseInt(match[1], 10) + result.set(pid, { ...result.get(pid), commandLine: match[2].trim() || undefined }) + } + + return result +} + +async function loadWindowsProcessMetadata( + pids: Set +): Promise> { + const result = new Map() + if (pids.size === 0) { + return result + } + try { + // Why the native snapshot: attributing ports used to fork a powershell.exe + // per scan just to turn PIDs into names. That is a ~700ms cold start, a + // conhost window, and one more thing a Group Policy can block -- for data + // the panel treats as optional anyway. + for (const row of await readWindowsProcessTable()) { + if (pids.has(row.pid)) { + result.set(row.pid, { + processName: row.name, + commandLine: row.command || undefined + }) + } + } + } catch { + // Process metadata is optional; port rows still render without attribution. + } + return result +} + +async function readTextIfAvailable(filePath: string): Promise { + try { + return await readFile(filePath, 'utf-8') + } catch { + return undefined + } +} diff --git a/src/main/ports/local-workspace-port-address.ts b/src/main/ports/local-workspace-port-address.ts new file mode 100644 index 00000000000..77b6e0b0b19 --- /dev/null +++ b/src/main/ports/local-workspace-port-address.ts @@ -0,0 +1,71 @@ +import type { RawListeningPort } from './local-workspace-port-scan-state' + +export function connectHostForBindHost(host: string): string { + if (host === '*' || host === '0.0.0.0' || host === '::') { + return 'localhost' + } + return host +} + +export function dedupeRawPorts(ports: RawListeningPort[]): RawListeningPort[] { + const seen = new Set() + const result: RawListeningPort[] = [] + for (const port of ports) { + const key = `${connectHostForBindHost(port.host)}:${port.port}:${port.pid ?? 'unknown'}` + if (seen.has(key)) { + continue + } + seen.add(key) + result.push(port) + } + return result +} + +export function parseAddressWithPort(value: string): { host: string; port: number } | null { + const trimmed = value.trim().replace(/\s+\(LISTEN\)$/i, '') + const bracketed = trimmed.match(/^\[([^\]]+)\]:(\d+)$/) + if (bracketed) { + return { host: bracketed[1], port: Number.parseInt(bracketed[2], 10) } + } + const match = trimmed.match(/^(.+):(\d+)$/) + if (!match) { + return null + } + const port = Number.parseInt(match[2], 10) + if (!Number.isFinite(port) || port <= 0 || port > 65535) { + return null + } + return { host: match[1], port } +} + +export function parseProcAddress(hexAddress: string): { host: string; port: number } | null { + const [addrHex, portHex] = hexAddress.split(':') + const port = Number.parseInt(portHex, 16) + if (!Number.isFinite(port) || port === 0) { + return null + } + if (addrHex.length === 8) { + const bytes = [6, 4, 2, 0].map((index) => Number.parseInt(addrHex.slice(index, index + 2), 16)) + return { host: bytes.join('.'), port } + } + if (addrHex.length === 32) { + if (addrHex === '00000000000000000000000000000000') { + return { host: '::', port } + } + if (addrHex === '00000000000000000000000001000000') { + return { host: '::1', port } + } + return { host: formatIPv6Address(addrHex), port } + } + return null +} + +function formatIPv6Address(hex: string): string { + const groups: string[] = [] + for (let i = 0; i < 32; i += 8) { + const chunk = hex.slice(i, i + 8) + const reversed = chunk.slice(6, 8) + chunk.slice(4, 6) + chunk.slice(2, 4) + chunk.slice(0, 2) + groups.push(reversed.slice(0, 4), reversed.slice(4, 8)) + } + return groups.map((group) => group.replace(/^0+/, '') || '0').join(':') +} diff --git a/src/main/ports/local-workspace-port-attribution.ts b/src/main/ports/local-workspace-port-attribution.ts new file mode 100644 index 00000000000..21180a512e1 --- /dev/null +++ b/src/main/ports/local-workspace-port-attribution.ts @@ -0,0 +1,210 @@ +import path from 'node:path' +import type { + WorkspacePort, + WorkspacePortOwner, + WorkspacePortProbe +} from '../../shared/workspace-ports' +import type { AdvertisedUrlWatcher } from './advertised-url-watcher' +import { connectHostForBindHost } from './local-workspace-port-address' +import type { + NormalizedWorkspacePortProbe, + RawListeningPort +} from './local-workspace-port-scan-state' + +const HTTP_PORTS: Record = { + 80: true, + 3000: true, + 3001: true, + 4200: true, + 5000: true, + 5173: true, + 5174: true, + 8000: true, + 8080: true, + 8888: true +} +const HTTPS_PORTS: Record = { 443: true, 8443: true } + +export function attributePortToWorkspace( + port: Pick, + worktrees: WorkspacePortProbe[] +): WorkspacePortOwner | undefined { + return attributePortToNormalizedWorkspaces(port, normalizeWorkspacePortProbes(worktrees)) +} + +export function normalizeWorkspacePortProbes( + worktrees: readonly WorkspacePortProbe[] +): NormalizedWorkspacePortProbe[] { + return worktrees.map((worktree) => ({ + worktree, + normalizedPath: normalizeComparablePath(worktree.path) + })) +} + +function attributePortToNormalizedWorkspaces( + port: Pick, + worktrees: readonly NormalizedWorkspacePortProbe[] +): WorkspacePortOwner | undefined { + const cwd = port.cwd ? normalizeComparablePath(port.cwd) : null + const commandLine = port.commandLine ? normalizeComparableText(port.commandLine) : null + + const cwdMatch = cwd + ? pickDeepestMatching(worktrees, ({ normalizedPath }) => + isSameOrDescendant(cwd, normalizedPath) + ) + : undefined + if (cwdMatch) { + return toOwner(cwdMatch.worktree, 'cwd') + } + + if (!commandLine) { + return undefined + } + + const commandMatch = pickDeepestMatching(worktrees, ({ normalizedPath }) => + includesPathBoundary(commandLine, normalizedPath) + ) + return commandMatch ? toOwner(commandMatch.worktree, 'command') : undefined +} +export function enrichPort( + port: RawListeningPort, + worktrees: readonly NormalizedWorkspacePortProbe[], + urlWatcher: Pick +): WorkspacePort { + const owner = attributePortToNormalizedWorkspaces(port, worktrees) + const base = { + id: `${port.host}:${port.port}:${port.pid ?? 'unknown'}`, + bindHost: port.host, + connectHost: connectHostForBindHost(port.host), + port: port.port, + pid: port.pid, + processName: port.processName, + protocol: inferProtocol(port.port) + } + + if (owner) { + // Why: only enrich workspace-attributed ports. Container and external + // ports may have URLs printed in unrelated terminals โ€” the worktree + // scoping is the primary false-positive filter. + const advertised = urlWatcher.lookup(owner.worktreeId, port.port, port.pid) + return { + ...base, + protocol: advertised?.protocol ?? base.protocol, + kind: 'workspace', + owner, + ...(advertised ? { advertisedUrl: advertised.origin } : {}) + } + } + if (isContainerProcess(port)) { + return { ...base, kind: 'container' } + } + return { ...base, kind: 'external' } +} + +export function reconcileAdvertisedUrls( + ports: RawListeningPort[], + worktrees: readonly NormalizedWorkspacePortProbe[], + urlWatcher: Pick +): void { + const observationsByWorktree = new Map() + for (const worktree of worktrees) { + observationsByWorktree.set(worktree.worktree.id, []) + } + for (const port of ports) { + const owner = attributePortToNormalizedWorkspaces(port, worktrees) + if (!owner) { + continue + } + observationsByWorktree.get(owner.worktreeId)?.push({ port: port.port, pid: port.pid }) + } + for (const [worktreeId, observations] of observationsByWorktree) { + // Why: the scanner sees port disappearance and PID changes before a lazy + // lookup would otherwise pin a stale banner to a new listener. + urlWatcher.reconcileScan([worktreeId], observations) + } +} + +export function compareWorkspacePorts(a: WorkspacePort, b: WorkspacePort): number { + const aRank = a.kind === 'workspace' ? 0 : a.kind === 'container' ? 1 : 2 + const bRank = b.kind === 'workspace' ? 0 : b.kind === 'container' ? 1 : 2 + return aRank - bRank || a.port - b.port || a.connectHost.localeCompare(b.connectHost) +} + +function inferProtocol(port: number): 'http' | 'https' | 'unknown' { + if (HTTPS_PORTS[port] === true) { + return 'https' + } + if (HTTP_PORTS[port] === true) { + return 'http' + } + return 'unknown' +} + +export function isContainerProcess( + port: Pick +): boolean { + const haystack = `${port.processName ?? ''} ${port.commandLine ?? ''}`.toLowerCase() + return /\b(com\.[\w.-]+\.backend|com\.container\w*|container\w*)\b/.test(haystack) +} + +function toOwner( + worktree: WorkspacePortProbe, + confidence: WorkspacePortOwner['confidence'] +): WorkspacePortOwner { + return { + worktreeId: worktree.id, + repoId: worktree.repoId, + displayName: worktree.displayName, + path: worktree.path, + confidence + } +} + +function pickDeepestMatching( + candidates: readonly T[], + predicate: (candidate: T) => boolean +): T | undefined { + let best: T | undefined + for (const candidate of candidates) { + if (!predicate(candidate)) { + continue + } + if (!best || candidate.normalizedPath.length > best.normalizedPath.length) { + best = candidate + } + } + return best +} + +function isSameOrDescendant(candidate: string, parent: string): boolean { + return candidate === parent || candidate.startsWith(`${parent}/`) +} + +function includesPathBoundary(commandLine: string, normalizedPath: string): boolean { + let index = commandLine.indexOf(normalizedPath) + while (index !== -1) { + const before = index === 0 ? '' : commandLine[index - 1] + const after = commandLine[index + normalizedPath.length] ?? '' + const startsOnBoundary = before === '' || /\s|["'=]/.test(before) + const endsOnBoundary = after === '' || /[\s"'/:]/.test(after) + if (startsOnBoundary && endsOnBoundary) { + return true + } + index = commandLine.indexOf(normalizedPath, index + normalizedPath.length) + } + return false +} + +function normalizeComparablePath(input: string): string { + if (input.startsWith('/')) { + // Why: command-line evidence for SSH/WSL/POSIX workspaces can be evaluated + // on a Windows host; path.resolve would reinterpret "/repo" as "G:/repo". + return normalizeComparableText(path.posix.resolve(input)) + } + return normalizeComparableText(path.resolve(input)) +} + +function normalizeComparableText(input: string): string { + const normalized = input.replace(/\\/g, '/').replace(/\/+/g, '/') + return process.platform === 'win32' ? normalized.toLowerCase() : normalized +} diff --git a/src/main/ports/local-workspace-port-scan-state.ts b/src/main/ports/local-workspace-port-scan-state.ts new file mode 100644 index 00000000000..582e8b9731e --- /dev/null +++ b/src/main/ports/local-workspace-port-scan-state.ts @@ -0,0 +1,122 @@ +import type { WorkspacePortScanResult, WorkspacePortProbe } from '../../shared/workspace-ports' +import { isPortScanWorkerUnavailableError } from './port-scan-command-client' +import { PortScanCommandTimeoutError } from './port-scan-command-protocol' +import { + WorkspacePortScanTimeoutBackoff, + type WorkspacePortScanTimeoutBackoffSnapshot +} from './workspace-port-scan-timeout-backoff' + +const SLOW_SPAWN_SKIP_METADATA_MS = 2_000 +const commandTimeoutBackoff = new WorkspacePortScanTimeoutBackoff() +let loggedWorkerUnavailable = false +let skippedMetadataOnLastScan = false +let lastListenerMetadata = new Map() + +export type WorkspacePortScanOptions = { + requireMetadata?: boolean +} + +export type RawListeningPort = { + host: string + port: number + pid?: number + processName?: string + commandLine?: string + cwd?: string +} + +export type ProcessMetadata = { + processName?: string + commandLine?: string + cwd?: string +} + +export type NormalizedWorkspacePortProbe = { + worktree: WorkspacePortProbe + normalizedPath: string +} + +export type PlatformListeningPortScan = { + ports: RawListeningPort[] + metadataAvailable: boolean +} + +export function getWorkspacePortScanCooldown(): WorkspacePortScanTimeoutBackoffSnapshot { + return commandTimeoutBackoff.snapshot() +} + +export function recordWorkspacePortScanSuccess(): void { + commandTimeoutBackoff.recordSuccess() +} + +export function recordWorkspacePortScanTimeout(): void { + commandTimeoutBackoff.recordTimeout() +} + +export function resetWorkspacePortScanTimeoutBackoffForTests(): void { + commandTimeoutBackoff.reset() + loggedWorkerUnavailable = false + skippedMetadataOnLastScan = false + lastListenerMetadata = new Map() +} + +export function shouldSkipMetadataCommands( + spawnMs: number, + options: WorkspacePortScanOptions +): boolean { + if (options.requireMetadata) { + return false + } + const skip = spawnMs > SLOW_SPAWN_SKIP_METADATA_MS && !skippedMetadataOnLastScan + skippedMetadataOnLastScan = skip + return skip +} + +function listenerMetadataKey(port: RawListeningPort): string { + return `${port.pid ?? 'unknown'}:${port.host}:${port.port}` +} + +export function rememberListenerMetadata(ports: readonly RawListeningPort[]): void { + lastListenerMetadata = new Map( + ports.map((port) => [ + listenerMetadataKey(port), + { processName: port.processName, commandLine: port.commandLine, cwd: port.cwd } + ]) + ) +} + +export function recallListenerMetadata(port: RawListeningPort): RawListeningPort { + const remembered = lastListenerMetadata.get(listenerMetadataKey(port)) + if (!remembered) { + return port + } + return { + ...port, + processName: port.processName ?? remembered.processName, + commandLine: port.commandLine ?? remembered.commandLine, + cwd: port.cwd ?? remembered.cwd + } +} + +export function warnWorkspacePortScanFailure(error: unknown): void { + if (isPortScanWorkerUnavailableError(error)) { + if (loggedWorkerUnavailable) { + return + } + loggedWorkerUnavailable = true + } + console.warn('[workspace-ports] scan failed', error) +} + +export function isWorkspacePortScanCommandTimeout(error: unknown): boolean { + return error instanceof PortScanCommandTimeoutError +} + +export function makeUnavailableWorkspacePortScan(reason: string): WorkspacePortScanResult { + return { + platform: process.platform, + scannedAt: Date.now(), + ports: [], + unavailableReason: reason + } +} diff --git a/src/main/ports/local-workspace-port-scanner.test.ts b/src/main/ports/local-workspace-port-scanner.test.ts index 132eba1950e..be37ad60eb3 100644 --- a/src/main/ports/local-workspace-port-scanner.test.ts +++ b/src/main/ports/local-workspace-port-scanner.test.ts @@ -1,14 +1,13 @@ import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' import path from 'node:path' +import { scanWorkspacePorts } from './local-workspace-port-scanner' +import { attributePortToWorkspace, isContainerProcess } from './local-workspace-port-attribution' import { - attributePortToWorkspace, - isContainerProcess, parseLsofListeningOutput, parseNetstatListeningOutput, - parseProcNetTcp, - resetWorkspacePortScanTimeoutBackoffForTests, - scanWorkspacePorts -} from './local-workspace-port-scanner' + parseProcNetTcp +} from './local-workspace-platform-port-scanner' +import { resetWorkspacePortScanTimeoutBackoffForTests } from './local-workspace-port-scan-state' import { PortScanCommandTimeoutError } from './port-scan-command-protocol' const runPortScanCommandMock = vi.hoisted(() => vi.fn()) diff --git a/src/main/ports/local-workspace-port-scanner.ts b/src/main/ports/local-workspace-port-scanner.ts index 66373db6929..7af3e8bba3c 100644 --- a/src/main/ports/local-workspace-port-scanner.ts +++ b/src/main/ports/local-workspace-port-scanner.ts @@ -1,81 +1,31 @@ -/* eslint-disable max-lines -- Why: the platform-specific scan paths share parsing, -attribution, and normalization rules that must stay in lockstep. */ -import { readFile, readdir, readlink } from 'node:fs/promises' -import path from 'node:path' -import type { - WorkspacePort, - WorkspacePortOwner, - WorkspacePortProbe, - WorkspacePortScanResult -} from '../../shared/workspace-ports' -import { getProcessOutputFields } from '../../shared/process-output-field-scanner' -import { readWindowsProcessTable } from '../windows/windows-process-table' +import type { WorkspacePortProbe, WorkspacePortScanResult } from '../../shared/workspace-ports' import { advertisedUrlWatcher, type AdvertisedUrlWatcher } from './advertised-url-watcher' -import { isPortScanWorkerUnavailableError, runPortScanCommand } from './port-scan-command-client' -import { PortScanCommandTimeoutError } from './port-scan-command-protocol' -import { WorkspacePortScanTimeoutBackoff } from './workspace-port-scan-timeout-backoff' - -// Why (#11161): on an EDR-hooked host process creation alone can take seconds. -// Past this, skip the scan's optional metadata commands for one cycle so a scan -// costs roughly one stall instead of three. -const SLOW_SPAWN_SKIP_METADATA_MS = 2_000 +import { + compareWorkspacePorts, + enrichPort, + normalizeWorkspacePortProbes, + reconcileAdvertisedUrls +} from './local-workspace-port-attribution' +import { scanPlatformListeningPorts } from './local-workspace-platform-port-scanner' +import { + getWorkspacePortScanCooldown, + isWorkspacePortScanCommandTimeout, + makeUnavailableWorkspacePortScan, + recordWorkspacePortScanSuccess, + recordWorkspacePortScanTimeout, + warnWorkspacePortScanFailure, + type WorkspacePortScanOptions +} from './local-workspace-port-scan-state' const MAX_PORTS = 200 -const HTTP_PORTS = new Set([80, 3000, 3001, 4200, 5000, 5173, 5174, 8000, 8080, 8888]) -const HTTPS_PORTS = new Set([443, 8443]) - -const commandTimeoutBackoff = new WorkspacePortScanTimeoutBackoff() -let loggedWorkerUnavailable = false -// Why (#11161): a hooked host stalls every spawn, so gating only on the current -// scan would drop metadata forever. Never skip twice running, so attribution โ€” -// and the Stop action and advertised-URL matching that ride on it โ€” recovers on -// the next tick. -let skippedMetadataOnLastScan = false -// Why (#11161): a skipped cycle would otherwise report every listener as -// external, so the panel flip-flops and the Stop action loses its owner. Carry -// the previous cycle's metadata forward, keyed tightly enough that a recycled -// pid cannot inherit it. -let lastListenerMetadata = new Map() - -export type WorkspacePortScanOptions = { - /** Set by attribution-dependent callers (Stop, the localhost-label allowlist) - * that must never trade owner metadata for scan latency. */ - requireMetadata?: boolean -} - -type RawListeningPort = { - host: string - port: number - pid?: number - processName?: string - commandLine?: string - cwd?: string -} - -type ProcessMetadata = { - processName?: string - commandLine?: string - cwd?: string -} - -type NormalizedWorkspacePortProbe = { - worktree: WorkspacePortProbe - normalizedPath: string -} - -type PlatformListeningPortScan = { - ports: RawListeningPort[] - /** False when a stalled spawn made the scan skip its cwd/command-line probes. */ - metadataAvailable: boolean -} export async function scanWorkspacePorts( worktrees: WorkspacePortProbe[], urlWatcher: Pick = advertisedUrlWatcher, options: WorkspacePortScanOptions = {} ): Promise { - const cooldown = commandTimeoutBackoff.snapshot() + const cooldown = getWorkspacePortScanCooldown() if (cooldown.isCoolingDown) { - return makeUnavailableScan( + return makeUnavailableWorkspacePortScan( `Port scanning is temporarily paused after a command timeout. Retrying in ${Math.ceil( cooldown.remainingMs / 1000 )}s.` @@ -84,7 +34,7 @@ export async function scanWorkspacePorts( try { const { ports: rawPorts, metadataAvailable } = await scanPlatformListeningPorts(options) - commandTimeoutBackoff.recordSuccess() + recordWorkspacePortScanSuccess() const normalizedWorktrees = normalizeWorkspacePortProbes(worktrees) // Why (#11161): without cwd/command-line every port looks unattributed, and // reconciling that would read as "the listener vanished" and evict cached @@ -98,639 +48,10 @@ export async function scanWorkspacePorts( .slice(0, MAX_PORTS) return { platform: process.platform, scannedAt: Date.now(), ports } } catch (error) { - if (isCommandTimeoutError(error)) { - commandTimeoutBackoff.recordTimeout() + if (isWorkspacePortScanCommandTimeout(error)) { + recordWorkspacePortScanTimeout() } - warnScanFailure(error) - return makeUnavailableScan(`Port scanning is unavailable on ${process.platform}.`) + warnWorkspacePortScanFailure(error) + return makeUnavailableWorkspacePortScan(`Port scanning is unavailable on ${process.platform}.`) } } - -export function resetWorkspacePortScanTimeoutBackoffForTests(): void { - commandTimeoutBackoff.reset() - loggedWorkerUnavailable = false - skippedMetadataOnLastScan = false - lastListenerMetadata = new Map() -} - -function shouldSkipMetadataCommands(spawnMs: number, opts: WorkspacePortScanOptions): boolean { - if (opts.requireMetadata) { - // Leave the skip parity alone: it belongs to the background scan cadence, - // and a one-shot user action must not shift which tick degrades. - return false - } - const skip = spawnMs > SLOW_SPAWN_SKIP_METADATA_MS && !skippedMetadataOnLastScan - skippedMetadataOnLastScan = skip - return skip -} - -/** Identity tight enough that a recycled pid cannot inherit stale metadata. */ -function listenerMetadataKey(port: RawListeningPort): string { - return `${port.pid ?? 'unknown'}:${port.host}:${port.port}` -} - -function rememberListenerMetadata(ports: readonly RawListeningPort[]): void { - lastListenerMetadata = new Map( - ports.map((port) => [ - listenerMetadataKey(port), - { processName: port.processName, commandLine: port.commandLine, cwd: port.cwd } - ]) - ) -} - -function recallListenerMetadata(port: RawListeningPort): RawListeningPort { - const remembered = lastListenerMetadata.get(listenerMetadataKey(port)) - if (!remembered) { - return port - } - return { - ...port, - processName: port.processName ?? remembered.processName, - commandLine: port.commandLine ?? remembered.commandLine, - cwd: port.cwd ?? remembered.cwd - } -} - -// Why: a mispackaged probe worker fails identically forever, so logging it on -// every 30s scan tick is pure noise. -function warnScanFailure(error: unknown): void { - if (isPortScanWorkerUnavailableError(error)) { - if (loggedWorkerUnavailable) { - return - } - loggedWorkerUnavailable = true - } - console.warn('[workspace-ports] scan failed', error) -} - -function makeUnavailableScan(reason: string): WorkspacePortScanResult { - return { - platform: process.platform, - scannedAt: Date.now(), - ports: [], - unavailableReason: reason - } -} - -export function attributePortToWorkspace( - port: Pick, - worktrees: WorkspacePortProbe[] -): WorkspacePortOwner | undefined { - return attributePortToNormalizedWorkspaces(port, normalizeWorkspacePortProbes(worktrees)) -} - -function normalizeWorkspacePortProbes( - worktrees: readonly WorkspacePortProbe[] -): NormalizedWorkspacePortProbe[] { - return worktrees.map((worktree) => ({ - worktree, - normalizedPath: normalizeComparablePath(worktree.path) - })) -} - -function attributePortToNormalizedWorkspaces( - port: Pick, - worktrees: readonly NormalizedWorkspacePortProbe[] -): WorkspacePortOwner | undefined { - const cwd = port.cwd ? normalizeComparablePath(port.cwd) : null - const commandLine = port.commandLine ? normalizeComparableText(port.commandLine) : null - - const cwdMatch = cwd - ? pickDeepestMatching(worktrees, ({ normalizedPath }) => - isSameOrDescendant(cwd, normalizedPath) - ) - : undefined - if (cwdMatch) { - return toOwner(cwdMatch.worktree, 'cwd') - } - - if (!commandLine) { - return undefined - } - - const commandMatch = pickDeepestMatching(worktrees, ({ normalizedPath }) => - includesPathBoundary(commandLine, normalizedPath) - ) - return commandMatch ? toOwner(commandMatch.worktree, 'command') : undefined -} - -export function parseLsofListeningOutput(output: string): RawListeningPort[] { - const ports: RawListeningPort[] = [] - let currentPid: number | undefined - let currentProcessName: string | undefined - - for (const line of output.split('\n')) { - if (!line) { - continue - } - const tag = line[0] - const value = line.slice(1) - if (tag === 'p') { - const pid = Number.parseInt(value, 10) - currentPid = Number.isFinite(pid) ? pid : undefined - currentProcessName = undefined - } else if (tag === 'c') { - currentProcessName = value - } else if (tag === 'n') { - const parsed = parseAddressWithPort(value) - if (parsed) { - ports.push({ pid: currentPid, processName: currentProcessName, ...parsed }) - } - } - } - - return dedupeRawPorts(ports) -} - -export function parseNetstatListeningOutput(output: string): RawListeningPort[] { - const ports: RawListeningPort[] = [] - for (const line of output.split('\n')) { - const fields = getProcessOutputFields(line, 6) - if (fields[0]?.toUpperCase() !== 'TCP') { - continue - } - const stateIndex = fields.findIndex((field) => field.toUpperCase() === 'LISTENING') - if (stateIndex < 2) { - continue - } - const parsed = parseAddressWithPort(fields[1]) - const pid = Number.parseInt(fields[stateIndex + 1] ?? '', 10) - if (!parsed) { - continue - } - ports.push({ ...parsed, pid: Number.isFinite(pid) ? pid : undefined }) - } - return dedupeRawPorts(ports) -} - -export function parseProcNetTcp(content: string): { host: string; port: number; inode: number }[] { - const results: { host: string; port: number; inode: number }[] = [] - const lines = content.split('\n') - for (let i = 1; i < lines.length; i++) { - const fields = getProcessOutputFields(lines[i], 10) - if (fields.length < 10 || fields[3] !== '0A') { - continue - } - const parsed = parseProcAddress(fields[1]) - const inode = Number.parseInt(fields[9], 10) - if (!parsed || !Number.isFinite(inode) || inode === 0) { - continue - } - results.push({ ...parsed, inode }) - } - return results -} - -async function scanPlatformListeningPorts( - options: WorkspacePortScanOptions -): Promise { - const scan = await dispatchPlatformListeningPortScan(options) - if (scan.metadataAvailable) { - rememberListenerMetadata(scan.ports) - return scan - } - return { ...scan, ports: scan.ports.map(recallListenerMetadata) } -} - -async function dispatchPlatformListeningPortScan( - options: WorkspacePortScanOptions -): Promise { - if (process.platform === 'linux') { - return scanLinuxProcPorts() - } - if (process.platform === 'darwin') { - return scanDarwinLsofPorts(options) - } - if (process.platform === 'win32') { - return scanWindowsNetstatPorts(options) - } - throw new Error(`Port scanning is not supported on ${process.platform}`) -} - -async function scanDarwinLsofPorts( - options: WorkspacePortScanOptions -): Promise { - const { stdout, spawnMs } = await runPortScanCommand('lsof', [ - '-nP', - '-iTCP', - '-sTCP:LISTEN', - '-F', - 'pcn' - ]) - const ports = parseLsofListeningOutput(stdout) - if (shouldSkipMetadataCommands(spawnMs, options)) { - return { ports, metadataAvailable: false } - } - const metadata = await loadDarwinProcessMetadata( - new Set(ports.flatMap((p) => (p.pid ? [p.pid] : []))) - ) - return { - ports: ports.map((port) => ({ ...metadata.get(port.pid ?? -1), ...port })), - metadataAvailable: true - } -} - -async function scanWindowsNetstatPorts( - options: WorkspacePortScanOptions -): Promise { - const { stdout, spawnMs } = await runPortScanCommand('netstat', ['-ano', '-p', 'tcp']) - const ports = parseNetstatListeningOutput(stdout) - if (shouldSkipMetadataCommands(spawnMs, options)) { - return { ports, metadataAvailable: false } - } - const metadata = await loadWindowsProcessMetadata( - new Set(ports.flatMap((p) => (p.pid ? [p.pid] : []))) - ) - return { - ports: ports.map((port) => ({ ...metadata.get(port.pid ?? -1), ...port })), - metadataAvailable: true - } -} - -async function scanLinuxProcPorts(): Promise { - const [tcp4, tcp6] = await Promise.all([ - readProcNet('/proc/net/tcp'), - readProcNet('/proc/net/tcp6') - ]) - const sockets = [...tcp4, ...tcp6] - const inodeToPid = await mapLinuxInodesToPids(new Set(sockets.map((socket) => socket.inode))) - const metadata = new Map() - const rawPorts: RawListeningPort[] = [] - - for (const socket of sockets) { - const pid = inodeToPid.get(socket.inode) - if (pid != null && !metadata.has(pid)) { - metadata.set(pid, await loadLinuxProcessMetadata(pid)) - } - rawPorts.push({ - host: socket.host, - port: socket.port, - pid, - ...metadata.get(pid ?? -1) - }) - } - - return { ports: dedupeRawPorts(rawPorts), metadataAvailable: true } -} - -async function readProcNet( - filePath: string -): Promise<{ host: string; port: number; inode: number }[]> { - try { - return parseProcNetTcp(await readFile(filePath, 'utf-8')) - } catch { - return [] - } -} - -async function mapLinuxInodesToPids(inodes: Set): Promise> { - const result = new Map() - if (inodes.size === 0) { - return result - } - let pids: string[] - try { - pids = (await readdir('/proc')).filter((entry) => /^\d+$/.test(entry)) - } catch { - return result - } - - for (const pidText of pids) { - let fds: string[] - try { - fds = await readdir(`/proc/${pidText}/fd`) - } catch { - continue - } - const pid = Number.parseInt(pidText, 10) - for (const fd of fds) { - let link: string - try { - link = await readlink(`/proc/${pidText}/fd/${fd}`) - } catch { - continue - } - const match = link.match(/^socket:\[(\d+)\]$/) - if (!match) { - continue - } - const inode = Number.parseInt(match[1], 10) - if (inodes.has(inode)) { - result.set(inode, pid) - } - } - } - return result -} - -async function loadLinuxProcessMetadata(pid: number): Promise { - const [comm, cmdline, cwd] = await Promise.all([ - readTextIfAvailable(`/proc/${pid}/comm`), - readTextIfAvailable(`/proc/${pid}/cmdline`), - readlink(`/proc/${pid}/cwd`).catch(() => undefined) - ]) - return { - processName: comm?.trim() || undefined, - commandLine: cmdline?.split('\u0000').join(' ').trim() || undefined, - cwd - } -} - -async function loadDarwinProcessMetadata(pids: Set): Promise> { - const result = new Map() - const pidList = Array.from(pids).join(',') - if (!pidList) { - return result - } - - // Why (#11161): sequential, not Promise.all โ€” the probe worker dispatches one - // command at a time, so issuing both at once would only queue the second. - const cwdOutput = await runPortScanCommand('lsof', [ - '-a', - '-p', - pidList, - '-d', - 'cwd', - '-Fn' - ]).catch(() => null) - const commandOutput = await runPortScanCommand('ps', [ - '-p', - pidList, - '-o', - 'pid=', - '-o', - 'command=' - ]).catch(() => null) - - let currentPid: number | null = null - for (const line of cwdOutput?.stdout.split('\n') ?? []) { - if (line.startsWith('p')) { - const pid = Number.parseInt(line.slice(1), 10) - currentPid = Number.isFinite(pid) ? pid : null - } else if (line.startsWith('n') && currentPid != null) { - result.set(currentPid, { ...result.get(currentPid), cwd: line.slice(1) || undefined }) - } - } - - for (const line of commandOutput?.stdout.split('\n') ?? []) { - const match = line.match(/^\s*(\d+)\s+(.+)$/) - if (!match) { - continue - } - const pid = Number.parseInt(match[1], 10) - result.set(pid, { ...result.get(pid), commandLine: match[2].trim() || undefined }) - } - - return result -} - -async function loadWindowsProcessMetadata( - pids: Set -): Promise> { - const result = new Map() - if (pids.size === 0) { - return result - } - try { - // Why the native snapshot: attributing ports used to fork a powershell.exe - // per scan just to turn PIDs into names. That is a ~700ms cold start, a - // conhost window, and one more thing a Group Policy can block -- for data - // the panel treats as optional anyway. - for (const row of await readWindowsProcessTable()) { - if (pids.has(row.pid)) { - result.set(row.pid, { - processName: row.name, - commandLine: row.command || undefined - }) - } - } - } catch { - // Process metadata is optional; port rows still render without attribution. - } - return result -} - -function isCommandTimeoutError(error: unknown): boolean { - return error instanceof PortScanCommandTimeoutError -} - -async function readTextIfAvailable(filePath: string): Promise { - try { - return await readFile(filePath, 'utf-8') - } catch { - return undefined - } -} - -function enrichPort( - port: RawListeningPort, - worktrees: readonly NormalizedWorkspacePortProbe[], - urlWatcher: Pick -): WorkspacePort { - const owner = attributePortToNormalizedWorkspaces(port, worktrees) - const base = { - id: `${port.host}:${port.port}:${port.pid ?? 'unknown'}`, - bindHost: port.host, - connectHost: connectHostForBindHost(port.host), - port: port.port, - pid: port.pid, - processName: port.processName, - protocol: inferProtocol(port.port) - } - - if (owner) { - // Why: only enrich workspace-attributed ports. Container and external - // ports may have URLs printed in unrelated terminals โ€” the worktree - // scoping is the primary false-positive filter. - const advertised = urlWatcher.lookup(owner.worktreeId, port.port, port.pid) - return { - ...base, - protocol: advertised?.protocol ?? base.protocol, - kind: 'workspace', - owner, - ...(advertised ? { advertisedUrl: advertised.origin } : {}) - } - } - if (isContainerProcess(port)) { - return { ...base, kind: 'container' } - } - return { ...base, kind: 'external' } -} - -function reconcileAdvertisedUrls( - ports: RawListeningPort[], - worktrees: readonly NormalizedWorkspacePortProbe[], - urlWatcher: Pick -): void { - const observationsByWorktree = new Map() - for (const worktree of worktrees) { - observationsByWorktree.set(worktree.worktree.id, []) - } - for (const port of ports) { - const owner = attributePortToNormalizedWorkspaces(port, worktrees) - if (!owner) { - continue - } - observationsByWorktree.get(owner.worktreeId)?.push({ port: port.port, pid: port.pid }) - } - for (const [worktreeId, observations] of observationsByWorktree) { - // Why: the scanner sees port disappearance and PID changes before a lazy - // lookup would otherwise pin a stale banner to a new listener. - urlWatcher.reconcileScan([worktreeId], observations) - } -} - -function compareWorkspacePorts(a: WorkspacePort, b: WorkspacePort): number { - const aRank = a.kind === 'workspace' ? 0 : a.kind === 'container' ? 1 : 2 - const bRank = b.kind === 'workspace' ? 0 : b.kind === 'container' ? 1 : 2 - return aRank - bRank || a.port - b.port || a.connectHost.localeCompare(b.connectHost) -} - -function inferProtocol(port: number): 'http' | 'https' | 'unknown' { - if (HTTPS_PORTS.has(port)) { - return 'https' - } - if (HTTP_PORTS.has(port)) { - return 'http' - } - return 'unknown' -} - -export function isContainerProcess( - port: Pick -): boolean { - const haystack = `${port.processName ?? ''} ${port.commandLine ?? ''}`.toLowerCase() - return /\b(com\.[\w.-]+\.backend|com\.container\w*|container\w*)\b/.test(haystack) -} - -function toOwner( - worktree: WorkspacePortProbe, - confidence: WorkspacePortOwner['confidence'] -): WorkspacePortOwner { - return { - worktreeId: worktree.id, - repoId: worktree.repoId, - displayName: worktree.displayName, - path: worktree.path, - confidence - } -} - -function pickDeepestMatching( - candidates: readonly T[], - predicate: (candidate: T) => boolean -): T | undefined { - let best: T | undefined - for (const candidate of candidates) { - if (!predicate(candidate)) { - continue - } - if (!best || candidate.normalizedPath.length > best.normalizedPath.length) { - best = candidate - } - } - return best -} - -function isSameOrDescendant(candidate: string, parent: string): boolean { - return candidate === parent || candidate.startsWith(`${parent}/`) -} - -function includesPathBoundary(commandLine: string, normalizedPath: string): boolean { - let index = commandLine.indexOf(normalizedPath) - while (index !== -1) { - const before = index === 0 ? '' : commandLine[index - 1] - const after = commandLine[index + normalizedPath.length] ?? '' - const startsOnBoundary = before === '' || /\s|["'=]/.test(before) - const endsOnBoundary = after === '' || /[\s"'/:]/.test(after) - if (startsOnBoundary && endsOnBoundary) { - return true - } - index = commandLine.indexOf(normalizedPath, index + normalizedPath.length) - } - return false -} - -function normalizeComparablePath(input: string): string { - if (input.startsWith('/')) { - // Why: command-line evidence for SSH/WSL/POSIX workspaces can be evaluated - // on a Windows host; path.resolve would reinterpret "/repo" as "G:/repo". - return normalizeComparableText(path.posix.resolve(input)) - } - return normalizeComparableText(path.resolve(input)) -} - -function normalizeComparableText(input: string): string { - const normalized = input.replace(/\\/g, '/').replace(/\/+/g, '/') - return process.platform === 'win32' ? normalized.toLowerCase() : normalized -} - -function connectHostForBindHost(host: string): string { - if (host === '*' || host === '0.0.0.0' || host === '::') { - return 'localhost' - } - return host -} - -function dedupeRawPorts(ports: RawListeningPort[]): RawListeningPort[] { - const seen = new Set() - const result: RawListeningPort[] = [] - for (const port of ports) { - const key = `${connectHostForBindHost(port.host)}:${port.port}:${port.pid ?? 'unknown'}` - if (seen.has(key)) { - continue - } - seen.add(key) - result.push(port) - } - return result -} - -function parseAddressWithPort(value: string): { host: string; port: number } | null { - const trimmed = value.trim().replace(/\s+\(LISTEN\)$/i, '') - const bracketed = trimmed.match(/^\[([^\]]+)\]:(\d+)$/) - if (bracketed) { - return { host: bracketed[1], port: Number.parseInt(bracketed[2], 10) } - } - const match = trimmed.match(/^(.+):(\d+)$/) - if (!match) { - return null - } - const port = Number.parseInt(match[2], 10) - if (!Number.isFinite(port) || port <= 0 || port > 65535) { - return null - } - return { host: match[1], port } -} - -function parseProcAddress(hexAddress: string): { host: string; port: number } | null { - const [addrHex, portHex] = hexAddress.split(':') - const port = Number.parseInt(portHex, 16) - if (!Number.isFinite(port) || port === 0) { - return null - } - if (addrHex.length === 8) { - const bytes = [6, 4, 2, 0].map((index) => Number.parseInt(addrHex.slice(index, index + 2), 16)) - return { host: bytes.join('.'), port } - } - if (addrHex.length === 32) { - if (addrHex === '00000000000000000000000000000000') { - return { host: '::', port } - } - if (addrHex === '00000000000000000000000001000000') { - return { host: '::1', port } - } - return { host: formatIPv6Address(addrHex), port } - } - return null -} - -function formatIPv6Address(hex: string): string { - const groups: string[] = [] - for (let i = 0; i < 32; i += 8) { - const chunk = hex.slice(i, i + 8) - const reversed = chunk.slice(6, 8) + chunk.slice(4, 6) + chunk.slice(2, 4) + chunk.slice(0, 2) - groups.push(reversed.slice(0, 4), reversed.slice(4, 8)) - } - return groups.map((group) => group.replace(/^0+/, '') || '0').join(':') -} diff --git a/src/main/ports/workspace-port-ownership.ts b/src/main/ports/workspace-port-ownership.ts index 31c7d193c62..754172d3b42 100644 --- a/src/main/ports/workspace-port-ownership.ts +++ b/src/main/ports/workspace-port-ownership.ts @@ -8,7 +8,8 @@ import type { WorkspacePortProbe, WorkspacePortScanResult } from '../../shared/workspace-ports' -import { scanWorkspacePorts, type WorkspacePortScanOptions } from './local-workspace-port-scanner' +import { scanWorkspacePorts } from './local-workspace-port-scanner' +import type { WorkspacePortScanOptions } from './local-workspace-port-scan-state' export type WorkspacePortProbeInput = WorkspacePortProbe & { connectionId?: string | null diff --git a/tests/e2e/helpers/terminal-active-pane.ts b/tests/e2e/helpers/terminal-active-pane.ts new file mode 100644 index 00000000000..b5987c61f3a --- /dev/null +++ b/tests/e2e/helpers/terminal-active-pane.ts @@ -0,0 +1,159 @@ +import { expect, type Page } from '@stablyai/playwright-test' +import { buildFreshShellProbeInputSequence } from '../terminal-probe-input-sequence' +import { + getTerminalContent, + resolveActiveTabId, + type ActivePaneHookDescriptor +} from './terminal-pane-identity' + +export async function waitForActivePaneHookDescriptor( + page: Page, + timeoutMs = 15_000 +): Promise { + let descriptor: ActivePaneHookDescriptor | null = null + await expect + .poll( + async () => { + const tabId = await resolveActiveTabId(page) + if (!tabId) { + descriptor = null + return false + } + descriptor = await page.evaluate((tabId) => { + const layoutHasLeaf = (node: unknown, targetLeafId: string): boolean => { + if (!node || typeof node !== 'object') { + return false + } + const record = node as { + type?: unknown + leafId?: unknown + first?: unknown + second?: unknown + } + if (record.type === 'leaf') { + return record.leafId === targetLeafId + } + return ( + layoutHasLeaf(record.first, targetLeafId) || + layoutHasLeaf(record.second, targetLeafId) + ) + } + + const store = window.__store + const manager = window.__paneManagers?.get(tabId) + if (!store || !manager) { + return null + } + const state = store.getState() + const worktreeId = state.activeWorktreeId + if ( + !worktreeId || + !(state.tabsByWorktree[worktreeId] ?? []).some((tab) => tab.id === tabId) + ) { + return null + } + + const activePane = manager.getActivePane?.() ?? manager.getPanes?.()[0] + const leafId = activePane?.leafId ?? null + const layout = state.terminalLayoutsByTabId[tabId] + if ( + !leafId || + !layoutHasLeaf(layout?.root, leafId) || + layout?.ptyIdsByLeafId?.[leafId] !== activePane?.container?.dataset?.ptyId + ) { + return null + } + return { paneKey: `${tabId}:${leafId}`, worktreeId } + }, tabId) + return descriptor !== null + }, + { + timeout: timeoutMs, + // Why: hook IPC routing drops statuses for pane keys before the store + // layout knows that leaf, even if the terminal DOM already has a PTY. + message: 'Active terminal pane did not become routable for hook status IPC' + } + ) + .toBe(true) + + if (!descriptor) { + throw new Error('Active terminal pane descriptor disappeared after routing wait') + } + return descriptor +} + +// Why: PTY IDs are opaque integers not exposed in the DOM. Probe each +// candidate with a unique marker and read back via SerializeAddon. +export async function discoverActivePtyId(page: Page): Promise { + const marker = `__PTY_PROBE_${Date.now()}__` + + const readCandidateIds = async (): Promise => { + const tabId = await resolveActiveTabId(page) + if (!tabId) { + return [] + } + return page.evaluate((tabId) => { + const store = window.__store + if (!store) { + return [] + } + return store.getState().ptyIdsByTabId[tabId] ?? [] + }, tabId) + } + + await expect + .poll(readCandidateIds, { + timeout: 15_000, + message: 'discoverActivePtyId: active tab never received PTY candidates' + }) + .not.toEqual([]) + + const candidateIds = await readCandidateIds() + + if (candidateIds.length === 0) { + // Why: blind-probing arbitrary PTY IDs can write into unrelated shells and + // hides real regressions in the tab->PTY mapping the test depends on. + throw new Error('discoverActivePtyId: active tab has no PTY candidates in store') + } + + const candidateInputs = candidateIds.map((_id, index) => + buildFreshShellProbeInputSequence(`echo ${marker}_${index}\r`) + ) + + await page.evaluate( + ({ candidateIds, candidateInputs }) => { + // Why: daemon PTY IDs can contain path separators and shell metacharacters. + // Echo a numeric probe index, then map it back to the opaque ID in Node. + for (const [index, id] of candidateIds.entries()) { + for (const input of candidateInputs[index] ?? []) { + window.api.pty.write(String(id), input) + } + } + }, + { candidateIds, candidateInputs } + ) + + let foundPtyId: string | null = null + await expect + .poll( + async () => { + const content = await getTerminalContent(page) + const markerRe = new RegExp(`${marker}_(\\d+)`, 'g') + const matches = [...content.matchAll(markerRe)] + if (matches.length > 0) { + const index = Number(matches.at(-1)?.[1] ?? Number.NaN) + foundPtyId = Number.isInteger(index) ? (candidateIds[index] ?? null) : null + return true + } + return false + }, + { timeout: 10_000, message: 'PTY marker did not appear in terminal buffer' } + ) + .toBe(true) + + if (!foundPtyId) { + throw new Error('discoverActivePtyId: no marker found in terminal buffer') + } + + return foundPtyId +} diff --git a/tests/e2e/helpers/terminal-pane-identity.ts b/tests/e2e/helpers/terminal-pane-identity.ts new file mode 100644 index 00000000000..bdc8f254b2b --- /dev/null +++ b/tests/e2e/helpers/terminal-pane-identity.ts @@ -0,0 +1,113 @@ +import type { Page } from '@stablyai/playwright-test' + +export type PaneIdentitySnapshot = { + tabId: string + activeLeafId: string | null + panes: { + numericPaneId: number + leafId: string + stablePaneId: string + datasetLeafId: string | null + ptyId: string | null + }[] + ptyIdsByLeafId: Record +} + +export type ActivePaneHookDescriptor = { + paneKey: string + worktreeId: string +} + +// Why: worktree restoration can render the terminal surface before the legacy +// global activeTabId settles. Prefer the active worktree's saved terminal tab +// pointer, then fall back to the first terminal tab. +export async function resolveActiveTabId(page: Page): Promise { + return page.evaluate(() => { + const store = window.__store + if (!store) { + return null + } + const state = store.getState() + const wId = state.activeWorktreeId + if (!wId) { + return null + } + const tabs = state.tabsByWorktree[wId] ?? [] + if (tabs.length === 0) { + return null + } + const pref = + state.activeTabType === 'terminal' + ? state.activeTabId + : (state.activeTabIdByWorktree?.[wId] ?? null) + if (pref && tabs.some((t) => t.id === pref)) { + return pref + } + return tabs[0]?.id ?? null + }) +} + +// Why: reads the buffer through the SerializeAddon that the PaneManager +// already loads for every terminal pane (exposed via VITE_EXPOSE_STORE). +export async function getTerminalContent(page: Page, charLimit = 4000): Promise { + const tabId = await resolveActiveTabId(page) + if (!tabId) { + return '' + } + return page.evaluate( + ({ tabId, charLimit }) => { + const paneManagers = window.__paneManagers + if (!paneManagers) { + return '' + } + + const manager = paneManagers.get(tabId) + if (!manager) { + return '' + } + + const activePane = manager.getActivePane?.() + if (!activePane) { + const panes = manager.getPanes?.() ?? [] + if (panes.length === 0) { + return '' + } + const text = panes[0].serializeAddon?.serialize?.() ?? '' + return text.slice(-charLimit) + } + + const text = activePane.serializeAddon?.serialize?.() ?? '' + return text.slice(-charLimit) + }, + { tabId, charLimit } + ) +} + +export async function readPaneIdentitySnapshot(page: Page): Promise { + const tabId = await resolveActiveTabId(page) + if (!tabId) { + return null + } + + return page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const store = window.__store + if (!manager || !store) { + return null + } + + const activePane = manager.getActivePane?.() ?? null + return { + tabId, + activeLeafId: activePane?.leafId ?? null, + panes: manager.getPanes().map((pane) => ({ + numericPaneId: pane.id, + leafId: pane.leafId, + stablePaneId: pane.stablePaneId, + datasetLeafId: pane.container.dataset.leafId ?? null, + ptyId: pane.container.dataset.ptyId ?? null + })), + ptyIdsByLeafId: store.getState().terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId ?? {} + } + }, tabId) +} diff --git a/tests/e2e/helpers/terminal-pane-operations.ts b/tests/e2e/helpers/terminal-pane-operations.ts new file mode 100644 index 00000000000..d3d488f35e3 --- /dev/null +++ b/tests/e2e/helpers/terminal-pane-operations.ts @@ -0,0 +1,226 @@ +import { expect, type Page } from '@stablyai/playwright-test' +import { + getTerminalContent, + readPaneIdentitySnapshot, + resolveActiveTabId +} from './terminal-pane-identity' + +export async function readTerminalPaneDomLeafOrder(page: Page): Promise { + const snapshot = await readPaneIdentitySnapshot(page) + if (!snapshot) { + return [] + } + + return page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + if (!manager) { + return [] + } + const paneElements = new Set(manager.getPanes().map((pane) => pane.container)) + return Array.from(document.querySelectorAll('.pane[data-leaf-id]')) + .filter((element) => paneElements.has(element)) + .map((element) => element.dataset.leafId ?? '') + .filter((leafId) => leafId.length > 0) + }, snapshot.tabId) +} + +export async function moveTerminalPaneByLeafId( + page: Page, + sourceLeafId: string, + targetLeafId: string, + zone: 'top' | 'bottom' | 'left' | 'right' +): Promise { + const snapshot = await readPaneIdentitySnapshot(page) + if (!snapshot) { + throw new Error('moveTerminalPaneByLeafId: no active terminal tab') + } + + await page.evaluate( + ({ tabId, sourceLeafId, targetLeafId, zone }) => { + const manager = window.__paneManagers?.get(tabId) + if (!manager) { + throw new Error('moveTerminalPaneByLeafId: active pane manager not ready') + } + const sourcePaneId = manager.getNumericIdForLeaf(sourceLeafId) + const targetPaneId = manager.getNumericIdForLeaf(targetLeafId) + if (sourcePaneId == null || targetPaneId == null) { + throw new Error('moveTerminalPaneByLeafId: source or target leaf is not mounted') + } + manager.movePane(sourcePaneId, targetPaneId, zone) + }, + { tabId: snapshot.tabId, sourceLeafId, targetLeafId, zone } + ) +} + +export async function sendToTerminal(page: Page, ptyId: string, text: string): Promise { + await page.evaluate( + ({ ptyId, text }) => { + window.api.pty.write(ptyId, text) + }, + { ptyId, text } + ) +} + +export async function execInTerminal(page: Page, ptyId: string, command: string): Promise { + await sendToTerminal(page, ptyId, `${command}\r`) +} + +export async function waitForActiveTerminalManager(page: Page, timeoutMs = 30_000): Promise { + await expect + .poll( + async () => { + const tabId = await resolveActiveTabId(page) + if (!tabId) { + return false + } + return page.evaluate((tabId) => { + const paneManagers = window.__paneManagers + if (!paneManagers) { + return false + } + return (paneManagers.get(tabId)?.getPanes?.().length ?? 0) > 0 + }, tabId) + }, + { + timeout: timeoutMs, + message: 'Active terminal PaneManager did not finish mounting' + } + ) + .toBe(true) +} + +export async function splitActiveTerminalPane( + page: Page, + direction: 'vertical' | 'horizontal' +): Promise { + const tabId = await resolveActiveTabId(page) + if (!tabId) { + throw new Error('splitActiveTerminalPane: no active terminal tab') + } + await page.evaluate( + ({ tabId, direction }) => { + const paneManagers = window.__paneManagers + if (!paneManagers) { + throw new Error('splitActiveTerminalPane: terminal store/manager unavailable') + } + + const manager = paneManagers.get(tabId) + const activePane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!manager?.splitPane || !activePane) { + throw new Error('splitActiveTerminalPane: active pane manager not ready') + } + + // Why: Electron key delivery to the terminal pane layer is flaky in E2E + // even when the visible pane tree is mounted. Driving the active + // PaneManager directly still exercises the real split/layout/PTY path + // without depending on window-focus timing. + manager.splitPane(activePane.id, direction) + }, + { tabId, direction } + ) +} + +export async function closeActiveTerminalPane(page: Page): Promise { + const tabId = await resolveActiveTabId(page) + if (!tabId) { + throw new Error('closeActiveTerminalPane: no active terminal tab') + } + await page.evaluate((tabId) => { + const paneManagers = window.__paneManagers + if (!paneManagers) { + throw new Error('closeActiveTerminalPane: terminal store/manager unavailable') + } + + const manager = paneManagers.get(tabId) + const panes = manager?.getPanes?.() ?? [] + if (!manager?.closePane || panes.length < 2) { + return + } + + const activePane = manager.getActivePane?.() ?? panes[0] + if (!activePane) { + return + } + + manager.closePane(activePane.id) + }, tabId) +} + +export async function focusLastTerminalPane(page: Page): Promise { + const tabId = await resolveActiveTabId(page) + if (!tabId) { + throw new Error('focusLastTerminalPane: no active terminal tab') + } + await page.evaluate((tabId) => { + const paneManagers = window.__paneManagers + if (!paneManagers) { + throw new Error('focusLastTerminalPane: terminal store/manager unavailable') + } + + const manager = paneManagers.get(tabId) + const panes = manager?.getPanes?.() ?? [] + const lastPane = panes.at(-1) ?? null + if (!manager?.setActivePane || !lastPane) { + throw new Error('focusLastTerminalPane: active pane manager not ready') + } + + manager.setActivePane(lastPane.id, { focus: true }) + }, tabId) +} + +// Why: hidden-window E2E mode keeps DOM visibility signals false. The pane +// manager tracks the authoritative active split layout independently of CSS. +export async function countVisibleTerminalPanes(page: Page): Promise { + const tabId = await resolveActiveTabId(page) + if (!tabId) { + return 0 + } + return page.evaluate((tabId) => { + const managerCount = window.__paneManagers?.get(tabId)?.getPanes?.().length ?? 0 + if (managerCount > 0) { + return managerCount + } + + const layout = window.__store?.getState().terminalLayoutsByTabId[tabId] + if (!layout) { + return 0 + } + + // Why: `root: null` means the default single-pane tab (no splits yet). + type N = { type: 'leaf' } | { type: 'split'; first: N | null; second: N | null } | null + const countLeaves = (node: N): number => { + if (!node || node.type === 'leaf') { + return 1 + } + return countLeaves(node.first) + countLeaves(node.second) + } + return countLeaves(layout.root as N) + }, tabId) +} + +export async function waitForTerminalOutput( + page: Page, + expected: string, + timeoutMs = 10_000, + charLimit = 4000 +): Promise { + await expect + .poll(async () => (await getTerminalContent(page, charLimit)).includes(expected), { + timeout: timeoutMs, + message: `Terminal did not contain "${expected}"` + }) + .toBe(true) +} + +export async function waitForPaneCount( + page: Page, + expectedCount: number, + timeoutMs = 10_000 +): Promise { + await expect + .poll(async () => countVisibleTerminalPanes(page), { + timeout: timeoutMs, + message: `Expected ${expectedCount} visible terminal panes` + }) + .toBe(expectedCount) +} diff --git a/tests/e2e/helpers/terminal.ts b/tests/e2e/helpers/terminal.ts index ec55b702c39..ddfaf506156 100644 --- a/tests/e2e/helpers/terminal.ts +++ b/tests/e2e/helpers/terminal.ts @@ -1,28 +1,40 @@ -/* eslint-disable max-lines -- Terminal E2E helpers share one PaneManager-backed path for PTY IO, split actions, and stable pane identity snapshots. */ import type { Page } from '@stablyai/playwright-test' import { expect } from '@stablyai/playwright-test' -import { buildFreshShellProbeInputSequence } from '../terminal-probe-input-sequence' +import { + getTerminalContent, + readPaneIdentitySnapshot, + resolveActiveTabId, + type ActivePaneHookDescriptor, + type PaneIdentitySnapshot +} from './terminal-pane-identity' +import { + discoverActivePtyId as discoverActivePtyIdImpl, + waitForActivePaneHookDescriptor as waitForActivePaneHookDescriptorImpl +} from './terminal-active-pane' +import { + closeActiveTerminalPane as closeActiveTerminalPaneImpl, + countVisibleTerminalPanes as countVisibleTerminalPanesImpl, + execInTerminal as execInTerminalImpl, + focusLastTerminalPane as focusLastTerminalPaneImpl, + moveTerminalPaneByLeafId as moveTerminalPaneByLeafIdImpl, + readTerminalPaneDomLeafOrder as readTerminalPaneDomLeafOrderImpl, + sendToTerminal as sendToTerminalImpl, + splitActiveTerminalPane as splitActiveTerminalPaneImpl, + waitForActiveTerminalManager as waitForActiveTerminalManagerImpl, + waitForPaneCount as waitForPaneCountImpl, + waitForTerminalOutput as waitForTerminalOutputImpl +} from './terminal-pane-operations' + +export { + getTerminalContent, + readPaneIdentitySnapshot, + resolveActiveTabId, + type ActivePaneHookDescriptor, + type PaneIdentitySnapshot +} export const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ -export type PaneIdentitySnapshot = { - tabId: string - activeLeafId: string | null - panes: { - numericPaneId: number - leafId: string - stablePaneId: string - datasetLeafId: string | null - ptyId: string | null - }[] - ptyIdsByLeafId: Record -} - -export type ActivePaneHookDescriptor = { - paneKey: string - worktreeId: string -} - // Why: typing-latency specs must type into xterm's helper textarea, not the // page body โ€” keyboard.type only reaches the PTY when that textarea has focus. export async function focusActiveTerminalInput(page: Page): Promise { @@ -53,71 +65,6 @@ export async function focusActiveTerminalInput(page: Page): Promise { }) } -// Why: worktree restoration can render the terminal surface before the legacy -// global activeTabId settles. Prefer the active worktree's saved terminal tab -// pointer, then fall back to the first terminal tab. -async function resolveActiveTabId(page: Page): Promise { - return page.evaluate(() => { - const store = window.__store - if (!store) { - return null - } - const state = store.getState() - const wId = state.activeWorktreeId - if (!wId) { - return null - } - const tabs = state.tabsByWorktree[wId] ?? [] - if (tabs.length === 0) { - return null - } - const pref = - state.activeTabType === 'terminal' - ? state.activeTabId - : (state.activeTabIdByWorktree?.[wId] ?? null) - if (pref && tabs.some((t) => t.id === pref)) { - return pref - } - return tabs[0]?.id ?? null - }) -} - -// Why: reads the buffer through the SerializeAddon that the PaneManager -// already loads for every terminal pane (exposed via VITE_EXPOSE_STORE). -export async function getTerminalContent(page: Page, charLimit = 4000): Promise { - const tabId = await resolveActiveTabId(page) - if (!tabId) { - return '' - } - return page.evaluate( - ({ tabId, charLimit }) => { - const paneManagers = window.__paneManagers - if (!paneManagers) { - return '' - } - - const manager = paneManagers.get(tabId) - if (!manager) { - return '' - } - - const activePane = manager.getActivePane?.() - if (!activePane) { - const panes = manager.getPanes?.() ?? [] - if (panes.length === 0) { - return '' - } - const text = panes[0].serializeAddon?.serialize?.() ?? '' - return text.slice(-charLimit) - } - - const text = activePane.serializeAddon?.serialize?.() ?? '' - return text.slice(-charLimit) - }, - { tabId, charLimit } - ) -} - export async function waitForActivePanePtyId(page: Page, timeoutMs = 15_000): Promise { let resolvedPtyId: string | null = null await expect @@ -148,187 +95,6 @@ export async function waitForActivePanePtyId(page: Page, timeoutMs = 15_000): Pr return resolvedPtyId } -export async function waitForActivePaneHookDescriptor( - page: Page, - timeoutMs = 15_000 -): Promise { - let descriptor: ActivePaneHookDescriptor | null = null - await expect - .poll( - async () => { - const tabId = await resolveActiveTabId(page) - if (!tabId) { - descriptor = null - return false - } - descriptor = await page.evaluate((tabId) => { - const layoutHasLeaf = (node: unknown, targetLeafId: string): boolean => { - if (!node || typeof node !== 'object') { - return false - } - const record = node as { - type?: unknown - leafId?: unknown - first?: unknown - second?: unknown - } - if (record.type === 'leaf') { - return record.leafId === targetLeafId - } - return ( - layoutHasLeaf(record.first, targetLeafId) || - layoutHasLeaf(record.second, targetLeafId) - ) - } - - const store = window.__store - const manager = window.__paneManagers?.get(tabId) - if (!store || !manager) { - return null - } - const state = store.getState() - const worktreeId = state.activeWorktreeId - if ( - !worktreeId || - !(state.tabsByWorktree[worktreeId] ?? []).some((tab) => tab.id === tabId) - ) { - return null - } - - const activePane = manager.getActivePane?.() ?? manager.getPanes?.()[0] - const leafId = activePane?.leafId ?? null - const layout = state.terminalLayoutsByTabId[tabId] - if ( - !leafId || - !layoutHasLeaf(layout?.root, leafId) || - layout?.ptyIdsByLeafId?.[leafId] !== activePane?.container?.dataset?.ptyId - ) { - return null - } - return { paneKey: `${tabId}:${leafId}`, worktreeId } - }, tabId) - return descriptor !== null - }, - { - timeout: timeoutMs, - // Why: hook IPC routing drops statuses for pane keys before the store - // layout knows that leaf, even if the terminal DOM already has a PTY. - message: 'Active terminal pane did not become routable for hook status IPC' - } - ) - .toBe(true) - - if (!descriptor) { - throw new Error('Active terminal pane descriptor disappeared after routing wait') - } - return descriptor -} - -// Why: PTY IDs are opaque integers not exposed in the DOM. Probe each -// candidate with a unique marker and read back via SerializeAddon. -export async function discoverActivePtyId(page: Page): Promise { - const marker = `__PTY_PROBE_${Date.now()}__` - - const readCandidateIds = async (): Promise => { - const tabId = await resolveActiveTabId(page) - if (!tabId) { - return [] - } - return page.evaluate((tabId) => { - const store = window.__store - if (!store) { - return [] - } - return store.getState().ptyIdsByTabId[tabId] ?? [] - }, tabId) - } - - await expect - .poll(readCandidateIds, { - timeout: 15_000, - message: 'discoverActivePtyId: active tab never received PTY candidates' - }) - .not.toEqual([]) - - const candidateIds = await readCandidateIds() - - if (candidateIds.length === 0) { - // Why: blind-probing arbitrary PTY IDs can write into unrelated shells and - // hides real regressions in the tab->PTY mapping the test depends on. - throw new Error('discoverActivePtyId: active tab has no PTY candidates in store') - } - - const candidateInputs = candidateIds.map((_id, index) => - buildFreshShellProbeInputSequence(`echo ${marker}_${index}\r`) - ) - - await page.evaluate( - ({ candidateIds, candidateInputs }) => { - // Why: daemon PTY IDs can contain path separators and shell metacharacters. - // Echo a numeric probe index, then map it back to the opaque ID in Node. - for (const [index, id] of candidateIds.entries()) { - for (const input of candidateInputs[index] ?? []) { - window.api.pty.write(String(id), input) - } - } - }, - { candidateIds, candidateInputs } - ) - - let foundPtyId: string | null = null - await expect - .poll( - async () => { - const content = await getTerminalContent(page) - const markerRe = new RegExp(`${marker}_(\\d+)`, 'g') - const matches = [...content.matchAll(markerRe)] - if (matches.length > 0) { - const index = Number(matches.at(-1)?.[1] ?? Number.NaN) - foundPtyId = Number.isInteger(index) ? (candidateIds[index] ?? null) : null - return true - } - return false - }, - { timeout: 10_000, message: 'PTY marker did not appear in terminal buffer' } - ) - .toBe(true) - - if (!foundPtyId) { - throw new Error('discoverActivePtyId: no marker found in terminal buffer') - } - - return foundPtyId -} - -export async function readPaneIdentitySnapshot(page: Page): Promise { - const tabId = await resolveActiveTabId(page) - if (!tabId) { - return null - } - - return page.evaluate((tabId) => { - const manager = window.__paneManagers?.get(tabId) - const store = window.__store - if (!manager || !store) { - return null - } - - const activePane = manager.getActivePane?.() ?? null - return { - tabId, - activeLeafId: activePane?.leafId ?? null, - panes: manager.getPanes().map((pane) => ({ - numericPaneId: pane.id, - leafId: pane.leafId, - stablePaneId: pane.stablePaneId, - datasetLeafId: pane.container.dataset.leafId ?? null, - ptyId: pane.container.dataset.ptyId ?? null - })), - ptyIdsByLeafId: store.getState().terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId ?? {} - } - }, tabId) -} - export async function waitForPaneIdentitySnapshot( page: Page, paneCount: number @@ -364,222 +130,74 @@ export async function waitForPaneIdentitySnapshot( return snapshot } -export async function readTerminalPaneDomLeafOrder(page: Page): Promise { - const snapshot = await readPaneIdentitySnapshot(page) - if (!snapshot) { - return [] - } - - return page.evaluate((tabId) => { - const manager = window.__paneManagers?.get(tabId) - if (!manager) { - return [] - } - const paneElements = new Set(manager.getPanes().map((pane) => pane.container)) - return Array.from(document.querySelectorAll('.pane[data-leaf-id]')) - .filter((element) => paneElements.has(element)) - .map((element) => element.dataset.leafId ?? '') - .filter((leafId) => leafId.length > 0) - }, snapshot.tabId) +export function waitForActivePaneHookDescriptor( + page: Page, + timeoutMs = 15_000 +): Promise { + return waitForActivePaneHookDescriptorImpl(page, timeoutMs) } -export async function moveTerminalPaneByLeafId( +export function discoverActivePtyId(page: Page): Promise { + return discoverActivePtyIdImpl(page) +} + +export function readTerminalPaneDomLeafOrder(page: Page): Promise { + return readTerminalPaneDomLeafOrderImpl(page) +} + +export function moveTerminalPaneByLeafId( page: Page, sourceLeafId: string, targetLeafId: string, zone: 'top' | 'bottom' | 'left' | 'right' ): Promise { - const snapshot = await readPaneIdentitySnapshot(page) - if (!snapshot) { - throw new Error('moveTerminalPaneByLeafId: no active terminal tab') - } - - await page.evaluate( - ({ tabId, sourceLeafId, targetLeafId, zone }) => { - const manager = window.__paneManagers?.get(tabId) - if (!manager) { - throw new Error('moveTerminalPaneByLeafId: active pane manager not ready') - } - const sourcePaneId = manager.getNumericIdForLeaf(sourceLeafId) - const targetPaneId = manager.getNumericIdForLeaf(targetLeafId) - if (sourcePaneId == null || targetPaneId == null) { - throw new Error('moveTerminalPaneByLeafId: source or target leaf is not mounted') - } - manager.movePane(sourcePaneId, targetPaneId, zone) - }, - { tabId: snapshot.tabId, sourceLeafId, targetLeafId, zone } - ) + return moveTerminalPaneByLeafIdImpl(page, sourceLeafId, targetLeafId, zone) } -export async function sendToTerminal(page: Page, ptyId: string, text: string): Promise { - await page.evaluate( - ({ ptyId, text }) => { - window.api.pty.write(ptyId, text) - }, - { ptyId, text } - ) +export function sendToTerminal(page: Page, ptyId: string, text: string): Promise { + return sendToTerminalImpl(page, ptyId, text) } -export async function execInTerminal(page: Page, ptyId: string, command: string): Promise { - await sendToTerminal(page, ptyId, `${command}\r`) +export function execInTerminal(page: Page, ptyId: string, command: string): Promise { + return execInTerminalImpl(page, ptyId, command) } -export async function waitForActiveTerminalManager(page: Page, timeoutMs = 30_000): Promise { - await expect - .poll( - async () => { - const tabId = await resolveActiveTabId(page) - if (!tabId) { - return false - } - return page.evaluate((tabId) => { - const paneManagers = window.__paneManagers - if (!paneManagers) { - return false - } - return (paneManagers.get(tabId)?.getPanes?.().length ?? 0) > 0 - }, tabId) - }, - { - timeout: timeoutMs, - message: 'Active terminal PaneManager did not finish mounting' - } - ) - .toBe(true) +export function waitForActiveTerminalManager(page: Page, timeoutMs = 30_000): Promise { + return waitForActiveTerminalManagerImpl(page, timeoutMs) } -export async function splitActiveTerminalPane( +export function splitActiveTerminalPane( page: Page, direction: 'vertical' | 'horizontal' ): Promise { - const tabId = await resolveActiveTabId(page) - if (!tabId) { - throw new Error('splitActiveTerminalPane: no active terminal tab') - } - await page.evaluate( - ({ tabId, direction }) => { - const paneManagers = window.__paneManagers - if (!paneManagers) { - throw new Error('splitActiveTerminalPane: terminal store/manager unavailable') - } - - const manager = paneManagers.get(tabId) - const activePane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null - if (!manager?.splitPane || !activePane) { - throw new Error('splitActiveTerminalPane: active pane manager not ready') - } - - // Why: Electron key delivery to the terminal pane layer is flaky in E2E - // even when the visible pane tree is mounted. Driving the active - // PaneManager directly still exercises the real split/layout/PTY path - // without depending on window-focus timing. - manager.splitPane(activePane.id, direction) - }, - { tabId, direction } - ) + return splitActiveTerminalPaneImpl(page, direction) } -export async function closeActiveTerminalPane(page: Page): Promise { - const tabId = await resolveActiveTabId(page) - if (!tabId) { - throw new Error('closeActiveTerminalPane: no active terminal tab') - } - await page.evaluate((tabId) => { - const paneManagers = window.__paneManagers - if (!paneManagers) { - throw new Error('closeActiveTerminalPane: terminal store/manager unavailable') - } - - const manager = paneManagers.get(tabId) - const panes = manager?.getPanes?.() ?? [] - if (!manager?.closePane || panes.length < 2) { - return - } - - const activePane = manager.getActivePane?.() ?? panes[0] - if (!activePane) { - return - } - - manager.closePane(activePane.id) - }, tabId) +export function closeActiveTerminalPane(page: Page): Promise { + return closeActiveTerminalPaneImpl(page) } -export async function focusLastTerminalPane(page: Page): Promise { - const tabId = await resolveActiveTabId(page) - if (!tabId) { - throw new Error('focusLastTerminalPane: no active terminal tab') - } - await page.evaluate((tabId) => { - const paneManagers = window.__paneManagers - if (!paneManagers) { - throw new Error('focusLastTerminalPane: terminal store/manager unavailable') - } - - const manager = paneManagers.get(tabId) - const panes = manager?.getPanes?.() ?? [] - const lastPane = panes.at(-1) ?? null - if (!manager?.setActivePane || !lastPane) { - throw new Error('focusLastTerminalPane: active pane manager not ready') - } - - manager.setActivePane(lastPane.id, { focus: true }) - }, tabId) +export function focusLastTerminalPane(page: Page): Promise { + return focusLastTerminalPaneImpl(page) } -// Why: hidden-window E2E mode keeps DOM visibility signals false. The pane -// manager tracks the authoritative active split layout independently of CSS. -export async function countVisibleTerminalPanes(page: Page): Promise { - const tabId = await resolveActiveTabId(page) - if (!tabId) { - return 0 - } - return page.evaluate((tabId) => { - const managerCount = window.__paneManagers?.get(tabId)?.getPanes?.().length ?? 0 - if (managerCount > 0) { - return managerCount - } - - const layout = window.__store?.getState().terminalLayoutsByTabId[tabId] - if (!layout) { - return 0 - } - - // Why: `root: null` means the default single-pane tab (no splits yet). - type N = { type: 'leaf' } | { type: 'split'; first: N | null; second: N | null } | null - const countLeaves = (node: N): number => { - if (!node || node.type === 'leaf') { - return 1 - } - return countLeaves(node.first) + countLeaves(node.second) - } - return countLeaves(layout.root as N) - }, tabId) +export function countVisibleTerminalPanes(page: Page): Promise { + return countVisibleTerminalPanesImpl(page) } -export async function waitForTerminalOutput( +export function waitForTerminalOutput( page: Page, expected: string, timeoutMs = 10_000, charLimit = 4000 ): Promise { - await expect - .poll(async () => (await getTerminalContent(page, charLimit)).includes(expected), { - timeout: timeoutMs, - message: `Terminal did not contain "${expected}"` - }) - .toBe(true) + return waitForTerminalOutputImpl(page, expected, timeoutMs, charLimit) } -export async function waitForPaneCount( +export function waitForPaneCount( page: Page, expectedCount: number, timeoutMs = 10_000 ): Promise { - await expect - .poll(async () => countVisibleTerminalPanes(page), { - timeout: timeoutMs, - message: `Expected ${expectedCount} visible terminal panes` - }) - .toBe(expectedCount) + return waitForPaneCountImpl(page, expectedCount, timeoutMs) } From 551fbb9ac76a75b813dce62753414d43beea3b90 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:30:25 -0700 Subject: [PATCH 16/29] Revert "feat(browser-preview): render remote HTML docs locally over an orca-preview scheme (STA-5557) (#16679)" This reverts commit 249d93bc5d2fd3b04581aae9916afc84bc787c8b. --- config/reliability-gates.jsonc | 615 +++------------ electron.vite.config.ts | 1 - .../browser-manager-annotation-bridge.test.ts | 210 ----- .../browser-manager-guest-lifecycle.test.ts | 98 --- ...owser-manager-guest-policy-profile.test.ts | 250 ------ .../browser-manager-viewport-override.test.ts | 29 + src/main/browser/browser-manager.ts | 106 +-- ...browser-session-partition-policies.test.ts | 185 ----- .../browser-session-partition-policies.ts | 33 +- .../doc-preview-download-block-notice.test.ts | 107 --- .../doc-preview-download-block-notice.ts | 34 - .../doc-preview-failure-notice.test.ts | 70 -- .../browser/doc-preview-failure-notice.ts | 34 - .../browser/doc-preview-file-reader.test.ts | 333 -------- src/main/browser/doc-preview-file-reader.ts | 198 ----- .../doc-preview-grant-registry.test.ts | 164 ---- .../browser/doc-preview-grant-registry.ts | 208 ----- .../browser/doc-preview-guest-policy.test.ts | 550 ------------- src/main/browser/doc-preview-guest-policy.ts | 247 ------ src/main/browser/doc-preview-protocol.test.ts | 336 -------- src/main/browser/doc-preview-protocol.ts | 144 ---- ...ffscreen-browser-backend-lifecycle.test.ts | 85 +- src/main/browser/offscreen-browser-backend.ts | 44 +- src/main/index.ts | 11 - src/main/ipc/browser-grab-ipc.ts | 19 - src/main/ipc/browser-guest-view-ipc.ts | 26 +- ...browser-preview-tool-authorization.test.ts | 510 ------------ .../ipc/browser-tab-registration-wait.test.ts | 108 --- src/main/ipc/browser-tab-registration-wait.ts | 13 - src/main/ipc/browser.test.ts | 54 +- src/main/ipc/browser.ts | 8 - src/main/ipc/doc-preview-grant-ipc.test.ts | 159 ---- src/main/ipc/doc-preview-grant-ipc.ts | 95 --- .../main-window-webview-security.test.ts | 261 +------ .../window/main-window-webview-security.ts | 61 +- src/preload/api-types.ts | 2 - src/preload/api/doc-preview-api.ts | 29 - .../doc-preview-link-interception.test.ts | 269 ------- src/preload/doc-preview-link-interception.ts | 152 ---- src/preload/doc-preview-link.ts | 15 - src/preload/index.ts | 27 - .../ClientHostedBrowserPagePane.tsx | 28 +- .../browser-guest-annotate-overlays.tsx | 189 ----- .../annotate/browser-page-grab-action.ts | 8 +- .../guest-annotation-viewport-bridge.ts | 43 -- .../use-browser-page-grab-annotations.ts | 16 +- .../assemble-chrome/BrowserAddressBar.tsx | 4 +- .../browser-chrome-address-slot.ts | 13 - .../browser-chrome-toolbar.tsx | 206 ----- .../browser-navigation-control-row.test.tsx | 66 +- .../browser-navigation-control-row.tsx | 61 +- .../assemble-chrome/browser-page-toolbar.tsx | 280 +++++-- .../browser-page-viewport-overlays.tsx | 163 +++- .../browser-reload-control.tsx | 95 --- .../browser-workspace-pane.tsx | 49 +- .../use-browser-page-webview-lifecycle.ts | 29 +- .../host-guest/use-guest-drag-passthrough.ts | 32 - .../browser-address-bar-navigation.test.ts | 27 - .../browser-address-bar-navigation.ts | 24 +- .../remote-browser-page-toolbar.tsx | 24 +- .../use-remote-browser-page-navigation.ts | 2 +- .../HtmlDocPreview.failure-message.test.tsx | 342 -------- .../HtmlDocPreview.toolbar.test.tsx | 582 -------------- .../workspace-doc/HtmlDocPreview.tsx | 413 ---------- .../doc-preview-document-actions.test.ts | 206 ----- .../doc-preview-document-actions.ts | 97 --- .../doc-preview-document-chip.tsx | 68 -- .../doc-preview-document-identity.test.ts | 67 -- .../doc-preview-document-identity.ts | 38 - .../doc-preview-failure-messages.ts | 70 -- .../doc-preview-overflow-menu.tsx | 76 -- .../workspace-doc/doc-preview-toolbar.tsx | 118 --- .../doc-preview-webview-history.ts | 80 -- .../use-doc-preview-guest-tools.test.ts | 134 ---- .../use-doc-preview-guest-tools.ts | 103 --- .../workspace-doc-page-pane.test.tsx | 109 --- .../workspace-doc/workspace-doc-page-pane.tsx | 60 -- .../editor/EditorPanelShell.header.test.tsx | 124 --- .../components/editor/EditorPanelShell.tsx | 3 +- .../src/components/editor/editor-header.ts | 5 - .../tab-strip-pointer-activation.test.tsx | 38 - .../tab-bar/tab-strip-pointer-activation.ts | 20 +- .../TerminalLinkActionPopover.tsx | 6 +- .../terminal-file-link-actions.test.ts | 62 -- .../terminal-file-link-actions.ts | 66 +- .../terminal-file-open-routing.ts | 48 +- ...minal-link-remote-runtime-ssh-open.test.ts | 64 +- .../terminal-remote-file-download-open.ts | 37 - .../ipc-events/browser-state-ipc-bridge.ts | 29 - .../doc-preview-external-link-bridge.test.ts | 92 --- .../src/hooks/useIpcEvents-lifecycle.test.ts | 2 - src/renderer/src/i18n/locales/en.json | 39 +- src/renderer/src/i18n/locales/ko.json | 77 +- src/renderer/src/i18n/locales/zh.json | 85 +- .../lib/browser-page-palette-activation.ts | 21 +- .../lib/browser-workspace-tab-activation.ts | 30 - .../src/lib/doc-preview-grants.test.ts | 169 ---- src/renderer/src/lib/doc-preview-grants.ts | 86 --- .../src/lib/execution-host-display-label.ts | 61 -- .../file-preview-action-visibility.test.tsx | 85 -- .../file-preview-capability-cleanup.test.ts | 91 +++ src/renderer/src/lib/file-preview.test.ts | 409 ++++------ src/renderer/src/lib/file-preview.ts | 259 +++---- .../src/lib/workspace-file-host-routing.ts | 50 -- ...ync-runtime-graph-editor-diff-tabs.test.ts | 180 ----- .../src/runtime/sync-runtime-graph.ts | 18 +- .../browser-workspace-doc-location.test.ts | 412 ---------- src/renderer/src/store/slices/browser.ts | 125 +-- .../src/store/slices/tabs-hydration.test.ts | 63 -- .../src/store/slices/tabs-hydration.ts | 9 - src/shared/browser-page-doc-location.ts | 27 - src/shared/browser-workspace-types.ts | 17 - src/shared/doc-preview-scheme.test.ts | 45 -- src/shared/doc-preview-scheme.ts | 105 --- .../workspace-session-browser-schema.ts | 20 +- .../helpers/paired-client-window-reveal.ts | 31 - .../helpers/paired-html-preview-inventory.ts | 329 +++----- .../paired-remote-html-browser-focus.spec.ts | 459 +++++++++++ ...d-remote-html-preview-local-render.spec.ts | 731 ------------------ 119 files changed, 1721 insertions(+), 12730 deletions(-) delete mode 100644 src/main/browser/browser-manager-annotation-bridge.test.ts delete mode 100644 src/main/browser/browser-manager-guest-policy-profile.test.ts delete mode 100644 src/main/browser/browser-session-partition-policies.test.ts delete mode 100644 src/main/browser/doc-preview-download-block-notice.test.ts delete mode 100644 src/main/browser/doc-preview-download-block-notice.ts delete mode 100644 src/main/browser/doc-preview-failure-notice.test.ts delete mode 100644 src/main/browser/doc-preview-failure-notice.ts delete mode 100644 src/main/browser/doc-preview-file-reader.test.ts delete mode 100644 src/main/browser/doc-preview-file-reader.ts delete mode 100644 src/main/browser/doc-preview-grant-registry.test.ts delete mode 100644 src/main/browser/doc-preview-grant-registry.ts delete mode 100644 src/main/browser/doc-preview-guest-policy.test.ts delete mode 100644 src/main/browser/doc-preview-guest-policy.ts delete mode 100644 src/main/browser/doc-preview-protocol.test.ts delete mode 100644 src/main/browser/doc-preview-protocol.ts delete mode 100644 src/main/ipc/browser-preview-tool-authorization.test.ts delete mode 100644 src/main/ipc/browser-tab-registration-wait.test.ts delete mode 100644 src/main/ipc/doc-preview-grant-ipc.test.ts delete mode 100644 src/main/ipc/doc-preview-grant-ipc.ts delete mode 100644 src/preload/api/doc-preview-api.ts delete mode 100644 src/preload/doc-preview-link-interception.test.ts delete mode 100644 src/preload/doc-preview-link-interception.ts delete mode 100644 src/preload/doc-preview-link.ts delete mode 100644 src/renderer/src/components/browser-pane/annotate/browser-guest-annotate-overlays.tsx delete mode 100644 src/renderer/src/components/browser-pane/annotate/guest-annotation-viewport-bridge.ts delete mode 100644 src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-address-slot.ts delete mode 100644 src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-toolbar.tsx delete mode 100644 src/renderer/src/components/browser-pane/assemble-chrome/browser-reload-control.tsx delete mode 100644 src/renderer/src/components/browser-pane/host-guest/use-guest-drag-passthrough.ts delete mode 100644 src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx delete mode 100644 src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx delete mode 100644 src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.tsx delete mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts delete mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.ts delete mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-chip.tsx delete mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-identity.test.ts delete mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-identity.ts delete mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-failure-messages.ts delete mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-overflow-menu.tsx delete mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-toolbar.tsx delete mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-webview-history.ts delete mode 100644 src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts delete mode 100644 src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.ts delete mode 100644 src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx delete mode 100644 src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.tsx delete mode 100644 src/renderer/src/components/editor/EditorPanelShell.header.test.tsx delete mode 100644 src/renderer/src/components/terminal-pane/terminal-remote-file-download-open.ts delete mode 100644 src/renderer/src/hooks/ipc-events/doc-preview-external-link-bridge.test.ts delete mode 100644 src/renderer/src/lib/browser-workspace-tab-activation.ts delete mode 100644 src/renderer/src/lib/doc-preview-grants.test.ts delete mode 100644 src/renderer/src/lib/doc-preview-grants.ts delete mode 100644 src/renderer/src/lib/execution-host-display-label.ts delete mode 100644 src/renderer/src/lib/file-preview-action-visibility.test.tsx create mode 100644 src/renderer/src/lib/file-preview-capability-cleanup.test.ts delete mode 100644 src/renderer/src/lib/workspace-file-host-routing.ts delete mode 100644 src/renderer/src/store/slices/browser-workspace-doc-location.test.ts delete mode 100644 src/shared/browser-page-doc-location.ts delete mode 100644 src/shared/doc-preview-scheme.test.ts delete mode 100644 src/shared/doc-preview-scheme.ts create mode 100644 tests/e2e/paired-remote-html-browser-focus.spec.ts delete mode 100644 tests/e2e/paired-remote-html-preview-local-render.spec.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 68dce578bc0..ac37e3760b9 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -2817,16 +2817,16 @@ }, { "id": "browser-session.remote-html-preview-ownership", - "title": "Paired HTML previews render locally as document browser tabs and create no host browser page", + "title": "Paired HTML previews keep one host page and one stable client split", "maturity": "experimental", "protection": "partial", "owner": "browser-runtime", "layer": "renderer-paired-runtime-reconciliation", "surfaces": [ - "paired HTML file preview served locally over the orca-preview scheme", - "preview split placement that leaves editor focus alone", - "grant-scoped document reads, including truncation and out-of-worktree refusal", - "capability-unknown and mixed-version browser-creation rejection cleanup", + "paired remote HTML file preview", + "background preview creation and explicit browser activation", + "client-only split placement", + "capability-unknown and mixed-version preview rejection cleanup", "post-create reconciliation failure and compensating rollback", "remote browser close convergence" ], @@ -2834,9 +2834,8 @@ "providers": ["remote-runtime"], "coveredPlatforms": ["macos"], "coveredProviders": ["remote-runtime"], - "coverageNotes": "A macOS desktop host and a separate paired Electron client exercise the Explorer preview action end to end: the document renders inside the client-local orca-preview guest, the client gains exactly one browser workspace and it is the document one, the host's own page registry and tab snapshot gain nothing, the split does not take focus from the source editor, an explicit click activates the preview, and closing it with its X leaves the editor and terminal intact. Both host oracles are read in a run where an ordinary URL browser tab from the same client does arrive in them, because a host that cannot project browser pages at all would otherwise answer the absence question the same way. A second journey quits that client with a document tab open and relaunches it on the same profile, which is the restore the user's original report turned on: the same workspace row comes back blank and named by the document, renders again, and does so over a grant URL that differs from the one that was quit. The same journey pins the guest URL to the orca-preview scheme, so a regression back to file:// or to a host browser page is visible. The rewritten journey caught a live gap the old one could not see: since previews became browser pages they had no chrome to hand focus to their guest, so main refused every reported link click as coming from an unfocused guest and the one route out of a preview was dead. Main's own log answered it โ€” registered true, grant live, focused false โ€” after the embedder reported the anchor really taking six real presses. The pane now hands focus over when it is the reader's surface, and the journey routes. The preview's own components moved out of the editor folder into browser-pane/workspace-doc/ with this change, since a preview is a browser pane and no longer an editor one; their i18n keys keep the historical auto.components.editor.HtmlDocPreview prefix rather than dropping thirty translated strings across four locales for a tidier key path. Seven gate commands named those files by their old paths and were re-issued at the new ones, and the nine dated runs behind them were retired rather than left naming files that are no longer there, on the same principle this gate already applied twice: vitest treats a missing named file as a filter and exits 0, so such a command reports green while silently running only the half that still exists. Each retired record's finding is carried into the replacement run recorded below it, including the red half of the entry/subresource pair, which was re-measured by re-applying its mutant at the new path rather than being dropped. The fence over the page-management channels was decided channel by channel before it was built, and that decision record is: of the 23 channels that can name a page, 6 are allowed for a workspace-document page โ€” the tool channels the reader drives, which reach it through the one authority that reads both halves of the registry โ€” 9 refuse (download accept and cancel, devtools, certificate proceed, client page metadata publish, viewport override, WebAuthn account response, registration probe, registration repair), 3 cannot name a page at all because they take a profile or a worktree instead, and the rest are the registration and activation doors, refused on membership. What shipped is not the per-channel guard that classification implies: eleven call sites read the browsing map directly, so a guard per channel is exactly the caller-flag seam that has to be remembered at every future call site. The registry was split in two instead, which makes the classification structural โ€” a document guest is absent from the browsing map, so every refusing channel refuses by absence rather than by a guard, and a channel added later inherits the refusal without being classified at all. Unit contracts cover grant scoping and path containment, per-owner read routing, refusal of a truncated or over-cap read, the old-server binary degradation, scheme admission on will-attach-webview, the guest navigation policy, the plan layer's local/SSH/paired/out-of-worktree routing, and the distinct sentence the reader sees for each failure reason. The paired browser-creation surfaces this gate still owns โ€” capability rejection cleanup, canonical page identity, reconciliation rollback, close convergence โ€” keep their existing headed and headless journeys. Linux and Windows live journeys remain gaps. A refused subresource is a notice on a rendered document rather than a failure panel, since only the entry document's failure leaves the reader with nothing; the entry document is served as text by every owner and cannot be refused for its format at all. Grants die with the renderer document that minted them, at window creation and on every reload, because the renderer holds the only record of which preview owns which grant. The mint and revoke channels answer only the trusted renderer, and a preview tab is never republished to mobile clients, which have no grant to read it with. External URLs leave the preview only via a trusted user click on an anchor while the preview is focused; script-driven navigation and popups reach nothing. The guest's navigation sinks are deny-only and route no URL at all, and the guest carries a preview-only preload, pinned by will-attach-webview and denied to every other attach path, whose capture-phase click handler reports an anchor's URL to main; main answers only a report from a preview guest with a live bound grant that is the focused contents, and only for http(s). Two earlier reader-facing records from 2026-08-26, which measured a shape where a subresource failure was filtered out and the panel still carried an unsupported-asset sentence, were retired with that shape rather than left contradicting the 10/10 runs of the command they share. Two more were retired on the same principle when the external-link design changed: a record showing that removing a recent-input gate let the unattended document open two browser tabs, and a record showing that dropping the webview's allowpopups attribute left a target=_blank press with nothing to route. Both measured machinery that no longer exists โ€” the input gate could not tell a script's navigation from the click it followed, which is the case now recorded below, and the preload intercepts the press before Chromium considers a popup at all, so allowpopups was removed. WebRTC is fenced by the guest's IP-handling policy alone: this Chromium answers a webrtc content-security-policy directive as unrecognized and gathers candidates anyway, so the directive is deliberately absent from the served policy and a unit assertion keeps it out. A standalone Electron probe on the same version gathered 7 candidates including a STUN reflexive one without the policy and 0 with it. Two command paths this gate used to carry no longer exist, and their structured evidence was retired with them rather than left naming files that are no longer there: vitest treats a missing named file as a filter and exits 0, so such a command reports green while silently running only the half that still exists. The first is src/renderer/src/lib/file-preview-capability-cleanup.test.ts, deleted in STA-5557 when HTML previews stopped creating paired browser tabs: on 2026-08-12 its five-assertion STA-4063 oracle ran red 5/5 on scan e4c278eb52 and on main bc2e30000b, partially green 2/5 on pre-fix PR #14100, and green 5/5 on candidate 14b5877a75; the surviving half of that oracle is web-runtime-browser-capability-cleanup.test.ts, still commanded above. The second is tests/e2e/paired-remote-html-browser-focus.spec.ts, renamed in STA-5557 to paired-remote-html-preview-local-render.spec.ts because the journey no longer proves anything about browser focus: its 2026-08-12 runs established the owning-runtime routing and no-focus-steal behavior of the then-current browser-tab preview (red on main 346e59c879 and a90a18d43d, green on candidates d05217c40d and b7de9c0961, red again with only the side-preview focus or routing seam disabled), and its 2026-08-26 runs are recorded below under the current path. An adversarial pass after the journey landed found four coverage gaps rather than shipping defects, and all four are pinned here. The page url had a third door: the navigation commit wrote whatever url was reported, unfenced, and while nothing reaches it for a document page today it sat one report away from giving a document page a URL that persistence, the publish boundary and the address bar all read. The group projection was the load-bearing half of the mobile publish boundary that no test had: removing it alone left the whole suite green, because no test in it had tab groups, while the phone payload gained a group whose order and active tab named a document tab it was never sent. The focus handoff was covered only in the narrowing direction, so widening it โ€” dropping the page-activity check, or either half of the reader-surface check โ€” survived both suites. And the handoff itself was offered once and never again, so another app taking the front left the preview's one route out shut until the pane remounted, which is a desktop gap rather than a test-only one.", + "coverageNotes": "A headed macOS desktop host and separate paired client exercise the Explorer eye-button path with a real remote file URL, host browser DOM, renderer split layout, background preview creation, explicit browser activation, browser close, forced capability rejection after caller preflight, a forced post-create reconciliation timeout, and navigation held beyond the client create deadline. The same reconciliation fault oracles pass against a live headless server. Unit contracts cover absent, unknown, and mixed-version capabilities, visible rejection, empty-split cleanup, owning-runtime routing, canonical preallocated page identity, navigation-independent acknowledgement, exact client materialization, confirmed rollback, ambiguous cleanup, and teardown. Linux and Windows live journeys remain gaps.", "motivatingLinks": [ - "https://linear.app/stably/issue/STA-5557/remote-html-preview-render-workspace-files-locally-over-the-file", "https://linear.app/stably/issue/STA-3872/unable-to-open-html-files-in-remote-server", "https://linear.app/stably/issue/STA-4025/p1-browser-create-reports-success-when-tab-reconcile-fails-after-pr", "https://linear.app/stably/issue/STA-4063/p1-paired-html-preview-rejects-outside-cleanup-after-pr-13909", @@ -2844,35 +2843,19 @@ "https://github.com/stablyai/orca/pull/14363", "https://github.com/stablyai/orca/pull/13876" ], - "invariant": "Opening one HTML preview from a paired client renders the workspace document in exactly one client-local browser tab, located by that document and served over the orca-preview scheme. The client gains exactly that one browser workspace and it is the document one โ€” blank where a URL page carries a URL, named by the document, with the chip naming the file โ€” while the host gains no browser page at all, neither in its own page registry nor in the tab snapshot its clients publish into. The preview occupies its own split without taking focus from the source editor; an explicit click activates it, and closing it removes only the preview. Following a document from a file link is the other half of that switch and does move the reader to it, tab group included, whether the preview is new or already open, because opening a file is a request to look at it. A document tab quit with the client comes back as the same row on a grant the relaunched client mints afresh. A preview is named by the browser page it is open in, not by a namespace of its own, and the page registry has two halves: a workspace-document guest is registered in its own map and is absent from the browsing one entirely. That absence is the fence. Page, session and profile management, agent tab enumeration and command targeting, download routing and certificate attribution all read the browsing map directly, in more places than a per-channel guard could be remembered in, so none of them can name a document page and none of them carries a guard. Browser tools the reader drives (element grab, hover describe, selection capture, the annotation viewport bridge) are the one operation that legitimately spans the halves, and they go through the single authority that reads both, keyed by the page and its hosting renderer. The halves are disjoint in both directions: browsing registration refuses a page the document half already holds, and minting a grant refuses a page the browsing half already holds, so one id can never name a surface in both. The headless backend acts on that refusal by destroying the window it had already opened rather than leaving a policy-less page behind an id nothing can drive, keeping nothing under that id for its own shutdown to hand back. Registration refuses on the same terms when the guest it was asked about is already gone. The exit door is guarded in both its halves: a preview withdraws by revoking its grant and never through the unregister channel, so a page the document half holds arriving there is refused before either the registration teardown or the grab-state disposal beside it, which would otherwise drop the intent an in-flight preview grab compares by identity and leave that grab answering ok without ever arming its guest. A bridge request whose guest does not resolve is refused without tearing down the page it named, so a misaddressed request cannot cancel a healthy page's in-flight downloads and grabs. The annotation viewport bridge resolves its guest when its serialized op actually runs rather than when the request arrived, so a cross-process navigation while it waited cannot leave the bridge installed in a retired guest while the reader looks at a new one. State main keys by a preview's page is disposed when that page's grant is revoked, which is the only signal a preview's surface is gone. A tool asking for a page whose guest has not attached yet waits for that registration and arms when it arrives, rather than answering not-ready at the reader; that wait resolves only the request already naming this page, never the worktree-wide or any-tab waits the CLI and agents use to ask for a browser tab to drive. Handing the previewed document to the reader's own machine routes on the owners its grant was minted against โ€” the file's own connection owner and the worktree's own runtime owner, neither read from the tab's stored fields. Only a document proven to live on this machine reaches the client OS; one with a resolved remote owner is downloaded first; and one whose owner cannot be resolved at all, workspace root included, is refused with a message naming that, because the download route would otherwise read the same absolute path on the client and hand back a same-named local file under the remote document's name. A runtime-owned path that falls outside its worktree root is refused by that route itself and surfaces as a failure toast rather than a download. Nothing the document does writes a file to this machine either: the preview partition denies downloads outright instead of routing them through the browser download flow, which has no page to attribute a preview's bytes to and would otherwise reserve a name in this desktop's Downloads folder and write them there unprompted. That refusal is visible to the reader and invisible to the document: the preview's shell carries a fixed sentence saying downloads are off, published at most once per preview per interval so a document asking in a loop cannot fill Orca's chrome, while the page itself gets back exactly what it got before, which is nothing. The sentence names no file, because the document chooses the name it offers; and a refusal never takes the document away the way an entry document's own failure does, whatever it names. A preview is a browser tab, not an editor tab in a preview mode: it is named the way a browser tab is named โ€” by the document it shows when that document declares a title, and by the file it shows when it does not โ€” while the chip goes on naming the file and the host whatever the document calls itself. A title is refused on the same terms the url is: a document that declares none has Chromium report the grant URL as its title, and that title is stored, mirrored onto the tab and written to disk, so anything carrying the scheme falls back to the file instead. It is created by the preview action as a page located by its document, it carries the workspace-relative path copy the editor's path header owned, and closing it revokes the grant that made the document readable while a URL tab closing beside it revokes nothing. Chrome persisted by builds that made previews editor tabs is dropped on restore rather than coming back naming a surface no restore can produce, and the ordinary editor tab for the same document is left alone. A document tab is held back at the mobile publish boundary โ€” no client holds its grant, and the wire has no tab kind for it โ€” while an ordinary browser tab beside it still publishes. It is held back from the group projection that publishes tab order, recency and group activity as well as from the tab list itself, so no published group names a tab the phone is never sent. A browser page can be located by a workspace document instead of a URL, and the document is the whole of its stored identity. The grant and the orca-preview URL that document is served over are minted when the page mounts and replaced by a hard reload, so neither is ever written to the page's url, mirrored onto its tab, persisted or published: such a page's url is the blank URL from creation through restore, including when a session written elsewhere carries a grant URL in, and what the session carries is the worktree and path a restored page mints afresh against today's owners. Every door onto a page's url holds that line โ€” creation, the title update, and the navigation commit alike โ€” so a report about a document page cannot give it a URL it never had, and the title fallback and the loading affordance follow the url each door actually wrote. The mirror carries the document too, so a tab entry cannot go on naming a document its active page has left. Every guest in the app is policy-attached through one door: a workspace document takes a restricted profile there rather than a separate installer beside it, so the attachment bookkeeping that door owns โ€” what registration refuses, and what teardown frees โ€” covers a preview on the same terms as a browsing page, and a preview takes none of the browsing machinery that door installs. That authority answers from the moment the embedder hands the guest over rather than only after a later navigation: the guest binds to the grant it is already showing, so the tools reach the document the reader opened and not just one they navigated to. A read the host reports as truncated or over-cap is refused rather than served partially, and a document outside the paired worktree is refused with a message naming that boundary instead of a bare read failure. The rendered document reaches nothing off-machine on its own: every served response carries a self-only content security policy, the preview session cancels any request that is not in-document, subframes cannot navigate outside the grant, a guest no document has yet bound to a grant may not navigate at all, the guest gathers no ICE candidates, and an SSH path that canonicalizes outside the grant root is refused before it is read. The one route out is a link the reader presses: a trusted click on an anchor, reported by the preview's own preload from a guest that holds focus and is still bound to a live grant, leaves as an Orca browser tab rather than a native window or a dead click. The preview hands its guest that focus itself whenever it is the surface the reader is in โ€” a browsing page gets it from the chrome around it, and a preview has no chrome to get it from โ€” and it does so only then, so a preview mounted behind a terminal or an editor never takes the keyboard from what the reader is actually in. It offers again when the window itself takes focus back and nothing in the embedder has claimed that focus, because another app coming to the front lands focus on the embedder rather than the guest and the route out would otherwise stay shut until something remounted the pane โ€” while the same window focus also arrives when the reader presses a tab, that being the guest's own blur returning, and taking focus back from there would fight the reader for their own click. Nothing else does. A navigation or popup the document starts by itself is swallowed whatever else is happening, including immediately after a genuine press elsewhere in the document, so a page that can read its grant cannot hand it to a browser tab; a middle click opens nothing; and a fragment link is answered inside the document. A preview attach carries the preview preload and no renderer-supplied one, and no other attach path can acquire it. A subresource the workspace will not send degrades the document to a notice naming that file, never to a failure panel over a page that rendered. A grant outlives neither the tab that owns it nor the renderer document that minted it, and only the trusted renderer can mint or revoke one. For the browser creations this gate still owns, owner-pinned creation returns the canonical host page identity before navigation readiness; delayed navigation cannot turn a created page into an unidentifiable failure or a duplicate retry. Capability rejection before host mutation must preserve the original error, issue no RPC, surface a failure toast, and remove only a caller-declared newly-created empty split. Post-create reconciliation failure requires exact rollback; ambiguous rollback rejects without local fallback.", - "oracle": "In paired Electron, write an HTML fixture that declares its own title on the host, invoke the Explorer preview action on the client, and require the document text to be readable out of the orca-preview guest before judging any absence. With that presence established, require the client to hold exactly one browser workspace more than its baseline and that workspace to be the document one: page and tab url blank, the document path mirrored onto both, the tab named by the document's title, the chip naming the file, no editor row of the retired preview species anywhere, and the guest URL carrying the orca-preview scheme. Ask the host through its own page registry as well as through the tab snapshot, and in the same run open an ordinary URL browser tab from the same client and require that one to arrive in both โ€” the presence precondition without which โ€œthe host gained nothingโ€ is satisfied just as well by an oracle that cannot see browser pages at all. Require the preview to sit in a group other than the source editor's while the active group and tab remain the source editor's. Then click away to the terminal, click the preview tab, require it to reactivate and still render, close it with its own X, and require the document tab to be gone while the host still holds only the URL tab and the source group, source editor and terminal survive. Quit the client with a document tab open and relaunch it on the same profile: require the same workspace row to come back, blank and named by the document, rendering the document again over a grant URL that differs from the one that was quit, with no preview-scheme or document-named page anywhere in what the host holds. Prove the halves are live by flipping one product property at a time and requiring the run to fail: publish document workspaces to the host like ordinary ones, and stop mirroring the document onto the workspace row. Have the fixture document attempt its own egress on every load โ€” an unattended window.open and location.href to an off-machine URL, plus an inline ICE gathering probe โ€” and require the same baseline counts and a candidate count of zero, so the document's own attempts are measured rather than assumed. Then, as a separate phase after the close oracle has already run, bring the client window to the front, press the document's heading with a real mouse event, and require the document to report that the same press drove it to attempt a second window.open and location.href while both browser counts stay at that phase's baseline and nothing routes โ€” the case a recent-input gate cannot distinguish from the press's own effect. Only then press the target=_blank link with a real mouse event and require both a recorded routing call that returned success and a browser count above that baseline, with the preview tab still open. Drive the preload's click policy as a unit oracle over a real document: a dispatched click, a trusted press on an external anchor, an anchor reached through what it wraps, an SVG animated href, a sibling preview link, fragment and percent-encoded fragment targets, a bare hash, and a middle click. Create a browser page located by a workspace document, handing creation a live grant URL, and require its stored url, its mirrored tab url and the written session payload all to be blank with no orca-preview string anywhere in what was written, while an ordinary page created the same way keeps the URL it was given and asks for the address bar the document page never does. Parse the written page and tab through the session schema and require the document to survive both halves. Hydrate them back and require the document page to return blank and still named โ€” including when its page row was salvaged away and only the tab's own copy remains, and when a foreign session carried a grant URL into both rows. Drive the mirror across a page switch out of the document and back, and across a repair in which the document is the only mirrored field that differs. Name a document page from its document and require the tab to take that name, name it with an empty title and require the file, name it with a live grant URL and require the file again with no preview scheme anywhere in the written session, and require an ordinary blank browser tab beside it to still be called New Tab. Dispatch a title update out of a rendered preview's own guest and require it to reach the page state while the identity chip still reads the document's workspace-relative path. Attach a browsing guest and a workspace-document guest through the same method in one run and require the browsing one to take clicked-link routing, popup handling and anti-detection while the document guest takes none of them, stays inside the grant it is showing, denies every window it asks for, and is dropped from the page-keyed document registry by the same teardown that frees its id for a later attach. Register a browsing guest and attach a workspace-document guest in one run against the real manager, require the one door to answer each page with the guest of its own half, and require the document page to be absent from the browsing map and from its enumeration. Drive both browsing registration entry points with a page the document half already holds and require them to register nothing, and drive the mint channel with a page the browsing half already holds and require it to refuse; and drive the offscreen one with a guest that is missing and with one already destroyed, requiring the same refusal. Arm a grab on a live preview target, drive the unregister channel at that same target in the window before the queued operation runs, and require the grab to reach the guest anyway โ€” then drive the same sequence for an ordinary browser page and require its grab state to be disposed after all. Hold one viewport-bridge op open, queue a second behind it, swap the page's guest while that second op waits, and require the injection to land in the guest the page has then. Revoke a grant after a tool has run against its page and require that page's grab state to be cancelled and disposed. Ask a tool for a document page whose guest has not attached, require the request to park in the registration wait, attach the guest, and require the same request to arm on it; require a page nothing ever renders to answer not-ready once that wait elapses. With a document open, ask a tool for a browsing page id and require it to be answered by the browsing half or not at all, with the same channel reaching the document guest under the page it really renders. Drive open-externally for a document whose per-file owner is remote while the workspace-scoped owner is unresolved, for a runtime-owned worktree whose preview tab carries no runtime id of its own, and for a worktree that resolves no runtime owner while the tab still carries one, requiring the download route in each; and for an owner that cannot be resolved at all, and for an unknown workspace root while nothing names another host, requiring a refusal that neither opens nor downloads. Drive the headless backend with a page the document half already holds and require it to reject, destroy the window, and unregister nothing โ€” then shut the backend down and require it still to have unregistered nothing. Navigate a bound preview guest at a second grant through both latch events and require it to stay on the grant it bound to. Mount a preview while a renderer drag is already in flight and require its guest to be click-through at the moment it is appended, not a turn later. Render the editor panel shell in each remaining tab mode and require the path header exactly where the surface does not already name itself. Drive the preview action and require a browser tab located by the document rather than an editor tab, require a second open of the same document to activate the tab it is already in, and require closing that tab to revoke its grant while a URL tab closed beside it revokes none. Hydrate a session carrying preview chrome from a build that made previews editor tabs and require it dropped while the ordinary editor tab for the same document survives. Publish a worktree holding a document tab and a URL tab and require only the URL tab to reach the mobile snapshot. Install the shared partition policies for a preview partition and for an ordinary browsing partition in the same run, fire each one's own will-download listener, and require the preview's to cancel while the browsing one still reaches the download router โ€” then require the preview protocol installer to be what asks for that deny. In the same run, require the cancelled download to raise a reader-facing notice and the routed one to raise none. Drive that notice directly for a guest bound to a live grant, for repeated attempts inside and outside its interval, for two previews at once, and for a contents no preview is bound to; require the guest registry to name the bound grant for a live preview guest and nothing for a contents that is not one, has committed no document, or is gone. Drive the shell with a refusal and require one fixed sentence, still one row after three more refusals, standing beside an asset failure rather than being counted with it, gone behind the failure panel, and ignored when it names another preview's grant. Drive the main-side report gate directly for an unfocused guest, a sender that is no preview guest, a guest with no bound or a revoked grant, and non-web URLs, and drive will-attach-webview in both preload directions. Run the per-owner reader, grant-containment, scheme-admission, guest-policy, and plan-routing contracts as unit oracles, including a host-reported truncation, an over-cap binary, and an out-of-worktree paired path. Drive the reader-facing component with the payloads the reader can actually produce โ€” the entry document fails only as truncated or unreadable, a subresource additionally as a refused format โ€” and require the asset case to leave the guest mounted. Drive the closed-tab cleanup hook, the window installer, and the grant IPC handlers directly, requiring the grant to be released when the preview tab closes, cleared at window creation and on a cross-document main-frame navigation, and refused to any sender that is not the trusted renderer. For the browser creations this gate still owns, run the unchanged contract oracle for direct create and side-preview callers with absent status, unknown capabilities, and a mixed-version host, requiring the original unsupported error or visible toast, zero RPCs, and no retained new split; hold a real navigation response beyond the 15-second client deadline after host creation and require the first RPC to return the exact host inventory page ID, one host page, and no retry; repeat reconciliation faults against headless serve and retain the separate exact-page reconciliation rollback oracle.", + "invariant": "Opening one HTML preview from a paired client succeeds only after exactly one browser page on the worktree-owning runtime materializes as exactly one client browser workspace and unified tab in the requested group. Owner-pinned creation returns the canonical host page identity before navigation readiness; delayed navigation cannot turn a created page into an unidentifiable failure or a duplicate retry. Capability rejection before host mutation must preserve the original error, issue no RPC, surface a failure toast, and remove only a caller-declared newly-created empty split. Post-create reconciliation failure requires exact rollback; ambiguous rollback rejects without local fallback. Preview focus and close preserve the source editor and terminal.", + "oracle": "Run one unchanged contract oracle for direct create and side-preview callers with absent status, unknown capabilities, and a mixed-version host. Require the original unsupported error or visible toast, zero RPCs, and no retained new split. Run it on the scan target, latest main, candidate, and candidate with the cleanup boundary disabled. In headed paired Electron, arm capability rejection after the caller preflight, invoke the visible HTML side-preview action, require the failure toast, and compare exact host/client browser, editor, terminal, and group inventories to baseline. Hold a real navigation response beyond the 15-second client deadline after host creation; require the first RPC to return the exact host inventory page ID, one host page, and no retry. Repeat reconciliation faults against headless serve. Retain the separate exact-page reconciliation rollback oracle.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-browser.test.ts src/main/runtime/rpc/methods/browser.test.ts src/renderer/src/lib/file-preview.test.ts src/renderer/src/runtime/web-session-browser-placement.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/runtime/remote-server-parity.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-materialization.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/doc-preview-grant-registry.test.ts src/main/browser/doc-preview-file-reader.test.ts src/main/browser/doc-preview-protocol.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/browser/doc-preview-failure-notice.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/lib/doc-preview-grants.test.ts src/renderer/src/lib/file-preview.test.ts src/renderer/src/lib/file-preview-action-visibility.test.tsx src/main/window/main-window-webview-security.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx src/renderer/src/hooks/ipc-events/doc-preview-external-link-bridge.test.ts src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-session-partition-policies.test.ts src/main/browser/doc-preview-protocol.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/doc-preview-download-block-notice.test.ts src/main/browser/browser-session-partition-policies.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx", - "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-manager-guest-policy-profile.test.ts src/main/window/main-window-webview-security.test.ts src/main/browser/doc-preview-guest-policy.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/lib/file-preview.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx src/shared/doc-preview-scheme.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts", - // The hardening round: the third url door, the group projection, and both directions of the focus handoff. - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx", - "pnpm exec vitest run --config config/vitest.config.ts src/preload/doc-preview-link-interception.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/window/main-window-webview-security.test.ts src/main/ipc/doc-preview-grant-ipc.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/ipc/browser.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/browser/browser-manager-annotation-bridge.test.ts src/main/browser/browser-manager-guest-lifecycle.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts src/renderer/src/components/editor/EditorPanelShell.header.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/browser/browser-manager-annotation-bridge.test.ts src/main/browser/browser-manager-guest-lifecycle.test.ts src/main/browser/offscreen-browser-backend-lifecycle.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts src/renderer/src/components/editor/EditorPanelShell.header.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/doc-preview-grant-ipc.test.ts src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.test.tsx src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/store/slices/tabs-hydration.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/ipc/browser-tab-registration-wait.test.ts src/main/ipc/doc-preview-grant-ipc.test.ts src/main/browser/browser-manager-guest-lifecycle.test.ts src/main/browser/browser-manager-guest-policy-profile.test.ts src/main/browser/browser-manager-annotation-bridge.test.ts src/main/browser/offscreen-browser-backend-lifecycle.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", - "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", - "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line --repeat-each=3", - // The changed-spec e2e job's own command, which is the only Linux run this gate has. - "npx playwright test --config tests/playwright.config.ts --project=electron-headless tests/e2e/paired-remote-html-preview-local-render.spec.ts --workers=1", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts src/renderer/src/lib/file-preview-capability-cleanup.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-browser.test.ts src/renderer/src/runtime/web-runtime-session.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/orca-runtime-browser.test.ts src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts src/renderer/src/hooks/useIpcEvents-close-routing-session-tabs.test.ts src/renderer/src/store/slices/browser.test.ts src/renderer/src/runtime/web-runtime-session.test.ts", "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-browser-create-navigation-deadline.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=3", + "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "SKIP_BUILD=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --reporter=line", "SKIP_BUILD=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line" ], @@ -2880,6 +2863,7 @@ "src/main/runtime/orca-runtime-browser.test.ts", "src/main/runtime/rpc/methods/browser.test.ts", "src/renderer/src/lib/file-preview.test.ts", + "src/renderer/src/lib/file-preview-capability-cleanup.test.ts", "src/renderer/src/runtime/web-session-browser-placement.test.ts", "src/renderer/src/runtime/web-runtime-browser-materialization.test.ts", "src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts", @@ -2890,38 +2874,7 @@ "src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts", "src/renderer/src/hooks/useIpcEvents-close-routing-session-tabs.test.ts", "src/renderer/src/store/slices/browser.test.ts", - "src/main/browser/doc-preview-grant-registry.test.ts", - "src/main/browser/doc-preview-file-reader.test.ts", - "src/main/browser/doc-preview-protocol.test.ts", - "src/main/browser/browser-session-partition-policies.test.ts", - "src/main/browser/doc-preview-download-block-notice.test.ts", - "src/main/browser/browser-manager-guest-policy-profile.test.ts", - "src/main/browser/doc-preview-guest-policy.test.ts", - "src/main/ipc/browser-tab-registration-wait.test.ts", - "src/main/browser/doc-preview-failure-notice.test.ts", - "src/renderer/src/store/slices/browser-workspace-doc-location.test.ts", - "src/preload/doc-preview-link-interception.test.ts", - "src/main/window/main-window-webview-security.test.ts", - "src/main/ipc/browser-preview-tool-authorization.test.ts", - "src/main/browser/browser-manager-annotation-bridge.test.ts", - "src/main/browser/browser-manager-guest-lifecycle.test.ts", - "src/main/browser/offscreen-browser-backend-lifecycle.test.ts", - "src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts", - "src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts", - "src/renderer/src/components/editor/EditorPanelShell.header.test.tsx", - "src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", - "src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx", - "src/shared/doc-preview-scheme.test.ts", - "src/renderer/src/lib/doc-preview-grants.test.ts", - "src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx", - "src/renderer/src/lib/file-preview-action-visibility.test.tsx", - "src/renderer/src/hooks/ipc-events/doc-preview-external-link-bridge.test.ts", - "src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts", - "src/main/ipc/doc-preview-grant-ipc.test.ts", - "src/renderer/src/store/slices/tabs-hydration.test.ts", - "src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.test.tsx", - "src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts", - "tests/e2e/paired-remote-html-preview-local-render.spec.ts", + "tests/e2e/paired-remote-html-browser-focus.spec.ts", "tests/e2e/paired-browser-create-navigation-deadline.spec.ts", "tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts" ], @@ -2933,13 +2886,18 @@ "direct creation removes the caller-declared new split without issuing an RPC" ] }, + { + "file": "src/renderer/src/lib/file-preview-capability-cleanup.test.ts", + "assertions": [ + "known unavailability reports visibly without creating a split", + "capability loss after caller preflight reports visibly without duplicating transaction cleanup" + ] + }, { "file": "src/renderer/src/lib/file-preview.test.ts", "assertions": [ - "a paired-runtime HTML preview opens a local doc preview instead of a runtime browser tab", - "a local workspace still opens the embedded browser tab at a file URL", - "a paired document outside the worktree names that boundary and creates no split", - "an SSH document outside the worktree root still previews" + "a failed side preview reports visibly without caller-owned split cleanup", + "overlapping side previews reuse the split without the first failure deleting it" ] }, { @@ -3015,152 +2973,11 @@ ] }, { - "file": "tests/e2e/paired-remote-html-preview-local-render.spec.ts", + "file": "tests/e2e/paired-remote-html-browser-focus.spec.ts", "assertions": [ - "the host's HTML document renders inside the client-local orca-preview guest", - "neither the client browser-workspace count nor the host browser-tab count moves off baseline", - "the preview opens in its own split with mode html-preview and leaves the source editor active", - "an explicit click reactivates the preview, and closing it preserves the source editor, its group, and the terminal", - "the document reports having attempted both its unattended and its post-press egress, so neither absence assertion can pass on a document that never tried", - "a scripted window.open and location.href driven by a real press elsewhere in the document route nothing and move no browser count", - "a real target=_blank press in the rendered document routes out as an Orca browser tab while the preview stays open" - ] - }, - { - "file": "src/main/browser/doc-preview-file-reader.test.ts", - "assertions": [ - "a host-reported truncated read is refused instead of served as partial bytes", - "an over-cap binary and an empty binary body are reported as too-large and unsupported rather than a bare read failure", - "a document outside the paired worktree is refused without issuing an RPC", - "an SSH path that canonicalizes outside the grant root is refused before any read", - "a 0-byte file the host still typed is served as 0 bytes rather than reported as a refused format" - ] - }, - { - "file": "src/main/browser/doc-preview-grant-registry.test.ts", - "assertions": [ - "a path escaping the grant root resolves to nothing, including sibling-prefix roots", - "a revoked grant stops resolving" - ] - }, - { - "file": "src/main/browser/doc-preview-protocol.test.ts", - "assertions": [ - "an unknown or revoked grant is answered exactly like a missing file, with no read attempted", - "a failed read pushes its reason for the requested path while a served document pushes nothing", - "every served document carries the self-only content security policy", - "the preview session cancels any request that is not in-document", - "the scheme is handled on the preview session and never on the default one", - "the scheme claims exactly its five privileges, so a widened one fails" - ] - }, - { - "file": "src/main/browser/doc-preview-failure-notice.test.ts", - "assertions": ["413 and 415 map to distinct reasons and everything else stays generic"] - }, - { - "file": "src/renderer/src/lib/file-preview-action-visibility.test.tsx", - "assertions": [ - "the Explorer row predicate and the preview plan return one answer for the same file" - ] - }, - { - "file": "src/renderer/src/hooks/ipc-events/doc-preview-external-link-bridge.test.ts", - "assertions": [ - "an external preview link becomes a browser tab, and a tab that is refused or that fails outright surfaces instead of being dropped" - ] - }, - { - "file": "src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx", - "assertions": [ - "each entry-document failure reason surfaces its own sentence instead of one generic unavailable panel", - "a refused subresource names itself in a notice, deduplicated per path, while the document stays rendered", - "an entry-document failure replaces the asset notice with the panel", - "a failure for another preview's grant leaves the rendered document alone" - ] - }, - { - "file": "src/main/browser/doc-preview-guest-policy.test.ts", - "assertions": [ - "the guest may only navigate within its own live grant, and not at all until a document has bound it to one", - "every navigation and popup sink refuses without routing, whenever genuine input happened", - "a subframe cannot navigate outside the grant, and only the main frame binds the guest to one", - "a reported click routes exactly once, and only from a focused preview guest with a live bound grant", - "a reported click on a non-web URL, or from a destroyed guest, routes nothing", - "the guest is denied non-proxied WebRTC UDP when its policy is installed" - ] - }, - { - "file": "src/preload/doc-preview-link-interception.test.ts", - "assertions": [ - "a click the document dispatched itself is not trusted and routes nothing", - "a trusted press on an external anchor reports it once and stops the navigation, including through what the anchor wraps and an SVG animated href", - "a fragment link scrolls in the document, raw or percent-encoded, and a bare hash goes to the top", - "a sibling preview document is left to the guest's own navigation, and a middle click opens nothing whether or not it is trusted", - "an SVG link is resolved against the document, so its relative, rooted and fragment forms behave exactly like the identical HTML anchor", - "the preload reports on exactly the channel main listens on" - ] - }, - { - "file": "src/main/window/main-window-webview-security.test.ts", - "assertions": [ - "an orca-preview src is admitted only on the preview partition with a live grant", - "preview guests receive their own policy instead of the browser guest policies", - "a preview attach is pinned to the preview preload over any renderer-supplied value, and a browsing attach never receives it", - "grants a renderer can no longer release are cleared at window creation and on reload, and survive an in-document navigation" - ] - }, - { - "file": "src/shared/doc-preview-scheme.test.ts", - "assertions": ["preview URLs round-trip per segment and reject a malformed grant id"] - }, - { - "file": "src/renderer/src/lib/doc-preview-grants.test.ts", - "assertions": [ - "grants are deduped per preview tab so a StrictMode double-mount cannot revoke the surviving webview", - "a mint that rejects after its tab was released and reopened leaves the reopened tab's grant revocable" - ] - }, - { - "file": "src/main/ipc/doc-preview-grant-ipc.test.ts", - "assertions": [ - "a sender that is not the trusted renderer mints no grant and revokes none", - "a reported link click reaches the guest policy with its own sender, and a non-string report reaches nothing" - ] - }, - { - "file": "src/renderer/src/store/slices/browser-workspace-doc-location.test.ts", - "assertions": [ - "closing a document browser tab revokes its grant, and closing a URL tab beside it revokes nothing", - "a document page's url is blank in the store, in the written session and after restore", - "a navigation committing a url onto a document page leaves the url blank, the document title standing and nothing loading, while an ordinary page beside it takes the url it is given" - ] - }, - { - "file": "src/renderer/src/store/slices/tabs-hydration.test.ts", - "assertions": [ - "chrome for a preview tab written before previews were browser tabs is dropped, and the ordinary editor tab for the same document is kept" - ] - }, - { - "file": "src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts", - "assertions": [ - "a browser tab located by a workspace document is not published to mobile clients, while an ordinary browser tab beside it is", - "with tab groups in play the document is kept out of the published groups too: no group's order, recency or active tab names a tab the snapshot does not carry, the held-back group leaves the published layout tree with it, and the grouped URL tab beside it still publishes" - ] - }, - { - "file": "src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx", - "assertions": [ - "the pane offers its guest focus when the reader is in this very preview", - "it refuses while the reader is in a terminal in front of it, in a different browser tab, or while its own page is not the active one" - ] - }, - { - "file": "src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", - "assertions": [ - "a preview that is the reader's surface hands its guest focus, and one that is not leaves focus alone", - "the window taking focus back re-offers it to the guest, and re-offers nothing for a preview that was refusing" + "one HTML open produces one client workspace, one client unified tab, and one host page", + "the source editor and remote frame remain visible in separate groups", + "preview creation leaves the editor active, an explicit browser click is authoritative, and close preserves the editor and terminal" ] }, { @@ -3182,301 +2999,22 @@ ], "evidenceRuns": [ { - "date": "2026-08-27", + "date": "2026-08-12", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts src/renderer/src/lib/file-preview-capability-cleanup.test.ts", "result": "failed", - "durationSeconds": 7, - "summary": "The adversarial pass answered the request for its own group-projection probe after this round landed, and it had measured a third observable this test was missing: the held-back group also has to leave the published layout tree, or the phone renders a split whose second pane can never hold anything. Adopted as a fourth assertion and re-run against the same sole delta, which now fails on the dangling-reference check and, with that check blinded, on the layout alone โ€” group-left and group-right against the group-right the clean tree publishes. The two probes agreed on everything else, including that the tab list is unchanged under this mutant, which is why an assertion reading only the tab list cannot reach the site at all." + "durationSeconds": 0.72, + "summary": "The unchanged STA-4063 oracle failed 5/5 on scan e4c278eb52 and latest main bc2e30000b: direct create never closed the new split, and side-preview rejection produced neither cleanup nor a toast. Pre-fix PR #14100 passed the two caller cases but still failed all three direct transaction cases." }, { - "date": "2026-08-27", + "date": "2026-08-12", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", - "result": "passed", - "durationSeconds": 3, - "summary": "The adversarial pass's four coverage findings, landed and pinned: 58/58. The third url door now keeps a document page blank and names it by its document, the group projection holds a document back from published groups as well as from the tab list, the pane's focus handoff is pinned in the widening direction as well as the narrowing one, and the handoff is re-offered when the window takes focus back." - }, - { - "date": "2026-08-27", - "runner": "local", - "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", - "result": "failed", - "durationSeconds": 3, - "summary": "Three sole-delta mutants on the third url door, each failing only the new store test: committing the reported url onto a document page, dropping the document from the title normalizer there (which renames the tab New Tab, since a blank url is what that fallback reads), and marking a blank document page loading. The ordinary-page half of the same test passes under all three, so the door is pinned as a document-only refusal rather than as a door that writes nothing." - }, - { - "date": "2026-08-27", - "runner": "local", - "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", - "result": "failed", - "durationSeconds": 3, - "summary": "Two sole-delta mutants on the mobile publish boundary. Dropping the document from the group projection's browser ids fails only the new grouped test โ€” the tab list stays correct because the per-tab guard still refuses the tab, and what breaks is a published group whose order and active tab name a tab the phone is never sent. This is the survivor the adversarial pass reported: it survives the whole suite without this test, because no other test in it has tab groups. Dropping the per-tab guard instead fails only the ungrouped test, so the two halves of the boundary are pinned separately." - }, - { - "date": "2026-08-27", - "runner": "local", - "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", - "result": "failed", - "durationSeconds": 3, - "summary": "Three sole-delta mutants widening the focus handoff, the direction the adversarial pass found uncovered: dropping the page-activity half, dropping the active-tab-type half, and dropping the active-browser-tab half each fail exactly one of the pane's refusals โ€” the inactive page, the terminal in front, and the other browser tab respectively. The presence half of that test passes under all three, so a pane that had stopped offering focus at all would still fail rather than pass the refusals vacuously." - }, - { - "date": "2026-08-27", - "runner": "local", - "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", - "result": "failed", - "durationSeconds": 3, - "summary": "One sole-delta mutant dropping the window-focus re-offer: the guest is focused once at mount and never again after the window comes back, which is the desktop gap behind it โ€” another app taking the front leaves the preview's only route out shut until the pane remounts. It fails only the new re-offer test, and the refusing-preview case beside it stays green, so the re-offer is pinned as gated rather than unconditional." - }, - { - "date": "2026-08-27", - "runner": "local", - "platform": "macos", - "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", - "result": "passed", - "durationSeconds": 186, - "summary": "The rewritten journey passed 2/2. The first test proves the inverted claim: the client gains exactly one browser workspace and it is the document one โ€” page and tab url blank, the document path mirrored onto both, the tab named by the document's own title, the chip still naming the file, no editor row of the retired preview species โ€” while the host's page registry and tab snapshot both stay at their pre-action content. Both host oracles carry a presence precondition in the same run: an ordinary URL browser tab this client opens does arrive in browser.tabList and in session.tabs.list, and leaves both again when it is closed, so the preview's absence is a decision rather than an oracle that cannot see browser pages. The document still reaches nothing on its own (zero ICE candidates, unattended and post-press egress both swallowed), and the one route out still works: a real press on a target=_blank anchor routes exactly one call and opens a tab while the preview stays open. The second test quits the client with a document tab open and relaunches it on the same profile: the same workspace row comes back blank and named by the document, renders again, and does so over a grant URL that differs from the one that was quit." - }, - { - "date": "2026-08-27", - "runner": "local", - "platform": "macos", - "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", - "result": "failed", - "durationSeconds": 108, - "summary": "Candidate with the sole delta that the preview action also opens an ordinary browser tab through the runtime โ€” what it did before STA-5557 โ€” failed at the client-workspace count, 2 against the 1 the claim allows. It dies there rather than at the host oracle, so it proves the exactly-one-workspace half; the host half has no single line to mutate, since a document page never calls the runtime at all, and is protected instead by the presence precondition recorded above." - }, - { - "date": "2026-08-27", - "runner": "local", - "platform": "macos", - "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", - "result": "failed", - "durationSeconds": 190, - "summary": "Candidate with the sole delta that the workspace row stops mirroring the document its active page shows failed in both tests on workspaceDocFilePath, null against the document path โ€” the tab entry would go on naming nothing while the page it renders is a document. Two earlier mutants survived and are reported as ill-posed rather than as blind spots: flipping the mobile publish filter changes what this desktop publishes to its own phone clients, not what a paired client puts on its host, and flipping the document mirror in the workspace constructor is repaired on the next state write by the active-page mirror, which is the line the mutant above hits." - }, - { - "date": "2026-08-27", - "runner": "local", - "platform": "macos", - "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", - "result": "failed", - "durationSeconds": 195, - "summary": "Candidate with the sole delta that the preview pane does not hand focus to its guest โ€” the tree as it stood before this change โ€” failed at the target=_blank press: six real presses reached the anchor, the embedder had the webview as its active element, and main's own log answered each report with registered true, grant live, focused false. This is the gap the rewrite found: since previews became browser pages they had no chrome to pass focus on, so the one route out of a preview was shut." - }, - { - "date": "2026-08-27", - "runner": "local", - "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/doc-preview-grant-registry.test.ts src/main/browser/doc-preview-file-reader.test.ts src/main/browser/doc-preview-protocol.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/browser/doc-preview-failure-notice.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/lib/doc-preview-grants.test.ts src/renderer/src/lib/file-preview.test.ts src/renderer/src/lib/file-preview-action-visibility.test.tsx src/main/window/main-window-webview-security.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx src/renderer/src/hooks/ipc-events/doc-preview-external-link-bridge.test.ts src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts", - "result": "passed", - "durationSeconds": 1.37, - "summary": "174/174 over the grant, reader, protocol, guest-policy, scheme-admission, plan-routing, action-visibility, failure-message, external-link and terminal-row contracts, re-issued at the relocated preview paths. This replaces the 2026-08-26 record of the same thirteen files at 152/152, whose command named the pre-relocation paths: it established the stale-mint rejection that must not evict a reopened preview tab, the CSP header and session request filter, the SSH symlink refusal, subframe containment, truncated and over-cap reads, the unservable-asset and empty-file split, the out-of-worktree paired refusal with SSH left unrestricted, reload re-minting a stale grant, and the guest deny-only navigation sinks. The count moved with the cases added since; nothing in those files changed but their directory." - }, - { - "date": "2026-08-27", - "runner": "local", - "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/doc-preview-download-block-notice.test.ts src/main/browser/browser-session-partition-policies.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx", - "result": "passed", - "durationSeconds": 1.78, - "summary": "64/64 on the visible download refusal, re-issued at the relocated path. This replaces the 2026-08-27 record of the same four files at 62/62, whose ten mutants โ€” removing the notice from the deny listener, adding it to the routing listener, dropping the guest-registry lookup that names which preview to tell, dropping the shell branch and the row, letting the row survive the failure panel, removing the per-preview throttle, sharing one throttle across previews, never expiring the window, and publishing for a contents no preview is bound to โ€” all failed as sole deltas. The relocation moved no line of any of them." - }, - { - "date": "2026-08-27", - "runner": "local", - "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx src/shared/doc-preview-scheme.test.ts", - "result": "passed", - "durationSeconds": 2.05, - "summary": "39/39 on the title-parity trio at the relocated path, replacing the 2026-08-27 record of the same three files, which killed five mutants as sole deltas: disabling the document branch restores New Tab for a named document, dropping the grant-URL refusal puts the scheme into the title, the tab mirror and the written session, returning New Tab instead of the file name breaks the fallback, removing the guest listener leaves the document unable to name its tab, and making the scheme test parse rather than prefix-match lets a title that merely looks like a preview URL through." - }, - { - "date": "2026-08-27", - "runner": "local", - "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx", - "result": "passed", - "durationSeconds": 1.54, - "summary": "14/14 on the reader-facing failure suite at its relocated path. This and the red run below replace the 2026-08-26 pair recorded against the pre-relocation path (10/10 green, 3/10 red), re-measured today so the entry/subresource split keeps a red half rather than only a green one." - }, - { - "date": "2026-08-27", - "runner": "local", - "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx", - "result": "failed", - "durationSeconds": 1.27, - "summary": "Candidate with the sole delta that every failure sets the blocking reason, asset or not, failed 4/14 on the asset-notice assertions โ€” the same mutant the retired 2026-08-26 record measured at 3/10, re-run at the relocated path. A refused subresource must name itself over a document that stays mounted; only the entry document may take the page away." - }, - { - "date": "2026-08-27", - "runner": "local", - "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/browser/browser-manager-annotation-bridge.test.ts src/main/browser/browser-manager-guest-lifecycle.test.ts src/main/browser/offscreen-browser-backend-lifecycle.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts src/renderer/src/components/editor/EditorPanelShell.header.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", - "result": "passed", - "durationSeconds": 1.52, - "summary": "174/174 at the relocated paths, replacing the 2026-08-27 records of the same command at 162/162 and 158/158. Those established, as sole deltas: removing the unregister-channel refusal goes red because the grab disposal beside it drops the intent an in-flight preview grab compares by identity, while an ordinary browser page driven through the identical sequence still has its grab state disposed; removing registrationโ€™s already-gone-guest refusal goes red for both a missing and a destroyed guest; removing the map deletion on the refusal path goes red at shutdown; the bridgeโ€™s own unregisterGuest branch, restored, tears down a live page over a misaddressed request; and the download fall-through for an unresolved owner hands back a same-named local file under a remote documentโ€™s name." - }, - { - "date": "2026-08-27", - "runner": "local", - "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/ipc/browser-tab-registration-wait.test.ts src/main/ipc/doc-preview-grant-ipc.test.ts src/main/browser/browser-manager-guest-lifecycle.test.ts src/main/browser/browser-manager-guest-policy-profile.test.ts src/main/browser/browser-manager-annotation-bridge.test.ts src/main/browser/offscreen-browser-backend-lifecycle.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", - "result": "passed", - "durationSeconds": 1.5, - "summary": "175/175 at the relocated paths on the unified tool authority, replacing the 2026-08-27 record of the same command at 174/174. That run killed twelve of fourteen mutants as sole deltas โ€” dropping the document branch from the one authority door, removing either browsing-registration refusal or the mint-side refusal that is its converse, deleting the registration notification or widening it to the worktree-wide and any-tab waits, disposing revoked state by grant instead of by page, dropping the hosting-renderer or destroyed-contents check, rebuilding the renderer tool target from the grant, and deleting the teardown identity check under a re-mint โ€” and reported two as ill-posed rather than as kills. The relocation changed no file it names except their directory." - }, -{ - "date": "2026-08-27", - "runner": "local", - "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/lib/file-preview.test.ts", - "result": "passed", - "durationSeconds": 22.09, - "summary": "29/29 on the candidate that makes the preview a browser tab. The preview action now creates a page located by the document; reopening the same document activates the tab it is already in rather than minting a second grant on one file; and closing that tab revokes its grant, which nothing else does now that the editor tab's close hook is gone. Red-green with each mutant as the sole delta: dropping the reuse lookup opens a second tab for a document already on screen, and dropping the release on close leaves the document readable through a grant nothing revokes until the process ends. Both are paired with presence preconditions in the same runs โ€” a second, different document still gets its own tab, and a URL tab closed beside the document tab revokes nothing, so a release fired for every close would fail rather than pass." - }, - { - "date": "2026-08-27", - "runner": "local", - "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/doc-preview-grant-ipc.test.ts src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.test.tsx src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/store/slices/tabs-hydration.test.ts", - "result": "passed", - "durationSeconds": 4.77, - "summary": "40/40 on the candidate that removes the editor preview species and moves its two boundary duties onto the browser tab. The publish filter moved with the tab: it used to exclude an editor file by mode, and now excludes a browser workspace by whether it is located by a document โ€” asserted at both the group projection and the tab loop, because a mutant that filters only one of them still publishes. Migration is the other half: sessions written by builds that made previews editor tabs carry chrome whose entity id encodes the document, and whose document was never persisted, so it has always come back naming a surface no restore produces; that chrome is now dropped. Each mutant as the sole delta: filtering neither place publishes the document tab to mobile, filtering only the projection still publishes it, and removing the migration leaves the stale strip entry. Presence preconditions in the same runs: an ordinary browser tab beside the document tab does publish, and the ordinary editor tab for the very same document survives hydration." - }, - { - "date": "2026-08-27", - "runner": "local", - "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts", - "result": "passed", - "durationSeconds": 1.54, - "summary": "10/10 on the candidate that gives a browser page a workspace-document location. The trap this is built around: page.url is read by persistence, the mobile publisher, history and the address bar, so a grant URL parked there would outlive the grant that named it and travel to readers that cannot resolve it. Creation overrides the url rather than trusting its caller, and restore re-asserts the same thing, so a session written by another build cannot carry one in. Red-green with each mutant as the sole delta: dropping the creation override writes the grant URL into the store and into the session payload; dropping the restore override brings a foreign session's grant URL back; leaving docLocation out of either browser schema strips it on load and restores the document as a blank New Tab under a strip entry that still names it (both schemas are plain z.object, and this is the trap that already cost one round on the editor schema); dropping it from the workspace build or the mirror leaves the tab entry naming a document its page has left, which the publish boundary reads; dropping the salvage-path copy loses the document when only the tab row survived; and dropping the address-bar exclusion focuses an address bar the surface does not have. The blank-url assertions are paired with a presence precondition in the same run: an ordinary tab created through the same call keeps its URL and does ask for the address bar." - }, - { - "date": "2026-08-27", - "runner": "local", - "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-manager-guest-policy-profile.test.ts src/main/window/main-window-webview-security.test.ts src/main/browser/doc-preview-guest-policy.test.ts", - "result": "passed", - "durationSeconds": 0.32, - "summary": "55/55 on the candidate that gives every guest one attach door. A preview used to reach its policies through an installer of its own, which meant the manager's attachment bookkeeping โ€” the set registration consults and the cleanup teardown runs โ€” never knew a preview existed. It is now a profile on `attachGuestPolicies`, and the doc policy is what that profile installs. Red-green with each mutant as the sole delta: deleting the profile branch hands a previewed document the browsing machinery (clicked-link routing, frame routing, popup child windows, anti-detection over the debugger) and lets it navigate off-grant; dropping the manager's destroyed wiring leaves the guest answerable through the grant-keyed authority after it dies and its id marked attached forever; and making the window fork ask for the default profile is caught at the fork itself. The absence assertions are paired with a presence precondition in the same run: a browsing guest attached through the same method observably takes every one of those things, so a profile that fenced nothing, or an attach path that had stopped installing anything at all, fails rather than passing empty." - }, - { - "date": "2026-08-27", - "runner": "local", - "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-session-partition-policies.test.ts src/main/browser/doc-preview-protocol.test.ts", - "result": "passed", - "durationSeconds": 0.17, - "summary": "22/22 on the candidate that stops a previewed document writing into this desktop's Downloads folder. The hole was read out of the shipping path, not guessed: the preview partition took the shared browser partition policies whole, including will-download, and a preview guest joins no browser-page registry, so handleGuestWillDownload resolved no owner context, routeBrowserClientDownload answered local for a non-client-hosted contents, and the item got a reserved Downloads name with no prompt and no tab to attribute it to. Downloads are now the one policy the preview partition does not inherit. Red-green with each mutant as the sole delta: making the installer ignore the deny option routes the preview partition's download to the router again, and making the preview protocol installer stop asking for the deny leaves the option unset. The absence half is paired with a presence precondition in the same run โ€” an ordinary browsing partition installed beside the preview one in one test still reaches the download router, so a deny that fenced everything, or a download path that had been removed entirely, would fail rather than pass quietly." - }, -{ - "date": "2026-08-27", - "runner": "local", - "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/ipc/browser.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts src/renderer/src/lib/file-preview-capability-cleanup.test.ts", "result": "passed", "durationSeconds": 0.7, - "summary": "99/99 on the candidate that binds a preview guest to its grant when the policy installs, not only on a later navigation. Live on a dev instance against a real SSH workspace, the previous shape refused every tool with not-ready for as long as the reader stayed on the page they opened: the embedder hands the guest over after it has already started loading its src, so the did-start-navigation latch never saw the only navigation most previews make. Following an in-document link bound it and the tools began answering, which is what identified the miss. Two cases pin it: a guest already reporting its URL at install, and one whose first navigation only reaches the commit." - }, - { - "date": "2026-08-26", - "runner": "local", - "platform": "macos", - "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", - "result": "passed", - "durationSeconds": 23.1, - "summary": "The journey passed 1/1 on the candidate that requires the guest to be bound to a grant before any navigation is allowed, rather than admitting an unbound one. The document still rendered and the target=_blank press still routed out, which is the half that would break if the renderer-set src reached will-navigate: it does not, because that load is browser-initiated, so the permissive branch it was written for is unreachable." - }, - { - "date": "2026-08-26", - "runner": "ci", - "platform": "linux", - "command": "npx playwright test --config tests/playwright.config.ts --project=electron-headless tests/e2e/paired-remote-html-preview-local-render.spec.ts --workers=1", - "result": "failed", - "durationSeconds": 72, - "summary": "The journey failed on Ubuntu at the post-press presence precondition, with #post-input-egress still reading 'idle' after 30 seconds. The trace shows the press was dispatched at 769.5,143.9, a point the guest's own hit test had just confirmed was on its heading, and the focus report before it was already isFocused true, so neither focus nor coordinates were the break: the press never entered the guest at all. A standalone Electron 43 probe under Xvfb with this suite's Linux flags reproduced it in roughly two runs in five when pressing immediately after guest attach โ€” the embedder received the pointerdown with the webview element as its target, meaning the browser resolved the press against the embedder because the freshly attached guest had not yet registered its hit-test region. The same probe never dropped a press on macOS, which is why local runs of either project could not see this. The spec now presses until the document answers, mirroring the target=_blank phase." - }, - { - "date": "2026-08-26", - "runner": "local", - "platform": "macos", - "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line --repeat-each=3", - "result": "passed", - "durationSeconds": 72, - "summary": "The journey passed 3/3 on the trusted-click candidate, which removed the recent-input gate, made both navigation sinks deny-only, dropped the webview's allowpopups attribute and gave the preview guest its own pinned preload. The fixture now also attempts a window.open and location.href from inside a pointerdown handler, so the egress rides a press the reader really made: it reported 'attempted' every run while both browser counts stayed at that phase's baseline and nothing routed, and the later real press on the target=_blank link still opened exactly one Orca browser tab with the preview still open." - }, - { - "date": "2026-08-26", - "runner": "local", - "platform": "macos", - "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", - "result": "failed", - "durationSeconds": 26.9, - "summary": "Candidate with the sole delta that the navigation sinks route again behind a two-second recent-input gate failed on clientBrowserWorkspaceCountAllWorktrees 1 versus baseline 0: the document's location.href, run from inside the handler for the reader's press on the heading, opened a real browser tab. That is the case the earlier design could not distinguish from the press's own effect, and it is why the sinks route nothing at all now. The same run still refused the unattended on-load egress, so this measures the new half specifically." - }, - { - "date": "2026-08-26", - "runner": "local", - "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/ipc/browser.test.ts", - "result": "passed", - "durationSeconds": 0.97, - "summary": "97/97 passed over the preview tool-authorization seam. The channel census asserts the split is total, so a new browser channel cannot be added without classifying it: all six tool channels resolved the preview guest and refused both a renderer that does not host the preview and a grant no preview rendered, while all twenty-three page/session channels never reached the preview authority at all. Five mutations were killed \u2014 dropping the preview branch (12 failures), turning the two authorities into a fallback chain (13), ignoring the hosting renderer id (6), ignoring grant revocation (1), and removing one channel from the census (1)." - }, - { - "date": "2026-08-26", - "runner": "local", - "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/preload/doc-preview-link-interception.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/window/main-window-webview-security.test.ts src/main/ipc/doc-preview-grant-ipc.test.ts", - "result": "passed", - "durationSeconds": 0.36, - "summary": "The four surfaces the trusted-click route is built from passed 63/63: the preload's click policy over a real document, including an untrusted dispatched click, an untrusted middle click, an SVG animated href resolved against the document so a relative, rooted or fragment SVG link behaves exactly like the identical HTML anchor, fragment and percent-encoded fragment targets and a trusted middle click; the guest policy's deny-only sinks, its refusal of a navigation that arrives before a document has bound the guest to a grant, and its report gate for an unfocused guest, an unregistered sender, a missing or revoked grant and non-web URLs; will-attach-webview pinning the preview preload in both directions; and the click channel handing main its own sender without a trusted-renderer check." - }, -{ - "date": "2026-08-26", - "runner": "local", - "platform": "macos", - "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", - "result": "passed", - "durationSeconds": 22.8, - "summary": "The journey passed 1/1 with the fixture document now attempting its own egress on every load: an unattended window.open and location.href to an off-machine URL, and an ICE gathering probe. Both browser counts held at baseline and the probe reported candidates=0, while the document still rendered and the later real target=_blank press still routed out as an Orca browser tab." - }, - { - "date": "2026-08-26", - "runner": "local", - "platform": "macos", - "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", - "result": "failed", - "durationSeconds": 23.6, - "summary": "Candidate with the sole delta that the preview guest no longer receives the disable_non_proxied_udp IP-handling policy failed on candidates=6 versus 0, proving the document can gather ICE โ€” including a STUN reflexive candidate against an off-machine server in the standalone probe โ€” whenever that one main-process call is missing." - }, - { - "date": "2026-08-26", - "runner": "local", - "platform": "macos", - "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line --repeat-each=3", - "result": "passed", - "durationSeconds": 66, - "summary": "The journey passed 3/3 again under --repeat-each=3 on the candidate that clears grants on every fresh shell document and gates the mint channel on the trusted renderer, so neither change disturbs a live preview: the document still rendered, both browser counts held at baseline, and the target=_blank press still routed out as an Orca browser tab." - }, -{ - "date": "2026-08-26", - "runner": "local", - "platform": "macos", - "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line --repeat-each=3", - "result": "passed", - "durationSeconds": 59.7, - "summary": "The renamed journey passed 3/3 under --repeat-each=3 on the hardened candidate: the host document rendered in the orca-preview guest, both browser counts held at baseline through the split, focus, reactivation and close phases, and the final phase's real target=_blank press routed out as an Orca browser tab with the routing call recorded as opened while the preview tab stayed open." + "summary": "Rebased candidate 14b5877a75 passed 5/5: absent, unknown, mixed-version, preflight-known, and post-preflight capability failures all reported failure and retained no new empty split." }, { "date": "2026-08-12", @@ -3532,6 +3070,78 @@ "durationSeconds": 3.48, "summary": "Seven focused host and renderer contract files passed 183/183 tests, covering owning-runtime routing, focused and unfocused creation, placement, reconciliation, failure cleanup, and teardown." }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=3", + "result": "passed", + "durationSeconds": 16.1, + "summary": "The candidate passed exact host/client cardinality, split stability, rendered DOM, focus authority, close convergence, and surviving editor and terminal." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "failed", + "durationSeconds": 11.6, + "summary": "Latest-main a90a18d43da333e1cc327779a3f9912a78308848 failed the byte-identical 5806b23f oracle because the HTML preview collapsed into the source editor group instead of retaining its requested split." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 6.5, + "summary": "Rebased candidate b7de9c0961a4edf389a29079b2b4d5250184b57e passed the byte-identical 5806b23f oracle: one host page, one client workspace, requested split, rendered DOM, stable focus, and browser-only close." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "failed", + "durationSeconds": 10.9, + "summary": "Rebased candidate b7de9c0961a4edf389a29079b2b4d5250184b57e with only the side-preview owning-runtime routing seam disabled failed the byte-identical 5806b23f oracle with two client workspaces." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 7.6, + "summary": "Final candidate d5767481b982b736e7cc5b4653b2a32a61280638 on merge-base 36d45af062ec4022265aabac8fc6a9d36eecedf1 passed the byte-identical 5806b23f oracle after final review and rebase: one host page, one client workspace, requested split, rendered DOM, stable focus, and browser-only close." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "failed", + "durationSeconds": 11.8, + "summary": "Latest-main 346e59c879 failed the byte-identical 2ec1dcf1 oracle with two client HTML browser workspaces instead of one." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 8.4, + "summary": "Candidate d05217c40d passed the byte-identical 2ec1dcf1 oracle: one host page, one client workspace, stable split, rendered DOM, editor focus after creation, explicit-click browser focus, and browser-only close." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "failed", + "durationSeconds": 12.3, + "summary": "Candidate d05217c40d with only the side-preview focusOnCreate:false call disabled failed the byte-identical 2ec1dcf1 oracle because preview creation activated the browser instead of preserving editor focus." + }, { "date": "2026-08-13", "runner": "local", @@ -3561,7 +3171,7 @@ }, "redGreenEvidence": { "status": "complete", - "evidence": "The unchanged STA-4063 five-assertion oracle is red 5/5 on scan e4c278eb52 and latest main bc2e30000b, partially green 2/5 on pre-fix PR #14100, green 5/5 on candidate 14b5877a75, and red 3/5 when only the new transaction cleanup boundary is disabled on final candidate a9e6fb7716. The earlier STA-4025 transaction oracle remains red on the post-#13909 base and green on this candidate. The byte-identical STA-4231 headed oracle is red on latest-main b19d99bd00: both 15-second creates return runtime_timeout without a page ID while the host retains two distinct pages after retry. The relevant behavior is unchanged through 0a0ae974d4. The candidate is green with one returned canonical ID, one host/client page, and no retry; disabling only the navigation-independent acknowledgement is red with the same two orphaned pages. The byte-identical 2ec1dcf1 Electron oracle remains red on latest main 346e59c879, green on candidate d05217c40d, and red with only side-preview focus disabled; historical 5806b23f evidence separately proves owning-runtime routing. For the STA-5557 preview behavior the rewritten journey is green 1/1 on the candidate and red when the sole delta makes the same preview action also create a browser tab, failing on clientBrowserWorkspaceCount 1 versus baseline 0. That absence assertion is gated on first reading the rendered document out of the guest, so it cannot pass by the preview never happening. Against pre-STA-5557 builds the journey is red by construction rather than by measurement: those builds have no html-preview tab mode, so the run would fail earlier, at materialization, and it has not been executed against an old build. The reader-facing half is red-green at the unit layer. The 6/6 green and 2/6 red pair recorded earlier on 2026-08-26 measured the superseded shape, where a subresource failure was filtered out entirely; the shipping shape is green 10/10 and red 3/10 when the sole delta makes every failure, asset or entry, set the blocking reason. The containment lines the second review found uncovered are red-green too: installing the scheme handler on the default session instead of the preview session, and widening the scheme privileges with allowServiceWorkers and bypassCSP, each fail the protocol suite as the sole delta, and deleting the html-preview case from the closed-tab cleanup fails the hook suite. The external-link phase added on 2026-08-26 is red-green by measurement: green 3/3 on the trusted-click design, in which the guest's own preload reports a trusted press on an anchor and main answers only a focused preview guest holding a live grant. Its earlier shape depended on the webview carrying allowpopups and went red with zero recorded routing calls when only that attribute was dropped; the preload now intercepts the press before Chromium considers a popup, so the attribute is gone and that measurement retired with it. One further run failed in the paired-client harness before reaching any preview step, when the client window never exposed its store within the launch timeout; that is a harness flake, not a preview signal. The two egress fences added on 2026-08-26 are red-green by measurement in the same journey, whose fixture attempts its own escape on every load and again from inside the handler for the reader's press on the heading: with deny-only navigation sinks the document opens no browser tab and the counts hold at baseline, and with the sole delta that those sinks route again behind a two-second recent-input gate the post-press location.href opens a real tab, clientBrowserWorkspaceCountAllWorktrees 1 against a baseline of 0. An earlier measurement of that same gate, taken when it was the shipping design, showed the unattended on-load egress opening two tabs once the gate was removed entirely; it was retired with the gate, because a gate that cannot tell a script's navigation from the click it followed was never the fence the claim needed. With the guest's IP-handling policy the document reports candidates=0, and with the sole delta that the policy call is removed it reports candidates=6. Both mutants were applied to the shipping module, rebuilt, and run against the live paired journey rather than reasoned about." + "evidence": "The unchanged STA-4063 five-assertion oracle is red 5/5 on scan e4c278eb52 and latest main bc2e30000b, partially green 2/5 on pre-fix PR #14100, green 5/5 on candidate 14b5877a75, and red 3/5 when only the new transaction cleanup boundary is disabled on final candidate a9e6fb7716. The earlier STA-4025 transaction oracle remains red on the post-#13909 base and green on this candidate. The byte-identical STA-4231 headed oracle is red on latest-main b19d99bd00: both 15-second creates return runtime_timeout without a page ID while the host retains two distinct pages after retry. The relevant behavior is unchanged through 0a0ae974d4. The candidate is green with one returned canonical ID, one host/client page, and no retry; disabling only the navigation-independent acknowledgement is red with the same two orphaned pages. The byte-identical 2ec1dcf1 Electron oracle remains red on latest main 346e59c879, green on candidate d05217c40d, and red with only side-preview focus disabled; historical 5806b23f evidence separately proves owning-runtime routing." }, "performanceBudget": { "required": true, @@ -3574,14 +3184,9 @@ "knownGaps": [ "Live paired-server validation currently covers macOS only.", "Multiple simultaneous viewers are not exercised by the Electron journey.", - "Local workspaces intentionally keep the embedded browser tab at a file URL and are not covered by the preview journey.", - "The SSH preview owner has unit coverage only; no live SSH host renders a preview in this gate.", - "The preview journey exercises one self-contained document. Relative subresources, reload re-reading the workspace disk, and the truncated and out-of-worktree refusals are covered by unit contracts only.", - "Grant revocation on tab close is asserted at the unit layer; the journey observes the tab and open file disappearing, not the main-process registry.", - "The external-link route trusts a real press on an anchor in a focused preview guest, so it proves the reader pressed that anchor, not that the anchor's visible text matches its target. The tab it opens is visible, which is the remaining containment.", - "WebRTC is fenced by the IP-handling policy rather than by removing the API; the guest can still construct a peer connection, and the journey measures that it gathers nothing." + "Direct SSH remote file URLs remain intentionally unsupported and local file previews retain their existing path." ], - "demotionRule": "Demote if a paired HTML preview creates a browser workspace on either side, renders from anything other than the orca-preview scheme, serves a read the host reported as truncated or over-cap, serves a document without the self-only content security policy, lets the preview session carry an off-machine request, reads an SSH path that canonicalizes outside its grant root, steals focus from the source editor when it opens, removes unrelated editor or terminal state on close, hides a document that rendered because one subresource was refused, leaves a grant readable after the tab or renderer that owns it is gone, mints one for a sender that is not the trusted renderer, opens a browser tab for a navigation the document started with no one at the keyboard, or lets the guest gather an ICE candidate; or if a paired browser creation this gate still owns produces duplicate client or host identities or loses its requested split." + "demotionRule": "Demote if one paired HTML action can create duplicate client or host browser identities, lose the requested split, steal editor focus before an explicit browser click, lose browser focus after that click, or remove unrelated editor or terminal state on close." }, { "id": "browser-network-tunnel.bounded-fail-closed-route", diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 4faa844ee4c..f81dbb93041 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -209,7 +209,6 @@ export const electronViteConfig: UserConfig = { index: resolve('src/main/index.ts'), // Why: sandboxed webview preloads cannot load Rollup helper chunks. 'browser-window-close-preload': resolve('src/preload/browser-window-close.ts'), - 'doc-preview-link-preload': resolve('src/preload/doc-preview-link.ts'), 'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'), 'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'), 'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'), diff --git a/src/main/browser/browser-manager-annotation-bridge.test.ts b/src/main/browser/browser-manager-annotation-bridge.test.ts deleted file mode 100644 index 2d9aa5e50fe..00000000000 --- a/src/main/browser/browser-manager-annotation-bridge.test.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const browserMocks = vi.hoisted(() => ({ - appGetPathMock: vi.fn(() => '/downloads'), - shellOpenExternalMock: vi.fn(), - browserWindowFromWebContentsMock: vi.fn(), - menuBuildFromTemplateMock: vi.fn(), - guestOffMock: vi.fn(), - guestOnMock: vi.fn(), - guestSetBackgroundThrottlingMock: vi.fn(), - guestSetWindowOpenHandlerMock: vi.fn(), - guestOpenDevToolsMock: vi.fn(), - webContentsFromIdMock: vi.fn(), - screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })), - openPopupWithOriginBarMock: vi.fn() -})) - -vi.mock('electron', () => ({ - app: { getPath: browserMocks.appGetPathMock }, - BrowserWindow: { fromWebContents: browserMocks.browserWindowFromWebContentsMock }, - clipboard: { writeText: vi.fn() }, - shell: { openExternal: browserMocks.shellOpenExternalMock }, - Menu: { buildFromTemplate: browserMocks.menuBuildFromTemplateMock }, - screen: { getCursorScreenPoint: browserMocks.screenGetCursorScreenPointMock }, - webContents: { fromId: browserMocks.webContentsFromIdMock } -})) - -vi.mock('./popup-origin-bar-window', () => ({ - openPopupWithOriginBar: browserMocks.openPopupWithOriginBarMock -})) - -import { browserManager } from './browser-manager' -import type { BrowserAnnotationViewportBridgeOptions } from '../../shared/browser-annotation-viewport-bridge' -import { - rendererWebContentsId, - resetBrowserManagerMocks, - resetBrowserManagerState -} from './browser-manager-test-harness' -import { - createViewportGuestFactory, - flushViewportOps -} from './browser-manager-viewport-test-fixtures' - -const { webContentsFromIdMock } = browserMocks -const makeGuest = createViewportGuestFactory(browserMocks) - -const BRIDGE_OPTIONS: BrowserAnnotationViewportBridgeOptions = { - emitViewport: false, - enabled: true, - markers: [], - token: 'annotationviewporttoken' -} - -function registerPage(pageId: string, guest: Record): void { - webContentsFromIdMock.mockReturnValue(guest) - browserManager.attachGuestPolicies(guest as never) - browserManager.registerGuest({ - browserPageId: pageId, - webContentsId: guest.id as number, - rendererWebContentsId - }) -} - -/** The production resolver's shape: read the registry now, not when the request was made. */ -function resolveFromRegistry(pageId: string): () => Electron.WebContents | null { - return () => browserManager.getAuthorizedGuest(pageId, rendererWebContentsId) -} - -describe('browserManager.setAnnotationViewportBridge', () => { - beforeEach(() => { - resetBrowserManagerMocks(browserMocks) - resetBrowserManagerState() - }) - - afterEach(() => { - vi.restoreAllMocks() - }) - - it('installs the bridge in an isolated world', async () => { - const { guest } = makeGuest(4646) - registerPage('tab-annotations', guest) - - const ok = await browserManager.setAnnotationViewportBridge( - 'tab-annotations', - BRIDGE_OPTIONS, - resolveFromRegistry('tab-annotations') - ) - - expect(ok).toBe(true) - expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledWith( - expect.any(Number), - [ - expect.objectContaining({ - code: expect.stringContaining('__orcaBrowserAnnotationViewportBridge') - }) - ], - false - ) - }) - - // Why this is the case that matters: a cross-process navigation re-registers the same page id - // with a new WebContents and leaves the retired one alive, so a bridge op that resolved its - // guest when the request arrived would inject into a page nobody is looking at โ€” and the - // annotation badges would stop tracking the document on screen. - it('injects into the guest the page has when a queued op finally runs, not the one it was asked with', async () => { - const { guest: firstGuest } = makeGuest(5101) - let releaseFirstInjection = (): void => {} - // One shared gate for every call, so a wrongly-routed second injection still settles and the - // test fails on where it landed rather than on a timeout. - const firstInjectionGate = new Promise((resolve) => { - releaseFirstInjection = () => resolve() - }) - firstGuest.executeJavaScriptInIsolatedWorld = vi.fn(() => firstInjectionGate) - registerPage('tab-swap', firstGuest) - - const firstDone = browserManager.setAnnotationViewportBridge( - 'tab-swap', - BRIDGE_OPTIONS, - resolveFromRegistry('tab-swap') - ) - await flushViewportOps() - - // A second request arrives while the first still holds the chain โ€” at this moment the page is - // still on the first guest, which is the guest a request-time resolution would capture. - const secondDone = browserManager.setAnnotationViewportBridge( - 'tab-swap', - BRIDGE_OPTIONS, - resolveFromRegistry('tab-swap') - ) - await flushViewportOps() - - // Only now does the page swap renderer processes, while the second op is still queued. - const { guest: secondGuest } = makeGuest(5102) - registerPage('tab-swap', secondGuest) - - releaseFirstInjection() - await expect(firstDone).resolves.toBe(true) - await expect(secondDone).resolves.toBe(true) - - expect(secondGuest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(1) - expect(firstGuest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(1) - }) - - // Why this is the resolver's cleanup and not the bridge's: the authority that reads the registry - // is the one that can see the guest is gone. The bridge only reports the refusal. - it('refuses when its guest died while the op was queued, and the resolver drops the registration', async () => { - const { guest } = makeGuest(5103) - registerPage('tab-dies', guest) - expect(browserManager.getGuestWebContentsId('tab-dies')).toBe(5103) - - webContentsFromIdMock.mockReturnValue(null) - await expect( - browserManager.setAnnotationViewportBridge( - 'tab-dies', - BRIDGE_OPTIONS, - resolveFromRegistry('tab-dies') - ) - ).resolves.toBe(false) - - // Why assert the registry and not just the answer: a stale guest has to clear every per-tab - // entry, or the page keeps a dead WebContents id that later ops resolve against. - expect(browserManager.getGuestWebContentsId('tab-dies')).toBeNull() - }) - - // Why this one exists: an unresolved guest is not the same as a dead one. A request addressed by - // the wrong renderer names a page that is alive and on screen, and answering it with teardown - // would cancel that page's in-flight downloads and grabs over a misaddressed message. - it('refuses a request from the wrong renderer without tearing down the healthy page it named', async () => { - const { guest } = makeGuest(5105) - registerPage('tab-mismatch', guest) - const unregisterGuest = vi.spyOn(browserManager, 'unregisterGuest') - - await expect( - browserManager.setAnnotationViewportBridge('tab-mismatch', BRIDGE_OPTIONS, () => - browserManager.getAuthorizedGuest('tab-mismatch', rendererWebContentsId + 1) - ) - ).resolves.toBe(false) - - expect(unregisterGuest).not.toHaveBeenCalled() - expect(browserManager.getGuestWebContentsId('tab-mismatch')).toBe(5105) - expect(guest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled() - }) - - it('refuses a destroyed guest without injecting into it', async () => { - const { guest } = makeGuest(5104) - registerPage('tab-destroyed', guest) - ;(guest.isDestroyed as ReturnType).mockReturnValue(true) - - await expect( - browserManager.setAnnotationViewportBridge( - 'tab-destroyed', - BRIDGE_OPTIONS, - () => guest as never - ) - ).resolves.toBe(false) - expect(guest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled() - }) - - // Why: a document page owns no browsing state, so treating its refusal as a stale page would run - // teardown against an id the browsing registry never held. - it('does not run page teardown when a document page resolves to nothing', async () => { - const unregisterGuest = vi.spyOn(browserManager, 'unregisterGuest') - - await expect( - browserManager.setAnnotationViewportBridge('doc-page-1', BRIDGE_OPTIONS, () => null) - ).resolves.toBe(false) - - expect(unregisterGuest).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/browser/browser-manager-guest-lifecycle.test.ts b/src/main/browser/browser-manager-guest-lifecycle.test.ts index 6d71db94585..d1a6a16c085 100644 --- a/src/main/browser/browser-manager-guest-lifecycle.test.ts +++ b/src/main/browser/browser-manager-guest-lifecycle.test.ts @@ -45,32 +45,6 @@ import { resetBrowserManagerMocks, resetBrowserManagerState } from './browser-manager-test-harness' -import { installDocPreviewGuestPolicy } from './doc-preview-guest-policy' -import { mintDocPreviewGrant, revokeAllDocPreviewGrants } from './doc-preview-grant-registry' -import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme' - -/** - * A page the document half of the registry really holds. Built rather than named: membership is - * what both doors refuse on now, so an id that merely looks like a preview's would be admitted. - */ -function registerWorkspaceDocPage(browserPageId: string): void { - const grant = mintDocPreviewGrant({ - owner: { kind: 'ssh', connectionId: 'ssh-1' }, - root: '/home/alice/docs', - entryRelativePath: 'index.html', - browserPageId - }) - const guest = { - isFocused: () => false, - isDestroyed: () => false, - getURL: () => buildDocPreviewUrl(grant.id, grant.entryRelativePath), - on: vi.fn(), - once: vi.fn(), - setWindowOpenHandler: vi.fn(), - setWebRTCIPHandlingPolicy: vi.fn() - } - installDocPreviewGuestPolicy(guest as never, { id: rendererWebContentsId, send: vi.fn() }) -} const { guestOffMock, @@ -86,7 +60,6 @@ describe('browserManager', () => { beforeEach(() => { resetBrowserManagerMocks(browserMocks) resetBrowserManagerState() - revokeAllDocPreviewGrants() }) afterEach(() => { @@ -119,77 +92,6 @@ describe('browserManager', () => { expect(browserManager.getSessionProfileIdForTab('browser-1')).toBe('work') }) - // Why both doors: one id in both halves of the registry would make the tool door answer with a - // document guest for a page the reader is browsing in. - it.each(['registerGuest', 'registerOffscreenGuest'] as const)( - 'refuses %s for a page the document registry already holds', - (entryPoint) => { - const guest = { - id: 129, - isDestroyed: vi.fn(() => false), - getType: vi.fn(() => 'webview'), - setBackgroundThrottling: guestSetBackgroundThrottlingMock, - setWindowOpenHandler: guestSetWindowOpenHandlerMock, - on: guestOnMock, - off: guestOffMock, - openDevTools: guestOpenDevToolsMock - } - webContentsFromIdMock.mockReturnValue(guest) - browserManager.attachGuestPolicies(guest as never) - const browserPageId = 'doc-page-1' - registerWorkspaceDocPage(browserPageId) - - if (entryPoint === 'registerGuest') { - expect( - browserManager.registerGuest({ - browserPageId, - webContentsId: guest.id, - rendererWebContentsId - }) - ).toBe(false) - } else { - expect( - browserManager.registerOffscreenGuest({ browserPageId, webContentsId: guest.id }) - ).toBe(false) - } - - expect(browserManager.getGuestWebContentsId(browserPageId)).toBeNull() - } - ) - - // Why this answer is load-bearing: the headless backend destroys its window on false, so a true - // for a guest that is already gone would leave a page id registered onto nothing. - it.each(['missing', 'destroyed'] as const)( - 'refuses registerOffscreenGuest when the named guest is %s', - (guestState) => { - webContentsFromIdMock.mockReturnValue( - guestState === 'missing' ? null : { id: 137, isDestroyed: vi.fn(() => true) } - ) - - expect( - browserManager.registerOffscreenGuest({ browserPageId: 'offscreen-1', webContentsId: 137 }) - ).toBe(false) - - expect(browserManager.getGuestWebContentsId('offscreen-1')).toBeNull() - } - ) - - // Why the exit door needs the same check: a document page withdraws by revoking its grant, so its - // id here is misaddressed โ€” and unregistering opens by evicting whatever grab that id names. - it('refuses unregisterGuest for a page the document registry holds', () => { - registerWorkspaceDocPage('doc-page-2') - const cancelGrabOp = vi.spyOn(browserManager, 'cancelGrabOp') - - browserManager.unregisterGuest('doc-page-2') - - expect(cancelGrabOp).not.toHaveBeenCalled() - - // The presence half: the same door does evict a browsing page's grab. - browserManager.unregisterGuest('browser-page-1') - expect(cancelGrabOp).toHaveBeenCalledWith('browser-page-1', 'evicted') - cancelGrabOp.mockRestore() - }) - it('blocks non-web guest navigations after attach', () => { const guest = { isDestroyed: vi.fn(() => false), diff --git a/src/main/browser/browser-manager-guest-policy-profile.test.ts b/src/main/browser/browser-manager-guest-policy-profile.test.ts deleted file mode 100644 index 47871f64ea9..00000000000 --- a/src/main/browser/browser-manager-guest-policy-profile.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const browserMocks = vi.hoisted(() => ({ - appGetPathMock: vi.fn(() => '/downloads'), - shellOpenExternalMock: vi.fn(), - browserWindowFromWebContentsMock: vi.fn(), - menuBuildFromTemplateMock: vi.fn(), - guestOffMock: vi.fn(), - guestOnMock: vi.fn(), - guestSetBackgroundThrottlingMock: vi.fn(), - guestSetWindowOpenHandlerMock: vi.fn(), - guestOpenDevToolsMock: vi.fn(), - webContentsFromIdMock: vi.fn(), - screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })), - openPopupWithOriginBarMock: vi.fn() -})) - -vi.mock('electron', () => ({ - app: { getPath: browserMocks.appGetPathMock }, - BrowserWindow: { fromWebContents: browserMocks.browserWindowFromWebContentsMock }, - clipboard: { writeText: vi.fn() }, - shell: { openExternal: browserMocks.shellOpenExternalMock }, - Menu: { buildFromTemplate: browserMocks.menuBuildFromTemplateMock }, - screen: { getCursorScreenPoint: browserMocks.screenGetCursorScreenPointMock }, - webContents: { fromId: browserMocks.webContentsFromIdMock } -})) -vi.mock('./popup-origin-bar-window', () => ({ - openPopupWithOriginBar: browserMocks.openPopupWithOriginBarMock -})) - -import { browserManager } from './browser-manager' -import { resetBrowserManagerMocks, resetBrowserManagerState } from './browser-manager-test-harness' -import { getWorkspaceDocPageGuest } from './doc-preview-guest-policy' -import { mintDocPreviewGrant, revokeAllDocPreviewGrants } from './doc-preview-grant-registry' -import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme' - -type GuestFake = { - id: number - url: string - listeners: Map void)[]> - windowOpenHandler: ((details: { url: string; frameName: string }) => unknown) | null - webRtcPolicy: string | null - isDestroyed: () => boolean - isFocused: () => boolean - getURL: () => string - getType: () => string - setBackgroundThrottling: (value: boolean) => void - setWebRTCIPHandlingPolicy: (policy: string) => void - setWindowOpenHandler: (handler: (details: { url: string; frameName: string }) => unknown) => void - executeJavaScriptInIsolatedWorld: ReturnType - debugger: { - isAttached: () => boolean - attach: ReturnType - sendCommand: ReturnType - } - on: (event: string, listener: (...args: never[]) => void) => void - once: (event: string, listener: (...args: never[]) => void) => void - off: (event: string, listener: (...args: never[]) => void) => void -} - -function createGuest(id: number, url: string): GuestFake { - const listeners = new Map void)[]>() - const guest: GuestFake = { - id, - url, - listeners, - windowOpenHandler: null, - webRtcPolicy: null, - isDestroyed: () => false, - isFocused: () => true, - getURL: () => guest.url, - getType: () => 'webview', - setBackgroundThrottling: vi.fn(), - setWebRTCIPHandlingPolicy: (policy: string) => { - guest.webRtcPolicy = policy - }, - setWindowOpenHandler: (handler) => { - guest.windowOpenHandler = handler - }, - executeJavaScriptInIsolatedWorld: vi.fn(async () => undefined), - debugger: { - isAttached: () => true, - attach: vi.fn(), - sendCommand: vi.fn(async () => undefined) - }, - on: (event, listener) => { - listeners.set(event, [...(listeners.get(event) ?? []), listener]) - }, - once: (event, listener) => { - listeners.set(event, [...(listeners.get(event) ?? []), listener]) - }, - off: (event, listener) => { - listeners.set( - event, - (listeners.get(event) ?? []).filter((entry) => entry !== listener) - ) - } - } - return guest -} - -function listenerCount(guest: GuestFake, event: string): number { - return guest.listeners.get(event)?.length ?? 0 -} - -/** Drives the guest's own will-navigate listeners and reports whether they refused. */ -function navigateTo(guest: GuestFake, url: string): boolean { - let prevented = false - const event = { preventDefault: () => (prevented = true) } as never - for (const listener of guest.listeners.get('will-navigate') ?? []) { - ;(listener as (event: unknown, url: string) => void)(event, url) - } - return prevented -} - -const host = { id: 5001, send: vi.fn() } as unknown as Electron.WebContents - -beforeEach(() => { - resetBrowserManagerMocks(browserMocks) - resetBrowserManagerState() - revokeAllDocPreviewGrants() - browserMocks.guestSetBackgroundThrottlingMock.mockReturnValue(undefined) -}) - -describe('guest policy profiles', () => { - function attachPreviewGuest(id = 301): { - guest: GuestFake - grantId: string - browserPageId: string - } { - const browserPageId = `doc-page-${id}` - const grant = mintDocPreviewGrant({ - owner: { kind: 'ssh', connectionId: 'ssh-1' }, - root: '/home/alice/docs', - entryRelativePath: 'index.html', - browserPageId - }) - const guest = createGuest(id, buildDocPreviewUrl(grant.id, 'index.html')) - browserManager.attachGuestPolicies(guest as never, null, { profile: 'workspace-doc', host }) - return { guest, grantId: grant.id, browserPageId } - } - - // The presence half of every absence below: a browsing guest observably takes all of it through - // the same method, so a profile that fenced nothing โ€” or an attach path that stopped installing - // anything at all โ€” cannot pass these by being uniformly empty. - it('gives a browsing guest link routing, popups and anti-detection', () => { - const guest = createGuest(300, 'https://example.com/') - - browserManager.attachGuestPolicies(guest as never) - - expect(listenerCount(guest, 'dom-ready')).toBe(1) - expect(listenerCount(guest, 'frame-created')).toBe(1) - expect(listenerCount(guest, 'did-create-window')).toBe(1) - expect(guest.debugger.sendCommand).toHaveBeenCalled() - expect(navigateTo(guest, 'https://elsewhere.example/')).toBe(false) - }) - - it('gives a workspace-document guest none of it', () => { - const { guest } = attachPreviewGuest() - - expect(listenerCount(guest, 'dom-ready')).toBe(0) - expect(listenerCount(guest, 'frame-created')).toBe(0) - expect(listenerCount(guest, 'did-create-window')).toBe(0) - expect(guest.debugger.sendCommand).not.toHaveBeenCalled() - expect(guest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled() - }) - - it('holds a workspace-document guest inside the grant it is showing', () => { - const { guest, grantId } = attachPreviewGuest() - - expect(navigateTo(guest, buildDocPreviewUrl(grantId, 'other.html'))).toBe(false) - expect(navigateTo(guest, 'https://example.com/')).toBe(true) - expect(guest.webRtcPolicy).toBe('disable_non_proxied_udp') - }) - - it('denies every window a workspace-document guest asks for', () => { - const { guest } = attachPreviewGuest() - - expect(guest.windowOpenHandler?.({ url: 'https://example.com/', frameName: '' })).toEqual({ - action: 'deny' - }) - }) - - // Why teardown and not just install: registration and teardown both key on the guest having been - // policy-attached, so a profile that installs outside that bookkeeping leaves the id marked - // attached forever and stays answerable to tools after the surface is gone. - it('tears a workspace-document guest down through the same policy cleanup', () => { - const { guest, browserPageId } = attachPreviewGuest(302) - expect(getWorkspaceDocPageGuest(browserPageId, host.id)).toBe(guest as never) - - for (const listener of guest.listeners.get('destroyed') ?? []) { - listener() - } - - expect(getWorkspaceDocPageGuest(browserPageId, host.id)).toBeNull() - // The manager's own bookkeeping was reached too: a second attach is refused while the id is - // still marked policy-attached, and accepted once its teardown has run. - browserManager.attachGuestPolicies(guest as never) - expect(listenerCount(guest, 'dom-ready')).toBe(1) - }) - - // The seam the whole split rests on: one public door answers for both halves, and each page id - // resolves in exactly one of them. Asserted against the real manager, because the IPC census - // test has to mock it. - describe('the one door across both halves of the registry', () => { - const BROWSING_PAGE_ID = 'browser-page-1' - - function registerBrowsingGuest(id = 400): GuestFake { - const guest = createGuest(id, 'https://example.com/') - browserMocks.webContentsFromIdMock.mockReturnValue(guest) - browserManager.attachGuestPolicies(guest as never) - browserManager.registerGuest({ - browserPageId: BROWSING_PAGE_ID, - workspaceId: 'workspace-1', - worktreeId: 'wt-1', - webContentsId: id, - rendererWebContentsId: host.id - }) - return guest - } - - it('answers each page with the guest of its own half', () => { - const browsing = registerBrowsingGuest() - const { guest: document, browserPageId } = attachPreviewGuest() - - expect(browserManager.getAuthorizedGuest(BROWSING_PAGE_ID, host.id)).toBe(browsing as never) - expect(browserManager.getAuthorizedGuest(browserPageId, host.id)).toBe(document as never) - }) - - // Why this is the containment claim and not a lookup detail: page management, agent commands, - // download routing and certificate attribution all read the browsing map directly, so a - // document page being absent from it is what fences them without a guard of their own. - it('keeps a document page out of the browsing map entirely', () => { - registerBrowsingGuest() - const { browserPageId } = attachPreviewGuest() - - expect(browserManager.getGuestWebContentsId(browserPageId)).toBeNull() - expect([...browserManager.getWebContentsIdByTabId().keys()]).toEqual([BROWSING_PAGE_ID]) - }) - }) - - it('attaches a guest once whatever profile it was asked for', () => { - const { guest } = attachPreviewGuest(303) - - browserManager.attachGuestPolicies(guest as never) - - expect(listenerCount(guest, 'dom-ready')).toBe(0) - expect(listenerCount(guest, 'will-navigate')).toBe(1) - }) -}) diff --git a/src/main/browser/browser-manager-viewport-override.test.ts b/src/main/browser/browser-manager-viewport-override.test.ts index b7d3bbabe0a..c72936cf6a3 100644 --- a/src/main/browser/browser-manager-viewport-override.test.ts +++ b/src/main/browser/browser-manager-viewport-override.test.ts @@ -882,5 +882,34 @@ describe('browserManager', () => { expect(debuggerAttach).toHaveBeenCalledWith('1.3') expect(debuggerSendCommand).not.toHaveBeenCalled() }) + + it('installs annotation viewport bridge in an isolated world', async () => { + const { guest } = makeGuest(4646) + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'tab-annotations', + webContentsId: guest.id as number, + rendererWebContentsId + }) + + const ok = await browserManager.setAnnotationViewportBridge('tab-annotations', { + emitViewport: false, + enabled: true, + markers: [], + token: 'annotationviewporttoken' + }) + + expect(ok).toBe(true) + expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledWith( + expect.any(Number), + [ + expect.objectContaining({ + code: expect.stringContaining('__orcaBrowserAnnotationViewportBridge') + }) + ], + false + ) + }) }) }) diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index 2b43e17a371..7ac64b485b9 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -63,11 +63,6 @@ import { BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID, buildBrowserAnnotationViewportBridgeScript } from '../../shared/browser-annotation-viewport-bridge' -import { - getWorkspaceDocPageGuest, - installDocPreviewGuestPolicy, - isWorkspaceDocPageId -} from './doc-preview-guest-policy' import type { KeybindingOverrides } from '../../shared/keybindings' import { BrowserCertificateTrustController, @@ -162,15 +157,6 @@ type PopupOwnerContext = { browserTabId: string rootGuestWebContentsId: number } -/** - * What a guest is allowed to be. A browsing guest is the web โ€” popups, clicked-link routing and - * anti-detection all apply. A workspace-document guest renders one granted document and gets none - * of that; `host` is the renderer that minted its grant, and the only sink for what it reports. - */ -export type BrowserGuestPolicy = - | { profile: 'browsing' } - | { profile: 'workspace-doc'; host: Electron.WebContents } -const BROWSING_GUEST_POLICY: BrowserGuestPolicy = { profile: 'browsing' } type PendingMainFrameNavigation = { currentUrl: string supersededUrls: string[] @@ -685,20 +671,12 @@ export class BrowserManager { attachGuestPolicies( guest: Electron.WebContents, - inheritedOwnerContext: PopupOwnerContext | null = null, - policy: BrowserGuestPolicy = BROWSING_GUEST_POLICY + inheritedOwnerContext: PopupOwnerContext | null = null ): void { if (this.policyAttachedGuestIds.has(guest.id)) { return } this.policyAttachedGuestIds.add(guest.id) - // Why one door with a profile rather than a second installer beside it: whether a guest was - // policy-attached at all is what registration and teardown both key on, so a guest that took - // another path into the app is invisible to both. - if (policy.profile === 'workspace-doc') { - this.attachWorkspaceDocGuestPolicies(guest, policy.host) - return - } if (inheritedOwnerContext) { this.popupOwnerContextByGuestId.set(guest.id, inheritedOwnerContext) } @@ -1038,30 +1016,6 @@ export class BrowserManager { }) } - /** - * A workspace document is not the web: no popups, no link routing, no anti-detection, and no - * navigation bookkeeping for chrome it does not have. What it does share with a browsing guest is - * this method's teardown, so a retired preview drops its listeners on the same path. - */ - private attachWorkspaceDocGuestPolicies( - guest: Electron.WebContents, - host: Electron.WebContents - ): void { - const disposeDocPolicy = installDocPreviewGuestPolicy(guest, host) - const handleDestroyed = (): void => { - this.cleanupGuestPolicyAttachment(guest.id) - } - guest.on('destroyed', handleDestroyed) - this.policyCleanupByGuestId.set(guest.id, () => { - disposeDocPolicy() - try { - guest.off('destroyed', handleDestroyed) - } catch { - // guest may already be destroyed - } - }) - } - // Why: navigator.userAgent (read by Google's auth JS) reflects the WebContents UA, // not the request header, so the header-level Firefox switch in setupClientHintsOverride // must be matched here per navigation or the two layers disagree โ€” itself a bot tell. @@ -1364,9 +1318,7 @@ export class BrowserManager { rendererWebContentsId }: BrowserGuestRegistration): boolean { const browserTabId = browserPageId ?? legacyBrowserTabId - // Why refuse rather than overwrite: the two halves of the registry must stay disjoint, or one - // id resolves in both and the tool door silently prefers the document guest over the page. - if (!browserTabId || isWorkspaceDocPageId(browserTabId)) { + if (!browserTabId) { return false } // Why: on guest-surface swap, cancel any grab bound to the old guest's listeners so it doesn't strand on a stale webContents. @@ -1425,12 +1377,6 @@ export class BrowserManager { } unregisterGuest(browserTabId: string): void { - // Why the check on the exit door too: a document page withdraws by revoking its grant, never - // through here, so its id arriving is misaddressed โ€” and the cancel below would evict that - // preview's live grab on the strength of it. - if (isWorkspaceDocPageId(browserTabId)) { - return - } // Why: teardown mid-grab must cancel it so the renderer gets a signal, not a dangling Promise. this.cancelGrabOp(browserTabId, 'evicted') @@ -1498,15 +1444,10 @@ export class BrowserManager { sessionProfileId?: string | null userAgentMode?: BrowserSessionUserAgentMode webContentsId: number - }): boolean { - // Why the same check on both registration doors: one id resolving in both halves is the exact - // confusion the split registries exist to prevent. - if (isWorkspaceDocPageId(browserPageId)) { - return false - } + }): void { const guest = webContents.fromId(webContentsId) if (!guest || guest.isDestroyed()) { - return false + return } // Why: offscreen pages have no renderer webview listeners, so main owns their load-failure lifecycle. this.offscreenGuestIds.add(webContentsId) @@ -1527,7 +1468,6 @@ export class BrowserManager { this.worktreeIdByTabId.set(browserPageId, worktreeId) } this.certificateTrustController?.onGuestRegistered(webContentsId, browserPageId) - return true } unregisterAll(): void { @@ -1903,13 +1843,12 @@ export class BrowserManager { async setAnnotationViewportBridge( browserTabId: string, - options: BrowserAnnotationViewportBridgeOptions, - resolveGuest: () => Electron.WebContents | null + options: BrowserAnnotationViewportBridgeOptions ): Promise { const prev = this.annotationViewportBridgeOpsByTabId.get(browserTabId) ?? Promise.resolve() const next = prev .catch(() => {}) - .then(() => this.doSetAnnotationViewportBridgeImpl(options, resolveGuest)) + .then(() => this.doSetAnnotationViewportBridgeImpl(browserTabId, options)) this.annotationViewportBridgeOpsByTabId.set(browserTabId, next) try { return await next @@ -1920,22 +1859,18 @@ export class BrowserManager { } } - // Why the caller resolves the guest: the same bridge serves browsing pages and workspace - // documents, which live in different halves of the page registry. - // Why a resolver and not the guest itself: this op may have waited behind another one, and a - // cross-process navigation meanwhile swaps the tab's contents without destroying the old one โ€” - // injecting into the guest the request named would bridge a page nobody is looking at. - // Why no tab id: with teardown gone this reaches only the guest the resolver hands back, and - // taking an id it cannot act on would invite the next reader to act on it. private async doSetAnnotationViewportBridgeImpl( - options: BrowserAnnotationViewportBridgeOptions, - resolveGuest: () => Electron.WebContents | null + browserTabId: string, + options: BrowserAnnotationViewportBridgeOptions ): Promise { - // Why no teardown here: the resolver already unregisters a page whose guest died, and the only - // case it uniquely leaves is an ownership mismatch on a healthy page โ€” where tearing down would - // cancel that page's in-flight downloads and grabs over a request that was merely misaddressed. - const guest = resolveGuest() + const webContentsId = this.webContentsIdByTabId.get(browserTabId) + if (!webContentsId) { + return false + } + const guest = webContents.fromId(webContentsId) if (!guest || guest.isDestroyed()) { + // Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps. + this.unregisterGuest(browserTabId) return false } @@ -2042,21 +1977,10 @@ export class BrowserManager { // --- Browser Context Grab โ€” main-owned operations --- /** Validate that the sender owns browserTabId; returns the guest WebContents or null. */ - /** - * The guest a request from `senderWebContentsId` may act on, across both halves of the page - * registry. This is the only door taught about workspace-document guests: they are kept out of - * the browsing maps entirely, so page management, agent commands, download routing and - * certificate attribution all miss them without a guard of their own โ€” and a reader who opens a - * tool on the document in front of them still gets an answer. - */ getAuthorizedGuest( browserTabId: string, senderWebContentsId: number ): Electron.WebContents | null { - const docGuest = getWorkspaceDocPageGuest(browserTabId, senderWebContentsId) - if (docGuest) { - return docGuest - } const registeredRenderer = this.rendererWebContentsIdByTabId.get(browserTabId) if (registeredRenderer == null || registeredRenderer !== senderWebContentsId) { return null diff --git a/src/main/browser/browser-session-partition-policies.test.ts b/src/main/browser/browser-session-partition-policies.test.ts deleted file mode 100644 index a3667154c9b..00000000000 --- a/src/main/browser/browser-session-partition-policies.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { BrowserSessionProfile } from '../../shared/browser-workspace-types' - -const mocks = vi.hoisted(() => ({ - handleGuestWillDownload: vi.fn(), - noticeDocPreviewDownloadBlocked: vi.fn() -})) - -type WillDownloadListener = ( - event: { preventDefault: () => void }, - item: { id: string }, - webContents: { id: number } -) => void - -type FakeSession = { - listeners: WillDownloadListener[] - on: ReturnType - removeListener: ReturnType - getUserAgent: () => string - setUserAgent: ReturnType - setPermissionRequestHandler: ReturnType - setPermissionCheckHandler: ReturnType - setDisplayMediaRequestHandler: ReturnType -} - -const sessionsByPartition = new Map() - -function fakeSession(): FakeSession { - const listeners: WillDownloadListener[] = [] - return { - listeners, - on: vi.fn((event: string, listener: WillDownloadListener) => { - if (event === 'will-download') { - listeners.push(listener) - } - }), - removeListener: vi.fn((event: string, listener: WillDownloadListener) => { - if (event !== 'will-download') { - return - } - const index = listeners.indexOf(listener) - if (index !== -1) { - listeners.splice(index, 1) - } - }), - getUserAgent: () => 'Mozilla/5.0 Orca', - setUserAgent: vi.fn(), - setPermissionRequestHandler: vi.fn(), - setPermissionCheckHandler: vi.fn(), - setDisplayMediaRequestHandler: vi.fn() - } -} - -vi.mock('electron', () => ({ - session: { - fromPartition: (partition: string) => { - const existing = sessionsByPartition.get(partition) - if (existing) { - return existing - } - const created = fakeSession() - sessionsByPartition.set(partition, created) - return created - } - } -})) -vi.mock('./browser-manager', () => ({ - browserManager: { - handleGuestWillDownload: mocks.handleGuestWillDownload, - installCertificateRequestGuard: vi.fn(), - removeCertificateRequestGuard: vi.fn(), - notifyPermissionDenied: vi.fn() - } -})) -vi.mock('./doc-preview-download-block-notice', () => ({ - noticeDocPreviewDownloadBlocked: mocks.noticeDocPreviewDownloadBlocked -})) -vi.mock('./browser-media-access', () => ({ - hasSystemMediaAccess: () => false, - requestSystemMediaAccess: async () => false -})) -vi.mock('./browser-session-ua', () => ({ - cleanElectronUserAgent: (userAgent: string) => userAgent, - setupClientHintsOverride: vi.fn() -})) -vi.mock('./browser-session-user-agent-mode', () => ({ - setBrowserSessionUserAgentMode: vi.fn() -})) -vi.mock('./browser-webauthn-access', () => ({ - allowsBrowserWebAuthnPermission: () => false, - clearBrowserWebAuthnAccessHandlers: vi.fn(), - installBrowserWebAuthnAccessHandlers: vi.fn() -})) - -type PartitionPolicyInstaller = ( - profile: BrowserSessionProfile, - options?: { downloads?: 'route' | 'deny' } -) => void - -// Why imported per test rather than at the top: the installer remembers which partitions it has -// already configured in module state, so a shared import would make the second test's install a -// no-op and leave it reading the first test's listener. -async function loadInstaller(): Promise { - const module = await import('./browser-session-partition-policies') - return module.installBrowserSessionPartitionPolicies -} - -function profileFor(partition: string): BrowserSessionProfile { - return { - id: partition, - scope: 'isolated', - partition, - label: partition, - source: null, - userAgentMode: 'clean' - } -} - -/** Fires the partition's real `will-download` listener and reports what it decided. */ -function fireWillDownload(partition: string): { cancelled: boolean } { - const sess = sessionsByPartition.get(partition) - if (!sess || sess.listeners.length !== 1) { - throw new Error(`expected exactly one will-download listener on ${partition}`) - } - let cancelled = false - sess.listeners[0]({ preventDefault: () => (cancelled = true) }, { id: 'item-1' }, { id: 42 }) - return { cancelled } -} - -beforeEach(() => { - vi.clearAllMocks() - sessionsByPartition.clear() - vi.resetModules() -}) - -describe('partition download policy', () => { - // The presence half: without it, a deny assertion passes for a partition that installed no - // listener at all, and would keep passing if the whole download path were removed. - it('routes a download on a partition that did not ask for the deny', async () => { - const install = await loadInstaller() - install(profileFor('persist:browsing-1')) - - expect(fireWillDownload('persist:browsing-1').cancelled).toBe(false) - expect(mocks.handleGuestWillDownload).toHaveBeenCalledWith( - expect.objectContaining({ guestWebContentsId: 42 }) - ) - }) - - it('cancels a download on a partition that asked for the deny, routing nothing', async () => { - const install = await loadInstaller() - install(profileFor('orca-doc-preview'), { downloads: 'deny' }) - - expect(fireWillDownload('orca-doc-preview').cancelled).toBe(true) - expect(mocks.handleGuestWillDownload).not.toHaveBeenCalled() - }) - - // Why in the same run as the routing test above: a refusal the reader cannot see is a pressed - // button that does nothing, and a notice on the routing partition would announce a download that - // is about to arrive normally. - it('tells the reader about the refusal, and only on the partition that refused', async () => { - const install = await loadInstaller() - install(profileFor('orca-doc-preview'), { downloads: 'deny' }) - install(profileFor('persist:browsing-1')) - - fireWillDownload('persist:browsing-1') - expect(mocks.noticeDocPreviewDownloadBlocked).not.toHaveBeenCalled() - - fireWillDownload('orca-doc-preview') - expect(mocks.noticeDocPreviewDownloadBlocked).toHaveBeenCalledWith( - expect.objectContaining({ id: 42 }) - ) - }) - - // Why both partitions in one run: the listener is module state shared across sessions, so a deny - // installed for one partition must not follow the next partition that installs after it. - it('keeps each partition on its own decision', async () => { - const install = await loadInstaller() - install(profileFor('orca-doc-preview'), { downloads: 'deny' }) - install(profileFor('persist:browsing-1')) - - expect(fireWillDownload('orca-doc-preview').cancelled).toBe(true) - expect(fireWillDownload('persist:browsing-1').cancelled).toBe(false) - expect(mocks.handleGuestWillDownload).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/main/browser/browser-session-partition-policies.ts b/src/main/browser/browser-session-partition-policies.ts index cf542665ae4..2118c62ed68 100644 --- a/src/main/browser/browser-session-partition-policies.ts +++ b/src/main/browser/browser-session-partition-policies.ts @@ -11,7 +11,6 @@ import { clearBrowserWebAuthnAccessHandlers, installBrowserWebAuthnAccessHandlers } from './browser-webauthn-access' -import { noticeDocPreviewDownloadBlocked } from './doc-preview-download-block-notice' // Why: one shared installer keeps every partition's deny-by-default permission/download policies from drifting apart. const configuredPartitions = new Set() @@ -23,23 +22,6 @@ const handleWillDownload = ( browserManager.handleGuestWillDownload({ guestWebContentsId: webContents.id, item }) } -/** - * Why a second listener instead of a branch inside the shared one: `will-download` is a session - * event that names no partition, so the only place the decision can be keyed by partition is which - * listener that partition's session got. A workspace-document guest has no page of its own to - * attribute a download to, so routing one lands it in this desktop's Downloads folder under a - * remote-authored name that nothing in the UI accounts for. - */ -const handleDeniedWillDownload = ( - event: Electron.Event, - _item: Electron.DownloadItem, - webContents: Electron.WebContents -): void => { - event.preventDefault() - // The page gets nothing back; the reader gets a sentence, or a pressed button just does nothing. - noticeDocPreviewDownloadBlocked(webContents) -} - function resolvePermissionNoticeUrl( webContents: Electron.WebContents, details: Electron.PermissionRequest | undefined @@ -55,13 +37,7 @@ function resolvePermissionNoticeUrl( } } -/** `route` hands the item to the owning page's download flow; `deny` cancels it before it starts. */ -export type BrowserPartitionDownloadPolicy = 'route' | 'deny' - -export function installBrowserSessionPartitionPolicies( - profile: BrowserSessionProfile, - options?: { downloads?: BrowserPartitionDownloadPolicy } -): void { +export function installBrowserSessionPartitionPolicies(profile: BrowserSessionProfile): void { const { partition } = profile const sess = session.fromPartition(partition) setBrowserSessionUserAgentMode(sess, profile.userAgentMode ?? 'clean') @@ -130,11 +106,7 @@ export function installBrowserSessionPartitionPolicies( callback({ video: undefined, audio: undefined }) }) sess.removeListener('will-download', handleWillDownload) - sess.removeListener('will-download', handleDeniedWillDownload) - sess.on( - 'will-download', - options?.downloads === 'deny' ? handleDeniedWillDownload : handleWillDownload - ) + sess.on('will-download', handleWillDownload) configuredPartitions.add(partition) } @@ -143,7 +115,6 @@ export function clearBrowserSessionPartitionPolicies(partition: string, sess: Se configuredPartitions.delete(partition) browserManager.removeCertificateRequestGuard(sess) sess.removeListener('will-download', handleWillDownload) - sess.removeListener('will-download', handleDeniedWillDownload) clearBrowserWebAuthnAccessHandlers(sess) sess.setPermissionRequestHandler(null) sess.setPermissionCheckHandler(null) diff --git a/src/main/browser/doc-preview-download-block-notice.test.ts b/src/main/browser/doc-preview-download-block-notice.test.ts deleted file mode 100644 index 9fc4396b329..00000000000 --- a/src/main/browser/doc-preview-download-block-notice.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - publishDocPreviewFailure: vi.fn(), - boundGrantIdByGuest: new Map() -})) - -vi.mock('./doc-preview-failure-notice', () => ({ - publishDocPreviewFailure: mocks.publishDocPreviewFailure -})) -vi.mock('./doc-preview-guest-policy', () => ({ - readDocPreviewGuestBoundGrantId: (guest: object) => mocks.boundGrantIdByGuest.get(guest) ?? null -})) - -const GRANT_ID = 'a'.repeat(32) -const OTHER_GRANT_ID = 'b'.repeat(32) - -/** Only the identity matters: the module asks the guest registry what grant this contents holds. */ -function guestBoundTo(grantId: string | null): Electron.WebContents { - const guest = {} as Electron.WebContents - if (grantId !== null) { - mocks.boundGrantIdByGuest.set(guest, grantId) - } - return guest -} - -async function loadNotifier(): Promise<(guest: Electron.WebContents) => void> { - const module = await import('./doc-preview-download-block-notice') - return module.noticeDocPreviewDownloadBlocked -} - -beforeEach(() => { - vi.clearAllMocks() - mocks.boundGrantIdByGuest.clear() - // Why per test: the module remembers which grants it has already told the reader about. - vi.resetModules() - vi.useFakeTimers() - vi.setSystemTime(new Date('2026-08-27T00:00:00.000Z')) -}) - -afterEach(() => { - vi.useRealTimers() -}) - -describe('noticeDocPreviewDownloadBlocked', () => { - it('tells the shell which preview had a download refused', async () => { - const notice = await loadNotifier() - - notice(guestBoundTo(GRANT_ID)) - - expect(mocks.publishDocPreviewFailure).toHaveBeenCalledWith({ - grantId: GRANT_ID, - reason: 'download-blocked' - }) - }) - - // Why: a document can ask in a loop. The reader learns nothing from the second notice, and every - // attempt would otherwise cross the IPC boundary and re-render the strip. - it('says it once however often the document asks', async () => { - const notice = await loadNotifier() - const guest = guestBoundTo(GRANT_ID) - - notice(guest) - notice(guest) - vi.advanceTimersByTime(1_500) - notice(guest) - - expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(1) - }) - - it('says it again for an attempt long after the last one', async () => { - const notice = await loadNotifier() - const guest = guestBoundTo(GRANT_ID) - - notice(guest) - vi.advanceTimersByTime(2_500) - notice(guest) - - expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(2) - }) - - // Why not one throttle for the whole app: two previews are two readers, and silencing the second - // because the first just refused something leaves that press unexplained. - it('throttles each preview on its own', async () => { - const notice = await loadNotifier() - - notice(guestBoundTo(GRANT_ID)) - notice(guestBoundTo(OTHER_GRANT_ID)) - - expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(2) - expect(mocks.publishDocPreviewFailure).toHaveBeenLastCalledWith({ - grantId: OTHER_GRANT_ID, - reason: 'download-blocked' - }) - }) - - // The absence half of the first test: no shell is showing this contents, so there is no preview - // to put a notice on. Without the presence tests above, this would pass on a module that never - // published anything at all. - it('says nothing for a contents no preview is bound to', async () => { - const notice = await loadNotifier() - - notice(guestBoundTo(null)) - - expect(mocks.publishDocPreviewFailure).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/browser/doc-preview-download-block-notice.ts b/src/main/browser/doc-preview-download-block-notice.ts deleted file mode 100644 index 2aefb2824a7..00000000000 --- a/src/main/browser/doc-preview-download-block-notice.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { publishDocPreviewFailure } from './doc-preview-failure-notice' -import { readDocPreviewGuestBoundGrantId } from './doc-preview-guest-policy' - -/** - * Why the reader is told at all, when the point of the fence is that nothing happens: a press that - * produces no file and no explanation reads as Orca being broken. The notice is chrome the document - * cannot see or read back, so the refusal stays as silent to the page as it was. - * - * Why a floor between notices: a document can ask in a loop, and every attempt would otherwise - * cross the IPC boundary and re-render the strip. The reader learns nothing from the thousandth. - */ -const NOTICE_MIN_INTERVAL_MS = 2_000 -const noticedAtByGrantId = new Map() - -export function noticeDocPreviewDownloadBlocked(guest: Electron.WebContents): void { - const grantId = readDocPreviewGuestBoundGrantId(guest) - // Nothing to route a notice to: no shell is showing this contents as a preview. - if (grantId === null) { - return - } - const now = Date.now() - // Doubles as the expiry check, so what survives is exactly "noticed inside the window", and a - // grant that stops downloading stops being remembered. - for (const [noticedGrantId, noticedAt] of noticedAtByGrantId) { - if (now - noticedAt >= NOTICE_MIN_INTERVAL_MS) { - noticedAtByGrantId.delete(noticedGrantId) - } - } - if (noticedAtByGrantId.has(grantId)) { - return - } - noticedAtByGrantId.set(grantId, now) - publishDocPreviewFailure({ grantId, reason: 'download-blocked' }) -} diff --git a/src/main/browser/doc-preview-failure-notice.test.ts b/src/main/browser/doc-preview-failure-notice.test.ts deleted file mode 100644 index 2272e7bf614..00000000000 --- a/src/main/browser/doc-preview-failure-notice.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' - -import { DOC_PREVIEW_LOAD_FAILURE_CHANNEL } from '../../shared/doc-preview-scheme' -import { publishDocPreviewFailure, setDocPreviewFailureSink } from './doc-preview-failure-notice' - -afterEach(() => { - setDocPreviewFailureSink(null) -}) - -describe('publishDocPreviewFailure', () => { - it('sends the grant, path, and reason on the failure channel', () => { - const send = vi.fn() - setDocPreviewFailureSink({ send }) - - publishDocPreviewFailure({ - grantId: 'a'.repeat(32), - relativePath: 'index.html', - reason: 'too-large' - }) - - expect(send).toHaveBeenCalledWith(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, { - grantId: 'a'.repeat(32), - relativePath: 'index.html', - reason: 'too-large' - }) - }) - - // Why: reads can outlive the window that asked for them; a missing sink must not throw inside - // the protocol handler. - it('is a no-op with no sink registered', () => { - expect(() => - publishDocPreviewFailure({ - grantId: 'b'.repeat(32), - relativePath: 'index.html', - reason: 'unreadable' - }) - ).not.toThrow() - }) - - it('sends nothing into a destroyed window', () => { - const send = vi.fn() - setDocPreviewFailureSink({ send, isDestroyed: () => true }) - - publishDocPreviewFailure({ - grantId: 'c'.repeat(32), - relativePath: 'index.html', - reason: 'unreadable' - }) - - expect(send).not.toHaveBeenCalled() - }) - - // Why: a WebContents can be torn down between the liveness check and the send. - it('swallows a throwing sink and stops using it', () => { - const send = vi.fn(() => { - throw new Error('Object has been destroyed') - }) - setDocPreviewFailureSink({ send }) - const failure = { - grantId: 'd'.repeat(32), - relativePath: 'index.html', - reason: 'unreadable' as const - } - - expect(() => publishDocPreviewFailure(failure)).not.toThrow() - publishDocPreviewFailure(failure) - - expect(send).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/main/browser/doc-preview-failure-notice.ts b/src/main/browser/doc-preview-failure-notice.ts deleted file mode 100644 index 1b7330d6f27..00000000000 --- a/src/main/browser/doc-preview-failure-notice.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { - DOC_PREVIEW_LOAD_FAILURE_CHANNEL, - type DocPreviewFailure -} from '../../shared/doc-preview-scheme' - -type DocPreviewFailureSink = { - send: (channel: string, payload: DocPreviewFailure) => void - isDestroyed?: () => boolean -} - -let failureSink: DocPreviewFailureSink | null = null - -export function setDocPreviewFailureSink(sink: DocPreviewFailureSink | null): void { - failureSink = sink -} - -/** - * The preview shell cannot read the guest's HTTP status, and a 4xx body renders as - * if it were the document. Pushing the reason lets the shell replace that with a - * localized notice for the failure the user actually hit. - */ -export function publishDocPreviewFailure(failure: DocPreviewFailure): void { - const sink = failureSink - // Why: a read can outlive the window that asked for it, and sending into torn-down - // WebContents throws โ€” an unreadable asset must not take the protocol handler with it. - if (!sink || sink.isDestroyed?.()) { - return - } - try { - sink.send(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, failure) - } catch { - failureSink = null - } -} diff --git a/src/main/browser/doc-preview-file-reader.test.ts b/src/main/browser/doc-preview-file-reader.test.ts deleted file mode 100644 index fc707279f8f..00000000000 --- a/src/main/browser/doc-preview-file-reader.test.ts +++ /dev/null @@ -1,333 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - callRuntimeEnvironment: vi.fn(), - readFile: vi.fn(), - realpath: vi.fn(), - requireSshFilesystemProvider: vi.fn() -})) - -vi.mock('../ipc/runtime-environment-transport-routing', () => ({ - callRuntimeEnvironment: mocks.callRuntimeEnvironment -})) -vi.mock('../persistence', () => ({ getCanonicalUserDataPath: () => '/user-data' })) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - requireSshFilesystemProvider: mocks.requireSshFilesystemProvider -})) - -import { FileReadCapExceededError } from '../ssh/ssh-filesystem-stream-reader' -import { docPreviewContentType, readDocPreviewFile } from './doc-preview-file-reader' -import { mintDocPreviewGrant, revokeAllDocPreviewGrants } from './doc-preview-grant-registry' - -function sshGrant(): ReturnType { - return mintDocPreviewGrant({ - owner: { kind: 'ssh', connectionId: 'ssh-1' }, - root: '/home/alice/docs', - entryRelativePath: 'index.html', - browserPageId: 'page-1' - }) -} - -function runtimeGrant(root = '/srv/repo/docs'): ReturnType { - return mintDocPreviewGrant({ - owner: { - kind: 'runtime', - environmentId: 'env-1', - worktreeSelector: 'id:wt-1', - worktreeRoot: '/srv/repo' - }, - root, - entryRelativePath: 'index.html', - browserPageId: 'page-1' - }) -} - -beforeEach(() => { - vi.clearAllMocks() - revokeAllDocPreviewGrants() - // Why: an unsymlinked host canonicalizes to the path it was given. - mocks.realpath.mockImplementation((path: string) => Promise.resolve(path)) - mocks.requireSshFilesystemProvider.mockReturnValue({ - readFile: mocks.readFile, - realpath: mocks.realpath - }) -}) - -describe('docPreviewContentType', () => { - it('maps document and asset extensions, defaulting to octet-stream', () => { - expect(docPreviewContentType('index.html')).toBe('text/html; charset=utf-8') - expect(docPreviewContentType('assets/app.CSS')).toBe('text/css; charset=utf-8') - expect(docPreviewContentType('assets/logo.png')).toBe('image/png') - expect(docPreviewContentType('data.bin')).toBe('application/octet-stream') - }) -}) - -describe('readDocPreviewFile โ€” ssh owner', () => { - it('reads text through the SSH filesystem provider', async () => { - mocks.readFile.mockResolvedValue({ content: '

hi

', isBinary: false }) - - const outcome = await readDocPreviewFile(sshGrant(), 'index.html') - - expect(mocks.requireSshFilesystemProvider).toHaveBeenCalledWith('ssh-1') - expect(mocks.readFile).toHaveBeenCalledWith('/home/alice/docs/index.html') - expect(outcome).toEqual({ - ok: true, - bytes: Buffer.from('

hi

', 'utf8'), - contentType: 'text/html; charset=utf-8' - }) - }) - - it('decodes a base64 binary asset', async () => { - const png = Buffer.from([0x89, 0x50, 0x4e, 0x47]) - mocks.readFile.mockResolvedValue({ content: png.toString('base64'), isBinary: true }) - - const outcome = await readDocPreviewFile(sshGrant(), 'assets/logo.png') - - expect(outcome).toEqual({ ok: true, bytes: png, contentType: 'image/png' }) - }) - - // Why: the SSH reader rejects an over-cap file rather than clamping it, so a completed read is - // always whole and needs no truncation flag. - it('serves a whole SSH read that carries no truncation flag', async () => { - mocks.readFile.mockResolvedValue({ content: '

whole

', isBinary: false }) - - expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({ ok: true }) - }) - - // Why: the SSH read path only serves images and PDFs as bytes, so a font is refused there by - // design โ€” the failure must name the file type, not a stale server. - it('reports a file type the host will not send as unsupported-asset', async () => { - mocks.readFile.mockResolvedValue({ content: '', isBinary: true }) - - const outcome = await readDocPreviewFile(sshGrant(), 'assets/font.woff2') - - expect(outcome).toMatchObject({ ok: false, status: 415, reason: 'unsupported-asset' }) - }) - - // Why: a host that still named the type read a 0-byte file, so 0 bytes is the honest answer โ€” - // reporting it as a refused format would be a failure the workspace never reported. - it('serves an empty file the host still typed instead of calling it unsupported', async () => { - mocks.readFile.mockResolvedValue({ content: '', isBinary: true, mimeType: 'image/png' }) - - const outcome = await readDocPreviewFile(sshGrant(), 'assets/logo.png') - - expect(outcome).toEqual({ ok: true, bytes: Buffer.alloc(0), contentType: 'image/png' }) - }) - - // Why: containment above is lexical, and the SSH read RPC enforces no root of its own, so a - // symlink inside the grant would otherwise read anything the account can reach. - it('404s a path that canonicalizes outside the grant root', async () => { - mocks.realpath.mockImplementation((path: string) => - Promise.resolve(path === '/home/alice/docs/escape.html' ? '/etc/shadow' : path) - ) - - const outcome = await readDocPreviewFile(sshGrant(), 'escape.html') - - expect(outcome).toMatchObject({ ok: false, status: 404 }) - expect(mocks.readFile).not.toHaveBeenCalled() - }) - - it('reads the canonical path once containment holds', async () => { - mocks.realpath.mockImplementation((path: string) => - Promise.resolve(path === '/home/alice/docs/link.html' ? '/home/alice/docs/real.html' : path) - ) - mocks.readFile.mockResolvedValue({ content: '

real

', isBinary: false }) - - expect(await readDocPreviewFile(sshGrant(), 'link.html')).toMatchObject({ ok: true }) - expect(mocks.readFile).toHaveBeenCalledWith('/home/alice/docs/real.html') - }) - - // Why: a symlinked root is legitimate; containment must be judged on what both sides resolve to. - it('keeps serving a grant whose own root is a symlink', async () => { - mocks.realpath.mockImplementation((path: string) => - Promise.resolve(path.replace('/home/alice/docs', '/mnt/data/docs')) - ) - mocks.readFile.mockResolvedValue({ content: '

hi

', isBinary: false }) - - expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({ ok: true }) - expect(mocks.readFile).toHaveBeenCalledWith('/mnt/data/docs/index.html') - }) - - it('404s when the host cannot canonicalize the path at all', async () => { - mocks.realpath.mockRejectedValue(new Error('no such file')) - - expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({ - ok: false, - status: 404 - }) - expect(mocks.readFile).not.toHaveBeenCalled() - }) - - it('404s a path outside the grant root without touching the provider', async () => { - const outcome = await readDocPreviewFile(sshGrant(), '../../etc/passwd') - - expect(outcome).toMatchObject({ ok: false, status: 404 }) - expect(mocks.requireSshFilesystemProvider).not.toHaveBeenCalled() - }) - - it('reports an over-cap SSH file as too large rather than unreadable', async () => { - mocks.readFile.mockRejectedValue(new FileReadCapExceededError('exceeds client cap')) - - expect(await readDocPreviewFile(sshGrant(), 'huge.html')).toMatchObject({ - ok: false, - status: 413 - }) - }) - - it('404s when the provider read fails', async () => { - mocks.readFile.mockRejectedValue(new Error('no such file')) - - expect(await readDocPreviewFile(sshGrant(), 'missing.html')).toMatchObject({ - ok: false, - status: 404 - }) - }) -}) - -describe('readDocPreviewFile โ€” paired runtime owner', () => { - it('reads text over worktree-relative files.read', async () => { - mocks.callRuntimeEnvironment.mockResolvedValue({ - ok: true, - result: { content: '

remote

', truncated: false, byteLength: 15 } - }) - - const outcome = await readDocPreviewFile(runtimeGrant(), 'index.html') - - expect(mocks.callRuntimeEnvironment).toHaveBeenCalledWith( - '/user-data', - 'env-1', - 'files.read', - { worktree: 'id:wt-1', relativePath: 'docs/index.html' }, - 15_000 - ) - expect(outcome).toEqual({ - ok: true, - bytes: Buffer.from('

remote

', 'utf8'), - contentType: 'text/html; charset=utf-8' - }) - }) - - it('falls back to the base64 preview RPC for a binary asset', async () => { - const png = Buffer.from([0x89, 0x50, 0x4e, 0x47]) - mocks.callRuntimeEnvironment - .mockResolvedValueOnce({ - ok: false, - error: { code: 'runtime_error', message: 'binary_file' } - }) - .mockResolvedValueOnce({ - ok: true, - result: { - content: png.toString('base64'), - isBinary: true, - isImage: true, - mimeType: 'image/png' - } - }) - - const outcome = await readDocPreviewFile(runtimeGrant(), 'assets/logo.png') - - expect(mocks.callRuntimeEnvironment).toHaveBeenNthCalledWith( - 2, - '/user-data', - 'env-1', - 'files.readPreview', - { worktree: 'id:wt-1', relativePath: 'docs/assets/logo.png' }, - 15_000 - ) - expect(outcome).toEqual({ ok: true, bytes: png, contentType: 'image/png' }) - }) - - it('degrades on an old server whose empty binary preview carries no metadata', async () => { - mocks.callRuntimeEnvironment - .mockResolvedValueOnce({ - ok: false, - error: { code: 'runtime_error', message: 'binary_file' } - }) - .mockResolvedValueOnce({ ok: true, result: { content: '', isBinary: true } }) - - expect(await readDocPreviewFile(runtimeGrant(), 'assets/logo.png')).toMatchObject({ - ok: false, - status: 415, - reason: 'unsupported-asset' - }) - }) - - it('serves an empty paired asset the host typed rather than reporting a refusal', async () => { - mocks.callRuntimeEnvironment - .mockResolvedValueOnce({ - ok: false, - error: { code: 'runtime_error', message: 'binary_file' } - }) - .mockResolvedValueOnce({ - ok: true, - result: { content: '', isBinary: true, isImage: true, mimeType: 'image/png' } - }) - - expect(await readDocPreviewFile(runtimeGrant(), 'assets/logo.png')).toEqual({ - ok: true, - bytes: Buffer.alloc(0), - contentType: 'image/png' - }) - }) - - // Why: files.read clamps text at the host cap and only says so in `truncated`; serving the - // clamped bytes renders a document that silently stops halfway. - it('refuses a truncated text read instead of serving the clamped bytes', async () => { - mocks.callRuntimeEnvironment.mockResolvedValue({ - ok: true, - result: { content: '

half of', truncated: true, byteLength: 40_000_000 } - }) - - const outcome = await readDocPreviewFile(runtimeGrant(), 'index.html') - - expect(outcome).toMatchObject({ ok: false, status: 413 }) - expect(outcome).not.toMatchObject({ ok: true }) - }) - - it('serves a read the host reports as complete', async () => { - mocks.callRuntimeEnvironment.mockResolvedValue({ - ok: true, - result: { content: '

all

', truncated: false, byteLength: 12 } - }) - - expect(await readDocPreviewFile(runtimeGrant(), 'index.html')).toMatchObject({ ok: true }) - }) - - // Why: the binary RPC has no `truncated` field โ€” it rejects an over-cap asset with this error. - it('reports the host rejecting an over-cap binary as too large', async () => { - mocks.callRuntimeEnvironment - .mockResolvedValueOnce({ - ok: false, - error: { code: 'runtime_error', message: 'binary_file' } - }) - .mockResolvedValueOnce({ - ok: false, - error: { code: 'runtime_error', message: 'file_too_large' } - }) - - expect(await readDocPreviewFile(runtimeGrant(), 'assets/huge.png')).toMatchObject({ - ok: false, - status: 413 - }) - }) - - it('does not treat an unrelated RPC failure as a binary fallback', async () => { - mocks.callRuntimeEnvironment.mockResolvedValue({ - ok: false, - error: { code: 'runtime_error', message: 'permission_denied' } - }) - - expect(await readDocPreviewFile(runtimeGrant(), 'index.html')).toMatchObject({ - ok: false, - status: 404 - }) - expect(mocks.callRuntimeEnvironment).toHaveBeenCalledOnce() - }) - - it('404s a document outside the worktree, which files.read cannot address', async () => { - const outcome = await readDocPreviewFile(runtimeGrant('/tmp/agent-docs'), 'index.html') - - expect(outcome).toMatchObject({ ok: false, status: 404 }) - expect(mocks.callRuntimeEnvironment).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/browser/doc-preview-file-reader.ts b/src/main/browser/doc-preview-file-reader.ts deleted file mode 100644 index 967461a91f9..00000000000 --- a/src/main/browser/doc-preview-file-reader.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { extname } from 'node:path' -import type { - RuntimeFilePreviewResult, - RuntimeFileReadResult -} from '../../shared/runtime-file-contracts' -import type { DocPreviewFileFailureReason } from '../../shared/doc-preview-scheme' -import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing' -import { FileReadCapExceededError } from '../ssh/ssh-filesystem-stream-reader' -import { getCanonicalUserDataPath } from '../persistence' -import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' -import { - resolveCanonicalDocPreviewPath, - resolveDocPreviewTargetPath, - toRuntimeWorktreeRelativePath, - type DocPreviewGrant -} from './doc-preview-grant-registry' - -const DOC_PREVIEW_READ_TIMEOUT_MS = 15_000 - -/** Why not "needs a newer server": the SSH read path only ever serves images and PDFs as bytes, so - * a font is refused there by design, not by version. Name the file type, not the host's age. */ -const UNSERVABLE_ASSET_PREVIEW_MESSAGE = 'This workspace cannot send this file type to a preview.' - -/** `files.read` clamps text at the host's cap and reports it; serving the clamped bytes would - * render a silently half-finished document. */ -const TRUNCATED_PREVIEW_MESSAGE = 'This document is too large for the server to send in full.' - -/** The paired host rejects an over-cap asset outright instead of clamping it. */ -const RUNTIME_TOO_LARGE_ERROR = 'file_too_large' - -/** Both owners refuse an over-cap file; only their error shapes differ. */ -function isTooLargeReadError(error: unknown): boolean { - return ( - error instanceof FileReadCapExceededError || - (error instanceof Error && error.message === RUNTIME_TOO_LARGE_ERROR) - ) -} - -export type DocPreviewReadOutcome = - | { ok: true; bytes: Buffer; contentType: string } - | { ok: false; status: number; reason: DocPreviewFileFailureReason; message: string } - -const DOC_PREVIEW_CONTENT_TYPES: Record = { - '.html': 'text/html; charset=utf-8', - '.htm': 'text/html; charset=utf-8', - '.css': 'text/css; charset=utf-8', - '.js': 'text/javascript; charset=utf-8', - '.mjs': 'text/javascript; charset=utf-8', - '.json': 'application/json; charset=utf-8', - '.txt': 'text/plain; charset=utf-8', - '.md': 'text/plain; charset=utf-8', - '.csv': 'text/plain; charset=utf-8', - '.svg': 'image/svg+xml', - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.webp': 'image/webp', - '.bmp': 'image/bmp', - '.ico': 'image/x-icon', - '.pdf': 'application/pdf', - '.woff': 'font/woff', - '.woff2': 'font/woff2', - '.ttf': 'font/ttf', - '.otf': 'font/otf' -} - -export function docPreviewContentType(relativePath: string): string { - return ( - DOC_PREVIEW_CONTENT_TYPES[extname(relativePath).toLowerCase()] ?? 'application/octet-stream' - ) -} - -type PreviewFileBytes = { - content: string - isBinary: boolean - truncated?: boolean - /** Set by every owner that agreed to serve the bytes, so it also survives a 0-byte file. */ - mimeType?: string -} - -function toOutcome(source: PreviewFileBytes, contentType: string): DocPreviewReadOutcome { - if (source.truncated) { - return { ok: false, status: 413, reason: 'too-large', message: TRUNCATED_PREVIEW_MESSAGE } - } - if (!source.isBinary) { - return { ok: true, bytes: Buffer.from(source.content, 'utf8'), contentType } - } - if (source.content) { - return { ok: true, bytes: Buffer.from(source.content, 'base64'), contentType } - } - // Why: an empty binary body is two different answers. A host that still named the file's type - // read a 0-byte file, and 0 bytes is what it should serve; a host that named no type declined - // the format outright and has nothing to send. - return source.mimeType - ? { ok: true, bytes: Buffer.alloc(0), contentType } - : { - ok: false, - status: 415, - reason: 'unsupported-asset', - message: UNSERVABLE_ASSET_PREVIEW_MESSAGE - } -} - -async function readRuntimeDocPreviewFile( - environmentId: string, - worktreeSelector: string, - relativePath: string -): Promise { - const userDataPath = getCanonicalUserDataPath() - const response = await callRuntimeEnvironment( - userDataPath, - environmentId, - 'files.read', - { worktree: worktreeSelector, relativePath }, - DOC_PREVIEW_READ_TIMEOUT_MS - ) - if (response.ok) { - const result = response.result as RuntimeFileReadResult - return { content: result.content, isBinary: false, truncated: result.truncated === true } - } - // Why: files.read rejects binaries with a typed error; the base64 preview RPC serves - // images and fonts the same way it does for markdown previews. Match the exact - // message so an unrelated failure can't spoof the fallback. - if (response.error.message !== 'binary_file') { - throw new Error(response.error.message) - } - const previewResponse = await callRuntimeEnvironment( - userDataPath, - environmentId, - 'files.readPreview', - { worktree: worktreeSelector, relativePath }, - DOC_PREVIEW_READ_TIMEOUT_MS - ) - if (!previewResponse.ok) { - throw new Error(previewResponse.error.message) - } - const preview = previewResponse.result as RuntimeFilePreviewResult - // Why: readPreview never clamps โ€” it rejects an over-cap asset โ€” so its body is whole or absent. - return { - content: preview.content, - isBinary: preview.isBinary, - ...(preview.mimeType ? { mimeType: preview.mimeType } : {}) - } -} - -function notFoundOutcome(message = 'Not found'): DocPreviewReadOutcome { - return { ok: false, status: 404, reason: 'unreadable', message } -} - -/** Reads one in-grant path over the same channel the editor uses for that owner. */ -export async function readDocPreviewFile( - grant: DocPreviewGrant, - relativePath: string -): Promise { - const absolutePath = resolveDocPreviewTargetPath(grant, relativePath) - if (!absolutePath) { - return notFoundOutcome() - } - const contentType = docPreviewContentType(relativePath) - try { - if (grant.owner.kind === 'ssh') { - const provider = requireSshFilesystemProvider(grant.owner.connectionId) - // Why: the SSH read RPC enforces no root of its own, so containment has to survive a symlink - // before the read โ€” the lexical check above only proves the requested path looked contained. - const canonicalPath = await resolveCanonicalDocPreviewPath(grant, absolutePath, (path) => - provider.realpath(path) - ) - if (!canonicalPath) { - return notFoundOutcome() - } - // Why: the SSH reader rejects an over-cap file outright, so its result is never partial. - return toOutcome(await provider.readFile(canonicalPath), contentType) - } - const worktreeRelativePath = toRuntimeWorktreeRelativePath( - grant.owner.worktreeRoot, - absolutePath - ) - if (!worktreeRelativePath) { - // Why: files.read is worktree-scoped, so a doc outside the worktree has no client-side channel. - return notFoundOutcome() - } - // Why no realpath pass here: the host resolves this path through resolveAuthorizedPath, which - // canonicalizes and re-checks the worktree root server-side before reading. - return toOutcome( - await readRuntimeDocPreviewFile( - grant.owner.environmentId, - grant.owner.worktreeSelector, - worktreeRelativePath - ), - contentType - ) - } catch (error) { - return isTooLargeReadError(error) - ? { ok: false, status: 413, reason: 'too-large', message: TRUNCATED_PREVIEW_MESSAGE } - : notFoundOutcome(error instanceof Error ? error.message : undefined) - } -} diff --git a/src/main/browser/doc-preview-grant-registry.test.ts b/src/main/browser/doc-preview-grant-registry.test.ts deleted file mode 100644 index 8aadf9ed695..00000000000 --- a/src/main/browser/doc-preview-grant-registry.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest' -import { - getDocPreviewGrant, - mintDocPreviewGrant, - resolveCanonicalDocPreviewPath, - resolveDocPreviewTargetPath, - revokeAllDocPreviewGrants, - revokeDocPreviewGrant, - toRuntimeWorktreeRelativePath, - type DocPreviewGrant -} from './doc-preview-grant-registry' - -const sshOwner = { kind: 'ssh', connectionId: 'ssh-1' } as const - -function mintPosixGrant(root = '/srv/repo/docs'): DocPreviewGrant { - return mintDocPreviewGrant({ - owner: sshOwner, - root, - entryRelativePath: 'index.html', - browserPageId: 'page-1' - }) -} - -beforeEach(() => { - revokeAllDocPreviewGrants() -}) - -describe('doc preview grants', () => { - it('mints unguessable ids and looks them up', () => { - const first = mintPosixGrant() - const second = mintPosixGrant() - - expect(first.id).toMatch(/^[0-9a-f]{32}$/) - expect(first.id).not.toBe(second.id) - expect(getDocPreviewGrant(first.id)).toBe(first) - }) - - it('returns nothing for an unknown or revoked grant', () => { - const grant = mintPosixGrant() - - expect(getDocPreviewGrant('0'.repeat(32))).toBeNull() - expect(revokeDocPreviewGrant(grant.id)).toBe(true) - expect(getDocPreviewGrant(grant.id)).toBeNull() - expect(revokeDocPreviewGrant(grant.id)).toBe(false) - }) -}) - -describe('resolveDocPreviewTargetPath', () => { - it('resolves paths inside the grant root', () => { - const grant = mintPosixGrant() - - expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('/srv/repo/docs/index.html') - expect(resolveDocPreviewTargetPath(grant, 'assets/logo.png')).toBe( - '/srv/repo/docs/assets/logo.png' - ) - }) - - it('refuses parent traversal, absolute escapes and empty paths', () => { - const grant = mintPosixGrant() - - expect(resolveDocPreviewTargetPath(grant, '../secret.env')).toBeNull() - expect(resolveDocPreviewTargetPath(grant, 'assets/../../secret.env')).toBeNull() - expect(resolveDocPreviewTargetPath(grant, '..')).toBeNull() - expect(resolveDocPreviewTargetPath(grant, '')).toBeNull() - expect(resolveDocPreviewTargetPath(grant, 'a//b')).toBeNull() - }) - - it('refuses backslash and NUL segments that could re-split on the owning host', () => { - const grant = mintPosixGrant() - - expect(resolveDocPreviewTargetPath(grant, '..\\secret.env')).toBeNull() - expect(resolveDocPreviewTargetPath(grant, 'index.html\0.png')).toBeNull() - }) - - // Why this is a traversal test and not a containment test: every request path that names a - // sibling directory has to climb out of the root first, so the `..` segment guard answers it - // before the prefix check runs. Sibling containment is exercised where it is reachable โ€” - // against a canonicalized path, below. - it('refuses a sibling directory by refusing the traversal that reaches it', () => { - const grant = mintPosixGrant() - - expect(resolveDocPreviewTargetPath(grant, '../docs-private/secret.html')).toBeNull() - }) - - it('keeps a Windows drive root addressable instead of turning it drive-relative', () => { - const grant = mintDocPreviewGrant({ - owner: sshOwner, - root: 'C:\\', - entryRelativePath: 'index.html', - browserPageId: 'page-1' - }) - - expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('C:\\index.html') - }) - - it('follows the owning host path flavor rather than this process platform', () => { - const windowsGrant = mintDocPreviewGrant({ - owner: sshOwner, - root: 'C:\\srv\\repo\\docs', - entryRelativePath: 'index.html', - browserPageId: 'page-1' - }) - - expect(resolveDocPreviewTargetPath(windowsGrant, 'assets/logo.png')).toBe( - 'C:\\srv\\repo\\docs\\assets\\logo.png' - ) - expect(resolveDocPreviewTargetPath(windowsGrant, '../secret.env')).toBeNull() - }) - - it('normalizes a trailing separator on the root', () => { - const grant = mintDocPreviewGrant({ - owner: sshOwner, - root: '/srv/repo/docs/', - entryRelativePath: 'index.html', - browserPageId: 'page-1' - }) - - expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('/srv/repo/docs/index.html') - expect(resolveDocPreviewTargetPath(grant, '../secret.env')).toBeNull() - }) -}) - -describe('resolveCanonicalDocPreviewPath', () => { - // Why here and not above: a canonical path is the one input that can name a sibling directory - // without traversing โ€” the host resolved a symlink to it โ€” so this is where the prefix check - // is the only thing standing between the grant and `/srv/repo/docs-private`. - it('refuses a canonical path in a sibling directory that shares the root prefix', async () => { - const grant = mintPosixGrant() - - await expect( - resolveCanonicalDocPreviewPath(grant, '/srv/repo/docs/report.html', async (path) => - path === grant.root ? path : '/srv/repo/docs-private/secret.html' - ) - ).resolves.toBeNull() - }) - - it('answers the canonical path when it stays inside the canonical root', async () => { - const grant = mintPosixGrant() - - await expect( - resolveCanonicalDocPreviewPath(grant, '/srv/repo/docs/report.html', async (path) => path) - ).resolves.toBe('/srv/repo/docs/report.html') - }) -}) - -describe('toRuntimeWorktreeRelativePath', () => { - it('produces a worktree-relative path for files inside the worktree', () => { - expect(toRuntimeWorktreeRelativePath('/srv/repo', '/srv/repo/docs/index.html')).toBe( - 'docs/index.html' - ) - }) - - it('rejects paths outside the worktree, which files.read cannot address', () => { - expect(toRuntimeWorktreeRelativePath('/srv/repo', '/tmp/agent/report.html')).toBeNull() - expect(toRuntimeWorktreeRelativePath('/srv/repo', '/srv/repo')).toBeNull() - }) - - it('uses Windows semantics for a Windows worktree root', () => { - expect(toRuntimeWorktreeRelativePath('C:\\srv\\repo', 'C:\\srv\\repo\\docs\\index.html')).toBe( - 'docs/index.html' - ) - expect(toRuntimeWorktreeRelativePath('C:\\srv\\repo', 'D:\\other\\index.html')).toBeNull() - }) -}) diff --git a/src/main/browser/doc-preview-grant-registry.ts b/src/main/browser/doc-preview-grant-registry.ts deleted file mode 100644 index 51237a685f1..00000000000 --- a/src/main/browser/doc-preview-grant-registry.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { randomBytes } from 'node:crypto' -import { posix, win32 } from 'node:path' -import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' - -/** - * A preview grant is the only authority that turns an `orca-preview://` request - * into bytes: it names the host that owns the file and the single directory - * subtree requests may resolve inside. No grant, no bytes. - */ -export type DocPreviewOwner = - | { kind: 'ssh'; connectionId: string } - | { - kind: 'runtime' - environmentId: string - /** Selector the runtime resolves `files.read` against. */ - worktreeSelector: string - /** Worktree root on the runtime host; `files.read` only accepts paths inside it. */ - worktreeRoot: string - } - -export type DocPreviewGrant = { - id: string - owner: DocPreviewOwner - /** Containing directory of the opened document, on the owning host. */ - root: string - /** Path of the opened document relative to `root`. */ - entryRelativePath: string - /** - * Browser page the reader opened this document in. Main registers the guest under it once the - * guest commits to the grant, so the surface a tool names is the page the reader is looking at - * and not the grant, which a re-mint replaces underneath the same page. - */ - browserPageId: string -} - -const grantsById = new Map() - -function pathFlavorFor(root: string): typeof posix | typeof win32 { - return isWindowsAbsolutePathLike(root) ? win32 : posix -} - -function normalizeRootPath(root: string): string { - const flavor = pathFlavorFor(root) - const normalized = - flavor === win32 ? flavor.normalize(root.replace(/\//g, '\\')) : flavor.normalize(root) - // Why: `C:\` is the whole root, and trimming its separator would make win32.join answer the - // drive-relative `C:x`, which resolves against the host's cwd instead of inside the grant. - if (flavor === win32 && /^[a-zA-Z]:\\$/.test(normalized)) { - return normalized - } - // Why: a trailing separator would make the containment prefix check accept a sibling directory. - return normalized.length > 1 && normalized.endsWith(flavor.sep) - ? normalized.slice(0, -1) - : normalized -} - -export function mintDocPreviewGrant(params: { - owner: DocPreviewOwner - root: string - entryRelativePath: string - browserPageId: string -}): DocPreviewGrant { - const grant: DocPreviewGrant = { - id: randomBytes(16).toString('hex'), - owner: params.owner, - root: normalizeRootPath(params.root), - entryRelativePath: params.entryRelativePath.replace(/\\/g, '/'), - browserPageId: params.browserPageId - } - grantsById.set(grant.id, grant) - return grant -} - -export function getDocPreviewGrant(grantId: string): DocPreviewGrant | null { - return grantsById.get(grantId) ?? null -} - -/** - * Why anything listens at all: a grant is the only thing that names a preview's lifetime. State - * elsewhere in main keyed by a preview's tool target โ€” grab intent, a queued grab chain โ€” has no - * other signal telling it the surface is gone, and would otherwise accrete one entry per grant - * for the life of the process. - */ -const revocationListeners = new Set<(grant: DocPreviewGrant) => void>() - -/** Why the whole grant and not its id: it is already gone from the registry when listeners run. */ -export function onDocPreviewGrantRevoked(listener: (grant: DocPreviewGrant) => void): () => void { - revocationListeners.add(listener) - return () => revocationListeners.delete(listener) -} - -function notifyRevoked(grant: DocPreviewGrant): void { - for (const listener of revocationListeners) { - listener(grant) - } -} - -export function revokeDocPreviewGrant(grantId: string): boolean { - canonicalRootByGrantId.delete(grantId) - const grant = grantsById.get(grantId) - if (!grant) { - return false - } - grantsById.delete(grantId) - notifyRevoked(grant) - return true -} - -export function revokeAllDocPreviewGrants(): void { - canonicalRootByGrantId.clear() - const revoked = [...grantsById.values()] - grantsById.clear() - for (const grant of revoked) { - notifyRevoked(grant) - } -} - -function hasUnsafeSegment(segments: string[]): boolean { - return segments.some( - (segment) => - segment.length === 0 || - segment === '.' || - segment === '..' || - segment.includes('\0') || - segment.includes('\\') - ) -} - -/** - * Resolves a request path to an absolute path on the owning host, or null when - * it would escape the grant's root. Path flavor follows the root (the owning - * host may be Windows while this client is not), never `process.platform`. - */ -export function resolveDocPreviewTargetPath( - grant: DocPreviewGrant, - relativePath: string -): string | null { - const segments = relativePath.split('/').filter((segment, index, all) => { - // Why: keep empty segments visible to the safety check except a single trailing one from `dir/`. - return !(segment === '' && index === all.length - 1) - }) - if (segments.length === 0 || hasUnsafeSegment(segments)) { - return null - } - const flavor = pathFlavorFor(grant.root) - const resolved = flavor.normalize(flavor.join(grant.root, ...segments)) - return isInsideRoot(grant.root, resolved, flavor) ? resolved : null -} - -function isInsideRoot( - root: string, - candidate: string, - flavor: typeof posix | typeof win32 -): boolean { - const rootPrefix = root.endsWith(flavor.sep) ? root : `${root}${flavor.sep}` - return candidate.startsWith(rootPrefix) -} - -/** Why: realpath is a host round-trip, and a grant's root is fixed for its lifetime. */ -const canonicalRootByGrantId = new Map>() - -/** - * Second containment pass for hosts where the lexical one is not enough: a symlink - * inside the root can point anywhere, and the SSH read RPC applies no root of its - * own. Both sides are canonicalized on the owning host before the prefix re-check; - * a host that cannot canonicalize a path answers nothing. - */ -export async function resolveCanonicalDocPreviewPath( - grant: DocPreviewGrant, - absolutePath: string, - realpath: (path: string) => Promise -): Promise { - try { - let canonicalRoot = canonicalRootByGrantId.get(grant.id) - if (!canonicalRoot) { - canonicalRoot = realpath(grant.root).then(normalizeRootPath) - canonicalRootByGrantId.set(grant.id, canonicalRoot) - } - const [root, canonicalPath] = await Promise.all([canonicalRoot, realpath(absolutePath)]) - const flavor = pathFlavorFor(root) - return isInsideRoot(root, canonicalPath, flavor) ? canonicalPath : null - } catch { - // Why: a root that no longer canonicalizes must not fall back to the lexical answer. - canonicalRootByGrantId.delete(grant.id) - return null - } -} - -/** - * Path a runtime `files.read` can address, i.e. relative to the worktree root. - * Returns null when the grant root sits outside the worktree โ€” the runtime file - * RPCs are worktree-scoped, so those documents are unreadable client-side. - */ -export function toRuntimeWorktreeRelativePath( - worktreeRoot: string, - absolutePath: string -): string | null { - const flavor = pathFlavorFor(worktreeRoot) - const normalizedRoot = normalizeRootPath(worktreeRoot) - const relative = flavor.relative(normalizedRoot, absolutePath) - if (!relative || relative === '..' || relative.startsWith(`..${flavor.sep}`)) { - return null - } - if (flavor === win32 && /^[a-zA-Z]:/.test(relative)) { - return null - } - return relative.replace(/\\/g, '/') -} diff --git a/src/main/browser/doc-preview-guest-policy.test.ts b/src/main/browser/doc-preview-guest-policy.test.ts deleted file mode 100644 index 26b117c0fc0..00000000000 --- a/src/main/browser/doc-preview-guest-policy.test.ts +++ /dev/null @@ -1,550 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - getWorkspaceDocPageGuest, - installDocPreviewGuestPolicy, - readDocPreviewGuestBoundGrantId, - reportDocPreviewLinkClick -} from './doc-preview-guest-policy' -import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme' -import { - mintDocPreviewGrant, - revokeAllDocPreviewGrants, - revokeDocPreviewGrant -} from './doc-preview-grant-registry' - -type GuestHandlers = Record void> - -const HOST_RENDERER_ID = 42 - -function installOnFakeGuest( - hostId: number = HOST_RENDERER_ID, - /** What the guest is already showing when the embedder hands it over, as a real one usually is. */ - initialUrl = '' -): { - contents: object - handlers: GuestHandlers - hostId: number - isFocused: ReturnType - send: ReturnType - setWebRTCIPHandlingPolicy: ReturnType - windowOpenHandler: (details: { url: string }) => { action: string } - /** Why without the `destroyed` event: Chromium tears the contents down before main runs it. */ - markContentsDestroyed: () => void -} { - const handlers: GuestHandlers = {} - let contentsDestroyed = false - const send = vi.fn() - const isFocused = vi.fn(() => true) - const setWebRTCIPHandlingPolicy = vi.fn() - let windowOpenHandler: (details: { url: string }) => { action: string } = () => ({ - action: 'deny' - }) - const register = (event: string, handler: (...args: never[]) => void): void => { - handlers[event] = handler - } - const guest = { - isFocused, - isDestroyed: () => contentsDestroyed, - getURL: () => initialUrl, - on: vi.fn(register), - once: vi.fn(register), - setWindowOpenHandler: vi.fn((handler: (details: { url: string }) => { action: string }) => { - windowOpenHandler = handler - }), - setWebRTCIPHandlingPolicy - } - installDocPreviewGuestPolicy(guest as never, { id: hostId, send }) - return { - contents: guest, - handlers, - hostId, - isFocused, - send, - setWebRTCIPHandlingPolicy, - windowOpenHandler: (details) => windowOpenHandler(details), - markContentsDestroyed: () => { - contentsDestroyed = true - } - } -} - -type FakeGuest = ReturnType - -function startMainFrameNavigation(guest: FakeGuest, url: string): void { - guest.handlers['did-start-navigation']?.({ url, isMainFrame: true } as never) -} - -/** A guest already showing a document, which is the only state a link can be pressed in. */ -function boundGuest(): { grant: ReturnType; guest: FakeGuest } { - const grant = mintGrant() - const guest = installOnFakeGuest() - startMainFrameNavigation(guest, buildDocPreviewUrl(grant.id, 'index.html')) - return { grant, guest } -} - -function reportClick(guest: FakeGuest, url: string): void { - reportDocPreviewLinkClick(guest.contents as never, url) -} - -// Why a fresh page per grant: the registry is keyed by the page now, so two grants sharing one -// would have the second silently replace the first rather than stand beside it. -let nextDocPageOrdinal = 0 - -function mintGrant(): ReturnType { - nextDocPageOrdinal += 1 - return mintDocPreviewGrant({ - owner: { kind: 'ssh', connectionId: 'ssh-1' }, - root: '/home/alice/docs', - entryRelativePath: 'index.html', - browserPageId: `doc-page-${nextDocPageOrdinal}` - }) -} - -beforeEach(() => { - vi.clearAllMocks() - revokeAllDocPreviewGrants() -}) - -describe('doc preview guest policy', () => { - it('allows relative navigation within the bound grant', () => { - const { grant, guest } = boundGuest() - const preventDefault = vi.fn() - - guest.handlers['will-navigate']?.( - { preventDefault } as never, - buildDocPreviewUrl(grant.id, 'guide.html') as never - ) - - expect(preventDefault).not.toHaveBeenCalled() - expect(guest.send).not.toHaveBeenCalled() - }) - - // Why an unlatched guest is refused rather than trusted: the renderer-set src is - // browser-initiated and never reaches will-navigate, so a navigation arriving before the latch is - // one the guest started for itself. - it('blocks a navigation the guest starts before a document has bound it', () => { - const grant = mintGrant() - const guest = installOnFakeGuest() - const preventDefault = vi.fn() - - guest.handlers['will-navigate']?.( - { preventDefault } as never, - buildDocPreviewUrl(grant.id, 'index.html') as never - ) - - expect(preventDefault).toHaveBeenCalledOnce() - expect(guest.send).not.toHaveBeenCalled() - }) - - it('blocks navigation into a different live grant once bound', () => { - const { guest } = boundGuest() - const otherGrant = mintGrant() - const preventDefault = vi.fn() - - guest.handlers['will-navigate']?.( - { preventDefault } as never, - buildDocPreviewUrl(otherGrant.id, 'index.html') as never - ) - - expect(preventDefault).toHaveBeenCalledOnce() - }) - - it('blocks a revoked grant even when it matches the bound id', () => { - const { grant, guest } = boundGuest() - revokeAllDocPreviewGrants() - const preventDefault = vi.fn() - - guest.handlers['will-navigate']?.( - { preventDefault } as never, - buildDocPreviewUrl(grant.id, 'guide.html') as never - ) - - expect(preventDefault).toHaveBeenCalledOnce() - }) - - it('blocks an external navigation without offering it to the renderer', () => { - const { guest } = boundGuest() - const preventDefault = vi.fn() - - guest.handlers['will-navigate']?.({ preventDefault } as never, 'https://example.com/' as never) - - expect(preventDefault).toHaveBeenCalledOnce() - expect(guest.send).not.toHaveBeenCalled() - }) - - it('blocks a file: navigation', () => { - const { guest } = boundGuest() - const preventDefault = vi.fn() - - guest.handlers['will-navigate']?.({ preventDefault } as never, 'file:///etc/passwd' as never) - - expect(preventDefault).toHaveBeenCalledOnce() - expect(guest.send).not.toHaveBeenCalled() - }) - - it('applies the same rule to redirects', () => { - const { guest } = boundGuest() - const preventDefault = vi.fn() - - guest.handlers['will-redirect']?.({ preventDefault } as never, 'https://evil.test/' as never) - - expect(preventDefault).toHaveBeenCalledOnce() - expect(guest.send).not.toHaveBeenCalled() - }) - - it('denies every popup and routes none of them', () => { - const { guest } = boundGuest() - - expect(guest.windowOpenHandler({ url: 'https://example.com/docs' })).toEqual({ - action: 'deny' - }) - expect(guest.windowOpenHandler({ url: 'file:///etc/passwd' })).toEqual({ action: 'deny' }) - expect(guest.send).not.toHaveBeenCalled() - }) - - // Why: will-navigate never fires for a subframe, so an