mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 16:03:26 +00:00
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:
@@ -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',
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,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}`
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}`)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 +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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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.`
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user