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 8710b4e82e3..59368273624 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 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 ef39952f641..f8902cfc867 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/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/tsconfig.cli.json b/config/tsconfig.cli.json index 747780e416b..a5d0acd6987 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -75,6 +75,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 c21496b40bc..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 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 c3caa93f12d..17691030acc 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -157,6 +157,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 { MobileMarkdownReader } from '../../../../src/session/MobileMarkdownReader' import { MobileSyntaxSegments } from '../../../../src/components/MobileSyntaxSegments' import { CustomKeyModal, @@ -273,7 +274,6 @@ import { TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND, updateTerminalCwdFromStreamEvent } from '../../../../src/session/mobile-session-route-helpers' -import { MobileMarkdownReader } from '../../../../src/session/MobileMarkdownReader' import { resolveTabStripScrollOffset } from '../../../../src/session/tab-strip-scroll' import { activateOpenedSourceControlDiffTab } from '../../../../src/session/opened-mobile-session-tab' import { 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/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/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 index 9046d50b854..a84599ded3a 100644 --- a/mobile/src/session/MobileMarkdownReader.tsx +++ b/mobile/src/session/MobileMarkdownReader.tsx @@ -1,12 +1,29 @@ -import { useState } from 'react' +import { useRef, useState } from 'react' import { ActivityIndicator, Pressable, Text, View } from 'react-native' -import { RefreshCw } from 'lucide-react-native' -import { MobileRichMarkdownEditor } from '../components/MobileRichMarkdownEditor' +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 } from './markdown-floating-actions-layout' +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, @@ -16,18 +33,13 @@ export function MobileMarkdownReader({ onCopy, onDiscard, keyboardLift -}: { - documentId: string - doc: MarkdownDocState | undefined - onRefresh: () => void - onChange: (content: string) => void - onSave: () => void - onCopy: () => void - onDiscard: () => void - keyboardLift: number -}): React.JSX.Element { +}: 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 ( @@ -54,14 +66,21 @@ export function MobileMarkdownReader({ : 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 + 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 ( ) : 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 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-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-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/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/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/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/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/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/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 9df17412c57..089595e85b7 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) } 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/git/runner-command-exec.test.ts b/src/main/git/runner-command-exec.test.ts index dd24b8c8fb0..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() }) 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.ts b/src/main/git/runner.ts index 2bca4a4e034..264bd7cbfca 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -182,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 @@ -972,10 +1060,18 @@ export async function gitExecFileAsync( signal: options.signal }) } - const resolved = resolveGitCommand(args, options) - const policy = options.useConfiguredSshCommandForNetwork - ? await buildNetworkSshPolicyEnv(options) - : { env: nonInteractiveGitEnv(options.env), mode: 'default' as const } + 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 }> => @@ -994,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 @@ -1066,7 +1162,12 @@ export async function gitExecFileAsyncBuffer( if (isWslLinkedWorktreeGitRoutingCandidate(options.cwd, options.wslDistro)) { await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro) } - const resolved = resolveGitCommand(args, options, true) + 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', @@ -1120,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) { @@ -1129,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 @@ -1303,10 +1413,29 @@ 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 = resolveGitCommand(args, { cwd: options.cwd, diff --git a/src/main/index.ts b/src/main/index.ts index 4879891e84f..20cd2e0a16c 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' @@ -401,10 +412,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, @@ -907,7 +931,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. @@ -918,10 +942,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. @@ -955,19 +988,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( @@ -1223,7 +1261,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') @@ -1312,6 +1350,7 @@ function openMainWindow(): BrowserWindow { void presentRendererRecoveryPrompt(recentRecoveryCount) }, deferLoad: true, + ...(options.revealOnDidFinishLoad === true ? { revealOnDidFinishLoad: true } : {}), title: devInstanceIdentity.name, getKeybindings: () => keybindings?.getOverrides(), onBeforeReload: ({ ignoreCache, webContentsId }) => { @@ -1351,6 +1390,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?.() @@ -2128,6 +2170,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) { @@ -2960,9 +3005,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) { @@ -3046,15 +3102,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/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 78e03f56065..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,5 @@ import type { ChildProcess } from 'node:child_process' -import { gitSpawn } from '../git/runner' +import { gitSpawnAfterWindowsEnvironmentReady } from '../git/runner' import { isWslLinkedWorktreeGitRoutingCandidate, prepareWslLinkedWorktreeGitRouting @@ -45,12 +45,13 @@ async function isInsideGitWorkTree( 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 => { @@ -88,6 +89,9 @@ async function isInsideGitWorkTree( child.kill() finish(false) }, 10_000) + if (signal?.aborted) { + cancel() + } }) } @@ -121,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 @@ -155,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) @@ -246,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)) + } }) } @@ -275,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) } }) @@ -297,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 9b0c91a6c65..e997ddd2e02 100644 --- a/src/main/ipc/filesystem-search-git.ts +++ b/src/main/ipc/filesystem-search-git.ts @@ -7,7 +7,7 @@ import { ingestGitGrepLine, SEARCH_TIMEOUT_MS } from '../../shared/text-search' -import { gitSpawn } from '../git/runner' +import { gitSpawnAfterWindowsEnvironmentReady } from '../git/runner' import { isWslLinkedWorktreeGitRoutingCandidate, prepareWslLinkedWorktreeGitRouting @@ -29,18 +29,18 @@ export async function searchWithGitGrep( 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/pty.test.ts b/src/main/ipc/pty.test.ts index d3ec26f91a6..d462541ed72 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 } @@ -9308,8 +9316,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 +9364,7 @@ describe('registerPtyHandlers', () => { id: 'pty-live-owner', codexHomePath: null, reattached: true, + reattachedHomeRoute: expectedHomeRouteAtStart, startedAt: expect.any(Date), startedSequence: expect.any(Number) }) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 03453c3aeeb..08ebb1b2183 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -203,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' @@ -1243,6 +1245,7 @@ export type CodexHomePtySpawnedLifecycleArgs = { id: string codexHomePath: string | null reattached?: boolean + reattachedHomeRoute?: CodexPaneHomeRoute | null launchEnv?: NodeJS.ProcessEnv startedAt?: Date startedSequence?: number @@ -1454,6 +1457,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 @@ -4412,6 +4439,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 @@ -4445,7 +4478,8 @@ export function registerPtyHandlers( codexHomePath: null, reattached: true, startedAt: codexHomeLaunchStartedAt, - startedSequence: codexHomeLaunchStartedSequence + startedSequence: codexHomeLaunchStartedSequence, + ...codexReattachedHomeRouteField(reattachedCodexHomeRoutes, result.id, true) }) } return result @@ -5120,6 +5154,7 @@ export function registerPtyHandlers( reattached: true, startedAt: codexHomeLaunchStartedAt, startedSequence: codexHomeLaunchStartedSequence, + ...codexReattachedHomeRouteField(reattachedCodexHomeRoutes, result.id, true), ...(env ? { launchEnv: env } : {}) }) } @@ -5293,6 +5328,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 } : {}) }) } @@ -5796,6 +5836,37 @@ 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) { @@ -6850,6 +6921,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/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/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 cc0b4b0452f..ab299057169 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -1045,6 +1045,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/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 0e507090c1f..2255e3a3b8e 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -5621,6 +5621,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'), @@ -7295,6 +7331,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 922a0ced183..c15cab49aad 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -3244,6 +3244,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 ) @@ -4473,6 +4480,7 @@ export class Store { linkedTask?: FolderWorkspace['linkedTask'] linkedTaskSourceContext?: FolderWorkspace['linkedTaskSourceContext'] connectionId?: string | null + creatorProvenance?: FolderWorkspace['creatorProvenance'] createdWithAgent?: FolderWorkspace['createdWithAgent'] pendingFirstAgentMessageRename?: boolean }): FolderWorkspace { @@ -4495,6 +4503,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 @@ -5839,6 +5848,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-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/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/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 6545486a3a2..aa40c7ee64b 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -7761,7 +7761,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 = { @@ -7786,7 +7786,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) @@ -7828,14 +7828,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, @@ -7884,7 +7886,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 = { @@ -7909,7 +7911,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) @@ -7954,7 +7956,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', @@ -7989,7 +7991,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) @@ -8126,7 +8128,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, @@ -8139,7 +8141,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) @@ -8162,7 +8164,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[] = [] @@ -8175,7 +8177,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') @@ -8199,11 +8201,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 = { @@ -8297,7 +8299,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') @@ -8314,7 +8316,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') @@ -8331,10 +8333,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') @@ -8358,10 +8360,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') @@ -8385,10 +8387,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') @@ -8414,10 +8416,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 e7e4bd9c582..ea19a0d15cb 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -103,7 +103,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, @@ -17885,6 +17890,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, @@ -17933,6 +17939,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, @@ -18607,6 +18614,7 @@ export class OrcaRuntimeService { name?: string folderPath?: string | null connectionId?: string | null + creatorProvenance?: FolderWorkspace['creatorProvenance'] linkedTask?: FolderWorkspace['linkedTask'] linkedTaskSourceContext?: FolderWorkspace['linkedTaskSourceContext'] createdWithAgent?: FolderWorkspace['createdWithAgent'] @@ -18635,7 +18643,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 } @@ -18720,6 +18731,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 } }) } @@ -18768,6 +18780,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') } @@ -18795,6 +18808,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 @@ -18922,6 +18936,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}`) } @@ -19168,10 +19185,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 @@ -19180,14 +19198,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) => { @@ -21958,6 +21976,7 @@ export class OrcaRuntimeService { pendingFirstAgentMessageRename?: boolean automationProvenance?: AutomationWorkspaceProvenance cliProvenance?: CliWorkspaceProvenance + creatorProvenance?: Worktree['creatorProvenance'] startup?: WorktreeStartupLaunch startupDraft?: string startupDraftPaste?: WorktreeStartupDraftPaste @@ -22024,6 +22043,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 @@ -22652,6 +22672,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/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index d1d692dab41..b52a52f24a7 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 { omitUndefinedValues, tolerateUnknownValues } from './ui-update-value-tolerance' @@ -214,15 +215,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/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 2b07a2e7fd6..0f087a2c272 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -727,6 +727,7 @@ export class OrcaRuntimeRpcServer { endpoint, deviceToken: device.token, publicKeyB64, + pairedDeviceId: device.deviceId, scope }) return { @@ -939,6 +940,7 @@ export class OrcaRuntimeRpcServer { endpoint: direct.endpoint, deviceToken: device.token, publicKeyB64, + pairedDeviceId: device.deviceId, scope: 'mobile', relay: relayPairing.relay }) 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/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts index 39b60c36a6a..63282b41ea7 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/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/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 15fc4e124c9..4f41621d87b 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1146,6 +1146,9 @@ export type PreloadApi = { get: () => { platform: NodeJS.Platform osRelease: string + arch: string + /** Login shell or ComSpec when available. */ + shell: string displayServer: 'wayland' | 'x11' | null } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 690b39118ad..18364e534b7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -570,9 +570,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'], 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.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/renderer/src/App.tsx b/src/renderer/src/App.tsx index b94cb9d4840..d205125700f 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -693,6 +693,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) @@ -965,6 +966,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) }) @@ -1097,10 +1102,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() ) @@ -1414,6 +1416,7 @@ function App(): React.JSX.Element { hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces, hideDetachedHeadWorkspaces, + hideWorkspacesFromOtherDevices, alwaysShowDefaultBranchWorkspace, showDotfilesByWorktree, filterRepoIds, @@ -1448,6 +1451,7 @@ function App(): React.JSX.Element { hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces, hideDetachedHeadWorkspaces, + hideWorkspacesFromOtherDevices, alwaysShowDefaultBranchWorkspace, showDotfilesByWorktree, filterRepoIds, 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 099478745e4..23f335c3927 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1121,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'] { 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/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 41564556b3f..3480672f578 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) @@ -746,6 +754,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( @@ -771,6 +786,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 @@ -797,6 +818,8 @@ function WorktreeJumpPaletteContent({ hideCliCreatedWorkspaces, hideDefaultBranchWorkspace, hideDetachedHeadWorkspaces, + hideWorkspacesFromOtherDevices, + pairedDeviceIdsByEnvironment, ptyIdsByTabId, showSleepingWorkspaces, tabsByWorktree, @@ -1848,7 +1871,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 @@ -1885,16 +1907,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 = @@ -1908,7 +1937,6 @@ function WorktreeJumpPaletteContent({ }, [commandSelectedItemId, prefetchCreateWorkspaceBaseForComposer, visible]) const handleQueryChange = useCallback((nextQuery: string) => { - latestQueryRef.current = nextQuery setQuery(nextQuery) setSelectedItemId('') listRef.current?.scrollTo(0, 0) @@ -2564,7 +2592,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')} >
} > - + )} diff --git a/src/renderer/src/components/tab-group/TabGroupSplitLayout.test.ts b/src/renderer/src/components/tab-group/TabGroupSplitLayout.test.ts index 20613d7a2a1..b0cadf8c4b0 100644 --- a/src/renderer/src/components/tab-group/TabGroupSplitLayout.test.ts +++ b/src/renderer/src/components/tab-group/TabGroupSplitLayout.test.ts @@ -100,6 +100,7 @@ describe('TabGroupSplitLayout', () => { return tabGroupPanelElement.props as { groupId: string worktreeId: string + isVisible: boolean isFocused: boolean hasSplitGroups: boolean reserveClosedExplorerToggleSpace: boolean @@ -112,6 +113,7 @@ describe('TabGroupSplitLayout', () => { expect.objectContaining({ groupId: 'group-1', worktreeId: 'wt-1', + isVisible: false, isFocused: false, hasSplitGroups: false, reserveClosedExplorerToggleSpace: true, @@ -125,6 +127,7 @@ describe('TabGroupSplitLayout', () => { expect.objectContaining({ groupId: 'group-1', worktreeId: 'wt-1', + isVisible: true, isFocused: true, hasSplitGroups: false, reserveClosedExplorerToggleSpace: true, diff --git a/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx b/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx index b467e582218..9a97ae673e4 100644 --- a/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx +++ b/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx @@ -182,6 +182,7 @@ function SplitNode({