diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 05a73229d69..d10463173dd 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -378,6 +378,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Test Windows PowerShell CLI boundary + run: pnpm exec vitest run --config config/vitest.config.ts src/main/cli/wsl-cli-powershell-boundary.test.ts + - name: Build package inputs run: pnpm run build:release diff --git a/.github/workflows/win-crash-survival-e2e.yml b/.github/workflows/win-crash-survival-e2e.yml index fb1d63a440a..8a645fd8ef8 100644 --- a/.github/workflows/win-crash-survival-e2e.yml +++ b/.github/workflows/win-crash-survival-e2e.yml @@ -77,7 +77,6 @@ jobs: '!config/reliability-gates.jsonc', '!config/max-lines-baseline.txt', '!config/vitest.config.ts', - 'config/build-plugins/**', 'native/**', 'resources/**', 'electron.vite.config.ts', diff --git a/AGENTS.md b/AGENTS.md index 67bcd37a538..58698e46a2e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,10 @@ All UI work — layout, color, typography, spacing, component selection, UX behavior — must follow [`docs/STYLEGUIDE.md`](./docs/STYLEGUIDE.md). Use the tokens defined in `src/renderer/src/assets/main.css` (the canonical source) and the shadcn primitives in `src/renderer/src/components/ui/`. Don't invent new color values, font sizes, or shadow tiers when a documented one already covers the role. When STYLEGUIDE.md is silent, follow the resolution order in its final section. +## Electron UI Validation + +Use the `$electron` skill and Playwright CDP for rendered Orca UI checks. Do not use computer-use for Orca UI validation. + # Style ## Concise/Brief Non-obviosu comments ONLY * DO NOT: be verbose, explain the obvious, walk through the code ("WHY not HOW") diff --git a/config/build-plugins/plain-node-entry-guard.ts b/config/build-plugins/plain-node-entry-guard.ts index b4b86a31c31..45d34b944f7 100644 --- a/config/build-plugins/plain-node-entry-guard.ts +++ b/config/build-plugins/plain-node-entry-guard.ts @@ -1,7 +1,8 @@ -import { spawnSync } from 'node:child_process' +import { spawn } from 'node:child_process' import { join } from 'node:path' import type { Plugin, Rollup } from 'vite' +type NormalizedInputOptions = Rollup.NormalizedInputOptions type NormalizedOutputOptions = Rollup.NormalizedOutputOptions type OutputBundle = Rollup.OutputBundle type OutputChunk = Rollup.OutputChunk @@ -42,9 +43,34 @@ const WORKER_THREAD_ENTRY_NAMES = [ 'port-scan-command-worker-entry' ] as const +export const GUARDED_ENTRY_NAMES = [ + ...PLAIN_NODE_ENTRY_NAMES, + ...WORKER_THREAD_ENTRY_NAMES +] as const + type EntryRuntime = 'plain-Node process' | 'worker thread' -const ELECTRON_REQUIRE_RE = /require\(\s*["']electron["']\s*\)/ +// Subpaths (electron/main) are as unloadable as the bare module under plain Node. +const ELECTRON_REQUIRE_RE = /require\(\s*["'`]electron(?:\/[^"'`]+)?["'`]\s*\)/ + +// Why: writeBundle skips any name missing from the bundle, so a renamed or +// removed rollup input would silently drop that entry from the guard and let the +// regression back in. Pin the lists to the input keys at build start instead. +function assertEntryNamesAreRollupInputs(input: NormalizedInputOptions['input']): void { + if (typeof input === 'string' || Array.isArray(input)) { + return + } + const inputNames = new Set(Object.keys(input)) + const missing = GUARDED_ENTRY_NAMES.filter((name) => !inputNames.has(name)) + if (missing.length > 0) { + throw new Error( + `[plain-node-entry-guard] guarded ${missing.map((name) => `"${name}"`).join(', ')} ` + + `${missing.length === 1 ? 'is not a rollup input' : 'are not rollup inputs'} anymore. ` + + `Update PLAIN_NODE_ENTRY_NAMES/WORKER_THREAD_ENTRY_NAMES in plain-node-entry-guard.ts to ` + + `the current entry names — a stale name silently stops guarding that entry.` + ) + } +} function collectReachableChunks( entry: OutputChunk, @@ -89,41 +115,125 @@ function assertNoElectronRequire( } } +// Owned by the argv parser in src/main/daemon/daemon-entry.ts — keep in sync. +const DAEMON_USAGE_PREFIX = 'Usage: daemon-entry' + +export type SmokeTimings = { + timeoutMs: number + // daemon-entry traps SIGTERM and awaits a native shutdown, so the deadline + // needs an uncatchable follow-up to stay a deadline. + killGraceMs: number +} + +const DEFAULT_SMOKE_TIMINGS: SmokeTimings = { timeoutMs: 15_000, killGraceMs: 2_000 } + +// Bound the wait for stderr to flush after exit; a grandchild inheriting stdio +// can hold the pipes open long after the child is gone. +const SMOKE_STDERR_DRAIN_MS = 250 + +type SmokeResult = { + status: number | null + signal: NodeJS.Signals | null + stderr: string + error?: Error + timedOut: boolean +} + +// Why not spawnSync({ timeout }): its timeout only sends killSignal and then +// keeps blocking until the child exits, so a child that traps SIGTERM hangs the +// build forever. Escalate to SIGKILL instead. +function runDaemonEntry(entryPath: string, timings: SmokeTimings): Promise { + return new Promise((resolve) => { + const child = spawn(process.execPath, [entryPath], { stdio: ['ignore', 'ignore', 'pipe'] }) + let stderr = '' + let timedOut = false + let settled = false + let forceKillTimer: NodeJS.Timeout | undefined + let drainTimer: NodeJS.Timeout | undefined + + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk: string) => { + stderr += chunk + }) + + const deadlineTimer = setTimeout(() => { + timedOut = true + child.kill('SIGTERM') + forceKillTimer = setTimeout(() => child.kill('SIGKILL'), timings.killGraceMs) + }, timings.timeoutMs) + + const finish = (status: number | null, signal: NodeJS.Signals | null, error?: Error): void => { + if (settled) { + return + } + settled = true + clearTimeout(deadlineTimer) + clearTimeout(forceKillTimer) + clearTimeout(drainTimer) + resolve({ status, signal, stderr, error, timedOut }) + } + + child.on('error', (error: Error) => finish(null, null, error)) + // 'close' gives the full stderr; 'exit' is the fallback so a held-open pipe + // cannot outlast the process itself. + child.on('close', (status, signal) => finish(status, signal)) + child.on('exit', (status, signal) => { + drainTimer = setTimeout(() => finish(status, signal), SMOKE_STDERR_DRAIN_MS) + }) + }) +} + // Why: proves the whole daemon-entry graph resolves under plain Node (no // unresolved requires). require("electron") does not throw in a dev tree with // node_modules present, so the static scan above — not this smoke — is the // electron regression guard; this only catches gross load failures. -function smokeLoadDaemonEntry(outputDir: string): void { +async function smokeLoadDaemonEntry(outputDir: string, timings: SmokeTimings): Promise { const entryPath = join(outputDir, 'daemon-entry.js') - const result = spawnSync(process.execPath, [entryPath], { - encoding: 'utf8', - timeout: 15_000 - }) + const result = await runDaemonEntry(entryPath, timings) if (result.error) { throw new Error( `[plain-node-entry-guard] could not smoke-load daemon-entry.js under plain Node: ` + `${result.error.message}` ) } - const stderr = result.stderr ?? '' + // Almost always means the daemon stopped rejecting an empty argv and started + // listening instead. + if (result.timedOut) { + throw new Error( + `[plain-node-entry-guard] daemon-entry.js did not exit within ${timings.timeoutMs}ms on an ` + + `empty argv under plain Node, so the smoke killed it.` + ) + } + if (result.signal) { + throw new Error( + `[plain-node-entry-guard] daemon-entry.js was killed by ${result.signal} under plain Node.` + ) + } + const stderr = result.stderr if (/Cannot find module|MODULE_NOT_FOUND/.test(stderr)) { throw new Error( `[plain-node-entry-guard] daemon-entry.js failed to load under plain Node:\n${stderr}` ) } - if (!stderr.includes('Usage: daemon-entry')) { + if (result.status === 0 || !stderr.includes(DAEMON_USAGE_PREFIX)) { throw new Error( - `[plain-node-entry-guard] daemon-entry.js did not reach argv parsing under plain Node ` + - `(expected the "Usage: daemon-entry" error). stderr:\n${stderr}` + `[plain-node-entry-guard] daemon-entry.js did not reject an empty argv under plain Node ` + + `(expected a non-zero exit and the "${DAEMON_USAGE_PREFIX}" error, got exit ` + + `${result.status}). stderr:\n${stderr}` ) } } -export function createPlainNodeEntryGuardPlugin(): Plugin { +export function createPlainNodeEntryGuardPlugin( + smokeTimings: SmokeTimings = DEFAULT_SMOKE_TIMINGS +): Plugin { let daemonOutputDir: string | undefined return { name: 'orca-plain-node-entry-guard', + buildStart(options: NormalizedInputOptions) { + assertEntryNamesAreRollupInputs(options.input) + }, writeBundle(options: NormalizedOutputOptions, bundle: OutputBundle) { // Why: skip in `electron-vite dev` watch mode — the smoke would respawn on // every rebuild, and the guard only needs to gate produced builds. @@ -159,11 +269,11 @@ export function createPlainNodeEntryGuardPlugin(): Plugin { daemonOutputDir = options.dir } }, - closeBundle() { + async closeBundle() { if (daemonOutputDir) { const outputDir = daemonOutputDir daemonOutputDir = undefined - smokeLoadDaemonEntry(outputDir) + await smokeLoadDaemonEntry(outputDir, smokeTimings) } } } diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index ebc372c480a..b01c26997e1 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -166,6 +166,12 @@ module.exports = { // Why: sherpa-onnx native bindings (platform-specific subpackages) must be // unpacked because they ship .node addons + .dylib/.so files that cannot be // dlopen()'d from inside the asar archive. + // Why: the OpenCode SQLite worker entry is also spawned by the scanner + // service, which runs under ELECTRON_RUN_AS_NODE and so cannot see into + // app.asar. Left packed, that spawn fails closed and every OpenCode session + // disappears from Agent Session History in packaged builds only. Worker + // entries reached solely from the Electron main process stay packed, since + // asar redirects their app.asar paths. asarUnpack: [ 'out/package.json', 'out/cli/**', @@ -183,6 +189,7 @@ module.exports = { 'out/main/hermes/**', 'out/main/daemon-entry.js', 'out/main/session-scanner-service-entry.js', + 'out/main/session-scanner-opencode-sqlite-worker-entry.js', 'out/main/plugin-host-entry.js', 'out/main/computer-sidecar.js', 'out/main/parcel-watcher-process-entry.js', diff --git a/config/localization-coverage-allowlist.json b/config/localization-coverage-allowlist.json index d3c9f3b0864..1f445997bda 100644 --- a/config/localization-coverage-allowlist.json +++ b/config/localization-coverage-allowlist.json @@ -1,11 +1,4 @@ [ - { - "filePath": "src/renderer/src/lib/agent-background-session-test-state.ts", - "kind": "object-property:title", - "text": "Terminal 1", - "dynamic": false, - "count": 1 - }, { "filePath": "src/renderer/src/components/sidebar/worktree-card-meta-row.tsx", "kind": "jsx-attribute:label", diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 7032549ed8d..4554e6bba8b 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -232,7 +232,6 @@ inline src/renderer/src/components/settings/AgentsPane.tsx inline src/renderer/src/components/settings/RepositoryHooksSection.tsx inline src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx inline src/renderer/src/components/settings/Settings.tsx -inline src/renderer/src/components/settings/SettingsFormControls.tsx inline src/renderer/src/components/sidebar/RemoteFileBrowser.tsx inline src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx inline src/renderer/src/components/sidebar/WorktreeContextMenu.tsx @@ -245,7 +244,6 @@ inline src/renderer/src/components/stats/usage-overview-model.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 -inline src/renderer/src/components/tab-bar/TabBar.tsx inline src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts inline src/renderer/src/components/tab-group/useTabDragSplit.ts inline src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts @@ -268,7 +266,6 @@ inline src/renderer/src/hooks/useComposerState.ts inline src/renderer/src/hooks/useEditorExternalWatch.ts inline src/renderer/src/hooks/useIpcEvents.test.ts inline src/renderer/src/hooks/useIpcEvents.ts -inline src/renderer/src/hooks/useIssueMetadata.ts inline src/renderer/src/hooks/useSettingsNavigationMetadata.ts inline src/renderer/src/lib/active-agent-note-send.test.ts inline src/renderer/src/lib/file-type-icons.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 16e479b2666..f5e5a39caeb 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1969,7 +1969,7 @@ "invariant": "Close permanently removes the owned provider session, agent descendants, and resume authority even when no TerminalPane is mounted; a terminating id remains reserved through natural exit, duplicate callers await the same completion, and immediate teardown upgrades any graceful request without signalling a recycled PID or a descendant tree after root ownership is lost; process-table work is locale-stable, bounded, fresh for each post-start request, same-turn coalesced, and begins within the requesting caller's deadline, including bulk worktree cleanup; detach and park preserve ownership; aliases prevent a detached agent's immutable physical pane key from being retired with its former tab.", "oracle": "Capture the exact PTY before parking, prove it remains listed while the view is absent, close through the product state boundary, and poll the provider inventory until that exact ID disappears; an agent-marked PTY's detached-pgid child is alive before close and absent afterward; unit tests keep a naturally exited id reserved without re-killing its PID or signalling its captured tree, upgrade pending and post-snapshot graceful kills to immediate, force ps into the C locale, coalesce each bounded bulk-shutdown batch, share duplicate teardown completion, coalesce 20 same-turn process-table requests, start one shared successor without waiting for the prior scan, terminate cyclic-looking traversal, retain the source scan's timestamp, bound both read phases, avoid ambiguous SIGKILL, and assert canonical owner dedupe, exact pane tombstones, chained detach transfer, and restart alias restoration.", "commands": [ - "pnpm dlx node@24 ./node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-pane-authority.test.ts src/main/ipc/agent-hooks.test.ts src/main/ipc/agent-pane-authority-ownership.test.ts src/main/ipc/pty-management.test.ts src/main/persistence.test.ts src/renderer/src/store/slices/agent-pane-authority.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/renderer/src/store/slices/terminal-tab-retirement.test.ts src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts", + "pnpm dlx node@24 ./node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-pane-authority.test.ts src/main/ipc/agent-hooks.test.ts src/main/ipc/agent-pane-authority-ownership.test.ts src/main/ipc/pty-management.test.ts src/main/persistence.test.ts src/renderer/src/store/slices/agent-pane-authority.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/renderer/src/store/slices/terminal-tab-retirement.test.ts src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts tests/e2e/completed-worker-retirement-resume.unit.test.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/pty-descendant-termination.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/providers/local-pty-provider.test.ts src/main/runtime/worktree-teardown.test.ts", "pnpm run test:e2e -- tests/e2e/terminal-parked-close-retirement.spec.ts --workers=1", "pnpm run test:e2e -- tests/e2e/agent-descendant-process-kill.spec.ts --workers=1" @@ -1989,11 +1989,18 @@ "src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts", "src/renderer/src/store/slices/terminal-tab-retirement.test.ts", "src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts", + "tests/e2e/completed-worker-retirement-resume.unit.test.ts", "src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts", "tests/e2e/terminal-parked-close-retirement.spec.ts", "tests/e2e/agent-descendant-process-kill.spec.ts" ], "assertionRefs": [ + { + "file": "tests/e2e/completed-worker-retirement-resume.unit.test.ts", + "assertions": [ + "explicit close revokes the exact live recovery record both before and after PTY exit removes the tab, while PTY exit alone preserves interrupted-session recovery" + ] + }, { "file": "tests/e2e/terminal-parked-close-retirement.spec.ts", "assertions": [ @@ -2711,11 +2718,13 @@ "oracle": "Renderer-state tests assert provider-session ownership across preserved and queued panes. Main/provider contracts assert atomic attach-only adoption, stable host/worktree/tab/leaf identity, no fresh spawn on adoption, and safe paired-runtime cancellation. The Electron oracle creates inactive runtime-owned Codex and Setup PTYs plus an unrelated canary, seeds an exact resumable provider session, removes only the target renderer projections, and activates the workspace. It requires byte-stable handle, PTY, incarnation, tab, leaf, process PID, renderer graph, persisted binding, runtime id, graph epoch, and daemon PID across first mount and reload; PID-specific DOM keyboard I/O must remain live with one launch, zero resume argv, zero signals, zero interruption text, and no canary mutation.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/resume-sleeping-agent-session.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/completed-worker-retirement-resume.unit.test.ts --reporter=verbose", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/resume-sleeping-agent-session.test.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/ipc/pty.test.ts src/main/runtime/orca-runtime.test.ts src/renderer/src/lib/pane-manager/pane-fit.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts", "pnpm exec electron-vite build --mode e2e && SKIP_BUILD=1 pnpm exec playwright test tests/e2e/live-background-terminal-mount-authority.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" ], "testFiles": [ "src/renderer/src/lib/resume-sleeping-agent-session.test.ts", + "tests/e2e/completed-worker-retirement-resume.unit.test.ts", "src/main/providers/local-pty-provider.test.ts", "src/main/daemon/terminal-host.test.ts", "src/main/daemon/daemon-pty-adapter.test.ts", @@ -2727,6 +2736,13 @@ "tests/e2e/live-background-terminal-mount-authority.spec.ts" ], "assertionRefs": [ + { + "file": "tests/e2e/completed-worker-retirement-resume.unit.test.ts", + "assertions": [ + "completed and explicitly retired workers retain no provider-resume authority on first workspace activation", + "the original tab, leaf, PTY, provider session, release state, replacement count, queued resume argv, emitted restored banner, and unrelated canary remain exact across restart" + ] + }, { "file": "src/renderer/src/lib/resume-sleeping-agent-session.test.ts", "assertions": [ @@ -7867,16 +7883,24 @@ "invariant": "A settled Dispatch may close only its one coordinator-created terminal lease. Explicit reuse, real user input, retain, identity or host change, ambiguity, and another resource for the same exact host/pane/process must fence closure. Output preservation and the requested-to-releasing transition are atomic, archives remain readable without the provider file, retries resume idempotently, and orchestration reset removes archive and authority state.", "oracle": "Record release intent for a settled owner, attempt exact reuse before close, and require worker-start to fail with terminal_release_in_progress while the terminal stays open; then release the original owner exactly once. Race retain and real user input against a controlled archive promise and require no committed archive or close. Change host or process identity and inject duplicate resource evidence to require retention. Freeze a structured transcript, delete its source file, and require archived worker-read to return the same bounded redacted messages. Restart a pending mutation, reset orchestration state, and create 50 resources while asserting replay convergence, zero orphan rows, two-query worker listing, and no unrelated close.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot" + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/completed-worker-retirement-resume.unit.test.ts --reporter=verbose" ], "testFiles": [ "src/main/runtime/rpc/methods/orchestration-worker-release.test.ts", "src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts", "src/main/runtime/rpc/orchestration-mutation-ledger.test.ts", "src/main/runtime/orchestration/worker-transcript-read.test.ts", - "src/renderer/src/lib/worker-terminal-takeover-report.test.ts" + "src/renderer/src/lib/worker-terminal-takeover-report.test.ts", + "tests/e2e/completed-worker-retirement-resume.unit.test.ts" ], "assertionRefs": [ + { + "file": "tests/e2e/completed-worker-retirement-resume.unit.test.ts", + "assertions": [ + "an already-exited succeeded Dispatch reaches released while its late exact close revokes the missing tab's stale resume record" + ] + }, { "file": "src/main/runtime/rpc/methods/orchestration-worker-release.test.ts", "assertions": [ diff --git a/config/scripts/audit-localization-coverage.mjs b/config/scripts/audit-localization-coverage.mjs index fd750a9150d..e25a7bc222e 100644 --- a/config/scripts/audit-localization-coverage.mjs +++ b/config/scripts/audit-localization-coverage.mjs @@ -7,6 +7,9 @@ import process from 'node:process' import ts from 'typescript-api' const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts']) +// Why: test-only modules live beside their spec as `*-test-harness.ts` / `*-fixtures.ts` here, not under `__tests__/`. +const TEST_SUPPORT_FILE_PATTERN = + /[.-](?:test-harness|test-fixtures?|test-state|test-support|fixtures?)\.[cm]?[jt]sx?$/ const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets']) const LOCALIZATION_CALL_NAMES = new Set(['t', 'translate']) const USER_VISIBLE_JSX_ATTRIBUTES = new Set([ @@ -73,13 +76,14 @@ function normalizePath(root, filePath) { return path.relative(root, filePath).split(path.sep).join('/') } -function isSkippedFile(root, filePath) { +export function isSkippedFile(root, filePath) { const relative = normalizePath(root, filePath) if ( relative.endsWith('.d.ts') || relative.includes('.test.') || relative.includes('.spec.') || - relative.includes('/__tests__/') + relative.includes('/__tests__/') || + TEST_SUPPORT_FILE_PATTERN.test(relative) ) { return true } diff --git a/config/scripts/audit-localization-coverage.test.mjs b/config/scripts/audit-localization-coverage.test.mjs index 291e6e29595..477054f2e8f 100644 --- a/config/scripts/audit-localization-coverage.test.mjs +++ b/config/scripts/audit-localization-coverage.test.mjs @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { collectLocalizationCandidates } from './audit-localization-coverage.mjs' +import { collectLocalizationCandidates, isSkippedFile } from './audit-localization-coverage.mjs' const ROOT = process.cwd() @@ -42,3 +42,23 @@ describe('localization coverage candidates', () => { expect(reports).toEqual([]) }) }) + +describe('localization coverage file skipping', () => { + function skipped(relativePath) { + return isSkippedFile(ROOT, `${ROOT}/${relativePath}`) + } + + it('skips test-only modules that sit beside their spec', () => { + expect(skipped('src/renderer/src/components/browser-pane/stream-test-harness.ts')).toBe(true) + expect(skipped('src/renderer/src/hooks/ipc-events-test-fixtures.ts')).toBe(true) + expect(skipped('src/renderer/src/lib/session-test-state.ts')).toBe(true) + expect(skipped('src/renderer/src/store/slices/routing-fixture.ts')).toBe(true) + expect(skipped('src/renderer/src/components/tab-bar/icon-stub.fixture.tsx')).toBe(true) + }) + + it('still scans shipped modules whose names merely mention a fixture concept', () => { + expect(skipped('src/renderer/src/components/browser-pane/fixture-picker.tsx')).toBe(false) + expect(skipped('src/renderer/src/components/settings/fixtures-panel.tsx')).toBe(false) + expect(skipped('src/renderer/src/components/browser-pane/BrowserPane.tsx')).toBe(false) + }) +}) diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 2ecda0e80eb..edb6d6ed5db 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -4,6 +4,9 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' +const REPO_ROOT = join(import.meta.dirname, '..', '..') +const SRC_MAIN_DIR = join(REPO_ROOT, 'src', 'main') + const require = createRequire(import.meta.url) const electronBuilderConfig = require('../electron-builder.config.cjs') const { FileMatcher } = require('app-builder-lib/out/fileMatcher') @@ -171,6 +174,29 @@ describe('electron-builder config', () => { ) }) + // Why: the scanner service is forked with ELECTRON_RUN_AS_NODE, so asar is + // invisible to it and a packed worker entry fails closed — dropping every + // OpenCode session in packaged builds while dev stays green. Three legs must + // agree on the filename, so all three are read rather than hardcoded. + it('unpacks the OpenCode SQLite worker entry the scanner service forks', async () => { + const spawnSource = await readFile( + join(SRC_MAIN_DIR, 'ai-vault', 'session-scanner-opencode-sqlite-worker-spawn.ts'), + 'utf8' + ) + const entryFilename = spawnSource.match(/WORKER_ENTRY_FILENAME = '([^']+)'/)?.[1] + + expect(entryFilename).toBeDefined() + expect(electronBuilderConfig.asarUnpack).toContain(`out/main/${entryFilename}`) + + // Why: the emitted path comes from the rollup input key under + // entryFileNames '[name].js', not from the source filename — renaming the + // key alone would leave the other two legs agreeing on a file that no + // longer exists. + const viteConfig = await readFile(join(REPO_ROOT, 'electron.vite.config.ts'), 'utf8') + expect(viteConfig).toContain("entryFileNames: '[name].js'") + expect(viteConfig).toMatch(new RegExp(`'${entryFilename.replace(/\.js$/, '')}':\\s*resolve\\(`)) + }) + it('keeps the worker-thread hang watchdog inside app.asar', () => { expect(electronBuilderConfig.asarUnpack).not.toContain( 'out/main/main-thread-hang-watchdog-entry.js' diff --git a/config/scripts/locale-ko-key-overrides.json b/config/scripts/locale-ko-key-overrides.json index e8d13941ffa..bd0c3c15aee 100644 --- a/config/scripts/locale-ko-key-overrides.json +++ b/config/scripts/locale-ko-key-overrides.json @@ -1058,36 +1058,6 @@ "auto.components.feature.wall.EditorAnimatedVisual.e16479c1c5": { "ko": "[data-slash-menu] [data-slash-row].slash-active { background: rgba(24,24,27,0.07); box-shadow: inset 0 0 0 1px rgba(24,24,27,0.06); } [data-md-active-line][data-role=\"active\"] { color: rgb(113 113 122); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; } [data-md-active-line][data-role=\"h1\"] { color: inherit; font-family: inherit; font-size: 18px; font-weight: 700; letter-spacing: -0.01em; line-height: 1.2; margin-top: 6px; } [data-md-caret] { display: inline-block; width: 1.5px; height: 1em; background: currentColor; vertical-align: -2px; margin-left: 1px; animation: md-caret-blink 1.05s steps(1) infinite; } @keyframes md-caret-blink { 0%, 50% { opacity: 1 } 51%, 100% { opacity: 0 } } @keyframes md-block-in { from { opacity: 0; transform: translateY(-2px); } to { opacity: 1; transform: none; } } @keyframes md-cursor-ripple { 0% { transform: scale(0.4); opacity: 0.9; } 100% { transform: scale(1.4); opacity: 0; } } [data-clicking=\"1\"] [data-cursor-ripple] { animation: md-cursor-ripple 460ms ease-out forwards; }" }, - "auto.components.feature.wall.FeatureTourPreview.04d54d50ec": { - "ko": "orca · zsh" - }, - "auto.components.feature.wall.FeatureTourPreview.304ad0dfc1": { - "ko": "생각 중..." - }, - "auto.components.feature.wall.FeatureTourPreview.3822d8d14b": { - "ko": "fix/worktree-picker-truncates" - }, - "auto.components.feature.wall.FeatureTourPreview.47f16ecf34": { - "ko": "여러 작업을 동시에 진행하세요. 각 워크스페이스는 브랜치, terminal, agent 활동을 함께 보관합니다." - }, - "auto.components.feature.wall.FeatureTourPreview.5d6ee181b6": { - "ko": "워크스페이스를 열어 terminal로 돌아가고, 테스트와 로그, agents 용으로 패널을 나누세요." - }, - "auto.components.feature.wall.FeatureTourPreview.6218a9014d": { - "ko": "pnpm playwright test" - }, - "auto.components.feature.wall.FeatureTourPreview.6ed43cb0e0": { - "ko": "dashboard.spec.ts" - }, - "auto.components.feature.wall.FeatureTourPreview.70aa182266": { - "ko": "목표를 넘겨두고 자리를 비우세요. 코디네이터 agent가 작업을 나누어 병렬 PR을 만듭니다." - }, - "auto.components.feature.wall.FeatureTourPreview.f10c14dd9d": { - "ko": "탭을 오갈 필요 없이 GitHub 또는 Linear 백로그에서 선택하고 한 번의 클릭으로 워크스페이스를 시작하세요." - }, - "auto.components.feature.wall.FeatureTourWorkspaceCard.9220794f6a": { - "ko": "작업 중" - }, "auto.components.feature.wall.FeatureWallSetupChecklist.713cc529a5": { "ko": "마일스톤" }, @@ -1502,12 +1472,6 @@ "auto.components.new.workspace.ProjectCombobox.search": { "ko": "프로젝트 검색..." }, - "auto.components.new.workspace.ProjectHostSetupCombobox.empty": { - "ko": "이 프로젝트에 준비된 호스트가 없습니다." - }, - "auto.components.new.workspace.ProjectHostSetupCombobox.placeholder": { - "ko": "호스트 선택" - }, "auto.components.notification.sound.options.0acd3d384e": { "ko": "클랙" }, @@ -1520,9 +1484,6 @@ "auto.components.notification.sound.options.86af8d938c": { "ko": "봉 소리" }, - "auto.components.onboarding.AgentFeatureSetupStep.97dcdc010f": { - "ko": "기능 활성화" - }, "auto.components.onboarding.AgentStep.1eee1c7bd8": { "ko": "PATH에서 agents를 찾지 못했습니다. 나중에 설치할 항목을 선택하거나 빈 terminal로 계속 진행하세요." }, @@ -1538,12 +1499,6 @@ "auto.components.onboarding.AgentStep.yoloPermissionsTooltip": { "ko": "중단을 줄이기 위해 agents 권한 확인을 건너뜁니다." }, - "auto.components.onboarding.FeatureSetupChecklist.01426f3a23": { - "ko": "Agents가 사이트를 탐색하고, 페이지를 검사하고, 브라우저 작업을 처리할 수 있습니다." - }, - "auto.components.onboarding.FeatureSetupChecklist.c5292c409d": { - "ko": "Agents는 요청 시 앱 창을 검사하고 로컬 앱을 작동할 수 있습니다." - }, "auto.components.onboarding.IntegrationsStep.0b4a7d23ab": { "ko": "로그인 중" }, @@ -1556,18 +1511,6 @@ "auto.components.onboarding.OnboardingFlow.windowsTerminalSubtitle": { "ko": "새 창에서 사용할 기본 셸과 terminal에서 오른쪽 클릭이 동작하는 방식을 선택하세요." }, - "auto.components.onboarding.RepoStep.466108ab89": { - "ko": "선택한 호스트에 존재하는 경로를 입력하세요." - }, - "auto.components.onboarding.RepoStep.6558d50c69": { - "ko": "한 번에 여러 repos를 가져오고 싶으신가요? 상위 폴더를 선택하세요." - }, - "auto.components.onboarding.RepoStep.c33b190ca3": { - "ko": "호스트 경로만" - }, - "auto.components.onboarding.RepoStep.cf23006ba7": { - "ko": "선택한 호스트" - }, "auto.components.onboarding.ThemeStep.7ee9234e54": { "ko": "Ghostty 설정을 찾았습니다." }, @@ -4976,9 +4919,6 @@ "auto.components.ui.color.picker.faa855a582": { "ko": "Hex" }, - "auto.components.ui.team.multi.combobox.18ec58881e": { - "ko": "팀 검색..." - }, "auto.components.workspace.cleanup.WorkspaceCleanupDialog.1b18868569": { "ko": "리뷰 필요" }, diff --git a/config/scripts/plain-node-entry-guard.test.ts b/config/scripts/plain-node-entry-guard.test.ts index d65b29cd253..673b796fd43 100644 --- a/config/scripts/plain-node-entry-guard.test.ts +++ b/config/scripts/plain-node-entry-guard.test.ts @@ -3,7 +3,10 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import type { Plugin, Rollup } from 'vite' import { afterEach, describe, expect, it } from 'vitest' -import { createPlainNodeEntryGuardPlugin } from '../build-plugins/plain-node-entry-guard' +import { + createPlainNodeEntryGuardPlugin, + GUARDED_ENTRY_NAMES +} from '../build-plugins/plain-node-entry-guard' let outputDir: string | undefined @@ -45,16 +48,16 @@ function runWriteBundle(plugin: Plugin, dir: string, code = ''): void { ) } -function runCloseBundle(plugin: Plugin): void { +async function runCloseBundle(plugin: Plugin): Promise { const hook = plugin.closeBundle if (typeof hook !== 'function') { throw new Error('Expected closeBundle hook') } - hook.call({} as never) + await hook.call({} as never) } describe('plain Node entry guard', () => { - it('smoke-loads the daemon after output files are written', () => { + it('smoke-loads the daemon after output files are written', async () => { const dir = createOutputDir() const plugin = createPlainNodeEntryGuardPlugin() @@ -64,17 +67,17 @@ describe('plain Node entry guard', () => { 'console.error("Usage: daemon-entry "); process.exit(1)\n' ) - expect(() => runCloseBundle(plugin)).not.toThrow() + await expect(runCloseBundle(plugin)).resolves.toBeUndefined() }) - it('runs the deferred smoke from closeBundle', () => { + it('runs the deferred smoke from closeBundle', async () => { const dir = createOutputDir() const plugin = createPlainNodeEntryGuardPlugin() runWriteBundle(plugin, dir) writeFileSync(join(dir, 'daemon-entry.js'), "require('./missing-module')\n") - expect(() => runCloseBundle(plugin)).toThrow('failed to load under plain Node') + await expect(runCloseBundle(plugin)).rejects.toThrow('failed to load under plain Node') }) it('rejects Electron imports during the static bundle scan', () => { @@ -84,6 +87,70 @@ describe('plain Node entry guard', () => { 'requires electron' ) }) + + it('rejects Electron subpath requires', () => { + const plugin = createPlainNodeEntryGuardPlugin() + + expect(() => runWriteBundle(plugin, createOutputDir(), 'require("electron/main")')).toThrow( + 'requires electron' + ) + }) + + it('fails the smoke when the daemon exits zero on an empty argv', async () => { + const dir = createOutputDir() + const plugin = createPlainNodeEntryGuardPlugin() + + runWriteBundle(plugin, dir) + writeFileSync(join(dir, 'daemon-entry.js'), 'console.error("Usage: daemon-entry ")\n') + + await expect(runCloseBundle(plugin)).rejects.toThrow('did not reject an empty argv') + }) + + // daemon-entry installs a SIGTERM handler, so the smoke deadline only holds if + // it escalates to SIGKILL — spawnSync's own timeout would block here forever. + it('kills a daemon that traps SIGTERM and never exits', async () => { + const dir = createOutputDir() + const plugin = createPlainNodeEntryGuardPlugin({ timeoutMs: 250, killGraceMs: 250 }) + + runWriteBundle(plugin, dir) + writeFileSync( + join(dir, 'daemon-entry.js'), + "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)\n" + ) + + await expect(runCloseBundle(plugin)).rejects.toThrow('did not exit within 250ms') + }, 10_000) +}) + +// Why: writeBundle skips names absent from the bundle, so a renamed rollup input +// would silently stop guarding that entry instead of failing the build. +describe('guarded entry names', () => { + function runBuildStart(plugin: Plugin, input: unknown): void { + const hook = plugin.buildStart + if (typeof hook !== 'function') { + throw new Error('Expected buildStart hook') + } + hook.call({} as never, { input } as Rollup.NormalizedInputOptions) + } + + it('rejects a guarded name that is no longer a rollup input', () => { + const plugin = createPlainNodeEntryGuardPlugin() + const input = Object.fromEntries( + GUARDED_ENTRY_NAMES.filter((name) => name !== 'stt-worker').map((name) => [ + name, + `${name}.ts` + ]) + ) + + expect(() => runBuildStart(plugin, input)).toThrow('"stt-worker"') + }) + + it('passes when every guarded name is a rollup input', () => { + const plugin = createPlainNodeEntryGuardPlugin() + const input = Object.fromEntries(GUARDED_ENTRY_NAMES.map((name) => [name, `${name}.ts`])) + + expect(() => runBuildStart(plugin, input)).not.toThrow() + }) }) // Why (#11161): Electron's module is not registered on worker threads either — diff --git a/config/scripts/verify-agent-hook-stdin-lifecycle.mjs b/config/scripts/verify-agent-hook-stdin-lifecycle.mjs index 06ee9734783..079735c41ec 100644 --- a/config/scripts/verify-agent-hook-stdin-lifecycle.mjs +++ b/config/scripts/verify-agent-hook-stdin-lifecycle.mjs @@ -5,6 +5,7 @@ import { accessSync, chmodSync, constants as fsConstants, + mkdirSync, mkdtempSync, readFileSync, rmSync, @@ -311,43 +312,37 @@ async function verifyForwarding(scripts, home, payload) { } } -// Why: rewrite from the path embedded in the installed command, not a -// reconstructed join(home, ...). That way missing/failing-script cases cannot -// silently re-run the real script if the install layout changes. -function rewriteLauncherScriptPath(command, nextPath) { - const match = /if \[ -f '([^']+)'/.exec(command) - if (!match) { - throw new Error('Installed launcher command did not reference a quoted script path') - } - return command.replaceAll(match[1], nextPath) -} - async function verifyInstalledLauncher(home, payload) { const settingsPath = join(home, '.claude', 'settings.json') const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) const command = findStrings(settings).find( (value) => value.includes('claude-hook.sh') && value.includes('if [ -f ') ) - if (!command || !command.includes('] && [ -r ') || !command.includes('else cat >/dev/null')) { + if ( + !command || + !command.includes('"$HOME/.orca/agent-hooks/claude-hook.sh"') || + !command.includes('] && [ -r ') || + !command.includes('else { command -p cat') + ) { throw new Error('Electron did not install the guarded Claude launcher') } const scratch = mkdtempSync(join(tmpdir(), 'orca-hook-launcher-')) try { - const missingPath = join(scratch, 'missing-hook.sh') const missingResult = await runShell( - rewriteLauncherScriptPath(command, missingPath), + command, payload, - withoutOrcaEnvironment({ HOME: home }) + withoutOrcaEnvironment({ HOME: scratch }) ) assertSuccessfulWrite(missingResult, 'installed missing-script launcher') - const failingPath = join(scratch, 'failing-hook.sh') + const failingPath = join(scratch, '.orca', 'agent-hooks', 'claude-hook.sh') + mkdirSync(join(scratch, '.orca', 'agent-hooks'), { recursive: true }) writeFileSync(failingPath, '#!/bin/sh\ncat >/dev/null\nexit 7\n', 'utf8') chmodSync(failingPath, 0o755) const failingResult = await runShell( - rewriteLauncherScriptPath(command, failingPath), + command, payload, - withoutOrcaEnvironment({ HOME: home }) + withoutOrcaEnvironment({ HOME: scratch }) ) if (failingResult.exitCode !== 7 || failingResult.stdinErrors.length > 0) { throw new Error('Installed launcher did not preserve a running script failure') diff --git a/config/scripts/verify-computer-native.mjs b/config/scripts/verify-computer-native.mjs index 91c27e03a5b..ba23a49cdd0 100644 --- a/config/scripts/verify-computer-native.mjs +++ b/config/scripts/verify-computer-native.mjs @@ -27,6 +27,12 @@ const checks = [ ], enabled: true }, + { + name: 'Linux snapshot renderer tests', + command: 'python3', + args: ['native/computer-use-linux/runtime_render_test.py'], + enabled: true + }, { name: 'native provider argument guardrails', run: verifyNativeArgumentGuardrails, @@ -69,6 +75,19 @@ const checks = [ run: verifyWindowsProviderHandshake, enabled: process.platform === 'win32' }, + { + name: 'Windows snapshot renderer tests', + command: 'powershell.exe', + args: [ + '-NoLogo', + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-File', + 'native/computer-use-windows/runtime-render.test.ps1' + ], + enabled: process.platform === 'win32' + }, { name: 'macOS helper app bundle and signature', run: verifyMacOSHelperApp, diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 747780e416b..572e9dcd0f9 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -13,6 +13,7 @@ "../src/main/agent-hooks/managed-agent-hook-controls.ts", "../src/main/agent-hooks/managed-agent-hook-registry.ts", "../src/main/agent-hooks/managed-hook-script-refresh.ts", + "../src/main/agent-hooks/runtime-home-hook-command.ts", "../src/main/amp/hook-service.ts", "../src/main/antigravity/hook-service.ts", "../src/main/claude/hook-settings.ts", @@ -75,6 +76,7 @@ "../src/main/openclaude/hook-service.ts", "../src/main/rolling-file-backup.ts", "../src/main/startup/hydrate-shell-path.ts", + "../src/main/startup/windows-shell-path-ownership.ts", // Why: serve-electron-flag-parity.test.ts checks the Electron-side serve argv rewrite against this // project's serve spec; the module has no imports, so listing it pulls in nothing else. "../src/main/startup/serve-mode-argv.ts", diff --git a/docs/STYLEGUIDE.md b/docs/STYLEGUIDE.md index d795f74039a..a76d981dd2f 100644 --- a/docs/STYLEGUIDE.md +++ b/docs/STYLEGUIDE.md @@ -67,9 +67,9 @@ A common point of drift. Use these conventions for any list-style row (worktrees - **Idle:** transparent background. - **Hover:** `bg-accent` (in the worktree sidebar, `bg-sidebar-accent`). -- **Keyboard-selected (cmdk highlight):** `data-[selected=true]:bg-accent` plus a `border-border` outline so the active row stays visible while the user types. The `data-selected` attribute is set by `cmdk` automatically. +- **Keyboard-selected (cmdk highlight):** do **not** rely on flat `bg-accent` alone on light popover/dialog surfaces — `--accent` (#f5f5f5) is nearly identical to `--background` (#fff), so the cursor vanishes. Use the jump-palette recipe in `main.css` (`.jump-palette-item[data-selected='true']`): `color-mix` foreground into background (~12%) plus an inset ring. Expose the mix as `--jump-palette-selection-surface` when nested cutouts (status pips) must match. The `data-selected` attribute is set by `cmdk` automatically. - **Persistent "current" / "active" row** (e.g. the worktree the user is viewing): also `bg-accent`, _plus_ a `data-current="true"` attribute so CSS or future styling can distinguish it from the cmdk highlight. -- **Don't:** hardcode `bg-[#ededed]` / `bg-[#333333]` or invent a "selected" color. The accent token already adapts to light/dark and matches the rest of the app. +- **Don't:** hardcode `bg-[#ededed]` / `bg-[#333333]` or invent a "selected" color. Mix from existing tokens (`foreground`/`background`/`accent`) so light/dark stay aligned. ### Color mixing @@ -110,7 +110,7 @@ Don't add a fourth level. If something needs more emphasis than "floating," you' Use the shadcn primitives in `src/renderer/src/components/ui/` before writing anything custom. The shadcn-style wrappers in this folder follow a consistent pattern: -- Most carry a `data-slot=""` attribute on their root for CSS targeting — do not strip it. (The non-shadcn helpers in this folder — `sonner`, `repo-multi-combobox`, `team-multi-combobox` — don't follow this pattern and shouldn't be modeled when adding new primitives that should.) +- Most carry a `data-slot=""` attribute on their root for CSS targeting — do not strip it. (The non-shadcn helpers in this folder — `sonner` and `repo-multi-combobox` — don't follow this pattern and shouldn't be modeled when adding new primitives that should.) - Use `cn()` for class merging. Pass user `className` last so callers can override. - Use `class-variance-authority` (CVA) for variants when there are multiple. @@ -148,7 +148,7 @@ When a control has multiple plausible primitives, use this fork: | Drawer / panel sliding in from an edge | `Sheet` | `Dialog` centered | | Single choice from a known list | `Select` | Custom listbox | | Single choice with search / fuzzy filtering | `Command` inside `Popover` | `Select` (no search) | -| Multi-select with search | `repo-multi-combobox` / `team-multi-combobox` (mirror their pattern) | Roll a new one | +| Multi-select with search | `repo-multi-combobox` (mirror its pattern) | Roll a new one | | Transient confirmation ("Saved", "Copied") | `sonner` toast | `Dialog`, inline banner | | Persistent inline status ("3 errors") | inline text + `Badge` | toast (toasts disappear) | diff --git a/docs/assets/caffeinate-agent-menu-after.png b/docs/assets/caffeinate-agent-menu-after.png new file mode 100644 index 00000000000..393fbd5c0b1 Binary files /dev/null and b/docs/assets/caffeinate-agent-menu-after.png differ diff --git a/docs/assets/caffeinate-agent-menu-before.png b/docs/assets/caffeinate-agent-menu-before.png new file mode 100644 index 00000000000..7c12d9ca581 Binary files /dev/null and b/docs/assets/caffeinate-agent-menu-before.png differ diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 105f14e8c70..cf0f1dd7edb 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -154,7 +154,7 @@ import { ActionSheetModal } from '../../../../src/components/ActionSheetModal' import { MobileAgentIcon } from '../../../../src/components/MobileAgentIcon' import { TextInputModal } from '../../../../src/components/TextInputModal' import { ConfirmModal } from '../../../../src/components/ConfirmModal' -import { MobileRichMarkdownEditor } from '../../../../src/components/MobileRichMarkdownEditor' +import { MobileMarkdownReader } from '../../../../src/session/MobileMarkdownReader' import { MobileSyntaxSegments } from '../../../../src/components/MobileSyntaxSegments' import { CustomKeyModal, @@ -267,7 +267,6 @@ import { TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND, updateTerminalCwdFromStreamEvent } from '../../../../src/session/mobile-session-route-helpers' -import { resolveMarkdownFloatingActionsBottom } from '../../../../src/session/markdown-floating-actions-layout' import { resolveTabStripScrollOffset } from '../../../../src/session/tab-strip-scroll' import { activateOpenedSourceControlDiffTab } from '../../../../src/session/opened-mobile-session-tab' import { @@ -275,7 +274,7 @@ import { dismissMobileSessionCreateWarningState, reconcileMobileSessionCreateWarningState } from '../../../../src/session/mobile-session-create-warning-state' -import { colors, spacing } from '../../../../src/theme/mobile-theme' +import { colors } from '../../../../src/theme/mobile-theme' import { QuickCommandsTabButton } from '../../../../src/session/QuickCommandsTabButton' import { styles } from '../../../../src/session/mobile-session-styles' import type { DiffComment, TerminalQuickCommand } from '../../../../../src/shared/types' @@ -302,132 +301,6 @@ import type { const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450 -function MarkdownReader({ - documentId, - doc, - onRefresh, - onChange, - onSave, - onCopy, - onDiscard, - keyboardLift -}: { - documentId: string - doc: MarkdownDocState | undefined - onRefresh: () => void - onChange: (content: string) => void - onSave: () => void - onCopy: () => void - onDiscard: () => void - keyboardLift: number -}) { - // Native Keyboard events under-report the WebView editor's covered area, so prefer the larger WebView-measured inset. - const [webviewKeyboardInset, setWebviewKeyboardInset] = useState(0) - const effectiveKeyboardLift = Math.max(keyboardLift, webviewKeyboardInset) - if (!doc || doc.status === 'loading') { - return ( - - - - ) - } - if (doc.status === 'error') { - return ( - - {doc.message} - - - Retry - - - ) - } - - const statusText = doc.saveError - ? doc.saveError - : doc.readOnlyReason - ? 'Read only' - : doc.stale - ? 'Changed on desktop' - : null - const showRefresh = (doc.stale && !doc.isDirty) || !doc.editable - const showCopy = doc.saveError || !doc.editable - const showSave = doc.isDirty || doc.saving - const showFloatingActions = statusText || showRefresh || showCopy || showSave - - return ( - - - {showFloatingActions ? ( - - {statusText ? ( - - {statusText} - - ) : null} - - {showCopy ? ( - - Copy - - ) : null} - {showRefresh ? ( - - - Refresh - - ) : null} - {doc.isDirty ? ( - - Discard - - ) : null} - {showSave ? ( - - {doc.saving ? ( - - ) : ( - Save - )} - - ) : null} - - - ) : null} - - ) -} - function DiffLineRow({ line, title, @@ -4636,7 +4509,7 @@ export default function SessionScreen() { ) : activeMarkdownTab ? ( - void readMarkdownTab(activeMarkdownTab)} diff --git a/mobile/app/h/[hostId]/tasks.tsx b/mobile/app/h/[hostId]/tasks.tsx index 677ded5aa78..553490b4748 100644 --- a/mobile/app/h/[hostId]/tasks.tsx +++ b/mobile/app/h/[hostId]/tasks.tsx @@ -115,13 +115,15 @@ import { import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme' import { triggerMediumImpact } from '../../../src/platform/haptics' import { - type GitHubProjectSortDirection, - type GitHubProjectTable as SharedGitHubProjectTable, groupRows, isIterationCurrent, sortRows, type ProjectGroup -} from '../../../src/tasks/mobile-github-project-group-sort' +} from '../../../../src/shared/github-project-group-sort' +import type { + GitHubProjectSortDirection, + GitHubProjectTable as SharedGitHubProjectTable +} from '../../../../src/shared/github-project-types' import { CROSS_REPO_DISPLAY_LIMIT, isGitHubWorkItemsSshRemoteRequiredError, diff --git a/mobile/src/components/MobilePrBasePicker.tsx b/mobile/src/components/MobilePrBasePicker.tsx deleted file mode 100644 index be9f92d65fc..00000000000 --- a/mobile/src/components/MobilePrBasePicker.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react' -import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native' -import { Check, ChevronDown } from 'lucide-react-native' -import { colors, radii, spacing, typography } from '../theme/mobile-theme' -import type { RpcClient } from '../transport/rpc-client' -import { searchBaseRefs } from '../source-control/mobile-base-ref-search' - -type Props = { - client: RpcClient | null - worktreeId: string - value: string - onChange: (ref: string) => void - editable?: boolean -} - -// Base-branch field for the create-PR composer: a free-text input that also searches -// repo refs (debounced) and offers matches to tap — the RN analogue of desktop's -// base-ref combobox. Free text stays valid so an SSH-only / unmatched ref can still -// be entered. -export function MobilePrBasePicker({ - client, - worktreeId, - value, - onChange, - editable = true -}: Props) { - const [results, setResults] = useState([]) - const [focused, setFocused] = useState(false) - const timer = useRef | null>(null) - // Guards: drop results after unmount, and ignore an earlier search whose response - // arrives after a later one (out-of-order network) so stale matches can't clobber. - const mounted = useRef(true) - const seq = useRef(0) - - useEffect(() => { - return () => { - mounted.current = false - if (timer.current) { - clearTimeout(timer.current) - } - } - }, []) - - const queryRefs = useCallback( - (query: string) => { - if (timer.current) { - clearTimeout(timer.current) - } - if (!client || query.trim().length === 0) { - // Advance the generation so an earlier in-flight search can't land and - // repopulate results after the input was cleared. - seq.current += 1 - setResults([]) - return - } - timer.current = setTimeout(() => { - const requestSeq = ++seq.current - void searchBaseRefs(client, worktreeId, query.trim()) - .then((refs) => { - if (!mounted.current || requestSeq !== seq.current) { - return - } - setResults(refs.filter((r) => r !== query).slice(0, 6)) - }) - // Why: a rejected ref search must not escape as an unhandled rejection; - // drop to an empty result set (free text stays valid to submit). - .catch(() => { - if (mounted.current && requestSeq === seq.current) { - setResults([]) - } - }) - }, 200) - }, - [client, worktreeId] - ) - - return ( - - - { - onChange(text) - queryRefs(text) - }} - onFocus={() => setFocused(true)} - onBlur={() => setFocused(false)} - placeholder="main" - placeholderTextColor={colors.textMuted} - autoCapitalize="none" - autoCorrect={false} - editable={editable} - /> - - - {focused && results.length > 0 ? ( - - {results.map((ref) => ( - [styles.resultRow, pressed && styles.resultRowPressed]} - onPress={() => { - onChange(ref) - setResults([]) - }} - > - - {ref} - - {ref === value ? ( - - ) : null} - - ))} - - ) : null} - - ) -} - -const styles = StyleSheet.create({ - inputShell: { - minHeight: 40, - flexDirection: 'row', - alignItems: 'center', - gap: spacing.xs, - backgroundColor: colors.bgRaised, - borderRadius: radii.input, - paddingHorizontal: spacing.md, - paddingVertical: spacing.xs - }, - inputShellDisabled: { - opacity: 0.6 - }, - input: { - flex: 1, - minWidth: 0, - padding: 0, - color: colors.textPrimary, - fontSize: typography.bodySize, - fontFamily: typography.monoFamily - }, - results: { - marginTop: spacing.xs, - borderWidth: StyleSheet.hairlineWidth, - borderColor: colors.borderSubtle, - borderRadius: radii.input, - backgroundColor: colors.bgPanel, - overflow: 'hidden' - }, - resultRow: { - minHeight: 40, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - gap: spacing.sm, - paddingHorizontal: spacing.md, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: colors.borderSubtle - }, - resultRowPressed: { backgroundColor: colors.bgRaised }, - resultText: { - flex: 1, - minWidth: 0, - color: colors.textPrimary, - fontSize: typography.bodySize, - fontFamily: typography.monoFamily - } -}) diff --git a/mobile/src/components/MobileRichMarkdownEditor.test.tsx b/mobile/src/components/MobileRichMarkdownEditor.test.tsx new file mode 100644 index 00000000000..20b17098d14 --- /dev/null +++ b/mobile/src/components/MobileRichMarkdownEditor.test.tsx @@ -0,0 +1,84 @@ +import { createElement, createRef, forwardRef, useImperativeHandle } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + MobileRichMarkdownEditor, + type MobileRichMarkdownEditorHandle +} from './MobileRichMarkdownEditor' + +const mocks = vi.hoisted(() => ({ + dismissKeyboard: vi.fn(), + injectJavaScript: vi.fn() +})) + +vi.mock('react-native', async () => { + const React = await import('react') + return { + Keyboard: { dismiss: mocks.dismissKeyboard }, + Linking: { openURL: vi.fn() }, + Pressable: 'Pressable', + ScrollView: ({ children, ...props }: { children?: unknown }) => + React.createElement('ScrollView', props, children), + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + View: 'View' + } +}) + +vi.mock('react-native-webview', () => { + const WebView = forwardRef((props: Record, ref) => { + useImperativeHandle(ref, () => ({ injectJavaScript: mocks.injectJavaScript })) + return createElement('WebView', props) + }) + return { WebView, default: WebView } +}) + +vi.mock('lucide-react-native', () => ({ + Bold: 'Bold', + ChevronDown: 'ChevronDown', + Code2: 'Code2', + FileCode2: 'FileCode2', + Heading1: 'Heading1', + Heading2: 'Heading2', + Heading3: 'Heading3', + ImageIcon: 'ImageIcon', + Italic: 'Italic', + Keyboard: 'Keyboard', + Link: 'Link', + List: 'List', + ListOrdered: 'ListOrdered', + ListTodo: 'ListTodo', + Pilcrow: 'Pilcrow', + Quote: 'Quote', + Strikethrough: 'Strikethrough' +})) + +describe('MobileRichMarkdownEditor', () => { + let renderer: ReactTestRenderer | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.clearAllMocks() + }) + + it('exposes WebView keyboard dismissal to its native parent', () => { + const editorRef = createRef() + act(() => { + renderer = create( + createElement(MobileRichMarkdownEditor, { + ref: editorRef, + content: '', + editable: true, + onChange: vi.fn() + }) + ) + }) + + act(() => editorRef.current?.dismissKeyboard()) + + expect(mocks.injectJavaScript).toHaveBeenCalledWith( + expect.stringContaining('window.__orcaRichMarkdown.dismissKeyboard()') + ) + expect(mocks.dismissKeyboard).toHaveBeenCalledOnce() + }) +}) diff --git a/mobile/src/components/MobileRichMarkdownEditor.tsx b/mobile/src/components/MobileRichMarkdownEditor.tsx index c9e0b44e082..8fbcaa149e7 100644 --- a/mobile/src/components/MobileRichMarkdownEditor.tsx +++ b/mobile/src/components/MobileRichMarkdownEditor.tsx @@ -1,5 +1,15 @@ -import { memo, useCallback, useEffect, useMemo, useRef, type ComponentType } from 'react' -import { Linking, Pressable, ScrollView, StyleSheet, View } from 'react-native' +import { + forwardRef, + memo, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + type ComponentType, + type ForwardedRef +} from 'react' +import { Keyboard, Linking, Pressable, ScrollView, StyleSheet, View } from 'react-native' import { Bold, Code2, @@ -77,6 +87,10 @@ type Props = { onKeyboardInsetChange?: (bottom: number) => void } +export type MobileRichMarkdownEditorHandle = { + dismissKeyboard: () => void +} + type EditorWebViewMessage = | { type: 'ready' } | { type: 'change'; markdown: string; generation: number } @@ -107,12 +121,10 @@ const TOOLBAR_ITEMS: ToolbarItem[] = [ { command: 'codeBlock', label: 'Code block', icon: FileCode2 } ] -function MobileRichMarkdownEditorInner({ - content, - editable, - onChange, - onKeyboardInsetChange -}: Props) { +function MobileRichMarkdownEditorInner( + { content, editable, onChange, onKeyboardInsetChange }: Props, + ref: ForwardedRef +) { const webViewRef = useRef(null) const readyRef = useRef(false) const documentGenerationRef = useRef(0) @@ -227,6 +239,15 @@ function MobileRichMarkdownEditorInner({ [inject] ) + const dismissKeyboard = useCallback(() => { + // Why: the caret lives in the WebView, so the injected blur is what closes the keyboard; + // Keyboard.dismiss only clears a native TextInput that stole focus first. + inject('window.__orcaRichMarkdown && window.__orcaRichMarkdown.dismissKeyboard();') + Keyboard.dismiss() + }, [inject]) + + useImperativeHandle(ref, () => ({ dismissKeyboard }), [dismissKeyboard]) + return ( @@ -278,7 +299,7 @@ function MobileRichMarkdownEditorInner({ ) } -export const MobileRichMarkdownEditor = memo(MobileRichMarkdownEditorInner) +export const MobileRichMarkdownEditor = memo(forwardRef(MobileRichMarkdownEditorInner)) const styles = StyleSheet.create({ container: { diff --git a/mobile/src/components/MobileSearchField.tsx b/mobile/src/components/MobileSearchField.tsx index 2dfd6bf26e4..cbeab92c208 100644 --- a/mobile/src/components/MobileSearchField.tsx +++ b/mobile/src/components/MobileSearchField.tsx @@ -149,8 +149,7 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', gap: spacing.sm, - // Why: bgRaised lifts the field off bgBase/bgPanel so search is an obvious - // control, matching TextInputModal / MobilePrBasePicker input shells. + // Keep search visually distinct from surrounding panels. backgroundColor: colors.bgRaised, borderWidth: 1, borderColor: colors.borderSubtle, diff --git a/mobile/src/components/mobile-rich-markdown-editor-html.test.ts b/mobile/src/components/mobile-rich-markdown-editor-html.test.ts index f21674478b1..b8321c7a2f2 100644 --- a/mobile/src/components/mobile-rich-markdown-editor-html.test.ts +++ b/mobile/src/components/mobile-rich-markdown-editor-html.test.ts @@ -11,9 +11,7 @@ function editorScript(): string { return script ?? '' } -function extractFunctionSource(script: string, name: string): string { - const start = script.indexOf(`function ${name}`) - expect(start).toBeGreaterThanOrEqual(0) +function extractBracedSource(script: string, start: number, label: string): string { const bodyStart = script.indexOf('{', start) let depth = 0 for (let index = bodyStart; index < script.length; index += 1) { @@ -28,7 +26,19 @@ function extractFunctionSource(script: string, name: string): string { } } } - throw new Error(`Could not extract ${name}`) + throw new Error(`Could not extract ${label}`) +} + +function extractFunctionSource(script: string, name: string): string { + const start = script.indexOf(`function ${name}`) + expect(start).toBeGreaterThanOrEqual(0) + return extractBracedSource(script, start, name) +} + +function extractEditorListenerSource(script: string, type: string): string { + const start = script.indexOf(`editor.addEventListener('${type}'`) + expect(start).toBeGreaterThanOrEqual(0) + return `${extractBracedSource(script, start, `${type} listener`)});` } function runtimeMarkdownToHtml(markdown: string, editable: boolean): string { @@ -65,6 +75,134 @@ function runtimeListMarkdown(): (list: unknown) => string { return new Function(sources)() as (list: unknown) => string } +type FakeRange = { + commonAncestorContainer: unknown + cloneRange: () => FakeRange + selectNodeContents: (node: unknown) => void + collapse: (toStart: boolean) => void +} + +function createFakeRange(container: unknown): FakeRange { + const range: FakeRange = { + commonAncestorContainer: container, + cloneRange: () => createFakeRange(range.commonAncestorContainer), + selectNodeContents: (node) => { + range.commonAncestorContainer = node + }, + collapse: () => {} + } + return range +} + +type FakeClickEvent = { + clientX: number + clientY: number + target: { closest: (selector: string) => unknown } + preventDefault: () => void +} + +// Drives the editor's real selection and click handling against a stub DOM whose blur +// drops the selection, the way WebKit does. +function createSelectionRuntime(caretContainer: string | null) { + const liveNodes = new Set(caretContainer ? [caretContainer] : []) + const listeners = new Map void>() + let focused = caretContainer != null + let ranges: FakeRange[] = caretContainer ? [createFakeRange(caretContainer)] : [] + let caretAtPoint: string | null = null + + const editor = { + contains: (node: unknown) => typeof node === 'string' && liveNodes.has(node), + focus: () => { + focused = true + fakeDocument.activeElement = editor + }, + blur: () => { + focused = false + fakeDocument.activeElement = null + ranges = [] + }, + addEventListener: (type: string, handler: (event: FakeClickEvent) => void) => { + listeners.set(type, handler) + } + } + const fakeDocument: { + activeElement: unknown + createRange: () => FakeRange + caretRangeFromPoint: () => FakeRange | null + } = { + activeElement: focused ? editor : null, + createRange: () => createFakeRange('detached'), + caretRangeFromPoint: () => (caretAtPoint ? createFakeRange(caretAtPoint) : null) + } + const fakeWindow = { + getSelection: () => ({ + get rangeCount() { + return ranges.length + }, + getRangeAt: (index: number) => ranges[index], + removeAllRanges: () => { + ranges = [] + }, + addRange: (range: FakeRange) => { + ranges = [range] + } + }) + } + + const script = editorScript() + const sources = [ + 'var editor = arguments[0];', + 'var document = arguments[1];', + 'var window = arguments[2];', + 'var editable = true;', + 'var savedSelectionRange = null;', + 'var selectionDroppedOnBlur = false;', + 'function post() {}', + 'function emitChange() {}', + extractFunctionSource(script, 'focusEditor'), + extractFunctionSource(script, 'rememberSelection'), + extractFunctionSource(script, 'applySelectionRange'), + extractFunctionSource(script, 'caretRangeAtPoint'), + extractFunctionSource(script, 'dismissKeyboard'), + extractFunctionSource(script, 'restoreSelectionOrEnd'), + extractEditorListenerSource(script, 'click'), + 'return { dismissKeyboard: dismissKeyboard, restoreSelectionOrEnd: restoreSelectionOrEnd };' + ].join('\n') + const api = new Function(sources)(editor, fakeDocument, fakeWindow) as { + dismissKeyboard: () => void + restoreSelectionOrEnd: () => void + } + + return { + dismissKeyboard: api.dismissKeyboard, + restoreSelectionOrEnd: api.restoreSelectionOrEnd, + tapAt: (container: string, options?: { uneditableAncestor?: string }) => { + liveNodes.add(container) + caretAtPoint = container + listeners.get('click')?.({ + clientX: 12, + clientY: 34, + target: { + closest: (selector: string) => + selector === '[contenteditable="false"]' ? (options?.uneditableAncestor ?? null) : null + }, + preventDefault: () => {} + }) + }, + detachEditorContent: () => liveNodes.clear(), + selectedContainer: () => { + if (ranges.length === 0) { + return null + } + const container = ranges[0].commonAncestorContainer + return container === editor ? 'editor-end' : (container as string) + }, + get focused() { + return focused + } + } +} + describe('mobile rich markdown editor HTML', () => { it('builds parseable WebView JavaScript', () => { const script = editorScript() @@ -233,4 +371,64 @@ describe('mobile rich markdown editor HTML', () => { expect(emitChange).not.toContain('window.setTimeout') expect(emitChange).toContain('generation: pendingGeneration') }) + + it('exposes a keyboard dismissal command that blurs WebView focus', () => { + const script = editorScript() + const dismissKeyboard = extractFunctionSource(script, 'dismissKeyboard') + + expect(dismissKeyboard).toContain('rememberSelection()') + expect(dismissKeyboard).toContain('document.activeElement.blur()') + expect(dismissKeyboard).toContain('editor.blur()') + expect(script).toContain('dismissKeyboard: dismissKeyboard') + }) + + it('reclaims editor focus at the tapped caret after keyboard dismissal', () => { + const runtime = createSelectionRuntime('paragraph-3') + + runtime.dismissKeyboard() + runtime.tapAt('paragraph-7') + + expect(runtime.focused).toBe(true) + expect(runtime.selectedContainer()).toBe('paragraph-7') + }) + + it('leaves task-list label taps to the checkbox instead of refocusing the editor', () => { + const runtime = createSelectionRuntime('paragraph-3') + + runtime.dismissKeyboard() + runtime.tapAt('paragraph-7', { uneditableAncestor: 'task-label' }) + + expect(runtime.focused).toBe(false) + expect(runtime.selectedContainer()).toBe(null) + }) + + it('restores the pre-dismissal caret so commands do not insert at the document end', () => { + const runtime = createSelectionRuntime('paragraph-3') + + runtime.dismissKeyboard() + expect(runtime.selectedContainer()).toBe(null) + + runtime.restoreSelectionOrEnd() + + expect(runtime.selectedContainer()).toBe('paragraph-3') + expect(runtime.focused).toBe(true) + }) + + it('falls back to the document end when no caret was ever placed', () => { + const runtime = createSelectionRuntime(null) + + runtime.restoreSelectionOrEnd() + + expect(runtime.selectedContainer()).toBe('editor-end') + }) + + it('drops a remembered caret whose nodes left the document', () => { + const runtime = createSelectionRuntime('paragraph-3') + + runtime.dismissKeyboard() + runtime.detachEditorContent() + runtime.restoreSelectionOrEnd() + + expect(runtime.selectedContainer()).toBe('editor-end') + }) }) diff --git a/mobile/src/components/mobile-rich-markdown-editor-html.ts b/mobile/src/components/mobile-rich-markdown-editor-html.ts index 32fc12b59b2..b7ff1649075 100644 --- a/mobile/src/components/mobile-rich-markdown-editor-html.ts +++ b/mobile/src/components/mobile-rich-markdown-editor-html.ts @@ -1,5 +1,7 @@ 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' export function escapeInjectedJavaScriptString(value: string): string { return JSON.stringify(value).replace(/<\/script/gi, '<\\/script') @@ -559,6 +561,9 @@ export function buildMobileRichMarkdownEditorHtml(): string { window.clearTimeout(inputTimer); documentGeneration = Number(generation) || 0; suppressInput = true; + // Why: replacing innerHTML detaches the remembered caret's nodes. + savedSelectionRange = null; + selectionDroppedOnBlur = false; lastMarkdown = String(markdown || ''); editor.innerHTML = markdownToHtml(lastMarkdown); syncTaskCheckboxesDisabled(); @@ -571,39 +576,8 @@ export function buildMobileRichMarkdownEditorHtml(): string { syncTaskCheckboxesDisabled(); } - function focusEditor() { - editor.focus(); - } - - function restoreSelectionOrEnd() { - focusEditor(); - var selection = window.getSelection(); - if (!selection || selection.rangeCount > 0) return; - var range = document.createRange(); - range.selectNodeContents(editor); - range.collapse(false); - selection.removeAllRanges(); - selection.addRange(range); - } - - function wrapSelection(tagName) { - restoreSelectionOrEnd(); - var selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) return; - var range = selection.getRangeAt(0); - if (range.collapsed) return; - var wrapper = document.createElement(tagName); - try { - range.surroundContents(wrapper); - } catch (_error) { - wrapper.appendChild(range.extractContents()); - range.insertNode(wrapper); - } - selection.removeAllRanges(); - selection.selectAllChildren(wrapper); - emitChange(); - } - +${MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT} +${MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT} function runCommand(command) { if (!editable || editor.getAttribute('contenteditable') !== 'true') return; restoreSelectionOrEnd(); @@ -632,6 +606,7 @@ export function buildMobileRichMarkdownEditorHtml(): string { } editor.addEventListener('input', function () { + selectionDroppedOnBlur = false; if (editable) emitChange(); }); editor.addEventListener('change', function (event) { @@ -654,7 +629,19 @@ export function buildMobileRichMarkdownEditorHtml(): string { return; } var input = event.target && event.target.closest && event.target.closest('input[type="checkbox"]'); - if (!input) return; + if (!input) { + if (!editable) return; + // Why: a task-list label forwards its click to the checkbox, so refocusing here would steal it and re-open the keyboard. + var uneditable = event.target && event.target.closest && event.target.closest('[contenteditable="false"]'); + if (uneditable && uneditable !== editor) return; + selectionDroppedOnBlur = false; + if (document.activeElement === editor) return; + // Why: refocusing after a dismissal otherwise types at the stale caret, not where the user tapped. + var caret = caretRangeAtPoint(event.clientX, event.clientY); + focusEditor(); + if (caret && editor.contains(caret.commonAncestorContainer)) applySelectionRange(caret); + return; + } if (!editable) { event.preventDefault(); return; @@ -670,7 +657,7 @@ export function buildMobileRichMarkdownEditorHtml(): string { } }); - window.__orcaRichMarkdown = { setMarkdown: setMarkdown, setEditable: setEditable, runCommand: runCommand, currentMarkdown: currentMarkdown }; + window.__orcaRichMarkdown = { setMarkdown: setMarkdown, setEditable: setEditable, runCommand: runCommand, currentMarkdown: currentMarkdown, dismissKeyboard: dismissKeyboard }; ${MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT} post({ type: 'ready' }); })(); diff --git a/mobile/src/components/mobile-rich-markdown-keyboard-dismiss-script.ts b/mobile/src/components/mobile-rich-markdown-keyboard-dismiss-script.ts new file mode 100644 index 00000000000..a4520ce4261 --- /dev/null +++ b/mobile/src/components/mobile-rich-markdown-keyboard-dismiss-script.ts @@ -0,0 +1,13 @@ +// Composed into the editor document after MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT, whose +// rememberSelection/selectionDroppedOnBlur this depends on. +export const MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT = ` + function dismissKeyboard() { + // Why: WebKit discards the DOM selection on blur, so capture the caret before it goes. + rememberSelection(); + selectionDroppedOnBlur = true; + if (document.activeElement && document.activeElement.blur) { + document.activeElement.blur(); + } + editor.blur(); + } +` diff --git a/mobile/src/components/mobile-rich-markdown-selection-script.ts b/mobile/src/components/mobile-rich-markdown-selection-script.ts new file mode 100644 index 00000000000..c649bd09ed8 --- /dev/null +++ b/mobile/src/components/mobile-rich-markdown-selection-script.ts @@ -0,0 +1,71 @@ +// Caret and selection management for the WebView editor. Split out so the editor +// document script stays inside its line budget. +export const MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT = ` + var savedSelectionRange = null; + var selectionDroppedOnBlur = false; + + function focusEditor() { + editor.focus(); + } + + function rememberSelection() { + var selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return; + var range = selection.getRangeAt(0); + if (editor.contains(range.commonAncestorContainer)) savedSelectionRange = range.cloneRange(); + } + + function applySelectionRange(range) { + var selection = window.getSelection(); + if (!selection) return; + selection.removeAllRanges(); + selection.addRange(range); + savedSelectionRange = range.cloneRange(); + } + + function caretRangeAtPoint(x, y) { + if (document.caretRangeFromPoint) return document.caretRangeFromPoint(x, y); + if (!document.caretPositionFromPoint) return null; + var position = document.caretPositionFromPoint(x, y); + if (!position) return null; + var range = document.createRange(); + range.setStart(position.offsetNode, position.offset); + range.collapse(true); + return range; + } + + function restoreSelectionOrEnd() { + focusEditor(); + var selection = window.getSelection(); + if (!selection) return; + // Why: the blur dropped the live selection, so commands would otherwise insert at the document end. + if (selectionDroppedOnBlur && savedSelectionRange && editor.contains(savedSelectionRange.commonAncestorContainer)) { + selectionDroppedOnBlur = false; + applySelectionRange(savedSelectionRange); + return; + } + if (selection.rangeCount > 0) return; + var range = document.createRange(); + range.selectNodeContents(editor); + range.collapse(false); + applySelectionRange(range); + } + + function wrapSelection(tagName) { + restoreSelectionOrEnd(); + var selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return; + var range = selection.getRangeAt(0); + if (range.collapsed) return; + var wrapper = document.createElement(tagName); + try { + range.surroundContents(wrapper); + } catch (_error) { + wrapper.appendChild(range.extractContents()); + range.insertNode(wrapper); + } + selection.removeAllRanges(); + selection.selectAllChildren(wrapper); + emitChange(); + } +` diff --git a/mobile/src/components/pr-sidebar/MobilePrComposeForm.tsx b/mobile/src/components/pr-sidebar/MobilePrComposeForm.tsx deleted file mode 100644 index d832d38c88d..00000000000 --- a/mobile/src/components/pr-sidebar/MobilePrComposeForm.tsx +++ /dev/null @@ -1,289 +0,0 @@ -import { useCallback, useState } from 'react' -import { ActivityIndicator, Pressable, Switch, Text, TextInput, View } from 'react-native' -import { - ArrowRight, - GitMerge, - GitPullRequestArrow, - Sparkles, - TriangleAlert, - X -} from 'lucide-react-native' -import { colors } from '../../theme/mobile-theme' -import type { RpcClient } from '../../transport/rpc-client' -import type { RpcSuccess } from '../../transport/types' -import { triggerError, triggerSuccess } from '../../platform/haptics' -import { - createMobilePr, - getMobilePrCreateSuccessWarning, - shouldPushBeforeMobilePrCreate, - type MobilePrPrefill -} from '../../source-control/mobile-pr-create' -import { hostedReviewCopy } from '../../source-control/hosted-review-copy' -import { - getPrComposeDisabledReason, - isBaseHeadDistinct -} from '../../source-control/pr-compose-validation' -import { MobilePrBasePicker } from '../MobilePrBasePicker' -import { mobilePrComposeFormStyles as styles } from './mobile-pr-compose-form-styles' - -export type PrComposePrefill = MobilePrPrefill - -type Props = { - client: RpcClient | null - worktreeId: string - prefill: PrComposePrefill - // Head branch — enables the base≠head guard and the "from " hint. - head?: string | null - onCancel: () => void - onCreated: (url: string, warning?: string) => void -} - -// A plain View keeps the compose form usable inside the PR sidebar ScrollView. -export function MobilePrComposeForm({ - client, - worktreeId, - prefill, - head, - onCancel, - onCreated -}: Props) { - const copy = hostedReviewCopy(prefill.provider) - const ReviewIcon = prefill.provider === 'gitlab' ? GitMerge : GitPullRequestArrow - const [title, setTitle] = useState(prefill.title) - const [body, setBody] = useState(prefill.body) - const [base, setBase] = useState(prefill.base) - const [draft, setDraft] = useState(false) - const [generating, setGenerating] = useState(false) - const [submitting, setSubmitting] = useState(false) - const [error, setError] = useState(null) - const pushBeforeCreate = shouldPushBeforeMobilePrCreate(prefill) - - const generate = useCallback(async () => { - if (!client || generating) { - return - } - setGenerating(true) - setError(null) - try { - const response = await client.sendRequest('git.generatePullRequestFields', { - worktree: `id:${worktreeId}`, - base, - title, - body, - draft - }) - if (!response.ok) { - setError(response.error?.message || 'Failed to generate PR fields') - return - } - const result = (response as RpcSuccess).result as { - success?: boolean - fields?: { base: string; title: string; body: string; draft: boolean } - error?: string - } - if (result.success && result.fields) { - setBase(result.fields.base || base) - setTitle(result.fields.title || title) - setBody(result.fields.body || body) - setDraft(result.fields.draft) - } else if (result.error) { - setError(result.error) - } - } catch (err) { - triggerError() - setError(err instanceof Error ? err.message : 'Failed to generate PR fields') - } finally { - setGenerating(false) - } - }, [base, body, client, draft, generating, title, worktreeId]) - - const headRef = head ?? '' - const baseConflict = base.trim().length > 0 && !isBaseHeadDistinct(base, headRef) - const submitDisabledReason = getPrComposeDisabledReason({ - title, - base, - head: headRef, - generating, - reviewLabel: copy.reviewLabel - }) - const canSubmit = submitDisabledReason === null - const fieldsLocked = submitting || generating - - const submit = useCallback(async () => { - if (!client || submitting || !canSubmit) { - return - } - setSubmitting(true) - setError(null) - try { - const outcome = await createMobilePr(client, worktreeId, { - provider: prefill.provider, - base, - // Send the same head the submit guard validated against, so the PR opens - // from the validated branch instead of a host-inferred one. - ...(head ? { head } : {}), - title, - body, - draft, - pushBeforeCreate - }) - if (outcome.ok) { - triggerSuccess() - const warning = getMobilePrCreateSuccessWarning(outcome, prefill.provider) - if (warning) { - setError(warning) - } - onCreated(outcome.url, warning) - } else { - triggerError() - setError(outcome.error) - } - } finally { - setSubmitting(false) - } - }, [ - base, - body, - canSubmit, - client, - copy.titleLabel, - draft, - head, - onCreated, - prefill.provider, - pushBeforeCreate, - submitting, - title, - worktreeId - ]) - - return ( - - - - - New {copy.reviewLabel} - - - [styles.genButton, pressed && styles.genButtonPressed]} - disabled={generating || submitting} - onPress={() => void generate()} - accessibilityRole="button" - accessibilityLabel={`Generate ${copy.reviewLabel} details with AI`} - > - {generating ? ( - - ) : ( - - )} - {generating ? 'Generating…' : 'Generate'} - - - - - - - - {head ? ( - - - {head} - - - - {base || 'base'} - - - ) : null} - - - - - - - {generating ? ( - - - Generating title and description… - - ) : null} - - - Base - - - - - - - Create as draft - - - {error || submitDisabledReason ? ( - - - {error ?? submitDisabledReason} - - ) : null} - [ - styles.submit, - (submitting || !canSubmit) && styles.submitDisabled, - pressed && styles.submitPressed - ]} - disabled={submitting || !canSubmit} - onPress={() => void submit()} - accessibilityRole="button" - > - {submitting ? ( - - ) : ( - - )} - - {pushBeforeCreate - ? draft - ? `Push & create draft ${copy.shortLabel}` - : `Push & create ${copy.shortLabel}` - : draft - ? `Create draft ${copy.shortLabel}` - : `Create ${copy.shortLabel}`} - - - - ) -} diff --git a/mobile/src/components/pr-sidebar/PRCommentCard.tsx b/mobile/src/components/pr-sidebar/PRCommentCard.tsx index ce1ef33cb96..23d768b7814 100644 --- a/mobile/src/components/pr-sidebar/PRCommentCard.tsx +++ b/mobile/src/components/pr-sidebar/PRCommentCard.tsx @@ -7,7 +7,7 @@ import { canEditComment, isResolvableComment } from '../../session/pr-comment-ac import { ConfirmModal } from '../ConfirmModal' import { CommentMarkdown } from './CommentMarkdown' import { PRCommentComposer } from './PRCommentComposer' -import { formatPrCommentRelativeTime } from './pr-comment-time' +import { formatPrCommentRelativeTime } from '../../../../src/shared/pr-comment-time' import { prCommentsStyles as styles } from './pr-comments-styles' export type PRCommentRepoSlug = { owner: string; repo: string; host?: string } diff --git a/mobile/src/components/pr-sidebar/PRCommentsSection.tsx b/mobile/src/components/pr-sidebar/PRCommentsSection.tsx index bb473307619..8fb5de31037 100644 --- a/mobile/src/components/pr-sidebar/PRCommentsSection.tsx +++ b/mobile/src/components/pr-sidebar/PRCommentsSection.tsx @@ -12,12 +12,14 @@ import { CommentMarkdown } from './CommentMarkdown' import { PRCommentCard, type PRCommentCardActions } from './PRCommentCard' import { PRCommentComposer } from './PRCommentComposer' import { - PR_COMMENT_AUDIENCE_FILTERS, filterPRCommentsByAudience, getPRCommentAudienceCounts, - getPRCommentAudienceEmptyLabel, type PRCommentAudienceFilter -} from './pr-comment-audience' +} from '../../../../src/shared/pr-comment-audience' +import { + PR_COMMENT_AUDIENCE_FILTERS, + getPRCommentAudienceEmptyLabel +} from './pr-comment-audience-labels' import { getPRCommentGroupCount, getPRCommentGroupId, @@ -25,7 +27,7 @@ import { groupPRComments, isResolvedPRCommentGroup, type PRCommentGroup -} from './pr-comment-groups' +} from '../../../../src/shared/pr-comment-groups' import { prCommentsStyles as styles } from './pr-comments-styles' import { mobilePrSidebarStyles as shared } from './mobile-pr-sidebar-styles' diff --git a/mobile/src/components/pr-sidebar/mobile-pr-compose-form-styles.ts b/mobile/src/components/pr-sidebar/mobile-pr-compose-form-styles.ts deleted file mode 100644 index 587082d972f..00000000000 --- a/mobile/src/components/pr-sidebar/mobile-pr-compose-form-styles.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { StyleSheet } from 'react-native' -import { colors, radii, spacing, typography } from '../../theme/mobile-theme' - -export const mobilePrComposeFormStyles = StyleSheet.create({ - root: { - gap: spacing.sm - }, - headingRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - gap: spacing.sm, - marginBottom: spacing.xs - }, - headingTitle: { - flex: 1, - minWidth: 0, - flexDirection: 'row', - alignItems: 'center', - gap: spacing.xs - }, - heading: { - color: colors.textPrimary, - fontSize: typography.bodySize, - fontWeight: '700' - }, - headingActions: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.xs - }, - genButton: { - minHeight: 32, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: spacing.xs, - paddingHorizontal: spacing.sm, - borderRadius: radii.button, - borderWidth: StyleSheet.hairlineWidth, - borderColor: colors.borderSubtle, - backgroundColor: colors.bgPanel - }, - genButtonPressed: { - opacity: 0.7 - }, - genButtonText: { - color: colors.textSecondary, - fontSize: typography.metaSize, - fontWeight: '700' - }, - iconButton: { - minWidth: 32, - minHeight: 32, - alignItems: 'center', - justifyContent: 'center', - borderRadius: radii.button - }, - branchFlow: { - minHeight: 28, - flexDirection: 'row', - alignItems: 'center', - gap: spacing.xs - }, - branchToken: { - maxWidth: 116, - color: colors.textPrimary, - fontSize: typography.metaSize, - fontFamily: typography.monoFamily - }, - branchTokenError: { - color: colors.statusRed - }, - fieldStack: { - gap: spacing.sm - }, - titleInput: { - minHeight: 40, - backgroundColor: colors.bgRaised, - borderRadius: radii.input, - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - color: colors.textPrimary, - fontSize: typography.bodySize, - fontWeight: '600' - }, - bodyInput: { - backgroundColor: colors.bgRaised, - borderRadius: radii.input, - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - color: colors.textPrimary, - fontSize: typography.bodySize, - // Why: a moderate fixed height avoids over-expanding inside the sidebar scroll. - minHeight: 120, - textAlignVertical: 'top' - }, - baseRow: { - minHeight: 40, - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm - }, - baseLabel: { - color: colors.textSecondary, - fontSize: typography.metaSize, - width: 36 - }, - baseControl: { - flex: 1, - minWidth: 0 - }, - draftRow: { - minHeight: 36, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - gap: spacing.sm, - borderWidth: StyleSheet.hairlineWidth, - borderColor: colors.borderSubtle, - borderRadius: radii.button, - backgroundColor: colors.bgPanel, - paddingHorizontal: spacing.sm - }, - draftText: { - color: colors.textPrimary, - fontSize: typography.metaSize, - fontWeight: '700' - }, - notice: { - flexDirection: 'row', - alignItems: 'flex-start', - gap: spacing.xs - }, - noticeText: { - flex: 1, - color: colors.textSecondary, - fontSize: typography.metaSize, - lineHeight: 18 - }, - errorText: { - color: colors.statusRed - }, - submit: { - marginTop: spacing.xs, - minHeight: 44, - borderRadius: radii.button, - backgroundColor: colors.textPrimary, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: spacing.xs - }, - submitDisabled: { - opacity: 0.45 - }, - submitPressed: { - opacity: 0.8 - }, - submitText: { - color: colors.bgBase, - fontSize: typography.bodySize, - fontWeight: '700' - } -}) diff --git a/mobile/src/components/pr-sidebar/pr-comment-audience-labels.ts b/mobile/src/components/pr-sidebar/pr-comment-audience-labels.ts new file mode 100644 index 00000000000..43f0d54289f --- /dev/null +++ b/mobile/src/components/pr-sidebar/pr-comment-audience-labels.ts @@ -0,0 +1,18 @@ +import type { PRCommentAudienceFilter } from '../../../../src/shared/pr-comment-audience' + +export const PR_COMMENT_AUDIENCE_FILTERS = [ + { value: 'all', label: 'All' }, + { value: 'human', label: 'Humans' }, + { value: 'bot', label: 'Bots' } +] satisfies { value: PRCommentAudienceFilter; label: string }[] + +export function getPRCommentAudienceEmptyLabel(filter: PRCommentAudienceFilter): string { + switch (filter) { + case 'bot': + return 'No bot comments.' + case 'human': + return 'No human comments.' + case 'all': + return 'No comments yet.' + } +} diff --git a/mobile/src/components/pr-sidebar/pr-comment-groups.ts b/mobile/src/components/pr-sidebar/pr-comment-groups.ts deleted file mode 100644 index 78bff853478..00000000000 --- a/mobile/src/components/pr-sidebar/pr-comment-groups.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { PRComment } from '../../../../src/shared/types' - -// Thread grouping for the PR comment timeline, ported from the desktop helper -// (src/renderer/src/lib/pr-comment-groups.ts) minus the DOM class constants. -// Inline review comments sharing a threadId collapse into a root + replies; all -// other comments are standalone. Upstream order is preserved. -export type PRCommentGroup = - | { kind: 'standalone'; comment: PRComment } - | { kind: 'thread'; threadId: string; root: PRComment; replies: PRComment[] } - -export function groupPRComments(comments: PRComment[]): PRCommentGroup[] { - const threadMap = new Map() - const groupsByFirstComment = new Map() - - for (const comment of comments) { - if (!comment.threadId) { - groupsByFirstComment.set(comment, { kind: 'standalone', comment }) - continue - } - const existing = threadMap.get(comment.threadId) - if (existing) { - existing.replies.push(comment) - continue - } - threadMap.set(comment.threadId, { root: comment, replies: [] }) - } - - const emitted = new Set() - const groups: PRCommentGroup[] = [] - for (const comment of comments) { - if (!comment.threadId) { - const group = groupsByFirstComment.get(comment) - if (group) { - groups.push(group) - } - continue - } - if (emitted.has(comment.threadId)) { - continue - } - emitted.add(comment.threadId) - const thread = threadMap.get(comment.threadId) - if (thread) { - groups.push({ kind: 'thread', threadId: comment.threadId, ...thread }) - } - } - return groups -} - -export function getPRCommentGroupComments(group: PRCommentGroup): PRComment[] { - return group.kind === 'thread' ? [group.root, ...group.replies] : [group.comment] -} - -export function getPRCommentGroupRoot(group: PRCommentGroup): PRComment { - return group.kind === 'thread' ? group.root : group.comment -} - -export function getPRCommentGroupCount(group: PRCommentGroup): number { - return getPRCommentGroupComments(group).length -} - -export function isResolvedPRCommentGroup(group: PRCommentGroup): boolean { - return getPRCommentGroupRoot(group).isResolved === true -} - -export function getPRCommentGroupId(group: PRCommentGroup): string { - return group.kind === 'thread' ? `thread:${group.threadId}` : `comment:${group.comment.id}` -} diff --git a/mobile/src/components/pr-sidebar/pr-comment-presentation.test.ts b/mobile/src/components/pr-sidebar/pr-comment-presentation.test.ts deleted file mode 100644 index e01fb4166dc..00000000000 --- a/mobile/src/components/pr-sidebar/pr-comment-presentation.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { PRComment } from '../../../../src/shared/types' -import { - createBotAuthorOverrideSet, - filterPRCommentsByAudience, - getPRCommentAudienceCounts, - isBotPRComment -} from './pr-comment-audience' -import { groupPRComments, isResolvedPRCommentGroup } from './pr-comment-groups' -import { formatPrCommentRelativeTime } from './pr-comment-time' - -function comment(overrides: Partial & { id: number }): PRComment { - return { - author: 'octocat', - authorAvatarUrl: '', - body: '', - createdAt: '', - url: '', - ...overrides - } -} - -describe('pr comment audience', () => { - it('classifies app bots, [bot] suffix, and known automation logins', () => { - expect(isBotPRComment(comment({ id: 1, author: 'alice' }))).toBe(false) - expect(isBotPRComment(comment({ id: 2, author: 'alice', isBot: true }))).toBe(true) - expect(isBotPRComment(comment({ id: 3, author: 'renovate[bot]' }))).toBe(true) - expect(isBotPRComment(comment({ id: 4, author: 'coderabbitai' }))).toBe(true) - }) - - it('counts and filters by audience', () => { - const comments = [ - comment({ id: 1, author: 'alice' }), - comment({ id: 2, author: 'dependabot[bot]' }), - comment({ id: 3, author: 'bob' }) - ] - expect(getPRCommentAudienceCounts(comments)).toEqual({ all: 3, human: 2, bot: 1 }) - expect(filterPRCommentsByAudience(comments, 'bot').map((c) => c.id)).toEqual([2]) - expect(filterPRCommentsByAudience(comments, 'human').map((c) => c.id)).toEqual([1, 3]) - expect(filterPRCommentsByAudience(comments, 'all')).toHaveLength(3) - }) - - it('classifies desktop-marked bot authors via overrides (desktop parity)', () => { - const overrides = createBotAuthorOverrideSet([' GretelFlux ']) - const comments = [comment({ id: 1, author: 'alice' }), comment({ id: 2, author: 'gretelflux' })] - expect(isBotPRComment(comments[1])).toBe(false) - expect(isBotPRComment(comments[1], overrides)).toBe(true) - expect(getPRCommentAudienceCounts(comments, overrides)).toEqual({ all: 2, human: 1, bot: 1 }) - expect(filterPRCommentsByAudience(comments, 'bot', overrides).map((c) => c.id)).toEqual([2]) - }) -}) - -describe('pr comment groups', () => { - it('threads comments sharing a threadId as root + replies, preserving order', () => { - const comments = [ - comment({ id: 1, author: 'a' }), - comment({ id: 2, author: 'b', threadId: 't1', isResolved: true }), - comment({ id: 3, author: 'c', threadId: 't1' }) - ] - const groups = groupPRComments(comments) - expect(groups).toHaveLength(2) - expect(groups[0]).toEqual({ kind: 'standalone', comment: comments[0] }) - expect(groups[1].kind).toBe('thread') - if (groups[1].kind === 'thread') { - expect(groups[1].root.id).toBe(2) - expect(groups[1].replies.map((r) => r.id)).toEqual([3]) - } - expect(isResolvedPRCommentGroup(groups[1])).toBe(true) - }) -}) - -describe('pr comment relative time', () => { - const now = Date.parse('2026-06-16T12:00:00Z') - it('formats buckets and rejects bad input', () => { - expect(formatPrCommentRelativeTime('2026-06-16T11:59:30Z', now)).toBe('just now') - expect(formatPrCommentRelativeTime('2026-06-16T11:30:00Z', now)).toBe('30m ago') - expect(formatPrCommentRelativeTime('2026-06-16T09:00:00Z', now)).toBe('3h ago') - expect(formatPrCommentRelativeTime('2026-06-10T12:00:00Z', now)).toBe('6d ago') - expect(formatPrCommentRelativeTime('not-a-date', now)).toBe('') - }) -}) diff --git a/mobile/src/components/pr-sidebar/pr-comment-time.ts b/mobile/src/components/pr-sidebar/pr-comment-time.ts deleted file mode 100644 index 47d78ea8a2f..00000000000 --- a/mobile/src/components/pr-sidebar/pr-comment-time.ts +++ /dev/null @@ -1,30 +0,0 @@ -// Relative timestamp for PR comments (ISO string in, "Xm/Xh/Xd/Xmo/Xy" out), -// mirroring the desktop formatRelativeTime so the timeline reads the same. Pure + -// unit-testable; nowMs is passed in (Date.now() is unavailable in some contexts). -export function formatPrCommentRelativeTime(iso: string, nowMs: number): string { - const ts = Date.parse(iso) - if (Number.isNaN(ts)) { - return '' - } - const delta = nowMs - ts - if (delta < 60_000) { - return 'just now' - } - const minutes = Math.floor(delta / 60_000) - if (minutes < 60) { - return `${minutes}m ago` - } - const hours = Math.floor(minutes / 60) - if (hours < 24) { - return `${hours}h ago` - } - const days = Math.floor(hours / 24) - if (days < 30) { - return `${days}d ago` - } - const months = Math.floor(days / 30) - if (months < 12) { - return `${months}mo ago` - } - return `${Math.floor(months / 12)}y ago` -} diff --git a/mobile/src/session/MobileMarkdownReader.test.tsx b/mobile/src/session/MobileMarkdownReader.test.tsx new file mode 100644 index 00000000000..c6ff322acba --- /dev/null +++ b/mobile/src/session/MobileMarkdownReader.test.tsx @@ -0,0 +1,119 @@ +import { createElement, forwardRef, useImperativeHandle } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MobileMarkdownReader } from './MobileMarkdownReader' +import type { MarkdownDocState } from './mobile-session-route-types' + +const mocks = vi.hoisted(() => ({ + dismissKeyboard: vi.fn(), + reportKeyboardInset: null as ((bottom: number) => void) | null +})) + +vi.mock('react-native', async () => { + const React = await import('react') + return { + ActivityIndicator: 'ActivityIndicator', + Platform: { OS: 'ios', select: (choices: Record) => choices.ios }, + Pressable: ({ children, ...props }: { children?: unknown }) => + React.createElement('Pressable', props, children), + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' + } +}) + +vi.mock('lucide-react-native', () => ({ + ChevronDown: 'ChevronDown', + Keyboard: 'KeyboardIcon', + RefreshCw: 'RefreshCw' +})) + +vi.mock('../components/MobileRichMarkdownEditor', () => { + const Editor = forwardRef( + (props: { onKeyboardInsetChange?: (bottom: number) => void }, ref: unknown) => { + mocks.reportKeyboardInset = props.onKeyboardInsetChange ?? null + useImperativeHandle(ref as never, () => ({ dismissKeyboard: mocks.dismissKeyboard })) + return createElement('MobileRichMarkdownEditor') + } + ) + return { MobileRichMarkdownEditor: Editor } +}) + +const readyDoc: MarkdownDocState = { + status: 'ready', + content: '# Notes', + localContent: '# Notes', + baseVersion: '1', + isDirty: false, + editable: true +} + +describe('MobileMarkdownReader', () => { + let renderer: ReactTestRenderer | null = null + + function render(keyboardLift: number, doc: MarkdownDocState = readyDoc): ReactTestRenderer { + act(() => { + renderer = create( + createElement(MobileMarkdownReader, { + documentId: 'doc-1', + doc, + keyboardLift, + onRefresh: vi.fn(), + onChange: vi.fn(), + onSave: vi.fn(), + onCopy: vi.fn(), + onDiscard: vi.fn() + }) + ) + }) + return renderer as unknown as ReactTestRenderer + } + + function dismissButtons(instance: ReactTestRenderer) { + return instance.root.findAll( + (node) => + typeof node.type === 'string' && node.props?.accessibilityLabel === 'Dismiss keyboard' + ) + } + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + mocks.reportKeyboardInset = null + vi.clearAllMocks() + }) + + it('offers keyboard dismissal while the keyboard covers the editor', () => { + const instance = render(291) + + const [button] = dismissButtons(instance) + expect(button).toBeDefined() + + act(() => button.props.onPress()) + + expect(mocks.dismissKeyboard).toHaveBeenCalledOnce() + }) + + it('hides the floating row entirely on a clean document with the keyboard closed', () => { + const instance = render(0) + + expect(dismissButtons(instance)).toHaveLength(0) + expect(instance.root.findAllByType('Text')).toHaveLength(0) + }) + + it('keeps document actions available without offering dismissal when the keyboard is closed', () => { + const instance = render(0, { ...readyDoc, isDirty: true }) + + expect(dismissButtons(instance)).toHaveLength(0) + expect(instance.root.findAllByType('Text').length).toBeGreaterThan(0) + }) + + it('treats a WebView-reported inset as an open keyboard when native lift reports none', () => { + const instance = render(0) + expect(dismissButtons(instance)).toHaveLength(0) + + act(() => mocks.reportKeyboardInset?.(291)) + + expect(dismissButtons(instance)).toHaveLength(1) + }) +}) diff --git a/mobile/src/session/MobileMarkdownReader.tsx b/mobile/src/session/MobileMarkdownReader.tsx new file mode 100644 index 00000000000..a84599ded3a --- /dev/null +++ b/mobile/src/session/MobileMarkdownReader.tsx @@ -0,0 +1,172 @@ +import { useRef, useState } from 'react' +import { ActivityIndicator, Pressable, Text, View } from 'react-native' +import { ChevronDown, Keyboard as KeyboardIcon, RefreshCw } from 'lucide-react-native' +import { + MobileRichMarkdownEditor, + type MobileRichMarkdownEditorHandle +} from '../components/MobileRichMarkdownEditor' +import { colors, spacing } from '../theme/mobile-theme' +import { + resolveMarkdownFloatingActionsBottom, + shouldShowMarkdownFloatingActions +} from './markdown-floating-actions-layout' +import type { MarkdownDocState } from './mobile-session-route-types' +import { styles } from './mobile-session-styles' + +type Props = { + documentId: string + doc: MarkdownDocState | undefined + onRefresh: () => void + onChange: (content: string) => void + onSave: () => void + onCopy: () => void + onDiscard: () => void + keyboardLift: number +} + +export function MobileMarkdownReader({ + documentId, + doc, + onRefresh, + onChange, + onSave, + onCopy, + onDiscard, + keyboardLift +}: Props) { + const editorRef = useRef(null) + // Native Keyboard events under-report the WebView editor's covered area, so prefer the larger WebView-measured inset. + const [webviewKeyboardInset, setWebviewKeyboardInset] = useState(0) + const effectiveKeyboardLift = Math.max(keyboardLift, webviewKeyboardInset) + const keyboardOpen = effectiveKeyboardLift > 0 + + if (!doc || doc.status === 'loading') { + return ( + + + + ) + } + if (doc.status === 'error') { + return ( + + {doc.message} + + + Retry + + + ) + } + + const statusText = doc.saveError + ? doc.saveError + : doc.readOnlyReason + ? 'Read only' + : doc.stale + ? 'Changed on desktop' + : null + const showRefresh = Boolean((doc.stale && !doc.isDirty) || !doc.editable) + const showCopy = Boolean(doc.saveError || !doc.editable) + const showSave = Boolean(doc.isDirty || doc.saving) + const showFloatingActions = shouldShowMarkdownFloatingActions({ + keyboardLift: effectiveKeyboardLift, + hasStatus: statusText != null, + showRefresh, + showCopy, + showSave + }) + + return ( + + + {showFloatingActions ? ( + + {statusText ? ( + + {statusText} + + ) : null} + + {keyboardOpen ? ( + editorRef.current?.dismissKeyboard()} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel="Dismiss keyboard" + accessibilityHint="Hides the software keyboard and keeps the markdown editor open." + > + + + + + + ) : null} + {showCopy ? ( + + Copy + + ) : null} + {showRefresh ? ( + + + Refresh + + ) : null} + {doc.isDirty ? ( + + Discard + + ) : null} + {showSave ? ( + + {doc.saving ? ( + + ) : ( + Save + )} + + ) : null} + + + ) : null} + + ) +} diff --git a/mobile/src/session/MobileNativeChatComposer.test.ts b/mobile/src/session/MobileNativeChatComposer.test.ts index eaf50ba08ab..1d1b0288467 100644 --- a/mobile/src/session/MobileNativeChatComposer.test.ts +++ b/mobile/src/session/MobileNativeChatComposer.test.ts @@ -298,11 +298,12 @@ describe('MobileNativeChatComposer', () => { }) it('moves the caret to the insert point after an autocomplete pick, then releases control', async () => { + const onChangeText = vi.fn() await act(async () => { renderer = create( createElement(MobileNativeChatComposer, { value: '/c', - onChangeText: vi.fn(), + onChangeText, onSend: vi.fn().mockResolvedValue(true), agent: 'claude' }) @@ -325,6 +326,7 @@ describe('MobileNativeChatComposer', () => { (node) => node.type === 'Pressable' && !node.props.accessibilityLabel )[0] as { props: { onPress: () => void } } await act(async () => firstSuggestion.props.onPress()) + expect(onChangeText).toHaveBeenCalledWith('/clear ') // `/clear ` is 7 chars — the caret jumps just past the inserted command + space. expect(input().props.selection).toEqual({ start: 7, end: 7 }) // The next native selection event releases control so manual placement still works. diff --git a/mobile/src/session/QuickCommandRow.tsx b/mobile/src/session/QuickCommandRow.tsx new file mode 100644 index 00000000000..401305b298c --- /dev/null +++ b/mobile/src/session/QuickCommandRow.tsx @@ -0,0 +1,199 @@ +import { useEffect, useRef, useState } from 'react' +import { View, Text, Pressable, StyleSheet } from 'react-native' +import * as Clipboard from 'expo-clipboard' +import { Check, Copy, Pencil, Play, Trash2 } from 'lucide-react-native' +import { colors, spacing, typography } from '../theme/mobile-theme' +import { MobileAgentIcon } from '../components/MobileAgentIcon' +import type { TerminalQuickCommand } from '../../../src/shared/types' +import { + getQuickCommandDisplayPreview, + getTerminalQuickCommandBody, + isAgentQuickCommand +} from '../terminal/quick-commands' + +type QuickCommandRowProps = { + command: TerminalQuickCommand + first: boolean + onLaunch: (command: TerminalQuickCommand) => void + onEdit: (command: TerminalQuickCommand) => void + onDelete: (command: TerminalQuickCommand) => void + disabled: boolean +} + +type CopyFeedback = { + body: string + status: 'copied' | 'failed' +} + +export function QuickCommandRow({ + command, + first, + onLaunch, + onEdit, + onDelete, + disabled +}: QuickCommandRowProps) { + const isAgent = isAgentQuickCommand(command) + const body = getTerminalQuickCommandBody(command) + const canCopy = body.trim().length > 0 + // Why: key feedback to the copied body so a prop change drops stale labels + // without setState-in-effect (react-doctor no-adjust-state-on-prop-change). + const [feedback, setFeedback] = useState(null) + const copyResetTimerRef = useRef | null>(null) + const mountedRef = useRef(true) + const copyStatus: 'idle' | 'copied' | 'failed' = + feedback != null && feedback.body === body ? feedback.status : 'idle' + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + if (copyResetTimerRef.current) { + clearTimeout(copyResetTimerRef.current) + } + } + }, []) + + // Drop any pending reset timer when the body changes; display status is already idle. + useEffect(() => { + if (copyResetTimerRef.current) { + clearTimeout(copyResetTimerRef.current) + copyResetTimerRef.current = null + } + }, [body]) + + const handleCopy = async (): Promise => { + if (!canCopy || disabled) { + return + } + try { + await Clipboard.setStringAsync(body) + if (!mountedRef.current) { + return + } + setFeedback({ body, status: 'copied' }) + } catch { + if (!mountedRef.current) { + return + } + setFeedback({ body, status: 'failed' }) + } + if (copyResetTimerRef.current) { + clearTimeout(copyResetTimerRef.current) + } + copyResetTimerRef.current = setTimeout(() => { + copyResetTimerRef.current = null + if (mountedRef.current) { + setFeedback(null) + } + }, 1500) + } + + const copyDisabled = disabled || !canCopy + const copyLabel = + copyStatus === 'copied' + ? 'Copied' + : copyStatus === 'failed' + ? "Couldn't copy" + : canCopy + ? `Copy ${command.label}` + : 'Nothing to copy' + const copyIconColor = + copyStatus === 'copied' + ? colors.statusGreen + : copyStatus === 'failed' + ? colors.statusRed + : colors.textSecondary + + return ( + + [styles.rowMain, pressed && !disabled && styles.pressed]} + disabled={disabled} + onPress={() => onLaunch(command)} + accessibilityRole="button" + accessibilityLabel={`Run ${command.label}`} + > + + {isAgent ? ( + + ) : ( + + )} + + + + {command.label} + + + {getQuickCommandDisplayPreview(command)} + + + + [ + styles.rowAction, + // Why: row already dims when `disabled`; only dim again for empty body. + !canCopy && styles.disabled, + pressed && !copyDisabled && styles.pressed + ]} + disabled={copyDisabled} + onPress={() => void handleCopy()} + accessibilityRole="button" + accessibilityLabel={copyLabel} + accessibilityState={{ disabled: copyDisabled }} + > + {copyStatus === 'copied' ? ( + + ) : ( + + )} + + [styles.rowAction, pressed && !disabled && styles.pressed]} + disabled={disabled} + onPress={() => onEdit(command)} + accessibilityLabel={`Edit ${command.label}`} + > + + + [styles.rowAction, pressed && !disabled && styles.pressed]} + disabled={disabled} + onPress={() => onDelete(command)} + accessibilityLabel={`Delete ${command.label}`} + > + + + + ) +} + +const styles = StyleSheet.create({ + pressed: { backgroundColor: colors.bgRaised }, + disabled: { opacity: 0.45 }, + row: { flexDirection: 'row', alignItems: 'center' }, + rowBorder: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.borderSubtle }, + rowMain: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + paddingVertical: spacing.md, + paddingLeft: spacing.md, + minWidth: 0 + }, + rowIcon: { + width: 26, + height: 26, + borderRadius: 6, + backgroundColor: colors.bgRaised, + alignItems: 'center', + justifyContent: 'center' + }, + rowText: { flex: 1, minWidth: 0 }, + rowLabel: { fontSize: 14, fontWeight: '600', color: colors.textPrimary }, + rowPreview: { fontSize: 12, color: colors.textSecondary, marginTop: 1 }, + mono: { fontFamily: typography.monoFamily }, + rowAction: { width: 40, height: 44, alignItems: 'center', justifyContent: 'center' } +}) diff --git a/mobile/src/session/QuickCommandsList.test.ts b/mobile/src/session/QuickCommandsList.test.ts index a396932acb6..37e41990a08 100644 --- a/mobile/src/session/QuickCommandsList.test.ts +++ b/mobile/src/session/QuickCommandsList.test.ts @@ -17,6 +17,7 @@ vi.mock('react-native', () => ({ vi.mock('lucide-react-native', () => ({ Check: 'Check', + Copy: 'Copy', Pencil: 'Pencil', Play: 'Play', Plus: 'Plus', @@ -24,6 +25,8 @@ vi.mock('lucide-react-native', () => ({ Trash2: 'Trash2' })) +vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() })) + vi.mock('../components/MobileAgentIcon', () => ({ MobileAgentIcon: 'MobileAgentIcon' })) describe('QuickCommandsList search', () => { diff --git a/mobile/src/session/QuickCommandsList.tsx b/mobile/src/session/QuickCommandsList.tsx index 2c76884d335..e8781163691 100644 --- a/mobile/src/session/QuickCommandsList.tsx +++ b/mobile/src/session/QuickCommandsList.tsx @@ -1,14 +1,11 @@ import { View, Text, Pressable, TextInput, StyleSheet, ActivityIndicator } from 'react-native' -import { Pencil, Plus, Search, Trash2, Check, Play } from 'lucide-react-native' -import { colors, spacing, typography } from '../theme/mobile-theme' +import { Check, Plus, Search } from 'lucide-react-native' +import { colors, spacing } from '../theme/mobile-theme' import { MobileAgentIcon } from '../components/MobileAgentIcon' import { MOBILE_AGENT_CATALOG } from '../tasks/mobile-agent-catalog' import type { TerminalQuickCommand, TuiAgent } from '../../../src/shared/types' -import { - getQuickCommandDisplayPreview, - isAgentQuickCommand, - supportsTerminalAgentQuickCommand -} from '../terminal/quick-commands' +import { supportsTerminalAgentQuickCommand } from '../terminal/quick-commands' +import { QuickCommandRow } from './QuickCommandRow' export const QUICK_COMMAND_SUPPORTED_AGENTS = MOBILE_AGENT_CATALOG.filter((entry) => supportsTerminalAgentQuickCommand(entry.id) @@ -162,67 +159,6 @@ function QuickCommandGroup({ ) } -function QuickCommandRow({ - command, - first, - onLaunch, - onEdit, - onDelete, - disabled -}: { - command: TerminalQuickCommand - first: boolean - onLaunch: (command: TerminalQuickCommand) => void - onEdit: (command: TerminalQuickCommand) => void - onDelete: (command: TerminalQuickCommand) => void - disabled: boolean -}) { - const isAgent = isAgentQuickCommand(command) - return ( - - [styles.rowMain, pressed && !disabled && styles.pressed]} - disabled={disabled} - onPress={() => onLaunch(command)} - accessibilityRole="button" - accessibilityLabel={`Run ${command.label}`} - > - - {isAgent ? ( - - ) : ( - - )} - - - - {command.label} - - - {getQuickCommandDisplayPreview(command)} - - - - [styles.rowAction, pressed && !disabled && styles.pressed]} - disabled={disabled} - onPress={() => onEdit(command)} - accessibilityLabel={`Edit ${command.label}`} - > - - - [styles.rowAction, pressed && !disabled && styles.pressed]} - disabled={disabled} - onPress={() => onDelete(command)} - accessibilityLabel={`Delete ${command.label}`} - > - - - - ) -} - export function QuickCommandAgentPicker({ selected, onSelect @@ -292,15 +228,6 @@ const styles = StyleSheet.create({ group: { backgroundColor: colors.bgPanel, borderRadius: 12, overflow: 'hidden' }, row: { flexDirection: 'row', alignItems: 'center' }, rowBorder: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.borderSubtle }, - rowMain: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - gap: spacing.md, - paddingVertical: spacing.md, - paddingLeft: spacing.md, - minWidth: 0 - }, rowIcon: { width: 26, height: 26, @@ -309,11 +236,6 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center' }, - rowText: { flex: 1, minWidth: 0 }, - rowLabel: { fontSize: 14, fontWeight: '600', color: colors.textPrimary }, - rowPreview: { fontSize: 12, color: colors.textSecondary, marginTop: 1 }, - mono: { fontFamily: typography.monoFamily }, - rowAction: { width: 40, height: 44, alignItems: 'center', justifyContent: 'center' }, agentLabel: { flex: 1, fontSize: 14, color: colors.textPrimary }, addRow: { flexDirection: 'row', diff --git a/mobile/src/session/markdown-floating-actions-layout.test.ts b/mobile/src/session/markdown-floating-actions-layout.test.ts index 021b8de958c..a4e139acc2f 100644 --- a/mobile/src/session/markdown-floating-actions-layout.test.ts +++ b/mobile/src/session/markdown-floating-actions-layout.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { resolveMarkdownFloatingActionsBottom } from './markdown-floating-actions-layout' +import { + resolveMarkdownFloatingActionsBottom, + shouldShowMarkdownFloatingActions +} from './markdown-floating-actions-layout' describe('resolveMarkdownFloatingActionsBottom', () => { it('keeps markdown actions at their resting bottom when the keyboard is closed', () => { @@ -22,3 +25,28 @@ describe('resolveMarkdownFloatingActionsBottom', () => { ).toBe(303) }) }) + +describe('shouldShowMarkdownFloatingActions', () => { + const idle = { + keyboardLift: 0, + hasStatus: false, + showRefresh: false, + showCopy: false, + showSave: false + } + + it('shows the floating row for keyboard dismissal without document actions', () => { + expect(shouldShowMarkdownFloatingActions({ ...idle, keyboardLift: 291 })).toBe(true) + }) + + it('hides the floating row on a clean document with the keyboard closed', () => { + expect(shouldShowMarkdownFloatingActions(idle)).toBe(false) + }) + + it.each(['hasStatus', 'showRefresh', 'showCopy', 'showSave'] as const)( + 'shows the floating row for %s alone', + (field) => { + expect(shouldShowMarkdownFloatingActions({ ...idle, [field]: true })).toBe(true) + } + ) +}) diff --git a/mobile/src/session/markdown-floating-actions-layout.ts b/mobile/src/session/markdown-floating-actions-layout.ts index 06aa5b9223f..bc506a88a9d 100644 --- a/mobile/src/session/markdown-floating-actions-layout.ts +++ b/mobile/src/session/markdown-floating-actions-layout.ts @@ -9,3 +9,19 @@ export function resolveMarkdownFloatingActionsBottom({ }): number { return keyboardLift > 0 ? keyboardLift + liftedClearance : restingBottom } + +export function shouldShowMarkdownFloatingActions({ + keyboardLift, + hasStatus, + showRefresh, + showCopy, + showSave +}: { + keyboardLift: number + hasStatus: boolean + showRefresh: boolean + showCopy: boolean + showSave: boolean +}): boolean { + return keyboardLift > 0 || hasStatus || showRefresh || showCopy || showSave +} diff --git a/mobile/src/session/mobile-native-chat-send.test.ts b/mobile/src/session/mobile-native-chat-send.test.ts index ccdacfd7b3e..cc70e2e3b0f 100644 --- a/mobile/src/session/mobile-native-chat-send.test.ts +++ b/mobile/src/session/mobile-native-chat-send.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client' @@ -7,10 +7,13 @@ import { openMobileNativeChatSendBudget, clearMobileNativeChatInput, sendMobileNativeChatMessage, - sendMobileNativeChatMessageWithOutcome + sendMobileNativeChatMessageWithOutcome, + typeMobileNativeChatCommandWithOutcome } from './mobile-native-chat-send' import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear' +afterEach(() => vi.useRealTimers()) + function clientWithResponse(response: unknown): RpcClient { return { sendRequest: vi.fn().mockResolvedValue(response) @@ -290,6 +293,65 @@ describe('sendMobileNativeChatMessage', () => { }) }) +describe('typeMobileNativeChatCommandWithOutcome', () => { + it('writes the Codex picker command as keys instead of one pasted text write', async () => { + vi.useFakeTimers() + const client = clientWithResponse({ + id: 'request', + ok: true, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime' } + }) + const result = typeMobileNativeChatCommandWithOutcome({ + client, + terminal: 'term', + command: '/model' + }) + await vi.runAllTimersAsync() + + await expect(result).resolves.toBe('accepted') + expect( + vi.mocked(client.sendRequest).mock.calls.map((call) => { + const params = call[1] as { text: string; enter: boolean } + return { text: params.text, enter: params.enter } + }) + ).toEqual( + ['\x15', '/', 'm', 'o', 'd', 'e', 'l', '\r'].map((text) => ({ + text, + enter: false + })) + ) + }) + + it('retires a parked launch draft only with the final typed Enter', async () => { + vi.useFakeTimers() + const client = clientWithResponse({ + id: 'request', + ok: true, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime' } + }) + const result = typeMobileNativeChatCommandWithOutcome({ + client, + terminal: 'term', + command: '/model', + resolvedLaunchDraft: { text: 'seed', createdAt: 7 } + }) + await vi.runAllTimersAsync() + await result + + const params = vi.mocked(client.sendRequest).mock.calls.map((call) => call[1]) as Array<{ + text: string + resolvedLaunchDraft?: { text: string; createdAt: number } + }> + expect(params.slice(0, -1).every((entry) => entry.resolvedLaunchDraft === undefined)).toBe(true) + expect(params.at(-1)).toMatchObject({ + text: '\r', + resolvedLaunchDraft: { text: 'seed', createdAt: 7 } + }) + }) +}) + describe('clearMobileNativeChatInput', () => { const accepted = { id: 'request', diff --git a/mobile/src/session/mobile-native-chat-send.ts b/mobile/src/session/mobile-native-chat-send.ts index ce432c3efb9..28f91759168 100644 --- a/mobile/src/session/mobile-native-chat-send.ts +++ b/mobile/src/session/mobile-native-chat-send.ts @@ -2,6 +2,7 @@ import type { RpcClient } from '../transport/rpc-client' import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client' import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response' +import { typeAgentTuiCommand } from '../../../src/shared/agent-tui-command-typing' type MobileTerminalClient = { id: string @@ -93,6 +94,35 @@ export async function sendMobileNativeChatMessage( return (await sendMobileNativeChatMessageWithOutcome(args)) === 'accepted' } +export async function typeMobileNativeChatCommandWithOutcome(args: { + client: RpcClient + terminal: string + command: string + resolvedLaunchDraft?: { text: string; createdAt: number } + mobileClient?: MobileTerminalClient + deadline?: number +}): Promise { + let writeIndex = 0 + return typeAgentTuiCommand({ + command: args.command, + write: (key) => { + const isSubmit = writeIndex === args.command.length + 1 + writeIndex += 1 + return sendMobileNativeChatMessageWithOutcome({ + client: args.client, + terminal: args.terminal, + text: key, + enter: false, + ...(isSubmit && args.resolvedLaunchDraft + ? { resolvedLaunchDraft: args.resolvedLaunchDraft } + : {}), + ...(args.mobileClient ? { mobileClient: args.mobileClient } : {}), + ...(args.deadline === undefined ? {} : { deadline: args.deadline }) + }) + } + }) +} + /** * Clear the agent's input line as its OWN write, before any body. * diff --git a/mobile/src/session/mobile-session-review-comment-styles.ts b/mobile/src/session/mobile-session-review-comment-styles.ts index 8d8b2d11ccd..a3b80615446 100644 --- a/mobile/src/session/mobile-session-review-comment-styles.ts +++ b/mobile/src/session/mobile-session-review-comment-styles.ts @@ -159,6 +159,11 @@ export const mobileSessionReviewCommentStyles = StyleSheet.create({ paddingHorizontal: spacing.md, paddingVertical: spacing.xs }, + markdownKeyboardDismissButton: { + width: 34, + justifyContent: 'center', + paddingHorizontal: 0 + }, markdownSaveButton: { backgroundColor: colors.bgRaised }, diff --git a/mobile/src/session/use-mobile-native-chat-message-send.test.ts b/mobile/src/session/use-mobile-native-chat-message-send.test.ts index 06b30f5cdb4..dd67cf46da8 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.test.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.test.ts @@ -7,8 +7,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const sendWithOutcome = vi.fn() const clearInputWrite = vi.fn() +const typeCommandWithOutcome = vi.fn() vi.mock('./mobile-native-chat-send', () => ({ sendMobileNativeChatMessageWithOutcome: (...args: unknown[]) => sendWithOutcome(...args), + typeMobileNativeChatCommandWithOutcome: (...args: unknown[]) => typeCommandWithOutcome(...args), clearMobileNativeChatInput: (...args: unknown[]) => clearInputWrite(...args), openMobileNativeChatSendBudget: () => Date.now() + 15_000, MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS: 15_000, @@ -84,6 +86,8 @@ describe('useMobileNativeChatMessageSend', () => { sendWithOutcome.mockResolvedValue('accepted') clearInputWrite.mockReset() clearInputWrite.mockResolvedValue(true) + typeCommandWithOutcome.mockReset() + typeCommandWithOutcome.mockResolvedValue('accepted') acceptSend.mockReset() holdUnconfirmedSend.mockReset() onCommandSend.mockReset() @@ -215,24 +219,46 @@ describe('useMobileNativeChatMessageSend', () => { expect(onCommandSend).toHaveBeenCalledWith('/clear') }) - it('never creates an optimistic echo for an unknown slash token', async () => { - // `/model` is not in Claude's autocomplete catalog, but the session-option - // recorder still recognizes it without claiming a generic command ran. - mount(() => null) - await act(async () => { - await api!.send('/model sonnet') - }) - expect(acceptSend).not.toHaveBeenCalled() - expect(onCommandSend).toHaveBeenCalledWith('/model sonnet') - }) + it.each(['claude', 'openclaude'] as const)( + 'keeps %s composer slash sends pasted', + async (agent) => { + // `/model` is not in Claude's autocomplete catalog, but the session-option + // recorder still recognizes it without claiming a generic command ran. + mount(() => null, agent) + await act(async () => { + await api!.send('/model sonnet') + }) + expect(acceptSend).not.toHaveBeenCalled() + expect(onCommandSend).toHaveBeenCalledWith('/model sonnet') + expect(sendWithOutcome).toHaveBeenCalledOnce() + expect(typeCommandWithOutcome).not.toHaveBeenCalled() + } + ) - it('classifies per agent: /model is a catalog command for Codex', async () => { + it('types Codex composer slash sends instead of pasting them', async () => { mount(() => null, 'codex') await act(async () => { await api!.send('/model') }) expect(acceptSend).not.toHaveBeenCalled() expect(onCommandSend).toHaveBeenCalledWith('/model') + expect(typeCommandWithOutcome).toHaveBeenCalledWith( + expect.objectContaining({ command: '/model', terminal: 'term' }) + ) + expect(sendWithOutcome).not.toHaveBeenCalled() + }) + + it('keeps Codex skill sends pasted', async () => { + mount(() => null, 'codex') + await act(async () => { + await api!.send('$ref-oss') + }) + expect(acceptSend).not.toHaveBeenCalled() + expect(onCommandSend).toHaveBeenCalledWith('$ref-oss') + expect(sendWithOutcome).toHaveBeenCalledWith( + expect.objectContaining({ text: '$ref-oss', terminal: 'term' }) + ) + expect(typeCommandWithOutcome).not.toHaveBeenCalled() }) it('holds only chat sends for transcript confirmation on a lost ack', async () => { @@ -248,16 +274,44 @@ describe('useMobileNativeChatMessageSend', () => { expect(holdUnconfirmedSend).toHaveBeenCalledTimes(1) }) - it('dispatchCommand surfaces the outcome without echo or composer sync', async () => { - mount(() => ({ text: DRAFT, createdAt: 1 })) + it.each(['claude', 'openclaude'] as const)( + 'keeps %s session-option commands pasted', + async (agent) => { + mount(() => ({ text: DRAFT, createdAt: 1 }), agent) + let outcome: string | undefined + await act(async () => { + outcome = await api!.dispatchCommand('/model sonnet', { delivery: 'type' }) + }) + expect(outcome).toBe('accepted') + expect(acceptSend).not.toHaveBeenCalled() + expect(onCommandSend).not.toHaveBeenCalled() + expect(sentArgs().resolvedLaunchDraft).toBeUndefined() + } + ) + + it('routes typed picker commands around the pasted composer-text send', async () => { + mount(() => null, 'codex') let outcome: string | undefined await act(async () => { - outcome = await api!.dispatchCommand('/model sonnet') + outcome = await api!.dispatchCommand('/model', { delivery: 'type' }) }) expect(outcome).toBe('accepted') - expect(acceptSend).not.toHaveBeenCalled() - expect(onCommandSend).not.toHaveBeenCalled() - expect(sentArgs().resolvedLaunchDraft).toBeUndefined() + expect(typeCommandWithOutcome).toHaveBeenCalledWith( + expect.objectContaining({ command: '/model', terminal: 'term' }) + ) + expect(sendWithOutcome).not.toHaveBeenCalled() + }) + + it('types Codex session-option commands without caller delivery metadata', async () => { + mount(() => null, 'codex') + await act(async () => { + await api!.dispatchCommand('/model') + }) + + expect(typeCommandWithOutcome).toHaveBeenCalledWith( + expect.objectContaining({ command: '/model', terminal: 'term' }) + ) + expect(sendWithOutcome).not.toHaveBeenCalled() }) it('binds classification to the agent that started the send', async () => { diff --git a/mobile/src/session/use-mobile-native-chat-message-send.ts b/mobile/src/session/use-mobile-native-chat-message-send.ts index 338c800cecd..17f34b86d27 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.ts @@ -4,8 +4,11 @@ import { clearMobileNativeChatInput, openMobileNativeChatSendBudget, sendMobileNativeChatMessageWithOutcome, + typeMobileNativeChatCommandWithOutcome, type MobileNativeChatSendOutcome } from './mobile-native-chat-send' +import type { CatalogCommandDelivery } from '../../../src/shared/agent-session-option-catalog' +import { isSlashCommandDraft } from '../../../src/shared/native-chat-slash-commands' import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input' import { classifyMobileNativeChatSend } from './mobile-native-chat-send-classification' import { @@ -32,7 +35,10 @@ export type MobileNativeChatMessageSend = { answerQuestion: (text: string) => Promise /** Session-option command dispatch (e.g. `/model sonnet`) — never touches the * composer draft; callers need the outcome to track dispatched state. */ - dispatchCommand: (text: string) => Promise + dispatchCommand: ( + text: string, + options?: { delivery?: CatalogCommandDelivery } + ) => Promise } /** The native-chat send seam: one write path shared by composer sends, image @@ -150,40 +156,42 @@ export function useMobileNativeChatMessageSend(args: { return 'rejected' } } - const outcome = await sendMobileNativeChatMessageWithOutcome({ - client, - terminal: handle, - text, - // Why: pre-clear only when nothing was deliberately pasted first. The heal - // above fires only for terminals a mobile image paste marked, so a desktop - // launch-draft prefill parked on the input line would otherwise glue onto - // this message. An image send already led its own paste with Ctrl+U, and a - // second one here would wipe the image it just pasted (desktop's image path - // likewise clears once, before the paste, and never again). - // - // Also skipped once the dedicated clear above ran: the line is already - // empty, and a Ctrl+U written immediately before body text in the SAME - // write reaches the agent as a literal control character rather than a - // keypress (observed live as a stray \x15 heading the received message). - clearInputFirst: !images?.length && !seededLaunchDraft, - ...(syncComposer && typeof seededLaunchDraft?.createdAt === 'number' - ? { - resolvedLaunchDraft: { - text: seededLaunchDraft.text, - createdAt: seededLaunchDraft.createdAt - } - } - : {}), - deadline, - ...(deviceTokenRef.current - ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } - : {}) - }) + const classification = classifyMobileNativeChatSend(agent, text) + const mobileClient = deviceTokenRef.current + ? { id: deviceTokenRef.current, type: 'mobile' as const } + : undefined + const resolvedLaunchDraft = + syncComposer && typeof seededLaunchDraft?.createdAt === 'number' + ? { text: seededLaunchDraft.text, createdAt: seededLaunchDraft.createdAt } + : undefined + const outcome = + agent === 'codex' && + classification !== 'chat' && + isSlashCommandDraft(text) && + !images?.length + ? await typeMobileNativeChatCommandWithOutcome({ + client, + terminal: handle, + command: text, + ...(resolvedLaunchDraft ? { resolvedLaunchDraft } : {}), + ...(mobileClient ? { mobileClient } : {}), + deadline + }) + : await sendMobileNativeChatMessageWithOutcome({ + client, + terminal: handle, + text, + // Why: an image send already cleared before its paste; a second + // clear here would wipe the image before submission. + clearInputFirst: !images?.length && !seededLaunchDraft, + ...(resolvedLaunchDraft ? { resolvedLaunchDraft } : {}), + deadline, + ...(mobileClient ? { mobileClient } : {}) + }) // Why (desktop parity): a slash/skill send dispatches into the agent's own // TUI, not the conversation — the transcript never echoes it as a user // turn, so an optimistic bubble would never reconcile and the // unconfirmed hold could never observe a landing. - const classification = classifyMobileNativeChatSend(agent, text) if (outcome === 'unknown') { if (classification === 'chat') { // Why: an ack-lost send usually WAS delivered (issue seen on cellular @@ -269,12 +277,41 @@ export function useMobileNativeChatMessageSend(args: { // spaces a send's body and its Enter ~500ms apart — so without this lock an // apply lands between them and is submitted as part of the user's prompt. const dispatchCommand = useCallback( - async (text: string): Promise => { + async ( + text: string, + _options?: { delivery?: CatalogCommandDelivery } + ): Promise => { const terminal = handleRef.current if (terminal && !acquireMobileNativeChatTerminalWrite(terminal)) { return 'rejected' } try { + if (agentRef.current === 'codex') { + if (!client || !terminal || !enabled) { + return 'rejected' + } + const deadline = openMobileNativeChatSendBudget() + const mobileClient = deviceTokenRef.current + ? { id: deviceTokenRef.current, type: 'mobile' as const } + : undefined + if ( + !(await healMobileNativeChatStaleInput({ + client, + terminal, + deviceToken: deviceTokenRef.current, + deadline + })) + ) { + return 'rejected' + } + return typeMobileNativeChatCommandWithOutcome({ + client, + terminal, + command: text, + ...(mobileClient ? { mobileClient } : {}), + deadline + }) + } return await sendMessage(text, undefined, false, false) } finally { if (terminal) { @@ -282,7 +319,7 @@ export function useMobileNativeChatMessageSend(args: { } } }, - [handleRef, sendMessage] + [client, deviceTokenRef, enabled, handleRef, sendMessage] ) return { send, sendWithOutcome, answerQuestion, dispatchCommand } diff --git a/mobile/src/session/use-mobile-native-chat-session-options.test.ts b/mobile/src/session/use-mobile-native-chat-session-options.test.ts index d7cab9a4c37..a587f3c5747 100644 --- a/mobile/src/session/use-mobile-native-chat-session-options.test.ts +++ b/mobile/src/session/use-mobile-native-chat-session-options.test.ts @@ -14,7 +14,7 @@ describe('useMobileNativeChatSessionOptions', () => { let renderer: ReactTestRenderer | null = null let api: MobileNativeChatSessionOptionsController | null = null let hookArgs: HookArgs - const dispatchCommand = vi.fn<(command: string) => Promise>() + const dispatchCommand = vi.fn() const onAgentPicker = vi.fn() function Probe(): null { @@ -97,14 +97,23 @@ describe('useMobileNativeChatSessionOptions', () => { expect(api!.snapshot[0]).toMatchObject({ valueSource: 'unknown' }) }) - it('applies Codex model changes through the native command', async () => { + it('types the Codex picker command and switches to the terminal', async () => { mount({ agent: 'codex' }) - expect(api!.snapshot[0]?.action).toBeUndefined() + expect(api!.snapshot[0]?.action).toEqual({ type: 'agent-picker' }) await act(async () => { - await api!.setOption('model', 'gpt-5.5') + await api!.invokeAction('model') }) - expect(dispatchCommand).toHaveBeenCalledWith('/model gpt-5.5') - expect(onAgentPicker).not.toHaveBeenCalled() + expect(dispatchCommand).toHaveBeenCalledWith('/model', { delivery: 'type' }) + expect(onAgentPicker).toHaveBeenCalledOnce() + }) + + it('types the Codex effort picker command', async () => { + mount({ agent: 'codex', reportedModel: 'gpt-5.5' }) + await act(async () => { + await api!.invokeAction('effort') + }) + expect(dispatchCommand).toHaveBeenCalledWith('/model', { delivery: 'type' }) + expect(onAgentPicker).toHaveBeenCalledOnce() }) it('seeds the current model from a hook-reported provider model', () => { diff --git a/mobile/src/session/use-mobile-native-chat-session-options.ts b/mobile/src/session/use-mobile-native-chat-session-options.ts index 09779756631..66a929aeb56 100644 --- a/mobile/src/session/use-mobile-native-chat-session-options.ts +++ b/mobile/src/session/use-mobile-native-chat-session-options.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import { getAgentSessionOptionCatalog, type AgentSessionOptionCatalog, + type CatalogCommandDelivery, type CatalogModel } from '../../../src/shared/agent-session-option-catalog' import type { @@ -95,7 +96,10 @@ export function useMobileNativeChatSessionOptions(args: { scopeKey: string | null /** Provider model from live agent status, when the hook reported one. */ reportedModel: string | null - dispatchCommand: (command: string) => Promise + dispatchCommand: ( + command: string, + options?: { delivery?: CatalogCommandDelivery } + ) => Promise /** A model change that must happen in the agent's own TUI picker was * dispatched — bring the terminal view forward. */ onAgentPicker?: () => void @@ -297,7 +301,9 @@ export function useMobileNativeChatSessionOptions(args: { ?.options.find((option) => option.id === id)?.apply const midSession = apply?.midSession if (midSession?.kind === 'agent-picker') { - const outcome = await dispatchCommand(midSession.command) + const outcome = midSession.delivery + ? await dispatchCommand(midSession.command, { delivery: midSession.delivery }) + : await dispatchCommand(midSession.command) if (outcome === 'rejected') { return false } diff --git a/mobile/src/session/use-mobile-native-chat-session.test.ts b/mobile/src/session/use-mobile-native-chat-session.test.ts index dfe5f285937..2ff97a3370c 100644 --- a/mobile/src/session/use-mobile-native-chat-session.test.ts +++ b/mobile/src/session/use-mobile-native-chat-session.test.ts @@ -109,7 +109,10 @@ describe('useMobileNativeChatSession', () => { await Promise.resolve() }) - expect(state?.messages).toEqual([]) + // The retained window keeps rendering while the source is gone; what must + // never land is the page that resolved after it disappeared. + expect(state?.messages.map((entry) => entry.id)).not.toContain('stale-page') + expect(state?.messages).toHaveLength(40) expect(state?.status).toBe('idle') expect(state?.loadingEarlier).toBe(false) }) @@ -557,6 +560,30 @@ describe('useMobileNativeChatSession transcriptLoading', () => { }) }) + it('keeps the retained transcript rendered when the stream reports an error', async () => { + // A transient read failure must not blank a conversation the user is looking + // at; the last settled list for this identity stays until a read supersedes it. + let emitFrame: (frame: unknown) => void = () => {} + const client = { + subscribe: vi.fn((_method: string, _params: unknown, onData: (frame: unknown) => void) => { + emitFrame = onData + onData({ type: 'snapshot', messages: [message('a-1')], hasMore: false }) + return () => {} + }) + } as unknown as RpcClient + await mountAt(client, 'session-a') + expect(renders.at(-1)).toMatchObject({ status: 'ready', ids: ['a-1'] }) + + renders.length = 0 + await act(async () => emitFrame({ type: 'error', message: 'stream broke' })) + + expect(renders.at(-1)).toMatchObject({ + status: 'error', + transcriptLoading: false, + ids: ['a-1'] + }) + }) + it('never holds a cached list across a host/workspace source change', async () => { const firstClient = { subscribe: vi.fn((_method: string, _params: unknown, onData: (frame: unknown) => void) => { diff --git a/mobile/src/session/use-mobile-native-chat-session.ts b/mobile/src/session/use-mobile-native-chat-session.ts index 656847079f9..a79b5648dd7 100644 --- a/mobile/src/session/use-mobile-native-chat-session.ts +++ b/mobile/src/session/use-mobile-native-chat-session.ts @@ -273,11 +273,12 @@ export function useMobileNativeChatSession(args: { })() }, [client, agent, sessionId, transcriptPath, hasMore, setList]) + // Held for any unsettled read, not just an in-flight one: a stream error or a + // dropped client would otherwise trade the conversation for an error card. const visibleMessages = transcriptRetentionRef.current.visible({ identity, messages, - settled: settledReady, - loading: status === 'loading' + settled: settledReady }) return { diff --git a/mobile/src/source-control/mobile-base-ref-search.test.ts b/mobile/src/source-control/mobile-base-ref-search.test.ts deleted file mode 100644 index b59b4fe8da2..00000000000 --- a/mobile/src/source-control/mobile-base-ref-search.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { mapBaseRefResults } from './mobile-base-ref-search' - -describe('mapBaseRefResults', () => { - it('extracts a clean string list from a well-formed payload', () => { - expect(mapBaseRefResults({ refs: ['main', 'dev'] })).toEqual(['main', 'dev']) - }) - - it('returns [] for malformed payloads and drops bad entries', () => { - expect(mapBaseRefResults(null)).toEqual([]) - expect(mapBaseRefResults({})).toEqual([]) - expect(mapBaseRefResults({ refs: 'x' })).toEqual([]) - expect(mapBaseRefResults({ refs: ['ok', 2, '', null, 'two'] })).toEqual(['ok', 'two']) - }) -}) diff --git a/mobile/src/source-control/mobile-base-ref-search.ts b/mobile/src/source-control/mobile-base-ref-search.ts deleted file mode 100644 index 84a962a41e5..00000000000 --- a/mobile/src/source-control/mobile-base-ref-search.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { RpcClient } from '../transport/rpc-client' -import type { RpcSuccess } from '../transport/types' -import { mobileRepoSelectorFromWorktreeId } from './mobile-pr-create' - -export function mapBaseRefResults(raw: unknown): string[] { - if (raw === null || typeof raw !== 'object') { - return [] - } - const refs = (raw as { refs?: unknown }).refs - if (!Array.isArray(refs)) { - return [] - } - return refs.filter((r): r is string => typeof r === 'string' && r.length > 0) -} - -export async function searchBaseRefs( - client: Pick, - worktreeId: string, - query: string, - limit = 20 -): Promise { - const response = await client.sendRequest('repo.searchRefs', { - repo: mobileRepoSelectorFromWorktreeId(worktreeId), - query, - limit - }) - if (!response.ok) { - return [] - } - return mapBaseRefResults((response as RpcSuccess).result) -} diff --git a/mobile/src/source-control/mobile-source-control-primary-action-decision.ts b/mobile/src/source-control/mobile-source-control-primary-action-decision.ts deleted file mode 100644 index eb0927bcff6..00000000000 --- a/mobile/src/source-control/mobile-source-control-primary-action-decision.ts +++ /dev/null @@ -1,268 +0,0 @@ -import type { MobileGitUpstreamStatus } from './mobile-git-status' - -export type MobileSourceControlPrimaryActionKind = - | 'commit' - | 'stage' - | 'push' - | 'pull' - | 'sync' - | 'publish' - -export type MobileSourceControlRemoteOpKind = - | 'push' - | 'force_push' - | 'pull' - | 'sync' - | 'fetch' - | 'fast_forward' - | 'publish' - | 'rebase' - -export type MobileSourceControlPrimaryActionTitleIntent = - | 'commit_in_progress' - | 'force_push_in_progress' - | 'action_in_progress' - | 'remote_operation_in_progress' - | 'remote_operation_blocks_commit' - | 'resolve_conflicts_before_commit' - | 'commit_staged_changes' - | 'enter_commit_message' - | 'stage_all_changes' - | 'stage_file_to_commit' - | 'checkout_branch_before_publish' - | 'publish_branch' - | 'force_push_with_lease' - | 'sync_counts' - | 'pull_count' - | 'push_count' - | 'nothing_to_commit_up_to_date' - -export type MobileSourceControlPrimaryActionDecision = { - kind: MobileSourceControlPrimaryActionKind - disabled: boolean - labelIntent: MobileSourceControlPrimaryActionKind | 'force_push' - titleIntent: MobileSourceControlPrimaryActionTitleIntent - count?: number - ahead?: number - behind?: number - upstreamName?: string - requiresForceWithLease?: boolean -} - -export type MobileSourceControlPrimaryActionDecisionInputs = { - stagedCount: number - hasUnstagedChanges: boolean - hasStageableChanges: boolean - hasPartiallyStagedChanges: boolean - hasMessage: boolean - hasUnresolvedConflicts: boolean - isCommitting: boolean - isRemoteOperationActive: boolean - upstreamStatus: MobileGitUpstreamStatus | undefined - inFlightRemoteOpKind?: MobileSourceControlRemoteOpKind | null - branchCommitsAhead?: number - hasCurrentBranch?: boolean -} - -// Why: Metro cannot load runtime modules from the desktop/root `src/shared` -// tree. Keep this mobile mirror narrow and parity-tested against the shared -// commit-area decision core so the semantic ladder cannot drift silently. -export function resolveMobileSourceControlCommitAreaPrimaryActionDecision( - inputs: MobileSourceControlPrimaryActionDecisionInputs -): MobileSourceControlPrimaryActionDecision { - const { - stagedCount, - hasUnstagedChanges, - hasStageableChanges, - hasMessage, - hasUnresolvedConflicts, - isCommitting, - isRemoteOperationActive, - upstreamStatus, - branchCommitsAhead, - hasCurrentBranch = true - } = inputs - - if (isCommitting) { - return { - kind: 'commit', - labelIntent: 'commit', - titleIntent: 'commit_in_progress', - disabled: true - } - } - - if (isRemoteOperationActive) { - return resolveMobilePrimaryActionDuringRemoteOp(inputs) - } - - if (hasUnresolvedConflicts) { - return { - kind: 'commit', - labelIntent: 'commit', - titleIntent: 'resolve_conflicts_before_commit', - disabled: true - } - } - - const hasStaged = stagedCount > 0 - - if (hasStaged && hasMessage) { - return { - kind: 'commit', - labelIntent: 'commit', - titleIntent: 'commit_staged_changes', - disabled: false - } - } - - if (hasStaged && !hasMessage) { - return { - kind: 'commit', - labelIntent: 'commit', - titleIntent: 'enter_commit_message', - disabled: true - } - } - - if (!hasStaged && hasStageableChanges) { - return { - kind: 'stage', - labelIntent: 'stage', - titleIntent: 'stage_all_changes', - disabled: false - } - } - - if (!upstreamStatus) { - return { - kind: 'commit', - labelIntent: 'commit', - titleIntent: 'stage_file_to_commit', - disabled: true - } - } - - if (!upstreamStatus.hasUpstream) { - if (!hasCurrentBranch) { - return { - kind: 'commit', - labelIntent: 'commit', - titleIntent: 'checkout_branch_before_publish', - disabled: true - } - } - return { - kind: 'publish', - labelIntent: 'publish', - titleIntent: 'publish_branch', - disabled: false - } - } - - if (upstreamStatus.ahead > 0 && upstreamStatus.behind > 0) { - if (shouldForcePushWithLeaseForMobileUpstream(upstreamStatus)) { - return { - kind: 'push', - labelIntent: 'force_push', - titleIntent: 'force_push_with_lease', - disabled: false, - count: branchCommitsAhead, - upstreamName: upstreamStatus.upstreamName, - requiresForceWithLease: true - } - } - return { - kind: 'sync', - labelIntent: 'sync', - titleIntent: 'sync_counts', - disabled: false, - ahead: upstreamStatus.ahead, - behind: upstreamStatus.behind - } - } - - if (upstreamStatus.behind > 0) { - return { - kind: 'pull', - labelIntent: 'pull', - titleIntent: 'pull_count', - disabled: false, - count: upstreamStatus.behind - } - } - - if (upstreamStatus.ahead > 0) { - return { - kind: 'push', - labelIntent: 'push', - titleIntent: 'push_count', - disabled: false, - count: upstreamStatus.ahead - } - } - - return { - kind: 'commit', - labelIntent: 'commit', - titleIntent: hasUnstagedChanges ? 'stage_file_to_commit' : 'nothing_to_commit_up_to_date', - disabled: true - } -} - -function resolveMobilePrimaryActionDuringRemoteOp( - inputs: MobileSourceControlPrimaryActionDecisionInputs -): MobileSourceControlPrimaryActionDecision { - const { inFlightRemoteOpKind, hasUnresolvedConflicts } = inputs - const candidate = resolveMobileSourceControlCommitAreaPrimaryActionDecision({ - ...inputs, - isRemoteOperationActive: false - }) - const inFlightIsPrimaryKind = - inFlightRemoteOpKind === 'push' || - inFlightRemoteOpKind === 'pull' || - inFlightRemoteOpKind === 'sync' || - inFlightRemoteOpKind === 'publish' - - if (inFlightRemoteOpKind === 'force_push') { - return { - kind: 'push', - labelIntent: 'force_push', - titleIntent: 'force_push_in_progress', - disabled: true, - requiresForceWithLease: true - } - } - - if (inFlightIsPrimaryKind && candidate.kind !== inFlightRemoteOpKind) { - return { - kind: inFlightRemoteOpKind, - labelIntent: inFlightRemoteOpKind, - titleIntent: 'action_in_progress', - disabled: true - } - } - - const titleIntent = hasUnresolvedConflicts - ? 'resolve_conflicts_before_commit' - : candidate.kind === 'commit' - ? 'remote_operation_blocks_commit' - : 'remote_operation_in_progress' - - return { - ...candidate, - titleIntent, - disabled: true - } -} - -function shouldForcePushWithLeaseForMobileUpstream( - status: MobileGitUpstreamStatus | undefined -): boolean { - return ( - status?.hasUpstream === true && - status.ahead > 0 && - status.behind > 0 && - status.behindCommitsArePatchEquivalent === true - ) -} diff --git a/mobile/src/source-control/mobile-source-control-primary-action.test.ts b/mobile/src/source-control/mobile-source-control-primary-action.test.ts index 0b127d9bde3..8314ecb2d80 100644 --- a/mobile/src/source-control/mobile-source-control-primary-action.test.ts +++ b/mobile/src/source-control/mobile-source-control-primary-action.test.ts @@ -1,9 +1,4 @@ import { describe, expect, it, vi } from 'vitest' -import { resolveSourceControlCommitAreaPrimaryActionDecision } from '../../../src/shared/source-control-primary-action-decision' -import { - resolveMobileSourceControlCommitAreaPrimaryActionDecision, - type MobileSourceControlPrimaryActionDecisionInputs -} from './mobile-source-control-primary-action-decision' import { buildMobileSourceControlPrimaryAction, type MobileSourceControlPrimaryActionArgs, @@ -159,113 +154,3 @@ describe('buildMobileSourceControlPrimaryAction', () => { expect(action.disabled).toBe(false) }) }) - -function decisionInputs( - overrides: Partial = {} -): MobileSourceControlPrimaryActionDecisionInputs { - return { - stagedCount: 0, - hasUnstagedChanges: false, - hasStageableChanges: false, - hasPartiallyStagedChanges: false, - hasMessage: false, - hasUnresolvedConflicts: false, - isCommitting: false, - isRemoteOperationActive: false, - upstreamStatus: undefined, - ...overrides - } -} - -describe('mobile source-control primary action decision parity', () => { - it.each([ - { - name: 'dirty tree stages first', - input: decisionInputs({ - hasUnstagedChanges: true, - hasStageableChanges: true, - upstreamStatus: { hasUpstream: true, ahead: 0, behind: 2 } - }) - }, - { - name: 'staged message commits', - input: decisionInputs({ stagedCount: 1, hasMessage: true }) - }, - { - name: 'staged without message blocks commit', - input: decisionInputs({ stagedCount: 1, hasMessage: false }) - }, - { - name: 'unresolved conflicts block commit', - input: decisionInputs({ stagedCount: 1, hasMessage: true, hasUnresolvedConflicts: true }) - }, - { - name: 'unpublished branch publishes', - input: decisionInputs({ - upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }, - hasCurrentBranch: true - }) - }, - { - name: 'detached head blocks publish', - input: decisionInputs({ - upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }, - hasCurrentBranch: false - }) - }, - { - name: 'tracked ahead pushes', - input: decisionInputs({ upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 } }) - }, - { - name: 'tracked behind pulls', - input: decisionInputs({ upstreamStatus: { hasUpstream: true, ahead: 0, behind: 3 } }) - }, - { - name: 'tracked diverged syncs', - input: decisionInputs({ upstreamStatus: { hasUpstream: true, ahead: 2, behind: 3 } }) - }, - { - name: 'patch-equivalent diverged force-pushes with lease', - input: decisionInputs({ - branchCommitsAhead: 4, - upstreamStatus: { - hasUpstream: true, - upstreamName: 'origin/feature', - ahead: 10, - behind: 2, - behindCommitsArePatchEquivalent: true - } - }) - }, - { - name: 'in-flight pull mirrors pull', - input: decisionInputs({ - isRemoteOperationActive: true, - inFlightRemoteOpKind: 'pull', - upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 } - }) - }, - { - name: 'in-flight force push mirrors force push', - input: decisionInputs({ - isRemoteOperationActive: true, - inFlightRemoteOpKind: 'force_push', - upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 } - }) - }, - { - name: 'in-flight push blocks committable candidate', - input: decisionInputs({ - isRemoteOperationActive: true, - inFlightRemoteOpKind: 'push', - stagedCount: 1, - hasMessage: true - }) - } - ])('matches the shared commit-area decision for $name', ({ input }) => { - expect(resolveMobileSourceControlCommitAreaPrimaryActionDecision(input)).toEqual( - resolveSourceControlCommitAreaPrimaryActionDecision(input) - ) - }) -}) diff --git a/mobile/src/source-control/mobile-source-control-primary-action.ts b/mobile/src/source-control/mobile-source-control-primary-action.ts index f62a7770d16..2a993967b52 100644 --- a/mobile/src/source-control/mobile-source-control-primary-action.ts +++ b/mobile/src/source-control/mobile-source-control-primary-action.ts @@ -1,14 +1,17 @@ import { - resolveMobileSourceControlCommitAreaPrimaryActionDecision, - type MobileSourceControlPrimaryActionDecision, - type MobileSourceControlPrimaryActionKind, - type MobileSourceControlRemoteOpKind -} from './mobile-source-control-primary-action-decision' + resolveSourceControlCommitAreaPrimaryActionDecision, + type SourceControlCommitAreaPrimaryActionDecision, + type SourceControlRemoteOpKind +} from '../../../src/shared/source-control-primary-action-decision' import type { MobileGitBranchCompareResult } from './mobile-branch-compare' import type { MobileGitStatusResult } from './mobile-git-status' type GitStep = { method: string; params?: Record } +type MobileSourceControlPrimaryActionKind = SourceControlCommitAreaPrimaryActionDecision['kind'] +type MobileSourceControlPrimaryActionDecision = SourceControlCommitAreaPrimaryActionDecision +type MobileSourceControlRemoteOpKind = SourceControlRemoteOpKind + export type MobileSourceControlPrimaryAction = { kind: MobileSourceControlPrimaryActionKind label: string @@ -44,7 +47,7 @@ export type MobileSourceControlPrimaryActionArgs = { export function buildMobileSourceControlPrimaryAction( args: MobileSourceControlPrimaryActionArgs ): MobileSourceControlPrimaryAction { - const decision = resolveMobileSourceControlCommitAreaPrimaryActionDecision({ + const decision = resolveSourceControlCommitAreaPrimaryActionDecision({ stagedCount: args.stagedCount, hasUnstagedChanges: args.unstagedCount > 0, hasStageableChanges: args.stageablePaths.length > 0, @@ -167,8 +170,16 @@ function getMobilePrimaryActionHint(decision: MobileSourceControlPrimaryActionDe return 'Stage at least one file to commit.' case 'checkout_branch_before_publish': return 'Check out a branch before publishing commits.' + case 'checking_review_status': + return 'Checking review status.' + case 'review_already_merged': + return 'Nothing to commit. The review is already merged.' case 'publish_branch': return 'Publish this branch to origin.' + case 'push_linked_review': + return 'Push updates to the linked review branch.' + case 'linked_review_target_unavailable': + return 'The linked review branch is unavailable.' case 'force_push_with_lease': return 'Force push with lease to update the remote branch.' case 'sync_counts': diff --git a/mobile/src/source-control/pr-compose-validation.test.ts b/mobile/src/source-control/pr-compose-validation.test.ts deleted file mode 100644 index d0f76f55e4b..00000000000 --- a/mobile/src/source-control/pr-compose-validation.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - canSubmitPrCompose, - getPrComposeDisabledReason, - isBaseHeadDistinct -} from './pr-compose-validation' - -describe('isBaseHeadDistinct', () => { - it('is true when base and head differ', () => { - expect(isBaseHeadDistinct('main', 'feature')).toBe(true) - }) - - it('is false when they match after normalization (case-insensitive, prefix-stripped)', () => { - expect(isBaseHeadDistinct('main', 'main')).toBe(false) - expect(isBaseHeadDistinct('refs/heads/main', 'main')).toBe(false) - expect(isBaseHeadDistinct('origin/Main', 'main')).toBe(false) - expect(isBaseHeadDistinct('upstream/main', 'refs/heads/main')).toBe(false) - }) - - it('is false for an empty base', () => { - expect(isBaseHeadDistinct('', 'feature')).toBe(false) - }) -}) - -describe('canSubmitPrCompose', () => { - it('requires a non-empty title and a distinct base', () => { - expect(canSubmitPrCompose('Title', 'main', 'feature')).toBe(true) - expect(canSubmitPrCompose(' ', 'main', 'feature')).toBe(false) - expect(canSubmitPrCompose('Title', 'main', 'main')).toBe(false) - }) -}) - -describe('getPrComposeDisabledReason', () => { - it('returns null when the form can submit', () => { - expect( - getPrComposeDisabledReason({ - title: 'Title', - base: 'main', - head: 'feature', - generating: false, - reviewLabel: 'pull request' - }) - ).toBeNull() - }) - - it('names the active blocker', () => { - expect( - getPrComposeDisabledReason({ - title: 'Title', - base: 'main', - head: 'feature', - generating: true, - reviewLabel: 'pull request' - }) - ).toBe('Wait for generation to finish.') - expect( - getPrComposeDisabledReason({ - title: '', - base: 'main', - head: 'feature', - generating: false, - reviewLabel: 'merge request' - }) - ).toBe('Enter a merge request title.') - expect( - getPrComposeDisabledReason({ - title: 'Title', - base: '', - head: 'feature', - generating: false, - reviewLabel: 'pull request' - }) - ).toBe('Choose a base branch.') - expect( - getPrComposeDisabledReason({ - title: 'Title', - base: 'main', - head: 'main', - generating: false, - reviewLabel: 'pull request' - }) - ).toBe('Base branch must differ from the head branch.') - }) -}) diff --git a/mobile/src/source-control/pr-compose-validation.ts b/mobile/src/source-control/pr-compose-validation.ts deleted file mode 100644 index c04f5c3f5b2..00000000000 --- a/mobile/src/source-control/pr-compose-validation.ts +++ /dev/null @@ -1,55 +0,0 @@ -// Ref normalizers ported from src/shared/hosted-review-refs.ts (value-imported, not -// referenced across the package boundary — Metro's resolver is rooted at mobile/ and -// can't bundle a runtime import from repo-root/src, unlike erased `import type`s). -// Keep in sync with the shared versions so mobile and desktop compare refs identically. -function normalizeHeadRef(ref: string): string { - return ref - .trim() - .replace(/^refs\/heads\//, '') - .replace(/^refs\/remotes\/[^/]+\//, '') -} - -function normalizeBaseRef(ref: string): string { - return normalizeHeadRef(ref).replace(/^(origin|upstream)\//, '') -} - -// Submit-gating for the create-PR composer, matching desktop CreatePullRequestDialog: -// the base ref must be non-empty and must differ from the head branch after ref -// normalization (strip refs/heads, remote prefixes, origin/upstream), case-insensitive. -export function isBaseHeadDistinct(base: string, head: string): boolean { - const b = normalizeBaseRef(base).toLowerCase() - const h = normalizeHeadRef(head).toLowerCase() - return b.length > 0 && b !== h -} - -export function canSubmitPrCompose(title: string, base: string, head: string): boolean { - return title.trim().length > 0 && isBaseHeadDistinct(base, head) -} - -export function getPrComposeDisabledReason({ - title, - base, - head, - generating, - reviewLabel -}: { - title: string - base: string - head: string - generating: boolean - reviewLabel: string -}): string | null { - if (generating) { - return 'Wait for generation to finish.' - } - if (title.trim().length === 0) { - return `Enter a ${reviewLabel} title.` - } - if (base.trim().length === 0) { - return 'Choose a base branch.' - } - if (!isBaseHeadDistinct(base, head)) { - return 'Base branch must differ from the head branch.' - } - return null -} diff --git a/mobile/src/tasks/mobile-github-project-group-sort.ts b/mobile/src/tasks/mobile-github-project-group-sort.ts deleted file mode 100644 index 7b86d3c38c0..00000000000 --- a/mobile/src/tasks/mobile-github-project-group-sort.ts +++ /dev/null @@ -1,261 +0,0 @@ -export type GitHubProjectSortDirection = 'ASC' | 'DESC' - -export type GitHubProjectFieldValue = - | { kind: 'text'; text: string } - | { kind: 'number'; number: number } - | { kind: 'date'; date: string } - | { kind: 'single-select'; optionId: string; name: string } - | { kind: 'iteration'; iterationId: string; title: string } - | { kind: 'labels'; labels: Array<{ name: string }> } - | { kind: 'users'; users: Array<{ login: string }> } - -export type GitHubProjectField = { - id: string - name: string - kind: string - options?: Array<{ id: string }> - iterations?: Array<{ - id: string - title?: string - startDate: string - duration: number - completed: boolean - }> -} - -export type GitHubProjectRow = { - position?: number | null - fieldValuesByFieldId: Record -} - -export type GitHubProjectSort = { - direction: GitHubProjectSortDirection - field: GitHubProjectField -} - -export type GitHubProjectTable = { - selectedView: { - groupByFields: GitHubProjectField[] - sortByFields: GitHubProjectSort[] - } - rows: GitHubProjectRow[] -} - -export type ProjectGroup = { - key: string - label: string - iteration: { - startDate: string - duration: number - completed: boolean - } | null - rows: GitHubProjectRow[] -} - -const EMPTY_GROUP_KEY = '__empty__' -const UNKNOWN_INDEX_SENTINEL = Number.MAX_SAFE_INTEGER - -function labelForEmpty(field: GitHubProjectField): string { - return `No ${field.name}` -} - -function deriveStringValue(value: GitHubProjectFieldValue): string { - switch (value.kind) { - case 'text': - return value.text - case 'number': - return String(value.number) - case 'date': - return value.date - case 'single-select': - return value.name - case 'iteration': - return value.title - case 'labels': - return value.labels.map((label) => label.name).join(', ') - case 'users': - return value.users.map((user) => user.login).join(', ') - } -} - -function getFieldValueForGrouping( - row: GitHubProjectRow, - field: GitHubProjectField -): { key: string; label: string; orderHint: number; iteration: ProjectGroup['iteration'] } { - const value = row.fieldValuesByFieldId[field.id] - if (!value) { - return { - key: EMPTY_GROUP_KEY, - label: labelForEmpty(field), - orderHint: UNKNOWN_INDEX_SENTINEL, - iteration: null - } - } - if (field.kind === 'iteration' && value.kind === 'iteration') { - const iterations = field.iterations ?? [] - const idx = iterations.findIndex((it) => it.id === value.iterationId) - const meta = iterations.find((it) => it.id === value.iterationId) - return { - key: value.iterationId, - label: value.title || meta?.title || 'Iteration', - orderHint: idx === -1 ? UNKNOWN_INDEX_SENTINEL - 1 : idx, - iteration: meta - ? { startDate: meta.startDate, duration: meta.duration, completed: meta.completed } - : null - } - } - if (field.kind === 'single-select' && value.kind === 'single-select') { - const idx = (field.options ?? []).findIndex((option) => option.id === value.optionId) - return { - key: value.optionId, - label: value.name, - orderHint: idx === -1 ? UNKNOWN_INDEX_SENTINEL - 1 : idx, - iteration: null - } - } - const label = deriveStringValue(value) - return { key: `raw:${label}`, label, orderHint: 0, iteration: null } -} - -export function groupRows( - table: GitHubProjectTable, - rowsInOrder: GitHubProjectRow[] -): ProjectGroup[] { - const groupField = table.selectedView.groupByFields[0] - if (!groupField) { - return [{ key: 'all', label: '', iteration: null, rows: rowsInOrder }] - } - const buckets = new Map< - string, - { - label: string - orderHint: number - iteration: ProjectGroup['iteration'] - rows: GitHubProjectRow[] - } - >() - for (const row of rowsInOrder) { - const { key, label, orderHint, iteration } = getFieldValueForGrouping(row, groupField) - let bucket = buckets.get(key) - if (!bucket) { - bucket = { label, orderHint, iteration, rows: [] } - buckets.set(key, bucket) - } - bucket.rows.push(row) - } - const entries = Array.from(buckets.entries()) - entries.sort((a, b) => { - if (a[0] === EMPTY_GROUP_KEY) { - return 1 - } - if (b[0] === EMPTY_GROUP_KEY) { - return -1 - } - if (groupField.kind === 'iteration' || groupField.kind === 'single-select') { - return a[1].orderHint - b[1].orderHint - } - return a[1].label.localeCompare(b[1].label) - }) - return entries.map(([key, value]) => ({ - key, - label: value.label, - iteration: value.iteration, - rows: value.rows - })) -} - -function compareSort(a: GitHubProjectRow, b: GitHubProjectRow, sort: GitHubProjectSort): number { - const field = sort.field - const aValue = a.fieldValuesByFieldId[field.id] - const bValue = b.fieldValuesByFieldId[field.id] - if (!aValue && !bValue) { - return 0 - } - if (!aValue) { - return 1 - } - if (!bValue) { - return -1 - } - - let cmp = 0 - if ( - field.kind === 'single-select' && - aValue.kind === 'single-select' && - bValue.kind === 'single-select' - ) { - const options = field.options ?? [] - const aIdx = options.findIndex((option) => option.id === aValue.optionId) - const bIdx = options.findIndex((option) => option.id === bValue.optionId) - cmp = - (aIdx === -1 ? UNKNOWN_INDEX_SENTINEL : aIdx) - (bIdx === -1 ? UNKNOWN_INDEX_SENTINEL : bIdx) - } else if ( - field.kind === 'iteration' && - aValue.kind === 'iteration' && - bValue.kind === 'iteration' - ) { - const iterations = field.iterations ?? [] - const aIdx = iterations.findIndex((iteration) => iteration.id === aValue.iterationId) - const bIdx = iterations.findIndex((iteration) => iteration.id === bValue.iterationId) - cmp = - (aIdx === -1 ? UNKNOWN_INDEX_SENTINEL : aIdx) - (bIdx === -1 ? UNKNOWN_INDEX_SENTINEL : bIdx) - } else if (aValue.kind === 'number' && bValue.kind === 'number') { - cmp = aValue.number - bValue.number - } else if (aValue.kind === 'date' && bValue.kind === 'date') { - cmp = aValue.date.localeCompare(bValue.date) - } else if (aValue.kind === 'text' && bValue.kind === 'text') { - cmp = aValue.text.localeCompare(bValue.text) - } else if (aValue.kind === 'users' && bValue.kind === 'users') { - const aLogin = aValue.users[0]?.login ?? '' - const bLogin = bValue.users[0]?.login ?? '' - if (!aLogin && !bLogin) { - cmp = 0 - } else if (!aLogin) { - cmp = 1 - } else if (!bLogin) { - cmp = -1 - } else { - cmp = aLogin.localeCompare(bLogin) - } - } else if (aValue.kind === 'labels' && bValue.kind === 'labels') { - const aName = aValue.labels[0]?.name ?? '' - const bName = bValue.labels[0]?.name ?? '' - if (!aName && !bName) { - cmp = 0 - } else if (!aName) { - cmp = 1 - } else if (!bName) { - cmp = -1 - } else { - cmp = aName.localeCompare(bName) - } - } else { - return 0 - } - return sort.direction === 'DESC' ? -cmp : cmp -} - -export function sortRows(table: GitHubProjectTable, rows: GitHubProjectRow[]): GitHubProjectRow[] { - const sorts = table.selectedView.sortByFields - const out = [...rows] - out.sort((a, b) => { - for (const sort of sorts) { - const cmp = compareSort(a, b, sort) - if (cmp !== 0) { - return cmp - } - } - return (a.position ?? UNKNOWN_INDEX_SENTINEL) - (b.position ?? UNKNOWN_INDEX_SENTINEL) - }) - return out -} - -export function isIterationCurrent(iteration: { startDate: string; duration: number }): boolean { - const start = new Date(`${iteration.startDate}T00:00:00Z`).getTime() - if (Number.isNaN(start)) { - return false - } - const end = start + iteration.duration * 86_400_000 - const now = Date.now() - return now >= start && now < end -} diff --git a/mobile/src/terminal/quick-commands.ts b/mobile/src/terminal/quick-commands.ts index 40f92a55439..d6e6b86caf3 100644 --- a/mobile/src/terminal/quick-commands.ts +++ b/mobile/src/terminal/quick-commands.ts @@ -2,6 +2,7 @@ import type { TerminalQuickCommand, TuiAgent } from '../../../src/shared/types' import { applyTerminalQuickCommandMutation, flattenTerminalQuickCommand, + getTerminalQuickCommandBody, isTerminalAgentQuickCommand, MAX_QUICK_COMMANDS, MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH, @@ -20,6 +21,7 @@ import { MOBILE_TUI_AGENT_LABELS } from '../tasks/mobile-tui-agents' // — agent-branded labels, native row truncation, and the launch plan — live here. export { isTerminalAgentQuickCommand as isAgentQuickCommand, + getTerminalQuickCommandBody, terminalQuickCommandMatchesRepo as quickCommandMatchesRepo, supportsTerminalAgentQuickCommand, MAX_QUICK_COMMANDS, diff --git a/mobile/src/transport/pairing-connection-attempt.test.ts b/mobile/src/transport/pairing-connection-attempt.test.ts deleted file mode 100644 index b05867eb7c0..00000000000 --- a/mobile/src/transport/pairing-connection-attempt.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { startPairingConnectionAttempt } from './pairing-connection-attempt' - -describe('pairing connection attempt cleanup', () => { - afterEach(() => { - vi.useRealTimers() - }) - - it('closes the temporary client when the overall pairing timeout fires', () => { - vi.useFakeTimers() - const closeClient = vi.fn() - - const attempt = startPairingConnectionAttempt({ timeoutMs: 25_000, closeClient }) - - expect(attempt.timedOut).toBe(false) - vi.advanceTimersByTime(24_999) - expect(closeClient).not.toHaveBeenCalled() - - vi.advanceTimersByTime(1) - expect(attempt.timedOut).toBe(true) - expect(closeClient).toHaveBeenCalledTimes(1) - - attempt.dispose() - expect(closeClient).toHaveBeenCalledTimes(1) - }) - - it('clears the timeout and closes the temporary client when disposed early', () => { - vi.useFakeTimers() - const closeClient = vi.fn() - - const attempt = startPairingConnectionAttempt({ timeoutMs: 25_000, closeClient }) - attempt.dispose() - - expect(attempt.timedOut).toBe(false) - expect(closeClient).toHaveBeenCalledTimes(1) - - vi.advanceTimersByTime(25_000) - expect(attempt.timedOut).toBe(false) - expect(closeClient).toHaveBeenCalledTimes(1) - }) -}) diff --git a/mobile/src/transport/pairing-connection-attempt.ts b/mobile/src/transport/pairing-connection-attempt.ts deleted file mode 100644 index 0a2d564949a..00000000000 --- a/mobile/src/transport/pairing-connection-attempt.ts +++ /dev/null @@ -1,50 +0,0 @@ -export type PairingConnectionAttempt = { - readonly timedOut: boolean - dispose: () => void -} - -export function startPairingConnectionAttempt({ - timeoutMs, - closeClient -}: { - timeoutMs: number - closeClient: () => void -}): PairingConnectionAttempt { - let disposed = false - let clientClosed = false - let timedOut = false - let timer: ReturnType | null = null - - function closeClientOnce() { - if (clientClosed) { - return - } - clientClosed = true - closeClient() - } - - function dispose() { - if (disposed) { - return - } - disposed = true - if (timer) { - clearTimeout(timer) - timer = null - } - closeClientOnce() - } - - timer = setTimeout(() => { - timer = null - timedOut = true - dispose() - }, timeoutMs) - - return { - get timedOut() { - return timedOut - }, - dispose - } -} diff --git a/native/computer-use-linux/runtime.py b/native/computer-use-linux/runtime.py index baeed544bd6..ac53f6cf65f 100644 --- a/native/computer-use-linux/runtime.py +++ b/native/computer-use-linux/runtime.py @@ -408,8 +408,11 @@ def render_accessibility_tree(root, window_rect, root_path, compact_browser_tabs item = record(node, len(records), path, window_rect) child_items = list(children(node)) role_key = (item["controlType"] or "").lower() - summary_values = text_snippets(node, limit=8, max_depth=4) - generic_summary = " ".join(summary_values) if role_key in {"panel", "filler", "unknown", "section"} and not item["name"] and not item["value"] and len(summary_values) >= 2 and is_plain_text_subtree(node) else None + generic_summary = None + if role_key in {"panel", "filler", "unknown", "section"} and not item["name"] and not item["value"]: + summary_values = text_snippets(node, limit=8, max_depth=4) + if len(summary_values) >= 2 and is_plain_text_subtree(node): + generic_summary = " ".join(summary_values) if should_elide(item, len(child_items), generic_summary): for child_index, child in child_items: walk(child, depth, path + [child_index]) diff --git a/native/computer-use-linux/runtime_render_test.py b/native/computer-use-linux/runtime_render_test.py new file mode 100644 index 00000000000..83a600c7d2d --- /dev/null +++ b/native/computer-use-linux/runtime_render_test.py @@ -0,0 +1,198 @@ +import importlib.util +import sys +import types +import unittest +from pathlib import Path + + +class FakeStateType: + PROTECTED = 1 + SELECTED = 2 + + +class FakeText: + @staticmethod + def get_character_count(node): + return len(node.value) + + @staticmethod + def get_text(node, start, end): + return node.value[start:end] + + +def load_runtime(): + gi = types.ModuleType("gi") + repository = types.ModuleType("gi.repository") + gi.require_version = lambda *_: None + repository.Atspi = types.SimpleNamespace(StateType=FakeStateType, Text=FakeText) + repository.Gdk = types.SimpleNamespace() + repository.GdkPixbuf = types.SimpleNamespace() + gi.repository = repository + sys.modules["gi"] = gi + sys.modules["gi.repository"] = repository + + path = Path(__file__).with_name("runtime.py") + spec = importlib.util.spec_from_file_location("orca_linux_runtime_test", path) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +runtime = load_runtime() + + +class FakeAccessible: + def __init__(self, role, name="", value="", children=(), counter=None): + self.role = role + self.name = name + self.value = value + self.children = list(children) + self.counter = counter or {"child_reads": 0} + for child in self.children: + child.use_counter(self.counter) + + def use_counter(self, counter): + self.counter = counter + for child in self.children: + child.use_counter(counter) + + def get_child_count(self): + self.counter["child_reads"] += 1 + return len(self.children) + + def get_child_at_index(self, index): + self.counter["child_reads"] += 1 + return self.children[index] + + def get_role_name(self): + return self.role + + def get_name(self): + return self.name + + def get_accessible_id(self): + return "" + + def get_toolkit_name(self): + return "fake" + + def get_component_iface(self): + return None + + def get_state_set(self): + return None + + def get_n_actions(self): + return 0 + + def is_text(self): + return bool(self.value) + + def get_text_iface(self): + return self if self.value else None + + def get_value_iface(self): + return None + + +class FailingChildrenAccessible(FakeAccessible): + def get_child_count(self): + raise RuntimeError("defunct node") + + +def render(root): + return runtime.render_accessibility_tree(root, None, [0]) + + +class RuntimeRenderTest(unittest.TestCase): + def test_named_non_generic_node_skips_unused_summary_walk(self): + root = FakeAccessible("button", "Save", children=[FakeAccessible("text", "unused")]) + + records, lines, truncation = render(root) + + self.assertEqual([record["name"] for record in records], ["Save"]) + self.assertEqual(lines, ["0 button Save"]) + self.assertEqual(root.counter["child_reads"], 2) + self.assertFalse(truncation["truncated"]) + + def test_named_generic_node_skips_unused_summary_walk(self): + root = FakeAccessible("section", "Details", children=[FakeAccessible("text", "body")]) + + records, lines, _ = render(root) + + self.assertEqual([record["name"] for record in records], ["Details", "body"]) + self.assertEqual(lines, ["0 section Details", "\t1 text body"]) + self.assertEqual(root.counter["child_reads"], 3) + + def test_unnamed_generic_node_keeps_plain_text_summary(self): + root = FakeAccessible( + "section", + children=[FakeAccessible("text", "Alpha"), FakeAccessible("text", "Beta")], + ) + + records, lines, _ = render(root) + + self.assertEqual(len(records), 1) + self.assertEqual(lines, ["0 section, Text: Alpha Beta"]) + self.assertEqual(root.counter["child_reads"], 13) + + def test_row_keeps_its_specific_summary_walk(self): + root = FakeAccessible( + "row", + "Invoice", + children=[FakeAccessible("text", "Alpha"), FakeAccessible("text", "Beta")], + ) + + records, lines, _ = render(root) + + self.assertEqual([record["name"] for record in records], ["Invoice", "Alpha", "Beta"]) + self.assertEqual(lines, ["0 row Invoice, Text: Alpha Beta", "\t1 text Alpha", "\t2 text Beta"]) + self.assertEqual(root.counter["child_reads"], 10) + + def test_empty_generic_wrapper_keeps_elision_path_and_depth(self): + root = FakeAccessible("section", children=[FakeAccessible("button", "Continue")]) + + records, lines, _ = render(root) + + self.assertEqual([record["runtimeId"] for record in records], [[0, 0]]) + self.assertEqual(lines, ["0 button Continue"]) + + def test_child_failure_keeps_fail_soft_row_output(self): + root = FailingChildrenAccessible("row", "Invoice") + + records, lines, truncation = render(root) + + self.assertEqual([record["name"] for record in records], ["Invoice"]) + self.assertEqual(lines, ["0 row Invoice"]) + self.assertFalse(truncation["truncated"]) + + def test_node_limit_keeps_exact_prefix_and_truncation(self): + root = FakeAccessible( + "document", + "Results", + children=[FakeAccessible("text", f"Item {index}") for index in range(runtime.MAX_NODES)], + ) + + records, _, truncation = render(root) + + self.assertEqual(len(records), runtime.MAX_NODES) + self.assertEqual(records[-1]["name"], f"Item {runtime.MAX_NODES - 2}") + self.assertTrue(truncation["truncated"]) + self.assertFalse(truncation["maxDepthReached"]) + + def test_depth_limit_keeps_exact_prefix_and_flag(self): + root = FakeAccessible("document", "Depth 65") + for depth in range(runtime.MAX_DEPTH, -1, -1): + root = FakeAccessible("document", f"Depth {depth}", children=[root]) + + records, _, truncation = render(root) + + self.assertEqual(len(records), runtime.MAX_DEPTH + 1) + self.assertEqual(records[-1]["name"], f"Depth {runtime.MAX_DEPTH}") + self.assertTrue(truncation["truncated"]) + self.assertTrue(truncation["maxDepthReached"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/native/computer-use-windows/runtime-render.test.ps1 b/native/computer-use-windows/runtime-render.test.ps1 new file mode 100644 index 00000000000..3e52b1a203e --- /dev/null +++ b/native/computer-use-windows/runtime-render.test.ps1 @@ -0,0 +1,224 @@ +$ErrorActionPreference = "Stop" + +function Assert-TestEqual($Actual, $Expected, [string]$Label) { + if ($Actual -ne $Expected) { + throw "$Label expected '$Expected', received '$Actual'" + } +} + +Add-Type -TypeDefinition @" +using System.Collections; +using System.Collections.Generic; + +public sealed class OrcaRenderTestCounter { + public int FindAll; +} + +public sealed class OrcaRenderTestBounds { + public bool IsEmpty = true; + public double X; + public double Y; + public double Width; + public double Height; +} + +public sealed class OrcaRenderTestControlType { + public string ProgrammaticName = ""; +} + +public sealed class OrcaRenderTestCurrent { + public string AutomationId = ""; + public OrcaRenderTestBounds BoundingRectangle = new OrcaRenderTestBounds(); + public string ClassName = "fake"; + public OrcaRenderTestControlType ControlType = new OrcaRenderTestControlType(); + public bool IsPassword; + public string LocalizedControlType = ""; + public string Name = ""; + public long NativeWindowHandle; +} + +public sealed class OrcaRenderTestPatternCurrent { + public bool IsSelected; + public string Value = ""; +} + +public sealed class OrcaRenderTestPattern { + public OrcaRenderTestPatternCurrent Current = new OrcaRenderTestPatternCurrent(); +} + +public sealed class OrcaRenderTestCollection : IEnumerable { + private readonly OrcaRenderTestElement[] values; + + public OrcaRenderTestCollection(OrcaRenderTestElement[] values) { + this.values = values; + } + + public int Count { + get { return values.Length; } + } + + public OrcaRenderTestElement Item(int index) { + return values[index]; + } + + public IEnumerator GetEnumerator() { + return values.GetEnumerator(); + } +} + +public sealed class OrcaRenderTestElement { + public readonly List Children = + new List(); + public OrcaRenderTestCounter Counter = new OrcaRenderTestCounter(); + public readonly OrcaRenderTestCurrent Current = new OrcaRenderTestCurrent(); + public bool FailFindAll; + public int RuntimeIdValue; + public string ValueText = ""; + + public OrcaRenderTestCollection FindAll(object scope, object condition) { + Counter.FindAll++; + if (FailFindAll) { + throw new System.InvalidOperationException("defunct node"); + } + return new OrcaRenderTestCollection(Children.ToArray()); + } + + public OrcaRenderTestPattern GetCurrentPattern(object pattern) { + OrcaRenderTestPattern result = new OrcaRenderTestPattern(); + result.Current.Value = ValueText; + return result; + } + + public int[] GetRuntimeId() { + return new int[] { RuntimeIdValue }; + } + + public object[] GetSupportedPatterns() { + return new object[0]; + } +} +"@ + +function New-TestCounter { + New-Object -TypeName OrcaRenderTestCounter +} + +function New-TestElement { + param( + [string]$Role, + [string]$Name = "", + [string]$Value = "", + [object[]]$Children = @(), + $Counter = $(New-TestCounter), + [switch]$FailFindAll, + [int]$RuntimeId = 1 + ) + $element = New-Object -TypeName OrcaRenderTestElement + $element.Counter = $Counter + $element.FailFindAll = [bool]$FailFindAll + $element.RuntimeIdValue = $RuntimeId + $element.ValueText = $Value + $element.Current.ControlType.ProgrammaticName = $Role + $element.Current.LocalizedControlType = $Role + $element.Current.Name = $Name + foreach ($child in @($Children)) { + [void]$element.Children.Add($child) + } + $element +} + +$operationPath = Join-Path ([IO.Path]::GetTempPath()) ("orca-runtime-render-test-" + [guid]::NewGuid() + ".json") +try { + Set-Content -LiteralPath $operationPath -Encoding UTF8 -Value '{"tool":"handshake"}' + $runtimeOutput = . (Join-Path $PSScriptRoot "runtime.ps1") -OperationPath $operationPath + $handshake = $runtimeOutput | ConvertFrom-Json + Assert-TestEqual $handshake.ok $true "runtime handshake" + + $counter = New-TestCounter + $leaf = New-TestElement -Role "text" -Name "unused" -Counter $counter -RuntimeId 2 + $root = New-TestElement -Role "button" -Name "Save" -Children @($leaf) -Counter $counter + $tree = Render-OrcaTree $root $null + Assert-TestEqual $tree.elements.Count 1 "named control record count" + Assert-TestEqual ([string]$tree.lines[0]) "0 button Save" "named control line" + Assert-TestEqual $counter.findAll 1 "named control child enumeration" + + $counter = New-TestCounter + $leaf = New-TestElement -Role "text" -Name "body" -Counter $counter -RuntimeId 2 + $root = New-TestElement -Role "group" -Name "Details" -Children @($leaf) -Counter $counter + $tree = Render-OrcaTree $root $null + Assert-TestEqual (($tree.elements | ForEach-Object { $_.name }) -join "|") "Details|body" "named generic records" + Assert-TestEqual (@($tree.lines) -join "|") "0 group Details|`t1 text body" "named generic lines" + Assert-TestEqual $counter.findAll 2 "named generic child enumeration" + + $counter = New-TestCounter + $alpha = New-TestElement -Role "text" -Name "Alpha" -Counter $counter -RuntimeId 2 + $beta = New-TestElement -Role "text" -Name "Beta" -Counter $counter -RuntimeId 3 + $root = New-TestElement -Role "group" -Children @($alpha, $beta) -Counter $counter + $tree = Render-OrcaTree $root $null + Assert-TestEqual $tree.elements.Count 1 "anonymous generic record count" + Assert-TestEqual ([string]$tree.lines[0]) "0 group, Text: Alpha Beta" "anonymous generic summary" + Assert-TestEqual $counter.findAll 7 "anonymous generic child enumeration" + + $counter = New-TestCounter + $alpha = New-TestElement -Role "text" -Name "Alpha" -Counter $counter -RuntimeId 2 + $beta = New-TestElement -Role "text" -Name "Beta" -Counter $counter -RuntimeId 3 + $root = New-TestElement -Role "row" -Name "Invoice" -Children @($alpha, $beta) -Counter $counter + $tree = Render-OrcaTree $root $null + Assert-TestEqual (($tree.elements | ForEach-Object { $_.name }) -join "|") "Invoice|Alpha|Beta" "row records" + Assert-TestEqual ([string]$tree.lines[0]) "0 row Invoice, Text: Alpha Beta" "row summary" + Assert-TestEqual $counter.findAll 6 "row child enumeration" + + $counter = New-TestCounter + $button = New-TestElement -Role "button" -Name "Continue" -Counter $counter -RuntimeId 2 + $root = New-TestElement -Role "group" -Children @($button) -Counter $counter + $tree = Render-OrcaTree $root $null + Assert-TestEqual $tree.elements.Count 1 "elided wrapper record count" + Assert-TestEqual ([string]$tree.lines[0]) "0 button Continue" "elided wrapper line" + Assert-TestEqual $counter.findAll 4 "elided wrapper child enumeration" + + $counter = New-TestCounter + $root = New-TestElement -Role "row" -Name "Invoice" -Counter $counter -FailFindAll + $tree = Render-OrcaTree $root $null + Assert-TestEqual $tree.elements.Count 1 "failed child read record count" + Assert-TestEqual ([string]$tree.lines[0]) "0 row Invoice" "failed child read line" + Assert-TestEqual $counter.findAll 2 "failed child read retries" + + $originalMaxNodes = $MaxNodes + try { + $MaxNodes = 3 + $counter = New-TestCounter + $children = @() + for ($index = 0; $index -lt 3; $index++) { + $children += New-TestElement -Role "text" -Name "Item $index" -Counter $counter -RuntimeId ($index + 2) + } + $root = New-TestElement -Role "document" -Name "Results" -Children $children -Counter $counter + $tree = Render-OrcaTree $root $null + Assert-TestEqual $tree.elements.Count 3 "node limit record count" + Assert-TestEqual ([string]$tree.elements[-1].name) "Item 1" "node limit prefix" + Assert-TestEqual $tree.truncation.truncated $true "node limit truncation" + Assert-TestEqual $tree.truncation.maxDepthReached $false "node limit depth flag" + } finally { + $MaxNodes = $originalMaxNodes + } + + $originalMaxDepth = $MaxDepth + try { + $MaxDepth = 2 + $counter = New-TestCounter + $root = New-TestElement -Role "document" -Name "Depth 3" -Counter $counter -RuntimeId 4 + for ($depth = 2; $depth -ge 0; $depth--) { + $root = New-TestElement -Role "document" -Name "Depth $depth" -Children @($root) -Counter $counter -RuntimeId ($depth + 1) + } + $tree = Render-OrcaTree $root $null + Assert-TestEqual $tree.elements.Count 3 "depth limit record count" + Assert-TestEqual ([string]$tree.elements[-1].name) "Depth 2" "depth limit prefix" + Assert-TestEqual $tree.truncation.truncated $true "depth limit truncation" + Assert-TestEqual $tree.truncation.maxDepthReached $true "depth limit flag" + } finally { + $MaxDepth = $originalMaxDepth + } + + Write-Output "windows-snapshot-render-tests-ok" +} finally { + Remove-Item -LiteralPath $operationPath -Force -ErrorAction SilentlyContinue +} diff --git a/native/computer-use-windows/runtime.ps1 b/native/computer-use-windows/runtime.ps1 index 39ab77e1704..2aa581901e6 100644 --- a/native/computer-use-windows/runtime.ps1 +++ b/native/computer-use-windows/runtime.ps1 @@ -561,10 +561,12 @@ function Render-OrcaTree($RootElement, $WindowFrame, [bool]$CompactBrowserTabs = $title = if ([string]::IsNullOrWhiteSpace($record.name)) { $record.automationId } else { $record.name } $role = if ([string]::IsNullOrWhiteSpace($record.localizedControlType)) { $record.controlType } else { $record.localizedControlType } $roleKey = $role.ToLowerInvariant() - $snippets = @(Get-OrcaTextSnippets $Node 8 4) $genericSummary = $null - if (($roleKey -in @("pane", "group", "custom", "unknown")) -and [string]::IsNullOrWhiteSpace($title) -and [string]::IsNullOrWhiteSpace($record.value) -and $snippets.Count -ge 2 -and (Test-OrcaPlainTextSubtree $Node)) { - $genericSummary = ($snippets -join " ") + if (($roleKey -in @("pane", "group", "custom", "unknown")) -and [string]::IsNullOrWhiteSpace($title) -and [string]::IsNullOrWhiteSpace($record.value)) { + $snippets = @(Get-OrcaTextSnippets $Node 8 4) + if ($snippets.Count -ge 2 -and (Test-OrcaPlainTextSubtree $Node)) { + $genericSummary = ($snippets -join " ") + } } if (($roleKey -in @("pane", "group", "custom", "unknown")) -and [string]::IsNullOrWhiteSpace($title) -and [string]::IsNullOrWhiteSpace($record.value) -and $meaningfulActions.Count -eq 0 -and $null -eq $genericSummary -and $children.Count -le 1) { for ($i = 0; $i -lt $children.Count; $i++) { diff --git a/src/main/agent-hooks/installer-utils.test.ts b/src/main/agent-hooks/installer-utils.test.ts index 75aabc6fc7e..2013865aa96 100644 --- a/src/main/agent-hooks/installer-utils.test.ts +++ b/src/main/agent-hooks/installer-utils.test.ts @@ -24,7 +24,6 @@ import { removeManagedCommands, wrapPosixHookCommand, wrapWindowsCmdHookCommand, - wrapWindowsGitBashHookCommand, readHooksJsonWithRaw, wrapWindowsHookCommand, writeManagedScript, @@ -32,6 +31,7 @@ import { type HooksConfig } from './installer-utils' import { POSIX_HOOK_STDIN_DRAIN_COMMAND } from './hook-stdin-contract' +import { wrapRuntimeHomeHookCommand } from './runtime-home-hook-command' let tmpDir: string let configPath: string @@ -320,6 +320,53 @@ describe('removeManagedCommands', () => { expect(cleaned).toEqual([{ hooks: [{ type: 'command', command: 'echo keep me' }] }]) }) + + it('removes exec-form hooks whose managed script path is an argument', () => { + const cleaned = removeManagedCommands( + [ + { + hooks: [ + { + type: 'command', + command: 'C:\\Windows\\System32\\conhost.exe', + args: [ + '--headless', + 'C:\\Windows\\System32\\cmd.exe', + '/d', + '/c', + 'C:\\Users\\alice\\.orca\\agent-hooks\\copilot-hook.cmd' + ] + }, + { type: 'command', command: 'echo keep me' } + ] + } + ], + match + ) + + expect(cleaned).toEqual([{ hooks: [{ type: 'command', command: 'echo keep me' }] }]) + }) + + it('preserves user hooks with malformed args fields', () => { + const definitions = [ + { + hooks: [ + { + type: 'command' as const, + command: 'echo keep me', + args: 'not-an-array' as unknown as string[] + }, + { + type: 'command' as const, + command: 'echo keep me too', + args: [42] as unknown as string[] + } + ] + } + ] + + expect(removeManagedCommands(definitions, match)).toEqual(definitions) + }) }) describe('hookDefinitionHasManagedCommand', () => { @@ -338,6 +385,34 @@ describe('hookDefinitionHasManagedCommand', () => { match ) ).toBe(true) + expect( + hookDefinitionHasManagedCommand( + { + hooks: [ + { + type: 'command', + command: 'C:\\Windows\\System32\\conhost.exe', + args: ['--headless', 'C:\\Users\\alice\\.orca\\agent-hooks\\copilot-hook.cmd'] + } + ] + }, + match + ) + ).toBe(true) + expect( + hookDefinitionHasManagedCommand( + { + hooks: [ + { + type: 'command', + command: 'echo no', + args: 'not-an-array' as unknown as string[] + } + ] + }, + match + ) + ).toBe(false) expect(hookDefinitionHasManagedCommand({ bash: 'echo no' }, match)).toBe(false) }) }) @@ -573,28 +648,87 @@ describe('wrapWindowsCmdHookCommand', () => { }) }) -describe('wrapWindowsGitBashHookCommand', () => { - it('guards the forward-slash fast path and drains when missing', () => { - expect( - wrapWindowsGitBashHookCommand('C:\\Users\\alice\\.orca\\agent-hooks\\claude-hook.cmd') - ).toBe( - `if [ -f 'C:/Users/alice/.orca/agent-hooks/claude-hook.cmd' ]; then 'C:/Users/alice/.orca/agent-hooks/claude-hook.cmd'; else ${POSIX_HOOK_STDIN_DRAIN_COMMAND}; fi` +describe('wrapRuntimeHomeHookCommand', () => { + it('selects the runtime platform variant under HOME', () => { + const command = wrapRuntimeHomeHookCommand('claude-hook') + + expect(command).toContain('case "${OSTYPE-}" in msys*|cygwin*|win32*)') + expect(command).toContain('case "$HOME" in *\\&*|*\\^*|*\\(*|*\\)*|*\\;*|*,*|*=*|*%*|*\\!*)') + expect(command).not.toContain('uname') + expect(command).toContain('"$HOME/.orca/agent-hooks/claude-hook.cmd"') + expect(command).toContain('/bin/sh "$HOME/.orca/agent-hooks/claude-hook.sh"') + expect(command).not.toMatch(/[A-Z]:[\\/]|\/Users\/|\/home\//) + }) + + it('rejects a script base name that could inject shell syntax', () => { + expect(() => wrapRuntimeHomeHookCommand('claude-hook; echo injected')).toThrow( + 'Invalid managed script base name' ) }) - it('falls back to the encoded launcher when bash would split the path', () => { - const scriptPath = 'C:\\Users\\Jane Doe\\.orca\\agent-hooks\\claude-hook.cmd' - const command = wrapWindowsGitBashHookCommand(scriptPath) - expect(command).toMatch(qualifiedWindowsPowerShellCommand) - expect(decodeWindowsHookCommand(command)).toBe(expectedDecodedWindowsHookCommand(scriptPath)) + it('executes the destination HOME script for the current runtime', () => { + const sourceHome = join(tmpDir, 'source profile') + const destinationHome = join(tmpDir, "destination $HOME ' & profile") + const sourceScriptDir = join(sourceHome, '.orca', 'agent-hooks') + const destinationScriptDir = join(destinationHome, '.orca', 'agent-hooks') + mkdirSync(sourceScriptDir, { recursive: true }) + mkdirSync(destinationScriptDir, { recursive: true }) + const windowsExitCode = process.platform === 'win32' ? 7 : 9 + const posixExitCode = process.platform === 'win32' ? 9 : 7 + writeFileSync( + join(destinationScriptDir, 'claude-hook.cmd'), + `@echo off\r\nexit /b ${windowsExitCode}\r\n`, + 'utf-8' + ) + writeFileSync( + join(destinationScriptDir, 'claude-hook.sh'), + `#!/bin/sh\nexit ${posixExitCode}\n`, + 'utf-8' + ) + writeFileSync(join(sourceScriptDir, 'claude-hook.cmd'), '@echo off\r\nexit /b 9\r\n', 'utf-8') + writeFileSync(join(sourceScriptDir, 'claude-hook.sh'), '#!/bin/sh\nexit 9\n', 'utf-8') + chmodSync(join(destinationScriptDir, 'claude-hook.sh'), 0o755) + + const shell = + process.platform === 'win32' + ? join(process.env.ProgramFiles ?? 'C:\\Program Files', 'Git', 'bin', 'bash.exe') + : '/bin/sh' + const result = spawnSync(shell, ['-c', wrapRuntimeHomeHookCommand('claude-hook')], { + env: { + ...process.env, + HOME: destinationHome.replaceAll('\\', '/'), + USERPROFILE: destinationHome + } + }) + + expect(result.error).toBeUndefined() + expect(result.status, result.stderr.toString()).toBe(7) }) - it('falls back to the encoded launcher when bash metacharacters are present', () => { - const scriptPath = 'C:\\Users\\alice & bob\\.orca\\agent-hooks\\claude-hook.cmd' - const command = wrapWindowsGitBashHookCommand(scriptPath) - expect(command).toMatch(qualifiedWindowsPowerShellCommand) - expect(command).not.toContain('& bob') - expect(decodeWindowsHookCommand(command)).toBe(expectedDecodedWindowsHookCommand(scriptPath)) + it.skipIf(process.platform !== 'win32')('keeps common Windows profiles on the fast path', () => { + const destinationHome = join(tmpDir, 'destination 国際 profile') + const scriptDir = join(destinationHome, '.orca', 'agent-hooks') + mkdirSync(scriptDir, { recursive: true }) + writeFileSync(join(scriptDir, 'claude-hook.cmd'), '@echo off\r\nexit /b 7\r\n', 'utf-8') + const gitBash = join(process.env.ProgramFiles ?? 'C:\\Program Files', 'Git', 'bin', 'bash.exe') + const result = spawnSync(gitBash, ['-c', wrapRuntimeHomeHookCommand('claude-hook')], { + env: { ...process.env, HOME: destinationHome.replaceAll('\\', '/') } + }) + + expect(result.error).toBeUndefined() + expect(result.status, result.stderr.toString()).toBe(7) + }) + + it('drains stdin when HOME is unavailable', () => { + const command = `unset HOME; ${wrapRuntimeHomeHookCommand('claude-hook')}` + const shell = + process.platform === 'win32' + ? join(process.env.ProgramFiles ?? 'C:\\Program Files', 'Git', 'bin', 'bash.exe') + : '/bin/sh' + const result = spawnSync(shell, ['-c', command], { input: Buffer.alloc(1_000_000, 'x') }) + + expect(result.error).toBeUndefined() + expect(result.status).toBe(0) }) }) diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts index a6d5d61fd41..f6e4c131826 100644 --- a/src/main/agent-hooks/installer-utils.ts +++ b/src/main/agent-hooks/installer-utils.ts @@ -20,6 +20,7 @@ import { writeRollingFileBackup } from '../rolling-file-backup' export type HookCommandConfig = { type: 'command' command: string + args?: string[] timeout?: number async?: boolean statusMessage?: string @@ -151,16 +152,6 @@ export function wrapWindowsCmdHookCommand(scriptPath: string): string { return WINDOWS_CMD_SAFE_PATH.test(scriptPath) ? scriptPath : wrapWindowsHookCommand(scriptPath) } -export const WINDOWS_GIT_BASH_SAFE_PATH = /^[A-Za-z0-9_.:/~-]+$/ - -export function wrapWindowsGitBashHookCommand(scriptPath: string): string { - const bashPath = scriptPath.replaceAll('\\', '/') - // Why: Claude's Git Bash runner can execute a forward-slash .cmd directly; unsafe paths stay encoded. - return WINDOWS_GIT_BASH_SAFE_PATH.test(bashPath) - ? `if [ -f ${quotePosixShellString(bashPath)} ]; then ${quotePosixShellString(bashPath)}; else ${POSIX_HOOK_STDIN_DRAIN_COMMAND}; fi` - : wrapWindowsHookCommand(scriptPath) -} - /** * Extra form lines inserted before the final `payload@-` line (each should end with ` ^`). * Used by Grok to attach `grokHome` without fragile string replace on the shared template. @@ -215,7 +206,8 @@ export function removeManagedCommands( const directManagedKeys = directCommandKeys.filter((key) => isManagedCommand(definition[key])) const hasNestedHooks = Array.isArray(definition.hooks) const hasManagedNestedHook = - hasNestedHooks && definition.hooks!.some((hook) => isManagedCommand(hook.command)) + hasNestedHooks && + definition.hooks!.some((hook) => hookHasManagedCommand(hook, isManagedCommand)) if (directManagedKeys.length === 0 && !hasManagedNestedHook) { return [definition] @@ -227,7 +219,9 @@ export function removeManagedCommands( } if (hasManagedNestedHook) { - const filteredHooks = definition.hooks!.filter((hook) => !isManagedCommand(hook.command)) + const filteredHooks = definition.hooks!.filter( + (hook) => !hookHasManagedCommand(hook, isManagedCommand) + ) if (filteredHooks.length > 0) { nextDefinition.hooks = filteredHooks } else { @@ -246,6 +240,11 @@ export function removeManagedCommands( }) } +function hookHasManagedCommand(hook: HookCommandConfig, matches: (value?: string) => boolean) { + const args = Array.isArray(hook.args) ? hook.args : [] + return matches(hook.command) || args.some((arg) => typeof arg === 'string' && matches(arg)) +} + export function hookDefinitionHasManagedCommand( definition: HookDefinition, isManagedCommand: (command: string | undefined) => boolean @@ -255,7 +254,7 @@ export function hookDefinitionHasManagedCommand( isManagedCommand(definition.bash) || isManagedCommand(definition.powershell) || (Array.isArray(definition.hooks) && - definition.hooks.some((hook) => isManagedCommand(hook.command))) + definition.hooks.some((hook) => hookHasManagedCommand(hook, isManagedCommand))) ) } diff --git a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts index 2ccfe4e54dd..7c27c2fadee 100644 --- a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts +++ b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts @@ -79,12 +79,9 @@ import { GeminiHookService } from '../gemini/hook-service' import { GrokHookService } from '../grok/hook-service' import { KimiHookService } from '../kimi/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' -import { - wrapPosixHookCommand, - wrapWindowsGitBashHookCommand, - wrapWindowsHookCommand -} from './installer-utils' +import { wrapPosixHookCommand, wrapWindowsHookCommand } from './installer-utils' import { POSIX_HOOK_STDIN_READER } from './hook-stdin-contract' +import { wrapRuntimeHomeHookCommand } from './runtime-home-hook-command' import { createAgentHookMemorySftp } from './agent-hook-memory-sftp.test-fixture' const REMOTE_HOME = '/home/dev' @@ -387,9 +384,9 @@ describe('Windows managed hook stdin structure', () => { args: ['/d', '/c', wrapWindowsHookCommand(missingScript)] }, { - name: 'Git Bash fast path', + name: 'portable Git Bash launcher', executable: gitBash, - args: ['-lc', wrapWindowsGitBashHookCommand(missingScript)] + args: ['-lc', wrapRuntimeHomeHookCommand('missing-orca-hook')] } ] for (const launcher of launcherCases) { diff --git a/src/main/agent-hooks/runtime-home-hook-command.ts b/src/main/agent-hooks/runtime-home-hook-command.ts new file mode 100644 index 00000000000..3ae887fc054 --- /dev/null +++ b/src/main/agent-hooks/runtime-home-hook-command.ts @@ -0,0 +1,22 @@ +import { POSIX_HOOK_STDIN_DRAIN_COMMAND } from './hook-stdin-contract' + +const MANAGED_SCRIPT_BASE_NAME = /^[A-Za-z0-9_-]+$/ +const WINDOWS_GIT_BASH_RUNTIME_HOME_UNSAFE = '*\\&*|*\\^*|*\\(*|*\\)*|*\\;*|*,*|*=*|*%*|*\\!*' + +export function wrapRuntimeHomeHookCommand(scriptBaseName: string): string { + if (!MANAGED_SCRIPT_BASE_NAME.test(scriptBaseName)) { + throw new Error(`Invalid managed script base name: ${scriptBaseName}`) + } + const windowsScript = `"$HOME/.orca/agent-hooks/${scriptBaseName}.cmd"` + const posixScript = `"$HOME/.orca/agent-hooks/${scriptBaseName}.sh"` + const drain = POSIX_HOOK_STDIN_DRAIN_COMMAND + const powershell = '"$SYSTEMROOT/System32/WindowsPowerShell/v1.0/powershell.exe"' + const powershellCommand = `$homePath = $env:HOME -replace '^/([A-Za-z])/', '$1:/'; $scriptPath = Join-Path $homePath '.orca\\agent-hooks\\${scriptBaseName}.cmd'; if (Test-Path -LiteralPath $scriptPath -PathType Leaf) { & $scriptPath; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null; exit 0` + const encodedCommand = Buffer.from(powershellCommand, 'utf16le').toString('base64') + const powershellInvocation = `${powershell} -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${encodedCommand}` + const encodedWindowsBranch = `if [ -f ${powershell} ]; then ${powershellInvocation}; else ${drain}; fi` + const windowsBranch = `if [ -f ${windowsScript} ]; then case "$HOME" in ${WINDOWS_GIT_BASH_RUNTIME_HOME_UNSAFE}) ${encodedWindowsBranch} ;; *) ${windowsScript} ;; esac; else ${drain}; fi` + const posixBranch = `if [ -f ${posixScript} ] && [ -r ${posixScript} ] && [ -x ${posixScript} ]; then /bin/sh ${posixScript}; else ${drain}; fi` + // Why: OSTYPE is shell-owned, so platform selection adds no process to every hook invocation. + return `if [ -z "\${HOME-}" ]; then ${drain}; else case "\${OSTYPE-}" in msys*|cygwin*|win32*) ${windowsBranch} ;; *) ${posixBranch} ;; esac; fi` +} diff --git a/src/main/artifacts/artifact-cloud-recovery.test.ts b/src/main/artifacts/artifact-cloud-recovery.test.ts new file mode 100644 index 00000000000..f57b53c2b02 --- /dev/null +++ b/src/main/artifacts/artifact-cloud-recovery.test.ts @@ -0,0 +1,346 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { isPackaged: false }, + safeStorage: { isEncryptionAvailable: () => false } +})) + +import { ArtifactCloudService } from './artifact-cloud-service' + +const createdPaths: string[] = [] +const apiUrl = 'http://localhost:3000' +const writeRequest = { + sourceKey: '/repo/report.html', + content: '

Recovery

', + contentType: 'text/html' as const, + fileName: 'report.html', + apiUrl, + authToken: 'token-a' +} + +afterEach(async () => { + vi.unstubAllGlobals() + await Promise.all( + createdPaths.splice(0).map((path) => rm(path, { recursive: true, force: true })) + ) +}) + +describe('ArtifactCloudService committed response loss recovery', () => { + it('reconciles one remotely revocable artifact after a committed create loses its response', async () => { + const userDataPath = await createUserDataPath() + const server = new ArtifactFaultServer() + server.loseNextCreateResponse = true + vi.stubGlobal('fetch', server.fetch) + + await expect(service(userDataPath).publish(writeRequest)).rejects.toThrow('response lost') + expect(server.createMutations).toBe(1) + expect(server.artifactSlugs()).toEqual(['artifact-1']) + await expect(publishedLink(userDataPath)).resolves.toBeNull() + + await expect( + service(userDataPath).publish({ ...writeRequest, content: '

Changed after loss

' }) + ).resolves.toMatchObject({ + status: 'ok', + value: { item: { artifact: { slug: 'artifact-1' } } } + }) + expect(server.createMutations).toBe(1) + expect(server.artifactSlugs()).toEqual(['artifact-1']) + expect(server.artifactContent('artifact-1')).toBe('

Changed after loss

') + await expect(publishedLink(userDataPath)).resolves.toBe('https://share.onorca.dev/a/artifact-1') + }) + + it('replays the exact create when content is unchanged after response loss', async () => { + const userDataPath = await createUserDataPath() + const server = new ArtifactFaultServer() + server.loseNextCreateResponse = true + vi.stubGlobal('fetch', server.fetch) + + await expect(service(userDataPath).publish(writeRequest)).rejects.toThrow('response lost') + await expect(service(userDataPath).publish(writeRequest)).resolves.toMatchObject({ + status: 'ok', + value: { item: { artifact: { slug: 'artifact-1' } } } + }) + expect(server.createMutations).toBe(1) + expect(server.artifactContent('artifact-1')).toBe(writeRequest.content) + }) + + it('updates a recovered share when its content changed after response loss', async () => { + const userDataPath = await createUserDataPath() + const server = new ArtifactFaultServer() + server.loseNextCreateResponse = true + vi.stubGlobal('fetch', server.fetch) + + await expect(service(userDataPath).share(writeRequest)).rejects.toThrow('response lost') + await expect( + service(userDataPath).share({ ...writeRequest, content: '

Changed share

' }) + ).resolves.toMatchObject({ + status: 'ok', + value: { artifact: { slug: 'artifact-1' } } + }) + expect(server.createMutations).toBe(1) + expect(server.artifactContent('artifact-1')).toBe('

Changed share

') + }) + + it('retains recovery until a changed-content update succeeds', async () => { + const userDataPath = await createUserDataPath() + const server = new ArtifactFaultServer() + server.loseNextCreateResponse = true + vi.stubGlobal('fetch', server.fetch) + + await expect(service(userDataPath).share(writeRequest)).rejects.toThrow('response lost') + server.rejectNextUpdateStatus = 503 + const changed = { ...writeRequest, content: '

Changed after update failure

' } + await expect(service(userDataPath).share(changed)).rejects.toMatchObject({ statusCode: 503 }) + await expect(service(userDataPath).share(changed)).resolves.toMatchObject({ + status: 'ok', + value: { artifact: { slug: 'artifact-1' } } + }) + expect(server.createMutations).toBe(1) + expect(server.artifactSlugs()).toEqual(['artifact-1']) + expect(server.artifactContent('artifact-1')).toBe(changed.content) + }) + + it('clears the durable mapping when a committed delete retry returns 404', async () => { + const userDataPath = await createUserDataPath() + const server = new ArtifactFaultServer() + vi.stubGlobal('fetch', server.fetch) + await service(userDataPath).publish(writeRequest) + + server.loseNextDeleteResponse = true + await expect( + service(userDataPath).unshare({ + sourceKey: writeRequest.sourceKey, + apiUrl, + authToken: 'token-a' + }) + ).rejects.toThrow('response lost') + expect(server.deleteMutations).toBe(1) + expect(server.artifactSlugs()).toEqual([]) + await expect(publishedLink(userDataPath)).resolves.toBe('https://share.onorca.dev/a/artifact-1') + + await expect( + service(userDataPath).unshare({ + sourceKey: writeRequest.sourceKey, + apiUrl, + authToken: 'token-a' + }) + ).resolves.toEqual({ status: 'ok', value: undefined }) + expect(server.deleteMutations).toBe(1) + expect(server.artifactSlugs()).toEqual([]) + await expect(publishedLink(userDataPath)).resolves.toBeNull() + }) + + it('keeps the durable mapping when a delete receives an unrelated 404', async () => { + const userDataPath = await createUserDataPath() + const server = new ArtifactFaultServer() + vi.stubGlobal('fetch', server.fetch) + await service(userDataPath).publish(writeRequest) + + server.rejectNextDeleteCode = 'not_found' + await expect( + service(userDataPath).unshare({ + sourceKey: writeRequest.sourceKey, + apiUrl, + authToken: 'token-a' + }) + ).rejects.toMatchObject({ statusCode: 404, errorCode: 'not_found' }) + expect(server.deleteMutations).toBe(0) + expect(server.artifactSlugs()).toEqual(['artifact-1']) + await expect(publishedLink(userDataPath)).resolves.toBe('https://share.onorca.dev/a/artifact-1') + }) + + it('drops an uncommitted validation failure so corrected content can create', async () => { + const userDataPath = await createUserDataPath() + const server = new ArtifactFaultServer() + server.rejectNextCreateStatus = 422 + vi.stubGlobal('fetch', server.fetch) + + await expect(service(userDataPath).publish(writeRequest)).rejects.toMatchObject({ + statusCode: 422 + }) + await expect( + service(userDataPath).publish({ ...writeRequest, content: '

Corrected

' }) + ).resolves.toMatchObject({ + status: 'ok', + value: { item: { artifact: { slug: 'artifact-1' } } } + }) + expect(server.createMutations).toBe(1) + expect(server.artifactContent('artifact-1')).toBe('

Corrected

') + }) + + it('keeps a replay intent when validation changes after the original commit', async () => { + const userDataPath = await createUserDataPath() + const server = new ArtifactFaultServer() + server.loseNextCreateResponse = true + vi.stubGlobal('fetch', server.fetch) + + await expect(service(userDataPath).publish(writeRequest)).rejects.toThrow('response lost') + server.rejectNextCreateStatus = 422 + const changed = { ...writeRequest, content: '

Changed after validation

' } + await expect(service(userDataPath).publish(changed)).rejects.toMatchObject({ statusCode: 422 }) + await expect(service(userDataPath).publish(changed)).resolves.toMatchObject({ + status: 'ok', + value: { item: { artifact: { slug: 'artifact-1' } } } + }) + expect(server.createMutations).toBe(1) + expect(server.artifactContent('artifact-1')).toBe(changed.content) + }) +}) + +class ArtifactFaultServer { + readonly fetch = vi.fn(this.handle.bind(this)) + createMutations = 0 + deleteMutations = 0 + loseNextCreateResponse = false + loseNextDeleteResponse = false + rejectNextCreateStatus: number | null = null + rejectNextDeleteCode: string | null = null + rejectNextUpdateStatus: number | null = null + private readonly artifacts = new Map() + private readonly createsByKey = new Map() + + artifactSlugs(): string[] { + return [...this.artifacts.keys()].sort() + } + + artifactContent(slug: string): string | undefined { + const body = this.artifacts.get(slug) + return body ? (JSON.parse(body) as { content?: string }).content : undefined + } + + private async handle(input: string | URL | Request, init?: RequestInit): Promise { + const method = init?.method ?? 'GET' + if (method === 'POST') { + return this.create(init) + } + if (method === 'PUT') { + return this.update(String(input), init) + } + if (method === 'DELETE') { + return this.delete(String(input)) + } + throw new Error(`Unexpected artifact request: ${method} ${String(input)}`) + } + + private create(init?: RequestInit): Response { + if (this.rejectNextCreateStatus !== null) { + const status = this.rejectNextCreateStatus + this.rejectNextCreateStatus = null + return jsonResponse({ code: 'artifact_validation_failed' }, status) + } + const key = new Headers(init?.headers).get('idempotency-key') + if (new Headers(init?.headers).get('authorization') !== 'Bearer token-a') { + throw new Error('Missing artifact authorization') + } + if (!key) { + throw new Error('Missing idempotency key') + } + const body = String(init?.body) + const existing = this.createsByKey.get(key) + if (existing) { + if (existing.body !== body) { + throw new Error('Idempotency key reused with another request') + } + return jsonResponse(existing.response, 201) + } + + this.createMutations += 1 + const slug = `artifact-${this.createMutations}` + const response = createResponseBody(slug) + this.createsByKey.set(key, { body, response }) + this.artifacts.set(slug, body) + if (this.loseNextCreateResponse) { + this.loseNextCreateResponse = false + throw new TypeError('response lost after committed create') + } + return jsonResponse(response, 201) + } + + private delete(url: string): Response { + if (this.rejectNextDeleteCode !== null) { + const code = this.rejectNextDeleteCode + this.rejectNextDeleteCode = null + return jsonResponse({ code }, 404) + } + const slug = decodeURIComponent(url.slice(url.lastIndexOf('/') + 1)) + if (!this.artifacts.delete(slug)) { + return jsonResponse({ code: 'artifact_not_found' }, 404) + } + this.deleteMutations += 1 + if (this.loseNextDeleteResponse) { + this.loseNextDeleteResponse = false + throw new TypeError('response lost after committed delete') + } + return new Response(null, { status: 204 }) + } + + private update(url: string, init?: RequestInit): Response { + if (this.rejectNextUpdateStatus !== null) { + const status = this.rejectNextUpdateStatus + this.rejectNextUpdateStatus = null + return jsonResponse({ code: 'artifact_update_failed' }, status) + } + const slug = decodeURIComponent(url.slice(url.lastIndexOf('/') + 1)) + if (!this.artifacts.has(slug)) { + return jsonResponse({ code: 'artifact_not_found' }, 404) + } + const headers = new Headers(init?.headers) + if ( + headers.get('authorization') !== 'Bearer token-a' || + headers.get('x-orca-edit-token') !== `edit-${slug}` + ) { + return jsonResponse({ code: 'artifact_forbidden' }, 403) + } + this.artifacts.set(slug, String(init?.body)) + return jsonResponse(createResponseBody(slug), 200) + } +} + +async function createUserDataPath(): Promise { + const path = await mkdtemp(join(tmpdir(), 'orca-artifact-recovery-')) + createdPaths.push(path) + return path +} + +function service(userDataPath: string): ArtifactCloudService { + return new ArtifactCloudService(userDataPath, () => true) +} + +async function publishedLink(userDataPath: string): Promise { + const result = await service(userDataPath).getPublishedLink({ + sourceKey: writeRequest.sourceKey, + apiUrl, + authToken: 'token-a' + }) + return result.status === 'ok' ? (result.value?.shareUrl ?? null) : null +} + +function jsonResponse(body: object, status: number): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' } + }) +} + +function createResponseBody(slug: string): object { + return { + artifact: { + version: 1, + slug, + title: null, + originalFileName: 'report.html', + sourceContentType: 'text/html', + renderedContentType: 'text/html', + createdAt: '2026-08-06T00:00:00.000Z', + updatedAt: '2026-08-06T00:00:00.000Z', + expiresAt: '2026-09-06T00:00:00.000Z', + byteSize: 17, + deletedAt: null + }, + shareUrl: `https://share.onorca.dev/a/${slug}`, + editToken: `edit-${slug}` + } +} diff --git a/src/main/artifacts/artifact-cloud-request.ts b/src/main/artifacts/artifact-cloud-request.ts index 6f1e0bb0f7b..4ad4633dac9 100644 --- a/src/main/artifacts/artifact-cloud-request.ts +++ b/src/main/artifacts/artifact-cloud-request.ts @@ -1,7 +1,14 @@ import type { ArtifactWriteRequest } from '../../shared/artifacts' import { OrcaCloudRequestError } from '../orca-profiles/profile-cloud-client' -export function artifactWriteBody(request: ArtifactWriteRequest): Record { +export type ArtifactWriteBody = { + content: string + contentType: ArtifactWriteRequest['contentType'] + fileName: string + title?: string +} + +export function artifactWriteBody(request: ArtifactWriteRequest): ArtifactWriteBody { return { content: request.content, contentType: request.contentType, diff --git a/src/main/artifacts/artifact-cloud-service.ts b/src/main/artifacts/artifact-cloud-service.ts index 6abb0525e9c..83b9836f940 100644 --- a/src/main/artifacts/artifact-cloud-service.ts +++ b/src/main/artifacts/artifact-cloud-service.ts @@ -12,6 +12,7 @@ import type { import { assertArtifactSharingAllowed } from '../../shared/artifact-sharing-gate' import { ensureActiveOrcaProfile } from '../orca-profiles/profile-index-store' import { getOrcaCloudAuthConfig } from '../orca-profiles/profile-cloud-auth-config' +import { prepareArtifactCloudUse } from '../orca-profiles/profile-artifact-cloud-cleanup' import { runWithFreshOrcaCloudSession } from '../orca-profiles/profile-cloud-session-refresh' import { allowsArtifactCloudAuthOverride, @@ -28,6 +29,7 @@ import { import type { ActiveOrcaProfileState } from '../orca-profiles/profile-index-store' import { artifactRequest, artifactWriteBody } from './artifact-cloud-request' import { ArtifactPublisher } from './artifact-publisher' +import { OrcaCloudRequestError } from '../orca-profiles/profile-cloud-client' type ArtifactAuthContext = { profileId: string @@ -35,6 +37,28 @@ type ArtifactAuthContext = { assertCurrent: () => void } +async function deleteArtifactRequest( + apiUrl: string, + token: string, + path: string, + editToken?: string +): Promise { + try { + await artifactRequest(apiUrl, token, path, { + method: 'DELETE', + ...(editToken ? { editToken } : {}) + }) + } catch (error) { + if ( + !(error instanceof OrcaCloudRequestError) || + error.statusCode !== 404 || + error.errorCode !== 'artifact_not_found' + ) { + throw error + } + } +} + function tokenFingerprint(token: string): string { return createHash('sha256').update(token).digest('hex') } @@ -227,10 +251,8 @@ export class ArtifactCloudService { } return this.publisher.runForSlug(record.slug, auth, async () => { auth.assertCurrent() - await artifactRequest(apiUrl, token, `/${record.slug}`, { - method: 'DELETE', - editToken: record.editToken - }) + await deleteArtifactRequest(apiUrl, token, `/${record.slug}`, record.editToken) + auth.assertCurrent() removeArtifactShareRecords(auth.profileId, this.userDataPath, auth.scope, { sourceKey: request.sourceKey, slug: record.slug @@ -244,9 +266,8 @@ export class ArtifactCloudService { return this.withAuth(options, (token, apiUrl, auth) => this.publisher.runForSlug(id, auth, async () => { auth.assertCurrent() - await artifactRequest(apiUrl, token, `/${encodeURIComponent(id)}`, { - method: 'DELETE' - }) + await deleteArtifactRequest(apiUrl, token, `/${encodeURIComponent(id)}`) + auth.assertCurrent() removeArtifactShareRecords(auth.profileId, this.userDataPath, auth.scope, { slug: id }) }) ) @@ -258,6 +279,7 @@ export class ArtifactCloudService { ): Promise> { const apiUrl = resolveArtifactCloudApiUrl(options.apiUrl) const active = ensureActiveOrcaProfile(this.userDataPath) + prepareArtifactCloudUse(active.profile, this.userDataPath) if (options.authToken?.trim()) { if (!allowsArtifactCloudAuthOverride()) { throw new Error( diff --git a/src/main/artifacts/artifact-create-intent-store.test.ts b/src/main/artifacts/artifact-create-intent-store.test.ts new file mode 100644 index 00000000000..dfcbe003df7 --- /dev/null +++ b/src/main/artifacts/artifact-create-intent-store.test.ts @@ -0,0 +1,319 @@ +import { execFileSync } from 'node:child_process' +import { mkdtemp, readFile, readdir, rm, stat, truncate, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ARTIFACT_CLI_MAX_RPC_BYTES, artifactWriteRequestByteLength } from '../../shared/artifacts' +import { + MAX_ARTIFACT_CREATE_INTENT_BYTES, + MAX_PENDING_ARTIFACT_CREATES, + clearArtifactCreateIntents, + getArtifactCreateIntent, + getOrCreateArtifactCreateIntent, + removeArtifactCreateIntent +} from './artifact-create-intent-store' +import type { ArtifactShareScope } from './artifact-share-record-store' + +vi.mock('node:child_process', () => ({ execFile: vi.fn(), execFileSync: vi.fn() })) + +const createdPaths: string[] = [] +const scope: ArtifactShareScope = { + cloudUserId: 'user-a', + cloudProfileId: 'cloud-a', + cloudOrganizationId: 'org-a', + apiOrigin: 'https://share.onorca.dev' +} +const body = { + content: '

Original

', + contentType: 'text/html' as const, + fileName: 'report.html', + title: 'Original' +} + +afterEach(async () => { + await Promise.all( + createdPaths.splice(0).map((path) => rm(path, { recursive: true, force: true })) + ) +}) + +describe('artifact create intent store', () => { + it('retains the first key and exact request until the matching create completes', async () => { + const userDataPath = await createUserDataPath() + const sourceKey = String.raw`C:\repo\report.html` + const first = getOrCreateArtifactCreateIntent( + 'local-profile', + userDataPath, + sourceKey, + scope, + 'key-a', + body + ) + const retry = getOrCreateArtifactCreateIntent( + 'local-profile', + userDataPath, + sourceKey, + scope, + 'key-b', + { ...body, content: '

Changed

' } + ) + + expect(first).toEqual(retry) + expect(retry).toMatchObject({ idempotencyKey: 'key-a', body }) + removeArtifactCreateIntent('local-profile', userDataPath, sourceKey, scope, 'key-b') + expect(getArtifactCreateIntent('local-profile', userDataPath, sourceKey, scope)).not.toBeNull() + removeArtifactCreateIntent('local-profile', userDataPath, sourceKey, scope, 'key-a') + expect(getArtifactCreateIntent('local-profile', userDataPath, sourceKey, scope)).toBeNull() + }) + + it.each([ + ['user', { cloudUserId: 'user-b' }], + ['profile', { cloudProfileId: 'cloud-b' }], + ['organization', { cloudOrganizationId: 'org-b' }], + ['API origin', { apiOrigin: 'http://localhost:3000' }] + ])('isolates recovery intent by %s', async (_name, changedScope) => { + const userDataPath = await createUserDataPath() + const sourceKey = '/repo/report.html' + getOrCreateArtifactCreateIntent('local-profile', userDataPath, sourceKey, scope, 'key-a', body) + + expect( + getArtifactCreateIntent('local-profile', userDataPath, sourceKey, { + ...scope, + ...changedScope + }) + ).toBeNull() + }) + + it('bounds unresolved payload storage without dropping an existing intent', async () => { + const userDataPath = await createUserDataPath() + for (let index = 0; index < MAX_PENDING_ARTIFACT_CREATES; index += 1) { + getOrCreateArtifactCreateIntent( + 'local-profile', + userDataPath, + `/repo/report-${index}.html`, + scope, + `key-${index}`, + body + ) + } + + expect(() => + getOrCreateArtifactCreateIntent( + 'local-profile', + userDataPath, + '/repo/overflow.html', + scope, + 'overflow-key', + body + ) + ).toThrow(/waiting for recovery/) + expect( + getOrCreateArtifactCreateIntent( + 'local-profile', + userDataPath, + '/repo/report-0.html', + scope, + 'replacement-key', + { ...body, content: 'replacement' } + ).idempotencyKey + ).toBe('key-0') + }) + + it('clears pending content at the profile lifecycle boundary', async () => { + const userDataPath = await createUserDataPath() + const directory = join(userDataPath, 'profiles', 'local-profile', 'artifact-create-intents') + getOrCreateArtifactCreateIntent( + 'local-profile', + userDataPath, + '/repo/report.html', + scope, + 'key-a', + body + ) + + clearArtifactCreateIntents('local-profile', userDataPath) + + expect( + getArtifactCreateIntent('local-profile', userDataPath, '/repo/report.html', scope) + ).toBeNull() + expect(await readdir(directory)).toEqual([]) + }) + + it('removes crash-left temporary writes before admitting another intent', async () => { + const userDataPath = await createUserDataPath() + const directory = join(userDataPath, 'profiles', 'local-profile', 'artifact-create-intents') + getOrCreateArtifactCreateIntent( + 'local-profile', + userDataPath, + '/repo/report.html', + scope, + 'key-a', + body + ) + removeArtifactCreateIntent('local-profile', userDataPath, '/repo/report.html', scope, 'key-a') + await writeFile(join(directory, 'crash-left.tmp'), 'partial') + + getOrCreateArtifactCreateIntent( + 'local-profile', + userDataPath, + '/repo/other.html', + scope, + 'key-b', + body + ) + + expect((await readdir(directory)).some((name) => name.endsWith('.tmp'))).toBe(false) + }) + + it('hardens one Windows journal directory without per-file PowerShell launches', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + vi.mocked(execFileSync).mockImplementation((file) => + String(file).endsWith('whoami.exe') ? '"USER","S-1-5-21-1000"' : '' + ) + try { + const userDataPath = await createUserDataPath() + getOrCreateArtifactCreateIntent( + 'local-profile', + userDataPath, + '/repo/report.html', + scope, + 'key-a', + body + ) + getOrCreateArtifactCreateIntent( + 'local-profile', + userDataPath, + '/repo/other.html', + scope, + 'key-b', + body + ) + + const powershellCalls = vi + .mocked(execFileSync) + .mock.calls.filter(([file]) => String(file).endsWith('powershell.exe')) + expect(powershellCalls).toHaveLength(1) + expect((powershellCalls[0]![1] as string[]).at(-1)).toBe('1') + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + vi.mocked(execFileSync).mockReset() + } + }) + + it('refuses to overwrite an unreadable matching intent', async () => { + const userDataPath = await createUserDataPath() + const directory = join(userDataPath, 'profiles', 'local-profile', 'artifact-create-intents') + getOrCreateArtifactCreateIntent( + 'local-profile', + userDataPath, + '/repo/report.html', + scope, + 'key-a', + body + ) + const [fileName] = await readdir(directory) + await writeFile(join(directory, fileName), '{broken-json') + + expect(() => + getOrCreateArtifactCreateIntent( + 'local-profile', + userDataPath, + '/repo/report.html', + scope, + 'key-b', + body + ) + ).toThrow(/could not be read safely/) + }) + + it('removes an unreadable intent after its mutation completes', async () => { + const userDataPath = await createUserDataPath() + const directory = join(userDataPath, 'profiles', 'local-profile', 'artifact-create-intents') + getOrCreateArtifactCreateIntent( + 'local-profile', + userDataPath, + '/repo/report.html', + scope, + 'key-a', + body + ) + const [fileName] = await readdir(directory) + await writeFile(join(directory, fileName), '{broken-json') + + expect(() => + removeArtifactCreateIntent('local-profile', userDataPath, '/repo/report.html', scope, 'key-a') + ).not.toThrow() + expect(await readdir(directory)).toEqual([]) + }) + + it('rejects a persisted content type outside the artifact allowlist', async () => { + const userDataPath = await createUserDataPath() + const directory = join(userDataPath, 'profiles', 'local-profile', 'artifact-create-intents') + getOrCreateArtifactCreateIntent( + 'local-profile', + userDataPath, + '/repo/report.html', + scope, + 'key-a', + body + ) + const [fileName] = await readdir(directory) + const path = join(directory, fileName) + const intent = JSON.parse(await readFile(path, 'utf8')) as { body: { contentType: string } } + intent.body.contentType = 'application/octet-stream' + await writeFile(path, JSON.stringify(intent)) + + expect(() => + getArtifactCreateIntent('local-profile', userDataPath, '/repo/report.html', scope) + ).toThrow(/unsupported format/) + }) + + it('persists a valid artifact request near the RPC limit', async () => { + const userDataPath = await createUserDataPath() + const nearLimitBody = { ...body, content: 'x'.repeat(ARTIFACT_CLI_MAX_RPC_BYTES - 200) } + expect( + artifactWriteRequestByteLength({ sourceKey: '/repo/report.html', ...nearLimitBody }) + ).toBeLessThanOrEqual(ARTIFACT_CLI_MAX_RPC_BYTES) + + expect(() => + getOrCreateArtifactCreateIntent( + 'local-profile', + userDataPath, + '/repo/report.html', + scope, + 'key-a', + nearLimitBody + ) + ).not.toThrow() + const directory = join(userDataPath, 'profiles', 'local-profile', 'artifact-create-intents') + const [fileName] = await readdir(directory) + expect((await stat(join(directory, fileName))).size).toBeGreaterThan(ARTIFACT_CLI_MAX_RPC_BYTES) + }) + + it('rejects an oversized recovery record before reading it', async () => { + const userDataPath = await createUserDataPath() + const directory = join(userDataPath, 'profiles', 'local-profile', 'artifact-create-intents') + getOrCreateArtifactCreateIntent( + 'local-profile', + userDataPath, + '/repo/report.html', + scope, + 'key-a', + body + ) + const [fileName] = await readdir(directory) + await truncate(join(directory, fileName), MAX_ARTIFACT_CREATE_INTENT_BYTES + 1) + + expect(() => + getArtifactCreateIntent('local-profile', userDataPath, '/repo/report.html', scope) + ).toThrow(/exceeds the supported size/) + }) +}) + +async function createUserDataPath(): Promise { + const path = await mkdtemp(join(tmpdir(), 'orca-artifact-create-intents-')) + createdPaths.push(path) + return path +} diff --git a/src/main/artifacts/artifact-create-intent-store.ts b/src/main/artifacts/artifact-create-intent-store.ts new file mode 100644 index 00000000000..d544d04ceec --- /dev/null +++ b/src/main/artifacts/artifact-create-intent-store.ts @@ -0,0 +1,255 @@ +import { createHash, randomBytes } from 'node:crypto' +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { join } from 'node:path' +import { ARTIFACT_CLI_MAX_RPC_BYTES } from '../../shared/artifacts' +import { + bestEffortFsyncDirectorySync, + fsyncFileSync, + hardenSecurePath +} from '../../shared/secure-file' +import { getOrcaProfileDirectory } from '../orca-profiles/profile-storage-paths' +import type { ArtifactWriteBody } from './artifact-cloud-request' +import type { ArtifactShareScope } from './artifact-share-record-store' + +export const MAX_PENDING_ARTIFACT_CREATES = 32 +export const MAX_ARTIFACT_CREATE_INTENT_BYTES = ARTIFACT_CLI_MAX_RPC_BYTES + 128 * 1024 + +const MAX_HARDENED_INTENT_DIRECTORIES = 64 +const hardenedIntentDirectories = new Set() + +export type ArtifactCreateIntent = { + version: 1 + sourceKey: string + scope: ArtifactShareScope + idempotencyKey: string + body: ArtifactWriteBody +} + +function intentDirectory(profileId: string, userDataPath: string): string { + return join(getOrcaProfileDirectory(profileId, userDataPath), 'artifact-create-intents') +} + +function ensureIntentDirectory(profileId: string, userDataPath: string): string { + const directory = intentDirectory(profileId, userDataPath) + mkdirSync(directory, { recursive: true, mode: 0o700 }) + if (!hardenedIntentDirectories.has(directory)) { + hardenSecurePath(directory, { + isDirectory: true, + platform: process.platform, + sync: true + }) + if (hardenedIntentDirectories.size >= MAX_HARDENED_INTENT_DIRECTORIES) { + const oldest = hardenedIntentDirectories.values().next().value + if (oldest !== undefined) { + hardenedIntentDirectories.delete(oldest) + } + } + hardenedIntentDirectories.add(directory) + } + return directory +} + +function removeTemporaryIntents(directory: string): void { + for (const name of readdirSync(directory)) { + if (name.endsWith('.tmp')) { + rmSync(join(directory, name), { force: true }) + } + } +} + +function writeIntent(path: string, directory: string, serializedIntent: string): void { + // Child files inherit the once-hardened directory ACL, avoiding per-create PowerShell launches. + const temporaryPath = `${path}.${process.pid}.${Date.now()}.${randomBytes(4).toString('hex')}.tmp` + try { + writeFileSync(temporaryPath, serializedIntent, { + encoding: 'utf8', + mode: 0o600 + }) + fsyncFileSync(temporaryPath) + renameSync(temporaryPath, path) + bestEffortFsyncDirectorySync(directory) + } catch (error) { + rmSync(temporaryPath, { force: true }) + throw error + } +} + +function intentPath( + profileId: string, + userDataPath: string, + sourceKey: string, + scope: ArtifactShareScope +): string { + const identity = JSON.stringify([ + sourceKey, + scope.cloudUserId, + scope.cloudProfileId, + scope.cloudOrganizationId, + scope.apiOrigin + ]) + const fileName = `${createHash('sha256').update(identity).digest('hex')}.json` + return join(intentDirectory(profileId, userDataPath), fileName) +} + +function scopeMatches(left: ArtifactShareScope, right: ArtifactShareScope): boolean { + return ( + left.cloudUserId === right.cloudUserId && + left.cloudProfileId === right.cloudProfileId && + left.cloudOrganizationId === right.cloudOrganizationId && + left.apiOrigin === right.apiOrigin + ) +} + +function isWriteBody(value: unknown): value is ArtifactWriteBody { + if (!value || typeof value !== 'object') { + return false + } + const body = value as Partial + return ( + typeof body.content === 'string' && + (body.contentType === 'text/html' || body.contentType === 'text/markdown') && + typeof body.fileName === 'string' && + (body.title === undefined || typeof body.title === 'string') + ) +} + +function isScope(value: unknown): value is ArtifactShareScope { + if (!value || typeof value !== 'object') { + return false + } + const scope = value as Partial + return [ + scope.cloudUserId, + scope.cloudProfileId, + scope.cloudOrganizationId, + scope.apiOrigin + ].every((field) => typeof field === 'string') +} + +function readIntent(path: string): ArtifactCreateIntent { + let size: number + try { + size = statSync(path).size + } catch (error) { + throw new Error('Artifact create recovery record could not be read safely.', { cause: error }) + } + if (size > MAX_ARTIFACT_CREATE_INTENT_BYTES) { + throw new Error('Artifact create recovery record exceeds the supported size.') + } + let parsed: unknown + try { + parsed = JSON.parse(readFileSync(path, 'utf8')) + } catch (error) { + throw new Error('Artifact create recovery record could not be read safely.', { cause: error }) + } + if (!parsed || typeof parsed !== 'object') { + throw new Error('Artifact create recovery record has an unsupported format.') + } + const intent = parsed as Partial + if ( + intent.version !== 1 || + typeof intent.sourceKey !== 'string' || + typeof intent.idempotencyKey !== 'string' || + !intent.idempotencyKey || + !isScope(intent.scope) || + !isWriteBody(intent.body) + ) { + throw new Error('Artifact create recovery record has an unsupported format.') + } + return intent as ArtifactCreateIntent +} + +export function getArtifactCreateIntent( + profileId: string, + userDataPath: string, + sourceKey: string, + scope: ArtifactShareScope +): ArtifactCreateIntent | null { + const path = intentPath(profileId, userDataPath, sourceKey, scope) + if (!existsSync(path)) { + return null + } + const intent = readIntent(path) + if (intent.sourceKey !== sourceKey || !scopeMatches(intent.scope, scope)) { + throw new Error('Artifact create recovery record does not match its storage identity.') + } + return intent +} + +export function getOrCreateArtifactCreateIntent( + profileId: string, + userDataPath: string, + sourceKey: string, + scope: ArtifactShareScope, + idempotencyKey: string, + body: ArtifactWriteBody +): ArtifactCreateIntent { + const existing = getArtifactCreateIntent(profileId, userDataPath, sourceKey, scope) + if (existing) { + return existing + } + const directory = ensureIntentDirectory(profileId, userDataPath) + removeTemporaryIntents(directory) + const pendingCount = readdirSync(directory).filter((name) => name.endsWith('.json')).length + if (pendingCount >= MAX_PENDING_ARTIFACT_CREATES) { + throw new Error('Too many artifact creates are waiting for recovery. Retry an earlier share.') + } + const intent: ArtifactCreateIntent = { + version: 1, + sourceKey, + scope, + idempotencyKey, + body + } + const serializedIntent = JSON.stringify(intent, null, 2) + if (Buffer.byteLength(serializedIntent, 'utf8') > MAX_ARTIFACT_CREATE_INTENT_BYTES) { + throw new Error('Artifact create recovery record exceeds the supported size.') + } + writeIntent(intentPath(profileId, userDataPath, sourceKey, scope), directory, serializedIntent) + return intent +} + +export function removeArtifactCreateIntent( + profileId: string, + userDataPath: string, + sourceKey: string, + scope: ArtifactShareScope, + expectedIdempotencyKey: string +): void { + const path = intentPath(profileId, userDataPath, sourceKey, scope) + if (!existsSync(path)) { + return + } + let matches = true + try { + matches = readIntent(path).idempotencyKey === expectedIdempotencyKey + } catch { + // An unreadable record cannot be replayed, so a completed mutation may discard it safely. + } + if (matches) { + rmSync(path, { force: true }) + bestEffortFsyncDirectorySync(intentDirectory(profileId, userDataPath)) + } +} + +export function clearArtifactCreateIntents(profileId: string, userDataPath: string): void { + const directory = intentDirectory(profileId, userDataPath) + if (!existsSync(directory)) { + return + } + for (const name of readdirSync(directory)) { + if (name.endsWith('.json') || name.endsWith('.tmp')) { + rmSync(join(directory, name), { force: true }) + } + } + bestEffortFsyncDirectorySync(directory) +} diff --git a/src/main/artifacts/artifact-publisher.ts b/src/main/artifacts/artifact-publisher.ts index 8f03c4e5b68..d70958e6b6f 100644 --- a/src/main/artifacts/artifact-publisher.ts +++ b/src/main/artifacts/artifact-publisher.ts @@ -4,7 +4,17 @@ import type { ArtifactWriteRequest } from '../../shared/artifacts' import { OrcaCloudRequestError } from '../orca-profiles/profile-cloud-client' -import { artifactRequest, artifactWriteBody } from './artifact-cloud-request' +import { + artifactRequest, + type ArtifactWriteBody, + artifactWriteBody +} from './artifact-cloud-request' +import { + type ArtifactCreateIntent, + getArtifactCreateIntent, + getOrCreateArtifactCreateIntent, + removeArtifactCreateIntent +} from './artifact-create-intent-store' import { type ArtifactShareScope, getArtifactShareRecord, @@ -15,6 +25,30 @@ import { type ArtifactCreateResponse = ArtifactListItem & { editToken: string } +type ArtifactCreateOutcome = { + editToken: string + intent: ArtifactCreateIntent + result: ArtifactPublishResult +} + +function artifactWriteBodiesMatch(left: ArtifactWriteBody, right: ArtifactWriteBody): boolean { + return ( + left.content === right.content && + left.contentType === right.contentType && + left.fileName === right.fileName && + left.title === right.title + ) +} + +function discardsCreateIntent(error: unknown): boolean { + return ( + error instanceof OrcaCloudRequestError && + error.statusCode >= 400 && + error.statusCode < 500 && + ![408, 409, 425, 429].includes(error.statusCode) + ) +} + type ArtifactPublishAuthContext = { profileId: string scope: ArtifactShareScope @@ -51,7 +85,23 @@ export class ArtifactPublisher { ): Promise { return this.runForSource(request.sourceKey, auth, async () => { auth.assertCurrent() - return (await this.create(request, token, apiUrl, auth, idempotencyKey)).item + const pending = getArtifactCreateIntent( + auth.profileId, + this.userDataPath, + request.sourceKey, + auth.scope + ) + const created = await this.create(request, token, apiUrl, auth, idempotencyKey, pending) + if (pending && !artifactWriteBodiesMatch(pending.body, artifactWriteBody(request))) { + const item = await this.updateExisting(request, token, apiUrl, auth, { + slug: created.result.item.artifact.slug, + editToken: created.editToken + }) + this.removeCreateIntent(request, auth, created.intent) + return item + } + this.removeCreateIntent(request, auth, created.intent) + return created.result.item }) } @@ -64,6 +114,25 @@ export class ArtifactPublisher { ): Promise { return this.runForSource(request.sourceKey, auth, async () => { auth.assertCurrent() + const pending = getArtifactCreateIntent( + auth.profileId, + this.userDataPath, + request.sourceKey, + auth.scope + ) + if (pending) { + const created = await this.create(request, token, apiUrl, auth, idempotencyKey, pending) + if (artifactWriteBodiesMatch(pending.body, artifactWriteBody(request))) { + this.removeCreateIntent(request, auth, created.intent) + return created.result + } + const item = await this.updateExisting(request, token, apiUrl, auth, { + slug: created.result.item.artifact.slug, + editToken: created.editToken + }) + this.removeCreateIntent(request, auth, created.intent) + return { change: 'created', item } + } const record = getArtifactShareRecord( auth.profileId, this.userDataPath, @@ -72,24 +141,8 @@ export class ArtifactPublisher { ) if (record) { try { - return await this.runForSlug(record.slug, auth, async () => { - auth.assertCurrent() - const item = await artifactRequest(apiUrl, token, `/${record.slug}`, { - method: 'PUT', - editToken: record.editToken, - body: artifactWriteBody(request) - }) - auth.assertCurrent() - refreshArtifactShareRecordExpiration( - auth.profileId, - this.userDataPath, - request.sourceKey, - auth.scope, - record, - item.artifact.expiresAt - ) - return { change: 'updated', item } - }) + const item = await this.updateExisting(request, token, apiUrl, auth, record) + return { change: 'updated', item } } catch (error) { if (!(error instanceof OrcaCloudRequestError) || error.statusCode !== 404) { throw error @@ -101,7 +154,9 @@ export class ArtifactPublisher { }) } } - return this.create(request, token, apiUrl, auth, idempotencyKey) + const created = await this.create(request, token, apiUrl, auth, idempotencyKey, null) + this.removeCreateIntent(request, auth, created.intent) + return created.result }) } @@ -121,18 +176,72 @@ export class ArtifactPublisher { return this.runSerialized(artifactOperationQueueKey('slug', auth, slug), operation) } + private updateExisting( + request: ArtifactWriteRequest, + token: string, + apiUrl: string, + auth: ArtifactPublishAuthContext, + record: { slug: string; editToken: string } + ): Promise { + return this.runForSlug(record.slug, auth, async () => { + auth.assertCurrent() + const item = await artifactRequest(apiUrl, token, `/${record.slug}`, { + method: 'PUT', + editToken: record.editToken, + body: artifactWriteBody(request) + }) + auth.assertCurrent() + refreshArtifactShareRecordExpiration( + auth.profileId, + this.userDataPath, + request.sourceKey, + auth.scope, + record, + item.artifact.expiresAt + ) + return item + }) + } + private async create( request: ArtifactWriteRequest, token: string, apiUrl: string, auth: ArtifactPublishAuthContext, - idempotencyKey: string - ): Promise { - const response = await artifactRequest(apiUrl, token, '', { - method: 'POST', - body: artifactWriteBody(request), - idempotencyKey - }) + idempotencyKey: string, + pending: ArtifactCreateIntent | null + ): Promise { + const replaying = pending !== null + const intent = + pending ?? + getOrCreateArtifactCreateIntent( + auth.profileId, + this.userDataPath, + request.sourceKey, + auth.scope, + idempotencyKey, + artifactWriteBody(request) + ) + let response: ArtifactCreateResponse + try { + response = await artifactRequest(apiUrl, token, '', { + method: 'POST', + body: intent.body, + idempotencyKey: intent.idempotencyKey + }) + } catch (error) { + // A replay may outlive its server receipt, so never abandon a possibly committed artifact. + if (!replaying && discardsCreateIntent(error)) { + removeArtifactCreateIntent( + auth.profileId, + this.userDataPath, + request.sourceKey, + auth.scope, + intent.idempotencyKey + ) + } + throw error + } auth.assertCurrent() saveArtifactShareRecord(auth.profileId, this.userDataPath, request.sourceKey, { slug: response.artifact.slug, @@ -142,11 +251,29 @@ export class ArtifactPublisher { ...auth.scope }) return { - change: 'created', - item: { artifact: response.artifact, shareUrl: response.shareUrl } + editToken: response.editToken, + intent, + result: { + change: 'created', + item: { artifact: response.artifact, shareUrl: response.shareUrl } + } } } + private removeCreateIntent( + request: ArtifactWriteRequest, + auth: ArtifactPublishAuthContext, + intent: ArtifactCreateIntent + ): void { + removeArtifactCreateIntent( + auth.profileId, + this.userDataPath, + request.sourceKey, + auth.scope, + intent.idempotencyKey + ) + } + private async runSerialized(key: string, operation: () => Promise): Promise { const previous = this.queues.get(key) ?? Promise.resolve() let release = (): void => {} diff --git a/src/main/artifacts/artifact-recovery-directory-fsync.test.ts b/src/main/artifacts/artifact-recovery-directory-fsync.test.ts new file mode 100644 index 00000000000..c815d91560d --- /dev/null +++ b/src/main/artifacts/artifact-recovery-directory-fsync.test.ts @@ -0,0 +1,88 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import type * as NodeFs from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' + +const fsyncMockState = vi.hoisted(() => ({ + directoryDescriptor: -1, + directoryErrorCode: 'EINVAL' +})) + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + return { + ...actual, + closeSync: (descriptor: number) => { + if (descriptor !== fsyncMockState.directoryDescriptor) { + actual.closeSync(descriptor) + } + }, + fsyncSync: (descriptor: number) => { + if (descriptor === fsyncMockState.directoryDescriptor) { + throw Object.assign(new Error('directory fsync failed'), { + code: fsyncMockState.directoryErrorCode + }) + } + return actual.fsyncSync(descriptor) + }, + openSync: (path: string, flags: string | number) => + actual.statSync(path).isDirectory() + ? fsyncMockState.directoryDescriptor + : actual.openSync(path, flags) + } +}) + +import { bestEffortFsyncDirectorySync, writeDurableSecureJsonFile } from '../../shared/secure-file' +import { + clearArtifactCreateIntents, + getOrCreateArtifactCreateIntent +} from './artifact-create-intent-store' + +const createdPaths: string[] = [] + +afterEach(() => { + fsyncMockState.directoryErrorCode = 'EINVAL' + for (const path of createdPaths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } +}) + +it('skips directory fsync on Windows and propagates I/O failures elsewhere', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-artifact-directory-fsync-eio-')) + createdPaths.push(directory) + fsyncMockState.directoryErrorCode = 'EIO' + + const fsyncDirectory = (): void => bestEffortFsyncDirectorySync(directory) + if (process.platform === 'win32') { + expect(fsyncDirectory).not.toThrow() + } else { + expect(fsyncDirectory).toThrow('directory fsync failed') + } +}) + +it('keeps durable artifact records usable when directory fsync is unsupported', () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-artifact-directory-fsync-')) + createdPaths.push(userDataPath) + const recordPath = join(userDataPath, 'artifact-shares.json') + + expect(() => writeDurableSecureJsonFile(recordPath, { ok: true })).not.toThrow() + expect(JSON.parse(readFileSync(recordPath, 'utf8'))).toEqual({ ok: true }) + + expect(() => + getOrCreateArtifactCreateIntent( + 'local-profile', + userDataPath, + '/repo/report.html', + { + cloudUserId: 'user-a', + cloudProfileId: 'profile-a', + cloudOrganizationId: 'org-a', + apiOrigin: 'https://share.onorca.dev' + }, + 'key-a', + { content: 'hello', contentType: 'text/markdown', fileName: 'report.md' } + ) + ).not.toThrow() + expect(() => clearArtifactCreateIntents('local-profile', userDataPath)).not.toThrow() +}) diff --git a/src/main/artifacts/artifact-share-record-store.ts b/src/main/artifacts/artifact-share-record-store.ts index 174856eaee4..a16ec19333c 100644 --- a/src/main/artifacts/artifact-share-record-store.ts +++ b/src/main/artifacts/artifact-share-record-store.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto' import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' -import { writeSecureJsonFile } from '../../shared/secure-file' +import { writeDurableSecureJsonFile, writeSecureJsonFile } from '../../shared/secure-file' import { getOrcaProfileDirectory } from '../orca-profiles/profile-storage-paths' export type ArtifactShareScope = { @@ -183,7 +183,7 @@ export function saveArtifactShareRecord( const records = readRecords(profileId, userDataPath) records.shares[sourceKey] = { ...record, savedAt: Date.now() } records.shares = pruneRecords(records.shares, Date.now()).shares - writeSecureJsonFile(recordPath(profileId, userDataPath), records) + writeDurableSecureJsonFile(recordPath(profileId, userDataPath), records) } export function refreshArtifactShareRecordExpiration( @@ -228,7 +228,7 @@ export function removeArtifactShareRecords( delete records.shares[sourceKey] } } - writeSecureJsonFile(recordPath(profileId, userDataPath), records) + writeDurableSecureJsonFile(recordPath(profileId, userDataPath), records) } export function clearArtifactShareRecords(profileId: string, userDataPath: string): void { @@ -238,7 +238,7 @@ export function clearArtifactShareRecords(profileId: string, userDataPath: strin } catch { // Clearing must recover sign-out from an unreadable token index. } - writeSecureJsonFile(recordPath(profileId, userDataPath), { + writeDurableSecureJsonFile(recordPath(profileId, userDataPath), { version: 2, lifecycleGeneration: lifecycleGeneration + 1, lifecycleNonce: randomUUID(), diff --git a/src/main/bitbucket/bitbucket-auth-config.ts b/src/main/bitbucket/bitbucket-auth-config.ts new file mode 100644 index 00000000000..83a4c7cf49d --- /dev/null +++ b/src/main/bitbucket/bitbucket-auth-config.ts @@ -0,0 +1,42 @@ +import { Buffer } from 'node:buffer' + +export const DEFAULT_API_BASE_URL = 'https://api.bitbucket.org/2.0' + +// Two interchangeable Bitbucket Cloud auth shapes: a single access token (Bearer) +// or an email + API token pair (HTTP Basic). Either satisfies `hasAuth`, and both +// may come from env vars or the in-app credential store. +export type BitbucketAuthConfig = { + baseUrl: string + accessToken: string | null + email: string | null + apiToken: string | null +} + +export function envValue(name: string): string | null { + const value = process.env[name]?.trim() ?? '' + return value.length > 0 ? value : null +} + +export function getEnvAuthConfig(): BitbucketAuthConfig { + return { + baseUrl: envValue('ORCA_BITBUCKET_API_BASE_URL') ?? DEFAULT_API_BASE_URL, + accessToken: envValue('ORCA_BITBUCKET_ACCESS_TOKEN'), + email: envValue('ORCA_BITBUCKET_EMAIL'), + apiToken: envValue('ORCA_BITBUCKET_API_TOKEN') + } +} + +export function hasAuth(config: BitbucketAuthConfig): boolean { + return Boolean(config.accessToken || (config.email && config.apiToken)) +} + +export function authHeaders(config: BitbucketAuthConfig): Record { + if (config.accessToken) { + return { Authorization: `Bearer ${config.accessToken}` } + } + if (config.email && config.apiToken) { + const encoded = Buffer.from(`${config.email}:${config.apiToken}`).toString('base64') + return { Authorization: `Basic ${encoded}` } + } + return {} +} diff --git a/src/main/bitbucket/client.test.ts b/src/main/bitbucket/client.test.ts index dd55bfad6d2..3b844725353 100644 --- a/src/main/bitbucket/client.test.ts +++ b/src/main/bitbucket/client.test.ts @@ -124,6 +124,105 @@ describe('Bitbucket client', () => { ).resolves.toMatchObject({ number: 42 }) }) + it('hides a MERGED PR matched only by feature-branch name so a new PR can be created', async () => { + const fetchMock = vi.fn(async (url: string) => { + if (url.includes('/statuses/build')) { + return Response.json({ values: [] }) + } + return Response.json({ + values: [ + { ...bitbucketPr(7), state: 'MERGED', source: { branch: { name: 'feature/login' } } } + ] + }) + }) + vi.stubGlobal('fetch', fetchMock) + + // Why: a merged branch match is history. Returning it made eligibility + // report "a pull request already exists" and blocked the branch's next PR. + await expect( + getBitbucketPullRequestForBranch('/repo', 'refs/heads/feature/login') + ).resolves.toBeNull() + }) + + it('keeps a DECLINED PR on a feature branch visible', async () => { + const fetchMock = vi.fn(async (url: string) => { + if (url.includes('/statuses/build')) { + return Response.json({ values: [] }) + } + return Response.json({ + values: [ + { ...bitbucketPr(9), state: 'DECLINED', source: { branch: { name: 'feature/login' } } } + ] + }) + }) + vi.stubGlobal('fetch', fetchMock) + + // Why: only merged matches are hidden. Hiding declined too made a declined + // PR permanently invisible off the default branch, unlike every other provider. + await expect( + getBitbucketPullRequestForBranch('/repo', 'refs/heads/feature/login') + ).resolves.toMatchObject({ number: 9, state: 'closed' }) + }) + + it('returns null instead of querying anonymously when no credential resolves', async () => { + delete process.env.ORCA_BITBUCKET_EMAIL + delete process.env.ORCA_BITBUCKET_API_TOKEN + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + // Why: an unauthenticated query 404s on private repos, which would read as + // "no pull request" and offer Create for a branch that already has one. + await expect( + getBitbucketPullRequestForBranch('/repo', 'refs/heads/feature/login') + ).resolves.toBeNull() + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('fetches an explicitly linked DECLINED PR before the branch index', async () => { + const fetchMock = vi.fn(async (url: string) => { + if (url.endsWith('/pullrequests/7')) { + return Response.json({ ...bitbucketPr(7), state: 'DECLINED' }) + } + if (url.includes('/statuses/build')) { + return Response.json({ values: [] }) + } + return new Response('branch index unavailable', { status: 503 }) + }) + vi.stubGlobal('fetch', fetchMock) + + await expect( + getBitbucketPullRequestForBranchOrThrow('/repo', 'refs/heads/feature/login', 7) + ).resolves.toMatchObject({ number: 7, state: 'closed' }) + expect(fetchMock.mock.calls.map(([url]) => url)).not.toContainEqual( + expect.stringContaining('/pullrequests?') + ) + }) + + it('falls back to the branch index when a linked PR number is stale', async () => { + const branchPR = bitbucketPr(7) + const fetchMock = vi.fn(async (url: string) => { + if (url.endsWith('/pullrequests/99')) { + return new Response('not found', { status: 404 }) + } + if (url.includes('/statuses/build')) { + return Response.json({ values: [] }) + } + return Response.json({ + values: [{ ...branchPR, source: { ...branchPR.source, branch: { name: 'feature/login' } } }] + }) + }) + vi.stubGlobal('fetch', fetchMock) + + await expect( + getBitbucketPullRequestForBranchOrThrow('/repo', 'refs/heads/feature/login', 99) + ).resolves.toMatchObject({ number: 7, state: 'open' }) + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ + expect.stringContaining('/pullrequests/99'), + expect.stringContaining('/pullrequests?'), + expect.stringContaining('/statuses/build') + ]) + }) + it('fetches a branch pull request and commit build status', async () => { const fetchMock = vi.fn(async (url: string, _init?: RequestInit) => { if (url.includes('/statuses/build')) { diff --git a/src/main/bitbucket/client.ts b/src/main/bitbucket/client.ts index e5163bd525a..34e4fe2a4fe 100644 --- a/src/main/bitbucket/client.ts +++ b/src/main/bitbucket/client.ts @@ -1,4 +1,3 @@ -import { Buffer } from 'node:buffer' import type { CheckStatus } from '../../shared/types' import { deriveBitbucketBuildStatus, @@ -8,25 +7,26 @@ import { type RawBitbucketBuildStatus, type RawBitbucketPullRequest } from './pull-request-mappers' -import { shouldHideNonOpenReviewOnDefaultBranch } from '../source-control/repo-default-branch' import { getBitbucketRepoRef, type BitbucketRepoRef } from './repository-ref' +import { shouldHideNonOpenReviewOnDefaultBranch } from '../source-control/repo-default-branch' import { getHostedReviewLocalGitOptions, type HostedReviewExecutionOptions } from '../source-control/hosted-review-git-options' import { cancelUnreadResponseBody } from '../lib/unread-response-body' +import { authHeaders, getEnvAuthConfig, hasAuth } from './bitbucket-auth-config' +import { accountNameFromUser, fetchBitbucketUser } from './user-request' +import { + getStoredBitbucketCredentialError, + getStoredBitbucketMetadata, + hasStoredBitbucketCredential, + loadStoredBitbucketSecret +} from './credential-store' +import { resolveBitbucketAuthConfig, storedAuthConfig } from './resolve-auth' -const DEFAULT_API_BASE_URL = 'https://api.bitbucket.org/2.0' const REQUEST_TIMEOUT_MS = 5000 const ALL_PULL_REQUEST_STATES = ['OPEN', 'MERGED', 'DECLINED', 'SUPERSEDED'] as const -type BitbucketAuthConfig = { - baseUrl: string - accessToken: string | null - email: string | null - apiToken: string | null -} - export type BitbucketAuthStatus = { configured: boolean authenticated: boolean @@ -38,42 +38,16 @@ type RequestOptions = { timeoutMs?: number } -function envValue(name: string): string | null { - const value = process.env[name]?.trim() ?? '' - return value.length > 0 ? value : null -} - -function getAuthConfig(): BitbucketAuthConfig { - return { - baseUrl: envValue('ORCA_BITBUCKET_API_BASE_URL') ?? DEFAULT_API_BASE_URL, - accessToken: envValue('ORCA_BITBUCKET_ACCESS_TOKEN'), - email: envValue('ORCA_BITBUCKET_EMAIL'), - apiToken: envValue('ORCA_BITBUCKET_API_TOKEN') - } -} - -function hasAuth(config: BitbucketAuthConfig): boolean { - return Boolean(config.accessToken || (config.email && config.apiToken)) -} - -function authHeaders(config: BitbucketAuthConfig): Record { - if (config.accessToken) { - return { Authorization: `Bearer ${config.accessToken}` } - } - if (config.email && config.apiToken) { - const encoded = Buffer.from(`${config.email}:${config.apiToken}`).toString('base64') - return { Authorization: `Basic ${encoded}` } - } - return {} -} - function isStringArray(value: string | readonly string[]): value is readonly string[] { return Array.isArray(value) } -function apiUrl(path: string, searchParams?: RequestOptions['searchParams']): string { - const config = getAuthConfig() - const base = config.baseUrl.replace(/\/+$/, '') +function apiUrl( + baseUrl: string, + path: string, + searchParams?: RequestOptions['searchParams'] +): string { + const base = baseUrl.replace(/\/+$/, '') const url = new URL(`${base}${path}`) if (searchParams) { for (const [key, value] of Object.entries(searchParams)) { @@ -95,11 +69,24 @@ async function requestJson( // Why: the existing-review lookup behind Create must distinguish a real // transport/auth failure from an accepted "no PR". When true, a failed request // throws instead of collapsing to null so callers never report false not_found. - throwOnFailure = false + throwOnFailure = false, + // Why: a linked PR number can be stale (deleted PR, wrong repo). A 404 there + // must fall through to the branch lookup rather than throw and hide the + // branch's real review, so only that caller opts into it. + notFoundIsNull = false ): Promise { - const config = getAuthConfig() + const config = resolveBitbucketAuthConfig() + // Why: a denied keychain prompt leaves no usable credential. Issuing the + // request anyway gets a 404 on private repos, which reads as "no pull + // request" and offers Create for a branch that already has one. + if (!hasAuth(config)) { + if (throwOnFailure) { + throw new Error('Bitbucket request failed: no usable credential') + } + return null + } try { - const response = await fetch(apiUrl(path, options.searchParams), { + const response = await fetch(apiUrl(config.baseUrl, path, options.searchParams), { headers: { Accept: 'application/json', ...authHeaders(config) @@ -108,6 +95,9 @@ async function requestJson( }) if (!response.ok) { await cancelUnreadResponseBody(response) + if (response.status === 404 && notFoundIsNull) { + return null + } if (throwOnFailure) { throw new Error(`Bitbucket request failed: HTTP ${response.status}`) } @@ -157,21 +147,37 @@ async function normalizePullRequest( return mapBitbucketPullRequest(raw, status) } +// Never decrypts. Env credentials are checked live; a stored credential is +// revalidated only when its secret already sits in memory from an earlier API +// call, and otherwise trusted from plaintext metadata — decrypting here would +// prompt for keychain access every time Settings opens. export async function getBitbucketAuthStatus(): Promise { - const config = getAuthConfig() - if (!hasAuth(config)) { - return { configured: false, authenticated: false, account: null } + const env = getEnvAuthConfig() + if (hasAuth(env)) { + const user = await fetchBitbucketUser(env) + return { + configured: true, + authenticated: user !== null, + account: accountNameFromUser(user) + } } - const user = await requestJson<{ - username?: string | null - display_name?: string | null - account_id?: string | null - }>('/user', { timeoutMs: 4000 }) - return { - configured: true, - authenticated: user !== null, - account: user?.username ?? user?.display_name ?? user?.account_id ?? null + const metadata = getStoredBitbucketMetadata() + if (metadata && hasStoredBitbucketCredential()) { + if (getStoredBitbucketCredentialError()) { + return { configured: true, authenticated: false, account: metadata.account } + } + const cached = loadStoredBitbucketSecret() + if (!cached) { + return { configured: true, authenticated: true, account: metadata.account } + } + const user = await fetchBitbucketUser(storedAuthConfig(metadata, cached)) + return { + configured: true, + authenticated: user !== null, + account: accountNameFromUser(user) ?? metadata.account + } } + return { configured: false, authenticated: false, account: null } } export async function getBitbucketPullRequest( @@ -216,6 +222,18 @@ export async function getBitbucketPullRequestForBranch( return null } + if (typeof linkedPRNumber === 'number') { + const raw = await requestJson( + `/repositories/${encodedRepoPath(repo)}/pullrequests/${encodeURIComponent(String(linkedPRNumber))}`, + {}, + throwOnFailure, + true + ) + if (raw) { + return normalizePullRequest(repo, raw) + } + } + if (branchName) { const query = [ `source.branch.name = "${escapeBitbucketQueryString(branchName)}"`, @@ -235,10 +253,16 @@ export async function getBitbucketPullRequestForBranch( ) const raw = list?.values?.[0] if (raw) { - // Why (#9171): discard a non-open implicit branch match on the repo - // default branch and fall through to the linked-number fallback below. + const state = mapBitbucketPullRequestState(raw.state) + // Why: a merged PR we only matched by branch name is history, not review + // context (GitHub's isMergedImplicitPR rule). Keeping it reported "a pull + // request already exists" and blocked the branch's next PR. Scoped to + // merged so a declined PR stays visible off the default branch, matching + // every other provider; the linked lookup above already returned early + // for an explicitly linked review. + const isMergedImplicitMatch = state === 'merged' const hideOnDefaultBranch = await shouldHideNonOpenReviewOnDefaultBranch({ - state: mapBitbucketPullRequestState(raw.state), + state, reviewNumber: raw.id ?? null, linkedReviewNumber: linkedPRNumber, branchName, @@ -246,21 +270,13 @@ export async function getBitbucketPullRequestForBranch( connectionId, localGitOptions: getHostedReviewLocalGitOptions(options) }) - if (!hideOnDefaultBranch) { + if (!isMergedImplicitMatch && !hideOnDefaultBranch) { return normalizePullRequest(repo, raw) } } } - if (typeof linkedPRNumber !== 'number') { - return null - } - const raw = await requestJson( - `/repositories/${encodedRepoPath(repo)}/pullrequests/${encodeURIComponent(String(linkedPRNumber))}`, - {}, - throwOnFailure - ) - return raw ? normalizePullRequest(repo, raw) : null + return null } /** diff --git a/src/main/bitbucket/credential-connection.test.ts b/src/main/bitbucket/credential-connection.test.ts new file mode 100644 index 00000000000..25c51e81f87 --- /dev/null +++ b/src/main/bitbucket/credential-connection.test.ts @@ -0,0 +1,163 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import type * as Os from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const OLD_ENV = process.env +const OLD_FETCH = globalThis.fetch +let tempHome = '' + +async function loadModule() { + vi.resetModules() + vi.doMock('electron', () => ({ + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(value), + decryptString: (value: Buffer) => value.toString('utf-8') + } + })) + vi.doMock('node:os', async () => { + const actual = await vi.importActual('node:os') + return { ...actual, homedir: () => tempHome } + }) + return import('./credential-connection') +} + +beforeEach(() => { + process.env = { ...OLD_ENV } + for (const key of [ + 'ORCA_BITBUCKET_ACCESS_TOKEN', + 'ORCA_BITBUCKET_EMAIL', + 'ORCA_BITBUCKET_API_TOKEN', + 'ORCA_BITBUCKET_API_BASE_URL' + ]) { + delete process.env[key] + } + tempHome = mkdtempSync(join(tmpdir(), 'orca-bitbucket-conn-')) +}) + +afterEach(() => { + process.env = OLD_ENV + globalThis.fetch = OLD_FETCH +}) + +describe('Bitbucket credential connection', () => { + it('verifies credentials before saving and reports a stored connection', async () => { + const conn = await loadModule() + globalThis.fetch = vi.fn(async () => + Response.json({ username: 'ada' }) + ) as unknown as typeof fetch + + await expect( + conn.connectBitbucket({ + authMode: 'basic', + email: 'ada@example.com', + apiToken: 'tok' + }) + ).resolves.toEqual({ ok: true, account: 'ada' }) + + expect(conn.getBitbucketConnectionStatus()).toEqual({ + configured: true, + source: 'stored', + account: 'ada', + authMode: 'basic', + email: 'ada@example.com', + baseUrl: null + }) + }) + + it('sends Basic auth built from the entered email and API token', async () => { + const conn = await loadModule() + const fetchSpy = vi.fn(async () => Response.json({ username: 'ada' })) + globalThis.fetch = fetchSpy as unknown as typeof fetch + + await conn.connectBitbucket({ authMode: 'basic', email: 'ada@example.com', apiToken: 'tok' }) + + const [url, init] = fetchSpy.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe('https://api.bitbucket.org/2.0/user') + expect((init.headers as Record).Authorization).toBe( + `Basic ${Buffer.from('ada@example.com:tok').toString('base64')}` + ) + }) + + it('rejects credentials that fail the /user check without saving them', async () => { + const conn = await loadModule() + globalThis.fetch = vi.fn( + async () => new Response(null, { status: 401 }) + ) as unknown as typeof fetch + + const result = await conn.connectBitbucket({ + authMode: 'token', + accessToken: 'bad' + }) + expect(result.ok).toBe(false) + expect(conn.getBitbucketConnectionStatus().source).toBe('none') + }) + + it('rejects an incomplete basic-auth credential before making a request', async () => { + const conn = await loadModule() + const fetchSpy = vi.fn() + globalThis.fetch = fetchSpy as unknown as typeof fetch + + const result = await conn.connectBitbucket({ + authMode: 'basic', + email: 'ada@example.com' + }) + expect(result.ok).toBe(false) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it('reports environment variables as the source and takes precedence over stored creds', async () => { + const conn = await loadModule() + globalThis.fetch = vi.fn(async () => + Response.json({ username: 'ada' }) + ) as unknown as typeof fetch + await conn.connectBitbucket({ + authMode: 'basic', + email: 'ada@example.com', + apiToken: 'tok' + }) + + process.env.ORCA_BITBUCKET_ACCESS_TOKEN = 'env-token' + expect(conn.getBitbucketConnectionStatus()).toMatchObject({ + configured: true, + source: 'environment', + authMode: 'token' + }) + }) + + it('lets an env API base URL override the stored one without env credentials', async () => { + const conn = await loadModule() + globalThis.fetch = vi.fn(async () => + Response.json({ username: 'ada' }) + ) as unknown as typeof fetch + await conn.connectBitbucket({ + authMode: 'basic', + email: 'ada@example.com', + apiToken: 'tok', + baseUrl: 'https://stored.example.com/2.0' + }) + + // Only the base URL is in the env, so `hasAuth(env)` is false — precedence + // is per-setting, not all-or-nothing. + process.env.ORCA_BITBUCKET_API_BASE_URL = 'https://env.example.com/2.0' + const { resolveBitbucketAuthConfig } = await import('./resolve-auth') + expect(resolveBitbucketAuthConfig().baseUrl).toBe('https://env.example.com/2.0') + }) + + it('clears the stored connection on disconnect', async () => { + const conn = await loadModule() + globalThis.fetch = vi.fn(async () => + Response.json({ username: 'ada' }) + ) as unknown as typeof fetch + await conn.connectBitbucket({ + authMode: 'basic', + email: 'ada@example.com', + apiToken: 'tok' + }) + + conn.disconnectBitbucket() + expect(conn.getBitbucketConnectionStatus().source).toBe('none') + }) +}) diff --git a/src/main/bitbucket/credential-connection.ts b/src/main/bitbucket/credential-connection.ts new file mode 100644 index 00000000000..821af4a4d2f --- /dev/null +++ b/src/main/bitbucket/credential-connection.ts @@ -0,0 +1,110 @@ +import { + DEFAULT_API_BASE_URL, + envValue, + getEnvAuthConfig, + hasAuth, + type BitbucketAuthConfig +} from './bitbucket-auth-config' +import { accountNameFromUser, fetchBitbucketUser } from './user-request' +import { + clearStoredBitbucketCredential, + getStoredBitbucketMetadata, + hasStoredBitbucketCredential, + saveBitbucketCredential +} from './credential-store' +import type { + BitbucketConnectArgs, + BitbucketConnectResult, + BitbucketConnectionStatus +} from '../../shared/bitbucket-credentials' + +export type { BitbucketConnectArgs, BitbucketConnectResult, BitbucketConnectionStatus } + +const VERIFY_TIMEOUT_MS = 6000 + +function normalize(value: string | null | undefined): string | null { + const trimmed = value?.trim() ?? '' + return trimmed.length > 0 ? trimmed : null +} + +function buildCandidateConfig(input: BitbucketConnectArgs): BitbucketAuthConfig { + return { + baseUrl: normalize(input.baseUrl) ?? DEFAULT_API_BASE_URL, + accessToken: input.authMode === 'token' ? normalize(input.accessToken) : null, + email: input.authMode === 'basic' ? normalize(input.email) : null, + apiToken: input.authMode === 'basic' ? normalize(input.apiToken) : null + } +} + +// Verifying against `/user` before persisting keeps the stored "connected +// account" honest and lets the dialog reject a dead token inline. +export async function connectBitbucket( + input: BitbucketConnectArgs +): Promise { + const config = buildCandidateConfig(input) + if (!hasAuth(config)) { + return { + ok: false, + error: + input.authMode === 'token' + ? 'Enter an access token.' + : 'Enter both an email and an API token.' + } + } + const user = await fetchBitbucketUser(config, VERIFY_TIMEOUT_MS) + if (!user) { + return { + ok: false, + error: 'Could not authenticate with Bitbucket. Check the credentials and try again.' + } + } + const account = accountNameFromUser(user) + saveBitbucketCredential({ + authMode: input.authMode, + email: config.email, + baseUrl: normalize(input.baseUrl), + account, + accessToken: config.accessToken, + apiToken: config.apiToken + }) + return { ok: true, account } +} + +export function disconnectBitbucket(): void { + clearStoredBitbucketCredential() +} + +// Reads env vars and plaintext metadata only — never decrypts — so the Settings +// card can call it on every open without a keychain prompt. +export function getBitbucketConnectionStatus(): BitbucketConnectionStatus { + const env = getEnvAuthConfig() + if (hasAuth(env)) { + return { + configured: true, + source: 'environment', + account: null, + authMode: env.accessToken ? 'token' : 'basic', + email: env.email, + baseUrl: envValue('ORCA_BITBUCKET_API_BASE_URL') + } + } + if (hasStoredBitbucketCredential()) { + const metadata = getStoredBitbucketMetadata() + return { + configured: true, + source: 'stored', + account: metadata?.account ?? null, + authMode: metadata?.authMode ?? null, + email: metadata?.email ?? null, + baseUrl: metadata?.baseUrl ?? null + } + } + return { + configured: false, + source: 'none', + account: null, + authMode: null, + email: null, + baseUrl: null + } +} diff --git a/src/main/bitbucket/credential-store.test.ts b/src/main/bitbucket/credential-store.test.ts new file mode 100644 index 00000000000..6c6a6754b12 --- /dev/null +++ b/src/main/bitbucket/credential-store.test.ts @@ -0,0 +1,218 @@ +import { existsSync, mkdtempSync, statSync } from 'node:fs' +import type * as Fs from 'node:fs' +import { tmpdir } from 'node:os' +import type * as Os from 'node:os' +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +let tempHome = '' +const decryptStringMock = vi.fn((value: Buffer) => value.toString('utf-8')) + +async function loadStore(options: { unlinkError?: NodeJS.ErrnoException } = {}) { + vi.resetModules() + vi.doMock('electron', () => ({ + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(value), + decryptString: decryptStringMock + } + })) + vi.doMock('node:os', async () => { + const actual = await vi.importActual('node:os') + return { ...actual, homedir: () => tempHome } + }) + if (options.unlinkError) { + // Why mocked: chmod-based failure injection is not portable — Windows has no + // POSIX modes and a root/elevated runner can unlink through a 0500 directory. + const error = options.unlinkError + vi.doMock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + return { + ...actual, + unlinkSync: () => { + throw error + } + } + }) + } + return import('./credential-store') +} + +beforeEach(() => { + tempHome = mkdtempSync(join(tmpdir(), 'orca-bitbucket-store-')) + decryptStringMock.mockClear() +}) + +describe('Bitbucket credential store', () => { + it('persists plaintext metadata and an encrypted secret, then reads them back', async () => { + const store = await loadStore() + store.saveBitbucketCredential({ + authMode: 'basic', + email: 'ada@example.com', + baseUrl: null, + account: 'ada', + accessToken: null, + apiToken: 'secret-token' + }) + + expect(store.hasStoredBitbucketCredential()).toBe(true) + expect(store.getStoredBitbucketMetadata()).toMatchObject({ + authMode: 'basic', + email: 'ada@example.com', + account: 'ada' + }) + expect(store.loadStoredBitbucketSecret()).toEqual({ + accessToken: null, + apiToken: 'secret-token' + }) + }) + + it('writes both credential files 0600', async () => { + const store = await loadStore() + store.saveBitbucketCredential({ + authMode: 'token', + email: null, + baseUrl: null, + account: 'dev', + accessToken: 'access-secret', + apiToken: null + }) + + for (const file of ['bitbucket-credential.enc', 'bitbucket-credential.json']) { + expect(statSync(join(tempHome, '.orca', file)).mode & 0o777).toBe(0o600) + } + }) + + it('re-tightens permissions when overwriting an existing credential', async () => { + const store = await loadStore() + const save = (account: string): void => + store.saveBitbucketCredential({ + authMode: 'token', + email: null, + baseUrl: null, + account, + accessToken: 'access-secret', + apiToken: null + }) + save('first') + // Why: writeFileSync's mode is ignored for an existing file, so a loosened + // credential would stay world-readable across a reconnect. + const { chmodSync } = await import('node:fs') + for (const file of ['bitbucket-credential.enc', 'bitbucket-credential.json']) { + chmodSync(join(tempHome, '.orca', file), 0o644) + } + save('second') + + for (const file of ['bitbucket-credential.enc', 'bitbucket-credential.json']) { + expect(statSync(join(tempHome, '.orca', file)).mode & 0o777).toBe(0o600) + } + }) + + it('does not decrypt for metadata/status reads — only on a forced secret load', async () => { + const store = await loadStore() + store.saveBitbucketCredential({ + authMode: 'token', + email: null, + baseUrl: 'https://api.bitbucket.org/2.0', + account: 'dev', + accessToken: 'access-secret', + apiToken: null + }) + + // Simulate a fresh session: caches cleared, files still on disk. + store._resetBitbucketCredentialCache() + + expect(store.getStoredBitbucketMetadata()?.account).toBe('dev') + expect(store.hasStoredBitbucketCredential()).toBe(true) + expect(decryptStringMock).not.toHaveBeenCalled() + + // Without force, the secret stays unread. + expect(store.loadStoredBitbucketSecret()).toBeNull() + expect(decryptStringMock).not.toHaveBeenCalled() + + // Forcing the load decrypts exactly once, then caches. + expect(store.loadStoredBitbucketSecret({ force: true })).toEqual({ + accessToken: 'access-secret', + apiToken: null + }) + expect(decryptStringMock).toHaveBeenCalledTimes(1) + expect(store.loadStoredBitbucketSecret()).not.toBeNull() + expect(decryptStringMock).toHaveBeenCalledTimes(1) + }) + + it('rejects non-string fields from hand-edited metadata and secret files', async () => { + const store = await loadStore() + const { writeFileSync } = await import('node:fs') + store.saveBitbucketCredential({ + authMode: 'basic', + email: 'ada@example.com', + baseUrl: null, + account: 'ada', + accessToken: null, + apiToken: 'secret-token' + }) + + writeFileSync( + join(tempHome, '.orca', 'bitbucket-credential.json'), + JSON.stringify({ version: 1, authMode: 'basic', email: { evil: true }, account: 42 }) + ) + writeFileSync( + join(tempHome, '.orca', 'bitbucket-credential.enc'), + JSON.stringify({ accessToken: ['nope'], apiToken: 7 }) + ) + store._resetBitbucketCredentialCache() + + expect(store.getStoredBitbucketMetadata()).toMatchObject({ email: null, account: null }) + expect(store.loadStoredBitbucketSecret({ force: true })).toEqual({ + accessToken: null, + apiToken: null + }) + }) + + it('clears both files and in-memory state on disconnect', async () => { + const store = await loadStore() + store.saveBitbucketCredential({ + authMode: 'basic', + email: 'ada@example.com', + baseUrl: null, + account: 'ada', + accessToken: null, + apiToken: 'secret-token' + }) + + store.clearStoredBitbucketCredential() + + expect(store.hasStoredBitbucketCredential()).toBe(false) + expect(store.getStoredBitbucketMetadata()).toBeNull() + expect(existsSync(join(tempHome, '.orca', 'bitbucket-credential.enc'))).toBe(false) + expect(existsSync(join(tempHome, '.orca', 'bitbucket-credential.json'))).toBe(false) + }) + + it('surfaces a non-ENOENT delete failure instead of silently keeping the files', async () => { + const denied: NodeJS.ErrnoException = Object.assign(new Error('permission denied'), { + code: 'EACCES' + }) + const store = await loadStore({ unlinkError: denied }) + store.saveBitbucketCredential({ + authMode: 'basic', + email: 'ada@example.com', + baseUrl: null, + account: 'ada', + accessToken: null, + apiToken: 'secret-token' + }) + + // Clearing memory while the files survive would resurrect the credential on + // the next launch, so the failure has to reach the caller. + expect(() => store.clearStoredBitbucketCredential()).toThrow(/permission denied/) + expect(existsSync(join(tempHome, '.orca', 'bitbucket-credential.enc'))).toBe(true) + }) + + it('ignores a missing file on disconnect', async () => { + const missing: NodeJS.ErrnoException = Object.assign(new Error('no such file'), { + code: 'ENOENT' + }) + const store = await loadStore({ unlinkError: missing }) + expect(() => store.clearStoredBitbucketCredential()).not.toThrow() + }) +}) diff --git a/src/main/bitbucket/credential-store.ts b/src/main/bitbucket/credential-store.ts new file mode 100644 index 00000000000..17b3bb56d2c --- /dev/null +++ b/src/main/bitbucket/credential-store.ts @@ -0,0 +1,202 @@ +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { + CredentialDecryptionError, + credentialFileHasContent, + readStoredCredentialToken, + restrictCredentialFileToOwner, + writeEncryptedCredential +} from '../integration-credential-file' +import type { BitbucketAuthMode } from '../../shared/bitbucket-credentials' + +// Why: the secret stays encrypted via safeStorage while this metadata stays +// plaintext, so status reads render the connected account without decrypting — +// otherwise every Settings open would trigger an OS keychain prompt. +export type BitbucketStoredMetadata = { + version: 1 + authMode: BitbucketAuthMode + email: string | null + baseUrl: string | null + account: string | null + updatedAt: string +} + +export type BitbucketStoredSecret = { + accessToken: string | null + apiToken: string | null +} + +export type BitbucketCredentialSaveInput = { + authMode: BitbucketAuthMode + email: string | null + baseUrl: string | null + account: string | null + accessToken: string | null + apiToken: string | null +} + +let cachedMetadata: BitbucketStoredMetadata | null = null +let metadataLoadedFromDisk = false +let cachedSecret: BitbucketStoredSecret | null = null +let credentialError: string | null = null + +function getOrcaDir(): string { + return join(homedir(), '.orca') +} + +function getMetadataPath(): string { + return join(getOrcaDir(), 'bitbucket-credential.json') +} + +function getSecretPath(): string { + return join(getOrcaDir(), 'bitbucket-credential.enc') +} + +function ensureOrcaDir(): void { + const dir = getOrcaDir() + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } +} + +// Why: hand-edited or truncated JSON must not put a non-string into an auth +// header, so every field is narrowed rather than trusted. +function asOptionalString(value: unknown): string | null { + return typeof value === 'string' ? value : null +} + +function readMetadataFromDisk(): BitbucketStoredMetadata | null { + const path = getMetadataPath() + if (!existsSync(path)) { + return null + } + try { + const parsed = JSON.parse(readFileSync(path, 'utf-8')) as Partial + if (parsed.authMode !== 'token' && parsed.authMode !== 'basic') { + return null + } + return { + version: 1, + authMode: parsed.authMode, + email: asOptionalString(parsed.email), + baseUrl: asOptionalString(parsed.baseUrl), + account: asOptionalString(parsed.account), + updatedAt: asOptionalString(parsed.updatedAt) ?? '' + } + } catch { + return null + } +} + +export function getStoredBitbucketMetadata(): BitbucketStoredMetadata | null { + if (!metadataLoadedFromDisk) { + cachedMetadata = readMetadataFromDisk() + metadataLoadedFromDisk = true + } + return cachedMetadata +} + +// Cheap "is a credential saved?" check: metadata present and a non-empty secret +// file. Never decrypts, so it is safe on every status poll. +export function hasStoredBitbucketCredential(): boolean { + return getStoredBitbucketMetadata() !== null && credentialFileHasContent(getSecretPath()) +} + +export function getStoredBitbucketCredentialError(): string | null { + return credentialError +} + +// Cache-first; only touches the keychain when `force` is set and the secret is +// not already in memory (mirrors Linear's `loadToken({ force })`). Throws +// CredentialDecryptionError when ciphertext cannot be decrypted. +export function loadStoredBitbucketSecret( + options: { force?: boolean } = {} +): BitbucketStoredSecret | null { + if (cachedSecret !== null) { + return cachedSecret + } + if (!options.force) { + return null + } + const path = getSecretPath() + if (!existsSync(path)) { + return null + } + try { + const token = readStoredCredentialToken('Bitbucket', readFileSync(path)) + if (!token) { + return null + } + const parsed = JSON.parse(token) as Partial + cachedSecret = { + accessToken: asOptionalString(parsed.accessToken), + apiToken: asOptionalString(parsed.apiToken) + } + credentialError = null + return cachedSecret + } catch (error) { + if (error instanceof CredentialDecryptionError) { + credentialError = error.message + throw error + } + return null + } +} + +export function saveBitbucketCredential(input: BitbucketCredentialSaveInput): void { + ensureOrcaDir() + const secret: BitbucketStoredSecret = { + accessToken: input.accessToken, + apiToken: input.apiToken + } + writeEncryptedCredential('Bitbucket', getSecretPath(), JSON.stringify(secret)) + const metadata: BitbucketStoredMetadata = { + version: 1, + authMode: input.authMode, + email: input.email, + baseUrl: input.baseUrl, + account: input.account, + updatedAt: new Date().toISOString() + } + writeFileSync(getMetadataPath(), JSON.stringify(metadata, null, 2), { + encoding: 'utf-8', + mode: 0o600 + }) + restrictCredentialFileToOwner(getMetadataPath()) + cachedMetadata = metadata + metadataLoadedFromDisk = true + cachedSecret = secret + credentialError = null +} + +// Why: swallowing a non-ENOENT unlink failure would clear memory while the +// files survive, so the credential silently returns on the next launch. +export function clearStoredBitbucketCredential(): void { + try { + for (const path of [getSecretPath(), getMetadataPath()]) { + try { + unlinkSync(path) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error + } + } + } + } finally { + // Why: on a partial delete the caches must still be dropped, or the session + // keeps authenticating with a credential the user just disconnected. + cachedMetadata = null + metadataLoadedFromDisk = true + cachedSecret = null + credentialError = null + } +} + +/** @internal - tests need a clean in-memory cache between cases. */ +export function _resetBitbucketCredentialCache(): void { + cachedMetadata = null + metadataLoadedFromDisk = false + cachedSecret = null + credentialError = null +} diff --git a/src/main/bitbucket/pull-request-creation.test.ts b/src/main/bitbucket/pull-request-creation.test.ts new file mode 100644 index 00000000000..ab06200e22e --- /dev/null +++ b/src/main/bitbucket/pull-request-creation.test.ts @@ -0,0 +1,180 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + createBitbucketPullRequest, + isBitbucketReviewCreationAuthenticated +} from './pull-request-creation' +import { _resetBitbucketRepoRefCache } from './repository-ref' + +const { gitExecFileAsyncMock, getSshGitProviderMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn(), + getSshGitProviderMock: vi.fn() +})) + +vi.mock('../git/runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock +})) + +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: getSshGitProviderMock, + getSshGitProviderGeneration: () => 0 +})) + +vi.mock('../source-control/pull-request-template', () => ({ + readHostedPullRequestTemplate: vi.fn(async () => 'Template body') +})) + +const OLD_ENV = process.env +const OLD_FETCH = globalThis.fetch + +const CREATE_INPUT = { + provider: 'bitbucket', + base: 'main', + head: 'feature/login', + title: 'Add login' +} as const + +function createdPullRequestResponse(): Response { + return Response.json({ + id: 42, + title: 'Add login', + state: 'OPEN', + updated_on: '2026-08-11T00:00:00Z', + links: { html: { href: 'https://bitbucket.org/team/repo/pull-requests/42' } }, + source: { branch: { name: 'feature/login' } }, + destination: { branch: { name: 'main' } } + }) +} + +describe('Bitbucket pull request creation', () => { + beforeEach(() => { + process.env = { ...OLD_ENV, ORCA_BITBUCKET_ACCESS_TOKEN: 'bb-token' } + gitExecFileAsyncMock.mockReset() + getSshGitProviderMock.mockReset() + gitExecFileAsyncMock.mockResolvedValue({ + stdout: 'https://bitbucket.org/team/repo.git\n', + stderr: '' + }) + _resetBitbucketRepoRefCache() + }) + + afterEach(() => { + process.env = OLD_ENV + globalThis.fetch = OLD_FETCH + _resetBitbucketRepoRefCache() + }) + + it('posts the Bitbucket source/destination create body to the repository endpoint', async () => { + const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(String(input)) + expect(url.origin).toBe('https://api.bitbucket.org') + expect(url.pathname).toBe('/2.0/repositories/team/repo/pullrequests') + const requestInit = init! + expect(requestInit.method).toBe('POST') + expect((requestInit.headers as Record).Authorization).toBe('Bearer bb-token') + expect(JSON.parse(String(requestInit.body))).toEqual({ + title: 'Add login', + description: '', + source: { branch: { name: 'feature/login' } }, + destination: { branch: { name: 'main' } } + }) + return createdPullRequestResponse() + }) + globalThis.fetch = fetchMock as unknown as typeof fetch + + await expect(createBitbucketPullRequest('/repo', CREATE_INPUT)).resolves.toEqual({ + ok: true, + number: 42, + url: 'https://bitbucket.org/team/repo/pull-requests/42' + }) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('uses the stored credential when no environment variable is set', async () => { + delete process.env.ORCA_BITBUCKET_ACCESS_TOKEN + const fetchMock = vi.fn(async () => createdPullRequestResponse()) + globalThis.fetch = fetchMock as unknown as typeof fetch + + const result = await createBitbucketPullRequest('/repo', CREATE_INPUT) + + // No env var and no stored credential: fail closed rather than POST anonymously. + expect(result).toMatchObject({ ok: false, code: 'auth_required' }) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('ignores a draft request rather than dead-ending a hidden persisted default', async () => { + const fetchMock = vi.fn(async () => createdPullRequestResponse()) + globalThis.fetch = fetchMock as unknown as typeof fetch + + // Why: the composer hides the Draft toggle for Bitbucket, so a `true` here + // is an unreachable persisted default the user cannot clear. + await expect( + createBitbucketPullRequest('/repo', { ...CREATE_INPUT, draft: true }) + ).resolves.toMatchObject({ ok: true, number: 42 }) + expect(JSON.parse(String((fetchMock.mock.calls[0] as never[])[1]['body']))).not.toHaveProperty( + 'draft' + ) + }) + + it('reports authenticated only when a credential resolves', async () => { + expect(isBitbucketReviewCreationAuthenticated()).toBe(true) + delete process.env.ORCA_BITBUCKET_ACCESS_TOKEN + expect(isBitbucketReviewCreationAuthenticated()).toBe(false) + }) + + it('maps a duplicate-branch rejection to already_exists with the existing review', async () => { + let call = 0 + const fetchMock = vi.fn(async () => { + call += 1 + if (call === 1) { + return Response.json( + { error: { message: 'There is already a pull request for this branch.' } }, + { status: 400 } + ) + } + return Response.json({ + values: [ + { + id: 7, + title: 'Add login', + state: 'OPEN', + updated_on: '2026-08-11T00:00:00Z', + links: { html: { href: 'https://bitbucket.org/team/repo/pull-requests/7' } }, + source: { branch: { name: 'feature/login' } }, + destination: { branch: { name: 'main' } } + } + ] + }) + }) + globalThis.fetch = fetchMock as unknown as typeof fetch + + expect(await createBitbucketPullRequest('/repo', CREATE_INPUT)).toMatchObject({ + ok: false, + code: 'already_exists', + existingReview: { number: 7, url: 'https://bitbucket.org/team/repo/pull-requests/7' } + }) + }) + + it('maps a 401 to auth_required pointing at both credential paths', async () => { + globalThis.fetch = vi.fn(async () => + Response.json({ error: { message: 'Unauthorized' } }, { status: 401 }) + ) as unknown as typeof fetch + + const result = await createBitbucketPullRequest('/repo', CREATE_INPUT) + + expect(result).toMatchObject({ ok: false, code: 'auth_required' }) + expect(!result.ok && result.error).toContain('Settings') + }) + + it('refuses a non-Bitbucket remote', async () => { + gitExecFileAsyncMock.mockResolvedValue({ + stdout: 'https://github.com/team/repo.git\n', + stderr: '' + }) + globalThis.fetch = vi.fn() as unknown as typeof fetch + + await expect(createBitbucketPullRequest('/repo', CREATE_INPUT)).resolves.toMatchObject({ + ok: false, + code: 'unsupported_provider' + }) + }) +}) diff --git a/src/main/bitbucket/pull-request-creation.ts b/src/main/bitbucket/pull-request-creation.ts new file mode 100644 index 00000000000..86a44f49b4e --- /dev/null +++ b/src/main/bitbucket/pull-request-creation.ts @@ -0,0 +1,221 @@ +import type { CreateHostedReviewInput, CreateHostedReviewResult } from '../../shared/hosted-review' +import { + normalizeHostedReviewBaseRef, + normalizeHostedReviewHeadRef +} from '../../shared/hosted-review-refs' +import { + HostedReviewApiRequestError, + requestHostedReviewJson +} from '../source-control/hosted-review-api-request' +import { readHostedPullRequestTemplate } from '../source-control/pull-request-template' +import { authHeaders, hasAuth } from './bitbucket-auth-config' +import { resolveBitbucketAuthConfig } from './resolve-auth' +import { getBitbucketPullRequestForBranch } from './client' +import { mapBitbucketPullRequest, type RawBitbucketPullRequest } from './pull-request-mappers' +import { getBitbucketRepoRef, type BitbucketRepoRef } from './repository-ref' +import type { HostedReviewExecutionOptions } from '../source-control/hosted-review-git-options' +import { getHostedReviewLocalGitOptions } from '../source-control/hosted-review-git-options' + +const CREATE_REQUEST_TIMEOUT_MS = 60_000 + +export function isBitbucketReviewCreationAuthenticated(): boolean { + return hasAuth(resolveBitbucketAuthConfig()) +} + +function encodedRepoPath(repo: BitbucketRepoRef): string { + return `${encodeURIComponent(repo.workspace)}/${encodeURIComponent(repo.repoSlug)}` +} + +function apiErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function classifyCreateError(error: unknown): CreateHostedReviewResult { + const message = apiErrorMessage(error) + if (message) { + console.warn('createBitbucketPullRequest failed:', message) + } + const lower = message.toLowerCase() + const status = error instanceof HostedReviewApiRequestError ? error.status : null + if (status === 401 || status === 403 || lower.includes('unauthorized')) { + return { + ok: false, + code: 'auth_required', + error: + 'Create PR failed: Bitbucket is not authenticated. Next step: connect Bitbucket in Settings > Integrations, or set ORCA_BITBUCKET_* in this environment.' + } + } + // Bitbucket answers a duplicate source branch with 400 plus this phrasing + // rather than a 409, so the text is the only reliable signal. + if (lower.includes('already exists') || lower.includes('already a pull request')) { + return { + ok: false, + code: 'already_exists', + error: 'A pull request already exists for this branch.' + } + } + if (error instanceof HostedReviewApiRequestError && error.timedOut) { + return { + ok: false, + code: 'unknown_completion', + error: 'PR creation may have completed. Refreshing branch review state...' + } + } + if (status === 400 || status === 422) { + return { + ok: false, + code: 'validation', + error: + 'Create PR failed: Bitbucket rejected the pull request. Check the base branch and branch state, then try again.' + } + } + if (status === 404) { + return { + ok: false, + code: 'validation', + error: + 'Create PR failed: Bitbucket could not find the repository. Check that the credential has write access to it.' + } + } + return { + ok: false, + code: 'unknown', + error: 'Create PR failed: Bitbucket could not create the pull request. Try again in a moment.' + } +} + +async function findExistingPullRequest( + repoPath: string, + head: string, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +): Promise<{ number: number; url: string } | null> { + const existing = await getBitbucketPullRequestForBranch( + repoPath, + head, + null, + connectionId, + options + ) + return existing ? { number: existing.number, url: existing.url } : null +} + +export async function createBitbucketPullRequest( + repoPath: string, + input: CreateHostedReviewInput, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +): Promise { + if (input.provider !== 'bitbucket') { + return { + ok: false, + code: 'unsupported_provider', + error: 'Creating reviews for this provider is not supported yet.' + } + } + + const config = resolveBitbucketAuthConfig() + if (!hasAuth(config)) { + return { + ok: false, + code: 'auth_required', + error: + 'Create PR failed: Bitbucket is not connected. Next step: connect Bitbucket in Settings > Integrations.' + } + } + + const repo = await getBitbucketRepoRef( + repoPath, + connectionId, + getHostedReviewLocalGitOptions(options) + ) + if (!repo) { + return { + ok: false, + code: 'unsupported_provider', + error: 'Creating pull requests requires a Bitbucket remote.' + } + } + + const base = normalizeHostedReviewBaseRef(input.base) + const head = input.head ? normalizeHostedReviewHeadRef(input.head) : '' + const title = input.title.trim() + if (!base || !head || !title) { + return { + ok: false, + code: 'validation', + error: 'Create PR failed: base branch, head branch, and title are required.' + } + } + if (head.toLowerCase() === base.toLowerCase()) { + return { + ok: false, + code: 'validation', + error: 'Create PR failed: choose a different base branch before creating a pull request.' + } + } + // Why: Bitbucket Cloud has no draft pull requests and the composer hides the + // toggle, so `draft` here can only be an unreachable persisted default — + // rejecting it would dead-end the user with no control to clear. + const description = + input.useTemplate && !input.body?.trim() + ? await readHostedPullRequestTemplate(repoPath, connectionId) + : (input.body ?? '') + const requestBody = { + title, + description, + source: { branch: { name: head } }, + destination: { branch: { name: base } } + } + + try { + const raw = await requestHostedReviewJson( + new URL( + `${config.baseUrl.replace(/\/+$/, '')}/repositories/${encodedRepoPath(repo)}/pullrequests` + ), + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...authHeaders(config) + }, + body: JSON.stringify(requestBody) + }, + CREATE_REQUEST_TIMEOUT_MS + ) + const created = mapBitbucketPullRequest(raw, 'neutral') + if (created) { + return { ok: true, number: created.number, url: created.url } + } + const found = await findExistingPullRequest(repoPath, head, connectionId, options).catch( + () => null + ) + return found + ? { ok: true, ...found } + : { + ok: false, + code: 'unknown_completion', + error: 'PR creation may have completed. Refreshing branch review state...' + } + } catch (error) { + const classified = classifyCreateError(error) + if ( + !classified.ok && + (classified.code === 'already_exists' || classified.code === 'unknown_completion') + ) { + const existing = await findExistingPullRequest(repoPath, head, connectionId, options).catch( + () => null + ) + if (existing) { + return { + ok: false, + code: 'already_exists', + error: 'A pull request already exists for this branch.', + existingReview: existing + } + } + } + return classified + } +} diff --git a/src/main/bitbucket/resolve-auth.ts b/src/main/bitbucket/resolve-auth.ts new file mode 100644 index 00000000000..8e5835ab57e --- /dev/null +++ b/src/main/bitbucket/resolve-auth.ts @@ -0,0 +1,48 @@ +import { + DEFAULT_API_BASE_URL, + envValue, + getEnvAuthConfig, + hasAuth, + type BitbucketAuthConfig +} from './bitbucket-auth-config' +import { + getStoredBitbucketMetadata, + loadStoredBitbucketSecret, + type BitbucketStoredMetadata, + type BitbucketStoredSecret +} from './credential-store' + +export function storedAuthConfig( + metadata: BitbucketStoredMetadata, + secret: BitbucketStoredSecret +): BitbucketAuthConfig { + return { + // Why: an explicit ORCA_BITBUCKET_API_BASE_URL still wins even when the + // credential itself is stored — env precedence is per-setting, not all-or-nothing. + baseUrl: envValue('ORCA_BITBUCKET_API_BASE_URL') ?? metadata.baseUrl ?? DEFAULT_API_BASE_URL, + accessToken: metadata.authMode === 'token' ? secret.accessToken : null, + email: metadata.authMode === 'basic' ? metadata.email : null, + apiToken: metadata.authMode === 'basic' ? secret.apiToken : null + } +} + +// Env vars win over in-app credentials so existing headless/SSH setups keep +// working unchanged. The stored secret is decrypted lazily and only here, on a +// real API call — never on a status read. +export function resolveBitbucketAuthConfig(): BitbucketAuthConfig { + const env = getEnvAuthConfig() + if (hasAuth(env)) { + return env + } + const metadata = getStoredBitbucketMetadata() + if (!metadata) { + return env + } + try { + const secret = loadStoredBitbucketSecret({ force: true }) + return secret ? storedAuthConfig(metadata, secret) : env + } catch { + // Decryption denied or unavailable: fall through as unauthenticated. + return env + } +} diff --git a/src/main/bitbucket/status-no-decrypt.test.ts b/src/main/bitbucket/status-no-decrypt.test.ts new file mode 100644 index 00000000000..946f1aea4aa --- /dev/null +++ b/src/main/bitbucket/status-no-decrypt.test.ts @@ -0,0 +1,127 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import type * as Os from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Why: opening Settings renders the Bitbucket card, which runs the preflight +// status (getBitbucketAuthStatus) and the card's status IPC +// (getBitbucketConnectionStatus). Neither may decrypt the stored secret, or the +// OS would prompt to unlock the keychain on every Settings open. These tests +// simulate a cold session (files on disk, memory cache empty). + +const decryptSpy = vi.fn((value: Buffer) => value.toString('utf-8')) +const OLD_ENV = process.env +const OLD_FETCH = globalThis.fetch +let tempHome = '' + +vi.mock('../git/runner', () => ({ gitExecFileAsync: vi.fn() })) + +async function loadModules() { + vi.resetModules() + vi.doMock('electron', () => ({ + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(value), + decryptString: decryptSpy + } + })) + vi.doMock('node:os', async () => { + const actual = await vi.importActual('node:os') + return { ...actual, homedir: () => tempHome } + }) + // Stub repo detection so the PR fetch reaches the auth-resolution path. + vi.doMock('./repository-ref', () => ({ + getBitbucketRepoRef: async () => ({ workspace: 'acme', repoSlug: 'app' }) + })) + const store = await import('./credential-store') + const client = await import('./client') + const connection = await import('./credential-connection') + return { store, client, connection } +} + +beforeEach(() => { + process.env = { ...OLD_ENV } + for (const key of [ + 'ORCA_BITBUCKET_ACCESS_TOKEN', + 'ORCA_BITBUCKET_EMAIL', + 'ORCA_BITBUCKET_API_TOKEN', + 'ORCA_BITBUCKET_API_BASE_URL' + ]) { + delete process.env[key] + } + tempHome = mkdtempSync(join(tmpdir(), 'orca-bb-nodecrypt-')) + decryptSpy.mockClear() +}) + +afterEach(() => { + process.env = OLD_ENV + globalThis.fetch = OLD_FETCH +}) + +const STORED_BASIC_CREDENTIAL = { + authMode: 'basic', + email: 'ada@example.com', + baseUrl: null, + account: 'ada', + accessToken: null, + apiToken: 'secret-token' +} as const + +describe('Bitbucket status reads never decrypt the stored secret', () => { + it('renders connected state from plaintext metadata without touching the keychain', async () => { + const { store, client, connection } = await loadModules() + const fetchSpy = vi.fn() + globalThis.fetch = fetchSpy as unknown as typeof fetch + store.saveBitbucketCredential(STORED_BASIC_CREDENTIAL) + + // Simulate relaunch: secret is on disk but the memory cache is cold, so a + // decrypt here would surface to the user as a keychain prompt. + store._resetBitbucketCredentialCache() + decryptSpy.mockClear() + + const auth = await client.getBitbucketAuthStatus() + const status = connection.getBitbucketConnectionStatus() + + expect(auth).toEqual({ configured: true, authenticated: true, account: 'ada' }) + expect(status).toMatchObject({ source: 'stored', account: 'ada', authMode: 'basic' }) + expect(decryptSpy).not.toHaveBeenCalled() + // A stored credential is trusted from metadata rather than revalidated. + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it('stays cold across repeated status reads, then decrypts once for a real API call', async () => { + const { store, client, connection } = await loadModules() + store.saveBitbucketCredential(STORED_BASIC_CREDENTIAL) + store._resetBitbucketCredentialCache() + decryptSpy.mockClear() + + for (let i = 0; i < 3; i += 1) { + await client.getBitbucketAuthStatus() + connection.getBitbucketConnectionStatus() + } + expect(decryptSpy).not.toHaveBeenCalled() + + globalThis.fetch = vi.fn(async () => Response.json({ values: [] })) as unknown as typeof fetch + await client.getBitbucketPullRequest('/repo', 1, null) + expect(decryptSpy).toHaveBeenCalledTimes(1) + }) + + it('revalidates a stored credential once its secret is already warm in memory', async () => { + const { store, client } = await loadModules() + store.saveBitbucketCredential(STORED_BASIC_CREDENTIAL) + decryptSpy.mockClear() + + // Saving leaves the secret cached, so status can check /user for free. + const fetchSpy = vi.fn(async () => new Response(null, { status: 401 })) + globalThis.fetch = fetchSpy as unknown as typeof fetch + + await expect(client.getBitbucketAuthStatus()).resolves.toEqual({ + configured: true, + authenticated: false, + account: 'ada' + }) + expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(decryptSpy).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/bitbucket/user-request.ts b/src/main/bitbucket/user-request.ts new file mode 100644 index 00000000000..6f83703d58e --- /dev/null +++ b/src/main/bitbucket/user-request.ts @@ -0,0 +1,39 @@ +import { authHeaders, type BitbucketAuthConfig } from './bitbucket-auth-config' +import { cancelUnreadResponseBody } from '../lib/unread-response-body' + +const USER_REQUEST_TIMEOUT_MS = 4000 + +export type RawBitbucketUser = { + username?: string | null + display_name?: string | null + account_id?: string | null +} + +export function accountNameFromUser(user: RawBitbucketUser | null): string | null { + return user?.username ?? user?.display_name ?? user?.account_id ?? null +} + +// Shared by live env-var status checks and by connect-time verification, so a +// credential is proven against `/user` before it is ever persisted. +export async function fetchBitbucketUser( + config: BitbucketAuthConfig, + timeoutMs: number = USER_REQUEST_TIMEOUT_MS +): Promise { + try { + const base = config.baseUrl.replace(/\/+$/, '') + const response = await fetch(`${base}/user`, { + headers: { + Accept: 'application/json', + ...authHeaders(config) + }, + signal: AbortSignal.timeout(timeoutMs) + }) + if (!response.ok) { + await cancelUnreadResponseBody(response) + return null + } + return (await response.json()) as RawBitbucketUser + } catch { + return null + } +} diff --git a/src/main/browser/browser-text-insertion.ts b/src/main/browser/browser-text-insertion.ts index 4d89b3a06fa..6c04fd52ae5 100644 --- a/src/main/browser/browser-text-insertion.ts +++ b/src/main/browser/browser-text-insertion.ts @@ -1,22 +1,10 @@ import { measureClipboardTextByteLength } from '../../shared/clipboard-text' import { yieldToEventLoop } from '../../shared/event-loop-yield' +import { getUtf8ChunkEndIndex } from '../../shared/utf8-byte-limits' import type { CdpCommandSender } from './snapshot-engine' export const BROWSER_TEXT_INSERT_CHUNK_BYTES = 64 * 1024 -function getUtf8ByteLengthForCodePoint(codePoint: number): number { - if (codePoint <= 0x7f) { - return 1 - } - if (codePoint <= 0x7ff) { - return 2 - } - if (codePoint <= 0xffff) { - return 3 - } - return 4 -} - export function splitBrowserTextInsertionChunks( text: string, maxChunkBytes = BROWSER_TEXT_INSERT_CHUNK_BYTES @@ -38,24 +26,11 @@ export function* iterateBrowserTextInsertionChunks( return } - let currentStart = 0 - let currentBytes = 0 - let index = 0 - while (index < text.length) { - const codePoint = text.codePointAt(index) ?? 0 - const codeUnitLength = codePoint > 0xffff ? 2 : 1 - const nextIndex = index + codeUnitLength - const characterBytes = getUtf8ByteLengthForCodePoint(codePoint) - if (currentBytes > 0 && currentBytes + characterBytes > normalizedMax) { - yield text.slice(currentStart, index) - currentStart = index - currentBytes = 0 - } - currentBytes += characterBytes - index = nextIndex - } - if (currentStart < text.length) { - yield text.slice(currentStart) + let startIndex = 0 + while (startIndex < text.length) { + const endIndex = getUtf8ChunkEndIndex(text, startIndex, normalizedMax) + yield text.slice(startIndex, endIndex) + startIndex = endIndex } } @@ -68,8 +43,7 @@ export async function insertTextThroughCdp( let chunk = chunks.next() while (!chunk.done) { await sender('Input.insertText', { text: chunk.value }) - // Why: browser automation text can be paste-sized; yielding keeps the main - // process responsive between bounded CDP payloads. + // Keep paste-sized insertion from monopolizing the main process. chunk = chunks.next() if (options?.yieldBetweenChunks !== false && !chunk.done) { await yieldToEventLoop() diff --git a/src/main/claude-usage/store.test.ts b/src/main/claude-usage/store.test.ts index 751ee0c0dae..3609399ac33 100644 --- a/src/main/claude-usage/store.test.ts +++ b/src/main/claude-usage/store.test.ts @@ -1,20 +1,12 @@ -import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import type * as FsPromises from 'node:fs/promises' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ClaudeUsagePersistedState } from './types' import type * as Scanner from './scanner' -const { getPathMock, writeOpens, writeGate } = vi.hoisted(() => ({ - getPathMock: vi.fn(() => '/tmp/orca-test-userdata'), - // Why only mode 'w': the durable write also opens the directory read-only to fsync it, so counting - // every open would hide a regression back to multiple full-cache rewrites per scan. - writeOpens: { value: 0, inFlight: 0, maxConcurrent: 0 }, - writeGate: { - blocked: false, - waiters: [] as (() => void)[] - } +const { getPathMock } = vi.hoisted(() => ({ + getPathMock: vi.fn(() => '/tmp/orca-test-userdata') })) vi.mock('electron', () => ({ @@ -23,29 +15,6 @@ vi.mock('electron', () => ({ } })) -vi.mock('node:fs/promises', async () => { - const actual = await vi.importActual('node:fs/promises') - return { - ...actual, - open: (async (...args: Parameters) => { - if (args[1] !== 'w') { - return actual.open(...args) - } - writeOpens.value += 1 - writeOpens.inFlight += 1 - writeOpens.maxConcurrent = Math.max(writeOpens.maxConcurrent, writeOpens.inFlight) - try { - if (writeGate.blocked) { - await new Promise((resolve) => writeGate.waiters.push(resolve)) - } - return await actual.open(...args) - } finally { - writeOpens.inFlight -= 1 - } - }) as typeof actual.open - } -}) - vi.mock('./scanner', async (importOriginal) => ({ ...(await importOriginal()), scanClaudeUsageFiles: vi.fn() @@ -54,12 +23,15 @@ vi.mock('./scanner', async (importOriginal) => ({ import { ClaudeUsageStore, initClaudeUsagePath } from './store' import { scanClaudeUsageFiles } from './scanner' -function createStoreWithState(state: Partial): ClaudeUsageStore { - const store = new ClaudeUsageStore({ +function createBackingStore(): ConstructorParameters[0] { + return { getRepos: () => [], - getAllWorktreeMeta: () => ({}), - getWorktreeMeta: () => undefined - } as never) + getAllWorktreeMeta: () => ({}) + } +} + +function createStoreWithState(state: Partial): ClaudeUsageStore { + const store = new ClaudeUsageStore(createBackingStore()) ;(store as unknown as { state: ClaudeUsagePersistedState }).state = { schemaVersion: 1, @@ -86,11 +58,6 @@ describe('ClaudeUsageStore', () => { tempUserData = mkdtempSync(join(tmpdir(), 'orca-claude-usage-store-')) getPathMock.mockReturnValue(tempUserData) initClaudeUsagePath() - writeOpens.value = 0 - writeOpens.inFlight = 0 - writeOpens.maxConcurrent = 0 - writeGate.blocked = false - writeGate.waiters = [] vi.mocked(scanClaudeUsageFiles).mockReset() vi.mocked(scanClaudeUsageFiles).mockResolvedValue({ processedFiles: [], @@ -106,6 +73,17 @@ describe('ClaudeUsageStore', () => { rmSync(tempUserData, { recursive: true, force: true }) }) + it('defaults a null legacy opt-in while invalidating the cache', () => { + writeFileSync( + join(tempUserData, 'orca-claude-usage.json'), + JSON.stringify({ schemaVersion: 4, scanState: { enabled: null } }) + ) + + const store = new ClaudeUsageStore(createBackingStore()) + + expect(store.getScanState().enabled).toBe(false) + }) + it('reports no data for Orca scope when only non-Orca usage exists', async () => { const store = createStoreWithState({ sessions: [ @@ -617,64 +595,7 @@ describe('ClaudeUsageStore', () => { expect(refreshMock).toHaveBeenCalledWith(false) }) - it('persists setEnabled via async durable write without leaving tmp files', async () => { - const store = createStoreWithState({ - schemaVersion: 5, - scanState: { - enabled: false, - lastScanStartedAt: null, - lastScanCompletedAt: null, - lastScanError: null - } - }) - - await store.setEnabled(true) - - expect(writeOpens.value).toBe(1) - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) - const persisted = JSON.parse( - readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8') - ) - expect(persisted.scanState.enabled).toBe(true) - // Pretty-print preserved for human inspection of the analytics cache. - expect(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).toContain('\n') - }) - - it('vetoes a stale concurrent async write so the newer snapshot wins', async () => { - const store = createStoreWithState({ - schemaVersion: 5, - scanState: { - enabled: true, - lastScanStartedAt: null, - lastScanCompletedAt: null, - lastScanError: null - } - }) - const internals = store as unknown as { - writeToDisk: () => Promise - state: ClaudeUsagePersistedState - } - - writeGate.blocked = true - const first = internals.writeToDisk() - await vi.waitFor(() => expect(writeGate.waiters.length).toBe(1)) - - internals.state.scanState.enabled = false - writeGate.blocked = false - const second = internals.writeToDisk() - writeGate.waiters.splice(0).forEach((resolve) => resolve()) - await Promise.all([first, second]) - - expect( - JSON.parse(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).scanState - .enabled - ).toBe(false) - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) - // Serialized, so the superseded write can be skipped safely rather than racing the newer one. - expect(writeOpens.maxConcurrent).toBe(1) - }) - - it('persists a successful refresh with one full-cache write', async () => { + it('adapts Claude scans to pretty-printed cache persistence', async () => { const store = createStoreWithState({ schemaVersion: 5, scanState: { @@ -687,25 +608,7 @@ describe('ClaudeUsageStore', () => { await store.refresh(true) - // Why exactly one: scan start used to rewrite the whole 20 MB cache before any result changed. - expect(writeOpens.value).toBe(1) - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) - expect( - JSON.parse(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).scanState - ).toMatchObject({ - lastScanStartedAt: new Date('2026-04-09T12:00:00.000-04:00').getTime(), - lastScanCompletedAt: new Date('2026-04-09T12:00:00.000-04:00').getTime(), - lastScanError: null - }) - }) - - it('sweeps a usage temp file orphaned by a crash between write and rename', async () => { - const orphan = join(tempUserData, 'orca-claude-usage.json.999.1.abc.tmp') - writeFileSync(orphan, '{}') - - createStoreWithState({}) - await vi.waitFor(() => - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) - ) + expect(scanClaudeUsageFiles).toHaveBeenCalledWith([], []) + expect(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).toContain('\n') }) }) diff --git a/src/main/claude-usage/store.ts b/src/main/claude-usage/store.ts index 180f7660b6b..66085504089 100644 --- a/src/main/claude-usage/store.ts +++ b/src/main/claude-usage/store.ts @@ -1,14 +1,11 @@ -/* eslint-disable max-lines -- Why: this store is the single main-process owner for Claude usage persistence, scan gating, and query semantics. Keeping those policy decisions together avoids split-brain range/scope logic across multiple files. */ +/* eslint-disable max-lines -- Why: Claude pricing, range, scope, breakdown, and automation-attribution policies remain one cohesive store. */ import { app } from 'electron' -import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' -import { UsageCacheSnapshotWriter } from '../usage-cache-snapshot-writer' import type { ClaudeUsageBreakdownKind, ClaudeUsageBreakdownRow, ClaudeUsageDailyPoint, ClaudeUsageRange, - ClaudeUsageScanState, ClaudeUsageScope, ClaudeUsageSessionRow, ClaudeUsageSnapshot, @@ -16,16 +13,15 @@ import type { } from '../../shared/claude-usage-types' import type { AutomationRunUsage } from '../../shared/automations-types' import type { Store } from '../persistence' -import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata' import type { ClaudeUsagePersistedState } from './types' -import { createWorktreeRefs } from '../usage/usage-worktree-refs' import { getSessionProjectLabel, scanClaudeUsageFiles } from './scanner' +import { getLocalUsageDay, getUsageRangeCutoff } from '../usage/usage-calendar-range' +import { UsageProviderStoreLifecycle } from '../usage/usage-provider-store-lifecycle' // Why: v5 widens Claude ownership keys (message-id / uuid fallbacks). Older // caches either lack ownership or used narrower keys and can under/over-count // after fork reclaim (#8006). const SCHEMA_VERSION = 5 -const STALE_MS = 5 * 60_000 const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000 // Why: capture the path after configureDevUserDataPath() but before app.setName() @@ -130,6 +126,21 @@ function getDefaultState(): ClaudeUsagePersistedState { } } +function normalizePersistedState(state: ClaudeUsagePersistedState): ClaudeUsagePersistedState { + if (state.schemaVersion === SCHEMA_VERSION) { + return state + } + // Scanner changes invalidate totals, but preserving enabled keeps existing tracking on. + const defaults = getDefaultState() + return { + ...defaults, + scanState: { + ...defaults.scanState, + enabled: state.scanState.enabled ?? defaults.scanState.enabled + } + } +} + export function initClaudeUsagePath(): void { _claudeUsageFile = join(app.getPath('userData'), 'orca-claude-usage.json') } @@ -288,120 +299,22 @@ function estimateCostUsd( ) } -function getRangeCutoff(range: ClaudeUsageRange): string | null { - if (range === 'all') { - return null - } - const days = range === '7d' ? 7 : range === '30d' ? 30 : 90 - const now = new Date() - now.setHours(0, 0, 0, 0) - now.setDate(now.getDate() - (days - 1)) - const year = now.getFullYear() - const month = String(now.getMonth() + 1).padStart(2, '0') - const day = String(now.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -} - -function getLocalDay(timestamp: string): string | null { - const parsed = new Date(timestamp) - if (Number.isNaN(parsed.getTime())) { - return null - } - const year = parsed.getFullYear() - const month = String(parsed.getMonth() + 1).padStart(2, '0') - const day = String(parsed.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -} - -function getWorktreeFingerprint(worktreesByRepo: Map): string { - const rows = [...worktreesByRepo.entries()] - .flatMap(([repoId, worktrees]) => - worktrees.map((worktree) => - JSON.stringify({ - repoId, - worktreeId: worktree.worktreeId, - path: worktree.path, - displayName: worktree.displayName - }) - ) - ) - .sort() - return JSON.stringify(rows) -} - -export class ClaudeUsageStore { - private state: ClaudeUsagePersistedState - private readonly store: Store - private scanPromise: Promise | null = null - // Why: the 20 MB usage JSON must not block the Electron main thread; the writer serializes writes - // and vetoes superseded renames. - private readonly writer = new UsageCacheSnapshotWriter('[claude-usage]', getClaudeUsageFile) - - constructor(store: Store) { - this.store = store - this.state = this.load() - } - - private load(): ClaudeUsagePersistedState { - try { - const usageFile = getClaudeUsageFile() - if (!existsSync(usageFile)) { - return getDefaultState() - } - const parsed = JSON.parse(readFileSync(usageFile, 'utf-8')) as ClaudeUsagePersistedState - if (parsed.schemaVersion !== SCHEMA_VERSION) { - // Why: scanner semantics affect persisted totals, so old Claude caches - // must be rebuilt after parser/source changes instead of reused briefly. - // Preserve scanState.enabled so existing users keep tracking on across - // schema bumps; the next refresh will repopulate the analytics. - const defaults = getDefaultState() - return { - ...defaults, - scanState: { - ...defaults.scanState, - enabled: parsed.scanState?.enabled ?? defaults.scanState.enabled - } - } - } - return { - ...getDefaultState(), - ...parsed, - scanState: { - ...getDefaultState().scanState, - ...parsed.scanState - } - } - } catch (error) { - // Why: Claude usage is a local analytics feature, not primary workspace - // state. A corrupt cache should degrade to a fresh rebuild instead of - // preventing Orca from booting, but we leave the file on disk for debugging. - console.error('[claude-usage] Failed to load persisted state, starting fresh:', error) - return getDefaultState() - } - } - - private writeToDisk(): Promise { - // Pretty-print preserved: humans inspect this analytics cache on disk. - return this.writer.write(() => JSON.stringify(this.state, null, 2)) - } - - /** Await queued cache writes so quit does not drop the final snapshot. */ - flush(): Promise { - return this.writer.flush() - } - - async setEnabled(enabled: boolean): Promise { - this.state.scanState.enabled = enabled - await this.writeToDisk() - return this.getScanState() - } - - getScanState(): ClaudeUsageScanState { - return { - ...this.state.scanState, - isScanning: this.scanPromise !== null, - hasAnyClaudeData: this.state.sessions.length > 0 || this.state.dailyAggregates.length > 0 - } +export class ClaudeUsageStore extends UsageProviderStoreLifecycle< + 'processedFiles', + ClaudeUsagePersistedState, + 'hasAnyClaudeData' +> { + constructor(store: Pick) { + super(store, { + logTag: '[claude-usage]', + resolveCacheFile: getClaudeUsageFile, + createDefaultState: getDefaultState, + normalizeState: normalizePersistedState, + sourceKey: 'processedFiles', + dataPresenceKey: 'hasAnyClaudeData', + jsonIndent: 2, + scan: scanClaudeUsageFiles + }) } getSnapshot( @@ -419,63 +332,6 @@ export class ClaudeUsageStore { } } - async refresh(force = false): Promise { - if (!this.state.scanState.enabled) { - return this.getScanState() - } - const currentWorktreeFingerprint = await this.getCurrentWorktreeFingerprint() - if (!force && this.state.scanState.lastScanCompletedAt) { - const ageMs = Date.now() - this.state.scanState.lastScanCompletedAt - if (ageMs < STALE_MS && this.state.worktreeFingerprint === currentWorktreeFingerprint) { - return this.getScanState() - } - } - await this.runScan() - return this.getScanState() - } - - private async runScan(): Promise { - if (this.scanPromise) { - await this.scanPromise - return - } - - this.state.scanState.lastScanStartedAt = Date.now() - this.state.scanState.lastScanError = null - - // Why no write here: persisting scan-start would rewrite the whole multi-MB cache before a single - // result changed. The completion/failure write below persists the same fields. - - // Why: assign scanPromise before any await so concurrent refresh shares one scan. - this.scanPromise = (async () => { - try { - const repos = this.store.getRepos() - const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) - const worktreeFingerprint = getWorktreeFingerprint(worktreesByRepo) - const result = await scanClaudeUsageFiles( - createWorktreeRefs(repos, worktreesByRepo), - this.state.worktreeFingerprint === worktreeFingerprint ? this.state.processedFiles : [] - ) - this.state.processedFiles = result.processedFiles - this.state.sessions = result.sessions - this.state.dailyAggregates = result.dailyAggregates - this.state.worktreeFingerprint = worktreeFingerprint - this.state.scanState.lastScanCompletedAt = Date.now() - this.state.scanState.lastScanError = null - // Why swallow: persistence is a cache concern. A disk failure must not turn a good scan into - // a scan error and reject refresh() for every query caller; writeToDisk already logs it. - await this.writeToDisk().catch(() => {}) - } catch (error) { - this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error) - await this.writeToDisk().catch(() => {}) - } finally { - this.scanPromise = null - } - })() - - await this.scanPromise - } - async getSummary(scope: ClaudeUsageScope, range: ClaudeUsageRange): Promise { await this.refresh(false) return this.buildSummary(scope, range) @@ -843,7 +699,7 @@ export class ClaudeUsageStore { } private getFilteredDaily(scope: ClaudeUsageScope, range: ClaudeUsageRange) { - const cutoff = getRangeCutoff(range) + const cutoff = getUsageRangeCutoff(range) return this.state.dailyAggregates.filter((entry) => { if (cutoff && entry.day < cutoff) { return false @@ -856,12 +712,12 @@ export class ClaudeUsageStore { } private getFilteredSessions(scope: ClaudeUsageScope, range: ClaudeUsageRange) { - const cutoff = getRangeCutoff(range) + const cutoff = getUsageRangeCutoff(range) return this.state.sessions.filter((session) => { // Why: daily aggregates use local calendar days, so session filtering has // to use the same conversion or the sessions table/counts can disagree // with the chart around UTC day boundaries. - const day = getLocalDay(session.lastTimestamp) + const day = getLocalUsageDay(session.lastTimestamp) if (!day) { return false } @@ -883,10 +739,4 @@ export class ClaudeUsageStore { Boolean(lastScanError) || lastScanCompletedAt === null || lastScanCompletedAt < completedAt ) } - - private async getCurrentWorktreeFingerprint(): Promise { - const repos = this.store.getRepos() - const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) - return getWorktreeFingerprint(worktreesByRepo) - } } diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts index 0d390c3a9e3..b3fa02debc5 100644 --- a/src/main/claude/hook-service.test.ts +++ b/src/main/claude/hook-service.test.ts @@ -17,18 +17,34 @@ vi.mock('electron', () => ({ import type { SFTPWrapper } from 'ssh2' import { createManagedCommandMatcher } from '../agent-hooks/installer-utils' import { ClaudeHookService } from './hook-service' -import { OPENCLAUDE_HOOK_SETTINGS } from './hook-settings' +import { getWindowsManagedLifecycleHook, OPENCLAUDE_HOOK_SETTINGS } from './hook-settings' const CLAUDE_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'claude-hook.cmd' : 'claude-hook.sh' const STATUSLINE_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'claude-statusline.cmd' : 'claude-statusline.sh' const OPENCLAUDE_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'openclaude-hook.cmd' : 'openclaude-hook.sh' -const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ const isClaudeManagedCommand = createManagedCommandMatcher(CLAUDE_SCRIPT_FILE_NAME) const isOpenClaudeManagedCommand = createManagedCommandMatcher(OPENCLAUDE_SCRIPT_FILE_NAME) +type TestHook = { command: string; args?: string[] } + +function hasManagedCommand(hook: TestHook, matcher: (command: string | undefined) => boolean) { + return matcher(hook.command) || hook.args?.some(matcher) === true +} + +describe('getWindowsManagedLifecycleHook', () => { + it('resolves the managed script from the runtime Windows profile', () => { + const scriptPath = 'C:\\Users\\%name%\\a^b&c\\.orca\\agent-hooks\\claude-hook.cmd' + const hook = getWindowsManagedLifecycleHook(scriptPath) + + expect(hook.args?.[0]).toBe('--headless') + expect(hook.args?.[1]).toMatch(/\\System32\\cmd\.exe$/i) + expect(hook.args?.at(-1)).toBe('%USERPROFILE%\\.orca\\agent-hooks\\claude-hook.cmd') + expect(hook.args).not.toContain(scriptPath) + }) +}) + type FakeFs = { files: Map dirs: Set @@ -162,18 +178,27 @@ describe('ClaudeHookService.install', () => { AWS_REGION: 'us-west-2' } }) - const legacyCommands = legacy.hooks.Stop.flatMap( - (definition: { hooks: { command: string }[] }) => - definition.hooks.map((hook) => hook.command) + const legacyHooks = legacy.hooks.Stop.flatMap( + (definition: { hooks: TestHook[] }) => definition.hooks ) - expect(legacyCommands).toContain('/usr/local/bin/user-hook') - expect(legacyCommands.some((command: string) => isClaudeManagedCommand(command))).toBe(true) + expect(legacyHooks.map((hook: TestHook) => hook.command)).toContain( + '/usr/local/bin/user-hook' + ) + const managedHook = legacyHooks.find((hook: TestHook) => + hasManagedCommand(hook, isClaudeManagedCommand) + ) + expect(JSON.stringify(managedHook)).not.toContain(tmpHome.replaceAll('\\', '/')) expect( - legacyCommands.some((command: string) => - command.includes('/Users/old/.orca/agent-hooks/claude-hook.sh') + legacyHooks.some((hook: TestHook) => hasManagedCommand(hook, isClaudeManagedCommand)) + ).toBe(true) + expect( + legacyHooks.some((hook: TestHook) => + hook.command.includes('/Users/old/.orca/agent-hooks/claude-hook.sh') ) ).toBe(false) - expect(isClaudeManagedCommand(legacy.hooks.StopFailure[0].hooks[0].command)).toBe(true) + expect(hasManagedCommand(legacy.hooks.StopFailure[0].hooks[0], isClaudeManagedCommand)).toBe( + true + ) expect( readFileSync(join(tmpHome, '.orca', 'agent-hooks', CLAUDE_SCRIPT_FILE_NAME), 'utf-8') ).toContain('DEVIN_PROJECT_DIR') @@ -194,7 +219,13 @@ describe('ClaudeHookService.install', () => { readFileSync(join(tmpHome, '.claude', 'settings.json'), 'utf-8') ) as { statusLine?: { type: string; command: string } } expect(settings.statusLine?.type).toBe('command') - expect(settings.statusLine?.command).toContain('claude-statusline') + expect(settings.statusLine?.command).toContain( + '"$HOME/.orca/agent-hooks/claude-statusline.cmd"' + ) + expect(settings.statusLine?.command).toContain( + '"$HOME/.orca/agent-hooks/claude-statusline.sh"' + ) + expect(settings.statusLine?.command).not.toContain(tmpHome.replaceAll('\\', '/')) const script = readFileSync( join(tmpHome, '.orca', 'agent-hooks', STATUSLINE_SCRIPT_FILE_NAME), @@ -307,12 +338,8 @@ describe('ClaudeHookService.install', () => { } }) - // Why: #6078 — Claude Code runs hooks through Git Bash, and an unquoted path - // with a space (e.g. `C:/Users/Jane Doe`) splits at the space. The managed - // command must use an encoded launcher so Git Bash/cmd.exe never splits or - // expands the raw path before invoking the managed .cmd. it.skipIf(process.platform !== 'win32')( - 'wraps the managed hook command to survive spaces in the profile path (#6078)', + 'runs portable managed hooks through headless exec form', () => { const tmpHome = mkdtempSync(join(tmpdir(), 'orca claude home with spaces ')) vi.stubEnv('HOME', tmpHome) @@ -322,11 +349,26 @@ describe('ClaudeHookService.install', () => { const settings = JSON.parse( readFileSync(join(tmpHome, '.claude', 'settings.json'), 'utf-8') - ) as { hooks: Record } + ) as { hooks: Record } + + const system32 = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32') + const scriptPath = join(tmpHome, '.orca', 'agent-hooks', CLAUDE_SCRIPT_FILE_NAME) + const runtimeScriptPath = join( + '%USERPROFILE%', + '.orca', + 'agent-hooks', + CLAUDE_SCRIPT_FILE_NAME + ) for (const eventName of ['UserPromptSubmit', 'Stop', 'StopFailure']) { - const command = settings.hooks[eventName]?.[0]?.hooks?.[0]?.command - expect(command).toMatch(WINDOWS_POWERSHELL_LAUNCHER) + const hook = settings.hooks[eventName]?.[0]?.hooks?.[0] + expect(hook).toEqual({ + type: 'command', + command: join(system32, 'conhost.exe'), + args: ['--headless', join(system32, 'cmd.exe'), '/d', '/c', runtimeScriptPath], + timeout: 10 + }) + expect(hook.args).not.toContain(scriptPath) } } finally { vi.unstubAllEnvs() @@ -335,9 +377,6 @@ describe('ClaudeHookService.install', () => { } ) - // Why: the launcher must stay PowerShell-encoded for Git Bash, but the hook - // POST inside the .cmd should use curl.exe so each hook spawns one - // interpreter, not two. Posting via a second PowerShell was the slow path. it.skipIf(process.platform !== 'win32')( 'posts from the managed .cmd via curl.exe, not a second PowerShell', () => { @@ -372,11 +411,7 @@ describe('ClaudeHookService.installRemote', () => { const settings = fs.files.get('/home/dev/.claude/settings.json') expect(settings).toBeTruthy() const parsed = JSON.parse(settings!) - // Why: every load-bearing event must be present and point at the - // remote-shaped script path with the guarded launcher applied. Drift in - // any of these is a real bug — Claude - // Code rejects unknown shapes silently and the agent-hooks pipeline - // goes dark. + // Why: Claude silently rejects drifted hook shapes, so assert every load-bearing event. for (const event of [ // Why: SessionStart is the only signal a resumed/idle session ever emits; // without it the sidebar row waits for the first prompt (STA-3386). @@ -394,8 +429,8 @@ describe('ClaudeHookService.installRemote', () => { ]) { expect(parsed.hooks[event]).toBeTruthy() const cmd = parsed.hooks[event][0].hooks[0].command as string - expect(cmd).toContain('/home/dev/.orca/agent-hooks/claude-hook.sh') - expect(cmd).toMatch(/^if \[ -f /) + expect(cmd).toContain('"$HOME/.orca/agent-hooks/claude-hook.sh"') + expect(cmd).not.toContain('/home/dev/.orca/agent-hooks/claude-hook.sh') } // Managed script body const script = fs.files.get('/home/dev/.orca/agent-hooks/claude-hook.sh') @@ -487,9 +522,9 @@ describe('OpenClaudeHookService-compatible install', () => { for (const event of ['UserPromptSubmit', 'Stop', 'StopFailure']) { const command = parsed.hooks[event][0].hooks[0].command as string expect(isOpenClaudeManagedCommand(command)).toBe(true) - if (process.platform !== 'win32') { - expect(command).toMatch(/^if \[ -f /) - } + expect(command).toContain('"$HOME/.orca/agent-hooks/openclaude-hook.cmd"') + expect(command).toContain('"$HOME/.orca/agent-hooks/openclaude-hook.sh"') + expect(command).not.toContain(tmpHome.replaceAll('\\', '/')) } expect( readFileSync(join(tmpHome, '.orca', 'agent-hooks', OPENCLAUDE_SCRIPT_FILE_NAME), 'utf-8') @@ -518,7 +553,8 @@ describe('OpenClaudeHookService-compatible install', () => { }) const parsed = JSON.parse(fs.files.get('/home/dev/.openclaude/settings.json')!) const command = parsed.hooks.StopFailure[0].hooks[0].command as string - expect(command).toContain('/home/dev/.orca/agent-hooks/openclaude-hook.sh') + expect(command).toContain('"$HOME/.orca/agent-hooks/openclaude-hook.sh"') + expect(command).not.toContain('/home/dev/.orca/agent-hooks/openclaude-hook.sh') expect(fs.files.get('/home/dev/.orca/agent-hooks/openclaude-hook.sh')).toContain('/hook/claude') }) }) diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index eb8891a5bf7..47269b1e17d 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -2,6 +2,7 @@ import { existsSync, rmSync, writeFileSync } from 'node:fs' import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' import { + buildManagedCommandHook, buildWindowsAgentHookCurlPostCommand, readHooksJson, writeHooksJson, @@ -29,6 +30,7 @@ import { getManagedScriptFileName, getConfigPath, getManagedCommand, + getManagedLifecycleHook, getManagedScriptPath, getPosixManagedScriptFileName, getRemoteConfigPath, @@ -37,6 +39,7 @@ import { getStatusLineScriptFileName, getStatusLineScriptPath, getStatusLineSlotState, + hasSameManagedHookInvocation, removeManagedHooks, removeManagedStatusLine, type ClaudeCompatibleHookSettings @@ -140,7 +143,7 @@ export class ClaudeHookService { } // Why: report partial registration instead of a false installed state. - const command = getManagedCommand(scriptPath) + const expectedHook = getManagedLifecycleHook(scriptPath, this.options.settings) const missing: string[] = [] let presentCount = 0 for (const event of CLAUDE_EVENTS) { @@ -148,7 +151,7 @@ export class ClaudeHookService { ? config.hooks![event.eventName]! : [] const hasCommand = definitions.some((definition) => - (definition.hooks ?? []).some((hook) => hook.command === command) + (definition.hooks ?? []).some((hook) => hasSameManagedHookInvocation(hook, expectedHook)) ) if (hasCommand) { presentCount += 1 @@ -198,10 +201,10 @@ export class ClaudeHookService { } } - const command = getManagedCommand(scriptPath) + const hook = getManagedLifecycleHook(scriptPath, this.options.settings) let nextConfig = applyManagedHooks( config, - command, + hook, getManagedScriptFileName(this.options.settings) ) writeManagedScript( @@ -259,9 +262,9 @@ export class ClaudeHookService { } } - // Why: the POSIX wrapper is identical regardless of where the script lands; only the path differs. - const command = getRemoteManagedCommand(remoteScriptPath) - const nextConfig = applyManagedHooks(config, command, remoteScriptFileName) + // Why: settings resolve HOME at runtime while SFTP still targets the discovered remote home. + const hook = buildManagedCommandHook(getRemoteManagedCommand(remoteScriptPath)) + const nextConfig = applyManagedHooks(config, hook, remoteScriptFileName) // Why: write scripts before settings to avoid settings pointing to missing scripts. // Why: SSH scripts always use POSIX .sh paths, regardless of the local OS. diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts index de932276545..d39b8e27bcb 100644 --- a/src/main/claude/hook-settings.ts +++ b/src/main/claude/hook-settings.ts @@ -1,30 +1,34 @@ import { homedir } from 'node:os' -import { join } from 'node:path' +import { basename, extname, join, win32 } from 'node:path' import { buildManagedCommandHook, createManagedCommandMatcher, getSharedManagedScriptPath, isPlainObject, + MANAGED_HOOK_TIMEOUT_SECONDS, removeManagedCommands, - wrapPosixHookCommand, - wrapWindowsGitBashHookCommand, + type HookCommandConfig, type HookDefinition, type HooksConfig } from '../agent-hooks/installer-utils' +import { wrapRuntimeHomeHookCommand } from '../agent-hooks/runtime-home-hook-command' export type ClaudeCompatibleHookSettings = { configDirName: '.claude' | '.openclaude' scriptBaseName: 'claude-hook' | 'openclaude-hook' + supportsExecHookArgs: boolean } export const CLAUDE_HOOK_SETTINGS: ClaudeCompatibleHookSettings = { configDirName: '.claude', - scriptBaseName: 'claude-hook' + scriptBaseName: 'claude-hook', + supportsExecHookArgs: true } export const OPENCLAUDE_HOOK_SETTINGS: ClaudeCompatibleHookSettings = { configDirName: '.openclaude', - scriptBaseName: 'openclaude-hook' + scriptBaseName: 'openclaude-hook', + supportsExecHookArgs: false } export const CLAUDE_EVENTS = [ @@ -105,18 +109,57 @@ export function getRemoteConfigPath(remoteHome: string, settings = CLAUDE_HOOK_S } export function getManagedCommand(scriptPath: string): string { - return process.platform === 'win32' - ? wrapWindowsGitBashHookCommand(scriptPath) - : wrapPosixHookCommand(scriptPath) + const scriptFileName = basename(scriptPath) + const extension = extname(scriptFileName) + return wrapRuntimeHomeHookCommand( + extension ? scriptFileName.slice(0, -extension.length) : scriptFileName + ) +} + +export function getManagedLifecycleHook( + scriptPath: string, + settings = CLAUDE_HOOK_SETTINGS +): HookCommandConfig { + if (process.platform !== 'win32' || !settings.supportsExecHookArgs) { + return buildManagedCommandHook(getManagedCommand(scriptPath)) + } + return getWindowsManagedLifecycleHook(scriptPath) +} + +export function getWindowsManagedLifecycleHook(scriptPath: string): HookCommandConfig { + const system32 = win32.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32') + const runtimeScriptPath = win32.join( + '%USERPROFILE%', + '.orca', + 'agent-hooks', + win32.basename(scriptPath) + ) + // Why: Claude's Windows shell form opens Git Bash consoles; exec form hosts the client in a windowless console. + return { + type: 'command', + command: win32.join(system32, 'conhost.exe'), + args: ['--headless', win32.join(system32, 'cmd.exe'), '/d', '/c', runtimeScriptPath], + timeout: MANAGED_HOOK_TIMEOUT_SECONDS + } +} + +export function hasSameManagedHookInvocation( + actual: HookCommandConfig, + expected: HookCommandConfig +): boolean { + return ( + actual.command === expected.command && + JSON.stringify(actual.args ?? []) === JSON.stringify(expected.args ?? []) + ) } export function getRemoteManagedCommand(scriptPath: string): string { - return wrapPosixHookCommand(scriptPath) + return getManagedCommand(scriptPath) } export function applyManagedHooks( config: HooksConfig, - command: string, + hook: HookCommandConfig, scriptFileName = getManagedScriptFileName() ): HooksConfig { const nextHooks = { ...config.hooks } @@ -127,7 +170,7 @@ export function applyManagedHooks( const cleaned = removeManagedCommands(current, isManagedCommand) const definition: HookDefinition = { ...event.definition, - hooks: [buildManagedCommandHook(command)] + hooks: [hook] } nextHooks[event.eventName] = [...cleaned, definition] } diff --git a/src/main/cli/cli-installer.test.ts b/src/main/cli/cli-installer.test.ts index 9c17fd21116..180611c1590 100644 --- a/src/main/cli/cli-installer.test.ts +++ b/src/main/cli/cli-installer.test.ts @@ -489,7 +489,7 @@ describe('CliInstaller', () => { const registryReader = new WindowsUserPathRegistryReader({ platform: 'win32', registryLoader: async () => ({ - HK: { CU: 0x80000001 }, + HK: { CU: 0x80000001, LM: 0x80000002 }, getRegistryKey: () => ({ Path: { name: 'Path', type: 2, value: registryPath } }) diff --git a/src/main/cli/windows-user-path-registry.test.ts b/src/main/cli/windows-user-path-registry.test.ts index 12beca13852..7f919694626 100644 --- a/src/main/cli/windows-user-path-registry.test.ts +++ b/src/main/cli/windows-user-path-registry.test.ts @@ -7,7 +7,7 @@ import { function registryModule(pathValue?: string, type = 2) { return { - HK: { CU: 0x80000001 }, + HK: { CU: 0x80000001, LM: 0x80000002 }, REG: { SZ: 1, EXPAND_SZ: 2 }, getRegistryKey: vi.fn(() => pathValue === undefined ? {} : { Path: { name: 'Path', type, value: pathValue } } diff --git a/src/main/cli/windows-user-path-registry.ts b/src/main/cli/windows-user-path-registry.ts index d3e493fd87d..90e8ba7863e 100644 --- a/src/main/cli/windows-user-path-registry.ts +++ b/src/main/cli/windows-user-path-registry.ts @@ -1,26 +1,17 @@ -import { createRequire } from 'node:module' +import { + loadWindowsNativeRegistry, + WINDOWS_REG_EXPAND_SZ, + WINDOWS_REG_SZ, + type WindowsNativeRegistryModule +} from '../windows-native-registry' export type WindowsUserPathReadResult = | { state: 'success'; value: string | null; expandable: boolean } | { state: 'unknown'; detail: string } -type RegistryValue = { - name?: unknown - type?: unknown - value?: unknown -} - -type WindowsRegistryModule = { - HK: { CU: number } - getRegistryKey: ( - root: number, - path: string - ) => Record | null | undefined -} - type WindowsUserPathRegistryReaderOptions = { platform?: NodeJS.Platform - registryLoader?: () => Promise + registryLoader?: () => Promise now?: () => number cacheTtlMs?: number } @@ -28,19 +19,9 @@ type WindowsUserPathRegistryReaderOptions = { const DEFAULT_CACHE_TTL_MS = 1_000 const USER_ENVIRONMENT_KEY = 'Environment' const USER_PATH_VALUE = 'Path' -const REG_SZ = 1 -const REG_EXPAND_SZ = 2 -const requireFromMain = createRequire(__filename) - -async function loadWindowsRegistryModule(): Promise { - // Why: the optional dependency is not present in non-Windows installs, so - // TypeScript and the main bundle must not resolve it eagerly. - return requireFromMain('windows-native-registry') as WindowsRegistryModule -} - export class WindowsUserPathRegistryReader { private readonly platform: NodeJS.Platform - private readonly registryLoader: () => Promise + private readonly registryLoader: () => Promise private readonly now: () => number private readonly cacheTtlMs: number private cached: { readAt: number; result: WindowsUserPathReadResult } | null = null @@ -49,7 +30,7 @@ export class WindowsUserPathRegistryReader { constructor(options: WindowsUserPathRegistryReaderOptions = {}) { this.platform = options.platform ?? process.platform - this.registryLoader = options.registryLoader ?? loadWindowsRegistryModule + this.registryLoader = options.registryLoader ?? (async () => loadWindowsNativeRegistry()) this.now = options.now ?? Date.now this.cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS } @@ -123,7 +104,7 @@ export class WindowsUserPathRegistryReader { return { state: 'success', value: null, expandable: false } } if ( - (pathEntry.type !== REG_SZ && pathEntry.type !== REG_EXPAND_SZ) || + (pathEntry.type !== WINDOWS_REG_SZ && pathEntry.type !== WINDOWS_REG_EXPAND_SZ) || typeof pathEntry.value !== 'string' ) { return { @@ -134,7 +115,7 @@ export class WindowsUserPathRegistryReader { return { state: 'success', value: pathEntry.value || null, - expandable: pathEntry.type === REG_EXPAND_SZ + expandable: pathEntry.type === WINDOWS_REG_EXPAND_SZ } } catch { return { diff --git a/src/main/cli/wsl-cli-installer.test.ts b/src/main/cli/wsl-cli-installer.test.ts index 13bd8088de3..581c6f075b6 100644 --- a/src/main/cli/wsl-cli-installer.test.ts +++ b/src/main/cli/wsl-cli-installer.test.ts @@ -313,7 +313,7 @@ describe('WslCliInstaller', () => { it('generates a launcher that forwards arguments through a PowerShell file bridge', () => { const launcher = _internals.buildWslLauncher( - 'C:\\Program Files\\Orca\\orca.cmd', + 'C:\\Program Files\\Orca\\resources\\bin\\orca.exe', '/home/alice/.local/share/orca/orca-wsl-bridge.ps1' ) const bridge = _internals.buildWslBridgeScript() @@ -333,21 +333,22 @@ describe('WslCliInstaller', () => { ) expect(launcher).toContain('"$ORCA_WIN_LAUNCHER" -WslCwd "$ORCA_WSL_CWD_WIN" "$@"') expect(launcher).not.toContain('-Command') - expect(bridge).toContain('[CmdletBinding(PositionalBinding=$false)]') - expect(bridge).toContain('[Parameter(Mandatory=$true, Position=0)]') - expect(bridge).toContain('[string]$WslCwd') - expect(bridge).toContain('[Parameter(ValueFromRemainingArguments=$true)]') + expect(bridge).not.toContain('[CmdletBinding') + expect(bridge).not.toMatch(/^param\(/m) + expect(bridge).toContain("$args[1] -eq '-WslCwd'") + expect(bridge).toContain('[string]$OrcaLauncher = $args[0]') + expect(bridge).toContain('$WslCwd = $args[2]') + expect(bridge).toContain('$ForwardArgs = @($args[$ForwardArgStart..($args.Count - 1)])') expect(bridge).toContain('if ([string]::IsNullOrEmpty($WslCwd))') expect(bridge).toContain('$env:ORCA_CLI_CWD = $WslCwd') expect(bridge).toContain('Push-Location -LiteralPath (Split-Path -Parent $OrcaLauncher)') - expect(bridge).toContain('& $OrcaLauncher @ForwardArgs') - const nullExitCodeBranch = bridge.indexOf('if ($null -eq $LASTEXITCODE)') - const invocationFailureBranch = bridge.indexOf('if (-not $?)') - expect(nullExitCodeBranch).toBeGreaterThan(-1) - // Why: native launchers can set a non-zero LASTEXITCODE while $? is false; - // checking the native status first preserves that specific exit code. - expect(nullExitCodeBranch).toBeLessThan(invocationFailureBranch) - expect(bridge).toContain('$exitCode = $LASTEXITCODE') + expect(bridge).toContain('function ConvertTo-NativeCommandLineArgument') + expect(bridge).toContain("[void]$Quoted.Append([char]'\\', $BackslashCount * 2 + 1)") + expect(bridge).toContain('$StartInfo.UseShellExecute = $false') + expect(bridge).toContain('[System.Diagnostics.Process]::Start($StartInfo)') + expect(bridge).toContain('$Process.WaitForExit()') + expect(bridge).toContain('$exitCode = $Process.ExitCode') + expect(bridge).not.toContain('& $OrcaLauncher @ForwardArgs') expect(bridge).toContain('Remove-Item Env:ORCA_CLI_CWD -ErrorAction SilentlyContinue') expect(bridge).toContain('catch') expect(bridge).toContain('$exitCode = 1') diff --git a/src/main/cli/wsl-cli-powershell-boundary.test.ts b/src/main/cli/wsl-cli-powershell-boundary.test.ts new file mode 100644 index 00000000000..7e706a0ed12 --- /dev/null +++ b/src/main/cli/wsl-cli-powershell-boundary.test.ts @@ -0,0 +1,120 @@ +import { spawnSync } from 'node:child_process' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { buildWslBridgeScript, buildWslLauncher } from './wsl-cli-scripts' + +const FORWARDED_ARGS = [ + 'terminal', + 'wait', + '--terminal', + 'term_example', + '--for', + 'tui-idle', + '--wsl', + 'forwarded-wsl-value', + '--orca', + 'forwarded-orca-value', + '--debug', + 'forwarded-debug-value', + '--deps', + '["task_907c556bfed6"]', + '--quoted-text', + 'tell me "what is next"', + '--empty', + '', + '--trailing-backslash', + 'C:\\path with spaces\\', + '--', + 'tail' +] + +describe('WSL CLI PowerShell boundary', () => { + it('keeps forwarded argv outside PowerShell parsing', () => { + const launcher = buildWslLauncher('C:\\Program Files\\Orca\\orca.exe') + const bridge = buildWslBridgeScript() + + expect(launcher).toContain('"$ORCA_WIN_LAUNCHER" -WslCwd "$ORCA_WSL_CWD_WIN" "$@"') + expect(bridge).not.toContain('[CmdletBinding') + expect(bridge).not.toMatch(/^param\(/m) + expect(bridge).toContain('$ForwardArgs = @($args[$ForwardArgStart..($args.Count - 1)])') + expect(bridge).toContain('function ConvertTo-NativeCommandLineArgument') + expect(bridge).toContain('$StartInfo.UseShellExecute = $false') + }) + + it.skipIf(process.platform !== 'win32')( + 'preserves native argv and exit status through Windows PowerShell 5.1', + async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-wsl-powershell-boundary-')) + const fixtureDir = join(root, 'fixture with spaces') + const bridgePath = join(fixtureDir, 'orca-wsl-bridge.ps1') + const targetPath = join(fixtureDir, 'argv-target.cjs') + const wslCwd = join(root, 'WSL cwd with spaces') + + try { + await mkdir(fixtureDir) + await writeFile(bridgePath, buildWslBridgeScript(), 'utf8') + await writeFile( + targetPath, + 'process.stdout.write(JSON.stringify({ argv: process.argv.slice(2), cwd: process.env.ORCA_CLI_CWD ?? null }))\n', + 'utf8' + ) + const invocations = [ + { + bridgeArgs: [process.execPath, '-WslCwd', wslCwd, targetPath, ...FORWARDED_ARGS], + expected: { argv: FORWARDED_ARGS, cwd: wslCwd } + }, + { + bridgeArgs: [process.execPath, '-WslCwd', wslCwd, targetPath], + expected: { argv: [], cwd: wslCwd } + }, + { + bridgeArgs: [process.execPath, targetPath, ...FORWARDED_ARGS], + expected: { argv: FORWARDED_ARGS, cwd: null } + } + ] + for (const { bridgeArgs, expected } of invocations) { + const result = spawnSync( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-File', + bridgePath, + ...bridgeArgs + ], + { encoding: 'utf8', env: { ...process.env, ORCA_CLI_CWD: 'stale' } } + ) + + expect(result.error).toBeUndefined() + expect(result.stderr).toBe('') + expect(result.status).toBe(0) + expect(JSON.parse(result.stdout.trim())).toEqual(expected) + } + + const exitResult = spawnSync( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-File', + bridgePath, + process.execPath, + '-e', + 'process.exit(23)' + ], + { encoding: 'utf8' } + ) + expect(exitResult.error).toBeUndefined() + expect(exitResult.status).toBe(23) + } finally { + await rm(root, { recursive: true, force: true }) + } + } + ) +}) diff --git a/src/main/cli/wsl-cli-scripts.ts b/src/main/cli/wsl-cli-scripts.ts index 3297b9be9c1..3dba83bd30c 100644 --- a/src/main/cli/wsl-cli-scripts.ts +++ b/src/main/cli/wsl-cli-scripts.ts @@ -34,35 +34,75 @@ exec "$ORCA_POWERSHELL" -NoProfile -ExecutionPolicy Bypass -File "$ORCA_BRIDGE_P export function buildWslBridgeScript(): string { return `${BRIDGE_MANAGED_MARKER} -[CmdletBinding(PositionalBinding=$false)] -param( - [Parameter(Mandatory=$true, Position=0)] - [string]$OrcaLauncher, +function ConvertTo-NativeCommandLineArgument { + param([AllowEmptyString()][string]$Value) - [string]$WslCwd, + if ($Value.Length -gt 0 -and $Value -notmatch '[\\s"]') { + return $Value + } - [Parameter(ValueFromRemainingArguments=$true)] - [string[]]$ForwardArgs -) + $Quoted = [System.Text.StringBuilder]::new() + [void]$Quoted.Append([char]'"') + [int]$BackslashCount = 0 + foreach ($Character in $Value.ToCharArray()) { + if ($Character -eq [char]'\\') { + $BackslashCount += 1 + continue + } + if ($Character -eq [char]'"') { + [void]$Quoted.Append([char]'\\', $BackslashCount * 2 + 1) + [void]$Quoted.Append([char]'"') + } else { + [void]$Quoted.Append([char]'\\', $BackslashCount) + [void]$Quoted.Append($Character) + } + $BackslashCount = 0 + } + [void]$Quoted.Append([char]'\\', $BackslashCount * 2) + [void]$Quoted.Append([char]'"') + return $Quoted.ToString() +} $exitCode = 0 try { + # Why: a param block prefix-binds forwarded flags such as --for in PowerShell 5.1. + if ($args.Count -lt 1) { + throw 'Invalid Orca WSL CLI bridge invocation.' + } + [string]$OrcaLauncher = $args[0] + [string]$WslCwd = '' + [int]$ForwardArgStart = 1 + if ($args.Count -ge 2 -and $args[1] -eq '-WslCwd') { + if ($args.Count -lt 3) { + throw 'Invalid Orca WSL CLI bridge invocation.' + } + $WslCwd = $args[2] + $ForwardArgStart = 3 + } + [string[]]$ForwardArgs = @() + if ($args.Count -gt $ForwardArgStart) { + $ForwardArgs = @($args[$ForwardArgStart..($args.Count - 1)]) + } if ([string]::IsNullOrEmpty($WslCwd)) { Remove-Item Env:ORCA_CLI_CWD -ErrorAction SilentlyContinue } else { $env:ORCA_CLI_CWD = $WslCwd } Push-Location -LiteralPath (Split-Path -Parent $OrcaLauncher) - & $OrcaLauncher @ForwardArgs - if ($null -eq $LASTEXITCODE) { - if (-not $?) { - $exitCode = 1 - } else { - $exitCode = 0 - } - } else { - $exitCode = $LASTEXITCODE + # Why: Windows PowerShell 5.1 cannot losslessly splat strings to native argv. + $StartInfo = [System.Diagnostics.ProcessStartInfo]::new() + $StartInfo.FileName = $OrcaLauncher + $StartInfo.Arguments = (($ForwardArgs | ForEach-Object { + ConvertTo-NativeCommandLineArgument $_ + }) -join ' ') + $StartInfo.UseShellExecute = $false + $Process = [System.Diagnostics.Process]::Start($StartInfo) + if ($null -eq $Process) { + throw 'Unable to start the Orca Windows CLI launcher.' } + $Process.WaitForExit() + $exitCode = $Process.ExitCode + $Process.Dispose() } catch { Write-Error $_ $exitCode = 1 diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 15a48821501..966d901f994 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -1206,6 +1206,44 @@ describe('CodexRuntimeHomeService', () => { } }) + it('resolves only Orca-owned homes used by live retained host shells', async () => { + const accountHome = createManagedAuth( + testState.userDataDir, + 'account-1', + createCodexAuthJson('managed@example.com', 'acct-managed', 'managed') + ) + const unownedHome = join(testState.fakeHomeDir, 'unowned-codex-home') + mkdirSync(unownedHome, { recursive: true }) + writeFileSync(join(unownedHome, '.orca-managed-home'), 'account-2\n', 'utf-8') + writePaneRegistry({ + 'shared-pane': { selectionKey: 'host', accountId: null, homeRoute: 'shared-home' }, + 'account-pane': { selectionKey: 'host', accountId: 'account-1', homeRoute: 'account-home' }, + 'unowned-pane': { selectionKey: 'host', accountId: 'account-2', homeRoute: 'account-home' }, + 'real-pane': { selectionKey: 'host', accountId: null, homeRoute: 'real-home' }, + 'wsl-pane': { selectionKey: 'wsl:Ubuntu', accountId: null, homeRoute: 'wsl-home' } + }) + const settings = createSettings({ + codexManagedAccounts: [ + createCodexAccountRecord('account-1', 'managed@example.com', 'acct-managed', accountHome), + createCodexAccountRecord('account-2', 'other@example.com', 'acct-other', unownedHome) + ] + }) + const store = createStore(settings) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + expect( + service.getRetainedHostCodexHookHomePaths([ + 'shared-pane', + 'account-pane', + 'unowned-pane', + 'real-pane', + 'wsl-pane', + 'unknown-pane' + ]) + ).toEqual([getRuntimeCodexHomePath(), accountHome]) + }) + it('keeps pre-rollout shared-home panes authenticated on the real-home lane', async () => { const oldSystemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-system') const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 68d054f3571..2d4b8239aad 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -83,6 +83,7 @@ import { migrateLegacySharedAuthToPerAccountHome } from './legacy-shared-auth-mi import { CodexCredentialAbsenceGrace } from './codex-credential-absence-grace' import { syncLegacySharedCodexConfigForRetainedPanes } from './legacy-shared-config-compatibility' import { + getCodexPaneAccount, hasRecordedLegacySharedCodexPane, type CodexPaneHomeRoute } from '../codex/codex-pane-account-registry' @@ -545,6 +546,40 @@ export class CodexRuntimeHomeService { return this.isHostSystemDefaultRealHome() ? 'real-home' : 'shared-home' } + getRetainedHostCodexHookHomePaths(ptyIds: readonly string[]): string[] { + const settings = this.store.getSettings() + const homes = new Map() + for (const ptyId of ptyIds) { + const record = getCodexPaneAccount(ptyId) + if (!record || record.selectionKey !== 'host') { + continue + } + if ( + record.homeRoute === undefined || + record.homeRoute === 'shared-home' || + record.homeRoute === 'custom-home' + ) { + const homePath = this.getRuntimeHomePath() + homes.set(normalizeRuntimePathForComparison(homePath), homePath) + continue + } + if (record.homeRoute !== 'account-home' || !record.accountId) { + continue + } + const account = settings.codexManagedAccounts.find( + (candidate) => candidate.id === record.accountId + ) + if (!account || this.getWslManagedHomePath(account)) { + continue + } + const homePath = this.getTrustedSelfContainedManagedHomePath(account) + if (homePath) { + homes.set(normalizeRuntimePathForComparison(homePath), homePath) + } + } + return [...homes.values()] + } + // Why: the real-home hook installer flips this gate off when the trust-grant // client reports the host incapable, keeping that host byte-identical to the // managed lane instead of shipping status-blind panes. diff --git a/src/main/codex-usage/store-snapshot.benchmark.test.ts b/src/main/codex-usage/store-snapshot.benchmark.test.ts index 6ecd39963b9..dbd1482f34c 100644 --- a/src/main/codex-usage/store-snapshot.benchmark.test.ts +++ b/src/main/codex-usage/store-snapshot.benchmark.test.ts @@ -16,8 +16,8 @@ vi.mock('electron', () => ({ function createStoreWithState(state: CodexUsagePersistedState): CodexUsageStore { const store = new CodexUsageStore({ getRepos: () => [], - getWorktreeMeta: () => undefined - } as never) + getAllWorktreeMeta: () => ({}) + }) ;(store as unknown as { state: CodexUsagePersistedState }).state = state return store diff --git a/src/main/codex-usage/store.test.ts b/src/main/codex-usage/store.test.ts index 83e5a43fabd..dc5d7b9fe56 100644 --- a/src/main/codex-usage/store.test.ts +++ b/src/main/codex-usage/store.test.ts @@ -1,25 +1,12 @@ /* eslint-disable max-lines */ -import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' -import type * as FsPromises from 'node:fs/promises' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { - CodexUsageDailyAggregate, - CodexUsagePersistedFile, - CodexUsagePersistedState, - CodexUsageSession -} from './types' +import type { CodexUsagePersistedState } from './types' -const { getPathMock, writeOpens, writeGate } = vi.hoisted(() => ({ - getPathMock: vi.fn(() => '/tmp/orca-test-userdata'), - // Why only mode 'w': the durable write also opens the directory read-only to fsync it, so counting - // every open would hide a regression back to multiple full-cache rewrites per scan. - writeOpens: { value: 0, inFlight: 0, maxConcurrent: 0 }, - writeGate: { - blocked: false, - waiters: [] as (() => void)[] - } +const { getPathMock } = vi.hoisted(() => ({ + getPathMock: vi.fn(() => '/tmp/orca-test-userdata') })) vi.mock('electron', () => ({ @@ -28,29 +15,6 @@ vi.mock('electron', () => ({ } })) -vi.mock('node:fs/promises', async () => { - const actual = await vi.importActual('node:fs/promises') - return { - ...actual, - open: (async (...args: Parameters) => { - if (args[1] !== 'w') { - return actual.open(...args) - } - writeOpens.value += 1 - writeOpens.inFlight += 1 - writeOpens.maxConcurrent = Math.max(writeOpens.maxConcurrent, writeOpens.inFlight) - try { - if (writeGate.blocked) { - await new Promise((resolve) => writeGate.waiters.push(resolve)) - } - return await actual.open(...args) - } finally { - writeOpens.inFlight -= 1 - } - }) as typeof actual.open - } -}) - vi.mock('./scanner', () => ({ scanCodexUsageFiles: vi.fn() })) @@ -58,27 +22,7 @@ vi.mock('./scanner', () => ({ import { CodexUsageStore, initCodexUsagePath, normalizePersistedState } from './store' import { scanCodexUsageFiles } from './scanner' -type ScanResult = { - processedFiles: CodexUsagePersistedFile[] - sessions: CodexUsageSession[] - dailyAggregates: CodexUsageDailyAggregate[] -} - -function createDeferred(): { - promise: Promise - resolve: (value: T) => void - reject: (reason?: unknown) => void -} { - let resolve!: (value: T) => void - let reject!: (reason?: unknown) => void - const promise = new Promise((promiseResolve, promiseReject) => { - resolve = promiseResolve - reject = promiseReject - }) - return { promise, resolve, reject } -} - -function createEmptyScanResult(): ScanResult { +function createEmptyScanResult() { return { processedFiles: [], sessions: [], @@ -89,9 +33,8 @@ function createEmptyScanResult(): ScanResult { function createStoreWithState(state: Partial): CodexUsageStore { const store = new CodexUsageStore({ getRepos: () => [], - getAllWorktreeMeta: () => ({}), - getWorktreeMeta: () => undefined - } as never) + getAllWorktreeMeta: () => ({}) + }) ;(store as unknown as { state: CodexUsagePersistedState }).state = { schemaVersion: 1, @@ -118,11 +61,6 @@ describe('CodexUsageStore', () => { tempUserData = mkdtempSync(join(tmpdir(), 'orca-codex-usage-store-')) getPathMock.mockReturnValue(tempUserData) initCodexUsagePath() - writeOpens.value = 0 - writeOpens.inFlight = 0 - writeOpens.maxConcurrent = 0 - writeGate.blocked = false - writeGate.waiters = [] vi.mocked(scanCodexUsageFiles).mockReset() vi.mocked(scanCodexUsageFiles).mockResolvedValue(createEmptyScanResult()) vi.useFakeTimers() @@ -134,7 +72,7 @@ describe('CodexUsageStore', () => { rmSync(tempUserData, { recursive: true, force: true }) }) - it('persists a successful refresh with one compact async disk write', async () => { + it('adapts Codex scans to compact cache persistence', async () => { const store = createStoreWithState({ schemaVersion: 5, scanState: { @@ -147,92 +85,9 @@ describe('CodexUsageStore', () => { await store.refresh(true) - // Why exactly one: a refresh that rewrites the whole 60 MB cache twice is the regression this guards. - expect(writeOpens.value).toBe(1) - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) const persistedJson = readFileSync(join(tempUserData, 'orca-codex-usage.json'), 'utf-8') + expect(scanCodexUsageFiles).toHaveBeenCalledWith([], []) expect(persistedJson).toBe(JSON.stringify(JSON.parse(persistedJson))) - expect(persistedJson).not.toContain('\n') - expect(JSON.parse(persistedJson).scanState).toMatchObject({ - enabled: true, - lastScanStartedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(), - lastScanCompletedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(), - lastScanError: null - }) - }) - - it('keeps scan start visible in memory while scan-start persistence is skipped', async () => { - const pendingScan = createDeferred() - vi.mocked(scanCodexUsageFiles).mockReturnValueOnce(pendingScan.promise) - const store = createStoreWithState({ - schemaVersion: 5, - scanState: { - enabled: true, - lastScanStartedAt: null, - lastScanCompletedAt: null, - lastScanError: 'previous failure' - } - }) - - const refreshPromise = store.refresh(true) - await Promise.resolve() - - expect(store.getScanState()).toMatchObject({ - isScanning: true, - lastScanStartedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(), - lastScanError: null - }) - expect(writeOpens.value).toBe(0) - - pendingScan.resolve(createEmptyScanResult()) - await refreshPromise - - expect(store.getScanState().isScanning).toBe(false) - expect(writeOpens.value).toBe(1) - }) - - it('vetoes a stale concurrent async write so the newer snapshot wins without leaking tmp files', async () => { - const store = createStoreWithState({ - schemaVersion: 5, - scanState: { - enabled: true, - lastScanStartedAt: null, - lastScanCompletedAt: null, - lastScanError: null - } - }) - const internals = store as unknown as { - writeToDisk: () => Promise - state: CodexUsagePersistedState - } - - writeGate.blocked = true - const first = internals.writeToDisk() - await vi.waitFor(() => expect(writeGate.waiters.length).toBe(1)) - - internals.state.scanState.enabled = false - writeGate.blocked = false - const second = internals.writeToDisk() - writeGate.waiters.splice(0).forEach((resolve) => resolve()) - await Promise.all([first, second]) - - expect( - JSON.parse(readFileSync(join(tempUserData, 'orca-codex-usage.json'), 'utf-8')).scanState - .enabled - ).toBe(false) - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) - // Serialized, so the superseded write can be skipped safely rather than racing the newer one. - expect(writeOpens.maxConcurrent).toBe(1) - }) - - it('sweeps a usage temp file orphaned by a crash between write and rename', async () => { - const orphan = join(tempUserData, 'orca-codex-usage.json.999.1.abc.tmp') - writeFileSync(orphan, '{}') - - createStoreWithState({}) - await vi.waitFor(() => - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) - ) }) it('reports no data for Orca scope when only non-Orca Codex usage exists', async () => { diff --git a/src/main/codex-usage/store.ts b/src/main/codex-usage/store.ts index 10aad99fbbf..c1784f556a1 100644 --- a/src/main/codex-usage/store.ts +++ b/src/main/codex-usage/store.ts @@ -1,14 +1,11 @@ -/* eslint-disable max-lines -- Why: this store owns Codex analytics persistence, scan policy, and renderer query semantics. Keeping them together prevents the Codex range/scope rules from drifting away from the scanner’s event model. */ +/* eslint-disable max-lines -- Why: Codex pricing, range, scope, breakdown, and automation-attribution policies remain one cohesive store. */ import { app } from 'electron' import { join } from 'node:path' -import { existsSync, readFileSync } from 'node:fs' -import { UsageCacheSnapshotWriter } from '../usage-cache-snapshot-writer' import type { CodexUsageBreakdownKind, CodexUsageBreakdownRow, CodexUsageDailyPoint, CodexUsageRange, - CodexUsageScanState, CodexUsageScope, CodexUsageSessionRow, CodexUsageSnapshot, @@ -16,13 +13,12 @@ import type { } from '../../shared/codex-usage-types' import type { AutomationRunUsage } from '../../shared/automations-types' import type { Store } from '../persistence' -import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata' import type { CodexUsagePersistedState } from './types' -import { createWorktreeRefs } from '../usage/usage-worktree-refs' import { CODEX_USAGE_SCHEMA_VERSION, codexUsageProvider } from './codex-usage-provider' +import { getLocalUsageDay, getUsageRangeCutoff } from '../usage/usage-calendar-range' +import { UsageProviderStoreLifecycle } from '../usage/usage-provider-store-lifecycle' const SCHEMA_VERSION = CODEX_USAGE_SCHEMA_VERSION -const STALE_MS = 5 * 60_000 const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000 let _codexUsageFile: string | null = null @@ -308,31 +304,6 @@ function estimateCostUsd( ) } -function getRangeCutoff(range: CodexUsageRange): string | null { - if (range === 'all') { - return null - } - const days = range === '7d' ? 7 : range === '30d' ? 30 : 90 - const now = new Date() - now.setHours(0, 0, 0, 0) - now.setDate(now.getDate() - (days - 1)) - const year = now.getFullYear() - const month = String(now.getMonth() + 1).padStart(2, '0') - const day = String(now.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -} - -function getLocalDay(timestamp: string): string | null { - const parsed = new Date(timestamp) - if (Number.isNaN(parsed.getTime())) { - return null - } - const year = parsed.getFullYear() - const month = String(parsed.getMonth() + 1).padStart(2, '0') - const day = String(parsed.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -} - type ScopedCodexUsageModelRow = { modelKey: string modelLabel: string @@ -345,78 +316,21 @@ type ScopedCodexUsageModelRow = { totalTokens: number } -function getWorktreeFingerprint(worktreesByRepo: Map): string { - const rows = [...worktreesByRepo.entries()] - .flatMap(([repoId, worktrees]) => - worktrees.map((worktree) => - JSON.stringify({ - repoId, - worktreeId: worktree.worktreeId, - path: worktree.path, - displayName: worktree.displayName - }) - ) - ) - .sort() - return JSON.stringify(rows) -} - -export class CodexUsageStore { - private state: CodexUsagePersistedState - private readonly store: Store - private scanPromise: Promise | null = null - // Why: the 60 MB usage JSON must not block the Electron main thread; the writer serializes writes - // and vetoes superseded renames. - private readonly writer = new UsageCacheSnapshotWriter('[codex-usage]', getCodexUsageFile) - - constructor(store: Store) { - this.store = store - this.state = this.load() - } - - private load(): CodexUsagePersistedState { - try { - const usageFile = getCodexUsageFile() - if (!existsSync(usageFile)) { - return getDefaultState() - } - const parsed = JSON.parse(readFileSync(usageFile, 'utf-8')) as CodexUsagePersistedState - return normalizePersistedState({ - ...getDefaultState(), - ...parsed, - scanState: { - ...getDefaultState().scanState, - ...parsed.scanState - } - }) - } catch (error) { - console.error('[codex-usage] Failed to load persisted state, starting fresh:', error) - return getDefaultState() - } - } - - private writeToDisk(): Promise { - // Compact: this cache reaches 60 MB, and pretty-printing it costs main-thread time per scan. - return this.writer.write(() => JSON.stringify(this.state)) - } - - /** Await queued cache writes so quit does not drop the final snapshot. */ - flush(): Promise { - return this.writer.flush() - } - - async setEnabled(enabled: boolean): Promise { - this.state.scanState.enabled = enabled - await this.writeToDisk() - return this.getScanState() - } - - getScanState(): CodexUsageScanState { - return { - ...this.state.scanState, - isScanning: this.scanPromise !== null, - hasAnyCodexData: this.state.sessions.length > 0 || this.state.dailyAggregates.length > 0 - } +export class CodexUsageStore extends UsageProviderStoreLifecycle< + 'processedFiles', + CodexUsagePersistedState, + 'hasAnyCodexData' +> { + constructor(store: Pick) { + super(store, { + logTag: '[codex-usage]', + resolveCacheFile: getCodexUsageFile, + createDefaultState: getDefaultState, + normalizeState: normalizePersistedState, + sourceKey: 'processedFiles', + dataPresenceKey: 'hasAnyCodexData', + scan: codexUsageProvider.scan + }) } getSnapshot( @@ -434,61 +348,6 @@ export class CodexUsageStore { } } - async refresh(force = false): Promise { - if (!this.state.scanState.enabled) { - return this.getScanState() - } - const currentWorktreeFingerprint = await this.getCurrentWorktreeFingerprint() - if (!force && this.state.scanState.lastScanCompletedAt) { - const ageMs = Date.now() - this.state.scanState.lastScanCompletedAt - if (ageMs < STALE_MS && this.state.worktreeFingerprint === currentWorktreeFingerprint) { - return this.getScanState() - } - } - await this.runScan() - return this.getScanState() - } - - private async runScan(): Promise { - if (this.scanPromise) { - await this.scanPromise - return - } - - this.state.scanState.lastScanStartedAt = Date.now() - this.state.scanState.lastScanError = null - // Why no write here: persisting scan-start would rewrite the whole multi-MB cache before a single - // result changed. The completion/failure write below persists the same fields. - - this.scanPromise = (async () => { - try { - const repos = this.store.getRepos() - const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) - const worktreeFingerprint = getWorktreeFingerprint(worktreesByRepo) - const result = await codexUsageProvider.scan( - createWorktreeRefs(repos, worktreesByRepo), - this.state.worktreeFingerprint === worktreeFingerprint ? this.state.processedFiles : [] - ) - this.state.processedFiles = result.processedFiles - this.state.sessions = result.sessions - this.state.dailyAggregates = result.dailyAggregates - this.state.worktreeFingerprint = worktreeFingerprint - this.state.scanState.lastScanCompletedAt = Date.now() - this.state.scanState.lastScanError = null - // Why swallow: persistence is a cache concern. A disk failure must not turn a good scan into - // a scan error and reject refresh() for every query caller; writeToDisk already logs it. - await this.writeToDisk().catch(() => {}) - } catch (error) { - this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error) - await this.writeToDisk().catch(() => {}) - } finally { - this.scanPromise = null - } - })() - - await this.scanPromise - } - async getSummary(scope: CodexUsageScope, range: CodexUsageRange): Promise { await this.refresh(false) return this.buildSummary(scope, range) @@ -888,7 +747,7 @@ export class CodexUsageStore { } private getFilteredDaily(scope: CodexUsageScope, range: CodexUsageRange) { - const cutoff = getRangeCutoff(range) + const cutoff = getUsageRangeCutoff(range) return this.state.dailyAggregates.filter((entry) => { if (cutoff && entry.day < cutoff) { return false @@ -901,9 +760,9 @@ export class CodexUsageStore { } private getFilteredSessions(scope: CodexUsageScope, range: CodexUsageRange) { - const cutoff = getRangeCutoff(range) + const cutoff = getUsageRangeCutoff(range) return this.state.sessions.filter((session) => { - const day = getLocalDay(session.lastTimestamp) + const day = getLocalUsageDay(session.lastTimestamp) if (!day) { return false } @@ -975,10 +834,4 @@ export class CodexUsageStore { Boolean(lastScanError) || lastScanCompletedAt === null || lastScanCompletedAt < completedAt ) } - - private async getCurrentWorktreeFingerprint(): Promise { - const repos = this.store.getRepos() - const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) - return getWorktreeFingerprint(worktreesByRepo) - } } diff --git a/src/main/codex/codex-pane-account-registry.ts b/src/main/codex/codex-pane-account-registry.ts index 640dcd17506..595f1d3e767 100644 --- a/src/main/codex/codex-pane-account-registry.ts +++ b/src/main/codex/codex-pane-account-registry.ts @@ -225,6 +225,13 @@ export function getCodexPaneAccount(ptyId: string): CodexPaneAccountRecord | nul return readRegistry().panes[ptyId] ?? null } +/** `custom-home` stays conservative because it can mask a non-comparable shared-home route. */ +export function isCodexPaneHomeRouteProvenAwayFromSharedHome( + route: CodexPaneHomeRoute | undefined +): boolean { + return route === 'real-home' || route === 'account-home' || route === 'wsl-home' +} + /** * Reports the lane each given PTY launched from, omitting panes with no record. * @@ -256,6 +263,18 @@ export function hasRecordedLegacySharedCodexPane(): boolean { ) } +/** True when startup may need to repair hooks for a retained managed host pane. */ +export function hasRecordedManagedHostCodexPane(): boolean { + return Object.values(readRegistry().panes).some( + (record) => + record.selectionKey === 'host' && + (record.homeRoute === undefined || + record.homeRoute === 'shared-home' || + record.homeRoute === 'custom-home' || + (record.homeRoute === 'account-home' && record.accountId !== null)) + ) +} + /** Drops records whose daemon PTYs are authoritatively absent. */ export function reconcileCodexPaneAccountsWithLivePtys(livePtyIds: readonly string[]): void { const registry = readRegistry() diff --git a/src/main/codex/codex-session-migration-ignored-launches.ts b/src/main/codex/codex-session-migration-ignored-launches.ts new file mode 100644 index 00000000000..fcddb0393bf --- /dev/null +++ b/src/main/codex/codex-session-migration-ignored-launches.ts @@ -0,0 +1,35 @@ +import { + CODEX_SESSION_MIGRATION_LEASE_RETENTION_MS, + evictStaleLeaseEntries +} from './codex-session-migration-recent-exits' + +type IgnoredLaunch = { recordedAt: number } + +/** Lease ids whose reattach was proven away from the shared home, so their exit owes no scan. */ +export class CodexSessionMigrationIgnoredLaunches { + private readonly launches = new Map() + + add(leaseId: string): void { + const now = Date.now() + this.launches.delete(leaseId) + this.launches.set(leaseId, { recordedAt: now }) + evictStaleLeaseEntries(this.launches, now) + } + + has(leaseId: string): boolean { + const launch = this.launches.get(leaseId) + if (!launch) { + return false + } + // Why: an entry whose exit never arrived must age out, or it absorbs a later exit and strands that launch active. + if (Date.now() - launch.recordedAt > CODEX_SESSION_MIGRATION_LEASE_RETENTION_MS) { + this.launches.delete(leaseId) + return false + } + return true + } + + delete(leaseId: string): boolean { + return this.has(leaseId) && this.launches.delete(leaseId) + } +} diff --git a/src/main/codex/codex-session-migration-recent-exits.test.ts b/src/main/codex/codex-session-migration-recent-exits.test.ts new file mode 100644 index 00000000000..84d08768402 --- /dev/null +++ b/src/main/codex/codex-session-migration-recent-exits.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { CodexSessionMigrationRecentExits } from './codex-session-migration-recent-exits' + +describe('CodexSessionMigrationRecentExits', () => { + it('refreshes a rerecorded ID before evicting the oldest entry', () => { + const exits = new CodexSessionMigrationRecentExits() + for (let index = 0; index < 256; index += 1) { + exits.record(`pty-${index}`, index + 1) + } + + exits.record('pty-0', 500) + exits.record('pty-overflow', 501) + + expect(exits.matchesAfter('pty-0', 499)).toBe(true) + expect(exits.matchesAfter('pty-1', 0)).toBe(false) + }) +}) diff --git a/src/main/codex/codex-session-migration-recent-exits.ts b/src/main/codex/codex-session-migration-recent-exits.ts new file mode 100644 index 00000000000..2a92441537b --- /dev/null +++ b/src/main/codex/codex-session-migration-recent-exits.ts @@ -0,0 +1,60 @@ +export const CODEX_SESSION_MIGRATION_LEASE_RETENTION_MS = 60_000 +const MAX_ENTRIES = 256 + +type RecentPtyExit = { sequence: number; recordedAt: number } + +// Why: one aging policy for every lease-keyed migration map, so none can grow for the process lifetime. +export function evictStaleLeaseEntries( + entries: Map, + now: number +): void { + for (const [leaseId, entry] of entries) { + if (now - entry.recordedAt > CODEX_SESSION_MIGRATION_LEASE_RETENTION_MS) { + entries.delete(leaseId) + } + } + while (entries.size > MAX_ENTRIES) { + const oldestLeaseId = entries.keys().next().value + if (oldestLeaseId === undefined) { + break + } + entries.delete(oldestLeaseId) + } +} + +export class CodexSessionMigrationRecentExits { + private readonly exits = new Map() + + record(leaseId: string, sequence: number): void { + const now = Date.now() + this.exits.delete(leaseId) + this.exits.set(leaseId, { sequence, recordedAt: now }) + evictStaleLeaseEntries(this.exits, now) + } + + consumeAfter(leaseId: string, startedSequence: number | undefined): RecentPtyExit | null { + const exit = this.exits.get(leaseId) + this.exits.delete(leaseId) + if ( + !exit || + startedSequence === undefined || + exit.sequence <= startedSequence || + Date.now() - exit.recordedAt > CODEX_SESSION_MIGRATION_LEASE_RETENTION_MS + ) { + return null + } + return exit + } + + matchesAfter(leaseId: string, startedSequence: number | undefined): boolean { + const exit = this.exits.get(leaseId) + if (!exit) { + return false + } + if (Date.now() - exit.recordedAt > CODEX_SESSION_MIGRATION_LEASE_RETENTION_MS) { + this.exits.delete(leaseId) + return false + } + return startedSequence !== undefined && exit.sequence > startedSequence + } +} diff --git a/src/main/codex/codex-session-migration-scheduler.test.ts b/src/main/codex/codex-session-migration-scheduler.test.ts index 9f9aed3173c..b495afb8e0e 100644 --- a/src/main/codex/codex-session-migration-scheduler.test.ts +++ b/src/main/codex/codex-session-migration-scheduler.test.ts @@ -491,6 +491,174 @@ describe('createCodexSessionMigrationScheduler', () => { await vi.waitFor(() => expect(finishScheduledRun).toHaveBeenCalledOnce()) }) + it('keeps an ignored reattach exit from poisoning a same-ID launch already starting', async () => { + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.ignoreLaunch('stable-pty', 1) + scheduler.finishLaunch('stable-pty', 3) + scheduler.beginLaunch('stable-pty', false, new Date(), 2) + await vi.advanceTimersByTimeAsync(1_000) + + expect(startBackfill).toHaveBeenCalledWith( + expect.objectContaining({ + scanDates: expect.any(Array), + writeCompletionMarker: false, + writeBoundedCompletionMarker: false + }), + undefined + ) + }) + + it('matches an ignored reattach exit that arrived before its lifecycle callback', async () => { + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.finishLaunch('stable-pty', 2) + scheduler.ignoreLaunch('stable-pty', 1) + scheduler.beginLaunch('stable-pty', false, new Date(), 3) + await vi.advanceTimersByTimeAsync(1_000) + + expect(startBackfill).toHaveBeenCalledWith( + expect.objectContaining({ + scanDates: expect.any(Array), + writeCompletionMarker: false, + writeBoundedCompletionMarker: false + }), + undefined + ) + }) + + it('keeps a newer same-ID launch active while the ignored incarnation exits', async () => { + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.ignoreLaunch('stable-pty', 1) + scheduler.beginLaunch('stable-pty', false, new Date(), 2) + scheduler.finishLaunch('stable-pty', 3) + await vi.advanceTimersByTimeAsync(1_000) + + expect(startBackfill).toHaveBeenCalledWith( + expect.objectContaining({ writeCompletionMarker: false }), + undefined + ) + + scheduler.finishLaunch('stable-pty', 4) + await vi.advanceTimersByTimeAsync(1_000) + expect(startBackfill).toHaveBeenLastCalledWith( + expect.objectContaining({ writeCompletionMarker: true }), + undefined + ) + }) + + it('releases a stranded active launch once the ignored incarnation ages out', async () => { + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + // The ignored incarnation never reports its exit, so only the newer launch ever finishes. + scheduler.ignoreLaunch('stable-pty', 1) + scheduler.beginLaunch('stable-pty', false, new Date(), 2) + await vi.advanceTimersByTimeAsync(61_000) + expect(startBackfill).toHaveBeenLastCalledWith( + expect.objectContaining({ writeCompletionMarker: false }), + undefined + ) + + scheduler.finishLaunch('stable-pty', 3) + await vi.advanceTimersByTimeAsync(1_000) + expect(startBackfill).toHaveBeenLastCalledWith( + expect.objectContaining({ writeCompletionMarker: true }), + undefined + ) + }) + + it('keeps late duplicate ignored callbacks from poisoning reuse', async () => { + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.ignoreLaunch('stable-pty', 1) + scheduler.finishLaunch('stable-pty', 3) + scheduler.ignoreLaunch('stable-pty', 2) + scheduler.ignoreLaunch('stable-pty', 2) + scheduler.beginLaunch('stable-pty', false, new Date(), 4) + await vi.advanceTimersByTimeAsync(1_000) + expect(startBackfill).toHaveBeenCalledTimes(1) + + scheduler.finishLaunch('stable-pty', 5) + await vi.advanceTimersByTimeAsync(1_000) + expect(startBackfill).toHaveBeenCalledTimes(2) + expect(startBackfill).toHaveBeenLastCalledWith( + expect.objectContaining({ writeCompletionMarker: true }), + undefined + ) + }) + + it('keeps three exit-before-callback reattaches from stranding a reused ID', async () => { + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.finishLaunch('stable-pty', 4) + scheduler.ignoreLaunch('stable-pty', 1) + scheduler.ignoreLaunch('stable-pty', 2) + scheduler.ignoreLaunch('stable-pty', 3) + scheduler.beginLaunch('stable-pty', false, new Date(), 5) + await vi.advanceTimersByTimeAsync(1_000) + expect(startBackfill).toHaveBeenLastCalledWith( + expect.objectContaining({ writeCompletionMarker: false }), + undefined + ) + + scheduler.finishLaunch('stable-pty', 6) + await vi.advanceTimersByTimeAsync(1_000) + expect(startBackfill).toHaveBeenCalledTimes(2) + expect(startBackfill).toHaveBeenLastCalledWith( + expect.objectContaining({ writeCompletionMarker: true }), + undefined + ) + }) + it('does not consume an exit from an earlier stable-id incarnation', async () => { const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) const scheduler = createCodexSessionMigrationScheduler({ diff --git a/src/main/codex/codex-session-migration-scheduler.ts b/src/main/codex/codex-session-migration-scheduler.ts index 28ce4b991da..9f08d83c5ea 100644 --- a/src/main/codex/codex-session-migration-scheduler.ts +++ b/src/main/codex/codex-session-migration-scheduler.ts @@ -1,4 +1,6 @@ import { getCodexSessionBackfillDate } from './codex-session-backfill-date' +import { CodexSessionMigrationIgnoredLaunches } from './codex-session-migration-ignored-launches' +import { CodexSessionMigrationRecentExits } from './codex-session-migration-recent-exits' import type { CodexSessionBackfillDate, CodexSessionBackfillOptions @@ -9,11 +11,6 @@ type MigrationRun = ( systemCodexHomePathOverride?: string ) => Promise -const EARLY_PTY_EXIT_RETENTION_MS = 60_000 -const MAX_EARLY_PTY_EXITS = 256 - -type EarlyPtyExit = { sequence: number; recordedAt: number } - export type CodexSessionMigrationScheduler = { beginLaunch( leaseId: string, @@ -21,6 +18,7 @@ export type CodexSessionMigrationScheduler = { startedAt?: Date, startedSequence?: number ): void + ignoreLaunch(leaseId: string, startedSequence: number): void finishLaunch(leaseId: string, exitSequence?: number): void scheduleInitialRun(): void scheduleRun(fullScanRequired?: boolean): void @@ -46,7 +44,10 @@ export function createCodexSessionMigrationScheduler(args: { let pendingFullScan = false let migrationTask: Promise | null = null const activeLaunches = new Map() - const earlyPtyExits = new Map() + // Why: non-shared reattach exits must not masquerade as exit-before-begin races for a reused id. + const ignoredLaunches = new CodexSessionMigrationIgnoredLaunches() + const ignoredPtyExits = new CodexSessionMigrationRecentExits() + const earlyPtyExits = new CodexSessionMigrationRecentExits() let activeRunStopObserved = false let rerunRequested = false @@ -194,18 +195,38 @@ export function createCodexSessionMigrationScheduler(args: { if (args.isQuitting() || activeLaunches.has(leaseId)) { return } - if (consumeEarlyPtyExit(earlyPtyExits, leaseId, startedSequence)) { + if (earlyPtyExits.consumeAfter(leaseId, startedSequence)) { scheduleRun(true) return } activeLaunches.set(leaseId, startedAt) scheduleRun(fullScanRequired) }, + ignoreLaunch(leaseId, startedSequence): void { + if (args.isQuitting() || ignoredLaunches.has(leaseId)) { + return + } + if (ignoredPtyExits.matchesAfter(leaseId, startedSequence)) { + return + } + const earlyExit = earlyPtyExits.consumeAfter(leaseId, startedSequence) + if (earlyExit) { + ignoredPtyExits.record(leaseId, earlyExit.sequence) + return + } + ignoredLaunches.add(leaseId) + }, finishLaunch(leaseId, exitSequence): void { + if (ignoredLaunches.delete(leaseId)) { + if (exitSequence !== undefined) { + ignoredPtyExits.record(leaseId, exitSequence) + } + return + } const startedAt = activeLaunches.get(leaseId) if (!startedAt) { if (exitSequence !== undefined) { - recordEarlyPtyExit(earlyPtyExits, leaseId, exitSequence) + earlyPtyExits.record(leaseId, exitSequence) } return } @@ -253,42 +274,6 @@ function compareBackfillDates( return left.join('-').localeCompare(right.join('-')) } -function recordEarlyPtyExit( - exits: Map, - leaseId: string, - sequence: number -): void { - const now = Date.now() - for (const [id, exit] of exits) { - if (now - exit.recordedAt > EARLY_PTY_EXIT_RETENTION_MS) { - exits.delete(id) - } - } - exits.set(leaseId, { sequence, recordedAt: now }) - while (exits.size > MAX_EARLY_PTY_EXITS) { - const oldestLeaseId = exits.keys().next().value - if (oldestLeaseId === undefined) { - break - } - exits.delete(oldestLeaseId) - } -} - -function consumeEarlyPtyExit( - exits: Map, - leaseId: string, - startedSequence: number | undefined -): boolean { - const exit = exits.get(leaseId) - exits.delete(leaseId) - return ( - exit !== undefined && - startedSequence !== undefined && - exit.sequence > startedSequence && - Date.now() - exit.recordedAt <= EARLY_PTY_EXIT_RETENTION_MS - ) -} - function isStoppedMigrationResult(result: unknown): boolean { return Boolean(result && typeof result === 'object' && 'stopped' in result && result.stopped) } diff --git a/src/main/codex/codex-stale-pane-accounts.test.ts b/src/main/codex/codex-stale-pane-accounts.test.ts index c5ee4802e5b..ebc953a4be7 100644 --- a/src/main/codex/codex-stale-pane-accounts.test.ts +++ b/src/main/codex/codex-stale-pane-accounts.test.ts @@ -8,6 +8,8 @@ import { forgetCodexPaneAccount, getCodexPaneAccount, hasRecordedLegacySharedCodexPane, + hasRecordedManagedHostCodexPane, + isCodexPaneHomeRouteProvenAwayFromSharedHome, reconcileCodexPaneAccountsWithLivePtys, recordCodexPaneAccount } from './codex-pane-account-registry' @@ -46,6 +48,17 @@ afterEach(() => { }) describe('codex pane account registry', () => { + it.each([ + ['real-home', true], + ['account-home', true], + ['wsl-home', true], + ['shared-home', false], + ['custom-home', false], + [undefined, false] + ] as const)('classifies whether %s proves a pane avoided the shared home', (route, expected) => { + expect(isCodexPaneHomeRouteProvenAwayFromSharedHome(route)).toBe(expected) + }) + it('survives a process restart so a daemon-backed shell stays attributable', () => { recordCodexPaneAccount('pty-1', { selectionKey: 'host', @@ -125,6 +138,40 @@ describe('codex pane account registry', () => { expect(hasRecordedLegacySharedCodexPane()).toBe(true) }) + it('requests startup inventory only for managed host panes', () => { + recordCodexPaneAccount('pty-real', { + selectionKey: 'host', + accountId: null, + homeRoute: 'real-home' + }) + recordCodexPaneAccount('pty-wsl', { + selectionKey: 'wsl:Ubuntu', + accountId: 'account-wsl', + homeRoute: 'account-home' + }) + + expect(hasRecordedManagedHostCodexPane()).toBe(false) + + recordCodexPaneAccount('pty-shared', { + selectionKey: 'host', + accountId: null, + homeRoute: 'shared-home' + }) + + expect(hasRecordedManagedHostCodexPane()).toBe(true) + + forgetCodexPaneAccount('pty-shared') + expect(hasRecordedManagedHostCodexPane()).toBe(false) + + recordCodexPaneAccount('pty-account', { + selectionKey: 'host', + accountId: 'account-host', + homeRoute: 'account-home' + }) + + expect(hasRecordedManagedHostCodexPane()).toBe(true) + }) + it('drops leaked records that are absent from the authoritative daemon inventory', () => { recordCodexPaneAccount('pty-live', { selectionKey: 'host', diff --git a/src/main/codex/retained-codex-hook-state.test.ts b/src/main/codex/retained-codex-hook-state.test.ts new file mode 100644 index 00000000000..642b8bc7476 --- /dev/null +++ b/src/main/codex/retained-codex-hook-state.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { reconcileRetainedCodexHookHomes } from './retained-codex-hook-state' + +function status(state: 'installed' | 'not_installed' | 'error'): AgentHookInstallStatus { + return { + agent: 'codex', + state, + configPath: '/runtime/hooks.json', + managedHooksPresent: state === 'installed', + detail: state === 'error' ? 'failed' : null + } +} + +describe('retained Codex hook state', () => { + it('repairs Orca hooks before a retained shell can launch Codex', () => { + const install = vi.fn(() => status('installed')) + const refreshRuntimeUserHooks = vi.fn(() => status('not_installed')) + + reconcileRetainedCodexHookHomes({ + hookService: { install, refreshRuntimeUserHooks }, + hooksEnabled: true, + runtimeHomePaths: ['/orca/shared-home', '/orca/account-home'] + }) + + expect(install).toHaveBeenCalledTimes(2) + expect(install).toHaveBeenNthCalledWith(1, '/orca/shared-home') + expect(install).toHaveBeenNthCalledWith(2, '/orca/account-home') + expect(refreshRuntimeUserHooks).not.toHaveBeenCalled() + }) + + it('removes only Orca hooks from retained homes when hooks are disabled', () => { + const install = vi.fn(() => status('installed')) + const refreshRuntimeUserHooks = vi.fn(() => status('not_installed')) + + reconcileRetainedCodexHookHomes({ + hookService: { install, refreshRuntimeUserHooks }, + hooksEnabled: false, + runtimeHomePaths: ['/orca/shared-home'] + }) + + expect(refreshRuntimeUserHooks).toHaveBeenCalledWith('/orca/shared-home') + expect(install).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/codex/retained-codex-hook-state.ts b/src/main/codex/retained-codex-hook-state.ts new file mode 100644 index 00000000000..ccdf8c86af3 --- /dev/null +++ b/src/main/codex/retained-codex-hook-state.ts @@ -0,0 +1,26 @@ +import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' + +type RetainedCodexHookService = { + install: (runtimeHomePath: string) => AgentHookInstallStatus + refreshRuntimeUserHooks: (runtimeHomePath: string) => AgentHookInstallStatus +} + +export function reconcileRetainedCodexHookHomes(args: { + hookService: RetainedCodexHookService + hooksEnabled: boolean + runtimeHomePaths: readonly string[] +}): void { + for (const runtimeHomePath of args.runtimeHomePaths) { + try { + const status = args.hooksEnabled + ? args.hookService.install(runtimeHomePath) + : args.hookService.refreshRuntimeUserHooks(runtimeHomePath) + if (status.state === 'error') { + console.warn('[codex-hook-service] failed to reconcile retained Codex home', status.detail) + } + } catch (error) { + // Why: a retained home repair is best-effort; daemon availability must not depend on a writable Codex config. + console.warn('[codex-hook-service] failed to reconcile retained Codex home', error) + } + } +} diff --git a/src/main/computer/key-mapping.test.ts b/src/main/computer/key-mapping.test.ts deleted file mode 100644 index 6b599cd483c..00000000000 --- a/src/main/computer/key-mapping.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { parseKey } from './key-mapping' - -describe('parseKey', () => { - it.each([ - ['a', { key: 'a', modifiers: [] }], - ['7', { key: '7', modifiers: [] }], - [';', { key: ';', modifiers: [] }], - ['+', { key: '+', modifiers: [] }], - ['Return', { key: 'Enter', modifiers: [] }], - ['Escape', { key: 'Escape', modifiers: [] }], - ['BackSpace', { key: 'Backspace', modifiers: [] }], - ['Tab', { key: 'Tab', modifiers: [] }], - ['space', { key: 'Space', modifiers: [] }], - ['Page_Up', { key: 'PageUp', modifiers: [] }], - ['Page_Down', { key: 'PageDown', modifiers: [] }], - ['Home', { key: 'Home', modifiers: [] }], - ['End', { key: 'End', modifiers: [] }], - ['Up', { key: 'ArrowUp', modifiers: [] }], - ['Down', { key: 'ArrowDown', modifiers: [] }], - ['Left', { key: 'ArrowLeft', modifiers: [] }], - ['Right', { key: 'ArrowRight', modifiers: [] }], - ['Delete', { key: 'Delete', modifiers: [] }], - ['Insert', { key: 'Insert', modifiers: [] }], - ['F1', { key: 'F1', modifiers: [] }], - ['F24', { key: 'F24', modifiers: [] }], - ['KP_0', { key: 'Numpad0', modifiers: [] }], - ['KP_9', { key: 'Numpad9', modifiers: [] }], - ['KP_Add', { key: 'NumpadAdd', modifiers: [] }], - ['KP_Subtract', { key: 'NumpadSubtract', modifiers: [] }], - ['KP_Multiply', { key: 'NumpadMultiply', modifiers: [] }], - ['KP_Divide', { key: 'NumpadDivide', modifiers: [] }], - ['KP_Enter', { key: 'NumpadEnter', modifiers: [] }] - ])('maps %s', (input, expected) => { - expect(parseKey(input)).toEqual(expected) - }) - - it.each([ - ['ctrl+a', { key: 'a', modifiers: ['Ctrl'] }], - ['control+a', { key: 'a', modifiers: ['Ctrl'] }], - ['ctrl+shift+t', { key: 't', modifiers: ['Ctrl', 'Shift'] }], - ['alt+F4', { key: 'F4', modifiers: ['Alt'] }], - ['cmd+a', { key: 'a', modifiers: ['Meta'] }], - ['command+a', { key: 'a', modifiers: ['Meta'] }], - ['CmdOrCtrl+a', { key: 'a', modifiers: [process.platform === 'darwin' ? 'Meta' : 'Ctrl'] }], - ['ctrl++', { key: '+', modifiers: ['Ctrl'] }], - ['ctrl+shift++', { key: '+', modifiers: ['Ctrl', 'Shift'] }], - ['super+Left', { key: 'ArrowLeft', modifiers: ['Meta'] }], - ['win+Right', { key: 'ArrowRight', modifiers: ['Meta'] }] - ])('maps chord %s', (input, expected) => { - expect(parseKey(input)).toEqual(expected) - }) - - it('rejects unknown modifiers and key names', () => { - expect(() => parseKey('hyper+a')).toThrow(expect.objectContaining({ code: 'invalid_argument' })) - expect(() => parseKey('NotAKey')).toThrow(expect.objectContaining({ code: 'invalid_argument' })) - expect(() => parseKey('F25')).toThrow(expect.objectContaining({ code: 'invalid_argument' })) - }) -}) diff --git a/src/main/computer/key-mapping.ts b/src/main/computer/key-mapping.ts deleted file mode 100644 index 4634f241268..00000000000 --- a/src/main/computer/key-mapping.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { RuntimeClientError } from './runtime-client-error' - -export type KeyChord = { - key: string - modifiers: string[] -} - -const MODIFIER_NAMES: Record = { - ctrl: 'Ctrl', - control: 'Ctrl', - shift: 'Shift', - alt: 'Alt', - meta: 'Meta', - super: 'Meta', - cmd: 'Meta', - command: 'Meta', - win: 'Meta' -} - -const PLATFORM_MODIFIER_NAMES: Record string> = { - cmdorctrl: () => (process.platform === 'darwin' ? 'Meta' : 'Ctrl'), - commandorcontrol: () => (process.platform === 'darwin' ? 'Meta' : 'Ctrl') -} - -const KEY_NAMES: Record = { - Return: 'Enter', - Enter: 'Enter', - Escape: 'Escape', - Esc: 'Escape', - BackSpace: 'Backspace', - Backspace: 'Backspace', - Tab: 'Tab', - space: 'Space', - Space: 'Space', - Page_Up: 'PageUp', - Page_Down: 'PageDown', - Home: 'Home', - End: 'End', - Up: 'ArrowUp', - Down: 'ArrowDown', - Left: 'ArrowLeft', - Right: 'ArrowRight', - Delete: 'Delete', - Insert: 'Insert', - KP_Add: 'NumpadAdd', - KP_Subtract: 'NumpadSubtract', - KP_Multiply: 'NumpadMultiply', - KP_Divide: 'NumpadDivide', - KP_Enter: 'NumpadEnter' -} - -export function parseKey(input: string): KeyChord { - const parts = splitKeyChord(input) - if (parts.length === 0) { - throw new RuntimeClientError('invalid_argument', 'key must not be empty') - } - - const keyPart = parts.at(-1)! - const modifiers = parts.slice(0, -1).map(parseModifier) - return { - key: parseBaseKey(keyPart), - modifiers: dedupeModifiers(modifiers) - } -} - -function splitKeyChord(input: string): string[] { - const trimmed = input.trim() - // Why: `+` is both the chord separator and a printable key users can press. - if (trimmed === '+') { - return ['+'] - } - if (trimmed.endsWith('+')) { - return [ - ...trimmed - .slice(0, -1) - .split('+') - .map((part) => part.trim()) - .filter(Boolean), - '+' - ] - } - return input - .split('+') - .map((part) => part.trim()) - .filter(Boolean) -} - -function parseModifier(input: string): string { - const platformModifier = PLATFORM_MODIFIER_NAMES[input.toLowerCase()] - if (platformModifier) { - return platformModifier() - } - const modifier = MODIFIER_NAMES[input.toLowerCase()] - if (!modifier) { - throw new RuntimeClientError('invalid_argument', `unknown modifier '${input}'`) - } - return modifier -} - -function parseBaseKey(input: string): string { - const mapped = KEY_NAMES[input] - if (mapped) { - return mapped - } - if (/^F([1-9]|1[0-9]|2[0-4])$/.test(input)) { - return input - } - const keypadDigit = input.match(/^KP_([0-9])$/) - if (keypadDigit) { - return `Numpad${keypadDigit[1]}` - } - if (isPrintableAscii(input)) { - return input - } - throw new RuntimeClientError('invalid_argument', `unknown key '${input}'`) -} - -function isPrintableAscii(input: string): boolean { - if (input.length !== 1) { - return false - } - const code = input.charCodeAt(0) - return code >= 0x20 && code <= 0x7e -} - -function dedupeModifiers(modifiers: string[]): string[] { - return [...new Set(modifiers)] -} diff --git a/src/main/computer/permissions.test.ts b/src/main/computer/permissions.test.ts deleted file mode 100644 index fd94f389b34..00000000000 --- a/src/main/computer/permissions.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { - notificationCtorMock, - notificationIsSupportedMock, - notificationOnMock, - notificationRemoveListenerMock, - notificationShowMock, - shellOpenExternalMock, - isTrustedAccessibilityClientMock -} = vi.hoisted(() => { - const notificationOnMock = vi.fn() - const notificationRemoveListenerMock = vi.fn() - const notificationShowMock = vi.fn() - const notificationCtorMock = vi.fn(function () { - return { - on: notificationOnMock, - removeListener: notificationRemoveListenerMock, - show: notificationShowMock - } - }) - return { - notificationCtorMock, - notificationIsSupportedMock: vi.fn(() => true), - notificationOnMock, - notificationRemoveListenerMock, - notificationShowMock, - shellOpenExternalMock: vi.fn(), - isTrustedAccessibilityClientMock: vi.fn(() => false) - } -}) - -vi.mock('electron', () => ({ - Notification: Object.assign(notificationCtorMock, { - isSupported: notificationIsSupportedMock - }), - shell: { - openExternal: shellOpenExternalMock - }, - systemPreferences: { - isTrustedAccessibilityClient: isTrustedAccessibilityClientMock - } -})) - -import { notifyPermissionRequired } from './permissions' - -describe('notifyPermissionRequired', () => { - const originalPlatform = process.platform - - beforeEach(() => { - vi.useFakeTimers() - vi.clearAllTimers() - notificationCtorMock.mockClear() - notificationIsSupportedMock.mockReset() - notificationIsSupportedMock.mockReturnValue(true) - notificationOnMock.mockClear() - notificationRemoveListenerMock.mockClear() - notificationShowMock.mockClear() - shellOpenExternalMock.mockClear() - Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) - }) - - afterEach(() => { - vi.useRealTimers() - Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) - }) - - function getNotificationEventHandler(eventName: string): (...args: unknown[]) => void { - const call = notificationOnMock.mock.calls.find((c: unknown[]) => c[0] === eventName) - if (!call) { - throw new Error(`Notification ${eventName} handler not registered`) - } - return call[1] as (...args: unknown[]) => void - } - - it('clears the retained notification fallback timer when the notification closes', () => { - notifyPermissionRequired('Enable accessibility') - - expect(notificationShowMock).toHaveBeenCalledTimes(1) - expect(vi.getTimerCount()).toBe(1) - - const closeHandler = getNotificationEventHandler('close') - closeHandler() - - expect(vi.getTimerCount()).toBe(0) - expect(notificationRemoveListenerMock).toHaveBeenCalledWith('close', closeHandler) - expect(notificationRemoveListenerMock).toHaveBeenCalledWith('click', expect.any(Function)) - }) - - it('opens macOS Accessibility settings on click after releasing notification state', () => { - Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) - notifyPermissionRequired('Enable accessibility') - - getNotificationEventHandler('click')() - - expect(shellOpenExternalMock).toHaveBeenCalledWith( - expect.stringContaining('Privacy_Accessibility') - ) - expect(vi.getTimerCount()).toBe(0) - }) - - it('clears retained notification state when native delivery fails', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - notifyPermissionRequired('Enable accessibility') - expect(vi.getTimerCount()).toBe(1) - - const failedHandler = getNotificationEventHandler('failed') - failedHandler({}, 'Application is not code signed') - - expect(warn).toHaveBeenCalledWith( - expect.stringContaining('Accessibility permission notification failed') - ) - expect(vi.getTimerCount()).toBe(0) - expect(notificationRemoveListenerMock).toHaveBeenCalledWith('failed', failedHandler) - } finally { - warn.mockRestore() - } - }) -}) diff --git a/src/main/computer/permissions.ts b/src/main/computer/permissions.ts deleted file mode 100644 index a98f01c3dbc..00000000000 --- a/src/main/computer/permissions.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Notification, shell } from 'electron' - -const ACCESSIBILITY_SETTINGS_URL = - 'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility' - -const activePermissionNotifications = new Set() - -/** Surface a notification through Orca's existing notification system (do not duplicate UI). */ -export function notifyPermissionRequired(instructions: string): void { - if (!Notification.isSupported()) { - return - } - - const notification = new Notification({ - title: 'Accessibility permission required', - body: instructions - }) - activePermissionNotifications.add(notification) - - let releaseTimer: ReturnType | null = null - let released = false - function release(): void { - if (released) { - return - } - released = true - activePermissionNotifications.delete(notification) - notification.removeListener('close', release) - notification.removeListener('click', onClick) - notification.removeListener('failed', onFailed) - if (releaseTimer) { - clearTimeout(releaseTimer) - releaseTimer = null - } - } - function onClick(): void { - release() - if (process.platform === 'darwin') { - void shell.openExternal(ACCESSIBILITY_SETTINGS_URL) - } - } - function onFailed(_event: unknown, error?: string): void { - // Why: Electron 42 reports macOS UNNotification delivery failures here, - // most commonly when a local validation build is not code-signed. - console.warn( - `[computer] Accessibility permission notification failed${error ? `: ${error}` : '.'}` - ) - release() - } - notification.on('close', release) - notification.on('click', onClick) - notification.on('failed', onFailed) - releaseTimer = setTimeout(release, 5 * 60 * 1000) - if (typeof releaseTimer.unref === 'function') { - releaseTimer.unref() - } - notification.show() -} diff --git a/src/main/daemon/binary-frame.test.ts b/src/main/daemon/binary-frame.test.ts deleted file mode 100644 index 81148f18a59..00000000000 --- a/src/main/daemon/binary-frame.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { encodeFrame, createFrameParser, FrameType, FRAME_HEADER_SIZE } from './binary-frame' - -describe('encodeFrame', () => { - it('encodes a data frame with correct header', () => { - const payload = Buffer.from('hello') - const frame = encodeFrame(FrameType.Data, payload) - - expect(frame.length).toBe(FRAME_HEADER_SIZE + payload.length) - expect(frame[0]).toBe(FrameType.Data) - expect(frame.readUInt32BE(1)).toBe(payload.length) - expect(frame.subarray(FRAME_HEADER_SIZE).toString()).toBe('hello') - }) - - it('encodes a resize frame', () => { - const payload = Buffer.from(JSON.stringify({ cols: 120, rows: 40 })) - const frame = encodeFrame(FrameType.Resize, payload) - - expect(frame[0]).toBe(FrameType.Resize) - expect(frame.readUInt32BE(1)).toBe(payload.length) - }) - - it('encodes an exit frame with exit code', () => { - const payload = Buffer.from(JSON.stringify({ code: 0 })) - const frame = encodeFrame(FrameType.Exit, payload) - - expect(frame[0]).toBe(FrameType.Exit) - const decoded = JSON.parse(frame.subarray(FRAME_HEADER_SIZE).toString()) - expect(decoded.code).toBe(0) - }) - - it('encodes empty payload', () => { - const frame = encodeFrame(FrameType.Kill, Buffer.alloc(0)) - - expect(frame.length).toBe(FRAME_HEADER_SIZE) - expect(frame[0]).toBe(FrameType.Kill) - expect(frame.readUInt32BE(1)).toBe(0) - }) - - it('throws on payload exceeding max size', () => { - const oversized = Buffer.alloc(1024 * 1024 + 1) - expect(() => encodeFrame(FrameType.Data, oversized)).toThrow() - }) -}) - -describe('createFrameParser', () => { - it('parses a single complete frame', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - const payload = Buffer.from('test data') - const frame = encodeFrame(FrameType.Data, payload) - parser.feed(frame) - - expect(onFrame).toHaveBeenCalledOnce() - expect(onFrame.mock.calls[0][0]).toBe(FrameType.Data) - expect(onFrame.mock.calls[0][1].toString()).toBe('test data') - }) - - it('parses multiple frames in one chunk', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - const frame1 = encodeFrame(FrameType.Data, Buffer.from('one')) - const frame2 = encodeFrame(FrameType.Data, Buffer.from('two')) - parser.feed(Buffer.concat([frame1, frame2])) - - expect(onFrame).toHaveBeenCalledTimes(2) - expect(onFrame.mock.calls[0][1].toString()).toBe('one') - expect(onFrame.mock.calls[1][1].toString()).toBe('two') - }) - - it('handles frame split across header boundary', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - const payload = Buffer.from('split test') - const frame = encodeFrame(FrameType.Data, payload) - - // Split in the middle of the header (3 bytes, then the rest) - parser.feed(frame.subarray(0, 3)) - expect(onFrame).not.toHaveBeenCalled() - - parser.feed(frame.subarray(3)) - expect(onFrame).toHaveBeenCalledOnce() - expect(onFrame.mock.calls[0][1].toString()).toBe('split test') - }) - - it('handles frame split in the middle of payload', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - const payload = Buffer.from('payload split') - const frame = encodeFrame(FrameType.Data, payload) - - // Split after header + 3 bytes of payload - const splitPoint = FRAME_HEADER_SIZE + 3 - parser.feed(frame.subarray(0, splitPoint)) - expect(onFrame).not.toHaveBeenCalled() - - parser.feed(frame.subarray(splitPoint)) - expect(onFrame).toHaveBeenCalledOnce() - expect(onFrame.mock.calls[0][1].toString()).toBe('payload split') - }) - - it('handles byte-by-byte feeding', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - const frame = encodeFrame(FrameType.Exit, Buffer.from('{"code":42}')) - - for (let i = 0; i < frame.length; i++) { - parser.feed(frame.subarray(i, i + 1)) - } - - expect(onFrame).toHaveBeenCalledOnce() - expect(onFrame.mock.calls[0][0]).toBe(FrameType.Exit) - expect(JSON.parse(onFrame.mock.calls[0][1].toString())).toEqual({ code: 42 }) - }) - - it('handles zero-length payload frame', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - const frame = encodeFrame(FrameType.Kill, Buffer.alloc(0)) - parser.feed(frame) - - expect(onFrame).toHaveBeenCalledOnce() - expect(onFrame.mock.calls[0][0]).toBe(FrameType.Kill) - expect(onFrame.mock.calls[0][1].length).toBe(0) - }) - - it('parses different frame types correctly', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - const frames = [ - encodeFrame(FrameType.Data, Buffer.from('data')), - encodeFrame(FrameType.Resize, Buffer.from('resize')), - encodeFrame(FrameType.Signal, Buffer.from('signal')) - ] - - parser.feed(Buffer.concat(frames)) - - expect(onFrame).toHaveBeenCalledTimes(3) - expect(onFrame.mock.calls[0][0]).toBe(FrameType.Data) - expect(onFrame.mock.calls[1][0]).toBe(FrameType.Resize) - expect(onFrame.mock.calls[2][0]).toBe(FrameType.Signal) - }) - - it('resets buffer state', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - // Feed partial frame then reset - const frame = encodeFrame(FrameType.Data, Buffer.from('lost')) - parser.feed(frame.subarray(0, 3)) - parser.reset() - - // Feed a new complete frame - const frame2 = encodeFrame(FrameType.Data, Buffer.from('fresh')) - parser.feed(frame2) - - expect(onFrame).toHaveBeenCalledOnce() - expect(onFrame.mock.calls[0][1].toString()).toBe('fresh') - }) -}) diff --git a/src/main/daemon/binary-frame.ts b/src/main/daemon/binary-frame.ts deleted file mode 100644 index e8c4fd6ae90..00000000000 --- a/src/main/daemon/binary-frame.ts +++ /dev/null @@ -1,56 +0,0 @@ -export { FrameType } from './types' -import type { FrameType } from './types' -import { FRAME_HEADER_SIZE, FRAME_MAX_PAYLOAD } from './types' - -export { FRAME_HEADER_SIZE } - -export function encodeFrame(type: FrameType, payload: Buffer): Buffer { - if (payload.length > FRAME_MAX_PAYLOAD) { - throw new Error(`Frame payload ${payload.length} exceeds max ${FRAME_MAX_PAYLOAD}`) - } - - const frame = Buffer.allocUnsafe(FRAME_HEADER_SIZE + payload.length) - frame[0] = type - frame.writeUInt32BE(payload.length, 1) - payload.copy(frame, FRAME_HEADER_SIZE) - return frame -} - -export type FrameParser = { - feed(chunk: Buffer): void - reset(): void -} - -export function createFrameParser( - onFrame: (type: FrameType, payload: Buffer) => void -): FrameParser { - let buffer: Buffer = Buffer.alloc(0) - - function parse(): void { - while (buffer.length >= FRAME_HEADER_SIZE) { - const payloadLength = buffer.readUInt32BE(1) - const totalLength = FRAME_HEADER_SIZE + payloadLength - - if (buffer.length < totalLength) { - break - } - - const type = buffer[0] as FrameType - const payload = buffer.subarray(FRAME_HEADER_SIZE, totalLength) - buffer = buffer.subarray(totalLength) - - onFrame(type, payload) - } - } - - return { - feed(chunk: Buffer): void { - buffer = buffer.length === 0 ? chunk : Buffer.concat([buffer, chunk]) - parse() - }, - - reset(): void { - buffer = Buffer.alloc(0) - } - } -} diff --git a/src/main/daemon/daemon-errors.test.ts b/src/main/daemon/daemon-errors.test.ts index d12ce645ee7..71374415772 100644 --- a/src/main/daemon/daemon-errors.test.ts +++ b/src/main/daemon/daemon-errors.test.ts @@ -2,8 +2,15 @@ import { describe, expect, it } from 'vitest' import { DaemonProtocolError, decodeDaemonResponseError, - SessionNotFoundError + isDaemonEndpointGoneError, + SessionNotFoundError, + TerminalHostGoneError } from './daemon-errors' +import { mapRuntimeError } from '../runtime/rpc/errors' + +function socketError(code: string, syscall: string): Error & { code: string; syscall: string } { + return Object.assign(new Error(`${syscall} ${code}`), { code, syscall }) +} describe('decodeDaemonResponseError', () => { it('types the exact legacy session-absence response', () => { @@ -18,3 +25,44 @@ describe('decodeDaemonResponseError', () => { ) }) }) + +describe('isDaemonEndpointGoneError', () => { + it('recognizes a missing Windows named pipe', () => { + const err = Object.assign( + new Error('connect ENOENT \\\\?\\pipe\\orca-terminal-host-v30-14cb7f94b511'), + { code: 'ENOENT', syscall: 'connect' } + ) + expect(isDaemonEndpointGoneError(err)).toBe(true) + }) + + it('recognizes a refused socket', () => { + expect(isDaemonEndpointGoneError(socketError('ECONNREFUSED', 'connect'))).toBe(true) + }) + + it('ignores a missing token file, which does not prove the endpoint is gone', () => { + expect(isDaemonEndpointGoneError(socketError('ENOENT', 'open'))).toBe(false) + }) + + it('ignores connect failures that are not proof of absence', () => { + for (const code of ['ETIMEDOUT', 'ECONNRESET', 'EPIPE', 'EACCES']) { + expect(isDaemonEndpointGoneError(socketError(code, 'connect'))).toBe(false) + } + }) + + it('ignores non-socket errors and non-objects', () => { + expect(isDaemonEndpointGoneError(new Error('Connection lost'))).toBe(false) + expect(isDaemonEndpointGoneError('connect ENOENT')).toBe(false) + expect(isDaemonEndpointGoneError(null)).toBe(false) + expect(isDaemonEndpointGoneError(undefined)).toBe(false) + }) + + it('keeps the host-gone marker across runtime RPC error mapping', () => { + const response = mapRuntimeError( + 'req-1', + { runtimeId: 'runtime-1' }, + new TerminalHostGoneError() + ) + + expect(response.error).toEqual({ code: 'runtime_error', message: 'terminal_host_gone' }) + }) +}) diff --git a/src/main/daemon/daemon-errors.ts b/src/main/daemon/daemon-errors.ts index bddb3690a4a..d5b7dc2418e 100644 --- a/src/main/daemon/daemon-errors.ts +++ b/src/main/daemon/daemon-errors.ts @@ -28,6 +28,24 @@ export class TerminalSessionOwnerUnverifiedError extends Error { } } +export class TerminalHostGoneError extends Error { + constructor() { + super('terminal_host_gone') + this.name = 'TerminalHostGoneError' + } +} + +// Connect ENOENT/ECONNREFUSED proves the endpoint is absent; open ENOENT can be a missing token file. +export function isDaemonEndpointGoneError(err: unknown): boolean { + const candidate = err as { code?: unknown; syscall?: unknown } | null + return ( + typeof candidate === 'object' && + candidate !== null && + candidate.syscall === 'connect' && + (candidate.code === 'ENOENT' || candidate.code === 'ECONNREFUSED') + ) +} + export function decodeDaemonResponseError(message: string): Error { const prefix = 'Session not found: ' return message.startsWith(prefix) diff --git a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts index 07f291a60ff..7ce08eaf83b 100644 --- a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts +++ b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts @@ -3,7 +3,7 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types' describe('foreground-confirmation daemon protocol', () => { it('rejects daemons from before the fresh-confirmation RPC', () => { - expect(PROTOCOL_VERSION).toBe(32) + expect(PROTOCOL_VERSION).toBe(33) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23) @@ -15,5 +15,6 @@ describe('foreground-confirmation daemon protocol', () => { expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(29) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(30) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(31) + expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(32) }) }) diff --git a/src/main/daemon/daemon-protocol-version.test.ts b/src/main/daemon/daemon-protocol-version.test.ts index 53ad0c06341..d47eecc9722 100644 --- a/src/main/daemon/daemon-protocol-version.test.ts +++ b/src/main/daemon/daemon-protocol-version.test.ts @@ -8,6 +8,7 @@ import { MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION, SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION, STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION, + WSL_POSIX_CWD_DAEMON_PROTOCOL_VERSION, PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION, supportsMode2031UnsubscribeFact @@ -15,7 +16,8 @@ import { describe('daemon protocol version', () => { it('ships bounded history transfer after the 2031-unsubscribe fact', () => { - expect(PROTOCOL_VERSION).toBe(32) + expect(PROTOCOL_VERSION).toBe(33) + expect(WSL_POSIX_CWD_DAEMON_PROTOCOL_VERSION).toBe(33) expect(SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION).toBe(32) expect(STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION).toBe(31) expect(HISTORY_SEED_TRANSFER_PROTOCOL_VERSION).toBe(30) @@ -25,7 +27,7 @@ describe('daemon protocol version', () => { expect(AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION).toBe(26) expect(AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION).toBe(26) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toEqual( - Array.from({ length: 31 }, (_, index) => index + 1) + Array.from({ length: 32 }, (_, index) => index + 1) ) }) diff --git a/src/main/daemon/daemon-protocol-version.ts b/src/main/daemon/daemon-protocol-version.ts index fee06b235f2..64c4d559670 100644 --- a/src/main/daemon/daemon-protocol-version.ts +++ b/src/main/daemon/daemon-protocol-version.ts @@ -1,6 +1,7 @@ // Why: daemons survive app updates, so wire behavior must be version-gated. -// v32 carries the corrected terminal snapshot serializer; older owners remain attachable. -export const PROTOCOL_VERSION = 32 +// v33 preserves selected-WSL POSIX cwd semantics; older owners remain attachable. +export const PROTOCOL_VERSION = 33 +export const WSL_POSIX_CWD_DAEMON_PROTOCOL_VERSION = 33 export const SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION = 32 export const STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION = 31 export const HISTORY_SEED_TRANSFER_PROTOCOL_VERSION = 30 @@ -26,7 +27,7 @@ export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24 export const MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION = 29 export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, - 28, 29, 30, 31 + 28, 29, 30, 31, 32 ] as const export function supportsPtyStartupIngress(protocolVersion: number): boolean { diff --git a/src/main/daemon/daemon-pty-provider.test.ts b/src/main/daemon/daemon-pty-provider.test.ts deleted file mode 100644 index 50800ffec89..00000000000 --- a/src/main/daemon/daemon-pty-provider.test.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { mkdtempSync, rmSync } from 'node:fs' -import { DaemonPtyProvider } from './daemon-pty-provider' -import { DaemonServer } from './daemon-server' -import type { SubprocessHandle } from './session' -import { getDaemonSocketPath } from './daemon-spawner' - -function createTestDir(): string { - return mkdtempSync(join(tmpdir(), 'daemon-provider-test-')) -} - -function createMockSubprocess(): SubprocessHandle & { - _simulateData: (data: string) => void - _simulateExit: (code: number) => void -} { - let onDataCb: ((data: string) => void) | null = null - let onExitCb: ((code: number) => void) | null = null - return { - pid: 77777, - getForegroundProcess: vi.fn(() => null), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), - forceKill: vi.fn(() => setTimeout(() => onExitCb?.(137), 5)), - signal: vi.fn(), - onData(cb) { - onDataCb = cb - }, - onExit(cb) { - onExitCb = cb - }, - dispose: vi.fn(), - _simulateData(data: string) { - onDataCb?.(data) - }, - _simulateExit(code: number) { - onExitCb?.(code) - } - } -} - -async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise { - const start = Date.now() - while (!predicate()) { - if (Date.now() - start > timeoutMs) { - throw new Error('waitFor timed out') - } - await new Promise((r) => setTimeout(r, 10)) - } -} - -describe('DaemonPtyProvider', () => { - let dir: string - let socketPath: string - let tokenPath: string - let server: DaemonServer - let provider: DaemonPtyProvider - let lastSubprocess: ReturnType - - beforeEach(async () => { - dir = createTestDir() - socketPath = getDaemonSocketPath(dir) - tokenPath = join(dir, 'test.token') - - server = new DaemonServer({ - socketPath, - tokenPath, - spawnSubprocess: () => { - lastSubprocess = createMockSubprocess() - return lastSubprocess - } - }) - await server.start() - - provider = new DaemonPtyProvider({ socketPath, tokenPath }) - }) - - afterEach(async () => { - await provider?.cleanup() - await server?.shutdown() - rmSync(dir, { recursive: true, force: true }) - }) - - describe('spawn', () => { - it('creates a session in the daemon and returns an id', async () => { - const result = await provider.spawn({ - cols: 80, - rows: 24, - sessionId: 'test-session' - }) - - expect(result.id).toBe('test-session') - }) - - it('spawns with cwd and env', async () => { - const result = await provider.spawn({ - cols: 80, - rows: 24, - cwd: '/home/user', - env: { MY_VAR: 'hello' }, - sessionId: 'session-with-env' - }) - - expect(result.id).toBe('session-with-env') - }) - }) - - describe('write', () => { - it('sends data to the daemon session', async () => { - await provider.spawn({ cols: 80, rows: 24, sessionId: 's1' }) - - // Should not throw - provider.write('s1', 'ls\n') - - // Give the fire-and-forget notify time to arrive - await new Promise((r) => setTimeout(r, 50)) - expect(lastSubprocess.write).toHaveBeenCalledWith('ls\n') - }) - }) - - describe('resize', () => { - it('resizes the daemon session', async () => { - await provider.spawn({ cols: 80, rows: 24, sessionId: 's1' }) - provider.resize('s1', 120, 40) - - await new Promise((r) => setTimeout(r, 50)) - expect(lastSubprocess.resize).toHaveBeenCalledWith(120, 40) - }) - }) - - describe('shutdown (kill session)', () => { - it('kills the session in the daemon', async () => { - await provider.spawn({ cols: 80, rows: 24, sessionId: 's1' }) - await provider.shutdown('s1', { immediate: false }) - - expect(lastSubprocess.kill).toHaveBeenCalled() - }) - }) - - describe('data events', () => { - it('delivers data events from the daemon to onData listeners', async () => { - const dataPayloads: { id: string; data: string }[] = [] - provider.onData((payload) => dataPayloads.push(payload)) - - await provider.spawn({ cols: 80, rows: 24, sessionId: 's1' }) - - // Simulate PTY output from subprocess - lastSubprocess._simulateData('hello from shell') - - await waitFor(() => dataPayloads.length > 0) - expect(dataPayloads[0]).toEqual({ id: 's1', data: 'hello from shell' }) - }) - }) - - describe('exit events', () => { - it('delivers exit events from the daemon to onExit listeners', async () => { - const exits: { id: string; code: number }[] = [] - provider.onExit((payload) => exits.push(payload)) - - await provider.spawn({ cols: 80, rows: 24, sessionId: 's1' }) - - lastSubprocess._simulateExit(42) - - await waitFor(() => exits.length > 0) - expect(exits[0]).toEqual({ id: 's1', code: 42, incarnationId: expect.any(String) }) - }) - }) - - describe('cleanup', () => { - it('disconnects from daemon without killing sessions', async () => { - await provider.spawn({ cols: 80, rows: 24, sessionId: 's1' }) - await provider.cleanup() - - // Session should still be alive in the daemon — verify by connecting a new provider - const provider2 = new DaemonPtyProvider({ socketPath, tokenPath }) - const result = await provider2.spawn({ cols: 80, rows: 24, sessionId: 's1' }) - - // Should reattach (not create new) since the session is alive - // The daemon's createOrAttach returns isNew=false for existing sessions - expect(result.id).toBe('s1') - await provider2.cleanup() - }) - }) - - describe('multiple sessions', () => { - it('handles multiple concurrent sessions', async () => { - const r1 = await provider.spawn({ cols: 80, rows: 24, sessionId: 'a' }) - const r2 = await provider.spawn({ cols: 80, rows: 24, sessionId: 'b' }) - const r3 = await provider.spawn({ cols: 80, rows: 24, sessionId: 'c' }) - - expect(r1.id).toBe('a') - expect(r2.id).toBe('b') - expect(r3.id).toBe('c') - }) - }) -}) diff --git a/src/main/daemon/daemon-pty-provider.ts b/src/main/daemon/daemon-pty-provider.ts deleted file mode 100644 index 2c75f9dcfb8..00000000000 --- a/src/main/daemon/daemon-pty-provider.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { DaemonClient } from './client' -import type { DaemonEvent, CreateOrAttachResult } from './types' -import type { PtyIncarnationId } from '../../shared/pty-incarnation' - -export type DaemonPtyProviderOptions = { - socketPath: string - tokenPath: string -} - -export type DaemonSpawnOptions = { - cols: number - rows: number - sessionId: string - cwd?: string - env?: Record - envToDelete?: string[] - command?: string -} - -export type DaemonSpawnResult = { - id: string - incarnationId?: PtyIncarnationId - isNew: boolean - pid: number | null -} - -export class DaemonPtyProvider { - private client: DaemonClient - private dataListeners: ((payload: { id: string; data: string }) => void)[] = [] - private exitListeners: ((payload: { - id: string - code: number - incarnationId?: PtyIncarnationId - }) => void)[] = [] - private removeEventListener: (() => void) | null = null - - constructor(opts: DaemonPtyProviderOptions) { - this.client = new DaemonClient({ - socketPath: opts.socketPath, - tokenPath: opts.tokenPath - }) - } - - async spawn(opts: DaemonSpawnOptions): Promise { - await this.client.ensureConnected() - this.setupEventRouting() - - const result = await this.client.request('createOrAttach', { - sessionId: opts.sessionId, - cols: opts.cols, - rows: opts.rows, - cwd: opts.cwd, - env: opts.env, - envToDelete: opts.envToDelete, - command: opts.command - }) - - return { - id: opts.sessionId, - ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), - isNew: result.isNew, - pid: result.pid - } - } - - write(id: string, data: string): void { - this.client.notify('write', { sessionId: id, data }) - } - - resize(id: string, cols: number, rows: number): void { - this.client.notify('resize', { sessionId: id, cols, rows }) - } - - async shutdown( - id: string, - opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } - ): Promise { - // Why: convert the absolute teardown deadline to a relative timeout only here, - // at the RPC leaf; undefined keeps the DaemonClient 30s default. - await this.client.request( - 'kill', - { sessionId: id, immediate: opts.immediate ?? false }, - opts.deadlineMs !== undefined ? Math.max(1, opts.deadlineMs - Date.now()) : undefined - ) - } - - onData(callback: (payload: { id: string; data: string }) => void): () => void { - this.dataListeners.push(callback) - return () => { - const idx = this.dataListeners.indexOf(callback) - if (idx !== -1) { - this.dataListeners.splice(idx, 1) - } - } - } - - onExit( - callback: (payload: { id: string; code: number; incarnationId?: PtyIncarnationId }) => void - ): () => void { - this.exitListeners.push(callback) - return () => { - const idx = this.exitListeners.indexOf(callback) - if (idx !== -1) { - this.exitListeners.splice(idx, 1) - } - } - } - - async cleanup(): Promise { - this.removeEventListener?.() - this.removeEventListener = null - this.client.disconnect() - } - - private setupEventRouting(): void { - if (this.removeEventListener) { - return - } - - this.removeEventListener = this.client.onEvent((raw) => { - const event = raw as DaemonEvent - if (event.type !== 'event') { - return - } - - if (event.event === 'data') { - for (const listener of this.dataListeners) { - listener({ id: event.sessionId, data: event.payload.data }) - } - } else if (event.event === 'exit') { - for (const listener of this.exitListeners) { - listener({ - id: event.sessionId, - code: event.payload.code, - ...(event.payload.incarnationId ? { incarnationId: event.payload.incarnationId } : {}) - }) - } - } - }) - } -} diff --git a/src/main/daemon/daemon-pty-router.test.ts b/src/main/daemon/daemon-pty-router.test.ts index 026e8430c28..41cc8016169 100644 --- a/src/main/daemon/daemon-pty-router.test.ts +++ b/src/main/daemon/daemon-pty-router.test.ts @@ -408,7 +408,7 @@ describe('DaemonPtyRouter', () => { expect(current.confirmForegroundProcess).toHaveBeenCalledWith('current-session') }) - it('preserves v30/v31 session owners and routes new sessions to v32', async () => { + it('preserves older session owners and routes new sessions to v33', async () => { const current = createAdapter('current', [], undefined, PROTOCOL_VERSION) const legacyV30 = createAdapter( 'v30', @@ -422,22 +422,27 @@ describe('DaemonPtyRouter', () => { undefined, STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION ) - const router = new DaemonPtyRouter({ current, legacy: [legacyV30, legacyV31] }) + const legacyV32 = createAdapter('v32', ['v32-session'], undefined, 32) + const router = new DaemonPtyRouter({ current, legacy: [legacyV30, legacyV31, legacyV32] }) await router.discoverLegacySessions() await router.spawn({ sessionId: 'v30-session', cols: 80, rows: 24 }) await router.spawn({ sessionId: 'v31-session', cols: 80, rows: 24 }) + await router.spawn({ sessionId: 'v32-session', cols: 80, rows: 24 }) const fresh = await router.spawn({ cols: 80, rows: 24 }) router.write('v30-session', 'old-v30\n') router.write('v31-session', 'old-v31\n') + router.write('v32-session', 'old-v32\n') router.write(fresh.id, 'new\n') expect(legacyV30.spawn).toHaveBeenCalledWith({ sessionId: 'v30-session', cols: 80, rows: 24 }) expect(legacyV31.spawn).toHaveBeenCalledWith({ sessionId: 'v31-session', cols: 80, rows: 24 }) + expect(legacyV32.spawn).toHaveBeenCalledWith({ sessionId: 'v32-session', cols: 80, rows: 24 }) expect(current.spawn).toHaveBeenCalledWith({ cols: 80, rows: 24 }) expect(legacyV30.write).toHaveBeenCalledWith('v30-session', 'old-v30\n') expect(legacyV31.write).toHaveBeenCalledWith('v31-session', 'old-v31\n') + expect(legacyV32.write).toHaveBeenCalledWith('v32-session', 'old-v32\n') expect(current.write).toHaveBeenCalledWith(fresh.id, 'new\n') }) diff --git a/src/main/daemon/node-pty-error-hints.test.ts b/src/main/daemon/node-pty-error-hints.test.ts index e8fb5a0ef8c..f43951f05a6 100644 --- a/src/main/daemon/node-pty-error-hints.test.ts +++ b/src/main/daemon/node-pty-error-hints.test.ts @@ -50,7 +50,7 @@ describe('node-pty diagnostic error hints', () => { it('hints local wrapped spawn errors from pty allocation failures', () => { const message = - 'Failed to spawn shell "/bin/zsh": node-pty: open_slave failed: EMFILE (errno 24, Too many open files) - slave=\'/dev/ttys003\' (shell: /bin/zsh, cwd: /tmp, arch: arm64, platform: darwin 25.0.0). If this persists, please file an issue.' + 'Failed to spawn shell "/bin/zsh": node-pty: open_slave failed: EMFILE (errno 24, Too many open files) - slave=\'/dev/ttys003\' (shell: /bin/zsh, cwd: /tmp, arch: arm64, platform: darwin 25.0.0, orca: 1.4.178). If this persists, please file an issue.' expect(addNodePtyRecoveryHint(message)).toBe(`${PTY_ALLOCATION_HINT} ${message}`) }) diff --git a/src/main/daemon/production-launcher.test.ts b/src/main/daemon/production-launcher.test.ts deleted file mode 100644 index 4404c5c2c99..00000000000 --- a/src/main/daemon/production-launcher.test.ts +++ /dev/null @@ -1,444 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { createProductionLauncher } from './production-launcher' -import { startDaemon, type DaemonHandle } from './daemon-main' -import { DaemonClient } from './client' -import type { SubprocessHandle } from './session' -import { getDaemonSocketPath } from './daemon-spawner' - -const { forkMock } = vi.hoisted(() => ({ - forkMock: vi.fn() -})) - -vi.mock('child_process', async () => { - const actual = await vi.importActual>('child_process') - return { ...actual, fork: forkMock } -}) - -function createTestDir(): string { - return mkdtempSync(join(tmpdir(), 'prod-launcher-test-')) -} - -function createMockSubprocess(): SubprocessHandle { - let onExitCb: ((code: number) => void) | null = null - return { - pid: 44444, - getForegroundProcess: vi.fn(() => null), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), - forceKill: vi.fn(), - signal: vi.fn(), - onData(_cb: (data: string) => void) {}, - onExit(cb: (code: number) => void) { - onExitCb = cb - }, - dispose: vi.fn() - } -} - -describe('createProductionLauncher', () => { - let dir: string - let handles: DaemonHandle[] - - beforeEach(() => { - dir = createTestDir() - handles = [] - }) - - afterEach(async () => { - for (const h of handles) { - await h.shutdown().catch(() => {}) - } - forkMock.mockReset() - rmSync(dir, { recursive: true, force: true }) - }) - - it('returns a launcher function', () => { - const launcher = createProductionLauncher({ - getDaemonEntryPath: () => '/fake/path.js', - getAppVersion: () => '1.2.3' - }) - expect(typeof launcher).toBe('function') - }) - - it('rejects either ownership argument without its pair before forking', async () => { - const launcher = createProductionLauncher({ - getDaemonEntryPath: () => '/fake/path.js', - getAppVersion: () => '1.2.3' - }) - - await expect( - launcher(socketPathFor(dir), tokenPathFor(dir), join(dir, 'daemon.pid')) - ).rejects.toThrow('provided together') - await expect( - launcher(socketPathFor(dir), tokenPathFor(dir), undefined, 'launch-a') - ).rejects.toThrow('provided together') - expect(forkMock).not.toHaveBeenCalled() - }) - - it('can be used with DaemonSpawner (in-process fallback)', async () => { - // Use in-process launcher for testing (same as DaemonSpawner tests) - const launcher = async (socketPath: string, tokenPath: string) => { - const handle = await startDaemon({ - socketPath, - tokenPath, - spawnSubprocess: () => createMockSubprocess() - }) - handles.push(handle) - return { shutdown: () => handle.shutdown() } - } - - const socketPath = getDaemonSocketPath(dir) - const tokenPath = join(dir, 'test.token') - const handle = await launcher(socketPath, tokenPath) - - const client = new DaemonClient({ socketPath, tokenPath }) - await client.ensureConnected() - expect(client.isConnected()).toBe(true) - client.disconnect() - - await handle.shutdown() - handles.pop() - }) - - it('removes startup child listeners after readiness', async () => { - const handlers: Record void)[]> = { - message: [], - error: [], - exit: [] - } - const child = { - pid: 12345, - killed: false, - on: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event]?.push(cb) - return child - }), - off: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] - return child - }), - kill: vi.fn(), - disconnect: vi.fn(), - unref: vi.fn() - } - forkMock.mockReturnValueOnce(child) - - const launcher = createProductionLauncher({ - getDaemonEntryPath: () => join(dir, 'daemon-entry.js'), - getAppVersion: () => '1.2.3' - }) - - const pidPath = join(dir, 'daemon.pid') - const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-a') - handlers.message[0]?.({ - type: 'ready', - startedAtMs: 123_456, - linuxStartTicks: '4242', - bootId: 'boot-a' - }) - const handle = await launch - - expect(handle.shutdown).toEqual(expect.any(Function)) - expect(handlers.message).toHaveLength(0) - expect(handlers.error).toHaveLength(0) - expect(handlers.exit).toHaveLength(0) - expect(child.disconnect).toHaveBeenCalled() - expect(child.unref).toHaveBeenCalled() - expect(forkMock).toHaveBeenCalledWith( - join(dir, 'daemon-entry.js'), - expect.arrayContaining([ - '--pid-record', - pidPath, - '--launch-nonce', - 'launch-a', - '--entry-path', - join(dir, 'daemon-entry.js'), - '--app-version', - '1.2.3' - ]), - expect.objectContaining({ stdio: ['ignore', 'ignore', 'ignore', 'ipc'] }) - ) - }) - - it('resolves shutdown only after observing child exit', async () => { - const handlers: Record void)[]> = { - message: [], - error: [], - exit: [] - } - const child = { - pid: 12345, - connected: true, - exitCode: null as number | null, - signalCode: null as NodeJS.Signals | null, - on: vi.fn((event: string, callback: (arg?: unknown) => void) => { - handlers[event]?.push(callback) - return child - }), - once: vi.fn((event: string, callback: (arg?: unknown) => void) => { - handlers[event]?.push(callback) - return child - }), - off: vi.fn((event: string, callback: (arg?: unknown) => void) => { - handlers[event] = handlers[event]?.filter((handler) => handler !== callback) ?? [] - return child - }), - kill: vi.fn((signal: NodeJS.Signals) => { - if (signal === 'SIGTERM') { - queueMicrotask(() => { - child.exitCode = 0 - for (const callback of handlers.exit.slice()) { - callback(0) - } - }) - } - return true - }), - disconnect: vi.fn(() => { - child.connected = false - }), - unref: vi.fn() - } - forkMock.mockReturnValueOnce(child) - const launcher = createProductionLauncher({ - getDaemonEntryPath: () => join(dir, 'daemon-entry.js'), - getAppVersion: () => '1.2.3' - }) - const pidPath = join(dir, 'daemon.pid') - writeFileSync(pidPath, JSON.stringify({ pid: 12345, launchNonce: 'launch-shutdown' })) - const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-shutdown') - handlers.message[0]?.({ type: 'ready', startedAtMs: 123_456 }) - const handle = await launch - - await expect(handle.shutdown()).resolves.toBeUndefined() - - expect(child.kill).toHaveBeenCalledWith('SIGTERM') - expect(child.exitCode).toBe(0) - expect(existsSync(pidPath)).toBe(false) - }) - - it('rejects shutdown and releases child handles when SIGKILL never produces exit', async () => { - vi.useFakeTimers() - try { - const handlers: Record void)[]> = { - message: [], - error: [], - exit: [] - } - const child = { - pid: 12345, - killed: false, - connected: false, - exitCode: null, - signalCode: null, - on: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event]?.push(cb) - return child - }), - once: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event]?.push(cb) - return child - }), - off: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] - return child - }), - kill: vi.fn(() => true), - disconnect: vi.fn(() => { - child.connected = false - }), - unref: vi.fn() - } - forkMock.mockReturnValueOnce(child) - - const launcher = createProductionLauncher({ - getDaemonEntryPath: () => join(dir, 'daemon-entry.js'), - getAppVersion: () => '1.2.3' - }) - - const launch = launcher(socketPathFor(dir), tokenPathFor(dir)) - handlers.message[0]?.({ type: 'ready', startedAtMs: 123_456 }) - const handle = await launch - - const shutdown = expect(handle.shutdown()).rejects.toThrow( - 'Daemon did not exit after SIGKILL' - ) - expect(handlers.exit).toHaveLength(1) - - await vi.advanceTimersByTimeAsync(6000) - await shutdown - - expect(child.kill).toHaveBeenNthCalledWith(1, 'SIGTERM') - expect(child.kill).toHaveBeenNthCalledWith(2, 'SIGKILL') - expect(handlers.exit).toHaveLength(0) - expect(child.unref).toHaveBeenCalledTimes(2) - } finally { - vi.useRealTimers() - } - }) - - it('preserves readiness and signaling failures while releasing startup IPC', async () => { - vi.useFakeTimers() - try { - const handlers: Record void)[]> = { - message: [], - error: [], - exit: [] - } - const signalError = Object.assign(new Error('permission denied'), { code: 'EACCES' }) - const child = { - pid: 12345, - connected: true, - exitCode: null, - signalCode: null, - on: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event]?.push(cb) - return child - }), - once: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event]?.push(cb) - return child - }), - off: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] - return child - }), - kill: vi.fn(() => { - throw signalError - }), - disconnect: vi.fn(() => { - child.connected = false - }), - unref: vi.fn() - } - forkMock.mockReturnValueOnce(child) - - const launcher = createProductionLauncher({ - getDaemonEntryPath: () => join(dir, 'daemon-entry.js'), - getAppVersion: () => '1.2.3' - }) - const launch = launcher(socketPathFor(dir), tokenPathFor(dir)) - handlers.message[0]?.({ type: 'ready' }) - - const error = await launch.catch((caught: unknown) => caught) - expect(error).toBeInstanceOf(AggregateError) - expect((error as AggregateError).errors).toEqual([ - expect.objectContaining({ message: 'Daemon readiness identity is incomplete' }), - signalError - ]) - expect(child.disconnect).toHaveBeenCalledOnce() - expect(child.unref).toHaveBeenCalledOnce() - expect(handlers.message).toHaveLength(0) - expect(handlers.error).toHaveLength(0) - expect(handlers.exit).toHaveLength(0) - } finally { - vi.useRealTimers() - } - }) - - it('preserves exact PID ownership when child exit cannot be confirmed', async () => { - const handlers: Record void)[]> = { - message: [], - error: [], - exit: [] - } - const signalError = Object.assign(new Error('signal blocked'), { code: 'EPERM' }) - const child = { - pid: 12345, - connected: true, - exitCode: null, - signalCode: null, - on: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event]?.push(cb) - return child - }), - once: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event]?.push(cb) - return child - }), - off: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] - return child - }), - kill: vi.fn(() => { - throw signalError - }), - disconnect: vi.fn(() => { - child.connected = false - }), - unref: vi.fn() - } - forkMock.mockReturnValueOnce(child) - const launcher = createProductionLauncher({ - getDaemonEntryPath: () => join(dir, 'daemon-entry.js'), - getAppVersion: () => '1.2.3' - }) - const pidPath = join(dir, 'daemon.pid') - writeFileSync(pidPath, JSON.stringify({ pid: 12345, launchNonce: 'launch-b' })) - - const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-b') - handlers.message[0]?.({ type: 'ready' }) - - const error = await launch.catch((caught: unknown) => caught) - expect(error).toBeInstanceOf(AggregateError) - expect((error as AggregateError).errors).toEqual([ - expect.objectContaining({ message: 'Daemon readiness identity is incomplete' }), - signalError - ]) - expect(child.disconnect).toHaveBeenCalledOnce() - expect(child.unref).toHaveBeenCalledOnce() - expect(existsSync(pidPath)).toBe(true) - }) - - it('removes exact child-side PID ownership after a pre-ready exit', async () => { - const handlers: Record void)[]> = { - message: [], - error: [], - exit: [] - } - const child = { - pid: 12345, - connected: true, - exitCode: null as number | null, - signalCode: null as NodeJS.Signals | null, - on: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event]?.push(cb) - return child - }), - off: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] - return child - }), - disconnect: vi.fn(() => { - child.connected = false - }), - unref: vi.fn() - } - forkMock.mockReturnValueOnce(child) - const pidPath = join(dir, 'daemon.pid') - writeFileSync(pidPath, JSON.stringify({ pid: 12345, launchNonce: 'launch-c' })) - const launcher = createProductionLauncher({ - getDaemonEntryPath: () => join(dir, 'daemon-entry.js'), - getAppVersion: () => '1.2.3' - }) - - const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-c') - child.exitCode = 1 - handlers.exit[0]?.(1) - - await expect(launch).rejects.toThrow('Daemon process exited prematurely with code 1') - expect(existsSync(pidPath)).toBe(false) - }) -}) - -function socketPathFor(dir: string): string { - return join(dir, 'test.sock') -} - -function tokenPathFor(dir: string): string { - return join(dir, 'test.token') -} diff --git a/src/main/daemon/production-launcher.ts b/src/main/daemon/production-launcher.ts deleted file mode 100644 index 342ff6e94ba..00000000000 --- a/src/main/daemon/production-launcher.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { fork, type ChildProcess } from 'node:child_process' -import { - unlinkOwnedDaemonPidFile, - type DaemonLauncher, - type DaemonProcessHandle -} from './daemon-spawner' -import { parseDaemonReadyIdentity, type DaemonReadyIdentity } from './daemon-ready-identity' - -const READY_TIMEOUT_MS = 10_000 - -export type ProductionLauncherOptions = { - getDaemonEntryPath: () => string - getAppVersion: () => string -} - -export function createProductionLauncher(opts: ProductionLauncherOptions): DaemonLauncher { - return async ( - socketPath: string, - tokenPath: string, - pidPath?: string, - launchNonce?: string - ): Promise => { - if ((pidPath === undefined) !== (launchNonce === undefined)) { - // Why: partial ownership metadata would launch a v24 daemon that cannot - // prove which PID record it may remove during self-retirement. - throw new Error('Daemon PID path and launch nonce must be provided together') - } - const entryPath = opts.getDaemonEntryPath() - - const appVersion = opts.getAppVersion() - const child = fork( - entryPath, - [ - '--socket', - socketPath, - '--token', - tokenPath, - ...(pidPath && launchNonce - ? [ - '--pid-record', - pidPath, - '--launch-nonce', - launchNonce, - '--entry-path', - entryPath, - '--app-version', - appVersion - ] - : []) - ], - { - // Why: detached daemon output is not consumed; ignored streams cannot - // keep Electron alive after the child and IPC channel are unreferenced. - stdio: ['ignore', 'ignore', 'ignore', 'ipc'], - detached: true, - env: { ...process.env }, - ...(process.platform === 'win32' ? { windowsHide: true } : {}) - } - ) - - try { - await waitForReady(child) - } catch (error) { - return rejectAfterChildCleanup(child, error, pidPath, launchNonce) - } - if (!Number.isSafeInteger(child.pid) || (child.pid as number) <= 0) { - return rejectAfterChildCleanup( - child, - new Error('Daemon readiness identity is incomplete'), - pidPath, - launchNonce - ) - } - - // Unref so the Electron process can exit without waiting for the daemon - child.unref() - child.disconnect() - - return { - shutdown: async () => { - await shutdownChild(child) - unlinkLaunchedDaemonPidFile(child, pidPath, launchNonce) - } - } - } -} - -function waitForReady(child: ChildProcess): Promise { - return new Promise((resolve, reject) => { - let timeout: ReturnType | undefined - let settled = false - function cleanupStartupListeners(): void { - if (timeout) { - clearTimeout(timeout) - } - child.off('message', onMessage) - child.off('error', onError) - child.off('exit', onExit) - } - function fail(error: Error): void { - if (settled) { - return - } - settled = true - cleanupStartupListeners() - reject(error) - } - function onMessage(msg: unknown): void { - if (msg && typeof msg === 'object' && (msg as Record).type === 'ready') { - if (settled) { - return - } - const readyIdentity = parseDaemonReadyIdentity(msg) - if (!readyIdentity) { - fail(new Error('Daemon readiness identity is incomplete')) - return - } - settled = true - // Why: the daemon is detached after readiness, so startup listeners - // must not keep the child process closure alive for the daemon lifetime. - cleanupStartupListeners() - resolve(readyIdentity) - } - } - function onError(err: Error): void { - fail(new Error(`Daemon process error: ${err.message}`)) - } - function onExit(code: number | null): void { - fail(new Error(`Daemon process exited prematurely with code ${code}`)) - } - - timeout = setTimeout(() => { - fail(new Error('Daemon failed to signal readiness within timeout')) - }, READY_TIMEOUT_MS) - - child.on('message', onMessage) - child.on('error', onError) - child.on('exit', onExit) - }) -} - -async function shutdownChild(child: ChildProcess): Promise { - try { - if ( - (child.exitCode !== null && child.exitCode !== undefined) || - (child.signalCode !== null && child.signalCode !== undefined) - ) { - return - } - await new Promise((resolve, reject) => { - let settled = false - let timeout: ReturnType - let forceTimeout: ReturnType | undefined - function finish(error?: unknown): void { - if (settled) { - return - } - settled = true - clearTimeout(timeout) - if (forceTimeout) { - clearTimeout(forceTimeout) - } - child.off('exit', onExit) - if (error) { - reject(error) - } else { - resolve() - } - } - - function onExit(): void { - finish() - } - - timeout = setTimeout(() => { - try { - if (child.kill('SIGKILL') === false) { - finish(new Error('Failed to deliver SIGKILL to daemon')) - return - } - } catch (error) { - finish(isNoSuchProcessError(error) ? undefined : error) - return - } - // Why: signal delivery is not process exit; keep the listener for one - // bounded interval before releasing launcher-owned handles. - if (!settled) { - forceTimeout = setTimeout( - () => finish(new Error('Daemon did not exit after SIGKILL')), - 1000 - ) - } - }, 5000) - - child.once('exit', onExit) - try { - if (child.kill('SIGTERM') === false) { - finish(new Error('Failed to deliver SIGTERM to daemon')) - } - } catch (error) { - finish(isNoSuchProcessError(error) ? undefined : error) - } - }) - } finally { - if (child.connected) { - child.disconnect() - } - child.unref() - } -} - -async function rejectAfterChildCleanup( - child: ChildProcess, - launchError: unknown, - pidPath?: string, - launchNonce?: string -): Promise { - try { - await shutdownChild(child) - } catch (cleanupError) { - throw new AggregateError( - [launchError, cleanupError], - 'Daemon launch and child cleanup both failed' - ) - } - unlinkLaunchedDaemonPidFile(child, pidPath, launchNonce) - throw launchError -} - -function unlinkLaunchedDaemonPidFile( - child: ChildProcess, - pidPath?: string, - launchNonce?: string -): void { - if (pidPath && launchNonce && Number.isSafeInteger(child.pid) && (child.pid as number) > 0) { - unlinkOwnedDaemonPidFile(pidPath, child.pid as number, launchNonce) - } -} - -function isNoSuchProcessError(error: unknown): boolean { - return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ESRCH' -} diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 9df17412c57..5322bd6294b 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -2613,6 +2613,8 @@ describe('createPtySubprocess', () => { spawnMock.mockImplementation(() => { throw new Error('File not found: ') }) + const previousVersion = process.env.ORCA_APP_VERSION + process.env.ORCA_APP_VERSION = '1.4.178-test' try { expect(() => @@ -2623,9 +2625,14 @@ describe('createPtySubprocess', () => { shellOverride: 'not-a-real-shell.exe' }) ).toThrow( - /Daemon failed to spawn shell "not-a-real-shell\.exe" with cwd ".+": File not found:/ + /Daemon failed to spawn shell "not-a-real-shell\.exe" with cwd ".+": File not found:.*orca: 1\.4\.178-test/ ) } finally { + if (previousVersion === undefined) { + delete process.env.ORCA_APP_VERSION + } else { + process.env.ORCA_APP_VERSION = previousVersion + } if (platform) { Object.defineProperty(process, 'platform', platform) } @@ -2958,36 +2965,39 @@ describe('createPtySubprocess', () => { expect(spawnCall[2].env.WSLENV ?? '').not.toContain(POWERLEVEL10K_WIZARD_DISABLE_ENV) }) - it('keeps daemon WSL split panes in their distro when cwd is already POSIX', () => { - const proc = mockPtyProcess() - spawnMock.mockReturnValue(proc) - const platform = Object.getOwnPropertyDescriptor(process, 'platform') + it.each(['/home/jin/repo/subdir', '/a', '/c'])( + 'keeps daemon WSL split panes in their distro when cwd is POSIX (%s)', + (cwd) => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { value: 'win32' }) + Object.defineProperty(process, 'platform', { value: 'win32' }) - try { - createPtySubprocess({ - sessionId: 'repo::\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo@@deadbeef', - cols: 80, - rows: 24, - cwd: '/home/jin/repo/subdir', - shellOverride: 'wsl.exe' - }) - } finally { - if (platform) { - Object.defineProperty(process, 'platform', platform) + try { + createPtySubprocess({ + sessionId: 'repo::\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo@@deadbeef', + cols: 80, + rows: 24, + cwd, + shellOverride: 'wsl.exe' + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } } - } - expect(validateWorkingDirectoryMock).toHaveBeenCalledWith( - '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo\\subdir' - ) - expect(spawnMock).toHaveBeenCalledWith( - 'wsl.exe', - ['-d', 'Ubuntu', '--', 'sh', '-c', expect.stringContaining("cd '/home/jin/repo/subdir'")], - expect.objectContaining({ cwd: expect.any(String) }) - ) - }) + expect(validateWorkingDirectoryMock).toHaveBeenCalledWith( + `\\\\wsl.localhost\\Ubuntu${cwd.replaceAll('/', '\\')}` + ) + expect(spawnMock).toHaveBeenCalledWith( + 'wsl.exe', + ['-d', 'Ubuntu', '--', 'sh', '-c', expect.stringContaining(`cd '${cwd}'`)], + expect.objectContaining({ cwd: expect.any(String) }) + ) + } + ) // Why: node-pty's UnixTerminal.destroy() registers _socket.once('close', () => // this.kill('SIGHUP')), and the socket 'close' event can fire concurrently diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 6fe39f57fba..7566b81f6fe 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -1,6 +1,7 @@ /* eslint-disable max-lines -- Why: daemon PTY spawning must keep platform launch setup, preflight, and lifecycle guards in one execution path. */ import * as pty from 'node-pty' import { statSync } from 'node:fs' +import { release } from 'node:os' import { delimiter, win32 as pathWin32 } from 'node:path' import type { SubprocessHandle } from './session' import { DaemonProtocolError } from './types' @@ -246,16 +247,25 @@ function removeInheritedElectronRunAsNode(env: Record): void { delete env.ELECTRON_RUN_AS_NODE } +function daemonEnvironmentDiagSuffix(): string { + const orca = process.env.ORCA_APP_VERSION?.trim() || '0.0.0-dev' + const systemVersion = + (process as NodeJS.Process & { getSystemVersion?: () => string }).getSystemVersion?.() || + release() + const platform = `${process.platform} ${systemVersion}` + return ` (orca: ${orca}, arch: ${process.arch}, platform: ${platform})` +} + /** * Formats a daemon preflight failure with the same ENOENT details node-pty exposes. */ function formatMissingDaemonPathError(kind: 'helper' | 'cwd', path: string): DaemonProtocolError { const detailName = kind === 'helper' ? 'helper' : 'cwd' const step = kind === 'helper' ? 'posix_spawn' : 'daemon_cwd' + const missingTarget = kind === 'helper' ? 'node-pty install' : 'working directory' + const diag = daemonEnvironmentDiagSuffix() return new DaemonProtocolError( - `Daemon's ${kind === 'helper' ? 'node-pty install' : 'working directory'} is gone ` + - `(worktree deleted?). Restart Orca. node-pty: ${step} failed: ENOENT ` + - `(errno 2, No such file or directory) - ${detailName}='${path}'` + `Daemon's ${missingTarget} is gone (worktree deleted?). Restart Orca. node-pty: ${step} failed: ENOENT (errno 2, No such file or directory) - ${detailName}='${path}'${diag}` ) } @@ -399,8 +409,9 @@ function preflightPosixPtySpawnEnvironment(validationCwd: string): void { */ function formatPtySpawnError(err: unknown, shellPath: string, spawnCwd: string): Error { const message = err instanceof Error ? err.message : String(err) + const diag = daemonEnvironmentDiagSuffix() const formatted = new DaemonProtocolError( - `Daemon failed to spawn shell "${shellPath}" with cwd "${spawnCwd}": ${message}` + `Daemon failed to spawn shell "${shellPath}" with cwd "${spawnCwd}": ${message}${diag}` ) if (err instanceof Error && err.stack) { formatted.stack = err.stack diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 865572ea0b1..1f067885467 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -383,23 +383,6 @@ export type DaemonSessionInfo = SessionInfo & { // existing importers keep one types entry point. export * from './daemon-stream-events' -// ─── Binary Frame Protocol (Daemon ↔ PTY Subprocess) ──────────────── -// -// 5-byte header: [type:1][length:4 big-endian] -// Followed by `length` bytes of payload. - -export const enum FrameType { - Data = 0x01, - Resize = 0x02, - Exit = 0x03, - Error = 0x04, - Kill = 0x05, - Signal = 0x06 -} - -export const FRAME_HEADER_SIZE = 5 -export const FRAME_MAX_PAYLOAD = 1024 * 1024 // 1MB - // ─── Notify prefix ────────────────────────────────────────────────── // Requests with IDs starting with this prefix are fire-and-forget: // the daemon processes them but does not send a response. diff --git a/src/main/emulator/android/scrcpy-control-protocol.test.ts b/src/main/emulator/android/scrcpy-control-protocol.test.ts deleted file mode 100644 index 10e0e7a842a..00000000000 --- a/src/main/emulator/android/scrcpy-control-protocol.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - SCRCPY_CONTROL_MSG_TYPE, - encodeBackOrScreenOn, - encodeInjectKeycode, - encodeInjectText, - encodeInjectTouchEvent -} from './scrcpy-control-protocol' - -describe('SCRCPY_CONTROL_MSG_TYPE', () => { - it('pins the v2.4 control message type codes', () => { - expect(SCRCPY_CONTROL_MSG_TYPE.INJECT_KEYCODE).toBe(0) - expect(SCRCPY_CONTROL_MSG_TYPE.INJECT_TEXT).toBe(1) - expect(SCRCPY_CONTROL_MSG_TYPE.INJECT_TOUCH_EVENT).toBe(2) - expect(SCRCPY_CONTROL_MSG_TYPE.INJECT_SCROLL_EVENT).toBe(3) - expect(SCRCPY_CONTROL_MSG_TYPE.BACK_OR_SCREEN_ON).toBe(4) - }) -}) - -describe('encodeInjectTouchEvent', () => { - it('encodes a touch-down event with the full 32-byte field layout', () => { - const buf = encodeInjectTouchEvent({ - action: 'down', - pointerId: 0x1122334455667788n, - x: 100, - y: 200, - screenWidth: 1080, - screenHeight: 1920 - }) - - expect(buf.length).toBe(32) - expect(buf.readUInt8(0)).toBe(SCRCPY_CONTROL_MSG_TYPE.INJECT_TOUCH_EVENT) - expect(buf.readUInt8(0)).toBe(2) - expect(buf.readUInt8(1)).toBe(0) // AMOTION_EVENT_ACTION_DOWN - expect(buf.readBigUInt64BE(2)).toBe(0x1122334455667788n) - expect(buf.readInt32BE(10)).toBe(100) - expect(buf.readInt32BE(14)).toBe(200) - expect(buf.readUInt16BE(18)).toBe(1080) - expect(buf.readUInt16BE(20)).toBe(1920) - expect(buf.readUInt16BE(22)).toBe(0xffff) // default pressure 1.0 for down - expect(buf.readUInt32BE(24)).toBe(1) // AMOTION_EVENT_BUTTON_PRIMARY - expect(buf.readUInt32BE(28)).toBe(1) // buttons set while pressed - }) - - it('encodes a move event with action code 2 and full default pressure', () => { - const buf = encodeInjectTouchEvent({ - action: 'move', - pointerId: 1n, - x: 5, - y: 6, - screenWidth: 720, - screenHeight: 1280 - }) - - expect(buf.length).toBe(32) - expect(buf.readUInt8(1)).toBe(2) // AMOTION_EVENT_ACTION_MOVE - expect(buf.readUInt16BE(22)).toBe(0xffff) - expect(buf.readUInt32BE(28)).toBe(1) - }) - - it('encodes an up event with zero default pressure and cleared buttons', () => { - const buf = encodeInjectTouchEvent({ - action: 'up', - pointerId: 1n, - x: 5, - y: 6, - screenWidth: 720, - screenHeight: 1280 - }) - - expect(buf.length).toBe(32) - expect(buf.readUInt8(1)).toBe(1) // AMOTION_EVENT_ACTION_UP - expect(buf.readUInt16BE(22)).toBe(0) // default pressure 0 on up - expect(buf.readUInt32BE(24)).toBe(1) // actionButton stays primary - expect(buf.readUInt32BE(28)).toBe(0) // buttons cleared on up - }) - - it('writes negative coordinates as signed int32', () => { - const buf = encodeInjectTouchEvent({ - action: 'move', - pointerId: 1n, - x: -3, - y: -7, - screenWidth: 1, - screenHeight: 1 - }) - - expect(buf.readInt32BE(10)).toBe(-3) - expect(buf.readInt32BE(14)).toBe(-7) - }) - - it('encodes explicit fractional pressure as fixed-point u16', () => { - const buf = encodeInjectTouchEvent({ - action: 'down', - pointerId: 1n, - x: 0, - y: 0, - screenWidth: 1, - screenHeight: 1, - pressure: 0.5 - }) - - expect(buf.readUInt16BE(22)).toBe(Math.round(0.5 * 0xffff)) - }) - - it('clamps pressure above 1 and below 0', () => { - const high = encodeInjectTouchEvent({ - action: 'down', - pointerId: 1n, - x: 0, - y: 0, - screenWidth: 1, - screenHeight: 1, - pressure: 2 - }) - expect(high.readUInt16BE(22)).toBe(0xffff) - - const low = encodeInjectTouchEvent({ - action: 'down', - pointerId: 1n, - x: 0, - y: 0, - screenWidth: 1, - screenHeight: 1, - pressure: -1 - }) - expect(low.readUInt16BE(22)).toBe(0) - }) -}) - -describe('encodeInjectKeycode', () => { - it('encodes a keycode-down event (14 bytes) with round-tripped fields', () => { - const buf = encodeInjectKeycode({ - action: 'down', - keycode: 66, // AKEYCODE_ENTER - repeat: 3, - metaState: 0x1000 - }) - - expect(buf.length).toBe(14) - expect(buf.readUInt8(0)).toBe(SCRCPY_CONTROL_MSG_TYPE.INJECT_KEYCODE) - expect(buf.readUInt8(0)).toBe(0) - expect(buf.readUInt8(1)).toBe(0) // AKEY_EVENT_ACTION_DOWN - expect(buf.readInt32BE(2)).toBe(66) - expect(buf.readInt32BE(6)).toBe(3) - expect(buf.readInt32BE(10)).toBe(0x1000) - }) - - it('encodes a keycode-up event and applies repeat/metaState defaults', () => { - const buf = encodeInjectKeycode({ action: 'up', keycode: 4 }) // AKEYCODE_BACK - - expect(buf.length).toBe(14) - expect(buf.readUInt8(1)).toBe(1) // AKEY_EVENT_ACTION_UP - expect(buf.readInt32BE(2)).toBe(4) - expect(buf.readInt32BE(6)).toBe(0) // default repeat - expect(buf.readInt32BE(10)).toBe(0) // default metaState - }) -}) - -describe('encodeInjectText', () => { - it('encodes the type byte, UTF-8 byte length header, and payload', () => { - const buf = encodeInjectText('hello') - - expect(buf.readUInt8(0)).toBe(SCRCPY_CONTROL_MSG_TYPE.INJECT_TEXT) - expect(buf.readUInt8(0)).toBe(1) - expect(buf.readUInt32BE(1)).toBe(5) - expect(buf.subarray(5).toString('utf8')).toBe('hello') - expect(buf.length).toBe(5 + 5) - }) - - it('uses UTF-8 byte length (not string length) for multi-byte characters', () => { - const text = 'é' // one JS char, two UTF-8 bytes - const buf = encodeInjectText(text) - - expect(text.length).toBe(1) - expect(buf.readUInt32BE(1)).toBe(2) - expect(buf.length).toBe(5 + 2) - expect(buf.subarray(5).toString('utf8')).toBe('é') - }) - - it('encodes an empty string as a 5-byte header with zero length', () => { - const buf = encodeInjectText('') - - expect(buf.length).toBe(5) - expect(buf.readUInt32BE(1)).toBe(0) - }) -}) - -describe('encodeBackOrScreenOn', () => { - it('encodes a 2-byte down message', () => { - const buf = encodeBackOrScreenOn('down') - - expect(buf.length).toBe(2) - expect(buf.readUInt8(0)).toBe(SCRCPY_CONTROL_MSG_TYPE.BACK_OR_SCREEN_ON) - expect(buf.readUInt8(0)).toBe(4) - expect(buf.readUInt8(1)).toBe(0) // AKEY_EVENT_ACTION_DOWN - }) - - it('encodes a 2-byte up message', () => { - const buf = encodeBackOrScreenOn('up') - - expect(buf.length).toBe(2) - expect(buf.readUInt8(1)).toBe(1) // AKEY_EVENT_ACTION_UP - }) -}) diff --git a/src/main/emulator/android/scrcpy-control-protocol.ts b/src/main/emulator/android/scrcpy-control-protocol.ts deleted file mode 100644 index 4e35148948c..00000000000 --- a/src/main/emulator/android/scrcpy-control-protocol.ts +++ /dev/null @@ -1,91 +0,0 @@ -// scrcpy server control protocol v2.4 -// Byte-exact encoders for the scrcpy control socket. Every multi-byte field is -// big-endian to match the server's DataInputStream reads. - -export const SCRCPY_CONTROL_MSG_TYPE = { - INJECT_KEYCODE: 0, - INJECT_TEXT: 1, - INJECT_TOUCH_EVENT: 2, - INJECT_SCROLL_EVENT: 3, - BACK_OR_SCREEN_ON: 4 -} as const - -export type ScrcpyKeyAction = 'down' | 'up' -export type ScrcpyTouchAction = 'down' | 'up' | 'move' - -// AKEY_EVENT_ACTION_* / AMOTION_EVENT_ACTION_* codes the server forwards to Android. -const KEY_ACTION_CODE: Record = { down: 0, up: 1 } -const TOUCH_ACTION_CODE: Record = { down: 0, up: 1, move: 2 } - -// AMOTION_EVENT_BUTTON_PRIMARY — scrcpy reports the primary button for touches. -const BUTTON_PRIMARY = 1 -// Pressure is a u16 fixed-point value where 0xFFFF represents 1.0. -const PRESSURE_MAX = 0xffff - -function encodePressure(pressure: number): number { - const fixed = Math.round(pressure * PRESSURE_MAX) - if (fixed < 0) { - return 0 - } - if (fixed > PRESSURE_MAX) { - return PRESSURE_MAX - } - return fixed -} - -export function encodeInjectTouchEvent(p: { - action: ScrcpyTouchAction - pointerId: bigint - x: number - y: number - screenWidth: number - screenHeight: number - pressure?: number -}): Buffer { - // A finger that lifts has no pressure; otherwise full pressure unless overridden. - const pressure = p.pressure ?? (p.action === 'up' ? 0 : 1) - const buf = Buffer.alloc(32) - buf.writeUInt8(SCRCPY_CONTROL_MSG_TYPE.INJECT_TOUCH_EVENT, 0) - buf.writeUInt8(TOUCH_ACTION_CODE[p.action], 1) - buf.writeBigUInt64BE(p.pointerId, 2) - buf.writeInt32BE(p.x, 10) - buf.writeInt32BE(p.y, 14) - buf.writeUInt16BE(p.screenWidth, 18) - buf.writeUInt16BE(p.screenHeight, 20) - buf.writeUInt16BE(encodePressure(pressure), 22) - buf.writeUInt32BE(BUTTON_PRIMARY, 24) - buf.writeUInt32BE(p.action === 'up' ? 0 : BUTTON_PRIMARY, 28) - return buf -} - -export function encodeInjectKeycode(p: { - action: ScrcpyKeyAction - keycode: number - repeat?: number - metaState?: number -}): Buffer { - const buf = Buffer.alloc(14) - buf.writeUInt8(SCRCPY_CONTROL_MSG_TYPE.INJECT_KEYCODE, 0) - buf.writeUInt8(KEY_ACTION_CODE[p.action], 1) - buf.writeInt32BE(p.keycode, 2) - buf.writeInt32BE(p.repeat ?? 0, 6) - buf.writeInt32BE(p.metaState ?? 0, 10) - return buf -} - -export function encodeInjectText(text: string): Buffer { - // The length prefix is the UTF-8 byte count, which can exceed text.length. - const payload = Buffer.from(text, 'utf8') - const buf = Buffer.alloc(5 + payload.length) - buf.writeUInt8(SCRCPY_CONTROL_MSG_TYPE.INJECT_TEXT, 0) - buf.writeUInt32BE(payload.length, 1) - payload.copy(buf, 5) - return buf -} - -export function encodeBackOrScreenOn(action: ScrcpyKeyAction): Buffer { - const buf = Buffer.alloc(2) - buf.writeUInt8(SCRCPY_CONTROL_MSG_TYPE.BACK_OR_SCREEN_ON, 0) - buf.writeUInt8(KEY_ACTION_CODE[action], 1) - return buf -} diff --git a/src/main/emulator/android/scrcpy-stream-session.ts b/src/main/emulator/android/scrcpy-stream-session.ts index b7cec131e2c..0333e9ee5db 100644 --- a/src/main/emulator/android/scrcpy-stream-session.ts +++ b/src/main/emulator/android/scrcpy-stream-session.ts @@ -22,8 +22,7 @@ import { emulatorProbe, emulatorProbeError } from '../emulator-probe' // A live scrcpy session validated against a real emulator. The connection // handshake (dummy byte, 64-byte device name, codec meta) and the server option -// set are coupled to the pinned scrcpy server version; the pure framing -// (scrcpy-video-frame-parser) and control encoding (scrcpy-control-protocol) are +// set are coupled to the pinned scrcpy server version; pure video framing is // unit-tested, while this orchestration is exercised live via probes. const DEVICE_NAME_BYTES = 64 @@ -198,7 +197,6 @@ export class ScrcpyStreamSession { socket.on('error', (error) => emulatorProbeError('scrcpy.control.fail', error, { serial: this.options.serial }) ) - // Drop the ref on reset so sendControl() can't write to a dead socket (half-open session). socket.on('close', () => { if (this.controlSocket === socket) { this.controlSocket = null @@ -253,11 +251,6 @@ export class ScrcpyStreamSession { } } - // Sends an encoded scrcpy control message (see scrcpy-control-protocol). - sendControl(message: Buffer): void { - this.controlSocket?.write(message) - } - private fail(message: string): void { if (this.closed) { return diff --git a/src/main/git/git-capability-state.test.ts b/src/main/git/git-capability-state.test.ts index 0d499aea163..b6e655efe62 100644 --- a/src/main/git/git-capability-state.test.ts +++ b/src/main/git/git-capability-state.test.ts @@ -1,13 +1,19 @@ -import { beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { clearGitCapabilityStateForTests, getLocalGitCapabilityCache, - getSshGitCapabilityCache + getSshGitCapabilityCache, + withLocalGitCapabilityCacheForExecution } from './git-capability-state' +import { + resetWslLinkedWorktreeGitRoutingForTests, + seedWslLinkedWorktreeGitRoutingForTests +} from './wsl-linked-worktree-git-routing' describe('Git capability execution-host state', () => { beforeEach(() => { clearGitCapabilityStateForTests() + resetWslLinkedWorktreeGitRoutingForTests() }) it('shares native state while isolating each WSL distro', () => { @@ -34,4 +40,34 @@ describe('Git capability execution-host state', () => { getSshGitCapabilityCache(replacementProvider) ) }) + + it('uses native capability state for a prepared host-routed WSL worktree', async () => { + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + try { + seedWslLinkedWorktreeGitRoutingForTests(String.raw`C:\repo\linked`) + + await expect( + withLocalGitCapabilityCacheForExecution( + { cwd: String.raw`C:\repo\linked`, wslDistro: 'Ubuntu' }, + async (capabilities) => capabilities + ) + ).resolves.toBe(getLocalGitCapabilityCache()) + expect(getLocalGitCapabilityCache()).not.toBe( + getLocalGitCapabilityCache({ wslDistro: 'Ubuntu' }) + ) + } finally { + platform.mockRestore() + } + }) + + it('starts non-candidate capability work without an added async turn', async () => { + let started = false + const result = withLocalGitCapabilityCacheForExecution({ cwd: '/repo' }, async () => { + started = true + return 'done' + }) + + expect(started).toBe(true) + await expect(result).resolves.toBe('done') + }) }) diff --git a/src/main/git/git-capability-state.ts b/src/main/git/git-capability-state.ts index aec7a369a26..7721df722c8 100644 --- a/src/main/git/git-capability-state.ts +++ b/src/main/git/git-capability-state.ts @@ -1,9 +1,14 @@ import { GitCapabilityCache } from '../../shared/git-capability-cache' import { parseWslUncPath } from '../../shared/wsl-paths' +import { + isWslLinkedWorktreeGitRoutingCandidate, + prepareWslLinkedWorktreeGitRouting +} from './wsl-linked-worktree-git-routing' type LocalGitCapabilityTarget = { cwd?: string wslDistro?: string + signal?: AbortSignal } const localCapabilitiesByExecutionHost = new Map() @@ -29,6 +34,28 @@ export function getLocalGitCapabilityCache( return cache } +export function withLocalGitCapabilityCacheForExecution( + target: LocalGitCapabilityTarget, + run: (capabilities: GitCapabilityCache) => Promise +): Promise { + if (!target.cwd || !isWslLinkedWorktreeGitRoutingCandidate(target.cwd, target.wslDistro)) { + try { + return run(getLocalGitCapabilityCache(target)) + } catch (error) { + return Promise.reject(error) + } + } + return prepareWslLinkedWorktreeGitRouting(target.cwd, target.wslDistro, { + signal: target.signal + }).then((usesHostGit) => + run( + getLocalGitCapabilityCache( + usesHostGit ? { cwd: target.cwd } : { cwd: target.cwd, wslDistro: target.wslDistro } + ) + ) + ) +} + export function getSshGitCapabilityCache(provider: object): GitCapabilityCache { let cache = sshCapabilitiesByProvider.get(provider) if (!cache) { diff --git a/src/main/git/runner-command-exec.test.ts b/src/main/git/runner-command-exec.test.ts index 41975f9d665..8d07bd5c555 100644 --- a/src/main/git/runner-command-exec.test.ts +++ b/src/main/git/runner-command-exec.test.ts @@ -179,8 +179,8 @@ describe('commandExecFileAsync Windows command shims', () => { describe('runner execFile timeout handling', () => { beforeEach(() => { execFileMock.mockReset() - execFileSyncMock.mockReset() spawnMock.mockReset() + execFileSyncMock.mockReset() vi.useFakeTimers() }) @@ -232,6 +232,22 @@ describe('runner execFile timeout handling', () => { expect(child.kill).toHaveBeenCalled() }) + it('kills an active gh execution when its caller aborts', async () => { + const child = createMockChildProcess(1234) + execFileMock.mockReturnValue(child) + const controller = new AbortController() + const promise = ghExecFileAsync(['api', 'repos/stablyai/orca/issues/5388'], { + cwd: '/repo', + signal: controller.signal + }) + const rejection = expect(promise).rejects.toMatchObject({ name: 'AbortError' }) + + controller.abort() + + await rejection + expect(child.kill).toHaveBeenCalled() + }) + it('honors explicit gh timeouts', async () => { const child = createMockChildProcess(1234) execFileMock.mockReturnValue(child) diff --git a/src/main/git/runner-windows-host-environment.test.ts b/src/main/git/runner-windows-host-environment.test.ts new file mode 100644 index 00000000000..e441b023ff4 --- /dev/null +++ b/src/main/git/runner-windows-host-environment.test.ts @@ -0,0 +1,266 @@ +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { execFileMock, spawnMock } = vi.hoisted(() => ({ + execFileMock: vi.fn(), + spawnMock: vi.fn() +})) + +vi.mock('node:child_process', () => ({ + execFile: execFileMock, + execFileSync: vi.fn(), + spawn: spawnMock +})) + +import { + awaitWindowsHostGitEnvironmentReady, + configureWindowsHostGitEnvironmentReadiness, + gitExecFileAsync, + gitExecFileAsyncBuffer, + gitSpawnAfterWindowsEnvironmentReady, + gitStreamStdout +} from './runner' + +type MockChildProcess = EventEmitter & { + stdout: EventEmitter + stderr: EventEmitter + pid: number + kill: ReturnType +} + +function createMockChildProcess(pid: number): MockChildProcess { + const child = new EventEmitter() as MockChildProcess + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + child.pid = pid + child.kill = vi.fn() + return child +} + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} + +async function withPlatform(platform: NodeJS.Platform, fn: () => Promise): Promise { + const original = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + try { + return await fn() + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: original }) + } +} + +describe('Windows host Git environment readiness', () => { + const originalPath = process.env.Path + + beforeEach(() => { + execFileMock.mockReset() + spawnMock.mockReset() + }) + + afterEach(() => { + configureWindowsHostGitEnvironmentReadiness(null) + if (originalPath === undefined) { + delete process.env.Path + } else { + process.env.Path = originalPath + } + }) + + it('waits for each native operation and refreshes a snapshotted PATH', async () => { + await withPlatform('win32', async () => { + const ready = deferred() + const waitUntilReady = vi.fn(() => ready.promise) + const child = createMockChildProcess(1234) + execFileMock.mockImplementation((_cmd, _args, _options, callback) => { + callback(null, 'ok', '') + return child + }) + configureWindowsHostGitEnvironmentReadiness(waitUntilReady) + process.env.Path = 'hydrating-path' + + const firstExec = gitExecFileAsync(['status'], { + cwd: String.raw`C:\repo`, + env: { Path: 'stale-path' } + }) + + expect(waitUntilReady).toHaveBeenCalledOnce() + expect(execFileMock).not.toHaveBeenCalled() + process.env.Path = 'hydrated-path' + ready.resolve() + await expect(firstExec).resolves.toEqual({ stdout: 'ok', stderr: '' }) + expect(execFileMock.mock.calls[0]?.[2]?.env?.Path).toBe('hydrated-path') + + await expect(gitExecFileAsync(['status'], { cwd: String.raw`C:\repo` })).resolves.toEqual({ + stdout: 'ok', + stderr: '' + }) + expect(waitUntilReady).toHaveBeenCalledTimes(2) + }) + }) + + it('waits before native spawned Git and refreshes a snapshotted PATH', async () => { + await withPlatform('win32', async () => { + const ready = deferred() + const waitUntilReady = vi.fn(() => ready.promise) + const child = createMockChildProcess(1234) + spawnMock.mockReturnValue(child) + configureWindowsHostGitEnvironmentReadiness(waitUntilReady) + process.env.Path = 'hydrating-path' + + const spawned = gitSpawnAfterWindowsEnvironmentReady(['clone', 'url', 'repo'], { + cwd: String.raw`C:\repo`, + env: { Path: 'stale-path' }, + stdio: ['ignore', 'ignore', 'pipe'] + }) + + expect(waitUntilReady).toHaveBeenCalledOnce() + expect(spawnMock).not.toHaveBeenCalled() + process.env.Path = 'hydrated-path' + ready.resolve() + + await expect(spawned).resolves.toBe(child) + expect(spawnMock.mock.calls[0]?.[2]?.env?.Path).toBe('hydrated-path') + }) + }) + + it('does not gate spawned Git routed through WSL on the Windows host profile', async () => { + await withPlatform('win32', async () => { + const waitUntilReady = vi.fn(() => new Promise(() => {})) + const child = createMockChildProcess(1234) + spawnMock.mockReturnValue(child) + configureWindowsHostGitEnvironmentReadiness(waitUntilReady) + + await expect( + gitSpawnAfterWindowsEnvironmentReady(['clone', 'url', 'repo'], { + cwd: String.raw`C:\repo`, + wslDistro: 'Ubuntu', + stdio: ['ignore', 'ignore', 'pipe'] + }) + ).resolves.toBe(child) + + expect(waitUntilReady).not.toHaveBeenCalled() + expect(spawnMock).toHaveBeenCalled() + }) + }) + + it('does not spawn Git when canceled during Windows environment readiness', async () => { + await withPlatform('win32', async () => { + const ready = deferred() + const controller = new AbortController() + configureWindowsHostGitEnvironmentReadiness(() => ready.promise) + + const spawned = gitSpawnAfterWindowsEnvironmentReady(['status'], { + cwd: String.raw`C:\repo`, + signal: controller.signal, + stdio: ['ignore', 'ignore', 'pipe'] + }) + controller.abort() + + await expect(spawned).rejects.toMatchObject({ name: 'AbortError' }) + expect(spawnMock).not.toHaveBeenCalled() + ready.resolve() + }) + }) + + it('cancels async Git immediately while Windows environment readiness is pending', async () => { + await withPlatform('win32', async () => { + const ready = deferred() + const controller = new AbortController() + configureWindowsHostGitEnvironmentReadiness(() => ready.promise) + + const operation = gitExecFileAsync(['status'], { + cwd: String.raw`C:\repo`, + signal: controller.signal + }) + controller.abort() + + await expect(operation).rejects.toMatchObject({ name: 'AbortError' }) + expect(execFileMock).not.toHaveBeenCalled() + ready.resolve() + }) + }) + + it('does not gate WSL paths at a synchronous Git consumer boundary', async () => { + await withPlatform('win32', async () => { + const waitUntilReady = vi.fn(() => new Promise(() => {})) + configureWindowsHostGitEnvironmentReadiness(waitUntilReady) + + await expect( + awaitWindowsHostGitEnvironmentReady({ + cwd: String.raw`\\wsl.localhost\Ubuntu\home\me\repo` + }) + ).resolves.toBeUndefined() + + expect(waitUntilReady).not.toHaveBeenCalled() + }) + }) + + it('does not gate Git routed through WSL on the Windows host profile', async () => { + await withPlatform('win32', async () => { + const waitUntilReady = vi.fn(() => new Promise(() => {})) + const child = createMockChildProcess(1234) + execFileMock.mockImplementation((_cmd, _args, _options, callback) => { + callback(null, 'ok', '') + return child + }) + configureWindowsHostGitEnvironmentReadiness(waitUntilReady) + + await expect( + gitExecFileAsync(['status'], { + cwd: String.raw`C:\repo`, + wslDistro: 'Ubuntu' + }) + ).resolves.toEqual({ stdout: 'ok', stderr: '' }) + + expect(waitUntilReady).not.toHaveBeenCalled() + expect(execFileMock).toHaveBeenCalled() + }) + }) + + it('gates buffered native Git reads on the current generation', async () => { + await withPlatform('win32', async () => { + const ready = deferred() + const child = createMockChildProcess(1234) + execFileMock.mockImplementation((_cmd, _args, _options, callback) => { + callback(null, Buffer.from('blob'), Buffer.alloc(0)) + return child + }) + configureWindowsHostGitEnvironmentReadiness(() => ready.promise) + + const read = gitExecFileAsyncBuffer(['show', 'HEAD:file'], { cwd: String.raw`C:\repo` }) + expect(execFileMock).not.toHaveBeenCalled() + ready.resolve() + + await expect(read).resolves.toEqual({ stdout: Buffer.from('blob') }) + }) + }) + + it('gates native Git streams and refreshes their snapshotted PATH', async () => { + await withPlatform('win32', async () => { + const ready = deferred() + const child = createMockChildProcess(1234) + spawnMock.mockReturnValue(child) + configureWindowsHostGitEnvironmentReadiness(() => ready.promise) + process.env.Path = 'hydrated-stream-path' + + const stream = gitStreamStdout(['status'], { + cwd: String.raw`C:\repo`, + env: { Path: 'stale-stream-path' }, + onStdout: () => {} + }) + expect(spawnMock).not.toHaveBeenCalled() + ready.resolve() + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce()) + expect(spawnMock.mock.calls[0]?.[2]?.env?.Path).toBe('hydrated-stream-path') + child.emit('close', 0) + + await expect(stream).resolves.toEqual({ stoppedEarly: false }) + }) + }) +}) diff --git a/src/main/git/runner-wsl-direct-read.test.ts b/src/main/git/runner-wsl-direct-read.test.ts index f90ea76ad9b..cc6e8364d7f 100644 --- a/src/main/git/runner-wsl-direct-read.test.ts +++ b/src/main/git/runner-wsl-direct-read.test.ts @@ -17,12 +17,18 @@ vi.mock('../observability/instrumentation', () => ({ })) vi.mock('../diagnostics/main-thread-churn-probe', () => ({ recordSubprocessSpawn: vi.fn() })) -import { gitExecFileAsync, gitStreamStdout } from './runner' +import { gitExecFileAsync, gitSpawn, gitStreamStdout } from './runner' import { getWslGitReadEnvironment, resetWslGitReadEnvironmentForTests, seedWslGitReadEnvironmentForTests } from './wsl-git-read-environment' +import { + prepareWslLinkedWorktreeGitRouting, + resetWslLinkedWorktreeGitRoutingForTests, + WSL_LINKED_WORKTREE_ROUTE_TTL_MS, + type WslLinkedWorktreeRoutingFileSystem +} from './wsl-linked-worktree-git-routing' const DISTRO = 'Ubuntu' const LOGIN_ENVIRONMENT = { @@ -71,10 +77,12 @@ describe('WSL direct Git reads', () => { execFileSyncMock.mockReset() spawnMock.mockReset() resetWslGitReadEnvironmentForTests() + resetWslLinkedWorktreeGitRoutingForTests() }) afterEach(() => { resetWslGitReadEnvironmentForTests() + resetWslLinkedWorktreeGitRoutingForTests() }) it('coalesces the login-shell environment probe per distro', async () => { @@ -495,4 +503,125 @@ describe('WSL direct Git reads', () => { expect(execFileMock.mock.calls[0]?.[0]).toBe('git') }) }) + + it('keeps gitSpawn cache-only while async linked-worktree discovery is pending', async () => { + await withPlatform('win32', async () => { + let releaseStat: (() => void) | undefined + const delayedStat = new Promise((resolve) => { + releaseStat = resolve + }) + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async () => { + await delayedStat + return { isDirectory: () => false, isFile: () => true } + }), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + const pending = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, DISTRO, { + platform: 'win32', + fileSystem + }) + spawnMock.mockReturnValue(createMockChild()) + + gitSpawn(['ls-files'], { + cwd: String.raw`C:\repo`, + wslDistro: DISTRO, + stdio: ['ignore', 'pipe', 'pipe'] + }) + + expect(fileSystem.stat).toHaveBeenCalledTimes(1) + expect(spawnMock.mock.calls[0]?.[0]).toBe('wsl.exe') + releaseStat?.() + await expect(pending).resolves.toBe(true) + + gitSpawn(['ls-files'], { + cwd: String.raw`C:\repo`, + wslDistro: DISTRO, + stdio: ['ignore', 'pipe', 'pipe'] + }) + expect(fileSystem.stat).toHaveBeenCalledTimes(1) + expect(spawnMock.mock.calls[1]?.[0]).toBe('git') + }) + }) + + it('aborts an async Git call while linked-worktree discovery remains pending', async () => { + await withPlatform('win32', async () => { + let releaseStat: (() => void) | undefined + const delayedStat = new Promise((resolve) => { + releaseStat = resolve + }) + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async () => { + await delayedStat + return { isDirectory: () => false, isFile: () => true } + }), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + const discovery = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, DISTRO, { + platform: 'win32', + fileSystem + }) + const controller = new AbortController() + + const command = gitExecFileAsync(['status', '--short'], { + cwd: String.raw`C:\repo`, + wslDistro: DISTRO, + signal: controller.signal + }) + controller.abort() + + await expect(command).rejects.toMatchObject({ name: 'AbortError' }) + expect(execFileMock).not.toHaveBeenCalled() + releaseStat?.() + await expect(discovery).resolves.toBe(true) + }) + }) + + it('keeps gitSpawn cache-only when a linked-worktree route expires', async () => { + await withPlatform('win32', async () => { + let currentTime = 1_000 + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => currentTime) + try { + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async () => ({ isDirectory: () => false, isFile: () => true })), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + await prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, DISTRO, { + platform: 'win32', + fileSystem + }) + spawnMock.mockReturnValue(createMockChild()) + + gitSpawn(['ls-files'], { + cwd: String.raw`C:\repo`, + wslDistro: DISTRO, + stdio: ['ignore', 'pipe', 'pipe'] + }) + currentTime += WSL_LINKED_WORKTREE_ROUTE_TTL_MS + gitSpawn(['ls-files'], { + cwd: String.raw`C:\repo`, + wslDistro: DISTRO, + stdio: ['ignore', 'pipe', 'pipe'] + }) + + expect(spawnMock.mock.calls[0]?.[0]).toBe('git') + expect(spawnMock.mock.calls[1]?.[0]).toBe('wsl.exe') + expect(fileSystem.stat).toHaveBeenCalledTimes(1) + + await prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, DISTRO, { + platform: 'win32', + fileSystem + }) + gitSpawn(['ls-files'], { + cwd: String.raw`C:\repo`, + wslDistro: DISTRO, + stdio: ['ignore', 'pipe', 'pipe'] + }) + expect(fileSystem.stat).toHaveBeenCalledTimes(2) + expect(spawnMock.mock.calls[2]?.[0]).toBe('git') + } finally { + nowSpy.mockRestore() + } + }) + }) }) diff --git a/src/main/git/runner-wsl-linked-gitdir-timeout.test.ts b/src/main/git/runner-wsl-linked-gitdir-timeout.test.ts new file mode 100644 index 00000000000..efc37c192f7 --- /dev/null +++ b/src/main/git/runner-wsl-linked-gitdir-timeout.test.ts @@ -0,0 +1,98 @@ +import { EventEmitter } from 'node:events' +import type * as FsPromises from 'node:fs/promises' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { execFileMock, statMock, readFileMock } = vi.hoisted(() => ({ + execFileMock: vi.fn(), + statMock: vi.fn(), + readFileMock: vi.fn() +})) + +vi.mock('node:child_process', () => ({ + execFile: execFileMock, + execFileSync: vi.fn(), + spawn: vi.fn() +})) + +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + stat: statMock, + readFile: readFileMock +})) + +import { gitExecFileAsync } from './runner' +import { + resetWslLinkedWorktreeGitRoutingForTests, + WSL_LINKED_WORKTREE_ROUTE_PROBE_TIMEOUT_MS +} from './wsl-linked-worktree-git-routing' + +function createMockChild(): EventEmitter & { pid: number; kill: ReturnType } { + const child = new EventEmitter() as EventEmitter & { + pid: number + kill: ReturnType + } + child.pid = 1234 + child.kill = vi.fn() + return child +} + +async function withWindowsPlatform(run: () => Promise): Promise { + const original = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + await run() + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: original }) + } +} + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + resetWslLinkedWorktreeGitRoutingForTests() +}) + +describe('WSL linked-worktree routing probe timeout', () => { + it('retries discovery on the next production Git call', async () => { + vi.useFakeTimers() + await withWindowsPlatform(async () => { + statMock + .mockImplementationOnce(() => new Promise(() => {})) + .mockResolvedValue({ isDirectory: () => false, isFile: () => true }) + readFileMock.mockResolvedValue('gitdir: C:/main/.git/worktrees/linked\n') + execFileMock.mockImplementation((command, _args, _options, callback) => { + const child = createMockChild() + queueMicrotask(() => { + if (command === 'wsl.exe') { + callback?.( + Object.assign(new Error('not a git repository'), { code: 128 }), + '', + 'fatal: not a git repository' + ) + } else { + callback?.(null, 'host git recovered\n', '') + } + }) + return child + }) + + const first = gitExecFileAsync(['status', '--short'], { + cwd: String.raw`C:\repo`, + wslDistro: 'Ubuntu' + }) + const firstFailure = expect(first).rejects.toThrow('not a git repository') + await vi.advanceTimersByTimeAsync(WSL_LINKED_WORKTREE_ROUTE_PROBE_TIMEOUT_MS) + await firstFailure + + await expect( + gitExecFileAsync(['status', '--short'], { + cwd: String.raw`C:\repo`, + wslDistro: 'Ubuntu' + }) + ).resolves.toEqual({ stdout: 'host git recovered\n', stderr: '' }) + + expect(statMock).toHaveBeenCalledTimes(2) + expect(execFileMock.mock.calls.map(([command]) => command)).toEqual(['wsl.exe', 'git']) + }) + }) +}) diff --git a/src/main/git/runner-wsl-linked-gitdir-windows.test.ts b/src/main/git/runner-wsl-linked-gitdir-windows.test.ts new file mode 100644 index 00000000000..1af46a51069 --- /dev/null +++ b/src/main/git/runner-wsl-linked-gitdir-windows.test.ts @@ -0,0 +1,161 @@ +import { execFileSync } from 'node:child_process' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { listFilesWithGit } from '../ipc/filesystem-list-files-git-fallback' +import { searchWithGitGrep } from '../ipc/filesystem-search-git' +import { + gitExecFileAsync, + gitExecFileAsyncBuffer, + gitStreamStdout, + toLinuxPath, + toWindowsWslPath +} from './runner' +import { listWorktrees } from './worktree' +import { resetWslLinkedWorktreeGitRoutingForTests } from './wsl-linked-worktree-git-routing' + +const distro = process.env.ORCA_TEST_WSL_DISTRO?.trim() +const fixtureRoots: string[] = [] +const wslFixtureRoots: string[] = [] + +function hostGit(args: string[], cwd: string): string { + return execFileSync('git', args, { cwd, encoding: 'utf8' }) +} + +function wslExec(args: string[]): string { + return execFileSync('wsl.exe', ['-d', distro!, '--exec', ...args], { encoding: 'utf8' }) +} + +afterEach(async () => { + await Promise.all( + fixtureRoots.splice(0).map((path) => rm(path, { recursive: true, force: true })) + ) + for (const path of wslFixtureRoots.splice(0)) { + if (!/^\/tmp\/orca-wsl-native-[A-Za-z0-9]+$/.test(path)) { + throw new Error(`Refusing to remove unexpected WSL fixture path: ${path}`) + } + wslExec(['rm', '-rf', '--', path]) + } +}) + +describe.runIf(process.platform === 'win32' && Boolean(distro))( + 'Windows-authored linked worktree in WSL', + () => { + it('handles the worktree through the production Git runner', async () => { + const fixtureRoot = await mkdtemp(join(process.cwd(), '.wsl-linked-gitdir-')) + fixtureRoots.push(fixtureRoot) + const mainPath = join(fixtureRoot, 'main') + const linkedPath = join(fixtureRoot, 'linked') + + hostGit(['init', mainPath], fixtureRoot) + hostGit(['config', 'user.name', 'Orca Test'], mainPath) + hostGit(['config', 'user.email', 'test@invalid'], mainPath) + await writeFile(join(mainPath, 'tracked.txt'), 'tracked\n') + hostGit(['add', 'tracked.txt'], mainPath) + hostGit(['commit', '-m', 'fixture'], mainPath) + hostGit(['worktree', 'add', '-b', 'linked', linkedPath], mainPath) + await writeFile(join(linkedPath, 'untracked.txt'), 'untracked\n') + + const gitFile = await readFile(join(linkedPath, '.git'), 'utf8') + expect(gitFile).toMatch(/^gitdir: [A-Za-z]:\//) + expect(() => + execFileSync( + 'wsl.exe', + ['-d', distro!, '--exec', 'git', '-C', toLinuxPath(linkedPath), 'status', '--short'], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] } + ) + ).toThrow(/not a git repository/i) + + await expect(listFilesWithGit(linkedPath, [], { wslDistro: distro! })).resolves.toEqual( + expect.arrayContaining(['tracked.txt', 'untracked.txt']) + ) + resetWslLinkedWorktreeGitRoutingForTests() + await expect( + searchWithGitGrep(linkedPath, { query: 'tracked', rootPath: linkedPath }, 10, { + wslDistro: distro! + }) + ).resolves.toMatchObject({ totalMatches: 2 }) + + let streamedStatus = '' + await expect( + gitStreamStdout(['status', '--short'], { + cwd: linkedPath, + wslDistro: distro!, + onStdout: (chunk) => { + streamedStatus += chunk + } + }) + ).resolves.toEqual({ stoppedEarly: false }) + expect(streamedStatus).toBe('?? untracked.txt\n') + + await gitExecFileAsync(['add', '--', 'untracked.txt'], { + cwd: linkedPath, + wslDistro: distro! + }) + await expect( + gitExecFileAsync(['status', '--short'], { cwd: linkedPath, wslDistro: distro! }) + ).resolves.toMatchObject({ stdout: 'A untracked.txt\n' }) + await expect( + gitExecFileAsync(['status', '--short'], { cwd: linkedPath }) + ).resolves.toMatchObject({ stdout: 'A untracked.txt\n' }) + await expect( + gitExecFileAsyncBuffer(['show', ':untracked.txt'], { + cwd: linkedPath, + wslDistro: distro! + }) + ).resolves.toMatchObject({ stdout: Buffer.from('untracked\n') }) + + const folderPath = join(linkedPath, 'packages', 'app') + await mkdir(folderPath, { recursive: true }) + await expect( + gitExecFileAsync(['rev-parse', '--show-toplevel'], { + cwd: folderPath, + wslDistro: distro! + }) + ).resolves.toMatchObject({ stdout: `${linkedPath.replace(/\\/g, '/')}\n` }) + + const linkedExecPath = await gitExecFileAsync(['--exec-path'], { + cwd: linkedPath, + wslDistro: distro! + }) + const mainExecPath = await gitExecFileAsync(['--exec-path'], { + cwd: mainPath, + wslDistro: distro! + }) + expect(linkedExecPath.stdout).toMatch(/^[A-Za-z]:\//) + expect(mainExecPath.stdout).toMatch(/(?:^|\n)\/[^\n]+\n$/) + await expect(listWorktrees(linkedPath, { wslDistro: distro! })).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: expect.stringMatching(/[\\/]linked$/) }) + ]) + ) + + const nestedPath = join(linkedPath, 'nested') + wslExec(['git', 'init', toLinuxPath(nestedPath)]) + await expect( + gitExecFileAsync(['--exec-path'], { cwd: nestedPath, wslDistro: distro! }) + ).resolves.toMatchObject({ stdout: expect.stringMatching(/(?:^|\n)\/[^\n]+\n$/) }) + }) + + it('keeps WSL-native repositories on WSL Git', async () => { + const linuxRoot = wslExec(['mktemp', '-d', '/tmp/orca-wsl-native-XXXXXX']).trim() + wslFixtureRoots.push(linuxRoot) + const windowsRoot = toWindowsWslPath(linuxRoot, distro!) + + wslExec(['git', 'init', linuxRoot]) + wslExec(['git', '-C', linuxRoot, 'config', 'user.name', 'Orca Test']) + wslExec(['git', '-C', linuxRoot, 'config', 'user.email', 'test@invalid']) + await writeFile(join(windowsRoot, 'tracked.txt'), 'tracked\n') + wslExec(['git', '-C', linuxRoot, 'add', 'tracked.txt']) + wslExec(['git', '-C', linuxRoot, 'commit', '-m', 'fixture']) + await writeFile(join(windowsRoot, 'untracked.txt'), 'untracked\n') + + await expect( + gitExecFileAsync(['status', '--short'], { cwd: windowsRoot, wslDistro: distro! }) + ).resolves.toMatchObject({ stdout: expect.stringMatching(/(?:^|\n)\?\? untracked\.txt\n$/) }) + await expect( + gitExecFileAsync(['--exec-path'], { cwd: windowsRoot, wslDistro: distro! }) + ).resolves.toMatchObject({ stdout: expect.stringMatching(/(?:^|\n)\/[^\n]+\n$/) }) + }) + } +) diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index ccde4a492f2..264bd7cbfca 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -48,6 +48,11 @@ import { peekWslGitReadEnvironment, type WslGitReadEnvironment } from './wsl-git-read-environment' +import { + isWslLinkedWorktreeGitRoutingCandidate, + prepareWslLinkedWorktreeGitRouting, + usesHostGitForWslLinkedWorktree +} from './wsl-linked-worktree-git-routing' // Re-exported for existing importers; lightweight consumers should import from './exec-error' to avoid this heavy module. import { extractExecError, parseRetryAfterMs } from './exec-error' export { extractExecError, parseRetryAfterMs } @@ -177,12 +182,100 @@ function resolveHostGitHubCli(command: 'gh', args: string[]): ResolvedCommand { } let defaultWslDistroOverride: string | null = null +let waitForWindowsHostGitEnvironment: (() => Promise) | null = null // Why: allow host commands fallback to route through the user's pinned WSL distro when host execution fails. export function setDefaultWslDistroOverride(distro: string | null): void { defaultWslDistroOverride = distro } +export function configureWindowsHostGitEnvironmentReadiness( + waitUntilReady: (() => Promise) | null +): void { + waitForWindowsHostGitEnvironment = waitUntilReady +} + +export async function awaitWindowsHostGitEnvironmentReady(options: { + cwd: string + wslDistro?: string + signal?: AbortSignal +}): Promise { + const resolved = resolveGitCommand(['--version'], options) + await prepareWindowsHostGitEnvironment(resolved, undefined, options.signal) +} + +function refreshWindowsHostPath(env: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv | undefined { + if (!env) { + return undefined + } + const currentPath = process.env.Path ?? process.env.PATH + if (currentPath === undefined) { + return env + } + const next = { ...env } + const pathKeys = Object.keys(next).filter((key) => key.toLowerCase() === 'path') + if (pathKeys.length === 0) { + next[process.env.Path === undefined ? 'PATH' : 'Path'] = currentPath + } else { + for (const key of pathKeys) { + next[key] = currentPath + } + } + return next +} + +function prepareWindowsHostGitEnvironment( + resolved: ResolvedCommand, + env: NodeJS.ProcessEnv | undefined, + signal?: AbortSignal +): Promise | null { + if ( + process.platform !== 'win32' || + resolved.wsl !== null || + waitForWindowsHostGitEnvironment === null + ) { + return null + } + const ready = waitForWindowsHostGitEnvironment().then(() => refreshWindowsHostPath(env)) + if (!signal) { + return ready + } + if (signal.aborted) { + return Promise.reject(createAbortError()) + } + return new Promise((resolve, reject) => { + let settled = false + const cleanup = (): void => signal.removeEventListener('abort', onAbort) + const onAbort = (): void => { + if (settled) { + return + } + settled = true + cleanup() + reject(createAbortError()) + } + signal.addEventListener('abort', onAbort, { once: true }) + ready.then( + (value) => { + if (settled) { + return + } + settled = true + cleanup() + resolve(value) + }, + (error) => { + if (settled) { + return + } + settled = true + cleanup() + reject(error) + } + ) + }) +} + function resolveDefaultWslCli(command: 'gh' | 'glab', args: string[]): ResolvedCommand | null { const distro = defaultWslDistroOverride ?? getDefaultWslDistro() return distro ? resolveCommand(command, args, undefined, distro) : null @@ -335,6 +428,10 @@ function resolveGitCommand( options: GitExecOptions, forceLoginShell = false ): ResolvedCommand { + if (usesHostGitForWslLinkedWorktree(options.cwd, options.wslDistro)) { + // Why: WSL Git resolves a Windows-authored linked-worktree pointer relative to cwd. + return { binary: 'git', args, cwd: options.cwd, wsl: null, wslMode: null } + } if (!forceLoginShell && shouldAttemptWslDirectGit(options)) { const distro = wslDistroForCommand(options.cwd, options.wslDistro) const environment = distro ? peekWslGitReadEnvironment(distro) : undefined @@ -908,13 +1005,7 @@ async function buildNetworkSshPolicyEnv(options: GitExecOptions): Promise<{ return { env: promptEnv, mode: 'explicit-env' } } - const resolved = resolveCommand( - 'git', - ['config', '--get', 'core.sshCommand'], - options.cwd, - options.wslDistro, - { useWslLoginShell: Boolean(options.wslDistro) } - ) + const resolved = resolveGitCommand(['config', '--get', 'core.sshCommand'], options, true) let configuredCommand = '' try { const { stdout } = await execFileCapture(resolved.binary, resolved.args, { @@ -964,10 +1055,23 @@ export async function gitExecFileAsync( return withGitSpan( { args, ...(options.cwd !== undefined ? { cwd: options.cwd } : {}) }, async () => { - const resolved = resolveGitCommand(args, options) - const policy = options.useConfiguredSshCommandForNetwork - ? await buildNetworkSshPolicyEnv(options) - : { env: nonInteractiveGitEnv(options.env), mode: 'default' as const } + if (isWslLinkedWorktreeGitRoutingCandidate(options.cwd, options.wslDistro)) { + await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro, { + signal: options.signal + }) + } + let resolved = resolveGitCommand(args, options) + const environmentReady = prepareWindowsHostGitEnvironment( + resolved, + options.env, + options.signal + ) + const env = environmentReady ? await environmentReady : options.env + const effectiveOptions = env === options.env ? options : { ...options, env } + resolved = resolveGitCommand(args, effectiveOptions) + const policy = effectiveOptions.useConfiguredSshCommandForNetwork + ? await buildNetworkSshPolicyEnv(effectiveOptions) + : { env: nonInteractiveGitEnv(effectiveOptions.env), mode: 'default' as const } const capture = ( command: ResolvedCommand ): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> => @@ -986,7 +1090,7 @@ export async function gitExecFileAsync( } catch (error) { if (directWslGitExitCode(error, resolved) !== null && !options.signal?.aborted) { const wasMissing = invalidateMissingDirectWslGit(error, resolved) - result = await capture(resolveGitCommand(args, options, true)) + result = await capture(resolveGitCommand(args, effectiveOptions, true)) // Why: matching failures can be normal Git control flow; only a successful login retry proves the direct environment was insufficient. disableDirectWslGitAfterSuccessfulFallback(wasMissing, resolved) const { stdout, stderr } = result @@ -1055,9 +1159,15 @@ export async function gitExecFileAsyncBuffer( args: string[], options: { cwd: string; maxBuffer?: number; wslDistro?: string } ): Promise<{ stdout: Buffer }> { - const resolved = resolveCommand('git', args, options.cwd, options.wslDistro, { - useWslLoginShell: Boolean(options.wslDistro) - }) + if (isWslLinkedWorktreeGitRoutingCandidate(options.cwd, options.wslDistro)) { + await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro) + } + let resolved = resolveGitCommand(args, options, true) + const environmentReady = prepareWindowsHostGitEnvironment(resolved, undefined) + if (environmentReady) { + await environmentReady + } + resolved = resolveGitCommand(args, options, true) const { stdout } = (await execFileCapture(resolved.binary, resolved.args, { cwd: resolved.cwd, encoding: 'buffer', @@ -1098,6 +1208,11 @@ export async function gitStreamStdout( ): Promise { const maxBuffer = options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER return withGitSpan({ args, cwd: options.cwd }, async () => { + if (isWslLinkedWorktreeGitRoutingCandidate(options.cwd, options.wslDistro)) { + await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro, { + signal: options.signal + }) + } const gitOptions: GitExecOptions = { cwd: options.cwd, ...(options.env ? { env: options.env } : {}), @@ -1106,6 +1221,15 @@ export async function gitStreamStdout( ...(options.signal ? { signal: options.signal } : {}) } let resolved = resolveGitCommand(args, gitOptions) + const environmentReady = prepareWindowsHostGitEnvironment( + resolved, + gitOptions.env, + options.signal + ) + if (environmentReady) { + gitOptions.env = await environmentReady + } + resolved = resolveGitCommand(args, gitOptions) const stream = (command: ResolvedCommand): Promise => new Promise((resolve, reject) => { if (options.signal?.aborted) { @@ -1115,7 +1239,7 @@ export async function gitStreamStdout( const stdio: SpawnOptions['stdio'] = ['ignore', 'pipe', 'pipe'] const spawnOptions = { cwd: options.cwd, - env: nonInteractiveGitEnv(options.env), + env: nonInteractiveGitEnv(gitOptions.env), stdio, wslDistro: options.wslDistro, windowsHide: true @@ -1289,13 +1413,34 @@ export function gitExecFileSync( * Spawn a git child process. Drop-in replacement for * `spawn('git', args, { cwd, stdio, ... })`. */ -export function gitSpawn( +type GitSpawnOptions = SpawnOptions & { cwd: string; wslDistro?: string } + +export async function gitSpawnAfterWindowsEnvironmentReady( args: string[], - options: SpawnOptions & { cwd: string; wslDistro?: string } -): ChildProcess { + options: GitSpawnOptions +): Promise { + if (options.signal?.aborted) { + throw createAbortError() + } + const resolved = resolveGitCommand(args, { + cwd: options.cwd, + ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), + ...(options.env ? { env: options.env } : {}) + }) + const env = await (prepareWindowsHostGitEnvironment(resolved, options.env, options.signal) ?? + options.env) + if (options.signal?.aborted) { + throw createAbortError() + } + return gitSpawn(args, env === options.env ? options : { ...options, env }) +} + +export function gitSpawn(args: string[], options: GitSpawnOptions): ChildProcess { const { wslDistro, ...spawnOptions } = options - const resolved = resolveCommand('git', args, options.cwd, wslDistro, { - useWslLoginShell: Boolean(wslDistro) + const resolved = resolveGitCommand(args, { + cwd: options.cwd, + ...(wslDistro ? { wslDistro } : {}), + ...(spawnOptions.env ? { env: spawnOptions.env } : {}) }) const spawnStartedAt = performance.now() const child = spawn(resolved.binary, resolved.args, { @@ -1438,8 +1583,24 @@ const GH_RETRY_DELAYS_MS = [250, 1000] as const const GH_RETRY_AFTER_MAX_MS = 30_000 const DEFAULT_GH_EXEC_TIMEOUT_MS = 30_000 -async function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) +async function sleep(ms: number, signal?: AbortSignal): Promise { + if (signal?.aborted) { + throw createAbortError() + } + await new Promise((resolve, reject) => { + const timer = setTimeout(finish, ms) + const onAbort = (): void => finish(createAbortError()) + function finish(error?: Error): void { + clearTimeout(timer) + signal?.removeEventListener('abort', onAbort) + if (error) { + reject(error) + } else { + resolve() + } + } + signal?.addEventListener('abort', onAbort, { once: true }) + }) } function defaultGhExecTimeoutMs(env: NodeJS.ProcessEnv = process.env): number { @@ -1611,7 +1772,8 @@ export async function ghExecFileAsync( maxBuffer: options.maxBuffer, // Why: bound gh so one stuck child fails visibly instead of wedging the IPC lane. timeout: options.timeout ?? defaultGhExecTimeoutMs(options.env), - env: nonInteractiveGhEnv(options.env) + env: nonInteractiveGhEnv(options.env), + signal: options.signal }) return { stdout: stdout as string, stderr: stderr as string } } catch (err) { @@ -1653,7 +1815,7 @@ export async function ghExecFileAsync( retryAfterMs !== null ? Math.min(retryAfterMs, GH_RETRY_AFTER_MAX_MS) : GH_RETRY_DELAYS_MS[attempt] - await sleep(delayMs) + await sleep(delayMs, options.signal) continue } throw err diff --git a/src/main/git/status-conflict-overlap.bench.test.ts b/src/main/git/status-conflict-overlap.bench.test.ts new file mode 100644 index 00000000000..5c92a23e10c --- /dev/null +++ b/src/main/git/status-conflict-overlap.bench.test.ts @@ -0,0 +1,250 @@ +import { performance } from 'node:perf_hooks' +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFsPromises from 'node:fs/promises' +import type * as GitRunner from './runner' +import type { GitExec } from '../../relay/git-handler-ops' +import type { RelayGitStreamExec } from '../../relay/git-stdout-stream' + +const { gitStreamStdoutMock, readFileMock } = vi.hoisted(() => ({ + gitStreamStdoutMock: vi.fn(), + readFileMock: vi.fn() +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, readFile: readFileMock } +}) + +vi.mock('./runner', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, gitStreamStdout: gitStreamStdoutMock } +}) + +import { getStatus } from './status' +import { getStatusOp } from '../../relay/git-handler-status-ops' + +const BENCH_DELAY_MS = 25 +const BENCH_SAMPLES = 31 +const BENCH_WARMUPS = 5 +const describeBench = process.env.ORCA_GIT_STATUS_OVERLAP_BENCH === '1' ? describe : describe.skip + +type Deferred = { + promise: Promise + resolve: (value: T) => void +} + +type BenchmarkResult = { + path: string + medianMs: number + p95Ms: number + minMs: number + maxMs: number +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise((innerResolve) => { + resolve = innerResolve + }) + return { promise, resolve } +} + +function wait(delayMs: number): Promise { + return new Promise((resolve) => setTimeout(resolve, delayMs)) +} + +function percentile(samples: number[], fraction: number): number { + const sorted = [...samples].sort((a, b) => a - b) + return sorted[Math.ceil(sorted.length * fraction) - 1]! +} + +describe('git status conflict-read overlap', () => { + const relayGit: GitExec = async () => ({ stdout: '', stderr: '' }) + + beforeEach(() => { + readFileMock.mockReset() + gitStreamStdoutMock.mockReset() + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + gitStreamStdoutMock.mockResolvedValue({ stoppedEarly: false }) + }) + + it('starts native status before conflict-marker I/O settles', async () => { + const markerRead = deferred() + const statusStarted = deferred() + readFileMock.mockReturnValue(markerRead.promise) + gitStreamStdoutMock.mockImplementation(async () => { + statusStarted.resolve() + return { stoppedEarly: false } + }) + + const resultPromise = getStatus('/repo') + await statusStarted.promise + expect(readFileMock).toHaveBeenCalledWith(join('/repo', '.git'), 'utf-8') + + markerRead.resolve('gitdir: /repo/.git/worktrees/feature\n') + await expect(resultPromise).resolves.toMatchObject({ + entries: [], + conflictOperation: 'unknown' + }) + }) + + it('starts relay status before conflict-marker I/O settles', async () => { + const markerRead = deferred() + const statusStarted = deferred() + readFileMock.mockReturnValue(markerRead.promise) + const relayStreamGit: RelayGitStreamExec = async () => { + statusStarted.resolve() + return { stoppedEarly: false } + } + + const resultPromise = getStatusOp(relayGit, relayStreamGit, { worktreePath: '/repo' }) + await statusStarted.promise + expect(readFileMock).toHaveBeenCalledWith(join('/repo', '.git'), 'utf-8') + + markerRead.resolve('gitdir: /repo/.git/worktrees/feature\n') + await expect(resultPromise).resolves.toMatchObject({ + entries: [], + conflictOperation: 'unknown' + }) + }) + + it('observes an early native status rejection while marker I/O remains pending', async () => { + const markerRead = deferred() + readFileMock.mockReturnValue(markerRead.promise) + gitStreamStdoutMock.mockRejectedValue(new Error('status failed first')) + let settled = false + + const resultPromise = getStatus('/repo').finally(() => { + settled = true + }) + await Promise.resolve() + await Promise.resolve() + expect(settled).toBe(false) + + markerRead.resolve('gitdir: /repo/.git/worktrees/feature\n') + await expect(resultPromise).resolves.toMatchObject({ + entries: [], + conflictOperation: 'unknown' + }) + }) + + it('keeps a synchronous native status failure fail-soft', async () => { + const statusError = new Error('status threw') + gitStreamStdoutMock.mockImplementation(() => { + throw statusError + }) + + await expect(getStatus('/repo')).resolves.toMatchObject({ + entries: [], + conflictOperation: 'unknown' + }) + }) + + it.each(['throw', 'reject'] as const)('keeps relay %s failures fail-soft', async (mode) => { + const statusError = new Error(`status ${mode}`) + const relayStreamGit = (() => { + if (mode === 'throw') { + throw statusError + } + return Promise.reject(statusError) + }) as RelayGitStreamExec + + await expect( + getStatusOp(relayGit, relayStreamGit, { worktreePath: '/repo' }) + ).resolves.toMatchObject({ entries: [], conflictOperation: 'unknown' }) + }) + + it('rethrows the original relay status failure after cancellation', async () => { + const controller = new AbortController() + const statusError = new Error('cancelled status') + const relayStreamGit: RelayGitStreamExec = async () => { + controller.abort(statusError) + throw statusError + } + + await expect( + getStatusOp( + relayGit, + relayStreamGit, + { worktreePath: '/repo' }, + { signal: controller.signal } + ) + ).rejects.toBe(statusError) + }) + + it('surfaces detector errors without waiting for a hung status read', async () => { + const statusStarted = deferred() + const relayStreamGit: RelayGitStreamExec = () => { + statusStarted.resolve() + return new Promise(() => {}) + } + const invalidPath = { toString: () => '/repo' } + + const resultPromise = getStatusOp(relayGit, relayStreamGit, { + worktreePath: invalidPath + }) + const rejection = expect(resultPromise).rejects.toThrow(TypeError) + await statusStarted.promise + await rejection + }) + + it('keeps detector errors ahead of concurrent status failures', async () => { + const statusError = new Error('status failed too') + const relayStreamGit = (() => { + throw statusError + }) as RelayGitStreamExec + const invalidPath = { toString: () => '/repo' } + + const result = getStatusOp(relayGit, relayStreamGit, { worktreePath: invalidPath }) + await expect(result).rejects.toBeInstanceOf(TypeError) + }) +}) + +describeBench('git status conflict-read overlap benchmark', () => { + it('measures native and relay orchestration with matched independent latency', async () => { + readFileMock.mockImplementation(async () => { + await wait(BENCH_DELAY_MS) + return 'gitdir: /repo/.git/worktrees/feature\n' + }) + gitStreamStdoutMock.mockImplementation(async () => { + await wait(BENCH_DELAY_MS) + return { stoppedEarly: false } + }) + const relayGit: GitExec = async () => ({ stdout: '', stderr: '' }) + const relayStreamGit: RelayGitStreamExec = async () => { + await wait(BENCH_DELAY_MS) + return { stoppedEarly: false } + } + const cases = [ + { name: 'native', run: () => getStatus('/repo') }, + { + name: 'relay', + run: () => getStatusOp(relayGit, relayStreamGit, { worktreePath: '/repo' }) + } + ] + + const results: BenchmarkResult[] = [] + for (const benchmarkCase of cases) { + for (let index = 0; index < BENCH_WARMUPS; index += 1) { + await benchmarkCase.run() + } + const samples: number[] = [] + for (let index = 0; index < BENCH_SAMPLES; index += 1) { + const startedAt = performance.now() + await benchmarkCase.run() + samples.push(performance.now() - startedAt) + } + results.push({ + path: benchmarkCase.name, + medianMs: Number(percentile(samples, 0.5).toFixed(3)), + p95Ms: Number(percentile(samples, 0.95).toFixed(3)), + minMs: Number(Math.min(...samples).toFixed(3)), + maxMs: Number(Math.max(...samples).toFixed(3)) + }) + } + + console.table(results) + expect(results).toHaveLength(2) + }, 10_000) +}) diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 07bf124d31c..a51d1784ddf 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -315,22 +315,32 @@ async function runGetStatus( // Why: stream + parse and stop at `limit` so a huge un-ignored folder can't buffer enough to crash the process. const parser = new StatusPorcelainParser() let didHitLimit = false + // Why: attach rejection ownership before awaiting marker I/O, so a fast Git failure cannot become unhandled. + const statusSettlementPromise = Promise.allSettled([ + (async () => { + const result = await gitStreamStdout(statusArgs, { + cwd: worktreePath, + wslDistro: options.wslDistro, + preferWslDirectGit: true, + // Why: status polling is read-like; disable optional locks to avoid racing terminal Git on index.lock. + env: gitOptionalLocksDisabledEnv(), + signal: options.signal, + onStdout: (chunk) => parser.update(chunk, limit) + }) + if (!result.stoppedEarly) { + parser.finish() + } + return result + })() + ]) const conflictOperation = await conflictPromise try { - const { stoppedEarly } = await gitStreamStdout(statusArgs, { - cwd: worktreePath, - wslDistro: options.wslDistro, - preferWslDirectGit: true, - // Why: status polling is read-like; disable optional locks to avoid racing terminal Git on index.lock. - env: gitOptionalLocksDisabledEnv(), - signal: options.signal, - onStdout: (chunk) => parser.update(chunk, limit) - }) - if (!stoppedEarly) { - parser.finish() + const [statusResult] = await statusSettlementPromise + if (statusResult.status === 'rejected') { + throw statusResult.reason } - didHitLimit = stoppedEarly + didHitLimit = statusResult.value.stoppedEarly statusSucceeded = true } catch (error) { // Why: an aborted scan must reject, not resolve as an empty result. diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index 064eb0887fd..9ee8eb2e188 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -30,7 +30,7 @@ import { isUnsupportedRevParsePathFormatError, isUnsupportedWorktreeListZError } from '../../shared/git-worktree-command-capabilities' -import { getLocalGitCapabilityCache } from './git-capability-state' +import { withLocalGitCapabilityCacheForExecution } from './git-capability-state' import { gitExecFileAsync, translateWslOutputPaths } from './runner' import { resolveGitDir, runWithGitReadCacheInvalidation } from './status' import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe' @@ -410,32 +410,32 @@ async function readRepoLocation( resolveBasePath: string, options: GitWorktreeExecOptions = {} ): Promise { - const capabilities = getLocalGitCapabilityCache({ - cwd: repoPath, - wslDistro: options.wslDistro - }) try { - return await capabilities.runWithFallback( - 'rev-parse-path-format', - async () => { - const { stdout } = await gitExecFileAsync( - ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'], - gitExecOptions(repoPath, options) + return await withLocalGitCapabilityCacheForExecution( + { cwd: repoPath, wslDistro: options.wslDistro, signal: options.signal }, + (capabilities) => + capabilities.runWithFallback( + 'rev-parse-path-format', + async () => { + const { stdout } = await gitExecFileAsync( + ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'], + gitExecOptions(repoPath, options) + ) + if (hasUnsupportedRevParsePathFormatEcho(stdout)) { + // Why: some old Git echoes the unknown option and exits zero; remember that compat signal even though parsing recovers. + capabilities.rememberUnsupported('rev-parse-path-format') + } + return parseRepoLocation(resolveBasePath, stdout) + }, + async () => { + const { stdout } = await gitExecFileAsync( + ['rev-parse', '--show-toplevel', '--git-common-dir'], + gitExecOptions(repoPath, options) + ) + return parseRepoLocation(resolveBasePath, stdout) + }, + isUnsupportedRevParsePathFormatError ) - if (hasUnsupportedRevParsePathFormatEcho(stdout)) { - // Why: some old Git echoes the unknown option and exits zero; remember that compat signal even though parsing recovers. - capabilities.rememberUnsupported('rev-parse-path-format') - } - return parseRepoLocation(resolveBasePath, stdout) - }, - async () => { - const { stdout } = await gitExecFileAsync( - ['rev-parse', '--show-toplevel', '--git-common-dir'], - gitExecOptions(repoPath, options) - ) - return parseRepoLocation(resolveBasePath, stdout) - }, - isUnsupportedRevParsePathFormatError ) } catch { return undefined @@ -578,41 +578,44 @@ async function readWorktreeList( repoPath: string, options: GitWorktreeExecOptions = {} ): Promise { - const capabilities = getLocalGitCapabilityCache({ - cwd: repoPath, - wslDistro: options.wslDistro - }) const execOptions = { cwd: repoPath, ...options, timeout: options.timeout ?? WORKTREE_LIST_TIMEOUT_MS } - return capabilities.runWithFallback( - 'worktree-list-z', - async () => { - const { stdout } = await gitExecFileAsync( - ['worktree', 'list', '--porcelain', '-z'], - execOptions + return withLocalGitCapabilityCacheForExecution( + { cwd: repoPath, wslDistro: options.wslDistro, signal: options.signal }, + (capabilities) => + capabilities.runWithFallback( + 'worktree-list-z', + async () => { + const { stdout } = await gitExecFileAsync( + ['worktree', 'list', '--porcelain', '-z'], + execOptions + ) + return normalizeMainWorktreePath( + repoPath, + parseWorktreeList(stdout, { nulDelimited: true }), + options + ) + }, + async () => { + // Why: `-z` preserves worktree paths with newlines but Git <2.36 rejects it; fall back to the line parser. + const { stdout } = await gitExecFileAsync( + ['worktree', 'list', '--porcelain'], + execOptions + ) + const normalized = await normalizeMainWorktreePath( + repoPath, + parseWorktreeList(stdout), + options + ) + // Why: Git <2.31 emits no `prunable`, so probe each linked path for existence instead of trusting + // stale registrations; a harmless backstop on 2.31–2.35 where parseWorktreeList already set it (#8389). + return annotatePrunableByExistence(normalized, repoPath, options) + }, + isUnsupportedWorktreeListZError ) - return normalizeMainWorktreePath( - repoPath, - parseWorktreeList(stdout, { nulDelimited: true }), - options - ) - }, - async () => { - // Why: `-z` preserves worktree paths with newlines but Git <2.36 rejects it; fall back to the line parser. - const { stdout } = await gitExecFileAsync(['worktree', 'list', '--porcelain'], execOptions) - const normalized = await normalizeMainWorktreePath( - repoPath, - parseWorktreeList(stdout), - options - ) - // Why: Git <2.31 emits no `prunable`, so probe each linked path for existence instead of trusting - // stale registrations; a harmless backstop on 2.31–2.35 where parseWorktreeList already set it (#8389). - return annotatePrunableByExistence(normalized, repoPath, options) - }, - isUnsupportedWorktreeListZError ) } @@ -1355,14 +1358,12 @@ async function deleteAlreadyMergedBranchAfterSafeDeleteFailure( }) const targetRefs = await getBranchCleanupTargetRefs(runGit, branchName) // Why: squash merges rewrite commit IDs, so `branch -d` rejects already-merged branches; delete only when Git proves no unmerged tree changes. - if ( - !(await branchHasNoUnmergedChangesWithLazyTargetRefresh( - runGit, - branchName, - targetRefs, - getLocalGitCapabilityCache({ cwd: repoPath, wslDistro: options.wslDistro }) - )) - ) { + const hasNoUnmergedChanges = await withLocalGitCapabilityCacheForExecution( + { cwd: repoPath, wslDistro: options.wslDistro, signal: options.signal }, + (capabilities) => + branchHasNoUnmergedChangesWithLazyTargetRefresh(runGit, branchName, targetRefs, capabilities) + ) + if (!hasNoUnmergedChanges) { return false } await forceDeleteLocalBranch(repoPath, branchName, branchHead, (args, cwd) => diff --git a/src/main/git/wsl-linked-worktree-git-routing.test.ts b/src/main/git/wsl-linked-worktree-git-routing.test.ts new file mode 100644 index 00000000000..9ab07309b1f --- /dev/null +++ b/src/main/git/wsl-linked-worktree-git-routing.test.ts @@ -0,0 +1,600 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + parseWindowsLinkedGitdir, + prepareWslLinkedWorktreeGitRouting, + resetWslLinkedWorktreeGitRoutingForTests, + seedWslLinkedWorktreeGitRoutingForTests, + usesHostGitForWslLinkedWorktree, + WSL_LINKED_WORKTREE_ROUTE_CACHE_PRUNE_THRESHOLD, + WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_PER_CWD, + WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_TOTAL, + WSL_LINKED_WORKTREE_ROUTE_PROBE_TIMEOUT_MS, + WSL_LINKED_WORKTREE_ROUTE_RETRY_BASE_MS, + WSL_LINKED_WORKTREE_ROUTE_TTL_MS, + type WslLinkedWorktreeRoutingFileSystem +} from './wsl-linked-worktree-git-routing' + +afterEach(() => resetWslLinkedWorktreeGitRoutingForTests()) + +const fileMarker = { isDirectory: () => false, isFile: () => true } +const directoryMarker = { isDirectory: () => true, isFile: () => false } + +function missingMarker(): NodeJS.ErrnoException { + return Object.assign(new Error('missing'), { code: 'ENOENT' }) +} + +describe('parseWindowsLinkedGitdir', () => { + it.each([ + ['gitdir: C:/repo/.git/worktrees/linked\n', 'C:/repo/.git/worktrees/linked'], + [String.raw`gitdir: D:\repo\.git\worktrees\linked`, String.raw`D:\repo\.git\worktrees\linked`] + ])('accepts a Windows drive-qualified gitdir', (content, expected) => { + expect(parseWindowsLinkedGitdir(content)).toBe(expected) + }) + + it.each([ + 'gitdir: ../main/.git/worktrees/linked', + 'gitdir: /home/dev/repo/.git/worktrees/linked', + 'gitdir: C:repo/.git/worktrees/linked', + 'not a gitdir' + ])('rejects the non-Windows-linked marker %s', (content) => { + expect(parseWindowsLinkedGitdir(content)).toBeNull() + }) +}) + +describe('usesHostGitForWslLinkedWorktree', () => { + it('scopes a cached route to the exact primed folder workspace', () => { + seedWslLinkedWorktreeGitRoutingForTests(String.raw`C:\repo\linked\packages\app`) + + expect( + usesHostGitForWslLinkedWorktree(String.raw`C:\repo\linked\packages\app`, 'Ubuntu', 'win32') + ).toBe(true) + expect(usesHostGitForWslLinkedWorktree('C:/repo/linked/packages/app/', 'Ubuntu', 'win32')).toBe( + true + ) + expect(usesHostGitForWslLinkedWorktree(String.raw`C:\repo\main`, 'Ubuntu', 'win32')).toBe(false) + }) + + it('does not affect native Windows, WSL-native, or non-Windows execution', () => { + seedWslLinkedWorktreeGitRoutingForTests(String.raw`C:\repo\linked`) + + expect(usesHostGitForWslLinkedWorktree(String.raw`C:\repo\linked`, undefined, 'win32')).toBe( + false + ) + expect( + usesHostGitForWslLinkedWorktree( + '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo', + 'Ubuntu', + 'win32' + ) + ).toBe(false) + expect(usesHostGitForWslLinkedWorktree(String.raw`C:\repo\linked`, 'Ubuntu', 'linux')).toBe( + false + ) + }) +}) + +describe('prepareWslLinkedWorktreeGitRouting', () => { + it('discovers parent markers independently for sibling folder workspaces', async () => { + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async (path) => { + if (path === String.raw`C:\repo\.git`) { + return fileMarker + } + throw missingMarker() + }), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo\packages\app`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(true) + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo\packages\other`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(true) + expect(fileSystem.stat).toHaveBeenCalledTimes(6) + expect(fileSystem.readFile).toHaveBeenCalledTimes(2) + }) + + it('caches an ordinary repository marker without reading it', async () => { + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async () => directoryMarker), + readFile: vi.fn(async () => '') + } + + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(false) + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(false) + expect(fileSystem.stat).toHaveBeenCalledTimes(1) + expect(fileSystem.readFile).not.toHaveBeenCalled() + }) + + it('fails closed without caching a marker read error', async () => { + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async () => fileMarker), + readFile: vi + .fn() + .mockRejectedValueOnce(Object.assign(new Error('access denied'), { code: 'EACCES' })) + .mockResolvedValueOnce('gitdir: C:/main/.git/worktrees/linked\n') + } + + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(false) + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(true) + expect(fileSystem.stat).toHaveBeenCalledTimes(2) + expect(fileSystem.readFile).toHaveBeenCalledTimes(2) + }) + + it('backs off repeated marker stat errors after one immediate retry', async () => { + let currentTime = 1_000 + const now = (): number => currentTime + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async () => { + throw Object.assign(new Error('device unavailable'), { code: 'EIO' }) + }), + readFile: vi.fn(async () => '') + } + const prepare = (): Promise => + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + now + }) + + await expect(prepare()).resolves.toBe(false) + await expect(prepare()).resolves.toBe(false) + await expect(prepare()).resolves.toBe(false) + currentTime += WSL_LINKED_WORKTREE_ROUTE_RETRY_BASE_MS - 1 + await expect(prepare()).resolves.toBe(false) + expect(fileSystem.stat).toHaveBeenCalledTimes(2) + + currentTime += 1 + await expect(prepare()).resolves.toBe(false) + expect(fileSystem.stat).toHaveBeenCalledTimes(3) + }) + + it('does not inherit a linked-parent route across a nested repository boundary', async () => { + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async (path) => + path === String.raw`C:\repo\linked\nested\.git` ? directoryMarker : fileMarker + ), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo\linked`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(true) + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo\linked\nested`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(false) + expect( + usesHostGitForWslLinkedWorktree(String.raw`C:\repo\linked\nested`, 'Ubuntu', 'win32') + ).toBe(false) + }) + + it('does not conflate case-distinct directories on case-sensitive Windows storage', async () => { + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async (path) => + path === String.raw`C:\Repo\.git` ? fileMarker : directoryMarker + ), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\Repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(true) + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(false) + }) + + it('revalidates a linked route that becomes an ordinary repository', async () => { + let linked = true + let currentTime = 1_000 + const now = (): number => currentTime + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async () => (linked ? fileMarker : directoryMarker)), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + now + }) + ).resolves.toBe(true) + linked = false + currentTime += WSL_LINKED_WORKTREE_ROUTE_TTL_MS - 1 + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + now + }) + ).resolves.toBe(true) + expect(fileSystem.stat).toHaveBeenCalledTimes(1) + + currentTime += 1 + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + now + }) + ).resolves.toBe(false) + expect(usesHostGitForWslLinkedWorktree(String.raw`C:\repo`, 'Ubuntu', 'win32', now)).toBe(false) + expect(fileSystem.stat).toHaveBeenCalledTimes(2) + expect(fileSystem.readFile).toHaveBeenCalledTimes(1) + }) + + it('revalidates an ordinary repository that becomes a linked worktree', async () => { + let linked = false + let currentTime = 1_000 + const now = (): number => currentTime + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async () => (linked ? fileMarker : directoryMarker)), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + now + }) + ).resolves.toBe(false) + linked = true + currentTime += WSL_LINKED_WORKTREE_ROUTE_TTL_MS - 1 + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + now + }) + ).resolves.toBe(false) + expect(fileSystem.stat).toHaveBeenCalledTimes(1) + + currentTime += 1 + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + now + }) + ).resolves.toBe(true) + expect(usesHostGitForWslLinkedWorktree(String.raw`C:\repo`, 'Ubuntu', 'win32', now)).toBe(true) + expect(fileSystem.stat).toHaveBeenCalledTimes(2) + expect(fileSystem.readFile).toHaveBeenCalledTimes(1) + }) + + it('does not let a sibling route supersede an unexpired nested-repository miss', async () => { + let linked = false + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async (path) => { + if (path === String.raw`C:\repo\.git`) { + return linked ? fileMarker : directoryMarker + } + throw missingMarker() + }), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo\packages\app`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(false) + linked = true + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo\packages\other`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(true) + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo\packages\app`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(false) + expect(fileSystem.stat).toHaveBeenCalledTimes(6) + }) + + it('does not evict fresh routes while pruning under working-directory churn', async () => { + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async () => fileMarker), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + + for (let index = 0; index <= WSL_LINKED_WORKTREE_ROUTE_CACHE_PRUNE_THRESHOLD; index += 1) { + await prepareWslLinkedWorktreeGitRouting(`C:\\repo-${index}`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + } + + expect(usesHostGitForWslLinkedWorktree(String.raw`C:\repo-0`, 'Ubuntu', 'win32')).toBe(true) + expect( + usesHostGitForWslLinkedWorktree( + `C:\\repo-${WSL_LINKED_WORKTREE_ROUTE_CACHE_PRUNE_THRESHOLD}`, + 'Ubuntu', + 'win32' + ) + ).toBe(true) + }) + + it('coalesces delayed discovery without blocking an event-loop turn', async () => { + let releaseStat: ((marker: typeof fileMarker) => void) | undefined + const delayedStat = new Promise((resolve) => { + releaseStat = resolve + }) + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(() => delayedStat), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + let settled = false + + const first = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }).finally(() => { + settled = true + }) + const second = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + await new Promise((resolve) => setImmediate(resolve)) + + expect(settled).toBe(false) + expect(fileSystem.stat).toHaveBeenCalledTimes(1) + releaseStat?.(fileMarker) + await expect(Promise.all([first, second])).resolves.toEqual([true, true]) + expect(fileSystem.readFile).toHaveBeenCalledTimes(1) + }) + + it('cancels a waiter without canceling or duplicating shared discovery', async () => { + let releaseStat: ((marker: typeof fileMarker) => void) | undefined + const delayedStat = new Promise((resolve) => { + releaseStat = resolve + }) + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(() => delayedStat), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + const controller = new AbortController() + + const cancelled = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + signal: controller.signal + }) + controller.abort() + + await expect(cancelled).rejects.toMatchObject({ name: 'AbortError' }) + expect(fileSystem.stat).toHaveBeenCalledTimes(1) + releaseStat?.(fileMarker) + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(true) + expect(fileSystem.stat).toHaveBeenCalledTimes(1) + }) + + it('ignores an old probe that settles after reset and a new same-path probe starts', async () => { + let releaseOld: ((marker: typeof fileMarker) => void) | undefined + const oldMarker = new Promise((resolve) => { + releaseOld = resolve + }) + const oldFileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(() => oldMarker), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + const oldRoute = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem: oldFileSystem + }) + + resetWslLinkedWorktreeGitRoutingForTests() + let releaseCurrent: ((marker: typeof directoryMarker) => void) | undefined + const currentMarker = new Promise((resolve) => { + releaseCurrent = resolve + }) + const currentFileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(() => currentMarker), + readFile: vi.fn(async () => '') + } + const currentRoute = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem: currentFileSystem + }) + + releaseOld?.(fileMarker) + await expect(oldRoute).resolves.toBe(false) + expect(usesHostGitForWslLinkedWorktree(String.raw`C:\repo`, 'Ubuntu', 'win32')).toBe(false) + const currentJoiner = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem: currentFileSystem + }) + expect(currentFileSystem.stat).toHaveBeenCalledTimes(1) + + releaseCurrent?.(directoryMarker) + await expect(Promise.all([currentRoute, currentJoiner])).resolves.toEqual([false, false]) + expect(currentFileSystem.stat).toHaveBeenCalledTimes(1) + }) + + it('fails closed and retries after a stalled discovery deadline', async () => { + vi.useFakeTimers() + try { + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi + .fn() + .mockImplementationOnce(() => new Promise(() => {})) + .mockResolvedValueOnce(fileMarker), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + + const first = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + const second = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + await vi.advanceTimersByTimeAsync(WSL_LINKED_WORKTREE_ROUTE_PROBE_TIMEOUT_MS) + + await expect(Promise.all([first, second])).resolves.toEqual([false, false]) + expect(fileSystem.stat).toHaveBeenCalledTimes(1) + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(true) + expect(fileSystem.stat).toHaveBeenCalledTimes(2) + expect(fileSystem.readFile).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('caps outstanding probes for one working directory after repeated deadlines', async () => { + vi.useFakeTimers() + try { + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(() => new Promise(() => {})), + readFile: vi.fn(async () => '') + } + const prepare = (): Promise => + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + + for (let index = 0; index < WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_PER_CWD; index += 1) { + const route = prepare() + await vi.advanceTimersByTimeAsync(WSL_LINKED_WORKTREE_ROUTE_PROBE_TIMEOUT_MS) + await expect(route).resolves.toBe(false) + } + await vi.advanceTimersByTimeAsync(WSL_LINKED_WORKTREE_ROUTE_RETRY_BASE_MS) + await expect(prepare()).resolves.toBe(false) + expect(fileSystem.stat).toHaveBeenCalledTimes(WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_PER_CWD) + } finally { + vi.useRealTimers() + } + }) + + it('caps outstanding probes across working directories', async () => { + vi.useFakeTimers() + try { + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(() => new Promise(() => {})), + readFile: vi.fn(async () => '') + } + const pending = Array.from( + { length: WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_TOTAL }, + (_, index) => + prepareWslLinkedWorktreeGitRouting(`C:\\repo-${index}`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ) + + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\blocked`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(false) + expect(fileSystem.stat).toHaveBeenCalledTimes(WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_TOTAL) + await vi.advanceTimersByTimeAsync(WSL_LINKED_WORKTREE_ROUTE_PROBE_TIMEOUT_MS) + await expect(Promise.all(pending)).resolves.toEqual( + Array.from({ length: WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_TOTAL }, () => false) + ) + } finally { + vi.useRealTimers() + } + }) + + it('coalesces concurrent revalidation after a cached route expires', async () => { + let currentTime = 1_000 + const now = (): number => currentTime + let releaseStat: ((marker: typeof fileMarker) => void) | undefined + const delayedStat = new Promise((resolve) => { + releaseStat = resolve + }) + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi + .fn() + .mockResolvedValueOnce(directoryMarker) + .mockReturnValueOnce(delayedStat), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + now + }) + ).resolves.toBe(false) + currentTime += WSL_LINKED_WORKTREE_ROUTE_TTL_MS + + const first = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + now + }) + const second = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + now + }) + + expect(fileSystem.stat).toHaveBeenCalledTimes(2) + releaseStat?.(fileMarker) + await expect(Promise.all([first, second])).resolves.toEqual([true, true]) + expect(fileSystem.stat).toHaveBeenCalledTimes(2) + expect(fileSystem.readFile).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/git/wsl-linked-worktree-git-routing.ts b/src/main/git/wsl-linked-worktree-git-routing.ts new file mode 100644 index 00000000000..440307fd5f0 --- /dev/null +++ b/src/main/git/wsl-linked-worktree-git-routing.ts @@ -0,0 +1,306 @@ +import * as fsPromises from 'node:fs/promises' +import { win32 } from 'node:path' +import type { Stats } from 'node:fs' + +type CachedRoute = { usesHostGit: boolean; expiresAt: number } +type TransientRouteFailure = { count: number; retryAfter: number } + +// Bounds stale routing after delete/recreate while amortizing async parent walks on slow storage. +export const WSL_LINKED_WORKTREE_ROUTE_TTL_MS = 30_000 +export const WSL_LINKED_WORKTREE_ROUTE_CACHE_PRUNE_THRESHOLD = 512 +export const WSL_LINKED_WORKTREE_ROUTE_PROBE_TIMEOUT_MS = 5_000 +export const WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_PER_CWD = 2 +export const WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_TOTAL = 32 +export const WSL_LINKED_WORKTREE_ROUTE_RETRY_BASE_MS = 1_000 +const WSL_LINKED_WORKTREE_ROUTE_RETRY_MAX_MS = 30_000 + +const routeByCwd = new Map() +const pendingRoutes = new Map>() +const transientFailuresByCwd = new Map() +const activeProbeCountByCwd = new Map() +let activeProbeCount = 0 +let routeProbeGeneration = 0 +let nextRoutePruneAt = 0 + +export function isWslLinkedWorktreeGitRoutingCandidate( + cwd: string, + wslDistro: string | undefined, + platform: NodeJS.Platform = process.platform +): boolean { + return platform === 'win32' && Boolean(wslDistro) && /^[A-Za-z]:[/\\]/.test(cwd) +} + +function normalize(path: string): string { + return win32.resolve(path) +} + +function cachedRoute(cwd: string, now: number): boolean | undefined { + const exact = routeByCwd.get(cwd) + if (!exact) { + return undefined + } + if (exact.expiresAt <= now) { + routeByCwd.delete(cwd) + return undefined + } + return exact.usesHostGit +} + +export function parseWindowsLinkedGitdir(content: string): string | null { + const firstLine = content.split(/\r?\n/, 1)[0] ?? '' + return firstLine.match(/^gitdir:\s*([A-Za-z]:[/\\].*?)\s*$/i)?.[1] ?? null +} + +export type WslLinkedWorktreeRoutingFileSystem = { + stat(path: string): Promise> + readFile(path: string): Promise +} + +export type WslLinkedWorktreeRoutingOptions = { + platform?: NodeJS.Platform + fileSystem?: WslLinkedWorktreeRoutingFileSystem + now?: () => number + signal?: AbortSignal +} + +const defaultFileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: (path) => fsPromises.stat(path), + readFile: (path) => fsPromises.readFile(path, 'utf8') +} + +async function shouldUseHostGit( + cwd: string, + fileSystem: WslLinkedWorktreeRoutingFileSystem +): Promise { + let candidate = cwd + const driveRoot = win32.parse(candidate).root + while (true) { + const markerPath = win32.join(candidate, '.git') + try { + const marker = await fileSystem.stat(markerPath) + if (marker.isDirectory()) { + return false + } + if (marker.isFile()) { + return parseWindowsLinkedGitdir(await fileSystem.readFile(markerPath)) !== null + } + return false + } catch (error) { + const code = error && typeof error === 'object' ? (error as NodeJS.ErrnoException).code : null + if (code !== 'ENOENT' && code !== 'ENOTDIR') { + throw error + } + } + if (candidate === driveRoot) { + return false + } + candidate = win32.dirname(candidate) + } +} + +function rememberRoute(cwd: string, usesHostGit: boolean, now: number): boolean { + const expiresAt = now + WSL_LINKED_WORKTREE_ROUTE_TTL_MS + if ( + routeByCwd.size >= WSL_LINKED_WORKTREE_ROUTE_CACHE_PRUNE_THRESHOLD && + now >= nextRoutePruneAt + ) { + for (const [cachedCwd, route] of routeByCwd) { + if (route.expiresAt <= now) { + routeByCwd.delete(cachedCwd) + } + } + nextRoutePruneAt = now + WSL_LINKED_WORKTREE_ROUTE_TTL_MS + } + routeByCwd.set(cwd, { usesHostGit, expiresAt }) + return usesHostGit +} + +function routingAbortError(): Error { + return Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' }) +} + +function rememberTransientFailure(cwd: string, now: number): void { + const count = (transientFailuresByCwd.get(cwd)?.count ?? 0) + 1 + const retryDelay = + count === 1 + ? 0 + : Math.min( + WSL_LINKED_WORKTREE_ROUTE_RETRY_BASE_MS * 2 ** Math.min(count - 2, 10), + WSL_LINKED_WORKTREE_ROUTE_RETRY_MAX_MS + ) + if ( + !transientFailuresByCwd.has(cwd) && + transientFailuresByCwd.size >= WSL_LINKED_WORKTREE_ROUTE_CACHE_PRUNE_THRESHOLD + ) { + const oldest = transientFailuresByCwd.keys().next().value + if (oldest !== undefined) { + transientFailuresByCwd.delete(oldest) + } + } + transientFailuresByCwd.delete(cwd) + transientFailuresByCwd.set(cwd, { count, retryAfter: now + retryDelay }) +} + +function canStartRouteProbe(cwd: string, now: number): boolean { + const failure = transientFailuresByCwd.get(cwd) + return Boolean( + (!failure || failure.retryAfter <= now) && + (activeProbeCountByCwd.get(cwd) ?? 0) < WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_PER_CWD && + activeProbeCount < WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_TOTAL + ) +} + +function trackRouteProbe(cwd: string): () => void { + const generation = routeProbeGeneration + activeProbeCount += 1 + activeProbeCountByCwd.set(cwd, (activeProbeCountByCwd.get(cwd) ?? 0) + 1) + return () => { + if (generation !== routeProbeGeneration) { + return + } + activeProbeCount -= 1 + const remaining = (activeProbeCountByCwd.get(cwd) ?? 1) - 1 + if (remaining === 0) { + activeProbeCountByCwd.delete(cwd) + } else { + activeProbeCountByCwd.set(cwd, remaining) + } + } +} + +function waitForRouteUnlessAborted( + route: Promise, + signal?: AbortSignal +): Promise { + if (!signal) { + return route + } + if (signal.aborted) { + return Promise.reject(routingAbortError()) + } + return new Promise((resolve, reject) => { + const onAbort = (): void => { + signal.removeEventListener('abort', onAbort) + reject(routingAbortError()) + } + signal.addEventListener('abort', onAbort, { once: true }) + route.then( + (result) => { + signal.removeEventListener('abort', onAbort) + resolve(result) + }, + (error) => { + signal.removeEventListener('abort', onAbort) + reject(error) + } + ) + }) +} + +function discoverRoute( + cwd: string, + fileSystem: WslLinkedWorktreeRoutingFileSystem, + now: () => number +): Promise { + return new Promise((resolve) => { + const generation = routeProbeGeneration + let settled = false + const finish = (usesHostGit: boolean, cacheable: boolean): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + if (generation !== routeProbeGeneration) { + resolve(false) + return + } + if (cacheable) { + transientFailuresByCwd.delete(cwd) + resolve(rememberRoute(cwd, usesHostGit, now())) + } else { + rememberTransientFailure(cwd, now()) + resolve(usesHostGit) + } + } + const timer = setTimeout(() => finish(false, false), WSL_LINKED_WORKTREE_ROUTE_PROBE_TIMEOUT_MS) + timer.unref() + const releaseProbe = trackRouteProbe(cwd) + void shouldUseHostGit(cwd, fileSystem).then( + (usesHostGit) => { + releaseProbe() + finish(usesHostGit, true) + }, + () => { + releaseProbe() + finish(false, false) + } + ) + }) +} + +export async function prepareWslLinkedWorktreeGitRouting( + cwd: string, + wslDistro: string | undefined, + options: WslLinkedWorktreeRoutingOptions = {} +): Promise { + const platform = options.platform ?? process.platform + const fileSystem = options.fileSystem ?? defaultFileSystem + const now = options.now ?? Date.now + if (!isWslLinkedWorktreeGitRoutingCandidate(cwd, wslDistro, platform)) { + return false + } + if (options.signal?.aborted) { + throw routingAbortError() + } + const normalizedCwd = normalize(cwd) + const cached = cachedRoute(normalizedCwd, now()) + if (cached !== undefined) { + return cached + } + const pending = pendingRoutes.get(normalizedCwd) + if (pending) { + return waitForRouteUnlessAborted(pending, options.signal) + } + if (!canStartRouteProbe(normalizedCwd, now())) { + return false + } + const discovery = discoverRoute(normalizedCwd, fileSystem, now) + const route = discovery.finally(() => { + if (pendingRoutes.get(normalizedCwd) === route) { + pendingRoutes.delete(normalizedCwd) + } + }) + pendingRoutes.set(normalizedCwd, route) + return waitForRouteUnlessAborted(route, options.signal) +} + +export function usesHostGitForWslLinkedWorktree( + cwd: string, + wslDistro: string | undefined, + platform: NodeJS.Platform = process.platform, + now: () => number = Date.now +): boolean { + return ( + isWslLinkedWorktreeGitRoutingCandidate(cwd, wslDistro, platform) && + cachedRoute(normalize(cwd), now()) === true + ) +} + +export function resetWslLinkedWorktreeGitRoutingForTests(): void { + routeByCwd.clear() + pendingRoutes.clear() + transientFailuresByCwd.clear() + activeProbeCountByCwd.clear() + activeProbeCount = 0 + routeProbeGeneration += 1 + nextRoutePruneAt = 0 +} + +export function seedWslLinkedWorktreeGitRoutingForTests(root: string): void { + const normalizedRoot = normalize(root) + routeByCwd.set(normalizedRoot, { + usesHostGit: true, + expiresAt: Number.POSITIVE_INFINITY + }) +} diff --git a/src/main/github/client-pr-check-details.test.ts b/src/main/github/client-pr-check-details.test.ts index b092b01eb94..4bb2fcbae76 100644 --- a/src/main/github/client-pr-check-details.test.ts +++ b/src/main/github/client-pr-check-details.test.ts @@ -148,20 +148,86 @@ describe('getPRCheckDetails', () => { expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 1, ['api', 'repos/acme/widgets/check-runs/88'], - { cwd: '/repo-root', host: 'github.com' } + expect.objectContaining({ + cwd: '/repo-root', + host: 'github.com', + signal: expect.any(AbortSignal) + }) ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['api', 'repos/acme/widgets/check-runs/88/annotations?per_page=20'], - { cwd: '/repo-root', host: 'github.com' } + expect.objectContaining({ + cwd: '/repo-root', + host: 'github.com', + signal: expect.any(AbortSignal) + }) ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, ['api', 'repos/acme/widgets/actions/runs/77/jobs?per_page=100'], - { cwd: '/repo-root', host: 'github.com' } + expect.objectContaining({ + cwd: '/repo-root', + host: 'github.com', + signal: expect.any(AbortSignal) + }) ) }) + it('rejects provider failures so callers can distinguish them from unavailable details', async () => { + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockRejectedValueOnce(new Error('authentication failed')) + + await expect(getPRCheckDetails('/repo-root', { checkRunId: 88 })).rejects.toThrow( + 'authentication failed' + ) + }) + + it('aborts host work at the cumulative check-details deadline', async () => { + vi.useFakeTimers() + try { + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockImplementation( + (_args: string[], options: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + options.signal?.addEventListener( + 'abort', + () => { + const error = new Error('aborted') + error.name = 'AbortError' + reject(error) + }, + { once: true } + ) + }) + ) + const request = getPRCheckDetails('/repo-root', { checkRunId: 88 }) + const rejection = expect(request).rejects.toThrow('Timed out loading check details.') + + await vi.advanceTimersByTimeAsync(25_000) + + await rejection + } finally { + vi.useRealTimers() + } + }) + + it('stops waiting for shared repository resolution at the host deadline', async () => { + vi.useFakeTimers() + try { + getOwnerRepoMock.mockImplementationOnce(() => new Promise(() => {})) + const request = getPRCheckDetails('/repo-root', { checkRunId: 88 }) + const rejection = expect(request).rejects.toThrow('Timed out loading check details.') + + await vi.advanceTimersByTimeAsync(25_000) + + await rejection + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + it('fetches sliced log tails for failed workflow jobs only', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) const actionUrl = 'https://github.com/acme/widgets/actions/runs/77/job/88' @@ -225,7 +291,11 @@ describe('getPRCheckDetails', () => { expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 4, ['api', 'repos/acme/widgets/actions/jobs/8801/logs'], - { cwd: '/repo-root', host: 'github.com' } + expect.objectContaining({ + cwd: '/repo-root', + host: 'github.com', + signal: expect.any(AbortSignal) + }) ) expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(4) }) diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index a33b9b38d20..b462b77a9cd 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -91,7 +91,8 @@ vi.mock('./gh-utils', () => ({ })) vi.mock('../git/runner', () => ({ - gitExecFileAsync: gitExecFileAsyncMock + gitExecFileAsync: gitExecFileAsyncMock, + ghExecFileAsync: ghExecFileAsyncMock })) vi.mock('../providers/ssh-git-dispatch', () => ({ @@ -186,6 +187,7 @@ import { _getTrackedUpstreamBranchCacheSizesForTests, _resetOwnerRepoCache, _resetMergeQueueCacheForTests, + _resetPRStackSummaryCacheForTests, __resetTrackedUpstreamBranchCacheForTests } from './client' import { __resetPRConflictSummaryCachesForTests } from './conflict-summary' @@ -193,6 +195,7 @@ import { resetMergedPRCommitMembershipCacheForTest } from './merged-pr-commit-me import { __resetRepoDefaultBranchCacheForTests } from '../source-control/repo-default-branch' import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' +import { _resetGitHubPRStackCacheForTests } from './github-pr-stack' // The origin-repository cache is module-level state; reset it so slugs // resolved by one test cannot leak into the next. @@ -273,6 +276,8 @@ describe('getPRForBranch', () => { acquireMock.mockResolvedValue(undefined) _resetOwnerRepoCache() _resetMergeQueueCacheForTests() + _resetPRStackSummaryCacheForTests() + _resetGitHubPRStackCacheForTests() __resetTrackedUpstreamBranchCacheForTests() __resetPRConflictSummaryCachesForTests() resetMergedPRCommitMembershipCacheForTest() @@ -431,7 +436,7 @@ describe('getPRForBranch', () => { const pr = await getPRForBranch('/repo-root', 'feature/local-worktree', 99) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) expect(gitExecFileAsyncMock).not.toHaveBeenCalled() expect(ghExecFileAsyncMock).toHaveBeenCalledWith( [ @@ -453,6 +458,81 @@ describe('getPRForBranch', () => { }) }) + it('caches exact REST stack probes across linked PR refreshes', async () => { + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'pr') { + return { + stdout: JSON.stringify({ + number: 99, + title: 'Linked PR', + state: 'OPEN', + url: 'https://github.com/acme/widgets/pull/99', + statusCheckRollup: [], + updatedAt: '2026-03-28T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + baseRefName: 'main', + headRefName: 'feature', + headRefOid: 'head-oid' + }) + } + } + return { + stdout: JSON.stringify({ + number: 99, + title: 'Linked PR', + state: 'open', + stack: null + }) + } + }) + + await getPRForBranch('/repo-root', 'feature', 99) + await getPRForBranch('/repo-root', 'feature', 99) + await getPRForBranch('/repo-root', 'feature', 99, 'ssh-1') + + expect( + ghExecFileAsyncMock.mock.calls.filter( + ([args]) => args[0] === 'api' && args[1]?.includes('/99') + ) + ).toHaveLength(2) + expect(ghExecFileAsyncMock.mock.calls.filter(([args]) => args[0] === 'pr')).toHaveLength(3) + }) + + it('caches failed REST stack probes across linked PR refreshes', async () => { + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'pr') { + return { + stdout: JSON.stringify({ + number: 99, + title: 'Linked PR', + state: 'OPEN', + url: 'https://github.com/acme/widgets/pull/99', + statusCheckRollup: [], + updatedAt: '2026-03-28T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + baseRefName: 'main', + headRefName: 'feature', + headRefOid: 'head-oid' + }) + } + } + throw new Error('GitHub is temporarily unavailable') + }) + + await getPRForBranch('/repo-root', 'feature', 99) + await getPRForBranch('/repo-root', 'feature', 99) + + expect( + ghExecFileAsyncMock.mock.calls.filter( + ([args]) => args[0] === 'api' && args[1]?.includes('/99') + ) + ).toHaveLength(1) + }) + it('hydrates repository merge method settings for exact PR lookups', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock @@ -518,6 +598,54 @@ describe('getPRForBranch', () => { ) }) + it('isolates viewer-dependent merge metadata across SSH connections', async () => { + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + let metadataProbe = 0 + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args.includes('graphql')) { + metadataProbe += 1 + return { + stdout: JSON.stringify({ + data: { + repository: { + autoMergeAllowed: metadataProbe === 1, + mergeQueue: null + } + } + }) + } + } + if (args[0] === 'pr') { + return { + stdout: JSON.stringify({ + number: 99, + title: 'Linked PR', + state: 'OPEN', + url: 'https://github.com/acme/widgets/pull/99', + statusCheckRollup: [], + updatedAt: '2026-03-28T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + reviewDecision: 'APPROVED', + mergeStateStatus: 'CLEAN', + autoMergeRequest: null, + baseRefName: 'main', + headRefName: 'feature', + headRefOid: 'head-oid' + }) + } + } + return { stdout: JSON.stringify({ number: 99, state: 'open', stack: null }) } + }) + + const firstAccount = await getPRForBranch('/repo-root', 'feature', 99, 'ssh-account-1') + const secondAccount = await getPRForBranch('/repo-root', 'feature', 99, 'ssh-account-2') + + expect(firstAccount?.autoMergeAllowed).toBe(true) + expect(secondAccount?.autoMergeAllowed).toBe(false) + expect(metadataProbe).toBe(2) + }) + it('treats linked PR metadata as authoritative even when the branch head differs', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'current-worktree-head\n', stderr: '' }) @@ -559,7 +687,7 @@ describe('getPRForBranch', () => { const pr = await getPRForBranch('/repo-root', 'feature/test', 99) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) expect(pr?.number).toBe(99) }) @@ -1181,7 +1309,7 @@ describe('getPRForBranch', () => { expect(outcome.kind === 'found' ? outcome.pr.headDivergedFromMergedPRAtOid : undefined).toBe( undefined ) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) }) it('leaves linked merged divergence unset when the membership probe throws', async () => { @@ -1223,7 +1351,7 @@ describe('getPRForBranch', () => { expect(outcome.kind === 'found' ? outcome.pr.headDivergedFromMergedPRAtOid : undefined).toBe( undefined ) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) }) it('prefers branch lookup over a fallback PR number', async () => { @@ -2866,7 +2994,7 @@ describe('getPRForBranch', () => { const pr = await getPRForBranch('/repo-root', 'refs/heads/local-created-from-pr', 77) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( [ 'pr', @@ -4244,8 +4372,252 @@ describe('GitHub GraphQL rate-limit guard', () => { expect(noteRateLimitSpendMock).toHaveBeenCalledTimes(2) }) + it('hydrates GitHub-registered stack metadata for exact linked PRs', async () => { + getOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca', host: 'github.com' }) + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 202, + title: 'Stack API', + state: 'OPEN', + url: 'https://github.com/stablyai/orca/pull/202', + statusCheckRollup: [], + updatedAt: '2026-08-10T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + baseRefName: 'stack/models', + headRefName: 'stack/api', + headRefOid: 'api-sha' + }) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 202, + title: 'Stack API', + state: 'open', + html_url: 'https://github.com/stablyai/orca/pull/202', + head: { ref: 'stack/api', sha: 'api-sha' }, + base: { ref: 'stack/models', sha: 'models-sha' }, + stack: { + number: 51, + position: 2, + size: 2, + base: { ref: 'main', sha: 'main-sha' } + } + }) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + data: { + repository: { + pullRequest: { + stack: { + number: 51, + size: 2, + baseRefName: 'main', + entries: { + nodes: [ + { + position: 1, + pullRequest: { + number: 201, + title: 'Stack models', + url: 'https://github.com/stablyai/orca/pull/201', + state: 'OPEN', + isDraft: false, + mergeable: 'MERGEABLE', + statusCheckRollup: { state: 'SUCCESS' } + } + }, + { + position: 2, + pullRequest: { + number: 202, + title: 'Stack API', + url: 'https://github.com/stablyai/orca/pull/202', + state: 'OPEN', + isDraft: false, + mergeable: 'MERGEABLE', + statusCheckRollup: { state: 'SUCCESS' } + } + } + ] + } + } + } + } + } + }) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + data: { + repository: { + mergeQueue: null, + ref: { rules: { nodes: [{ type: 'MERGE_QUEUE' }] } } + } + } + }) + }) + + const pr = await getPRForBranch('/repo-root', 'stack/api', 202) + + expect(pr?.stack).toMatchObject({ + number: 51, + position: 2, + size: 2, + baseRefName: 'main', + entries: [ + { number: 201, position: 1 }, + { number: 202, position: 2 } + ] + }) + expect(pr?.mergeQueueRequired).toBe(true) + const mergeQueueMetadataCall = ghExecFileAsyncMock.mock.calls.find( + ([args]) => args.includes('graphql') && args.includes('branch=main') + ) + expect(mergeQueueMetadataCall?.[0]).toEqual(expect.arrayContaining(['-f', 'branch=main'])) + }) + + it('uses async merge only for GitHub-registered stacks', async () => { + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 202, + title: 'Stack API', + state: 'open', + head: { ref: 'stack/api', sha: 'api-sha' }, + base: { ref: 'stack/models', sha: 'models-sha' }, + stack: { + number: 51, + position: 2, + size: 2, + base: { ref: 'main', sha: 'main-sha' } + } + }) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ data: { repository: { mergeQueue: { id: 'MQ_kw' } } } }) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ status: 'enqueued', details: { message: 'Queued' } }) + }) + + await expect( + mergePR('/repo-root', 202, 'squash', undefined, { + owner: 'stablyai', + repo: 'orca', + host: 'github.com' + }) + ).resolves.toEqual({ ok: true }) + + const mergeCall = ghExecFileAsyncMock.mock.calls.find(([args]) => + args.includes('repos/stablyai/orca/pulls/202/merge-async') + ) + expect(mergeCall?.[0]).toEqual( + expect.arrayContaining([ + 'PUT', + 'repos/stablyai/orca/pulls/202/merge-async', + 'merge_action=merge_queue', + 'sha=api-sha' + ]) + ) + expect(mergeCall?.[0]).not.toContain('merge_method=squash') + expect(acquireMock).toHaveBeenCalledTimes(2) + expect(releaseMock).toHaveBeenCalledTimes(2) + expect( + ghExecFileAsyncMock.mock.calls.some(([args]) => args[0] === 'pr' && args[1] === 'merge') + ).toBe(false) + }) + + it('never falls back to legacy merge after an async stack merge transport failure', async () => { + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 202, + state: 'open', + head: { ref: 'stack/api', sha: 'api-sha' }, + base: { ref: 'stack/models', sha: 'models-sha' }, + stack: { + number: 51, + position: 2, + size: 2, + base: { ref: 'main', sha: 'main-sha' } + } + }) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ data: { repository: { mergeQueue: null } } }) + }) + .mockRejectedValueOnce(new Error('socket closed after request submission')) + + await expect( + mergePR('/repo-root', 202, 'squash', undefined, { + owner: 'stablyai', + repo: 'orca', + host: 'github.com' + }) + ).resolves.toEqual({ ok: false, error: 'socket closed after request submission' }) + expect( + ghExecFileAsyncMock.mock.calls.some(([args]) => args[0] === 'pr' && args[1] === 'merge') + ).toBe(false) + }) + + it('keeps legacy merge for unregistered dependent PR chains', async () => { + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 202, + title: 'Dependent API', + state: 'open', + head: { ref: 'feature/api', sha: 'api-sha' }, + base: { ref: 'feature/models', sha: 'models-sha' }, + stack: null + }) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 202, + title: 'Dependent API', + state: 'OPEN', + url: 'https://github.com/stablyai/orca/pull/202', + statusCheckRollup: [], + updatedAt: '2026-08-10T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + baseRefName: 'feature/models', + headRefOid: 'api-sha' + }) + }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + + await expect( + mergePR('/repo-root', 202, 'squash', undefined, { + owner: 'stablyai', + repo: 'orca', + host: 'github.com' + }) + ).resolves.toEqual({ ok: true }) + + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 3, + ['pr', 'merge', '202', '--squash', '--repo', 'stablyai/orca'], + expect.objectContaining({ env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }) }) + ) + }) + it('uses explicit PR repo for merge and title mutations', async () => { ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 7, + title: 'PR', + state: 'open', + head: { ref: 'feature', sha: 'head-oid' }, + base: { ref: 'main', sha: 'base-oid' }, + stack: null + }) + }) .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 7, @@ -4279,8 +4651,12 @@ describe('GitHub GraphQL rate-limit guard', () => { ).resolves.toBe(true) expect(getOwnerRepoMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['api', 'repos/stablyai/orca/pulls/7'], { + cwd: '/repo-root', + host: 'github.com' + }) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, + 2, [ 'pr', 'view', @@ -4293,7 +4669,7 @@ describe('GitHub GraphQL rate-limit guard', () => { { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, + 3, ['pr', 'merge', '7', '--squash', '--repo', 'stablyai/orca'], expect.objectContaining({ cwd: '/repo-root', @@ -4302,7 +4678,7 @@ describe('GitHub GraphQL rate-limit guard', () => { }) ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 3, + 4, ['pr', 'edit', '7', '--title', 'New title', '--repo', 'stablyai/orca'], { cwd: '/repo-root', host: 'github.com' } ) @@ -4310,6 +4686,7 @@ describe('GitHub GraphQL rate-limit guard', () => { it('sets and disables PR auto-merge with explicit PR repos and SSH context', async () => { ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid' }) }) @@ -4330,13 +4707,16 @@ describe('GitHub GraphQL rate-limit guard', () => { }) ).resolves.toEqual({ ok: true }) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['api', 'repos/stablyai/orca/pulls/7'], { + host: 'github.com' + }) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, + 2, ['pr', 'view', '7', '--json', 'id,headRefOid,baseRefName', '--repo', 'stablyai/orca'], { host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, + 3, expect.arrayContaining([ 'api', 'graphql', @@ -4353,7 +4733,7 @@ describe('GitHub GraphQL rate-limit guard', () => { }) ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 3, + 4, ['pr', 'merge', '7', '--disable-auto', '--repo', 'stablyai/orca'], expect.objectContaining({ env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }), @@ -4365,6 +4745,7 @@ describe('GitHub GraphQL rate-limit guard', () => { it('enables auto-merge without invoking the direct merge command', async () => { ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid' }) }) @@ -4391,8 +4772,39 @@ describe('GitHub GraphQL rate-limit guard', () => { ).toBe(false) }) + it('rejects auto-merge for GitHub-registered stacks', async () => { + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 202, + title: 'Stack API', + state: 'open', + head: { ref: 'stack/api', sha: 'head-oid' }, + base: { ref: 'stack/models', sha: 'models-sha' }, + stack: { + number: 51, + position: 2, + size: 2, + base: { ref: 'main', sha: 'main-sha' } + } + }) + }) + + await expect( + setPRAutoMerge('/repo-root', 202, true, 'squash', undefined, { + owner: 'stablyai', + repo: 'orca', + host: 'github.com' + }) + ).resolves.toEqual({ + ok: false, + error: 'GitHub does not support auto-merge for stacked pull requests.' + }) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + it('translates the GitHub clean-status rejection into an actionable message', async () => { ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid' }) }) @@ -4412,6 +4824,7 @@ describe('GitHub GraphQL rate-limit guard', () => { it('uses the queue-aware gh merge path when the base branch has a merge queue', async () => { ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid', baseRefName: 'main' }) }) @@ -4429,12 +4842,12 @@ describe('GitHub GraphQL rate-limit guard', () => { ).resolves.toEqual({ ok: true }) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, + 3, expect.arrayContaining(['api', 'graphql', '-f', 'branch=main']), { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 3, + 4, ['pr', 'merge', '7', '--auto', '--squash', '--repo', 'stablyai/orca'], expect.objectContaining({ cwd: '/repo-root', @@ -4450,24 +4863,26 @@ describe('GitHub GraphQL rate-limit guard', () => { }) it('blocks direct merge when GitHub reports required approval', async () => { - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 7, - title: 'PR', - state: 'OPEN', - url: 'https://github.com/stablyai/orca/pull/7', - statusCheckRollup: [], - updatedAt: '2026-04-01T00:00:00Z', - isDraft: false, - mergeable: 'MERGEABLE', - reviewDecision: 'REVIEW_REQUIRED', - mergeStateStatus: 'CLEAN', - autoMergeRequest: null, - baseRefName: 'main', - baseRefOid: 'base-oid', - headRefOid: 'head-oid' + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 7, + title: 'PR', + state: 'OPEN', + url: 'https://github.com/stablyai/orca/pull/7', + statusCheckRollup: [], + updatedAt: '2026-04-01T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + reviewDecision: 'REVIEW_REQUIRED', + mergeStateStatus: 'CLEAN', + autoMergeRequest: null, + baseRefName: 'main', + baseRefOid: 'base-oid', + headRefOid: 'head-oid' + }) }) - }) await expect( mergePR('/repo-root', 7, 'squash', undefined, { @@ -4480,8 +4895,8 @@ describe('GitHub GraphQL rate-limit guard', () => { error: 'This pull request requires review approval before it can be merged.' }) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) - expect(ghExecFileAsyncMock.mock.calls[1]?.[0]).toContain('graphql') + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3) + expect(ghExecFileAsyncMock.mock.calls[2]?.[0]).toContain('graphql') }) it('detects merge queues once per base branch and blocks direct merges', async () => { @@ -4502,10 +4917,12 @@ describe('GitHub GraphQL rate-limit guard', () => { headRefOid: 'head-oid' } ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) .mockResolvedValueOnce({ stdout: JSON.stringify(prView) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ data: { repository: { mergeQueue: { id: 'MQ_kw' } } } }) }) + .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) .mockResolvedValueOnce({ stdout: JSON.stringify(prView) }) await expect( @@ -4526,10 +4943,10 @@ describe('GitHub GraphQL rate-limit guard', () => { expect( ghExecFileAsyncMock.mock.calls.filter((call) => call[0].includes('graphql')) ).toHaveLength(1) - expect(ghExecFileAsyncMock.mock.calls[1]?.[0]).toEqual( + expect(ghExecFileAsyncMock.mock.calls[2]?.[0]).toEqual( expect.arrayContaining(['-f', 'owner=stablyai', '-f', 'repo=orca', '-f', 'branch=true']) ) - expect(ghExecFileAsyncMock.mock.calls[1]?.[0]).not.toContain('-F') + expect(ghExecFileAsyncMock.mock.calls[2]?.[0]).not.toContain('-F') }) it('caches unknown merge queue probes after GraphQL failures', async () => { @@ -4553,7 +4970,9 @@ describe('GitHub GraphQL rate-limit guard', () => { ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify(prView) }) .mockRejectedValueOnce(new Error('network is down')) + .mockResolvedValueOnce({ stdout: '{}' }) .mockResolvedValueOnce({ stdout: JSON.stringify(prView) }) + .mockResolvedValueOnce({ stdout: '{}' }) await expect(getPRForBranch('/repo-root', 'feature/test', 7)).resolves.toMatchObject({ mergeQueueRequired: null @@ -4679,21 +5098,23 @@ describe('GitHub GraphQL rate-limit guard', () => { }) it('returns conflicting file details instead of running gh merge when PR is dirty', async () => { - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 7, - title: 'PR', - state: 'OPEN', - url: 'https://github.com/stablyai/orca/pull/7', - statusCheckRollup: [], - updatedAt: '2026-04-01T00:00:00Z', - isDraft: false, - mergeable: 'CONFLICTING', - baseRefName: 'main', - baseRefOid: 'base-oid', - headRefOid: 'head-oid' + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 7, + title: 'PR', + state: 'OPEN', + url: 'https://github.com/stablyai/orca/pull/7', + statusCheckRollup: [], + updatedAt: '2026-04-01T00:00:00Z', + isDraft: false, + mergeable: 'CONFLICTING', + baseRefName: 'main', + baseRefOid: 'base-oid', + headRefOid: 'head-oid' + }) }) - }) gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: '' }) .mockResolvedValueOnce({ stdout: 'latest-base-oid\n' }) @@ -4716,11 +5137,12 @@ describe('GitHub GraphQL rate-limit guard', () => { '- src/conflict.ts' }) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) }) it('does not run merge conflict preflight for SSH-backed repos', async () => { ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 7, @@ -4746,9 +5168,9 @@ describe('GitHub GraphQL rate-limit guard', () => { }) ).resolves.toEqual({ ok: true }) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, + 3, ['pr', 'merge', '7', '--squash', '--repo', 'stablyai/orca'], expect.objectContaining({ env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }) diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 4f95432fcfd..f95bfe7fdd8 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -20,7 +20,8 @@ import type { GitHubPullRequestStateUpdate, GitHubRerunPRChecksResult, GitHubPRMergeMethod, - GitHubPRMergeMethodSettings + GitHubPRMergeMethodSettings, + GitHubPRStack } from '../../shared/types' import type { CreateHostedReviewInput, CreateHostedReviewResult } from '../../shared/hosted-review' import { @@ -123,8 +124,13 @@ import { spendsSharedGitHubComQuota, type RateLimitBucketKind } from './rate-limit' +import { + GITHUB_CHECK_DETAILS_HOST_TIMEOUT_MS, + GITHUB_CHECK_DETAILS_TIMEOUT_MESSAGE +} from '../../shared/github-check-details-deadline' +import { hydrateGitHubPRStack, mergeGitHubPRStack } from './github-pr-stack' -type GhExecOptions = GitHubRepoExecOptions +type GhExecOptions = GitHubRepoExecOptions & { signal?: AbortSignal } type HostedReviewLocalGitOptions = ReturnType const ORCA_REPO = 'stablyai/orca' @@ -149,6 +155,30 @@ function setPrCheckLogTailCache(cacheKey: string, logTail: string | null): void prCheckLogTailCache.delete(oldestKey) } } + +function rethrowCheckDetailsAbort(signal: AbortSignal | undefined, error: unknown): void { + if (signal?.aborted) { + throw error + } +} + +function waitForCheckDetailsResolution(operation: Promise, signal: AbortSignal): Promise { + if (signal.aborted) { + return Promise.reject(signal.reason) + } + return new Promise((resolve, reject) => { + const finish = (settle: () => void): void => { + signal.removeEventListener('abort', onAbort) + settle() + } + const onAbort = (): void => finish(() => reject(signal.reason)) + signal.addEventListener('abort', onAbort, { once: true }) + void operation.then( + (value) => finish(() => resolve(value)), + (error) => finish(() => reject(error)) + ) + }) +} const MERGE_QUEUE_CACHE_TTL_MS = 10 * 60 * 1000 const MERGE_QUEUE_UNKNOWN_CACHE_TTL_MS = 60 * 1000 const MERGE_QUEUE_CACHE_MAX_ENTRIES = 256 @@ -161,11 +191,30 @@ const repositoryMergeMetadataCache = new Map< string, { value: GitHubRepositoryMergeMetadata; expiresAt: number } >() +const PR_STACK_SUMMARY_CACHE_TTL_MS = 60_000 +const PR_STACK_SUMMARY_CACHE_MAX_ENTRIES = 512 +const prStackSummaryCache = new Map< + string, + { value: GitHubPRStack | undefined; expiresAt: number } +>() +const prStackSummaryInFlight = new Map>() + +function githubPRStackExecutionScope( + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): string { + return connectionId ? `ssh:${connectionId}` : `local:${localGitOptions.wslDistro ?? 'host'}` +} export function _resetMergeQueueCacheForTests(): void { repositoryMergeMetadataCache.clear() } +export function _resetPRStackSummaryCacheForTests(): void { + prStackSummaryCache.clear() + prStackSummaryInFlight.clear() +} + export function _getMergeQueueCacheSizeForTests(): number { return repositoryMergeMetadataCache.size } @@ -2162,6 +2211,8 @@ type PullRequestLookupData = { headRefName?: string baseRefOid?: string headRefOid?: string + stack?: GitHubPRStack + stackMetadataChecked?: boolean } type RestPullRequest = { @@ -2177,6 +2228,12 @@ type RestPullRequest = { mergeable_state?: string | null base?: { ref?: string; sha?: string } head?: { ref?: string; sha?: string } + stack?: { + number?: number + position?: number + size?: number + base?: { ref?: string; sha?: string } + } | null } const PR_LOOKUP_JSON_FIELDS = @@ -2216,6 +2273,19 @@ function derivePullRequestMergeable(data: PullRequestLookupData): PRMergeableSta } function mapRestPullRequest(pr: RestPullRequest): PullRequestLookupData { + const stack = + typeof pr.stack?.number === 'number' && + typeof pr.stack.position === 'number' && + typeof pr.stack.size === 'number' && + typeof pr.stack.base?.ref === 'string' + ? { + number: pr.stack.number, + position: pr.stack.position, + size: pr.stack.size, + baseRefName: pr.stack.base.ref, + ...(typeof pr.stack.base.sha === 'string' ? { baseSha: pr.stack.base.sha } : {}) + } + : undefined return { number: pr.number, title: pr.title, @@ -2228,7 +2298,9 @@ function mapRestPullRequest(pr: RestPullRequest): PullRequestLookupData { baseRefName: pr.base?.ref, headRefName: pr.head?.ref, baseRefOid: pr.base?.sha, - headRefOid: pr.head?.sha + headRefOid: pr.head?.sha, + stackMetadataChecked: true, + ...(stack ? { stack } : {}) } } @@ -2298,9 +2370,10 @@ function cacheRepositoryMergeMetadata( async function detectRepositoryMergeMetadata( ownerRepo: GitHubApiRepository, branchName: string | undefined, - ghOptions: GhExecOptions + ghOptions: GhExecOptions, + executionScope = 'default' ): Promise { - const cacheKey = `${githubRepoIdentityKey(ownerRepo)}:${branchName ?? '__repo__'}` + const cacheKey = `${executionScope}\0${githubRepoIdentityKey(ownerRepo)}:${branchName ?? '__repo__'}` pruneRepositoryMergeMetadataCache() const cached = repositoryMergeMetadataCache.get(cacheKey) if (cached) { @@ -2311,7 +2384,7 @@ async function detectRepositoryMergeMetadata( return { mergeQueueRequired: null, autoMergeAllowed: null } } const query = branchName - ? `query($owner: String!, $repo: String!, $branch: String!) { + ? `query($owner: String!, $repo: String!, $branch: String!, $qualified: String!) { repository(owner: $owner, name: $repo) { viewerDefaultMergeMethod mergeCommitAllowed @@ -2319,6 +2392,9 @@ async function detectRepositoryMergeMetadata( squashMergeAllowed autoMergeAllowed mergeQueue(branch: $branch) { id } + ref(qualifiedName: $qualified) { + rules(first: 50) { nodes { type } } + } } }` : `query($owner: String!, $repo: String!) { @@ -2344,6 +2420,7 @@ async function detectRepositoryMergeMetadata( ] if (branchName) { args.push('-f', `branch=${branchName}`) + args.push('-f', `qualified=refs/heads/${branchName}`) } const { stdout } = await ghExecFileAsync(args, { ...ghOptions, @@ -2358,6 +2435,7 @@ async function detectRepositoryMergeMetadata( squashMergeAllowed?: unknown autoMergeAllowed?: unknown mergeQueue?: { id?: unknown } | null + ref?: { rules?: { nodes?: ({ type?: unknown } | null)[] | null } | null } | null } | null } } @@ -2371,7 +2449,10 @@ async function detectRepositoryMergeMetadata( }) : undefined const value: GitHubRepositoryMergeMetadata = { - mergeQueueRequired: branchName ? Boolean(repository?.mergeQueue) : null, + mergeQueueRequired: branchName + ? Boolean(repository?.mergeQueue) || + Boolean(repository?.ref?.rules?.nodes?.some((rule) => rule?.type === 'MERGE_QUEUE')) + : null, autoMergeAllowed: typeof repository?.autoMergeAllowed === 'boolean' ? repository.autoMergeAllowed : null, ...(mergeMethodSettings ? { mergeMethodSettings } : {}) @@ -2392,13 +2473,19 @@ async function detectRepositoryMergeMetadata( async function hydratePullRequestLookupData( ownerRepo: OwnerRepo, data: PullRequestLookupData, - ghOptions: GhExecOptions + ghOptions: GhExecOptions, + executionScope: string ): Promise { const normalized = normalizePullRequestLookupData(data) const hasRichMergeFields = 'reviewDecision' in data || 'mergeStateStatus' in data || 'autoMergeRequest' in data const mergeMetadata = hasRichMergeFields - ? await detectRepositoryMergeMetadata(ownerRepo, normalized.baseRefName, ghOptions) + ? await detectRepositoryMergeMetadata( + ownerRepo, + normalized.stack?.baseRefName ?? normalized.baseRefName, + ghOptions, + executionScope + ) : undefined return { ...normalized, @@ -2413,13 +2500,17 @@ async function hydratePullRequestLookupData( async function hydrateBranchLookupWithExactPR( ownerRepo: OwnerRepo, branchData: PullRequestLookupData | null, - ghOptions: GhExecOptions + ghOptions: GhExecOptions, + executionScope: string ): Promise { if (!branchData) { return null } try { - return (await getPRByNumber(ownerRepo, branchData.number, ghOptions)) ?? branchData + return ( + (await getPRByNumber(ownerRepo, branchData.number, ghOptions, executionScope, branchData)) ?? + branchData + ) } catch { return branchData } @@ -2772,6 +2863,7 @@ async function lookupPRByBranchName(args: { headRepo: OwnerRepo | null branchName: string ghOptions: GhExecOptions + executionScope: string }): Promise<{ data: PullRequestLookupData | null dataRepo: OwnerRepo | null @@ -2791,7 +2883,12 @@ async function lookupPRByBranchName(args: { ) : await getFallbackPRListForBranch(candidate, args.branchName, args.ghOptions) // Why: REST/list branch lookup identifies the PR cheaply; exact `gh pr view` carries review, merge-queue, and auto-merge state. - const data = await hydrateBranchLookupWithExactPR(candidate, branchData, args.ghOptions) + const data = await hydrateBranchLookupWithExactPR( + candidate, + branchData, + args.ghOptions, + args.executionScope + ) if (data) { return { data, dataRepo: candidate } } @@ -2810,7 +2907,12 @@ async function lookupPRByBranchName(args: { args.branchName, args.ghOptions ) - const data = await hydrateBranchLookupWithExactPR(candidate, branchData, args.ghOptions) + const data = await hydrateBranchLookupWithExactPR( + candidate, + branchData, + args.ghOptions, + args.executionScope + ) if (data) { return { data, dataRepo: candidate } } @@ -2857,10 +2959,71 @@ async function getRestPRByNumber( return mapRestPullRequest(JSON.parse(stdout) as RestPullRequest) } +function prunePRStackSummaryCache(now = Date.now()): void { + for (const [key, cached] of prStackSummaryCache) { + if (cached.expiresAt <= now) { + prStackSummaryCache.delete(key) + } + } + while (prStackSummaryCache.size > PR_STACK_SUMMARY_CACHE_MAX_ENTRIES) { + const oldestKey = prStackSummaryCache.keys().next().value + if (oldestKey === undefined) { + return + } + prStackSummaryCache.delete(oldestKey) + } +} + +async function getCachedGitHubPRStackSummary( + ownerRepo: GitHubApiRepository, + number: number, + ghOptions: ReturnType, + executionScope: string +): Promise { + const key = `${executionScope}\0${githubRepoIdentityKey(ownerRepo)}#${number}` + const now = Date.now() + prunePRStackSummaryCache(now) + const cached = prStackSummaryCache.get(key) + if (cached && cached.expiresAt > now) { + return cached.value + } + const existing = prStackSummaryInFlight.get(key) + if (existing) { + return existing + } + const request = getRestPRByNumber(ownerRepo, number, ghOptions).then((pr) => pr?.stack) + prStackSummaryInFlight.set(key, request) + try { + const value = await request + prStackSummaryCache.delete(key) + prStackSummaryCache.set(key, { + value, + expiresAt: Date.now() + PR_STACK_SUMMARY_CACHE_TTL_MS + }) + prunePRStackSummaryCache() + return value + } catch (err) { + // Why: avoid repeating a failed REST probe on every review poll. + prStackSummaryCache.delete(key) + prStackSummaryCache.set(key, { + value: undefined, + expiresAt: Date.now() + PR_STACK_SUMMARY_CACHE_TTL_MS + }) + prunePRStackSummaryCache() + throw err + } finally { + if (prStackSummaryInFlight.get(key) === request) { + prStackSummaryInFlight.delete(key) + } + } +} + async function getPRByNumber( ownerRepo: GitHubApiRepository, number: number, - ghOptions: ReturnType + ghOptions: ReturnType, + executionScope: string, + knownPullRequestData?: PullRequestLookupData | null ): Promise { try { const { stdout } = await ghExecFileAsync( @@ -2875,10 +3038,16 @@ async function getPRByNumber( ], { ...ghOptions, ...githubHostExecOptions(ownerRepo) } ) + const exactData = JSON.parse(stdout) as PullRequestLookupData return hydratePullRequestLookupData( ownerRepo, - JSON.parse(stdout) as PullRequestLookupData, - ghOptions + { + ...knownPullRequestData, + ...exactData, + ...(knownPullRequestData?.stack ? { stack: knownPullRequestData.stack } : {}) + }, + ghOptions, + executionScope ) } catch (err) { // Why: deleted/edited linked PR metadata falls back to branch discovery; quota/auth/network failures get one cheaper REST exact lookup. @@ -2886,8 +3055,13 @@ async function getPRByNumber( return null } try { - const restData = await getRestPRByNumber(ownerRepo, number, ghOptions) - return restData ? hydratePullRequestLookupData(ownerRepo, restData, ghOptions) : null + const restData = + knownPullRequestData === undefined + ? await getRestPRByNumber(ownerRepo, number, ghOptions) + : knownPullRequestData + return restData + ? hydratePullRequestLookupData(ownerRepo, restData, ghOptions, executionScope) + : null } catch (restErr) { if (isNotFoundGhError(restErr)) { return null @@ -2904,10 +3078,16 @@ async function lookupPRByNumber(args: { candidates: OwnerRepo[] number: number ghOptions: ReturnType + executionScope: string }): Promise<{ data: PullRequestLookupData | null; dataRepo: OwnerRepo | null }> { for (const candidate of args.candidates) { try { - const linkedData = await getPRByNumber(candidate, args.number, args.ghOptions) + const linkedData = await getPRByNumber( + candidate, + args.number, + args.ghOptions, + args.executionScope + ) if (!linkedData) { continue } @@ -3006,6 +3186,7 @@ export async function getPRForBranchOutcome( const localGitOptions = localGitArgs[0] ?? {} const context = githubRepoContext(repoPath, connectionId, localGitOptions) const ghOptions = ghRepoExecOptions(context) + const executionScope = githubPRStackExecutionScope(connectionId, localGitOptions) await acquire() try { @@ -3032,6 +3213,7 @@ export async function getPRForBranchOutcome( let pendingBranchLookupError: unknown let hasPendingBranchLookupError = false let currentHeadOidForMergedImplicit: string | null | undefined + let usedExactNumberLookup = false const explicitCurrentHeadOid = typeof options.currentHeadOid === 'string' && options.currentHeadOid.trim().length > 0 @@ -3104,10 +3286,12 @@ export async function getPRForBranchOutcome( } if (typeof linkedPRNumber === 'number') { + usedExactNumberLookup = true const exactLookup = await lookupPRByNumber({ candidates, number: linkedPRNumber, - ghOptions + ghOptions, + executionScope }) data = exactLookup.data dataRepo = exactLookup.dataRepo @@ -3117,7 +3301,8 @@ export async function getPRForBranchOutcome( candidates, headRepo, branchName, - ghOptions + ghOptions, + executionScope }) data = branchLookup.data dataRepo = branchLookup.dataRepo @@ -3149,7 +3334,8 @@ export async function getPRForBranchOutcome( candidates, headRepo: upstreamHeadRepo, branchName: upstreamBranch.branchName, - ghOptions + ghOptions, + executionScope }) data = upstreamLookup.data dataRepo = upstreamLookup.dataRepo @@ -3172,10 +3358,12 @@ export async function getPRForBranchOutcome( dataHeadRepo = headRepo } if (!data && typeof linkedPRNumber !== 'number' && typeof fallbackPRNumber === 'number') { + usedExactNumberLookup = true const fallbackLookup = await lookupPRByNumber({ candidates, number: fallbackPRNumber, - ghOptions + ghOptions, + executionScope }) data = fallbackLookup.data dataRepo = fallbackLookup.dataRepo @@ -3221,7 +3409,42 @@ export async function getPRForBranchOutcome( return { kind: 'no-pr', fetchedAt: Date.now() } } + if (!data.stackMetadataChecked && dataRepo && usedExactNumberLookup) { + try { + data.stack = await getCachedGitHubPRStackSummary( + dataRepo, + data.number, + ghOptions, + executionScope + ) + data.stackMetadataChecked = true + } catch { + // Stack metadata is additive; exact PR lookup remains usable without it. + } + } const mergeable = derivePullRequestMergeable(data) + const stack = + data.stack && dataRepo + ? await hydrateGitHubPRStack( + dataRepo, + data.number, + data.stack, + ghOptions, + data.updatedAt, + executionScope + ) + : data.stack + const stackMergeQueueRequired = + stack && dataRepo + ? ( + await detectRepositoryMergeMetadata( + dataRepo, + stack.baseRefName, + ghOptions, + executionScope + ) + ).mergeQueueRequired + : undefined const conflictSummary = !connectionId && mergeable === 'CONFLICTING' && @@ -3251,13 +3474,19 @@ export async function getPRForBranchOutcome( ...(data.reviewDecision !== undefined ? { reviewDecision: data.reviewDecision } : {}), ...(data.autoMergeEnabled !== undefined ? { autoMergeEnabled: data.autoMergeEnabled } : {}), ...(data.autoMergeAllowed !== undefined ? { autoMergeAllowed: data.autoMergeAllowed } : {}), - ...(data.mergeQueueRequired !== undefined - ? { mergeQueueRequired: data.mergeQueueRequired } + ...(stackMergeQueueRequired !== undefined || data.mergeQueueRequired !== undefined + ? { + mergeQueueRequired: + stackMergeQueueRequired !== undefined + ? stackMergeQueueRequired + : data.mergeQueueRequired + } : {}), ...(data.mergeMethodSettings !== undefined ? { mergeMethodSettings: data.mergeMethodSettings } : {}), ...(data.mergeStateStatus !== undefined ? { mergeStateStatus: data.mergeStateStatus } : {}), + ...(stack ? { stack } : {}), headSha: data.headRefOid, ...(confirmedContainedHeadOid ? { confirmedContainedHeadOid } : {}), ...(headDivergedFromMergedPRAtOid ? { headDivergedFromMergedPRAtOid } : {}), @@ -3893,6 +4122,7 @@ async function attachFailedJobLogTails( ) job.logTail = sliceCheckLogTail(stdout) } catch (err) { + rethrowCheckDetailsAbort(ghOptions.signal, err) console.warn('getPRCheckDetails workflow job log fetch failed:', err) job.logTail = null } @@ -3925,20 +4155,34 @@ export async function getPRCheckDetails( prRepo?: GitHubApiRepository | null }, connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} + localGitOptions: LocalGitExecOptions = {}, + callerSignal?: AbortSignal ): Promise { - const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( - repoPath, - args.prRepo, - connectionId, - localGitOptions - ) - if (!ownerRepo) { - return null + const controller = new AbortController() + let hostDeadlineExpired = false + const forwardCallerAbort = (): void => controller.abort(callerSignal?.reason) + if (callerSignal?.aborted) { + forwardCallerAbort() + } else { + callerSignal?.addEventListener('abort', forwardCallerAbort, { once: true }) } - - await acquire() + const hostDeadline = setTimeout(() => { + hostDeadlineExpired = true + controller.abort(new Error(GITHUB_CHECK_DETAILS_TIMEOUT_MESSAGE)) + }, GITHUB_CHECK_DETAILS_HOST_TIMEOUT_MS) + let acquired = false try { + const resolved = await waitForCheckDetailsResolution( + resolveGitHubRepoExecution(repoPath, args.prRepo, connectionId, localGitOptions), + controller.signal + ) + if (!resolved.ownerRepo) { + return null + } + const ownerRepo = resolved.ownerRepo + const ghOptions: GhExecOptions = { ...resolved.ghOptions, signal: controller.signal } + await acquire(controller.signal) + acquired = true let checkRun: Record | null = null let annotations: PRCheckRunDetails['annotations'] = [] if (args.checkRunId) { @@ -3957,6 +4201,7 @@ export async function getPRCheckDetails( ) annotations = mapCheckAnnotations(JSON.parse(annotationsResult.stdout)) } catch (err) { + rethrowCheckDetailsAbort(controller.signal, err) console.warn('getPRCheckDetails annotations fetch failed:', err) } } @@ -3975,6 +4220,7 @@ export async function getPRCheckDetails( jobs = mapWorkflowJobs(JSON.parse(stdout), args.checkName) await attachFailedJobLogTails(jobs, ownerRepo, ghOptions) } catch (err) { + rethrowCheckDetailsAbort(controller.signal, err) console.warn('getPRCheckDetails workflow jobs fetch failed:', err) } } @@ -3999,9 +4245,16 @@ export async function getPRCheckDetails( } } catch (err) { console.warn('getPRCheckDetails failed:', err) - return null + if (hostDeadlineExpired && !callerSignal?.aborted) { + throw new Error(GITHUB_CHECK_DETAILS_TIMEOUT_MESSAGE) + } + throw err } finally { - release() + clearTimeout(hostDeadline) + callerSignal?.removeEventListener('abort', forwardCallerAbort) + if (acquired) { + release() + } } } @@ -4760,7 +5013,35 @@ export async function mergePR( return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } } await acquire() + let concurrencySlotHeld = true try { + let stackSummary: GitHubPRStack | undefined + let stackHeadSha: string | undefined + try { + const restData = await getRestPRByNumber(ownerRepo, prNumber, ghOptions) + stackSummary = restData?.stack + stackHeadSha = restData?.headRefOid + } catch { + // GitHub remains authoritative when stack metadata cannot be read. + } + if (stackSummary) { + const mergeMetadata = await detectRepositoryMergeMetadata( + ownerRepo, + stackSummary.baseRefName, + ghOptions, + githubPRStackExecutionScope(connectionId, localGitOptions) + ) + release() + concurrencySlotHeld = false + return await mergeGitHubPRStack({ + repository: ownerRepo, + prNumber, + method, + mergeAction: mergeMetadata.mergeQueueRequired === true ? 'merge_queue' : 'direct_merge', + headSha: stackHeadSha, + ghOptions + }) + } const mergeBlocker = await getPRMergeBlocker( repoPath, prNumber, @@ -4788,7 +5069,9 @@ export async function mergePR( err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error' return { ok: false, error: message } } finally { - release() + if (concurrencySlotHeld) { + release() + } } } @@ -4899,11 +5182,25 @@ async function enablePRAutoMerge( ownerRepo: GitHubApiRepository | null, ghOptions: GhExecOptions ): Promise<{ ok: true } | { ok: false; error: string }> { + if (ownerRepo) { + try { + const restData = await getRestPRByNumber(ownerRepo, prNumber, ghOptions) + if (restData?.stack) { + return { + ok: false, + error: 'GitHub does not support auto-merge for stacked pull requests.' + } + } + } catch { + // GitHub remains authoritative when stack metadata cannot be read. + } + } const pr = await getPRAutoMergeIdentity(prNumber, ownerRepo, ghOptions) if (!pr?.id) { return { ok: false, error: 'Could not resolve GitHub pull request ID' } } - if (await shouldUseMergeQueueAutoMerge(pr, ownerRepo, ghOptions)) { + const useMergeQueue = await shouldUseMergeQueueAutoMerge(pr, ownerRepo, ghOptions) + if (useMergeQueue) { await runPRAutoMergeCommand(prNumber, method, ownerRepo, ghOptions) return { ok: true } } @@ -4950,7 +5247,12 @@ async function getPRMergeBlocker( } try { - const pr = await getPRByNumber(ownerRepo, prNumber, ghOptions) + const pr = await getPRByNumber( + ownerRepo, + prNumber, + ghOptions, + githubPRStackExecutionScope(connectionId, localGitOptions) + ) if (!pr) { return null } diff --git a/src/main/github/conflict-summary.ts b/src/main/github/conflict-summary.ts index 73ec657977c..a8c52c46085 100644 --- a/src/main/github/conflict-summary.ts +++ b/src/main/github/conflict-summary.ts @@ -6,7 +6,7 @@ import { import { gitExecFileAsync } from '../git/runner' import { clearGitCapabilityStateForTests, - getLocalGitCapabilityCache + withLocalGitCapabilityCacheForExecution } from '../git/git-capability-state' import { __resetPRConflictSummaryDerivationCachesForTests, @@ -219,10 +219,6 @@ async function loadConflictingFiles( baseOid: string, localGitOptions: LocalGitExecOptions ): Promise { - const capabilities = getLocalGitCapabilityCache({ - cwd: repoPath, - wslDistro: localGitOptions.wslDistro - }) const modernArgs = [ 'merge-tree', '--write-tree', @@ -244,40 +240,44 @@ async function loadConflictingFiles( baseOid ] - return capabilities.runWithFallback( - 'merge-tree-write-tree', - () => + return withLocalGitCapabilityCacheForExecution( + { cwd: repoPath, wslDistro: localGitOptions.wslDistro }, + (capabilities) => capabilities.runWithFallback( - 'merge-tree-merge-base', + 'merge-tree-write-tree', + () => + capabilities.runWithFallback( + 'merge-tree-merge-base', + async () => { + try { + const result = await gitExecFileAsync(modernArgs, { + cwd: repoPath, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) + }) + return parseMergeTreeNameOnlyOutput(result.stdout) + } catch (error) { + if (isUnsupportedMergeTreeWriteTreeError(error)) { + throw error + } + // Why: `git merge-tree --write-tree` exits 1 for conflicts but still + // writes the useful file list; only option rejection reaches fallback. + const stdoutFromError = getGitErrorOutput(error, 'stdout') + if (stdoutFromError) { + return parseMergeTreeNameOnlyOutput(stdoutFromError) + } + throw error + } + }, + () => loadConflictingFilesWithLegacyMergeTree(repoPath, legacyArgs, localGitOptions), + isUnsupportedMergeTreeMergeBaseError + ), async () => { - try { - const result = await gitExecFileAsync(modernArgs, { - cwd: repoPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) - }) - return parseMergeTreeNameOnlyOutput(result.stdout) - } catch (error) { - if (isUnsupportedMergeTreeWriteTreeError(error)) { - throw error - } - // Why: `git merge-tree --write-tree` exits 1 for conflicts but still - // writes the useful file list; only option rejection reaches fallback. - const stdoutFromError = getGitErrorOutput(error, 'stdout') - if (stdoutFromError) { - return parseMergeTreeNameOnlyOutput(stdoutFromError) - } - throw error - } + // Why: Git before 2.38 cannot derive a reliable real-merge conflict list; + // fail closed without respawning the same rejected command every refresh. + throw new Error('Git merge-tree --write-tree is unavailable on this execution host.') }, - () => loadConflictingFilesWithLegacyMergeTree(repoPath, legacyArgs, localGitOptions), - isUnsupportedMergeTreeMergeBaseError - ), - async () => { - // Why: Git before 2.38 cannot derive a reliable real-merge conflict list; - // fail closed without respawning the same rejected command every refresh. - throw new Error('Git merge-tree --write-tree is unavailable on this execution host.') - }, - isUnsupportedMergeTreeWriteTreeError + isUnsupportedMergeTreeWriteTreeError + ) ) } diff --git a/src/main/github/gh-utils-concurrency.test.ts b/src/main/github/gh-utils-concurrency.test.ts new file mode 100644 index 00000000000..de6389d260a --- /dev/null +++ b/src/main/github/gh-utils-concurrency.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { acquire, release } from './gh-utils' + +describe('GitHub concurrency', () => { + it('removes aborted waiters without consuming a permit', async () => { + await Promise.all([acquire(), acquire(), acquire(), acquire()]) + const controller = new AbortController() + const queued = acquire(controller.signal) + + controller.abort() + + await expect(queued).rejects.toMatchObject({ name: 'AbortError' }) + release() + await expect(acquire()).resolves.toBeUndefined() + + release() + release() + release() + release() + }) +}) diff --git a/src/main/github/gh-utils.ts b/src/main/github/gh-utils.ts index d6770189924..ffeb2ca22c7 100644 --- a/src/main/github/gh-utils.ts +++ b/src/main/github/gh-utils.ts @@ -40,25 +40,52 @@ export type { PRRepositoryCandidates, ResolvedIssueSource } from './github-owner const MAX_CONCURRENT = 4 let running = 0 -const queue: (() => void)[] = [] +type QueueEntry = { + signal?: AbortSignal + start: () => void + reject: (error: Error) => void +} +const queue: QueueEntry[] = [] -export function acquire(): Promise { +function githubOperationAbortError(): Error { + const error = new Error('GitHub operation aborted') + error.name = 'AbortError' + return error +} + +export function acquire(signal?: AbortSignal): Promise { + if (signal?.aborted) { + return Promise.reject(githubOperationAbortError()) + } if (running < MAX_CONCURRENT) { running += 1 return Promise.resolve() } - return new Promise((resolve) => - queue.push(() => { - running += 1 - resolve() - }) - ) + return new Promise((resolve, reject) => { + const entry: QueueEntry = { + signal, + reject, + start: () => { + signal?.removeEventListener('abort', onAbort) + running += 1 + resolve() + } + } + const onAbort = (): void => { + const index = queue.indexOf(entry) + if (index === -1) { + return + } + queue.splice(index, 1) + reject(githubOperationAbortError()) + } + signal?.addEventListener('abort', onAbort, { once: true }) + queue.push(entry) + }) } export function release(): void { running -= 1 const next = queue.shift() - if (next) { - next() - } + next?.start() } diff --git a/src/main/github/github-pr-stack-async-merge.ts b/src/main/github/github-pr-stack-async-merge.ts new file mode 100644 index 00000000000..1b3909af035 --- /dev/null +++ b/src/main/github/github-pr-stack-async-merge.ts @@ -0,0 +1,145 @@ +import type { GitHubPRMergeMethod } from '../../shared/types' +import { ghExecFileAsync } from '../git/runner' +import { acquire, release } from './gh-utils' +import { + githubHostExecOptions, + type GitHubApiRepository, + type GitHubRepoExecOptions +} from './github-api-repository' + +const POLL_INTERVAL_MS = 1_000 +const MAX_POLLS = 180 + +type AsyncMergeResponse = { + status?: unknown + details?: { + message?: unknown + uuid?: unknown + } +} + +type AsyncMergeResult = + | { kind: 'pending'; uuid: string } + | { kind: 'success' } + | { kind: 'failure'; message: string } + +export type GitHubPRStackMergeAction = 'direct_merge' | 'merge_queue' + +function parseResponse(value: string): AsyncMergeResponse | null { + try { + return JSON.parse(value) as AsyncMergeResponse + } catch { + return null + } +} + +function errorResponseBody(error: unknown): AsyncMergeResponse | null { + if (!error || typeof error !== 'object' || !('stdout' in error)) { + return null + } + const stdout = (error as { stdout?: unknown }).stdout + return parseResponse(Buffer.isBuffer(stdout) ? stdout.toString('utf8') : String(stdout)) +} + +function classifyResult(response: AsyncMergeResponse): AsyncMergeResult { + if (response.status === 'merged' || response.status === 'enqueued') { + return { kind: 'success' } + } + if (response.status === 'pending' && typeof response.details?.uuid === 'string') { + return { kind: 'pending', uuid: response.details.uuid } + } + const message = + typeof response.details?.message === 'string' + ? response.details.message + : 'GitHub could not merge this stack.' + return { kind: 'failure', message } +} + +function waitForNextPoll(): Promise { + return new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)) +} + +async function runStackMergeCommand( + command: string[], + options: NonNullable[1]> +) { + await acquire() + try { + return await ghExecFileAsync(command, options) + } finally { + release() + } +} + +export async function mergeGitHubPRStack(args: { + repository: GitHubApiRepository + prNumber: number + method: GitHubPRMergeMethod + mergeAction: GitHubPRStackMergeAction + headSha?: string + ghOptions: GitHubRepoExecOptions +}): Promise<{ ok: true } | { ok: false; error: string }> { + const endpoint = `repos/${args.repository.owner}/${args.repository.repo}/pulls/${args.prNumber}/merge-async` + const command = ['api', '-X', 'PUT', endpoint, '-f', `merge_action=${args.mergeAction}`] + if (args.mergeAction === 'direct_merge') { + command.push('-f', `merge_method=${args.method}`) + } + if (args.headSha) { + command.push('-f', `sha=${args.headSha}`) + } + + let submitted: AsyncMergeResponse | null + try { + const { stdout } = await runStackMergeCommand(command, { + ...args.ghOptions, + ...githubHostExecOptions(args.repository), + env: { ...process.env, GH_PROMPT_DISABLED: '1' } + }) + submitted = parseResponse(stdout) + } catch (error) { + submitted = errorResponseBody(error) + if (!submitted) { + throw error + } + } + if (!submitted) { + return { ok: false, error: 'GitHub returned an invalid stack merge response.' } + } + let result = classifyResult(submitted) + if (result.kind === 'success') { + return { ok: true } + } + if (result.kind === 'failure') { + return { ok: false, error: result.message } + } + + const uuid = encodeURIComponent(result.uuid) + for (let poll = 0; poll < MAX_POLLS; poll++) { + await waitForNextPoll() + let response: AsyncMergeResponse | null + try { + const { stdout } = await runStackMergeCommand(['api', `${endpoint}/${uuid}`], { + ...args.ghOptions, + ...githubHostExecOptions(args.repository) + }) + response = parseResponse(stdout) + } catch (error) { + // Why: the merge was submitted; a transient poll failure cannot make it fail. + response = errorResponseBody(error) + if (!response) { + continue + } + } + if (!response) { + return { ok: false, error: 'GitHub returned an invalid stack merge response.' } + } + result = classifyResult(response) + if (result.kind === 'success') { + return { ok: true } + } + if (result.kind === 'failure') { + return { ok: false, error: result.message } + } + } + return { ok: false, error: 'GitHub is still merging this stack. Refresh to check its status.' } +} diff --git a/src/main/github/github-pr-stack.test.ts b/src/main/github/github-pr-stack.test.ts new file mode 100644 index 00000000000..17cd6475941 --- /dev/null +++ b/src/main/github/github-pr-stack.test.ts @@ -0,0 +1,331 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { GitHubPRStack } from '../../shared/types' + +const { ghExecFileAsyncMock, rateLimitGuardMock, noteRateLimitSpendMock } = vi.hoisted(() => ({ + ghExecFileAsyncMock: vi.fn(), + rateLimitGuardMock: vi.fn(() => ({ blocked: false })), + noteRateLimitSpendMock: vi.fn() +})) + +vi.mock('../git/runner', () => ({ ghExecFileAsync: ghExecFileAsyncMock })) +vi.mock('./rate-limit', () => ({ + repositoryRateLimitGuard: rateLimitGuardMock, + noteRepositoryRateLimitSpend: noteRateLimitSpendMock +})) + +import { + _resetGitHubPRStackCacheForTests, + hydrateGitHubPRStack, + mergeGitHubPRStack +} from './github-pr-stack' + +const repository = { owner: 'stablyai', repo: 'orca', host: 'github.com' } +const summary: GitHubPRStack = { + number: 51, + position: 2, + size: 3, + baseRefName: 'main', + baseSha: 'base-sha' +} + +beforeEach(() => { + vi.useRealTimers() + ghExecFileAsyncMock.mockReset() + rateLimitGuardMock.mockReset() + rateLimitGuardMock.mockReturnValue({ blocked: false }) + noteRateLimitSpendMock.mockReset() + _resetGitHubPRStackCacheForTests() +}) + +describe('hydrateGitHubPRStack', () => { + it('maps and caches every entry in one GraphQL request', async () => { + ghExecFileAsyncMock.mockResolvedValue({ + stdout: JSON.stringify({ + data: { + repository: { + pullRequest: { + stack: { + number: 51, + size: 3, + baseRefName: 'main', + entries: { + nodes: [ + { + position: 2, + pullRequest: { + number: 202, + title: 'API', + url: 'https://github.com/stablyai/orca/pull/202', + updatedAt: '2026-08-10T00:00:00Z', + state: 'OPEN', + isDraft: false, + headRefName: 'stack/api', + headRefOid: 'api-sha', + mergeable: 'MERGEABLE', + reviewDecision: 'APPROVED', + mergeStateStatus: 'CLEAN', + statusCheckRollup: { state: 'SUCCESS' } + } + }, + { + position: 1, + pullRequest: { + number: 201, + title: 'Models', + url: 'https://github.com/stablyai/orca/pull/201', + updatedAt: '2026-08-10T00:00:00Z', + state: 'OPEN', + isDraft: false, + mergeable: 'UNKNOWN', + statusCheckRollup: { state: 'PENDING' } + } + } + ] + } + } + } + } + } + }) + }) + + const first = await hydrateGitHubPRStack(repository, 202, summary, { cwd: '/repo' }) + const sibling = await hydrateGitHubPRStack( + repository, + 201, + { ...summary, position: 1 }, + { cwd: '/other-worktree' } + ) + + expect(first).toMatchObject({ + number: 51, + position: 2, + baseSha: 'base-sha', + entries: [ + { number: 201, position: 1, checksStatus: 'pending' }, + { + number: 202, + position: 2, + checksStatus: 'success', + reviewDecision: 'APPROVED' + } + ] + }) + expect(sibling.position).toBe(1) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(noteRateLimitSpendMock).toHaveBeenCalledWith( + repository, + 'graphql', + 1, + expect.any(Object) + ) + }) + + it('refreshes cached stack details when the current PR changed', async () => { + const response = (updatedAt: string, isDraft: boolean) => ({ + stdout: JSON.stringify({ + data: { + repository: { + pullRequest: { + stack: { + number: 51, + size: 3, + baseRefName: 'main', + entries: { + nodes: [ + { + position: 2, + pullRequest: { + number: 202, + title: 'API', + url: 'https://github.com/stablyai/orca/pull/202', + updatedAt, + state: 'OPEN', + isDraft, + mergeable: 'MERGEABLE' + } + } + ] + } + } + } + } + } + }) + }) + ghExecFileAsyncMock + .mockResolvedValueOnce(response('2026-08-10T00:00:00Z', true)) + .mockResolvedValueOnce(response('2026-08-10T00:01:00Z', false)) + + const draft = await hydrateGitHubPRStack( + repository, + 202, + summary, + { cwd: '/repo' }, + '2026-08-10T00:00:00Z' + ) + const ready = await hydrateGitHubPRStack( + repository, + 202, + summary, + { cwd: '/repo' }, + '2026-08-10T00:01:00Z' + ) + + expect(draft.entries?.[0]?.state).toBe('draft') + expect(ready.entries?.[0]?.state).toBe('open') + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) + + it('keeps the REST summary when GraphQL is unavailable', async () => { + ghExecFileAsyncMock.mockRejectedValue(new Error('field is unavailable')) + + await expect(hydrateGitHubPRStack(repository, 202, summary, { cwd: '/repo' })).resolves.toEqual( + summary + ) + }) + + it('isolates cached details across execution scopes', async () => { + ghExecFileAsyncMock.mockResolvedValue({ + stdout: JSON.stringify({ + data: { + repository: { + pullRequest: { + stack: { number: 51, size: 3, baseRefName: 'main', entries: { nodes: [] } } + } + } + } + }) + }) + + await hydrateGitHubPRStack(repository, 202, summary, { cwd: '/repo' }, undefined, 'local:host') + await hydrateGitHubPRStack(repository, 202, summary, {}, undefined, 'ssh:ssh-1') + + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) +}) + +describe('mergeGitHubPRStack', () => { + it('submits the expected head and polls pending merges to completion', async () => { + vi.useFakeTimers() + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ status: 'pending', details: { uuid: 'merge-uuid' } }) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ status: 'merged', details: { message: 'Merged' } }) + }) + + const result = mergeGitHubPRStack({ + repository, + prNumber: 202, + method: 'squash', + mergeAction: 'direct_merge', + headSha: 'api-sha', + ghOptions: { cwd: '/repo' } + }) + await vi.advanceTimersByTimeAsync(1_000) + + await expect(result).resolves.toEqual({ ok: true }) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 1, + expect.arrayContaining([ + 'PUT', + 'repos/stablyai/orca/pulls/202/merge-async', + 'merge_method=squash', + 'merge_action=direct_merge', + 'sha=api-sha' + ]), + expect.objectContaining({ cwd: '/repo', host: 'github.com' }) + ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, + ['api', 'repos/stablyai/orca/pulls/202/merge-async/merge-uuid'], + expect.objectContaining({ cwd: '/repo', host: 'github.com' }) + ) + }) + + it('returns GitHub atomic failure details', async () => { + ghExecFileAsyncMock.mockResolvedValue({ + stdout: JSON.stringify({ + status: 'failed', + details: { message: 'A pull request has merge conflicts.' } + }) + }) + + await expect( + mergeGitHubPRStack({ + repository, + prNumber: 202, + method: 'rebase', + mergeAction: 'direct_merge', + ghOptions: { cwd: '/repo' } + }) + ).resolves.toEqual({ ok: false, error: 'A pull request has merge conflicts.' }) + }) + + it('omits the unsupported merge method when queueing a stack', async () => { + ghExecFileAsyncMock.mockResolvedValue({ + stdout: JSON.stringify({ status: 'enqueued', details: { message: 'Queued' } }) + }) + + await expect( + mergeGitHubPRStack({ + repository, + prNumber: 202, + method: 'squash', + mergeAction: 'merge_queue', + ghOptions: { cwd: '/repo' } + }) + ).resolves.toEqual({ ok: true }) + + const command = ghExecFileAsyncMock.mock.calls[0]?.[0] + expect(command).toContain('merge_action=merge_queue') + expect(command).not.toContain('merge_method=squash') + }) + + it('continues polling after a transient transport failure', async () => { + vi.useFakeTimers() + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ status: 'pending', details: { uuid: 'merge-uuid' } }) + }) + .mockRejectedValueOnce(new Error('temporary gateway failure')) + .mockResolvedValueOnce({ stdout: JSON.stringify({ status: 'merged' }) }) + + const result = mergeGitHubPRStack({ + repository, + prNumber: 202, + method: 'squash', + mergeAction: 'direct_merge', + ghOptions: { cwd: '/repo' } + }) + await vi.advanceTimersByTimeAsync(1_000) + await vi.advanceTimersByTimeAsync(1_000) + + await expect(result).resolves.toEqual({ ok: true }) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3) + }) + + it('reports an in-progress merge after polling is exhausted', async () => { + vi.useFakeTimers() + ghExecFileAsyncMock.mockResolvedValue({ + stdout: JSON.stringify({ status: 'pending', details: { uuid: 'merge-uuid' } }) + }) + + const result = mergeGitHubPRStack({ + repository, + prNumber: 202, + method: 'squash', + mergeAction: 'direct_merge', + ghOptions: { cwd: '/repo' } + }) + await vi.advanceTimersByTimeAsync(180_000) + + await expect(result).resolves.toEqual({ + ok: false, + error: 'GitHub is still merging this stack. Refresh to check its status.' + }) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(181) + }) +}) diff --git a/src/main/github/github-pr-stack.ts b/src/main/github/github-pr-stack.ts new file mode 100644 index 00000000000..24d0773a91c --- /dev/null +++ b/src/main/github/github-pr-stack.ts @@ -0,0 +1,282 @@ +import type { + CheckStatus, + GitHubPRStack, + GitHubPRStackEntry, + PRMergeableState, + PRReviewDecision, + PRState +} from '../../shared/types' +import { githubRepoIdentityKey } from '../../shared/github-repository-identity-key' +import { ghExecFileAsync } from '../git/runner' +import { + githubHostExecOptions, + type GitHubApiRepository, + type GitHubRepoExecOptions +} from './github-api-repository' +import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from './rate-limit' + +const STACK_CACHE_TTL_MS = 30_000 +const STACK_CACHE_MAX_ENTRIES = 256 + +type CachedStackDetails = { + value: Omit | null + expiresAt: number +} + +type GraphQLStackEntry = { + position?: unknown + pullRequest?: { + number?: unknown + title?: unknown + url?: unknown + updatedAt?: unknown + state?: unknown + isDraft?: unknown + headRefName?: unknown + headRefOid?: unknown + mergeable?: unknown + reviewDecision?: unknown + mergeStateStatus?: unknown + statusCheckRollup?: { state?: unknown } | null + } | null +} + +type GraphQLStackResponse = { + data?: { + repository?: { + pullRequest?: { + stack?: { + number?: unknown + size?: unknown + baseRefName?: unknown + entries?: { nodes?: (GraphQLStackEntry | null)[] | null } | null + } | null + } | null + } | null + } +} + +const stackDetailsCache = new Map() +const stackDetailsInFlight = new Map | null>>() + +export function _resetGitHubPRStackCacheForTests(): void { + stackDetailsCache.clear() + stackDetailsInFlight.clear() +} + +function stackCacheKey( + repository: GitHubApiRepository, + stackNumber: number, + executionScope: string +): string { + return `${executionScope}\0${githubRepoIdentityKey(repository)}:${stackNumber}` +} + +function pruneStackCache(now = Date.now()): void { + for (const [key, cached] of stackDetailsCache) { + if (cached.expiresAt <= now) { + stackDetailsCache.delete(key) + } + } + while (stackDetailsCache.size > STACK_CACHE_MAX_ENTRIES) { + const oldestKey = stackDetailsCache.keys().next().value + if (oldestKey === undefined) { + return + } + stackDetailsCache.delete(oldestKey) + } +} + +function mapStackPRState(value: unknown, isDraft: unknown): PRState { + if (value === 'MERGED') { + return 'merged' + } + if (value === 'CLOSED') { + return 'closed' + } + return isDraft === true ? 'draft' : 'open' +} + +function mapStackCheckStatus(value: unknown): CheckStatus { + if (value === 'SUCCESS') { + return 'success' + } + if (value === 'FAILURE' || value === 'ERROR') { + return 'failure' + } + if (value === 'PENDING' || value === 'EXPECTED') { + return 'pending' + } + return 'neutral' +} + +function mapStackMergeable(value: unknown): PRMergeableState { + return value === 'MERGEABLE' || value === 'CONFLICTING' ? value : 'UNKNOWN' +} + +function mapReviewDecision(value: unknown): PRReviewDecision | null | undefined { + if (value === null) { + return null + } + if (value === 'APPROVED' || value === 'CHANGES_REQUESTED' || value === 'REVIEW_REQUIRED') { + return value + } + return undefined +} + +function mapStackEntry(entry: GraphQLStackEntry): GitHubPRStackEntry | null { + const pr = entry.pullRequest + if ( + typeof entry.position !== 'number' || + typeof pr?.number !== 'number' || + typeof pr.title !== 'string' || + typeof pr.url !== 'string' + ) { + return null + } + const reviewDecision = mapReviewDecision(pr.reviewDecision) + return { + position: entry.position, + number: pr.number, + title: pr.title, + url: pr.url, + ...(typeof pr.updatedAt === 'string' ? { updatedAt: pr.updatedAt } : {}), + state: mapStackPRState(pr.state, pr.isDraft), + checksStatus: mapStackCheckStatus(pr.statusCheckRollup?.state), + mergeable: mapStackMergeable(pr.mergeable), + ...(reviewDecision !== undefined ? { reviewDecision } : {}), + ...(typeof pr.mergeStateStatus === 'string' ? { mergeStateStatus: pr.mergeStateStatus } : {}), + ...(typeof pr.headRefName === 'string' ? { headRefName: pr.headRefName } : {}), + ...(typeof pr.headRefOid === 'string' ? { headSha: pr.headRefOid } : {}) + } +} + +function parseStackDetails( + response: GraphQLStackResponse, + expectedStackNumber: number, + fallbackBaseRefName: string, + fallbackSize: number +): Omit | null { + const stack = response.data?.repository?.pullRequest?.stack + if (!stack || stack.number !== expectedStackNumber) { + return null + } + const entries = (stack.entries?.nodes ?? []) + .flatMap((entry) => (entry ? [mapStackEntry(entry)] : [])) + .filter((entry): entry is GitHubPRStackEntry => entry !== null) + .sort((a, b) => a.position - b.position) + return { + number: expectedStackNumber, + size: typeof stack.size === 'number' ? stack.size : fallbackSize, + baseRefName: typeof stack.baseRefName === 'string' ? stack.baseRefName : fallbackBaseRefName, + ...(entries.length > 0 ? { entries } : {}) + } +} + +const STACK_DETAILS_QUERY = ` +query($owner: String!, $repo: String!, $pr: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $pr) { + stack { + number + size + baseRefName + entries(first: 100) { + nodes { + position + pullRequest { + number + title + url + updatedAt + state + isDraft + headRefName + headRefOid + mergeable + reviewDecision + mergeStateStatus + statusCheckRollup { state } + } + } + } + } + } + } +}` + +async function fetchStackDetails( + repository: GitHubApiRepository, + prNumber: number, + summary: GitHubPRStack, + ghOptions: GitHubRepoExecOptions +): Promise | null> { + if (repositoryRateLimitGuard(repository, 'graphql', ghOptions).blocked) { + return null + } + noteRepositoryRateLimitSpend(repository, 'graphql', 1, ghOptions) + const { stdout } = await ghExecFileAsync( + [ + 'api', + 'graphql', + '-f', + `query=${STACK_DETAILS_QUERY}`, + '-f', + `owner=${repository.owner}`, + '-f', + `repo=${repository.repo}`, + '-F', + `pr=${prNumber}` + ], + { ...ghOptions, ...githubHostExecOptions(repository) } + ) + return parseStackDetails( + JSON.parse(stdout) as GraphQLStackResponse, + summary.number, + summary.baseRefName, + summary.size + ) +} + +export async function hydrateGitHubPRStack( + repository: GitHubApiRepository, + prNumber: number, + summary: GitHubPRStack, + ghOptions: GitHubRepoExecOptions, + prUpdatedAt?: string, + executionScope = 'local:host' +): Promise { + const key = stackCacheKey(repository, summary.number, executionScope) + const now = Date.now() + pruneStackCache(now) + const cached = stackDetailsCache.get(key) + const cachedPRUpdatedAt = cached?.value?.entries?.find( + (entry) => entry.number === prNumber + )?.updatedAt + const cachedMatchesPR = !prUpdatedAt || !cachedPRUpdatedAt || cachedPRUpdatedAt === prUpdatedAt + if (cached && cached.expiresAt > now && cachedMatchesPR) { + return cached.value + ? { ...cached.value, position: summary.position, baseSha: summary.baseSha } + : summary + } + const existing = stackDetailsInFlight.get(key) + if (existing) { + const value = await existing + return value ? { ...value, position: summary.position, baseSha: summary.baseSha } : summary + } + const request = fetchStackDetails(repository, prNumber, summary, ghOptions).catch(() => null) + stackDetailsInFlight.set(key, request) + try { + const value = await request + stackDetailsCache.delete(key) + stackDetailsCache.set(key, { value, expiresAt: Date.now() + STACK_CACHE_TTL_MS }) + pruneStackCache() + return value ? { ...value, position: summary.position, baseSha: summary.baseSha } : summary + } finally { + if (stackDetailsInFlight.get(key) === request) { + stackDetailsInFlight.delete(key) + } + } +} + +export { mergeGitHubPRStack } from './github-pr-stack-async-merge' diff --git a/src/main/github/github-stack-api-responses.ts b/src/main/github/github-stack-api-responses.ts new file mode 100644 index 00000000000..727d72331ef --- /dev/null +++ b/src/main/github/github-stack-api-responses.ts @@ -0,0 +1,52 @@ +import type { HostedReviewSummary } from '../../shared/hosted-review' + +export type NumberedHostedReviewSummary = Omit & { number: number } + +export type GitHubStackPullRequest = NumberedHostedReviewSummary & { + headRefName: string + baseRefName: string +} + +export type GitHubStack = { + number: number + open: boolean + pull_requests: { number: number }[] +} + +export function parseGitHubStackPullRequests(stdout: string): GitHubStackPullRequest[] { + const pullRequests = JSON.parse(stdout) as { + number?: unknown + html_url?: unknown + head?: { ref?: unknown } + base?: { ref?: unknown } + }[] + return pullRequests.flatMap((pullRequest) => { + const number = Number(pullRequest.number) + const url = typeof pullRequest.html_url === 'string' ? pullRequest.html_url : '' + const headRefName = typeof pullRequest.head?.ref === 'string' ? pullRequest.head.ref : '' + const baseRefName = typeof pullRequest.base?.ref === 'string' ? pullRequest.base.ref : '' + return Number.isInteger(number) && number > 0 && url && headRefName && baseRefName + ? [{ number, url, headRefName, baseRefName }] + : [] + }) +} + +export function parseGitHubStacks(stdout: string): GitHubStack[] { + const stacks = JSON.parse(stdout) as { + number?: unknown + open?: unknown + pull_requests?: { number?: unknown }[] + }[] + return stacks.flatMap((stack) => { + const number = Number(stack.number) + const pullRequests = (stack.pull_requests ?? []).flatMap((pullRequest) => { + const pullRequestNumber = Number(pullRequest.number) + return Number.isInteger(pullRequestNumber) && pullRequestNumber > 0 + ? [{ number: pullRequestNumber }] + : [] + }) + return Number.isInteger(number) && number > 0 + ? [{ number, open: stack.open === true, pull_requests: pullRequests }] + : [] + }) +} diff --git a/src/main/github/stacked-pr-creation.test.ts b/src/main/github/stacked-pr-creation.test.ts new file mode 100644 index 00000000000..e97ea14f2a4 --- /dev/null +++ b/src/main/github/stacked-pr-creation.test.ts @@ -0,0 +1,273 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { ghExecFileAsyncMock, repositoryMock } = vi.hoisted(() => ({ + ghExecFileAsyncMock: vi.fn(), + repositoryMock: vi.fn() +})) + +vi.mock('./gh-utils', () => ({ + acquire: vi.fn(), + release: vi.fn(), + ghExecFileAsync: ghExecFileAsyncMock, + ghRepoExecOptions: (context: { repoPath: string; connectionId?: string | null }) => + context.connectionId ? {} : { cwd: context.repoPath }, + githubRepoContext: ( + repoPath: string, + connectionId?: string | null, + localGitOptions?: Record + ) => ({ repoPath, connectionId, localGitOptions }) +})) + +vi.mock('./github-api-repository', () => ({ + getOriginGitHubApiRepository: repositoryMock, + githubHostExecOptions: (repository: { host?: string }) => ({ host: repository.host }) +})) + +import { + prepareGitHubStackedPullRequest, + registerGitHubStackedPullRequest +} from './stacked-pr-creation' + +const repository = { owner: 'acme', repo: 'orca', host: 'github.com' } +const parentReview = { number: 41, url: 'https://github.com/acme/orca/pull/41' } +const currentReview = { number: 42, url: 'https://github.com/acme/orca/pull/42' } + +function pullRequest(number: number, head: string, base: string) { + return { + number, + html_url: `https://github.com/acme/orca/pull/${number}`, + head: { ref: head }, + base: { ref: base } + } +} + +function stack(number: number, pullRequests: number[]) { + return { + number, + open: true, + pull_requests: pullRequests.map((pullRequestNumber) => ({ number: pullRequestNumber })) + } +} + +beforeEach(() => { + ghExecFileAsyncMock.mockReset() + repositoryMock.mockReset() + repositoryMock.mockResolvedValue(repository) +}) + +describe('prepareGitHubStackedPullRequest', () => { + it('resolves an open parent PR and an existing current PR', async () => { + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify([pullRequest(41, 'stack/parent', 'main')]) }) + .mockResolvedValueOnce({ + stdout: JSON.stringify([pullRequest(42, 'stack/child', 'stack/parent')]) + }) + .mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [40, 41])]) }) + .mockResolvedValueOnce({ stdout: '[]' }) + + const result = await prepareGitHubStackedPullRequest('/repo', { + provider: 'github', + base: 'origin/stack/parent', + head: 'refs/heads/stack/child', + title: 'Child' + }) + + expect(result).toMatchObject({ + ok: true, + parentReview: { number: 41 }, + currentReview: { number: 42 } + }) + expect(ghExecFileAsyncMock.mock.calls[0][0]).toEqual([ + 'api', + 'repos/acme/orca/pulls?head=acme%3Astack%2Fparent&state=open&per_page=2' + ]) + expect(ghExecFileAsyncMock.mock.calls[1][0]).toEqual([ + 'api', + 'repos/acme/orca/pulls?head=acme%3Astack%2Fchild&base=stack%2Fparent&state=open&per_page=2' + ]) + }) + + it('allows an idempotent retry after the child was already registered', async () => { + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify([pullRequest(41, 'stack/parent', 'main')]) }) + .mockResolvedValueOnce({ + stdout: JSON.stringify([pullRequest(42, 'stack/child', 'stack/parent')]) + }) + .mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [41, 42])]) }) + .mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [41, 42])]) }) + + const result = await prepareGitHubStackedPullRequest('/repo', { + provider: 'github', + base: 'stack/parent', + head: 'stack/child', + title: 'Child' + }) + + expect(result).toMatchObject({ ok: true, currentReview: { number: 42 } }) + }) + + it('requires an open PR for the selected parent branch', async () => { + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: '[]' }) + .mockResolvedValueOnce({ stdout: '[]' }) + + const result = await prepareGitHubStackedPullRequest('/repo', { + provider: 'github', + base: 'feature/parent', + head: 'feature/child', + title: 'Child' + }) + + expect(result).toMatchObject({ ok: false, code: 'validation' }) + if (!result.ok) { + expect(result.error).toContain('does not have an open pull request') + } + }) + + it('rejects a parent that is not the top of its stack', async () => { + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify([pullRequest(41, 'stack/parent', 'main')]) }) + .mockResolvedValueOnce({ stdout: '[]' }) + .mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [41, 45])]) }) + + const result = await prepareGitHubStackedPullRequest('/repo', { + provider: 'github', + base: 'stack/parent', + head: 'stack/child', + title: 'Child' + }) + + expect(result).toMatchObject({ ok: false, code: 'validation' }) + if (!result.ok) { + expect(result.error).toContain('top pull request') + } + }) + + it('does not offer stacks on GitHub Enterprise Server', async () => { + repositoryMock.mockResolvedValue({ + owner: 'acme', + repo: 'orca', + host: 'github.acme.test' + }) + + const result = await prepareGitHubStackedPullRequest('/repo', { + provider: 'github', + base: 'stack/parent', + head: 'stack/child', + title: 'Child' + }) + + expect(result).toMatchObject({ ok: false, code: 'validation' }) + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) +}) + +describe('registerGitHubStackedPullRequest', () => { + it('creates a new stack with the parent and current PR', async () => { + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: '[]' }) + .mockResolvedValueOnce({ stdout: '[]' }) + .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 50 }) }) + + const result = await registerGitHubStackedPullRequest({ + repoPath: '/repo', + repository, + parentReview, + currentReview + }) + + expect(result).toMatchObject({ ok: true, number: 42, stackNumber: 50 }) + expect(ghExecFileAsyncMock.mock.calls[2][0]).toEqual([ + 'api', + '-X', + 'POST', + 'repos/acme/orca/stacks', + '-F', + 'pull_requests[]=41', + '-F', + 'pull_requests[]=42' + ]) + }) + + it('appends the current PR when the parent is the existing top', async () => { + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [40, 41])]) }) + .mockResolvedValueOnce({ stdout: '[]' }) + .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 50 }) }) + + const result = await registerGitHubStackedPullRequest({ + repoPath: '/repo', + repository, + parentReview, + currentReview, + connectionId: 'ssh-1' + }) + + expect(result).toMatchObject({ ok: true, stackNumber: 50 }) + expect(ghExecFileAsyncMock.mock.calls[2][0]).toEqual([ + 'api', + '-X', + 'POST', + 'repos/acme/orca/stacks/50/add', + '-F', + 'pull_requests[]=42' + ]) + expect(ghExecFileAsyncMock.mock.calls[2][1]).not.toHaveProperty('cwd') + }) + + it('treats an already registered parent-child pair as success', async () => { + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [41, 42])]) }) + .mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [41, 42])]) }) + + const result = await registerGitHubStackedPullRequest({ + repoPath: '/repo', + repository, + parentReview, + currentReview + }) + + expect(result).toMatchObject({ ok: true, stackNumber: 50 }) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) + + it('does not claim registration when the stack no longer holds the parent', async () => { + // A concurrent stack edit can drop the parent while the child sits at index 0. + // Reading index 0 off a findIndex miss would report that pair as registered. + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [42])]) }) + .mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [42])]) }) + + const result = await registerGitHubStackedPullRequest({ + repoPath: '/repo', + repository, + parentReview, + currentReview + }) + + expect(result).toMatchObject({ + ok: false, + error: 'The pull request already belongs to a different GitHub stack.', + createdReview: currentReview + }) + }) + + it('preserves the created PR when registration fails', async () => { + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: '[]' }) + .mockResolvedValueOnce({ stdout: '[]' }) + .mockRejectedValueOnce(new Error('HTTP 422')) + + const result = await registerGitHubStackedPullRequest({ + repoPath: '/repo', + repository, + parentReview, + currentReview + }) + + expect(result).toMatchObject({ + ok: false, + createdReview: currentReview + }) + }) +}) diff --git a/src/main/github/stacked-pr-creation.ts b/src/main/github/stacked-pr-creation.ts new file mode 100644 index 00000000000..357f78bcbc6 --- /dev/null +++ b/src/main/github/stacked-pr-creation.ts @@ -0,0 +1,310 @@ +import type { + CreateStackedHostedReviewInput, + CreateStackedHostedReviewResult +} from '../../shared/hosted-review' +import { isDefaultGitHubHost } from '../../shared/github-repository-identity-key' +import { + normalizeHostedReviewBaseRef, + normalizeHostedReviewHeadRef +} from '../../shared/hosted-review-refs' +import { acquire, ghExecFileAsync, ghRepoExecOptions, githubRepoContext, release } from './gh-utils' +import { + getOriginGitHubApiRepository, + githubHostExecOptions, + type GitHubApiRepository +} from './github-api-repository' +import { + getHostedReviewLocalGitOptions, + type HostedReviewExecutionOptions +} from '../source-control/hosted-review-git-options' +import { + parseGitHubStackPullRequests, + parseGitHubStacks, + type GitHubStack, + type GitHubStackPullRequest, + type NumberedHostedReviewSummary +} from './github-stack-api-responses' + +type StackedPullRequestPlan = + | { + ok: true + repository: GitHubApiRepository + parentReview: GitHubStackPullRequest + currentReview: GitHubStackPullRequest | null + } + | Extract + +function creationError(error: string): Extract { + return { ok: false, code: 'validation', error } +} + +function isStacksUnavailableError(error: unknown): boolean { + const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase() + return message.includes('http 404') || message.includes('feature not supported') +} + +function ghOptions( + repoPath: string, + repository: GitHubApiRepository, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +) { + return { + ...ghRepoExecOptions( + githubRepoContext(repoPath, connectionId, getHostedReviewLocalGitOptions(options)) + ), + ...githubHostExecOptions(repository), + timeout: 60_000 + } +} + +async function findOpenPullRequestsForBranch( + repoPath: string, + repository: GitHubApiRepository, + branch: string, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {}, + base?: string +): Promise { + const head = encodeURIComponent(`${repository.owner}:${branch}`) + const baseQuery = base ? `&base=${encodeURIComponent(base)}` : '' + const endpoint = `repos/${repository.owner}/${repository.repo}/pulls?head=${head}${baseQuery}&state=open&per_page=2` + const { stdout } = await ghExecFileAsync( + ['api', endpoint], + ghOptions(repoPath, repository, connectionId, options) + ) + return parseGitHubStackPullRequests(stdout) +} + +async function getStacksForPullRequest( + repoPath: string, + repository: GitHubApiRepository, + pullRequestNumber: number, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +): Promise { + const endpoint = `repos/${repository.owner}/${repository.repo}/stacks?pull_request=${pullRequestNumber}` + const { stdout } = await ghExecFileAsync( + ['api', endpoint], + ghOptions(repoPath, repository, connectionId, options) + ) + return parseGitHubStacks(stdout) +} + +function validateParentStack( + parentReview: NumberedHostedReviewSummary, + stacks: GitHubStack[] +): Extract | null { + if (stacks.length > 1) { + return creationError('The selected parent pull request belongs to multiple stacks.') + } + const stack = stacks[0] + if (!stack) { + return null + } + if (!stack.open) { + return creationError('The selected parent belongs to a closed stack.') + } + if (stack.pull_requests.at(-1)?.number !== parentReview.number) { + return creationError( + 'Choose the top pull request in the stack as the base branch before adding another layer.' + ) + } + return null +} + +export async function prepareGitHubStackedPullRequest( + repoPath: string, + input: CreateStackedHostedReviewInput, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +): Promise { + if (input.provider !== 'github') { + return creationError('Stacked pull request creation is available only for GitHub repositories.') + } + const repository = await getOriginGitHubApiRepository( + repoPath, + connectionId, + getHostedReviewLocalGitOptions(options) + ) + if (!repository || !isDefaultGitHubHost(repository.host)) { + return creationError('GitHub stacked pull requests are available only on GitHub.com.') + } + const base = normalizeHostedReviewBaseRef(input.base).trim() + const head = input.head ? normalizeHostedReviewHeadRef(input.head).trim() : '' + if (!base || !head || base.toLowerCase() === head.toLowerCase()) { + return creationError('Choose a different parent branch before creating a stacked pull request.') + } + + await acquire() + try { + const [parentPullRequests, currentPullRequests] = await Promise.all([ + findOpenPullRequestsForBranch(repoPath, repository, base, connectionId, options), + findOpenPullRequestsForBranch(repoPath, repository, head, connectionId, options, base) + ]) + if (parentPullRequests.length === 0) { + return creationError( + `The parent branch ${base} does not have an open pull request. Create that pull request first.` + ) + } + if (parentPullRequests.length !== 1) { + return creationError(`Orca found multiple open pull requests for the parent branch ${base}.`) + } + if (currentPullRequests.length > 1) { + return creationError(`Orca found multiple open pull requests for the current branch ${head}.`) + } + const parentReview = parentPullRequests[0] + const currentReview = currentPullRequests[0] ?? null + const [parentStacks, currentStacks] = await Promise.all([ + getStacksForPullRequest(repoPath, repository, parentReview.number, connectionId, options), + currentReview + ? getStacksForPullRequest(repoPath, repository, currentReview.number, connectionId, options) + : Promise.resolve([]) + ]) + if ( + currentReview && + registeredStackNumber(parentReview, currentReview, parentStacks, currentStacks) + ) { + return { ok: true, repository, parentReview, currentReview } + } + if (currentStacks.length > 0) { + return creationError('The pull request already belongs to a different GitHub stack.') + } + const parentError = validateParentStack(parentReview, parentStacks) + return ( + parentError ?? { + ok: true, + repository, + parentReview, + currentReview + } + ) + } catch (error) { + console.warn('GitHub stack creation preflight failed:', error) + return { + ok: false, + code: isStacksUnavailableError(error) ? 'validation' : 'unknown', + error: isStacksUnavailableError(error) + ? 'GitHub stacked pull requests are not available for this repository.' + : 'Orca could not verify the parent pull request. Retry in a moment.' + } + } finally { + release() + } +} + +function registeredStackNumber( + parentReview: NumberedHostedReviewSummary, + currentReview: NumberedHostedReviewSummary, + parentStacks: GitHubStack[], + currentStacks: GitHubStack[] +): number | null { + const parentStack = parentStacks[0] + const currentStack = currentStacks[0] + if (!parentStack || !currentStack || parentStack.number !== currentStack.number) { + return null + } + const parentPosition = parentStack.pull_requests.findIndex( + (pullRequest) => pullRequest.number === parentReview.number + ) + // Why: a miss is -1, and -1 + 1 reads the first entry — which reports "already + // registered" whenever the current PR heads a stack the parent has left. + if (parentPosition < 0) { + return null + } + return parentStack.pull_requests[parentPosition + 1]?.number === currentReview.number + ? parentStack.number + : null +} + +export async function registerGitHubStackedPullRequest(args: { + repoPath: string + repository: GitHubApiRepository + parentReview: NumberedHostedReviewSummary + currentReview: NumberedHostedReviewSummary + connectionId?: string | null + options?: HostedReviewExecutionOptions +}): Promise { + const options = args.options ?? {} + await acquire() + try { + const [parentStacks, currentStacks] = await Promise.all([ + getStacksForPullRequest( + args.repoPath, + args.repository, + args.parentReview.number, + args.connectionId, + options + ), + getStacksForPullRequest( + args.repoPath, + args.repository, + args.currentReview.number, + args.connectionId, + options + ) + ]) + const existingStackNumber = registeredStackNumber( + args.parentReview, + args.currentReview, + parentStacks, + currentStacks + ) + if (existingStackNumber) { + return { + ok: true, + ...args.currentReview, + stackNumber: existingStackNumber, + parentReview: args.parentReview + } + } + if (currentStacks.length > 0) { + return { + ...creationError('The pull request already belongs to a different GitHub stack.'), + createdReview: args.currentReview + } + } + const parentError = validateParentStack(args.parentReview, parentStacks) + if (parentError) { + return { ...parentError, createdReview: args.currentReview } + } + + const parentStack = parentStacks[0] + const endpoint = parentStack + ? `repos/${args.repository.owner}/${args.repository.repo}/stacks/${parentStack.number}/add` + : `repos/${args.repository.owner}/${args.repository.repo}/stacks` + const pullRequests = parentStack + ? [args.currentReview.number] + : [args.parentReview.number, args.currentReview.number] + const command = ['api', '-X', 'POST', endpoint] + for (const pullRequest of pullRequests) { + command.push('-F', `pull_requests[]=${pullRequest}`) + } + const { stdout } = await ghExecFileAsync(command, { + ...ghOptions(args.repoPath, args.repository, args.connectionId, options), + idempotent: false + }) + const stackNumber = Number((JSON.parse(stdout) as { number?: unknown }).number) + if (!Number.isInteger(stackNumber) || stackNumber <= 0) { + throw new Error('GitHub returned an invalid stack response.') + } + return { + ok: true, + ...args.currentReview, + stackNumber, + parentReview: args.parentReview + } + } catch (error) { + console.warn('GitHub stack registration failed:', error) + return { + ok: false, + code: isStacksUnavailableError(error) ? 'validation' : 'unknown', + error: isStacksUnavailableError(error) + ? 'The pull request was created, but GitHub stacks are not available for this repository.' + : 'The pull request was created, but GitHub could not add it to the stack. Retry to finish stack registration.', + createdReview: args.currentReview + } + } finally { + release() + } +} diff --git a/src/main/global-fetch-call-site-audit.test.ts b/src/main/global-fetch-call-site-audit.test.ts index 625d9fa2b04..b46c1b793be 100644 --- a/src/main/global-fetch-call-site-audit.test.ts +++ b/src/main/global-fetch-call-site-audit.test.ts @@ -18,6 +18,7 @@ const AUDITED_GLOBAL_FETCH_LINES = new Map([ ['main/artifacts/artifact-cloud-request.ts', 1], ['main/azure-devops/azure-devops-api-request.ts', 1], ['main/bitbucket/client.ts', 1], + ['main/bitbucket/user-request.ts', 1], ['main/gitea/client.ts', 1], ['main/orca-profiles/profile-cloud-client.ts', 1], ['main/orca-profiles/profile-cloud-org-members-client.ts', 1], diff --git a/src/main/index.ts b/src/main/index.ts index 0a4be2562c8..3f076c2e908 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -39,7 +39,10 @@ import { shutdownDaemon } from './daemon/daemon-init' import { - hasRecordedLegacySharedCodexPane, + type CodexPaneHomeRoute, + getCodexPaneAccount, + hasRecordedManagedHostCodexPane, + isCodexPaneHomeRouteProvenAwayFromSharedHome, reconcileCodexPaneAccountsWithLivePtys } from './codex/codex-pane-account-registry' import { closeAllWatchers } from './ipc/filesystem-watcher' @@ -160,6 +163,10 @@ import { createWslCliReconciliationStartupBarrier } from './startup/wsl-cli-reco import { getDevInstanceIdentity } from './startup/dev-instance-identity' import { hydrateShellPath, mergePathSegments } from './startup/hydrate-shell-path' import { createWindowsShellPathHydration } from './startup/windows-shell-path-hydration' +import { + startWindowsDesktopBeforeShellPathReady, + type WindowsDesktopStartupServices +} from './startup/windows-desktop-shell-path-startup' import { acquireSingleInstanceLock, logSingleInstanceLockBypass, @@ -216,6 +223,7 @@ import { } from './codex-accounts/runtime-selection' import { normalizeClaudeRuntimeSelection } from './claude-accounts/runtime-selection' import { codexHookService, setSystemCodexHomeHookSweepSuppressed } from './codex/hook-service' +import { reconcileRetainedCodexHookHomes } from './codex/retained-codex-hook-state' import { ensureRealHomeCodexHookState, isRealHomeCodexHookLaneUsable @@ -253,7 +261,10 @@ import { maybeAutoRenameBranchOnFirstWork } from './agent-hooks/first-work-branc import { rememberBranchRenameFailureOutput } from './agent-hooks/branch-rename-failure-output' import { renameWorktreeFolderOnFirstWork } from './agent-hooks/first-work-folder-rename' import { moveWorktree } from './git/worktree' -import { setDefaultWslDistroOverride } from './git/runner' +import { + configureWindowsHostGitEnvironmentReadiness, + setDefaultWslDistroOverride +} from './git/runner' import { getRepoIdFromWorktreeId } from '../shared/worktree-id' import { parseWorkspaceKey } from '../shared/workspace-scope' import { setMigrationUnsupportedPtyListener } from './agent-hooks/migration-unsupported-pty-state' @@ -400,10 +411,23 @@ function handleCodexHomePtySpawned(args: { id: string codexHomePath: string | null reattached?: boolean + reattachedHomeRoute?: CodexPaneHomeRoute | null launchEnv?: NodeJS.ProcessEnv startedAt?: Date startedSequence?: number }): void { + // Why: only shared or ambiguous retained shells can create rollout logs that still need publication. + if (args.reattached && args.startedSequence !== undefined) { + const paneAccount = getCodexPaneAccount(args.id) + const homeRoute = + args.reattachedHomeRoute !== undefined + ? (args.reattachedHomeRoute ?? undefined) + : paneAccount?.homeRoute + if (codexSessionMigration && isCodexPaneHomeRouteProvenAwayFromSharedHome(homeRoute)) { + codexSessionMigration.ignoreLaunch(args.id, args.startedSequence) + return + } + } const fullScanRequired = codexRuntimeHome?.beginHostSystemDefaultSessionMigrationLaunch(args.codexHomePath, { reattached: args.reattached, @@ -906,7 +930,7 @@ async function reapRestoredSubagentsWithoutLiveAgent(): Promise { }) } -function startTerminalRuntimeStartupServices(): Promise { +function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServices { logStartupMilestone('first-window-startup-services-start') const startupServices = startFirstWindowStartupServices({ // Why: both desktop and headless serve must adopt the same persistent provider before creating terminals or a renderer. @@ -917,10 +941,19 @@ function startTerminalRuntimeStartupServices(): Promise { await initDaemonPtyProvider(signal, { macosLoginSessionWatch: process.platform === 'darwin' && !isServeMode }) - if (codexRuntimeHome?.isHostSystemDefaultRealHome() && hasRecordedLegacySharedCodexPane()) { + // Why: a retained shell keeps its launch-time Codex home even when the current routing lane changes. + if (codexRuntimeHome && hasRecordedManagedHostCodexPane()) { const livePtyIds = await listLiveDaemonPtyIds() if (livePtyIds) { reconcileCodexPaneAccountsWithLivePtys(livePtyIds) + const settings = store?.getSettings() + reconcileRetainedCodexHookHomes({ + hookService: codexHookService, + hooksEnabled: + isAgentStatusHooksEnabled(settings) && + settings?.disabledTuiAgents.includes('codex') !== true, + runtimeHomePaths: codexRuntimeHome.getRetainedHostCodexHookHomePaths(livePtyIds) + }) } } // Why: retained shells can invoke Codex immediately after the startup gate. @@ -954,19 +987,24 @@ function startTerminalRuntimeStartupServices(): Promise { console.error('[agent-hooks] Failed to start local hook server:', error) } }) - firstWindowStartupServicesReady = startupServices.firstWindowReady - localPtyStartupReady = startupServices.localPtyReady - localPtyProviderStartupReady = startupServices.localPtyProviderReady - void firstWindowStartupServicesReady.then(() => { + void startupServices.firstWindowReady.then(() => { logStartupMilestone('first-window-startup-services-ready') }) - void localPtyStartupReady.then(() => { + void startupServices.localPtyReady.then(() => { logStartupMilestone('local-pty-startup-ready') void reapRestoredSubagentsWithoutLiveAgent().catch((error) => { console.warn('[agent-hooks] restored-subagent liveness probe failed:', error) }) }) - return firstWindowStartupServicesReady + return startupServices +} + +function bindTerminalRuntimeStartupServices( + services: Promise +): void { + firstWindowStartupServicesReady = services.then((value) => value.firstWindowReady) + localPtyStartupReady = services.then((value) => value.localPtyReady) + localPtyProviderStartupReady = services.then((value) => value.localPtyProviderReady) } function prepareCodexRuntimeHomeForLaunch( @@ -1222,7 +1260,7 @@ function syncMacMenuBarIcon(showMenuBarIcon: boolean): Tray | null { return options ? setMacMenuBarIconVisible(showMenuBarIcon, options) : null } -function openMainWindow(): BrowserWindow { +function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): BrowserWindow { logStartupMilestone('open-main-window-start') if (!store) { throw new Error('Store must be initialized before opening the main window') @@ -1311,6 +1349,7 @@ function openMainWindow(): BrowserWindow { void presentRendererRecoveryPrompt(recentRecoveryCount) }, deferLoad: true, + ...(options.revealOnDidFinishLoad === true ? { revealOnDidFinishLoad: true } : {}), title: devInstanceIdentity.name, getKeybindings: () => keybindings?.getOverrides(), onBeforeReload: ({ ignoreCache, webContentsId }) => { @@ -1350,6 +1389,9 @@ function openMainWindow(): BrowserWindow { logStartupMilestone('ready-to-show') setImmediate(createSystemTrayDeferred) }) + window.once('show', () => { + logStartupMilestone('window-shown') + }) const trayCreateFallback = setTimeout(createSystemTrayDeferred, TRAY_CREATE_FALLBACK_MS) trayCreateFallback.unref?.() @@ -2127,6 +2169,9 @@ void app.whenReady().then(async () => { const activeOrcaProfile = ensureActiveOrcaProfile() store = new Store({ dataFile: activeOrcaProfile.dataFile }) const windowsShellPathHydration = createWindowsShellPathHydration() + configureWindowsHostGitEnvironmentReadiness( + process.platform === 'win32' ? windowsShellPathHydration.whenReady : null + ) if (process.platform === 'win32') { const settings = store.getSettings() if (app.isPackaged) { @@ -2954,9 +2999,20 @@ void app.whenReady().then(async () => { } }) - // Why: Git hooks inherit process.env, so Source Control must not open before profile PATH settles. - await windowsShellPathHydration.whenReady() - startTerminalRuntimeStartupServices() + const shellPathReady = windowsShellPathHydration.whenReady() + let desktopWindow: BrowserWindow | null = null + if (process.platform === 'win32' && app.isPackaged && !serveOptions) { + const desktopStartup = startWindowsDesktopBeforeShellPathReady({ + openWindow: () => openMainWindow({ revealOnDidFinishLoad: true }), + shellPathReady, + startServices: startTerminalRuntimeStartupServices + }) + desktopWindow = desktopStartup.window + bindTerminalRuntimeStartupServices(desktopStartup.services) + } else { + await shellPathReady + bindTerminalRuntimeStartupServices(Promise.resolve(startTerminalRuntimeStartupServices())) + } app.on('activate', handleMacAppActivation) if (serveOptions) { @@ -3040,15 +3096,21 @@ void app.whenReady().then(async () => { } // Why: window and RPC startup run in parallel; registerPtyHandlers gates PTY spawns so RPC binds without racing the daemon provider swap. + const desktopRuntimeRpc = runtimeRpc + if (!desktopRuntimeRpc) { + throw new Error('runtime_rpc_unavailable') + } const [win, runtimeRpcStartResult] = await Promise.all([ - Promise.resolve(openMainWindow()), - runtimeRpc.start().then( - () => ({ ok: true as const }), - (error: unknown) => { - recordRuntimeRpcStartFailure(error) - return { ok: false as const, error } - } - ) + Promise.resolve(desktopWindow ?? openMainWindow()), + shellPathReady + .then(() => desktopRuntimeRpc.start()) + .then( + () => ({ ok: true as const }), + (error: unknown) => { + recordRuntimeRpcStartFailure(error) + return { ok: false as const, error } + } + ) ]) if (!runtimeRpcStartResult.ok) { void showRuntimeRpcStartupFailureDialog(win, runtimeRpcStartResult.error) diff --git a/src/main/integration-credential-file.ts b/src/main/integration-credential-file.ts index 2ad90492f1b..c65dcc1fa7a 100644 --- a/src/main/integration-credential-file.ts +++ b/src/main/integration-credential-file.ts @@ -1,4 +1,4 @@ -import { statSync } from 'node:fs' +import { chmodSync, statSync, writeFileSync } from 'node:fs' import { safeStorage } from 'electron' import { credentialDecryptionMessage, @@ -15,6 +15,36 @@ export function credentialFileHasContent(path: string): boolean { } } +// Falls back to 0600 plaintext when the OS keyring is unavailable, matching the +// Linear/Jira token writers so a keyring-less box still connects. +export function writeEncryptedCredential( + service: IntegrationCredentialService, + path: string, + value: string +): void { + if (safeStorage.isEncryptionAvailable()) { + writeFileSync(path, safeStorage.encryptString(value), { mode: 0o600 }) + restrictCredentialFileToOwner(path) + return + } + console.warn( + `[${service.toLowerCase()}] safeStorage encryption unavailable — storing credential in plaintext` + ) + writeFileSync(path, value, { encoding: 'utf-8', mode: 0o600 }) + restrictCredentialFileToOwner(path) +} + +// Why: writeFileSync's `mode` only applies when it creates the file, so +// rewriting an existing credential would silently keep looser permissions. +// Best-effort: Windows has no POSIX modes, so chmod is a documented no-op there. +export function restrictCredentialFileToOwner(path: string): void { + try { + chmodSync(path, 0o600) + } catch { + // Best-effort hardening; the write itself already succeeded. + } +} + export class CredentialDecryptionError extends Error { constructor(service: IntegrationCredentialService) { super(credentialDecryptionMessage(service)) diff --git a/src/main/ipc/bitbucket.ts b/src/main/ipc/bitbucket.ts new file mode 100644 index 00000000000..2b9b030c691 --- /dev/null +++ b/src/main/ipc/bitbucket.ts @@ -0,0 +1,59 @@ +import { ipcMain } from 'electron' +import { + connectBitbucket, + disconnectBitbucket, + getBitbucketConnectionStatus, + type BitbucketConnectArgs, + type BitbucketConnectResult, + type BitbucketConnectionStatus +} from '../bitbucket/credential-connection' +import { _resetPreflightCache } from './preflight' + +function optionalString(value: unknown): string | null { + return typeof value === 'string' ? value : null +} + +function normalizeConnectInput(value: unknown): BitbucketConnectArgs | null { + if (!value || typeof value !== 'object') { + return null + } + const raw = value as Record + if (raw.authMode !== 'token' && raw.authMode !== 'basic') { + return null + } + return { + authMode: raw.authMode, + accessToken: optionalString(raw.accessToken), + email: optionalString(raw.email), + apiToken: optionalString(raw.apiToken), + baseUrl: optionalString(raw.baseUrl) + } +} + +export function registerBitbucketHandlers(): void { + ipcMain.handle( + 'bitbucket:connect', + async (_event, args: unknown): Promise => { + const input = normalizeConnectInput(args) + if (!input) { + return { ok: false, error: 'Invalid Bitbucket credentials' } + } + const result = await connectBitbucket(input) + if (result.ok) { + // Preflight caches source-control status per session; reset so the card + // reflects the new connection without a relaunch. + _resetPreflightCache() + } + return result + } + ) + + ipcMain.handle('bitbucket:disconnect', async (): Promise => { + disconnectBitbucket() + _resetPreflightCache() + }) + + ipcMain.handle('bitbucket:status', async (): Promise => { + return getBitbucketConnectionStatus() + }) +} diff --git a/src/main/ipc/claude-usage.ts b/src/main/ipc/claude-usage.ts deleted file mode 100644 index 3496706e8e3..00000000000 --- a/src/main/ipc/claude-usage.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { ipcMain } from 'electron' -import type { ClaudeUsageStore } from '../claude-usage/store' -import type { - ClaudeUsageBreakdownKind, - ClaudeUsageRange, - ClaudeUsageScope -} from '../../shared/claude-usage-types' - -export function registerClaudeUsageHandlers(claudeUsage: ClaudeUsageStore): void { - ipcMain.handle('claudeUsage:getScanState', () => claudeUsage.getScanState()) - ipcMain.handle('claudeUsage:setEnabled', (_event, args: { enabled: boolean }) => - claudeUsage.setEnabled(args.enabled) - ) - ipcMain.handle('claudeUsage:refresh', (_event, args?: { force?: boolean }) => - claudeUsage.refresh(args?.force ?? false) - ) - ipcMain.handle( - 'claudeUsage:getSnapshot', - (_event, args: { scope: ClaudeUsageScope; range: ClaudeUsageRange; limit?: number }) => - claudeUsage.getSnapshot(args.scope, args.range, args.limit) - ) - ipcMain.handle( - 'claudeUsage:getSummary', - (_event, args: { scope: ClaudeUsageScope; range: ClaudeUsageRange }) => - claudeUsage.getSummary(args.scope, args.range) - ) - ipcMain.handle( - 'claudeUsage:getDaily', - (_event, args: { scope: ClaudeUsageScope; range: ClaudeUsageRange }) => - claudeUsage.getDaily(args.scope, args.range) - ) - ipcMain.handle( - 'claudeUsage:getBreakdown', - ( - _event, - args: { scope: ClaudeUsageScope; range: ClaudeUsageRange; kind: ClaudeUsageBreakdownKind } - ) => claudeUsage.getBreakdown(args.scope, args.range, args.kind) - ) - ipcMain.handle( - 'claudeUsage:getRecentSessions', - (_event, args: { scope: ClaudeUsageScope; range: ClaudeUsageRange; limit?: number }) => - claudeUsage.getRecentSessions(args.scope, args.range, args.limit) - ) -} diff --git a/src/main/ipc/codex-usage.ts b/src/main/ipc/codex-usage.ts deleted file mode 100644 index 3db15a92076..00000000000 --- a/src/main/ipc/codex-usage.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { ipcMain } from 'electron' -import type { CodexUsageStore } from '../codex-usage/store' -import type { - CodexUsageBreakdownKind, - CodexUsageRange, - CodexUsageScope -} from '../../shared/codex-usage-types' - -export function registerCodexUsageHandlers(codexUsage: CodexUsageStore): void { - ipcMain.handle('codexUsage:getScanState', () => codexUsage.getScanState()) - ipcMain.handle('codexUsage:setEnabled', (_event, args: { enabled: boolean }) => - codexUsage.setEnabled(args.enabled) - ) - ipcMain.handle('codexUsage:refresh', (_event, args?: { force?: boolean }) => - codexUsage.refresh(args?.force ?? false) - ) - ipcMain.handle( - 'codexUsage:getSnapshot', - (_event, args: { scope: CodexUsageScope; range: CodexUsageRange; limit?: number }) => - codexUsage.getSnapshot(args.scope, args.range, args.limit) - ) - ipcMain.handle( - 'codexUsage:getSummary', - (_event, args: { scope: CodexUsageScope; range: CodexUsageRange }) => - codexUsage.getSummary(args.scope, args.range) - ) - ipcMain.handle( - 'codexUsage:getDaily', - (_event, args: { scope: CodexUsageScope; range: CodexUsageRange }) => - codexUsage.getDaily(args.scope, args.range) - ) - ipcMain.handle( - 'codexUsage:getBreakdown', - ( - _event, - args: { scope: CodexUsageScope; range: CodexUsageRange; kind: CodexUsageBreakdownKind } - ) => codexUsage.getBreakdown(args.scope, args.range, args.kind) - ) - ipcMain.handle( - 'codexUsage:getRecentSessions', - (_event, args: { scope: CodexUsageScope; range: CodexUsageRange; limit?: number }) => - codexUsage.getRecentSessions(args.scope, args.range, args.limit) - ) -} diff --git a/src/main/ipc/filesystem-list-files-git-directory-expansion.test.ts b/src/main/ipc/filesystem-list-files-git-directory-expansion.test.ts index 5d5cf163746..5d7507a5615 100644 --- a/src/main/ipc/filesystem-list-files-git-directory-expansion.test.ts +++ b/src/main/ipc/filesystem-list-files-git-directory-expansion.test.ts @@ -10,7 +10,7 @@ const { gitSpawnMock } = vi.hoisted(() => ({ })) vi.mock('../git/runner', () => ({ - gitSpawn: gitSpawnMock + gitSpawnAfterWindowsEnvironmentReady: gitSpawnMock })) import { listFilesWithGit } from './filesystem-list-files-git-fallback' @@ -56,11 +56,13 @@ describe('main Quick Open git directory expansion', () => { const primary = createMockProcess() const ignored = createMockProcess() gitSpawnMock - .mockReturnValueOnce(revParse) - .mockReturnValueOnce(primary) - .mockReturnValueOnce(ignored) + .mockResolvedValueOnce(revParse) + .mockResolvedValueOnce(primary) + .mockResolvedValueOnce(ignored) const promise = listFilesWithGit(root, [], {}) + await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => expect(revParse.listenerCount('close')).toBeGreaterThan(0)) revParse.emit('close', 0, null) await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledTimes(3)) ;(primary.stdout as unknown as EventEmitter).emit( @@ -86,12 +88,14 @@ describe('main Quick Open git directory expansion', () => { const primary = createMockProcess() const ignored = createMockProcess() gitSpawnMock - .mockReturnValueOnce(revParse) - .mockReturnValueOnce(primary) - .mockReturnValueOnce(ignored) + .mockResolvedValueOnce(revParse) + .mockResolvedValueOnce(primary) + .mockResolvedValueOnce(ignored) const controller = new AbortController() const promise = listFilesWithGit(root, [], {}, controller.signal) + await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => expect(revParse.listenerCount('close')).toBeGreaterThan(0)) revParse.emit('close', 0, null) await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledTimes(3)) controller.abort() @@ -100,4 +104,92 @@ describe('main Quick Open git directory expansion', () => { expect(primary.kill).toHaveBeenCalled() expect(ignored.kill).toHaveBeenCalled() }) + + it('kills a repository probe returned after cancellation', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-main-git-probe-race-')) + tempDirs.push(root) + const revParse = createMockProcess() + let resolveRevParse!: (child: ChildProcess) => void + gitSpawnMock.mockReturnValueOnce( + new Promise((resolve) => { + resolveRevParse = resolve + }) + ) + + const controller = new AbortController() + const promise = listFilesWithGit(root, [], {}, controller.signal) + await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledOnce()) + controller.abort() + resolveRevParse(revParse) + + await expect(promise).rejects.toSatisfy(isFileListingCancellation) + expect(revParse.kill).toHaveBeenCalled() + expect(revParse.listenerCount('close')).toBe(0) + }) + + it('kills file scans returned after cancellation', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-main-git-scan-race-')) + tempDirs.push(root) + const revParse = createMockProcess() + const primary = createMockProcess() + const ignored = createMockProcess() + let resolvePrimary!: (child: ChildProcess) => void + let resolveIgnored!: (child: ChildProcess) => void + gitSpawnMock + .mockResolvedValueOnce(revParse) + .mockReturnValueOnce( + new Promise((resolve) => { + resolvePrimary = resolve + }) + ) + .mockReturnValueOnce( + new Promise((resolve) => { + resolveIgnored = resolve + }) + ) + + const controller = new AbortController() + const promise = listFilesWithGit(root, [], {}, controller.signal) + await vi.waitFor(() => expect(revParse.listenerCount('close')).toBeGreaterThan(0)) + revParse.emit('close', 0, null) + await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledTimes(3)) + controller.abort() + resolvePrimary(primary) + resolveIgnored(ignored) + + await expect(promise).rejects.toSatisfy(isFileListingCancellation) + expect(primary.kill).toHaveBeenCalled() + expect(ignored.kill).toHaveBeenCalled() + }) + + it('cancels a pending sibling spawn when the primary scan fails', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-main-git-sibling-failure-')) + tempDirs.push(root) + const revParse = createMockProcess() + const primary = createMockProcess() + let pendingSignal: AbortSignal | undefined + gitSpawnMock + .mockResolvedValueOnce(revParse) + .mockResolvedValueOnce(primary) + .mockImplementationOnce( + (_args: string[], options: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + pendingSignal = options.signal + options.signal?.addEventListener( + 'abort', + () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' })), + { once: true } + ) + }) + ) + + const promise = listFilesWithGit(root, [], {}) + await vi.waitFor(() => expect(revParse.listenerCount('close')).toBeGreaterThan(0)) + revParse.emit('close', 0, null) + await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledTimes(3)) + primary.emit('error', new Error('primary failed')) + + await expect(promise).rejects.toThrow('primary failed') + expect(pendingSignal?.aborted).toBe(true) + }) }) diff --git a/src/main/ipc/filesystem-list-files-git-fallback.ts b/src/main/ipc/filesystem-list-files-git-fallback.ts index ff390d13caf..bebab27808d 100644 --- a/src/main/ipc/filesystem-list-files-git-fallback.ts +++ b/src/main/ipc/filesystem-list-files-git-fallback.ts @@ -1,5 +1,9 @@ import type { ChildProcess } from 'node:child_process' -import { gitSpawn } from '../git/runner' +import { gitSpawnAfterWindowsEnvironmentReady } from '../git/runner' +import { + isWslLinkedWorktreeGitRoutingCandidate, + prepareWslLinkedWorktreeGitRouting +} from '../git/wsl-linked-worktree-git-routing' import { buildGitLsFilesArgsForQuickOpen, shouldExcludeQuickOpenRelPath, @@ -28,12 +32,26 @@ async function isInsideGitWorkTree( if (signal?.aborted) { throw fileListingCancellationError(signal) } + if (isWslLinkedWorktreeGitRoutingCandidate(rootPath, localGitOptions.wslDistro)) { + try { + await prepareWslLinkedWorktreeGitRouting(rootPath, localGitOptions.wslDistro, { signal }) + } catch (error) { + if (signal?.aborted) { + throw fileListingCancellationError(signal) + } + throw error + } + } + if (signal?.aborted) { + throw fileListingCancellationError(signal) + } + const child = await gitSpawnAfterWindowsEnvironmentReady(['rev-parse', '--is-inside-work-tree'], { + cwd: rootPath, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), + ...(signal ? { signal } : {}), + stdio: ['ignore', 'ignore', 'ignore'] + }) return new Promise((resolve, reject) => { - const child = gitSpawn(['rev-parse', '--is-inside-work-tree'], { - cwd: rootPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), - stdio: ['ignore', 'ignore', 'ignore'] - }) let done = false let timer: ReturnType const cleanup = (): void => { @@ -71,6 +89,9 @@ async function isInsideGitWorkTree( child.kill() finish(false) }, 10_000) + if (signal?.aborted) { + cancel() + } }) } @@ -104,8 +125,17 @@ export async function listFilesWithGit( reject: (error: Error) => void resolve: () => void }[] = [] + const scanController = new AbortController() - const runGitLsFiles = (args: string[]): Promise => { + const runGitLsFiles = async (args: string[]): Promise => { + // Why: git ls-files outputs paths relative to cwd, so we set cwd to + // rootPath and use the output directly — no prefix stripping needed. + const child = await gitSpawnAfterWindowsEnvironmentReady(['ls-files', ...args], { + cwd: rootPath, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), + signal: scanController.signal, + stdio: ['ignore', 'pipe', 'pipe'] + }) return new Promise((resolve, reject) => { let buf = '' let done = false @@ -138,13 +168,6 @@ export async function listFilesWithGit( return maxResults !== undefined && directFileCandidates.size >= maxResults } - // Why: git ls-files outputs paths relative to cwd, so we set cwd to - // rootPath and use the output directly — no prefix stripping needed. - const child = gitSpawn(['ls-files', ...args], { - cwd: rootPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), - stdio: ['ignore', 'pipe', 'pipe'] - }) let timer: ReturnType const cleanup = (): void => { clearTimeout(timer) @@ -229,6 +252,10 @@ export async function listFilesWithGit( child.kill() rejectPass(new Error('git ls-files timed out')) }, 10000) + if (scanController.signal.aborted) { + child.kill() + rejectPass(fileListingCancellationError(scanController.signal)) + } }) } @@ -258,14 +285,17 @@ export async function listFilesWithGit( } } - const onAbort = (): void => killSurvivors('git ls-files cancelled') + const onAbort = (): void => { + scanController.abort(signal?.reason) + killSurvivors('git ls-files cancelled') + } signal?.addEventListener('abort', onAbort, { once: true }) try { const runIgnoredPass = () => // Why: ignored files are supplementary — a failed or timed-out ignored // pass must not discard the primary listing the user actually needs. runGitLsFiles(ignoredPass).catch((err: Error) => { - if (!signal?.aborted) { + if (!scanController.signal.aborted) { console.warn('[quick-open] git ignored-file pass failed; keeping primary results:', err) } }) @@ -280,6 +310,7 @@ export async function listFilesWithGit( } } } catch (err) { + scanController.abort(err) killSurvivors() if (signal?.aborted) { throw fileListingCancellationError(signal) diff --git a/src/main/ipc/filesystem-search-git.test.ts b/src/main/ipc/filesystem-search-git.test.ts index e6d29acb3cd..ae73b0aa2e2 100644 --- a/src/main/ipc/filesystem-search-git.test.ts +++ b/src/main/ipc/filesystem-search-git.test.ts @@ -184,6 +184,9 @@ describe('filesystem-search-git', () => { const promise = searchWithGitGrep('/mock/root', { query: 'ok', rootPath: '/mock/root' }, 100) + await vi.waitFor(() => + expect((proc.stdout as unknown as EventEmitter).listenerCount('data')).toBeGreaterThan(0) + ) ;(proc.stdout as unknown as EventEmitter).emit('data', 'valid.ts\x001\x00ok\npartial') await vi.runOnlyPendingTimersAsync() diff --git a/src/main/ipc/filesystem-search-git.ts b/src/main/ipc/filesystem-search-git.ts index 065dbd11dad..e997ddd2e02 100644 --- a/src/main/ipc/filesystem-search-git.ts +++ b/src/main/ipc/filesystem-search-git.ts @@ -7,7 +7,11 @@ import { ingestGitGrepLine, SEARCH_TIMEOUT_MS } from '../../shared/text-search' -import { gitSpawn } from '../git/runner' +import { gitSpawnAfterWindowsEnvironmentReady } from '../git/runner' +import { + isWslLinkedWorktreeGitRoutingCandidate, + prepareWslLinkedWorktreeGitRouting +} from '../git/wsl-linked-worktree-git-routing' /** * Fallback text search using git grep. Used when rg is not available. @@ -16,24 +20,27 @@ import { gitSpawn } from '../git/runner' * is launched from a desktop entry (which inherits a minimal system PATH). * git grep is always available since this is a git-focused app. */ -export function searchWithGitGrep( +export async function searchWithGitGrep( rootPath: string, args: SearchOptions, maxResults: number, localGitOptions: { wslDistro?: string } = {} ): Promise { + if (isWslLinkedWorktreeGitRoutingCandidate(rootPath, localGitOptions.wslDistro)) { + await prepareWslLinkedWorktreeGitRouting(rootPath, localGitOptions.wslDistro) + } + const gitArgs = buildGitGrepArgs(args.query, args) + const child = await gitSpawnAfterWindowsEnvironmentReady(gitArgs, { + cwd: rootPath, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), + stdio: ['ignore', 'pipe', 'pipe'] + }) return new Promise((resolve) => { - const gitArgs = buildGitGrepArgs(args.query, args) const matchRegex = buildSubmatchRegex(args.query, args) const acc = createAccumulator() let stdoutBuffer = '' let done = false - const child = gitSpawn(gitArgs, { - cwd: rootPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), - stdio: ['ignore', 'pipe', 'pipe'] - }) let killTimeout: ReturnType function resolveOnce(): void { diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index ad68010cbb7..2c66ab9ad09 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -3098,6 +3098,42 @@ describe('registerFilesystemHandlers', () => { ) }) + it('forwards the pinned head through SSH branch diff queries', async () => { + const result = { + kind: 'text', + originalContent: 'left', + modifiedContent: 'right', + originalIsBinary: false, + modifiedIsBinary: false + } + const getBranchDiff = vi.fn().mockResolvedValue([result]) + getSshGitProviderMock.mockReturnValue({ getBranchDiff }) + + registerFilesystemHandlers(store as never) + + await expect( + handlers.get('git:branchDiff')!(null, { + worktreePath: '/home/user/project', + compare: { + baseRef: 'origin/main', + baseOid: 'base-oid', + headOid: 'head-oid', + mergeBase: 'merge-base-oid' + }, + filePath: 'src/file.ts', + oldPath: 'src/old-file.ts', + connectionId: 'conn-1' + }) + ).resolves.toEqual(result) + + expect(getBranchDiff).toHaveBeenCalledWith('/home/user/project', 'merge-base-oid', { + includePatch: true, + headOid: 'head-oid', + filePath: 'src/file.ts', + oldPath: 'src/old-file.ts' + }) + }) + // Why: the original SSH Quick Open bug had two halves — relay-side policy // drift AND the main dispatcher silently dropping excludePaths before the // provider saw them. This test guards the second half: regardless of diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index d5fb98086f8..40701203e06 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -5,7 +5,7 @@ import type { FileHandle } from 'node:fs/promises' import { randomUUID } from 'node:crypto' import { dirname, extname, join, resolve } from 'node:path' import type { ChildProcess } from 'node:child_process' -import { gitExecFileAsync, wslAwareSpawn } from '../git/runner' +import { awaitWindowsHostGitEnvironmentReady, gitExecFileAsync, wslAwareSpawn } from '../git/runner' import { parseWslPath, toWindowsWslPath } from '../wsl' import { tryDeleteWslUncPath } from '../wsl-unc-delete' import type { Store } from '../persistence' @@ -2076,6 +2076,7 @@ export function registerFilesystemHandlers( } const results = await provider.getBranchDiff(args.worktreePath, args.compare.mergeBase, { includePatch: true, + headOid: args.compare.headOid, filePath: args.filePath, oldPath: args.oldPath }) @@ -2321,6 +2322,7 @@ export function registerFilesystemHandlers( return provider.getRemoteFileUrl(args.worktreePath, args.relativePath, args.line) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + await awaitWindowsHostGitEnvironmentReady({ cwd: worktreePath }) return getRemoteFileUrl(worktreePath, args.relativePath, args.line) } ) @@ -2341,6 +2343,7 @@ export function registerFilesystemHandlers( return provider.getRemoteCommitUrl(args.worktreePath, sha) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + await awaitWindowsHostGitEnvironmentReady({ cwd: worktreePath }) return getRemoteCommitUrl(worktreePath, sha) } ) diff --git a/src/main/ipc/hosted-review.test.ts b/src/main/ipc/hosted-review.test.ts index cef5d7f1d66..0399446e462 100644 --- a/src/main/ipc/hosted-review.test.ts +++ b/src/main/ipc/hosted-review.test.ts @@ -13,6 +13,7 @@ function setPlatform(platform: NodeJS.Platform): void { const { handleMock, createHostedReviewMock, + createStackedHostedReviewMock, getHostedReviewCreationEligibilityMock, getHostedReviewForBranchMock, resolveRegisteredWorktreePathMock, @@ -20,6 +21,7 @@ const { } = vi.hoisted(() => ({ handleMock: vi.fn(), createHostedReviewMock: vi.fn(), + createStackedHostedReviewMock: vi.fn(), getHostedReviewCreationEligibilityMock: vi.fn(), getHostedReviewForBranchMock: vi.fn(), resolveRegisteredWorktreePathMock: vi.fn(), @@ -37,6 +39,10 @@ vi.mock('../source-control/hosted-review-creation', () => ({ getHostedReviewCreationEligibility: getHostedReviewCreationEligibilityMock })) +vi.mock('../source-control/stacked-hosted-review-creation', () => ({ + createStackedHostedReview: createStackedHostedReviewMock +})) + vi.mock('../source-control/hosted-review', () => ({ getHostedReviewForBranch: getHostedReviewForBranchMock })) @@ -87,6 +93,7 @@ describe('registerHostedReviewHandlers', () => { setPlatform(ORIGINAL_PLATFORM) handleMock.mockReset() createHostedReviewMock.mockReset() + createStackedHostedReviewMock.mockReset() getHostedReviewCreationEligibilityMock.mockReset() getHostedReviewForBranchMock.mockReset() resolveRegisteredWorktreePathMock.mockReset() @@ -384,6 +391,35 @@ describe('registerHostedReviewHandlers', () => { ) }) + it('routes stacked creation through its dedicated SSH-safe handler', async () => { + createStackedHostedReviewMock.mockResolvedValueOnce({ + ok: true, + number: 43, + url: 'https://github.com/acme/orca/pull/43', + stackNumber: 50, + parentReview: { number: 42, url: 'https://github.com/acme/orca/pull/42' } + }) + registerHostedReviewHandlers(store as never, stats as never) + + await handlers['hostedReview:createStacked'](null, { + repoPath, + repoId: repo.id, + worktreePath, + provider: 'github', + base: 'stack/parent', + head: 'stack/child', + title: 'Child' + }) + + expect(createStackedHostedReviewMock).toHaveBeenCalledWith( + worktreePath, + expect.objectContaining({ base: 'stack/parent', head: 'stack/child' }), + 'ssh-1', + {} + ) + expect(createHostedReviewMock).not.toHaveBeenCalled() + }) + it('rejects creation when repoId and repoPath point at different registered repos', async () => { store.getRepo.mockImplementation((repoId: string) => repoId === repo.id ? { ...repo, path: '/other/repo' } : null diff --git a/src/main/ipc/hosted-review.ts b/src/main/ipc/hosted-review.ts index 670064d3e54..736668e1f0d 100644 --- a/src/main/ipc/hosted-review.ts +++ b/src/main/ipc/hosted-review.ts @@ -2,6 +2,7 @@ import { ipcMain } from 'electron' import { posix, resolve } from 'node:path' import type { CreateHostedReviewArgs, + CreateStackedHostedReviewArgs, HostedReviewCreationEligibilityArgs, HostedReviewForBranchArgs } from '../../shared/hosted-review' @@ -12,6 +13,7 @@ import { createHostedReview, getHostedReviewCreationEligibility } from '../source-control/hosted-review-creation' +import { createStackedHostedReview } from '../source-control/stacked-hosted-review-creation' import { getHostedReviewForBranch } from '../source-control/hosted-review' import { resolveRegisteredWorktreePath } from './filesystem-auth' import { listRepoWorktrees } from '../repo-worktrees' @@ -160,4 +162,44 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector } return result }) + + ipcMain.handle( + 'hostedReview:createStacked', + async (_event, args: CreateStackedHostedReviewArgs) => { + const repo = assertRegisteredRepo(args.repoPath, store, args.repoId) + const worktreePath = await resolveHostedReviewWorktreePath(repo, store, args.worktreePath) + const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo) + const sharedLinkPaths = repo.connectionId ? [] : getWorktreeSharedLinkPaths(repo) + const executionOptions = { + ...(Object.keys(localGitOptions).length > 0 + ? { localGitExecOptions: localGitOptions } + : {}), + ...(sharedLinkPaths.length > 0 ? { sharedLinkPaths } : {}) + } + const input = { + provider: args.provider, + base: args.base, + head: args.head, + title: args.title, + body: args.body, + draft: args.draft, + ...(args.useTemplate !== undefined ? { useTemplate: args.useTemplate } : {}) + } + const result = await createStackedHostedReview( + worktreePath, + input, + repo.connectionId ?? null, + executionOptions + ) + if (result.ok && !stats.hasCountedPR(result.url)) { + stats.record({ + type: 'pr_created', + at: Date.now(), + repoId: repo.id, + meta: { prNumber: result.number, prUrl: result.url } + }) + } + return result + } + ) } diff --git a/src/main/ipc/opencode-usage.ts b/src/main/ipc/opencode-usage.ts deleted file mode 100644 index 47b3c95dea7..00000000000 --- a/src/main/ipc/opencode-usage.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { ipcMain } from 'electron' -import type { OpenCodeUsageStore } from '../opencode-usage/store' -import type { - OpenCodeUsageBreakdownKind, - OpenCodeUsageRange, - OpenCodeUsageScope -} from '../../shared/opencode-usage-types' - -export function registerOpenCodeUsageHandlers(openCodeUsage: OpenCodeUsageStore): void { - ipcMain.handle('openCodeUsage:getScanState', () => openCodeUsage.getScanState()) - ipcMain.handle('openCodeUsage:setEnabled', (_event, args: { enabled: boolean }) => - openCodeUsage.setEnabled(args.enabled) - ) - ipcMain.handle('openCodeUsage:refresh', (_event, args?: { force?: boolean }) => - openCodeUsage.refresh(args?.force ?? false) - ) - ipcMain.handle( - 'openCodeUsage:getSnapshot', - (_event, args: { scope: OpenCodeUsageScope; range: OpenCodeUsageRange; limit?: number }) => - openCodeUsage.getSnapshot(args.scope, args.range, args.limit) - ) - ipcMain.handle( - 'openCodeUsage:getSummary', - (_event, args: { scope: OpenCodeUsageScope; range: OpenCodeUsageRange }) => - openCodeUsage.getSummary(args.scope, args.range) - ) - ipcMain.handle( - 'openCodeUsage:getDaily', - (_event, args: { scope: OpenCodeUsageScope; range: OpenCodeUsageRange }) => - openCodeUsage.getDaily(args.scope, args.range) - ) - ipcMain.handle( - 'openCodeUsage:getBreakdown', - ( - _event, - args: { - scope: OpenCodeUsageScope - range: OpenCodeUsageRange - kind: OpenCodeUsageBreakdownKind - } - ) => openCodeUsage.getBreakdown(args.scope, args.range, args.kind) - ) - ipcMain.handle( - 'openCodeUsage:getRecentSessions', - (_event, args: { scope: OpenCodeUsageScope; range: OpenCodeUsageRange; limit?: number }) => - openCodeUsage.getRecentSessions(args.scope, args.range, args.limit) - ) -} diff --git a/src/main/ipc/preflight-windows-path-refresh.repro.test.ts b/src/main/ipc/preflight-windows-path-refresh.repro.test.ts index 38e135d4558..916ab35ab63 100644 --- a/src/main/ipc/preflight-windows-path-refresh.repro.test.ts +++ b/src/main/ipc/preflight-windows-path-refresh.repro.test.ts @@ -3,45 +3,11 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, describe, expect, it, vi } from 'vitest' -const { registryQueryAsyncMock, registryQuerySyncMock } = vi.hoisted(() => ({ - registryQueryAsyncMock: vi.fn(), - registryQuerySyncMock: vi.fn() -})) - -vi.mock('node:child_process', async (importOriginal) => { - const original = await importOriginal>() - const originalExecFile = original.execFile as ( - command: string, - commandArgs: string[], - commandOptions: unknown, - commandCallback: (error: Error | null, stdout: string, stderr: string) => void - ) => unknown - const registryAwareExecFile = ( - file: string, - args: string[], - options: unknown, - callback: (error: Error | null, stdout: string, stderr: string) => void - ): unknown => { - if (file.toLowerCase().endsWith('\\reg.exe')) { - return registryQueryAsyncMock(file, args, options, callback) - } - return originalExecFile(file, args, options, callback) - } - const customPromisify = Symbol.for('nodejs.util.promisify.custom') - Object.defineProperty(registryAwareExecFile, customPromisify, { - value: (originalExecFile as unknown as Record)[customPromisify] - }) - return { - ...original, - execFile: registryAwareExecFile, - execFileSync: registryQuerySyncMock - } -}) - import { __resetPersistedWindowsPathCacheForTests, mergePersistedWindowsPathAsync } from '../pty/windows-environment-path' +import { __setWindowsPathRegistryLoaderForTests } from '../pty/windows-path-registry-reader' import { execLocalPreflightCommand } from './preflight-command-exec' describe.runIf(process.platform === 'win32')('Windows preflight Path refresh reproduction', () => { @@ -50,8 +16,7 @@ describe.runIf(process.platform === 'win32')('Windows preflight Path refresh rep afterEach(() => { process.env.Path = originalPath - registryQueryAsyncMock.mockReset() - registryQuerySyncMock.mockReset() + __setWindowsPathRegistryLoaderForTests() __resetPersistedWindowsPathCacheForTests() for (const directory of fixtureDirs.splice(0)) { rmSync(directory, { recursive: true, force: true }) @@ -68,22 +33,13 @@ describe.runIf(process.platform === 'win32')('Windows preflight Path refresh rep ) let persistedUserPath = '' - registryQuerySyncMock.mockImplementation((_file, args: string[]) => { - const value = String(args[1]).startsWith('HKCU') ? persistedUserPath : '' - return ` Path REG_SZ ${value}\r\n` - }) - registryQueryAsyncMock.mockImplementation( - ( - _file: string, - args: string[], - _options: unknown, - callback: (error: Error | null, stdout: string, stderr: string) => void - ) => { - const value = String(args[1]).startsWith('HKCU') ? persistedUserPath : '' - callback(null, ` Path REG_SZ ${value}\r\n`, '') - return {} as never - } - ) + const getRegistryKey = vi.fn((root: number) => ({ + Path: { type: 1, value: root === 2 ? persistedUserPath : '' } + })) + __setWindowsPathRegistryLoaderForTests(() => ({ + HK: { LM: 1, CU: 2 }, + getRegistryKey + })) __resetPersistedWindowsPathCacheForTests() await expect(execLocalPreflightCommand(command, ['/?'])).rejects.toMatchObject({ @@ -97,7 +53,6 @@ describe.runIf(process.platform === 'win32')('Windows preflight Path refresh rep await expect(execLocalPreflightCommand(command, ['/?'])).resolves.toMatchObject({ stdout: expect.any(String) }) - expect(registryQuerySyncMock).toHaveBeenCalledTimes(2) - expect(registryQueryAsyncMock).toHaveBeenCalledTimes(2) + expect(getRegistryKey).toHaveBeenCalledTimes(4) }) }) diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index d3ec26f91a6..cb8908e5c8f 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -70,6 +70,7 @@ const { clearPaneKeyAliasesForPtyMock, recordCodexPaneAccountMock, forgetCodexPaneAccountMock, + getCodexPaneAccountMock, ensureCodexBackfillRecoveryMock } = vi.hoisted(() => ({ handleMock: vi.fn(), @@ -107,6 +108,7 @@ const { clearPaneKeyAliasesForPtyMock: vi.fn(), recordCodexPaneAccountMock: vi.fn(), forgetCodexPaneAccountMock: vi.fn(), + getCodexPaneAccountMock: vi.fn(), ensureCodexBackfillRecoveryMock: vi.fn(() => Promise.resolve()) })) @@ -221,7 +223,8 @@ vi.mock('../agent-hooks/migration-unsupported-pty-state', () => ({ vi.mock('../codex/codex-pane-account-registry', () => ({ recordCodexPaneAccount: recordCodexPaneAccountMock, - forgetCodexPaneAccount: forgetCodexPaneAccountMock + forgetCodexPaneAccount: forgetCodexPaneAccountMock, + getCodexPaneAccount: getCodexPaneAccountMock })) vi.mock('../codex/codex-state-db-backfill-recovery', () => ({ @@ -410,6 +413,7 @@ describe('registerPtyHandlers', () => { clearPaneKeyAliasesForPtyMock.mockReset() recordCodexPaneAccountMock.mockReset() forgetCodexPaneAccountMock.mockReset() + getCodexPaneAccountMock.mockReset() ensureCodexBackfillRecoveryMock.mockReset() ensureCodexBackfillRecoveryMock.mockResolvedValue(undefined) mainWindow.webContents.on.mockReset() @@ -9092,16 +9096,20 @@ describe('registerPtyHandlers', () => { { label: 'git worktree', worktreeId: 'repo-1::/tmp/live-owner', - cwd: '/tmp/live-owner' + cwd: '/tmp/live-owner', + paneAccountAtStart: { homeRoute: 'shared-home' }, + expectedHomeRouteAtStart: 'shared-home' }, { label: 'folder workspace', worktreeId: 'folder:live-owner', - cwd: '/tmp' + cwd: '/tmp', + paneAccountAtStart: null, + expectedHomeRouteAtStart: null } ])( 'adopts a completed runtime-owned pane before replacement launch preflight ($label)', - async ({ worktreeId, cwd }) => { + async ({ worktreeId, cwd, paneAccountAtStart, expectedHomeRouteAtStart }) => { type StableAdoption = { result: { id: string; incarnationId?: string; isReattach?: boolean } owner: { handle?: string; tabId: string; leafId: string; ptyId: string } @@ -9253,13 +9261,26 @@ describe('registerPtyHandlers', () => { store.persistPtyBinding.mockClear() mainWindow.webContents.send.mockClear() + wslUncDirectoryExistsAsyncMock.mockClear() const mountArgs = { cols: 120, rows: 40, - cwd, + cwd: '/a', + cwdFallback: 'worktree', command: 'claude --resume provider-session', launchAgent: 'claude', worktreeId, + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'repo-1', + distro: 'Ubuntu-24.04', + reason: 'project-override', + cacheKey: 'repo-1:wsl' + } + }, tabId, leafId, env: { @@ -9292,6 +9313,7 @@ describe('registerPtyHandlers', () => { sessionId: 'pty-live-owner' }) expect(providerSpawn.mock.calls[1]?.[0].command).toBeUndefined() + expect(wslUncDirectoryExistsAsyncMock).not.toHaveBeenCalled() expect(runtime.createPreAllocatedTerminalHandle).not.toHaveBeenCalled() expect(prepareClaudeAuth).not.toHaveBeenCalled() expect(runtime.registerPreAllocatedHandleForPty).not.toHaveBeenCalled() @@ -9308,8 +9330,10 @@ describe('registerPtyHandlers', () => { runtime.beginPtyRegistration.mockImplementation(() => { runtimeSecondAdoption ??= spawnController.adoptStablePane(adoptionArgs) }) + getCodexPaneAccountMock.mockReturnValue(paneAccountAtStart) const rendererFirstMount = handlers.get('pty:spawn')!(null, mountArgs) await vi.waitFor(() => expect(providerSpawn).toHaveBeenCalledTimes(3)) + getCodexPaneAccountMock.mockReturnValue({ homeRoute: 'real-home' }) releaseAttach() await vi.waitFor(() => expect(runtimeSecondAdoption).not.toBeNull()) const pendingRuntimeAdoption = runtimeSecondAdoption @@ -9354,6 +9378,7 @@ describe('registerPtyHandlers', () => { id: 'pty-live-owner', codexHomePath: null, reattached: true, + reattachedHomeRoute: expectedHomeRouteAtStart, startedAt: expect.any(Date), startedSequence: expect.any(Number) }) @@ -12169,51 +12194,60 @@ describe('registerPtyHandlers', () => { expect(result.startupCwdFallback).toEqual({ kind: 'worktree', cwd: worktreePath }) }) - it('keeps an existing POSIX startup cwd for the selected WSL runtime', async () => { - const originalPlatform = process.platform - const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-cwd' }) - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - wslUncDirectoryExistsAsyncMock.mockResolvedValue(true) - statSyncMock.mockImplementation((target: string) => { - if (target === '/home/alice/repo') { - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) - } - return { isDirectory: () => true, mode: 0o755 } - }) - - try { - installDaemonTestProvider({ spawn: providerSpawn }) - registerPtyHandlers(mainWindow as never) - await handlers.get('pty:spawn')!(null, { - cols: 80, - rows: 24, - cwd: '/home/alice/repo', - cwdFallback: 'worktree', - worktreeId: 'repo-1::C:\\Users\\alice\\repo', - projectRuntime: { - status: 'resolved', - runtime: { - kind: 'wsl', - hostPlatform: 'wsl', - projectId: 'repo-1', - distro: 'Ubuntu-24.04', - reason: 'project-override', - cacheKey: 'repo-1:wsl' - } + it.each(['/home/alice/repo', '/a', '/c'])( + 'keeps an existing POSIX startup cwd for the selected WSL runtime (%s)', + async (startupCwd) => { + const originalPlatform = process.platform + const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-cwd' }) + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + wslUncDirectoryExistsAsyncMock.mockResolvedValue(true) + statSyncMock.mockImplementation((target: string) => { + if (target === startupCwd) { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) } + return { isDirectory: () => true, mode: 0o755 } }) - expect(statSyncMock).not.toHaveBeenCalledWith('/home/alice/repo') - expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledWith( - '\\\\wsl.localhost\\Ubuntu-24.04\\home\\alice\\repo' - ) - expect(providerSpawn).toHaveBeenCalledWith( - expect.objectContaining({ cwd: '/home/alice/repo', shellOverride: 'wsl.exe' }) - ) - } finally { - Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + try { + installDaemonTestProvider({ spawn: providerSpawn }) + registerPtyHandlers(mainWindow as never) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: startupCwd, + cwdFallback: 'worktree', + worktreeId: 'repo-1::C:\\Users\\alice\\repo', + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'repo-1', + distro: 'Ubuntu-24.04', + reason: 'project-override', + cacheKey: 'repo-1:wsl' + } + } + }) + + const expectedValidationCwd = `\\\\wsl.localhost\\Ubuntu-24.04${startupCwd.replaceAll('/', '\\')}` + expect(statSyncMock).not.toHaveBeenCalledWith(startupCwd) + expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledTimes(1) + expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledWith(expectedValidationCwd) + expect(providerSpawn).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: startupCwd, + shellOverride: 'wsl.exe' + }) + ) + expect(providerSpawn).toHaveBeenCalledWith( + expect.not.objectContaining({ prevalidatedCwd: expect.anything() }) + ) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } } - }) + ) it('falls back when the selected WSL runtime reports a POSIX cwd missing', async () => { const originalPlatform = process.platform @@ -12228,7 +12262,7 @@ describe('registerPtyHandlers', () => { const result = (await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, - cwd: '/home/alice/deleted', + cwd: '/a', cwdFallback: 'worktree', worktreeId: `repo-1::${worktreePath}`, projectRuntime: { @@ -12244,13 +12278,71 @@ describe('registerPtyHandlers', () => { } })) as { startupCwdFallback?: { kind: string; cwd: string } } + expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledTimes(1) expect(providerSpawn).toHaveBeenCalledWith(expect.objectContaining({ cwd: worktreePath })) + expect(providerSpawn).toHaveBeenCalledWith( + expect.not.objectContaining({ prevalidatedCwd: expect.anything() }) + ) expect(result.startupCwdFallback).toEqual({ kind: 'worktree', cwd: worktreePath }) } finally { Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) + it('does not probe or forward local WSL cwd evidence for SSH fallback spawns', async () => { + const sshSpawn = vi.fn(async () => ({ id: 'ssh-wsl-looking-cwd' })) + registerSshPtyProvider('ssh-1', { + spawn: sshSpawn, + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + registerPtyHandlers(mainWindow as never) + + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/a', + cwdFallback: 'worktree', + connectionId: 'ssh-1', + worktreeId: 'repo-1::C:\\Users\\alice\\repo', + shellOverride: 'wsl.exe', + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'repo-1', + distro: 'Ubuntu-24.04', + reason: 'project-override', + cacheKey: 'repo-1:wsl' + } + } + }) + + expect(wslUncDirectoryExistsAsyncMock).not.toHaveBeenCalled() + expect(sshSpawn).toHaveBeenCalledWith(expect.objectContaining({ cwd: '/a' })) + expect(sshSpawn).toHaveBeenCalledWith( + expect.not.objectContaining({ prevalidatedCwd: expect.anything() }) + ) + }) + it('preserves a POSIX WSL cwd when the distro probe is inconclusive', async () => { const originalPlatform = process.platform const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-inconclusive-cwd' }) @@ -12263,15 +12355,13 @@ describe('registerPtyHandlers', () => { const result = (await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, - cwd: '/home/alice/repo', + cwd: '/a', cwdFallback: 'worktree', worktreeId: 'repo-1::C:/Users/alice/repo', shellOverride: 'wsl.exe' })) as { startupCwdFallback?: unknown } - expect(providerSpawn).toHaveBeenCalledWith( - expect.objectContaining({ cwd: '/home/alice/repo' }) - ) + expect(providerSpawn).toHaveBeenCalledWith(expect.objectContaining({ cwd: '/a' })) expect(result.startupCwdFallback).toBeUndefined() } finally { Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) @@ -12290,17 +12380,15 @@ describe('registerPtyHandlers', () => { const result = (await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, - cwd: '/home/alice/repo', + cwd: '/c', cwdFallback: 'worktree', worktreeId: 'repo-1::C:/Users/alice/repo', shellOverride: 'wsl.exe' })) as { startupCwdFallback?: unknown } - expect(statSyncMock).not.toHaveBeenCalledWith('/home/alice/repo') + expect(statSyncMock).not.toHaveBeenCalledWith('/c') expect(wslUncDirectoryExistsAsyncMock).not.toHaveBeenCalled() - expect(providerSpawn).toHaveBeenCalledWith( - expect.objectContaining({ cwd: '/home/alice/repo' }) - ) + expect(providerSpawn).toHaveBeenCalledWith(expect.objectContaining({ cwd: '/c' })) expect(result.startupCwdFallback).toBeUndefined() } finally { Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) @@ -12308,7 +12396,11 @@ describe('registerPtyHandlers', () => { }) it.each([ - { exists: true, expectedCwd: '/home/alice/repo', expectedFallback: undefined }, + { + exists: true, + expectedCwd: '/home/alice/repo', + expectedFallback: undefined + }, { exists: false, expectedCwd: '\\\\wsl.localhost\\Ubuntu\\home\\alice', @@ -12337,9 +12429,13 @@ describe('registerPtyHandlers', () => { worktreeId: 'repo-1::\\\\wsl.localhost\\Ubuntu\\home\\alice' })) as { startupCwdFallback?: { kind: string; cwd: string } } + expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledTimes(testCase.exists ? 1 : 2) expect(providerSpawn).toHaveBeenCalledWith( expect.objectContaining({ cwd: testCase.expectedCwd }) ) + expect(providerSpawn).toHaveBeenCalledWith( + expect.not.objectContaining({ prevalidatedCwd: expect.anything() }) + ) expect(result.startupCwdFallback).toEqual(testCase.expectedFallback) } finally { Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) @@ -12358,15 +12454,16 @@ describe('registerPtyHandlers', () => { const result = (await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, - cwd: '/home/alice/deleted', + cwd: '/c', cwdFallback: 'worktree', worktreeId: 'repo-1::\\\\wsl.localhost\\Ubuntu\\home\\alice' })) as { startupCwdFallback?: unknown } expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledTimes(2) expect(providerSpawn).toHaveBeenCalledWith( - expect.objectContaining({ cwd: '/home/alice/deleted' }) + expect.not.objectContaining({ prevalidatedCwd: expect.anything() }) ) + expect(providerSpawn).toHaveBeenCalledWith(expect.objectContaining({ cwd: '/c' })) expect(result.startupCwdFallback).toBeUndefined() } finally { Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) @@ -12429,12 +12526,46 @@ describe('registerPtyHandlers', () => { expect(providerSpawn).toHaveBeenCalledWith( expect.objectContaining({ cwd: worktreePath, shellOverride: 'wsl.exe' }) ) + expect(wslUncDirectoryExistsAsyncMock).not.toHaveBeenCalled() expect(result.startupCwdFallback).toEqual({ kind: 'worktree', cwd: worktreePath }) } finally { Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) + it('probes Git Bash /c as its existing native drive root without falling back', async () => { + const originalPlatform = process.platform + const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-git-bash-cwd' }) + const worktreePath = 'C:/Users/alice/repo' + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + try { + installDaemonTestProvider({ spawn: providerSpawn }) + registerPtyHandlers(mainWindow as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/c', + cwdFallback: 'worktree', + worktreeId: `repo-1::${worktreePath}`, + shellOverride: 'C:\\Program Files\\Git\\bin\\bash.exe' + })) as { startupCwdFallback?: { kind: string; cwd: string } } + + expect(wslUncDirectoryExistsAsyncMock).not.toHaveBeenCalled() + expect(statSyncMock).toHaveBeenCalledWith('C:\\') + expect(statSyncMock).not.toHaveBeenCalledWith('/c') + expect(providerSpawn).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: '/c', + shellOverride: expect.stringContaining('bash') + }) + ) + expect(result.startupCwdFallback).toBeUndefined() + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + it('keeps a missing cwd unchanged without the fallback flag', async () => { registerPtyHandlers(mainWindow as never) existsSyncMock.mockImplementation((target: string) => target !== '/repo/app/deleted-folder') diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 24786811086..e0c5fad3442 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -70,6 +70,7 @@ import { } from '../../shared/pi-agent-kind' import { isPwshAvailableAsync } from '../pwsh' import { LocalPtyProvider } from '../providers/local-pty-provider' +import { normalizeWindowsTerminalCwd } from '../providers/windows-shell-args' import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' import { isPtyWriteUnavailableError } from '../providers/pty-write-unavailable-error' import { @@ -96,7 +97,11 @@ import { import { resolveWslSessionContext } from '../daemon/wsl-session-context' import { addNodePtyRecoveryHint } from '../daemon/node-pty-error-hints' import { recordDaemonStreamBacklogEvent } from '../daemon/daemon-stream-backlog-probe' -import { TerminalSessionOwnerUnverifiedError } from '../daemon/daemon-errors' +import { + isDaemonEndpointGoneError, + TerminalHostGoneError, + TerminalSessionOwnerUnverifiedError +} from '../daemon/daemon-errors' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' import { CLAUDE_AUTH_ENV_VARS, hasClaudeAuthEnvConflict } from '../claude-accounts/environment' @@ -198,7 +203,9 @@ import { } from '../codex-accounts/managed-codex-auth-readiness' import { forgetCodexPaneAccount, - recordCodexPaneAccount + getCodexPaneAccount, + recordCodexPaneAccount, + type CodexPaneHomeRoute } from '../codex/codex-pane-account-registry' import { resolveCodexPaneLaunchAccount } from '../codex/codex-pane-launch-account' import { getSystemCodexHomePath } from '../codex/codex-home-paths' @@ -846,6 +853,10 @@ async function attachStablePaneOwner( if (error instanceof TerminalSessionOwnerUnverifiedError) { throw new Error('terminal_pane_owner_unverified') } + // Why: translate before paired-runtime RPC strips the socket error's code and syscall. + if (isDaemonEndpointGoneError(error)) { + throw new TerminalHostGoneError() + } if (!isPtyAlreadyGoneError(error)) { throw error } @@ -1238,6 +1249,7 @@ export type CodexHomePtySpawnedLifecycleArgs = { id: string codexHomePath: string | null reattached?: boolean + reattachedHomeRoute?: CodexPaneHomeRoute | null launchEnv?: NodeJS.ProcessEnv startedAt?: Date startedSequence?: number @@ -1449,6 +1461,30 @@ function recordCodexPaneAccountForSpawn(args: { recordCodexPaneAccount(args.ptyId, record) } +function snapshotCodexPaneHomeRoutes( + ptyIds: readonly (string | null | undefined)[] +): ReadonlyMap { + const routes = new Map() + for (const ptyId of ptyIds) { + if (!ptyId || routes.has(ptyId)) { + continue + } + routes.set(ptyId, getCodexPaneAccount(ptyId)?.homeRoute ?? null) + } + return routes +} + +function codexReattachedHomeRouteField( + routes: ReadonlyMap, + ptyId: string, + reattached: boolean +): { reattachedHomeRoute?: CodexPaneHomeRoute | null } { + if (!reattached || !routes.has(ptyId)) { + return {} + } + return { reattachedHomeRoute: routes.get(ptyId) ?? null } +} + function readEnvWithProcessFallback( baseEnv: Record, key: string @@ -4407,6 +4443,12 @@ export function registerPtyHandlers( const codexHomeLaunchStartedAt = !args.connectionId ? new Date() : undefined const codexHomeLaunchStartedSequence = !args.connectionId ? ++ptyLifecycleSequence : undefined const preAdoptedStablePane = args.adoptedStablePane ?? null + const reattachedCodexHomeRoutes = !args.connectionId + ? snapshotCodexPaneHomeRoutes([ + preAdoptedStablePane?.result.id, + args.sessionId ? getAppPtyId(args.connectionId, args.sessionId) : undefined + ]) + : new Map() const startupPromise = getLocalPtyStartupPromise(args.connectionId) if (startupPromise) { await startupPromise @@ -4440,7 +4482,8 @@ export function registerPtyHandlers( codexHomePath: null, reattached: true, startedAt: codexHomeLaunchStartedAt, - startedSequence: codexHomeLaunchStartedSequence + startedSequence: codexHomeLaunchStartedSequence, + ...codexReattachedHomeRouteField(reattachedCodexHomeRoutes, result.id, true) }) } return result @@ -5115,6 +5158,7 @@ export function registerPtyHandlers( reattached: true, startedAt: codexHomeLaunchStartedAt, startedSequence: codexHomeLaunchStartedSequence, + ...codexReattachedHomeRouteField(reattachedCodexHomeRoutes, result.id, true), ...(env ? { launchEnv: env } : {}) }) } @@ -5288,6 +5332,11 @@ export function registerPtyHandlers( codexHomePath: selectedCodexHomePath, startedAt: codexHomeLaunchStartedAt, startedSequence: codexHomeLaunchStartedSequence, + ...codexReattachedHomeRouteField( + reattachedCodexHomeRoutes, + result.id, + result.isReattach === true + ), ...(result.isReattach === true ? { reattached: true } : env ? { launchEnv: env } : {}) }) } @@ -5786,88 +5835,45 @@ export function registerPtyHandlers( ) => { const codexHomeLaunchStartedAt = !args.connectionId ? new Date() : undefined const codexHomeLaunchStartedSequence = !args.connectionId ? ++ptyLifecycleSequence : undefined + const initialLeafId = + typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null + const initialPaneKey = + typeof args.worktreeId === 'string' && + typeof args.tabId === 'string' && + isValidTerminalTabId(args.tabId) && + args.tabId.length <= 512 && + initialLeafId + ? makePaneKey(args.tabId, initialLeafId) + : null + const initialStablePanePtyId = (() => { + try { + return !args.connectionId && initialPaneKey + ? resolveStablePaneOwner( + runtime, + store, + initialPaneKey, + args.worktreeId, + args.connectionId + )?.ptyId + : undefined + } catch { + return undefined + } + })() + const reattachedCodexHomeRoutes = !args.connectionId + ? snapshotCodexPaneHomeRoutes([ + initialStablePanePtyId, + args.sessionId ? getAppPtyId(args.connectionId, args.sessionId) : undefined + ]) + : new Map() const spawnTiming = createPtySpawnTiming() const startupPromise = getLocalPtyStartupPromise(args.connectionId) if (startupPromise) { await startupPromise } - // Why: honor the fallback only for fresh local spawns — reattach needs exact cwd and SSH can't probe the local filesystem. - const requestedMissingCwdFallback = - !args.connectionId && !args.sessionId && args.cwdFallback === 'worktree' - const isWslOwnedPosixCwd = - args.cwd?.startsWith('/') === true && !/^\/[A-Za-z](?:\/|$)/.test(args.cwd) - const startupWorkspaceCwd = - requestedMissingCwdFallback && isWslOwnedPosixCwd - ? resolvePtySpawnStartupCwd(args.worktreeId, '.') - : undefined - const initiallyResolvedStartupCwd = - requestedMissingCwdFallback && isWslOwnedPosixCwd - ? resolvePtySpawnStartupCwd(args.worktreeId, args.cwd) - : undefined - const startupTerminalRuntimeOptions = - requestedMissingCwdFallback && process.platform === 'win32' - ? resolveLocalWindowsTerminalRuntimeOptions({ - requestedShellOverride: args.shellOverride, - settings: getSettings?.(), - projectRuntime: args.projectRuntime, - fallbackHostShell: process.env.COMSPEC || 'powershell.exe' - }) - : undefined - const wslRuntimeOwnsStartupCwd = - requestedMissingCwdFallback && - isWslOwnedPosixCwd && - (isWslShellName(startupTerminalRuntimeOptions?.shellOverride) || - isWslUncPath(startupWorkspaceCwd ?? '')) - const startupWslContext = wslRuntimeOwnsStartupCwd - ? resolveWslSessionContext({ - cwd: startupWorkspaceCwd, - shellOverride: startupTerminalRuntimeOptions?.shellOverride, - terminalWindowsWslDistro: startupTerminalRuntimeOptions?.terminalWindowsWslDistro - }) - : undefined - let wslStartupCwdExists: boolean | null = null - let wslWorkspaceCwdExists: boolean | null = null - if (startupWslContext && initiallyResolvedStartupCwd) { - const validationCwd = toWindowsWslPath( - initiallyResolvedStartupCwd, - startupWslContext.distro - ) - wslStartupCwdExists = isWslUncPath(validationCwd) - ? await wslUncDirectoryExistsAsync(validationCwd) - : localStartupCwdDirectoryExists(validationCwd) - if (wslStartupCwdExists === false && startupWorkspaceCwd) { - wslWorkspaceCwdExists = isWslUncPath(startupWorkspaceCwd) - ? await wslUncDirectoryExistsAsync(startupWorkspaceCwd) - : localStartupCwdDirectoryExists(startupWorkspaceCwd) - } - } - const allowMissingCwdFallback = - requestedMissingCwdFallback && - (!wslRuntimeOwnsStartupCwd || - (wslStartupCwdExists === false && wslWorkspaceCwdExists === true)) - let didFallbackToWorkspaceRootCwd = false - const cwd = resolvePtySpawnStartupCwd( - args.worktreeId, - args.cwd, - allowMissingCwdFallback - ? { - directoryExists: (path) => - startupWslContext && - wslStartupCwdExists === false && - path === initiallyResolvedStartupCwd - ? false - : startupWslContext && path === startupWorkspaceCwd - ? wslWorkspaceCwdExists === true - : localStartupCwdDirectoryExists(path), - onFallbackToWorkspaceRoot: () => { - didFallbackToWorkspaceRootCwd = true - } - } - : undefined - ) - const startupCwdFallback = - didFallbackToWorkspaceRootCwd && cwd ? ({ kind: 'worktree', cwd } as const) : undefined - spawnTiming.mark('preflight') + let cwd = resolvePtySpawnStartupCwd(args.worktreeId, args.cwd) + let prevalidatedCwd: string | undefined + let startupCwdFallback: { kind: 'worktree'; cwd: string } | undefined const earlyLeafId = typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null const earlyPaneKey = @@ -5918,6 +5924,7 @@ export function registerPtyHandlers( await assertFolderWorkspacePtyPathUsable(args.worktreeId) } const provider = getProvider(args.connectionId) + spawnTiming.mark('stable_adoption_setup') const preAdoptedStablePane = earlyStablePaneOwner && earlyWorktreeId ? await adoptStablePane({ @@ -5931,9 +5938,97 @@ export function registerPtyHandlers( ownsPaneSpawnReservation: true }) : null + spawnTiming.mark('stable_adoption') if (earlyStablePaneOwner && !preAdoptedStablePane) { await assertFolderWorkspacePtyPathUsable(args.worktreeId) } + if (!preAdoptedStablePane) { + // Why: reattach needs exact cwd, SSH cannot probe locally, and successful stable-pane adoption needs no launch preflight. + const requestedMissingCwdFallback = + !args.connectionId && !args.sessionId && args.cwdFallback === 'worktree' + const isPosixStartupCwd = args.cwd?.startsWith('/') === true + const startupWorkspaceCwd = + requestedMissingCwdFallback && isPosixStartupCwd + ? resolvePtySpawnStartupCwd(args.worktreeId, '.') + : undefined + const initiallyResolvedStartupCwd = + requestedMissingCwdFallback && isPosixStartupCwd ? cwd : undefined + const startupTerminalRuntimeOptions = + requestedMissingCwdFallback && process.platform === 'win32' + ? resolveLocalWindowsTerminalRuntimeOptions({ + requestedShellOverride: args.shellOverride, + settings: getSettings?.(), + projectRuntime: args.projectRuntime, + fallbackHostShell: process.env.COMSPEC || 'powershell.exe' + }) + : undefined + const wslRuntimeOwnsStartupCwd = + requestedMissingCwdFallback && + isPosixStartupCwd && + (isWslShellName(startupTerminalRuntimeOptions?.shellOverride) || + isWslUncPath(startupWorkspaceCwd ?? '')) + const startupWslContext = wslRuntimeOwnsStartupCwd + ? resolveWslSessionContext({ + cwd: startupWorkspaceCwd, + shellOverride: startupTerminalRuntimeOptions?.shellOverride, + terminalWindowsWslDistro: startupTerminalRuntimeOptions?.terminalWindowsWslDistro + }) + : undefined + let wslStartupCwdExists: boolean | null = null + let wslWorkspaceCwdExists: boolean | null = null + if (startupWslContext && initiallyResolvedStartupCwd) { + const validationCwd = toWindowsWslPath( + initiallyResolvedStartupCwd, + startupWslContext.distro + ) + wslStartupCwdExists = isWslUncPath(validationCwd) + ? await wslUncDirectoryExistsAsync(validationCwd) + : localStartupCwdDirectoryExists(validationCwd) + if (wslStartupCwdExists === true) { + prevalidatedCwd = validationCwd + } + if (wslStartupCwdExists === false && startupWorkspaceCwd) { + wslWorkspaceCwdExists = isWslUncPath(startupWorkspaceCwd) + ? await wslUncDirectoryExistsAsync(startupWorkspaceCwd) + : localStartupCwdDirectoryExists(startupWorkspaceCwd) + if (wslWorkspaceCwdExists === true) { + prevalidatedCwd = startupWorkspaceCwd + } + } + } + const allowMissingCwdFallback = + requestedMissingCwdFallback && + (!wslRuntimeOwnsStartupCwd || + (wslStartupCwdExists === false && wslWorkspaceCwdExists === true)) + let didFallbackToWorkspaceRootCwd = false + cwd = resolvePtySpawnStartupCwd( + args.worktreeId, + args.cwd, + allowMissingCwdFallback + ? { + directoryExists: (path) => + startupWslContext && + wslStartupCwdExists === false && + path === initiallyResolvedStartupCwd + ? false + : startupWslContext && path === startupWorkspaceCwd + ? wslWorkspaceCwdExists === true + : localStartupCwdDirectoryExists( + process.platform === 'win32' ? normalizeWindowsTerminalCwd(path) : path + ), + onFallbackToWorkspaceRoot: () => { + didFallbackToWorkspaceRootCwd = true + } + } + : undefined + ) + if (didFallbackToWorkspaceRootCwd && wslWorkspaceCwdExists === true && cwd) { + prevalidatedCwd = cwd + } + startupCwdFallback = + didFallbackToWorkspaceRootCwd && cwd ? { kind: 'worktree', cwd } : undefined + } + spawnTiming.mark('preflight') const freshSpawnRecovery = preAdoptedStablePane ? undefined : recoverFreshSpawnProviderRouting(provider, args.connectionId, args.sessionId) @@ -6272,6 +6367,7 @@ export function registerPtyHandlers( cols: args.cols, rows: args.rows, cwd, + ...(prevalidatedCwd && !isDaemonHostSpawn ? { prevalidatedCwd } : {}), env: spawnEnv, ...(isMintedSessionId ? { isNewSession: true } : {}) } @@ -6840,6 +6936,11 @@ export function registerPtyHandlers( codexHomePath: selectedCodexHomePath, startedAt: codexHomeLaunchStartedAt, startedSequence: codexHomeLaunchStartedSequence, + ...codexReattachedHomeRouteField( + reattachedCodexHomeRoutes, + result.id, + result.isReattach === true + ), ...(result.isReattach === true ? { reattached: true } : baseEnv diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 55043e2b526..9bee285bff4 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -6,9 +6,7 @@ const { callRuntimeEnvironmentMock, registerCliHandlersMock, registerPreflightHandlersMock, - registerClaudeUsageHandlersMock, - registerCodexUsageHandlersMock, - registerOpenCodeUsageHandlersMock, + registerUsageProviderHandlersMock, registerGitHubHandlersMock, registerFeedbackHandlersMock, registerStatsHandlersMock, @@ -50,6 +48,7 @@ const { registerAppHandlersMock, registerLinearHandlersMock, registerJiraHandlersMock, + registerBitbucketHandlersMock, registerGitLabHandlersMock, registerHostedReviewHandlersMock, registerExportHandlersMock, @@ -72,9 +71,7 @@ const { callRuntimeEnvironmentMock: vi.fn(), registerCliHandlersMock: vi.fn(), registerPreflightHandlersMock: vi.fn(), - registerClaudeUsageHandlersMock: vi.fn(), - registerCodexUsageHandlersMock: vi.fn(), - registerOpenCodeUsageHandlersMock: vi.fn(), + registerUsageProviderHandlersMock: vi.fn(), registerGitHubHandlersMock: vi.fn(), registerFeedbackHandlersMock: vi.fn(), registerStatsHandlersMock: vi.fn(), @@ -116,6 +113,7 @@ const { registerAppHandlersMock: vi.fn(), registerLinearHandlersMock: vi.fn(), registerJiraHandlersMock: vi.fn(), + registerBitbucketHandlersMock: vi.fn(), registerGitLabHandlersMock: vi.fn(), registerHostedReviewHandlersMock: vi.fn(), registerExportHandlersMock: vi.fn(), @@ -180,16 +178,8 @@ vi.mock('./preflight', () => ({ registerPreflightHandlers: registerPreflightHandlersMock })) -vi.mock('./claude-usage', () => ({ - registerClaudeUsageHandlers: registerClaudeUsageHandlersMock -})) - -vi.mock('./codex-usage', () => ({ - registerCodexUsageHandlers: registerCodexUsageHandlersMock -})) - -vi.mock('./opencode-usage', () => ({ - registerOpenCodeUsageHandlers: registerOpenCodeUsageHandlersMock +vi.mock('./usage-provider-handlers', () => ({ + registerUsageProviderHandlers: registerUsageProviderHandlersMock })) vi.mock('./github', () => ({ @@ -372,6 +362,10 @@ vi.mock('./jira', () => ({ registerJiraHandlers: registerJiraHandlersMock })) +vi.mock('./bitbucket', () => ({ + registerBitbucketHandlers: registerBitbucketHandlersMock +})) + vi.mock('./gitlab', () => ({ registerGitLabHandlers: registerGitLabHandlersMock })) @@ -395,9 +389,7 @@ describe('registerCoreHandlers', () => { callRuntimeEnvironmentMock.mockReset() registerCliHandlersMock.mockReset() registerPreflightHandlersMock.mockReset() - registerClaudeUsageHandlersMock.mockReset() - registerCodexUsageHandlersMock.mockReset() - registerOpenCodeUsageHandlersMock.mockReset() + registerUsageProviderHandlersMock.mockReset() registerGitHubHandlersMock.mockReset() registerFeedbackHandlersMock.mockReset() registerStatsHandlersMock.mockReset() @@ -438,6 +430,7 @@ describe('registerCoreHandlers', () => { registerAppHandlersMock.mockReset() registerLinearHandlersMock.mockReset() registerJiraHandlersMock.mockReset() + registerBitbucketHandlersMock.mockReset() registerGitLabHandlersMock.mockReset() registerHostedReviewHandlersMock.mockReset() registerExportHandlersMock.mockReset() @@ -494,9 +487,11 @@ describe('registerCoreHandlers', () => { result: { sessions: 'bad-shape' } }) - expect(registerClaudeUsageHandlersMock).toHaveBeenCalledWith(claudeUsage) - expect(registerCodexUsageHandlersMock).toHaveBeenCalledWith(codexUsage) - expect(registerOpenCodeUsageHandlersMock).toHaveBeenCalledWith(openCodeUsage) + expect(registerUsageProviderHandlersMock).toHaveBeenCalledWith({ + claudeUsage, + codexUsage, + openCodeUsage + }) expect(registerAppHandlersMock).toHaveBeenCalledWith(store, { onBeforeRelaunch }) expect(registerCodexAccountHandlersMock).toHaveBeenCalledWith( codexAccounts, @@ -516,6 +511,7 @@ describe('registerCoreHandlers', () => { expect(registerGitHubHandlersMock).toHaveBeenCalledWith(store, stats) expect(registerLinearHandlersMock).toHaveBeenCalled() expect(registerJiraHandlersMock).toHaveBeenCalled() + expect(registerBitbucketHandlersMock).toHaveBeenCalled() expect(registerGitLabHandlersMock).toHaveBeenCalledWith(store) expect(registerHostedReviewHandlersMock).toHaveBeenCalledWith(store, stats) expect(registerFeedbackHandlersMock).toHaveBeenCalled() diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 607aa08ce3e..7825e73c57e 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -8,14 +8,13 @@ import type { StatsCollector } from '../stats/collector' import { registerFilesystemHandlers } from './filesystem' import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment' import { registerFilesystemWatcherHandlers } from './filesystem-watcher' -import { registerClaudeUsageHandlers } from './claude-usage' -import { registerCodexUsageHandlers } from './codex-usage' -import { registerOpenCodeUsageHandlers } from './opencode-usage' +import { registerUsageProviderHandlers } from './usage-provider-handlers' import { registerGitHubHandlers } from './github' import { registerGitLabHandlers } from './gitlab' import { registerHostedReviewHandlers } from './hosted-review' import { registerLinearHandlers } from './linear' import { registerJiraHandlers } from './jira' +import { registerBitbucketHandlers } from './bitbucket' import { registerFeedbackHandlers } from './feedback' import { registerCrashReportingHandlers } from './crash-reporting' import { registerExportHandlers } from './export' @@ -143,9 +142,7 @@ export function registerCoreHandlers( registerAppHandlers(store, { onBeforeRelaunch: lifecycleOptions.onBeforeRelaunch }) registerCliHandlers() registerPreflightHandlers() - registerClaudeUsageHandlers(claudeUsage) - registerCodexUsageHandlers(codexUsage) - registerOpenCodeUsageHandlers(openCodeUsage) + registerUsageProviderHandlers({ claudeUsage, codexUsage, openCodeUsage }) registerCodexAccountHandlers(codexAccounts, () => store.getSettings()) registerAgentHookHandlers(runtime, { getPtyIdForPaneKey }) registerCodexConfigSyncHandlers(codexAccounts.runtimeHomeService) @@ -159,6 +156,7 @@ export function registerCoreHandlers( registerHostedReviewHandlers(store, stats) registerLinearHandlers() registerJiraHandlers() + registerBitbucketHandlers() registerFeedbackHandlers() if (crashReports) { registerCrashReportingHandlers(crashReports) diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index b6ec0118a61..d3440ed4579 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -18,6 +18,7 @@ const { mockFilesystemProvider, mockMultiplexer, gitExecFileAsyncMock, + gitSpawnAfterWindowsEnvironmentReadyMock, gitSpawnMock, listWorktreeGraphMock, invalidateAuthorizedRootsCacheMock, @@ -69,6 +70,7 @@ const { notify: vi.fn() }, gitSpawnMock: vi.fn(), + gitSpawnAfterWindowsEnvironmentReadyMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), listWorktreeGraphMock: vi.fn(), invalidateAuthorizedRootsCacheMock: vi.fn(), @@ -104,7 +106,8 @@ vi.mock('../git/runner', async () => ({ gitExecFileAsync: gitExecFileAsyncMock, gitExecFileAsyncBuffer: vi.fn(), gitStreamStdout: vi.fn(), - gitSpawn: gitSpawnMock + gitSpawn: gitSpawnMock, + gitSpawnAfterWindowsEnvironmentReady: gitSpawnAfterWindowsEnvironmentReadyMock })) vi.mock('../git/worktree', () => ({ @@ -1149,7 +1152,7 @@ describe('repos:addRemote', () => { gitSpawnMock.mockImplementation(() => { const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } proc.stderr = new EventEmitter() - queueMicrotask(() => proc.emit('close', 0, null)) + setImmediate(() => proc.emit('close', 0, null)) return proc }) mockWindow.webContents.send.mockReset() @@ -1849,11 +1852,14 @@ describe('repos:add + repos:clone', () => { mockStore.updateProjectHostSetup.mockReset() mockWindow.webContents.send.mockReset() gitSpawnMock.mockReset() + gitSpawnAfterWindowsEnvironmentReadyMock + .mockReset() + .mockImplementation(async (...args) => gitSpawnMock(...args)) invalidateAuthorizedRootsCacheMock.mockReset() prepareLocalWorktreeRootForRepoMock.mockReset().mockResolvedValue(undefined) gitSpawnMock.mockImplementation(() => { const proc = createMockCloneProcess() - queueMicrotask(() => proc.emit('close', 0, null)) + setImmediate(() => proc.emit('close', 0, null)) return proc }) @@ -2243,7 +2249,7 @@ describe('repos:add + repos:clone', () => { ) gitSpawnMock.mockImplementationOnce(() => { const proc = createMockCloneProcess() - queueMicrotask(() => { + setImmediate(() => { cloned = true proc.emit('close', 0, null) }) @@ -2390,6 +2396,84 @@ describe('repos:add + repos:clone', () => { await expect(handlers.get('repos:cloneAbort')!(null, undefined)).resolves.toBeUndefined() }) + it('cancels a local clone before Git starts when environment readiness is pending', async () => { + const destination = await createTempRoot() + const clonePath = join(destination, 'orca') + gitSpawnAfterWindowsEnvironmentReadyMock.mockImplementation( + (_args: string[], options: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + const abort = (): void => { + reject(Object.assign(new Error('The operation was aborted'), { name: 'AbortError' })) + } + if (options.signal?.aborted) { + abort() + } else { + options.signal?.addEventListener('abort', abort, { once: true }) + } + }) + ) + + const clonePromise = Promise.resolve( + handlers.get('repos:clone')!(null, { + url: 'https://example.com/orca.git', + destination + }) + ) + const rejection = expect(clonePromise).rejects.toThrow('Clone failed') + await waitForAssertion(() => + expect(gitSpawnAfterWindowsEnvironmentReadyMock).toHaveBeenCalledOnce() + ) + expect(gitSpawnMock).not.toHaveBeenCalled() + + await handlers.get('repos:cloneAbort')!(null, undefined) + await rejection + expect(gitSpawnMock).not.toHaveBeenCalled() + expect(existsSync(clonePath)).toBe(false) + }) + + it('cancels every concurrent local clone waiting for environment readiness', async () => { + const firstDestination = await createTempRoot() + const secondDestination = await createTempRoot() + gitSpawnAfterWindowsEnvironmentReadyMock.mockImplementation( + (_args: string[], options: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + const abort = (): void => { + reject(Object.assign(new Error('The operation was aborted'), { name: 'AbortError' })) + } + if (options.signal?.aborted) { + abort() + } else { + options.signal?.addEventListener('abort', abort, { once: true }) + } + }) + ) + + const firstClone = Promise.resolve( + handlers.get('repos:clone')!(null, { + url: 'https://example.com/first.git', + destination: firstDestination + }) + ) + const secondClone = Promise.resolve( + handlers.get('repos:clone')!(null, { + url: 'https://example.com/second.git', + destination: secondDestination + }) + ) + const firstRejection = expect(firstClone).rejects.toThrow('Clone failed') + const secondRejection = expect(secondClone).rejects.toThrow('Clone failed') + await waitForAssertion(() => + expect(gitSpawnAfterWindowsEnvironmentReadyMock).toHaveBeenCalledTimes(2) + ) + + await handlers.get('repos:cloneAbort')!(null, undefined) + + await Promise.all([firstRejection, secondRejection]) + expect(gitSpawnMock).not.toHaveBeenCalled() + expect(existsSync(join(firstDestination, 'first'))).toBe(false) + expect(existsSync(join(secondDestination, 'second'))).toBe(false) + }) + it('does not remove an existing target directory when aborting a pending clone', async () => { const destination = await createTempRoot() const clonePath = join(destination, 'orca') diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index bb1d8e6ff69..558ed1d5a22 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -44,7 +44,12 @@ import { isWorkspaceLinkedItemSourceContextMatch } from '../../shared/workspace- import { invalidateAuthorizedRootsCache } from './filesystem-auth' import type { ChildProcess } from 'node:child_process' import { access, mkdir, readdir, rm } from 'node:fs/promises' -import { gitExecFileAsync, gitSpawn, nonInteractiveGitEnv } from '../git/runner' +import { + awaitWindowsHostGitEnvironmentReady, + gitExecFileAsync, + gitSpawnAfterWindowsEnvironmentReady, + nonInteractiveGitEnv +} from '../git/runner' import { isAbsolute, join, posix } from 'node:path' import { cleanupClaimedCloneTarget, @@ -283,6 +288,9 @@ async function addLocalRepoFromPath( kind: 'git' | 'folder' = 'git' ): Promise<{ repo: Repo; alreadyExisted: boolean } | { error: string }> { const repoKind = kind === 'folder' ? 'folder' : 'git' + if (repoKind === 'git') { + await awaitWindowsHostGitEnvironmentReady({ cwd: path }) + } if (repoKind === 'git' && !isGitRepo(path)) { return { error: `Not a valid git repository: ${path}` } } @@ -762,6 +770,7 @@ export function setRepoRemoteClientNotifier(notifier: RepoRemoteClientNotifier): // Why: module-scoped so the abort handle survives macOS window re-creation, when registerRepoHandlers re-runs. let activeClone: ActiveCloneMetadata | null = null +const pendingLocalCloneControllers = new Set() let activeRemoteClone: ActiveRemoteCloneMetadata | null = null let nextCloneGeneration = 1 const latestCloneGenerationByPath = new Map() @@ -1169,6 +1178,10 @@ async function scanNestedReposForIpc(args: { }): Promise { validateNestedRepoScanRoot(args.path, args.connectionId) if (!args.connectionId) { + await awaitWindowsHostGitEnvironmentReady({ + cwd: args.path, + ...(args.signal ? { signal: args.signal } : {}) + }) return scanNestedRepos({ path: args.path, options: args.options, @@ -1507,7 +1520,10 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v { getSshFilesystemProvider } ) assertFolderWorkspacePathUsable(status) - const workspace = store.createFolderWorkspace(args) + const workspace = store.createFolderWorkspace({ + ...args, + creatorProvenance: { kind: 'host' } + }) notifyReposChanged(mainWindow) return workspace } @@ -1699,10 +1715,16 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v continue } importRepoPath = await importTargetResolver.resolveSsh(repoPath, gitProvider) - } else if (!isGitRepo(repoPath)) { - results.push({ path: repoPath, status: 'failed', error: 'Not a valid git repository' }) - continue } else { + await awaitWindowsHostGitEnvironmentReady({ cwd: repoPath }) + if (!isGitRepo(repoPath)) { + results.push({ + path: repoPath, + status: 'failed', + error: 'Not a valid git repository' + }) + continue + } importRepoPath = await importTargetResolver.resolveLocal(repoPath) } const normalizedImportRepoPath = normalizeRuntimePathForComparison(importRepoPath) @@ -2322,6 +2344,10 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v }) ipcMain.handle('repos:cloneAbort', async () => { + for (const controller of pendingLocalCloneControllers) { + controller.abort() + } + pendingLocalCloneControllers.clear() if (activeClone) { const clone = activeClone clone.abortRequested = true @@ -2358,24 +2384,30 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v // Why: spawn (not execFile) avoids the maxBuffer limit — clone progress on stderr can exceed Node's 1 MB default. // Why: --progress forces git to emit progress even when stderr isn't a TTY. const cloneMetadataRef: { current: ActiveCloneMetadata | null } = { current: null } - await new Promise((resolve, reject) => { + let proc: Awaited> + const pendingController = new AbortController() + pendingLocalCloneControllers.add(pendingController) + try { // Why: use the parent destination as cwd so the runner detects a WSL path and routes through wsl.exe. // Why: '--' isolates the URL so a malicious URL can't be read as git flags (command injection). - let proc: ReturnType - try { - proc = gitSpawn(['clone', '--progress', '--', args.url, clonePath], { + proc = await gitSpawnAfterWindowsEnvironmentReady( + ['clone', '--progress', '--', args.url, clonePath], + { cwd: args.destination, // Why: without this, an auth-needing clone pops Git Credential Manager's OAuth window on Windows, unclosable in a restricted env (issue #7652). env: nonInteractiveGitEnv(), + signal: pendingController.signal, stdio: ['ignore', 'ignore', 'pipe'] - }) - } catch (err) { - void cleanupClaimedCloneTarget(clonePath, claimedTarget).finally(() => { - const message = err instanceof Error ? err.message : String(err) - reject(new Error(`Clone failed: ${message}`)) - }) - return - } + } + ) + } catch (err) { + await cleanupClaimedCloneTarget(clonePath, claimedTarget) + const message = err instanceof Error ? err.message : String(err) + throw new Error(`Clone failed: ${message}`) + } finally { + pendingLocalCloneControllers.delete(pendingController) + } + await new Promise((resolve, reject) => { const generation = nextCloneGeneration++ latestCloneGenerationByPath.set(clonePathKey, generation) const metadata: ActiveCloneMetadata = { diff --git a/src/main/ipc/runtime-environment-transport-routing.ts b/src/main/ipc/runtime-environment-transport-routing.ts index 852b97273c5..d144ee087e1 100644 --- a/src/main/ipc/runtime-environment-transport-routing.ts +++ b/src/main/ipc/runtime-environment-transport-routing.ts @@ -66,7 +66,10 @@ export async function getRuntimeEnvironmentStatus( ) } if (response.ok === true) { - markEnvironmentUsed(userDataPath, environment.id, { runtimeId: response._meta.runtimeId }) + markEnvironmentUsed(userDataPath, environment.id, { + runtimeId: response._meta.runtimeId, + pairedDeviceId: response.result.pairedDeviceId + }) reconnectRemoteRuntimeSharedControlConnection(environment.id) } return attachRemoteControlDiagnostics( diff --git a/src/main/ipc/usage-provider-handlers.test.ts b/src/main/ipc/usage-provider-handlers.test.ts new file mode 100644 index 00000000000..54733b87c26 --- /dev/null +++ b/src/main/ipc/usage-provider-handlers.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from 'vitest' + +const { handle } = vi.hoisted(() => ({ handle: vi.fn() })) + +vi.mock('electron', () => ({ ipcMain: { handle } })) + +import { registerUsageProviderHandlers } from './usage-provider-handlers' + +describe('usage provider IPC handlers', () => { + it('registers every provider route and forwards query arguments', () => { + const createUsage = () => ({ + getScanState: vi.fn(), + setEnabled: vi.fn(), + refresh: vi.fn(), + getSnapshot: vi.fn(), + getSummary: vi.fn(), + getDaily: vi.fn(), + getBreakdown: vi.fn(), + getRecentSessions: vi.fn() + }) + const claudeUsage = createUsage() + const codexUsage = createUsage() + const openCodeUsage = createUsage() + registerUsageProviderHandlers({ + claudeUsage: claudeUsage as never, + codexUsage: codexUsage as never, + openCodeUsage: openCodeUsage as never + }) + + const prefixes = ['claudeUsage', 'codexUsage', 'openCodeUsage'] + const suffixes = Object.keys(claudeUsage) + expect(handle.mock.calls.map(([channel]) => channel)).toEqual( + prefixes.flatMap((prefix) => suffixes.map((suffix) => `${prefix}:${suffix}`)) + ) + + const call = (prefix: string, suffix: string, args?: unknown): unknown => { + const handler = handle.mock.calls.find( + ([channel]) => channel === `${prefix}:${suffix}` + )?.[1] as ((event: unknown, args?: unknown) => unknown) | undefined + return handler?.({}, args) + } + call('claudeUsage', 'getScanState') + call('codexUsage', 'getScanState') + call('openCodeUsage', 'getScanState') + call('claudeUsage', 'setEnabled', { enabled: true }) + call('claudeUsage', 'refresh') + call('claudeUsage', 'refresh', { force: true }) + call('claudeUsage', 'getSnapshot', { scope: 'orca', range: '30d', limit: 7 }) + call('claudeUsage', 'getSummary', { scope: 'all', range: '7d' }) + call('claudeUsage', 'getDaily', { scope: 'orca', range: '90d' }) + call('claudeUsage', 'getBreakdown', { scope: 'all', range: 'all', kind: 'model' }) + call('claudeUsage', 'getRecentSessions', { scope: 'orca', range: '30d', limit: 4 }) + + expect(claudeUsage.getScanState).toHaveBeenCalledWith() + expect(codexUsage.getScanState).toHaveBeenCalledWith() + expect(openCodeUsage.getScanState).toHaveBeenCalledWith() + expect(claudeUsage.setEnabled).toHaveBeenCalledWith(true) + expect(claudeUsage.refresh.mock.calls).toEqual([[false], [true]]) + expect(claudeUsage.getSnapshot).toHaveBeenCalledWith('orca', '30d', 7) + expect(claudeUsage.getSummary).toHaveBeenCalledWith('all', '7d') + expect(claudeUsage.getDaily).toHaveBeenCalledWith('orca', '90d') + expect(claudeUsage.getBreakdown).toHaveBeenCalledWith('all', 'all', 'model') + expect(claudeUsage.getRecentSessions).toHaveBeenCalledWith('orca', '30d', 4) + }) +}) diff --git a/src/main/ipc/usage-provider-handlers.ts b/src/main/ipc/usage-provider-handlers.ts new file mode 100644 index 00000000000..58496f1c8ad --- /dev/null +++ b/src/main/ipc/usage-provider-handlers.ts @@ -0,0 +1,68 @@ +import { ipcMain } from 'electron' +import type { ClaudeUsageStore } from '../claude-usage/store' +import type { CodexUsageStore } from '../codex-usage/store' +import type { OpenCodeUsageStore } from '../opencode-usage/store' + +type UsageProviderStores = { + claudeUsage: ClaudeUsageStore + codexUsage: CodexUsageStore + openCodeUsage: OpenCodeUsageStore +} + +type UsageProviderChannelPrefix = keyof UsageProviderStores + +type UsageProviderHandlerStore = { + getScanState: () => unknown + setEnabled: (enabled: boolean) => unknown + refresh: (force?: boolean) => unknown + getSnapshot: (scope: Scope, range: Range, limit?: number) => unknown + getSummary: (scope: Scope, range: Range) => unknown + getDaily: (scope: Scope, range: Range) => unknown + getBreakdown: (scope: Scope, range: Range, kind: BreakdownKind) => unknown + getRecentSessions: (scope: Scope, range: Range, limit?: number) => unknown +} + +type UsageRangeArgs = { + scope: Scope + range: Range +} + +function registerProviderHandlers( + prefix: UsageProviderChannelPrefix, + usage: UsageProviderHandlerStore +): void { + ipcMain.handle(`${prefix}:getScanState`, () => usage.getScanState()) + ipcMain.handle(`${prefix}:setEnabled`, (_event, args: { enabled: boolean }) => + usage.setEnabled(args.enabled) + ) + ipcMain.handle(`${prefix}:refresh`, (_event, args?: { force?: boolean }) => + usage.refresh(args?.force ?? false) + ) + ipcMain.handle( + `${prefix}:getSnapshot`, + (_event, args: UsageRangeArgs & { limit?: number }) => + usage.getSnapshot(args.scope, args.range, args.limit) + ) + ipcMain.handle(`${prefix}:getSummary`, (_event, args: UsageRangeArgs) => + usage.getSummary(args.scope, args.range) + ) + ipcMain.handle(`${prefix}:getDaily`, (_event, args: UsageRangeArgs) => + usage.getDaily(args.scope, args.range) + ) + ipcMain.handle( + `${prefix}:getBreakdown`, + (_event, args: UsageRangeArgs & { kind: BreakdownKind }) => + usage.getBreakdown(args.scope, args.range, args.kind) + ) + ipcMain.handle( + `${prefix}:getRecentSessions`, + (_event, args: UsageRangeArgs & { limit?: number }) => + usage.getRecentSessions(args.scope, args.range, args.limit) + ) +} + +export function registerUsageProviderHandlers(stores: UsageProviderStores): void { + registerProviderHandlers('claudeUsage', stores.claudeUsage) + registerProviderHandlers('codexUsage', stores.codexUsage) + registerProviderHandlers('openCodeUsage', stores.openCodeUsage) +} diff --git a/src/main/ipc/worktree-base-directory-poller.test.ts b/src/main/ipc/worktree-base-directory-poller.test.ts index e28823adbc8..101d48e22cb 100644 --- a/src/main/ipc/worktree-base-directory-poller.test.ts +++ b/src/main/ipc/worktree-base-directory-poller.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { writeFileSync } from 'node:fs' +import { mkdirSync, writeFileSync } from 'node:fs' import { mkdtemp, mkdir, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -265,6 +265,44 @@ describe('worktree base directory poller', () => { ).toHaveLength(2) }) + it('rescans on the next tick when a write races an in-flight full scan', async () => { + const root = await makeRoot() + const worktree = join(root, 'raced') + const received: WorktreeBasePollEvent[][] = [] + const target = makeTarget('base', root) + const snapshotTicks: number[] = [] + let raced = false + const poller = await startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + (events) => received.push(events), + { + pollIntervalMs: 0, + onSnapshotTaken: (tick) => { + snapshotTicks.push(tick) + if (raced) { + return + } + raced = true + mkdirSync(worktree) + writeFileSync(join(worktree, '.git'), 'gitdir: elsewhere') + } + } + ) + cleanups.push(() => poller.unsubscribe()) + + await waitForEvents(received, (flat) => + flat.some((event) => event.type === 'create' && event.path === join(worktree, '.git')) + ) + + // A write landing after a scan's listings must leave the gate stale, so the + // next tick rescans instead of deferring the create to the backstop. + expect(snapshotTicks.slice(0, 2)).toEqual([ + WORKTREE_BASE_BACKSTOP_TICKS, + WORKTREE_BASE_BACKSTOP_TICKS + 1 + ]) + }) + it('parks base scans while hidden and losslessly detects changes on resume', async () => { const root = await makeRoot() const visibility = createVisibilityHarness() diff --git a/src/main/ipc/worktree-base-directory-poller.ts b/src/main/ipc/worktree-base-directory-poller.ts index b5076c2ed7f..42ee184b811 100644 --- a/src/main/ipc/worktree-base-directory-poller.ts +++ b/src/main/ipc/worktree-base-directory-poller.ts @@ -65,6 +65,8 @@ export type WorktreeBasePollerOptions = { onFullScan?: () => void /** Test hook: called before a pending `.git` marker stat. */ onPendingMarkerProbe?: (path: string) => void + /** Test hook: awaited with the tick after a full scan's listings, to land a racing write. */ + onSnapshotTaken?: (tick: number) => void | Promise /** Test hook: overrides the fast-probe window. */ pendingMarkerMaxTicks?: number } @@ -117,6 +119,8 @@ type BaseSnapshot = { // dirs whose listing determines the candidate set: the root plus any // nested repo containers. Their stat signatures gate the next full scan. gateDirs: string[] + // index-aligned with gateDirs, each sampled *before* that dir's listing + gateSignatures: string[] } // Depth-1 worktree dirs (flat layout), plus depth-2 dirs under each nested @@ -128,6 +132,10 @@ async function snapshotBase( ): Promise { const markers = new Map() const gateDirs = [rootPath] + // Why: sampling the signature before the listing makes a write that races the + // scan look stale next tick (one redundant rescan) instead of invisible until + // the backstop, which is up to 15 ticks of missed creates/deletes. + const gateSignatures = [await dirSignature(rootPath)] const configs = [...repos.values()] const includeFlat = configs.some((config) => !config.nestWorkspaces) const nestedRepoNames = new Set( @@ -142,7 +150,7 @@ async function snapshotBase( } catch { // Root vanished: an empty snapshot diffs into delete events for every // previously-known worktree dir, matching the old watcher's error path. - return { markers, gateDirs } + return { markers, gateDirs, gateSignatures } } const candidates: string[] = [] @@ -156,6 +164,7 @@ async function snapshotBase( } if (nestedRepoNames.has(normalizeRuntimePathForComparison(entry.name))) { gateDirs.push(entryPath) + gateSignatures.push(await dirSignature(entryPath)) let subEntries try { subEntries = await readdir(entryPath, { withFileTypes: true }) @@ -173,7 +182,7 @@ async function snapshotBase( for (const dir of candidates) { markers.set(dir, await hasGitMarker(dir)) } - return { markers, gateDirs } + return { markers, gateDirs, gateSignatures } } function diffBase(prev: BaseSnapshot, next: BaseSnapshot): WorktreeBasePollEvent[] { @@ -203,7 +212,6 @@ async function startBasePoller( let ticking = false let tickCount = 0 let snapshot = await snapshotBase(target.path, getRepos()) - let gateSignatures = await Promise.all(snapshot.gateDirs.map(dirSignature)) let timer: ReturnType | null = null let parkedWhileHidden = false const pendingMarkerMaxTicks = options.pendingMarkerMaxTicks ?? PENDING_MARKER_MAX_TICKS @@ -218,7 +226,7 @@ async function startBasePoller( const fullScan = async (): Promise => { options.onFullScan?.() const next = await snapshotBase(target.path, getRepos()) - const nextSignatures = await Promise.all(next.gateDirs.map(dirSignature)) + await options.onSnapshotTaken?.(tickCount) if (disposed) { return } @@ -236,7 +244,6 @@ async function startBasePoller( } } snapshot = next - gateSignatures = nextSignatures if (events.length > 0) { onEvents(events) } @@ -274,8 +281,8 @@ async function startBasePoller( // are untouched, skip the readdir + per-candidate stat fan-out entirely. const signatures = await Promise.all(snapshot.gateDirs.map(dirSignature)) const gateChanged = - signatures.length !== gateSignatures.length || - signatures.some((sig, index) => sig !== gateSignatures[index]) + signatures.length !== snapshot.gateSignatures.length || + signatures.some((sig, index) => sig !== snapshot.gateSignatures[index]) if (gateChanged) { await fullScan() return diff --git a/src/main/ipc/worktree-metadata-merge.ts b/src/main/ipc/worktree-metadata-merge.ts index 8c4aa0efead..6b7542d18d4 100644 --- a/src/main/ipc/worktree-metadata-merge.ts +++ b/src/main/ipc/worktree-metadata-merge.ts @@ -2,6 +2,7 @@ import { basename } from 'node:path' import type { GitWorktreeInfo, Worktree, WorktreeMeta } from '../../shared/types' import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' import { getLinkedWorkItemMetadata } from './worktree-linked-work-item-metadata' +import { normalizeWorkspaceCreatorProvenance } from '../../shared/workspace-creator-provenance' /** * Merge raw git worktree info with persisted user metadata into a full Worktree. @@ -13,6 +14,7 @@ export function mergeWorktree( defaultDisplayName?: string ): Worktree { const branchShort = git.branch.replace(/^refs\/heads\//, '') + const creatorProvenance = normalizeWorkspaceCreatorProvenance(meta?.creatorProvenance) return { id: `${repoId}::${git.path}`, ...(meta?.instanceId !== undefined ? { instanceId: meta.instanceId } : {}), @@ -22,6 +24,7 @@ export function mergeWorktree( ...(meta?.projectHostSetupId !== undefined ? { projectHostSetupId: meta.projectHostSetupId } : {}), + ...(creatorProvenance ? { creatorProvenance } : {}), path: git.path, head: git.head, branch: git.branch, diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index b09b4e71c93..f83c5035976 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -1784,6 +1784,7 @@ export async function createRemoteWorktree( createdAt: now, orcaCreatedAt: now, orcaCreationSource: 'ssh', + creatorProvenance: { kind: 'host' }, orcaCreationWorkspaceLayout: getWorktreeCreationLayout(repo, settings), ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), ...(args.cliProvenance ? { cliProvenance: args.cliProvenance } : {}), @@ -2392,6 +2393,7 @@ export async function createLocalWorktree( createdAt: now, orcaCreatedAt: now, orcaCreationSource: 'desktop', + creatorProvenance: { kind: 'host' }, orcaCreationWorkspaceLayout: getWorktreeCreationLayout(repo, settings), ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), ...(args.cliProvenance ? { cliProvenance: args.cliProvenance } : {}), diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index b5e3038cf44..180affdaedf 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -1051,6 +1051,7 @@ function createFolderWorkspace( createdAt: now, orcaCreatedAt: now, orcaCreationSource: 'desktop', + creatorProvenance: { kind: 'host' }, ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), ...(args.cliProvenance ? { cliProvenance: args.cliProvenance } : {}), ...(args.createdWithAgent ? { createdWithAgent: args.createdWithAgent } : {}), diff --git a/src/main/opencode-usage/store.test.ts b/src/main/opencode-usage/store.test.ts index 6f3759efa7f..d565327fd18 100644 --- a/src/main/opencode-usage/store.test.ts +++ b/src/main/opencode-usage/store.test.ts @@ -1,24 +1,15 @@ -import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' -import type * as FsPromises from 'node:fs/promises' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { OpenCodeUsageDailyAggregate, - OpenCodeUsagePersistedDatabase, OpenCodeUsagePersistedState, OpenCodeUsageSession } from './types' -const { getPathMock, writeOpens, writeGate } = vi.hoisted(() => ({ - getPathMock: vi.fn(() => '/tmp/orca-test-userdata'), - // Why only mode 'w': the durable write also opens the directory read-only to fsync it, so counting - // every open would hide a regression back to multiple full-cache rewrites per scan. - writeOpens: { value: 0, inFlight: 0, maxConcurrent: 0 }, - writeGate: { - blocked: false, - waiters: [] as (() => void)[] - } +const { getPathMock } = vi.hoisted(() => ({ + getPathMock: vi.fn(() => '/tmp/orca-test-userdata') })) vi.mock('electron', () => ({ @@ -27,29 +18,6 @@ vi.mock('electron', () => ({ } })) -vi.mock('node:fs/promises', async () => { - const actual = await vi.importActual('node:fs/promises') - return { - ...actual, - open: (async (...args: Parameters) => { - if (args[1] !== 'w') { - return actual.open(...args) - } - writeOpens.value += 1 - writeOpens.inFlight += 1 - writeOpens.maxConcurrent = Math.max(writeOpens.maxConcurrent, writeOpens.inFlight) - try { - if (writeGate.blocked) { - await new Promise((resolve) => writeGate.waiters.push(resolve)) - } - return await actual.open(...args) - } finally { - writeOpens.inFlight -= 1 - } - }) as typeof actual.open - } -}) - vi.mock('./scanner', () => ({ scanOpenCodeUsageDatabases: vi.fn() })) @@ -57,24 +25,7 @@ vi.mock('./scanner', () => ({ import { OpenCodeUsageStore, initOpenCodeUsagePath, normalizePersistedState } from './store' import { scanOpenCodeUsageDatabases } from './scanner' -type ScanResult = { - processedDatabases: OpenCodeUsagePersistedDatabase[] - sessions: OpenCodeUsageSession[] - dailyAggregates: OpenCodeUsageDailyAggregate[] -} - -function createDeferred(): { - promise: Promise - resolve: (value: T) => void -} { - let resolve!: (value: T) => void - const promise = new Promise((promiseResolve) => { - resolve = promiseResolve - }) - return { promise, resolve } -} - -function createEmptyScanResult(): ScanResult { +function createEmptyScanResult() { return { processedDatabases: [], sessions: [], @@ -101,9 +52,8 @@ function getDefaultState(): OpenCodeUsagePersistedState { function createStoreWithState(state: Partial): OpenCodeUsageStore { const store = new OpenCodeUsageStore({ getRepos: () => [], - getAllWorktreeMeta: () => ({}), - getWorktreeMeta: () => undefined - } as never) + getAllWorktreeMeta: () => ({}) + }) ;(store as unknown as { state: OpenCodeUsagePersistedState }).state = { ...getDefaultState(), @@ -212,11 +162,6 @@ describe('OpenCodeUsageStore', () => { tempUserData = mkdtempSync(join(tmpdir(), 'orca-opencode-usage-store-')) getPathMock.mockReturnValue(tempUserData) initOpenCodeUsagePath() - writeOpens.value = 0 - writeOpens.inFlight = 0 - writeOpens.maxConcurrent = 0 - writeGate.blocked = false - writeGate.waiters = [] vi.mocked(scanOpenCodeUsageDatabases).mockReset() vi.mocked(scanOpenCodeUsageDatabases).mockResolvedValue(createEmptyScanResult()) vi.useFakeTimers() @@ -228,7 +173,7 @@ describe('OpenCodeUsageStore', () => { rmSync(tempUserData, { recursive: true, force: true }) }) - it('persists a successful refresh with one full-cache write', async () => { + it('adapts OpenCode scans to pretty-printed cache persistence', async () => { const store = createStoreWithState({ scanState: { enabled: true, @@ -240,89 +185,9 @@ describe('OpenCodeUsageStore', () => { await store.refresh(true) - // Why exactly one: a refresh that rewrites the whole multi-MB cache twice is the regression this guards. - expect(writeOpens.value).toBe(1) - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) const persistedJson = readFileSync(join(tempUserData, 'orca-opencode-usage.json'), 'utf-8') + expect(scanOpenCodeUsageDatabases).toHaveBeenCalledWith([], []) expect(persistedJson).toContain('\n') - expect(JSON.parse(persistedJson).scanState).toMatchObject({ - enabled: true, - lastScanStartedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(), - lastScanCompletedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(), - lastScanError: null - }) - }) - - it('keeps scan start visible in memory while scan-start persistence is skipped', async () => { - const pendingScan = createDeferred() - vi.mocked(scanOpenCodeUsageDatabases).mockReturnValueOnce(pendingScan.promise) - const store = createStoreWithState({ - scanState: { - enabled: true, - lastScanStartedAt: null, - lastScanCompletedAt: null, - lastScanError: 'previous failure' - } - }) - - const refreshPromise = store.refresh(true) - await Promise.resolve() - - expect(store.getScanState()).toMatchObject({ - isScanning: true, - lastScanStartedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(), - lastScanError: null - }) - expect(writeOpens.value).toBe(0) - - pendingScan.resolve(createEmptyScanResult()) - await refreshPromise - - expect(store.getScanState().isScanning).toBe(false) - expect(writeOpens.value).toBe(1) - }) - - it('vetoes a stale concurrent async write so the newer snapshot wins without leaking tmp files', async () => { - const store = createStoreWithState({ - scanState: { - enabled: true, - lastScanStartedAt: null, - lastScanCompletedAt: null, - lastScanError: null - } - }) - const internals = store as unknown as { - writeToDisk: () => Promise - state: OpenCodeUsagePersistedState - } - - writeGate.blocked = true - const first = internals.writeToDisk() - await vi.waitFor(() => expect(writeGate.waiters.length).toBe(1)) - - internals.state.scanState.enabled = false - writeGate.blocked = false - const second = internals.writeToDisk() - writeGate.waiters.splice(0).forEach((resolve) => resolve()) - await Promise.all([first, second]) - - expect( - JSON.parse(readFileSync(join(tempUserData, 'orca-opencode-usage.json'), 'utf-8')).scanState - .enabled - ).toBe(false) - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) - // Serialized, so the superseded write can be skipped safely rather than racing the newer one. - expect(writeOpens.maxConcurrent).toBe(1) - }) - - it('sweeps a usage temp file orphaned by a crash between write and rename', async () => { - const orphan = join(tempUserData, 'orca-opencode-usage.json.999.1.abc.tmp') - writeFileSync(orphan, '{}') - - createStoreWithState({}) - await vi.waitFor(() => - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) - ) }) it('reports no data for Orca scope when only non-Orca OpenCode usage exists', async () => { diff --git a/src/main/opencode-usage/store.ts b/src/main/opencode-usage/store.ts index c85fa519ba5..b87357f9b0a 100644 --- a/src/main/opencode-usage/store.ts +++ b/src/main/opencode-usage/store.ts @@ -1,27 +1,23 @@ -/* eslint-disable max-lines -- Why: this store owns OpenCode analytics persistence, scan policy, and renderer query semantics. Keeping range/scope queries next to scan persistence prevents UI totals from drifting from the SQLite projection. */ +/* eslint-disable max-lines -- Why: OpenCode cost normalization and range, scope, and breakdown policies remain one cohesive store. */ import { app } from 'electron' import { join } from 'node:path' -import { existsSync, readFileSync } from 'node:fs' -import { UsageCacheSnapshotWriter } from '../usage-cache-snapshot-writer' import type { OpenCodeUsageBreakdownKind, OpenCodeUsageBreakdownRow, OpenCodeUsageDailyPoint, OpenCodeUsageRange, - OpenCodeUsageScanState, OpenCodeUsageScope, OpenCodeUsageSessionRow, OpenCodeUsageSnapshot, OpenCodeUsageSummary } from '../../shared/opencode-usage-types' import type { Store } from '../persistence' -import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata' import type { OpenCodeUsageDailyAggregate, OpenCodeUsagePersistedState } from './types' -import { createWorktreeRefs } from '../usage/usage-worktree-refs' import { OPENCODE_USAGE_SCHEMA_VERSION, openCodeUsageProvider } from './opencode-usage-provider' +import { getLocalUsageDay, getUsageRangeCutoff } from '../usage/usage-calendar-range' +import { UsageProviderStoreLifecycle } from '../usage/usage-provider-store-lifecycle' const SCHEMA_VERSION = OPENCODE_USAGE_SCHEMA_VERSION -const STALE_MS = 5 * 60_000 let _openCodeUsageFile: string | null = null @@ -70,47 +66,6 @@ function getOpenCodeUsageFile(): string { return _openCodeUsageFile } -function getRangeCutoff(range: OpenCodeUsageRange): string | null { - if (range === 'all') { - return null - } - const days = range === '7d' ? 7 : range === '30d' ? 30 : 90 - const now = new Date() - now.setHours(0, 0, 0, 0) - now.setDate(now.getDate() - (days - 1)) - const year = now.getFullYear() - const month = String(now.getMonth() + 1).padStart(2, '0') - const day = String(now.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -} - -function getLocalDay(timestamp: string): string | null { - const parsed = new Date(timestamp) - if (Number.isNaN(parsed.getTime())) { - return null - } - const year = parsed.getFullYear() - const month = String(parsed.getMonth() + 1).padStart(2, '0') - const day = String(parsed.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -} - -function getWorktreeFingerprint(worktreesByRepo: Map): string { - const rows = [...worktreesByRepo.entries()] - .flatMap(([repoId, worktrees]) => - worktrees.map((worktree) => - JSON.stringify({ - repoId, - worktreeId: worktree.worktreeId, - path: worktree.path, - displayName: worktree.displayName - }) - ) - ) - .sort() - return JSON.stringify(rows) -} - function addCost(left: number | null, right: number | null): number | null { if (left === null && right === null) { return null @@ -148,62 +103,22 @@ function normalizeSessionCost( } } -export class OpenCodeUsageStore { - private state: OpenCodeUsagePersistedState - private readonly store: Store - private scanPromise: Promise | null = null - // Why: the multi-MB usage JSON must not block the Electron main thread; the writer serializes - // writes and vetoes superseded renames. - private readonly writer = new UsageCacheSnapshotWriter('[opencode-usage]', getOpenCodeUsageFile) - - constructor(store: Store) { - this.store = store - this.state = this.load() - } - - private load(): OpenCodeUsagePersistedState { - try { - const usageFile = getOpenCodeUsageFile() - if (!existsSync(usageFile)) { - return getDefaultState() - } - const parsed = JSON.parse(readFileSync(usageFile, 'utf-8')) as OpenCodeUsagePersistedState - return normalizePersistedState({ - ...getDefaultState(), - ...parsed, - scanState: { - ...getDefaultState().scanState, - ...parsed.scanState - } - }) - } catch (error) { - console.error('[opencode-usage] Failed to load persisted state, starting fresh:', error) - return getDefaultState() - } - } - - private writeToDisk(): Promise { - // Pretty-print preserved: humans inspect this analytics cache on disk. - return this.writer.write(() => JSON.stringify(this.state, null, 2)) - } - - /** Await queued cache writes so quit does not drop the final snapshot. */ - flush(): Promise { - return this.writer.flush() - } - - async setEnabled(enabled: boolean): Promise { - this.state.scanState.enabled = enabled - await this.writeToDisk() - return this.getScanState() - } - - getScanState(): OpenCodeUsageScanState { - return { - ...this.state.scanState, - isScanning: this.scanPromise !== null, - hasAnyOpenCodeData: this.state.sessions.length > 0 || this.state.dailyAggregates.length > 0 - } +export class OpenCodeUsageStore extends UsageProviderStoreLifecycle< + 'processedDatabases', + OpenCodeUsagePersistedState, + 'hasAnyOpenCodeData' +> { + constructor(store: Pick) { + super(store, { + logTag: '[opencode-usage]', + resolveCacheFile: getOpenCodeUsageFile, + createDefaultState: getDefaultState, + normalizeState: normalizePersistedState, + sourceKey: 'processedDatabases', + dataPresenceKey: 'hasAnyOpenCodeData', + jsonIndent: 2, + scan: openCodeUsageProvider.scan + }) } getSnapshot( @@ -221,63 +136,6 @@ export class OpenCodeUsageStore { } } - async refresh(force = false): Promise { - if (!this.state.scanState.enabled) { - return this.getScanState() - } - const currentWorktreeFingerprint = await this.getCurrentWorktreeFingerprint() - if (!force && this.state.scanState.lastScanCompletedAt) { - const ageMs = Date.now() - this.state.scanState.lastScanCompletedAt - if (ageMs < STALE_MS && this.state.worktreeFingerprint === currentWorktreeFingerprint) { - return this.getScanState() - } - } - await this.runScan() - return this.getScanState() - } - - private async runScan(): Promise { - if (this.scanPromise) { - await this.scanPromise - return - } - - this.state.scanState.lastScanStartedAt = Date.now() - this.state.scanState.lastScanError = null - // Why no write here: persisting scan-start would rewrite the whole cache before a single result - // changed. The completion/failure write below persists the same fields. - - this.scanPromise = (async () => { - try { - const repos = this.store.getRepos() - const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) - const worktreeFingerprint = getWorktreeFingerprint(worktreesByRepo) - const result = await openCodeUsageProvider.scan( - createWorktreeRefs(repos, worktreesByRepo), - this.state.worktreeFingerprint === worktreeFingerprint - ? this.state.processedDatabases - : [] - ) - this.state.processedDatabases = result.processedDatabases - this.state.sessions = result.sessions - this.state.dailyAggregates = result.dailyAggregates - this.state.worktreeFingerprint = worktreeFingerprint - this.state.scanState.lastScanCompletedAt = Date.now() - this.state.scanState.lastScanError = null - // Why swallow: persistence is a cache concern. A disk failure must not turn a good scan into - // a scan error and reject refresh() for every query caller; writeToDisk already logs it. - await this.writeToDisk().catch(() => {}) - } catch (error) { - this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error) - await this.writeToDisk().catch(() => {}) - } finally { - this.scanPromise = null - } - })() - - await this.scanPromise - } - async getSummary( scope: OpenCodeUsageScope, range: OpenCodeUsageRange @@ -479,7 +337,7 @@ export class OpenCodeUsageStore { scope: OpenCodeUsageScope, range: OpenCodeUsageRange ): OpenCodeUsageDailyAggregate[] { - const cutoff = getRangeCutoff(range) + const cutoff = getUsageRangeCutoff(range) return this.state.dailyAggregates.filter((row) => { if (scope === 'orca' && !row.worktreeId) { return false @@ -492,13 +350,13 @@ export class OpenCodeUsageStore { } private getFilteredSessions(scope: OpenCodeUsageScope, range: OpenCodeUsageRange) { - const cutoff = getRangeCutoff(range) + const cutoff = getUsageRangeCutoff(range) return this.state.sessions.filter((session) => { if (scope === 'orca' && !session.primaryWorktreeId) { return false } if (cutoff) { - const day = getLocalDay(session.lastTimestamp) + const day = getLocalUsageDay(session.lastTimestamp) if (!day || day < cutoff) { return false } @@ -506,9 +364,4 @@ export class OpenCodeUsageStore { return true }) } - - private async getCurrentWorktreeFingerprint(): Promise { - const repos = this.store.getRepos() - return getWorktreeFingerprint(loadKnownUsageWorktreesByRepo(this.store, repos)) - } } diff --git a/src/main/orca-profiles/profile-artifact-cloud-cleanup.test.ts b/src/main/orca-profiles/profile-artifact-cloud-cleanup.test.ts new file mode 100644 index 00000000000..0624124c385 --- /dev/null +++ b/src/main/orca-profiles/profile-artifact-cloud-cleanup.test.ts @@ -0,0 +1,244 @@ +import { mkdirSync } from 'node:fs' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { OrcaProfileCloudSummary } from '../../shared/orca-profiles' +import type * as ArtifactCreateIntentStore from '../artifacts/artifact-create-intent-store' +import type * as ProfileArtifactCloudCleanup from './profile-artifact-cloud-cleanup' +import type * as ProfileIndexStore from './profile-index-store' + +vi.mock('../artifacts/artifact-create-intent-store', async () => { + const actual = await vi.importActual( + '../artifacts/artifact-create-intent-store' + ) + return { ...actual, clearArtifactCreateIntents: vi.fn(actual.clearArtifactCreateIntents) } +}) + +vi.mock('./profile-index-store', async () => { + const actual = await vi.importActual('./profile-index-store') + return { ...actual, writeProfileIndex: vi.fn(actual.writeProfileIndex) } +}) + +vi.mock('./profile-artifact-cloud-cleanup', async () => { + const actual = await vi.importActual( + './profile-artifact-cloud-cleanup' + ) + return { + ...actual, + commitArtifactCloudCleanup: vi.fn(actual.commitArtifactCloudCleanup) + } +}) + +import { + clearArtifactCreateIntents, + getArtifactCreateIntent, + getOrCreateArtifactCreateIntent +} from '../artifacts/artifact-create-intent-store' +import type { ArtifactShareScope } from '../artifacts/artifact-share-record-store' +import { + artifactCloudCleanupNeedsCommit, + commitArtifactCloudCleanup, + prepareArtifactCloudCleanup, + prepareArtifactCloudUse +} from './profile-artifact-cloud-cleanup' +import { linkOrcaProfileToCloud, unlinkOrcaProfileFromCloud } from './profile-cloud-index' +import { + getOrcaProfileIndexPath, + getOrcaProfileDirectory, + loadOrCreateProfileIndex, + readProfileIndex, + writeProfileIndex +} from './profile-index-store' + +const createdPaths: string[] = [] +const profileId = 'local-default' + +afterEach(async () => { + vi.clearAllMocks() + await Promise.all( + createdPaths.splice(0).map((path) => rm(path, { recursive: true, force: true })) + ) +}) + +describe('profile artifact cloud cleanup', () => { + it('preserves recovery state when the profile write fails', async () => { + const userDataPath = await createLinkedProfile(cloud('org-a')) + const scope = shareScope('org-a') + createIntent(userDataPath, scope) + vi.mocked(writeProfileIndex).mockImplementationOnce(() => { + throw new Error('profile write failed') + }) + + expect(() => linkOrcaProfileToCloud(profileId, cloud('org-b'), userDataPath)).toThrow( + 'profile write failed' + ) + expect(getArtifactCreateIntent(profileId, userDataPath, '/report.md', scope)).not.toBeNull() + + linkOrcaProfileToCloud(profileId, cloud('org-b'), userDataPath) + expect(getArtifactCreateIntent(profileId, userDataPath, '/report.md', scope)).toBeNull() + }) + + it('retries cleanup after the profile transition commits', async () => { + const userDataPath = await createLinkedProfile(cloud('org-a')) + const scope = shareScope('org-a') + createIntent(userDataPath, scope) + vi.mocked(clearArtifactCreateIntents).mockImplementationOnce(() => { + throw new Error('cleanup failed') + }) + + expect(() => linkOrcaProfileToCloud(profileId, cloud('org-b'), userDataPath)).toThrow( + 'cleanup failed' + ) + expect(currentCloud(userDataPath)?.activeOrgId).toBe('org-b') + expect(getArtifactCreateIntent(profileId, userDataPath, '/report.md', scope)).not.toBeNull() + + linkOrcaProfileToCloud(profileId, cloud('org-b'), userDataPath) + expect(getArtifactCreateIntent(profileId, userDataPath, '/report.md', scope)).toBeNull() + }) + + it('reconciles an interrupted transition before linking again', async () => { + const userDataPath = await createLinkedProfile(cloud('org-a')) + const scope = shareScope('org-a') + createIntent(userDataPath, scope) + interruptNextCleanupCommit() + + expect(() => linkOrcaProfileToCloud(profileId, cloud('org-b'), userDataPath)).toThrow( + 'cleanup commit interrupted' + ) + expect(currentCloud(userDataPath)?.activeOrgId).toBe('org-b') + vi.mocked(writeProfileIndex).mockClear() + vi.mocked(clearArtifactCreateIntents).mockImplementationOnce(() => { + throw new Error('cleanup failed') + }) + + expect(() => linkOrcaProfileToCloud(profileId, cloud('org-c'), userDataPath)).toThrow( + 'cleanup failed' + ) + expect(writeProfileIndex).not.toHaveBeenCalled() + expect(currentCloud(userDataPath)?.activeOrgId).toBe('org-b') + expect(getArtifactCreateIntent(profileId, userDataPath, '/report.md', scope)).not.toBeNull() + + linkOrcaProfileToCloud(profileId, cloud('org-c'), userDataPath) + expect(currentCloud(userDataPath)?.activeOrgId).toBe('org-c') + expect(getArtifactCreateIntent(profileId, userDataPath, '/report.md', scope)).toBeNull() + }) + + it('reconciles an interrupted transition before unlinking', async () => { + const userDataPath = await createLinkedProfile(cloud('org-a')) + const scope = shareScope('org-a') + createIntent(userDataPath, scope) + interruptNextCleanupCommit() + + expect(() => linkOrcaProfileToCloud(profileId, cloud('org-b'), userDataPath)).toThrow( + 'cleanup commit interrupted' + ) + expect(currentCloud(userDataPath)?.activeOrgId).toBe('org-b') + vi.mocked(writeProfileIndex).mockClear() + vi.mocked(clearArtifactCreateIntents).mockImplementationOnce(() => { + throw new Error('cleanup failed') + }) + + expect(() => unlinkOrcaProfileFromCloud(profileId, userDataPath)).toThrow('cleanup failed') + expect(writeProfileIndex).not.toHaveBeenCalled() + expect(currentCloud(userDataPath)?.activeOrgId).toBe('org-b') + expect(getArtifactCreateIntent(profileId, userDataPath, '/report.md', scope)).not.toBeNull() + + unlinkOrcaProfileFromCloud(profileId, userDataPath) + expect(currentCloud(userDataPath)).toBeUndefined() + expect(getArtifactCreateIntent(profileId, userDataPath, '/report.md', scope)).toBeNull() + }) + + it('preserves an orphaned local marker for an unknown profile', async () => { + const userDataPath = await createLinkedProfile(cloud('org-a')) + const orphanProfileId = 'missing-profile' + const scope = shareScope('org-a') + mkdirSync(getOrcaProfileDirectory(orphanProfileId, userDataPath), { recursive: true }) + createIntent(userDataPath, scope, orphanProfileId) + prepareArtifactCloudCleanup(orphanProfileId, userDataPath, undefined) + + const transitions = [ + () => linkOrcaProfileToCloud(orphanProfileId, cloud('org-b'), userDataPath), + () => unlinkOrcaProfileFromCloud(orphanProfileId, userDataPath) + ] + for (const transition of transitions) { + expect(transition).toThrow('unknown_orca_profile') + expect(artifactCloudCleanupNeedsCommit(orphanProfileId, userDataPath, undefined)).toBe(true) + expect( + getArtifactCreateIntent(orphanProfileId, userDataPath, '/report.md', scope) + ).not.toBeNull() + } + }) + + it('cleans old recovery state when the active organization changes', async () => { + const userDataPath = await createLinkedProfile(cloud('org-a')) + const scope = shareScope('org-a') + createIntent(userDataPath, scope) + + linkOrcaProfileToCloud(profileId, cloud('org-b'), userDataPath) + + expect(getArtifactCreateIntent(profileId, userDataPath, '/report.md', scope)).toBeNull() + }) + + it('blocks artifact use until a visible transition is durably committed', async () => { + const cloudSummary = cloud('org-a') + const userDataPath = await createLinkedProfile(cloudSummary) + prepareArtifactCloudCleanup(profileId, userDataPath, cloudSummary) + + expect(() => + prepareArtifactCloudUse({ id: profileId, cloud: cloudSummary }, userDataPath) + ).toThrow(/transition must be retried/) + }) +}) + +function cloud(activeOrgId: string): OrcaProfileCloudSummary { + return { + cloudProfileId: 'cloud-profile-a', + userId: 'user-a', + email: 'user@example.com', + activeOrgId, + linkedAt: 1 + } +} + +function shareScope(cloudOrganizationId: string): ArtifactShareScope { + return { + cloudUserId: 'user-a', + cloudProfileId: 'cloud-profile-a', + cloudOrganizationId, + apiOrigin: 'https://share.onorca.dev' + } +} + +async function createLinkedProfile(cloudSummary: OrcaProfileCloudSummary): Promise { + const userDataPath = await mkdtemp(join(tmpdir(), 'orca-profile-artifact-cleanup-')) + createdPaths.push(userDataPath) + loadOrCreateProfileIndex(userDataPath) + linkOrcaProfileToCloud(profileId, cloudSummary, userDataPath) + vi.clearAllMocks() + return userDataPath +} + +function createIntent( + userDataPath: string, + scope: ArtifactShareScope, + targetProfileId = profileId +): void { + getOrCreateArtifactCreateIntent(targetProfileId, userDataPath, '/report.md', scope, 'key-a', { + content: '# report', + contentType: 'text/markdown', + fileName: 'report.md' + }) +} + +function currentCloud(userDataPath: string): OrcaProfileCloudSummary | undefined { + return readProfileIndex(getOrcaProfileIndexPath(userDataPath))?.profiles.find( + (profile) => profile.id === profileId + )?.cloud +} + +function interruptNextCleanupCommit(): void { + vi.mocked(commitArtifactCloudCleanup).mockImplementationOnce(() => { + throw new Error('cleanup commit interrupted') + }) +} diff --git a/src/main/orca-profiles/profile-artifact-cloud-cleanup.ts b/src/main/orca-profiles/profile-artifact-cloud-cleanup.ts new file mode 100644 index 00000000000..88e90471914 --- /dev/null +++ b/src/main/orca-profiles/profile-artifact-cloud-cleanup.ts @@ -0,0 +1,131 @@ +import { existsSync, readFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import type { OrcaProfileCloudSummary, OrcaProfileSummary } from '../../shared/orca-profiles' +import { bestEffortFsyncDirectorySync, writeDurableSecureJsonFile } from '../../shared/secure-file' +import { clearArtifactCreateIntents } from '../artifacts/artifact-create-intent-store' +import { clearArtifactShareRecords } from '../artifacts/artifact-share-record-store' +import { getOrcaProfileDirectory } from './profile-storage-paths' + +type ArtifactCloudCleanupMarker = { + version: 1 + phase: 'prepared' | 'committed' + targetIdentity: string +} + +function cleanupMarkerPath(profileId: string, userDataPath: string): string { + return join(getOrcaProfileDirectory(profileId, userDataPath), 'artifact-cloud-cleanup.json') +} + +export function artifactCloudIdentity(cloud: OrcaProfileCloudSummary | undefined): string { + return cloud + ? JSON.stringify([cloud.userId, cloud.cloudProfileId, cloud.activeOrgId ?? '']) + : 'local' +} + +export function prepareArtifactCloudCleanup( + profileId: string, + userDataPath: string, + targetCloud: OrcaProfileCloudSummary | undefined +): void { + writeDurableSecureJsonFile(cleanupMarkerPath(profileId, userDataPath), { + version: 1, + phase: 'prepared', + targetIdentity: artifactCloudIdentity(targetCloud) + } satisfies ArtifactCloudCleanupMarker) +} + +function readCleanupMarker( + profileId: string, + userDataPath: string +): ArtifactCloudCleanupMarker | null { + const path = cleanupMarkerPath(profileId, userDataPath) + if (!existsSync(path)) { + return null + } + let parsed: unknown + try { + parsed = JSON.parse(readFileSync(path, 'utf8')) + } catch (error) { + throw new Error('Artifact cloud cleanup marker could not be read safely.', { cause: error }) + } + if ( + !parsed || + typeof parsed !== 'object' || + (parsed as Partial).version !== 1 || + !['prepared', 'committed'].includes( + (parsed as Partial).phase ?? '' + ) || + typeof (parsed as Partial).targetIdentity !== 'string' + ) { + throw new Error('Artifact cloud cleanup marker has an unsupported format.') + } + return parsed as ArtifactCloudCleanupMarker +} + +export function artifactCloudCleanupNeedsCommit( + profileId: string, + userDataPath: string, + targetCloud: OrcaProfileCloudSummary | undefined +): boolean { + const marker = readCleanupMarker(profileId, userDataPath) + return ( + marker?.phase === 'prepared' && marker.targetIdentity === artifactCloudIdentity(targetCloud) + ) +} + +export function commitArtifactCloudCleanup( + profileId: string, + userDataPath: string, + targetCloud: OrcaProfileCloudSummary | undefined +): void { + const targetIdentity = artifactCloudIdentity(targetCloud) + const marker = readCleanupMarker(profileId, userDataPath) + if (marker?.phase !== 'prepared' || marker.targetIdentity !== targetIdentity) { + throw new Error('Artifact cloud cleanup marker does not match the profile transition.') + } + writeDurableSecureJsonFile(cleanupMarkerPath(profileId, userDataPath), { + version: 1, + phase: 'committed', + targetIdentity + } satisfies ArtifactCloudCleanupMarker) +} + +export function completeArtifactCloudCleanupIfCommitted( + profileId: string, + userDataPath: string, + currentCloud: OrcaProfileCloudSummary | undefined +): void { + const marker = readCleanupMarker(profileId, userDataPath) + if ( + marker?.phase !== 'committed' || + marker.targetIdentity !== artifactCloudIdentity(currentCloud) + ) { + return + } + clearArtifactCreateIntents(profileId, userDataPath) + clearArtifactShareRecords(profileId, userDataPath) + rmSync(cleanupMarkerPath(profileId, userDataPath), { force: true }) + bestEffortFsyncDirectorySync(getOrcaProfileDirectory(profileId, userDataPath)) +} + +function assertArtifactCloudCleanupReady( + profileId: string, + userDataPath: string, + currentCloud: OrcaProfileCloudSummary | undefined +): void { + const marker = readCleanupMarker(profileId, userDataPath) + if ( + marker?.phase === 'prepared' && + marker.targetIdentity === artifactCloudIdentity(currentCloud) + ) { + throw new Error('The Orca profile transition must be retried before publishing artifacts.') + } +} + +export function prepareArtifactCloudUse( + profile: Pick, + userDataPath: string +): void { + completeArtifactCloudCleanupIfCommitted(profile.id, userDataPath, profile.cloud) + assertArtifactCloudCleanupReady(profile.id, userDataPath, profile.cloud) +} diff --git a/src/main/orca-profiles/profile-cloud-index.ts b/src/main/orca-profiles/profile-cloud-index.ts index 7186ddd2a63..ccf7e70adb9 100644 --- a/src/main/orca-profiles/profile-cloud-index.ts +++ b/src/main/orca-profiles/profile-cloud-index.ts @@ -11,7 +11,12 @@ import { loadOrCreateProfileIndex, writeProfileIndex } from './profile-index-store' -import { clearArtifactShareRecords } from '../artifacts/artifact-share-record-store' +import { + artifactCloudCleanupNeedsCommit, + commitArtifactCloudCleanup, + completeArtifactCloudCleanupIfCommitted, + prepareArtifactCloudCleanup +} from './profile-artifact-cloud-cleanup' export type CreateCloudLinkedOrcaProfileRecordResult = OrcaProfileListState & { profile: OrcaProfileSummary @@ -50,6 +55,19 @@ function toLocalProfile(profile: OrcaProfileSummary, now: number): OrcaProfileSu } } +function reconcileCurrentArtifactCloudCleanup( + profileId: string, + userDataPath: string, + currentCloud: OrcaProfileCloudSummary | undefined +): void { + completeArtifactCloudCleanupIfCommitted(profileId, userDataPath, currentCloud) + if (!artifactCloudCleanupNeedsCommit(profileId, userDataPath, currentCloud)) { + return + } + commitArtifactCloudCleanup(profileId, userDataPath, currentCloud) + completeArtifactCloudCleanupIfCommitted(profileId, userDataPath, currentCloud) +} + export function createCloudLinkedOrcaProfileRecord( cloud: OrcaProfileCloudSummary, args: { name?: string }, @@ -92,6 +110,12 @@ export function linkOrcaProfileToCloud( userDataPath: string ): OrcaProfileListState { const index = loadOrCreateProfileIndex(userDataPath) + const currentProfile = index.profiles.find((profile) => profile.id === profileId) + if (!currentProfile) { + throw new Error('unknown_orca_profile') + } + reconcileCurrentArtifactCloudCleanup(profileId, userDataPath, currentProfile.cloud) + const cleanupNeedsCommit = artifactCloudCleanupNeedsCommit(profileId, userDataPath, cloud) const now = Date.now() let found = false let cloudIdentityChanged = false @@ -103,21 +127,26 @@ export function linkOrcaProfileToCloud( cloudIdentityChanged = Boolean( profile.cloud && (profile.cloud.userId !== cloud.userId || - profile.cloud.cloudProfileId !== cloud.cloudProfileId) + profile.cloud.cloudProfileId !== cloud.cloudProfileId || + (profile.cloud.activeOrgId ?? '') !== (cloud.activeOrgId ?? '')) ) return toCloudLinkedProfile(profile, cloud, now) }) if (!found) { throw new Error('unknown_orca_profile') } - if (cloudIdentityChanged) { - clearArtifactShareRecords(profileId, userDataPath) + if (cloudIdentityChanged || cleanupNeedsCommit) { + prepareArtifactCloudCleanup(profileId, userDataPath, cloud) } const nextIndex = { ...index, profiles } writeProfileIndex(getOrcaProfileIndexPath(userDataPath), nextIndex) + if (cloudIdentityChanged || cleanupNeedsCommit) { + commitArtifactCloudCleanup(profileId, userDataPath, cloud) + completeArtifactCloudCleanupIfCommitted(profileId, userDataPath, cloud) + } return { activeProfileId: nextIndex.activeProfileId, profiles: nextIndex.profiles @@ -129,6 +158,11 @@ export function unlinkOrcaProfileFromCloud( userDataPath: string ): OrcaProfileListState { const index = loadOrCreateProfileIndex(userDataPath) + const currentProfile = index.profiles.find((profile) => profile.id === profileId) + if (!currentProfile) { + throw new Error('unknown_orca_profile') + } + reconcileCurrentArtifactCloudCleanup(profileId, userDataPath, currentProfile.cloud) const now = Date.now() let found = false const profiles = index.profiles.map((profile) => { @@ -141,12 +175,14 @@ export function unlinkOrcaProfileFromCloud( if (!found) { throw new Error('unknown_orca_profile') } - clearArtifactShareRecords(profileId, userDataPath) + prepareArtifactCloudCleanup(profileId, userDataPath, undefined) const nextIndex = { ...index, profiles } writeProfileIndex(getOrcaProfileIndexPath(userDataPath), nextIndex) + commitArtifactCloudCleanup(profileId, userDataPath, undefined) + completeArtifactCloudCleanupIfCommitted(profileId, userDataPath, undefined) return { activeProfileId: nextIndex.activeProfileId, profiles: nextIndex.profiles diff --git a/src/main/orca-profiles/profile-index-store.ts b/src/main/orca-profiles/profile-index-store.ts index 903e7a85ff8..8c1b4490f45 100644 --- a/src/main/orca-profiles/profile-index-store.ts +++ b/src/main/orca-profiles/profile-index-store.ts @@ -8,6 +8,7 @@ import { } from 'node:fs' import { randomUUID } from 'node:crypto' import { dirname } from 'node:path' +import { bestEffortFsyncDirectorySync, fsyncFileSync } from '../../shared/secure-file' import type { GlobalSettings } from '../../shared/types' import { createDefaultLocalOrcaProfile, @@ -130,7 +131,9 @@ export function writeProfileIndex(indexPath: string, index: OrcaProfileIndex): v } const tmpPath = `${indexPath}.tmp` writeFileSync(tmpPath, JSON.stringify(index, null, 2), 'utf-8') + fsyncFileSync(tmpPath) renameSync(tmpPath, indexPath) + bestEffortFsyncDirectorySync(dirname(indexPath)) } function copyIfPresent(source: string, target: string): void { diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 240c79c4dbe..86de2ab0685 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -5622,6 +5622,42 @@ describe('Store', () => { expect(persisted.settings).not.toHaveProperty('terminalScrollbackBytes') }) + it('normalizes terminal cursor style before persistence and listener broadcasts', async () => { + const store = await createStore() + store.updateSettings({ terminalCursorStyle: 'underline' }) + const listener = vi.fn() + store.onSettingsChanged(listener) + + const invalid = store.updateSettings( + { terminalCursorStyle: 'beam' as never }, + { notifyListeners: true } + ) + + expect(invalid.terminalCursorStyle).toBe('block') + expect(invalid.terminalCursorStyleDefaultedToBlock).toBe(true) + expect(listener).toHaveBeenCalledWith( + { + terminalCursorStyle: 'block' + }, + expect.objectContaining({ terminalCursorStyle: 'block' }), + undefined + ) + + const valid = store.updateSettings( + { terminalCursorStyle: 'underline' }, + { notifyListeners: true } + ) + expect(valid.terminalCursorStyle).toBe('underline') + expect(listener).toHaveBeenLastCalledWith( + { terminalCursorStyle: 'underline' }, + expect.objectContaining({ terminalCursorStyle: 'underline' }), + undefined + ) + + store.flush() + expect((readDataFile() as PersistedState).settings.terminalCursorStyle).toBe('underline') + }) + it('normalizes disabled TUI agents on load and update', async () => { writeFileSync( join(testState.dir, 'orca-data.json'), @@ -7319,6 +7355,22 @@ describe('Store', () => { expect(store.getSettings().terminalCursorStyleDefaultedToBlock).toBe(true) }) + it('replaces an invalid persisted terminal cursor choice after migration', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { terminalCursorStyle: 'beam', terminalCursorStyleDefaultedToBlock: true }, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + const store = await createStore() + expect(store.getSettings().terminalCursorStyle).toBe('block') + store.flush() + expect((readDataFile() as PersistedState).settings.terminalCursorStyle).toBe('block') + }) + it('preserves explicit "false" terminalMacOptionAsAlt through migration', async () => { // 'false' never matched the old default — it was an explicit choice. writeDataFile({ diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 1baa79580f6..e772d82f555 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -3245,6 +3245,13 @@ export class Store { parsed.settings ) const migratedTerminalCursorStyle = normalizeTerminalCursorStyleDefault(parsed.settings) + if ( + parsed.settings?.terminalCursorStyle !== + migratedTerminalCursorStyle.terminalCursorStyle || + parsed.settings?.terminalCursorStyleDefaultedToBlock !== true + ) { + this.loadNeedsSave = true + } const migratedTerminalLineHeight = normalizeTerminalLineHeight( parsed.settings?.terminalLineHeight ) @@ -4478,6 +4485,7 @@ export class Store { linkedTask?: FolderWorkspace['linkedTask'] linkedTaskSourceContext?: FolderWorkspace['linkedTaskSourceContext'] connectionId?: string | null + creatorProvenance?: FolderWorkspace['creatorProvenance'] createdWithAgent?: FolderWorkspace['createdWithAgent'] pendingFirstAgentMessageRename?: boolean }): FolderWorkspace { @@ -4500,6 +4508,7 @@ export class Store { name: normalizeFolderWorkspaceName(input.name, `${group.name} workspace`), folderPath, connectionId: input.connectionId ?? group.connectionId ?? null, + ...(input.creatorProvenance ? { creatorProvenance: input.creatorProvenance } : {}), linkedTask, linkedTaskSourceContext: isWorkspaceLinkedItemSourceContextMatch(linkedTask, sourceContext) ? sourceContext @@ -5844,6 +5853,15 @@ export class Store { updates.terminalCustomThemes ) } + if ('terminalCursorStyle' in updates) { + Object.assign( + sanitizedUpdates, + normalizeTerminalCursorStyleDefault( + { terminalCursorStyle: updates.terminalCursorStyle }, + { preserveExplicitValue: true } + ) + ) + } if ('terminalScrollbackRows' in updates) { sanitizedUpdates.terminalScrollbackRows = normalizeDesktopTerminalScrollbackRows( updates.terminalScrollbackRows diff --git a/src/main/plugins/plugin-git-repository.ts b/src/main/plugins/plugin-git-repository.ts index e8870f9e7fe..ac786273fc1 100644 --- a/src/main/plugins/plugin-git-repository.ts +++ b/src/main/plugins/plugin-git-repository.ts @@ -1,26 +1,17 @@ -import { execFile } from 'node:child_process' -import { promisify } from 'node:util' import { isAllowedPluginGitUrl, PLUGIN_COMMIT_PATTERN } from '../../shared/plugins/plugin-install-lockfile' +import { gitExecFileAsync } from '../git/runner' -const execFileAsync = promisify(execFile) const PLUGIN_GIT_TIMEOUT_MS = 120_000 /** Runs system Git with argv-only invocation so credential helpers and SSH * remotes work without exposing an executable remote-helper surface. */ export async function runPluginGit(args: string[], cwd: string): Promise { - const { stdout } = await execFileAsync('git', args, { + const { stdout } = await gitExecFileAsync(args, { cwd, - timeout: PLUGIN_GIT_TIMEOUT_MS, - windowsHide: true, - env: { - ...process.env, - // Existing non-interactive helpers and SSH agents still work, but a - // background marketplace refresh can never hang on a terminal prompt. - GIT_TERMINAL_PROMPT: '0' - } + timeout: PLUGIN_GIT_TIMEOUT_MS }) return stdout.trim() } diff --git a/src/main/providers/git-provider-contract.ts b/src/main/providers/git-provider-contract.ts index c204b9d9016..d13bf5addf9 100644 --- a/src/main/providers/git-provider-contract.ts +++ b/src/main/providers/git-provider-contract.ts @@ -66,7 +66,7 @@ export type IGitProvider = { getBranchDiff( worktreePath: string, baseRef: string, - options?: { includePatch?: boolean; filePath?: string; oldPath?: string } + options?: { includePatch?: boolean; filePath?: string; oldPath?: string; headOid?: string } ): Promise getCommitDiff( worktreePath: string, diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index a7b675e78da..03e7127ce4c 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -14,7 +14,8 @@ const { resolveAgentForegroundProcessMock, readWindowsConptyProcessIdsMock, killWithDescendantSweepMock, - isWslAvailableAsyncMock + isWslAvailableAsyncMock, + wslUncDirectoryExistsMock } = vi.hoisted(() => ({ existsSyncMock: vi.fn(), statSyncMock: vi.fn(), @@ -26,7 +27,8 @@ const { resolveAgentForegroundProcessMock: vi.fn(), readWindowsConptyProcessIdsMock: vi.fn(), killWithDescendantSweepMock: vi.fn(), - isWslAvailableAsyncMock: vi.fn() + isWslAvailableAsyncMock: vi.fn(), + wslUncDirectoryExistsMock: vi.fn() })) vi.mock('fs', () => ({ @@ -102,7 +104,7 @@ vi.mock('../wsl', () => ({ isWslAvailableAsync: () => isWslAvailableAsyncMock(), // Why: WSL worktree validation now asks the distro; these tests use WSL UNC // cwds that are meant to exist, so report them present without spawning wsl.exe. - wslUncDirectoryExists: () => true + wslUncDirectoryExists: (...args: unknown[]) => wslUncDirectoryExistsMock(...args) })) import { @@ -170,6 +172,8 @@ describe('LocalPtyProvider', () => { readWindowsConptyProcessIdsMock.mockResolvedValue(null) isWslAvailableAsyncMock.mockReset() isWslAvailableAsyncMock.mockResolvedValue(true) + wslUncDirectoryExistsMock.mockReset() + wslUncDirectoryExistsMock.mockReturnValue(true) exitCb = undefined mockProc = { @@ -978,29 +982,50 @@ describe('LocalPtyProvider', () => { expect(spawnCall[2].env.HISTFILE).toContain('terminal-history-wsl/Debian') }) - it('translates a POSIX cwd through the preferred WSL distro', async () => { + it.each(['/home/jin/repo', '/a', '/c'])( + 'preserves a POSIX cwd through the preferred WSL distro (%s)', + async (cwd) => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + await provider.spawn({ + cols: 80, + rows: 24, + worktreeId: 'repo-1::C:\\Users\\jin\\repo', + cwd, + prevalidatedCwd: `\\\\wsl.localhost\\Debian${cwd.replaceAll('/', '\\')}`, + shellOverride: 'wsl.exe', + terminalWindowsWslDistro: 'Debian' + }) + + const spawnCall = spawnMock.mock.calls.at(-1)! + expect(spawnCall[0]).toBe('wsl.exe') + expect(spawnCall[1]).toEqual([ + '-d', + 'Debian', + '--', + 'sh', + '-c', + expect.stringContaining(`cd '${cwd}'`) + ]) + expect(spawnCall[2].cwd).not.toBe(cwd) + expect(wslUncDirectoryExistsMock).not.toHaveBeenCalled() + } + ) + + it('revalidates when cwd evidence does not exactly match the resolved WSL path', async () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) await provider.spawn({ cols: 80, rows: 24, worktreeId: 'repo-1::C:\\Users\\jin\\repo', - cwd: '/home/jin/repo', + cwd: '/a', + prevalidatedCwd: '\\\\wsl.localhost\\Debian\\c', shellOverride: 'wsl.exe', terminalWindowsWslDistro: 'Debian' }) - const spawnCall = spawnMock.mock.calls.at(-1)! - expect(spawnCall[0]).toBe('wsl.exe') - expect(spawnCall[1]).toEqual([ - '-d', - 'Debian', - '--', - 'sh', - '-c', - expect.stringContaining("cd '/home/jin/repo'") - ]) - expect(spawnCall[2].cwd).not.toBe('/home/jin/repo') + expect(wslUncDirectoryExistsMock).toHaveBeenCalledWith('\\\\wsl.localhost\\Debian\\a') }) it('resolves and persists the default distro for Windows cwd WSL terminals', async () => { diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index d1ba44e5f2e..aafd04b9e1b 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -670,7 +670,9 @@ export class LocalPtyProvider implements IPtyProvider { } ensureNodePtySpawnHelperExecutable() - validateWorkingDirectory(validationCwd) + if (args.prevalidatedCwd !== validationCwd) { + validateWorkingDirectory(validationCwd) + } const spawnEnv: Record = { ...mergeGitConfigEnvProtocol(stripInheritedBuildModeEnv(process.env), args.env), diff --git a/src/main/providers/local-pty-utils-windows-fallback.test.ts b/src/main/providers/local-pty-utils-windows-fallback.test.ts index 0de16d03f17..a71537ba8b2 100644 --- a/src/main/providers/local-pty-utils-windows-fallback.test.ts +++ b/src/main/providers/local-pty-utils-windows-fallback.test.ts @@ -133,18 +133,28 @@ describe('spawnShellWithFallback on Windows', () => { const ptySpawn = vi.fn(() => { throw new Error(ACCESS_DENIED_5) }) as unknown as typeof pty.spawn + const previousVersion = process.env.ORCA_APP_VERSION + process.env.ORCA_APP_VERSION = '1.4.178-test' - expect(() => - spawnShellWithFallback({ - shellPath: PWSH7, - shellArgs: attempts[0].shellArgs, - cols: 80, - rows: 24, - cwd: 'C:\\repo', - env: {}, - ptySpawn, - windowsFallbackAttempts: attempts - }) - ).toThrow(/Failed to spawn shell/) + try { + expect(() => + spawnShellWithFallback({ + shellPath: PWSH7, + shellArgs: attempts[0].shellArgs, + cols: 80, + rows: 24, + cwd: 'C:\\repo', + env: {}, + ptySpawn, + windowsFallbackAttempts: attempts + }) + ).toThrow(/Failed to spawn shell.*orca: 1\.4\.178-test/) + } finally { + if (previousVersion === undefined) { + delete process.env.ORCA_APP_VERSION + } else { + process.env.ORCA_APP_VERSION = previousVersion + } + } }) }) diff --git a/src/main/providers/local-pty-utils.test.ts b/src/main/providers/local-pty-utils.test.ts index bc812e1a46a..6b4846617ca 100644 --- a/src/main/providers/local-pty-utils.test.ts +++ b/src/main/providers/local-pty-utils.test.ts @@ -93,9 +93,21 @@ describe('validateWorkingDirectory', () => { it('rejects a missing native Windows path', () => { existsSyncMock.mockReturnValue(false) + const previousVersion = process.env.ORCA_APP_VERSION + process.env.ORCA_APP_VERSION = '1.4.178-test' - expect(() => validateWorkingDirectory(NATIVE_DIR)).toThrow(/does not exist/) - expect(wslUncDirectoryExistsMock).not.toHaveBeenCalled() + try { + expect(() => validateWorkingDirectory(NATIVE_DIR)).toThrow( + /does not exist.*orca: 1\.4\.178-test/ + ) + expect(wslUncDirectoryExistsMock).not.toHaveBeenCalled() + } finally { + if (previousVersion === undefined) { + delete process.env.ORCA_APP_VERSION + } else { + process.env.ORCA_APP_VERSION = previousVersion + } + } }) it('rejects a native Windows path that exists but is not a directory', () => { diff --git a/src/main/providers/local-pty-utils.ts b/src/main/providers/local-pty-utils.ts index 3e21095d535..cb414d05a8e 100644 --- a/src/main/providers/local-pty-utils.ts +++ b/src/main/providers/local-pty-utils.ts @@ -1,5 +1,6 @@ import { basename, isAbsolute, join } from 'node:path' import { existsSync, accessSync, statSync, chmodSync, constants as fsConstants } from 'node:fs' +import { release } from 'node:os' import type * as pty from 'node-pty' import { isWslUncPath } from '../../shared/wsl-paths' import { wslUncDirectoryExists } from '../wsl' @@ -99,10 +100,26 @@ export function ensureNodePtySpawnHelperExecutable(): void { } } +function formatLocalPtyEnvironmentDiag(extra: Record = {}): string { + const systemVersion = + (process as NodeJS.Process & { getSystemVersion?: () => string }).getSystemVersion?.() || + release() + const parts = { + ...extra, + arch: process.arch, + platform: `${process.platform} ${systemVersion}`, + orca: process.env.ORCA_APP_VERSION?.trim() || '0.0.0-dev' + } + return Object.entries(parts) + .map(([key, value]) => `${key}: ${value}`) + .join(', ') +} + function throwMissingWorkingDirectory(cwd: string): never { throw new Error( `Working directory "${cwd}" does not exist. ` + - `It may have been deleted or is on an unmounted volume.` + `It may have been deleted or is on an unmounted volume ` + + `(${formatLocalPtyEnvironmentDiag({ cwd })}).` ) } @@ -305,12 +322,7 @@ export function spawnShellWithFallback(params: ShellSpawnParams): ShellSpawnResu } } - const diag = [ - `shell: ${shellPath}`, - `cwd: ${cwd}`, - `arch: ${process.arch}`, - `platform: ${process.platform} ${process.getSystemVersion?.() ?? ''}` - ].join(', ') + const diag = formatLocalPtyEnvironmentDiag({ shell: shellPath, cwd }) throw new Error( `Failed to spawn shell "${shellPath}": ${primaryError ?? 'unknown error'} (${diag}). ` + `If this persists, please file an issue.` diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index 398729efe17..e1017cab2c4 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -38,6 +38,8 @@ export type PtySpawnOptions = { cols: number rows: number cwd?: string + /** Exact per-spawn cwd already proven by main; providers validate any other resolved path. */ + prevalidatedCwd?: string env?: Record envToDelete?: string[] /** Main-validated home provenance for an automatic Codex session resume. */ diff --git a/src/main/providers/ssh-git-provider.test.ts b/src/main/providers/ssh-git-provider.test.ts index c0613f35f2e..20ebd9293a1 100644 --- a/src/main/providers/ssh-git-provider.test.ts +++ b/src/main/providers/ssh-git-provider.test.ts @@ -1081,6 +1081,125 @@ describe('SshGitProvider', () => { expect(result).toEqual(diffs) }) + it('getBranchDiff forwards a pinned head OID without adding it when omitted', async () => { + const headOid = 'a'.repeat(40) + mux.request.mockResolvedValue([]) + + await provider.getBranchDiff('/home/user/repo', 'main', { + includePatch: true, + filePath: 'src/file.ts', + headOid + }) + await provider.getBranchDiff('/home/user/repo', 'main', { + includePatch: true, + filePath: 'src/other-file.ts', + headOid: undefined + }) + + expect(mux.request).toHaveBeenNthCalledWith(1, 'git.branchDiff', { + worktreePath: '/home/user/repo', + baseRef: 'main', + includePatch: true, + filePath: 'src/file.ts', + headOid, + __streamResponse: true + }) + expect(mux.request).toHaveBeenNthCalledWith(2, 'git.branchDiff', { + worktreePath: '/home/user/repo', + baseRef: 'main', + includePatch: true, + filePath: 'src/other-file.ts', + __streamResponse: true + }) + }) + + // Why: an unpinned compare snapshot reaches getBranchDiff as null despite the type. + it('omits a null head OID and shares the absent-head dedupe key', async () => { + const diffs = [{ kind: 'text', originalContent: 'old', modifiedContent: 'new' }] + const pendingDiff = deferredValue(diffs) + const headOid = 'a'.repeat(40) + mux.request.mockReturnValue(pendingDiff.promise) + + const reads = [ + provider.getBranchDiff('/home/user/repo', 'main', { + includePatch: true, + filePath: 'src/file.ts', + headOid: null as unknown as undefined + }), + provider.getBranchDiff('/home/user/repo', 'main', { + includePatch: true, + filePath: 'src/file.ts' + }), + provider.getBranchDiff('/home/user/repo', 'main', { + includePatch: true, + filePath: 'src/file.ts', + headOid + }) + ] + + await waitForRequestCount(mux.request, 2) + expect(mux.request).toHaveBeenCalledTimes(2) + expect(mux.request).toHaveBeenNthCalledWith(1, 'git.branchDiff', { + worktreePath: '/home/user/repo', + baseRef: 'main', + includePatch: true, + filePath: 'src/file.ts', + __streamResponse: true + }) + expect(mux.request).toHaveBeenNthCalledWith(2, 'git.branchDiff', { + worktreePath: '/home/user/repo', + baseRef: 'main', + includePatch: true, + filePath: 'src/file.ts', + headOid, + __streamResponse: true + }) + + pendingDiff.resolve() + await expect(Promise.all(reads)).resolves.toEqual(Array.from({ length: 3 }, () => diffs)) + }) + + it('coalesces matching branch diff heads while keeping distinct and absent heads separate', async () => { + const diffs = [{ kind: 'text', originalContent: 'old', modifiedContent: 'new' }] + const pendingDiff = deferredValue(diffs) + const firstHeadOid = 'a'.repeat(40) + const secondHeadOid = 'b'.repeat(40) + mux.request.mockReturnValue(pendingDiff.promise) + + const reads = [ + provider.getBranchDiff('/home/user/repo', 'main', { + includePatch: true, + filePath: 'src/file.ts', + headOid: firstHeadOid + }), + provider.getBranchDiff('/home/user/repo', 'main', { + filePath: 'src/file.ts', + headOid: firstHeadOid, + includePatch: true + }), + provider.getBranchDiff('/home/user/repo', 'main', { + includePatch: true, + filePath: 'src/file.ts', + headOid: secondHeadOid + }), + provider.getBranchDiff('/home/user/repo', 'main', { + includePatch: true, + filePath: 'src/file.ts' + }), + provider.getBranchDiff('/home/user/repo', 'main', { + includePatch: true, + filePath: 'src/file.ts', + headOid: undefined + }) + ] + + await waitForRequestCount(mux.request, 3) + expect(mux.request).toHaveBeenCalledTimes(3) + + pendingDiff.resolve() + await expect(Promise.all(reads)).resolves.toEqual(Array.from({ length: 5 }, () => diffs)) + }) + it('coalesces concurrent identical diff RPCs while in flight', async () => { const diff = { kind: 'text', diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index e4c306529ee..51e2b91e1c2 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -41,6 +41,7 @@ type NonInteractiveExecQueueEntry = { } const NON_INTERACTIVE_TRANSPORT_TIMEOUT_MARGIN_MS = 5_000 +const ABSENT_BRANCH_DIFF_HEAD_OID = { absent: true } as const function isJsonRpcMethodNotFoundError(error: unknown): boolean { if (!error || typeof error !== 'object') { @@ -658,9 +659,14 @@ export class SshGitProvider implements IGitProvider { async getBranchDiff( worktreePath: string, baseRef: string, - options?: { includePatch?: boolean; filePath?: string; oldPath?: string } + options?: { includePatch?: boolean; filePath?: string; oldPath?: string; headOid?: string } ): Promise { const keyOptions = options ?? {} + const { headOid: rawHeadOid, ...relayOptions } = keyOptions + // Why: compare snapshots type headOid as `string | null`, so collapse an + // unpinned null to absent instead of putting a field on the wire that a + // pinned-OID relay must reject. + const headOid = rawHeadOid == null ? undefined : rawHeadOid return this.gitDiffReadDedupe.run( stableInFlightKey([ 'branchDiff', @@ -668,13 +674,15 @@ export class SshGitProvider implements IGitProvider { baseRef, keyOptions.includePatch ?? null, keyOptions.filePath ?? null, - keyOptions.oldPath ?? null + keyOptions.oldPath ?? null, + headOid === undefined ? ABSENT_BRANCH_DIFF_HEAD_OID : headOid ]), async () => (await requestGitStreamable(this.mux, 'git.branchDiff', { worktreePath, baseRef, - ...options + ...relayOptions, + ...(headOid === undefined ? {} : { headOid }) })) as GitDiffResult[] ) as Promise } diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index 53a83e6638d..c3bead6c5b2 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -219,7 +219,7 @@ describe('resolveWindowsShellLaunchArgs', () => { it('starts Git Bash as an interactive login shell with UTF-8 console setup', () => { const result = resolveWindowsShellLaunchArgs( 'C:\\Program Files\\Git\\bin\\bash.exe', - 'C:\\Users\\alice\\code', + '/c', 'C:\\Users\\alice' ) @@ -237,8 +237,8 @@ describe('resolveWindowsShellLaunchArgs', () => { // Must stay fail-open: `;` (not `&&`) so a missing chcp.com can't abort the // exec and kill the terminal on startup. expect(bashCommand).not.toContain('&&') - expect(result.effectiveCwd).toBe('C:\\Users\\alice\\code') - expect(result.validationCwd).toBe('C:\\Users\\alice\\code') + expect(result.effectiveCwd).toBe('C:\\') + expect(result.validationCwd).toBe('C:\\') }) it('does not apply Git Bash launch args to unrelated bash.exe paths', () => { @@ -307,17 +307,15 @@ describe('resolveWindowsShellLaunchArgs', () => { expect(result.validationCwd).toBe('C:\\Users\\alice\\project') }) - it('does not treat MSYS drive cwd as a WSL POSIX cwd', () => { - const result = resolveWindowsShellLaunchArgs( - 'wsl.exe', - '/c/Users/alice/project', - 'C:\\Users\\alice', - { distro: 'Ubuntu', treatPosixCwdAsWsl: true } - ) + it.each(['/a', '/c'])('keeps a selected WSL runtime single-letter cwd exact (%s)', (cwd) => { + const result = resolveWindowsShellLaunchArgs('wsl.exe', cwd, 'C:\\Users\\alice', { + distro: 'Ubuntu', + treatPosixCwdAsWsl: true + }) - expect(result.shellArgs).toEqual(expectedWslArgs('/mnt/c/Users/alice/project', 'Ubuntu')) + expect(result.shellArgs).toEqual(expectedWslArgs(cwd, 'Ubuntu')) expect(result.effectiveCwd).toBe('C:\\Users\\alice') - expect(result.validationCwd).toBe('C:\\Users\\alice\\project') + expect(result.validationCwd).toBe(`\\\\wsl.localhost\\Ubuntu\\${cwd.slice(1)}`) }) it('escapes single quotes when translating a WSL cwd', () => { diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index bde8a872efb..772ef8d9f05 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -131,7 +131,8 @@ function buildWslShellArgs(linuxCwd: string, distro?: string): string[] { return distro ? ['-d', distro, ...shellArgs] : shellArgs } -function normalizeMsysDrivePath(cwd: string): string { +/** Converts an MSYS drive spelling to the native cwd used by Windows terminal processes. */ +export function normalizeWindowsTerminalCwd(cwd: string): string { const match = cwd.match(/^\/([A-Za-z])(?:\/(.*))?$/) if (!match) { return cwd @@ -161,8 +162,7 @@ export function resolveWindowsShellLaunchArgs( startupCommand?: string ): WindowsShellLaunchArgs { const shellBasename = pathWin32.basename(shellPath).toLowerCase() - const nativeCwd = normalizeMsysDrivePath(cwd) - const isMsysDriveCwd = nativeCwd !== cwd + const nativeCwd = normalizeWindowsTerminalCwd(cwd) if (shellBasename === 'cmd.exe') { const shellArgStartupCommand = getCmdShellArgStartupCommand(startupCommand) @@ -210,7 +210,7 @@ export function resolveWindowsShellLaunchArgs( validationCwd: cwd } } - if (wslContext?.treatPosixCwdAsWsl && cwd.startsWith('/') && !isMsysDriveCwd) { + if (wslContext?.treatPosixCwdAsWsl && cwd.startsWith('/')) { return { shellArgs: buildWslShellArgs(cwd, wslContext.distro), effectiveCwd: defaultCwd, diff --git a/src/main/pty/windows-environment-path-main-loop.test.ts b/src/main/pty/windows-environment-path-main-loop.test.ts new file mode 100644 index 00000000000..fbe185479cc --- /dev/null +++ b/src/main/pty/windows-environment-path-main-loop.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + __resetPersistedWindowsPathCacheForTests, + readPersistedWindowsPathSegments +} from './windows-environment-path' +import { __setWindowsPathRegistryLoaderForTests } from './windows-path-registry-reader' + +const CREATE_PROCESS_DELAY_MS = 160 +const delayedExecFileSync = vi.hoisted(() => + vi.fn(() => { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, CREATE_PROCESS_DELAY_MS) + return ' Path REG_EXPAND_SZ C:\\Delayed' + }) +) + +vi.mock('node:child_process', () => ({ + execFile: vi.fn(), + execFileSync: delayedExecFileSync +})) + +describe('persisted Windows PATH process creation', () => { + afterEach(() => { + __resetPersistedWindowsPathCacheForTests() + __setWindowsPathRegistryLoaderForTests() + delayedExecFileSync.mockClear() + }) + + async function measureMainLoopGap(run: () => T): Promise<{ + callMs: number + maxGapMs: number + result: T + }> { + let lastTick = performance.now() + let maxGapMs = 0 + const timer = setInterval(() => { + const now = performance.now() + maxGapMs = Math.max(maxGapMs, now - lastTick - 5) + lastTick = now + }, 5) + await new Promise((resolve) => setTimeout(resolve, 20)) + + const startedAt = performance.now() + const result = run() + const callMs = performance.now() - startedAt + await new Promise((resolve) => setTimeout(resolve, 20)) + clearInterval(timer) + return { callMs, maxGapMs, result } + } + + it('proves delayed process creation blocks while native registry reads create no process', async () => { + const legacy = await measureMainLoopGap(() => + readPersistedWindowsPathSegments({ + platform: 'win32', + env: { SystemRoot: 'C:\\Windows' }, + execFileSync: delayedExecFileSync as never + }) + ) + + expect(legacy.result).toEqual(['C:\\Delayed', 'C:\\Delayed']) + expect(delayedExecFileSync).toHaveBeenCalledTimes(2) + expect(legacy.callMs).toBeGreaterThanOrEqual(CREATE_PROCESS_DELAY_MS * 2) + expect(legacy.maxGapMs).toBeGreaterThanOrEqual(CREATE_PROCESS_DELAY_MS) + + __resetPersistedWindowsPathCacheForTests() + delayedExecFileSync.mockClear() + __setWindowsPathRegistryLoaderForTests(() => ({ + HK: { LM: 1, CU: 2 }, + getRegistryKey: (root) => ({ + Path: { type: 1, value: root === 1 ? 'C:\\Machine' : 'C:\\User' } + }) + })) + const native = readPersistedWindowsPathSegments({ + platform: 'win32', + env: { SystemRoot: 'C:\\Windows' } + }) + + expect(native).toEqual(['C:\\Machine', 'C:\\User']) + expect(delayedExecFileSync).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/pty/windows-environment-path.test.ts b/src/main/pty/windows-environment-path.test.ts index d639ee604ce..e1ace3350ca 100644 --- a/src/main/pty/windows-environment-path.test.ts +++ b/src/main/pty/windows-environment-path.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { defaultExecFileMock, defaultExecFileSyncMock } = vi.hoisted(() => ({ defaultExecFileMock: vi.fn(), @@ -18,8 +18,26 @@ import { readPersistedWindowsPathSegmentsAsync, resolvePathEnvKey } from './windows-environment-path' +import { __setWindowsPathRegistryLoaderForTests } from './windows-path-registry-reader' -type ExecCallback = (error: Error | null, stdout: string, stderr: string) => void +const registryGetKeyMock = vi.fn() + +function registryPath(value: string): Record { + return { Path: { type: 1, value } } +} + +beforeEach(() => { + registryGetKeyMock.mockReset() + __setWindowsPathRegistryLoaderForTests(() => ({ + HK: { LM: 1, CU: 2 }, + getRegistryKey: registryGetKeyMock + })) +}) + +afterEach(() => { + __resetPersistedWindowsPathCacheForTests() + __setWindowsPathRegistryLoaderForTests() +}) describe('readPersistedWindowsPathSegments', () => { it('reads machine and user Path values from the Windows registry', () => { @@ -67,18 +85,17 @@ describe('readPersistedWindowsPathSegments', () => { it('caches production registry reads briefly', () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - defaultExecFileSyncMock - .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') - .mockReturnValueOnce(' Path REG_SZ C:\\User\r\n') + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User')) __resetPersistedWindowsPathCacheForTests() try { expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) - expect(defaultExecFileSyncMock).toHaveBeenCalledTimes(2) + expect(registryGetKeyMock).toHaveBeenCalledTimes(2) } finally { __resetPersistedWindowsPathCacheForTests() - defaultExecFileSyncMock.mockReset() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) @@ -86,27 +103,26 @@ describe('readPersistedWindowsPathSegments', () => { it('force-refreshes the production registry cache', () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - defaultExecFileSyncMock - .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') - .mockReturnValueOnce(' Path REG_SZ C:\\User\r\n') - .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') - .mockReturnValueOnce(' Path REG_SZ C:\\User;C:\\Program Files\\GitHub CLI\r\n') + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User')) + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User;C:\\Program Files\\GitHub CLI')) __resetPersistedWindowsPathCacheForTests() try { expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) - expect(defaultExecFileSyncMock).toHaveBeenCalledTimes(2) + expect(registryGetKeyMock).toHaveBeenCalledTimes(2) expect(readPersistedWindowsPathSegments({ forceRefresh: true })).toEqual([ 'C:\\Machine', 'C:\\User', 'C:\\Program Files\\GitHub CLI' ]) - expect(defaultExecFileSyncMock).toHaveBeenCalledTimes(4) + expect(registryGetKeyMock).toHaveBeenCalledTimes(4) } finally { __resetPersistedWindowsPathCacheForTests() - defaultExecFileSyncMock.mockReset() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) @@ -114,9 +130,9 @@ describe('readPersistedWindowsPathSegments', () => { it('keeps the last good segments when a forced read hits a blocked registry', () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - defaultExecFileSyncMock - .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') - .mockReturnValueOnce(' Path REG_SZ C:\\User\r\n') + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User')) .mockImplementation(() => { throw new Error('ERROR: Access is denied.') }) @@ -131,7 +147,6 @@ describe('readPersistedWindowsPathSegments', () => { expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) } finally { __resetPersistedWindowsPathCacheForTests() - defaultExecFileSyncMock.mockReset() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) @@ -139,10 +154,10 @@ describe('readPersistedWindowsPathSegments', () => { it('still clears the cache when the registry reports an empty Path', () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - defaultExecFileSyncMock - .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') - .mockReturnValueOnce(' Path REG_SZ C:\\User\r\n') - .mockReturnValue(' Path REG_SZ \r\n') + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User')) + .mockReturnValue(registryPath('')) __resetPersistedWindowsPathCacheForTests() try { @@ -154,7 +169,6 @@ describe('readPersistedWindowsPathSegments', () => { expect(readPersistedWindowsPathSegments()).toEqual([]) } finally { __resetPersistedWindowsPathCacheForTests() - defaultExecFileSyncMock.mockReset() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) @@ -162,13 +176,9 @@ describe('readPersistedWindowsPathSegments', () => { it('deduplicates concurrent forced asynchronous refreshes and merges each environment', async () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const callbacks: ExecCallback[] = [] - defaultExecFileMock.mockImplementation( - (_file: string, _args: string[], _options: unknown, callback: ExecCallback) => { - callbacks.push(callback) - return {} as never - } - ) + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User')) __resetPersistedWindowsPathCacheForTests() try { @@ -177,41 +187,25 @@ describe('readPersistedWindowsPathSegments', () => { const first = mergePersistedWindowsPathAsync(firstEnv, { forceRefresh: true }) const second = mergePersistedWindowsPathAsync(secondEnv, { forceRefresh: true }) - expect(defaultExecFileMock).toHaveBeenCalledTimes(2) - callbacks[0]?.(null, ' Path REG_SZ C:\\Machine\r\n', '') - callbacks[1]?.(null, ' Path REG_SZ C:\\User\r\n', '') await Promise.all([first, second]) expect(firstEnv.Path).toBe('C:\\First;C:\\Machine;C:\\User') expect(secondEnv.Path).toBe('C:\\Second;C:\\Machine;C:\\User') + expect(registryGetKeyMock).toHaveBeenCalledTimes(2) } finally { __resetPersistedWindowsPathCacheForTests() - defaultExecFileMock.mockReset() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) - it('keeps the last good cache when bounded asynchronous reads time out', async () => { + it('keeps the last good cache when native registry reads fail', async () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - defaultExecFileMock - .mockImplementationOnce( - (_file: string, _args: string[], _options: unknown, callback: ExecCallback) => { - callback(null, ' Path REG_SZ C:\\Machine\r\n', '') - return {} as never - } - ) - .mockImplementationOnce( - (_file: string, _args: string[], _options: unknown, callback: ExecCallback) => { - callback(null, ' Path REG_SZ C:\\User\r\n', '') - return {} as never - } - ) - .mockImplementation( - (_file: string, _args: string[], _options: unknown, callback: ExecCallback) => { - callback(Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' }), '', '') - return {} as never - } - ) + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User')) + .mockImplementation(() => { + throw new Error('registry unavailable') + }) __resetPersistedWindowsPathCacheForTests() try { @@ -223,7 +217,7 @@ describe('readPersistedWindowsPathSegments', () => { 'C:\\Machine', 'C:\\User' ]) - expect(defaultExecFileMock).toHaveBeenCalledTimes(2) + expect(registryGetKeyMock).toHaveBeenCalledTimes(2) await expect(readPersistedWindowsPathSegmentsAsync({ forceRefresh: true })).resolves.toEqual([ 'C:\\Machine', 'C:\\User' @@ -232,11 +226,9 @@ describe('readPersistedWindowsPathSegments', () => { 'C:\\Machine', 'C:\\User' ]) - expect(defaultExecFileMock).toHaveBeenCalledTimes(4) - expect(defaultExecFileMock.mock.calls[2]?.[2]).toMatchObject({ timeout: 5_000 }) + expect(registryGetKeyMock).toHaveBeenCalledTimes(4) } finally { __resetPersistedWindowsPathCacheForTests() - defaultExecFileMock.mockReset() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) diff --git a/src/main/pty/windows-environment-path.ts b/src/main/pty/windows-environment-path.ts index 36032da9aec..8fad453cab5 100644 --- a/src/main/pty/windows-environment-path.ts +++ b/src/main/pty/windows-environment-path.ts @@ -1,9 +1,10 @@ -import { execFile, execFileSync } from 'node:child_process' +import type { execFile, execFileSync } from 'node:child_process' import { expandWindowsEnvironmentVariables, expandWindowsPathEnvironmentVariables } from '../../shared/windows-environment-expansion' import { getRegExePath } from '../win32-utils' +import { readWindowsPathRegistry } from './windows-path-registry-reader' type ExecFile = typeof execFile type ExecFileSync = typeof execFileSync @@ -16,10 +17,7 @@ type ReadWindowsPathOptions = { platform?: NodeJS.Platform } -type RegistryPathRead = { - failed: boolean - segments: string[] -} +type RegistryPathRead = { failed: boolean; segments: string[] } const WINDOWS_PATH_REGISTRY_KEYS = [ ['HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment', 'Path'], @@ -71,6 +69,19 @@ function registryOutputSegments( : [] } +function readNativeRegistryPaths( + env: NodeJS.ProcessEnv, + pathDelimiter: string +): RegistryPathRead[] { + return readWindowsPathRegistry().map((read) => ({ + failed: read.failed, + segments: + read.value === null + ? [] + : splitPathSegments(expandWindowsEnvironmentVariables(read.value, env), pathDelimiter) + })) +} + function keepLastGoodSegments(segments: string[], failedReads: number): string[] { if (failedReads === WINDOWS_PATH_REGISTRY_KEYS.length && persistedWindowsPathCache) { return [...persistedWindowsPathCache.segments] @@ -145,34 +156,39 @@ export function readPersistedWindowsPathSegments(options: ReadWindowsPathOptions return [...persistedWindowsPathCache.segments] } - const run = options.execFileSync ?? execFileSync const env = options.env ?? process.env const pathDelimiter = getPathDelimiter(platform) - const segments: string[] = [] - let failedReads = 0 - - for (const [key, valueName] of WINDOWS_PATH_REGISTRY_KEYS) { - try { - const output = run(getRegExePath(env), ['query', key, '/v', valueName], { - encoding: 'utf8', - timeout: PERSISTED_WINDOWS_PATH_QUERY_TIMEOUT_MS, - windowsHide: true + const reads = options.execFileSync + ? WINDOWS_PATH_REGISTRY_KEYS.map(([key, valueName]) => { + try { + const output = options.execFileSync!( + getRegExePath(env), + ['query', key, '/v', valueName], + { + encoding: 'utf8', + timeout: PERSISTED_WINDOWS_PATH_QUERY_TIMEOUT_MS, + windowsHide: true + } + ) + return { + failed: false, + segments: registryOutputSegments(output, valueName, env, pathDelimiter) + } + } catch { + return { failed: true, segments: [] } + } }) - segments.push(...registryOutputSegments(output, valueName, env, pathDelimiter)) - } catch { - // Registry access can fail in stripped test containers or remote-like - // Windows contexts. Existing PATH remains the fallback in those cases. - failedReads += 1 - } - } + : readNativeRegistryPaths(env, pathDelimiter) + const segments = reads.flatMap((read) => read.segments) + const failedReads = reads.filter((read) => read.failed).length if (!useProductionCache) { return segments } - // Why: local PTY spawn is a hot path on Windows, and each uncached read - // runs two synchronous `reg.exe query` subprocesses. A short TTL keeps - // terminal bursts cheap while still picking up newly installed CLIs soon. + // Why: local PTY spawn is a hot path on Windows, and each uncached refresh performs two + // synchronous native registry reads. A short TTL keeps terminal bursts cheap while still + // picking up newly installed CLIs soon. return cachePersistedWindowsPathSegments(segments, failedReads) } @@ -202,14 +218,22 @@ export async function readPersistedWindowsPathSegmentsAsync( return [...(await pendingPersistedWindowsPathRefresh)] } - const run = options.execFile ?? execFile const env = options.env ?? process.env const pathDelimiter = getPathDelimiter(platform) - const executable = getRegExePath(env) - const refresh = Promise.all( - WINDOWS_PATH_REGISTRY_KEYS.map((registryValue) => - readRegistryPathAsync(run, executable, registryValue, env, pathDelimiter) - ) + const refresh = ( + options.execFile + ? Promise.all( + WINDOWS_PATH_REGISTRY_KEYS.map((registryValue) => + readRegistryPathAsync( + options.execFile!, + getRegExePath(env), + registryValue, + env, + pathDelimiter + ) + ) + ) + : Promise.resolve(readNativeRegistryPaths(env, pathDelimiter)) ).then((reads) => { const segments = reads.flatMap((read) => read.segments) if (!useProductionCache) { diff --git a/src/main/pty/windows-path-registry-reader.test.ts b/src/main/pty/windows-path-registry-reader.test.ts new file mode 100644 index 00000000000..634cd30e72a --- /dev/null +++ b/src/main/pty/windows-path-registry-reader.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + __setWindowsPathRegistryLoaderForTests, + readWindowsPathRegistry +} from './windows-path-registry-reader' + +describe('readWindowsPathRegistry', () => { + afterEach(() => __setWindowsPathRegistryLoaderForTests()) + + it('reads machine and user PATH values without creating a process', () => { + const getRegistryKey = vi + .fn() + .mockReturnValueOnce({ PATH: { type: 2, value: '%SystemRoot%\\System32' } }) + .mockReturnValueOnce({ Path: { type: 1, value: 'C:\\Users\\me\\bin' } }) + __setWindowsPathRegistryLoaderForTests(() => ({ + HK: { LM: 1, CU: 2 }, + getRegistryKey + })) + + expect(readWindowsPathRegistry()).toEqual([ + { failed: false, value: '%SystemRoot%\\System32' }, + { failed: false, value: 'C:\\Users\\me\\bin' } + ]) + expect(getRegistryKey).toHaveBeenNthCalledWith( + 1, + 1, + 'SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment' + ) + expect(getRegistryKey).toHaveBeenNthCalledWith(2, 2, 'Environment') + }) + + it('distinguishes an empty PATH from a failed registry read', () => { + const getRegistryKey = vi + .fn() + .mockReturnValueOnce({ Path: { type: 1, value: '' } }) + .mockReturnValueOnce({ Path: { type: 4, value: 1 } }) + __setWindowsPathRegistryLoaderForTests(() => ({ + HK: { LM: 1, CU: 2 }, + getRegistryKey + })) + + expect(readWindowsPathRegistry()).toEqual([ + { failed: false, value: '' }, + { failed: true, value: null } + ]) + }) + + it('treats a missing PATH value as a failed query', () => { + __setWindowsPathRegistryLoaderForTests(() => ({ + HK: { LM: 1, CU: 2 }, + getRegistryKey: vi.fn(() => ({})) + })) + + expect(readWindowsPathRegistry()).toEqual([ + { failed: true, value: null }, + { failed: true, value: null } + ]) + }) + + it('fails closed when the optional native module is unavailable', () => { + __setWindowsPathRegistryLoaderForTests(() => { + throw new Error('native module unavailable') + }) + + expect(readWindowsPathRegistry()).toEqual([ + { failed: true, value: null }, + { failed: true, value: null } + ]) + }) +}) diff --git a/src/main/pty/windows-path-registry-reader.ts b/src/main/pty/windows-path-registry-reader.ts new file mode 100644 index 00000000000..6f98c45e390 --- /dev/null +++ b/src/main/pty/windows-path-registry-reader.ts @@ -0,0 +1,67 @@ +import { + loadWindowsNativeRegistry, + WINDOWS_REG_EXPAND_SZ, + WINDOWS_REG_SZ, + type WindowsNativeRegistryModule +} from '../windows-native-registry' + +export type WindowsPathRegistryRead = { + failed: boolean + value: string | null +} + +const MACHINE_ENVIRONMENT_KEY = 'SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment' +const USER_ENVIRONMENT_KEY = 'Environment' +const PATH_VALUE = 'Path' + +let windowsRegistryLoader = loadWindowsNativeRegistry + +function readRegistryPath( + registry: WindowsNativeRegistryModule, + root: number, + key: string +): WindowsPathRegistryRead { + try { + const values = registry.getRegistryKey(root, key) + if (!values || typeof values !== 'object') { + return { failed: true, value: null } + } + const entry = Object.entries(values).find( + ([name]) => name.toLowerCase() === PATH_VALUE.toLowerCase() + )?.[1] + if (!entry) { + return { failed: true, value: null } + } + if ( + (entry.type !== WINDOWS_REG_SZ && entry.type !== WINDOWS_REG_EXPAND_SZ) || + typeof entry.value !== 'string' + ) { + return { failed: true, value: null } + } + return { failed: false, value: entry.value } + } catch { + return { failed: true, value: null } + } +} + +export function readWindowsPathRegistry(): WindowsPathRegistryRead[] { + let registry: WindowsNativeRegistryModule + try { + registry = windowsRegistryLoader() + } catch { + return [ + { failed: true, value: null }, + { failed: true, value: null } + ] + } + return [ + readRegistryPath(registry, registry.HK.LM, MACHINE_ENVIRONMENT_KEY), + readRegistryPath(registry, registry.HK.CU, USER_ENVIRONMENT_KEY) + ] +} + +export function __setWindowsPathRegistryLoaderForTests( + loader?: () => WindowsNativeRegistryModule +): void { + windowsRegistryLoader = loader ?? loadWindowsNativeRegistry +} diff --git a/src/main/runtime/multi-client-navigation-isolation.integration.test.ts b/src/main/runtime/multi-client-navigation-isolation.integration.test.ts index 1c641334016..50fd09a4639 100644 --- a/src/main/runtime/multi-client-navigation-isolation.integration.test.ts +++ b/src/main/runtime/multi-client-navigation-isolation.integration.test.ts @@ -304,6 +304,8 @@ describe('paired runtime navigation isolation', () => { const clientB = await authenticate(offerB.pairingUrl) sessions.push(clientB) expect(offerA.deviceId).not.toBe(offerB.deviceId) + expect(parsePairingCode(offerA.pairingUrl)?.pairedDeviceId).toBe(offerA.deviceId) + expect(parsePairingCode(offerB.pairingUrl)?.pairedDeviceId).toBe(offerB.deviceId) expect(parsePairingCode(offerA.pairingUrl)?.deviceToken).not.toBe( parsePairingCode(offerB.pairingUrl)?.deviceToken ) @@ -318,12 +320,92 @@ describe('paired runtime navigation isolation', () => { focusTerminal, clientA, clientB, + deviceIdA: offerA.deviceId, + deviceIdB: offerB.deviceId, pairingUrlA: offerA.pairingUrl, readerA, readerB } } + it('attributes workspace creation to each authenticated device across reconnect', async () => { + const harness = await startHarness() + const createManagedWorktree = vi + .spyOn(harness.runtime, 'createManagedWorktree') + .mockImplementation(async (args) => { + const creatorProvenance = (args as unknown as Record).creatorProvenance + return { + worktree: { + id: worktreeId(args.name), + creatorProvenance + } + } as never + }) + + send(harness.clientA, { + id: 'status-a', + method: 'status.get' + }) + send(harness.clientB, { + id: 'status-b', + method: 'status.get' + }) + const [statusA, statusB] = await Promise.all([ + harness.readerA.next('status-a'), + harness.readerB.next('status-b') + ]) + expect(statusA).toMatchObject({ + ok: true, + result: { pairedDeviceId: harness.deviceIdA } + }) + expect(statusB).toMatchObject({ + ok: true, + result: { pairedDeviceId: harness.deviceIdB } + }) + + send(harness.clientA, { + id: 'create-a', + method: 'worktree.create', + params: { + repo: REPO_ID, + name: 'created-a', + creatorProvenance: { kind: 'paired-device', deviceId: harness.deviceIdB } + } + }) + send(harness.clientB, { + id: 'create-b', + method: 'worktree.create', + params: { repo: REPO_ID, name: 'created-b' } + }) + await Promise.all([harness.readerA.next('create-a'), harness.readerB.next('create-b')]) + expect(createManagedWorktree).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'created-a', + creatorProvenance: { kind: 'paired-device', deviceId: harness.deviceIdA } + }) + ) + expect(createManagedWorktree).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'created-b', + creatorProvenance: { kind: 'paired-device', deviceId: harness.deviceIdB } + }) + ) + + await new Promise((resolve) => { + harness.clientA.ws.once('close', () => resolve()) + harness.clientA.ws.close() + }) + const reconnectedA = await authenticate(harness.pairingUrlA) + sessions.push(reconnectedA) + const reconnectedReaderA = createReader(reconnectedA) + readers.push(reconnectedReaderA) + send(reconnectedA, { id: 'status-a2', method: 'status.get' }) + await expect(reconnectedReaderA.next('status-a2')).resolves.toMatchObject({ + ok: true, + result: { pairedDeviceId: harness.deviceIdA } + }) + }) + it('keeps worktree navigation local to each paired runtime client by default', async () => { const harness = await startHarness() const clientSelections = { diff --git a/src/main/runtime/orca-runtime-git-branch-diff.test.ts b/src/main/runtime/orca-runtime-git-branch-diff.test.ts new file mode 100644 index 00000000000..4c469758760 --- /dev/null +++ b/src/main/runtime/orca-runtime-git-branch-diff.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { GlobalSettings } from '../../shared/types' +import { RuntimeGitCommands, type ResolvedRuntimeGitWorktree } from './orca-runtime-git' + +const getSshGitProviderMock = vi.hoisted(() => vi.fn()) + +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: getSshGitProviderMock +})) + +function makeWorktree(path: string): ResolvedRuntimeGitWorktree { + const worktree = { + id: 'wt-1', + repoId: 'repo-1', + path, + linkedIssue: null, + git: { + path, + branch: 'main', + isBare: false, + isMainWorktree: false, + head: 'a'.repeat(40) + } + } satisfies Partial + return worktree as unknown as ResolvedRuntimeGitWorktree +} + +describe('RuntimeGitCommands branch diff', () => { + beforeEach(() => { + getSshGitProviderMock.mockReset() + }) + + it('forwards the pinned head through normalized nested SSH paths', async () => { + const mergeBase = 'a'.repeat(40) + const headOid = 'b'.repeat(40) + const result = { + kind: 'text', + originalContent: 'left', + modifiedContent: 'right', + originalIsBinary: false, + modifiedIsBinary: false + } + const provider = { getBranchDiff: vi.fn().mockResolvedValue([result]) } + getSshGitProviderMock.mockReturnValue(provider) + const commands = new RuntimeGitCommands({ + resolveRuntimeGitTarget: async () => ({ + worktree: makeWorktree('/remote/repo'), + connectionId: 'conn-1' + }), + getRuntimeSettings: () => ({}) as GlobalSettings + }) + + await expect( + commands.getRuntimeGitBranchDiff( + 'id:wt-1', + { mergeBase, headOid }, + 'src\\file.ts', + 'src\\old-file.ts' + ) + ).resolves.toEqual(result) + + expect(provider.getBranchDiff).toHaveBeenCalledWith('/remote/repo', mergeBase, { + includePatch: true, + headOid, + filePath: 'src/file.ts', + oldPath: 'src/old-file.ts' + }) + }) +}) diff --git a/src/main/runtime/orca-runtime-git.ts b/src/main/runtime/orca-runtime-git.ts index 88fb102f7e9..022b53ac948 100644 --- a/src/main/runtime/orca-runtime-git.ts +++ b/src/main/runtime/orca-runtime-git.ts @@ -79,7 +79,7 @@ import type { import { prepareLocalCommitMessageAgentEnv } from '../text-generation/commit-message-agent-environment' import { getPullRequestDraftContext } from '../text-generation/pull-request-context' import { normalizeRuntimeRelativePath } from './runtime-relative-paths' -import { gitExecFileAsync } from '../git/runner' +import { awaitWindowsHostGitEnvironmentReady, gitExecFileAsync } from '../git/runner' import type { GitRuntimeOptions } from '../git/git-runtime-options' import { resolveHostedReviewBodyForGeneration } from '../source-control/pull-request-template' import { @@ -538,6 +538,7 @@ export class RuntimeGitCommands { } const results = await provider.getBranchDiff(target.worktree.path, compare.mergeBase, { includePatch: true, + headOid: compare.headOid, filePath: relativePath, oldPath: oldRelativePath }) @@ -985,6 +986,7 @@ export class RuntimeGitCommands { } return provider.getRemoteFileUrl(target.worktree.path, normalizedRelativePath, line) } + await awaitWindowsHostGitEnvironmentReady({ cwd: target.worktree.path }) return getRemoteFileUrl(target.worktree.path, normalizedRelativePath, line) } @@ -1000,6 +1002,7 @@ export class RuntimeGitCommands { } return provider.getRemoteCommitUrl(target.worktree.path, sha) } + await awaitWindowsHostGitEnvironmentReady({ cwd: target.worktree.path }) return getRemoteCommitUrl(target.worktree.path, sha) } } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 031c9be80c5..e5bacc5eb9e 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -234,6 +234,7 @@ const { invalidateAuthorizedRootsCacheMock, prepareLocalWorktreeRootForRepoMock, createHostedReviewMock, + createStackedHostedReviewMock, getHostedReviewCreationEligibilityMock, getHostedReviewForBranchMock, getPRForBranchMock, @@ -341,6 +342,7 @@ const { invalidateAuthorizedRootsCacheMock: vi.fn(), prepareLocalWorktreeRootForRepoMock: vi.fn(), createHostedReviewMock: vi.fn(), + createStackedHostedReviewMock: vi.fn(), getHostedReviewCreationEligibilityMock: vi.fn(), getHostedReviewForBranchMock: vi.fn(), getPRForBranchMock: vi.fn().mockResolvedValue(null), @@ -513,6 +515,10 @@ vi.mock('../source-control/hosted-review-creation', () => ({ getHostedReviewCreationEligibility: getHostedReviewCreationEligibilityMock })) +vi.mock('../source-control/stacked-hosted-review-creation', () => ({ + createStackedHostedReview: createStackedHostedReviewMock +})) + vi.mock('../source-control/hosted-review', () => ({ getHostedReviewForBranch: getHostedReviewForBranchMock })) @@ -732,6 +738,14 @@ function resetRuntimeTestMocks(): void { number: 1, url: 'https://example.com/pull/1' }) + createStackedHostedReviewMock.mockReset() + createStackedHostedReviewMock.mockResolvedValue({ + ok: true, + number: 2, + url: 'https://example.com/pull/2', + stackNumber: 10, + parentReview: { number: 1, url: 'https://example.com/pull/1' } + }) getHostedReviewCreationEligibilityMock.mockReset() getHostedReviewCreationEligibilityMock.mockResolvedValue({ provider: 'github', @@ -6636,6 +6650,24 @@ describe('OrcaRuntimeService', () => { ) }) + it('forwards check-details cancellation without local Git overrides', async () => { + const runtime = new OrcaRuntimeService(store) + const signal = new AbortController().signal + + await runtime.getRepoPRCheckDetails('id:repo-1', { checkRunId: 9 }, signal) + + expect(getGitHubPRCheckDetailsMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + { + checkRunId: 9, + prRepo: null + }, + null, + {}, + signal + ) + }) + it('routes runtime GitHub PR details and actions through the selected WSL project runtime', async () => { setPlatform('win32') const runtimeStore = { @@ -6659,6 +6691,7 @@ describe('OrcaRuntimeService', () => { const runtime = new OrcaRuntimeService(runtimeStore as never) const localGitOptions = { wslDistro: 'Ubuntu' } const prRepo = { owner: 'acme', repo: 'orca', host: 'github.acme.test' } + const checkDetailsSignal = new AbortController().signal await runtime.getRepoPRForBranch('id:repo-1', 'feature/wsl', 42, 43) await runtime.getRepoWorkItem('id:repo-1', 42, 'pr') @@ -6670,13 +6703,17 @@ describe('OrcaRuntimeService', () => { failedOnly: true, prRepo }) - await runtime.getRepoPRCheckDetails('id:repo-1', { - checkRunId: 9, - workflowRunId: 8, - checkName: 'lint', - url: 'https://example.com/check', - prRepo - }) + await runtime.getRepoPRCheckDetails( + 'id:repo-1', + { + checkRunId: 9, + workflowRunId: 8, + checkName: 'lint', + url: 'https://example.com/check', + prRepo + }, + checkDetailsSignal + ) await runtime.getRepoPRComments('id:repo-1', 42, prRepo, { noCache: true }) await runtime.getRepoPRFileContents('id:repo-1', { prNumber: 42, @@ -6778,7 +6815,8 @@ describe('OrcaRuntimeService', () => { prRepo }, null, - localGitOptions + localGitOptions, + checkDetailsSignal ) expect(getGitHubPRCommentsMock).toHaveBeenCalledWith( TEST_REPO_PATH, @@ -6981,6 +7019,15 @@ describe('OrcaRuntimeService', () => { body: '', draft: false }) + await runtime.createStackedHostedReview({ + repoSelector: `id:${TEST_REPO_ID}`, + provider: 'github', + base: 'stack/parent', + head: 'feature/ssh', + title: 'Feature SSH', + body: '', + draft: false + }) expect(getHostedReviewCreationEligibilityMock).toHaveBeenCalledWith( expect.objectContaining({ @@ -6998,6 +7045,16 @@ describe('OrcaRuntimeService', () => { }), 'ssh-1' ) + expect(createStackedHostedReviewMock).toHaveBeenCalledWith( + '/remote/repo', + expect.objectContaining({ + provider: 'github', + base: 'stack/parent', + head: 'feature/ssh' + }), + 'ssh-1', + {} + ) }) it('routes local WSL project hosted review flows through runtime git options', async () => { @@ -7060,6 +7117,15 @@ describe('OrcaRuntimeService', () => { body: '', draft: false }) + await runtime.createStackedHostedReview({ + repoSelector: `id:${TEST_REPO_ID}`, + provider: 'github', + base: 'stack/parent', + head: 'feature/wsl', + title: 'Feature WSL', + body: '', + draft: false + }) expect(getHostedReviewCreationEligibilityMock).toHaveBeenCalledWith( expect.objectContaining({ @@ -7088,6 +7154,16 @@ describe('OrcaRuntimeService', () => { null, { localGitExecOptions: { wslDistro: 'Ubuntu' } } ) + expect(createStackedHostedReviewMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + expect.objectContaining({ + provider: 'github', + base: 'stack/parent', + head: 'feature/wsl' + }), + null, + { localGitExecOptions: { wslDistro: 'Ubuntu' } } + ) }) it('treats SSH worktree drift as unknown without local git probes', async () => { @@ -7737,7 +7813,7 @@ describe('OrcaRuntimeService', () => { it('keeps project clone setup on the cloned host-qualified repo', async () => { const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-project-clone-')) const clonePath = join(destination, 'orca') - const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn') + const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady') const repos: Record[] = [] getRepoUpstreamMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) const runtimeStore = { @@ -7762,7 +7838,7 @@ describe('OrcaRuntimeService', () => { spawnSpy.mockImplementation(() => { const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } proc.stderr = new EventEmitter() - queueMicrotask(() => { + setImmediate(() => { mkdirSync(clonePath, { recursive: true }) execFileSync('git', ['init'], { cwd: clonePath, stdio: 'ignore' }) proc.emit('close', 0, null) @@ -7804,14 +7880,16 @@ describe('OrcaRuntimeService', () => { const existingFolder = join(destination, 'orca') mkdirSync(existingFolder, { recursive: true }) execFileSync('git', ['init'], { cwd: existingFolder, stdio: 'ignore' }) - const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn').mockImplementation(() => { - // Why: unreachable while the guard holds; stubbed so a regression records the call - // instead of shelling out to a real network clone. - const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } - proc.stderr = new EventEmitter() - queueMicrotask(() => proc.emit('close', 1, null)) - return proc as never - }) + const spawnSpy = vi + .spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady') + .mockImplementation(() => { + // Why: unreachable while the guard holds; stubbed so a regression records the call + // instead of shelling out to a real network clone. + const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } + proc.stderr = new EventEmitter() + setImmediate(() => proc.emit('close', 1, null)) + return proc as never + }) const repos: Record[] = [] const runtimeStore = { ...store, @@ -7860,7 +7938,7 @@ describe('OrcaRuntimeService', () => { it('adopts public clone repos into host-qualified project setup', async () => { const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-project-clone-')) const clonePath = join(destination, 'orca') - const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn') + const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady') const repos: Record[] = [] getRepoUpstreamMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) const runtimeStore = { @@ -7885,7 +7963,7 @@ describe('OrcaRuntimeService', () => { spawnSpy.mockImplementation(() => { const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } proc.stderr = new EventEmitter() - queueMicrotask(() => { + setImmediate(() => { mkdirSync(clonePath, { recursive: true }) execFileSync('git', ['init'], { cwd: clonePath, stdio: 'ignore' }) proc.emit('close', 0, null) @@ -7930,7 +8008,7 @@ describe('OrcaRuntimeService', () => { it('keeps project clone repos split by runtime host on the same clone path', async () => { const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-project-clone-')) const clonePath = join(destination, 'orca') - const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn') + const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady') const repos: Record[] = [ { id: 'repo-host-a', @@ -7965,7 +8043,7 @@ describe('OrcaRuntimeService', () => { spawnSpy.mockImplementation(() => { const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } proc.stderr = new EventEmitter() - queueMicrotask(() => { + setImmediate(() => { mkdirSync(clonePath, { recursive: true }) execFileSync('git', ['init'], { cwd: clonePath, stdio: 'ignore' }) proc.emit('close', 0, null) @@ -8102,7 +8180,7 @@ describe('OrcaRuntimeService', () => { }) it('defaults runtime cloneRepo badgeColor to DEFAULT_REPO_BADGE_COLOR', async () => { - const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn') + const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady') const added: Record[] = [] const colorStore = { ...store, @@ -8115,7 +8193,7 @@ describe('OrcaRuntimeService', () => { spawnSpy.mockImplementation(() => { const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } proc.stderr = new EventEmitter() - queueMicrotask(() => proc.emit('close', 0, null)) + setImmediate(() => proc.emit('close', 0, null)) return proc as never }) const runtime = new OrcaRuntimeService(colorStore as never) @@ -8138,7 +8216,7 @@ describe('OrcaRuntimeService', () => { }) it('drops a same-path negative submodule cache before runtime cloneRepo', async () => { - const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn') + const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady') const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-reclone-')) const clonePath = join(destination, 'reclone') const added: Record[] = [] @@ -8151,7 +8229,7 @@ describe('OrcaRuntimeService', () => { spawnSpy.mockImplementation(() => { const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } proc.stderr = new EventEmitter() - queueMicrotask(() => { + setImmediate(() => { void mkdir(clonePath, { recursive: true }) .then(() => writeFile(join(clonePath, '.gitmodules'), '[submodule "lib"]\n\tpath = vendor/lib\n') @@ -8175,11 +8253,11 @@ describe('OrcaRuntimeService', () => { }) it('preserves existing badgeColor on runtime cloneRepo folder->git dedupe upgrade', async () => { - const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn') + const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady') spawnSpy.mockImplementation(() => { const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } proc.stderr = new EventEmitter() - queueMicrotask(() => proc.emit('close', 0, null)) + setImmediate(() => proc.emit('close', 0, null)) return proc as never }) const existing = { @@ -8273,7 +8351,7 @@ describe('OrcaRuntimeService', () => { }) it('rejects runtime cloneRepo dot-segment URLs before spawning git', async () => { - const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn') + const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady') const runtime = createRuntime() const tempRoot = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-')) const destination = join(tempRoot, 'destination') @@ -8290,7 +8368,7 @@ describe('OrcaRuntimeService', () => { }) it('rejects runtime cloneRepo parent-segment URLs before spawning git', async () => { - const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn') + const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady') const runtime = createRuntime() const tempRoot = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-')) const destination = join(tempRoot, 'destination') @@ -8307,10 +8385,10 @@ describe('OrcaRuntimeService', () => { }) it('removes an owned runtime clone target when git exits unsuccessfully', async () => { - const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn') + const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady') const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } proc.stderr = new EventEmitter() - spawnSpy.mockReturnValue(proc as never) + spawnSpy.mockResolvedValue(proc as never) const runtime = createRuntime() const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-')) const clonePath = join(destination, 'repo-badge-color') @@ -8334,10 +8412,10 @@ describe('OrcaRuntimeService', () => { }) it('preserves an existing runtime clone target when git exits unsuccessfully', async () => { - const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn') + const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady') const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } proc.stderr = new EventEmitter() - spawnSpy.mockReturnValue(proc as never) + spawnSpy.mockResolvedValue(proc as never) const runtime = createRuntime() const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-')) const clonePath = join(destination, 'repo-badge-color') @@ -8361,10 +8439,10 @@ describe('OrcaRuntimeService', () => { }) it('skips runtime clone failure cleanup when the owned target is replaced', async () => { - const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn') + const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady') const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } proc.stderr = new EventEmitter() - spawnSpy.mockReturnValue(proc as never) + spawnSpy.mockResolvedValue(proc as never) const runtime = createRuntime() const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-')) const clonePath = join(destination, 'repo-badge-color') @@ -8390,10 +8468,10 @@ describe('OrcaRuntimeService', () => { }) it('serializes concurrent runtime clones for the same target', async () => { - const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn') + const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady') const firstProc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } firstProc.stderr = new EventEmitter() - spawnSpy.mockReturnValueOnce(firstProc as never) + spawnSpy.mockResolvedValueOnce(firstProc as never) const added: Record[] = [] const colorStore = { ...store, diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 99f9f5aad07..0b234b24160 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -94,7 +94,12 @@ import { AGENT_PROMPT_SUBMIT_DELAY_MS, buildAgentPromptPasteBytes } from '../../shared/agent-prompt-injection' -import { gitExecFileAsync, gitSpawn, nonInteractiveGitEnv } from '../git/runner' +import { + awaitWindowsHostGitEnvironmentReady, + gitExecFileAsync, + gitSpawnAfterWindowsEnvironmentReady, + nonInteractiveGitEnv +} from '../git/runner' import { runWithGitReadCacheInvalidation } from '../git/status' import { cleanupClaimedCloneTarget, @@ -149,6 +154,7 @@ import type { OrchestrationEnvironmentTransport, OrchestrationWorkerServer } from './orchestration/environment-transport' +import { clearFederationAckCheckpoints } from './orchestration/federation-ack-checkpoints' import { syncFederatedDispatch } from './orchestration/federation-sync' import { formatMessagePointer } from './orchestration/formatter' import { selectExactWorkerProviderSession } from './orchestration/worker-provider-session' @@ -670,6 +676,8 @@ import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-im import type { CreateHostedReviewInput, CreateHostedReviewResult, + CreateStackedHostedReviewInput, + CreateStackedHostedReviewResult, HostedReviewCreationEligibility, HostedReviewCreationEligibilityArgs, HostedReviewInfo @@ -679,6 +687,7 @@ import { createHostedReview as createHostedReviewFromRepo, getHostedReviewCreationEligibility as getHostedReviewCreationEligibilityFromRepo } from '../source-control/hosted-review-creation' +import { createStackedHostedReview as createStackedHostedReviewFromRepo } from '../source-control/stacked-hosted-review-creation' import { getLocalProjectGitExecOptions, getLocalProjectWorktreeGitOptions, @@ -2674,7 +2683,10 @@ export class OrcaRuntimeService { private managedHookReconciliationTail: Promise = Promise.resolve() private readonly orchestrationEnvironmentTransport: OrchestrationEnvironmentTransport | null private readonly orchestrationFederationTimers = new Map>() - private readonly orchestrationFederationSyncs = new Map>() + private readonly orchestrationFederationSyncs = new Map< + string, + { db: OrchestrationDb; promise: Promise } + >() private readonly orchestrationFederationWarnings = new Set() private rendererGraphEpoch = 0 private graphStatus: RuntimeGraphStatus = 'unavailable' @@ -3807,6 +3819,9 @@ export class OrcaRuntimeService { } setOrchestrationDb(db: OrchestrationDb): void { + clearFederationAckCheckpoints(this) + this.orchestrationFederationSyncs.clear() + this.orchestrationFederationWarnings.clear() this._orchestrationDb = db } @@ -4637,29 +4652,46 @@ export class OrcaRuntimeService { ) } - private syncOrchestrationFederatedDispatch(dispatchId: string): Promise { + syncOrchestrationFederatedDispatch(dispatchId: string): Promise { + const db = this.getOrchestrationDb() const current = this.orchestrationFederationSyncs.get(dispatchId) - if (current) { - return current + if (current?.db === db) { + return current.promise } const sync = syncFederatedDispatch(this, dispatchId) .then(() => { - this.orchestrationFederationWarnings.delete(dispatchId) + if (this.orchestrationFederationSyncs.get(dispatchId)?.promise === sync) { + this.orchestrationFederationWarnings.delete(dispatchId) + } }) .catch((error: unknown) => { - if (!this.orchestrationFederationWarnings.has(dispatchId)) { + if ( + this.orchestrationFederationSyncs.get(dispatchId)?.promise === sync && + !this.orchestrationFederationWarnings.has(dispatchId) + ) { console.warn(`[orchestration] Federation sync failed for ${dispatchId}:`, error) this.orchestrationFederationWarnings.add(dispatchId) } throw error }) .finally(() => { - this.orchestrationFederationSyncs.delete(dispatchId) + if (this.orchestrationFederationSyncs.get(dispatchId)?.promise === sync) { + this.orchestrationFederationSyncs.delete(dispatchId) + } }) - this.orchestrationFederationSyncs.set(dispatchId, sync) + this.orchestrationFederationSyncs.set(dispatchId, { db, promise: sync }) return sync } + async syncOrchestrationFederatedDispatchAfterCurrent(dispatchId: string): Promise { + const db = this.getOrchestrationDb() + const current = this.orchestrationFederationSyncs.get(dispatchId) + if (current?.db === db) { + await current.promise.catch(() => undefined) + } + await this.syncOrchestrationFederatedDispatch(dispatchId) + } + ensureOrchestrationFederationRelay(runId?: string): void { if (!this.orchestrationEnvironmentTransport) { return @@ -4694,6 +4726,8 @@ export class OrcaRuntimeService { } this.orchestrationFederationTimers.clear() this.orchestrationFederationWarnings.clear() + this.orchestrationFederationSyncs.clear() + clearFederationAckCheckpoints(this) } getStartedAt(): number { @@ -17612,6 +17646,7 @@ export class OrcaRuntimeService { ...(meta?.manualOrder !== undefined ? { manualOrder: meta.manualOrder } : {}), lastActivityAt: worktree.lastActivityAt, ...(worktree.createdAt !== undefined ? { createdAt: worktree.createdAt } : {}), + ...(worktree.creatorProvenance ? { creatorProvenance: worktree.creatorProvenance } : {}), linkedIssue: worktree.linkedIssue, linkedPR, linkedLinearIssue: meta?.linkedLinearIssue ?? null, @@ -17660,6 +17695,7 @@ export class OrcaRuntimeService { ...(worktree.manualOrder !== undefined ? { manualOrder: worktree.manualOrder } : {}), lastActivityAt: worktree.lastActivityAt, ...(worktree.createdAt !== undefined ? { createdAt: worktree.createdAt } : {}), + ...(worktree.creatorProvenance ? { creatorProvenance: worktree.creatorProvenance } : {}), linkedIssue: worktree.linkedIssue ?? null, linkedPR: null, linkedLinearIssue: worktree.linkedLinearIssue ?? null, @@ -18334,6 +18370,7 @@ export class OrcaRuntimeService { name?: string folderPath?: string | null connectionId?: string | null + creatorProvenance?: FolderWorkspace['creatorProvenance'] linkedTask?: FolderWorkspace['linkedTask'] linkedTaskSourceContext?: FolderWorkspace['linkedTaskSourceContext'] createdWithAgent?: FolderWorkspace['createdWithAgent'] @@ -18362,7 +18399,10 @@ export class OrcaRuntimeService { { getSshFilesystemProvider } ) assertFolderWorkspacePathUsable(status) - const workspace = this.store.createFolderWorkspace(input) + const workspace = this.store.createFolderWorkspace({ + ...input, + creatorProvenance: input.creatorProvenance ?? { kind: 'host' } + }) this.notifyReposChanged() return workspace } @@ -18447,6 +18487,7 @@ export class OrcaRuntimeService { if (!isAbsolute(path)) { throw new Error('Project path must be an absolute path') } + await awaitWindowsHostGitEnvironmentReady({ cwd: path }) return scanNestedRepos({ path, options: { timeoutMs: 15_000 } }) } @@ -18495,6 +18536,7 @@ export class OrcaRuntimeService { projectPaths: string[] mode: ProjectGroupImportMode }): Promise { + await awaitWindowsHostGitEnvironmentReady({ cwd: args.parentPath }) if (!this.store?.createProjectGroup || !this.store?.moveProjectToGroup) { throw new Error('runtime_unavailable') } @@ -18522,6 +18564,7 @@ export class OrcaRuntimeService { const importTargetResolver = createNestedRepoImportTargetResolver() for (const [projectGroupOrder, repoPath] of selection.selectedPaths.entries()) { try { + await awaitWindowsHostGitEnvironmentReady({ cwd: repoPath }) if (!isGitRepo(repoPath)) { results.push({ path: repoPath, status: 'failed', error: 'Not a valid git repository' }) continue @@ -18649,6 +18692,9 @@ export class OrcaRuntimeService { // server-side repo paths to be explicit so `orca serve` cwd is irrelevant. throw new Error('Project path must be an absolute path') } + if (kind === 'git') { + await awaitWindowsHostGitEnvironmentReady({ cwd: path }) + } if (kind === 'git' && !isGitRepo(path)) { throw new Error(`Not a valid git repository: ${path}`) } @@ -18895,10 +18941,11 @@ export class OrcaRuntimeService { await mkdir(trimmedDestination, { recursive: true }) const claimedTarget = await claimCloneTarget(clonePath) - await new Promise((resolve, reject) => { - let proc: ReturnType - try { - proc = gitSpawn(['clone', '--progress', '--', trimmedUrl, clonePath], { + let proc: Awaited> + try { + proc = await gitSpawnAfterWindowsEnvironmentReady( + ['clone', '--progress', '--', trimmedUrl, clonePath], + { cwd: trimmedDestination, // Why: without the non-interactive guard, a clone that needs GitHub // auth makes Git Credential Manager pop its "Connect to GitHub" OAuth @@ -18907,14 +18954,14 @@ export class OrcaRuntimeService { // (issue #7652). Fail fast with a clear error instead. env: nonInteractiveGitEnv(), stdio: ['ignore', 'ignore', 'pipe'] - }) - } catch (err) { - void cleanupClaimedCloneTarget(clonePath, claimedTarget).finally(() => { - const message = err instanceof Error ? err.message : String(err) - reject(new Error(`Clone failed: ${message}`)) - }) - return - } + } + ) + } catch (err) { + await cleanupClaimedCloneTarget(clonePath, claimedTarget) + const message = err instanceof Error ? err.message : String(err) + throw new Error(`Clone failed: ${message}`) + } + await new Promise((resolve, reject) => { let stderrTail = '' let settled = false proc.stderr?.on('data', (chunk: Buffer) => { @@ -19644,6 +19691,36 @@ export class OrcaRuntimeService { return result } + async createStackedHostedReview( + args: CreateStackedHostedReviewInput & { repoSelector: string; worktreeSelector?: string } + ): Promise { + const { repo, repoPath } = await this.resolveHostedReviewTarget(args) + const executionOptions = this.getHostedReviewExecutionOptions(repo) + const result = await createStackedHostedReviewFromRepo( + repoPath, + { + provider: args.provider, + base: args.base, + head: args.head, + title: args.title, + body: args.body, + draft: args.draft, + ...(args.useTemplate !== undefined ? { useTemplate: args.useTemplate } : {}) + }, + repo.connectionId ?? null, + executionOptions ?? {} + ) + if (result.ok && this.stats && !this.stats.hasCountedPR(result.url)) { + this.stats.record({ + type: 'pr_created', + at: Date.now(), + repoId: repo.id, + meta: { prNumber: result.number, prUrl: result.url } + }) + } + return result + } + async listGitLabRepoWorkItems( repoSelector: string, state?: MRListState, @@ -20083,14 +20160,17 @@ export class OrcaRuntimeService { checkName?: string url?: string | null prRepo?: GitHubOwnerRepo | null - } + }, + signal?: AbortSignal ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) + const localGitOptions = this.getLocalGitExecutionOptionArgs(repo)[0] ?? {} return getPRCheckDetails( repo.path, { ...args, prRepo: args.prRepo ?? null }, repo.connectionId ?? null, - ...this.getLocalGitExecutionOptionArgs(repo) + localGitOptions, + signal ) } @@ -21682,6 +21762,7 @@ export class OrcaRuntimeService { pendingFirstAgentMessageRename?: boolean automationProvenance?: AutomationWorkspaceProvenance cliProvenance?: CliWorkspaceProvenance + creatorProvenance?: Worktree['creatorProvenance'] startup?: WorktreeStartupLaunch startupDraft?: string startupDraftPaste?: WorktreeStartupDraftPaste @@ -21748,6 +21829,7 @@ export class OrcaRuntimeService { }, ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), ...(args.cliProvenance ? { cliProvenance: args.cliProvenance } : {}), + creatorProvenance: args.creatorProvenance ?? { kind: 'host' }, ...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}), ...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}), ...(args.linkedLinearIssue !== undefined @@ -22376,6 +22458,7 @@ export class OrcaRuntimeService { : {}), ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), ...(args.cliProvenance ? { cliProvenance: args.cliProvenance } : {}), + creatorProvenance: args.creatorProvenance ?? { kind: 'host' }, ...(args.comment !== undefined ? { comment: args.comment } : {}), ...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}), ...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}) diff --git a/src/main/runtime/orchestration/federation-ack-checkpoints.ts b/src/main/runtime/orchestration/federation-ack-checkpoints.ts new file mode 100644 index 00000000000..2b488c1b7a5 --- /dev/null +++ b/src/main/runtime/orchestration/federation-ack-checkpoints.ts @@ -0,0 +1,71 @@ +import type { OrcaRuntimeService } from '../orca-runtime' + +export type FederationAckIdentity = { + environmentId: string + peerFingerprint: string + remoteRuntimeEpoch: string +} + +type FederationAckCheckpoint = FederationAckIdentity & { throughSequence: number } +type FederationAckDispatchState = { checkpoint: FederationAckCheckpoint | null } +type FederationAckRuntimeState = { byDispatch: Map } + +export type FederationAckLease = { + runtimeState: FederationAckRuntimeState + dispatchState: FederationAckDispatchState +} + +const federationAckStates = new WeakMap() + +export function clearFederationAckCheckpoints(runtime: OrcaRuntimeService): void { + federationAckStates.delete(runtime) +} + +export function acquireFederationAckLease( + runtime: OrcaRuntimeService, + dispatchId: string +): FederationAckLease { + let runtimeState = federationAckStates.get(runtime) + if (!runtimeState) { + runtimeState = { byDispatch: new Map() } + federationAckStates.set(runtime, runtimeState) + } + let dispatchState = runtimeState.byDispatch.get(dispatchId) + if (!dispatchState) { + dispatchState = { checkpoint: null } + runtimeState.byDispatch.set(dispatchId, dispatchState) + } + return { runtimeState, dispatchState } +} + +export function getFederationAckedThrough( + lease: FederationAckLease, + identity: FederationAckIdentity +): number { + const checkpoint = lease.dispatchState.checkpoint + return checkpoint?.environmentId === identity.environmentId && + checkpoint.peerFingerprint === identity.peerFingerprint && + checkpoint.remoteRuntimeEpoch === identity.remoteRuntimeEpoch + ? checkpoint.throughSequence + : 0 +} + +export function recordFederationAckCheckpoint( + runtime: OrcaRuntimeService, + lease: FederationAckLease, + checkpoint: FederationAckCheckpoint +): void { + if (federationAckStates.get(runtime) !== lease.runtimeState) { + return + } + const current = lease.dispatchState.checkpoint + if ( + current?.environmentId === checkpoint.environmentId && + current.peerFingerprint === checkpoint.peerFingerprint && + current.remoteRuntimeEpoch === checkpoint.remoteRuntimeEpoch && + current.throughSequence >= checkpoint.throughSequence + ) { + return + } + lease.dispatchState.checkpoint = checkpoint +} diff --git a/src/main/runtime/orchestration/federation-sync.test.ts b/src/main/runtime/orchestration/federation-sync.test.ts index 339b1a55f27..5470fe6416d 100644 --- a/src/main/runtime/orchestration/federation-sync.test.ts +++ b/src/main/runtime/orchestration/federation-sync.test.ts @@ -1,6 +1,92 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from '../orca-runtime' +import { + acquireFederationAckLease, + clearFederationAckCheckpoints, + getFederationAckedThrough, + recordFederationAckCheckpoint, + type FederationAckIdentity +} from './federation-ack-checkpoints' import { parseRelayedMessage } from './federation-sync' +function createIdleSyncHarness() { + let remoteRuntimeEpoch = 'remote_epoch_1' + let blockedAck: { reached: () => void; released: Promise } | null = null + let blockedPull: { reached: () => void; released: Promise } | null = null + const federated = { + environment_id: 'environment_windows', + environment_name: 'windows', + peer_fingerprint: 'windows_peer_fingerprint', + to_home_imported_sequence: 2 + } + const createDb = () => + ({ + getFederatedDispatch: () => federated, + getDispatchContextById: () => ({ run_id: 'run_home', task_id: 'task_home' }), + getWorkerDispatch: () => ({ state: 'ready' }), + listPendingFederationRelay: () => [] + }) as never + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(createDb()) + vi.spyOn(runtime, 'resolveOrchestrationWorkerServer').mockReturnValue({ + peerFingerprint: federated.peer_fingerprint + } as never) + const remoteCall = vi + .spyOn(runtime, 'callOrchestrationWorkerServer') + .mockImplementation(async (_environmentId, method) => { + if (method === 'orchestration.federationPull') { + const gate = blockedPull + if (gate) { + gate.reached() + await gate.released + if (blockedPull === gate) { + blockedPull = null + } + } + return { runtimeEpoch: remoteRuntimeEpoch, items: [] } + } + if (method === 'orchestration.federationAck') { + const gate = blockedAck + if (gate) { + gate.reached() + await gate.released + if (blockedAck === gate) { + blockedAck = null + } + } + return { acknowledgedThrough: federated.to_home_imported_sequence } + } + throw new Error(`Unexpected method ${method}`) + }) + return { + runtime, + remoteCall, + advanceCursor: () => { + federated.to_home_imported_sequence += 1 + }, + restartRemote: () => { + remoteRuntimeEpoch = 'remote_epoch_2' + }, + replaceDb: () => runtime.setOrchestrationDb(createDb()), + blockAck: () => { + let noteReached!: () => void + let release!: () => void + const reached = new Promise((resolve) => (noteReached = resolve)) + const released = new Promise((resolve) => (release = resolve)) + blockedAck = { reached: noteReached, released } + return { reached, release } + }, + blockPull: () => { + let noteReached!: () => void + let release!: () => void + const reached = new Promise((resolve) => (noteReached = resolve)) + const released = new Promise((resolve) => (release = resolve)) + blockedPull = { reached: noteReached, released } + return { reached, release } + } + } +} + describe('federation relay parsing', () => { it('accepts a supported message type', () => { expect( @@ -16,3 +102,160 @@ describe('federation relay parsing', () => { ).toThrowError('Federated relay message type invented is not supported.') }) }) + +describe('federation relay acknowledgments', () => { + it('acknowledges only new progress until remote runtime identity changes', async () => { + const { runtime, remoteCall, advanceCursor, restartRemote } = createIdleSyncHarness() + const ackCalls = () => + remoteCall.mock.calls.filter(([, method]) => method === 'orchestration.federationAck') + + await runtime.syncOrchestrationFederatedDispatch('dispatch_remote') + await runtime.syncOrchestrationFederatedDispatch('dispatch_remote') + expect(ackCalls()).toHaveLength(1) + + advanceCursor() + await runtime.syncOrchestrationFederatedDispatch('dispatch_remote') + expect(ackCalls()).toHaveLength(2) + + restartRemote() + await runtime.syncOrchestrationFederatedDispatch('dispatch_remote') + expect(ackCalls().map(([, , params]) => params)).toEqual([ + { dispatchId: 'dispatch_remote', throughSequence: 2 }, + { dispatchId: 'dispatch_remote', throughSequence: 3 }, + { dispatchId: 'dispatch_remote', throughSequence: 3 } + ]) + }) + + it('coalesces overlapping syncs while an acknowledgment is in flight', async () => { + const { runtime, remoteCall, blockAck } = createIdleSyncHarness() + const gate = blockAck() + + const first = runtime.syncOrchestrationFederatedDispatch('dispatch_remote') + await gate.reached + const second = runtime.syncOrchestrationFederatedDispatch('dispatch_remote') + expect(second).toBe(first) + gate.release() + await Promise.all([first, second]) + + expect( + remoteCall.mock.calls.filter(([, method]) => method === 'orchestration.federationAck') + ).toHaveLength(1) + }) + + it('pulls again after a sync that predates a terminal observation', async () => { + const { runtime, remoteCall, blockPull } = createIdleSyncHarness() + const gate = blockPull() + + const oldSync = runtime.syncOrchestrationFederatedDispatch('dispatch_remote') + await gate.reached + const freshSync = runtime.syncOrchestrationFederatedDispatchAfterCurrent('dispatch_remote') + expect( + remoteCall.mock.calls.filter(([, method]) => method === 'orchestration.federationPull') + ).toHaveLength(1) + gate.release() + await Promise.all([oldSync, freshSync]) + + expect( + remoteCall.mock.calls.filter(([, method]) => method === 'orchestration.federationPull') + ).toHaveLength(2) + expect( + remoteCall.mock.calls.filter(([, method]) => method === 'orchestration.federationAck') + ).toHaveLength(1) + }) + + it('starts a new sync when the orchestration database changes in flight', async () => { + const { runtime, remoteCall, blockAck, replaceDb } = createIdleSyncHarness() + const gate = blockAck() + + const oldSync = runtime.syncOrchestrationFederatedDispatch('dispatch_remote') + await gate.reached + replaceDb() + const newSync = runtime.syncOrchestrationFederatedDispatch('dispatch_remote') + expect(newSync).not.toBe(oldSync) + await vi.waitFor(() => + expect( + remoteCall.mock.calls.filter(([, method]) => method === 'orchestration.federationAck') + ).toHaveLength(2) + ) + gate.release() + await Promise.all([oldSync, newSync]) + await runtime.syncOrchestrationFederatedDispatch('dispatch_remote') + + expect( + remoteCall.mock.calls.filter(([, method]) => method === 'orchestration.federationAck') + ).toHaveLength(2) + }) + + it('starts a new sync when relay state resets in flight', async () => { + const { runtime, remoteCall, blockAck } = createIdleSyncHarness() + const gate = blockAck() + + const oldSync = runtime.syncOrchestrationFederatedDispatch('dispatch_remote') + await gate.reached + runtime.stopOrchestrationFederationRelay() + const newSync = runtime.syncOrchestrationFederatedDispatch('dispatch_remote') + expect(newSync).not.toBe(oldSync) + await vi.waitFor(() => + expect( + remoteCall.mock.calls.filter(([, method]) => method === 'orchestration.federationAck') + ).toHaveLength(2) + ) + gate.release() + await Promise.all([oldSync, newSync]) + await runtime.syncOrchestrationFederatedDispatch('dispatch_remote') + + expect( + remoteCall.mock.calls.filter(([, method]) => method === 'orchestration.federationAck') + ).toHaveLength(2) + }) + + it('matches checkpoints only to their exact remote identity and never moves backward', () => { + const runtime = {} as OrcaRuntimeService + const identity: FederationAckIdentity = { + environmentId: 'environment_windows', + peerFingerprint: 'windows_peer_fingerprint', + remoteRuntimeEpoch: 'remote_epoch_1' + } + const lease = acquireFederationAckLease(runtime, 'dispatch_remote') + recordFederationAckCheckpoint(runtime, lease, { + ...identity, + throughSequence: 2 + }) + + recordFederationAckCheckpoint(runtime, lease, { + ...identity, + throughSequence: 3 + }) + recordFederationAckCheckpoint(runtime, lease, { + ...identity, + throughSequence: 2 + }) + + expect(getFederationAckedThrough(lease, identity)).toBe(3) + expect( + getFederationAckedThrough(lease, { ...identity, remoteRuntimeEpoch: 'remote_epoch_2' }) + ).toBe(0) + expect( + getFederationAckedThrough(lease, { ...identity, peerFingerprint: 'replacement_peer' }) + ).toBe(0) + expect(getFederationAckedThrough(lease, { ...identity, environmentId: 'replacement' })).toBe(0) + }) + + it('fences delayed writes after runtime reset', () => { + const runtime = {} as OrcaRuntimeService + const identity: FederationAckIdentity = { + environmentId: 'environment_windows', + peerFingerprint: 'windows_peer_fingerprint', + remoteRuntimeEpoch: 'remote_epoch_1' + } + const staleRuntimeLease = acquireFederationAckLease(runtime, 'dispatch_remote') + clearFederationAckCheckpoints(runtime) + recordFederationAckCheckpoint(runtime, staleRuntimeLease, { + ...identity, + throughSequence: 2 + }) + expect( + getFederationAckedThrough(acquireFederationAckLease(runtime, 'dispatch_remote'), identity) + ).toBe(0) + }) +}) diff --git a/src/main/runtime/orchestration/federation-sync.ts b/src/main/runtime/orchestration/federation-sync.ts index 85856883aa9..5ad4f1fc09a 100644 --- a/src/main/runtime/orchestration/federation-sync.ts +++ b/src/main/runtime/orchestration/federation-sync.ts @@ -6,6 +6,11 @@ import { } from './types' import type { OrcaRuntimeService } from '../orca-runtime' import { OrchestrationError } from './orchestration-error' +import { + acquireFederationAckLease, + getFederationAckedThrough, + recordFederationAckCheckpoint +} from './federation-ack-checkpoints' const MESSAGE_TYPE_SET = new Set(MESSAGE_TYPES) @@ -52,6 +57,7 @@ export async function syncFederatedDispatch( `Saved environment ${federated.environment_name} now identifies a different Orca server.` ) } + const ackLease = acquireFederationAckLease(runtime, dispatchId) const pulled = (await runtime.callOrchestrationWorkerServer( federated.environment_id, @@ -95,7 +101,12 @@ export async function syncFederatedDispatch( imported += stored.duplicate ? 0 : 1 } - if (cursor > 0) { + const ackIdentity = { + environmentId: federated.environment_id, + peerFingerprint: federated.peer_fingerprint, + remoteRuntimeEpoch: pulled.runtimeEpoch + } + if (cursor > getFederationAckedThrough(ackLease, ackIdentity)) { await runtime.callOrchestrationWorkerServer( federated.environment_id, 'orchestration.federationAck', @@ -103,6 +114,10 @@ export async function syncFederatedDispatch( 15_000, { orchestrationRequestId: `relay_ack_${dispatchId}_${cursor}` } ) + recordFederationAckCheckpoint(runtime, ackLease, { + ...ackIdentity, + throughSequence: cursor + }) } const toWorker = db.getWorkerDispatch(dispatchId)?.state === 'ready' diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index 43ac0356609..e59f0551863 100644 --- a/src/main/runtime/rpc/methods/client-ui-schemas.ts +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -19,6 +19,7 @@ import { } from '../../../../shared/worktree-card-properties' import { isPluginPanelTabKey } from '../../../../shared/plugins/plugin-manifest' import type { TaskProvider } from '../../../../shared/types' +import { ClientUiWorkspaceFilterFields } from './client-ui-workspace-filter-fields' import { TaskResumeState } from './task-resume-state-schema' import { WorkspaceCleanup } from './workspace-cleanup-ui-schema' import { omitUndefinedValues, tolerateUnknownValues } from './ui-update-value-tolerance' @@ -202,15 +203,10 @@ const UiUpdateFields = z manualRepoOrder: z .array(z.object({ hostId: z.string(), repoId: z.string() }).strict()) .optional(), - hideDefaultBranchWorkspace: z.boolean().optional(), - hideAutomationGeneratedWorkspaces: z.boolean().optional(), + ...ClientUiWorkspaceFilterFields, // Why: rides App.tsx's debounced writer, so omitting it rejected that entire // payload (sidebar widths, filters, agent acks) for every paired client. showDotfilesByWorktree: z.record(z.string(), z.boolean()).optional(), - hideCliCreatedWorkspaces: z.boolean().optional(), - hideDetachedHeadWorkspaces: z.boolean().optional(), - alwaysShowDefaultBranchWorkspace: z.boolean().optional(), - filterRepoIds: StringArray.optional(), collapsedGroups: StringArray.optional(), uiZoomLevel: z.number().finite().optional(), editorFontZoomLevel: z.number().finite().optional(), diff --git a/src/main/runtime/rpc/methods/client-ui-workspace-filter-fields.ts b/src/main/runtime/rpc/methods/client-ui-workspace-filter-fields.ts new file mode 100644 index 00000000000..d0239b03ec3 --- /dev/null +++ b/src/main/runtime/rpc/methods/client-ui-workspace-filter-fields.ts @@ -0,0 +1,11 @@ +import { z } from 'zod' + +export const ClientUiWorkspaceFilterFields = { + hideDefaultBranchWorkspace: z.boolean().optional(), + hideAutomationGeneratedWorkspaces: z.boolean().optional(), + hideCliCreatedWorkspaces: z.boolean().optional(), + hideDetachedHeadWorkspaces: z.boolean().optional(), + hideWorkspacesFromOtherDevices: z.boolean().optional(), + alwaysShowDefaultBranchWorkspace: z.boolean().optional(), + filterRepoIds: z.array(z.string()).optional() +} diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index 2a4b17b455e..18c747c242c 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -422,6 +422,24 @@ describe('client UI RPC methods', () => { }) }) + it('does not let a paired client replace the host workspace origin filter', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateUIState: vi.fn(() => getDefaultUIState()) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('ui.set', { + hideWorkspacesFromOtherDevices: true, + sidebarWidth: 280 + }) + ) + + expect(response).toMatchObject({ ok: true }) + expect(runtime.updateUIState).toHaveBeenCalledWith({ sidebarWidth: 280 }) + }) + it('accepts persisted literal UI arrays and nested UI state', async () => { const updated: PersistedUIState = { ...getDefaultUIState(), diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index a10f1b66708..540333bf5ac 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -55,9 +55,11 @@ export const CLIENT_UI_METHODS: RpcMethod[] = [ defineMethod({ name: 'ui.set', params: UiUpdate, - handler: (params, { runtime }) => ({ - ui: runtime.updateUIState(params as Partial) - }) + handler: (params, { runtime }) => { + const { hideWorkspacesFromOtherDevices: _clientLocalFilter, ...hostUpdates } = params + void _clientLocalFilter + return { ui: runtime.updateUIState(hostUpdates as Partial) } + } }), defineMethod({ name: 'ui.recordFeatureInteraction', diff --git a/src/main/runtime/rpc/methods/folder-workspace.ts b/src/main/runtime/rpc/methods/folder-workspace.ts index baf03ada5d0..9f36b7eb4dc 100644 --- a/src/main/runtime/rpc/methods/folder-workspace.ts +++ b/src/main/runtime/rpc/methods/folder-workspace.ts @@ -5,6 +5,7 @@ import { isTuiAgent } from '../../../../shared/tui-agent-config' import { TaskSourceContextSchema } from '../../../../shared/task-source-context-schema' import { WorkspaceLinkedItemSchema } from '../../../../shared/workspace-linked-item-schema' import { isWorkspaceLinkedItemSourceContextMatch } from '../../../../shared/workspace-linked-item-source-context' +import { resolveRpcWorkspaceCreatorProvenance } from '../workspace-creator-context' const FolderWorkspaceLinkedTask = WorkspaceLinkedItemSchema.nullable() @@ -94,8 +95,11 @@ export const FOLDER_WORKSPACE_METHODS: RpcMethod[] = [ defineMethod({ name: 'folderWorkspace.create', params: FolderWorkspaceCreate, - handler: async (params, { runtime }) => ({ - folderWorkspace: await runtime.createFolderWorkspace(params) + handler: async (params, context) => ({ + folderWorkspace: await context.runtime.createFolderWorkspace({ + ...params, + creatorProvenance: resolveRpcWorkspaceCreatorProvenance(context) + }) }) }), defineMethod({ diff --git a/src/main/runtime/rpc/methods/github.test.ts b/src/main/runtime/rpc/methods/github.test.ts index 0a283b52072..72a4e150077 100644 --- a/src/main/runtime/rpc/methods/github.test.ts +++ b/src/main/runtime/rpc/methods/github.test.ts @@ -192,6 +192,32 @@ describe('github RPC methods', () => { expect(response).toMatchObject({ ok: true, result: [] }) }) + it('forwards request cancellation to PR check-details work', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getRepoPRCheckDetails: vi.fn().mockResolvedValue(null) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + const controller = new AbortController() + + await dispatcher.dispatch( + makeRequest('github.prCheckDetails', { repo: 'repo-1', checkRunId: 9 }), + { signal: controller.signal } + ) + + expect(runtime.getRepoPRCheckDetails).toHaveBeenCalledWith( + 'repo-1', + { + checkRunId: 9, + workflowRunId: undefined, + checkName: undefined, + url: undefined, + prRepo: null + }, + controller.signal + ) + }) + it('fetches PR comments on the runtime server with explicit PR repo', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/github.ts b/src/main/runtime/rpc/methods/github.ts index ed715e4e0e8..c9f8935b128 100644 --- a/src/main/runtime/rpc/methods/github.ts +++ b/src/main/runtime/rpc/methods/github.ts @@ -439,14 +439,18 @@ export const GITHUB_METHODS: RpcMethod[] = [ defineMethod({ name: 'github.prCheckDetails', params: PullRequestCheckDetails, - handler: async (params, { runtime }) => - runtime.getRepoPRCheckDetails(params.repo, { - checkRunId: params.checkRunId, - workflowRunId: params.workflowRunId, - checkName: params.checkName, - url: params.url, - prRepo: params.prRepo ?? null - }) + handler: async (params, { runtime, signal }) => + runtime.getRepoPRCheckDetails( + params.repo, + { + checkRunId: params.checkRunId, + workflowRunId: params.workflowRunId, + checkName: params.checkName, + url: params.url, + prRepo: params.prRepo ?? null + }, + signal + ) }), defineMethod({ name: 'github.rerunPRChecks', diff --git a/src/main/runtime/rpc/methods/hosted-review.test.ts b/src/main/runtime/rpc/methods/hosted-review.test.ts index 3d132e82e41..ba4b05790b7 100644 --- a/src/main/runtime/rpc/methods/hosted-review.test.ts +++ b/src/main/runtime/rpc/methods/hosted-review.test.ts @@ -162,4 +162,39 @@ describe('hosted review RPC methods', () => { result: { ok: true, number: 51 } }) }) + + it('dispatches stacked creation through a distinct runtime method', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + createStackedHostedReview: vi.fn().mockResolvedValue({ + ok: true, + number: 52, + url: 'https://github.com/acme/orca/pull/52', + stackNumber: 60, + parentReview: { number: 51, url: 'https://github.com/acme/orca/pull/51' } + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: HOSTED_REVIEW_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('hostedReview.createStacked', { + repo: 'repo-1', + worktree: 'path:/worktrees/child', + provider: 'github', + base: 'stack/parent', + head: 'stack/child', + title: 'Child' + }) + ) + + expect(runtime.createStackedHostedReview).toHaveBeenCalledWith({ + repoSelector: 'repo-1', + worktreeSelector: 'path:/worktrees/child', + provider: 'github', + base: 'stack/parent', + head: 'stack/child', + title: 'Child' + }) + expect(response).toMatchObject({ ok: true, result: { ok: true, stackNumber: 60 } }) + }) }) diff --git a/src/main/runtime/rpc/methods/hosted-review.ts b/src/main/runtime/rpc/methods/hosted-review.ts index b658c4c74cb..0d48b02fb19 100644 --- a/src/main/runtime/rpc/methods/hosted-review.ts +++ b/src/main/runtime/rpc/methods/hosted-review.ts @@ -105,5 +105,21 @@ export const HOSTED_REVIEW_METHODS: RpcMethod[] = [ draft: params.draft, useTemplate: params.useTemplate }) + }), + defineMethod({ + name: 'hostedReview.createStacked', + params: HostedReviewCreate, + handler: async (params, { runtime }) => + runtime.createStackedHostedReview({ + repoSelector: params.repo, + worktreeSelector: params.worktree, + provider: params.provider, + base: params.base, + head: params.head, + title: params.title, + body: params.body, + draft: params.draft, + useTemplate: params.useTemplate + }) }) ] diff --git a/src/main/runtime/rpc/methods/orchestration-federation.test.ts b/src/main/runtime/rpc/methods/orchestration-federation.test.ts index e797a09c256..94674ee84cc 100644 --- a/src/main/runtime/rpc/methods/orchestration-federation.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-federation.test.ts @@ -508,7 +508,7 @@ describe('orchestration federation', () => { it('retries a lost relay acknowledgment without duplicating the home message', async () => { const task = createHomeTask() await homeDispatcher.dispatch(startRequest(task.id)) - const dispatch = homeDb.getDispatchContext(task.id)! + homeRuntime.stopOrchestrationFederationRelay() const prompt = vi.mocked(workerRuntime.sendTerminalAgentPrompt).mock.calls[0]?.[1] ?? '' const capability = prompt.match(/--dispatch-capability (dcap_[A-Za-z0-9_-]+)/)?.[1] await workerDispatcher.dispatch({ @@ -526,6 +526,7 @@ describe('orchestration federation', () => { } }) loseNextAckResponse = true + const remoteCall = vi.spyOn(homeRuntime, 'callOrchestrationWorkerServer') await expect(homeRuntime.syncOrchestrationFederation()).resolves.toBeUndefined() await homeRuntime.syncOrchestrationFederation() @@ -535,7 +536,10 @@ describe('orchestration federation', () => { .getRunMailboxHistory(task.run_id, 10) .filter((message) => message.subject === 'Checkpoint') ).toHaveLength(1) - expect(homeDb.getFederatedDispatch(dispatch.id)?.to_home_imported_sequence).toBe(1) + const acknowledgments = remoteCall.mock.calls.filter( + ([, method]) => method === 'orchestration.federationAck' + ) + expect(acknowledgments).toHaveLength(2) }) it('rejects a reordered relay gap, then converges without loss or duplication', async () => { diff --git a/src/main/runtime/rpc/methods/orchestration-worker-control.ts b/src/main/runtime/rpc/methods/orchestration-worker-control.ts index 5798806d899..87133ede7b2 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-control.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-control.ts @@ -4,7 +4,6 @@ import { type OrchestrationWorkerReadResult } from '../../../../shared/orchestration-worker-output' import { OrchestrationError } from '../../orchestration/orchestration-error' -import { syncFederatedDispatch } from '../../orchestration/federation-sync' import { defineMethod, type RpcMethod } from '../core' import { OptionalFiniteNumber, requiredString } from '../schemas' import { @@ -54,7 +53,9 @@ export const ORCHESTRATION_WORKER_CONTROL_METHODS: RpcMethod[] = [ attachment.state === 'succeeded' || (attachment.state === 'failed' && attachment.stage === 'worker_report_queued') ) { - await syncFederatedDispatch(runtime, params.dispatch).catch(() => undefined) + await runtime + .syncOrchestrationFederatedDispatchAfterCurrent(params.dispatch) + .catch(() => undefined) } else if ( attachment.state === 'stopped' && ['stopping', 'stop_unknown'].includes(worker.state) diff --git a/src/main/runtime/rpc/methods/orchestration-worker-stop.ts b/src/main/runtime/rpc/methods/orchestration-worker-stop.ts index f393e65012a..0802fac3954 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-stop.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-stop.ts @@ -1,6 +1,5 @@ import { z } from 'zod' import { OrchestrationError } from '../../orchestration/orchestration-error' -import { syncFederatedDispatch } from '../../orchestration/federation-sync' import { defineMethod, type RpcMethod } from '../core' import { requiredString } from '../schemas' import { @@ -49,7 +48,9 @@ export const ORCHESTRATION_WORKER_STOP_METHODS: RpcMethod[] = [ } if (remote.state === 'succeeded' || remote.state === 'failed') { db.resumeFederatedWorkerForTerminalRelay(params.dispatch) - await syncFederatedDispatch(runtime, params.dispatch).catch(() => undefined) + await runtime + .syncOrchestrationFederatedDispatchAfterCurrent(params.dispatch) + .catch(() => undefined) return { dispatchId: params.dispatch, state: db.getWorkerDispatch(params.dispatch)?.state ?? remote.state, diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index d9d91237d08..6fb19e0a4c1 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -1788,11 +1788,14 @@ describe('orchestration RPC methods', () => { }) }) - it('rejects invalid deps JSON', async () => { + it('rejects non-JSON deps', async () => { setup() await expect( call('orchestration.taskCreate', { spec: 'bad', deps: 'not-json' }) ).rejects.toThrow('Invalid --deps') + await expect( + call('orchestration.taskCreate', { spec: 'bad', deps: '[task_example]' }) + ).rejects.toThrow('Invalid --deps') }) }) @@ -3075,9 +3078,11 @@ describe('orchestration RPC methods', () => { it('resets all state', async () => { setup() seedResetState() + const stopRelay = vi.spyOn(runtime, 'stopOrchestrationFederationRelay') const result = (await call('orchestration.reset', { all: true })) as { reset: string } expect(result.reset).toBe('all') + expect(stopRelay).toHaveBeenCalledOnce() expect(db.getInbox()).toHaveLength(0) expect(db.listTasks()).toHaveLength(0) }) @@ -3085,8 +3090,10 @@ describe('orchestration RPC methods', () => { it('resets tasks only', async () => { setup() seedResetState() + const stopRelay = vi.spyOn(runtime, 'stopOrchestrationFederationRelay') await call('orchestration.reset', { tasks: true }) + expect(stopRelay).toHaveBeenCalledOnce() expect(db.getInbox()).toHaveLength(1) expect(db.listTasks()).toHaveLength(0) }) @@ -3094,8 +3101,10 @@ describe('orchestration RPC methods', () => { it('resets messages only', async () => { setup() seedResetState() + const stopRelay = vi.spyOn(runtime, 'stopOrchestrationFederationRelay') await call('orchestration.reset', { messages: true }) + expect(stopRelay).not.toHaveBeenCalled() expect(db.getInbox()).toHaveLength(0) expect(db.listTasks()).toHaveLength(1) }) diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index 22518b3717b..fdcb9168e69 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -1527,10 +1527,12 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ handler: (params, { runtime }) => { const db = runtime.getOrchestrationDb() if (params.all) { + runtime.stopOrchestrationFederationRelay() db.resetAll() return { reset: 'all' } } if (params.tasks) { + runtime.stopOrchestrationFederationRelay() db.resetTasks() return { reset: 'tasks' } } diff --git a/src/main/runtime/rpc/methods/repo.test.ts b/src/main/runtime/rpc/methods/repo.test.ts index df5bbff67b8..09991c4103e 100644 --- a/src/main/runtime/rpc/methods/repo.test.ts +++ b/src/main/runtime/rpc/methods/repo.test.ts @@ -440,7 +440,8 @@ describe('repo RPC methods', () => { expect(runtime.listFolderWorkspaces).toHaveBeenCalled() expect(runtime.createFolderWorkspace).toHaveBeenCalledWith({ projectGroupId: group.id, - name: 'Refund fix' + name: 'Refund fix', + creatorProvenance: { kind: 'host' } }) expect(runtime.updateFolderWorkspace).toHaveBeenCalledWith('folder-workspace-1', { comment: 'x' diff --git a/src/main/runtime/rpc/methods/status.ts b/src/main/runtime/rpc/methods/status.ts index fe9cb78ce45..03d66f84fb1 100644 --- a/src/main/runtime/rpc/methods/status.ts +++ b/src/main/runtime/rpc/methods/status.ts @@ -5,10 +5,11 @@ export const STATUS_METHODS: RpcMethod[] = [ defineMethod({ name: 'status.get', params: null, - handler: (_params, { runtime }) => { + handler: (_params, { runtime, pairedDeviceId }) => { const snapshot = getRemoteServerUpdaterSnapshot(runtime.getRuntimeId()) return { ...runtime.getStatus(), + ...(pairedDeviceId ? { pairedDeviceId } : {}), appVersion: snapshot.appVersion, remoteUpdateSupport: snapshot.support } diff --git a/src/main/runtime/rpc/methods/worktree.test.ts b/src/main/runtime/rpc/methods/worktree.test.ts index 3fe4b5da15c..0b5be4da384 100644 --- a/src/main/runtime/rpc/methods/worktree.test.ts +++ b/src/main/runtime/rpc/methods/worktree.test.ts @@ -155,6 +155,7 @@ describe('worktree RPC methods', () => { setupDecision: 'skip', createdWithAgent: undefined, automationProvenance: undefined, + creatorProvenance: { kind: 'host' }, startup: undefined, startupDraft: undefined, lineage: { diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index acc2dab0537..3d2a05b523f 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -7,6 +7,7 @@ import { buildCliWorkspaceProvenance } from '../../../../shared/cli-workspace-pr import { defineMethod, type RpcMethod } from '../core' import { resolveWorktreeCatalogSnapshot } from '../worktree-catalog-snapshot' import { resolveRuntimeNavigationTarget } from '../../../../shared/runtime-navigation' +import { resolveRpcWorkspaceCreatorProvenance } from '../workspace-creator-context' import { WorktreeCreate, WorktreeDetectedListParams, @@ -96,11 +97,12 @@ export const WORKTREE_METHODS: RpcMethod[] = [ defineMethod({ name: 'worktree.create', params: WorktreeCreate, - handler: async (params, { runtime }) => + handler: async (params, context) => // Why: a mobile create interrupted by a connection migration is retried with // the same clientMutationId; dedupe so the host returns the in-flight/created // worktree instead of spawning a duplicate. No key (desktop/CLI) runs plainly. - runtime.dedupeWorktreeCreate(params.repo, params.clientMutationId, async () => { + context.runtime.dedupeWorktreeCreate(params.repo, params.clientMutationId, async () => { + const { runtime } = context const repo = await runtime.showRepo(params.repo) const automationProvenance = resolveAutomationWorkspaceProvenance({ authority: runtime, @@ -145,6 +147,7 @@ export const WORKTREE_METHODS: RpcMethod[] = [ startupAgent: params.startupAgent ?? params.createdWithAgent, createdAt: Date.now() }), + creatorProvenance: resolveRpcWorkspaceCreatorProvenance(context), startup: params.startupCommand ? { command: params.startupCommand, diff --git a/src/main/runtime/rpc/terminal-stream-byte-length.test.ts b/src/main/runtime/rpc/terminal-stream-byte-length.test.ts index bf07fe38fc8..60c4c86fe44 100644 --- a/src/main/runtime/rpc/terminal-stream-byte-length.test.ts +++ b/src/main/runtime/rpc/terminal-stream-byte-length.test.ts @@ -7,9 +7,24 @@ import { } from './terminal-stream-byte-length' import { TERMINAL_OUTPUT_BATCH_MAX_BYTES } from '../../../shared/terminal-multiplex-flow-control' -// Byte-for-byte copy of the pre-change implementation (shared/clipboard-text.ts +// Copy of the pre-change implementation (shared/clipboard-text.ts // measureClipboardTextByteLength), kept here so equivalence is checked against the -// ACTUAL old code path rather than a paraphrase of it. +// ACTUAL old code path rather than a paraphrase of it. The one deliberate deviation is +// `legacyCodePointAt`: raw `String.prototype.codePointAt` reads one code unit past the end +// of a sliced string once V8 optimizes its caller, so the naive copy is not a stable +// reference. See src/shared/utf8-byte-limits.ts (readUtf8CodePointAt). +function legacyCodePointAt(text: string, index: number): number { + const leadUnit = text.charCodeAt(index) + if (leadUnit < 0xd800 || leadUnit > 0xdbff || index + 1 >= text.length) { + return leadUnit + } + const trailUnit = text.charCodeAt(index + 1) + if (trailUnit < 0xdc00 || trailUnit > 0xdfff) { + return leadUnit + } + return (leadUnit - 0xd800) * 0x400 + (trailUnit - 0xdc00) + 0x10000 +} + function legacyUtf8ByteLengthForCodePoint(codePoint: number): number { if (codePoint <= 0x7f) { return 1 @@ -30,7 +45,7 @@ function legacyMeasure( const stopAfterBytes = options.stopAfterBytes let byteLength = 0 for (let index = 0; index < text.length; index += 1) { - const codePoint = text.codePointAt(index) ?? 0 + const codePoint = legacyCodePointAt(text, index) byteLength += legacyUtf8ByteLengthForCodePoint(codePoint) if (Number.isFinite(stopAfterBytes) && byteLength > (stopAfterBytes ?? 0)) { return { byteLength, exceededLimit: true } @@ -128,6 +143,39 @@ const EDGE_STRINGS = [ `${'é'.repeat(500)}\ud800` ] +// Regression for the intermittent "measurement diverged at 13 units" failure: the fuzzers +// build a rope and cut it at a fixed code-unit count, which can split a surrogate pair and +// leave the low half in the parent just past the slice. Optimized `codePointAt` pairs across +// that boundary, so the scan measured one byte too many, but only after the enclosing function +// tiered up, which made the failure look load-dependent. 13 code units is V8's minimum length +// for a sliced string, which is why the divergence started exactly there. +describe('measuring a prefix slice that cuts a surrogate pair in half', () => { + // Kept first in the file so the scan is still specializing on this shape when it tiers up. + it('measures the slice like the encoder does in every JIT tier', () => { + const sliced = 'abcdefghijkl\u{1f600}'.slice(0, 13) + expect(sliced.length).toBe(13) + expect(sliced.charCodeAt(12)).toBe(0xd83d) + // 12 ASCII bytes plus U+FFFD for the orphaned high surrogate. + expect(Buffer.byteLength(sliced, 'utf8')).toBe(15) + + const observedByteLengths = new Set() + const observedExceeded = new Set() + const observedMeasurements = new Set() + for (let iteration = 0; iteration < 200_000; iteration += 1) { + observedByteLengths.add(terminalStreamByteLength(sliced)) + observedExceeded.add(terminalStreamByteLengthExceeds(sliced, 15)) + observedMeasurements.add( + JSON.stringify(measureTerminalStreamByteLength(sliced, { stopAfterBytes: 15 })) + ) + } + expect([...observedByteLengths]).toEqual([15]) + expect([...observedExceeded]).toEqual([false]) + expect([...observedMeasurements]).toEqual([ + JSON.stringify({ byteLength: 15, exceededLimit: false }) + ]) + }) +}) + describe('terminal stream byte length equivalence with the legacy code-point scan', () => { it('matches the legacy total byte length on edge strings', () => { for (const text of EDGE_STRINGS) { diff --git a/src/main/runtime/rpc/workspace-creator-context.ts b/src/main/runtime/rpc/workspace-creator-context.ts new file mode 100644 index 00000000000..de0b27965fd --- /dev/null +++ b/src/main/runtime/rpc/workspace-creator-context.ts @@ -0,0 +1,14 @@ +import type { WorkspaceCreatorProvenance } from '../../../shared/types' +import type { RpcContext } from './core' + +export function resolveRpcWorkspaceCreatorProvenance( + context: Pick +): WorkspaceCreatorProvenance { + if (context.pairedDeviceId) { + return { kind: 'paired-device', deviceId: context.pairedDeviceId } + } + if (context.clientId || context.clientKind || context.connectionId) { + throw new Error('authenticated_device_identity_missing') + } + return { kind: 'host' } +} diff --git a/src/main/runtime/runtime-folder-workspace.ts b/src/main/runtime/runtime-folder-workspace.ts index ba17a7e03e1..fcbbacbd51d 100644 --- a/src/main/runtime/runtime-folder-workspace.ts +++ b/src/main/runtime/runtime-folder-workspace.ts @@ -1,6 +1,7 @@ import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' import type { Repo, Worktree, WorktreeMeta } from '../../shared/types' import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree-id' +import { normalizeWorkspaceCreatorProvenance } from '../../shared/workspace-creator-provenance' export function getRuntimeFolderWorkspaceRootId(repo: Repo): string { return `${repo.id}::${repo.path}` @@ -23,6 +24,7 @@ export function mergeRuntimeFolderWorkspace( worktreeId: string, meta: WorktreeMeta ): Worktree { + const creatorProvenance = normalizeWorkspaceCreatorProvenance(meta.creatorProvenance) return { id: worktreeId, ...(meta.instanceId !== undefined ? { instanceId: meta.instanceId } : {}), @@ -32,6 +34,7 @@ export function mergeRuntimeFolderWorkspace( ...(meta.projectHostSetupId !== undefined ? { projectHostSetupId: meta.projectHostSetupId } : {}), + ...(creatorProvenance ? { creatorProvenance } : {}), path: repo.path, head: '', branch: '', diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index f4d5006303a..b783382c544 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -316,6 +316,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'host.wsl.isAvailable', 'host.wsl.listDistros', 'hostedReview.create', + 'hostedReview.createStacked', 'hostedReview.forBranch', 'hostedReview.getCreationEligibility', 'linear.getCustomView', @@ -715,6 +716,7 @@ export class OrcaRuntimeRpcServer { endpoint, deviceToken: device.token, publicKeyB64, + pairedDeviceId: device.deviceId, scope }) return { @@ -927,6 +929,7 @@ export class OrcaRuntimeRpcServer { endpoint: direct.endpoint, deviceToken: device.token, publicKeyB64, + pairedDeviceId: device.deviceId, scope: 'mobile', relay: relayPairing.relay }) diff --git a/src/main/source-control/forge-provider.test.ts b/src/main/source-control/forge-provider.test.ts index ae5e5623a97..c31d84b3c87 100644 --- a/src/main/source-control/forge-provider.test.ts +++ b/src/main/source-control/forge-provider.test.ts @@ -1,7 +1,9 @@ +import { supportsHostedReviewCreation } from '../../shared/hosted-review-creation-providers' import { beforeEach, describe, expect, it, vi } from 'vitest' const { createGitHubPullRequestMock, + createBitbucketPullRequestMock, createGitLabMergeRequestMock, createAzureDevOpsPullRequestMock, createGiteaPullRequestMock, @@ -16,6 +18,7 @@ const { getEnterpriseGitHubRepoSlugMock } = vi.hoisted(() => ({ createGitHubPullRequestMock: vi.fn(), + createBitbucketPullRequestMock: vi.fn(), createGitLabMergeRequestMock: vi.fn(), createAzureDevOpsPullRequestMock: vi.fn(), createGiteaPullRequestMock: vi.fn(), @@ -60,6 +63,10 @@ vi.mock('../bitbucket/client', () => ({ getBitbucketPullRequest: vi.fn() })) +vi.mock('../bitbucket/pull-request-creation', () => ({ + createBitbucketPullRequest: createBitbucketPullRequestMock +})) + vi.mock('../azure-devops/client', () => ({ getAzureDevOpsRepoSlug: getAzureDevOpsRepoSlugMock, getAzureDevOpsPullRequestForBranch: vi.fn(), @@ -99,6 +106,7 @@ describe('forge provider interface', () => { beforeEach(() => { createGitHubPullRequestMock.mockReset() createGitLabMergeRequestMock.mockReset() + createBitbucketPullRequestMock.mockReset() createAzureDevOpsPullRequestMock.mockReset() createGiteaPullRequestMock.mockReset() getAzureDevOpsRepoSlugMock.mockReset() @@ -170,10 +178,16 @@ describe('forge provider interface', () => { ).toEqual([ ['gitlab', true], ['github', true], - ['bitbucket', false], + ['bitbucket', true], ['azure-devops', true], ['gitea', true] ]) + // Why: the shared list is what the Create blocker and the renderer read. + // When it drifted from this one, Bitbucket had a working createReview but + // still reported "provider does not support creating a pull request". + for (const provider of FORGE_PROVIDERS) { + expect(supportsHostedReviewCreation(provider.id)).toBe(provider.supportsReviewCreation) + } createGitHubPullRequestMock.mockResolvedValue({ ok: true, number: 12, @@ -201,6 +215,28 @@ describe('forge provider interface', () => { }) }) + it('routes Bitbucket review creation through the shared provider contract', async () => { + createBitbucketPullRequestMock.mockResolvedValue({ + ok: true, + number: 23, + url: 'https://bitbucket.org/team/orca/pull-requests/23' + }) + + const provider = getForgeProviderById('bitbucket') + const input = { + provider: 'bitbucket' as const, + base: 'main', + head: 'feature/provider-interface', + title: 'Add provider interface' + } + await expect(provider.createReview?.('/repo', input)).resolves.toEqual({ + ok: true, + number: 23, + url: 'https://bitbucket.org/team/orca/pull-requests/23' + }) + expect(createBitbucketPullRequestMock).toHaveBeenCalledWith('/repo', input) + }) + it('routes GitLab review creation through the shared provider contract', async () => { createGitLabMergeRequestMock.mockResolvedValue({ ok: true, diff --git a/src/main/source-control/forge-provider.ts b/src/main/source-control/forge-provider.ts index 19526ad71fa..775943052da 100644 --- a/src/main/source-control/forge-provider.ts +++ b/src/main/source-control/forge-provider.ts @@ -15,6 +15,7 @@ import { getBitbucketPullRequestForBranchOrThrow, getBitbucketRepoSlug } from '../bitbucket/client' +import { createBitbucketPullRequest } from '../bitbucket/pull-request-creation' import { getGiteaPullRequest, getGiteaPullRequestForBranchOrThrow, @@ -198,7 +199,7 @@ const gitHubForgeProvider = { const bitbucketForgeProvider = { id: 'bitbucket', - supportsReviewCreation: false, + supportsReviewCreation: true, resolveRepository: (context) => getBitbucketRepoSlug( context.repoPath, @@ -225,7 +226,8 @@ const bitbucketForgeProvider = { ...hostedReviewExecutionArgs(input) ) return pr ? mapBitbucketReview(pr) : null - } + }, + createReview: createBitbucketPullRequest } satisfies ForgeProvider const azureDevOpsForgeProvider = { diff --git a/src/main/source-control/hosted-review-creation-eligibility.test.ts b/src/main/source-control/hosted-review-creation-eligibility.test.ts index f2fa711ee54..2cf262e8cca 100644 --- a/src/main/source-control/hosted-review-creation-eligibility.test.ts +++ b/src/main/source-control/hosted-review-creation-eligibility.test.ts @@ -472,29 +472,31 @@ describe('getHostedReviewCreationEligibility', () => { blockedReason: null, nextAction: null, defaultBaseRef: 'origin/main', - head: 'feature/create-pr' + head: 'feature/create-pr', + stackedCreationSupported: true }) }) it('detects a GitHub Enterprise Server branch as the GitHub provider (#8312)', async () => { mockGitHubEnterpriseProvider() - await expect( - getHostedReviewCreationEligibility({ - repoPath: '/repo', - branch: 'feature/create-pr', - base: 'origin/main', - hasUncommittedChanges: false, - hasUpstream: true, - ahead: 0, - behind: 0 - }) - ).resolves.toMatchObject({ + const result = await getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/create-pr', + base: 'origin/main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + + expect(result).toMatchObject({ provider: 'github', canCreate: true, blockedReason: null, nextAction: null }) + expect(result).not.toHaveProperty('stackedCreationSupported') // Enterprise auth was already confirmed during detection; the gate must not // fire a redundant gh probe. diff --git a/src/main/source-control/hosted-review-creation.ts b/src/main/source-control/hosted-review-creation.ts index f28c319f3ea..0fa50ef3a32 100644 --- a/src/main/source-control/hosted-review-creation.ts +++ b/src/main/source-control/hosted-review-creation.ts @@ -18,7 +18,10 @@ import { } from '../../shared/hosted-review-creation-providers' import { isAzureDevOpsReviewCreationAuthenticated } from '../azure-devops/pull-request-creation' import { isGiteaReviewCreationAuthenticated } from '../gitea/pull-request-creation' +import { isBitbucketReviewCreationAuthenticated } from '../bitbucket/pull-request-creation' import { getEnterpriseGitHubRepoSlug } from '../github/github-enterprise-repository' +import { getRepoSlug } from '../github/client' +import { isDefaultGitHubHost } from '../../shared/github-repository-identity-key' import { acquire, ghExecFileAsync, gitExecFileAsync, release } from '../github/gh-utils' import { isNoUpstreamError, normalizeGitErrorMessage } from '../../shared/git-remote-error' import type { GitUpstreamStatus } from '../../shared/types' @@ -291,6 +294,14 @@ function reviewCopy(provider: HostedReviewProvider): { authInstruction: 'Set ORCA_GITEA_TOKEN' } } + if (provider === 'bitbucket') { + return { + shortLabel: 'PR', + reviewLabel: 'pull request', + providerName: 'Bitbucket', + authInstruction: 'Connect Bitbucket in Settings > Integrations' + } + } return { shortLabel: 'PR', reviewLabel: 'pull request', @@ -314,6 +325,11 @@ async function isProviderAuthenticated( if (provider === 'gitea') { return isGiteaReviewCreationAuthenticated() } + if (provider === 'bitbucket') { + // Why: falling through to the GitHub check made Create PR unusable for + // anyone with Bitbucket connected but no `gh auth login`. + return isBitbucketReviewCreationAuthenticated() + } return isGitHubAuthenticated(repoPath, connectionId, options) } @@ -521,12 +537,19 @@ export async function getHostedReviewCreationEligibility( : lookupFailed ? 'unavailable' : 'not_found' + const githubRepository = + provider === 'github' + ? await getRepoSlug(args.repoPath, args.connectionId, args).catch(() => null) + : null const baseResult = { provider, review: review ? { number: review.number, url: review.url } : null, reviewLookupOutcome, defaultBaseRef, - head: branch || null + head: branch || null, + ...(githubRepository && isDefaultGitHubHost(githubRepository.host) + ? { stackedCreationSupported: true } + : {}) } if (!branch || branch === 'HEAD') { diff --git a/src/main/source-control/stacked-hosted-review-creation.test.ts b/src/main/source-control/stacked-hosted-review-creation.test.ts new file mode 100644 index 00000000000..7814e66e6c8 --- /dev/null +++ b/src/main/source-control/stacked-hosted-review-creation.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { prepareMock, registerMock, createMock } = vi.hoisted(() => ({ + prepareMock: vi.fn(), + registerMock: vi.fn(), + createMock: vi.fn() +})) + +vi.mock('../github/stacked-pr-creation', () => ({ + prepareGitHubStackedPullRequest: prepareMock, + registerGitHubStackedPullRequest: registerMock +})) + +vi.mock('./hosted-review-creation', () => ({ createHostedReview: createMock })) + +import { createStackedHostedReview } from './stacked-hosted-review-creation' + +const input = { + provider: 'github' as const, + base: 'stack/parent', + head: 'stack/child', + title: 'Child' +} +const repository = { owner: 'acme', repo: 'orca', host: 'github.com' } +const parentReview = { number: 41, url: 'https://github.com/acme/orca/pull/41' } +const currentReview = { number: 42, url: 'https://github.com/acme/orca/pull/42' } + +beforeEach(() => { + prepareMock.mockReset() + registerMock.mockReset() + createMock.mockReset() +}) + +describe('createStackedHostedReview', () => { + it('creates the current PR before registering the stack', async () => { + prepareMock.mockResolvedValue({ + ok: true, + repository, + parentReview, + currentReview: null + }) + createMock.mockResolvedValue({ ok: true, ...currentReview }) + registerMock.mockResolvedValue({ + ok: true, + ...currentReview, + parentReview, + stackNumber: 50 + }) + + const result = await createStackedHostedReview('/repo', input, 'ssh-1') + + expect(result).toMatchObject({ ok: true, stackNumber: 50 }) + expect(createMock).toHaveBeenCalledWith('/repo', input, 'ssh-1', {}) + expect(registerMock).toHaveBeenCalledWith( + expect.objectContaining({ parentReview, currentReview, connectionId: 'ssh-1' }) + ) + }) + + it('retries registration without creating a duplicate PR', async () => { + prepareMock.mockResolvedValue({ + ok: true, + repository, + parentReview, + currentReview + }) + registerMock.mockResolvedValue({ + ok: true, + ...currentReview, + parentReview, + stackNumber: 50 + }) + + await createStackedHostedReview('/repo', input) + + expect(createMock).not.toHaveBeenCalled() + expect(registerMock).toHaveBeenCalledOnce() + }) + + it('does not create a PR when the parent topology is invalid', async () => { + prepareMock.mockResolvedValue({ + ok: false, + code: 'validation', + error: 'Choose the top pull request.' + }) + + const result = await createStackedHostedReview('/repo', input) + + expect(result).toMatchObject({ ok: false, code: 'validation' }) + expect(createMock).not.toHaveBeenCalled() + expect(registerMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/source-control/stacked-hosted-review-creation.ts b/src/main/source-control/stacked-hosted-review-creation.ts new file mode 100644 index 00000000000..0a36588f493 --- /dev/null +++ b/src/main/source-control/stacked-hosted-review-creation.ts @@ -0,0 +1,61 @@ +import type { + CreateStackedHostedReviewInput, + CreateStackedHostedReviewResult, + HostedReviewSummary +} from '../../shared/hosted-review' +import { + prepareGitHubStackedPullRequest, + registerGitHubStackedPullRequest +} from '../github/stacked-pr-creation' +import { createHostedReview } from './hosted-review-creation' +import type { HostedReviewExecutionOptions } from './hosted-review-git-options' + +export async function createStackedHostedReview( + repoPath: string, + input: CreateStackedHostedReviewInput, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +): Promise { + const plan = await prepareGitHubStackedPullRequest(repoPath, input, connectionId, options) + if (!plan.ok) { + return plan + } + + let currentReview: (HostedReviewSummary & { number: number }) | null = plan.currentReview + if (!currentReview) { + const created = await createHostedReview(repoPath, input, connectionId, options) + if (!created.ok) { + if (!created.existingReview?.number) { + return created + } + return { + ok: false, + code: 'validation', + error: + 'An open pull request already exists for this branch but does not target the selected parent branch.', + createdReview: { + number: created.existingReview.number, + url: created.existingReview.url + } + } + } else { + currentReview = { number: created.number, url: created.url } + } + } + if (!currentReview) { + return { + ok: false, + code: 'unknown_completion', + error: 'Pull request creation may have completed. Retry to finish stack registration.' + } + } + + return registerGitHubStackedPullRequest({ + repoPath, + repository: plan.repository, + parentReview: plan.parentReview, + currentReview, + connectionId, + options + }) +} diff --git a/src/main/sqlite/sync-database.test.ts b/src/main/sqlite/sync-database.test.ts new file mode 100644 index 00000000000..34677095b26 --- /dev/null +++ b/src/main/sqlite/sync-database.test.ts @@ -0,0 +1,153 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import SyncDatabase from './sync-database' + +const temporaryDirectories: string[] = [] +const openDatabases: SyncDatabase.Database[] = [] + +async function createDatabase(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'orca-sync-database-')) + temporaryDirectories.push(directory) + const db = new SyncDatabase(join(directory, 'test.db')) + openDatabases.push(db) + db.exec( + 'CREATE TABLE items (id TEXT PRIMARY KEY, label TEXT); ' + + "INSERT INTO items (id, label) VALUES ('a', 'alpha'), ('b', 'beta')" + ) + return db +} + +afterEach(async () => { + for (const db of openDatabases.splice(0)) { + try { + db.close() + } catch { + // already closed by the test + } + } + await Promise.all( + temporaryDirectories.splice(0).map((dir) => rm(dir, { recursive: true, force: true })) + ) +}) + +describe('SyncDatabase statement cache', () => { + it('reuses the same statement object for identical SQL', async () => { + const db = await createDatabase() + const sql = 'SELECT label FROM items WHERE id = ?' + + expect(db.prepare(sql)).toBe(db.prepare(sql)) + expect(db.prepare('SELECT id FROM items WHERE id = ?')).not.toBe(db.prepare(sql)) + }) + + it('returns correct rows when a reused statement is bound to different values', async () => { + const db = await createDatabase() + const sql = 'SELECT label FROM items WHERE id = ?' + + expect(db.prepare(sql).get('a')).toEqual({ label: 'alpha' }) + expect(db.prepare(sql).get('b')).toEqual({ label: 'beta' }) + expect(db.prepare(sql).get('missing')).toBeUndefined() + expect(db.prepare(sql).all('a')).toEqual([{ label: 'alpha' }]) + }) + + it('bounds the cache so per-arity SQL cannot grow it without limit', async () => { + const db = await createDatabase() + const first = 'SELECT 0 AS n' + const firstStatement = db.prepare(first) + for (let index = 1; index <= 256; index += 1) { + db.prepare(`SELECT ${index} AS n`) + } + + expect(db.prepare(first)).not.toBe(firstStatement) + expect(db.prepare('SELECT 256 AS n')).toBe(db.prepare('SELECT 256 AS n')) + }) + + it('drops cached statements on close', async () => { + const db = await createDatabase() + db.prepare('SELECT label FROM items WHERE id = ?') + db.close() + + const cache = (db as unknown as { statementCache: Map }).statementCache + expect(cache.size).toBe(0) + }) + + it('does not serve a stale statement after DDL adds a column', async () => { + const db = await createDatabase() + expect(db.prepare('SELECT * FROM items WHERE id = ?').all('a')).toEqual([ + { id: 'a', label: 'alpha' } + ]) + + db.exec("ALTER TABLE items ADD COLUMN note TEXT; UPDATE items SET note = 'noted'") + + expect(db.prepare('SELECT * FROM items WHERE id = ?').all('a')).toEqual([ + { id: 'a', label: 'alpha', note: 'noted' } + ]) + expect(db.prepare('SELECT label FROM items WHERE id = ?').all('a')).toEqual([ + { label: 'alpha' } + ]) + }) + + it('keeps a cached statement correct when another connection changes the schema', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-sync-database-')) + temporaryDirectories.push(directory) + const path = join(directory, 'shared.db') + const writer = new SyncDatabase(path) + const reader = new SyncDatabase(path) + openDatabases.push(writer, reader) + writer.exec("CREATE TABLE items (id TEXT PRIMARY KEY); INSERT INTO items VALUES ('a')") + const sql = 'SELECT id FROM items ORDER BY id' + expect(reader.prepare(sql).all()).toEqual([{ id: 'a' }]) + + writer.exec("ALTER TABLE items ADD COLUMN note TEXT; INSERT INTO items VALUES ('b', 'noted')") + + expect(reader.prepare(sql).all()).toEqual([{ id: 'a' }, { id: 'b' }]) + expect(reader.prepare('SELECT note FROM items WHERE id = ?').all('b')).toEqual([ + { note: 'noted' } + ]) + }) + + it('does not cache wildcard selects or pragma statements', async () => { + const db = await createDatabase() + + expect(db.prepare('SELECT * FROM items')).not.toBe(db.prepare('SELECT * FROM items')) + expect(db.prepare('PRAGMA table_info(items)')).not.toBe(db.prepare('PRAGMA table_info(items)')) + expect(db.prepare('SELECT COUNT(*) AS n FROM items')).toBe( + db.prepare('SELECT COUNT(*) AS n FROM items') + ) + }) + + it('keeps cached statements across transaction control and other non-DDL exec calls', async () => { + const db = await createDatabase() + const sql = 'SELECT label FROM items WHERE id = ?' + const statement = db.prepare(sql) + + db.exec('BEGIN IMMEDIATE') + db.exec("INSERT INTO items (id, label) VALUES ('c', 'gamma')") + db.exec('COMMIT') + + expect(db.prepare(sql)).toBe(statement) + expect(statement.get('c')).toEqual({ label: 'gamma' }) + }) + + it('preserves pragma and exec behavior', async () => { + const db = await createDatabase() + + expect(db.pragma('journal_mode', { simple: true })).toBe('delete') + expect(db.pragma('table_info(items)')).toEqual([ + expect.objectContaining({ name: 'id' }), + expect.objectContaining({ name: 'label' }) + ]) + expect(db.pragma('table_info(missing_table)')).toEqual([]) + expect(db.pragma('table_info(missing_table)', { simple: true })).toBeUndefined() + }) + + it('rejects a missing file when fileMustExist is set', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-sync-database-')) + temporaryDirectories.push(directory) + + expect(() => new SyncDatabase(join(directory, 'absent.db'), { fileMustExist: true })).toThrow( + /does not exist/ + ) + }) +}) diff --git a/src/main/sqlite/sync-database.ts b/src/main/sqlite/sync-database.ts index 70d8fa1c06a..68faef8b83c 100644 --- a/src/main/sqlite/sync-database.ts +++ b/src/main/sqlite/sync-database.ts @@ -15,6 +15,18 @@ type PragmaOptions = { export type SqliteStatement = StatementSync +// Why: dynamic `IN (?,?,…)` clauses mint a new SQL string per arity, so the cache must stay bounded. +const STATEMENT_CACHE_LIMIT = 256 +const AGGREGATE_STAR = /\(\s*\*\s*\)/g +const PRAGMA_STATEMENT = /^\s*PRAGMA\b/i +const SCHEMA_CHANGING_SQL = /\b(?:ALTER|CREATE|DROP|REINDEX|VACUUM|ATTACH|DETACH)\b/i + +// Why: node:sqlite builds the first post-schema-change row from stale column names, so a reused +// wildcard SELECT can drop a freshly added column; PRAGMAs are one-shot config, never hot-path. +function isStatementCacheable(sql: string): boolean { + return !PRAGMA_STATEMENT.test(sql) && !sql.replace(AGGREGATE_STAR, '').includes('*') +} + // Why: SSH companions target Node 18 and import this adapter without opening SQLite. function loadDatabaseSync(): typeof DatabaseSync { if (typeof process.getBuiltinModule !== 'function') { @@ -26,6 +38,7 @@ function loadDatabaseSync(): typeof DatabaseSync { class SyncDatabase { private readonly db: DatabaseSync + private readonly statementCache = new Map() constructor(path: SqlitePath, options: SyncDatabaseOptions = {}) { if ( @@ -44,11 +57,31 @@ class SyncDatabase { } exec(sql: string): void { + // Why: drop cached statements before DDL lands so a partially applied batch cannot leave stale ones. + if (SCHEMA_CHANGING_SQL.test(sql)) { + this.statementCache.clear() + } this.db.exec(sql) } prepare(sql: string): StatementSync { - return this.db.prepare(sql) + const cached = this.statementCache.get(sql) + if (cached) { + this.statementCache.delete(sql) + this.statementCache.set(sql, cached) + return cached + } + const statement = this.db.prepare(sql) + if (isStatementCacheable(sql)) { + if (this.statementCache.size >= STATEMENT_CACHE_LIMIT) { + const oldest = this.statementCache.keys().next().value + if (oldest !== undefined) { + this.statementCache.delete(oldest) + } + } + this.statementCache.set(sql, statement) + } + return statement } pragma(sql: string, options?: PragmaOptions): unknown { @@ -64,6 +97,7 @@ class SyncDatabase { } close(): void { + this.statementCache.clear() this.db.close() } } diff --git a/src/main/ssh/ssh-port-scanner.test.ts b/src/main/ssh/ssh-port-scanner.test.ts index 09be7c1f686..f903d71acf0 100644 --- a/src/main/ssh/ssh-port-scanner.test.ts +++ b/src/main/ssh/ssh-port-scanner.test.ts @@ -235,6 +235,40 @@ describe('PortScanner', () => { expect(scanner.getDetectedPorts('t1')).toEqual([]) }) + it('aborts in-flight requests before stopping or replacing a target scan', async () => { + const harness = createVisibilityHarness(true) + const signals: AbortSignal[] = [] + const request = vi.fn( + (_method: string, _params?: Record, options?: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + const signal = options?.signal + if (!signal) { + reject(new Error('missing request signal')) + return + } + signals.push(signal) + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + ) + const mux = { request } as unknown as SshChannelMultiplexer + const scanner = new PortScanner(harness.visibility) + + scanner.startScanning('t1', mux, vi.fn()) + expect(signals).toHaveLength(1) + expect(signals[0].aborted).toBe(false) + + scanner.startScanning('t1', mux, vi.fn()) + expect(signals).toHaveLength(2) + expect(signals[0].aborted).toBe(true) + expect(signals[1].aborted).toBe(false) + + scanner.stopScanning('t1') + expect(signals[1].aborted).toBe(true) + await vi.advanceTimersByTimeAsync(10 * BASE) + expect(request).toHaveBeenCalledTimes(2) + expect(harness.listenerCount()).toBe(0) + }) + it('keeps targets independent: stopping one host leaves the other scanning', async () => { const harness = createVisibilityHarness(true) let nextA = 3000 diff --git a/src/main/ssh/ssh-port-scanner.ts b/src/main/ssh/ssh-port-scanner.ts index 6404d035f22..2dbabff597a 100644 --- a/src/main/ssh/ssh-port-scanner.ts +++ b/src/main/ssh/ssh-port-scanner.ts @@ -19,6 +19,7 @@ export type PortScannerWindowVisibility = { type ScanHandle = { timer: ReturnType | null + requestAbortController: AbortController | null intervalMs: number // Why: while the window is hidden the scan chain is parked outright — no // timer wakeups, no remote requests — and the visibility listener resumes @@ -48,6 +49,7 @@ export class PortScanner { const handle: ScanHandle = { timer: null, + requestAbortController: null, intervalMs: SSH_PORT_SCAN_BASE_INTERVAL_MS, parkedWhileHidden: false, unsubscribeVisibility: () => {}, @@ -65,8 +67,12 @@ export class PortScanner { return } polling = true + const requestAbortController = new AbortController() + handle.requestAbortController = requestAbortController try { - const result = (await mux.request('ports.detect')) as { + const result = (await mux.request('ports.detect', undefined, { + signal: requestAbortController.signal + })) as { ports: DetectedPort[] platform: string } @@ -94,6 +100,9 @@ export class PortScanner { } catch { // Relay disconnected or request timed out — retry on next interval } finally { + if (handle.requestAbortController === requestAbortController) { + handle.requestAbortController = null + } polling = false } } @@ -142,6 +151,8 @@ export class PortScanner { if (handle.timer) { clearTimeout(handle.timer) } + handle.requestAbortController?.abort() + handle.requestAbortController = null handle.unsubscribeVisibility() this.handles.delete(targetId) } diff --git a/src/main/ssh/ssh-relay-session-relay-loss.test.ts b/src/main/ssh/ssh-relay-session-relay-loss.test.ts index 9e70466ab31..5053426131d 100644 --- a/src/main/ssh/ssh-relay-session-relay-loss.test.ts +++ b/src/main/ssh/ssh-relay-session-relay-loss.test.ts @@ -232,6 +232,53 @@ describe('SshRelaySession relay loss during setup', () => { expect(unregisterSshFilesystemProvider).toHaveBeenCalledWith('target-1') }) + // #13548 follow-up: cancelling the in-flight port scan emits rpc.cancel on the control + // lane, which a saturated writer turns into mux.dispose('connection_lost'). That fires + // during our own teardown, so it must not be reported as a lost relay. + it.each([ + ['detach', (session: SshRelaySession) => session.beginShutdownDetach()], + ['dispose', (session: SshRelaySession) => session.dispose()] + ])( + 'does not report relay loss when the port-scan cancel kills the mux on %s', + async (_path, teardown) => { + const { session, onRelayLost } = createSession() + // Mirrors SshChannelMultiplexer.request: an in-flight request whose signal aborts + // emits rpc.cancel, and the mock mux dies on that notify like a saturated writer. + muxRequestMock.mockImplementation( + async (method: string, _params?: unknown, options?: { signal?: AbortSignal }) => { + if (method !== 'ports.detect') { + return [] + } + const mux = session.getMux() as unknown as { + failNotifyMethod: string | null + notify: (method: string, params?: unknown) => void + } + mux.failNotifyMethod = 'rpc.cancel' + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener( + 'abort', + () => { + mux.notify('rpc.cancel', { id: 1 }) + reject(new Error('cancelled')) + }, + { once: true } + ) + }) + } + ) + + await session.establish({} as SshConnection) + expect(session.getState()).toBe('ready') + expect(muxRequestMock).toHaveBeenCalledWith('ports.detect', undefined, expect.anything()) + const mux = session.getMux() as unknown as { notify: ReturnType } + + teardown(session) + + expect(mux.notify).toHaveBeenCalledWith('rpc.cancel', { id: 1 }) + expect(onRelayLost).not.toHaveBeenCalled() + } + ) + // #11953: reattachKnownPtys swallows every per-PTY failure, so a mux killed by the // reattach burst itself never reaches the catch — the post-reattach gate has to notice. it('routes a mux that dies during PTY reattach into relay-loss recovery', async () => { diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 8cb6e642812..e002c334aad 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -640,6 +640,7 @@ export class SshRelaySession { return } + this.releaseRelayLossWatcher() // Cancel any in-flight reconnect this.abortController?.abort() const abortController = new AbortController() @@ -835,6 +836,7 @@ export class SshRelaySession { private runDisposal(pendingDetach: Promise | null): Promise { // Why: the whole in-memory half runs before any await so a concurrent connect can never observe // a half-torn session; only the durability barriers below are deferred onto the returned promise. + this.releaseRelayLossWatcher() this.abortController?.abort() this.stopPortScanning() this.broadcastEmptyLists() @@ -908,6 +910,7 @@ export class SshRelaySession { return } detachSshPtyConsumerRecovery(this.targetId, this.ptyConsumerClientInstanceId) + this.releaseRelayLossWatcher() this.abortController?.abort() this.stopPortScanning() this.broadcastEmptyLists() @@ -927,6 +930,7 @@ export class SshRelaySession { // step — a fast reconnect must reclaim this identity instead of minting one, even if a // teardown call below throws unexpectedly. detachSshPtyConsumerRecovery(this.targetId, this.ptyConsumerClientInstanceId) + this.releaseRelayLossWatcher() this.abortController?.abort() this.stopPortScanning() this.broadcastEmptyLists() @@ -947,9 +951,21 @@ export class SshRelaySession { // ── Private ─────────────────────────────────────────────────────── + // Why: teardown itself can kill the mux — an aborted request emits rpc.cancel, and a saturated + // control lane turns that admission failure into mux.dispose('connection_lost'). Every teardown + // path must release the watcher before its first mux write, or our own shutdown re-enters + // recovery as a spurious relay loss. teardownProviders is not early enough: stopPortScanning + // runs ahead of it and is what emits that frame. Call this ahead of abortController.abort() + // too — that signal reaches no mux request today, but plumbing it into one would otherwise + // reopen the same hole silently. + private releaseRelayLossWatcher(): void { + this.muxDisposeCleanup?.() + this.muxDisposeCleanup = null + } + // Why: onStateChange only fires on SSH-level reconnects, so watch for relay-channel loss while SSH stays up and fire onRelayLost. private watchMuxForRelayLoss(mux: SshChannelMultiplexer): void { - this.muxDisposeCleanup?.() + this.releaseRelayLossWatcher() this.muxDisposeCleanup = mux.onDispose((reason) => { if (reason === 'connection_lost' && this.mux === mux && !this.isDisposed()) { console.warn( @@ -1560,8 +1576,7 @@ export class SshRelaySession { reason: 'shutdown' | 'connection_lost', outputGenerationReason: string = reason ): void { - this.muxDisposeCleanup?.() - this.muxDisposeCleanup = null + this.releaseRelayLossWatcher() this.muxNotificationCleanup?.() this.muxNotificationCleanup = null for (const cleanup of this.ptyRecoveryNotificationCleanups) { diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts index 001bc5252d1..3e40b5e5f57 100644 --- a/src/main/startup/desktop-startup-ordering.test.ts +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -26,15 +26,20 @@ describe('startup ordering', () => { expect(attachBlock).toContain( 'awaitLocalPtyProviderStartup: () => localPtyProviderStartupReady' ) - expect(source).toContain('firstWindowStartupServicesReady = startupServices.firstWindowReady') - expect(source).toContain('localPtyStartupReady = startupServices.localPtyReady') + expect(source).toContain( + 'firstWindowStartupServicesReady = services.then((value) => value.firstWindowReady)' + ) + expect(source).toContain('localPtyStartupReady = services.then((value) => value.localPtyReady)') - const windowIndex = desktopStartup.indexOf('Promise.resolve(openMainWindow())') + const windowIndex = desktopStartup.indexOf('Promise.resolve(desktopWindow ?? openMainWindow())') const rpcStartIndex = desktopStartup.indexOf('desktopRuntimeRpc.start()') const legacyRpcStartIndex = desktopStartup.indexOf('runtimeRpc.start()') expect(windowIndex).toBeGreaterThanOrEqual(0) expect(Math.max(rpcStartIndex, legacyRpcStartIndex)).toBeGreaterThanOrEqual(0) + expect(desktopStartup).toMatch( + /shellPathReady\s*\.then\(\(\) => (?:desktopRuntimeRpc|runtimeRpc)\.start\(\)\)/ + ) expect(desktopStartup).toContain('recordRuntimeRpcStartFailure(') // Why: `void`, not `await` — awaiting the dialog would park the rest of startup behind a modal. expect(desktopStartup).toMatch(/void showRuntimeRpcStartupFailureDialog\(\s*win,/) @@ -60,7 +65,7 @@ describe('startup ordering', () => { it('bounds WSL reconciliation before serve RPC while leaving desktop startup independent', () => { const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') const barrierStart = source.indexOf("ipcMain.handle('app:awaitFirstWindowStartupServices'") - const barrierEnd = source.indexOf("ipcMain.handle(\n 'app:startupDiagnostic'", barrierStart) + const barrierEnd = source.indexOf("'app:startupDiagnostic'", barrierStart) const barrier = source.slice(barrierStart, barrierEnd) const reconciliationStart = source.indexOf( 'managedWslCliReconciliationReady = reconcileManagedWslCliRegistrations(' @@ -68,22 +73,40 @@ describe('startup ordering', () => { const serveStart = source.indexOf('if (serveOptions) {', reconciliationStart) const serveReady = source.indexOf('await printServeReady(serveOptions)', serveStart) const serveEnd = source.indexOf('return', serveReady) - const desktopWindowStart = source.indexOf('Promise.resolve(openMainWindow())') + const desktopWindowStart = source.indexOf( + 'const desktopStartup = startWindowsDesktopBeforeShellPathReady(' + ) + const desktopWindowJoin = source.indexOf( + 'Promise.resolve(desktopWindow ?? openMainWindow())', + serveEnd + ) const serveStartup = source.slice(serveStart, serveEnd) - const desktopStartup = source.slice(serveEnd, desktopWindowStart) + const desktopStartup = source.slice(reconciliationStart, serveStart) + expect(barrierStart).toBeGreaterThanOrEqual(0) + expect(barrierEnd).toBeGreaterThan(barrierStart) expect(reconciliationStart).toBeGreaterThanOrEqual(0) expect(serveStart).toBeGreaterThan(reconciliationStart) expect(serveEnd).toBeGreaterThan(serveStart) // Why: bound against serveEnd, not reconciliationStart — an earlier openMainWindow() call // would steal this anchor, collapse desktopStartup to '', and pass the negative check below. - expect(desktopWindowStart).toBeGreaterThan(serveEnd) + expect(desktopWindowStart).toBeGreaterThan(reconciliationStart) + expect(desktopWindowStart).toBeLessThan(serveStart) + expect(desktopWindowJoin).toBeGreaterThan(serveEnd) expect(serveStartup).toContain('await managedWslCliStartupBarrierReady') expect(serveStartup).not.toContain('await managedWslCliReconciliationReady') expect(serveStartup.indexOf('await managedWslCliStartupBarrierReady')).toBeLessThan( serveStartup.indexOf('await runtimeRpc.start()') ) expect(desktopStartup).not.toContain('await managedWslCliReconciliationReady') + expect(desktopStartup).toContain( + "process.platform === 'win32' && app.isPackaged && !serveOptions" + ) + expect(desktopStartup).toContain( + 'openWindow: () => openMainWindow({ revealOnDidFinishLoad: true })' + ) + expect(desktopStartup).toContain('shellPathReady,') + expect(desktopStartup).toContain('startServices: startTerminalRuntimeStartupServices') expect(barrier).toContain('managedWslCliStartupBarrierReady') expect(barrier).not.toContain('managedWslCliReconciliationReady') expect(barrier).toContain("ipcMain.handle('app:recoverLegacyWorkerTerminalsForRendererStartup'") @@ -98,19 +121,29 @@ describe('startup ordering', () => { it('reconciles retained Codex homes after authoritative daemon inventory', () => { const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') const daemonInitIndex = source.indexOf('await initDaemonPtyProvider(signal') - const routeGateIndex = source.indexOf( - 'codexRuntimeHome?.isHostSystemDefaultRealHome()', + const retainedPaneGateIndex = source.indexOf( + 'hasRecordedManagedHostCodexPane()', daemonInitIndex ) const inventoryIndex = source.indexOf('await listLiveDaemonPtyIds()', daemonInitIndex) const reconciliation = 'codexRuntimeHome?.reconcileLegacySharedHomeForRetainedPanes()' const reconciliationIndex = source.indexOf(reconciliation, inventoryIndex) + const hookReconciliationIndex = source.indexOf( + 'reconcileRetainedCodexHookHomes({', + inventoryIndex + ) const serveIndex = source.indexOf('if (serveOptions) {', reconciliationIndex) - const desktopIndex = source.indexOf('Promise.resolve(openMainWindow())', serveIndex) + const desktopIndex = source.indexOf( + 'Promise.resolve(desktopWindow ?? openMainWindow())', + serveIndex + ) expect(daemonInitIndex).toBeGreaterThanOrEqual(0) - expect(routeGateIndex).toBeGreaterThan(daemonInitIndex) - expect(inventoryIndex).toBeGreaterThan(routeGateIndex) + expect(retainedPaneGateIndex).toBeGreaterThan(daemonInitIndex) + expect(inventoryIndex).toBeGreaterThan(daemonInitIndex) + expect(inventoryIndex).toBeGreaterThan(retainedPaneGateIndex) + expect(hookReconciliationIndex).toBeGreaterThan(inventoryIndex) + expect(hookReconciliationIndex).toBeLessThan(reconciliationIndex) expect(reconciliationIndex).toBeGreaterThan(inventoryIndex) expect(serveIndex).toBeGreaterThan(reconciliationIndex) expect(desktopIndex).toBeGreaterThan(serveIndex) diff --git a/src/main/startup/hydrate-shell-path.test.ts b/src/main/startup/hydrate-shell-path.test.ts index 316baad7280..f096dd03ed3 100644 --- a/src/main/startup/hydrate-shell-path.test.ts +++ b/src/main/startup/hydrate-shell-path.test.ts @@ -182,6 +182,10 @@ describe('hydrateShellPath', () => { describe('mergePathSegments', () => { const originalPath = process.env.PATH + beforeEach(() => { + _resetHydrateShellPathCache() + }) + afterEach(() => { if (originalPath === undefined) { delete process.env.PATH diff --git a/src/main/startup/hydrate-shell-path.ts b/src/main/startup/hydrate-shell-path.ts index 87747399615..9cf7dc75608 100644 --- a/src/main/startup/hydrate-shell-path.ts +++ b/src/main/startup/hydrate-shell-path.ts @@ -2,6 +2,7 @@ import { spawn } from 'node:child_process' import { delimiter, win32 as pathWin32 } from 'node:path' import type { ShellHydrationFailureReason } from '../../shared/types' import { resolveWindowsShellStartupFamily } from '../../shared/windows-terminal-shell' +import { WindowsShellPathOwnership, windowsPathSegmentKey } from './windows-shell-path-ownership' // Why: GUI-launched Electron can miss PATH entries added by shell profiles. // Tools installed into ~/.opencode/bin, ~/.cargo/bin, pyenv/volta/fnm @@ -35,7 +36,7 @@ let configuredWindowsShell = 'powershell.exe' let configuredWindowsGitBashPath: string | null = null let configuredWindowsFallbackShell: string | null = null let windowsShellConfigurationVersion = 0 -const windowsIntroducedPathKeys = new Set() +const windowsPathOwnership = new WindowsShellPathOwnership() /** @internal - tests need a clean hydration cache between cases. */ export function _resetHydrateShellPathCache(): void { @@ -45,7 +46,7 @@ export function _resetHydrateShellPathCache(): void { configuredWindowsGitBashPath = null configuredWindowsFallbackShell = null windowsShellConfigurationVersion = 0 - windowsIntroducedPathKeys.clear() + windowsPathOwnership.reset() } function pickShell(): string | null { @@ -211,6 +212,9 @@ export function hydrateShellPath(options: HydrateOptions = {}): Promise { + if (platform === 'win32') { + windowsPathOwnership.restore(process.env) + } const result = await spawner(shell) if (!result.ok && result.failureReason === 'spawn_error' && fallbackShell) { return spawner(fallbackShell) @@ -248,7 +252,7 @@ export function configureWindowsShellPathHydration( ) { return } - clearWindowsIntroducedPathSegments() + windowsPathOwnership.restore(process.env) configuredWindowsShell = next configuredWindowsGitBashPath = gitBashPath configuredWindowsFallbackShell = fallbackShell @@ -268,25 +272,6 @@ function uniquePathSegments(segments: string[], pathKey: (segment: string) => st }) } -function windowsPathKey(segment: string): string { - const normalized = pathWin32.normalize(segment) - const root = pathWin32.parse(normalized).root - const withoutTrailingSlash = - normalized.length > root.length ? normalized.replace(/[\\/]+$/, '') : normalized - return withoutTrailingSlash.toLowerCase() -} - -function clearWindowsIntroducedPathSegments(): void { - if (process.platform !== 'win32' || windowsIntroducedPathKeys.size === 0) { - return - } - const currentSegments = (process.env.PATH ?? '').split(pathWin32.delimiter).filter(Boolean) - process.env.PATH = currentSegments - .filter((segment) => !windowsIntroducedPathKeys.has(windowsPathKey(segment))) - .join(pathWin32.delimiter) - windowsIntroducedPathKeys.clear() -} - /** * Promote shell-discovered PATH segments to the front of process.env.PATH, * preserving shell ordering and avoiding duplicates. Returns the segments that @@ -296,11 +281,14 @@ export function mergePathSegments(segments: string[]): string[] { if (segments.length === 0) { return [] } - const current = process.env.PATH ?? '' + if (process.platform === 'win32') { + windowsPathOwnership.restore(process.env) + } + const current = process.env.PATH ?? process.env.Path ?? '' const pathDelimiter = process.platform === 'win32' ? pathWin32.delimiter : delimiter const currentSegments = current.split(pathDelimiter).filter(Boolean) const pathKey = - process.platform === 'win32' ? windowsPathKey : (segment: string): string => segment + process.platform === 'win32' ? windowsPathSegmentKey : (segment: string): string => segment const shellSegments = uniquePathSegments(segments, pathKey) const shellSegmentSet = new Set(shellSegments.map(pathKey)) const existing = new Set(currentSegments.map(pathKey)) @@ -316,11 +304,10 @@ export function mergePathSegments(segments: string[]): string[] { // Why: shell-provided entries must win over hardcoded packaged-app fallbacks. // A seeded fallback can point at a stale CLI while the user's shell resolves // a healthy one from the same directory list in a different order. - process.env.PATH = next if (process.platform === 'win32') { - for (const segment of added) { - windowsIntroducedPathKeys.add(windowsPathKey(segment)) - } + windowsPathOwnership.apply(process.env, next) + } else { + process.env.PATH = next } return added } diff --git a/src/main/startup/serve-desktop-activation-wiring.test.ts b/src/main/startup/serve-desktop-activation-wiring.test.ts index d1cb5bfd2fb..239a7618567 100644 --- a/src/main/startup/serve-desktop-activation-wiring.test.ts +++ b/src/main/startup/serve-desktop-activation-wiring.test.ts @@ -16,7 +16,7 @@ describe('serve desktop activation wiring', () => { it('settles the persistent provider before headless PTY registration', () => { const appReadyIndex = source.indexOf('app.whenReady().then(async () => {') const startupIndex = source.indexOf( - '\n startTerminalRuntimeStartupServices()\n', + 'bindTerminalRuntimeStartupServices(Promise.resolve(startTerminalRuntimeStartupServices()))', appReadyIndex ) const serveIndex = source.indexOf('if (serveOptions) {', appReadyIndex) diff --git a/src/main/startup/windows-desktop-shell-path-startup.test.ts b/src/main/startup/windows-desktop-shell-path-startup.test.ts new file mode 100644 index 00000000000..3778e0b2688 --- /dev/null +++ b/src/main/startup/windows-desktop-shell-path-startup.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest' +import { startWindowsDesktopBeforeShellPathReady } from './windows-desktop-shell-path-startup' + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} + +describe('Windows desktop shell PATH startup', () => { + it('opens the first window while shell PATH hydration remains unresolved', async () => { + const shellPath = deferred() + const window = { visible: true } + const openWindow = vi.fn(() => window) + const startServices = vi.fn(() => ({ + firstWindowReady: Promise.resolve(), + localPtyReady: Promise.resolve(), + localPtyProviderReady: Promise.resolve() + })) + + const startup = startWindowsDesktopBeforeShellPathReady({ + openWindow, + shellPathReady: shellPath.promise, + startServices + }) + + expect(startup.window).toBe(window) + expect(openWindow).toHaveBeenCalledOnce() + expect(startServices).not.toHaveBeenCalled() + + shellPath.resolve() + await startup.services + expect(startServices).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/startup/windows-desktop-shell-path-startup.ts b/src/main/startup/windows-desktop-shell-path-startup.ts new file mode 100644 index 00000000000..3933a79b64b --- /dev/null +++ b/src/main/startup/windows-desktop-shell-path-startup.ts @@ -0,0 +1,18 @@ +export type WindowsDesktopStartupServices = { + firstWindowReady: Promise + localPtyReady: Promise + localPtyProviderReady: Promise +} + +type WindowsDesktopShellPathStartupOptions = { + openWindow: () => TWindow + shellPathReady: Promise + startServices: () => WindowsDesktopStartupServices +} + +export function startWindowsDesktopBeforeShellPathReady( + options: WindowsDesktopShellPathStartupOptions +): { window: TWindow; services: Promise } { + const services = options.shellPathReady.then(options.startServices) + return { window: options.openWindow(), services } +} diff --git a/src/main/startup/windows-shell-path-ownership.test.ts b/src/main/startup/windows-shell-path-ownership.test.ts new file mode 100644 index 00000000000..e4e4b128d93 --- /dev/null +++ b/src/main/startup/windows-shell-path-ownership.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { WindowsShellPathOwnership, windowsPathSegmentKey } from './windows-shell-path-ownership' + +describe('Windows shell PATH ownership', () => { + it.each([ + ['C:\\', 'c:\\'], + ['C:\\Tools\\', 'c:\\tools'], + ['c:/TOOLS', 'c:\\tools'], + ['\\\\Server\\Share\\', '\\\\server\\share\\'], + ['\\\\SERVER\\Share', '\\\\server\\share\\'] + ])('normalizes %s to %s', (segment, expected) => { + expect(windowsPathSegmentKey(segment)).toBe(expected) + }) + + it.each(['PATH', 'Path'])( + 'restores the complete %s baseline without changing its casing', + (key) => { + const env: Record = { + [key]: 'C:\\B;c:\\b;C:\\;\\\\Server\\Share\\' + } + const ownership = new WindowsShellPathOwnership() + ownership.apply(env, 'C:\\Profile;C:\\B;C:\\;\\\\Server\\Share\\') + + ownership.restore(env) + + expect(env).toEqual({ [key]: 'C:\\B;c:\\b;C:\\;\\\\Server\\Share\\' }) + } + ) + + it('preserves PATH entries appended outside shell hydration', () => { + const env = { Path: 'C:\\B;C:\\A' } + const ownership = new WindowsShellPathOwnership() + ownership.apply(env, 'C:\\Profile;C:\\A;C:\\B') + env.Path += ';C:\\NewlyInstalled' + + ownership.restore(env) + + expect(env.Path).toBe('C:\\B;C:\\A;C:\\NewlyInstalled') + }) + + it('updates the first effective key when both Windows casings exist', () => { + const env = { Path: 'C:\\B;C:\\A', PATH: 'C:\\ignored' } + const ownership = new WindowsShellPathOwnership() + ownership.apply(env, 'C:\\Profile;C:\\A;C:\\B') + + ownership.restore(env) + + expect(env).toEqual({ Path: 'C:\\B;C:\\A', PATH: 'C:\\ignored' }) + }) +}) diff --git a/src/main/startup/windows-shell-path-ownership.ts b/src/main/startup/windows-shell-path-ownership.ts new file mode 100644 index 00000000000..8a4d63ef02a --- /dev/null +++ b/src/main/startup/windows-shell-path-ownership.ts @@ -0,0 +1,68 @@ +import { win32 as pathWin32 } from 'node:path' + +type PathEnvironment = Record + +type AppliedWindowsPath = { + appliedValue: string + baselineValue: string + pathKey: string +} + +function firstPathKey(env: PathEnvironment): string | undefined { + return Object.keys(env).find((key) => key.toLowerCase() === 'path' && env[key] !== undefined) +} + +export function windowsPathSegmentKey(segment: string): string { + const normalized = pathWin32.normalize(segment) + const root = pathWin32.parse(normalized).root + const withoutTrailingSlash = + normalized.length > root.length ? normalized.replace(/[\\/]+$/, '') : normalized + return withoutTrailingSlash.toLowerCase() +} + +function splitPath(pathValue: string): string[] { + return pathValue.split(pathWin32.delimiter).filter(Boolean) +} + +function externalAdditions(application: AppliedWindowsPath, currentValue: string): string[] { + if (currentValue === application.appliedValue) { + return [] + } + // Why: forced Windows preflight appends newly installed registry paths after hydration. + const appliedKeys = new Set(splitPath(application.appliedValue).map(windowsPathSegmentKey)) + return splitPath(currentValue).filter( + (segment) => !appliedKeys.has(windowsPathSegmentKey(segment)) + ) +} + +export class WindowsShellPathOwnership { + private application: AppliedWindowsPath | null = null + + reset(): void { + this.application = null + } + + restore(env: PathEnvironment): void { + const application = this.application + if (!application) { + return + } + const pathKey = firstPathKey(env) ?? application.pathKey + const currentValue = env[pathKey] ?? '' + const additions = externalAdditions(application, currentValue) + env[pathKey] = [application.baselineValue, ...additions] + .filter(Boolean) + .join(pathWin32.delimiter) + this.application = null + } + + apply(env: PathEnvironment, value: string): void { + const pathKey = firstPathKey(env) ?? 'Path' + this.application = { + appliedValue: value, + baselineValue: env[pathKey] ?? '', + pathKey + } + env[pathKey] = value + } +} diff --git a/src/main/startup/windows-shell-path-restoration.windows.test.ts b/src/main/startup/windows-shell-path-restoration.windows.test.ts new file mode 100644 index 00000000000..d612c3726eb --- /dev/null +++ b/src/main/startup/windows-shell-path-restoration.windows.test.ts @@ -0,0 +1,165 @@ +import { spawnSync } from 'node:child_process' +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, win32 as pathWin32 } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + _resetHydrateShellPathCache, + configureWindowsShellPathHydration, + hydrateShellPath, + mergePathSegments, + type HydrationResult +} from './hydrate-shell-path' +import { createWindowsShellPathHydration } from './windows-shell-path-hydration' + +type Deferred = { + promise: Promise + resolve: (value: T) => void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} + +function successfulHydration(segments: string[]): HydrationResult { + return { segments, ok: true, failureReason: 'none' } +} + +function resolveProbe(pathValue: string): string { + const env: NodeJS.ProcessEnv = { ...process.env, PATH: pathValue, PATHEXT: '.CMD;.EXE' } + delete env.Path + const result = spawnSync( + process.env.ComSpec ?? 'C:\\Windows\\System32\\cmd.exe', + ['/d', '/s', '/c', 'orca-path-probe.cmd'], + { encoding: 'utf8', env } + ) + expect(result.error).toBeUndefined() + expect(result.status).toBe(0) + return result.stdout.trim() +} + +describe.runIf(process.platform === 'win32')('Windows shell PATH restoration', () => { + const originalPath = process.env.PATH + let fixtureRoot = '' + let shellADir = '' + let shellBDir = '' + let shellBProfileDir = '' + + beforeEach(() => { + _resetHydrateShellPathCache() + fixtureRoot = mkdtempSync(join(tmpdir(), 'orca-shell-path-')) + shellADir = join(fixtureRoot, 'shell-a') + shellBDir = join(fixtureRoot, 'shell-b') + shellBProfileDir = join(fixtureRoot, 'shell-b-profile') + mkdirSync(shellADir) + mkdirSync(shellBDir) + mkdirSync(shellBProfileDir) + writeFileSync(join(shellADir, 'orca-path-probe.cmd'), '@echo shell-a\r\n') + writeFileSync(join(shellBDir, 'orca-path-probe.cmd'), '@echo shell-b\r\n') + }) + + afterEach(() => { + _resetHydrateShellPathCache() + if (originalPath === undefined) { + delete process.env.PATH + } else { + process.env.PATH = originalPath + } + rmSync(fixtureRoot, { force: true, recursive: true }) + }) + + it('restores the inherited baseline before applying a different shell profile', () => { + const baseline = [shellBDir, shellADir] + process.env.PATH = baseline.join(pathWin32.delimiter) + + mergePathSegments([shellADir, shellBDir]) + expect(process.env.PATH?.split(pathWin32.delimiter)).toEqual([shellADir, shellBDir]) + expect(resolveProbe(process.env.PATH ?? '')).toBe('shell-a') + + configureWindowsShellPathHydration('git-bash', 'C:\\Git\\bin\\bash.exe') + const shellBOutput = [shellBProfileDir, ...(process.env.PATH ?? '').split(pathWin32.delimiter)] + mergePathSegments(shellBOutput) + + expect({ + path: process.env.PATH?.split(pathWin32.delimiter), + selectedExecutable: resolveProbe(process.env.PATH ?? '') + }).toEqual({ + path: [shellBProfileDir, ...baseline], + selectedExecutable: 'shell-b' + }) + }) + + it('generation-fences an active shell A probe before shell B inherits the baseline', async () => { + const baseline = [shellBDir, shellADir] + process.env.PATH = baseline.join(pathWin32.delimiter) + const shellAResult = deferred() + const hydrate = vi.fn<() => Promise>() + hydrate + .mockReturnValueOnce(shellAResult.promise) + .mockImplementationOnce(async () => + successfulHydration([ + shellBProfileDir, + ...(process.env.PATH ?? '').split(pathWin32.delimiter) + ]) + ) + const coordinator = createWindowsShellPathHydration({ hydrate }) + + const shellAReady = coordinator.hydrate('powershell.exe') + await vi.waitFor(() => expect(hydrate).toHaveBeenCalledOnce()) + const shellBReady = coordinator.hydrate('git-bash') + shellAResult.resolve(successfulHydration([shellADir, shellBDir])) + await Promise.all([shellAReady, shellBReady]) + + expect(process.env.PATH?.split(pathWin32.delimiter)).toEqual([shellBProfileDir, ...baseline]) + expect(resolveProbe(process.env.PATH ?? '')).toBe('shell-b') + }) + + it('preserves a newly installed PATH entry across shell changes', () => { + const baseline = [shellBDir, shellADir] + const installedDir = join(fixtureRoot, 'newly-installed') + process.env.PATH = baseline.join(pathWin32.delimiter) + mergePathSegments([shellADir, shellBDir]) + process.env.PATH += `${pathWin32.delimiter}${installedDir}` + + configureWindowsShellPathHydration('git-bash', 'C:\\Git\\bin\\bash.exe') + mergePathSegments([shellBProfileDir, ...(process.env.PATH ?? '').split(pathWin32.delimiter)]) + + expect(process.env.PATH?.split(pathWin32.delimiter)).toEqual([ + shellBProfileDir, + ...baseline, + installedDir + ]) + }) + + it.each(['cmd.exe', 'wsl.exe'])('restores the exact baseline before switching to %s', (shell) => { + const baseline = `${shellBDir};${shellBDir.toUpperCase()}\\;C:\\;\\\\Server\\Share\\` + process.env.PATH = baseline + mergePathSegments([shellADir, shellBDir]) + + configureWindowsShellPathHydration(shell) + + expect(process.env.PATH).toBe(baseline) + }) + + it('restores the baseline before a forced same-shell probe', async () => { + const baseline = [shellBDir, shellADir].join(pathWin32.delimiter) + process.env.PATH = baseline + mergePathSegments([shellADir, shellBDir]) + let inheritedPath = '' + + await hydrateShellPath({ + force: true, + spawner: async () => { + inheritedPath = process.env.PATH ?? '' + return { segments: [], ok: false, failureReason: 'empty_path' } + } + }) + + expect(inheritedPath).toBe(baseline) + expect(process.env.PATH).toBe(baseline) + }) +}) diff --git a/src/main/usage/usage-calendar-range.test.ts b/src/main/usage/usage-calendar-range.test.ts new file mode 100644 index 00000000000..73645c2ccc1 --- /dev/null +++ b/src/main/usage/usage-calendar-range.test.ts @@ -0,0 +1,29 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getLocalUsageDay, getUsageRangeCutoff } from './usage-calendar-range' + +describe('usage calendar ranges', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 3, 10, 12)) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it.each([ + ['7d', '2026-04-04'], + ['30d', '2026-03-12'], + ['90d', '2026-01-11'], + ['all', null] + ] as const)('uses an inclusive local-calendar cutoff for %s', (range, expected) => { + expect(getUsageRangeCutoff(range)).toBe(expected) + }) + + it('maps timestamps to local calendar days and rejects invalid values', () => { + const localTimestamp = new Date(2026, 3, 4, 23, 30).toISOString() + + expect(getLocalUsageDay(localTimestamp)).toBe('2026-04-04') + expect(getLocalUsageDay('not-a-date')).toBeNull() + }) +}) diff --git a/src/main/usage/usage-calendar-range.ts b/src/main/usage/usage-calendar-range.ts new file mode 100644 index 00000000000..255ef245be5 --- /dev/null +++ b/src/main/usage/usage-calendar-range.ts @@ -0,0 +1,24 @@ +type UsageCalendarRange = '7d' | '30d' | '90d' | 'all' + +function formatLocalDay(date: Date): string { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +export function getUsageRangeCutoff(range: UsageCalendarRange): string | null { + if (range === 'all') { + return null + } + const days = range === '7d' ? 7 : range === '30d' ? 30 : 90 + const cutoff = new Date() + cutoff.setHours(0, 0, 0, 0) + cutoff.setDate(cutoff.getDate() - (days - 1)) + return formatLocalDay(cutoff) +} + +export function getLocalUsageDay(timestamp: string): string | null { + const parsed = new Date(timestamp) + return Number.isNaN(parsed.getTime()) ? null : formatLocalDay(parsed) +} diff --git a/src/main/usage/usage-provider-store-lifecycle.test.ts b/src/main/usage/usage-provider-store-lifecycle.test.ts new file mode 100644 index 00000000000..02a5571363d --- /dev/null +++ b/src/main/usage/usage-provider-store-lifecycle.test.ts @@ -0,0 +1,293 @@ +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import type * as FsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { UsageScanWorktreeRef } from './usage-provider-contract' +import { UsageProviderStoreLifecycle } from './usage-provider-store-lifecycle' + +const { writeProbe } = vi.hoisted(() => ({ + writeProbe: { + opens: 0, + renames: 0, + blocked: false, + waiters: [] as (() => void)[] + } +})) + +vi.mock('node:fs/promises', async () => { + const actual = await vi.importActual('node:fs/promises') + return { + ...actual, + open: (async (...args: Parameters) => { + if (args[1] === 'w') { + writeProbe.opens += 1 + if (writeProbe.blocked) { + await new Promise((resolve) => writeProbe.waiters.push(resolve)) + } + } + return actual.open(...args) + }) as typeof actual.open, + rename: ((...args: Parameters) => { + writeProbe.renames += 1 + return actual.rename(...args) + }) as typeof actual.rename + } +}) + +type TestSource = { id: string } +type TestSession = { id: string } +type TestDailyAggregate = { day: string } +type TestScanState = { + enabled: boolean + lastScanStartedAt: number | null + lastScanCompletedAt: number | null + lastScanError: string | null +} +type TestState = { + schemaVersion: number + worktreeFingerprint: string | null + processedSources: TestSource[] + sessions: TestSession[] + dailyAggregates: TestDailyAggregate[] + scanState: TestScanState +} +type TestScanResult = Pick +type TestScan = ( + worktrees: UsageScanWorktreeRef[], + previous: TestSource[] +) => Promise + +const NOW = Date.parse('2026-04-10T16:00:00.000Z') +const EMPTY_WORKTREE_FINGERPRINT = '[]' + +function makeState( + overrides: Partial> & { scanState?: Partial } = {} +): TestState { + const { scanState, ...stateOverrides } = overrides + return { + schemaVersion: 1, + worktreeFingerprint: null, + processedSources: [], + sessions: [], + dailyAggregates: [], + ...stateOverrides, + scanState: { + enabled: false, + lastScanStartedAt: null, + lastScanCompletedAt: null, + lastScanError: null, + ...scanState + } + } +} + +function emptyScanResult(): TestScanResult { + return { processedSources: [], sessions: [], dailyAggregates: [] } +} + +function createDeferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve + }) + return { promise, resolve } +} + +class TestUsageStore extends UsageProviderStoreLifecycle< + 'processedSources', + TestState, + 'hasAnyTestData' +> { + constructor(cacheFile: string, scan: TestScan) { + super( + { + getRepos: () => [], + getAllWorktreeMeta: () => ({}) + }, + { + logTag: '[test-usage]', + resolveCacheFile: () => cacheFile, + createDefaultState: makeState, + normalizeState: (state) => state, + sourceKey: 'processedSources', + dataPresenceKey: 'hasAnyTestData', + scan + } + ) + } + + replaceState(state: TestState): void { + this.state = state + } + + getState(): TestState { + return this.state + } +} + +describe('UsageProviderStoreLifecycle', () => { + let tempDirectory: string + let stores: TestUsageStore[] + let scan: ReturnType> + + function createStore( + cacheFile = join(tempDirectory, `usage-${stores.length}.json`) + ): TestUsageStore { + const store = new TestUsageStore(cacheFile, scan) + stores.push(store) + return store + } + + beforeEach(() => { + tempDirectory = mkdtempSync(join(tmpdir(), 'orca-usage-lifecycle-')) + stores = [] + writeProbe.opens = 0 + writeProbe.renames = 0 + writeProbe.blocked = false + writeProbe.waiters = [] + scan = vi.fn().mockResolvedValue(emptyScanResult()) + vi.spyOn(Date, 'now').mockReturnValue(NOW) + }) + + afterEach(async () => { + await Promise.all(stores.map((store) => store.flush())) + rmSync(tempDirectory, { recursive: true, force: true }) + vi.restoreAllMocks() + }) + + it('skips disabled and fresh matching states', async () => { + const store = createStore() + + await store.refresh() + expect(scan).not.toHaveBeenCalled() + + store.replaceState( + makeState({ + worktreeFingerprint: EMPTY_WORKTREE_FINGERPRINT, + scanState: { enabled: true, lastScanCompletedAt: NOW - 1 } + }) + ) + await store.refresh() + + expect(scan).not.toHaveBeenCalled() + }) + + it('invalidates prior sources on fingerprint changes and reuses them for forced scans', async () => { + const store = createStore() + const refreshedSources = [{ id: 'refreshed' }] + scan.mockResolvedValueOnce({ + processedSources: refreshedSources, + sessions: [], + dailyAggregates: [] + }) + store.replaceState( + makeState({ + worktreeFingerprint: 'outdated', + processedSources: [{ id: 'stale' }], + scanState: { enabled: true, lastScanCompletedAt: NOW - 1 } + }) + ) + + await store.refresh() + expect(scan).toHaveBeenLastCalledWith([], []) + + scan.mockClear() + await store.refresh(true) + + expect(scan).toHaveBeenCalledWith([], refreshedSources) + }) + + it('shares one in-flight scan and exposes its live state', async () => { + const pendingScan = createDeferred() + scan.mockReturnValueOnce(pendingScan.promise) + const store = createStore() + store.replaceState( + makeState({ scanState: { enabled: true, lastScanError: 'previous failure' } }) + ) + + const firstRefresh = store.refresh(true) + const secondRefresh = store.refresh(true) + await vi.waitFor(() => expect(scan).toHaveBeenCalledTimes(1)) + + expect(store.getScanState()).toMatchObject({ + isScanning: true, + lastScanStartedAt: NOW, + lastScanError: null + }) + expect(writeProbe.opens).toBe(0) + + pendingScan.resolve({ + processedSources: [{ id: 'source' }], + sessions: [{ id: 'session' }], + dailyAggregates: [] + }) + await Promise.all([firstRefresh, secondRefresh]) + + expect(store.getScanState()).toMatchObject({ + isScanning: false, + lastScanCompletedAt: NOW, + hasAnyTestData: true + }) + expect(writeProbe.opens).toBe(1) + expect(store.getState().processedSources).toEqual([{ id: 'source' }]) + }) + + it('vetoes a superseded generation before rename', async () => { + const cacheFile = join(tempDirectory, 'usage-0.json') + const store = createStore() + + writeProbe.blocked = true + const first = store.setEnabled(true) + await vi.waitFor(() => expect(writeProbe.waiters).toHaveLength(1)) + const second = store.setEnabled(false) + writeProbe.blocked = false + writeProbe.waiters.splice(0).forEach((resolve) => resolve()) + await Promise.all([first, second]) + + expect(writeProbe.opens).toBe(2) + expect(writeProbe.renames).toBe(1) + expect(JSON.parse(readFileSync(cacheFile, 'utf-8')).scanState.enabled).toBe(false) + expect(readdirSync(tempDirectory).filter((name) => name.endsWith('.tmp'))).toHaveLength(0) + }) + + it('sweeps a temp file orphaned before rename', async () => { + const cacheFile = join(tempDirectory, 'orphaned-usage.json') + const orphan = `${cacheFile}.${process.pid + 1}.1.test.tmp` + writeFileSync(orphan, '{}') + + createStore(cacheFile) + + await vi.waitFor(() => expect(existsSync(orphan)).toBe(false)) + }) + + it('retains the last successful projection when a scan fails', async () => { + const cacheFile = join(tempDirectory, 'usage-0.json') + const store = createStore() + const previousState = makeState({ + worktreeFingerprint: EMPTY_WORKTREE_FINGERPRINT, + processedSources: [{ id: 'source' }], + sessions: [{ id: 'session' }], + dailyAggregates: [{ day: '2026-04-09' }], + scanState: { enabled: true, lastScanCompletedAt: NOW - 1_000 } + }) + store.replaceState(previousState) + scan.mockRejectedValueOnce(new Error('scan exploded')) + + await expect(store.refresh(true)).resolves.toMatchObject({ + isScanning: false, + lastScanCompletedAt: NOW - 1_000, + lastScanError: 'scan exploded' + }) + + expect(store.getState()).toMatchObject({ + worktreeFingerprint: EMPTY_WORKTREE_FINGERPRINT, + processedSources: previousState.processedSources, + sessions: previousState.sessions, + dailyAggregates: previousState.dailyAggregates + }) + expect(JSON.parse(readFileSync(cacheFile, 'utf-8')).scanState.lastScanError).toBe( + 'scan exploded' + ) + }) +}) diff --git a/src/main/usage/usage-provider-store-lifecycle.ts b/src/main/usage/usage-provider-store-lifecycle.ts new file mode 100644 index 00000000000..850d9e61bda --- /dev/null +++ b/src/main/usage/usage-provider-store-lifecycle.ts @@ -0,0 +1,170 @@ +import { existsSync, readFileSync } from 'node:fs' +import type { Store } from '../persistence' +import { UsageCacheSnapshotWriter } from '../usage-cache-snapshot-writer' +import { loadKnownUsageWorktreesByRepo } from '../usage-worktree-metadata' +import type { UsageScanWorktreeRef } from './usage-provider-contract' +import { createWorktreeRefs, getUsageWorktreeFingerprint } from './usage-worktree-refs' + +const STALE_MS = 5 * 60_000 + +type UsageProviderScanState = { + enabled: boolean + lastScanStartedAt: number | null + lastScanCompletedAt: number | null + lastScanError: string | null +} + +type UsageProviderStoreState = { + worktreeFingerprint: string | null + sessions: unknown[] + dailyAggregates: unknown[] + scanState: UsageProviderScanState +} & Record + +type UsageProviderScanProjection< + SourceKey extends string, + State extends UsageProviderStoreState +> = Pick + +type UsageProviderStoreLifecycleConfig< + SourceKey extends string, + State extends UsageProviderStoreState, + DataPresenceKey extends string +> = { + logTag: string + resolveCacheFile: () => string + createDefaultState: () => State + normalizeState: (state: State) => State + sourceKey: SourceKey + dataPresenceKey: DataPresenceKey + jsonIndent?: number + scan: ( + worktrees: UsageScanWorktreeRef[], + previous: State[SourceKey] + ) => Promise> +} + +type PublicUsageProviderScanState = UsageProviderScanState & { + isScanning: boolean +} & Record + +export abstract class UsageProviderStoreLifecycle< + SourceKey extends string, + State extends UsageProviderStoreState, + DataPresenceKey extends string +> { + protected state: State + private scanPromise: Promise | null = null + private readonly writer: UsageCacheSnapshotWriter + + constructor( + private readonly store: Pick, + private readonly config: UsageProviderStoreLifecycleConfig + ) { + this.writer = new UsageCacheSnapshotWriter(config.logTag, config.resolveCacheFile) + this.state = this.load() + } + + getScanState(): PublicUsageProviderScanState { + return { + ...this.state.scanState, + isScanning: this.scanPromise !== null, + [this.config.dataPresenceKey]: + this.state.sessions.length > 0 || this.state.dailyAggregates.length > 0 + } as PublicUsageProviderScanState + } + + /** Await queued cache writes so quit does not drop the final snapshot. */ + flush(): Promise { + return this.writer.flush() + } + + async setEnabled(enabled: boolean): Promise> { + this.state.scanState.enabled = enabled + await this.writeToDisk() + return this.getScanState() + } + + async refresh(force = false): Promise> { + if (!this.state.scanState.enabled) { + return this.getScanState() + } + const currentWorktreeFingerprint = await this.getCurrentWorktreeFingerprint() + if (!force && this.state.scanState.lastScanCompletedAt) { + const ageMs = Date.now() - this.state.scanState.lastScanCompletedAt + if (ageMs < STALE_MS && this.state.worktreeFingerprint === currentWorktreeFingerprint) { + return this.getScanState() + } + } + await this.runScan() + return this.getScanState() + } + + protected writeToDisk(): Promise { + return this.writer.write(() => JSON.stringify(this.state, null, this.config.jsonIndent)) + } + + private load(): State { + const defaults = this.config.createDefaultState() + try { + const cacheFile = this.config.resolveCacheFile() + if (!existsSync(cacheFile)) { + return defaults + } + const parsed = JSON.parse(readFileSync(cacheFile, 'utf-8')) as State + return this.config.normalizeState({ + ...defaults, + ...parsed, + scanState: { ...defaults.scanState, ...parsed.scanState } + }) + } catch (error) { + console.error(`${this.config.logTag} Failed to load persisted state, starting fresh:`, error) + return defaults + } + } + + private async runScan(): Promise { + if (this.scanPromise) { + await this.scanPromise + return + } + + this.state.scanState.lastScanStartedAt = Date.now() + this.state.scanState.lastScanError = null + + // Assign before yielding so concurrent refreshes share one scan. + this.scanPromise = (async () => { + try { + const repos = this.store.getRepos() + const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) + const worktreeFingerprint = getUsageWorktreeFingerprint(worktreesByRepo) + const result = await this.config.scan( + createWorktreeRefs(repos, worktreesByRepo), + this.state.worktreeFingerprint === worktreeFingerprint + ? this.state[this.config.sourceKey] + : this.config.createDefaultState()[this.config.sourceKey] + ) + this.state[this.config.sourceKey] = result[this.config.sourceKey] + this.state.sessions = result.sessions + this.state.dailyAggregates = result.dailyAggregates + this.state.worktreeFingerprint = worktreeFingerprint + this.state.scanState.lastScanCompletedAt = Date.now() + this.state.scanState.lastScanError = null + // Persistence failures do not turn a successful source scan into a scan failure. + await this.writeToDisk().catch(() => {}) + } catch (error) { + this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error) + await this.writeToDisk().catch(() => {}) + } finally { + this.scanPromise = null + } + })() + + await this.scanPromise + } + + private async getCurrentWorktreeFingerprint(): Promise { + const repos = this.store.getRepos() + return getUsageWorktreeFingerprint(loadKnownUsageWorktreesByRepo(this.store, repos)) + } +} diff --git a/src/main/usage/usage-worktree-refs.test.ts b/src/main/usage/usage-worktree-refs.test.ts new file mode 100644 index 00000000000..56efe7e4fb8 --- /dev/null +++ b/src/main/usage/usage-worktree-refs.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import type { UsageWorktreeRef } from '../usage-worktree-metadata' +import { getUsageWorktreeFingerprint } from './usage-worktree-refs' + +function worktree(worktreeId: string, path: string, displayName: string): UsageWorktreeRef { + return { worktreeId, path, displayName } +} + +describe('getUsageWorktreeFingerprint', () => { + it('is stable across repo and worktree ordering but changes with identity metadata', () => { + const first = new Map([ + [ + 'repo-b', + [ + worktree('repo-b::/repo/b-two', '/repo/b-two', 'B Two'), + worktree('repo-b::/repo/b-one', '/repo/b-one', 'B One') + ] + ], + ['repo-a', [worktree('repo-a::/repo/a', '/repo/a', 'A')]] + ]) + const reordered = new Map([ + ['repo-a', [worktree('repo-a::/repo/a', '/repo/a', 'A')]], + [ + 'repo-b', + [ + worktree('repo-b::/repo/b-one', '/repo/b-one', 'B One'), + worktree('repo-b::/repo/b-two', '/repo/b-two', 'B Two') + ] + ] + ]) + const renamed = new Map(reordered) + renamed.set('repo-a', [worktree('repo-a::/repo/a', '/repo/a', 'Renamed A')]) + + expect(getUsageWorktreeFingerprint(first)).toBe(getUsageWorktreeFingerprint(reordered)) + expect(getUsageWorktreeFingerprint(renamed)).not.toBe(getUsageWorktreeFingerprint(first)) + }) +}) diff --git a/src/main/usage/usage-worktree-refs.ts b/src/main/usage/usage-worktree-refs.ts index 0b4347a90a5..f76ed7c0a58 100644 --- a/src/main/usage/usage-worktree-refs.ts +++ b/src/main/usage/usage-worktree-refs.ts @@ -1,6 +1,25 @@ import type { Repo } from '../../shared/types' +import type { UsageWorktreeRef } from '../usage-worktree-metadata' import type { UsageScanWorktreeRef } from './usage-provider-contract' +export function getUsageWorktreeFingerprint( + worktreesByRepo: Map +): string { + const rows = [...worktreesByRepo.entries()] + .flatMap(([repoId, worktrees]) => + worktrees.map((worktree) => + JSON.stringify({ + repoId, + worktreeId: worktree.worktreeId, + path: worktree.path, + displayName: worktree.displayName + }) + ) + ) + .sort() + return JSON.stringify(rows) +} + export function createWorktreeRefs( repos: Repo[], worktreesByRepo: Map diff --git a/src/main/window/clipboard-dashboard-popout-access.test.ts b/src/main/window/clipboard-dashboard-popout-access.test.ts index 2fd1ca680e2..df91e29a2ed 100644 --- a/src/main/window/clipboard-dashboard-popout-access.test.ts +++ b/src/main/window/clipboard-dashboard-popout-access.test.ts @@ -39,6 +39,7 @@ vi.mock('electron', () => ({ vi.mock('./dashboard-popout-window', () => ({ isDashboardPopoutRenderer })) vi.mock('./clipboard-remote-file-copy', () => ({ cleanupExpiredRemoteClipboardFiles: vi.fn(async () => undefined), + scheduleLegacyRemoteClipboardFileCleanup: vi.fn(), writeRemoteFileToClipboard: vi.fn() })) diff --git a/src/main/window/clipboard-ipc-handlers.test.ts b/src/main/window/clipboard-ipc-handlers.test.ts index b1da7e9e226..e12f7d633db 100644 --- a/src/main/window/clipboard-ipc-handlers.test.ts +++ b/src/main/window/clipboard-ipc-handlers.test.ts @@ -12,6 +12,8 @@ const { spawnMock, childStdinEndMock, resolveAuthorizedPathMock, + fsAccessMock, + fsLstatMock, fsMkdirMock, fsOpendirMock, fsRmMock, @@ -45,6 +47,8 @@ const { return child }), resolveAuthorizedPathMock: vi.fn(), + fsAccessMock: vi.fn(), + fsLstatMock: vi.fn(), fsMkdirMock: vi.fn(), fsOpendirMock: vi.fn(), fsRmMock: vi.fn(), @@ -68,11 +72,14 @@ vi.mock('node:child_process', () => ({ })) vi.mock('node:fs/promises', () => ({ + access: fsAccessMock, + lstat: fsLstatMock, mkdir: fsMkdirMock, opendir: fsOpendirMock, rm: fsRmMock, open: fsOpenMock, stat: fsStatMock, + writeFile: fsWriteFileMock, default: { writeFile: fsWriteFileMock } @@ -134,6 +141,11 @@ import { setTrustedClipboardRendererWebContentsId } from './clipboard-ipc-handlers' +const REMOTE_CLIPBOARD_STAGING_ROOT = join( + '/tmp', + `orca-clipboard-files${typeof process.getuid === 'function' ? `-${process.getuid()}` : ''}` +) + function getRegisteredHandlers(): Map unknown> { const handlers = new Map unknown>() for (const [channel, handler] of handleMock.mock.calls as [ @@ -187,6 +199,13 @@ describe('registerClipboardHandlers', () => { childStdinEndMock.mockClear() resolveAuthorizedPathMock.mockReset() resolveAuthorizedPathMock.mockImplementation(async (path: string) => path) + fsLstatMock.mockReset() + fsLstatMock.mockResolvedValue({ + isDirectory: () => true, + isSymbolicLink: () => false, + mode: 0o700, + uid: typeof process.getuid === 'function' ? process.getuid() : 0 + }) fsMkdirMock.mockReset() fsMkdirMock.mockResolvedValue(undefined) fsOpendirMock.mockReset() @@ -316,8 +335,8 @@ describe('registerClipboardHandlers', () => { const handlers = getRegisteredHandlers() const tempDir = join( - '/tmp', - 'orca-clipboard-file-1760000000000-00000000-0000-4000-8000-000000000000' + REMOTE_CLIPBOARD_STAGING_ROOT, + '1760000000000-00000000-0000-4000-8000-000000000000' ) const tempPath = join(tempDir, 'report.pdf') @@ -329,6 +348,10 @@ describe('registerClipboardHandlers', () => { ).resolves.toEqual({ ok: true }) expect(provider.stat).toHaveBeenCalledWith('/remote/report.pdf') + expect(fsMkdirMock).toHaveBeenCalledWith(REMOTE_CLIPBOARD_STAGING_ROOT, { + recursive: true, + mode: 0o700 + }) expect(fsMkdirMock).toHaveBeenCalledWith(tempDir, { mode: 0o700 }) expect(provider.downloadFile).toHaveBeenCalledWith('/remote/report.pdf', tempPath) expect(fsStatMock).toHaveBeenCalledWith(tempPath) @@ -353,7 +376,6 @@ describe('registerClipboardHandlers', () => { ).resolves.toEqual({ ok: false, reason: 'is-directory' }) expect(provider.downloadFile).not.toHaveBeenCalled() - expect(fsMkdirMock).not.toHaveBeenCalled() expect(clipboardWriteBufferMock).not.toHaveBeenCalled() }) @@ -367,8 +389,8 @@ describe('registerClipboardHandlers', () => { const handlers = getRegisteredHandlers() const tempDir = join( - '/tmp', - 'orca-clipboard-file-1760000000000-00000000-0000-4000-8000-000000000000' + REMOTE_CLIPBOARD_STAGING_ROOT, + '1760000000000-00000000-0000-4000-8000-000000000000' ) const tempPath = join(tempDir, 'report.pdf') @@ -380,7 +402,12 @@ describe('registerClipboardHandlers', () => { ).rejects.toThrow('transfer failed') expect(provider.downloadFile).toHaveBeenCalledWith('/remote/report.pdf', tempPath) - expect(fsRmMock).toHaveBeenCalledWith(tempDir, { recursive: true, force: true }) + expect(fsRmMock).toHaveBeenCalledWith(tempDir, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 100 + }) expect(clipboardWriteBufferMock).not.toHaveBeenCalled() }) diff --git a/src/main/window/clipboard-ipc-handlers.ts b/src/main/window/clipboard-ipc-handlers.ts index 275c0e26240..450c6a6e140 100644 --- a/src/main/window/clipboard-ipc-handlers.ts +++ b/src/main/window/clipboard-ipc-handlers.ts @@ -31,6 +31,7 @@ import { } from './clipboard-file-copy' import { cleanupExpiredRemoteClipboardFiles, + scheduleLegacyRemoteClipboardFileCleanup, writeRemoteFileToClipboard } from './clipboard-remote-file-copy' import { saveClipboardImageBufferInRuntime } from './clipboard-runtime-image-upload' @@ -85,6 +86,7 @@ export function registerClipboardHandlers(store: Store): void { ipcMain.removeHandler('clipboard:saveImageAsTempFile') void cleanupExpiredRemoteClipboardFiles() + scheduleLegacyRemoteClipboardFileCleanup() ipcMain.handle('clipboard:readText', async (event, options?: ReadClipboardTextOptions) => { assertTrustedClipboardTextSender(event) diff --git a/src/main/window/clipboard-remote-file-cleanup.bench.test.ts b/src/main/window/clipboard-remote-file-cleanup.bench.test.ts new file mode 100644 index 00000000000..5e671930af3 --- /dev/null +++ b/src/main/window/clipboard-remote-file-cleanup.bench.test.ts @@ -0,0 +1,147 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { access, mkdir, mkdtemp, opendir, readdir, rm, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' +import { + cleanupExpiredRemoteClipboardStaging, + createRemoteClipboardTransferDirectory +} from './clipboard-remote-file-staging' + +const BENCH_ENABLED = process.env.ORCA_CLIPBOARD_CLEANUP_BENCH === '1' +const FOREIGN_ENTRY_COUNT = Number(process.env.ORCA_CLIPBOARD_CLEANUP_BENCH_ENTRIES ?? 100_000) +const WARMUP_RUNS = 3 +const SAMPLE_RUNS_PER_ARM = 24 +const FIXTURE_PREFIX = 'orca-clipboard-cleanup-bench-' +const NOW_MS = 1_760_000_000_000 + +describe.skipIf(!BENCH_ENABLED)('remote clipboard cleanup benchmark', () => { + let fixtureRoot = '' + let freshTransfer = '' + + beforeAll(async () => { + fixtureRoot = await mkdtemp(join(tmpdir(), FIXTURE_PREFIX)) + await createForeignEntries(fixtureRoot, FOREIGN_ENTRY_COUNT) + const expiredTransfer = await createRemoteClipboardTransferDirectory( + fixtureRoot, + NOW_MS - 2 * 60 * 60 * 1000, + '00000000-0000-4000-8000-000000000000' + ) + freshTransfer = await createRemoteClipboardTransferDirectory( + fixtureRoot, + NOW_MS, + '00000000-0000-4000-8000-000000000001' + ) + const expiredAt = new Date(NOW_MS - 2 * 60 * 60 * 1000) + await utimes(expiredTransfer, expiredAt, expiredAt) + await cleanupExpiredRemoteClipboardStaging(fixtureRoot, NOW_MS) + + await expect(access(expiredTransfer)).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(access(freshTransfer)).resolves.toBeUndefined() + expect(await countForeignEntries(fixtureRoot)).toBe(FOREIGN_ENTRY_COUNT) + }, 900_000) + + afterAll(async () => { + if ( + fixtureRoot && + dirname(fixtureRoot) === tmpdir() && + basename(fixtureRoot).startsWith(FIXTURE_PREFIX) + ) { + await rm(fixtureRoot, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 100 + }) + } + }, 900_000) + + it('reports shared-root and owned-parent median and p95', async () => { + for (let index = 0; index < WARMUP_RUNS; index += 1) { + await scanSharedTempRoot(fixtureRoot) + await cleanupExpiredRemoteClipboardStaging(fixtureRoot, NOW_MS) + } + + const samples = { ownedParent: [] as number[], sharedRoot: [] as number[] } + const launchPositions = { + ownedParent: [0, 0], + sharedRoot: [0, 0] + } + for (let index = 0; index < SAMPLE_RUNS_PER_ARM; index += 1) { + const order = + index % 2 === 0 + ? (['sharedRoot', 'ownedParent'] as const) + : (['ownedParent', 'sharedRoot'] as const) + for (const [position, arm] of order.entries()) { + launchPositions[arm][position] += 1 + samples[arm].push( + await measure(() => + arm === 'sharedRoot' + ? scanSharedTempRoot(fixtureRoot) + : cleanupExpiredRemoteClipboardStaging(fixtureRoot, NOW_MS) + ) + ) + } + } + + expect(launchPositions).toEqual({ + ownedParent: [SAMPLE_RUNS_PER_ARM / 2, SAMPLE_RUNS_PER_ARM / 2], + sharedRoot: [SAMPLE_RUNS_PER_ARM / 2, SAMPLE_RUNS_PER_ARM / 2] + }) + expect(samples.ownedParent).toHaveLength(SAMPLE_RUNS_PER_ARM) + expect(samples.sharedRoot).toHaveLength(SAMPLE_RUNS_PER_ARM) + expect(await countForeignEntries(fixtureRoot)).toBe(FOREIGN_ENTRY_COUNT) + await expect(access(freshTransfer)).resolves.toBeUndefined() + const sharedRoot = summarize(samples.sharedRoot) + const ownedParent = summarize(samples.ownedParent) + console.log('correctness=pass') + console.log( + `sampling=ABBA positions-per-arm=${SAMPLE_RUNS_PER_ARM / 2}/${SAMPLE_RUNS_PER_ARM / 2} p95-order-drift<=1-slot` + ) + console.log(`post12917-shared-root median=${sharedRoot.median}ms p95=${sharedRoot.p95}ms`) + console.log(`owned-parent median=${ownedParent.median}ms p95=${ownedParent.p95}ms`) + }, 900_000) +}) + +async function createForeignEntries(root: string, count: number): Promise { + const batchSize = 128 + for (let start = 0; start < count; start += batchSize) { + const tasks: Promise[] = [] + for (let index = start; index < Math.min(start + batchSize, count); index += 1) { + const target = join(root, `foreign-${String(index).padStart(8, '0')}`) + tasks.push(index % 2 === 0 ? writeFile(target, '') : mkdir(target)) + } + await Promise.all(tasks) + } +} + +async function countForeignEntries(root: string): Promise { + return (await readdir(root)).filter((name) => name.startsWith('foreign-')).length +} + +async function scanSharedTempRoot(root: string): Promise { + const directory = await opendir(root) + try { + for await (const entry of directory) { + if (entry.isDirectory() && entry.name.startsWith('orca-clipboard-file-')) { + // Reproduces the remaining shared-root iteration after #12917. + } + } + } finally { + await directory.close().catch(() => undefined) + } +} + +async function measure(operation: () => Promise): Promise { + const startedAt = process.hrtime.bigint() + await operation() + return Number(process.hrtime.bigint() - startedAt) / 1_000_000 +} + +function summarize(samples: number[]): { median: string; p95: string } { + const sorted = [...samples].sort((left, right) => left - right) + const middle = Math.floor(sorted.length / 2) + const median = + sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle] + const p95 = sorted[Math.ceil(sorted.length * 0.95) - 1] + return { median: median.toFixed(3), p95: p95.toFixed(3) } +} diff --git a/src/main/window/clipboard-remote-file-cleanup.test.ts b/src/main/window/clipboard-remote-file-cleanup.test.ts deleted file mode 100644 index 56700cc025e..00000000000 --- a/src/main/window/clipboard-remote-file-cleanup.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { join } from 'node:path' - -const { opendirMock, rmMock, statMock } = vi.hoisted(() => ({ - opendirMock: vi.fn(), - rmMock: vi.fn(), - statMock: vi.fn() -})) - -vi.mock('node:fs/promises', () => ({ - mkdir: vi.fn(), - opendir: opendirMock, - rm: rmMock, - stat: statMock -})) -vi.mock('electron', () => ({ app: { getPath: () => '/tmp' } })) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - requireSshFilesystemProvider: vi.fn() -})) -vi.mock('./clipboard-file-copy', () => ({ writeFileToClipboard: vi.fn() })) - -import { cleanupExpiredRemoteClipboardFiles } from './clipboard-remote-file-copy' - -const TTL_MS = 60 * 60 * 1000 -const NOW_MS = 1_760_000_000_000 - -function mockTempRoot(entries: Iterable<{ name: string; isDirectory: () => boolean }>): void { - opendirMock.mockResolvedValue({ - async *[Symbol.asyncIterator]() { - yield* entries - }, - close: vi.fn().mockResolvedValue(undefined) - }) -} - -function* orcaStagingDirs(count: number): Generator<{ name: string; isDirectory: () => boolean }> { - for (let index = 0; index < count; index += 1) { - yield { name: `orca-clipboard-file-expired-${index}`, isDirectory: () => true } - } -} - -function* unrelatedTempEntries( - count: number -): Generator<{ name: string; isDirectory: () => boolean }> { - for (let index = 0; index < count; index += 1) { - // Mirrors a real temp root: mostly foreign entries, both files and directories. - yield { name: `unrelated-${index}`, isDirectory: () => index % 2 === 0 } - } -} - -describe('cleanupExpiredRemoteClipboardFiles', () => { - beforeEach(() => { - vi.clearAllMocks() - rmMock.mockResolvedValue(undefined) - statMock.mockResolvedValue({ mtimeMs: NOW_MS - TTL_MS - 1 }) - }) - - it('streams all entries with at most eight cleanups in flight', async () => { - mockTempRoot(orcaStagingDirs(257)) - let active = 0 - let peak = 0 - statMock.mockImplementation(async () => { - active += 1 - peak = Math.max(peak, active) - await new Promise((resolve) => setImmediate(resolve)) - active -= 1 - return { mtimeMs: NOW_MS - TTL_MS - 1 } - }) - - await cleanupExpiredRemoteClipboardFiles(NOW_MS) - - expect(rmMock).toHaveBeenCalledTimes(257) - expect(peak).toBe(8) - }) - - it('does no per-entry work for foreign temp-root entries', async () => { - mockTempRoot(unrelatedTempEntries(200_000)) - - await cleanupExpiredRemoteClipboardFiles(NOW_MS) - - expect(statMock).not.toHaveBeenCalled() - expect(rmMock).not.toHaveBeenCalled() - }) - - it('acts on owned directories while the temp root is still being enumerated', async () => { - // Regression: the sweep materialized the whole temp root and built one promise - // per entry before doing any work, so a %TEMP% with ~1.2M unrelated entries - // froze the main process at startup (#12835). Streaming means an owned - // directory is swept before enumeration reaches the end. - let sweptDuringEnumeration = false - function* enumeration(): Generator<{ name: string; isDirectory: () => boolean }> { - yield { name: 'orca-clipboard-file-expired', isDirectory: () => true } - for (const entry of unrelatedTempEntries(1_000)) { - sweptDuringEnumeration ||= rmMock.mock.calls.length > 0 - yield entry - } - } - mockTempRoot(enumeration()) - - await cleanupExpiredRemoteClipboardFiles(NOW_MS) - - expect(sweptDuringEnumeration).toBe(true) - expect(rmMock).toHaveBeenCalledTimes(1) - }) - - it('removes only expired staging directories it owns', async () => { - function* interleaved(): Generator<{ name: string; isDirectory: () => boolean }> { - yield* unrelatedTempEntries(50_000) - yield { name: 'orca-clipboard-file-expired', isDirectory: () => true } - yield* unrelatedTempEntries(50_000) - yield { name: 'orca-clipboard-file-fresh', isDirectory: () => true } - yield { name: 'orca-clipboard-file-plain-file', isDirectory: () => false } - } - mockTempRoot(interleaved()) - statMock.mockImplementation(async (targetPath: string) => ({ - mtimeMs: targetPath.endsWith('expired') ? NOW_MS - TTL_MS - 1 : NOW_MS - 1000 - })) - - await cleanupExpiredRemoteClipboardFiles(NOW_MS) - - expect(statMock).toHaveBeenCalledTimes(2) - expect(rmMock).toHaveBeenCalledTimes(1) - expect(rmMock).toHaveBeenCalledWith(join('/tmp', 'orca-clipboard-file-expired'), { - recursive: true, - force: true - }) - }) - - it('closes the temp-root handle and still sweeps when enumeration fails midway', async () => { - const close = vi.fn().mockResolvedValue(undefined) - opendirMock.mockResolvedValue({ - async *[Symbol.asyncIterator]() { - yield { name: 'orca-clipboard-file-expired', isDirectory: () => true } - throw new Error('EIO') - }, - close - }) - - await expect(cleanupExpiredRemoteClipboardFiles(NOW_MS)).resolves.toBeUndefined() - - expect(rmMock).toHaveBeenCalledTimes(1) - expect(close).toHaveBeenCalledTimes(1) - }) - - it('returns quietly when the temp root cannot be opened', async () => { - opendirMock.mockRejectedValue(new Error('EACCES')) - - await expect(cleanupExpiredRemoteClipboardFiles(NOW_MS)).resolves.toBeUndefined() - - expect(statMock).not.toHaveBeenCalled() - expect(rmMock).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/window/clipboard-remote-file-copy.test.ts b/src/main/window/clipboard-remote-file-copy.test.ts new file mode 100644 index 00000000000..1f851e1ec09 --- /dev/null +++ b/src/main/window/clipboard-remote-file-copy.test.ts @@ -0,0 +1,149 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + cleanupLegacyRemoteClipboardStagingMock, + createRemoteClipboardTransferDirectoryMock, + downloadFileMock, + requireSshFilesystemProviderMock, + RemoteClipboardStagingRootUnsafeErrorMock, + spanFailMock, + startSpanMock +} = vi.hoisted(() => { + class RemoteClipboardStagingRootUnsafeErrorMock extends Error {} + const spanFailMock = vi.fn() + return { + cleanupLegacyRemoteClipboardStagingMock: vi.fn(async () => undefined), + createRemoteClipboardTransferDirectoryMock: vi.fn(), + downloadFileMock: vi.fn(), + requireSshFilesystemProviderMock: vi.fn(), + RemoteClipboardStagingRootUnsafeErrorMock, + spanFailMock, + startSpanMock: vi.fn(() => ({ fail: spanFailMock })) + } +}) + +vi.mock('electron', () => ({ app: { getPath: () => '/tmp' } })) +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + requireSshFilesystemProvider: requireSshFilesystemProviderMock +})) +vi.mock('../observability/tracer', () => ({ startSpan: startSpanMock })) +vi.mock('./clipboard-file-copy', () => ({ writeFileToClipboard: vi.fn() })) +vi.mock('./clipboard-remote-file-staging', () => ({ + cleanupExpiredRemoteClipboardStaging: vi.fn(async () => undefined), + cleanupLegacyRemoteClipboardStaging: cleanupLegacyRemoteClipboardStagingMock, + createRemoteClipboardTransferDirectory: createRemoteClipboardTransferDirectoryMock, + RemoteClipboardStagingRootUnsafeError: RemoteClipboardStagingRootUnsafeErrorMock, + removeRemoteClipboardTransferDirectory: vi.fn(), + scheduleRemoteClipboardTransferCleanup: vi.fn() +})) + +import { + scheduleLegacyRemoteClipboardFileCleanup, + writeRemoteFileToClipboard +} from './clipboard-remote-file-copy' +import { RemoteClipboardStagingRootUnsafeError } from './clipboard-remote-file-staging' + +beforeEach(() => { + vi.clearAllMocks() + requireSshFilesystemProviderMock.mockReturnValue({ + stat: vi.fn(async () => ({ type: 'file' })), + downloadFile: downloadFileMock + }) +}) + +describe('remote clipboard staging failures', () => { + it('rejects remote directories before creating staging', async () => { + requireSshFilesystemProviderMock.mockReturnValue({ + stat: vi.fn(async () => ({ type: 'directory' })), + downloadFile: downloadFileMock + }) + + await expect( + writeRemoteFileToClipboard({ + remotePath: '/repo/src', + connectionId: 'ssh-1', + deps: { + platform: 'win32', + writeBuffer: vi.fn(), + runCommand: vi.fn() + } + }) + ).resolves.toEqual({ ok: false, reason: 'is-directory' }) + + expect(createRemoteClipboardTransferDirectoryMock).not.toHaveBeenCalled() + expect(downloadFileMock).not.toHaveBeenCalled() + }) + + it.each([ + { + category: 'unsafe-root', + error: new RemoteClipboardStagingRootUnsafeError() + }, + { + category: 'permissions', + error: Object.assign(new Error('access denied'), { code: 'EACCES' }) + }, + { + category: 'permissions', + error: Object.assign(new Error('operation not permitted'), { code: 'EPERM' }) + }, + { + category: 'path-conflict', + error: Object.assign(new Error('path exists'), { code: 'EEXIST' }) + }, + { + category: 'unavailable', + error: new Error('storage offline') + } + ])( + 'returns a toast-safe reason and records $category diagnostics', + async ({ category, error }) => { + createRemoteClipboardTransferDirectoryMock.mockRejectedValueOnce(error) + + await expect( + writeRemoteFileToClipboard({ + remotePath: '/repo/readme.md', + connectionId: 'ssh-1', + deps: { + platform: 'win32', + writeBuffer: vi.fn(), + runCommand: vi.fn() + } + }) + ).resolves.toEqual({ ok: false, reason: 'staging-unavailable' }) + + expect(startSpanMock).toHaveBeenCalledWith('clipboard.remote_staging_init', { + attributes: { + operation: 'create', + platform: process.platform, + failure_category: category + } + }) + expect(spanFailMock).toHaveBeenCalledWith(error) + expect(downloadFileMock).not.toHaveBeenCalled() + } + ) +}) + +describe('legacy remote clipboard cleanup scheduling', () => { + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('runs the shared-root compatibility scan once after the startup delay', async () => { + vi.useFakeTimers() + vi.spyOn(Date, 'now').mockReturnValue(1_760_000_000_000) + + scheduleLegacyRemoteClipboardFileCleanup() + scheduleLegacyRemoteClipboardFileCleanup() + await vi.advanceTimersByTimeAsync(29_999) + + expect(cleanupLegacyRemoteClipboardStagingMock).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1) + + expect(cleanupLegacyRemoteClipboardStagingMock).toHaveBeenCalledOnce() + expect(cleanupLegacyRemoteClipboardStagingMock).toHaveBeenCalledWith('/tmp', 1_760_000_000_000) + }) +}) diff --git a/src/main/window/clipboard-remote-file-copy.ts b/src/main/window/clipboard-remote-file-copy.ts index 2afc60cda30..47bbbc0c5bb 100644 --- a/src/main/window/clipboard-remote-file-copy.ts +++ b/src/main/window/clipboard-remote-file-copy.ts @@ -1,25 +1,32 @@ import { randomUUID } from 'node:crypto' -import type { Dir } from 'node:fs' -import { mkdir, opendir, rm, stat } from 'node:fs/promises' +import { stat } from 'node:fs/promises' import { join } from 'node:path' import { app } from 'electron' import { getRuntimePathBasename } from '../../shared/cross-platform-path' +import { startSpan } from '../observability/tracer' import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { writeFileToClipboard, type ClipboardFileDeps, type ClipboardFileResult } from './clipboard-file-copy' +import { + cleanupExpiredRemoteClipboardStaging, + cleanupLegacyRemoteClipboardStaging, + createRemoteClipboardTransferDirectory, + RemoteClipboardStagingRootUnsafeError, + removeRemoteClipboardTransferDirectory, + scheduleRemoteClipboardTransferCleanup +} from './clipboard-remote-file-staging' type RemoteClipboardFileDeps = Omit -const REMOTE_CLIPBOARD_FILE_TTL_MS = 60 * 60 * 1000 -const REMOTE_CLIPBOARD_FILE_PREFIX = 'orca-clipboard-file-' -const REMOTE_CLIPBOARD_CLEANUP_CONCURRENCY = 8 +const REMOTE_CLIPBOARD_LEGACY_CLEANUP_DELAY_MS = 30 * 1000 const WINDOWS_RESERVED_LOCAL_BASENAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i const LOCAL_FILENAME_REPLACEMENT_CHARS = new Set(['<', '>', ':', '"', '/', '\\', '|', '?', '*']) +let legacyCleanupScheduled = false export async function writeRemoteFileToClipboard({ remotePath, @@ -39,11 +46,21 @@ export async function writeRemoteFileToClipboard({ throw new Error('Remote file download is unavailable. Reconnect the SSH target and retry.') } - const tempDir = join( - app.getPath('temp'), - `${REMOTE_CLIPBOARD_FILE_PREFIX}${Date.now()}-${randomUUID()}` - ) - await mkdir(tempDir, { mode: 0o700 }) + const tempRoot = app.getPath('temp') + let tempDir: string + try { + tempDir = await createRemoteClipboardTransferDirectory(tempRoot, Date.now(), randomUUID()) + } catch (error) { + const span = startSpan('clipboard.remote_staging_init', { + attributes: { + operation: 'create', + platform: process.platform, + failure_category: getStagingFailureCategory(error) + } + }) + span.fail(error instanceof Error ? error : String(error)) + return { ok: false, reason: 'staging-unavailable' } + } const localPath = join( tempDir, sanitizeLocalClipboardFilename(getRuntimePathBasename(remotePath)) @@ -70,62 +87,33 @@ export async function writeRemoteFileToClipboard({ // Why: OS file clipboards keep a path reference, so the staged copy must // survive after this IPC call long enough for the user to paste it. keepTempFile = true - scheduleRemoteClipboardFileCleanup(tempDir) + scheduleRemoteClipboardTransferCleanup(tempRoot, tempDir) } return result } finally { if (!keepTempFile) { - await rm(tempDir, { recursive: true, force: true }).catch(() => undefined) + await removeRemoteClipboardTransferDirectory(tempRoot, tempDir) } } } -// Why: the OS temp root is shared with every other program and routinely holds -// millions of unrelated entries on long-lived machines, so this startup sweep -// streams it and only ever retains work for entries it actually owns. export async function cleanupExpiredRemoteClipboardFiles(nowMs = Date.now()): Promise { - const tempRoot = app.getPath('temp') - let tempRootDir: Dir - try { - tempRootDir = await opendir(tempRoot) - } catch { + await cleanupExpiredRemoteClipboardStaging(app.getPath('temp'), nowMs) +} + +export async function cleanupLegacyRemoteClipboardFiles(nowMs = Date.now()): Promise { + await cleanupLegacyRemoteClipboardStaging(app.getPath('temp'), nowMs) +} + +export function scheduleLegacyRemoteClipboardFileCleanup(): void { + if (legacyCleanupScheduled) { return } - - const pending = new Set>() - try { - for await (const entry of tempRootDir) { - if (!entry.isDirectory() || !entry.name.startsWith(REMOTE_CLIPBOARD_FILE_PREFIX)) { - continue - } - const cleanup = cleanupExpiredRemoteClipboardDirectory(join(tempRoot, entry.name), nowMs) - pending.add(cleanup) - void cleanup.finally(() => pending.delete(cleanup)) - if (pending.size >= REMOTE_CLIPBOARD_CLEANUP_CONCURRENCY) { - await Promise.race(pending) - } - } - } catch { - // Why: a partial best-effort sweep beats failing startup on a temp-root read error. - } finally { - // Why: exhausting the iterator already closes the handle; closing again is harmless. - await tempRootDir.close().catch(() => undefined) - } - await Promise.all(pending) -} - -async function cleanupExpiredRemoteClipboardDirectory( - tempDir: string, - nowMs: number -): Promise { - try { - const tempStats = await stat(tempDir) - if (nowMs - tempStats.mtimeMs >= REMOTE_CLIPBOARD_FILE_TTL_MS) { - await rm(tempDir, { recursive: true, force: true }) - } - } catch { - // Why: stale staged SSH files should not make startup cleanup noisy. - } + legacyCleanupScheduled = true + const timer = setTimeout(() => { + void cleanupLegacyRemoteClipboardFiles().catch(() => undefined) + }, REMOTE_CLIPBOARD_LEGACY_CLEANUP_DELAY_MS) + unrefTimer(timer) } function sanitizeLocalClipboardFilename(remoteBasename: string): string { @@ -140,11 +128,22 @@ function sanitizeLocalClipboardFilename(remoteBasename: string): string { return sanitized } -function scheduleRemoteClipboardFileCleanup(tempDir: string): void { - const timer = setTimeout(() => { - void rm(tempDir, { recursive: true, force: true }).catch(() => undefined) - }, REMOTE_CLIPBOARD_FILE_TTL_MS) +function unrefTimer(timer: ReturnType): void { if (typeof timer === 'object' && 'unref' in timer) { timer.unref() } } + +function getStagingFailureCategory(error: unknown): string { + if (error instanceof RemoteClipboardStagingRootUnsafeError) { + return 'unsafe-root' + } + const code = error instanceof Error && 'code' in error ? String(error.code) : undefined + if (code === 'EACCES' || code === 'EPERM') { + return 'permissions' + } + if (code === 'EEXIST' || code === 'ENOTDIR') { + return 'path-conflict' + } + return 'unavailable' +} diff --git a/src/main/window/clipboard-remote-file-staging-fs.test.ts b/src/main/window/clipboard-remote-file-staging-fs.test.ts new file mode 100644 index 00000000000..dc464528cb6 --- /dev/null +++ b/src/main/window/clipboard-remote-file-staging-fs.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { access, lstat, mkdir, mkdtemp, readdir, rm, symlink, utimes } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' + +import { + cleanupExpiredRemoteClipboardStaging, + createRemoteClipboardTransferDirectory, + getRemoteClipboardStagingRoot, + RemoteClipboardStagingRootUnsafeError +} from './clipboard-remote-file-staging' + +const FIXTURE_PREFIX = 'orca-clipboard-staging-test-' +const NOW_MS = 1_760_000_000_000 +const fixtures: string[] = [] + +afterEach(async () => { + await Promise.all( + fixtures.splice(0).map(async (fixture) => { + expect(dirname(fixture)).toBe(tmpdir()) + expect(basename(fixture)).toMatch(new RegExp(`^${FIXTURE_PREFIX}`)) + await rm(fixture, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }) + }) + ) +}) + +describe('remote clipboard staging filesystem contract', () => { + it('removes only expired owned transfers with real filesystem metadata', async () => { + const tempRoot = await makeFixture() + const foreignDirectory = join(tempRoot, 'foreign-directory') + await mkdir(foreignDirectory) + const expiredTransfer = await createRemoteClipboardTransferDirectory( + tempRoot, + NOW_MS - 2 * 60 * 60 * 1000, + '00000000-0000-4000-8000-000000000000' + ) + const freshTransfer = await createRemoteClipboardTransferDirectory( + tempRoot, + NOW_MS, + '00000000-0000-4000-8000-000000000001' + ) + const expiredAt = new Date(NOW_MS - 2 * 60 * 60 * 1000) + await utimes(expiredTransfer, expiredAt, expiredAt) + + await cleanupExpiredRemoteClipboardStaging(tempRoot, NOW_MS) + + await expect(access(expiredTransfer)).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(access(freshTransfer)).resolves.toBeUndefined() + await expect(access(foreignDirectory)).resolves.toBeUndefined() + if (typeof process.getuid === 'function') { + const rootStats = await lstat(getRemoteClipboardStagingRoot(tempRoot)) + expect(rootStats.uid).toBe(process.getuid()) + expect(rootStats.mode & 0o777).toBe(0o700) + } + }) + + it('rejects a real staging-root symlink without writing through it', async () => { + const tempRoot = await makeFixture() + const outsideDirectory = join(tempRoot, 'outside') + const stagingRoot = getRemoteClipboardStagingRoot(tempRoot) + await mkdir(outsideDirectory) + await symlink(outsideDirectory, stagingRoot, process.platform === 'win32' ? 'junction' : 'dir') + + await expect( + createRemoteClipboardTransferDirectory( + tempRoot, + NOW_MS, + '00000000-0000-4000-8000-000000000000' + ) + ).rejects.toBeInstanceOf(RemoteClipboardStagingRootUnsafeError) + + expect(await readdir(outsideDirectory)).toEqual([]) + }) +}) + +async function makeFixture(): Promise { + const fixture = await mkdtemp(join(tmpdir(), FIXTURE_PREFIX)) + fixtures.push(fixture) + return fixture +} diff --git a/src/main/window/clipboard-remote-file-staging.test.ts b/src/main/window/clipboard-remote-file-staging.test.ts new file mode 100644 index 00000000000..5ca20e68383 --- /dev/null +++ b/src/main/window/clipboard-remote-file-staging.test.ts @@ -0,0 +1,360 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { join, resolve } from 'node:path' + +const { accessMock, lstatMock, mkdirMock, opendirMock, rmMock, writeFileMock } = vi.hoisted(() => ({ + accessMock: vi.fn(), + lstatMock: vi.fn(), + mkdirMock: vi.fn(), + opendirMock: vi.fn(), + rmMock: vi.fn(), + writeFileMock: vi.fn() +})) + +vi.mock('node:fs/promises', () => ({ + access: accessMock, + lstat: lstatMock, + mkdir: mkdirMock, + opendir: opendirMock, + rm: rmMock, + writeFile: writeFileMock +})) + +import { + cleanupExpiredRemoteClipboardStaging, + cleanupLegacyRemoteClipboardStaging, + createRemoteClipboardTransferDirectory, + getRemoteClipboardStagingRoot, + removeRemoteClipboardTransferDirectory, + scheduleRemoteClipboardTransferCleanup +} from './clipboard-remote-file-staging' + +const TTL_MS = 60 * 60 * 1000 +const RETRY_MS = 60 * 1000 +const NOW_MS = 1_760_000_000_000 +const TEMP_ROOT = resolve('fixture-temp') +const UID_SUFFIX = typeof process.getuid === 'function' ? `-${process.getuid()}` : '' +const STAGING_ROOT = join(TEMP_ROOT, `orca-clipboard-files${UID_SUFFIX}`) +const MARKER_PATH = join(STAGING_ROOT, '.legacy-cleanup-complete') +const UUID_A = '00000000-0000-4000-8000-000000000000' +const UUID_B = '00000000-0000-4000-8000-000000000001' +const EXPIRED_TRANSFER = `1759990000000-${UUID_A}` +const FRESH_TRANSFER = `1760000000000-${UUID_B}` +const LEGACY_EXPIRED = `orca-clipboard-file-${EXPIRED_TRANSFER}` +const LEGACY_FRESH = `orca-clipboard-file-${FRESH_TRANSFER}` + +type MockDirent = { name: string; isDirectory: () => boolean } + +function directoryEntry(name: string, isDirectory = true): MockDirent { + return { name, isDirectory: () => isDirectory } +} + +function openedDirectory(entries: Iterable): { + [Symbol.asyncIterator]: () => AsyncGenerator + close: ReturnType +} { + return { + async *[Symbol.asyncIterator]() { + yield* entries + }, + close: vi.fn().mockResolvedValue(undefined) + } +} + +function safeDirectoryStats(mtimeMs = NOW_MS - TTL_MS - 1) { + return { + isDirectory: () => true, + isSymbolicLink: () => false, + mode: 0o700, + mtimeMs, + uid: typeof process.getuid === 'function' ? process.getuid() : 0 + } +} + +function missingError(): NodeJS.ErrnoException { + return Object.assign(new Error('missing'), { code: 'ENOENT' }) +} + +describe('remote clipboard staging ownership', () => { + beforeEach(() => { + vi.clearAllMocks() + accessMock.mockRejectedValue(missingError()) + lstatMock.mockResolvedValue(safeDirectoryStats()) + mkdirMock.mockResolvedValue(undefined) + opendirMock.mockResolvedValue(openedDirectory([])) + rmMock.mockResolvedValue(undefined) + writeFileMock.mockResolvedValue(undefined) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('creates a private owned parent and a per-transfer child', async () => { + const result = await createRemoteClipboardTransferDirectory(TEMP_ROOT, NOW_MS, UUID_A) + + expect(result).toBe(join(STAGING_ROOT, `${NOW_MS}-${UUID_A}`)) + expect(mkdirMock).toHaveBeenNthCalledWith(1, STAGING_ROOT, { + recursive: true, + mode: 0o700 + }) + expect(mkdirMock).toHaveBeenNthCalledWith(2, result, { mode: 0o700 }) + }) + + it('scans only the owned parent during normal crash cleanup', async () => { + await cleanupExpiredRemoteClipboardStaging(TEMP_ROOT, NOW_MS) + + expect(opendirMock).toHaveBeenCalledOnce() + expect(opendirMock).toHaveBeenCalledWith(STAGING_ROOT) + expect(opendirMock).not.toHaveBeenCalledWith(TEMP_ROOT) + }) + + it('removes expired crash leftovers and preserves fresh or unknown children', async () => { + opendirMock.mockResolvedValue( + openedDirectory([ + directoryEntry(EXPIRED_TRANSFER), + directoryEntry(FRESH_TRANSFER), + directoryEntry('unknown-child'), + directoryEntry('.legacy-cleanup-complete', false) + ]) + ) + lstatMock.mockImplementation(async (targetPath: string) => + safeDirectoryStats(targetPath.endsWith(FRESH_TRANSFER) ? NOW_MS - 1_000 : undefined) + ) + + await cleanupExpiredRemoteClipboardStaging(TEMP_ROOT, NOW_MS) + + expect(rmMock).toHaveBeenCalledOnce() + expect(rmMock).toHaveBeenCalledWith(join(STAGING_ROOT, EXPIRED_TRANSFER), { + force: true, + maxRetries: 3, + recursive: true, + retryDelay: 100 + }) + }) + + it('rejects a symlinked owned parent before opening it', async () => { + lstatMock.mockResolvedValue({ + ...safeDirectoryStats(), + isDirectory: () => false, + isSymbolicLink: () => true + }) + + await expect(cleanupExpiredRemoteClipboardStaging(TEMP_ROOT, NOW_MS)).resolves.toBeUndefined() + + expect(opendirMock).not.toHaveBeenCalled() + expect(rmMock).not.toHaveBeenCalled() + }) + + it.skipIf(typeof process.getuid !== 'function')( + 'rejects wrong-owner and insecure POSIX parents', + async () => { + lstatMock + .mockResolvedValueOnce({ ...safeDirectoryStats(), uid: process.getuid!() + 1 }) + .mockResolvedValueOnce({ ...safeDirectoryStats(), mode: 0o755 }) + + await cleanupExpiredRemoteClipboardStaging(TEMP_ROOT, NOW_MS) + await cleanupExpiredRemoteClipboardStaging(TEMP_ROOT, NOW_MS) + + expect(opendirMock).not.toHaveBeenCalled() + } + ) + + it('rejects child symlinks and names outside the owned parent', async () => { + opendirMock.mockResolvedValue( + openedDirectory([directoryEntry(EXPIRED_TRANSFER), directoryEntry('..')]) + ) + lstatMock.mockImplementation(async (targetPath: string) => + targetPath === STAGING_ROOT + ? safeDirectoryStats() + : { + ...safeDirectoryStats(), + isDirectory: () => false, + isSymbolicLink: () => true + } + ) + + await cleanupExpiredRemoteClipboardStaging(TEMP_ROOT, NOW_MS) + + expect(lstatMock).not.toHaveBeenCalledWith(TEMP_ROOT) + expect(rmMock).not.toHaveBeenCalled() + }) + + it('contains explicit removals to direct owned children', async () => { + expect( + await removeRemoteClipboardTransferDirectory(TEMP_ROOT, join(STAGING_ROOT, '..', 'victim')) + ).toBe(false) + expect(await removeRemoteClipboardTransferDirectory(TEMP_ROOT, STAGING_ROOT)).toBe(false) + + expect(rmMock).not.toHaveBeenCalled() + }) + + it('treats an already-removed transfer as cleanup success', async () => { + lstatMock.mockImplementation(async (targetPath: string) => { + if (targetPath === STAGING_ROOT) { + return safeDirectoryStats() + } + throw missingError() + }) + + await expect( + removeRemoteClipboardTransferDirectory(TEMP_ROOT, join(STAGING_ROOT, EXPIRED_TRANSFER)) + ).resolves.toBe(true) + + expect(rmMock).not.toHaveBeenCalled() + }) + + it('continues past Windows-style lock failures and retries on a later sweep', async () => { + opendirMock.mockResolvedValue( + openedDirectory([directoryEntry(EXPIRED_TRANSFER), directoryEntry(`1759990000000-${UUID_B}`)]) + ) + rmMock.mockImplementation(async (targetPath: string) => { + if (targetPath.endsWith(EXPIRED_TRANSFER)) { + throw Object.assign(new Error('locked'), { code: 'EPERM' }) + } + }) + + await expect(cleanupExpiredRemoteClipboardStaging(TEMP_ROOT, NOW_MS)).resolves.toBeUndefined() + + expect(rmMock).toHaveBeenCalledTimes(2) + rmMock.mockResolvedValue(undefined) + await cleanupExpiredRemoteClipboardStaging(TEMP_ROOT, NOW_MS) + expect(rmMock).toHaveBeenCalledTimes(4) + }) + + it('bounds locked-file timer retries', async () => { + vi.useFakeTimers() + rmMock.mockRejectedValue(Object.assign(new Error('locked'), { code: 'EPERM' })) + + scheduleRemoteClipboardTransferCleanup(TEMP_ROOT, join(STAGING_ROOT, EXPIRED_TRANSFER)) + await vi.advanceTimersByTimeAsync(TTL_MS + 4 * RETRY_MS) + + expect(rmMock).toHaveBeenCalledTimes(4) + expect(vi.getTimerCount()).toBe(0) + }) + + it('keeps owned-child cleanup concurrency at eight', async () => { + const entries = Array.from({ length: 257 }, (_, index) => + directoryEntry(`1759990000000-00000000-0000-4000-8000-${String(index).padStart(12, '0')}`) + ) + opendirMock.mockResolvedValue(openedDirectory(entries)) + let active = 0 + let peak = 0 + lstatMock.mockImplementation(async () => { + active += 1 + peak = Math.max(peak, active) + await new Promise((resolveImmediate) => setImmediate(resolveImmediate)) + active -= 1 + return safeDirectoryStats() + }) + + await cleanupExpiredRemoteClipboardStaging(TEMP_ROOT, NOW_MS) + + expect(rmMock).toHaveBeenCalledTimes(257) + expect(peak).toBe(8) + }) +}) + +describe('legacy remote clipboard staging compatibility', () => { + beforeEach(() => { + vi.clearAllMocks() + accessMock.mockRejectedValue(missingError()) + lstatMock.mockResolvedValue(safeDirectoryStats()) + mkdirMock.mockResolvedValue(undefined) + opendirMock.mockResolvedValue(openedDirectory([])) + rmMock.mockResolvedValue(undefined) + writeFileMock.mockResolvedValue(undefined) + }) + + it('marks migration complete when a legacy child falls beyond the 4096-entry window', async () => { + let visited = 0 + let reachedLateLegacyChild = false + function* foreignEntries(): Generator { + for (let index = 0; index < 200_000; index += 1) { + if (index === 4_096) { + reachedLateLegacyChild = true + yield directoryEntry(LEGACY_EXPIRED) + } + visited += 1 + yield directoryEntry(`foreign-${index}`, index % 2 === 0) + } + } + opendirMock.mockResolvedValue(openedDirectory(foreignEntries())) + + await cleanupLegacyRemoteClipboardStaging(TEMP_ROOT, NOW_MS) + + expect(visited).toBe(4_096) + expect(reachedLateLegacyChild).toBe(false) + expect(lstatMock).toHaveBeenCalledOnce() + expect(rmMock).not.toHaveBeenCalled() + expect(writeFileMock).toHaveBeenCalledWith(MARKER_PATH, '', { + flag: 'wx', + mode: 0o600 + }) + }) + + it('removes only exact expired legacy children encountered inside the bound', async () => { + opendirMock.mockResolvedValue( + openedDirectory([ + directoryEntry('orca-clipboard-file-lookalike'), + directoryEntry(LEGACY_EXPIRED), + directoryEntry(LEGACY_EXPIRED, false) + ]) + ) + + await cleanupLegacyRemoteClipboardStaging(TEMP_ROOT, NOW_MS) + + expect(rmMock).toHaveBeenCalledOnce() + expect(rmMock).toHaveBeenCalledWith(join(TEMP_ROOT, LEGACY_EXPIRED), { + force: true, + maxRetries: 3, + recursive: true, + retryDelay: 100 + }) + expect(writeFileMock).toHaveBeenCalledOnce() + }) + + it('does not mark migration complete while a fresh or locked legacy child remains', async () => { + opendirMock.mockResolvedValue( + openedDirectory([directoryEntry(LEGACY_FRESH), directoryEntry(LEGACY_EXPIRED)]) + ) + lstatMock.mockImplementation(async (targetPath: string) => + safeDirectoryStats(targetPath.endsWith(LEGACY_FRESH) ? NOW_MS - 1_000 : undefined) + ) + rmMock.mockRejectedValue(Object.assign(new Error('locked'), { code: 'EBUSY' })) + + await cleanupLegacyRemoteClipboardStaging(TEMP_ROOT, NOW_MS) + + expect(writeFileMock).not.toHaveBeenCalled() + }) + + it('never follows legacy symlinks outside the shared temp root', async () => { + opendirMock.mockResolvedValue(openedDirectory([directoryEntry(LEGACY_EXPIRED)])) + lstatMock.mockImplementation(async (targetPath: string) => + targetPath === STAGING_ROOT + ? safeDirectoryStats() + : { + ...safeDirectoryStats(), + isDirectory: () => false, + isSymbolicLink: () => true + } + ) + + await cleanupLegacyRemoteClipboardStaging(TEMP_ROOT, NOW_MS) + + expect(rmMock).not.toHaveBeenCalled() + expect(writeFileMock).toHaveBeenCalledOnce() + }) + + it('skips the shared root after the one-time marker exists', async () => { + accessMock.mockResolvedValue(undefined) + + await cleanupLegacyRemoteClipboardStaging(TEMP_ROOT, NOW_MS) + + expect(accessMock).toHaveBeenCalledWith(MARKER_PATH) + expect(opendirMock).not.toHaveBeenCalled() + }) + + it('uses a platform-safe owned parent name', () => { + expect(getRemoteClipboardStagingRoot(TEMP_ROOT)).toBe(STAGING_ROOT) + }) +}) diff --git a/src/main/window/clipboard-remote-file-staging.ts b/src/main/window/clipboard-remote-file-staging.ts new file mode 100644 index 00000000000..58c1e0f2518 --- /dev/null +++ b/src/main/window/clipboard-remote-file-staging.ts @@ -0,0 +1,266 @@ +import type { Dir, Stats } from 'node:fs' +import { access, lstat, mkdir, opendir, rm, writeFile } from 'node:fs/promises' +import { basename, dirname, join, resolve } from 'node:path' + +const REMOTE_CLIPBOARD_STAGING_ROOT_NAME = 'orca-clipboard-files' +const REMOTE_CLIPBOARD_LEGACY_PREFIX = 'orca-clipboard-file-' +const REMOTE_CLIPBOARD_MIGRATION_MARKER = '.legacy-cleanup-complete' +const REMOTE_CLIPBOARD_FILE_TTL_MS = 60 * 60 * 1000 +const REMOTE_CLIPBOARD_CLEANUP_CONCURRENCY = 8 +const REMOTE_CLIPBOARD_CLEANUP_RETRY_MS = 60 * 1000 +const REMOTE_CLIPBOARD_CLEANUP_RETRY_LIMIT = 3 +// Why: compatibility cleanup must never restore O(shared temp root) work. +const REMOTE_CLIPBOARD_LEGACY_ENTRY_LIMIT = 4_096 +const UUID_PATTERN = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' +const TRANSFER_DIRECTORY_PATTERN = new RegExp(`^\\d{1,16}-${UUID_PATTERN}$`, 'i') +const LEGACY_DIRECTORY_PATTERN = new RegExp( + `^${REMOTE_CLIPBOARD_LEGACY_PREFIX}\\d{1,16}-${UUID_PATTERN}$`, + 'i' +) +const REMOVE_OPTIONS = { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 100 +} as const + +type CleanupResult = 'failed' | 'fresh' | 'ignored' | 'removed' + +export class RemoteClipboardStagingRootUnsafeError extends Error { + constructor() { + super('Remote clipboard staging root is unsafe') + this.name = 'RemoteClipboardStagingRootUnsafeError' + } +} + +export function getRemoteClipboardStagingRoot(tempRoot: string): string { + const uidSuffix = typeof process.getuid === 'function' ? `-${process.getuid()}` : '' + return join(tempRoot, `${REMOTE_CLIPBOARD_STAGING_ROOT_NAME}${uidSuffix}`) +} + +export async function createRemoteClipboardTransferDirectory( + tempRoot: string, + createdAtMs: number, + transferId: string +): Promise { + const stagingRoot = await ensureRemoteClipboardStagingRoot(tempRoot) + const transferDirectory = join(stagingRoot, `${createdAtMs}-${transferId}`) + if ( + !isTransferDirectoryName(basename(transferDirectory)) || + !isDirectChild(stagingRoot, transferDirectory) + ) { + throw new Error('Remote clipboard transfer path escapes its staging root') + } + await mkdir(transferDirectory, { mode: 0o700 }) + const transferStats = await lstat(transferDirectory) + if (!isSafeOwnedDirectory(transferStats)) { + throw new Error('Remote clipboard transfer directory is unsafe') + } + return transferDirectory +} + +export async function cleanupExpiredRemoteClipboardStaging( + tempRoot: string, + nowMs = Date.now() +): Promise { + let stagingRoot: string + try { + stagingRoot = await ensureRemoteClipboardStagingRoot(tempRoot) + } catch { + return + } + await sweepDirectories(stagingRoot, nowMs, isTransferDirectoryName) +} + +export async function cleanupLegacyRemoteClipboardStaging( + tempRoot: string, + nowMs = Date.now() +): Promise { + let stagingRoot: string + try { + stagingRoot = await ensureRemoteClipboardStagingRoot(tempRoot) + } catch { + return + } + + const markerPath = join(stagingRoot, REMOTE_CLIPBOARD_MIGRATION_MARKER) + try { + await access(markerPath) + return + } catch (error) { + if (!isMissingPathError(error)) { + return + } + } + + const result = await sweepDirectories( + tempRoot, + nowMs, + isLegacyTransferDirectoryName, + REMOTE_CLIPBOARD_LEGACY_ENTRY_LIMIT + ) + if (result.complete && !result.hasFreshDirectories && !result.hasFailures) { + await writeFile(markerPath, '', { flag: 'wx', mode: 0o600 }).catch(() => undefined) + } +} + +export async function removeRemoteClipboardTransferDirectory( + tempRoot: string, + transferDirectory: string +): Promise { + const stagingRoot = getRemoteClipboardStagingRoot(tempRoot) + if ( + !isDirectChild(stagingRoot, transferDirectory) || + !isTransferDirectoryName(basename(resolve(transferDirectory))) + ) { + return false + } + try { + await ensureRemoteClipboardStagingRoot(tempRoot) + const transferStats = await lstat(transferDirectory) + if (!isSafeOwnedDirectory(transferStats)) { + return false + } + await rm(transferDirectory, REMOVE_OPTIONS) + return true + } catch (error) { + return isMissingPathError(error) + } +} + +export function scheduleRemoteClipboardTransferCleanup( + tempRoot: string, + transferDirectory: string +): void { + scheduleCleanupAttempt( + tempRoot, + transferDirectory, + REMOTE_CLIPBOARD_FILE_TTL_MS, + REMOTE_CLIPBOARD_CLEANUP_RETRY_LIMIT + ) +} + +function scheduleCleanupAttempt( + tempRoot: string, + transferDirectory: string, + delayMs: number, + retriesRemaining: number +): void { + const timer = setTimeout(() => { + void removeRemoteClipboardTransferDirectory(tempRoot, transferDirectory).then((removed) => { + if (!removed && retriesRemaining > 0) { + scheduleCleanupAttempt( + tempRoot, + transferDirectory, + REMOTE_CLIPBOARD_CLEANUP_RETRY_MS, + retriesRemaining - 1 + ) + } + }) + }, delayMs) + if (typeof timer === 'object' && 'unref' in timer) { + timer.unref() + } +} + +async function ensureRemoteClipboardStagingRoot(tempRoot: string): Promise { + const stagingRoot = getRemoteClipboardStagingRoot(tempRoot) + await mkdir(stagingRoot, { recursive: true, mode: 0o700 }) + const rootStats = await lstat(stagingRoot) + if (!isSafeOwnedDirectory(rootStats)) { + throw new RemoteClipboardStagingRootUnsafeError() + } + return stagingRoot +} + +async function sweepDirectories( + root: string, + nowMs: number, + ownsEntry: (name: string) => boolean, + entryLimit = Number.POSITIVE_INFINITY +): Promise<{ complete: boolean; hasFailures: boolean; hasFreshDirectories: boolean }> { + let rootDir: Dir + try { + rootDir = await opendir(root) + } catch { + return { complete: false, hasFailures: false, hasFreshDirectories: false } + } + + let complete = true + let entriesVisited = 0 + let hasFailures = false + let hasFreshDirectories = false + const pending = new Set>() + try { + for await (const entry of rootDir) { + entriesVisited += 1 + if (entry.isDirectory() && ownsEntry(entry.name)) { + const candidate = join(root, entry.name) + if (isDirectChild(root, candidate)) { + const cleanup = cleanupDirectory(candidate, nowMs).then((result) => { + hasFailures ||= result === 'failed' + hasFreshDirectories ||= result === 'fresh' + }) + pending.add(cleanup) + void cleanup.finally(() => pending.delete(cleanup)) + if (pending.size >= REMOTE_CLIPBOARD_CLEANUP_CONCURRENCY) { + await Promise.race(pending) + } + } + } + if (entriesVisited >= entryLimit) { + break + } + } + } catch { + complete = false + } finally { + await rootDir.close().catch(() => undefined) + } + await Promise.all(pending) + return { complete, hasFailures, hasFreshDirectories } +} + +async function cleanupDirectory(directory: string, nowMs: number): Promise { + try { + const directoryStats = await lstat(directory) + if (!isSafeOwnedDirectory(directoryStats)) { + return 'ignored' + } + if (nowMs - directoryStats.mtimeMs < REMOTE_CLIPBOARD_FILE_TTL_MS) { + return 'fresh' + } + await rm(directory, REMOVE_OPTIONS) + return 'removed' + } catch (error) { + return isMissingPathError(error) ? 'ignored' : 'failed' + } +} + +function isSafeOwnedDirectory(stats: Stats): boolean { + if (!stats.isDirectory() || stats.isSymbolicLink()) { + return false + } + if (typeof process.getuid !== 'function') { + return true + } + return stats.uid === process.getuid() && (stats.mode & 0o777) === 0o700 +} + +function isDirectChild(parent: string, candidate: string): boolean { + return dirname(resolve(candidate)) === resolve(parent) +} + +function isTransferDirectoryName(name: string): boolean { + return TRANSFER_DIRECTORY_PATTERN.test(name) +} + +function isLegacyTransferDirectoryName(name: string): boolean { + return LEGACY_DIRECTORY_PATTERN.test(name) +} + +function isMissingPathError(error: unknown): boolean { + return ( + error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT' + ) +} diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index b6d1ff70035..3f02ba389d2 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -157,6 +157,9 @@ describe('createMainWindow', () => { on: vi.fn((event, handler) => { windowHandlers[event] = handler }), + once: vi.fn((event, handler) => { + windowHandlers[event] = handler + }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), invalidate: vi.fn(), @@ -699,6 +702,9 @@ describe('createMainWindow', () => { on: vi.fn((event, handler) => { windowHandlers[event] = handler }), + once: vi.fn((event, handler) => { + windowHandlers[event] = handler + }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), invalidate: vi.fn(), @@ -3431,6 +3437,9 @@ describe('createMainWindow', () => { on: vi.fn((event, handler) => { windowHandlers[event] = handler }), + once: vi.fn((event, handler) => { + windowHandlers[event] = handler + }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), invalidate: vi.fn(), @@ -3490,6 +3499,21 @@ describe('createMainWindow', () => { expect(browserWindowInstance.show).toHaveBeenCalledTimes(1) }) + it('can reveal the startup window after renderer load before ready-to-show', () => { + const { browserWindowInstance, windowHandlers } = createStartupRevealWindowFixture() + + createMainWindow(null, { revealOnDidFinishLoad: true }) + const revealAfterLoad = browserWindowInstance.webContents.on.mock.calls.find( + ([event]) => event === 'did-finish-load' + )?.[1] + expect(revealAfterLoad).toBeTypeOf('function') + revealAfterLoad?.() + + expect(browserWindowInstance.show).toHaveBeenCalledTimes(1) + windowHandlers['ready-to-show']() + expect(browserWindowInstance.show).toHaveBeenCalledTimes(1) + }) + it('reveals the startup window on Windows when ready-to-show never fires', () => { vi.useFakeTimers() const { browserWindowInstance } = createStartupRevealWindowFixture() diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 17bcd061e81..e46d84855f3 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -206,6 +206,8 @@ type CreateMainWindowOptions = { }) => void /** Defer renderer load until IPC handlers are registered, or eager renderer calls race into missing channels. */ deferLoad?: boolean + /** Reveal after load instead of first paint when startup must show the shell before slower renderer work. */ + revealOnDidFinishLoad?: boolean title?: string getKeybindings?: () => KeybindingOverrides | undefined onBeforeReload?: (options: { ignoreCache: boolean; webContentsId: number }) => void @@ -377,6 +379,9 @@ export function createMainWindow( mainWindow.show() } mainWindow.on('ready-to-show', revealInitialWindow) + if (opts?.revealOnDidFinishLoad === true) { + mainWindow.webContents.on('did-finish-load', revealInitialWindow) + } // Why: persist window bounds to restore last position/size; debounce to avoid hammering persistence during resize drags. let boundsTimer: ReturnType | null = null diff --git a/src/main/windows-native-registry.ts b/src/main/windows-native-registry.ts new file mode 100644 index 00000000000..e3a2d77889d --- /dev/null +++ b/src/main/windows-native-registry.ts @@ -0,0 +1,25 @@ +import { createRequire } from 'node:module' + +export type WindowsNativeRegistryValue = { + name?: unknown + type?: unknown + value?: unknown +} + +export type WindowsNativeRegistryModule = { + HK: { CU: number; LM: number } + getRegistryKey: ( + root: number, + path: string + ) => Record | null | undefined +} + +export const WINDOWS_REG_SZ = 1 +export const WINDOWS_REG_EXPAND_SZ = 2 + +const requireFromMain = createRequire(__filename) + +export function loadWindowsNativeRegistry(): WindowsNativeRegistryModule { + // Why: non-Windows installs omit this optional dependency, so never resolve it at module load. + return requireFromMain('windows-native-registry') as WindowsNativeRegistryModule +} diff --git a/src/main/worktree-removal-safety.ts b/src/main/worktree-removal-safety.ts index 5aa8d619d92..ef303c18220 100644 --- a/src/main/worktree-removal-safety.ts +++ b/src/main/worktree-removal-safety.ts @@ -23,7 +23,8 @@ const ORCA_OWNED_PROVENANCE_META_KEYS = [ 'orcaCreationSource', 'orcaCreationWorkspaceLayout', 'automationProvenance', - 'cliProvenance' + 'cliProvenance', + 'creatorProvenance' ] as const type UnregisteredOrcaCleanupMeta = Pick< WorktreeMeta, diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 2f1d7e4d12e..5878afc57b4 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2,12 +2,18 @@ import type { CreateHostedReviewArgs, CreateHostedReviewResult, + CreateStackedHostedReviewArgs, + CreateStackedHostedReviewResult, HostedReviewCreationEligibility, HostedReviewCreationEligibilityArgs, HostedReviewForBranchArgs, HostedReviewInfo, HostedReviewProvider } from '../shared/hosted-review' +import type { + BitbucketConnectArgs, + BitbucketConnectionStatus +} from '../shared/bitbucket-credentials' import type { NativeFileDropPayload } from '../shared/native-file-drop' import type { ComputerAwakeStatus } from '../shared/computer-awake-mode' import type { BrowserFindSource } from '../shared/browser-find-source' @@ -443,17 +449,7 @@ import type { ComputerUsePermissionSetupResult, ComputerUsePermissionStatusResult } from '../shared/computer-use-permissions-types' -import type { - ClaudeUsageBreakdownKind, - ClaudeUsageBreakdownRow, - ClaudeUsageDailyPoint, - ClaudeUsageRange, - ClaudeUsageScanState, - ClaudeUsageScope, - ClaudeUsageSessionRow, - ClaudeUsageSnapshot, - ClaudeUsageSummary -} from '../shared/claude-usage-types' +import type { ClaudeUsageBreakdownKind, ClaudeUsageSnapshot } from '../shared/claude-usage-types' import type { CodexRateLimitResetResult, GrokAccountStatus, @@ -480,27 +476,10 @@ import type { WorkspacePortScanResult } from '../shared/workspace-ports' import type { GhAuthDiagnostic } from '../shared/github-auth-types' -import type { - CodexUsageBreakdownKind, - CodexUsageBreakdownRow, - CodexUsageDailyPoint, - CodexUsageRange, - CodexUsageScanState, - CodexUsageScope, - CodexUsageSessionRow, - CodexUsageSnapshot, - CodexUsageSummary -} from '../shared/codex-usage-types' +import type { CodexUsageBreakdownKind, CodexUsageSnapshot } from '../shared/codex-usage-types' import type { OpenCodeUsageBreakdownKind, - OpenCodeUsageBreakdownRow, - OpenCodeUsageDailyPoint, - OpenCodeUsageRange, - OpenCodeUsageScanState, - OpenCodeUsageScope, - OpenCodeUsageSessionRow, - OpenCodeUsageSnapshot, - OpenCodeUsageSummary + OpenCodeUsageSnapshot } from '../shared/opencode-usage-types' import type { AiVaultDeleteSessionArgs, @@ -837,92 +816,39 @@ export type MemoryApi = { getSnapshot: () => Promise } -export type ClaudeUsageApi = { - getScanState: () => Promise - setEnabled: (args: { enabled: boolean }) => Promise - refresh: (args?: { force?: boolean }) => Promise - getSnapshot: (args: { - scope: ClaudeUsageScope - range: ClaudeUsageRange - limit?: number - }) => Promise - getSummary: (args: { - scope: ClaudeUsageScope - range: ClaudeUsageRange - }) => Promise - getDaily: (args: { - scope: ClaudeUsageScope - range: ClaudeUsageRange - }) => Promise - getBreakdown: (args: { - scope: ClaudeUsageScope - range: ClaudeUsageRange - kind: ClaudeUsageBreakdownKind - }) => Promise - getRecentSessions: (args: { - scope: ClaudeUsageScope - range: ClaudeUsageRange - limit?: number - }) => Promise +type UsageProviderSnapshot = { + scanState: unknown + summary: { scope: string; range: string } + daily: unknown[] + modelBreakdown: unknown[] + recentSessions: unknown[] } -export type CodexUsageApi = { - getScanState: () => Promise - setEnabled: (args: { enabled: boolean }) => Promise - refresh: (args?: { force?: boolean }) => Promise - getSnapshot: (args: { - scope: CodexUsageScope - range: CodexUsageRange - limit?: number - }) => Promise - getSummary: (args: { - scope: CodexUsageScope - range: CodexUsageRange - }) => Promise - getDaily: (args: { - scope: CodexUsageScope - range: CodexUsageRange - }) => Promise - getBreakdown: (args: { - scope: CodexUsageScope - range: CodexUsageRange - kind: CodexUsageBreakdownKind - }) => Promise - getRecentSessions: (args: { - scope: CodexUsageScope - range: CodexUsageRange - limit?: number - }) => Promise +type UsageQueryArgs = Pick< + Snapshot['summary'], + 'scope' | 'range' +> + +type UsageProviderApi = { + getScanState: () => Promise + setEnabled: (args: { enabled: boolean }) => Promise + refresh: (args?: { force?: boolean }) => Promise + getSnapshot: (args: UsageQueryArgs & { limit?: number }) => Promise + getSummary: (args: UsageQueryArgs) => Promise + getDaily: (args: UsageQueryArgs) => Promise + getBreakdown: ( + args: UsageQueryArgs & { kind: BreakdownKind } + ) => Promise + getRecentSessions: ( + args: UsageQueryArgs & { limit?: number } + ) => Promise } -export type OpenCodeUsageApi = { - getScanState: () => Promise - setEnabled: (args: { enabled: boolean }) => Promise - refresh: (args?: { force?: boolean }) => Promise - getSnapshot: (args: { - scope: OpenCodeUsageScope - range: OpenCodeUsageRange - limit?: number - }) => Promise - getSummary: (args: { - scope: OpenCodeUsageScope - range: OpenCodeUsageRange - }) => Promise - getDaily: (args: { - scope: OpenCodeUsageScope - range: OpenCodeUsageRange - }) => Promise - getBreakdown: (args: { - scope: OpenCodeUsageScope - range: OpenCodeUsageRange - kind: OpenCodeUsageBreakdownKind - }) => Promise - getRecentSessions: (args: { - scope: OpenCodeUsageScope - range: OpenCodeUsageRange - limit?: number - }) => Promise -} +export type ClaudeUsageApi = UsageProviderApi + +export type CodexUsageApi = UsageProviderApi + +export type OpenCodeUsageApi = UsageProviderApi export type AiVaultApi = { listSessions: (args?: AiVaultListArgs) => Promise @@ -1227,6 +1153,9 @@ export type PreloadApi = { get: () => { platform: NodeJS.Platform osRelease: string + arch: string + /** Login shell or ComSpec when available. */ + shell: string displayServer: 'wayland' | 'x11' | null } } @@ -2064,6 +1993,7 @@ export type PreloadApi = { args: HostedReviewCreationEligibilityArgs ) => Promise create: (args: CreateHostedReviewArgs) => Promise + createStacked: (args: CreateStackedHostedReviewArgs) => Promise } // ── GitLab — parallel to gh, MR/issue surface only in v1 ──────── // Shapes mirror gh.* except where GitLab's API differs (MR states, host-qualified project path, `glab api -i` paging). @@ -2209,6 +2139,13 @@ export type PreloadApi = { } ) => Promise | null> } + bitbucket: { + connect: ( + args: BitbucketConnectArgs + ) => Promise<{ ok: true; account: string | null } | { ok: false; error: string }> + disconnect: () => Promise + status: () => Promise + } linear: { connect: (args: { apiKey: string diff --git a/src/preload/index.ts b/src/preload/index.ts index d6d2f48842a..b8925205948 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -226,6 +226,7 @@ import type { } from './api-types' import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events' import { createBrowserFindSubscriptions } from './browser-find-subscriptions' +import { createUsageProviderApi } from './usage-provider-api' import type { AppStarSource } from '../shared/gh-star-source' import type { ExecutionHostId } from '../shared/execution-host' import type { @@ -568,9 +569,15 @@ const api = { platform: { get: () => ({ platform: process.platform, + // Why: sandboxed preload cannot require node:os; Electron exposes the OS + // version on process.getSystemVersion when available. osRelease: (process as NodeJS.Process & { getSystemVersion?: () => string }).getSystemVersion?.() ?? '', + arch: process.arch, + // Why: these identify the default shell without probing user config files. + // process.env is available in the sandboxed preload; node:os is not. + shell: process.env.SHELL?.trim() || process.env.ComSpec?.trim() || '', displayServer: getLinuxDisplayServer() }) } satisfies PreloadApi['platform'], @@ -1710,12 +1717,29 @@ const api = { ipcRenderer.invoke('hostedReview:forBranch', args), getCreationEligibility: (args: unknown): Promise => ipcRenderer.invoke('hostedReview:getCreationEligibility', args), - create: (args: unknown): Promise => ipcRenderer.invoke('hostedReview:create', args) + create: (args: unknown): Promise => ipcRenderer.invoke('hostedReview:create', args), + createStacked: (args: unknown): Promise => + ipcRenderer.invoke('hostedReview:createStacked', args) }, // Why: GitLab bindings live in `./gitlab` so `gl.*` changes don't conflict on every upstream sync of this central file. gl: glApi, + bitbucket: { + connect: (args: { + authMode: 'token' | 'basic' + accessToken?: string | null + email?: string | null + apiToken?: string | null + baseUrl?: string | null + }): Promise<{ ok: true; account: string | null } | { ok: false; error: string }> => + ipcRenderer.invoke('bitbucket:connect', args), + + disconnect: (): Promise => ipcRenderer.invoke('bitbucket:disconnect'), + + status: (): Promise => ipcRenderer.invoke('bitbucket:status') + }, + linear: { connect: (args: { apiKey: string @@ -4219,59 +4243,9 @@ const api = { getSnapshot: (): Promise => ipcRenderer.invoke('memory:getSnapshot') }, - claudeUsage: { - getScanState: (): Promise => ipcRenderer.invoke('claudeUsage:getScanState'), - setEnabled: (args: { enabled: boolean }): Promise => - ipcRenderer.invoke('claudeUsage:setEnabled', args), - refresh: (args?: { force?: boolean }): Promise => - ipcRenderer.invoke('claudeUsage:refresh', args), - getSnapshot: (args: { scope: string; range: string; limit?: number }): Promise => - ipcRenderer.invoke('claudeUsage:getSnapshot', args), - getSummary: (args: { scope: string; range: string }): Promise => - ipcRenderer.invoke('claudeUsage:getSummary', args), - getDaily: (args: { scope: string; range: string }): Promise => - ipcRenderer.invoke('claudeUsage:getDaily', args), - getBreakdown: (args: { scope: string; range: string; kind: string }): Promise => - ipcRenderer.invoke('claudeUsage:getBreakdown', args), - getRecentSessions: (args: { scope: string; range: string; limit?: number }): Promise => - ipcRenderer.invoke('claudeUsage:getRecentSessions', args) - }, - - codexUsage: { - getScanState: (): Promise => ipcRenderer.invoke('codexUsage:getScanState'), - setEnabled: (args: { enabled: boolean }): Promise => - ipcRenderer.invoke('codexUsage:setEnabled', args), - refresh: (args?: { force?: boolean }): Promise => - ipcRenderer.invoke('codexUsage:refresh', args), - getSnapshot: (args: { scope: string; range: string; limit?: number }): Promise => - ipcRenderer.invoke('codexUsage:getSnapshot', args), - getSummary: (args: { scope: string; range: string }): Promise => - ipcRenderer.invoke('codexUsage:getSummary', args), - getDaily: (args: { scope: string; range: string }): Promise => - ipcRenderer.invoke('codexUsage:getDaily', args), - getBreakdown: (args: { scope: string; range: string; kind: string }): Promise => - ipcRenderer.invoke('codexUsage:getBreakdown', args), - getRecentSessions: (args: { scope: string; range: string; limit?: number }): Promise => - ipcRenderer.invoke('codexUsage:getRecentSessions', args) - }, - - openCodeUsage: { - getScanState: (): Promise => ipcRenderer.invoke('openCodeUsage:getScanState'), - setEnabled: (args: { enabled: boolean }): Promise => - ipcRenderer.invoke('openCodeUsage:setEnabled', args), - refresh: (args?: { force?: boolean }): Promise => - ipcRenderer.invoke('openCodeUsage:refresh', args), - getSnapshot: (args: { scope: string; range: string; limit?: number }): Promise => - ipcRenderer.invoke('openCodeUsage:getSnapshot', args), - getSummary: (args: { scope: string; range: string }): Promise => - ipcRenderer.invoke('openCodeUsage:getSummary', args), - getDaily: (args: { scope: string; range: string }): Promise => - ipcRenderer.invoke('openCodeUsage:getDaily', args), - getBreakdown: (args: { scope: string; range: string; kind: string }): Promise => - ipcRenderer.invoke('openCodeUsage:getBreakdown', args), - getRecentSessions: (args: { scope: string; range: string; limit?: number }): Promise => - ipcRenderer.invoke('openCodeUsage:getRecentSessions', args) - }, + claudeUsage: createUsageProviderApi(ipcRenderer, 'claudeUsage'), + codexUsage: createUsageProviderApi(ipcRenderer, 'codexUsage'), + openCodeUsage: createUsageProviderApi(ipcRenderer, 'openCodeUsage'), aiVault: { listSessions: (args?: AiVaultListArgs): Promise => diff --git a/src/preload/usage-provider-api.test.ts b/src/preload/usage-provider-api.test.ts new file mode 100644 index 00000000000..0ce594cbb9f --- /dev/null +++ b/src/preload/usage-provider-api.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from 'vitest' +import { createUsageProviderApi } from './usage-provider-api' + +describe('usage provider preload API', () => { + it('keeps every provider on its existing IPC channels', async () => { + const invoke = vi.fn().mockResolvedValue(undefined) + const query = { scope: 'orca' as const, range: '30d' as const } + + for (const prefix of ['claudeUsage', 'codexUsage', 'openCodeUsage'] as const) { + const api = createUsageProviderApi({ invoke } as never, prefix) + await api.getScanState() + await api.setEnabled({ enabled: true }) + await api.refresh() + await api.getSnapshot({ ...query, limit: 7 }) + await api.getSummary(query) + await api.getDaily(query) + await api.getBreakdown({ ...query, kind: 'project' }) + await api.getRecentSessions({ ...query, limit: 4 }) + + expect(invoke.mock.calls).toEqual([ + [`${prefix}:getScanState`], + [`${prefix}:setEnabled`, { enabled: true }], + [`${prefix}:refresh`, undefined], + [`${prefix}:getSnapshot`, { ...query, limit: 7 }], + [`${prefix}:getSummary`, query], + [`${prefix}:getDaily`, query], + [`${prefix}:getBreakdown`, { ...query, kind: 'project' }], + [`${prefix}:getRecentSessions`, { ...query, limit: 4 }] + ]) + invoke.mockClear() + } + }) +}) diff --git a/src/preload/usage-provider-api.ts b/src/preload/usage-provider-api.ts new file mode 100644 index 00000000000..b80d72850c0 --- /dev/null +++ b/src/preload/usage-provider-api.ts @@ -0,0 +1,29 @@ +import type { IpcRenderer } from 'electron' +import type { PreloadApi } from './api-types' + +type UsageProviderApiKey = 'claudeUsage' | 'codexUsage' | 'openCodeUsage' +type UsageProviderApi = PreloadApi[UsageProviderApiKey] +type UsageRangeArgs = { scope: string; range: string } + +export function createUsageProviderApi( + ipc: Pick, + prefix: Key +): PreloadApi[Key] +export function createUsageProviderApi( + ipc: Pick, + prefix: UsageProviderApiKey +): UsageProviderApi { + return { + getScanState: () => ipc.invoke(`${prefix}:getScanState`), + setEnabled: (args: { enabled: boolean }) => ipc.invoke(`${prefix}:setEnabled`, args), + refresh: (args?: { force?: boolean }) => ipc.invoke(`${prefix}:refresh`, args), + getSnapshot: (args: UsageRangeArgs & { limit?: number }) => + ipc.invoke(`${prefix}:getSnapshot`, args), + getSummary: (args: UsageRangeArgs) => ipc.invoke(`${prefix}:getSummary`, args), + getDaily: (args: UsageRangeArgs) => ipc.invoke(`${prefix}:getDaily`, args), + getBreakdown: (args: UsageRangeArgs & { kind: string }) => + ipc.invoke(`${prefix}:getBreakdown`, args), + getRecentSessions: (args: UsageRangeArgs & { limit?: number }) => + ipc.invoke(`${prefix}:getRecentSessions`, args) + } +} diff --git a/src/relay/git-handler-branch-diff-equivalence.test.ts b/src/relay/git-handler-branch-diff-equivalence.test.ts new file mode 100644 index 00000000000..d33886b4f5a --- /dev/null +++ b/src/relay/git-handler-branch-diff-equivalence.test.ts @@ -0,0 +1,207 @@ +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import * as path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { RelayContext } from './context' +import { GitHandler } from './git-handler' +import { + createMockDispatcher, + gitCommit, + gitInit, + type MockDispatcher, + type RelayDispatcher +} from './git-handler-test-setup' + +// Why this file exists: the pinned route replaced the legacy route's own +// `diff --name-status -M -C` rediscovery with values the caller already holds. +// These tests drive the real product contract against real Git and assert the +// two routes agree entry-for-entry, so "SSH still renders what it used to" is +// evidence rather than an argument about call sites. + +type BranchCompareResult = { + summary: { mergeBase: string; headOid: string; status: string; changedFiles: number } + entries: { path: string; oldPath?: string; status: string }[] +} + +type DiffEntry = Record + +function git(repoPath: string, args: string[]): string { + return execFileSync('git', args, { cwd: repoPath, encoding: 'utf8' }) +} + +/** + * Builds one repo whose base..head range exercises every shape the review + * panel can hand to a single-file branch diff. + */ +function buildScenarioRepo(): { repoPath: string; baseOid: string } { + const repoPath = mkdtempSync(path.join(tmpdir(), 'relay-branch-diff-equivalence-')) + gitInit(repoPath) + + const write = (relativePath: string, contents: string | Buffer): void => { + const target = path.join(repoPath, relativePath) + mkdirSync(path.dirname(target), { recursive: true }) + writeFileSync(target, contents) + } + + write('modified.txt', 'before\n') + write('deleted.txt', 'doomed\n') + write('renamed-from.txt', 'stable rename payload\n') + write('renamed-and-edited-from.txt', 'rename plus edit, line one\nline two\nline three\n') + write('copied-source.txt', 'copy me, line one\nline two\nline three\nline four\n') + write('nested/deep/inner.txt', 'nested before\n') + write('spaced name.txt', 'spaced before\n') + write('ünïcode-ページ.txt', 'unicode before\n') + write('binary-modified.bin', Buffer.from([0, 1, 2, 3, 0, 255])) + write('binary-deleted.bin', Buffer.from([9, 8, 7, 0])) + write('emptied.txt', 'about to be emptied\n') + write('crlf.txt', 'crlf before\r\nsecond line\r\n') + write('mode-changed.sh', '#!/bin/sh\necho hi\n') + gitCommit(repoPath, 'base') + const baseOid = git(repoPath, ['rev-parse', 'HEAD']).trim() + + write('modified.txt', 'after\n') + rmSync(path.join(repoPath, 'deleted.txt')) + rmSync(path.join(repoPath, 'binary-deleted.bin')) + git(repoPath, ['mv', 'renamed-from.txt', 'renamed-to.txt']) + git(repoPath, ['mv', 'renamed-and-edited-from.txt', 'renamed-and-edited-to.txt']) + write('renamed-and-edited-to.txt', 'rename plus edit, line one\nline two CHANGED\nline three\n') + write('copied-target.txt', 'copy me, line one\nline two\nline three\nline four\n') + write('added.txt', 'brand new\n') + write('binary-added.bin', Buffer.from([4, 4, 0, 4])) + write('binary-modified.bin', Buffer.from([0, 1, 2, 3, 0, 254])) + write('nested/deep/inner.txt', 'nested after\n') + write('spaced name.txt', 'spaced after\n') + write('ünïcode-ページ.txt', 'unicode after\n') + write('emptied.txt', '') + write('crlf.txt', 'crlf after\r\nsecond line\r\n') + git(repoPath, ['update-index', '--chmod=+x', 'mode-changed.sh']) + gitCommit(repoPath, 'head') + + return { repoPath, baseOid } +} + +describe('pinned and legacy branch diff equivalence against real Git', () => { + let dispatcher: MockDispatcher + let handler: GitHandler + let repoPath = '' + let baseOid = '' + + beforeEach(() => { + dispatcher = createMockDispatcher() + handler = new GitHandler(dispatcher as unknown as RelayDispatcher, new RelayContext()) + const built = buildScenarioRepo() + repoPath = built.repoPath + baseOid = built.baseOid + }) + + afterEach(() => { + handler.dispose() + if (repoPath) { + rmSync(repoPath, { recursive: true, force: true }) + } + }) + + async function branchCompare(): Promise { + return (await dispatcher.callRequest('git.branchCompare', { + worktreePath: repoPath, + baseRef: baseOid + })) as BranchCompareResult + } + + async function branchDiff(params: Record): Promise { + return (await dispatcher.callRequest('git.branchDiff', { + worktreePath: repoPath, + baseRef: baseOid, + includePatch: true, + ...params + })) as DiffEntry[] + } + + it('produces identical results for every changed file the review panel can open', async () => { + const compare = await branchCompare() + expect(compare.summary.status).toBe('ready') + // Guard the guard: a truncated scenario set would make this test vacuously pass. + expect(compare.entries.length).toBeGreaterThanOrEqual(14) + + const divergences: string[] = [] + for (const entry of compare.entries) { + // Exactly what the renderer sends: paths from the compare entry list, + // OIDs from the compare summary that produced that same list. + const callerShape = { filePath: entry.path, oldPath: entry.oldPath } + const legacy = await branchDiff(callerShape) + const pinned = await branchDiff({ + ...callerShape, + baseRef: compare.summary.mergeBase, + headOid: compare.summary.headOid + }) + + if (JSON.stringify(legacy) !== JSON.stringify(pinned)) { + divergences.push( + `${entry.status} ${entry.path}${entry.oldPath ? ` (from ${entry.oldPath})` : ''}\n` + + ` legacy: ${JSON.stringify(legacy)}\n pinned: ${JSON.stringify(pinned)}` + ) + } + } + + expect(divergences.join('\n')).toBe('') + }) + + it('agrees on content for renames, additions, deletions and binaries specifically', async () => { + const compare = await branchCompare() + const byPath = new Map(compare.entries.map((entry) => [entry.path, entry])) + + // Why assert content and not just equality: two identically-empty results + // would satisfy the equivalence test above while rendering nothing. + const rename = byPath.get('renamed-and-edited-to.txt') + expect(rename?.oldPath).toBe('renamed-and-edited-from.txt') + const [renamePinned] = await branchDiff({ + baseRef: compare.summary.mergeBase, + headOid: compare.summary.headOid, + filePath: rename!.path, + oldPath: rename!.oldPath + }) + expect(renamePinned).toMatchObject({ + originalContent: 'rename plus edit, line one\nline two\nline three\n', + modifiedContent: 'rename plus edit, line one\nline two CHANGED\nline three\n' + }) + + const [addedPinned] = await branchDiff({ + baseRef: compare.summary.mergeBase, + headOid: compare.summary.headOid, + filePath: 'added.txt' + }) + expect(addedPinned).toMatchObject({ originalContent: '', modifiedContent: 'brand new\n' }) + + const [deletedPinned] = await branchDiff({ + baseRef: compare.summary.mergeBase, + headOid: compare.summary.headOid, + filePath: 'deleted.txt' + }) + expect(deletedPinned).toMatchObject({ originalContent: 'doomed\n', modifiedContent: '' }) + + const [binaryPinned] = await branchDiff({ + baseRef: compare.summary.mergeBase, + headOid: compare.summary.headOid, + filePath: 'binary-modified.bin' + }) + expect(binaryPinned).toMatchObject({ kind: 'binary' }) + }) + + it('holds the pinned revision when HEAD moves mid-review, where legacy drifts', async () => { + const compare = await branchCompare() + writeFileSync(path.join(repoPath, 'modified.txt'), 'drifted after the snapshot\n') + gitCommit(repoPath, 'drift') + + const pinned = await branchDiff({ + baseRef: compare.summary.mergeBase, + headOid: compare.summary.headOid, + filePath: 'modified.txt' + }) + const legacy = await branchDiff({ filePath: 'modified.txt' }) + + expect(pinned[0]).toMatchObject({ modifiedContent: 'after\n' }) + // The divergence is the fix: legacy silently re-resolves live HEAD. + expect(legacy[0]).toMatchObject({ modifiedContent: 'drifted after the snapshot\n' }) + }) +}) diff --git a/src/relay/git-handler-branch-diff-ops.ts b/src/relay/git-handler-branch-diff-ops.ts new file mode 100644 index 00000000000..1da0de82be8 --- /dev/null +++ b/src/relay/git-handler-branch-diff-ops.ts @@ -0,0 +1,57 @@ +import { buildDiffResult } from './git-diff-result' +import { readBlobAtOid, type GitBufferExec } from './git-handler-ops' + +const FULL_GIT_OBJECT_ID_PATTERN = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/ + +function assertFullGitObjectId(value: unknown, label: string): asserts value is string { + if (typeof value !== 'string' || !FULL_GIT_OBJECT_ID_PATTERN.test(value)) { + throw new Error(`${label} must be a full git object id`) + } +} + +export function isFullGitObjectId(value: unknown): value is string { + return typeof value === 'string' && FULL_GIT_OBJECT_ID_PATTERN.test(value) +} + +export function parseOptionalBranchDiffHeadOid( + params: Record +): string | undefined { + const { headOid } = params + // Why: GitBranchCompareSummary.headOid is `string | null`, so a mixed-version + // client can put an explicit null on the wire. Treat it as unpinned rather + // than rejecting a request the legacy path would have served. + if (headOid == null) { + return undefined + } + assertFullGitObjectId(headOid, 'headOid') + return headOid +} + +export async function branchDiffEntryAtPinnedOids( + gitBuffer: GitBufferExec, + worktreePath: string, + baseOid: string, + headOid: string, + filePath: string, + oldPath?: string +) { + assertFullGitObjectId(baseOid, 'baseRef') + assertFullGitObjectId(headOid, 'headOid') + try { + const [left, right] = await Promise.all([ + readBlobAtOid(gitBuffer, worktreePath, baseOid, oldPath ?? filePath), + readBlobAtOid(gitBuffer, worktreePath, headOid, filePath) + ]) + return [buildDiffResult(left.content, right.content, left.isBinary, right.isBinary, filePath)] + } catch { + return [ + { + kind: 'text' as const, + originalContent: '', + modifiedContent: '', + originalIsBinary: false, + modifiedIsBinary: false + } + ] + } +} diff --git a/src/relay/git-handler-branch-diff.test.ts b/src/relay/git-handler-branch-diff.test.ts new file mode 100644 index 00000000000..ce60c8cd439 --- /dev/null +++ b/src/relay/git-handler-branch-diff.test.ts @@ -0,0 +1,392 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import * as path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { RelayContext } from './context' +import { + branchDiffEntryAtPinnedOids, + isFullGitObjectId, + parseOptionalBranchDiffHeadOid +} from './git-handler-branch-diff-ops' +import { GitHandler } from './git-handler' +import { branchDiffEntries, type GitBufferExec, type GitExec } from './git-handler-ops' +import { + createMockDispatcher, + gitCommit, + gitInit, + type MockDispatcher, + type RelayDispatcher +} from './git-handler-test-setup' + +const BASE_OID = 'a'.repeat(40) +const HEAD_OID = 'b'.repeat(40) +const OTHER_HEAD_OID = 'c'.repeat(40) +const MERGE_BASE_OID = 'd'.repeat(40) +const FILE_PATH = 'src/file.ts' + +type GitBufferTarget = { + gitBuffer(args: string[], cwd: string): Promise +} + +type GitTarget = { + git(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }> +} + +function deferredBuffer(): { promise: Promise; resolve: (content: string) => void } { + let resolve!: (value: Buffer) => void + const promise = new Promise((innerResolve) => { + resolve = innerResolve + }) + return { promise, resolve: (content) => resolve(Buffer.from(content)) } +} + +describe('pinned relay branch diff operation', () => { + it.each([ + { + name: 'addition', + status: `A\t${FILE_PATH}\n`, + left: new Error('missing'), + right: Buffer.from('added\n') + }, + { + name: 'deletion', + status: `D\t${FILE_PATH}\n`, + left: Buffer.from('deleted\n'), + right: new Error('missing') + }, + { + name: 'binary content', + status: `M\t${FILE_PATH}\n`, + left: Buffer.from([0, 1]), + right: Buffer.from([0, 2]) + }, + { + name: 'blob read failure', + status: `M\t${FILE_PATH}\n`, + left: new Error('left failed'), + right: new Error('right failed') + } + ])('matches the legacy $name result', async ({ status, left, right }) => { + const createGitBuffer = (): GitBufferExec => + vi.fn(async (args) => { + const value = args[2].startsWith(`${BASE_OID}:`) ? left : right + if (value instanceof Error) { + throw value + } + return value + }) + const git = vi.fn(async (args) => { + if (args[0] === 'rev-parse' && args.includes('HEAD')) { + return { stdout: `${HEAD_OID}\n`, stderr: '' } + } + if (args[0] === 'rev-parse' || args[0] === 'merge-base') { + return { stdout: `${BASE_OID}\n`, stderr: '' } + } + return { stdout: status, stderr: '' } + }) + + const pinned = await branchDiffEntryAtPinnedOids( + createGitBuffer(), + '/repo', + BASE_OID, + HEAD_OID, + FILE_PATH + ) + const legacy = await branchDiffEntries(git, createGitBuffer(), '/repo', BASE_OID, { + includePatch: true, + filePath: FILE_PATH + }) + + expect(pinned).toEqual(legacy) + }) + + // Why: GitBranchCompareSummary.headOid is `string | null`, so an unpinned + // snapshot arrives as an explicit null and must stay servable. + it.each([ + { name: 'absent', params: {} }, + { name: 'null', params: { headOid: null } }, + { name: 'undefined', params: { headOid: undefined } } + ])('treats a $name head OID as unpinned', ({ params }) => { + expect(parseOptionalBranchDiffHeadOid(params)).toBeUndefined() + }) + + it.each([ + { name: 'SHA-1', headOid: HEAD_OID }, + { name: 'SHA-256', headOid: 'b'.repeat(64) } + ])('returns a supplied $name head OID', ({ headOid }) => { + expect(parseOptionalBranchDiffHeadOid({ headOid })).toBe(headOid) + }) + + it.each([{ headOid: 'abc123' }, { headOid: '' }, { headOid: 'main' }, { headOid: 123 }])( + 'rejects the supplied malformed head OID $headOid', + ({ headOid }) => { + expect(() => parseOptionalBranchDiffHeadOid({ headOid })).toThrow( + 'headOid must be a full git object id' + ) + } + ) + + it('recognizes only full object ids', () => { + expect(isFullGitObjectId(BASE_OID)).toBe(true) + expect(isFullGitObjectId('A'.repeat(64))).toBe(true) + for (const value of ['origin/main', 'abc123', '', 'g'.repeat(40), null, undefined, 123, {}]) { + expect(isFullGitObjectId(value)).toBe(false) + } + }) + + // Why: the route guards this today, but the export is reachable on its own — + // a symbolic ref here would resolve `git show origin/main:` at live HEAD. + it('rejects a symbolic ref at the module boundary', async () => { + const gitBuffer = vi.fn(async () => Buffer.from('')) + await expect( + branchDiffEntryAtPinnedOids(gitBuffer, '/repo', 'origin/main', HEAD_OID, FILE_PATH) + ).rejects.toThrow('baseRef must be a full git object id') + await expect( + branchDiffEntryAtPinnedOids(gitBuffer, '/repo', BASE_OID, 'HEAD', FILE_PATH) + ).rejects.toThrow('headOid must be a full git object id') + expect(gitBuffer).not.toHaveBeenCalled() + }) +}) + +describe('GitHandler pinned branch diff route', () => { + let dispatcher: MockDispatcher + let handler: GitHandler + + beforeEach(() => { + dispatcher = createMockDispatcher() + handler = new GitHandler(dispatcher as unknown as RelayDispatcher, new RelayContext()) + }) + + afterEach(() => { + handler.dispose() + vi.restoreAllMocks() + }) + + function request(overrides: Record = {}): Promise { + return dispatcher.callRequest('git.branchDiff', { + worktreePath: '/repo', + baseRef: BASE_OID, + headOid: HEAD_OID, + includePatch: true, + filePath: FILE_PATH, + ...overrides + }) + } + + function mockLegacyGit(nameStatus = '') { + return vi.spyOn(handler as unknown as GitTarget, 'git').mockImplementation(async (args) => { + if (args[0] === 'rev-parse' && args.includes('HEAD')) { + return { stdout: `${HEAD_OID}\n`, stderr: '' } + } + if (args[0] === 'rev-parse') { + return { stdout: `${BASE_OID}\n`, stderr: '' } + } + if (args[0] === 'merge-base') { + return { stdout: `${MERGE_BASE_OID}\n`, stderr: '' } + } + return { stdout: nameStatus, stderr: '' } + }) + } + + it('starts exactly two pinned blob reads concurrently without metadata commands', async () => { + const left = deferredBuffer() + const right = deferredBuffer() + const gitBufferSpy = vi + .spyOn(handler as unknown as GitBufferTarget, 'gitBuffer') + .mockImplementation((args) => { + if (args[2] === `${BASE_OID}:${FILE_PATH}`) { + return left.promise + } + if (args[2] === `${HEAD_OID}:${FILE_PATH}`) { + return right.promise + } + throw new Error(`unexpected blob spec: ${args[2]}`) + }) + const gitSpy = vi + .spyOn(handler as unknown as GitTarget, 'git') + .mockRejectedValue(new Error('metadata command should not run')) + + const resultPromise = request() + await vi.waitFor(() => expect(gitBufferSpy).toHaveBeenCalledTimes(2)) + + expect(gitSpy).not.toHaveBeenCalled() + expect(gitBufferSpy.mock.calls.map(([args]) => args)).toEqual([ + ['show', '--end-of-options', `${BASE_OID}:${FILE_PATH}`], + ['show', '--end-of-options', `${HEAD_OID}:${FILE_PATH}`] + ]) + + left.resolve('before\n') + right.resolve('after\n') + await expect(resultPromise).resolves.toEqual([ + { + kind: 'text', + originalContent: 'before\n', + modifiedContent: 'after\n', + originalIsBinary: false, + modifiedIsBinary: false + } + ]) + }) + + it('reads the old path only from the base OID', async () => { + const oldPath = 'src/old-file.ts' + const gitBufferSpy = vi + .spyOn(handler as unknown as GitBufferTarget, 'gitBuffer') + .mockImplementation(async (args) => Buffer.from(args[2].startsWith(BASE_OID) ? 'old' : 'new')) + + await request({ oldPath }) + + expect(gitBufferSpy.mock.calls.map(([args]) => args[2])).toEqual([ + `${BASE_OID}:${oldPath}`, + `${HEAD_OID}:${FILE_PATH}` + ]) + }) + + it('coalesces the same head OID while keeping different heads independent', async () => { + const pending = deferredBuffer() + const gitBufferSpy = vi + .spyOn(handler as unknown as GitBufferTarget, 'gitBuffer') + .mockImplementation(() => pending.promise) + + const requests = [request(), request(), request({ headOid: OTHER_HEAD_OID })] + await vi.waitFor(() => expect(gitBufferSpy).toHaveBeenCalledTimes(4)) + + const specs = gitBufferSpy.mock.calls.map(([args]) => args[2]) + expect(specs.filter((spec) => spec === `${BASE_OID}:${FILE_PATH}`)).toHaveLength(2) + expect(specs.filter((spec) => spec === `${HEAD_OID}:${FILE_PATH}`)).toHaveLength(1) + expect(specs.filter((spec) => spec === `${OTHER_HEAD_OID}:${FILE_PATH}`)).toHaveLength(1) + + pending.resolve('content\n') + await Promise.all(requests) + }) + + it('rejects every supplied malformed head OID before running Git', async () => { + const gitBufferSpy = vi.spyOn(handler as unknown as GitBufferTarget, 'gitBuffer') + const invalidHeadOids = [7, {}, '', 'a'.repeat(39), 'g'.repeat(40)] + + for (const headOid of invalidHeadOids) { + await expect(request({ headOid })).rejects.toThrow('headOid must be a full git object id') + } + + expect(gitBufferSpy).not.toHaveBeenCalled() + }) + + it('accepts SHA-256 object IDs', async () => { + const baseOid = 'A'.repeat(64) + const headOid = 'b'.repeat(64) + const gitBufferSpy = vi + .spyOn(handler as unknown as GitBufferTarget, 'gitBuffer') + .mockResolvedValue(Buffer.from('content\n')) + + await request({ baseRef: baseOid, headOid }) + + expect(gitBufferSpy.mock.calls.map(([args]) => args[2])).toEqual([ + `${baseOid}:${FILE_PATH}`, + `${headOid}:${FILE_PATH}` + ]) + }) + + // Why: a symbolic base ref used to select the pinned route and then throw. + it('selects the pinned route only when the base ref is a full object id', async () => { + const gitSpy = mockLegacyGit(`M\t${FILE_PATH}\n`) + const gitBufferSpy = vi + .spyOn(handler as unknown as GitBufferTarget, 'gitBuffer') + .mockResolvedValue(Buffer.from('content\n')) + + await expect(request({ baseRef: 'origin/main' })).resolves.toHaveLength(1) + + expect(gitSpy.mock.calls.map(([args]) => args.join(' '))).toEqual([ + 'rev-parse --verify HEAD', + 'rev-parse --verify origin/main', + `merge-base ${BASE_OID} ${HEAD_OID}`, + `-c core.quotePath=false diff --name-status -M -C ${MERGE_BASE_OID} ${HEAD_OID}` + ]) + expect(gitBufferSpy.mock.calls.map(([args]) => args[2])).toEqual([ + `${MERGE_BASE_OID}:${FILE_PATH}`, + `${HEAD_OID}:${FILE_PATH}` + ]) + + gitSpy.mockClear() + gitBufferSpy.mockClear() + await request() + + expect(gitSpy).not.toHaveBeenCalled() + expect(gitBufferSpy.mock.calls.map(([args]) => args[2])).toEqual([ + `${BASE_OID}:${FILE_PATH}`, + `${HEAD_OID}:${FILE_PATH}` + ]) + }) + + it('serves an explicitly null head OID through the legacy path', async () => { + const gitSpy = mockLegacyGit(`M\t${FILE_PATH}\n`) + const gitBufferSpy = vi.spyOn(handler as unknown as GitBufferTarget, 'gitBuffer') + + await expect(request({ headOid: null, includePatch: false })).resolves.toEqual([ + { + kind: 'text', + originalContent: '', + modifiedContent: '', + originalIsBinary: false, + modifiedIsBinary: false + } + ]) + + expect(gitSpy).toHaveBeenCalledTimes(4) + expect(gitBufferSpy).not.toHaveBeenCalled() + }) + + it('uses the legacy path when patch or file-path conditions are not met', async () => { + const gitSpy = mockLegacyGit() + const gitBufferSpy = vi.spyOn(handler as unknown as GitBufferTarget, 'gitBuffer') + + await Promise.all([ + request({ baseRef: 'main', includePatch: false }), + request({ baseRef: 'main', filePath: '' }), + request({ baseRef: 'main', filePath: { malformed: true } }) + ]) + + expect(gitSpy).toHaveBeenCalledTimes(12) + expect(gitBufferSpy).not.toHaveBeenCalled() + }) + + it('keeps the requested head pinned after repository HEAD moves', async () => { + const repoPath = mkdtempSync(path.join(tmpdir(), 'relay-pinned-branch-diff-')) + try { + gitInit(repoPath) + writeFileSync(path.join(repoPath, 'file.txt'), 'base\n') + gitCommit(repoPath, 'base') + const baseOid = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: repoPath, + encoding: 'utf8' + }).trim() + + writeFileSync(path.join(repoPath, 'file.txt'), 'pinned\n') + gitCommit(repoPath, 'pinned') + const pinnedHeadOid = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: repoPath, + encoding: 'utf8' + }).trim() + + writeFileSync(path.join(repoPath, 'file.txt'), 'moved\n') + gitCommit(repoPath, 'moved') + + const result = (await request({ + worktreePath: repoPath, + baseRef: baseOid, + headOid: pinnedHeadOid, + filePath: 'file.txt' + })) as { originalContent: string; modifiedContent: string }[] + + expect(result[0]).toMatchObject({ + originalContent: 'base\n', + modifiedContent: 'pinned\n' + }) + expect( + execFileSync('git', ['show', 'HEAD:file.txt'], { cwd: repoPath, encoding: 'utf8' }) + ).toBe('moved\n') + } finally { + rmSync(repoPath, { recursive: true, force: true }) + } + }) +}) diff --git a/src/relay/git-handler-status-ops.ts b/src/relay/git-handler-status-ops.ts index 60ea5913139..a9d0b63ea8a 100644 --- a/src/relay/git-handler-status-ops.ts +++ b/src/relay/git-handler-status-ops.ts @@ -91,7 +91,36 @@ export async function getStatusOp( ) // Why: reject NaN/negative limits — NaN would silently disable capping, negatives would over-truncate. const limit = resolveGitStatusLimit(params.limit) - const conflictOperation = await detectConflictOperation(worktreePath) + const conflictPromise = detectConflictOperation(worktreePath) + // Why: core.quotePath=false keeps non-ASCII filenames as raw UTF-8 instead of octal escapes that render as gibberish. + const statusArgs = [ + '-c', + 'core.quotePath=false', + 'status', + '--porcelain=v2', + '--branch', + '--untracked-files=all' + ] + if (includeIgnored) { + statusArgs.push('--ignored=matching') + } + // Why: attach rejection ownership before awaiting marker I/O, so a fast Git failure cannot become unhandled. + const statusSettlementPromise = Promise.allSettled([ + (async () => { + const parser = new StatusPorcelainParser() + const result = await streamGit(statusArgs, worktreePath, { + // Why: status polling is read-like; avoid racing terminal Git on .git/worktrees/*/index.lock. + disableOptionalLocks: true, + signal: options.signal, + onStdout: (chunk) => parser.update(chunk, limit) + }) + if (!result.stoppedEarly) { + parser.finish() + } + return { parser, stoppedEarly: result.stoppedEarly } + })() + ]) + const conflictOperation = await conflictPromise const entries: Record[] = [] let head: string | undefined let branch: string | undefined @@ -103,28 +132,11 @@ export async function getStatusOp( let branchLineTotal: GitBranchLineTotal | undefined try { - // Why: core.quotePath=false keeps non-ASCII filenames as raw UTF-8 instead of octal escapes that render as gibberish. - const statusArgs = [ - '-c', - 'core.quotePath=false', - 'status', - '--porcelain=v2', - '--branch', - '--untracked-files=all' - ] - if (includeIgnored) { - statusArgs.push('--ignored=matching') - } - const parser = new StatusPorcelainParser() - const { stoppedEarly } = await streamGit(statusArgs, worktreePath, { - // Why: status polling is read-like; avoid racing terminal Git on .git/worktrees/*/index.lock. - disableOptionalLocks: true, - signal: options.signal, - onStdout: (chunk) => parser.update(chunk, limit) - }) - if (!stoppedEarly) { - parser.finish() + const [statusResult] = await statusSettlementPromise + if (statusResult.status === 'rejected') { + throw statusResult.reason } + const { parser, stoppedEarly } = statusResult.value head = parser.branch.head branch = parser.branch.branch ignoredPaths = parser.ignoredPaths diff --git a/src/relay/git-handler-utils.test.ts b/src/relay/git-handler-utils.test.ts index e1c18a78d33..43f1d53d5d1 100644 --- a/src/relay/git-handler-utils.test.ts +++ b/src/relay/git-handler-utils.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from 'vitest' -import { parseStatusOutput } from './git-status-output-parser' import { isUnsupportedWorktreeListZError, parseWorktreeList } from './git-handler-utils' describe('parseWorktreeList', () => { @@ -105,72 +104,3 @@ describe('isUnsupportedWorktreeListZError', () => { expect(isUnsupportedWorktreeListZError(error)).toBe(false) }) }) - -describe('parseStatusOutput', () => { - it('parses upstream ahead/behind from porcelain v2 branch headers', () => { - const result = parseStatusOutput( - [ - '# branch.oid abcdef1234567890', - '# branch.head feature/prompts', - '# branch.upstream origin/feature/prompts', - '# branch.ab +2 -3', - '' - ].join('\n') - ) - - expect(result.upstreamStatus).toEqual({ - hasUpstream: true, - upstreamName: 'origin/feature/prompts', - ahead: 2, - behind: 3 - }) - }) - - it('reports no upstream when porcelain v2 omits branch.upstream', () => { - const result = parseStatusOutput( - ['# branch.oid abcdef1234567890', '# branch.head feature/prompts', ''].join('\n') - ) - - expect(result.upstreamStatus).toEqual({ hasUpstream: false, ahead: 0, behind: 0 }) - }) - - it('parses ignored porcelain records separately from actionable entries', () => { - const result = parseStatusOutput(['! dist/', '! .env', '? scratch.txt', ''].join('\n')) - - expect(result.ignoredPaths).toEqual(['dist/', '.env']) - expect(result.entries).toEqual([ - { path: 'scratch.txt', status: 'untracked', area: 'untracked' } - ]) - }) - - it('parses rename records with spaces in the paths', () => { - const result = parseStatusOutput( - '2 R. N... 100644 100644 100644 aaaa bbbb R100 src/new name.ts\tsrc/old name.ts\n' - ) - - expect(result.entries).toEqual([ - { path: 'src/new name.ts', oldPath: 'src/old name.ts', status: 'renamed', area: 'staged' } - ]) - }) - - it('parses submodule dirtiness flags from porcelain records', () => { - const result = parseStatusOutput( - '1 AM S..U 000000 160000 160000 0000000000000000000000000000000000000000 7844cb64e631f17a9ca5b548f3500ef7cecd2f17 nested-repo\n' - ) - - expect(result.entries).toEqual([ - { - path: 'nested-repo', - status: 'added', - area: 'staged', - submodule: { commitChanged: false, trackedChanges: false, untrackedChanges: true } - }, - { - path: 'nested-repo', - status: 'modified', - area: 'unstaged', - submodule: { commitChanged: false, trackedChanges: false, untrackedChanges: true } - } - ]) - }) -}) diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index 0abc9f5d5be..3e8a20ea992 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -18,6 +18,11 @@ import { validateGitExecArgs, type GitExec } from './git-handler-ops' +import { + branchDiffEntryAtPinnedOids, + isFullGitObjectId, + parseOptionalBranchDiffHeadOid +} from './git-handler-branch-diff-ops' import { buildSubmoduleInnerCommitRangeDiff, computeSubmodulePointerDiff, @@ -1148,6 +1153,7 @@ export class GitHandler { if (baseRef.startsWith('-')) { throw new Error('Base ref must not start with "-"') } + const headOid = parseOptionalBranchDiffHeadOid(params) const options = { includePatch: params.includePatch as boolean | undefined, filePath: params.filePath as string | undefined, @@ -1158,18 +1164,36 @@ export class GitHandler { 'branchDiff', worktreePath, baseRef, + headOid ?? null, options.includePatch ?? null, options.filePath ?? null, options.oldPath ?? null ]), - () => - branchDiffEntries( + () => { + if ( + headOid && + isFullGitObjectId(baseRef) && + options.includePatch === true && + typeof options.filePath === 'string' && + options.filePath.length > 0 + ) { + return branchDiffEntryAtPinnedOids( + this.gitBuffer.bind(this), + worktreePath, + baseRef, + headOid, + options.filePath, + options.oldPath + ) + } + return branchDiffEntries( this.git.bind(this), this.gitBuffer.bind(this), worktreePath, baseRef, options ) + } ) return this.maybeStreamResponse(result, params, context) } diff --git a/src/relay/git-status-output-parser.test.ts b/src/relay/git-status-output-parser.test.ts deleted file mode 100644 index ec25e9b72ba..00000000000 --- a/src/relay/git-status-output-parser.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { parseStatusOutput } from './git-status-output-parser' - -describe('parseStatusOutput', () => { - it('marks staged S... submodule rows as commit-changed gitlinks', () => { - const parsed = parseStatusOutput( - '1 M. S... 160000 160000 160000 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb flutter_mine\n' - ) - - expect(parsed.entries).toEqual([ - { - path: 'flutter_mine', - status: 'modified', - area: 'staged', - submodule: { commitChanged: true, trackedChanges: false, untrackedChanges: false } - } - ]) - }) -}) diff --git a/src/relay/git-status-output-parser.ts b/src/relay/git-status-output-parser.ts deleted file mode 100644 index 9bb1d344073..00000000000 --- a/src/relay/git-status-output-parser.ts +++ /dev/null @@ -1,177 +0,0 @@ -export function parseStatusChar(char: string): string { - switch (char) { - case 'M': - return 'modified' - case 'A': - return 'added' - case 'D': - return 'deleted' - case 'R': - return 'renamed' - case 'C': - return 'copied' - default: - return 'modified' - } -} - -/** - * Parse `git status --porcelain=v2` output into structured entries. - * Does NOT handle unmerged entries (those require worktree access). - */ -export function parseStatusOutput(stdout: string): { - entries: Record[] - unmergedLines: string[] - ignoredPaths: string[] - head?: string - branch?: string - upstreamStatus: { - hasUpstream: boolean - upstreamName?: string - ahead: number - behind: number - } -} { - const entries: Record[] = [] - const unmergedLines: string[] = [] - const ignoredPaths: string[] = [] - let head: string | undefined - let branch: string | undefined - let upstreamName: string | undefined - let upstreamAheadBehind: { ahead: number; behind: number } | null = null - - for (const line of stdout.split(/\r?\n/)) { - if (!line) { - continue - } - - if (line.startsWith('# branch.oid ')) { - head = line.slice('# branch.oid '.length).trim() - continue - } - - if (line.startsWith('# branch.head ')) { - const branchHead = line.slice('# branch.head '.length).trim() - branch = branchHead && branchHead !== '(detached)' ? `refs/heads/${branchHead}` : '' - continue - } - - if (line.startsWith('# branch.upstream ')) { - upstreamName = line.slice('# branch.upstream '.length).trim() || undefined - continue - } - - if (line.startsWith('# branch.ab ')) { - upstreamAheadBehind = parseBranchAheadBehind(line) - continue - } - - if (line.startsWith('1 ') || line.startsWith('2 ')) { - const parts = line.split(' ') - const xy = parts[1] - const indexStatus = xy[0] - const worktreeStatus = xy[1] - - if (line.startsWith('2 ')) { - // Why: porcelain v2 type-2 format is `2 XY sub mH mI mW hH hI Xscore path\torigPath`. - // The new path starts after 9 fixed fields and can contain spaces; origPath follows the tab. - const tabParts = line.split('\t') - const filePath = tabParts[0].split(' ').slice(9).join(' ') - const oldPath = tabParts.slice(1).join('\t') - if (indexStatus !== '.') { - entries.push({ - path: filePath, - status: parseStatusChar(indexStatus), - area: 'staged', - oldPath, - ...submoduleStatusField(parts[2], indexStatus) - }) - } - if (worktreeStatus !== '.') { - entries.push({ - path: filePath, - status: parseStatusChar(worktreeStatus), - area: 'unstaged', - oldPath, - ...submoduleStatusField(parts[2], worktreeStatus) - }) - } - } else { - const filePath = parts.slice(8).join(' ') - if (indexStatus !== '.') { - entries.push({ - path: filePath, - status: parseStatusChar(indexStatus), - area: 'staged', - ...submoduleStatusField(parts[2], indexStatus) - }) - } - if (worktreeStatus !== '.') { - entries.push({ - path: filePath, - status: parseStatusChar(worktreeStatus), - area: 'unstaged', - ...submoduleStatusField(parts[2], worktreeStatus) - }) - } - } - } else if (line.startsWith('? ')) { - entries.push({ path: line.slice(2), status: 'untracked', area: 'untracked' }) - } else if (line.startsWith('! ')) { - ignoredPaths.push(line.slice(2)) - } else if (line.startsWith('u ')) { - unmergedLines.push(line) - } - } - - return { - entries, - unmergedLines, - ignoredPaths, - head, - branch, - upstreamStatus: upstreamName - ? { - hasUpstream: true, - upstreamName, - ahead: upstreamAheadBehind?.ahead ?? 0, - behind: upstreamAheadBehind?.behind ?? 0 - } - : { hasUpstream: false, ahead: 0, behind: 0 } - } -} - -function parseSubmoduleStatus( - submoduleField: string | undefined, - statusChar = '.' -): { commitChanged: boolean; trackedChanges: boolean; untrackedChanges: boolean } | undefined { - if (!submoduleField?.startsWith('S')) { - return undefined - } - return { - commitChanged: submoduleField[1] === 'C' || (submoduleField === 'S...' && statusChar === 'M'), - trackedChanges: submoduleField[2] === 'M', - untrackedChanges: submoduleField[3] === 'U' - } -} - -function submoduleStatusField( - submoduleField: string | undefined, - statusChar: string -): - | { submodule: { commitChanged: boolean; trackedChanges: boolean; untrackedChanges: boolean } } - | {} { - const submodule = parseSubmoduleStatus(submoduleField, statusChar) - return submodule ? { submodule } : {} -} - -function parseBranchAheadBehind(line: string): { ahead: number; behind: number } | null { - const match = line.match(/^# branch\.ab \+(\d+) -(\d+)$/) - if (!match) { - return null - } - return { - ahead: Number.parseInt(match[1], 10), - behind: Number.parseInt(match[2], 10) - } -} diff --git a/src/relay/port-scan-handler.test.ts b/src/relay/port-scan-handler.test.ts index 9df947b1aad..4ed3bce6ecd 100644 --- a/src/relay/port-scan-handler.test.ts +++ b/src/relay/port-scan-handler.test.ts @@ -1,7 +1,155 @@ -import { describe, expect, it, vi } from 'vitest' -import { parseHexAddress } from './port-scan-handler' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { MethodHandler, RequestContext } from './dispatcher' + +const { readFileMock, readdirMock, readlinkMock } = vi.hoisted(() => ({ + readFileMock: vi.fn(), + readdirMock: vi.fn(), + readlinkMock: vi.fn() +})) + +vi.mock('node:fs/promises', () => ({ + readFile: readFileMock, + readdir: readdirMock, + readlink: readlinkMock +})) + +import { parseHexAddress, PortScanHandler } from './port-scan-handler' import { parseWindowsNetstatOutput, parseWindowsPowerShellPortRows } from './windows-port-scan' +const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform') + +beforeEach(() => { + vi.clearAllMocks() + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) +}) + +afterEach(() => { + if (originalPlatformDescriptor) { + Object.defineProperty(process, 'platform', originalPlatformDescriptor) + } +}) + +function capturePortDetectHandler(): MethodHandler { + let handler: MethodHandler | undefined + new PortScanHandler({ + onRequest: (method, nextHandler) => { + expect(method).toBe('ports.detect') + handler = nextHandler + } + }) + if (!handler) { + throw new Error('ports.detect handler was not registered') + } + return handler +} + +function requestContext(signal?: AbortSignal): RequestContext { + return { clientId: 1, isStale: () => signal?.aborted ?? false, signal } +} + +function createDeferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve = (_value: T): void => { + throw new Error('deferred promise was not initialized') + } + const promise = new Promise((nextResolve) => { + resolve = nextResolve + }) + return { promise, resolve } +} + +function mockLinuxProcScan({ + pidCount, + fdCount, + firstReadlink +}: { + pidCount: number + fdCount: number + firstReadlink?: Promise +}): void { + const tcpHeader = + 'sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode' + const tcpRow = + '0: 0100007F:0BB8 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 11111' + readFileMock.mockImplementation(async (path: string) => { + if (path === '/proc/net/tcp') { + return `${tcpHeader}\n${tcpRow}\n` + } + if (path === '/proc/net/tcp6') { + return `${tcpHeader}\n` + } + if (path.endsWith('/cmdline')) { + return '/usr/bin/node\0server.js' + } + throw new Error(`unexpected readFile: ${path}`) + }) + + const pids = Array.from({ length: pidCount }, (_, index) => String(1_000 + index)) + const fds = Array.from({ length: fdCount }, (_, index) => String(index)) + readdirMock.mockImplementation(async (path: string) => { + if (path === '/proc') { + return pids + } + if (path.endsWith('/fd')) { + return fds + } + throw new Error(`unexpected readdir: ${path}`) + }) + + let first = true + readlinkMock.mockImplementation(() => { + if (first && firstReadlink) { + first = false + return firstReadlink + } + first = false + return Promise.resolve('socket:[11111]') + }) +} + +describe('PortScanHandler Linux cancellation', () => { + it('does not touch procfs for an already-cancelled request', async () => { + const controller = new AbortController() + controller.abort() + + await expect( + capturePortDetectHandler()({}, requestContext(controller.signal)) + ).rejects.toMatchObject({ name: 'AbortError' }) + + expect(readFileMock).not.toHaveBeenCalled() + expect(readdirMock).not.toHaveBeenCalled() + expect(readlinkMock).not.toHaveBeenCalled() + }) + + it('stops a large pid and fd walk at the filesystem operation already in flight', async () => { + const firstReadlink = createDeferred() + mockLinuxProcScan({ pidCount: 1_000, fdCount: 100, firstReadlink: firstReadlink.promise }) + const controller = new AbortController() + const scan = capturePortDetectHandler()({}, requestContext(controller.signal)) + + await vi.waitFor(() => expect(readlinkMock).toHaveBeenCalledTimes(1)) + controller.abort() + firstReadlink.resolve('socket:[11111]') + + await expect(scan).rejects.toMatchObject({ name: 'AbortError' }) + expect(readdirMock).toHaveBeenCalledTimes(2) + expect(readdirMock).toHaveBeenNthCalledWith(1, '/proc') + expect(readdirMock).toHaveBeenNthCalledWith(2, '/proc/1000/fd') + expect(readlinkMock).toHaveBeenCalledTimes(1) + expect(readlinkMock).toHaveBeenCalledWith('/proc/1000/fd/0') + }) + + it('preserves detected port results when the request stays live', async () => { + mockLinuxProcScan({ pidCount: 1, fdCount: 1 }) + + await expect( + capturePortDetectHandler()({}, requestContext(new AbortController().signal)) + ).resolves.toEqual({ + ports: [{ host: '127.0.0.1', port: 3000, pid: 1_000, processName: 'node' }], + platform: 'linux' + }) + }) +}) + describe('parseHexAddress', () => { it('parses IPv4 localhost (127.0.0.1)', () => { // 127.0.0.1 in little-endian hex: 0100007F diff --git a/src/relay/port-scan-handler.ts b/src/relay/port-scan-handler.ts index a576d8921fa..40150133a04 100644 --- a/src/relay/port-scan-handler.ts +++ b/src/relay/port-scan-handler.ts @@ -16,11 +16,11 @@ const SYSTEM_PORTS_TO_EXCLUDE = new Set([22]) const MAX_DETECTED_PORTS = 50 export class PortScanHandler { - constructor(dispatcher: RelayDispatcher) { + constructor(dispatcher: Pick) { dispatcher.onRequest('ports.detect', async (_params, context: RequestContext) => { if (process.platform === 'linux') { return { - ports: await this.scanLinuxListeningPorts(), + ports: await this.scanLinuxListeningPorts(context.signal), platform: process.platform } } @@ -37,11 +37,13 @@ export class PortScanHandler { }) } - private async scanLinuxListeningPorts(): Promise { + private async scanLinuxListeningPorts(signal?: AbortSignal): Promise { + signal?.throwIfAborted() const [tcp4, tcp6] = await Promise.all([ - this.readProcNet('/proc/net/tcp'), - this.readProcNet('/proc/net/tcp6') + this.readProcNet('/proc/net/tcp', signal), + this.readProcNet('/proc/net/tcp6', signal) ]) + signal?.throwIfAborted() const listeningSockets = [...tcp4, ...tcp6] if (listeningSockets.length === 0) { @@ -49,7 +51,7 @@ export class PortScanHandler { } const inodeSet = new Set(listeningSockets.map((s) => s.inode)) - const inodeToPid = await this.mapInodesToPids(inodeSet) + const inodeToPid = await this.mapInodesToPids(inodeSet, signal) const seen = new Set() const results: DetectedPort[] = [] @@ -57,6 +59,7 @@ export class PortScanHandler { const relayParentPid = process.ppid for (const socket of listeningSockets) { + signal?.throwIfAborted() const key = `${socket.host}:${socket.port}` if (seen.has(key)) { continue @@ -72,7 +75,7 @@ export class PortScanHandler { continue } - const processName = pid != null ? await this.getProcessName(pid) : undefined + const processName = pid != null ? await this.getProcessName(pid, signal) : undefined if (processName === 'sshd') { continue @@ -93,14 +96,18 @@ export class PortScanHandler { } private async readProcNet( - path: string + path: string, + signal?: AbortSignal ): Promise<{ port: number; host: string; inode: number }[]> { + signal?.throwIfAborted() let content: string try { content = await readFile(path, 'utf-8') } catch { + signal?.throwIfAborted() return [] } + signal?.throwIfAborted() const lines = content.split('\n') const results: { port: number; host: string; inode: number }[] = [] @@ -133,7 +140,11 @@ export class PortScanHandler { return results } - private async mapInodesToPids(inodes: Set): Promise> { + private async mapInodesToPids( + inodes: Set, + signal?: AbortSignal + ): Promise> { + signal?.throwIfAborted() const result = new Map() if (inodes.size === 0) { return result @@ -143,27 +154,35 @@ export class PortScanHandler { try { pids = (await readdir('/proc')).filter((name) => /^\d+$/.test(name)) } catch { + signal?.throwIfAborted() return result } + signal?.throwIfAborted() for (const pidStr of pids) { + signal?.throwIfAborted() const fdDir = `/proc/${pidStr}/fd` let fds: string[] try { fds = await readdir(fdDir) } catch { + signal?.throwIfAborted() continue } + signal?.throwIfAborted() const pid = Number.parseInt(pidStr, 10) for (const fd of fds) { + signal?.throwIfAborted() let link: string try { link = await readlink(`${fdDir}/${fd}`) } catch { + signal?.throwIfAborted() continue } + signal?.throwIfAborted() const match = link.match(/^socket:\[(\d+)\]$/) if (!match) { @@ -180,9 +199,11 @@ export class PortScanHandler { return result } - private async getProcessName(pid: number): Promise { + private async getProcessName(pid: number, signal?: AbortSignal): Promise { + signal?.throwIfAborted() try { const cmdline = await readFile(`/proc/${pid}/cmdline`, 'utf-8') + signal?.throwIfAborted() if (!cmdline) { return undefined } @@ -195,6 +216,7 @@ export class PortScanHandler { const parts = exe.split('/') return parts.at(-1) } catch { + signal?.throwIfAborted() return undefined } } diff --git a/src/relay/pty-source-replay-index.test.ts b/src/relay/pty-source-replay-index.test.ts deleted file mode 100644 index efbb17451b0..00000000000 --- a/src/relay/pty-source-replay-index.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { PtySourceSpan } from '../shared/pty-source-credit-contract' -import { PtySourceReplayIndex } from './pty-source-replay-index' - -function span(id: string, data: string, sourceStartSu: number): PtySourceSpan { - return { - id: 'pty-1', - providerGeneration: 1, - clientGeneration: 1, - ownerGeneration: 1, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-1', - spanId: id, - sourceStartSu, - sourceEndSu: sourceStartSu + data.length, - displayStart: sourceStartSu, - displayEnd: sourceStartSu + data.length, - data, - splittable: true, - transform: { transformed: false, rawLengthSu: data.length, scalarSafe: true } - } -} - -describe('PtySourceReplayIndex', () => { - it('wraps the existing chunked buffer and preserves exact retained source ranges', () => { - const replay = new PtySourceReplayIndex(6) - replay.append(span('one', 'abcd', 0)) - replay.append(span('two', 'efgh', 4)) - - expect(replay.readLegacy()).toBe('cdefgh') - expect(replay.recoveryFrom(3)).toEqual({ - ok: true, - records: [ - expect.objectContaining({ sourceStartSu: 3, data: 'd' }), - expect.objectContaining({ sourceStartSu: 4, data: 'efgh' }) - ], - liveEndSourceSu: 8 - }) - expect(replay.recoveryFrom(1)).toMatchObject({ - ok: false, - reason: 'checkpoint-before-retained-range' - }) - }) - - it('does not wall-clock dedupe identical replay payloads', () => { - const first = new PtySourceReplayIndex(16) - const second = new PtySourceReplayIndex(16) - first.append(span('first', 'same', 0)) - second.append({ ...span('second', 'same', 0), deliveryToken: 'token-2' }) - - expect(first.recoveryFrom(0)).toMatchObject({ ok: true }) - expect(second.recoveryFrom(0)).toMatchObject({ ok: true }) - }) - - it('preserves indivisible-form split metadata during recovery', () => { - const replay = new PtySourceReplayIndex(16) - replay.append({ - ...span('range', 'abcd', 0), - splittable: undefined, - indivisible: false - }) - - expect(replay.recoveryFrom(2)).toMatchObject({ - ok: true, - records: [expect.objectContaining({ sourceStartSu: 2, data: 'cd' })] - }) - }) - - it('rejects replay checkpoints and retained heads inside a scalar', () => { - const replay = new PtySourceReplayIndex(4) - replay.append(span('range', 'a😀b', 0)) - expect(replay.recoveryFrom(2)).toMatchObject({ - ok: false, - reason: 'checkpoint-inside-scalar' - }) - - const trimmed = new PtySourceReplayIndex(1) - trimmed.append(span('emoji', '😀', 0)) - expect(trimmed.recoveryFrom(1)).toMatchObject({ - ok: false, - reason: 'checkpoint-before-retained-range', - retainedStartSourceSu: 2 - }) - }) - - it('rejects a recovery checkpoint beyond the live source range', () => { - const replay = new PtySourceReplayIndex(16) - replay.append(span('range', 'abcd', 0)) - - expect(replay.recoveryFrom(5)).toMatchObject({ - ok: false, - reason: 'checkpoint-after-live-range', - liveEndSourceSu: 4 - }) - }) -}) diff --git a/src/relay/pty-source-replay-index.ts b/src/relay/pty-source-replay-index.ts deleted file mode 100644 index 1dfe5596eb1..00000000000 --- a/src/relay/pty-source-replay-index.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { RecentPtyOutputBuffer } from '../main/runtime/recent-pty-output-buffer' -import { ptySourceSpanIsSplittable, type PtySourceSpan } from '../shared/pty-source-credit-contract' -import { - assertNonNegativeSafeInteger, - assertPtySourceSpan -} from '../shared/pty-source-credit-validation' - -export type PtySourceReplayRecord = Readonly< - Pick< - PtySourceSpan, - | 'spanId' - | 'sourceStartSu' - | 'sourceEndSu' - | 'displayStart' - | 'displayEnd' - | 'data' - | 'splittable' - | 'indivisible' - | 'transform' - > -> - -export type PtySourceReplayResult = - | Readonly<{ ok: true; records: readonly PtySourceReplayRecord[]; liveEndSourceSu: number }> - | Readonly<{ - ok: false - reason: - | 'checkpoint-before-retained-range' - | 'checkpoint-after-live-range' - | 'checkpoint-inside-indivisible-transform' - | 'checkpoint-inside-scalar' - retainedStartSourceSu: number - liveEndSourceSu: number - }> - -export class PtySourceReplayIndex { - private readonly output: RecentPtyOutputBuffer - private readonly records: PtySourceReplayRecord[] = [] - private displayEnd = 0 - private retainedStartSourceSu = 0 - private liveEndSourceSu = 0 - - constructor(limit: number) { - this.output = new RecentPtyOutputBuffer({ preserveChunkBoundaries: false, limit }) - } - - append(span: PtySourceSpan): void { - assertPtySourceSpan(span) - if ( - span.sourceStartSu !== this.liveEndSourceSu || - span.displayStart !== this.displayEnd || - span.displayEnd - span.displayStart !== span.data.length - ) { - throw new Error('PTY replay source/display ranges must be contiguous') - } - this.output.append(span.data) - this.records.push( - Object.freeze({ - spanId: span.spanId, - sourceStartSu: span.sourceStartSu, - sourceEndSu: span.sourceEndSu, - displayStart: span.displayStart, - displayEnd: span.displayEnd, - data: span.data, - splittable: span.splittable, - indivisible: span.indivisible, - transform: span.transform - }) - ) - this.displayEnd = span.displayEnd - this.liveEndSourceSu = span.sourceEndSu - this.evictUnmappedPrefix() - } - - readLegacy(): string { - return this.output.read() - } - - recoveryFrom(checkpointSourceEndSu: number): PtySourceReplayResult { - assertNonNegativeSafeInteger(checkpointSourceEndSu, 'checkpointSourceEndSu') - if (checkpointSourceEndSu < this.retainedStartSourceSu) { - return Object.freeze({ - ok: false, - reason: 'checkpoint-before-retained-range', - retainedStartSourceSu: this.retainedStartSourceSu, - liveEndSourceSu: this.liveEndSourceSu - }) - } - if (checkpointSourceEndSu > this.liveEndSourceSu) { - return Object.freeze({ - ok: false, - reason: 'checkpoint-after-live-range', - retainedStartSourceSu: this.retainedStartSourceSu, - liveEndSourceSu: this.liveEndSourceSu - }) - } - const containing = this.records.find( - (record) => - record.sourceStartSu < checkpointSourceEndSu && checkpointSourceEndSu < record.sourceEndSu - ) - if ( - containing?.transform.transformed || - (containing && !ptySourceSpanIsSplittable(containing)) - ) { - return Object.freeze({ - ok: false, - reason: 'checkpoint-inside-indivisible-transform', - retainedStartSourceSu: this.retainedStartSourceSu, - liveEndSourceSu: this.liveEndSourceSu - }) - } - if ( - containing && - splitsSurrogatePair(containing.data, checkpointSourceEndSu - containing.sourceStartSu) - ) { - return Object.freeze({ - ok: false, - reason: 'checkpoint-inside-scalar', - retainedStartSourceSu: this.retainedStartSourceSu, - liveEndSourceSu: this.liveEndSourceSu - }) - } - const records = this.records - .filter((record) => record.sourceEndSu > checkpointSourceEndSu) - .map((record) => this.sliceRecord(record, checkpointSourceEndSu)) - return Object.freeze({ - ok: true, - records: Object.freeze(records), - liveEndSourceSu: this.liveEndSourceSu - }) - } - - private evictUnmappedPrefix(): void { - const retainedDisplayStart = this.displayEnd - this.output.read().length - while (this.records[0]?.displayEnd <= retainedDisplayStart) { - const removed = this.records.shift()! - this.retainedStartSourceSu = removed.sourceEndSu - } - const head = this.records[0] - if (!head || head.displayStart >= retainedDisplayStart) { - return - } - if (head.transform.transformed || !ptySourceSpanIsSplittable(head)) { - this.records.shift() - this.retainedStartSourceSu = head.sourceEndSu - return - } - const displayOffset = retainedDisplayStart - head.displayStart - if (splitsSurrogatePair(head.data, displayOffset)) { - this.records.shift() - this.retainedStartSourceSu = head.sourceEndSu - return - } - const sourceStartSu = head.sourceStartSu + displayOffset - this.records[0] = Object.freeze({ - ...head, - spanId: `${head.spanId}:retained:${sourceStartSu}`, - sourceStartSu, - displayStart: retainedDisplayStart, - data: head.data.slice(displayOffset), - transform: Object.freeze({ - ...head.transform, - rawLengthSu: head.sourceEndSu - sourceStartSu - }) - }) - this.retainedStartSourceSu = sourceStartSu - } - - private sliceRecord( - record: PtySourceReplayRecord, - checkpointSourceEndSu: number - ): PtySourceReplayRecord { - if (checkpointSourceEndSu <= record.sourceStartSu) { - return record - } - const offset = checkpointSourceEndSu - record.sourceStartSu - return Object.freeze({ - ...record, - spanId: `${record.spanId}:recovery:${checkpointSourceEndSu}`, - sourceStartSu: checkpointSourceEndSu, - displayStart: record.displayStart + offset, - data: record.data.slice(offset), - transform: Object.freeze({ - ...record.transform, - rawLengthSu: record.sourceEndSu - checkpointSourceEndSu - }) - }) - } -} - -function splitsSurrogatePair(data: string, offset: number): boolean { - const preceding = data.charCodeAt(offset - 1) - const following = data.charCodeAt(offset) - return ( - offset > 0 && - preceding >= 0xd800 && - preceding <= 0xdbff && - following >= 0xdc00 && - following <= 0xdfff - ) -} diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 141049b04c2..84b6bb9e155 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -690,6 +690,7 @@ function App(): React.JSX.Element { const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) + const hideWorkspacesFromOtherDevices = useAppStore((s) => s.hideWorkspacesFromOtherDevices) const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces) const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces) const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace) @@ -745,14 +746,7 @@ function App(): React.JSX.Element { const featureTipsSuppressedByOnboardingThisSessionRef = useRef(false) const unmountAddRepoDialogTimerRef = useRef | null>(null) const [featureTipCliInstalled, setFeatureTipCliInstalled] = useState(null) - const [onboardingSettingsDetour, setOnboardingSettingsDetour] = useState(false) const shouldRenderOnboarding = onboarding !== null && shouldShowOnboarding(onboarding) - const onboardingSettingsDetourActive = - onboardingSettingsDetour && activeView === 'settings' && shouldRenderOnboarding - if (onboardingSettingsDetour && !onboardingSettingsDetourActive) { - // Why: the detour is valid only while Settings is onscreen; clear it during render so onboarding resumes without an extra Effect pass. - setOnboardingSettingsDetour(false) - } useEffect(() => { if (activeModal === 'add-repo') { @@ -875,10 +869,6 @@ function App(): React.JSX.Element { settings ]) - const beginOnboardingSettingsDetour = useCallback(() => { - setOnboardingSettingsDetour(true) - }, []) - // Why: useLayoutEffect fires before paint, so dispatching SYNC_FIT_PANES_EVENT reflows the terminal in the same frame as the width change — no wrongly-sized transient. useLayoutEffect(() => { window.dispatchEvent(new CustomEvent(SYNC_FIT_PANES_EVENT)) @@ -973,6 +963,10 @@ function App(): React.JSX.Element { // Why: disconnected SSH repos hydrate from local metadata; only runtime-owned repos use placeholders. parseExecutionHostId(getRepoExecutionHostId(repo))?.kind !== 'runtime' ) + // Why: worktree refresh can spawn host Git; wait for main's shell-PATH generation fence first. + await timeRendererStartupStep('first-window-services-await', () => + window.api.app.awaitFirstWindowStartupServices() + ) await timeRendererStartupStep('fetch-hydration-worktrees', () => mapWithConcurrency(hydrationRepos, WORKTREE_REFRESH_CONCURRENCY, (repo) => actions.fetchWorktrees(repo.id, { executionHostId: getRepoExecutionHostId(repo) }) @@ -1105,10 +1099,7 @@ function App(): React.JSX.Element { logRendererStartupDiagnostic('ssh-reconnect-skipped', { connectionIds: 0 }) } - // Why: main overlaps daemon/hook startup with hydration, but restored terminals need those services ready before they spawn/reconnect PTYs. - await timeRendererStartupStep('first-window-services-await', () => - window.api.app.awaitFirstWindowStartupServices() - ) + // first-window-services-await already fenced worktree hydration; terminal recovery reuses that ready state. await timeRendererStartupStep('recover-legacy-worker-terminals-pre-reconnect', () => window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() ) @@ -1422,6 +1413,7 @@ function App(): React.JSX.Element { hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces, hideDetachedHeadWorkspaces, + hideWorkspacesFromOtherDevices, alwaysShowDefaultBranchWorkspace, showDotfilesByWorktree, filterRepoIds, @@ -1456,6 +1448,7 @@ function App(): React.JSX.Element { hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces, hideDetachedHeadWorkspaces, + hideWorkspacesFromOtherDevices, alwaysShowDefaultBranchWorkspace, showDotfilesByWorktree, filterRepoIds, @@ -2752,23 +2745,18 @@ function App(): React.JSX.Element { > - {onboarding && shouldRenderOnboarding && !onboardingSettingsDetourActive ? ( + {onboarding && shouldRenderOnboarding ? ( - + ) : null} diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index 80bb0037eac..9a64224f072 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -36,6 +36,10 @@ describe('renderer startup runtime routing', () => { const hydrationWorktreesIndex = source.indexOf( "timeRendererStartupStep('fetch-hydration-worktrees'" ) + const servicesIndex = source.indexOf( + "timeRendererStartupStep('first-window-services-await'", + sessionIndex + ) const fullWorktreesIndex = source.indexOf('await actions.fetchAllWorktrees()') const lineageIndex = startupBlock.indexOf('actions.fetchWorktreeLineage()') @@ -53,7 +57,8 @@ describe('renderer startup runtime routing', () => { expect(localReposIndex).toBeLessThan(localGroupsIndex) expect(localGroupsIndex).toBeLessThan(localFoldersIndex) expect(localReposIndex).toBeLessThan(sessionIndex) - expect(sessionIndex).toBeLessThan(hydrationWorktreesIndex) + expect(sessionIndex).toBeLessThan(servicesIndex) + expect(servicesIndex).toBeLessThan(hydrationWorktreesIndex) const hydrationWorktreeBlock = source.slice( hydrationWorktreesIndex, source.indexOf('await keybindingsPromise') diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 85d6a2c116a..23f335c3927 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1027,11 +1027,6 @@ html.native-shell .app-layout { color: var(--muted-foreground); } -.titlebar-profile-switcher { - -webkit-app-region: no-drag; - flex-shrink: 0; -} - .editor-header { display: flex; align-items: center; @@ -1126,6 +1121,31 @@ html.native-shell .app-layout { overflow: hidden; } +/* ── Jump palette (Cmd+J) ─────────────────────────────── */ + +/* Why the data-slot qualifier: CommandItem also sets data-[selected=true]:bg-accent; + this selector must beat that utility so light-mode selection is actually visible. */ +[data-slot='command-item'].jump-palette-item[data-selected='true'] { + /* Why: flat --accent (#f5f5f5) on the palette surface (#fff) is nearly invisible + in light mode — the keyboard cursor has to read as a solid selected row. + Mix foreground into background and draw an inset ring; expose the surface + as a var so status-pip cutouts can match without hardcoding. */ + --jump-palette-selection-surface: color-mix(in srgb, var(--foreground) 12%, var(--background)); + background: var(--jump-palette-selection-surface); + border-color: transparent; + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--foreground) 18%, transparent); + color: var(--foreground); +} + +.dark [data-slot='command-item'].jump-palette-item[data-selected='true'] { + /* Why accent, not the light-mode mix: --accent (#404040) already reads clearly on + dark surfaces; a low-%% foreground mix was a visible step down from the old row. */ + --jump-palette-selection-surface: var(--accent); + background: var(--jump-palette-selection-surface); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--foreground) 16%, transparent); + color: var(--accent-foreground); +} + /* ── Sidebar ─────────────────────────────────────────── */ [data-file-explorer-row][data-selected='true'] { @@ -2599,106 +2619,6 @@ html.native-shell .app-layout { animation: feature-wall-meta-pulse 1100ms ease-out 1; } -/* Onboarding step 5: the compact "Take the tour" intro expands into the - full tour shell, so animate the browser snapshots to avoid a hard jump. */ -html.onboarding-tour-start-transition::view-transition-old(root), -html.onboarding-tour-start-transition::view-transition-new(root) { - animation-duration: 760ms; - animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); - animation-fill-mode: both; - mix-blend-mode: normal; -} - -html.onboarding-tour-start-transition::view-transition-old(root) { - animation-name: onboarding-tour-start-old; -} - -html.onboarding-tour-start-transition::view-transition-new(root) { - animation-name: onboarding-tour-start-new; -} - -@keyframes onboarding-tour-start-old { - 0% { - opacity: 1; - transform: translateY(0) scale(1); - } - 28% { - opacity: 0.9; - transform: translateY(-2px) scale(0.998); - } - 42% { - opacity: 0; - transform: translateY(-12px) scale(0.99); - } - 100% { - opacity: 0; - transform: translateY(-12px) scale(0.99); - } -} - -@keyframes onboarding-tour-start-new { - 0% { - opacity: 0; - transform: translateY(18px) scale(0.992); - } - 34% { - opacity: 0; - transform: translateY(18px) scale(0.992); - } - 100% { - opacity: 1; - transform: translateY(0) scale(1); - } -} - -/* ── Feature tour nudge: cycling preview ─────────────────────────── */ - -/* Why: the bottom-right nudge cycles through 4 silhouette frames - (Workspace → Orchestration → Tasks → Terminal). Each frame is visible for - 1/4 of the cycle. Holding fade-out late (24%–25%) gives the user dwell - time on the resolved state before swapping. Uses CSS-driven timing rather - than React state so we stay cheap when the popup is mounted but idle. */ -:root { - --feature-tour-cycle: 16s; -} - -.feature-tour-frame { - position: absolute; - inset: 0; - opacity: 0; - animation: feature-tour-frame-fade var(--feature-tour-cycle) linear infinite both; -} -.feature-tour-frame[data-frame='1'] { - animation-delay: 0s; -} -.feature-tour-frame[data-frame='2'] { - animation-delay: 4s; -} -.feature-tour-frame[data-frame='3'] { - animation-delay: 8s; -} -.feature-tour-frame[data-frame='4'] { - animation-delay: 12s; -} - -@keyframes feature-tour-frame-fade { - 0% { - opacity: 0; - } - 1.5% { - opacity: 1; - } - 24% { - opacity: 1; - } - 25% { - opacity: 0; - } - 100% { - opacity: 0; - } -} - .orca-contextual-tour-target-rings { border-radius: 9999px; pointer-events: none; @@ -2736,520 +2656,6 @@ html.onboarding-tour-start-transition::view-transition-new(root) { } } -.feature-tour-copy-slide { - position: absolute; - inset: 0; - display: flex; - flex-direction: column; - gap: 4px; - opacity: 0; - animation: feature-tour-frame-fade var(--feature-tour-cycle) linear infinite both; -} -.feature-tour-copy-slide[data-frame='1'] { - animation-delay: 0s; -} -.feature-tour-copy-slide[data-frame='2'] { - animation-delay: 4s; -} -.feature-tour-copy-slide[data-frame='3'] { - animation-delay: 8s; -} -.feature-tour-copy-slide[data-frame='4'] { - animation-delay: 12s; -} - -.feature-tour-dot { - width: 5px; - height: 5px; - border-radius: 9999px; - background: color-mix(in srgb, var(--foreground) 18%, transparent); - animation: feature-tour-dot-pulse var(--feature-tour-cycle) linear infinite; -} -.feature-tour-dot[data-frame='1'] { - animation-delay: 0s; -} -.feature-tour-dot[data-frame='2'] { - animation-delay: 4s; -} -.feature-tour-dot[data-frame='3'] { - animation-delay: 8s; -} -.feature-tour-dot[data-frame='4'] { - animation-delay: 12s; -} -@keyframes feature-tour-dot-pulse { - 0%, - 24% { - background: var(--primary); - transform: scale(1.05); - } - 25%, - 100% { - background: color-mix(in srgb, var(--foreground) 18%, transparent); - transform: scale(1); - } -} - -/* Frame 1 — Workspace: agent-state dot pulses while a "fix login race" - workspace shows two agent rows working in parallel. */ -.feature-tour-ws-status { - animation: feature-tour-status-pulse 1.6s ease-in-out infinite; -} -@keyframes feature-tour-status-pulse { - 0%, - 100% { - opacity: 0.95; - transform: scale(1); - } - 50% { - opacity: 0.55; - transform: scale(0.85); - } -} - -/* Frame 2 — Orchestration: child cards flash as bubbles land along the - dashed paths fanning down from the coordinator. */ -/* Why: bubbles use percent-based left/top so they always travel from the - root card's right edge (34%) to the child card's left edge (64%), - regardless of preview width. The previous offset-path used hardcoded - pixels and drifted off the dashed lines once the preview resized. */ -.feature-tour-orch-bubble { - position: absolute; - left: 34%; - top: 50%; - width: 12px; - height: 12px; - margin-left: -6px; - margin-top: -6px; - border-radius: 3px; - background: var(--primary); - color: var(--primary-foreground); - display: inline-flex; - align-items: center; - justify-content: center; - opacity: 0; - z-index: 4; -} -.feature-tour-orch-bubble.top { - animation: feature-tour-orch-travel-top var(--feature-tour-cycle) cubic-bezier(0.4, 0, 0.2, 1) - infinite; - animation-delay: 4s; -} -.feature-tour-orch-bubble.mid { - animation: feature-tour-orch-travel-mid var(--feature-tour-cycle) cubic-bezier(0.4, 0, 0.2, 1) - infinite; - animation-delay: calc(4s + 0.2s); -} -.feature-tour-orch-bubble.bot { - animation: feature-tour-orch-travel-bot var(--feature-tour-cycle) cubic-bezier(0.4, 0, 0.2, 1) - infinite; - animation-delay: calc(4s + 0.4s); -} -@keyframes feature-tour-orch-travel-top { - 0% { - left: 34%; - top: 50%; - opacity: 0; - } - 2% { - opacity: 1; - } - 3.5% { - left: 49%; - top: 50%; - opacity: 1; - } - 6.5% { - left: 64%; - top: 18%; - opacity: 1; - } - 7.5% { - left: 64%; - top: 18%; - opacity: 0; - } - 25%, - 100% { - left: 64%; - top: 18%; - opacity: 0; - } -} -@keyframes feature-tour-orch-travel-mid { - 0% { - left: 34%; - top: 50%; - opacity: 0; - } - 2% { - opacity: 1; - } - 6.5% { - left: 64%; - top: 50%; - opacity: 1; - } - 7.5% { - left: 64%; - top: 50%; - opacity: 0; - } - 25%, - 100% { - left: 64%; - top: 50%; - opacity: 0; - } -} -@keyframes feature-tour-orch-travel-bot { - 0% { - left: 34%; - top: 50%; - opacity: 0; - } - 2% { - opacity: 1; - } - 3.5% { - left: 49%; - top: 50%; - opacity: 1; - } - 6.5% { - left: 64%; - top: 82%; - opacity: 1; - } - 7.5% { - left: 64%; - top: 82%; - opacity: 0; - } - 25%, - 100% { - left: 64%; - top: 82%; - opacity: 0; - } -} - -.feature-tour-orch-child { - animation: feature-tour-orch-child-flash var(--feature-tour-cycle) linear infinite; -} -.feature-tour-orch-child.top { - animation-delay: 4s; -} -.feature-tour-orch-child.mid { - animation-delay: calc(4s + 0.2s); -} -.feature-tour-orch-child.bot { - animation-delay: calc(4s + 0.4s); -} -@keyframes feature-tour-orch-child-flash { - 0%, - 6% { - box-shadow: 0 0 0 0 transparent; - border-color: var(--border); - } - 7% { - box-shadow: 0 0 0 2px color-mix(in srgb, var(--foreground) 12%, transparent); - border-color: color-mix(in srgb, var(--foreground) 38%, transparent); - } - 9% { - box-shadow: 0 0 0 0 transparent; - border-color: var(--border); - } - 25%, - 100% { - box-shadow: 0 0 0 0 transparent; - border-color: var(--border); - } -} - -/* Frame 3 — Tasks: cursor lands on the second row, the "Open" pill morphs - to a dark "Start workspace →" pill, then a workspace card slides in. */ -.feature-tour-tasks-cursor { - /* Why: anchored from the row's right edge so the cursor sits just past the - pill's right edge (12px row padding + ~6px past pill end) instead of - covering the label text. The arrow tip (top-left of the SVG) lands on the - dark pill's trailing edge with the body of the cursor extending right. */ - position: absolute; - top: 50%; - right: 14px; - width: 14px; - height: 14px; - pointer-events: none; - z-index: 8; - transform: translate(120px, -30%); - opacity: 0; - animation: feature-tour-tasks-cursor var(--feature-tour-cycle) cubic-bezier(0.45, 0.05, 0.2, 1) - infinite; - animation-delay: 8s; -} -.feature-tour-tasks-cursor svg { - width: 14px; - height: 14px; - display: block; -} -@keyframes feature-tour-tasks-cursor { - 0% { - transform: translate(160px, -10%); - opacity: 0; - } - 2% { - opacity: 1; - } - 5% { - transform: translate(60px, -30%); - opacity: 1; - } - 8% { - transform: translate(0px, -30%); - opacity: 1; - } - 9% { - transform: translate(0px, -10%); - opacity: 1; - } - 10.5% { - transform: translate(0px, -30%); - opacity: 1; - } - 23.5% { - transform: translate(0px, -30%); - opacity: 1; - } - 24.5% { - opacity: 0; - } - 25%, - 100% { - transform: translate(0px, -30%); - opacity: 0; - } -} - -.feature-tour-tasks-row { - animation: feature-tour-tasks-row-hover var(--feature-tour-cycle) linear infinite; - animation-delay: 8s; -} -@keyframes feature-tour-tasks-row-hover { - 0%, - 4% { - background: transparent; - box-shadow: none; - } - 6% { - background: color-mix(in srgb, var(--foreground) 5%, transparent); - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--foreground) 6%, transparent); - } - 23.5% { - background: color-mix(in srgb, var(--foreground) 5%, transparent); - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--foreground) 6%, transparent); - } - 25%, - 100% { - background: transparent; - box-shadow: none; - } -} - -.feature-tour-tasks-pill { - animation: feature-tour-tasks-pill-morph var(--feature-tour-cycle) linear infinite; - animation-delay: 8s; -} -@keyframes feature-tour-tasks-pill-morph { - 0%, - 6% { - width: 28px; - background: rgb(34 197 94 / 0.14); - border-color: rgb(34 197 94 / 0.3); - box-shadow: none; - } - 8% { - width: 78px; - background: var(--primary); - border-color: var(--primary); - box-shadow: 0 1px 2px rgb(0 0 0 / 0.18); - } - 23.5% { - width: 78px; - background: var(--primary); - border-color: var(--primary); - box-shadow: 0 1px 2px rgb(0 0 0 / 0.18); - } - 25%, - 100% { - width: 28px; - background: rgb(34 197 94 / 0.14); - border-color: rgb(34 197 94 / 0.3); - box-shadow: none; - } -} - -.feature-tour-tasks-pill-label { - opacity: 0; - animation: feature-tour-tasks-pill-label var(--feature-tour-cycle) linear infinite; - animation-delay: 8s; -} -@keyframes feature-tour-tasks-pill-label { - 0%, - 7% { - opacity: 0; - } - 9% { - opacity: 1; - } - 23.5% { - opacity: 1; - } - 24.5% { - opacity: 0; - } - 25%, - 100% { - opacity: 0; - } -} - -/* Click ring fires when the cursor "clicks" the morphed pill on the tasks - row. Positioned to overlay the right-side pill of the second task. */ -.feature-tour-tasks-click-ring { - /* Why: ring is centered on the cursor tip (right=28 from row's right edge) - so the click feedback lands where the cursor is, on the arrow icon — not - centered on the pill, which would visually disconnect from the cursor. */ - position: absolute; - top: 50%; - right: 14px; - width: 28px; - height: 28px; - margin-top: -14px; - border-radius: 9999px; - border: 1.5px solid color-mix(in srgb, var(--foreground) 55%, transparent); - opacity: 0; - z-index: 7; - pointer-events: none; - animation: feature-tour-tasks-click-ring var(--feature-tour-cycle) linear infinite; - animation-delay: 8s; -} -@keyframes feature-tour-tasks-click-ring { - 0%, - 7% { - opacity: 0; - transform: scale(0.4); - } - 8% { - opacity: 0.85; - transform: scale(0.6); - } - 10% { - opacity: 0; - transform: scale(1.4); - } - 25%, - 100% { - opacity: 0; - transform: scale(1.4); - } -} - -/* Newly-created workspace card slides in beneath the task row once the - user clicks "Start workspace" — sells the "task → workspace" jump. */ -.feature-tour-tasks-workspace { - opacity: 0; - transform: translateY(-6px); - animation: feature-tour-tasks-workspace var(--feature-tour-cycle) linear infinite; - animation-delay: 8s; -} -@keyframes feature-tour-tasks-workspace { - 0%, - 10% { - opacity: 0; - transform: translateY(-6px); - } - 12% { - opacity: 1; - transform: translateY(0); - } - 23.5% { - opacity: 1; - transform: translateY(0); - } - 25%, - 100% { - opacity: 0; - transform: translateY(-6px); - } -} - -/* Frame 4 — Terminal: command stays anchored while the split panes stream - Playwright and Claude activity. */ -.feature-tour-terminal-line { - width: 23ch; -} - -.feature-tour-terminal-output { - opacity: 0; - transform: translateY(2px); - animation: feature-tour-terminal-output var(--feature-tour-cycle) linear infinite; - animation-delay: 12s; -} -.feature-tour-terminal-output[data-line='1'] { - animation-delay: calc(12s + 0.05s); -} -.feature-tour-terminal-output[data-line='2'] { - animation-delay: calc(12s + 0.25s); -} -.feature-tour-terminal-output[data-line='3'] { - animation-delay: calc(12s + 0.45s); -} -@keyframes feature-tour-terminal-output { - 0%, - 10% { - opacity: 0; - transform: translateY(2px); - } - 12% { - opacity: 1; - transform: translateY(0); - } - 23.5% { - opacity: 1; - transform: translateY(0); - } - 25%, - 100% { - opacity: 0; - transform: translateY(2px); - } -} - -@media (prefers-reduced-motion: reduce) { - .feature-tour-frame, - .feature-tour-copy-slide, - .feature-tour-dot, - .feature-tour-ws-status, - .feature-tour-orch-bubble, - .feature-tour-orch-child, - .feature-tour-tasks-cursor, - .feature-tour-tasks-row, - .feature-tour-tasks-pill, - .feature-tour-tasks-pill-label, - .feature-tour-tasks-click-ring, - .feature-tour-tasks-workspace, - .feature-tour-terminal-output { - animation: none !important; - } - .feature-tour-frame[data-frame='1'], - .feature-tour-copy-slide[data-frame='1'] { - opacity: 1; - } - .feature-tour-dot[data-frame='1'] { - background: var(--primary); - } -} - /* ── Diff comment decorations ────────────────────────────────────── */ /* Why: the "+" button is appended into Monaco's editor DOM node by diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 0e50c3bbc50..eee4a5a106c 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -93,10 +93,12 @@ import { } from '@/components/editor/large-diff-section-content' import { CHECK_COLOR, CHECK_ICON } from '@/components/right-sidebar/checks-panel-content' import { + beginGitHubChecksTabDetails, createGitHubChecksTabState, + resetGitHubChecksTabForSource, resolveGitHubChecksTabState, + settleGitHubChecksTabDetails, toggleGitHubChecksTabExpandedKey, - updateGitHubChecksTabDetails, updateGitHubChecksTabLocalChecks, type CheckDetailsLoadState } from '@/components/github-checks-tab-state' @@ -113,10 +115,12 @@ import { import { filterPRCommentsByAudience, getPRCommentAudienceCounts, - getPRCommentAudienceEmptyLabel, - getPrCommentAudienceFilters, type PRCommentAudienceFilter -} from '@/lib/pr-comment-audience' +} from '../../../shared/pr-comment-audience' +import { + getPRCommentAudienceEmptyLabel, + getPrCommentAudienceFilters +} from '@/lib/pr-comment-audience-labels' import { usePRBotAuthorOverrides } from '@/lib/pr-bot-author-overrides' import { getPRCommentGroupCount, @@ -124,11 +128,13 @@ import { getPRCommentGroupRoot, groupPRComments, isResolvedPRCommentGroup, + type PRCommentGroup +} from '../../../shared/pr-comment-groups' +import { PR_COMMENT_OPEN_AUTHOR_CLASS, PR_COMMENT_RESOLVED_AUTHOR_CLASS, - PR_COMMENT_RESOLVED_CONTAINER_CLASS, - type PRCommentGroup -} from '@/lib/pr-comment-groups' + PR_COMMENT_RESOLVED_CONTAINER_CLASS +} from '@/lib/pr-comment-resolution-classes' import { getCommentReplyTargetCandidates, resolveCommentReplyTarget @@ -141,6 +147,7 @@ import { buildPRCommentConversationReplyBody } from '@/components/right-sidebar/ import { useAppStore } from '@/store' import { useAllWorktrees } from '@/store/selectors' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { withGitHubCheckDetailsTimeout } from '@/runtime/github-check-details-timeout' import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/useIssueMetadata' import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' import { GitHubMarkdownComposer } from '@/components/github/GitHubMarkdownComposer' @@ -3215,19 +3222,46 @@ function ChecksTab({ variant?: 'compact' | 'page' onChecksUpdated: (checks: PRCheckDetail[]) => void }): React.JSX.Element { - const [refreshing, setRefreshing] = useState(false) - const [rerunning, setRerunning] = useState(false) const [fixingChecks, setFixingChecks] = useState(false) - const [checksState, setChecksState] = useState(() => createGitHubChecksTabState(checks)) const mountedRef = useMountedRef() - const resolvedChecksState = resolveGitHubChecksTabState(checksState, checks) + const prRepo = useMemo(() => resolvePullRequestRepo(item), [item]) + const nextCheckDetailsRequestIdRef = useRef(0) + const checkDetailsContextKey = [ + sourceContext ? getTaskSourceCacheScope(sourceContext) : 'local', + repoId ?? item.repoId ?? '', + repoPath ?? '', + prRepo ? githubRepoIdentityKey(prRepo) : '', + item.id, + item.number, + headSha ?? '' + ].join('\0') + const [checksState, setChecksState] = useState(() => + createGitHubChecksTabState(checks, checkDetailsContextKey) + ) + const resolvedChecksState = resolveGitHubChecksTabState( + checksState, + checks, + checkDetailsContextKey + ) + const committedChecksContextOwnerRef = useRef(resolvedChecksState.contextOwner) + const nextChecksRefreshRequestIdRef = useRef(0) + const activeChecksRefreshRequestIdRef = useRef(null) + const [refreshingOwner, setRefreshingOwner] = useState<{ + contextOwner: object + requestId: number + } | null>(null) + const refreshing = refreshingOwner?.contextOwner === resolvedChecksState.contextOwner + const [rerunningOwner, setRerunningOwner] = useState(null) + const rerunning = rerunningOwner === resolvedChecksState.contextOwner + useLayoutEffect(() => { + committedChecksContextOwnerRef.current = resolvedChecksState.contextOwner + }, [resolvedChecksState.contextOwner]) if (resolvedChecksState !== checksState) { // Why: a parent check refresh replaces the source list; reset local state before stale rows/details can paint. setChecksState(resolvedChecksState) } const { localChecks, expandedCheckKey, detailsByCheckKey } = resolvedChecksState const list = useMemo(() => localChecks ?? checks ?? [], [checks, localChecks]) - const prRepo = useMemo(() => resolvePullRequestRepo(item), [item]) const runtimeHost = getGitHubSourceRuntimeHost(sourceContext) const canUseChecksRepoContext = canUseGitHubRepoContext(repoPath, sourceContext) const sorted = sortChecksBySeverity(list) @@ -3258,72 +3292,107 @@ function ChecksTab({ : 'text-muted-foreground' const canFixBrokenChecks = Boolean((repoId ?? item.repoId) && failedChecks.length > 0) - const handleRefresh = useCallback(async (): Promise => { - if (!canUseChecksRepoContext) { - toast.error( - translate( - 'auto.components.GitHubItemDialog.e7007aa1d8', - 'Unable to refresh checks without a repository path.' + const handleRefresh = useCallback( + async (expectedContextOwner?: object): Promise => { + if (!canUseChecksRepoContext) { + toast.error( + translate( + 'auto.components.GitHubItemDialog.e7007aa1d8', + 'Unable to refresh checks without a repository path.' + ) ) - ) - return null - } - setRefreshing(true) - try { - const nextChecks = (await (runtimeHost - ? callRuntimeRpc( - { kind: 'environment', environmentId: runtimeHost.environmentId }, - 'github.prChecks', - { - repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), + return null + } + const refreshContextOwner = expectedContextOwner ?? committedChecksContextOwnerRef.current + if (committedChecksContextOwnerRef.current !== refreshContextOwner) { + return null + } + const refreshRequestId = ++nextChecksRefreshRequestIdRef.current + activeChecksRefreshRequestIdRef.current = refreshRequestId + setRefreshingOwner({ contextOwner: refreshContextOwner, requestId: refreshRequestId }) + try { + const nextChecks = (await (runtimeHost + ? callRuntimeRpc( + { kind: 'environment', environmentId: runtimeHost.environmentId }, + 'github.prChecks', + { + repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), + prNumber: item.number, + headSha, + prRepo, + noCache: true + }, + { timeoutMs: 30_000 } + ) + : window.api.gh.prChecks({ + repoPath: repoPath ?? '', + repoId: repoId ?? undefined, + sourceContext, prNumber: item.number, headSha, prRepo, noCache: true - }, - { timeoutMs: 30_000 } + }))) as PRCheckDetail[] + if ( + !mountedRef.current || + committedChecksContextOwnerRef.current !== refreshContextOwner || + activeChecksRefreshRequestIdRef.current !== refreshRequestId + ) { + return null + } + setChecksState((current) => + current.contextOwner === refreshContextOwner + ? updateGitHubChecksTabLocalChecks(resetGitHubChecksTabForSource(current), nextChecks) + : current + ) + onChecksUpdated(nextChecks) + return nextChecks + } catch (err) { + if ( + mountedRef.current && + committedChecksContextOwnerRef.current === refreshContextOwner && + activeChecksRefreshRequestIdRef.current === refreshRequestId + ) { + toast.error( + err instanceof Error + ? err.message + : translate('auto.components.GitHubItemDialog.0bbdc673c1', 'Failed to refresh checks') ) - : window.api.gh.prChecks({ - repoPath: repoPath ?? '', - repoId: repoId ?? undefined, - sourceContext, - prNumber: item.number, - headSha, - prRepo, - noCache: true - }))) as PRCheckDetail[] - setChecksState((current) => updateGitHubChecksTabLocalChecks(current, nextChecks)) - onChecksUpdated(nextChecks) - return nextChecks - } catch (err) { - toast.error( - err instanceof Error - ? err.message - : translate('auto.components.GitHubItemDialog.0bbdc673c1', 'Failed to refresh checks') - ) - return null - } finally { - setRefreshing(false) - } - }, [ - canUseChecksRepoContext, - headSha, - item.number, - item.repoId, - onChecksUpdated, - runtimeHost, - prRepo, - repoId, - repoPath, - sourceContext - ]) + } + return null + } finally { + if (activeChecksRefreshRequestIdRef.current === refreshRequestId) { + activeChecksRefreshRequestIdRef.current = null + } + if (mountedRef.current) { + setRefreshingOwner((current) => + current?.requestId === refreshRequestId ? null : current + ) + } + } + }, + [ + canUseChecksRepoContext, + headSha, + item.number, + item.repoId, + mountedRef, + onChecksUpdated, + runtimeHost, + prRepo, + repoId, + repoPath, + sourceContext + ] + ) const handleRerun = useCallback( async (failedOnly: boolean): Promise => { if (!canUseChecksRepoContext || rerunning) { return } - setRerunning(true) + const rerunContextOwner = committedChecksContextOwnerRef.current + setRerunningOwner(rerunContextOwner) try { const result = runtimeHost ? await callRuntimeRpc>>( @@ -3347,6 +3416,9 @@ function ChecksTab({ failedOnly, prRepo }) + if (!mountedRef.current || committedChecksContextOwnerRef.current !== rerunContextOwner) { + return + } if (!result.ok) { toast.error(result.error) return @@ -3356,15 +3428,19 @@ function ChecksTab({ ? translate('auto.components.GitHubItemDialog.ddafe851e1', 'Check rerun requested') : translate('auto.components.GitHubItemDialog.e463ec935f', 'Check reruns requested') ) - await handleRefresh() + await handleRefresh(rerunContextOwner) } catch (err) { - toast.error( - err instanceof Error - ? err.message - : translate('auto.components.GitHubItemDialog.9e7c221b8d', 'Failed to rerun checks') - ) + if (mountedRef.current && committedChecksContextOwnerRef.current === rerunContextOwner) { + toast.error( + err instanceof Error + ? err.message + : translate('auto.components.GitHubItemDialog.9e7c221b8d', 'Failed to rerun checks') + ) + } } finally { - setRerunning(false) + if (mountedRef.current) { + setRerunningOwner((current) => (current === rerunContextOwner ? null : current)) + } } }, [ @@ -3373,6 +3449,7 @@ function ChecksTab({ headSha, item.number, item.repoId, + mountedRef, prRepo, runtimeHost, rerunning, @@ -3441,77 +3518,74 @@ function ChecksTab({ } }, [failedChecks.length, fixingChecks, item, list, repoId]) - const handleToggleCheckDetails = useCallback( - (check: PRCheckDetail): void => { - const key = getCheckDetailsKey(check) - setChecksState((current) => toggleGitHubChecksTabExpandedKey(current, key)) - if ( - !canUseChecksRepoContext || - detailsByCheckKey[key] || - (!check.checkRunId && !check.workflowRunId && !check.url) - ) { + const requestCheckDetails = useCallback( + (check: PRCheckDetail, key: string): void => { + if (!canUseChecksRepoContext || (!check.checkRunId && !check.workflowRunId && !check.url)) { return } - setChecksState((current) => - updateGitHubChecksTabDetails(current, key, { - loading: true, - details: null, - error: null - }) - ) - const detailsRequest = runtimeHost - ? callRuntimeRpc>>( - { kind: 'environment', environmentId: runtimeHost.environmentId }, - 'github.prCheckDetails', - { - repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), + const requestId = ++nextCheckDetailsRequestIdRef.current + const commit = (next: Omit): void => { + if (!mountedRef.current) { + return + } + setChecksState((current) => settleGitHubChecksTabDetails(current, key, requestId, next)) + } + setChecksState((current) => beginGitHubChecksTabDetails(current, key, requestId)) + const detailsRequest = withGitHubCheckDetailsTimeout((signal) => + runtimeHost + ? callRuntimeRpc>>( + { kind: 'environment', environmentId: runtimeHost.environmentId }, + 'github.prCheckDetails', + { + repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), + checkRunId: check.checkRunId, + workflowRunId: check.workflowRunId, + checkName: check.name, + url: check.url, + prRepo + }, + { timeoutMs: 30_000, signal } + ) + : window.api.gh.prCheckDetails({ + repoPath: repoPath ?? '', + repoId: repoId ?? undefined, + sourceContext, checkRunId: check.checkRunId, workflowRunId: check.workflowRunId, checkName: check.name, url: check.url, prRepo - }, - { timeoutMs: 30_000 } - ) - : window.api.gh.prCheckDetails({ - repoPath: repoPath ?? '', - repoId: repoId ?? undefined, - sourceContext, - checkRunId: check.checkRunId, - workflowRunId: check.workflowRunId, - checkName: check.name, - url: check.url, - prRepo - }) + }) + ) void detailsRequest .then((details) => { - if (!mountedRef.current) { - return - } - setChecksState((current) => - updateGitHubChecksTabDetails(current, key, { - loading: false, - details, - error: details ? null : 'No inline details are available for this check.' - }) - ) + commit({ + loading: false, + details, + error: details + ? null + : translate( + 'auto.components.GitHubItemDialog.e15a8b77ef', + 'No inline details are available for this check.' + ) + }) }) .catch((err) => { - if (!mountedRef.current) { - return - } - setChecksState((current) => - updateGitHubChecksTabDetails(current, key, { - loading: false, - details: null, - error: err instanceof Error ? err.message : 'Failed to load check details.' - }) - ) + commit({ + loading: false, + details: null, + error: + err instanceof Error + ? err.message + : translate( + 'auto.components.GitHubItemDialog.e45324fbed', + 'Failed to load check details.' + ) + }) }) }, [ canUseChecksRepoContext, - detailsByCheckKey, item.repoId, mountedRef, runtimeHost, @@ -3522,6 +3596,18 @@ function ChecksTab({ ] ) + const handleToggleCheckDetails = useCallback( + (check: PRCheckDetail): void => { + const key = getCheckDetailsKey(check) + setChecksState((current) => toggleGitHubChecksTabExpandedKey(current, key)) + if (detailsByCheckKey[key]) { + return + } + requestCheckDetails(check, key) + }, + [detailsByCheckKey, requestCheckDetails] + ) + const refreshAction = ( @@ -3715,7 +3801,7 @@ function ChecksTab({ return (
- {state?.loading ? ( + {state?.loading && !state.error ? (
{translate('auto.components.GitHubItemDialog.934d87ab96', 'Loading check details…')} @@ -3746,7 +3832,27 @@ function ChecksTab({ )}
- {state?.error &&
{state.error}
} + {state?.error && ( +
+ + {state.error} + + +
+ )} {hasOutput && (
diff --git a/src/renderer/src/components/Landing.tsx b/src/renderer/src/components/Landing.tsx index 2d4b392b71b..bcf5c66ec1f 100644 --- a/src/renderer/src/components/Landing.tsx +++ b/src/renderer/src/components/Landing.tsx @@ -149,7 +149,7 @@ function PreflightBanner({ repos }: { issues: PreflightIssue[] - repos: Repo[] + repos: readonly Repo[] }): React.JSX.Element | null { // Why: keying the seed on the current GitHub project set means adding a new // GitHub project (which changes the key) re-evaluates dismissals, so a lapsed diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index bc1691f293a..b9683150d17 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -84,7 +84,7 @@ type NewWorkspaceComposerCardProps = { nameInputRef?: React.RefObject quickAgent: TuiAgent | null onQuickAgentChange: (agent: TuiAgent | null) => void - eligibleRepos: RepoOption[] + eligibleRepos: readonly RepoOption[] repoId: string projectOptions?: NewWorkspaceProjectOption[] selectedProjectId?: string | null @@ -98,7 +98,7 @@ type NewWorkspaceComposerCardProps = { selectedEphemeralVmRecipeId?: string | null onEphemeralVmRecipeChange?: (recipeId: string | null) => void ephemeralVmRecipeError?: string | null - repoBackedSearchRepos?: RepoOption[] + repoBackedSearchRepos?: readonly RepoOption[] repoBackedSourcesDisabled?: boolean allowSmartNameAddProject?: boolean smartNameRepoSwitchTarget?: 'project' | 'task-source' diff --git a/src/renderer/src/components/PullRequestPage.tsx b/src/renderer/src/components/PullRequestPage.tsx index 4b46d0148e5..69969b5f3bd 100644 --- a/src/renderer/src/components/PullRequestPage.tsx +++ b/src/renderer/src/components/PullRequestPage.tsx @@ -89,10 +89,12 @@ import { import { CHECK_COLOR, CHECK_ICON } from '@/components/right-sidebar/checks-panel-content' import { SourceControlAgentActionDialog } from '@/components/right-sidebar/SourceControlAgentActionDialog' import { + beginGitHubChecksTabDetails, createGitHubChecksTabState, + resetGitHubChecksTabForSource, resolveGitHubChecksTabState, + settleGitHubChecksTabDetails, toggleGitHubChecksTabExpandedKey, - updateGitHubChecksTabDetails, updateGitHubChecksTabLocalChecks, type CheckDetailsLoadState } from '@/components/github-checks-tab-state' @@ -109,10 +111,12 @@ import { import { filterPRCommentsByAudience, getPRCommentAudienceCounts, - getPRCommentAudienceEmptyLabel, - getPrCommentAudienceFilters, type PRCommentAudienceFilter -} from '@/lib/pr-comment-audience' +} from '../../../shared/pr-comment-audience' +import { + getPRCommentAudienceEmptyLabel, + getPrCommentAudienceFilters +} from '@/lib/pr-comment-audience-labels' import { usePRBotAuthorOverrides } from '@/lib/pr-bot-author-overrides' import { getPRCommentGroupCount, @@ -120,11 +124,13 @@ import { getPRCommentGroupRoot, groupPRComments, isResolvedPRCommentGroup, + type PRCommentGroup +} from '../../../shared/pr-comment-groups' +import { PR_COMMENT_OPEN_AUTHOR_CLASS, PR_COMMENT_RESOLVED_AUTHOR_CLASS, - PR_COMMENT_RESOLVED_CONTAINER_CLASS, - type PRCommentGroup -} from '@/lib/pr-comment-groups' + PR_COMMENT_RESOLVED_CONTAINER_CLASS +} from '@/lib/pr-comment-resolution-classes' import { resolveCommentReplyTarget } from '@/components/comment-reply-target-state' import { attachPRReviewReplyParent, @@ -134,6 +140,7 @@ import { buildPRCommentConversationReplyBody } from '@/components/right-sidebar/ import { useAppStore } from '@/store' import { useAllWorktrees } from '@/store/selectors' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { withGitHubCheckDetailsTimeout } from '@/runtime/github-check-details-timeout' import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/useIssueMetadata' import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' import { @@ -3217,13 +3224,41 @@ function ChecksTab({ const repo = useAppStore((s) => targetRepoId ? (s.repos.find((candidate) => candidate.id === targetRepoId) ?? null) : null ) - const [refreshing, setRefreshing] = useState(false) - const [rerunning, setRerunning] = useState(false) const [fixingChecks, setFixingChecks] = useState(false) const [fixChecksComposerPrompt, setFixChecksComposerPrompt] = useState(null) - const [checksState, setChecksState] = useState(() => createGitHubChecksTabState(checks)) const mountedRef = useMountedRef() - const resolvedChecksState = resolveGitHubChecksTabState(checksState, checks) + const prRepo = useMemo(() => resolvePullRequestRepo(item), [item]) + const nextCheckDetailsRequestIdRef = useRef(0) + const checkDetailsContextKey = [ + sourceContext ? getTaskSourceCacheScope(sourceContext) : 'local', + repoId ?? item.repoId ?? '', + repoPath ?? '', + prRepo ? githubRepoIdentityKey(prRepo) : '', + item.id, + item.number, + headSha ?? '' + ].join('\0') + const [checksState, setChecksState] = useState(() => + createGitHubChecksTabState(checks, checkDetailsContextKey) + ) + const resolvedChecksState = resolveGitHubChecksTabState( + checksState, + checks, + checkDetailsContextKey + ) + const committedChecksContextOwnerRef = useRef(resolvedChecksState.contextOwner) + const nextChecksRefreshRequestIdRef = useRef(0) + const activeChecksRefreshRequestIdRef = useRef(null) + const [refreshingOwner, setRefreshingOwner] = useState<{ + contextOwner: object + requestId: number + } | null>(null) + const refreshing = refreshingOwner?.contextOwner === resolvedChecksState.contextOwner + const [rerunningOwner, setRerunningOwner] = useState(null) + const rerunning = rerunningOwner === resolvedChecksState.contextOwner + useLayoutEffect(() => { + committedChecksContextOwnerRef.current = resolvedChecksState.contextOwner + }, [resolvedChecksState.contextOwner]) if (resolvedChecksState !== checksState) { // Why: reconcile before paint when a parent check refresh replaces the source list, so stale rows/details never show. setChecksState(resolvedChecksState) @@ -3317,7 +3352,6 @@ function ChecksTab({ }, [item, targetRepoId] ) - const prRepo = useMemo(() => resolvePullRequestRepo(item), [item]) const runtimeHost = getGitHubSourceRuntimeHost(sourceContext) const canUseChecksRepoContext = canUseGitHubRepoContext(repoPath, sourceContext) const sorted = sortChecksBySeverity(list) @@ -3348,72 +3382,107 @@ function ChecksTab({ : 'text-muted-foreground' const canFixBrokenChecks = Boolean((repoId ?? item.repoId) && failedChecks.length > 0) - const handleRefresh = useCallback(async (): Promise => { - if (!canUseChecksRepoContext) { - toast.error( - translate( - 'auto.components.PullRequestPage.c057f2fcb0', - 'Unable to refresh checks without a repository path.' + const handleRefresh = useCallback( + async (expectedContextOwner?: object): Promise => { + if (!canUseChecksRepoContext) { + toast.error( + translate( + 'auto.components.PullRequestPage.c057f2fcb0', + 'Unable to refresh checks without a repository path.' + ) ) - ) - return null - } - setRefreshing(true) - try { - const nextChecks = (await (runtimeHost - ? callRuntimeRpc( - { kind: 'environment', environmentId: runtimeHost.environmentId }, - 'github.prChecks', - { - repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), + return null + } + const refreshContextOwner = expectedContextOwner ?? committedChecksContextOwnerRef.current + if (committedChecksContextOwnerRef.current !== refreshContextOwner) { + return null + } + const refreshRequestId = ++nextChecksRefreshRequestIdRef.current + activeChecksRefreshRequestIdRef.current = refreshRequestId + setRefreshingOwner({ contextOwner: refreshContextOwner, requestId: refreshRequestId }) + try { + const nextChecks = (await (runtimeHost + ? callRuntimeRpc( + { kind: 'environment', environmentId: runtimeHost.environmentId }, + 'github.prChecks', + { + repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), + prNumber: item.number, + headSha, + prRepo, + noCache: true + }, + { timeoutMs: 30_000 } + ) + : window.api.gh.prChecks({ + repoPath: repoPath ?? '', + repoId: repoId ?? undefined, + sourceContext, prNumber: item.number, headSha, prRepo, noCache: true - }, - { timeoutMs: 30_000 } + }))) as PRCheckDetail[] + if ( + !mountedRef.current || + committedChecksContextOwnerRef.current !== refreshContextOwner || + activeChecksRefreshRequestIdRef.current !== refreshRequestId + ) { + return null + } + setChecksState((current) => + current.contextOwner === refreshContextOwner + ? updateGitHubChecksTabLocalChecks(resetGitHubChecksTabForSource(current), nextChecks) + : current + ) + onChecksUpdated(nextChecks) + return nextChecks + } catch (err) { + if ( + mountedRef.current && + committedChecksContextOwnerRef.current === refreshContextOwner && + activeChecksRefreshRequestIdRef.current === refreshRequestId + ) { + toast.error( + err instanceof Error + ? err.message + : translate('auto.components.PullRequestPage.246b2c6456', 'Failed to refresh checks') ) - : window.api.gh.prChecks({ - repoPath: repoPath ?? '', - repoId: repoId ?? undefined, - sourceContext, - prNumber: item.number, - headSha, - prRepo, - noCache: true - }))) as PRCheckDetail[] - setChecksState((current) => updateGitHubChecksTabLocalChecks(current, nextChecks)) - onChecksUpdated(nextChecks) - return nextChecks - } catch (err) { - toast.error( - err instanceof Error - ? err.message - : translate('auto.components.PullRequestPage.246b2c6456', 'Failed to refresh checks') - ) - return null - } finally { - setRefreshing(false) - } - }, [ - canUseChecksRepoContext, - headSha, - item.number, - item.repoId, - onChecksUpdated, - runtimeHost, - prRepo, - repoId, - repoPath, - sourceContext - ]) + } + return null + } finally { + if (activeChecksRefreshRequestIdRef.current === refreshRequestId) { + activeChecksRefreshRequestIdRef.current = null + } + if (mountedRef.current) { + setRefreshingOwner((current) => + current?.requestId === refreshRequestId ? null : current + ) + } + } + }, + [ + canUseChecksRepoContext, + headSha, + item.number, + item.repoId, + mountedRef, + onChecksUpdated, + runtimeHost, + prRepo, + repoId, + repoPath, + sourceContext + ] + ) const handleRerun = useCallback( async (failedOnly: boolean): Promise => { if (!canUseChecksRepoContext || rerunning) { return } - setRerunning(true) + const rerunContextOwner = committedChecksContextOwnerRef.current + setRerunningOwner(rerunContextOwner) try { const result = runtimeHost ? await callRuntimeRpc>>( @@ -3437,6 +3506,9 @@ function ChecksTab({ failedOnly, prRepo }) + if (!mountedRef.current || committedChecksContextOwnerRef.current !== rerunContextOwner) { + return + } if (!result.ok) { toast.error(result.error) return @@ -3446,15 +3518,19 @@ function ChecksTab({ ? translate('auto.components.PullRequestPage.5963a6a852', 'Check rerun requested') : translate('auto.components.PullRequestPage.18f2af42ac', 'Check reruns requested') ) - await handleRefresh() + await handleRefresh(rerunContextOwner) } catch (err) { - toast.error( - err instanceof Error - ? err.message - : translate('auto.components.PullRequestPage.788a782bb0', 'Failed to rerun checks') - ) + if (mountedRef.current && committedChecksContextOwnerRef.current === rerunContextOwner) { + toast.error( + err instanceof Error + ? err.message + : translate('auto.components.PullRequestPage.788a782bb0', 'Failed to rerun checks') + ) + } } finally { - setRerunning(false) + if (mountedRef.current) { + setRerunningOwner((current) => (current === rerunContextOwner ? null : current)) + } } }, [ @@ -3463,6 +3539,7 @@ function ChecksTab({ headSha, item.number, item.repoId, + mountedRef, prRepo, runtimeHost, rerunning, @@ -3525,77 +3602,74 @@ function ChecksTab({ } }, [failedChecks.length, fixingChecks, item, list, targetRepoId]) - const handleToggleCheckDetails = useCallback( - (check: PRCheckDetail): void => { - const key = getCheckDetailsKey(check) - setChecksState((current) => toggleGitHubChecksTabExpandedKey(current, key)) - if ( - !canUseChecksRepoContext || - detailsByCheckKey[key] || - (!check.checkRunId && !check.workflowRunId && !check.url) - ) { + const requestCheckDetails = useCallback( + (check: PRCheckDetail, key: string): void => { + if (!canUseChecksRepoContext || (!check.checkRunId && !check.workflowRunId && !check.url)) { return } - setChecksState((current) => - updateGitHubChecksTabDetails(current, key, { - loading: true, - details: null, - error: null - }) - ) - const detailsRequest = runtimeHost - ? callRuntimeRpc>>( - { kind: 'environment', environmentId: runtimeHost.environmentId }, - 'github.prCheckDetails', - { - repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), + const requestId = ++nextCheckDetailsRequestIdRef.current + const commit = (next: Omit): void => { + if (!mountedRef.current) { + return + } + setChecksState((current) => settleGitHubChecksTabDetails(current, key, requestId, next)) + } + setChecksState((current) => beginGitHubChecksTabDetails(current, key, requestId)) + const detailsRequest = withGitHubCheckDetailsTimeout((signal) => + runtimeHost + ? callRuntimeRpc>>( + { kind: 'environment', environmentId: runtimeHost.environmentId }, + 'github.prCheckDetails', + { + repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), + checkRunId: check.checkRunId, + workflowRunId: check.workflowRunId, + checkName: check.name, + url: check.url, + prRepo + }, + { timeoutMs: 30_000, signal } + ) + : window.api.gh.prCheckDetails({ + repoPath: repoPath ?? '', + repoId: repoId ?? undefined, + sourceContext, checkRunId: check.checkRunId, workflowRunId: check.workflowRunId, checkName: check.name, url: check.url, prRepo - }, - { timeoutMs: 30_000 } - ) - : window.api.gh.prCheckDetails({ - repoPath: repoPath ?? '', - repoId: repoId ?? undefined, - sourceContext, - checkRunId: check.checkRunId, - workflowRunId: check.workflowRunId, - checkName: check.name, - url: check.url, - prRepo - }) + }) + ) void detailsRequest .then((details) => { - if (!mountedRef.current) { - return - } - setChecksState((current) => - updateGitHubChecksTabDetails(current, key, { - loading: false, - details, - error: details ? null : 'No inline details are available for this check.' - }) - ) + commit({ + loading: false, + details, + error: details + ? null + : translate( + 'auto.components.PullRequestPage.6b1d5ee3e4', + 'No inline details are available for this check.' + ) + }) }) .catch((err) => { - if (!mountedRef.current) { - return - } - setChecksState((current) => - updateGitHubChecksTabDetails(current, key, { - loading: false, - details: null, - error: err instanceof Error ? err.message : 'Failed to load check details.' - }) - ) + commit({ + loading: false, + details: null, + error: + err instanceof Error + ? err.message + : translate( + 'auto.components.PullRequestPage.e04c027d98', + 'Failed to load check details.' + ) + }) }) }, [ canUseChecksRepoContext, - detailsByCheckKey, item.repoId, mountedRef, runtimeHost, @@ -3606,6 +3680,18 @@ function ChecksTab({ ] ) + const handleToggleCheckDetails = useCallback( + (check: PRCheckDetail): void => { + const key = getCheckDetailsKey(check) + setChecksState((current) => toggleGitHubChecksTabExpandedKey(current, key)) + if (detailsByCheckKey[key]) { + return + } + requestCheckDetails(check, key) + }, + [detailsByCheckKey, requestCheckDetails] + ) + const refreshAction = ( @@ -3799,7 +3885,7 @@ function ChecksTab({ return (
- {state?.loading ? ( + {state?.loading && !state.error ? (
{translate('auto.components.PullRequestPage.d8e82b7f15', 'Loading check details…')} @@ -3830,7 +3916,27 @@ function ChecksTab({ )}
- {state?.error &&
{state.error}
} + {state?.error && ( +
+
+ {state.error} +
+ +
+ )} {hasOutput && (
diff --git a/src/renderer/src/components/QuickOpen.mount-gating.test.tsx b/src/renderer/src/components/QuickOpen.mount-gating.test.tsx new file mode 100644 index 00000000000..fa0c57dfcf8 --- /dev/null +++ b/src/renderer/src/components/QuickOpen.mount-gating.test.tsx @@ -0,0 +1,143 @@ +// @vitest-environment happy-dom + +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import QuickOpen from './QuickOpen' + +const contentProbe = vi.hoisted(() => ({ + enabled: vi.fn(), + renders: vi.fn(), + storeNotifications: vi.fn(), + subscriptions: vi.fn(), + unsubscriptions: vi.fn() +})) + +vi.mock('@/components/quick-open-file-list', async () => { + const React = await import('react') + return { + useRuntimeFileListForWorktree: ({ enabled }: { enabled: boolean }) => { + contentProbe.enabled(enabled) + contentProbe.renders() + React.useEffect(() => { + contentProbe.subscriptions() + const unsubscribe = useAppStore.subscribe(() => contentProbe.storeNotifications()) + return () => { + contentProbe.unsubscriptions() + unsubscribe() + } + }, []) + return { files: [], loading: false, loadError: null } + } + } +}) + +vi.mock('@/hooks/useModalReturnFocus', () => ({ + useModalReturnFocus: () => ({ captureReturnFocus: vi.fn(), skipReturnFocus: vi.fn() }) +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('@/components/ui/command', () => { + return { + CommandDialog: ({ children, open }: { children: ReactNode; open?: boolean }) => + open ?
{children}
: null, + CommandInput: () => , + CommandList: ({ children }: { children: ReactNode }) =>
{children}
, + CommandEmpty: ({ children }: { children: ReactNode }) =>
{children}
, + CommandItem: ({ children }: { children: ReactNode }) =>
{children}
+ } +}) + +const initialAppState = useAppStore.getInitialState() +let testContainer: HTMLDivElement +let testRoot: Root + +async function flushEffects(): Promise { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +function activeContentSubscriptions(): number { + return ( + contentProbe.subscriptions.mock.calls.length - contentProbe.unsubscriptions.mock.calls.length + ) +} + +async function churnClosedStore(count: number): Promise { + await act(async () => { + for (let index = 0; index < count; index += 1) { + useAppStore.setState({ activeWorktreeId: `remote-background-${index}` }) + } + }) +} + +describe('QuickOpen mount gating', () => { + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + contentProbe.enabled.mockClear() + contentProbe.renders.mockClear() + contentProbe.storeNotifications.mockClear() + contentProbe.subscriptions.mockClear() + contentProbe.unsubscriptions.mockClear() + useAppStore.setState(initialAppState, true) + useAppStore.setState({ activeModal: 'quick-open', activeWorktreeId: null }) + testContainer = document.createElement('div') + document.body.appendChild(testContainer) + testRoot = createRoot(testContainer) + }) + + afterEach(async () => { + await act(async () => testRoot.unmount()) + document.body.replaceChildren() + useAppStore.setState(initialAppState, true) + vi.clearAllTimers() + vi.useRealTimers() + }) + + it('unmounts subscribed content after the close linger', async () => { + await act(async () => testRoot.render()) + await flushEffects() + + expect(testContainer.querySelector('[data-command-dialog="true"]')).not.toBeNull() + expect(activeContentSubscriptions()).toBe(1) + + await act(async () => useAppStore.setState({ activeModal: 'none' })) + + expect(testContainer.querySelector('[data-command-dialog="true"]')).toBeNull() + expect(contentProbe.enabled).toHaveBeenLastCalledWith(false) + expect(activeContentSubscriptions()).toBe(1) + + await act(async () => vi.advanceTimersByTimeAsync(299)) + expect(activeContentSubscriptions()).toBe(1) + + await act(async () => vi.advanceTimersByTimeAsync(1)) + expect(activeContentSubscriptions()).toBe(0) + + const rendersAfterUnmount = contentProbe.renders.mock.calls.length + const notificationsAfterUnmount = contentProbe.storeNotifications.mock.calls.length + await churnClosedStore(1_000) + + expect(contentProbe.renders).toHaveBeenCalledTimes(rendersAfterUnmount) + expect(contentProbe.storeNotifications).toHaveBeenCalledTimes(notificationsAfterUnmount) + }) + + it('cancels the pending unmount when reopened during the linger', async () => { + await act(async () => testRoot.render()) + await flushEffects() + + await act(async () => useAppStore.setState({ activeModal: 'none' })) + await act(async () => vi.advanceTimersByTimeAsync(299)) + await act(async () => useAppStore.setState({ activeModal: 'quick-open' })) + await act(async () => vi.advanceTimersByTimeAsync(1_000)) + + expect(testContainer.querySelector('[data-command-dialog="true"]')).not.toBeNull() + expect(activeContentSubscriptions()).toBe(1) + }) +}) diff --git a/src/renderer/src/components/QuickOpen.tsx b/src/renderer/src/components/QuickOpen.tsx index 05e2613ce0d..cb7c8847642 100644 --- a/src/renderer/src/components/QuickOpen.tsx +++ b/src/renderer/src/components/QuickOpen.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useDeferredValue, useMemo, useState } from 'react' +import React, { useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react' import { useAppStore } from '@/store' import { useActiveWorktree } from '@/store/selectors' import { detectLanguage } from '@/lib/language-detect' @@ -21,6 +21,8 @@ import { QuickOpenInstallRgGuidance } from '@/components/quick-open-install-rg-guidance' +const QUICK_OPEN_CLOSE_LINGER_MS = 300 + function FooterKey({ children }: { children: React.ReactNode }): React.JSX.Element { return ( @@ -31,6 +33,24 @@ function FooterKey({ children }: { children: React.ReactNode }): React.JSX.Eleme export default function QuickOpen(): React.JSX.Element | null { const visible = useAppStore((s) => s.activeModal === 'quick-open') + const [lingering, setLingering] = useState(visible) + useEffect(() => { + if (visible) { + setLingering(true) + return + } + // Why: keep scan cancellation and the dialog exit animation mounted before releasing remote file state. + const timer = window.setTimeout(() => setLingering(false), QUICK_OPEN_CLOSE_LINGER_MS) + return () => window.clearTimeout(timer) + }, [visible]) + + if (!visible && !lingering) { + return null + } + return +} + +function QuickOpenContent({ visible }: { visible: boolean }): React.JSX.Element { const closeModal = useAppStore((s) => s.closeModal) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const openFile = useAppStore((s) => s.openFile) diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index dc39c3a0644..a8938a4e507 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -30,19 +30,14 @@ import { ORCA_EDITOR_REQUEST_FILE_CLOSE_EVENT, ORCA_EDITOR_SAVE_AND_CLOSE_EVENT, ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT, + type EditorRequestCmdSaveDetail, type EditorRequestFileCloseDetail, requestEditorSaveQuiesce } from './editor/editor-autosave' import { isIntentionalAppRestartInProgress } from '@/lib/updater-beforeunload' import { preventUnloadAndScheduleShutdownCheckpointReset } from '@/lib/shutdown-checkpoint-guard' import EditorAutosaveController from './editor/EditorAutosaveController' -import type { - Tab, - TabContentType, - TabGroupLayoutNode, - TerminalTab, - TuiAgent -} from '../../../shared/types' +import type { Tab, TabGroupLayoutNode, TerminalTab, TuiAgent } from '../../../shared/types' import { hasFeatureInteraction } from '../../../shared/feature-interactions' import BrowserPane from './browser-pane/BrowserPane' import { RetainedBrowserPaneOverlayLayer } from './browser-pane/BrowserPaneOverlayLayer' @@ -185,18 +180,12 @@ import { combineTerminalWorktreeParkIds, useManualTerminalWorktreeParking } from './terminal-pane/use-manual-terminal-worktree-parking' +import { EDITOR_TAB_CONTENT_TYPES, getEditorCmdSaveFileId } from './editor/editor-cmd-save-target' const EditorPanel = lazy(() => import('./editor/EditorPanel')) // Why: gate handler runs after a dialog advances so a stray carry-over click can't act on the next dialog; ~200ms absorbs a physical double-click while staying responsive. const CLOSE_DIALOG_DEBOUNCE_MS = 200 -const EDITOR_TAB_CONTENT_TYPES = new Set([ - 'editor', - 'diff', - 'conflict-review', - 'check-details' -]) - type TerminalStoreSnapshot = ReturnType function haveSameIdSet(left: ReadonlySet, right: ReadonlySet): boolean { @@ -2077,10 +2066,17 @@ function Terminal(): React.JSX.Element | null { target?.closest('textarea:not(.xterm-helper-textarea), input') !== null if (!inEditor) { const state = useAppStore.getState() - if (state.activeTabType === 'editor' && state.activeFileId) { + const floatingPanelOwnsEvent = + isEventTargetInsideFloatingWorkspacePanel(e.target) || floatingWorkspaceFocused + const requestedFileId = getEditorCmdSaveFileId(state, floatingPanelOwnsEvent) + if (requestedFileId) { e.preventDefault() notifyTerminalCapture('editor.save') - window.dispatchEvent(new Event(ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT)) + window.dispatchEvent( + new CustomEvent(ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT, { + detail: { fileId: requestedFileId } + }) + ) return } } diff --git a/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx b/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx index e263a0db926..3e4037d4761 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx @@ -315,6 +315,28 @@ describe('WorktreeJumpPalette recent chats & terminals', () => { expect(getCommandValue()).toBe('workspace-tab:tab-host') }) + // Why: after typing, arrow moves must stick. Dropping onValueChange while cmdk already + // advanced its internal cursor made the next ArrowDown a no-op (Object.is short-circuit). + it('keeps arrow selection after the typed query ranking has committed', async () => { + await renderPalette(makeTypedRelevanceState()) + + await act(async () => { + setCommandQuery?.('perf') + }) + await flushEffects() + expect(getCommandValue()).toBe('workspace-tab:tab-host') + + const rows = getRenderedRowIds().filter((id) => id.length > 0) + expect(rows.length).toBeGreaterThan(1) + + await act(async () => { + setCommandSelection?.(rows[1]) + }) + await flushEffects() + + expect(getCommandValue()).toBe(rows[1]) + }) + it('keeps worktrees ahead of tabs when a worktree holds the stronger match', async () => { await renderPalette({ ...makeTypedRelevanceState(), diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index b03d7026950..aaccdda3da7 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -54,6 +54,11 @@ import { isDefaultBranchWorkspace, isSleepingSweepExemptWorkspace } from '@/components/sidebar/visible-worktrees' +import { + EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, + getPairedDeviceIdsByEnvironment, + isWorkspaceFromOtherDevice +} from '@/components/sidebar/workspace-creator-visibility' import { getLiveAgentStatusByWorktreeId, isInactiveWorkspace } from '@/lib/worktree-activity-state' import { orderEmptyQueryWorktrees } from '@/lib/order-empty-query-worktrees' import { @@ -265,6 +270,9 @@ const CREATE_WORKSPACE_QUICK_ACTION_ITEM_ID = `quick-action:${CREATE_WORKSPACE_Q // Why: outlast the CommandDialog close animation so its rows do not disappear mid-fade. const PALETTE_CLOSE_LINGER_MS = 300 +// Why `jump-palette-item`: selection chrome lives in main.css — flat accent is invisible on light popovers. +const JUMP_PALETTE_ITEM_CLASSNAME = + 'jump-palette-item group mx-0.5 flex cursor-pointer items-center gap-3 rounded-lg border border-transparent px-3 text-left outline-none transition-[background-color,box-shadow]' type OpenTabPaletteItem = BrowserPaletteItem | SimulatorPaletteItem | WorkspaceTabPaletteItem @@ -615,6 +623,7 @@ function WorktreeJumpPaletteContent({ const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces) const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces) + const hideWorkspacesFromOtherDevices = useAppStore((s) => s.hideWorkspacesFromOtherDevices) const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace) const lastVisitedAtByWorktreeId = useAppStore((s) => s.lastVisitedAtByWorktreeId) @@ -631,7 +640,6 @@ function WorktreeJumpPaletteContent({ const [query, setQuery] = useState('') const deferredQuery = useDeferredValue(query) const [selectedItemId, setSelectedItemId] = useState('') - const latestQueryRef = useRef('') // Why: the id cmdk auto-selected for the last committed list, so a late recent-order snapshot can // tell "nobody has moved the highlight yet" from "the user arrowed somewhere deliberately". const autoSelectedItemIdRef = useRef(null) @@ -748,6 +756,13 @@ function WorktreeJumpPaletteContent({ ), [agentStatusByPaneKey, tabsByWorktree] ) + const pairedDeviceIdsByEnvironment = useMemo( + () => + hideWorkspacesFromOtherDevices + ? getPairedDeviceIdsByEnvironment(runtimeEnvironments, runtimeStatusByEnvironmentId) + : EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, + [hideWorkspacesFromOtherDevices, runtimeEnvironments, runtimeStatusByEnvironmentId] + ) // Why: empty-query mirrors sidebar filters so Search opens on the same quiet list; typed search widens to global non-archived scope. const emptyQueryVisibleWorktrees = useMemo( @@ -773,6 +788,12 @@ function WorktreeJumpPaletteContent({ if (hideDetachedHeadWorkspaces && isDetachedHeadWorkspace(worktree)) { return false } + if ( + hideWorkspacesFromOtherDevices && + isWorkspaceFromOtherDevice(worktree, pairedDeviceIdsByEnvironment) + ) { + return false + } if ( !showSleepingWorkspaces && // Why the exemption here too: Cmd+J re-implements the sidebar's @@ -799,6 +820,8 @@ function WorktreeJumpPaletteContent({ hideCliCreatedWorkspaces, hideDefaultBranchWorkspace, hideDetachedHeadWorkspaces, + hideWorkspacesFromOtherDevices, + pairedDeviceIdsByEnvironment, ptyIdsByTabId, showSleepingWorkspaces, tabsByWorktree, @@ -1850,7 +1873,6 @@ function WorktreeJumpPaletteContent({ ? document.activeElement : null skipRestoreFocusRef.current = false - latestQueryRef.current = '' setQuery('') setSelectedItemId('') // Why: reset on open, not on close — closing races the fade-out, and a @@ -1887,16 +1909,23 @@ function WorktreeJumpPaletteContent({ showCreateAction }) - const handleCommandSelectionChange = useCallback( - (nextItemId: string) => { - // Why: cmdk can report the old list head before the deferred query commits its new ranking. - if (latestQueryRef.current !== deferredQuery) { - return - } - setSelectedItemId(nextItemId) - }, - [deferredQuery] - ) + // Why: cmdk writes its internal cursor *before* calling onValueChange. Dropping that + // callback (e.g. while deferredQuery lags the keystroke) leaves internal state advanced + // while the controlled `value` prop stays put — the next ArrowDown/Up then no-ops on + // cmdk's Object.is guard, so keyboard navigation dies after typing. Always accept the + // report so the cursor stays aligned; the deferred-commit effect below re-auto-selects + // the new ranking head once the list the user sees actually changes. + const handleCommandSelectionChange = useCallback((nextItemId: string) => { + setSelectedItemId(nextItemId) + }, []) + + // Why: while query is mid-deferral, cmdk (and mid-lag arrows) can latch a selection + // against the previous ranking. When the deferred list commits, snap Enter back to the + // new head — matching handleQueryChange's clear, but covering selection that landed + // after the keystroke and before this commit. + useLayoutEffect(() => { + setSelectedItemId('') + }, [deferredQuery]) useEffect(() => { const isCreateWorkspaceHighlighted = @@ -1910,7 +1939,6 @@ function WorktreeJumpPaletteContent({ }, [commandSelectedItemId, prefetchCreateWorkspaceBaseForComposer, visible]) const handleQueryChange = useCallback((nextQuery: string) => { - latestQueryRef.current = nextQuery setQuery(nextQuery) setSelectedItemId('') listRef.current?.scrollTo(0, 0) @@ -2566,7 +2594,7 @@ function WorktreeJumpPaletteContent({ key={entry.id} value={CREATE_WORKTREE_ITEM_ID} onSelect={handleCreateWorktree} - className="group mx-0.5 mt-1 flex cursor-pointer items-center gap-3 rounded-lg border border-transparent px-3 py-1.5 text-left outline-none transition-[background-color,border-color,box-shadow] data-[selected=true]:border-border data-[selected=true]:bg-accent data-[selected=true]:text-foreground" + className={cn(JUMP_PALETTE_ITEM_CLASSNAME, 'mt-1 py-1.5')} >