Merge remote-tracking branch 'origin/main' into brennanb2025/codex-structured-mobile-write

# Conflicts:
#	mobile/app/h/[hostId]/session/[worktreeId].tsx
#	mobile/src/session/MobileMarkdownReader.tsx
#	src/renderer/src/components/native-chat/NativeChatView.tsx
#	src/renderer/src/components/native-chat/native-chat-view-types.ts
#	src/renderer/src/components/tab-bar/TabBar.tsx
#	src/renderer/src/runtime/web-session-tabs-sync.ts
This commit is contained in:
Brennan Benson
2026-08-11 13:16:52 -07:00
371 changed files with 21026 additions and 3937 deletions
@@ -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',
+4
View File
@@ -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")
+124 -14
View File
@@ -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<SmokeResult> {
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<void> {
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)
}
}
}
+7
View File
@@ -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',
@@ -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",
-2
View File
@@ -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
@@ -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
}
@@ -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)
})
})
@@ -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'
+74 -7
View File
@@ -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<void> {
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 <socket>"); 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 <socket>")\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 —
+1
View File
@@ -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",
+2 -2
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -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 {
@@ -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<string, unknown>, 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<MobileRichMarkdownEditorHandle>()
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()
})
})
@@ -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<MobileRichMarkdownEditorHandle>
) {
const webViewRef = useRef<WebView>(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 (
<View style={styles.container}>
<View style={styles.toolbar}>
@@ -278,7 +299,7 @@ function MobileRichMarkdownEditorInner({
)
}
export const MobileRichMarkdownEditor = memo(MobileRichMarkdownEditorInner)
export const MobileRichMarkdownEditor = memo(forwardRef(MobileRichMarkdownEditorInner))
const styles = StyleSheet.create({
container: {
@@ -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<string, (event: FakeClickEvent) => 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')
})
})
@@ -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' });
})();
@@ -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();
}
`
@@ -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();
}
`
@@ -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<string, unknown>) => 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)
})
})
+58 -18
View File
@@ -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<MobileRichMarkdownEditorHandle>(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 (
<View style={styles.markdownState}>
@@ -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 (
<View style={styles.markdownEditor}>
<MobileRichMarkdownEditor
ref={editorRef}
key={documentId}
content={doc.localContent}
editable={doc.editable && !doc.saving}
@@ -73,6 +92,7 @@ export function MobileMarkdownReader({
pointerEvents="box-none"
style={[
styles.markdownFloatingBar,
// Why: editor focus lives in a WebView, so lift native Save/Discard controls instead of resizing it.
{
bottom: resolveMarkdownFloatingActionsBottom({
keyboardLift: effectiveKeyboardLift,
@@ -91,6 +111,26 @@ export function MobileMarkdownReader({
</Text>
) : null}
<View style={styles.markdownFloatingActions}>
{keyboardOpen ? (
<Pressable
style={[styles.markdownFloatingButton, styles.markdownKeyboardDismissButton]}
onPress={() => editorRef.current?.dismissKeyboard()}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel="Dismiss keyboard"
accessibilityHint="Hides the software keyboard and keeps the markdown editor open."
>
<View style={styles.keyboardDismissGlyph}>
<KeyboardIcon size={15} color={colors.textSecondary} strokeWidth={2} />
<ChevronDown
size={10}
color={colors.textSecondary}
strokeWidth={2.5}
style={styles.keyboardDismissChevron}
/>
</View>
</Pressable>
) : null}
{showCopy ? (
<Pressable style={styles.markdownFloatingButton} onPress={onCopy}>
<Text style={styles.markdownFloatingButtonText}>Copy</Text>
+199
View File
@@ -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<CopyFeedback | null>(null)
const copyResetTimerRef = useRef<ReturnType<typeof setTimeout> | 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<void> => {
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 (
<View style={[styles.row, !first && styles.rowBorder, disabled && styles.disabled]}>
<Pressable
style={({ pressed }) => [styles.rowMain, pressed && !disabled && styles.pressed]}
disabled={disabled}
onPress={() => onLaunch(command)}
accessibilityRole="button"
accessibilityLabel={`Run ${command.label}`}
>
<View style={styles.rowIcon}>
{isAgent ? (
<MobileAgentIcon agentId={command.agent} size={16} />
) : (
<Play size={14} color={colors.textPrimary} fill={colors.textPrimary} />
)}
</View>
<View style={styles.rowText}>
<Text style={styles.rowLabel} numberOfLines={1}>
{command.label}
</Text>
<Text style={[styles.rowPreview, !isAgent && styles.mono]} numberOfLines={1}>
{getQuickCommandDisplayPreview(command)}
</Text>
</View>
</Pressable>
<Pressable
style={({ pressed }) => [
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' ? (
<Check size={15} color={copyIconColor} />
) : (
<Copy size={15} color={copyIconColor} />
)}
</Pressable>
<Pressable
style={({ pressed }) => [styles.rowAction, pressed && !disabled && styles.pressed]}
disabled={disabled}
onPress={() => onEdit(command)}
accessibilityLabel={`Edit ${command.label}`}
>
<Pencil size={15} color={colors.textSecondary} />
</Pressable>
<Pressable
style={({ pressed }) => [styles.rowAction, pressed && !disabled && styles.pressed]}
disabled={disabled}
onPress={() => onDelete(command)}
accessibilityLabel={`Delete ${command.label}`}
>
<Trash2 size={15} color={colors.statusRed} />
</Pressable>
</View>
)
}
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' }
})
@@ -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', () => {
+4 -82
View File
@@ -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 (
<View style={[styles.row, !first && styles.rowBorder, disabled && styles.disabled]}>
<Pressable
style={({ pressed }) => [styles.rowMain, pressed && !disabled && styles.pressed]}
disabled={disabled}
onPress={() => onLaunch(command)}
accessibilityRole="button"
accessibilityLabel={`Run ${command.label}`}
>
<View style={styles.rowIcon}>
{isAgent ? (
<MobileAgentIcon agentId={command.agent} size={16} />
) : (
<Play size={14} color={colors.textPrimary} fill={colors.textPrimary} />
)}
</View>
<View style={styles.rowText}>
<Text style={styles.rowLabel} numberOfLines={1}>
{command.label}
</Text>
<Text style={[styles.rowPreview, !isAgent && styles.mono]} numberOfLines={1}>
{getQuickCommandDisplayPreview(command)}
</Text>
</View>
</Pressable>
<Pressable
style={({ pressed }) => [styles.rowAction, pressed && !disabled && styles.pressed]}
disabled={disabled}
onPress={() => onEdit(command)}
accessibilityLabel={`Edit ${command.label}`}
>
<Pencil size={15} color={colors.textSecondary} />
</Pressable>
<Pressable
style={({ pressed }) => [styles.rowAction, pressed && !disabled && styles.pressed]}
disabled={disabled}
onPress={() => onDelete(command)}
accessibilityLabel={`Delete ${command.label}`}
>
<Trash2 size={15} color={colors.statusRed} />
</Pressable>
</View>
)
}
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',
@@ -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)
}
)
})
@@ -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
}
@@ -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
},
@@ -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) => {
@@ -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 {
+2
View File
@@ -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,
@@ -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: '<h1>Recovery</h1>',
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: '<h1>Changed after loss</h1>' })
).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('<h1>Changed after loss</h1>')
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: '<h1>Changed share</h1>' })
).resolves.toMatchObject({
status: 'ok',
value: { artifact: { slug: 'artifact-1' } }
})
expect(server.createMutations).toBe(1)
expect(server.artifactContent('artifact-1')).toBe('<h1>Changed share</h1>')
})
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: '<h1>Changed after update failure</h1>' }
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: '<h1>Corrected</h1>' })
).resolves.toMatchObject({
status: 'ok',
value: { item: { artifact: { slug: 'artifact-1' } } }
})
expect(server.createMutations).toBe(1)
expect(server.artifactContent('artifact-1')).toBe('<h1>Corrected</h1>')
})
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: '<h1>Changed after validation</h1>' }
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<string, string>()
private readonly createsByKey = new Map<string, { body: string; response: object }>()
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<Response> {
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<string> {
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<string | null> {
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}`
}
}
+8 -1
View File
@@ -1,7 +1,14 @@
import type { ArtifactWriteRequest } from '../../shared/artifacts'
import { OrcaCloudRequestError } from '../orca-profiles/profile-cloud-client'
export function artifactWriteBody(request: ArtifactWriteRequest): Record<string, string> {
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,
+29 -7
View File
@@ -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<void> {
try {
await artifactRequest<void>(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<void>(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<void>(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<ArtifactCloudOperation<T>> {
const apiUrl = resolveArtifactCloudApiUrl(options.apiUrl)
const active = ensureActiveOrcaProfile(this.userDataPath)
prepareArtifactCloudUse(active.profile, this.userDataPath)
if (options.authToken?.trim()) {
if (!allowsArtifactCloudAuthOverride()) {
throw new Error(
@@ -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: '<h1>Original</h1>',
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: '<h1>Changed</h1>' }
)
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<string> {
const path = await mkdtemp(join(tmpdir(), 'orca-artifact-create-intents-'))
createdPaths.push(path)
return path
}
@@ -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<string>()
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<ArtifactWriteBody>
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<ArtifactShareScope>
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<ArtifactCreateIntent>
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)
}
+157 -30
View File
@@ -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<ArtifactListItem> {
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<ArtifactPublishResult> {
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<ArtifactListItem>(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<ArtifactListItem> {
return this.runForSlug(record.slug, auth, async () => {
auth.assertCurrent()
const item = await artifactRequest<ArtifactListItem>(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<ArtifactPublishResult> {
const response = await artifactRequest<ArtifactCreateResponse>(apiUrl, token, '', {
method: 'POST',
body: artifactWriteBody(request),
idempotencyKey
})
idempotencyKey: string,
pending: ArtifactCreateIntent | null
): Promise<ArtifactCreateOutcome> {
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<ArtifactCreateResponse>(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<T>(key: string, operation: () => Promise<T>): Promise<T> {
const previous = this.queues.get(key) ?? Promise.resolve()
let release = (): void => {}
@@ -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<typeof NodeFs>('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()
})
@@ -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(),
@@ -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')
@@ -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<string, string>()
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.
@@ -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()
@@ -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<string, IgnoredLaunch>()
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)
}
}
@@ -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)
})
})
@@ -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<T extends { recordedAt: number }>(
entries: Map<string, T>,
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<string, RecentPtyExit>()
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
}
}
@@ -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({
@@ -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<unknown>
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<void> | null = null
const activeLaunches = new Map<string, Date>()
const earlyPtyExits = new Map<string, EarlyPtyExit>()
// 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<string, EarlyPtyExit>,
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<string, EarlyPtyExit>,
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)
}
@@ -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',
@@ -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()
})
})
@@ -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)
}
}
}
+1 -1
View File
@@ -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}`)
})
+8 -1
View File
@@ -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)
}
+15 -4
View File
@@ -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<string, string>): 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
+1 -1
View File
@@ -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()
})
@@ -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<typeof vi.fn>
}
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<void>; resolve: () => void } {
let resolve!: () => void
const promise = new Promise<void>((next) => {
resolve = next
})
return { promise, resolve }
}
async function withPlatform<T>(platform: NodeJS.Platform, fn: () => Promise<T>): Promise<T> {
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<void>(() => {}))
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<void>(() => {}))
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<void>(() => {}))
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 })
})
})
})
+139 -10
View File
@@ -182,12 +182,100 @@ function resolveHostGitHubCli(command: 'gh', args: string[]): ResolvedCommand {
}
let defaultWslDistroOverride: string | null = null
let waitForWindowsHostGitEnvironment: (() => Promise<void>) | 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<void>) | null
): void {
waitForWindowsHostGitEnvironment = waitUntilReady
}
export async function awaitWindowsHostGitEnvironmentReady(options: {
cwd: string
wslDistro?: string
signal?: AbortSignal
}): Promise<void> {
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<NodeJS.ProcessEnv | undefined> | 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<GitStreamResult> =>
new Promise<GitStreamResult>((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<ChildProcess> {
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,
+84 -22
View File
@@ -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<void> {
})
}
function startTerminalRuntimeStartupServices(): Promise<void> {
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<void> {
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<void> {
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<WindowsDesktopStartupServices>
): 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)
@@ -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<ChildProcess>((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<ChildProcess>((resolve) => {
resolvePrimary = resolve
})
)
.mockReturnValueOnce(
new Promise<ChildProcess>((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<ChildProcess>((_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)
})
})
@@ -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<typeof setTimeout>
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<void> => {
const runGitLsFiles = async (args: string[]): Promise<void> => {
// 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<typeof setTimeout>
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)
@@ -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()
+7 -7
View File
@@ -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<typeof setTimeout>
function resolveOnce(): void {
+36
View File
@@ -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
+4 -1
View File
@@ -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)
}
)
+15 -4
View File
@@ -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)
})
+78 -2
View File
@@ -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<string, CodexPaneHomeRoute | null> {
const routes = new Map<string, CodexPaneHomeRoute | null>()
for (const ptyId of ptyIds) {
if (!ptyId || routes.has(ptyId)) {
continue
}
routes.set(ptyId, getCodexPaneAccount(ptyId)?.homeRoute ?? null)
}
return routes
}
function codexReattachedHomeRouteField(
routes: ReadonlyMap<string, CodexPaneHomeRoute | null>,
ptyId: string,
reattached: boolean
): { reattachedHomeRoute?: CodexPaneHomeRoute | null } {
if (!reattached || !routes.has(ptyId)) {
return {}
}
return { reattachedHomeRoute: routes.get(ptyId) ?? null }
}
function readEnvWithProcessFallback(
baseEnv: Record<string, string>,
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<string, CodexPaneHomeRoute | null>()
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<string, CodexPaneHomeRoute | null>()
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
+88 -4
View File
@@ -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')
+49 -17
View File
@@ -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<AbortController>()
let activeRemoteClone: ActiveRemoteCloneMetadata | null = null
let nextCloneGeneration = 1
const latestCloneGenerationByPath = new Map<string, number>()
@@ -1169,6 +1178,10 @@ async function scanNestedReposForIpc(args: {
}): Promise<NestedRepoScanResult> {
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<void>((resolve, reject) => {
let proc: Awaited<ReturnType<typeof gitSpawnAfterWindowsEnvironmentReady>>
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<typeof gitSpawn>
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<void>((resolve, reject) => {
const generation = nextCloneGeneration++
latestCloneGenerationByPath.set(clonePathKey, generation)
const metadata: ActiveCloneMetadata = {
@@ -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(
@@ -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()
+14 -7
View File
@@ -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<void>
/** 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<BaseSnapshot> {
const markers = new Map<string, boolean>()
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<typeof setTimeout> | null = null
let parkedWhileHidden = false
const pendingMarkerMaxTicks = options.pendingMarkerMaxTicks ?? PENDING_MARKER_MAX_TICKS
@@ -218,7 +226,7 @@ async function startBasePoller(
const fullScan = async (): Promise<void> => {
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
+3
View File
@@ -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,
+2
View File
@@ -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 } : {}),
+1
View File
@@ -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 } : {}),
@@ -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<typeof ArtifactCreateIntentStore>(
'../artifacts/artifact-create-intent-store'
)
return { ...actual, clearArtifactCreateIntents: vi.fn(actual.clearArtifactCreateIntents) }
})
vi.mock('./profile-index-store', async () => {
const actual = await vi.importActual<typeof ProfileIndexStore>('./profile-index-store')
return { ...actual, writeProfileIndex: vi.fn(actual.writeProfileIndex) }
})
vi.mock('./profile-artifact-cloud-cleanup', async () => {
const actual = await vi.importActual<typeof ProfileArtifactCloudCleanup>(
'./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<string> {
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')
})
}
@@ -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<ArtifactCloudCleanupMarker>).version !== 1 ||
!['prepared', 'committed'].includes(
(parsed as Partial<ArtifactCloudCleanupMarker>).phase ?? ''
) ||
typeof (parsed as Partial<ArtifactCloudCleanupMarker>).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<OrcaProfileSummary, 'id' | 'cloud'>,
userDataPath: string
): void {
completeArtifactCloudCleanupIfCommitted(profile.id, userDataPath, profile.cloud)
assertArtifactCloudCleanupReady(profile.id, userDataPath, profile.cloud)
}
+41 -5
View File
@@ -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
@@ -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 {
+52
View File
@@ -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({
+18
View File
@@ -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
+3 -12
View File
@@ -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<string> {
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()
}
+1 -1
View File
@@ -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<GitDiffResult[]>
getCommitDiff(
worktreePath: string,
@@ -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
}
}
})
})
+14 -2
View File
@@ -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', () => {
+19 -7
View File
@@ -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, string> = {}): 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.`
+119
View File
@@ -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',
+11 -3
View File
@@ -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<GitDiffResult[]> {
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<GitDiffResult[]>
}
@@ -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<string, unknown>).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<void>((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 = {
@@ -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<ResolvedRuntimeGitWorktree>
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'
})
})
})
+4 -1
View File
@@ -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)
}
}
+32 -30
View File
@@ -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<string, unknown>[] = []
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<string, unknown>[] = []
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<string, unknown>[] = []
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<string, unknown>[] = [
{
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<string, unknown>[] = []
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<string, unknown>[] = []
@@ -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<string, unknown>[] = []
const colorStore = {
...store,
+35 -14
View File
@@ -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<ProjectGroupImportResult> {
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<void>((resolve, reject) => {
let proc: ReturnType<typeof gitSpawn>
try {
proc = gitSpawn(['clone', '--progress', '--', trimmedUrl, clonePath], {
let proc: Awaited<ReturnType<typeof gitSpawnAfterWindowsEnvironmentReady>>
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<void>((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 } : {})
@@ -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(),
@@ -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()
}
@@ -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(),
+5 -3
View File
@@ -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<PersistedUIState>)
})
handler: (params, { runtime }) => {
const { hideWorkspacesFromOtherDevices: _clientLocalFilter, ...hostUpdates } = params
void _clientLocalFilter
return { ui: runtime.updateUIState(hostUpdates as Partial<PersistedUIState>) }
}
}),
defineMethod({
name: 'ui.recordFeatureInteraction',
@@ -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({
+2 -1
View File
@@ -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'
+2 -1
View File
@@ -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
}
@@ -155,6 +155,7 @@ describe('worktree RPC methods', () => {
setupDecision: 'skip',
createdWithAgent: undefined,
automationProvenance: undefined,
creatorProvenance: { kind: 'host' },
startup: undefined,
startupDraft: undefined,
lineage: {

Some files were not shown because too many files have changed in this diff Show More