Persist profile state in SQLite with background writes (#22612)

Migrate profile state to SQLite and move writes and backups into a background worker. Acknowledge terminal, SSH and automation changes only after durable saves. Preserve JSON import, recovery, rollback and compatibility exports.

Validate migration, worker failures, maintenance, cross-profile moves and terminal lifetime races with unit, integration and end-to-end coverage.
This commit is contained in:
OrcaWin
2026-09-25 22:47:33 -07:00
committed by GitHub
parent f0a3610928
commit 82412dab8b
490 changed files with 41290 additions and 3485 deletions
+25 -5
View File
@@ -16,15 +16,33 @@ type OutputChunk = Rollup.OutputChunk
// electron, and smoke-loads daemon-entry under plain Node to prove its module
// graph still resolves.
// Entries executed as plain Node (ELECTRON_RUN_AS_NODE / no electron runtime):
// forked daemon, parcel-watcher, WSL filesystem and computer sidecars, and the CLI-run
// agent-hooks entry. require("electron") throws MODULE_NOT_FOUND in all of them.
// The CLI loads these paths after electron-vite replaces out/main.
export const CLI_MAIN_ENTRY_NAMES = [
'agent-hooks/managed-agent-hook-controls',
'codex/managed-home-shell-preflight',
'claude-accounts/keychain',
...[
'access',
'active-location',
'storage-classification',
'offline-settings',
'export-path',
'backup-path',
'database-recovery',
'domain-reader',
'recovery',
'recovery-command'
].map((module) => `persistence/profile-state/profile-state-${module}`),
'startup/http1-compatibility-marker'
] as const
// Plain-Node processes and CLI modules cannot load Electron's API.
const PLAIN_NODE_ENTRY_NAMES = [
'daemon-entry',
'parcel-watcher-process-entry',
'computer-sidecar',
'wsl-transcript-fs-process-entry',
'agent-hooks/managed-agent-hook-controls'
...CLI_MAIN_ENTRY_NAMES
] as const
// Entries executed as worker threads of the main process. Electron's module is
@@ -41,7 +59,9 @@ const WORKER_THREAD_ENTRY_NAMES = [
'session-scanner-worker-entry',
'main-thread-hang-watchdog-entry',
'port-scan-command-worker-entry',
'usage-scan-worker-entry'
'usage-scan-worker-entry',
'profile-state-backup-worker-entry',
'profile-state-writer-worker-entry'
] as const
export const GUARDED_ENTRY_NAMES = [
+2
View File
@@ -281,6 +281,8 @@ module.exports = {
'out/main/gemini/**',
'out/main/grok/**',
'out/main/hermes/**',
'out/main/persistence/profile-state/**',
'out/main/startup/http1-compatibility-marker.js',
'out/main/daemon-entry.js',
'out/main/session-scanner-service-entry.js',
'out/main/wsl-transcript-fs-process-entry.js',
+24 -12
View File
@@ -21,6 +21,7 @@ import {
import { arch, platform, tmpdir } from 'node:os'
import { join } from 'node:path'
import process from 'node:process'
import { smokeProfileStateWorkers } from './profile-state-worker-smoke.mjs'
import {
ORCAD_VERSION_FILENAME,
ORCAD_RIPGREP_ARTIFACTS
@@ -99,10 +100,8 @@ cpSync(join(ROOT, 'resources', 'licenses', 'ripgrep'), join(OUT_DIR, 'ripgrep',
recursive: true
})
/** Why one call per child and not one `outdir` build: esbuild mirrors each entry's source
* directory under `outdir`, and both children must land flat beside orcad.js — that is where
* their runtime resolvers look for them. */
function buildForkedChild(entryPoint, outfile) {
// Child and worker resolvers require flat entries beside orcad.js.
function buildIsolatedEntry(entryPoint, outfile) {
return build({
entryPoints: [entryPoint],
bundle: true,
@@ -120,9 +119,15 @@ function buildForkedChild(entryPoint, outfile) {
})
}
const childResults = await Promise.all([
buildForkedChild(WATCHER_ENTRY, WATCHER_OUT_FILE),
buildForkedChild(DAEMON_ENTRY, DAEMON_OUT_FILE)
const isolatedResults = await Promise.all([
buildIsolatedEntry(WATCHER_ENTRY, WATCHER_OUT_FILE),
buildIsolatedEntry(DAEMON_ENTRY, DAEMON_OUT_FILE),
...['writer', 'backup'].map((role) =>
buildIsolatedEntry(
join(ROOT, `src/main/persistence/profile-state/profile-state-${role}-worker-entry.ts`),
join(OUT_DIR, `profile-state-${role}-worker-entry.js`)
)
)
])
const result = await build({
@@ -147,9 +152,7 @@ const output = Object.values(result.metafile.outputs).find(
// Why check `original` and not just `path`: when electron is bundleable, esbuild
// rewrites `path` to the resolved file under node_modules and the naive check passes
// while the package is very much in the bundle.
// Why both metafiles: the forked children ship in the same deployment and run under the
// same plain Node. A daemon-entry that reached electron would fail at fork time, on the
// path whose whole point is that terminals survive.
// Every isolated entry ships under the same plain-Node compatibility contract.
function collectImporters(metafiles, matches) {
const importers = new Set()
for (const metafile of metafiles) {
@@ -164,7 +167,7 @@ function collectImporters(metafiles, matches) {
return importers
}
const metafiles = [result.metafile, ...childResults.map((child) => child.metafile)]
const metafiles = [result.metafile, ...isolatedResults.map((entry) => entry.metafile)]
const electronImporters = collectImporters(
metafiles,
(specifier) => specifier === 'electron' || specifier.startsWith('electron/')
@@ -254,6 +257,12 @@ if (graphErrors.length > 0) {
)
process.exitCode = 1
}
try {
await smokeProfileStateWorkers(OUT_DIR)
} catch (error) {
console.error('[build-orcad] profile state worker check failed:', error)
process.exitCode = 1
}
}
// Why a content hash and not ORCAD_VERSION alone: the remote install directory is keyed on
@@ -295,7 +304,10 @@ async function smokeLoadWatcherChild() {
resolve(failure)
}
child.on('message', (message) => {
if (message?.op === 'subscribe-started') {
// Wait until the subscribe lifecycle has sent its final acknowledgement.
// Disconnecting on subscribe-started races the subsequent subscribed or
// subscribe-failed message and makes the child report an expected EPIPE.
if (message?.op === 'subscribed' || message?.op === 'subscribe-failed') {
child.disconnect()
}
})
@@ -1,6 +1,8 @@
import { readFileSync, readdirSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { electronViteConfig } from '../../electron.vite.config'
import { GUARDED_ENTRY_NAMES } from '../build-plugins/plain-node-entry-guard'
const REPO_ROOT = resolve(__dirname, '..', '..')
const CLI_ROOT = join(REPO_ROOT, 'src', 'cli')
@@ -18,7 +20,7 @@ function listCliSourceFiles(dir: string): string[] {
}
// Why: `import type` is erased by tsc, so it needs no emitted module at runtime.
const VALUE_IMPORT_FROM_MAIN = /(?<!\btype\s)from '\.\.\/\.\.\/main\/([^']+)'/g
const VALUE_IMPORT_FROM_MAIN = /(?<!\btype\s)from '(?:\.\.\/)+main\/([^']+)'/g
function findMainImports(): { file: string; module: string }[] {
return listCliSourceFiles(CLI_ROOT).flatMap((file) => {
@@ -30,12 +32,12 @@ function findMainImports(): { file: string; module: string }[] {
})
}
function findElectronViteMainEntries(): Set<string> {
const config = readFileSync(join(REPO_ROOT, 'electron.vite.config.ts'), 'utf-8')
return new Set(
// Why: entries wrap across lines once the path is long, so allow whitespace.
[...config.matchAll(/resolve\(\s*'src\/main\/([^']+)\.ts'\s*\)/g)].map((match) => match[1])
)
function findElectronViteMainEntries(): Record<string, string> {
const input = electronViteConfig.main?.build?.rollupOptions?.input
if (!input || typeof input !== 'object' || Array.isArray(input)) {
throw new Error('Expected named main-process inputs')
}
return input
}
describe('CLI imports of main-process modules', () => {
@@ -45,14 +47,25 @@ describe('CLI imports of main-process modules', () => {
// final-artifact runtime verifier.
it('has an electron-vite entry for every main module the CLI imports', () => {
const entries = findElectronViteMainEntries()
const missing = findMainImports().filter(({ module }) => !entries.has(module))
const missing = findMainImports().filter(
({ module }) => entries[module] !== join(REPO_ROOT, 'src', 'main', `${module}.ts`)
)
expect(missing).toEqual([])
})
it('guards every CLI main module against Electron imports', () => {
const guarded = new Set<string>(GUARDED_ENTRY_NAMES)
expect(findMainImports().filter(({ module }) => !guarded.has(module))).toEqual([])
})
it('finds the imports it is meant to guard', () => {
// Why: a broken matcher would make the guard above vacuously pass.
expect(findMainImports()).toContainEqual({
file: join('src', 'cli', 'profile-state-location.ts'),
module: 'persistence/profile-state/profile-state-active-location'
})
expect(findMainImports().length).toBeGreaterThanOrEqual(2)
expect(findElectronViteMainEntries().size).toBeGreaterThanOrEqual(2)
expect(Object.keys(findElectronViteMainEntries()).length).toBeGreaterThanOrEqual(2)
})
})
@@ -73,9 +73,7 @@ function failBootstrapWithBanner(options: {
return processMock
}
const electronBuilderConfig = createRequire(import.meta.url)('../electron-builder.config.cjs') as {
files: string[]
}
const electronBuilderConfig = createRequire(import.meta.url)('../electron-builder.config.cjs')
describe('Electron Vite output contract', () => {
it("minifies main and renderer with rolldown's in-process minifier", () => {
@@ -104,6 +102,33 @@ describe('Electron Vite output contract', () => {
expect(output.chunkFileNames).toBe('chunks/[name]-[hash].js')
})
it('keeps offline profile-state CLI imports unpacked at stable paths', () => {
const input = electronViteConfig.main?.build?.rollupOptions?.input
if (!input || typeof input !== 'object' || Array.isArray(input)) {
throw new Error('Expected named main-process inputs')
}
for (const name of [
'persistence/profile-state/profile-state-access',
'persistence/profile-state/profile-state-active-location',
'persistence/profile-state/profile-state-backup-path',
'persistence/profile-state/profile-state-database-recovery',
'persistence/profile-state/profile-state-domain-reader',
'persistence/profile-state/profile-state-export-path',
'persistence/profile-state/profile-state-offline-settings',
'persistence/profile-state/profile-state-recovery',
'persistence/profile-state/profile-state-recovery-command',
'persistence/profile-state/profile-state-storage-classification',
'startup/http1-compatibility-marker'
]) {
expect(input).toHaveProperty(name)
}
expect(electronBuilderConfig.asarUnpack).toContain('out/main/persistence/profile-state/**')
expect(electronBuilderConfig.asarUnpack).toContain(
'out/main/startup/http1-compatibility-marker.js'
)
})
it('externalizes packaged dependencies but bundles self-contained main dependencies', () => {
const external = electronViteConfig.main?.build?.rollupOptions?.external
if (typeof external !== 'function') {
+41 -19
View File
@@ -4,6 +4,7 @@ import { join } from 'node:path'
import type { Plugin, Rollup } from 'vite'
import { afterEach, describe, expect, it } from 'vitest'
import {
CLI_MAIN_ENTRY_NAMES,
createPlainNodeEntryGuardPlugin,
GUARDED_ENTRY_NAMES
} from '../build-plugins/plain-node-entry-guard'
@@ -158,8 +159,8 @@ describe('guarded entry names', () => {
// main-process worker and kills it at startup. The worker entries carried only
// hand-written "must stay electron-free" comments, and the port-scan worker sits
// one import away from a client that deliberately does require electron.
describe('worker thread entry guard', () => {
function runWorkerWriteBundle(plugin: Plugin, bundle: Rollup.OutputBundle): void {
describe('CLI and worker thread entry guard', () => {
function runEntryWriteBundle(plugin: Plugin, bundle: Rollup.OutputBundle): void {
const hook = plugin.writeBundle
if (typeof hook !== 'function') {
throw new Error('Expected writeBundle hook')
@@ -171,7 +172,7 @@ describe('worker thread entry guard', () => {
)
}
function workerChunk(name: string, code: string, imports: string[] = []): Rollup.OutputChunk {
function entryChunk(name: string, code: string, imports: string[] = []): Rollup.OutputChunk {
return {
type: 'chunk',
code,
@@ -183,33 +184,54 @@ describe('worker thread entry guard', () => {
} as Rollup.OutputChunk
}
it.each(CLI_MAIN_ENTRY_NAMES)('rejects direct and transitive Electron imports in %s', (name) => {
const plugin = createPlainNodeEntryGuardPlugin()
const entry = entryChunk(name, 'require("electron")')
const bundle: Rollup.OutputBundle = { [entry.fileName]: entry }
expect(() => runEntryWriteBundle(plugin, bundle)).toThrow('requires electron')
entry.code = ''
const shared = entryChunk('shared', 'require("electron/main")')
shared.isEntry = false
bundle[shared.fileName] = shared
for (const edge of ['imports', 'dynamicImports'] as const) {
entry[edge] = [shared.fileName]
expect(() => runEntryWriteBundle(plugin, bundle)).toThrow('requires electron')
entry[edge] = []
}
shared.code = 'require("node:fs")'
entry.imports = [shared.fileName]
expect(() => runEntryWriteBundle(plugin, bundle)).not.toThrow()
})
it('rejects an Electron require reachable from a worker entry', () => {
const plugin = createPlainNodeEntryGuardPlugin()
const bundle = {
'port-scan-command-worker-entry.js': workerChunk(
const bundle: Rollup.OutputBundle = {
'port-scan-command-worker-entry.js': entryChunk(
'port-scan-command-worker-entry',
'require("electron")'
)
} as Rollup.OutputBundle
}
expect(() => runWorkerWriteBundle(plugin, bundle)).toThrow('requires electron')
expect(() => runEntryWriteBundle(plugin, bundle)).toThrow('requires electron')
})
it('names the worker-thread runtime so the failure is actionable', () => {
const plugin = createPlainNodeEntryGuardPlugin()
const bundle = {
'stt-worker.js': workerChunk('stt-worker', 'require("electron")')
} as Rollup.OutputBundle
const bundle: Rollup.OutputBundle = {
'stt-worker.js': entryChunk('stt-worker', 'require("electron")')
}
expect(() => runWorkerWriteBundle(plugin, bundle)).toThrow('runs as a worker thread')
expect(() => runEntryWriteBundle(plugin, bundle)).toThrow('runs as a worker thread')
})
// The real risk is transitive: a worker entry importing a shared chunk that
// reaches the electron-requiring client, not a direct import anyone would spot.
it('follows shared chunks out of a worker entry', () => {
const plugin = createPlainNodeEntryGuardPlugin()
const bundle = {
'session-scanner-opencode-sqlite-worker-entry.js': workerChunk(
const bundle: Rollup.OutputBundle = {
'session-scanner-opencode-sqlite-worker-entry.js': entryChunk(
'session-scanner-opencode-sqlite-worker-entry',
'require("./chunks/shared.js")',
['chunks/shared.js']
@@ -223,20 +245,20 @@ describe('worker thread entry guard', () => {
isEntry: false,
name: 'shared'
} as Rollup.OutputChunk
} as Rollup.OutputBundle
}
expect(() => runWorkerWriteBundle(plugin, bundle)).toThrow('chunks/shared.js')
expect(() => runEntryWriteBundle(plugin, bundle)).toThrow('chunks/shared.js')
})
it('passes a clean worker entry', () => {
const plugin = createPlainNodeEntryGuardPlugin()
const bundle = {
'warp-theme-parser-worker.js': workerChunk(
const bundle: Rollup.OutputBundle = {
'warp-theme-parser-worker.js': entryChunk(
'warp-theme-parser-worker',
'require("node:worker_threads")'
)
} as Rollup.OutputBundle
}
expect(() => runWorkerWriteBundle(plugin, bundle)).not.toThrow()
expect(() => runEntryWriteBundle(plugin, bundle)).not.toThrow()
})
})
@@ -0,0 +1,134 @@
import { deepStrictEqual } from 'node:assert'
import { build } from 'esbuild'
import { mkdtempSync, rmSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { Worker } from 'node:worker_threads'
async function initializeFixture(directory, databasePath, profileId) {
const fixture = join(directory, 'initialize.cjs')
await build({
stdin: {
contents: `import { openProfileStateDatabase } from './src/main/persistence/profile-state/profile-state-database';
export function initialize(path, profileId) { openProfileStateDatabase(path, profileId).db.close() }`,
resolveDir: resolve(import.meta.dirname, '../..'),
sourcefile: 'profile-state-build-fixture.ts'
},
outfile: fixture,
bundle: true,
platform: 'node',
format: 'cjs',
logLevel: 'silent'
})
createRequire(import.meta.url)(fixture).initialize(databasePath, profileId)
}
function runWorker(entry, workerData, steps, timeoutMs) {
return new Promise((resolve, reject) => {
const worker = new Worker(entry, { workerData, execArgv: [] })
let received = 0
let failure
const stop = (error) => {
failure ??= error
void worker.terminate().catch((terminationError) => {
failure ??= terminationError
})
}
const timer = setTimeout(() => stop(new Error(`${entry} timed out`)), timeoutMs)
worker.on('message', (response) => {
if (failure) {
return
}
const step = steps[received]
if (!step) {
stop(new Error(`${entry} sent an unexpected response`))
return
}
try {
if (response?.ok === false) {
throw new Error(`${entry}: ${response.error?.message ?? response.error}`)
}
for (const [key, expected] of Object.entries(step.reply)) {
deepStrictEqual(response?.[key], expected, `${entry}: unexpected ${key}`)
}
received++
const next = steps[received]
if (next) {
worker.postMessage(next.request)
}
} catch (error) {
stop(error)
}
})
worker.on('error', (error) => {
failure ??= error
})
worker.once('exit', (code) => {
clearTimeout(timer)
if (failure || code !== 0 || received !== steps.length) {
reject(failure ?? new Error(`${entry} exited before completing its protocol (${code})`))
} else {
resolve()
}
})
})
}
/** Exercise the shipped entries and copied state before publishing their content version. */
export async function smokeProfileStateWorkers(outDir, { timeoutMs = 30_000 } = {}) {
const directory = mkdtempSync(join(tmpdir(), 'orca-profile-worker-smoke-'))
const databasePath = join(directory, 'profile.db')
const targetPath = join(directory, 'backup.db')
const profileId = 'build-smoke'
const payload = JSON.stringify({ theme: 'dark', witness: 'saved \ud800 \u{1f419}' })
try {
await initializeFixture(directory, databasePath, profileId)
await runWorker(
join(outDir, 'profile-state-writer-worker-entry.js'),
{ databasePath, profileId, revision: 0 },
[
{ reply: { id: 0, ok: true, revision: 0 } },
{
request: {
id: 1,
command: 'write-complete',
replacements: [{ domain: 'settings', payload }]
},
reply: { id: 1, ok: true, revision: 1 }
},
{
request: { id: 2, command: 'close' },
reply: { id: 2, ok: true, revision: 1 }
}
],
timeoutMs
)
await runWorker(
join(outDir, 'profile-state-backup-worker-entry.js'),
{ databasePath, profileId, targetPath },
[{ reply: { ok: true } }],
timeoutMs
)
const { DatabaseSync } = process.getBuiltinModule('node:sqlite')
const database = new DatabaseSync(targetPath, { readOnly: true })
try {
deepStrictEqual(database.prepare('PRAGMA quick_check').get()['quick_check'], 'ok')
deepStrictEqual(
database
.prepare("SELECT payload FROM profile_state_documents WHERE domain = 'settings'")
.get()?.payload,
payload
)
deepStrictEqual(
database.prepare("SELECT value FROM profile_state_meta WHERE key = 'revision'").get()
?.value,
'1'
)
} finally {
database.close()
}
} finally {
rmSync(directory, { recursive: true, force: true })
}
}
@@ -0,0 +1,91 @@
import { build } from 'esbuild'
import { copyFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { smokeProfileStateWorkers } from './profile-state-worker-smoke.mjs'
const directories = []
const writerFilename = 'profile-state-writer-worker-entry.js'
const backupFilename = 'profile-state-backup-worker-entry.js'
let builtDirectory
function fixtureDirectory() {
const directory = mkdtempSync(join(tmpdir(), 'orca-profile-worker-build-test-'))
directories.push(directory)
return directory
}
beforeAll(async () => {
builtDirectory = fixtureDirectory()
await Promise.all(
['writer', 'backup'].map((role) =>
build({
entryPoints: [
resolve(`src/main/persistence/profile-state/profile-state-${role}-worker-entry.ts`)
],
outfile: join(builtDirectory, `profile-state-${role}-worker-entry.js`),
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
logLevel: 'silent'
})
)
)
})
afterAll(() => {
for (const directory of directories) {
rmSync(directory, { recursive: true, force: true })
}
})
describe('profile state build smoke', () => {
it('writes through the built writer and verifies the built backup after handle release', async () => {
await expect(smokeProfileStateWorkers(builtDirectory)).resolves.toBeUndefined()
})
it('rejects a worker that exits without completing its protocol', async () => {
const directory = fixtureDirectory()
writeFileSync(join(directory, writerFilename), 'process.exit(0)\n')
await expect(smokeProfileStateWorkers(directory)).rejects.toThrow('before completing')
})
it('rejects a mismatched startup revision', async () => {
const directory = fixtureDirectory()
writeFileSync(
join(directory, writerFilename),
`const { parentPort } = require('node:worker_threads')
parentPort.postMessage({ id: 0, ok: true, revision: 7 })
parentPort.close()`
)
await expect(smokeProfileStateWorkers(directory)).rejects.toThrow('unexpected revision')
})
it('does not accept a close acknowledgement from a worker that remains alive', async () => {
const directory = fixtureDirectory()
writeFileSync(
join(directory, writerFilename),
`const { parentPort } = require('node:worker_threads')
parentPort.postMessage({ id: 0, ok: true, revision: 0 })
parentPort.on('message', ({ id }) => parentPort.postMessage({ id, ok: true, revision: 1 }))
setInterval(() => {}, 1000)`
)
await expect(smokeProfileStateWorkers(directory, { timeoutMs: 2_000 })).rejects.toThrow(
'timed out'
)
})
it('does not accept a successful backup response without the copied database', async () => {
const directory = fixtureDirectory()
copyFileSync(join(builtDirectory, writerFilename), join(directory, writerFilename))
writeFileSync(
join(directory, backupFilename),
`const { parentPort } = require('node:worker_threads')
parentPort.postMessage({ ok: true })
parentPort.close()`
)
await expect(smokeProfileStateWorkers(directory)).rejects.toThrow()
})
})
+62
View File
@@ -20,6 +20,68 @@
"../src/main/agent-hooks/managed-hook-script-refresh.ts",
"../src/main/agent-hooks/posix-hook-command.ts",
"../src/main/agent-hooks/runtime-home-hook-command.ts",
"../src/main/orca-profiles/profile-storage-paths.ts",
"../src/main/persistence/profile-state/profile-state-database.ts",
"../src/main/persistence/profile-state/profile-state-database-errors.ts",
"../src/main/persistence/profile-state/profile-state-database-validation.ts",
"../src/main/persistence/profile-state/profile-state-database-schema.ts",
"../src/main/persistence/profile-state/profile-state-documents.ts",
"../src/main/persistence/profile-state/profile-state-json-acceptance.ts",
"../src/main/persistence/profile-state/profile-state-revision.ts",
"../src/main/persistence/profile-state/profile-state-read-snapshot.ts",
"../src/main/persistence/profile-state/profile-state-sqlite-authority.ts",
"../src/main/persistence/profile-state/profile-state-authority-exports.ts",
"../src/main/persistence/profile-state/profile-state-complete-replacements.ts",
"../src/main/persistence/profile-state/profile-state-revision-readmission.ts",
"../src/main/persistence/profile-state/profile-state-writer-protocol.ts",
"../src/main/persistence/loading-store/profile-state-authority.ts",
"../src/main/persistence/profile-state/profile-state-automation-runs-migration.ts",
"../src/main/persistence/profile-state/profile-state-document-reader.ts",
"../src/main/persistence/profile-state/profile-state-document-validation.ts",
"../src/main/persistence/profile-state/profile-state-domain-writes.ts",
"../src/main/persistence/profile-state/profile-state-write-transaction.ts",
"../src/main/persistence/profile-state/profile-state-domain-write-validation.ts",
"../src/main/persistence/profile-state/profile-state-domain-reader.ts",
"../src/main/persistence/profile-state/profile-state-automation-runs.ts",
"../src/main/persistence/profile-state/profile-state-automation-runs-model.ts",
"../src/main/persistence/profile-state/profile-state-automation-runs-payload.ts",
"../src/main/persistence/profile-state/profile-state-automation-runs-reader.ts",
"../src/main/persistence/profile-state/profile-state-automation-runs-storage.ts",
"../src/main/persistence/profile-state/profile-state-automation-runs-validation.ts",
"../src/main/persistence/profile-state/profile-state-automation-runs-writer.ts",
"../src/main/persistence/profile-state/profile-state-offline-settings.ts",
"../src/main/persistence/profile-state/profile-state-export-path.ts",
"../src/main/persistence/profile-state/profile-state-legacy-backup-path.ts",
"../src/main/persistence/profile-state/profile-state-backup-path.ts",
"../src/main/persistence/profile-state/profile-state-backup-rotation.ts",
"../src/main/persistence/profile-state/profile-state-backup-job.ts",
"../src/main/persistence/profile-state/profile-state-backup-worker.ts",
"../src/main/persistence/profile-state/profile-state-backup-worker-entry.ts",
"../src/main/worker-thread-entry-path.ts",
"../src/main/persistence/profile-state/profile-state-database-snapshot.ts",
"../src/main/persistence/profile-state/profile-state-database-recovery.ts",
"../src/main/persistence/profile-state/profile-state-recovery-required.ts",
"../src/main/persistence/profile-state/profile-state-recovery.ts",
"../src/main/persistence/profile-state/profile-state-recovery-copy.ts",
"../src/main/persistence/profile-state/profile-state-recovery-command.ts",
"../src/main/orca-profiles/profile-project-move-record.ts",
"../src/main/orca-profiles/profile-project-domain-changes.ts",
"../src/main/persistence/profile-state/profile-state-active-location.ts",
"../src/main/persistence/profile-state/profile-state-access.ts",
"../src/main/persistence/profile-state/profile-state-access-owner.ts",
"../src/main/persistence/profile-state/profile-state-access-identity.ts",
"../src/main/daemon/daemon-process-start-time.ts",
"../src/main/daemon/daemon-process-identity-query.ts",
"../src/main/startup/startup-diagnostics.ts",
"../src/main/persistence/profile-state/profile-state-versioned-export.ts",
"../src/main/persistence/profile-state/profile-state-backup-temporary-files.ts",
"../src/main/persistence/profile-state/profile-state-database-quarantine.ts",
"../src/main/persistence/profile-state/profile-state-storage-classification.ts",
"../src/main/durable-file-write.ts",
"../src/shared/secure-file.ts",
"../src/main/sqlite/harden-database-files.ts",
"../src/main/startup/http1-compatibility-marker.ts",
"../src/main/startup/http1-compatibility-profile-state.ts",
"../src/main/agent-hooks/windows-direct-cmd-hook-command.ts",
"../src/main/agent-hooks/windows-powershell-hook-launcher.ts",
"../src/main/amp/agent-status-plugin-source.ts",
+13 -11
View File
@@ -5,7 +5,10 @@ import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { createBootstrapFatalExitBanner } from './config/build-plugins/bootstrap-fatal-exit-banner'
import { createPdfjsViewerAssetsPlugin } from './config/build-plugins/pdfjs-viewer-assets'
import { createPlainNodeEntryGuardPlugin } from './config/build-plugins/plain-node-entry-guard'
import {
CLI_MAIN_ENTRY_NAMES,
createPlainNodeEntryGuardPlugin
} from './config/build-plugins/plain-node-entry-guard'
import packageJson from './package.json' with { type: 'json' }
const BUNDLED_MAIN_DEPENDENCIES = new Set([
@@ -246,6 +249,12 @@ export const electronViteConfig: UserConfig = {
// corpora and read SQLite synchronously; a worker thread keeps that
// off the main-process event loop.
'usage-scan-worker-entry': resolve('src/main/usage/usage-scan-worker-entry.ts'),
'profile-state-backup-worker-entry': resolve(
'src/main/persistence/profile-state/profile-state-backup-worker-entry.ts'
),
'profile-state-writer-worker-entry': resolve(
'src/main/persistence/profile-state/profile-state-writer-worker-entry.ts'
),
// Why: forked with ELECTRON_RUN_AS_NODE so @parcel/watcher faults
// can't take down the main process (issue #7547).
'parcel-watcher-process-entry': resolve('src/main/ipc/parcel-watcher-process-entry.ts'),
@@ -254,16 +263,9 @@ export const electronViteConfig: UserConfig = {
'main-thread-hang-watchdog-entry': resolve(
'src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts'
),
// Why: electron-vite cleans out/main in dev. The dev CLI imports
// this path for `orca agent hooks ...`, so it must survive rebuilds.
'agent-hooks/managed-agent-hook-controls': resolve(
'src/main/agent-hooks/managed-agent-hook-controls.ts'
),
'codex/managed-home-shell-preflight': resolve(
'src/main/codex/managed-home-shell-preflight.ts'
),
// Why: account import mutates the user's macOS Keychain from the CLI.
'claude-accounts/keychain': resolve('src/main/claude-accounts/keychain.ts')
...Object.fromEntries(
CLI_MAIN_ENTRY_NAMES.map((module) => [module, resolve(`src/main/${module}.ts`)])
)
},
// Why: Rolldown's SSR default is ESM, but Electron and sidecar launchers
// consume these stable CommonJS paths.
+5
View File
@@ -183,6 +183,11 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [
keys: ['agent hooks prepare-codex', 'agent hooks status', 'agent hooks off', 'agent hooks on'],
load: async () => (await import('./handlers/agent-hooks.js')).AGENT_HOOK_HANDLERS
},
{
name: 'profile-state',
keys: ['profile state exports', 'profile state rollback'],
load: async () => (await import('./handlers/profile-state.js')).PROFILE_STATE_HANDLERS
},
{
name: 'diagnostics',
keys: ['diagnostics memory'],
@@ -0,0 +1,42 @@
import { afterEach, expect, it, vi } from 'vitest'
import { main } from '../index'
const { prepare } = vi.hoisted(() => ({ prepare: vi.fn() }))
vi.mock('../runtime-client', () => ({
RuntimeClient: class {
async call() {
return { result: { settings: { agentStatusHooksEnabled: true } } }
}
},
RuntimeClientError: Error,
getDefaultUserDataPath: () => '/unused/user-data'
}))
vi.mock('../../main/codex/managed-home-shell-preflight', () => ({
prepareManagedCodexHomeBeforeShellLaunch: prepare
}))
vi.mock('../../main/persistence/profile-state/profile-state-offline-settings', () => {
throw new Error('Offline profile settings loaded during online preparation')
})
vi.mock('../../main/persistence/profile-state/profile-state-access', () => {
throw new Error('Profile admission loaded during online preparation')
})
vi.mock('../profile-state-location', () => {
throw new Error('Profile location loaded during online preparation')
})
afterEach(() => {
vi.unstubAllEnvs()
vi.restoreAllMocks()
process.exitCode = undefined
})
it('prepares Codex through the runtime without loading offline profile storage', async () => {
vi.stubEnv('WSL_DISTRO_NAME', '')
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
await main(['agent', 'hooks', 'prepare-codex'])
expect(error).not.toHaveBeenCalled()
expect(prepare).toHaveBeenCalledWith({
userDataPath: '/unused/user-data',
hooksEnabled: true
})
})
+288 -2
View File
@@ -1,9 +1,28 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import * as fs from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getDefaultPersistedState } from '../../shared/constants'
import type { PersistedState } from '../../shared/persisted-state-types'
import {
getOrcaProfileDataFile,
getOrcaProfileStateDatabaseFile
} from '../../main/orca-profiles/profile-storage-paths'
import { openProfileStateDatabase } from '../../main/persistence/profile-state/profile-state-database'
import {
exportProfileStateJson,
hashProfileStateJson,
importProfileStateJson
} from '../../main/persistence/profile-state/profile-state-documents'
import { ProfileStateSqliteAuthority } from '../../main/persistence/profile-state/profile-state-sqlite-authority'
import {
acquireProfileStateMaintenance,
acquireProfileStateRuntimeAdmission,
type ProfileStateRuntimeAdmission
} from '../../main/persistence/profile-state/profile-state-access'
vi.mock('node:fs', async (original) => ({ ...(await original<typeof fs>()) }))
const {
applyAgentStatusHooksEnabledMock,
@@ -74,6 +93,14 @@ function writeDataFile(userDataPath: string, state: PersistedState): void {
writeFileSync(join(userDataPath, 'orca-data.json'), JSON.stringify(state, null, 2), 'utf-8')
}
function writeActiveProfileIndex(userDataPath: string, profileId: string): void {
writeFileSync(
join(userDataPath, 'orca-profile-index.json'),
JSON.stringify({ activeProfileId: profileId, profiles: [{ id: profileId }] }),
'utf-8'
)
}
async function runAgentHooksOff(userDataPath: string): Promise<void> {
getDefaultUserDataPathMock.mockReturnValue(userDataPath)
await main(['agent', 'hooks', 'off', '--json'], userDataPath)
@@ -84,7 +111,7 @@ describe('agent hooks CLI handler', () => {
beforeEach(() => {
userDataPath = mkdtempSync(join(tmpdir(), 'orca-agent-hooks-cli-'))
applyAgentStatusHooksEnabledMock.mockReturnValue([])
applyAgentStatusHooksEnabledMock.mockReset().mockReturnValue([])
callMock.mockReset()
getCliStatusMock.mockClear()
getManagedAgentHookStatusesMock.mockReturnValue([])
@@ -109,6 +136,98 @@ describe('agent hooks CLI handler', () => {
expect(persisted.settings.agentStatusHooksEnabled).toBe(false)
})
it.each(['root-json', 'profile-json', 'sqlite'] as const)(
'refuses offline %s mutation when startup wins after the stopped-status response',
async (backend) => {
const profileId = 'startup-race'
const directory =
backend === 'root-json' ? userDataPath : join(userDataPath, 'profiles', profileId)
mkdirSync(directory, { recursive: true })
const dataFile = join(directory, 'orca-data.json')
const raw = JSON.stringify({
settings: { agentStatusHooksEnabled: true },
unknown: { retained: null }
})
writeFileSync(dataFile, raw)
if (backend !== 'root-json') {
writeActiveProfileIndex(userDataPath, profileId)
}
const databaseFile = join(directory, 'profile-state.db')
if (backend === 'sqlite') {
const opened = openProfileStateDatabase(databaseFile, profileId)
try {
importProfileStateJson(opened.db, raw, {
acceptedLegacyJsonHash: hashProfileStateJson(raw)
})
} finally {
opened.db.close()
}
}
const stopped = await getCliStatusMock()
let runtime: ProfileStateRuntimeAdmission | undefined
getCliStatusMock.mockImplementationOnce(async () => {
runtime = acquireProfileStateRuntimeAdmission(userDataPath)
return stopped
})
try {
await runAgentHooksOff(userDataPath)
expect(process.exitCode).toBe(1)
expect(applyAgentStatusHooksEnabledMock).not.toHaveBeenCalled()
expect(readFileSync(dataFile, 'utf8')).toBe(raw)
if (backend === 'sqlite') {
const opened = openProfileStateDatabase(databaseFile, profileId)
try {
expect(JSON.parse(exportProfileStateJson(opened.db))).toEqual(JSON.parse(raw))
} finally {
opened.db.close()
}
}
} finally {
runtime?.release()
}
process.exitCode = undefined
await runAgentHooksOff(userDataPath)
expect(process.exitCode).not.toBe(1)
expect(applyAgentStatusHooksEnabledMock).toHaveBeenCalledOnce()
}
)
it.each(['root-json', 'profile-json'] as const)(
'excludes startup and other offline writers through %s publication',
async (backend) => {
const profileId = 'offline-first'
const directory =
backend === 'root-json' ? userDataPath : join(userDataPath, 'profiles', profileId)
mkdirSync(directory, { recursive: true })
if (backend === 'profile-json') {
writeActiveProfileIndex(userDataPath, profileId)
}
const dataFile = join(directory, 'orca-data.json')
writeFileSync(dataFile, JSON.stringify({ settings: { agentStatusHooksEnabled: true } }))
const rename = fs.renameSync
let checkedPublication = false
vi.spyOn(fs, 'renameSync').mockImplementation((source, target) => {
if (target === dataFile) {
checkedPublication = true
expect(() => acquireProfileStateRuntimeAdmission(userDataPath)).toThrow()
expect(() => acquireProfileStateMaintenance(userDataPath)).toThrow()
}
return rename(source, target)
})
await runAgentHooksOff(userDataPath)
expect(checkedPublication).toBe(true)
expect(process.exitCode).not.toBe(1)
const runtime = acquireProfileStateRuntimeAdmission(userDataPath)
try {
expect(JSON.parse(readFileSync(dataFile, 'utf8')).settings.agentStatusHooksEnabled).toBe(
false
)
} finally {
runtime.release()
}
}
)
it('keeps missing new card style off when updating offline settings', async () => {
const existing = getDefaultPersistedState(userDataPath)
delete existing.settings.experimentalNewWorktreeCardStyle
@@ -129,6 +248,31 @@ describe('agent hooks CLI handler', () => {
expect(readDataFile(userDataPath).settings.experimentalNewWorktreeCardStyle).toBe(true)
})
it.each(['on', 'off', 'status', 'prepare-codex'])(
'refuses explicit remote selection before local hook command %s',
async (command) => {
const state = getDefaultPersistedState(userDataPath)
writeDataFile(userDataPath, state)
const before = readFileSync(join(userDataPath, 'orca-data.json'), 'utf8')
getDefaultUserDataPathMock.mockReturnValue(userDataPath)
for (const selector of ['environment', 'pairing-code']) {
process.exitCode = undefined
await main(
['agent', 'hooks', command, `--${selector}`, 'unreachable-host', '--json'],
userDataPath
)
expect(process.exitCode).toBe(1)
expect(getCliStatusMock).not.toHaveBeenCalled()
expect(callMock).not.toHaveBeenCalled()
expect(applyAgentStatusHooksEnabledMock).not.toHaveBeenCalled()
expect(prepareManagedCodexHomeBeforeShellLaunchMock).not.toHaveBeenCalled()
expect(readFileSync(join(userDataPath, 'orca-data.json'), 'utf8')).toBe(before)
}
}
)
it('prepares managed Codex trust with the current hooks setting', async () => {
const state = getDefaultPersistedState(userDataPath)
state.settings.agentStatusHooksEnabled = false
@@ -231,4 +375,146 @@ describe('agent hooks CLI handler', () => {
timeoutMs: 1_000
})
})
it('updates an established SQLite profile without rewriting its JSON export', async () => {
const profileId = 'work-profile'
const profileDirectory = join(userDataPath, 'profiles', profileId)
const dataFile = getOrcaProfileDataFile(profileId, userDataPath)
const databaseFile = getOrcaProfileStateDatabaseFile(profileId, userDataPath)
const raw = JSON.stringify({
settings: {
agentStatusHooksEnabled: true,
disabledTuiAgents: ['codex'],
opencodeSessionCookie: 'encrypted-ciphertext'
},
unknownDomain: { preserved: true }
})
mkdirSync(profileDirectory, { recursive: true })
writeFileSync(dataFile, raw, 'utf-8')
writeActiveProfileIndex(userDataPath, profileId)
const opened = openProfileStateDatabase(databaseFile, profileId)
try {
importProfileStateJson(opened.db, raw, {
acceptedLegacyJsonHash: hashProfileStateJson(raw)
})
} finally {
opened.db.close()
}
await runAgentHooksOff(userDataPath)
expect(readFileSync(dataFile, 'utf-8')).toBe(raw)
const readBack = openProfileStateDatabase(databaseFile, profileId)
try {
expect(JSON.parse(exportProfileStateJson(readBack.db))).toMatchObject({
settings: {
agentStatusHooksEnabled: false,
opencodeSessionCookie: 'encrypted-ciphertext',
disabledTuiAgents: ['codex']
},
unknownDomain: { preserved: true }
})
} finally {
readBack.db.close()
}
})
it.each(['update-failed', 'unreachable', 'status-failed'] as const)(
'preserves a live SQLite writer when runtime contact is %s',
async (failure) => {
const profileId = 'live-profile'
const profileDirectory = join(userDataPath, 'profiles', profileId)
mkdirSync(profileDirectory, { recursive: true })
writeActiveProfileIndex(userDataPath, profileId)
const authority = new ProfileStateSqliteAuthority(
getOrcaProfileStateDatabaseFile(profileId, userDataPath),
profileId
)
authority.writeSerializedState(
Buffer.from(JSON.stringify({ settings: { agentStatusHooksEnabled: true } }))
)
if (failure === 'status-failed') {
getCliStatusMock.mockRejectedValueOnce(new Error('status transport unavailable'))
} else {
getCliStatusMock.mockResolvedValueOnce({
id: 'test-status',
ok: true,
result: {
app: { running: true, pid: null },
runtime: {
state: failure === 'unreachable' ? 'starting' : 'ready',
reachable: failure !== 'unreachable',
runtimeId: null
},
graph: { state: 'ready' }
},
_meta: { runtimeId: 'test' }
})
callMock.mockRejectedValueOnce(new Error('settings request timed out'))
}
try {
await runAgentHooksOff(userDataPath)
expect(process.exitCode).toBe(1)
expect(applyAgentStatusHooksEnabledMock).not.toHaveBeenCalled()
expect(() =>
authority.writeSerializedDomains([
{ domain: 'ui', payload: '{"marker":"still-writable"}' }
])
).not.toThrow()
const persisted = JSON.parse(authority.readSerializedState() ?? '{}')
expect(persisted).toMatchObject({
settings: { agentStatusHooksEnabled: true },
ui: { marker: 'still-writable' }
})
} finally {
authority.close()
}
}
)
it('keeps a JSON-only active profile on the legacy path without creating SQLite', async () => {
const profileId = 'json-profile'
const profileDirectory = join(userDataPath, 'profiles', profileId)
mkdirSync(profileDirectory, { recursive: true })
writeDataFile(profileDirectory, getDefaultPersistedState(userDataPath))
writeActiveProfileIndex(userDataPath, profileId)
await runAgentHooksOff(userDataPath)
expect(existsSync(getOrcaProfileStateDatabaseFile(profileId, userDataPath))).toBe(false)
expect(
JSON.parse(readFileSync(getOrcaProfileDataFile(profileId, userDataPath), 'utf-8')).settings
.agentStatusHooksEnabled
).toBe(false)
})
it('fails closed when a profile has corrupt SQLite alongside legacy JSON', async () => {
const profileId = 'corrupt-profile'
const profileDirectory = join(userDataPath, 'profiles', profileId)
mkdirSync(profileDirectory, { recursive: true })
const state = getDefaultPersistedState(userDataPath)
writeDataFile(profileDirectory, state)
writeActiveProfileIndex(userDataPath, profileId)
const databaseFile = getOrcaProfileStateDatabaseFile(profileId, userDataPath)
writeFileSync(databaseFile, 'not sqlite', 'utf-8')
const before = readFileSync(getOrcaProfileDataFile(profileId, userDataPath), 'utf-8')
await runAgentHooksOff(userDataPath)
expect(process.exitCode).toBe(1)
expect(readFileSync(getOrcaProfileDataFile(profileId, userDataPath), 'utf-8')).toBe(before)
})
it('fails closed when a profile index is present but unreadable', async () => {
const legacy = getDefaultPersistedState(userDataPath)
writeDataFile(userDataPath, legacy)
writeFileSync(join(userDataPath, 'orca-profile-index.json'), '{ torn', 'utf-8')
const before = readFileSync(join(userDataPath, 'orca-data.json'), 'utf-8')
await runAgentHooksOff(userDataPath)
expect(process.exitCode).toBe(1)
expect(readFileSync(join(userDataPath, 'orca-data.json'), 'utf-8')).toBe(before)
})
})
+85 -48
View File
@@ -4,6 +4,7 @@ import { dirname, join } from 'node:path'
import { randomUUID } from 'node:crypto'
import type { CommandHandler } from '../dispatch'
import { printResult } from '../format'
import { rejectRemoteSelectionFlags } from '../remote-selection-flag-rejection'
import {
RuntimeClientError,
type RuntimeClient,
@@ -16,6 +17,7 @@ import { normalizeDisabledTuiAgents } from '../../shared/tui-agent-selection'
import type { GlobalSettings } from '../../shared/global-settings-types'
import type { PersistedState } from '../../shared/persisted-state-types'
import { prepareManagedCodexHomeBeforeShellLaunch } from '../../main/codex/managed-home-shell-preflight'
import type { ProfileStateOfflineLocation } from '../../main/persistence/profile-state/profile-state-offline-settings'
type AgentHookCommandResult = {
enabled: boolean
@@ -27,28 +29,15 @@ type AgentHookCommandResult = {
// Covers managed-home verification, WSL identity, trust grant, and bounded app-server reap.
const WSL_CODEX_PREPARE_TIMEOUT_MS = 50_000
function getDataPath(): string {
const userDataPath = getDefaultUserDataPath()
const indexPath = join(userDataPath, 'orca-profile-index.json')
for (const candidate of [indexPath, `${indexPath}.bak`]) {
try {
const parsed: unknown = JSON.parse(readFileSync(candidate, 'utf-8'))
if (!isRecord(parsed) || !Array.isArray(parsed.profiles)) {
continue
}
const profileId = parsed.activeProfileId
if (
typeof profileId === 'string' &&
/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(profileId) &&
parsed.profiles.some((profile) => isRecord(profile) && profile.id === profileId)
) {
return join(userDataPath, 'profiles', profileId, 'orca-data.json')
}
} catch {
// Try the profile-index backup, then the legacy pre-profile path.
}
}
return join(userDataPath, 'orca-data.json')
async function getDataPath(): Promise<string> {
return (
(await getProfileStateLocation())?.dataFile ?? join(getDefaultUserDataPath(), 'orca-data.json')
)
}
async function getProfileStateLocation(): Promise<ProfileStateOfflineLocation | undefined> {
const { getActiveProfileStateLocation } = await import('../profile-state-location.js')
return getActiveProfileStateLocation()
}
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -92,11 +81,29 @@ function writePersistedState(dataPath: string, state: PersistedState): void {
}
}
function readHookSettingsFromDisk(): Pick<
GlobalSettings,
'agentStatusHooksEnabled' | 'disabledTuiAgents'
async function readHookSettingsFromDisk(): Promise<
Pick<GlobalSettings, 'agentStatusHooksEnabled' | 'disabledTuiAgents'>
> {
const state = readPersistedState(getDataPath())
const { acquireProfileStateRuntimeAdmission } =
await import('../../main/persistence/profile-state/profile-state-access.js')
const admission = acquireProfileStateRuntimeAdmission(getDefaultUserDataPath())
try {
return await readAdmittedHookSettingsFromDisk()
} finally {
admission.release()
}
}
async function readAdmittedHookSettingsFromDisk(): Promise<
Pick<GlobalSettings, 'agentStatusHooksEnabled' | 'disabledTuiAgents'>
> {
const profileStateLocation = await getProfileStateLocation()
if (profileStateLocation) {
const { readAgentHookSettingsFromProfileState } =
await import('../../main/persistence/profile-state/profile-state-offline-settings.js')
return readAgentHookSettingsFromProfileState(profileStateLocation)
}
const state = readPersistedState(await getDataPath())
return {
agentStatusHooksEnabled: state.settings?.agentStatusHooksEnabled !== false,
disabledTuiAgents: normalizeDisabledTuiAgents(state.settings?.disabledTuiAgents)
@@ -123,11 +130,32 @@ async function readHookSettings(
return readHookSettingsFromDisk()
}
function updateEnabledOnDisk(enabled: boolean): {
async function updateEnabledOnDisk(enabled: boolean): Promise<{
settingsPath: string
settings: Pick<GlobalSettings, 'agentCmdOverrides' | 'disabledTuiAgents'>
} {
const dataPath = getDataPath()
}> {
const { acquireProfileStateMaintenance } =
await import('../../main/persistence/profile-state/profile-state-access.js')
// A stopped-status response cannot exclude first migration racing this JSON write.
const maintenance = acquireProfileStateMaintenance(getDefaultUserDataPath())
try {
return await updateAdmittedEnabledOnDisk(enabled)
} finally {
maintenance.release()
}
}
async function updateAdmittedEnabledOnDisk(enabled: boolean): Promise<{
settingsPath: string
settings: Pick<GlobalSettings, 'agentCmdOverrides' | 'disabledTuiAgents'>
}> {
const profileStateLocation = await getProfileStateLocation()
if (profileStateLocation) {
const { updateAgentHookSettingsFromProfileState } =
await import('../../main/persistence/profile-state/profile-state-offline-settings.js')
return updateAgentHookSettingsFromProfileState(profileStateLocation, enabled)
}
const dataPath = await getDataPath()
const state = readPersistedState(dataPath)
state.settings = {
...getDefaultPersistedState(homedir()).settings,
@@ -145,20 +173,18 @@ function updateEnabledOnDisk(enabled: boolean): {
}
async function updateRunningRuntime(client: RuntimeClient, enabled: boolean): Promise<boolean> {
try {
const status = await client.getCliStatus()
if (!status.result.runtime.reachable) {
return false
const status = await client.getCliStatus()
if (!status.result.runtime.reachable) {
if (status.result.app.running) {
throw new RuntimeClientError(
'runtime_error',
'Orca is running but unavailable. Retry when it responds, or stop Orca before changing agent hooks offline.'
)
}
await client.call(
'settings.update',
{ agentStatusHooksEnabled: enabled },
{ timeoutMs: 10_000 }
)
return true
} catch {
return false
}
await client.call('settings.update', { agentStatusHooksEnabled: enabled }, { timeoutMs: 10_000 })
return true
}
function localSuccess<TResult>(result: TResult): RuntimeRpcSuccess<TResult> {
@@ -193,8 +219,8 @@ async function setAgentHooksEnabled(
const { applyAgentStatusHooksEnabled, getManagedAgentHookStatuses } =
await import('../../main/agent-hooks/managed-agent-hook-controls.js')
const updatedRuntime = await updateRunningRuntime(client, enabled)
const offlineUpdate = updatedRuntime ? null : updateEnabledOnDisk(enabled)
const settingsPath = offlineUpdate?.settingsPath ?? getDataPath()
const offlineUpdate = updatedRuntime ? null : await updateEnabledOnDisk(enabled)
const settingsPath = offlineUpdate?.settingsPath ?? (await getDataPath())
const statuses = updatedRuntime
? getManagedAgentHookStatuses()
: await applyAgentStatusHooksEnabled(enabled, offlineUpdate?.settings)
@@ -207,7 +233,8 @@ async function setAgentHooksEnabled(
}
export const AGENT_HOOK_HANDLERS: Record<string, CommandHandler> = {
'agent hooks prepare-codex': async ({ client }) => {
'agent hooks prepare-codex': async ({ client, flags }) => {
rejectRemoteHookSelection(flags)
if (process.env.WSL_DISTRO_NAME?.trim()) {
try {
await client.call(
@@ -231,23 +258,33 @@ export const AGENT_HOOK_HANDLERS: Record<string, CommandHandler> = {
settings.agentStatusHooksEnabled && !settings.disabledTuiAgents.includes('codex')
})
},
'agent hooks status': async ({ json }) => {
'agent hooks status': async ({ json, flags }) => {
rejectRemoteHookSelection(flags)
const { getManagedAgentHookStatuses } =
await import('../../main/agent-hooks/managed-agent-hook-controls.js')
const result: AgentHookCommandResult = {
enabled: readHookSettingsFromDisk().agentStatusHooksEnabled,
settingsPath: getDataPath(),
enabled: (await readHookSettingsFromDisk()).agentStatusHooksEnabled,
settingsPath: await getDataPath(),
appliedBy: 'offline',
statuses: getManagedAgentHookStatuses()
}
printResult(localSuccess(result), json, formatAgentHookCommandResult)
},
'agent hooks off': async ({ client, json }) => {
'agent hooks off': async ({ client, json, flags }) => {
rejectRemoteHookSelection(flags)
const result = await setAgentHooksEnabled(client, false)
printResult(localSuccess(result), json, formatAgentHookCommandResult)
},
'agent hooks on': async ({ client, json }) => {
'agent hooks on': async ({ client, json, flags }) => {
rejectRemoteHookSelection(flags)
const result = await setAgentHooksEnabled(client, true)
printResult(localSuccess(result), json, formatAgentHookCommandResult)
}
}
function rejectRemoteHookSelection(flags: ReadonlyMap<string, string | boolean>): void {
rejectRemoteSelectionFlags(
flags,
'agent hooks; run this command on the machine whose hooks you want to manage.'
)
}
@@ -0,0 +1,245 @@
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import * as durableFileWrite from '../../main/durable-file-write'
import {
acquireProfileStateMaintenance,
acquireProfileStateRuntimeAdmission
} from '../../main/persistence/profile-state/profile-state-access'
import {
openProfileStateDatabase,
openProfileStateDatabaseReadOnly
} from '../../main/persistence/profile-state/profile-state-database'
import {
importProfileStateJson,
readProfileStateSnapshot
} from '../../main/persistence/profile-state/profile-state-documents'
import {
createProfileStateDatabaseBackupId,
profileStateDatabaseBackupPath
} from '../../main/persistence/profile-state/profile-state-backup-path'
import { writeProfileStateDatabaseSnapshotAsync } from '../../main/persistence/profile-state/profile-state-database-snapshot'
import { restoreProfileStateDatabaseBackup } from '../../main/persistence/profile-state/profile-state-database-recovery'
import {
assertNoRetainedProfileStateExports,
ProfileStateRecoveryRequiredError
} from '../../main/persistence/profile-state/profile-state-recovery-required'
import { RuntimeClient } from '../runtime-client'
import { PROFILE_STATE_HANDLERS } from './profile-state'
const mocks = vi.hoisted(() => ({ root: vi.fn(), status: vi.fn() }))
vi.mock('../runtime-client', () => ({
getDefaultUserDataPath: mocks.root,
RuntimeClient: class {
getCliStatus = mocks.status
},
RuntimeClientError: class extends Error {
constructor(
readonly code: string,
message: string
) {
super(message)
}
}
}))
const roots: string[] = []
const profileId = 'admission-recovery'
const backupState = {
settings: {
theme: 'restored',
httpProxyUrl: 'sealed:backup',
electronHttp1CompatibilityMode: true
},
extension: { unknown: [null, '\ud800', 'backup'] },
opaque: null
}
const liveState = {
settings: { theme: 'runtime-before-restore', httpProxyUrl: 'sealed:live' },
extension: { unknown: [null, '\ud800', 'live'] },
opaque: null
}
beforeEach(() => {
mocks.status.mockReset().mockResolvedValue({
result: { app: { running: false }, runtime: { reachable: false } }
})
vi.spyOn(console, 'log').mockImplementation(() => {})
})
afterEach(() => {
vi.restoreAllMocks()
for (const root of roots.splice(0)) {
rmSync(root, { recursive: true, force: true })
}
})
async function fixture() {
const root = mkdtempSync(join(tmpdir(), 'orca-recovery-admission-'))
roots.push(root)
const directory = join(root, 'profiles', profileId)
mkdirSync(directory, { recursive: true })
writeFileSync(
join(root, 'orca-profile-index.json'),
JSON.stringify({ activeProfileId: profileId, profiles: [{ id: profileId }] })
)
const databasePath = join(directory, 'profile-state.db')
const dataFile = join(directory, 'orca-data.json')
const backupId = createProfileStateDatabaseBackupId()
const backupPath = profileStateDatabaseBackupPath(databasePath, backupId)
const source = openProfileStateDatabase(databasePath, profileId)
try {
importProfileStateJson(source.db, JSON.stringify(backupState))
await writeProfileStateDatabaseSnapshotAsync(source.db, backupPath)
importProfileStateJson(source.db, JSON.stringify(liveState), { expectedRevision: 1 })
} finally {
source.db.close()
}
mocks.root.mockReturnValue(root)
return { root, directory, databasePath, dataFile, backupId, backupPath }
}
function rollback(profile: Awaited<ReturnType<typeof fixture>>): Promise<void> {
const handler = PROFILE_STATE_HANDLERS['profile state rollback']
if (handler === undefined) {
throw new Error('Profile rollback handler is missing')
}
return handler({
flags: new Map([['backup', profile.backupId]]),
client: new RuntimeClient(profile.root),
cwd: profile.root,
json: true
})
}
function state(databasePath: string) {
const opened = openProfileStateDatabaseReadOnly(databasePath, profileId)
try {
return readProfileStateSnapshot(opened.db)
} finally {
opened.db.close()
}
}
describe('offline recovery excludes runtime admission', () => {
it('refuses rollback without changing the database when a move journal is unresolved', async () => {
const profile = await fixture()
const before = readFileSync(profile.databasePath)
const intents = join(profile.root, 'profile-move-intents')
mkdirSync(intents)
const intentPath = join(intents, '00000000-0000-0000-0000-000000000001.json')
writeFileSync(intentPath, '{"partial":true}')
await expect(rollback(profile)).rejects.toThrow('pending project move')
expect(readFileSync(profile.databasePath)).toEqual(before)
expect(readFileSync(intentPath, 'utf8')).toBe('{"partial":true}')
})
it('refuses recovery before any mutation when a runtime has already entered', async () => {
const profile = await fixture()
const original = readFileSync(profile.databasePath)
const backup = readFileSync(profile.backupPath)
const admission = acquireProfileStateRuntimeAdmission(profile.root)
const runtime = openProfileStateDatabase(profile.databasePath, profileId)
try {
await expect(rollback(profile)).rejects.toThrow('in use')
expect(mocks.status).not.toHaveBeenCalled()
expect(JSON.parse(readProfileStateSnapshot(runtime.db).json)).toEqual(liveState)
expect(readFileSync(profile.databasePath)).toEqual(original)
expect(readFileSync(profile.backupPath)).toEqual(backup)
expect(
readdirSync(profile.directory).some((name) => name.startsWith('profile-state-corrupt'))
).toBe(false)
} finally {
runtime.db.close()
admission.release()
}
})
it('blocks startup between the stopped census and restoration while preserving complete original and restored state', async () => {
const profile = await fixture()
const original = readFileSync(profile.databasePath)
const backup = readFileSync(profile.backupPath)
mocks.status.mockImplementation(async () => {
const stopped = { result: { app: { running: false }, runtime: { reachable: false } } }
expect(() => acquireProfileStateRuntimeAdmission(profile.root)).toThrow('in use')
expect(() => acquireProfileStateMaintenance(profile.root)).toThrow('in use')
return stopped
})
await rollback(profile)
expect(mocks.status).toHaveBeenCalledOnce()
expect(JSON.parse(state(profile.databasePath).json)).toEqual(backupState)
expect(readFileSync(profile.backupPath)).toEqual(backup)
const quarantine = readdirSync(profile.directory).find((name) =>
name.startsWith('profile-state-corrupt')
)
expect(quarantine).toBeDefined()
if (quarantine === undefined) {
throw new Error('Recovery did not preserve a quarantine')
}
const quarantined = join(profile.directory, quarantine, 'profile-state.db')
expect(readFileSync(quarantined)).toEqual(original)
expect(JSON.parse(state(quarantined).json)).toEqual(liveState)
const admission = acquireProfileStateRuntimeAdmission(profile.root)
expect(JSON.parse(state(profile.databasePath).json)).toEqual(backupState)
admission.release()
})
it('keeps startup blocked after failed durable publication and permits an explicit successful retry', async () => {
const profile = await fixture()
const backup = readFileSync(profile.backupPath)
const rename = durableFileWrite.renameDurableSync
const failure = vi
.spyOn(durableFileWrite, 'renameDurableSync')
.mockImplementation((from, to) => {
if (to === profile.databasePath) {
throw new Error('injected recovery publication failure')
}
rename(from, to)
})
await expect(rollback(profile)).rejects.toThrow('injected recovery publication failure')
const admission = acquireProfileStateRuntimeAdmission(profile.root)
try {
expect(() =>
assertNoRetainedProfileStateExports({
dataFile: profile.dataFile,
databaseFile: profile.databasePath,
profileId
})
).toThrow(ProfileStateRecoveryRequiredError)
} finally {
admission.release()
}
expect(readFileSync(profile.backupPath)).toEqual(backup)
const quarantine = readdirSync(profile.directory).find((name) =>
name.startsWith('profile-state-corrupt')
)
if (quarantine === undefined) {
throw new Error('Recovery did not preserve original state')
}
expect(JSON.parse(state(join(profile.directory, quarantine, 'profile-state.db')).json)).toEqual(
liveState
)
failure.mockRestore()
await rollback(profile)
expect(JSON.parse(state(profile.databasePath).json)).toEqual(backupState)
acquireProfileStateRuntimeAdmission(profile.root).release()
})
it('rejects fabricated or released maintenance handles before replacing any database bytes', async () => {
const profile = await fixture()
const maintenance = acquireProfileStateMaintenance(profile.root)
const original = readFileSync(profile.databasePath)
const options = { ...profile, profileId }
expect(() =>
restoreProfileStateDatabaseBackup({ ...options, maintenance: { ...maintenance } })
).toThrow('acquired')
maintenance.release()
expect(() => restoreProfileStateDatabaseBackup({ ...options, maintenance })).toThrow('released')
expect(readFileSync(profile.databasePath)).toEqual(original)
})
})
+487
View File
@@ -0,0 +1,487 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { basename, join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import * as durableFileWrite from '../../main/durable-file-write'
import * as http1Marker from '../../main/startup/http1-compatibility-marker'
import { readPersistedHttp1CompatibilityMode } from '../../main/startup/http1-compatibility-profile-state'
import {
openProfileStateDatabase,
openProfileStateDatabaseReadOnly,
profileStateDatabaseFile
} from '../../main/persistence/profile-state/profile-state-database'
import {
exportProfileStateJson,
importProfileStateJson
} from '../../main/persistence/profile-state/profile-state-documents'
import {
createProfileStateDatabaseBackupId,
profileStateDatabaseBackupPath
} from '../../main/persistence/profile-state/profile-state-backup-path'
import { writeProfileStateDatabaseSnapshotAsync } from '../../main/persistence/profile-state/profile-state-database-snapshot'
import { profileStateJsonExportPath } from '../../main/persistence/profile-state/profile-state-export-path'
import { main } from '../index'
const { getCliStatusMock, getDefaultUserDataPathMock, runtimeClientConstructorMock } = vi.hoisted(
() => ({
getCliStatusMock: vi.fn(),
getDefaultUserDataPathMock: vi.fn(),
runtimeClientConstructorMock: vi.fn()
})
)
vi.mock('../runtime-client', () => {
class RuntimeClientError extends Error {
readonly code: string
readonly data: unknown
constructor(code: string, message: string, data?: unknown) {
super(message)
this.code = code
this.data = data
}
}
class RuntimeClient {
getCliStatus = getCliStatusMock
constructor(
_userDataPath?: string,
_requestTimeoutMs?: number,
remotePairingCode?: string | null,
environmentSelector?: string | null
) {
runtimeClientConstructorMock(remotePairingCode, environmentSelector)
}
}
return {
RuntimeClient,
RuntimeClientError,
getDefaultUserDataPath: getDefaultUserDataPathMock
}
})
const temporaryDirectories: string[] = []
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true })
}
vi.restoreAllMocks()
runtimeClientConstructorMock.mockReset()
process.exitCode = 0
})
function createProfile(): {
userDataPath: string
dataFile: string
databaseFile: string
exportPath: string
} {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-profile-state-cli-'))
temporaryDirectories.push(userDataPath)
const profileId = 'profile-cli-recovery'
const profileDirectory = join(userDataPath, 'profiles', profileId)
mkdirSync(profileDirectory, { recursive: true })
writeFileSync(
join(userDataPath, 'orca-profile-index.json'),
JSON.stringify({ activeProfileId: profileId, profiles: [{ id: profileId }] }),
'utf8'
)
const dataFile = join(profileDirectory, 'orca-data.json')
const databaseFile = profileStateDatabaseFile(profileDirectory)
const exportPath = profileStateJsonExportPath(dataFile, 1)
writeFileSync(dataFile, JSON.stringify({ settings: { theme: 'old' } }), 'utf8')
writeFileSync(
exportPath,
JSON.stringify({ settings: { theme: 'recovered', electronHttp1CompatibilityMode: true } }),
'utf8'
)
writeFileSync(databaseFile, 'damaged sqlite primary', 'utf8')
writeFileSync(`${databaseFile}-wal`, 'damaged wal sidecar', 'utf8')
return { userDataPath, dataFile, databaseFile, exportPath }
}
async function createDatabaseBackup(
profile: ReturnType<typeof createProfile>,
profileId = 'profile-cli-recovery'
) {
const id = createProfileStateDatabaseBackupId()
const path = profileStateDatabaseBackupPath(profile.databaseFile, id)
const source = openProfileStateDatabase(join(profile.userDataPath, 'backup-source.db'), profileId)
try {
importProfileStateJson(
source.db,
JSON.stringify({
settings: {
theme: 'sqlite-recovered',
electronHttp1CompatibilityMode: true,
httpProxyUrl: 'sealed:unchanged'
},
extensionState: { retained: true }
})
)
await writeProfileStateDatabaseSnapshotAsync(source.db, path)
} finally {
source.db.close()
}
return { id, path }
}
describe('profile-state CLI recovery', () => {
beforeEach(() => {
getCliStatusMock.mockResolvedValue({
id: 'status',
ok: true,
result: {
app: { running: false, pid: null },
runtime: { state: 'not_running', reachable: false, runtimeId: null },
graph: { state: 'not_running' }
},
_meta: { runtimeId: 'test' }
})
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
})
it('restores the selected export through the offline CLI command', async () => {
const profile = createProfile()
getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath)
await main(['profile', 'state', 'rollback', '--revision', '1', '--json'], profile.userDataPath)
expect(existsSync(profile.databaseFile)).toBe(false)
expect(readFileSync(profile.dataFile, 'utf8')).toBe(
JSON.stringify({ settings: { theme: 'recovered', electronHttp1CompatibilityMode: true } })
)
expect(
JSON.parse(readFileSync(join(profile.userDataPath, 'http1-compatibility.json'), 'utf8'))
).toMatchObject({
enabled: true,
profileId: 'profile-cli-recovery'
})
const output = vi.mocked(console.log).mock.calls.at(-1)?.[0]
expect(String(output)).toContain('quarantineDirectory')
expect(getCliStatusMock).toHaveBeenCalledOnce()
})
it('adopts current JSON through CLI with an honest source description', async () => {
const profile = createProfile()
getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath)
const original = readFileSync(profile.dataFile)
await main(['profile', 'state', 'rollback', '--current-json'], profile.userDataPath)
expect(readFileSync(profile.dataFile)).toEqual(original)
expect(existsSync(profile.databaseFile)).toBe(false)
const output = String(vi.mocked(console.log).mock.calls.at(-1)?.[0])
expect(output).toContain('source: current JSON')
expect(output).not.toContain('revision:')
})
it.each([
['--current-json', '--revision', '1'],
['--current-json', '--backup', '1'],
['--current-json=false']
])('rejects ambiguous current JSON arguments: %s', async (...flags) => {
getCliStatusMock.mockClear()
const profile = createProfile()
getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath)
await main(['profile', 'state', 'rollback', ...flags, '--json'], profile.userDataPath)
expect(process.exitCode).toBe(1)
expect(existsSync(profile.databaseFile)).toBe(true)
expect(getCliStatusMock).not.toHaveBeenCalled()
})
it('keeps profile-state recovery local when remote selection is configured', async () => {
const profile = createProfile()
getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath)
vi.stubEnv('ORCA_PAIRING_CODE', 'remote-pairing-code')
vi.stubEnv('ORCA_ENVIRONMENT', 'stale-environment')
await main(['profile', 'state', 'rollback', '--revision', '1', '--json'], profile.userDataPath)
expect(runtimeClientConstructorMock).toHaveBeenCalledWith(null, null)
expect(vi.mocked(console.log).mock.calls.at(-1)?.[0]).toContain('quarantineDirectory')
})
it.each([true, false])(
'recovers an absent database and archives every export (legacy JSON present: %s)',
async (hasJson) => {
const profile = createProfile()
getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath)
rmSync(profile.databaseFile)
rmSync(`${profile.databaseFile}-wal`)
if (!hasJson) {
rmSync(profile.dataFile)
}
const laterExport = profileStateJsonExportPath(profile.dataFile, 2)
writeFileSync(laterExport, JSON.stringify({ settings: { theme: 'later' } }))
const selectedBytes = readFileSync(profile.exportPath)
const laterBytes = readFileSync(laterExport)
await main(
['profile', 'state', 'rollback', '--revision', '1', '--json'],
profile.userDataPath
)
expect(process.exitCode).toBe(0)
expect(readFileSync(profile.dataFile)).toEqual(selectedBytes)
expect(existsSync(profile.exportPath)).toBe(false)
expect(existsSync(laterExport)).toBe(false)
const output: unknown = JSON.parse(String(vi.mocked(console.log).mock.calls.at(-1)?.[0]))
expect(output).toMatchObject({ ok: true, result: { removedDatabaseFiles: [] } })
if (
!output ||
typeof output !== 'object' ||
!('result' in output) ||
!output.result ||
typeof output.result !== 'object' ||
!('quarantineDirectory' in output.result) ||
typeof output.result.quarantineDirectory !== 'string'
) {
throw new Error('Expected rollback archive directory')
}
const archive = output.result.quarantineDirectory
expect(readFileSync(join(archive, basename(profile.exportPath)))).toEqual(selectedBytes)
expect(readFileSync(join(archive, basename(laterExport)))).toEqual(laterBytes)
expect(existsSync(join(archive, basename(profile.dataFile)))).toBe(hasJson)
expect(readPersistedHttp1CompatibilityMode(profile.userDataPath)).toBe(true)
}
)
it('preserves all live recovery sources when archiving an export fails', async () => {
const profile = createProfile()
getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath)
const unavailableExport = profileStateJsonExportPath(profile.dataFile, 2)
mkdirSync(unavailableExport)
const original = readFileSync(profile.dataFile)
await main(['profile', 'state', 'rollback', '--revision', '1', '--json'], profile.userDataPath)
expect(process.exitCode).toBe(1)
expect(readFileSync(profile.dataFile)).toEqual(original)
expect(existsSync(profile.exportPath)).toBe(true)
expect(existsSync(profile.databaseFile)).toBe(true)
expect(existsSync(`${profile.databaseFile}-wal`)).toBe(true)
})
it('falls back to restored settings when refreshing the marker fails', async () => {
const profile = createProfile()
getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath)
http1Marker.writeHttp1CompatibilityMarker(profile.userDataPath, false, 'profile-cli-recovery')
const writeFileDurableSync = durableFileWrite.writeFileDurableSync
vi.spyOn(durableFileWrite, 'writeFileDurableSync').mockImplementation(
(tmp, target, contents) => {
if (target === join(profile.userDataPath, http1Marker.HTTP1_COMPATIBILITY_MARKER_FILE)) {
throw new Error('injected marker write failure')
}
return writeFileDurableSync(tmp, target, contents)
}
)
await main(['profile', 'state', 'rollback', '--revision', '1', '--json'], profile.userDataPath)
expect(existsSync(profile.databaseFile)).toBe(false)
expect(
http1Marker.readHttp1CompatibilityMarker(profile.userDataPath, 'profile-cli-recovery')
).toBeNull()
expect(readPersistedHttp1CompatibilityMode(profile.userDataPath)).toBe(true)
expect(String(vi.mocked(console.log).mock.calls.at(-1)?.[0])).toContain('quarantineDirectory')
})
it('preserves SQLite authority when the old marker cannot be invalidated', async () => {
const profile = createProfile()
getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath)
// A directory at the marker path makes non-recursive removal fail on every supported OS.
mkdirSync(join(profile.userDataPath, http1Marker.HTTP1_COMPATIBILITY_MARKER_FILE))
await main(['profile', 'state', 'rollback', '--revision', '1', '--json'], profile.userDataPath)
expect(readFileSync(profile.databaseFile, 'utf8')).toBe('damaged sqlite primary')
expect(existsSync(profile.exportPath)).toBe(true)
expect(readFileSync(profile.dataFile, 'utf8')).toBe(
JSON.stringify({ settings: { theme: 'old' } })
)
expect(String(vi.mocked(console.log).mock.calls.at(-1)?.[0])).not.toContain(
'quarantineDirectory'
)
expect(process.exitCode).toBe(1)
})
it('does not invalidate the active setting for an invalid recovery export', async () => {
const profile = createProfile()
getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath)
http1Marker.writeHttp1CompatibilityMarker(profile.userDataPath, true, 'profile-cli-recovery')
writeFileSync(profile.exportPath, 'invalid JSON')
await main(['profile', 'state', 'rollback', '--revision', '1', '--json'], profile.userDataPath)
expect(existsSync(profile.databaseFile)).toBe(true)
expect(
http1Marker.readHttp1CompatibilityMarker(profile.userDataPath, 'profile-cli-recovery')
).toBe(true)
expect(process.exitCode).toBe(1)
})
it('rejects an explicit remote selector instead of silently ignoring it', async () => {
const profile = createProfile()
getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath)
await main(
['profile', 'state', 'rollback', '--revision', '1', '--environment', 'remote', '--json'],
profile.userDataPath
)
expect(existsSync(profile.databaseFile)).toBe(true)
expect(vi.mocked(console.log).mock.calls.at(-1)?.[0]).toContain(
'`--environment` does not retarget profile-state recovery'
)
})
it.each([['--revision', '1'], ['--current-json']])(
'refuses rollback while runtime is reachable: %s',
async (...flags) => {
const profile = createProfile()
getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath)
getCliStatusMock.mockResolvedValueOnce({
id: 'status',
ok: true,
result: {
app: { running: true, pid: 123 },
runtime: { state: 'ready', reachable: true, runtimeId: 'desktop' },
graph: { state: 'ready' }
},
_meta: { runtimeId: 'test' }
})
await main(['profile', 'state', 'rollback', ...flags], profile.userDataPath)
expect(existsSync(profile.databaseFile)).toBe(true)
expect(readFileSync(profile.dataFile, 'utf8')).toBe(
JSON.stringify({ settings: { theme: 'old' } })
)
expect(vi.mocked(console.error).mock.calls.at(-1)?.[0]).toContain('Stop Orca')
}
)
it('lists SQLite backups alongside JSON exports without opening the damaged primary', async () => {
const profile = createProfile()
const backup = await createDatabaseBackup(profile)
getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath)
getCliStatusMock.mockClear()
await main(['profile', 'state', 'exports', '--json'], profile.userDataPath)
const output: unknown = JSON.parse(String(vi.mocked(console.log).mock.calls.at(-1)?.[0]))
expect(output).toMatchObject({
ok: true,
result: { exportPaths: [profile.exportPath], backups: [{ id: backup.id, path: backup.path }] }
})
expect(getCliStatusMock).not.toHaveBeenCalled()
})
it.each([true, false])(
'restores SQLite backup authority with damaged database present=%s',
async (hasDatabase) => {
const profile = createProfile()
const backup = await createDatabaseBackup(profile)
getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath)
http1Marker.writeHttp1CompatibilityMarker(profile.userDataPath, false, 'profile-cli-recovery')
if (!hasDatabase) {
rmSync(profile.databaseFile)
rmSync(`${profile.databaseFile}-wal`)
rmSync(profile.dataFile)
}
await main(
['profile', 'state', 'rollback', '--backup', backup.id, '--json'],
profile.userDataPath
)
expect(process.exitCode).toBe(0)
expect(existsSync(profile.dataFile)).toBe(false)
expect(existsSync(backup.path)).toBe(true)
const restored = openProfileStateDatabaseReadOnly(
profile.databaseFile,
'profile-cli-recovery'
)
try {
expect(JSON.parse(exportProfileStateJson(restored.db))).toMatchObject({
settings: { theme: 'sqlite-recovered', httpProxyUrl: 'sealed:unchanged' },
extensionState: { retained: true }
})
} finally {
restored.db.close()
}
expect(
http1Marker.readHttp1CompatibilityMarker(profile.userDataPath, 'profile-cli-recovery')
).toBe(true)
expect(String(vi.mocked(console.log).mock.calls.at(-1)?.[0])).toContain('"storage": "sqlite"')
}
)
it('rejects a backup belonging to another profile before invalidating the startup marker', async () => {
const profile = createProfile()
const backup = await createDatabaseBackup(profile, 'foreign-profile')
getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath)
http1Marker.writeHttp1CompatibilityMarker(profile.userDataPath, true, 'profile-cli-recovery')
await main(
['profile', 'state', 'rollback', '--backup', backup.id, '--json'],
profile.userDataPath
)
expect(process.exitCode).toBe(1)
expect(readFileSync(profile.databaseFile, 'utf8')).toBe('damaged sqlite primary')
expect(
http1Marker.readHttp1CompatibilityMarker(profile.userDataPath, 'profile-cli-recovery')
).toBe(true)
})
it('requires an unambiguous retained backup selection', async () => {
const profile = createProfile()
const backup = await createDatabaseBackup(profile)
getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath)
await main(
['profile', 'state', 'rollback', '--backup', backup.id, '--revision', '1', '--json'],
profile.userDataPath
)
expect(process.exitCode).toBe(1)
expect(readFileSync(profile.databaseFile, 'utf8')).toBe('damaged sqlite primary')
expect(String(vi.mocked(console.log).mock.calls.at(-1)?.[0])).toContain('exactly one')
})
it('rejects escaping backup IDs without touching any recovery artifact', async () => {
const profile = createProfile()
getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath)
await main(
['profile', 'state', 'rollback', '--backup', '../../outside', '--json'],
profile.userDataPath
)
expect(process.exitCode).toBe(1)
expect(readFileSync(profile.databaseFile, 'utf8')).toBe('damaged sqlite primary')
expect(String(vi.mocked(console.log).mock.calls.at(-1)?.[0])).toContain('backup is unavailable')
})
it('refuses database backup restoration while the app is running', async () => {
const profile = createProfile()
const backup = await createDatabaseBackup(profile)
getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath)
getCliStatusMock.mockResolvedValueOnce({
result: { app: { running: true }, runtime: { reachable: false } }
})
await main(
['profile', 'state', 'rollback', '--backup', backup.id, '--json'],
profile.userDataPath
)
expect(process.exitCode).toBe(1)
expect(readFileSync(profile.databaseFile, 'utf8')).toBe('damaged sqlite primary')
expect(String(vi.mocked(console.log).mock.calls.at(-1)?.[0])).toContain('Stop Orca')
})
})
+153
View File
@@ -0,0 +1,153 @@
import type { CommandHandler } from '../dispatch'
import { printResult } from '../format'
import { rejectRemoteSelectionFlags } from '../remote-selection-flag-rejection'
import {
getDefaultUserDataPath,
RuntimeClientError,
type RuntimeClient,
type RuntimeRpcSuccess
} from '../runtime-client'
import {
getProfileStateExports,
rollbackProfileState
} from '../../main/persistence/profile-state/profile-state-recovery-command'
import { acquireProfileStateMaintenance } from '../../main/persistence/profile-state/profile-state-access'
import {
isProfileStateRecoveryCommandError,
type ProfileStateExportsResult,
type ProfileStateRollbackResult,
type ProfileStateRecoverySelector
} from '../../shared/profile-state-recovery-command'
import {
canLaunchProfileStateRecovery,
launchProfileStateRecovery
} from '../runtime/profile-state-recovery-launch'
function localSuccess<TResult>(result: TResult): RuntimeRpcSuccess<TResult> {
return {
id: 'local',
ok: true,
result,
_meta: { runtimeId: 'local' }
}
}
function formatExports(result: ProfileStateExportsResult): string {
return [
`profileId: ${result.profileId}`,
`dataFile: ${result.dataFile}`,
`databaseFile: ${result.databaseFile}`,
'JSON exports:',
...(result.exportPaths.length > 0 ? result.exportPaths : ['(none)']),
'SQLite backups:',
...(result.backups.length > 0
? result.backups.map((backup) => `${backup.id}: ${backup.path}`)
: ['(none)'])
].join('\n')
}
function formatRollback(result: ProfileStateRollbackResult): string {
return [
`profileId: ${result.profileId}`,
result.revision === null ? 'source: current JSON' : `revision: ${result.revision}`,
`storage: ${result.storage}`,
`restored: ${result.restoredPath}`,
`quarantine: ${result.quarantineDirectory}`,
`removedDatabaseFiles: ${result.removedDatabaseFiles.length}`
].join('\n')
}
function rejectProfileStateRemoteSelection(flags: ReadonlyMap<string, string | boolean>): void {
rejectRemoteSelectionFlags(
flags,
"profile-state recovery; it operates on this machine's active profile."
)
}
async function requireStoppedRuntime(client: RuntimeClient): Promise<void> {
const status = await client.getCliStatus()
if (status.result.runtime.reachable || status.result.app.running) {
throw new RuntimeClientError(
'runtime_error',
'Stop Orca before profile-state rollback so no process can write the SQLite database.'
)
}
}
function parseRevision(flags: Map<string, string | boolean>): number {
const rawRevision = flags.get('revision')
if (typeof rawRevision !== 'string' || rawRevision.length === 0) {
throw new RuntimeClientError('invalid_argument', 'Profile-state rollback requires --revision.')
}
const revision = Number(rawRevision)
if (!Number.isSafeInteger(revision) || revision < 1) {
throw new RuntimeClientError(
'invalid_argument',
`Invalid profile-state revision: ${rawRevision}`
)
}
return revision
}
export const PROFILE_STATE_HANDLERS: Record<string, CommandHandler> = {
'profile state exports': async ({ flags, json }) => {
rejectProfileStateRemoteSelection(flags)
const result = translateRecoveryError(() => getProfileStateExports(getDefaultUserDataPath()))
printResult(localSuccess(result), json, formatExports)
},
'profile state rollback': async ({ client, flags, json }) => {
rejectProfileStateRemoteSelection(flags)
const selector = parseSelector(flags)
const userDataPath = getDefaultUserDataPath()
let result: ProfileStateRollbackResult
if (canLaunchProfileStateRecovery()) {
await requireStoppedRuntime(client)
result = await launchProfileStateRecovery({ userDataPath, selector })
} else {
const maintenance = acquireProfileStateMaintenance(userDataPath)
try {
await requireStoppedRuntime(client)
result = translateRecoveryError(() =>
rollbackProfileState(userDataPath, selector, maintenance)
)
} finally {
maintenance.release()
}
}
printResult(localSuccess(result), json, formatRollback)
}
}
function parseSelector(flags: Map<string, string | boolean>): ProfileStateRecoverySelector {
if (['revision', 'backup', 'current-json'].filter((flag) => flags.has(flag)).length !== 1) {
throw new RuntimeClientError(
'invalid_argument',
'Select exactly one of --revision, --backup, or --current-json.'
)
}
if (flags.has('current-json')) {
if (flags.get('current-json') !== true) {
throw new RuntimeClientError('invalid_argument', '--current-json does not take a value.')
}
return { kind: 'current-json' }
}
if (!flags.has('backup')) {
return { kind: 'json', revision: parseRevision(flags) }
}
const backupId = flags.get('backup')
if (typeof backupId !== 'string' || backupId.length === 0) {
throw new RuntimeClientError('invalid_argument', 'Profile-state rollback requires --backup.')
}
return { kind: 'sqlite', backupId }
}
function translateRecoveryError<T>(operation: () => T): T {
try {
return operation()
} catch (error) {
if (isProfileStateRecoveryCommandError(error)) {
throw new RuntimeClientError(error.code, error.message)
}
throw error
}
}
+2 -1
View File
@@ -38,7 +38,8 @@ function shouldIgnoreRemoteSelection(commandPath: string[]): boolean {
commandPath[0] === 'serve' ||
commandPath[0] === 'agent' ||
commandPath[0] === 'vm' ||
commandPath[0] === 'agent-context'
commandPath[0] === 'agent-context' ||
commandPath[0] === 'profile'
)
}
+13
View File
@@ -0,0 +1,13 @@
import { getActiveProfileStateLocation as resolveActiveProfileStateLocation } from '../main/persistence/profile-state/profile-state-active-location'
import { RuntimeClientError, getDefaultUserDataPath } from './runtime-client'
export function getActiveProfileStateLocation(userDataPath = getDefaultUserDataPath()) {
try {
return resolveActiveProfileStateLocation(userDataPath)
} catch (error) {
throw new RuntimeClientError(
'runtime_error',
error instanceof Error ? error.message : String(error)
)
}
}
+3 -1
View File
@@ -153,7 +153,9 @@ describe('RuntimeClient module-graph deferral', () => {
async (_name, argv, constructs) => {
vi.stubEnv('ORCA_PAIRING_CODE', 'pairing-code')
vi.stubEnv('ORCA_ENVIRONMENT', 'some-environment')
getCliStatusMock.mockResolvedValue({ result: { runtime: { reachable: false } } })
getCliStatusMock.mockResolvedValue({
result: { runtime: { reachable: false }, app: { running: false } }
})
await main(argv, '/tmp/repo')
+3 -3
View File
@@ -255,7 +255,7 @@ function waitForRecipeJson(child: ReturnType<typeof spawnProcess>): Promise<numb
})
}
function getExecutableAppArgs(executable: string): string[] {
export function getExecutableAppArgs(executable: string): string[] {
const args = process.env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT === '1' ? [resolveAppRoot()] : []
if (shouldDisableExtractedAppImageSandbox(executable)) {
args.push('--no-sandbox')
@@ -289,14 +289,14 @@ function getExecutableSpawnOptions(executable: string): Pick<SpawnOptions, 'shel
return process.platform === 'win32' && /\.(?:cmd|bat)$/i.test(executable) ? { shell: true } : {}
}
function resolveAppRoot(): string {
export function resolveAppRoot(): string {
// Why: dev-mode resource resolution in the Electron child may consult
// process.cwd(). Pin it to the app root so `orca serve` behaves the same
// regardless of the shell directory it was launched from.
return resolve(__dirname, '../../..')
}
function resolveForegroundOrcaExecutable(): string {
export function resolveForegroundOrcaExecutable(): string {
const overrideExecutable = process.env.ORCA_APP_EXECUTABLE
if (typeof overrideExecutable === 'string' && overrideExecutable.trim().length > 0) {
return overrideExecutable
@@ -0,0 +1,154 @@
import { realpathSync } from 'node:fs'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
PROFILE_STATE_RECOVERY_FLAG,
PROFILE_STATE_RECOVERY_RESULT_PREFIX
} from '../../shared/profile-state-recovery-command'
import {
canLaunchProfileStateRecovery,
launchProfileStateRecovery
} from './profile-state-recovery-launch'
const mocks = vi.hoisted(() => ({ run: vi.fn() }))
vi.mock('../../shared/child-process/run-process', () => ({ runProcess: mocks.run }))
vi.mock('./launch', () => ({
resolveForegroundOrcaExecutable: () => '/packaged/Orca',
resolveAppRoot: () => '/application',
getExecutableAppArgs: () => ['/application'],
stripElectronRunAsNode: (env: NodeJS.ProcessEnv) => {
const clean = { ...env }
delete clean.ELECTRON_RUN_AS_NODE
return clean
}
}))
const result = {
profileId: 'profile',
dataFile: '/root/orca-data.json',
databaseFile: '/root/profile-state.db',
exportPaths: [],
backups: [],
revision: 1,
quarantineDirectory: '/root/quarantine',
removedDatabaseFiles: [],
storage: 'json',
restoredPath: '/root/orca-data.json'
}
const request = { userDataPath: '.', selector: { kind: 'json', revision: 1 } } as const
beforeEach(() => {
mocks.run.mockReset().mockResolvedValue({
code: 0,
signal: null,
timedOut: false,
stdout: `${PROFILE_STATE_RECOVERY_RESULT_PREFIX}${JSON.stringify({ ok: true, result })}\n`,
stderr: ''
})
})
afterEach(() => vi.unstubAllEnvs())
describe('profile-state recovery launch', () => {
it('preserves direct participation only for plain Node without an explicit Electron executable', () => {
vi.stubEnv('ELECTRON_RUN_AS_NODE', undefined)
vi.stubEnv('ORCA_APP_EXECUTABLE', undefined)
expect(canLaunchProfileStateRecovery()).toBe(false)
vi.stubEnv('ELECTRON_RUN_AS_NODE', '1')
expect(canLaunchProfileStateRecovery()).toBe(true)
vi.stubEnv('ELECTRON_RUN_AS_NODE', undefined)
vi.stubEnv('ORCA_APP_EXECUTABLE', '/explicit/Orca')
expect(canLaunchProfileStateRecovery()).toBe(true)
})
it('uses a foreground-safe serve request and binds the canonical recovery root', async () => {
vi.stubEnv('ELECTRON_RUN_AS_NODE', '1')
vi.stubEnv('ORCA_USER_DATA_PATH', '/stale/root')
expect(await launchProfileStateRecovery(request)).toEqual(result)
expect(mocks.run).toHaveBeenCalledWith(
expect.objectContaining({
program: '/packaged/Orca',
args: [
'/application',
'--serve',
PROFILE_STATE_RECOVERY_FLAG,
JSON.stringify({ ...request, userDataPath: realpathSync('.') })
],
env: expect.objectContaining({
ORCA_BACKGROUND_LAUNCH: '1',
ORCA_USER_DATA_PATH: realpathSync('.')
}),
timeoutMs: null
})
)
expect(mocks.run.mock.calls[0][0].env).not.toHaveProperty('ELECTRON_RUN_AS_NODE')
})
it('round-trips current JSON selection without requiring an invented revision', async () => {
const current = { ...result, revision: null }
mocks.run.mockResolvedValue({
code: 0,
signal: null,
timedOut: false,
stdout: `${PROFILE_STATE_RECOVERY_RESULT_PREFIX}${JSON.stringify({ ok: true, result: current })}\n`,
stderr: ''
})
expect(
await launchProfileStateRecovery({ userDataPath: '.', selector: { kind: 'current-json' } })
).toEqual(current)
expect(mocks.run.mock.calls[0][0].args.at(-1)).toContain('"kind":"current-json"')
})
it('preserves a structured refusal from the lock owner', async () => {
mocks.run.mockResolvedValue({
code: 1,
stdout: `${PROFILE_STATE_RECOVERY_RESULT_PREFIX}${JSON.stringify({ ok: false, code: 'invalid_argument', message: 'Backup unavailable' })}`
})
await expect(launchProfileStateRecovery(request)).rejects.toMatchObject({
code: 'invalid_argument',
message: 'Backup unavailable'
})
})
it.each([
{ code: 1 },
{ signal: 'SIGKILL' },
{ timedOut: true },
{ outputTruncated: true },
{ stdout: '' },
{ stdout: `${PROFILE_STATE_RECOVERY_RESULT_PREFIX}{` },
{ stdout: `${PROFILE_STATE_RECOVERY_RESULT_PREFIX}{"ok":true,"result":{}}` },
{
stdout: `${PROFILE_STATE_RECOVERY_RESULT_PREFIX}{}\n${PROFILE_STATE_RECOVERY_RESULT_PREFIX}{}`
}
])('rejects incomplete or ambiguous child results %j', async (override) => {
const original = await mocks.run()
mocks.run.mockResolvedValue({ ...original, ...override })
await expect(launchProfileStateRecovery(request)).rejects.toMatchObject({
code: 'runtime_error'
})
})
it('propagates launch failure without retrying another recovery path', async () => {
mocks.run.mockRejectedValue(new Error('Executable unavailable'))
await expect(launchProfileStateRecovery(request)).rejects.toThrow('Executable unavailable')
expect(mocks.run).toHaveBeenCalledOnce()
})
it('retains bounded child diagnostics when recovery exits without a result', async () => {
mocks.run.mockResolvedValue({
code: null,
signal: 'SIGTRAP',
timedOut: false,
stdout: '',
stderr: `${'x'.repeat(5000)}\nsandbox unavailable\n`
})
await expect(launchProfileStateRecovery(request)).rejects.toMatchObject({
data: {
exitCode: null,
signal: 'SIGTRAP',
timedOut: false,
outputTruncated: false,
stderr: `${'x'.repeat(5000)}\nsandbox unavailable`.slice(-4096)
}
})
expect(mocks.run).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,78 @@
import { realpathSync } from 'node:fs'
import { runProcess } from '../../shared/child-process/run-process'
import {
PROFILE_STATE_RECOVERY_FLAG,
PROFILE_STATE_RECOVERY_RESULT_PREFIX,
profileStateRecoveryResponseSchema,
type ProfileStateRecoveryRequest,
type ProfileStateRollbackResult
} from '../../shared/profile-state-recovery-command'
import {
getExecutableAppArgs,
resolveAppRoot,
resolveForegroundOrcaExecutable,
stripElectronRunAsNode
} from './launch'
import { RuntimeClientError } from './types'
export function canLaunchProfileStateRecovery(): boolean {
return process.env.ELECTRON_RUN_AS_NODE === '1' || !!process.env.ORCA_APP_EXECUTABLE?.trim()
}
export async function launchProfileStateRecovery(
request: ProfileStateRecoveryRequest
): Promise<ProfileStateRollbackResult> {
const executable = resolveForegroundOrcaExecutable()
const userDataPath = realpathSync(request.userDataPath)
const response = await runProcess({
program: executable,
args: [
...getExecutableAppArgs(executable),
'--serve',
PROFILE_STATE_RECOVERY_FLAG,
JSON.stringify({ ...request, userDataPath })
],
cwd: resolveAppRoot(),
env: {
...stripElectronRunAsNode(process.env),
ORCA_BACKGROUND_LAUNCH: '1',
ORCA_USER_DATA_PATH: userDataPath
},
// Recovery may copy large backups; the lock owner must finish or be explicitly terminated.
timeoutMs: null
})
const lines = response.stdout
.split(/\r?\n/)
.filter((line) => line.startsWith(PROFILE_STATE_RECOVERY_RESULT_PREFIX))
if (!response.outputTruncated && lines.length === 1) {
let parsed: unknown
try {
parsed = JSON.parse(lines[0].slice(PROFILE_STATE_RECOVERY_RESULT_PREFIX.length))
} catch {
throw new RuntimeClientError(
'runtime_error',
'Orca recovery returned an invalid response. Inspect retained recovery artifacts before retrying.'
)
}
const result = profileStateRecoveryResponseSchema.safeParse(parsed)
if (result.success) {
if (!result.data.ok) {
throw new RuntimeClientError(result.data.code, result.data.message)
}
if (response.code === 0 && !response.signal && !response.timedOut) {
return result.data.result
}
}
}
throw new RuntimeClientError(
'runtime_error',
'Orca recovery did not complete successfully. Inspect retained recovery artifacts before retrying.',
{
exitCode: response.code,
signal: response.signal,
timedOut: response.timedOut,
outputTruncated: response.outputTruncated ?? false,
stderr: response.stderr.trim().slice(-4096)
}
)
}
+3 -1
View File
@@ -18,6 +18,7 @@ import { VM_COMMAND_SPECS } from './vm'
import { SKILL_COMMAND_SPECS } from './skills'
import { ARTIFACT_COMMAND_SPECS } from './artifacts'
import { SEARCH_COMMAND_SPECS } from './search'
import { PROFILE_STATE_COMMAND_SPECS } from './profile-state'
export const COMMAND_SPECS: CommandSpec[] = [
...CORE_COMMAND_SPECS,
@@ -38,5 +39,6 @@ export const COMMAND_SPECS: CommandSpec[] = [
...VM_COMMAND_SPECS,
...EMULATOR_COMMAND_SPECS,
...SKILL_COMMAND_SPECS,
...SEARCH_COMMAND_SPECS
...SEARCH_COMMAND_SPECS,
...PROFILE_STATE_COMMAND_SPECS
]
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { parseArgs, validateCommandAndFlags } from '../args'
import { PROFILE_STATE_COMMAND_SPECS } from './profile-state'
describe('profile state rollback discovery', () => {
it.each([
{ argv: ['profile', 'state', 'rollback', '--current-json'] },
{ argv: ['--current-json', 'profile', 'state', 'rollback'] },
{ argv: ['profile', '--current-json', 'state', 'rollback'] }
])('parses the current JSON selector as a boolean: $argv', ({ argv }) => {
const parsed = parseArgs(argv)
expect(parsed.commandPath).toEqual(['profile', 'state', 'rollback'])
expect(parsed.flags.get('current-json')).toBe(true)
expect(() => validateCommandAndFlags(PROFILE_STATE_COMMAND_SPECS, parsed)).not.toThrow()
})
it('explains that adoption selects one full state and preserves both copies', () => {
const spec = PROFILE_STATE_COMMAND_SPECS.find((item) => item.path.at(-1) === 'rollback')
expect(spec?.usage).toContain('--current-json')
expect(spec?.notes?.join('\n')).toContain('without merging; both copies are archived')
expect(spec?.examples).toContain('orca profile state rollback --current-json')
})
})
+30
View File
@@ -0,0 +1,30 @@
import type { CommandSpec } from '../args'
import { GLOBAL_FLAGS } from '../args'
export const PROFILE_STATE_COMMAND_SPECS: CommandSpec[] = [
{
path: ['profile', 'state', 'exports'],
summary: 'List retained SQLite backups and JSON exports for profile-state recovery',
usage: 'orca profile state exports [--json]',
allowedFlags: [...GLOBAL_FLAGS]
},
{
path: ['profile', 'state', 'rollback'],
destructive: true,
summary: 'Restore a SQLite backup, retained JSON export, or current JSON profile',
usage:
'orca profile state rollback (--backup <id> | --revision <revision> | --current-json) [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'revision', 'backup', 'current-json'],
notes: [
'Orca must be stopped. Recovery validates the selected artifact and archives the current database family, JSON, and retained recovery artifacts before replacing state.',
'--backup restores SQLite authority; --revision restores a JSON export for an older compatible runtime.',
'--current-json keeps the current orca-data.json, including edits from an older build. It replaces SQLite state without merging; both copies are archived. The next SQLite-capable start imports the selected JSON.'
],
examples: [
'orca profile state exports',
'orca profile state rollback --backup <id>',
'orca profile state rollback --revision 1',
'orca profile state rollback --current-json'
]
}
]
+21
View File
@@ -43,6 +43,7 @@ export class ActiveViewPreference {
/** Set by flushAsync so the quit flush is the final write; see scheduleSave. */
private quitFlushStarted = false
private quitFlushPromise: Promise<void> | null = null
private maintenancePaused = false
constructor(dataFile: string, legacyActiveView: unknown) {
this.file = getActiveViewPreferenceFile(dataFile)
@@ -78,6 +79,9 @@ export class ActiveViewPreference {
return
}
this.writeGeneration += 1
if (this.maintenancePaused) {
return
}
if (this.writeTimer) {
clearTimeout(this.writeTimer)
}
@@ -246,4 +250,21 @@ export class ActiveViewPreference {
await this.pendingWrite
}
}
pauseForMaintenance(): () => void {
if (this.maintenancePaused || this.quitFlushStarted) {
throw new Error('Active-view persistence is already paused')
}
this.maintenancePaused = true
if (this.writeTimer) {
clearTimeout(this.writeTimer)
this.writeTimer = null
}
return () => {
this.maintenancePaused = false
if (this.activeView !== this.persistedActiveView) {
this.scheduleSave()
}
}
}
}
@@ -151,12 +151,14 @@ describe('preserveAgentAuthBeforeRestart', () => {
)
})
it('flushes the store when auth services are missing', async () => {
it('checkpoints admitted state without waiting for ongoing edits when auth services are missing', async () => {
const flushPendingOrThrowAsync = vi.fn()
await preserveAgentAuthBeforeRestart({ store: { flushPendingOrThrowAsync } })
expect(flushPendingOrThrowAsync).toHaveBeenCalledTimes(1)
expect(flushPendingOrThrowAsync).toHaveBeenCalledExactlyOnceWith({
drainToStableGeneration: false
})
})
it('logs secret-free warnings and does not throw when sync fails', async () => {
+1 -1
View File
@@ -44,7 +44,7 @@ export async function preserveAgentAuthBeforeRestart({
const storePreservation = store
? runWithinLifecycleTimeout(
'Store persistence',
() => store.flushPendingOrThrowAsync(),
() => store.flushPendingOrThrowAsync({ drainToStableGeneration: false }),
remainingLifecycleTime(startedAt)
)
: Promise.resolve()
@@ -0,0 +1,154 @@
import type { WebContents } from 'electron'
import { isDeepStrictEqual } from 'node:util'
import type { Automation, AutomationRun } from '../../shared/automations-types'
import { getRepoExecutionHostId } from '../../shared/execution-host'
import type { Store } from '../persistence'
import type { AutomationRunWriter } from './automation-run-writer'
import type { HeadlessAutomationDispatcher } from './headless-dispatch'
import type { HeadlessAutomationDispatchContext } from './headless-dispatch-runner'
import { runHeadlessAutomationDispatch } from './headless-dispatch-runner'
import type { AutomationRunTargetResult } from './run-target-resolution'
import { createAutomationDispatchToken } from './dispatch-tokens'
import { NO_DISPATCH_HOST, sendRendererDispatch } from './dispatch-refusal'
export type AutomationRendererChannel = Pick<WebContents, 'isDestroyed' | 'send'>
export class AutomationDispatchCancelledError extends Error {}
type DispatchContext = Pick<
HeadlessAutomationDispatchContext,
'runPrecheck' | 'markDispatchResult' | 'watchRun'
> & {
store: Store
runs: AutomationRunWriter
isActive(): boolean
getRenderer(): AutomationRendererChannel | null
headlessDispatcher: HeadlessAutomationDispatcher | null
resolveTarget(automation: Automation): AutomationRunTargetResult
}
function definition(automation: Automation) {
const { lastRunAt: _last, updatedAt: _updated, nextRunAt: _next, ...configured } = automation
return configured
}
function destination(target: Extract<AutomationRunTargetResult, { ok: true }>) {
return {
cwd: target.cwd,
repoId: target.repo.id,
repoPath: target.repo.path,
host: getRepoExecutionHostId(target.repo),
setupId: target.setup?.id
}
}
/** Claim durably, then recheck everything that an acknowledgement wait can invalidate. */
export async function requestAutomationDispatch(
ctx: DispatchContext,
automation: Automation,
run: AutomationRun,
expectedTarget: AutomationRunTargetResult
): Promise<AutomationRun> {
const expectedDefinition = structuredClone(definition(automation))
const expectedDestination = expectedTarget.ok ? destination(expectedTarget) : undefined
const readRun = (): AutomationRun => {
if (!ctx.isActive()) {
throw new AutomationDispatchCancelledError(
'Orca stopped before this automation could launch.'
)
}
const current = ctx.store.listAutomationRuns(automation.id).find((entry) => entry.id === run.id)
if (!current || !ctx.store.listAutomations().some((entry) => entry.id === automation.id)) {
throw new AutomationDispatchCancelledError(
'The automation was removed before it could launch.'
)
}
return current
}
const resolveCurrentTarget = (): AutomationRunTargetResult => {
const current = ctx.store.listAutomations().find((entry) => entry.id === automation.id)
if (!current || !isDeepStrictEqual(expectedDefinition, definition(current))) {
return { ok: false, error: 'The automation changed before this run could launch.' }
}
const target = ctx.resolveTarget(current)
if (
target.ok &&
expectedDestination &&
!isDeepStrictEqual(expectedDestination, destination(target))
) {
return {
ok: false,
error: 'The automation destination changed before this run could launch.'
}
}
return target
}
const refuse = (error: string) =>
ctx.runs.updateRun({
runId: run.id,
status: 'skipped_unavailable',
workspaceId: automation.workspaceId,
error
})
const returnDurable = async (current: AutomationRun) => {
await ctx.store.flushPendingOrThrowAsync({ drainToStableGeneration: false })
return current
}
run = readRun()
if (run.status !== 'pending') {
return returnDurable(run)
}
let target = resolveCurrentTarget()
if (!target.ok || (!ctx.getRenderer() && !ctx.headlessDispatcher)) {
return refuse(target.ok ? NO_DISPATCH_HOST : target.error)
}
await ctx.runs.updateRun({
runId: run.id,
status: 'dispatching',
workspaceId: automation.workspaceId,
error: null
})
run = readRun()
if (run.status !== 'dispatching') {
return returnDurable(run)
}
target = resolveCurrentTarget()
if (!target.ok) {
return refuse(target.error)
}
const renderer = ctx.getRenderer()
if (renderer) {
return sendRendererDispatch(
renderer,
{
automation,
run,
dispatchToken: createAutomationDispatchToken(automation.id, run.id)
},
ctx.runs,
run
)
}
const dispatcher = ctx.headlessDispatcher
if (!dispatcher) {
return refuse(NO_DISPATCH_HOST)
}
return runHeadlessAutomationDispatch({
...ctx,
automation,
run,
target,
dispatcher: (request) => {
if (readRun().status !== 'dispatching') {
throw new AutomationDispatchCancelledError('The run changed before its agent could launch.')
}
const latestTarget = resolveCurrentTarget()
if (!latestTarget.ok) {
throw new AutomationDispatchCancelledError(latestTarget.error)
}
return dispatcher({ ...request, target: latestTarget })
}
})
}
@@ -5,20 +5,27 @@
*/
import { describe, expect, it, vi } from 'vitest'
import { createAutomationRunWriter } from './automation-run-writer'
import { AutomationService } from './service'
import { collectAutomationRunUsage } from './run-usage-collection'
import { buildProfileStateCutoverFixture } from '../persistence/profile-state-cutover-fixture'
import type { Store } from '../persistence'
import type { Automation, AutomationRun } from '../../shared/automations-types'
const SSH_SELECTOR = { kind: 'ssh', targetId: 'ssh-1' } as const
const data = buildProfileStateCutoverFixture('/fixture')
const automation = { ...data.automations[0], id: 'auto-1' }
const run = { ...data.automationRuns[0], id: 'run-1', automationId: automation.id }
function writerWith(selector: ReturnType<Store['automationChangeSelector']>) {
const publish = vi.fn()
const automationChangeSelector = vi.fn(() => selector)
const store = {
createAutomationRun: vi.fn(() => ({ id: 'run-1', automationId: 'auto-1' }) as AutomationRun),
updateAutomationRun: vi.fn(() => ({ id: 'run-1', automationId: 'auto-1' }) as AutomationRun),
flushPendingOrThrowAsync: vi.fn().mockResolvedValue(undefined),
createAutomationRun: vi.fn(() => run),
updateAutomationRun: vi.fn(() => run),
recordRepeatedAutomationSkip: vi.fn(() => null),
advanceAutomationNextRun: vi.fn(() => automation),
automationChangeSelector
} as unknown as Store
}
return {
publish,
automationChangeSelector,
@@ -28,61 +35,68 @@ function writerWith(selector: ReturnType<Store['automationChangeSelector']>) {
}
describe('automation run writer publications', () => {
it('names the host a created run belongs to', () => {
it('waits for durable acknowledgement before publishing or returning a run', async () => {
const { store, writer, publish } = writerWith(SSH_SELECTOR)
const acknowledgement = Promise.withResolvers<void>()
vi.spyOn(store, 'flushPendingOrThrowAsync').mockReturnValue(acknowledgement.promise)
const completed = vi.fn()
const pending = writer.updateRun({ runId: 'run-1', status: 'dispatching' }).then(completed)
await Promise.resolve()
expect(publish).not.toHaveBeenCalled()
expect(completed).not.toHaveBeenCalled()
acknowledgement.resolve()
await pending
expect(publish).toHaveBeenCalledOnce()
expect(completed).toHaveBeenCalledOnce()
})
it('rejects a failed write without publishing success', async () => {
const { store, writer, publish } = writerWith(SSH_SELECTOR)
vi.spyOn(store, 'flushPendingOrThrowAsync').mockRejectedValue(new Error('disk full'))
await expect(writer.updateRun({ runId: 'run-1', status: 'completed' })).rejects.toThrow(
'disk full'
)
expect(publish).not.toHaveBeenCalled()
})
it('names the host a created run belongs to', async () => {
const { writer, publish } = writerWith(SSH_SELECTOR)
writer.createRun({ id: 'auto-1' } as Automation, 0, 'scheduled')
await writer.createRun(automation, 0, 'scheduled')
expect(publish).toHaveBeenCalledWith({ reason: 'run', selector: SSH_SELECTOR })
})
it('resolves the host from the written run, which is all a dispatch result names', () => {
it('resolves the host from the written run, which is all a dispatch result names', async () => {
const { writer, publish, automationChangeSelector } = writerWith(SSH_SELECTOR)
writer.updateRun({ runId: 'run-1', status: 'completed', usage: null })
await writer.updateRun({ runId: 'run-1', status: 'completed', usage: null })
expect(automationChangeSelector).toHaveBeenCalledWith('auto-1')
expect(publish).toHaveBeenCalledWith({ reason: 'run', selector: SSH_SELECTOR })
})
it('keeps the usage reason on a usage-bearing write', () => {
it('keeps the usage reason on a usage-bearing write', async () => {
const { writer, publish } = writerWith({ kind: 'self' })
writer.updateRun({
await writer.updateRun({
runId: 'run-1',
status: 'completed',
usage: { status: 'known' } as AutomationRun['usage']
usage: await collectAutomationRunUsage({
automation,
run,
claudeUsage: null,
codexUsage: null
})
})
expect(publish).toHaveBeenCalledWith({ reason: 'usage', selector: { kind: 'self' } })
})
// Over-broad beats silent: a subscriber must still hear that something changed.
it('falls back to the whole authority when the record can no longer be named', () => {
it('falls back to the whole authority when the record can no longer be named', async () => {
const { writer, publish } = writerWith(null)
writer.createRun({ id: 'auto-1' } as Automation, 0, 'scheduled')
await writer.createRun(automation, 0, 'scheduled')
expect(publish).toHaveBeenCalledWith({ reason: 'run' })
})
it('does not project a selector nobody will hear', () => {
const automationChangeSelector = vi.fn(() => SSH_SELECTOR)
const store = {
createAutomationRun: vi.fn(() => ({ id: 'run-1', automationId: 'auto-1' }) as AutomationRun),
automationChangeSelector
} as unknown as Store
createAutomationRunWriter(store, null).createRun({ id: 'auto-1' } as Automation, 0, 'scheduled')
it('does not project a selector nobody will hear', async () => {
const { store, automationChangeSelector } = writerWith(SSH_SELECTOR)
await createAutomationRunWriter(store, null).createRun(automation, 0, 'scheduled')
expect(automationChangeSelector).not.toHaveBeenCalled()
})
// Why the renderer dispatch path no longer emits its own: the scoped event is
// published during the write, so it is queued before the reply the caller awaits.
it('publishes before markDispatchResult settles', async () => {
const publish = vi.fn()
const store = {
updateAutomationRun: vi.fn(
() => ({ id: 'run-1', automationId: 'auto-1', status: 'dispatched' }) as AutomationRun
),
automationChangeSelector: vi.fn(() => SSH_SELECTOR)
} as unknown as Store
const service = new AutomationService(store, { onAutomationsChanged: publish })
const settled = service.markDispatchResult({ runId: 'run-1', status: 'dispatched' })
expect(publish).toHaveBeenCalledWith({ reason: 'run', selector: SSH_SELECTOR })
await settled
})
})
+28 -7
View File
@@ -2,18 +2,31 @@ import type { Store } from '../persistence'
import type { PublishAutomationsChanged } from '../../shared/runtime-client-events'
import type { AutomationDispatchResult, AutomationRun } from '../../shared/automations-types'
type DurableWrite<T extends (...args: never[]) => unknown> = (
...args: Parameters<T>
) => Promise<ReturnType<T>>
export type AutomationRunWriter = {
createRun: Store['createAutomationRun']
updateRun: Store['updateAutomationRun']
createRun: DurableWrite<Store['createAutomationRun']>
updateRun: DurableWrite<Store['updateAutomationRun']>
/** Null when nothing could be folded — the caller then writes an ordinary run. */
repeatSkip: Store['recordRepeatedAutomationSkip']
repeatSkip: DurableWrite<Store['recordRepeatedAutomationSkip']>
advanceNextRun: DurableWrite<Store['advanceAutomationNextRun']>
}
/** Wraps run persistence so every committed write announces itself. Clients with
* the Automations page closed — or none attached at all — have no other way to
* learn that a run progressed, so the event must follow the write, not a render. */
export function createAutomationRunWriter(
store: Store,
store: Pick<
Store,
| 'createAutomationRun'
| 'updateAutomationRun'
| 'recordRepeatedAutomationSkip'
| 'advanceAutomationNextRun'
| 'automationChangeSelector'
| 'flushPendingOrThrowAsync'
>,
publish: PublishAutomationsChanged | null
): AutomationRunWriter {
// A run write never moves the record, so its own host is the whole publication.
@@ -26,19 +39,27 @@ export function createAutomationRunWriter(
publish({ reason, ...(selector ? { selector } : {}) })
}
return {
createRun: (automation, scheduledFor, trigger): AutomationRun => {
advanceNextRun: async (id, now) => {
const automation = store.advanceAutomationNextRun(id, now)
await store.flushPendingOrThrowAsync({ drainToStableGeneration: false })
return automation
},
createRun: async (automation, scheduledFor, trigger): Promise<AutomationRun> => {
const run = store.createAutomationRun(automation, scheduledFor, trigger)
await store.flushPendingOrThrowAsync({ drainToStableGeneration: false })
announce(automation.id, 'run')
return run
},
updateRun: (result: AutomationDispatchResult): AutomationRun => {
updateRun: async (result: AutomationDispatchResult): Promise<AutomationRun> => {
const run = store.updateAutomationRun(result)
await store.flushPendingOrThrowAsync({ drainToStableGeneration: false })
announce(run.automationId, result.usage ? 'usage' : 'run')
return run
},
repeatSkip: (automationId, error, scheduledFor): AutomationRun | null => {
repeatSkip: async (automationId, error, scheduledFor): Promise<AutomationRun | null> => {
const run = store.recordRepeatedAutomationSkip(automationId, error, scheduledFor)
if (run) {
await store.flushPendingOrThrowAsync({ drainToStableGeneration: false })
announce(automationId, 'run')
}
return run
@@ -0,0 +1,245 @@
import { describe, expect, it, vi } from 'vitest'
import { AutomationService } from './service'
import type { Store } from '../persistence'
import {
createWorkerMaintenanceFixture,
maintenanceBarrier
} from '../persistence/loading-store/profile-state-maintenance-fixture'
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../telemetry/cohort-classifier', () => ({
getCohortAtEmit: () => ({ nth_repo_added: 2 })
}))
vi.mock('../ssh/ssh-config-parser', () => ({
loadUserSshConfig: () => ({ hosts: [] }),
sshConfigHostsToTargets: () => []
}))
async function fixture() {
const fixture = await createWorkerMaintenanceFixture()
for (const automation of fixture.store.listAutomations()) {
fixture.store.updateAutomation(automation.id, { enabled: false })
}
const automation = fixture.store.createAutomation({
name: 'Durable run',
prompt: 'Check the project',
agentId: 'claude',
projectId: 'repo-local',
workspaceMode: 'existing',
workspaceId: 'repo-local::/fixture/local',
timezone: 'UTC',
rrule: 'FREQ=HOURLY;BYMINUTE=0',
dtstart: Date.now() - 60_000
})
await fixture.store.flushPendingOrThrowAsync()
return { ...fixture, automation }
}
const launch = { workspaceId: 'repo-local::/fixture/local', terminalSessionId: 'run-tab' }
function blockAcknowledgement(store: Store, index: number) {
const gate = maintenanceBarrier()
const blocked = maintenanceBarrier()
const flush = store.flushPendingOrThrowAsync.bind(store)
let calls = 0
vi.spyOn(store, 'flushPendingOrThrowAsync').mockImplementation(async (options) => {
calls += 1
if (calls === index) {
blocked.resolve()
await gate.promise
}
await flush(options)
})
return { blocked: blocked.promise, release: gate.resolve }
}
describe('automation background writer durability', () => {
it('claims a shared occurrence once when callers await the same pending row', async () => {
vi.spyOn(Date, 'now').mockReturnValue(1_800_000_000_000)
const { store, automation } = await fixture()
const dispatcher = vi.fn(async () => launch)
const service = new AutomationService(store, { headlessDispatcher: dispatcher })
try {
const runs = await Promise.all([service.runNow(automation.id), service.runNow(automation.id)])
expect(new Set(runs.map((run) => run.id)).size).toBe(1)
expect(dispatcher).toHaveBeenCalledOnce()
} finally {
service.stop()
}
})
it('persists dispatch intent before starting external work', async () => {
const { store, automation, readState } = await fixture()
const gate = maintenanceBarrier()
const flush = store.flushPendingOrThrowAsync.bind(store)
vi.spyOn(store, 'flushPendingOrThrowAsync').mockImplementationOnce(async (options) => {
await gate.promise
await flush(options)
})
const dispatcher = vi.fn(async () => {
expect(readState().automationRuns).toContainEqual(
expect.objectContaining({ automationId: automation.id, status: 'dispatching' })
)
return launch
})
const service = new AutomationService(store, { headlessDispatcher: dispatcher })
try {
const run = service.runNow(automation.id)
await Promise.resolve()
expect(dispatcher).not.toHaveBeenCalled()
gate.resolve()
await expect(run).resolves.toMatchObject({ status: 'dispatched' })
expect(dispatcher).toHaveBeenCalledOnce()
} finally {
service.stop()
}
})
it('never dispatches when the writer refuses the durable acknowledgement', async () => {
const { store, automation } = await fixture()
vi.spyOn(store, 'flushPendingOrThrowAsync').mockRejectedValueOnce(new Error('disk full'))
const dispatcher = vi.fn(async () => launch)
const service = new AutomationService(store, { headlessDispatcher: dispatcher })
try {
await expect(service.runNow(automation.id)).rejects.toThrow('disk full')
expect(dispatcher).not.toHaveBeenCalled()
} finally {
service.stop()
}
})
it.each([
['dispatching', 1],
['dispatched', 1],
['completed', 1],
['dispatching', 120_001],
['dispatched', 120_001],
['completed', 120_001]
] as const)(
'preserves a durable %s occurrence after %s ms when next-run advancement was interrupted',
async (status, lateness) => {
const clock = vi.spyOn(Date, 'now').mockReturnValue(1_800_000_000_000)
const { store, automation, readState } = await fixture()
store.updateAutomation(automation.id, { missedRunGraceMinutes: 0 })
const dueAt = automation.nextRunAt
const run = store.createAutomationRun(automation, dueAt)
store.updateAutomationRun({ runId: run.id, status })
await store.flushPendingOrThrowAsync()
clock.mockReturnValue(dueAt + lateness)
const dispatcher = vi.fn(async () => launch)
const service = new AutomationService(store, { headlessDispatcher: dispatcher })
try {
service.start()
await vi.waitFor(() => {
const stored = readState().automations.find(
(entry: { id: string }) => entry.id === automation.id
)
expect(stored.nextRunAt).toBeGreaterThan(dueAt + lateness)
})
expect(dispatcher).not.toHaveBeenCalled()
expect(store.listAutomationRuns(automation.id)).toHaveLength(1)
expect(store.listAutomationRuns(automation.id)[0].status).toBe(status)
} finally {
service.stop()
}
}
)
it.each([1, 2])('cancels a pending dispatch stopped during acknowledgement %s', async (index) => {
const { store, automation } = await fixture()
const acknowledgement = blockAcknowledgement(store, index)
const dispatcher = vi.fn(async () => launch)
const service = new AutomationService(store, { headlessDispatcher: dispatcher })
const pending = service.runNow(automation.id)
const rejected = expect(pending).rejects.toThrow('stopped before')
await acknowledgement.blocked
service.stop()
acknowledgement.release()
await rejected
expect(dispatcher).not.toHaveBeenCalled()
})
it('does not resurrect or dispatch an automation deleted during its intent acknowledgement', async () => {
const { store, automation, readState } = await fixture()
const acknowledgement = blockAcknowledgement(store, 2)
const dispatcher = vi.fn(async () => launch)
const service = new AutomationService(store, { headlessDispatcher: dispatcher })
try {
const pending = service.runNow(automation.id)
const rejected = expect(pending).rejects.toThrow('removed before')
await acknowledgement.blocked
store.deleteAutomation(automation.id)
acknowledgement.release()
await rejected
expect(dispatcher).not.toHaveBeenCalled()
expect(
readState().automationRuns.some(
(run: { automationId: string }) => run.automationId === automation.id
)
).toBe(false)
} finally {
service.stop()
}
})
it.each([1, 2])('refuses changed instructions during acknowledgement %s', async (index) => {
const { store, automation } = await fixture()
const acknowledgement = blockAcknowledgement(store, index)
const dispatcher = vi.fn(async () => launch)
const service = new AutomationService(store, { headlessDispatcher: dispatcher })
try {
const pending = service.runNow(automation.id)
await acknowledgement.blocked
store.updateAutomation(automation.id, { prompt: 'Different instructions' })
acknowledgement.release()
await expect(pending).resolves.toMatchObject({
status: 'skipped_unavailable',
error: expect.stringContaining('changed before')
})
expect(dispatcher).not.toHaveBeenCalled()
} finally {
service.stop()
}
})
it.each([1, 2])('refuses a moved execution host during acknowledgement %s', async (index) => {
const { store, automation } = await fixture()
const acknowledgement = blockAcknowledgement(store, index)
const dispatcher = vi.fn(async () => launch)
const service = new AutomationService(store, { headlessDispatcher: dispatcher })
try {
const pending = service.runNow(automation.id)
await acknowledgement.blocked
store.updateRepo('repo-local', { executionHostId: 'runtime:other-host' })
acknowledgement.release()
await expect(pending).resolves.toMatchObject({ status: 'skipped_unavailable' })
expect(dispatcher).not.toHaveBeenCalled()
} finally {
service.stop()
}
})
it.each(['disconnect', 'replacement'] as const)(
'refuses an unready renderer after %s during acknowledgement',
async (change) => {
const { store, automation } = await fixture()
const acknowledgement = blockAcknowledgement(store, 2)
const renderer = { isDestroyed: () => false, send: vi.fn() }
const replacement = { isDestroyed: () => false, send: vi.fn() }
const service = new AutomationService(store)
service.setWebContents(renderer)
service.setRendererReady()
try {
const pending = service.runNow(automation.id)
await acknowledgement.blocked
service.setWebContents(change === 'disconnect' ? null : replacement)
acknowledgement.release()
await expect(pending).resolves.toMatchObject({ status: 'skipped_unavailable' })
expect(renderer.send).not.toHaveBeenCalled()
expect(replacement.send).not.toHaveBeenCalled()
} finally {
service.stop()
}
}
)
})
@@ -76,7 +76,11 @@ describe('AutomationService zero-grace tick latency', () => {
service.setWebContents({ isDestroyed: () => false, send: vi.fn() })
service.start()
service.setRendererReady()
await vi.advanceTimersByTimeAsync(0)
await vi.waitFor(() => {
if (store.listAutomations().some((automation) => automation.nextRunAt <= at)) {
throw new Error('Automation evaluation is still saving its next occurrence')
}
})
service.stop()
}
@@ -59,13 +59,14 @@ function makeRunWriter(foldsRepeat: boolean): {
const created: string[] = []
const updated: { status: string; error?: string | null }[] = []
const writer: AutomationRunWriter = {
repeatSkip: () => (foldsRepeat ? makeRun('folded') : null),
createRun: () => {
advanceNextRun: async () => brokenAutomation,
repeatSkip: async () => (foldsRepeat ? makeRun('folded') : null),
createRun: async () => {
const run = makeRun(`run-${created.length + 1}`)
created.push(run.id)
return run
},
updateRun: (args) => {
updateRun: async (args) => {
updated.push({ status: args.status, error: args.error })
return { ...makeRun(args.runId), status: args.status, error: args.error ?? null }
}
@@ -78,11 +79,11 @@ describe('recordUnevaluableAutomation', () => {
vi.restoreAllMocks()
})
it('writes one run and logs once when the record is newly broken', () => {
it('writes one run and logs once when the record is newly broken', async () => {
const logged = vi.spyOn(console, 'error').mockImplementation(() => {})
const { writer, created, updated } = makeRunWriter(false)
recordUnevaluableAutomation({
await recordUnevaluableAutomation({
runs: writer,
automation: brokenAutomation,
error: new Error('Invalid cron day of month.')
@@ -95,12 +96,12 @@ describe('recordUnevaluableAutomation', () => {
// The record is retried every tick on purpose, so a repaired schedule resumes on its own.
// The fold is what keeps that from writing a row, and logging, once per tick forever.
it('stays silent on a record it has already reported', () => {
it('stays silent on a record it has already reported', async () => {
const logged = vi.spyOn(console, 'error').mockImplementation(() => {})
const { writer, created } = makeRunWriter(true)
for (let tick = 0; tick < 5; tick += 1) {
recordUnevaluableAutomation({
await recordUnevaluableAutomation({
runs: writer,
automation: brokenAutomation,
error: new Error('Invalid cron day of month.')
+21 -15
View File
@@ -43,17 +43,17 @@ export function describeScheduledRefusal(input: {
* and doc:94 asks for both. Never dispatches: the reason is the one the
* scheduler would have written for the same record.
*/
export function recordRefusedAutomationRun(input: {
export async function recordRefusedAutomationRun(input: {
store: Store
runs: AutomationRunWriter
automation: Automation
allowRemoteHostScheduling: boolean
}): void {
}): Promise<void> {
const target = resolveAutomationRunTarget(input.store, input.automation, {
allowRemoteHostScheduling: input.allowRemoteHostScheduling
})
const run = input.runs.createRun(input.automation, Date.now(), 'manual')
input.runs.updateRun({
const run = await input.runs.createRun(input.automation, Date.now(), 'manual')
await input.runs.updateRun({
runId: run.id,
status: 'skipped_unavailable',
workspaceId: input.automation.workspaceId,
@@ -66,21 +66,24 @@ export function recordRefusedAutomationRun(input: {
* stalled. Folds on the fixed sentence and the unchanged nextRunAt, so a record that stays
* broken writes one row rather than one per tick, and never throws back into the tick.
*/
export function recordUnevaluableAutomation(input: {
export async function recordUnevaluableAutomation(input: {
runs: AutomationRunWriter
automation: Automation
error: unknown
}): void {
}): Promise<void> {
const { automation } = input
try {
// nextRunAt deliberately stays put: the record is retried so a repaired schedule resumes
// on its own. The fold is what keeps that from writing a row — and logging — every tick.
if (input.runs.repeatSkip(automation.id, UNEVALUABLE_SCHEDULE, automation.nextRunAt)) {
if (await input.runs.repeatSkip(automation.id, UNEVALUABLE_SCHEDULE, automation.nextRunAt)) {
return
}
console.error('[automations] failed to evaluate automation:', automation.id, input.error)
const run = input.runs.createRun(automation, automation.nextRunAt)
input.runs.updateRun({
const run = await input.runs.createRun(automation, automation.nextRunAt)
if (run.status !== 'pending') {
return
}
await input.runs.updateRun({
runId: run.id,
status: 'skipped_unavailable',
workspaceId: automation.workspaceId,
@@ -101,12 +104,12 @@ export function recordUnevaluableAutomation(input: {
* Sends the dispatch request through the renderer channel, closing the run out as
* `dispatch_failed` when the send throws — a failed send is not an unreadable schedule.
*/
export function sendRendererDispatch(
export async function sendRendererDispatch(
channel: Pick<WebContents, 'send'> | null,
payload: AutomationDispatchRequest,
runs: AutomationRunWriter,
run: AutomationRun
): AutomationRun {
): Promise<AutomationRun> {
try {
channel?.send('automations:dispatchRequested', payload)
return run
@@ -153,13 +156,16 @@ export function missedBeyondGrace(input: {
return input.now - input.scheduledFor > graceMs + jitterMs
}
export function recordMissedRun(input: {
export async function recordMissedRun(input: {
runs: AutomationRunWriter
automation: Automation
scheduledFor: number
}): void {
const missed = input.runs.createRun(input.automation, input.scheduledFor)
input.runs.updateRun({
}): Promise<void> {
const missed = await input.runs.createRun(input.automation, input.scheduledFor)
if (missed.status !== 'pending') {
return
}
await input.runs.updateRun({
runId: missed.id,
status: 'skipped_missed',
workspaceId: input.automation.workspaceId,
@@ -0,0 +1,107 @@
import { describe, expect, it, vi } from 'vitest'
import { buildProfileStateCutoverFixture } from '../persistence/profile-state-cutover-fixture'
import type { AutomationRun } from '../../shared/automations-types'
import type { HeadlessAutomationDispatchLaunch } from './headless-dispatch'
import { runHeadlessAutomationDispatch } from './headless-dispatch-runner'
function fixture(launch: HeadlessAutomationDispatchLaunch) {
const state = buildProfileStateCutoverFixture('/fixture')
const automation = { ...state.automations[0], precheck: null }
const run: AutomationRun = {
...state.automationRuns[0],
automationId: automation.id,
status: 'dispatching'
}
const dispatched: AutomationRun = { ...run, ...launch, status: 'dispatched' }
return {
automation,
run,
target: { ok: true as const, cwd: state.repos[0].path, repo: state.repos[0] },
dispatcher: vi.fn(async () => launch),
runs: {
createRun: vi.fn(async () => run),
updateRun: vi.fn(async () => dispatched),
repeatSkip: vi.fn(async () => null),
advanceNextRun: vi.fn(async () => automation)
},
runPrecheck: vi.fn(async () => null),
markDispatchResult: vi.fn(async () => dispatched),
watchRun: vi.fn()
}
}
const terminal = {
workspaceId: 'launched-workspace',
terminalSessionId: 'launched-tab',
terminalPaneKey: 'launched-pane',
terminalPtyId: 'launched-pty'
}
describe('headless automation observation during persistence', () => {
it('handles an early completion rejection while the dispatched write is stalled', async () => {
const completion = Promise.withResolvers<never>()
const acknowledgement = Promise.withResolvers<AutomationRun>()
const context = fixture({ ...terminal, completion: completion.promise })
context.runs.updateRun.mockReturnValueOnce(acknowledgement.promise)
const pending = runHeadlessAutomationDispatch(context)
await vi.waitFor(() => expect(context.runs.updateRun).toHaveBeenCalledOnce())
completion.reject(new Error('agent exited early'))
await vi.waitFor(() =>
expect(context.markDispatchResult).toHaveBeenCalledWith({
runId: context.run.id,
status: 'dispatch_failed',
...terminal,
workspaceDisplayName: null,
error: 'agent exited early'
})
)
acknowledgement.resolve({ ...context.run, ...terminal, status: 'dispatched' })
await pending
})
it('starts terminal observation even when the dispatched write fails after launch', async () => {
const context = fixture(terminal)
const failure = new Error('disk full')
context.runs.updateRun.mockRejectedValueOnce(failure)
await expect(runHeadlessAutomationDispatch(context)).rejects.toBe(failure)
expect(context.watchRun).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
...terminal,
status: 'dispatched'
})
)
expect(context.runs.updateRun).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
...terminal,
status: 'dispatched'
})
)
})
it('keeps receiving completion after the launched run fails to persist', async () => {
const completion = Promise.withResolvers<{ status: 'completed' }>()
const context = fixture({ ...terminal, completion: completion.promise })
context.runs.updateRun.mockRejectedValueOnce(new Error('writer unavailable'))
await expect(runHeadlessAutomationDispatch(context)).rejects.toThrow('writer unavailable')
completion.resolve({ status: 'completed' })
await vi.waitFor(() =>
expect(context.markDispatchResult).toHaveBeenCalledWith(
expect.objectContaining({ ...terminal, status: 'completed' })
)
)
expect(context.runs.updateRun).toHaveBeenCalledOnce()
})
it('records an actual launch rejection as dispatch failure', async () => {
const context = fixture(terminal)
context.dispatcher.mockRejectedValueOnce(new Error('shell unavailable'))
await runHeadlessAutomationDispatch(context)
expect(context.runs.updateRun).toHaveBeenCalledExactlyOnceWith({
runId: context.run.id,
status: 'dispatch_failed',
workspaceId: context.automation.workspaceId,
error: 'shell unavailable'
})
expect(context.watchRun).not.toHaveBeenCalled()
})
})
@@ -8,7 +8,10 @@ import {
didAutomationPrecheckPass,
formatAutomationPrecheckFailure
} from '../../shared/automation-precheck'
import type { HeadlessAutomationDispatcher } from './headless-dispatch'
import type {
HeadlessAutomationDispatcher,
HeadlessAutomationDispatchLaunch
} from './headless-dispatch'
import type { AutomationRunTargetResult } from './run-target-resolution'
import type { AutomationRunWriter } from './automation-run-writer'
@@ -42,47 +45,9 @@ export async function runHeadlessAutomationDispatch(
error: formatAutomationPrecheckFailure(precheckResult)
})
}
let launch: HeadlessAutomationDispatchLaunch
try {
const launch = await ctx.dispatcher({ automation, run, target })
const launchRunTarget = {
workspaceId: launch.workspaceId,
workspaceDisplayName: launch.workspaceDisplayName ?? null,
terminalSessionId: launch.terminalSessionId,
terminalPaneKey: launch.terminalPaneKey ?? null,
terminalPtyId: launch.terminalPtyId ?? null
}
const updated = runs.updateRun({
runId: run.id,
status: 'dispatched',
...launchRunTarget,
error: null
})
if (!launch.completion) {
// Why: a dispatcher that reports no completion promise would otherwise
// leave the run at 'dispatched' for the process lifetime.
ctx.watchRun(updated)
return updated
}
void launch.completion
.then((completion) =>
ctx.markDispatchResult({
runId: run.id,
status: completion.status,
...launchRunTarget,
precheckResult,
outputSnapshot: completion.outputSnapshot ?? null,
error: completion.error ?? null
})
)
.catch((error) =>
ctx.markDispatchResult({
runId: run.id,
status: 'dispatch_failed',
...launchRunTarget,
error: describeDispatchError(error)
})
)
return updated
launch = await ctx.dispatcher({ automation, run, target })
} catch (error) {
return runs.updateRun({
runId: run.id,
@@ -91,4 +56,43 @@ export async function runHeadlessAutomationDispatch(
error: describeDispatchError(error)
})
}
const launchRunTarget = {
workspaceId: launch.workspaceId,
workspaceDisplayName: launch.workspaceDisplayName ?? null,
terminalSessionId: launch.terminalSessionId,
terminalPaneKey: launch.terminalPaneKey ?? null,
terminalPtyId: launch.terminalPtyId ?? null
}
const updated = runs.updateRun({
runId: run.id,
status: 'dispatched',
...launchRunTarget,
error: null
})
// Observe the launched agent even while persistence is stalled or rejects its acknowledgement.
if (!launch.completion) {
ctx.watchRun({ ...run, ...launchRunTarget, status: 'dispatched', error: null })
} else {
void launch.completion
.then(
(completion) =>
ctx.markDispatchResult({
runId: run.id,
status: completion.status,
...launchRunTarget,
precheckResult,
outputSnapshot: completion.outputSnapshot ?? null,
error: completion.error ?? null
}),
(error) =>
ctx.markDispatchResult({
runId: run.id,
status: 'dispatch_failed',
...launchRunTarget,
error: describeDispatchError(error)
})
)
.catch((error) => console.error('[automations] Failed to persist run completion:', error))
}
return updated
}
+2 -2
View File
@@ -19,7 +19,7 @@ import {
type RefusableAutomationService = {
runNow: (automationId: string) => Promise<AutomationRun>
recordRefusedRun: (automationId: string) => void
recordRefusedRun: (automationId: string) => void | Promise<void>
}
export async function runAutomationNowFenced(input: {
@@ -34,7 +34,7 @@ export async function runAutomationNowFenced(input: {
error instanceof AutomationOwnerConflictError &&
error.code === AUTOMATION_OWNER_CONFLICT_CODES.targetRemoved
) {
input.service.recordRefusedRun(input.automationId)
await input.service.recordRefusedRun(input.automationId)
}
throw error
}
@@ -168,6 +168,9 @@ describe('authority-owned automation run completion', () => {
it('reconciles stranded runs on startup without claiming completion', async () => {
const store = await createStore()
const automation = createAutomation(store)
store.updateAutomation(automation.id, { enabled: false })
const pendingManual = store.createAutomationRun(automation, 3_000, 'manual')
const pendingScheduled = store.createAutomationRun(automation, 4_000, 'scheduled')
const dispatched = store.createAutomationRun(automation, 1_000, 'manual')
store.updateAutomationRun({
runId: dispatched.id,
@@ -199,6 +202,11 @@ describe('authority-owned automation run completion', () => {
expect(readRun(store, automation.id, dispatching.id).status).toBe('dispatch_failed')
expect(readRun(store, automation.id, dispatched.id).error).toContain('terminal')
expect(readRun(store, automation.id, dispatching.id).error).toContain('agent started')
expect(readRun(store, automation.id, pendingManual.id)).toMatchObject({
status: 'dispatch_failed',
error: 'Orca stopped before this manual run could launch.'
})
expect(readRun(store, automation.id, pendingScheduled.id).status).toBe('pending')
service.stop()
vi.useRealTimers()
})
@@ -341,7 +349,7 @@ describe('automationsChanged publication', () => {
})
const run = await service.runNow(automation.id)
expect(seen.map((entry) => entry.payload.reason)).toEqual(['run', 'run'])
expect(seen.map((entry) => entry.payload.reason)).toEqual(['run', 'run', 'run'])
expect(seen.at(-1)?.status).toBe('dispatched')
await service.markDispatchResult({
@@ -350,7 +358,7 @@ describe('automationsChanged publication', () => {
...LAUNCH_TARGET,
error: null
})
expect(seen.map((entry) => entry.payload.reason)).toEqual(['run', 'run', 'run', 'usage'])
expect(seen.map((entry) => entry.payload.reason)).toEqual(['run', 'run', 'run', 'run', 'usage'])
expect(seen.at(-1)?.status).toBe('completed')
// Every run/usage write names its own host, so one automation's run cannot
// invalidate the rest of the authority.
@@ -24,6 +24,9 @@ export type AutomationRunTerminalObserver = {
/** Truthful reason for a run this authority can no longer observe; never claims completion. */
export function describeStrandedAutomationRun(run: AutomationRun): string {
if (run.status === 'pending') {
return 'Orca stopped before this manual run could launch.'
}
if (run.status === 'dispatching') {
return 'Orca stopped before this run reported that its agent started.'
}
@@ -139,7 +142,12 @@ export class AutomationRunCompletionWatcher {
* reported ready and still cannot find it. */
reconcileRetainedRuns(runs: readonly AutomationRun[]): void {
this.reconciler.reconcile(
runs.filter((run) => run.status === 'dispatched' || run.status === 'dispatching')
runs.filter(
(run) =>
run.status === 'dispatched' ||
run.status === 'dispatching' ||
(run.status === 'pending' && run.trigger === 'manual')
)
)
}
+66 -72
View File
@@ -1,13 +1,13 @@
import type { WebContents } from 'electron'
/** All the service asks of the renderer: is it still there, and take this message. Narrower
* than WebContents so a test can supply the real shape instead of casting one. */
export type AutomationRendererChannel = Pick<WebContents, 'isDestroyed' | 'send'>
import {
AutomationDispatchCancelledError,
requestAutomationDispatch,
type AutomationRendererChannel
} from './automation-dispatch-request'
export type { AutomationRendererChannel } from './automation-dispatch-request'
import type { Store } from '../persistence'
import {
isFinalAutomationRunStatus,
type Automation,
type AutomationDispatchRequest,
type AutomationDispatchResult,
type AutomationPrecheckResult,
type AutomationRun
@@ -18,8 +18,7 @@ import { runAutomationPrecheck } from './precheck-runner'
import { resolveAutomationRunTarget, type AutomationRunTargetResult } from './run-target-resolution'
import { writeAutomationRunUsage } from './run-usage-collection'
import type { HeadlessAutomationDispatcher } from './headless-dispatch'
import { clearAutomationDispatchTokens, createAutomationDispatchToken } from './dispatch-tokens'
import { runHeadlessAutomationDispatch } from './headless-dispatch-runner'
import { clearAutomationDispatchTokens } from './dispatch-tokens'
import {
AutomationRunCompletionWatcher,
type AutomationRunTerminalObserver
@@ -31,9 +30,7 @@ import {
missedBeyondGrace,
recordMissedRun,
recordRefusedAutomationRun,
recordUnevaluableAutomation,
sendRendererDispatch,
NO_DISPATCH_HOST
recordUnevaluableAutomation
} from './dispatch-refusal'
import type {
AutomationsChangedPayload,
@@ -49,6 +46,8 @@ export class AutomationService {
private webContents: AutomationRendererChannel | null = null
private rendererReady = false
private evaluating = false
private stopped = false
private dispatchGeneration = 0
private readonly claudeUsage: ClaudeUsageStore | null
private readonly codexUsage: CodexUsageStore | null
private readonly allowRemoteHostScheduling: boolean
@@ -114,6 +113,7 @@ export class AutomationService {
if (this.timer) {
return
}
this.stopped = false
this.timer = setInterval(() => {
void this.evaluateDueRuns()
}, this.tickMs)
@@ -130,6 +130,8 @@ export class AutomationService {
}
stop(): void {
this.stopped = true
this.dispatchGeneration += 1
this.completionWatcher?.dispose()
if (!this.timer) {
return
@@ -139,19 +141,21 @@ export class AutomationService {
}
async runNow(automationId: string): Promise<AutomationRun> {
const generation = this.dispatchGeneration
const automation = this.store.listAutomations().find((entry) => entry.id === automationId)
if (!automation) {
throw new Error('Automation not found.')
}
const run = this.runs.createRun(automation, Date.now(), 'manual')
return await this.requestDispatch(automation, run, this.resolveTarget(automation))
const target = this.resolveTarget(automation)
const run = await this.runs.createRun(automation, Date.now(), 'manual')
return await this.requestDispatch(automation, run, target, generation)
}
/** The run-history row doc:94 pairs with the typed refusal an execute fence throws. */
recordRefusedRun(automationId: string): void {
async recordRefusedRun(automationId: string): Promise<void> {
const automation = this.store.listAutomations().find((entry) => entry.id === automationId)
if (automation) {
recordRefusedAutomationRun({
await recordRefusedAutomationRun({
store: this.store,
runs: this.runs,
automation,
@@ -198,7 +202,7 @@ export class AutomationService {
}
async markDispatchResult(result: AutomationDispatchResult): Promise<AutomationRun> {
const run = this.runs.updateRun(result)
const run = await this.runs.updateRun(result)
clearAutomationDispatchTokens(run.automationId, run.id)
if (!isFinalAutomationRunStatus(run.status)) {
if (run.status === 'dispatched') {
@@ -224,13 +228,17 @@ export class AutomationService {
}
private async evaluateDueRuns(): Promise<void> {
if (this.evaluating) {
if (this.evaluating || this.stopped) {
return
}
this.evaluating = true
const generation = this.dispatchGeneration
try {
const now = Date.now()
for (const automation of this.store.listAutomations()) {
if (this.stopped || generation !== this.dispatchGeneration) {
break
}
if (!automation.enabled || automation.nextRunAt > now) {
continue
}
@@ -239,7 +247,14 @@ export class AutomationService {
try {
await this.evaluateAutomation(automation, now)
} catch (error) {
recordUnevaluableAutomation({ runs: this.runs, automation, error })
if (
!(error instanceof AutomationDispatchCancelledError) &&
!this.stopped &&
generation === this.dispatchGeneration &&
this.store.listAutomations().some((current) => current.id === automation.id)
) {
await recordUnevaluableAutomation({ runs: this.runs, automation, error })
}
}
}
} finally {
@@ -248,14 +263,15 @@ export class AutomationService {
}
private async evaluateAutomation(automation: Automation, now: number): Promise<void> {
const generation = this.dispatchGeneration
const scheduledFor = this.store.getLatestAutomationOccurrence(automation, now)
if (scheduledFor === null) {
this.store.advanceAutomationNextRun(automation.id, now)
await this.runs.advanceNextRun(automation.id, now)
return
}
if (missedBeyondGrace({ automation, scheduledFor, now, tickMs: this.tickMs })) {
recordMissedRun({ runs: this.runs, automation, scheduledFor })
this.store.advanceAutomationNextRun(automation.id, now)
await recordMissedRun({ runs: this.runs, automation, scheduledFor })
await this.runs.advanceNextRun(automation.id, now)
return
}
@@ -263,14 +279,20 @@ export class AutomationService {
// */5 automation would otherwise write ~288 identical rows a day — past
// retention, which would evict the automation's real history.
const target = this.resolveTarget(automation)
const refusal = describeScheduledRefusal({ target, canDispatch: this.canDispatch() })
if (refusal && this.runs.repeatSkip(automation.id, refusal, scheduledFor)) {
this.store.advanceAutomationNextRun(automation.id, now)
const canDispatch = this.canDispatchToRenderer() || Boolean(this.headlessDispatcher)
const refusal = describeScheduledRefusal({ target, canDispatch })
if (refusal && (await this.runs.repeatSkip(automation.id, refusal, scheduledFor))) {
await this.runs.advanceNextRun(automation.id, now)
return
}
await this.requestDispatch(automation, this.runs.createRun(automation, scheduledFor), target)
this.store.advanceAutomationNextRun(automation.id, now)
await this.requestDispatch(
automation,
await this.runs.createRun(automation, scheduledFor),
target,
generation
)
await this.runs.advanceNextRun(automation.id, now)
}
private resolveTarget(automation: Automation): AutomationRunTargetResult {
@@ -284,55 +306,27 @@ export class AutomationService {
return Boolean(webContents && !webContents.isDestroyed() && this.rendererReady)
}
/** Headless serve counts: it launches runs with no window at all. */
private canDispatch(): boolean {
return this.canDispatchToRenderer() || Boolean(this.headlessDispatcher)
}
private async requestDispatch(
private requestDispatch(
automation: Automation,
run: AutomationRun,
target: AutomationRunTargetResult
target: AutomationRunTargetResult,
generation: number
): Promise<AutomationRun> {
if (!target.ok) {
return this.runs.updateRun({
runId: run.id,
status: 'skipped_unavailable',
workspaceId: automation.workspaceId,
error: target.error
})
}
if (!this.canDispatchToRenderer()) {
if (this.headlessDispatcher) {
return await runHeadlessAutomationDispatch({
automation,
run,
target,
dispatcher: this.headlessDispatcher,
runs: this.runs,
runPrecheck: () => this.runPrecheck(automation.id, run.id),
markDispatchResult: (result) => this.markDispatchResult(result),
watchRun: (dispatched) => this.completionWatcher?.watch(dispatched)
})
}
return this.runs.updateRun({
runId: run.id,
status: 'skipped_unavailable',
workspaceId: automation.workspaceId,
error: NO_DISPATCH_HOST
})
}
const updated = this.runs.updateRun({
runId: run.id,
status: 'dispatching',
workspaceId: automation.workspaceId,
error: null
})
const payload: AutomationDispatchRequest = {
return requestAutomationDispatch(
{
store: this.store,
runs: this.runs,
isActive: () => !this.stopped && generation === this.dispatchGeneration,
getRenderer: () => (this.canDispatchToRenderer() ? this.webContents : null),
headlessDispatcher: this.headlessDispatcher,
resolveTarget: (current) => this.resolveTarget(current),
runPrecheck: () => this.runPrecheck(automation.id, run.id),
markDispatchResult: (result) => this.markDispatchResult(result),
watchRun: (dispatched) => this.completionWatcher?.watch(dispatched)
},
automation,
run: updated,
dispatchToken: createAutomationDispatchToken(automation.id, updated.id)
}
return sendRendererDispatch(this.webContents, payload, this.runs, updated)
run,
target
)
}
}
@@ -0,0 +1,87 @@
import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import type * as NodeFsPromises from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
import { writeFileDurable, writeFileDurableIfCurrent } from '../durable-file-write'
const rename = vi.hoisted(() => vi.fn())
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof NodeFsPromises>()
rename.mockImplementation(actual.rename)
return { ...actual, rename }
})
const platform = process.platform
const roots: string[] = []
afterEach(() => {
Object.defineProperty(process, 'platform', { value: platform })
for (const root of roots.splice(0)) {
rmSync(root, { recursive: true, force: true })
}
rename.mockClear()
vi.restoreAllMocks()
})
function fixture() {
const root = mkdtempSync(join(tmpdir(), 'orca-async-rename-'))
roots.push(root)
const target = join(root, 'state.json')
const temporary = join(root, 'temporary.json')
writeFileSync(target, 'old')
return { root, target, temporary }
}
it.each(['EPERM', 'EACCES', 'EBUSY'])(
'retries a transient Windows %s without blocking the event loop',
async (code) => {
const { target, temporary } = fixture()
Object.defineProperty(process, 'platform', { value: 'win32' })
rename.mockRejectedValueOnce(Object.assign(new Error('file busy'), { code }))
let ticked = false
const timer = setTimeout(() => {
ticked = true
}, 0)
try {
await writeFileDurable(temporary, target, 'new')
expect(readFileSync(target, 'utf8')).toBe('new')
expect(rename).toHaveBeenCalledTimes(2)
expect(ticked).toBe(true)
} finally {
clearTimeout(timer)
}
}
)
it.each([
['win32', 'EPERM', 6],
['win32', 'ENOSPC', 1],
['linux', 'EBUSY', 1]
] as const)('bounds %s %s failures and preserves the old file', async (host, code, attempts) => {
const { root, target, temporary } = fixture()
Object.defineProperty(process, 'platform', { value: host })
for (let attempt = 0; attempt < attempts; attempt++) {
rename.mockRejectedValueOnce(Object.assign(new Error('injected file failure'), { code }))
}
await expect(writeFileDurable(temporary, target, 'new')).rejects.toThrow('injected file failure')
expect(rename).toHaveBeenCalledTimes(attempts)
expect(readFileSync(target, 'utf8')).toBe('old')
expect(readdirSync(root)).toEqual(['state.json'])
})
it('does not publish a superseded snapshot after a Windows retry delay', async () => {
const { root, target, temporary } = fixture()
Object.defineProperty(process, 'platform', { value: 'win32' })
let current = true
rename.mockImplementationOnce(async () => {
current = false
writeFileSync(target, 'newer snapshot')
throw Object.assign(new Error('busy'), { code: 'EBUSY' })
})
await expect(
writeFileDurableIfCurrent(temporary, target, 'stale snapshot', () => current)
).resolves.toBe(false)
expect(rename).toHaveBeenCalledOnce()
expect(readFileSync(target, 'utf8')).toBe('newer snapshot')
expect(readdirSync(root)).toEqual(['state.json'])
})
@@ -28,7 +28,7 @@ type CodexAccountSelectionDependencies = {
lifecycle: CodexAccountServiceLifecycle
resolveSystemDefault: () => CodexSystemDefaultIdentity
removeManagedHome: (candidatePath: string, expectedAccountId: string) => void
discardResetAttempts: (accountId: string) => void
discardResetAttempts: (accountId: string) => Promise<void>
}
export class CodexAccountSelection {
@@ -83,10 +83,13 @@ export class CodexAccountSelection {
}
this.dependencies.removeManagedHome(account.managedHomePath, account.id)
// Why: a removed account can no longer appear in the switcher dropdown,
// so purge its cached usage to avoid stale entries.
this.dependencies.rateLimits.evictInactiveCodexCache(accountId)
this.dependencies.discardResetAttempts(accountId)
try {
await this.dependencies.discardResetAttempts(accountId)
} catch (error) {
// Removal already succeeded; retain the ledger's safety guards if cleanup fails.
console.warn('[codex-accounts] Removed account, but credit ledger cleanup failed:', error)
}
const accountTarget = getCodexSelectionTargetForAccount(account)
this.startQuotaRefresh(
getSelectedCodexAccountIdForTarget(settings, accountTarget) === accountId
@@ -171,8 +171,8 @@ export class CodexResetCreditCoordinator {
})
}
discardForRemovedAccount(accountId: string): void {
this.ledger.discardForRemovedAccount(accountId)
discardForRemovedAccount(accountId: string): Promise<void> {
return this.ledger.discardForRemovedAccount(accountId)
}
private startAttempt(
@@ -182,6 +182,9 @@ export class CodexResetCreditCoordinator {
): Promise<CodexResetCreditConsumeResult> {
const promise = this.dependencies.serializeMutation(
async (): Promise<CodexResetCreditConsumeResult> => {
if (this.ledger.error) {
throw this.ledger.error
}
const isFresh = attempt.state === 'fresh'
let validation: { managedHomePath: string; rateLimits: RateLimitState }
try {
@@ -204,7 +207,7 @@ export class CodexResetCreditCoordinator {
throw error
}
if (isFresh) {
this.ledger.markProviderPending(idempotencyKey, attempt)
await this.ledger.markProviderPending(idempotencyKey, attempt)
}
const { outcome, state } =
await this.dependencies.rateLimits.consumeCodexRateLimitResetCredit({
@@ -220,7 +223,7 @@ export class CodexResetCreditCoordinator {
codex: this.dependencies.getSnapshot(),
rateLimits: state
}
this.ledger.markSettled(idempotencyKey, attempt, outcome)
await this.ledger.markSettled(idempotencyKey, attempt, outcome)
return result
}
)
@@ -0,0 +1,131 @@
import { describe, expect, it, vi } from 'vitest'
import type { CodexResetCreditAttemptLedger } from '../../shared/codex-reset-credit-attempt-ledger'
import type { CodexResetCreditExpectedScope } from '../../shared/codex-reset-credit-scope'
import { ProfileStateWriterError } from '../persistence/profile-state/profile-state-writer-errors'
import { CodexResetCreditLedger } from './codex-reset-credit-ledger'
function scope(accountId: string): CodexResetCreditExpectedScope {
return {
target: { runtime: 'host', wslDistro: null },
accountId,
accountRevision: 1,
offerRevision: 'offer-1'
}
}
function setup() {
let durable: CodexResetCreditAttemptLedger = { version: 1, attempts: [] }
const barrier = vi.fn(async () => {})
const store = {
getCodexResetCreditAttemptLedger: () => structuredClone(durable),
replaceCodexResetCreditAttemptLedgerAndFlush: vi.fn(
async (next: CodexResetCreditAttemptLedger) => {
await barrier()
durable = structuredClone(next)
}
)
}
return { ledger: new CodexResetCreditLedger(store), store, barrier }
}
describe('async reset-credit ledger', () => {
it('serializes replacement construction so concurrent account writes survive', async () => {
const { ledger, store, barrier } = setup()
const gate = Promise.withResolvers<void>()
barrier.mockImplementationOnce(() => gate.promise)
const first = ledger.createFresh('first', scope('account-1'))
const second = ledger.createFresh('second', scope('account-2'))
const pendingFirst = ledger.markProviderPending('first', first)
const pendingSecond = ledger.markProviderPending('second', second)
await vi.waitFor(() => expect(barrier).toHaveBeenCalledOnce())
expect(first.state).toBe('fresh')
expect(second.state).toBe('fresh')
expect(store.getCodexResetCreditAttemptLedger().attempts).toEqual([])
gate.resolve()
await Promise.all([pendingFirst, pendingSecond])
expect(store.getCodexResetCreditAttemptLedger().attempts).toMatchObject([
{ idempotencyKey: 'first', state: 'providerPending' },
{ idempotencyKey: 'second', state: 'providerPending' }
])
expect(ledger.getUnresolvedKey(first.accountScopeKey)).toBe('first')
expect(ledger.getUnresolvedKey(second.accountScopeKey)).toBe('second')
})
it('keeps pending guards until settlement commits and can retry a known failure', async () => {
const { ledger, store, barrier } = setup()
const attempt = ledger.createFresh('first', scope('account-1'))
await ledger.markProviderPending('first', attempt)
const gate = Promise.withResolvers<void>()
barrier.mockImplementationOnce(() => gate.promise)
const settled = ledger.markSettled('first', attempt, 'reset')
const rejected = expect(settled).rejects.toThrow('disk full')
await vi.waitFor(() => expect(barrier).toHaveBeenCalledTimes(2))
expect(attempt.state).toBe('providerPending')
expect(ledger.getUnresolvedKey(attempt.accountScopeKey)).toBe('first')
gate.reject(new Error('disk full'))
await rejected
expect(attempt.state).toBe('providerPending')
expect(store.getCodexResetCreditAttemptLedger().attempts[0]?.state).toBe('providerPending')
expect(ledger.error).toBeNull()
await ledger.markSettled('first', attempt, 'alreadyRedeemed')
expect(attempt.settledOutcome).toBe('alreadyRedeemed')
expect(ledger.getUnresolvedKey(attempt.accountScopeKey)).toBeUndefined()
})
it('waits for queued writes before removing an account and retains other accounts', async () => {
const { ledger, store, barrier } = setup()
const first = ledger.createFresh('first', scope('account-1'))
const second = ledger.createFresh('second', scope('account-2'))
await ledger.markProviderPending('first', first)
const gate = Promise.withResolvers<void>()
barrier.mockImplementationOnce(() => gate.promise)
const pendingSecond = ledger.markProviderPending('second', second)
const removed = ledger.discardForRemovedAccount('account-1')
await vi.waitFor(() => expect(barrier).toHaveBeenCalledTimes(2))
expect(ledger.get('first')).toBe(first)
gate.resolve()
await Promise.all([pendingSecond, removed])
expect(store.getCodexResetCreditAttemptLedger().attempts).toMatchObject([
{ idempotencyKey: 'second', state: 'providerPending' }
])
expect(ledger.get('first')).toBeUndefined()
expect(ledger.getUnresolvedKey(first.accountScopeKey)).toBeUndefined()
expect(ledger.get('second')).toBe(second)
})
it('retains the removed account guard when its async durability barrier fails', async () => {
const { ledger, barrier } = setup()
const attempt = ledger.createFresh('first', scope('account-1'))
await ledger.markProviderPending('first', attempt)
barrier.mockRejectedValueOnce(new Error('disk full'))
await expect(ledger.discardForRemovedAccount('account-1')).rejects.toThrow('disk full')
expect(ledger.get('first')).toBe(attempt)
expect(ledger.getUnresolvedKey(attempt.accountScopeKey)).toBe('first')
})
it('fails queued and future mutations closed when a commit outcome is unknown', async () => {
const { ledger, store, barrier } = setup()
const attempt = ledger.createFresh('first', scope('account-1'))
const second = ledger.createFresh('second', scope('account-2'))
const gate = Promise.withResolvers<void>()
barrier.mockImplementationOnce(() => gate.promise)
const pending = ledger.markProviderPending('first', attempt)
const queued = ledger.markProviderPending('second', second)
const rejected = expect(pending).rejects.toThrow('worker stopped')
const blocked = expect(queued).rejects.toThrow('durability is unknown')
await vi.waitFor(() => expect(barrier).toHaveBeenCalledOnce())
gate.reject(new ProfileStateWriterError('worker-exit', 'worker stopped', 'indeterminate'))
await Promise.all([rejected, blocked])
ledger.releaseFresh('first', attempt)
expect(ledger.get('first')).toBe(attempt)
expect(ledger.getClaimedKey(attempt.scopeKey)).toBe('first')
expect(store.replaceCodexResetCreditAttemptLedgerAndFlush).toHaveBeenCalledOnce()
await expect(ledger.discardForRemovedAccount('account-1')).rejects.toThrow(
'durability is unknown'
)
})
})
@@ -9,6 +9,7 @@ import type {
RateLimitRuntimeTarget
} from '../../shared/rate-limit-types'
import type { Store } from '../persistence'
import { profileStateWriterFailureOutcome } from '../persistence/profile-state/profile-state-writer-errors'
export type CodexResetCreditAttempt = {
expectedScope: CodexResetCreditExpectedScope
@@ -45,14 +46,20 @@ export class CodexResetCreditLedger {
private readonly attemptKeyByOffer = new Map<string, string>()
private readonly unresolvedKeyByAccountScope = new Map<string, string>()
private durableLedger: CodexResetCreditAttemptLedger | null = null
private loadError: Error | null = null
private stateError: Error | null = null
private mutationQueue: Promise<void> = Promise.resolve()
constructor(private readonly store: Store) {
constructor(
private readonly store: Pick<
Store,
'getCodexResetCreditAttemptLedger' | 'replaceCodexResetCreditAttemptLedgerAndFlush'
>
) {
this.hydrate()
}
get error(): Error | null {
return this.loadError
return this.stateError
}
get(idempotencyKey: string): CodexResetCreditAttempt | undefined {
@@ -114,32 +121,40 @@ export class CodexResetCreditLedger {
)
}
markProviderPending(idempotencyKey: string, attempt: CodexResetCreditAttempt): void {
this.persist({ idempotencyKey, expectedScope: attempt.expectedScope, state: 'providerPending' })
attempt.state = 'providerPending'
this.unresolvedKeyByAccountScope.set(attempt.accountScopeKey, idempotencyKey)
markProviderPending(idempotencyKey: string, attempt: CodexResetCreditAttempt): Promise<void> {
return this.serializeMutation(async () => {
await this.persist({
idempotencyKey,
expectedScope: attempt.expectedScope,
state: 'providerPending'
})
attempt.state = 'providerPending'
this.unresolvedKeyByAccountScope.set(attempt.accountScopeKey, idempotencyKey)
})
}
markSettled(
idempotencyKey: string,
attempt: CodexResetCreditAttempt,
outcome: CodexRateLimitResetOutcome
): void {
this.persist({
idempotencyKey,
expectedScope: attempt.expectedScope,
state: 'settled',
outcome
): Promise<void> {
return this.serializeMutation(async () => {
await this.persist({
idempotencyKey,
expectedScope: attempt.expectedScope,
state: 'settled',
outcome
})
attempt.state = 'settled'
attempt.settledOutcome = outcome
if (this.unresolvedKeyByAccountScope.get(attempt.accountScopeKey) === idempotencyKey) {
this.unresolvedKeyByAccountScope.delete(attempt.accountScopeKey)
}
})
attempt.state = 'settled'
attempt.settledOutcome = outcome
if (this.unresolvedKeyByAccountScope.get(attempt.accountScopeKey) === idempotencyKey) {
this.unresolvedKeyByAccountScope.delete(attempt.accountScopeKey)
}
}
releaseFresh(idempotencyKey: string, attempt: CodexResetCreditAttempt): void {
if (attempt.state !== 'fresh') {
if (attempt.state !== 'fresh' || this.stateError) {
return
}
this.attemptsByKey.delete(idempotencyKey)
@@ -151,7 +166,11 @@ export class CodexResetCreditLedger {
// Why: a removed account's managed home is gone, so its unresolved providerPending
// attempt can never validate or be replayed; drop it so a target-scoped default reset
// is not wedged forever by hasPendingResetForTarget matching the orphan.
discardForRemovedAccount(accountId: string): void {
discardForRemovedAccount(accountId: string): Promise<void> {
return this.serializeMutation(() => this.discardAccountAttempts(accountId))
}
private async discardAccountAttempts(accountId: string): Promise<void> {
const staleAttempts = [...this.attemptsByKey].filter(
([, attempt]) => attempt.expectedScope.accountId === accountId
)
@@ -167,7 +186,7 @@ export class CodexResetCreditLedger {
const nextLedger: CodexResetCreditAttemptLedger = { version: 1, attempts }
// Persist first so a failed durability barrier leaves the in-memory
// fail-closed guards aligned with the ledger that will reload.
this.store.replaceCodexResetCreditAttemptLedgerAndFlush(nextLedger)
await this.store.replaceCodexResetCreditAttemptLedgerAndFlush(nextLedger)
this.durableLedger = structuredClone(nextLedger)
}
}
@@ -202,14 +221,34 @@ export class CodexResetCreditLedger {
}
}
} catch (error) {
this.loadError =
this.stateError =
error instanceof Error ? error : new Error('Codex reset-credit attempt ledger is corrupt')
}
}
private persist(nextAttempt: DurableCodexResetCreditAttempt): void {
private serializeMutation(operation: () => Promise<void>): Promise<void> {
const next = this.mutationQueue.then(async () => {
if (this.stateError) {
throw this.stateError
}
try {
await operation()
} catch (error) {
if (profileStateWriterFailureOutcome(error) === 'indeterminate') {
this.stateError = new Error('Codex reset-credit attempt durability is unknown', {
cause: error
})
}
throw error
}
})
this.mutationQueue = next.catch(() => {})
return next
}
private async persist(nextAttempt: DurableCodexResetCreditAttempt): Promise<void> {
if (!this.durableLedger) {
throw this.loadError ?? new Error('Codex reset-credit attempt ledger is unavailable')
throw this.stateError ?? new Error('Codex reset-credit attempt ledger is unavailable')
}
const index = this.durableLedger.attempts.findIndex(
(attempt) => attempt.idempotencyKey === nextAttempt.idempotencyKey
@@ -221,7 +260,7 @@ export class CodexResetCreditLedger {
attempts[index] = nextAttempt
}
const nextLedger: CodexResetCreditAttemptLedger = { version: 1, attempts }
this.store.replaceCodexResetCreditAttemptLedgerAndFlush(nextLedger)
await this.store.replaceCodexResetCreditAttemptLedgerAndFlush(nextLedger)
this.durableLedger = structuredClone(nextLedger)
}
}
+36 -5
View File
@@ -1,6 +1,8 @@
import { randomUUID } from 'node:crypto'
import { copyFileSync, existsSync, linkSync, renameSync, rmSync, writeFileSync } from 'node:fs'
import { rename } from 'node:fs/promises'
import { dirname } from 'node:path'
import { setTimeout } from 'node:timers/promises'
import { grantDirAcl, isPermissionError } from '../win32-utils'
import { nodeFileContentsEqualSync } from '../../shared/node-file-content-equality'
@@ -168,7 +170,7 @@ function assertHardLinkPublicationSupported(sourcePath: string, targetPath: stri
}
}
function publishFileWithoutOverwrite(sourcePath: string, targetPath: string): boolean {
export function publishFileWithoutOverwrite(sourcePath: string, targetPath: string): boolean {
try {
linkSync(sourcePath, targetPath)
return true
@@ -201,19 +203,38 @@ export function renameFileWithWindowsRetry(source: string, target: string): void
runFileOperationWithWindowsRetry(() => renameSync(source, target))
}
export async function renameFileWithWindowsRetryAsync(
source: string,
target: string,
isCurrent: () => boolean = () => true
): Promise<boolean> {
for (let attempt = 1; ; attempt++) {
if (!isCurrent()) {
return false
}
try {
await rename(source, target)
return true
} catch (error) {
if (!shouldRetryFileOperation(error, attempt)) {
throw error
}
await setTimeout(attempt * 50)
}
}
}
export function copyFileWithWindowsRetry(source: string, target: string): void {
runFileOperationWithWindowsRetry(() => copyFileSync(source, target))
}
function runFileOperationWithWindowsRetry(operation: () => void): void {
const maxAttempts = process.platform === 'win32' ? 6 : 1
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
for (let attempt = 1; ; attempt++) {
try {
operation()
return
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (attempt < maxAttempts && (code === 'EPERM' || code === 'EACCES' || code === 'EBUSY')) {
if (shouldRetryFileOperation(error, attempt)) {
sleepSync(attempt * 50)
continue
}
@@ -222,6 +243,16 @@ function runFileOperationWithWindowsRetry(operation: () => void): void {
}
}
function shouldRetryFileOperation(error: unknown, attempt: number): boolean {
return (
process.platform === 'win32' &&
attempt < 6 &&
error instanceof Error &&
'code' in error &&
(error.code === 'EPERM' || error.code === 'EACCES' || error.code === 'EBUSY')
)
}
// Why: writeFileAtomically is a sync API called from sync paths, so the retry
// backoff must park the thread instead of burning CPU in a Date.now() loop.
const sleepBuffer = new Int32Array(new SharedArrayBuffer(4))
@@ -215,47 +215,67 @@ describe('CodexAccountService config sync', () => {
})
})
it('removes an account and cleans up managed home', async () => {
const managedHomePath = createManagedHome(
testState.userDataDir,
'account-1',
'',
'{"account":"managed"}\n'
)
const settings = createSettings({
codexManagedAccounts: [
{
id: 'account-1',
email: 'user@example.com',
managedHomePath,
providerAccountId: null,
workspaceLabel: null,
workspaceAccountId: null,
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
}
],
activeCodexManagedAccountId: 'account-1'
})
const store = createStore(settings)
const rateLimits = createRateLimits()
const runtimeHome = createRuntimeHome()
it.each(['healthy', 'unreadable'])(
'removes an account with a %s credit ledger',
async (ledger) => {
const managedHomePath = createManagedHome(
testState.userDataDir,
'account-1',
'',
'{"account":"managed"}\n'
)
const settings = createSettings({
codexManagedAccounts: [
{
id: 'account-1',
email: 'user@example.com',
managedHomePath,
providerAccountId: null,
workspaceLabel: null,
workspaceAccountId: null,
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
}
],
activeCodexManagedAccountId: 'account-1'
})
const store = createStore(settings)
const rateLimits = createRateLimits()
const runtimeHome = createRuntimeHome()
const { CodexAccountService } = await import('./service')
const service = new CodexAccountService(
store as never,
rateLimits as never,
runtimeHome as never
)
const ledgerError = new Error('credit ledger unreadable')
if (ledger === 'unreadable') {
store.getCodexResetCreditAttemptLedger.mockImplementation(() => {
throw ledgerError
})
}
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const result = await service.removeAccount('account-1')
const { CodexAccountService } = await import('./service')
const service = new CodexAccountService(
store as never,
rateLimits as never,
runtimeHome as never
)
expect(result.accounts).toHaveLength(0)
expect(result.activeAccountId).toBe(null)
expect(existsSync(managedHomePath)).toBe(false)
expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalled()
})
const result = await service.removeAccount('account-1')
expect(result.accounts).toHaveLength(0)
expect(result.activeAccountId).toBe(null)
expect(existsSync(managedHomePath)).toBe(false)
expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalled()
expect(rateLimits.evictInactiveCodexCache).toHaveBeenCalledWith('account-1')
if (ledger === 'unreadable') {
expect(warn).toHaveBeenCalledWith(
'[codex-accounts] Removed account, but credit ledger cleanup failed:',
expect.any(Error)
)
expect(store.replaceCodexResetCreditAttemptLedgerAndFlush).not.toHaveBeenCalled()
}
warn.mockRestore()
}
)
it('refuses to remove a managed home owned by a different account', async () => {
const otherAccountHome = createManagedHome(
@@ -146,9 +146,22 @@ describe('CodexAccountService config sync', () => {
account,
limits
})!
const store = createStore(settings)
const persist = store.replaceCodexResetCreditAttemptLedgerAndFlush.getMockImplementation()!
const pendingCommit = Promise.withResolvers<void>()
const settledCommit = Promise.withResolvers<void>()
store.replaceCodexResetCreditAttemptLedgerAndFlush
.mockImplementationOnce(async (ledger) => {
await pendingCommit.promise
await persist(ledger)
})
.mockImplementationOnce(async (ledger) => {
await settledCommit.promise
await persist(ledger)
})
const { CodexAccountService } = await import('./service')
const service = new CodexAccountService(
createStore(settings) as never,
store as never,
rateLimits as never,
createRuntimeHome() as never
)
@@ -157,9 +170,20 @@ describe('CodexAccountService config sync', () => {
const first = service.consumeRateLimitResetCredit(idempotencyKey, expectedScope)
const second = service.consumeRateLimitResetCredit(idempotencyKey, expectedScope)
expect(second).toBe(first)
await vi.waitFor(() =>
expect(store.replaceCodexResetCreditAttemptLedgerAndFlush).toHaveBeenCalledOnce()
)
expect(consume).not.toHaveBeenCalled()
pendingCommit.resolve()
await vi.waitFor(() => expect(consume).toHaveBeenCalledOnce())
const selectingNextAccount = service.selectAccount(nextAccount.id)
finishConsume?.({ outcome: 'reset', state })
await vi.waitFor(() =>
expect(store.replaceCodexResetCreditAttemptLedgerAndFlush).toHaveBeenCalledTimes(2)
)
expect(service.listAccounts().activeAccountId).toBe(account.id)
expect(store.getCodexResetCreditAttemptLedger().attempts[0]?.state).toBe('providerPending')
settledCommit.resolve()
const resetResults = await Promise.all([first, second])
expect(resetResults).toMatchObject([
@@ -406,9 +430,10 @@ describe('CodexAccountService config sync', () => {
const limits = createResetCreditLimits()
const state = createResetRateLimitState(limits)
const store = createStore(settings)
store.replaceCodexResetCreditAttemptLedgerAndFlush.mockImplementationOnce(() => {
throw new Error('disk full')
})
const pendingCommit = Promise.withResolvers<void>()
store.replaceCodexResetCreditAttemptLedgerAndFlush.mockImplementationOnce(
() => pendingCommit.promise
)
const consume = vi.fn()
const expectedScope = buildCodexResetCreditExpectedScope({
target: state.codexTarget,
@@ -426,9 +451,15 @@ describe('CodexAccountService config sync', () => {
createRuntimeHome() as never
)
await expect(
const rejected = expect(
service.consumeRateLimitResetCredit('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', expectedScope)
).rejects.toThrow('disk full')
await vi.waitFor(() =>
expect(store.replaceCodexResetCreditAttemptLedgerAndFlush).toHaveBeenCalledOnce()
)
expect(consume).not.toHaveBeenCalled()
pendingCommit.reject(new Error('disk full'))
await rejected
expect(consume).not.toHaveBeenCalled()
expect(store.getCodexResetCreditAttemptLedger().attempts).toEqual([])
})
@@ -453,11 +484,11 @@ describe('CodexAccountService config sync', () => {
const state = createResetRateLimitState(limits)
const store = createStore(settings)
const persist = store.replaceCodexResetCreditAttemptLedgerAndFlush.getMockImplementation()!
store.replaceCodexResetCreditAttemptLedgerAndFlush.mockImplementation((ledger) => {
store.replaceCodexResetCreditAttemptLedgerAndFlush.mockImplementation(async (ledger) => {
if (ledger.attempts[0]?.state === 'settled') {
throw new Error('settle disk full')
}
persist(ledger)
return persist(ledger)
})
const expectedScope = buildCodexResetCreditExpectedScope({
target: state.codexTarget,
@@ -145,7 +145,7 @@ describe('Codex reset-credit managed-home ownership', () => {
}
]
}
fixture.store.replaceCodexResetCreditAttemptLedgerAndFlush(pendingLedger)
await fixture.store.replaceCodexResetCreditAttemptLedgerAndFlush(pendingLedger)
makeHomeUnsafe(fixture.managedHomePath)
const settingsBefore = structuredClone(fixture.store.getSettings())
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { existsSync } from 'node:fs'
import { buildCodexResetCreditExpectedScope } from '../../shared/codex-reset-credit-scope'
import {
createManagedHome,
@@ -329,7 +330,7 @@ describe('CodexAccountService config sync', () => {
limits
})!
const store = createStore(settings)
store.replaceCodexResetCreditAttemptLedgerAndFlush({
await store.replaceCodexResetCreditAttemptLedgerAndFlush({
version: 1,
attempts: [
{
@@ -379,7 +380,7 @@ describe('CodexAccountService config sync', () => {
limits
})!
const store = createStore(settings)
store.replaceCodexResetCreditAttemptLedgerAndFlush({
await store.replaceCodexResetCreditAttemptLedgerAndFlush({
version: 1,
attempts: [
{
@@ -414,7 +415,7 @@ describe('CodexAccountService config sync', () => {
expect(store.getCodexResetCreditAttemptLedger().attempts).toEqual([])
})
it('keeps reset attempts fail-closed when removal cannot persist their purge', async () => {
it('reports account removal while keeping reset attempts guarded after a failed purge', async () => {
const managedHomePath = createManagedHome(testState.userDataDir, 'account-1')
const account = {
id: 'account-1',
@@ -438,7 +439,7 @@ describe('CodexAccountService config sync', () => {
limits
})!
const store = createStore(settings)
store.replaceCodexResetCreditAttemptLedgerAndFlush({
await store.replaceCodexResetCreditAttemptLedgerAndFlush({
version: 1,
attempts: [
{
@@ -459,11 +460,17 @@ describe('CodexAccountService config sync', () => {
} as never,
createRuntimeHome() as never
)
vi.spyOn(store, 'replaceCodexResetCreditAttemptLedgerAndFlush').mockImplementationOnce(() => {
throw new Error('disk full')
})
const failure = new Error('disk full')
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.spyOn(store, 'replaceCodexResetCreditAttemptLedgerAndFlush').mockRejectedValueOnce(failure)
await expect(service.removeAccount('account-1')).rejects.toThrow('disk full')
await expect(service.removeAccount('account-1')).resolves.toMatchObject({ accounts: [] })
expect(store.getSettings().codexManagedAccounts).toEqual([])
expect(existsSync(managedHomePath)).toBe(false)
expect(warning).toHaveBeenCalledWith(
'[codex-accounts] Removed account, but credit ledger cleanup failed:',
failure
)
await expect(service.consumeCurrentRateLimitResetCredit()).rejects.toThrow('unknown outcome')
expect(consume).not.toHaveBeenCalled()
})
@@ -56,9 +56,11 @@ export function createStore(settings: GlobalSettings) {
return settings
}),
getCodexResetCreditAttemptLedger: vi.fn(() => structuredClone(resetLedger)),
replaceCodexResetCreditAttemptLedgerAndFlush: vi.fn((next: CodexResetCreditAttemptLedger) => {
resetLedger = structuredClone(next)
})
replaceCodexResetCreditAttemptLedgerAndFlush: vi.fn(
async (next: CodexResetCreditAttemptLedger) => {
resetLedger = structuredClone(next)
}
)
}
}
+4 -1
View File
@@ -60,7 +60,10 @@ export function probeDaemonSocket(
socketPath: string,
timeoutMs = DAEMON_SOCKET_PROBE_TIMEOUT_MS
): Promise<boolean> {
const { promise, resolve } = Promise.withResolvers<boolean>()
let resolve!: (alive: boolean) => void
const promise = new Promise<boolean>((settle) => {
resolve = settle
})
if (process.platform !== 'win32' && !existsSync(socketPath)) {
resolve(false)
return promise
@@ -16,22 +16,26 @@ export type PsProcessIdentity = {
startedAtMs: number | null
}
function parsePsProcessIdentity(output: string): PsProcessIdentity {
function parsePsProcessIdentity(output: string, utc = false): PsProcessIdentity {
// BSD ps formats lstart as a fixed-width 24-character timestamp.
const startedAtMs = Date.parse(output.slice(0, 24))
const startedAtMs = Date.parse(output.slice(0, 24) + (utc ? ' UTC' : ''))
return {
commandLine: output.slice(24).trim(),
startedAtMs: Number.isFinite(startedAtMs) ? startedAtMs : null
}
}
export function getPsProcessIdentity(pid: number): PsProcessIdentity | null {
export function getPsProcessIdentity(
pid: number,
options?: { utc?: boolean }
): PsProcessIdentity | null {
try {
const output = execFileSync('ps', ['-p', String(pid), '-o', 'lstart=', '-o', 'command='], {
encoding: 'utf8',
timeout: 2_000
timeout: 2_000,
...(options?.utc ? { env: { ...process.env, TZ: 'UTC', LC_ALL: 'C' } } : {})
})
return parsePsProcessIdentity(output)
return parsePsProcessIdentity(output, options?.utc)
} catch {
return null
}
@@ -0,0 +1,32 @@
import { afterEach, expect, it, vi } from 'vitest'
import { getPsProcessIdentity } from './daemon-process-identity-query'
const { execFileSync } = vi.hoisted(() => ({ execFileSync: vi.fn() }))
vi.mock('node:child_process', () => ({ execFileSync, execFile: vi.fn() }))
afterEach(() => {
vi.unstubAllEnvs()
vi.clearAllMocks()
})
it.each(['America/Los_Angeles', 'America/New_York', 'UTC'])(
'keeps the autumn clock transition unambiguous under %s',
(timezone) => {
vi.stubEnv('TZ', timezone)
execFileSync.mockReturnValue('Sun Nov 1 09:30:00 2026 /path/to/orca\n')
expect(getPsProcessIdentity(42, { utc: true })).toEqual({
startedAtMs: Date.parse('2026-11-01T09:30:00Z'),
commandLine: '/path/to/orca'
})
expect(execFileSync).toHaveBeenCalledWith(
'ps',
['-p', '42', '-o', 'lstart=', '-o', 'command='],
expect.objectContaining({ env: expect.objectContaining({ TZ: 'UTC', LC_ALL: 'C' }) })
)
}
)
it('treats an unreadable UTC process start as unknown', () => {
execFileSync.mockReturnValue(' /path/to/orca\n')
expect(getPsProcessIdentity(42, { utc: true })?.startedAtMs).toBeNull()
})
@@ -33,7 +33,14 @@ export class DaemonPtySpawnPreparations {
clientId,
requestId
}
this.cancellationByPreparation.set(preparation, Promise.withResolvers<void>())
let resolveCancellation!: () => void
const cancellation = new Promise<void>((resolve) => {
resolveCancellation = resolve
})
this.cancellationByPreparation.set(preparation, {
promise: cancellation,
resolve: resolveCancellation
})
if (Number.isSafeInteger(cancelAfterMs) && Number(cancelAfterMs) > 0) {
preparation.cancelTimer = setTimeout(
() => this.cancelPreparation(preparation),
@@ -116,7 +116,10 @@ export function scheduleTerminalHistoryPermissionRepair(basePath: string): Promi
}
scheduledBasePaths.delete(oldest.value)
}
const { promise, resolve: settle } = Promise.withResolvers<boolean>()
let settle!: (repaired: boolean) => void
const promise = new Promise<boolean>((resolve) => {
settle = resolve
})
const timer = setTimeout(() => {
repairTerminalHistoryPermissions(key).then(settle, () => settle(false))
}, REPAIR_START_DELAY_MS)
@@ -1,6 +1,11 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type * as pty from 'node-pty'
import { warmWindowsConptyOnce } from './windows-conpty-warmup'
import { assignHostProcessToKillOnCloseJob } from '../windows/windows-pty-job'
vi.mock('../windows/windows-pty-job', () => ({
assignHostProcessToKillOnCloseJob: vi.fn(() => true)
}))
function setPlatform(platform: NodeJS.Platform): () => void {
const original = process.platform
@@ -17,6 +22,7 @@ afterEach(() => {
restorePlatform?.()
restorePlatform = null
vi.restoreAllMocks()
vi.clearAllMocks()
})
function makeFakePty(): { proc: pty.IPty; fireExit: () => void } {
@@ -41,6 +47,7 @@ describe('warmWindowsConptyOnce', () => {
await flushImmediates()
expect(spawnPty).not.toHaveBeenCalled()
expect(assignHostProcessToKillOnCloseJob).not.toHaveBeenCalled()
})
it('spawns a short-lived cmd.exe with the bundled ConPTY on Windows', async () => {
@@ -52,6 +59,7 @@ describe('warmWindowsConptyOnce', () => {
await flushImmediates()
expect(spawnPty).toHaveBeenCalledTimes(1)
expect(assignHostProcessToKillOnCloseJob).toHaveBeenCalledBefore(vi.mocked(spawnPty))
const [file, args, options] = vi.mocked(spawnPty).mock.calls[0]
expect(String(file).toLowerCase()).toContain('cmd')
expect(args).toEqual(['/c', 'exit'])
+3
View File
@@ -1,5 +1,6 @@
import os from 'node:os'
import * as pty from 'node-pty'
import { assignHostProcessToKillOnCloseJob } from '../windows/windows-pty-job'
const WARMUP_KILL_TIMEOUT_MS = 10_000
@@ -17,6 +18,8 @@ export function warmWindowsConptyOnce(spawnPty: typeof pty.spawn = pty.spawn): v
// real spawn arriving first simply does the warming itself.
setImmediate(() => {
try {
// Warm-up children must die with the daemon, even before its first real terminal.
assignHostProcessToKillOnCloseJob()
const proc = spawnPty(process.env.COMSPEC || 'cmd.exe', ['/c', 'exit'], {
name: 'xterm-256color',
cols: 2,
@@ -1,6 +1,15 @@
// Empirical proof that the durable write fsyncs the file, and the directory where the platform
// allows it. Counted at the module boundary rather than inferred from reading the implementation.
import { closeSync, fsyncSync, mkdtempSync, openSync, readFileSync, rmSync } from 'node:fs'
import {
closeSync,
existsSync,
fsyncSync,
mkdtempSync,
openSync,
readFileSync,
rmSync,
writeFileSync
} from 'node:fs'
import type * as NodeFs from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -9,23 +18,57 @@ import { expect, it, vi } from 'vitest'
/** Why the rename is recorded too: an fsync moved after the rename still fsyncs a file, so a
* fsync-only log reads identically for the correct and the broken order. The rename is the boundary
* the ordering is defined against, so it has to appear in the same sequence. */
const syscalls: ('fsync:file' | 'fsync:directory' | 'rename')[] = []
const syscalls: ('fsync:file' | 'fsync:directory' | 'rename' | 'link')[] = []
vi.mock('node:fs', async () => {
const actual = await vi.importActual<typeof NodeFs>('node:fs')
return {
...actual,
fsyncSync: (fd: number) => {
actual.fsyncSync(fd)
syscalls.push(actual.fstatSync(fd).isDirectory() ? 'fsync:directory' : 'fsync:file')
return actual.fsyncSync(fd)
},
renameSync: (from: NodeFs.PathLike, to: NodeFs.PathLike) => {
actual.renameSync(from, to)
syscalls.push('rename')
return actual.renameSync(from, to)
},
linkSync: (from: NodeFs.PathLike, to: NodeFs.PathLike) => {
actual.linkSync(from, to)
syscalls.push('link')
}
}
})
it('publishes a new file durably and cannot replace an existing destination', async () => {
const { publishFileDurableSync } = await import('./durable-file-write')
const dir = mkdtempSync(join(tmpdir(), 'orca-publish-fsync-'))
try {
const supported = directoryFsyncSupported(dir)
const staged = join(dir, 'staged')
const target = join(dir, 'target')
writeFileSync(staged, 'first')
const fd = openSync(staged, 'r+')
try {
fsyncSync(fd)
} finally {
closeSync(fd)
}
syscalls.length = 0
expect(publishFileDurableSync(staged, target)).toBe(true)
expect(syscalls).toEqual(supported ? ['link', 'fsync:directory'] : ['link'])
expect(existsSync(staged)).toBe(false)
expect(readFileSync(target, 'utf8')).toBe('first')
writeFileSync(staged, 'second')
syscalls.length = 0
expect(publishFileDurableSync(staged, target)).toBe(false)
expect(readFileSync(target, 'utf8')).toBe('first')
expect(readFileSync(staged, 'utf8')).toBe('second')
expect(syscalls).toEqual([])
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
/** Windows cannot open a directory for fsync, and some filesystems reject it; probe rather than
* assume, so the expectation tracks the real platform instead of a hardcoded OS list. */
function directoryFsyncSupported(directory: string): boolean {
+26 -8
View File
@@ -4,9 +4,13 @@
// hour's loss; fsync stops it from happening.
import { closeSync, fsyncSync, openSync, rmSync, writeFileSync } from 'node:fs'
import { copyFile, open, readdir, rename, rm, stat } from 'node:fs/promises'
import { copyFile, open, readdir, rm, stat } from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
import { renameFileWithWindowsRetry } from './codex-accounts/fs-utils'
import {
publishFileWithoutOverwrite,
renameFileWithWindowsRetry,
renameFileWithWindowsRetryAsync
} from './codex-accounts/fs-utils'
/**
* fsync a directory so a rename within it is durable. Best-effort by design: Windows cannot open a
@@ -43,12 +47,28 @@ function syncDirectorySync(directory: string): void {
}
}
/** Rename an already-fsynced file and make the containing directory durable. */
export function renameDurableSync(tmpPath: string, finalPath: string): void {
renameFileWithWindowsRetry(tmpPath, finalPath)
syncDirectorySync(dirname(finalPath))
}
/** Publish an already-fsynced file without replacing a concurrently created destination. */
export function publishFileDurableSync(tmpPath: string, finalPath: string): boolean {
if (!publishFileWithoutOverwrite(tmpPath, finalPath)) {
return false
}
syncDirectorySync(dirname(finalPath))
rmSync(tmpPath)
return true
}
/**
* Rename and then fsync the containing directory. For callers that already fsynced the temp file
* themselves and need the rename made durable.
*/
export async function renameDurable(tmpPath: string, finalPath: string): Promise<void> {
await rename(tmpPath, finalPath)
await renameFileWithWindowsRetryAsync(tmpPath, finalPath)
await syncDirectory(dirname(finalPath))
}
@@ -96,7 +116,7 @@ export async function copyFileDurable(sourcePath: string, finalPath: string): Pr
} finally {
await handle.close()
}
await rename(tmpPath, finalPath)
await renameFileWithWindowsRetryAsync(tmpPath, finalPath)
renamed = true
await syncDirectory(dirname(finalPath))
return true
@@ -132,10 +152,9 @@ export async function writeFileDurableIfCurrent(
try {
// Why: fsync BEFORE rename. A rename that lands first can expose a zero-length file.
await writeTempFileDurable(tmpPath, payload)
if (!isCurrent()) {
if (!(await renameFileWithWindowsRetryAsync(tmpPath, finalPath, isCurrent))) {
return false
}
await rename(tmpPath, finalPath)
renamed = true
await syncDirectory(dirname(finalPath))
return true
@@ -202,9 +221,8 @@ export function writeFileDurableSync(
} finally {
closeSync(fd)
}
renameFileWithWindowsRetry(tmpPath, finalPath)
renameDurableSync(tmpPath, finalPath)
renamed = true
syncDirectorySync(dirname(finalPath))
} finally {
if (!renamed) {
rmSync(tmpPath, { force: true })
+40 -5
View File
@@ -1,6 +1,7 @@
import { app, type BrowserWindow } from 'electron'
import { app, clipboard, dialog, type BrowserWindow } from 'electron'
import { parseSkillShareId } from '../shared/skill-share-link'
import { createMacAppActivationHandler } from './window/macos-app-activation'
import { isBackgroundLaunch } from './window/foreground-activation-policy'
import {
focusExistingWindow as focusExistingWindowAction,
setMainWindowOpener
@@ -13,6 +14,12 @@ import { initializeMainProcessReady } from './startup/main-process-ready'
import { installMainProcessQuitHandlers } from './startup/main-process-quit'
import { shouldActivateDesktopForSecondInstance } from './startup/single-instance-lock'
import { resolveOpenedMarkdownDocuments } from './startup/os-opened-markdown-files'
import {
formatProfileStateStartupFailure,
profileStateStartupFailureClass
} from './persistence/profile-state/profile-state-startup-failure'
import { recordDurableCrashBreadcrumb } from './crash-reporting/durable-crash-breadcrumb'
import { presentProfileStateStartupRecoveryDialog } from './persistence/profile-state/profile-state-startup-recovery-dialog'
function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): BrowserWindow {
return openMainWindowController(options)
@@ -107,9 +114,37 @@ if (preflightReady) {
registerMainProcessIpcHandlers()
installMainProcessQuitHandlers()
void app.whenReady().then(async () => {
await initializeMainProcessReady({
openMainWindow,
handleMacAppActivation
})
try {
await initializeMainProcessReady({
openMainWindow,
handleMacAppActivation
})
} catch (error) {
const message =
formatProfileStateStartupFailure(error) ??
`Orca could not finish starting: ${error instanceof Error ? error.message : String(error)}`
const failureClass = profileStateStartupFailureClass(error)
if (failureClass !== undefined) {
recordDurableCrashBreadcrumb('profile_state_startup_failed', {
failure_class: failureClass
})
}
console.error(`[profile-state] ${message}`)
if (!state.isServeMode && !isBackgroundLaunch()) {
try {
await presentProfileStateStartupRecoveryDialog({
message,
...(failureClass === 'recovery-required' || failureClass === 'ambiguous-authority'
? { recoveryCommand: 'orca profile state exports' }
: {}),
showMessageBox: (options) => dialog.showMessageBox(options),
copyToClipboard: (text) => clipboard.writeText(text)
})
} catch (dialogError) {
console.warn('[profile-state] Recovery dialog failed; exiting safely:', dialogError)
}
}
app.exit(1)
}
})
}
@@ -0,0 +1,25 @@
import type { TransferOrcaProfileProjectArgs } from '../../shared/orca-profiles'
export function transferProjectArgsFromUnknown(args: unknown): TransferOrcaProfileProjectArgs {
if (
typeof args !== 'object' ||
args === null ||
!('sourceProfileId' in args) ||
typeof args.sourceProfileId !== 'string' ||
!('targetProfileId' in args) ||
typeof args.targetProfileId !== 'string' ||
!('repoId' in args) ||
typeof args.repoId !== 'string' ||
!('mode' in args) ||
(args.mode !== 'move' && args.mode !== 'copy')
) {
throw new Error('invalid_orca_profile_project_transfer')
}
const sourceProfileId = args.sourceProfileId.trim()
const targetProfileId = args.targetProfileId.trim()
const repoId = args.repoId.trim()
if (!sourceProfileId || !targetProfileId || !repoId) {
throw new Error('invalid_orca_profile_project_transfer')
}
return { sourceProfileId, targetProfileId, repoId, mode: args.mode }
}
@@ -0,0 +1,115 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it, vi } from 'vitest'
import {
createWorkerMaintenanceFixture,
maintenanceBarrier
} from '../persistence/loading-store/profile-state-maintenance-fixture'
import { registerOrcaProfileHandlers } from './orca-profiles'
const { handlers, quit, select } = vi.hoisted(() => ({
handlers: new Map<string, (event: unknown, args: unknown) => Promise<unknown>>(),
quit: vi.fn(),
select: vi.fn()
}))
vi.mock('electron', () => ({
app: { quit },
ipcMain: {
handle: (channel: string, handler: (event: unknown, args: unknown) => Promise<unknown>) => {
handlers.set(channel, handler)
}
}
}))
vi.mock('../app-relaunch', () => ({ relaunchApp: vi.fn() }))
vi.mock('../orca-profiles/profile-index-store', () => ({
getOrcaProfileListState: () => ({ activeProfileId: 'source', profiles: [] }),
setActiveOrcaProfile: select,
createLocalOrcaProfile: vi.fn(),
seedNewOrcaProfileTelemetryConsent: vi.fn()
}))
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../telemetry/cohort-classifier', () => ({
getCohortAtEmit: () => ({ nth_repo_added: 2 })
}))
vi.mock('../ssh/ssh-config-parser', () => ({
loadUserSshConfig: () => ({ hosts: [] }),
sshConfigHostsToTargets: () => []
}))
describe('plain profile switch persistence', () => {
it('preserves shutdown changes when quit starts during the switch checkpoint', async () => {
const { store, authority, readState } = await createWorkerMaintenanceFixture()
store.upsertSshRemotePtyLease({ targetId: 'remote', ptyId: 'pty', state: 'attached' })
await store.flushPendingOrThrowAsync()
const started = maintenanceBarrier()
const release = maintenanceBarrier()
const hold = async (write: () => Promise<void>) => {
started.resolve()
await release.promise
await write()
}
const selective = authority.writeSerializedDomains.bind(authority)
const complete = authority.writeCompleteSerializedDomains.bind(authority)
vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce((domains) =>
hold(() => selective(domains))
)
vi.spyOn(authority, 'writeCompleteSerializedDomains').mockImplementationOnce((domains) =>
hold(() => complete(domains))
)
store.updateSettings({ theme: 'dark' })
registerOrcaProfileHandlers(store)
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const switching = handlers
.get('orcaProfiles:switch')?.(
{ sender: { isDestroyed: () => false, send: vi.fn() } },
{ profileId: 'target' }
)
.catch((error: unknown) => error)
await started.promise
store.markSshRemotePtyLeasesForShutdown('remote', 'detached')
const final = store.flushFinalOrThrowAsync()
release.resolve()
await final
await expect(switching).resolves.toEqual({ status: 'relaunching' })
expect(readState()).toMatchObject({
settings: { theme: 'dark' },
sshRemotePtyLeases: [expect.objectContaining({ state: 'detached' })]
})
})
it('admits pre-relaunch writes and includes SSH detach in the final source checkpoint', async () => {
const { store, readState, dataFile } = await createWorkerMaintenanceFixture()
vi.spyOn(console, 'warn').mockImplementation(() => {})
store.upsertSshRemotePtyLease({ targetId: 'remote', ptyId: 'pty', state: 'attached' })
await store.flushPendingOrThrowAsync()
const cleanupSaved = vi.fn()
let final: Promise<void> | undefined
quit.mockImplementation(() => {
store.markSshRemotePtyLeasesForShutdown('remote', 'detached')
final = store.flushFinalOrThrowAsync({ exportJsonCompatibility: true })
})
registerOrcaProfileHandlers(store, {
onBeforeRelaunch: async () => {
store.updateSettings({ theme: 'light' })
await store.flushPendingOrThrowAsync({ drainToStableGeneration: false })
cleanupSaved()
}
})
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const switchProfile = handlers.get('orcaProfiles:switch')
expect(switchProfile).toBeDefined()
await switchProfile?.(
{ sender: { isDestroyed: () => false, send: vi.fn() } },
{ profileId: 'target' }
)
await vi.advanceTimersByTimeAsync(150)
expect(final).toBeDefined()
await final
expect(select).toHaveBeenCalledWith('target')
expect(cleanupSaved).toHaveBeenCalledOnce()
expect(readState()).toMatchObject({
settings: { theme: 'light' },
sshRemotePtyLeases: [expect.objectContaining({ state: 'detached' })]
})
expect(JSON.parse(readFileSync(dataFile, 'utf8'))).toEqual(readState())
})
})
+173 -24
View File
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as ProfileStoragePaths from '../orca-profiles/profile-storage-paths'
const {
handlers,
@@ -11,7 +12,8 @@ const {
getOrcaProfileListStateMock,
seedNewOrcaProfileTelemetryConsentMock,
setActiveOrcaProfileMock,
transferOrcaProfileProjectMock
transferOrcaProfileProjectMock,
hasOrcaProfileStateDatabaseMock
} = vi.hoisted(() => ({
handlers: new Map<string, (_event: unknown, args?: unknown) => unknown>(),
appExitMock: vi.fn(),
@@ -23,7 +25,8 @@ const {
getOrcaProfileListStateMock: vi.fn(),
seedNewOrcaProfileTelemetryConsentMock: vi.fn(),
setActiveOrcaProfileMock: vi.fn(),
transferOrcaProfileProjectMock: vi.fn()
transferOrcaProfileProjectMock: vi.fn(),
hasOrcaProfileStateDatabaseMock: vi.fn()
}))
vi.mock('electron', () => ({
@@ -54,21 +57,36 @@ vi.mock('../orca-profiles/profile-index-store', () => ({
setActiveOrcaProfile: setActiveOrcaProfileMock
}))
function makeStoreMock(flushPendingOrThrowAsync = vi.fn()): {
flushPendingOrThrowAsync: typeof flushPendingOrThrowAsync
freezeWrites: ReturnType<typeof vi.fn>
getSettings: () => Record<string, never>
} {
return { flushPendingOrThrowAsync, freezeWrites: vi.fn(), getSettings: () => ({}) }
function makeStoreMock(flushPendingOrThrowAsync = vi.fn()) {
const freezeWrites = vi.fn()
const resumeMaintenance = vi.fn(async () => {})
return {
flushPendingOrThrowAsync,
freezeWrites,
resumeMaintenance,
beginProfileMaintenance: vi.fn(async (options: unknown) => {
await flushPendingOrThrowAsync(options)
freezeWrites()
return { resume: resumeMaintenance }
}),
getSettings: () => ({})
}
}
vi.mock('../orca-profiles/profile-project-transfer', () => ({
transferOrcaProfileProject: transferOrcaProfileProjectMock
}))
vi.mock('../orca-profiles/profile-storage-paths', async (importOriginal) => ({
...(await importOriginal<typeof ProfileStoragePaths>()),
hasOrcaProfileStateDatabase: hasOrcaProfileStateDatabaseMock
}))
import { registerOrcaProfileHandlers } from './orca-profiles'
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
const ipcEvent = { sender: { isDestroyed: () => false, send: vi.fn() } }
describe('registerOrcaProfileHandlers', () => {
beforeEach(() => {
// Why the port and per-test: userData resolves through AppEnvironment now, and
@@ -76,6 +94,7 @@ describe('registerOrcaProfileHandlers', () => {
installFakeAppEnvironment({ getPath: () => '/tmp/orca-user-data' })
vi.useFakeTimers()
handlers.clear()
ipcEvent.sender.send.mockClear()
appExitMock.mockReset()
appQuitMock.mockReset()
appRelaunchMock.mockReset()
@@ -87,6 +106,7 @@ describe('registerOrcaProfileHandlers', () => {
seedNewOrcaProfileTelemetryConsentMock.mockReset()
setActiveOrcaProfileMock.mockReset()
transferOrcaProfileProjectMock.mockReset()
hasOrcaProfileStateDatabaseMock.mockReset().mockReturnValue(false)
})
afterEach(() => {
@@ -107,12 +127,12 @@ describe('registerOrcaProfileHandlers', () => {
registerOrcaProfileHandlers(makeStoreMock() as never)
await expect(Promise.resolve(handlers.get('orcaProfiles:list')?.(null))).resolves.toEqual({
await expect(Promise.resolve(handlers.get('orcaProfiles:list')?.(ipcEvent))).resolves.toEqual({
...listState,
multiProfileUi: false
})
await expect(
Promise.resolve(handlers.get('orcaProfiles:createLocal')?.(null, { name: 'Work' }))
Promise.resolve(handlers.get('orcaProfiles:createLocal')?.(ipcEvent, { name: 'Work' }))
).resolves.toBe(createState)
expect(createLocalOrcaProfileMock).toHaveBeenCalledWith({ name: 'Work' })
})
@@ -127,11 +147,13 @@ describe('registerOrcaProfileHandlers', () => {
})
registerOrcaProfileHandlers(makeStoreMock() as never)
await expect(Promise.resolve(handlers.get('orcaProfiles:list')?.(null))).resolves.toEqual({
activeProfileId: 'local-default',
profiles: [],
multiProfileUi: true
})
await expect(Promise.resolve(handlers.get('orcaProfiles:list')?.(ipcEvent))).resolves.toEqual(
{
activeProfileId: 'local-default',
profiles: [],
multiProfileUi: true
}
)
} finally {
if (previous === undefined) {
delete process.env.ORCA_MULTI_PROFILE_UI
@@ -155,7 +177,7 @@ describe('registerOrcaProfileHandlers', () => {
registerOrcaProfileHandlers(makeStoreMock(flush) as never, { onBeforeRelaunch })
const resultPromise = Promise.resolve(
handlers.get('orcaProfiles:switch')?.(null, { profileId: 'local-work' })
handlers.get('orcaProfiles:switch')?.(ipcEvent, { profileId: 'local-work' })
)
await expect(resultPromise).resolves.toEqual({ status: 'relaunching' })
@@ -189,7 +211,7 @@ describe('registerOrcaProfileHandlers', () => {
registerOrcaProfileHandlers(makeStoreMock(flush) as never)
await expect(
Promise.resolve(handlers.get('orcaProfiles:switch')?.(null, { profileId: 'local-work' }))
Promise.resolve(handlers.get('orcaProfiles:switch')?.(ipcEvent, { profileId: 'local-work' }))
).rejects.toThrow('flush_failed')
expect(setActiveOrcaProfileMock).not.toHaveBeenCalled()
@@ -206,10 +228,10 @@ describe('registerOrcaProfileHandlers', () => {
registerOrcaProfileHandlers(makeStoreMock(flush) as never, { onBeforeRelaunch })
const switchProfile = Promise.resolve(
handlers.get('orcaProfiles:switch')?.(null, { profileId: 'local-work' })
handlers.get('orcaProfiles:switch')?.(ipcEvent, { profileId: 'local-work' })
)
const rejection = expect(switchProfile).rejects.toThrow('orca_profile_persistence_timeout')
await vi.advanceTimersByTimeAsync(20_000)
await vi.advanceTimersByTimeAsync(60_000)
await rejection
expect(setActiveOrcaProfileMock).not.toHaveBeenCalled()
@@ -225,7 +247,9 @@ describe('registerOrcaProfileHandlers', () => {
registerOrcaProfileHandlers(makeStoreMock() as never)
await expect(
Promise.resolve(handlers.get('orcaProfiles:switch')?.(null, { profileId: 'local-default' }))
Promise.resolve(
handlers.get('orcaProfiles:switch')?.(ipcEvent, { profileId: 'local-default' })
)
).resolves.toEqual({ status: 'already-active' })
expect(setActiveOrcaProfileMock).not.toHaveBeenCalled()
@@ -236,7 +260,7 @@ describe('registerOrcaProfileHandlers', () => {
registerOrcaProfileHandlers(makeStoreMock() as never)
await expect(
Promise.resolve(handlers.get('orcaProfiles:switch')?.(null, { profileId: ' ' }))
Promise.resolve(handlers.get('orcaProfiles:switch')?.(ipcEvent, { profileId: ' ' }))
).rejects.toThrow('invalid_orca_profile_id')
})
@@ -260,7 +284,7 @@ describe('registerOrcaProfileHandlers', () => {
await expect(
Promise.resolve(
handlers.get('orcaProfiles:transferProject')?.(null, {
handlers.get('orcaProfiles:transferProject')?.(ipcEvent, {
sourceProfileId: ' personal ',
targetProfileId: ' work ',
repoId: ' repo-1 ',
@@ -302,7 +326,7 @@ describe('registerOrcaProfileHandlers', () => {
await expect(
Promise.resolve(
handlers.get('orcaProfiles:transferProject')?.(null, {
handlers.get('orcaProfiles:transferProject')?.(ipcEvent, {
sourceProfileId: 'personal',
targetProfileId: 'work',
repoId: 'repo-1',
@@ -328,11 +352,56 @@ describe('registerOrcaProfileHandlers', () => {
await vi.advanceTimersByTimeAsync(150)
expect(appRelaunchMock).toHaveBeenCalledOnce()
expect(ipcEvent.sender.send).toHaveBeenCalledWith('app:restart-committed')
expect(relaunchAppMock).toHaveBeenCalledWith('profile-transfer')
expect(appQuitMock).toHaveBeenCalledOnce()
expect(appExitMock).not.toHaveBeenCalled()
})
it('relaunches the closed source when a completed move cannot update the profile index', async () => {
const store = makeStoreMock()
getOrcaProfileListStateMock.mockReturnValue({ activeProfileId: 'personal', profiles: [] })
transferOrcaProfileProjectMock.mockReturnValue({ status: 'transferred', mode: 'move' })
setActiveOrcaProfileMock.mockImplementationOnce(() => {
throw new Error('profile index disk full')
})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies every Store operation exercised by these IPC handlers.
registerOrcaProfileHandlers(store as never)
await expect(
handlers.get('orcaProfiles:transferProject')?.(ipcEvent, {
sourceProfileId: 'personal',
targetProfileId: 'work',
repoId: 'repo-1',
mode: 'move'
})
).rejects.toThrow('profile index disk full')
expect(store.freezeWrites).toHaveBeenCalledOnce()
expect(store.resumeMaintenance).not.toHaveBeenCalled()
expect(ipcEvent.sender.send).toHaveBeenCalledWith('app:restart-committed')
await vi.advanceTimersByTimeAsync(150)
expect(relaunchAppMock).toHaveBeenCalledWith('profile-transfer')
expect(appQuitMock).toHaveBeenCalledOnce()
})
it('keeps the active profile writable during a transfer between inactive profiles', async () => {
const store = makeStoreMock()
getOrcaProfileListStateMock.mockReturnValue({ activeProfileId: 'active', profiles: [] })
transferOrcaProfileProjectMock.mockReturnValue({ status: 'transferred', mode: 'copy' })
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies the Store operations exercised by the handlers.
registerOrcaProfileHandlers(store as never)
await handlers.get('orcaProfiles:transferProject')?.(ipcEvent, {
sourceProfileId: 'personal',
targetProfileId: 'work',
repoId: 'repo-1',
mode: 'copy'
})
expect(store.beginProfileMaintenance).not.toHaveBeenCalled()
expect(store.freezeWrites).not.toHaveBeenCalled()
expect(store.flushPendingOrThrowAsync).toHaveBeenCalledBefore(transferOrcaProfileProjectMock)
})
it('rejects transfers that would mutate the active target profile offline', async () => {
getOrcaProfileListStateMock.mockReturnValue({
activeProfileId: 'work',
@@ -342,7 +411,7 @@ describe('registerOrcaProfileHandlers', () => {
await expect(
Promise.resolve(
handlers.get('orcaProfiles:transferProject')?.(null, {
handlers.get('orcaProfiles:transferProject')?.(ipcEvent, {
sourceProfileId: 'personal',
targetProfileId: 'work',
repoId: 'repo-1',
@@ -353,4 +422,84 @@ describe('registerOrcaProfileHandlers', () => {
expect(transferOrcaProfileProjectMock).not.toHaveBeenCalled()
})
it('freezes a newly migrated source after transfer failure and reopens its current profile', async () => {
const store = makeStoreMock()
const onBeforeRelaunch = vi.fn()
getOrcaProfileListStateMock.mockReturnValue({ activeProfileId: 'personal', profiles: [] })
transferOrcaProfileProjectMock.mockImplementation(() => {
hasOrcaProfileStateDatabaseMock.mockReturnValue(true)
throw new Error('source commit interrupted')
})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies every Store operation exercised by these IPC handlers.
registerOrcaProfileHandlers(store as never, { onBeforeRelaunch })
await expect(
Promise.resolve(
handlers.get('orcaProfiles:transferProject')?.(ipcEvent, {
sourceProfileId: 'personal',
targetProfileId: 'work',
repoId: 'repo-1',
mode: 'move'
})
)
).rejects.toThrow('source commit interrupted')
expect(store.flushPendingOrThrowAsync).toHaveBeenCalledBefore(transferOrcaProfileProjectMock)
expect(store.freezeWrites).toHaveBeenCalledOnce()
expect(store.freezeWrites).toHaveBeenCalledBefore(onBeforeRelaunch)
expect(setActiveOrcaProfileMock).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(150)
expect(ipcEvent.sender.send).toHaveBeenCalledWith('app:restart-committed')
expect(relaunchAppMock).toHaveBeenCalledWith('profile-transfer')
expect(appQuitMock).toHaveBeenCalledOnce()
})
it('keeps an active JSON source writable after validation fails without a migration', async () => {
const store = makeStoreMock()
const onBeforeRelaunch = vi.fn()
getOrcaProfileListStateMock.mockReturnValue({ activeProfileId: 'personal', profiles: [] })
transferOrcaProfileProjectMock.mockImplementation(() => {
throw new Error('unknown_source_repo')
})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies every Store operation exercised by these IPC handlers.
registerOrcaProfileHandlers(store as never, { onBeforeRelaunch })
await expect(
Promise.resolve(
handlers.get('orcaProfiles:transferProject')?.(ipcEvent, {
sourceProfileId: 'personal',
targetProfileId: 'work',
repoId: 'repo-1',
mode: 'move'
})
)
).rejects.toThrow('unknown_source_repo')
expect(store.resumeMaintenance).toHaveBeenCalledOnce()
expect(onBeforeRelaunch).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(150)
expect(relaunchAppMock).not.toHaveBeenCalled()
})
it.each([
null,
{},
{ sourceProfileId: 4 },
{
sourceProfileId: 'personal',
targetProfileId: 'work',
repoId: 'repo-1',
mode: 'invalid'
}
])('rejects malformed transfer arguments before disk work: %j', async (args) => {
const store = makeStoreMock()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies every Store operation exercised by these IPC handlers.
registerOrcaProfileHandlers(store as never)
await expect(
Promise.resolve(handlers.get('orcaProfiles:transferProject')?.(ipcEvent, args))
).rejects.toThrow('invalid_orca_profile_project_transfer')
expect(store.flushPendingOrThrowAsync).not.toHaveBeenCalled()
expect(transferOrcaProfileProjectMock).not.toHaveBeenCalled()
})
})
+48 -43
View File
@@ -1,4 +1,4 @@
import { app, ipcMain } from 'electron'
import { app, ipcMain, type WebContents } from 'electron'
import type { Store } from '../persistence'
import { relaunchApp, type AppRelaunchReason } from '../app-relaunch'
import type {
@@ -33,8 +33,12 @@ import {
import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths'
import { isMultiProfileUiEnabled } from '../orca-profiles/profile-ui-scope'
import { transferOrcaProfileProject } from '../orca-profiles/profile-project-transfer'
import { transferActiveProfileProject } from '../orca-profiles/profile-active-transfer'
import { findOrcaProfileProjectsByPath } from '../orca-profiles/profile-project-presence'
import { flushActiveProfileBeforeFileMutation } from '../orca-profiles/profile-persistence-deadline'
import {
flushActiveProfileBeforeFileMutation,
flushActiveProfileBeforeRelaunch
} from '../orca-profiles/profile-persistence-deadline'
import { normalizeExecutionHostId } from '../../shared/execution-host'
import {
createCloudLinkedOrcaProfile,
@@ -47,6 +51,7 @@ import {
import { registerOrcaProfileOrgMemberHandlers } from './orca-profile-org-members-handlers'
import { onOrcaCloudSessionInvalidated } from '../orca-profiles/profile-cloud-session-invalidation'
import { broadcastOrcaProfileAuthStatusChanged } from './orca-profile-auth-status-broadcast'
import { transferProjectArgsFromUnknown } from './orca-profile-project-transfer-args'
type RegisterOrcaProfileHandlersOptions = {
onBeforeRelaunch?: () => void | Promise<void>
@@ -55,40 +60,16 @@ type RegisterOrcaProfileHandlersOptions = {
}
function profileIdFromArgs(args: unknown): string {
if (
!args ||
typeof args !== 'object' ||
typeof (args as SwitchOrcaProfileArgs).profileId !== 'string'
) {
throw new Error('invalid_orca_profile_id')
}
const profileId = (args as SwitchOrcaProfileArgs).profileId.trim()
const profileId =
args && typeof args === 'object' && 'profileId' in args && typeof args.profileId === 'string'
? args.profileId.trim()
: ''
if (!profileId) {
throw new Error('invalid_orca_profile_id')
}
return profileId
}
function transferProjectArgsFromUnknown(args: unknown): TransferOrcaProfileProjectArgs {
if (!args || typeof args !== 'object') {
throw new Error('invalid_orca_profile_project_transfer')
}
const candidate = args as TransferOrcaProfileProjectArgs
const sourceProfileId = candidate.sourceProfileId?.trim()
const targetProfileId = candidate.targetProfileId?.trim()
const repoId = candidate.repoId?.trim()
const mode = candidate.mode
if (!sourceProfileId || !targetProfileId || !repoId || (mode !== 'move' && mode !== 'copy')) {
throw new Error('invalid_orca_profile_project_transfer')
}
return {
sourceProfileId,
targetProfileId,
repoId,
mode
}
}
function findProjectsByPathArgsFromUnknown(args: unknown): FindOrcaProfileProjectsByPathArgs {
if (!args || typeof args !== 'object') {
throw new Error('invalid_orca_profile_project_path')
@@ -157,7 +138,12 @@ async function runBeforeProfileRelaunch(
}
}
function scheduleProfileRelaunch(reason: Extract<AppRelaunchReason, `profile-${string}`>): void {
type ProfileRelaunchReason = Extract<AppRelaunchReason, `profile-${string}`>
function scheduleProfileRelaunch(reason: ProfileRelaunchReason, sender: WebContents): void {
if (!sender.isDestroyed()) {
sender.send('app:restart-committed')
}
setTimeout(() => {
relaunchApp(reason)
// Why: app.quit() (not app.exit) so before-quit/will-quit still run —
@@ -197,7 +183,7 @@ export function registerOrcaProfileHandlers(
ipcMain.handle(
'orcaProfiles:switch',
async (_event, args: SwitchOrcaProfileArgs): Promise<SwitchOrcaProfileResult> => {
async (event, args: SwitchOrcaProfileArgs): Promise<SwitchOrcaProfileResult> => {
const profileId = profileIdFromArgs(args)
const current = getOrcaProfileListState()
if (profileId === current.activeProfileId) {
@@ -217,11 +203,12 @@ export function registerOrcaProfileHandlers(
}
// Why: the current profile must be persisted before the global index
// points startup at the target profile.
await flushActiveProfileBeforeFileMutation(store)
await runBeforeProfileRelaunch(options.onBeforeRelaunch)
// Switching leaves source files intact; relaunch cleanup still needs its live writer.
await flushActiveProfileBeforeRelaunch(store)
setActiveOrcaProfile(profileId)
await runBeforeProfileRelaunch(options.onBeforeRelaunch)
scheduleProfileRelaunch('profile-switch')
scheduleProfileRelaunch('profile-switch', event.sender)
return { status: 'relaunching' }
}
@@ -230,7 +217,7 @@ export function registerOrcaProfileHandlers(
ipcMain.handle(
'orcaProfiles:transferProject',
async (
_event,
event,
rawArgs: TransferOrcaProfileProjectArgs
): Promise<TransferOrcaProfileProjectResult> => {
const args = transferProjectArgsFromUnknown(rawArgs)
@@ -241,19 +228,37 @@ export function registerOrcaProfileHandlers(
if (args.mode === 'move' && args.sourceProfileId === current.activeProfileId) {
// Why: transfer before any relaunch side effect so a duplicate-target
// or validation failure cannot strand the app in a quitting state.
await flushActiveProfileBeforeFileMutation(store)
const result = transferOrcaProfileProject(args, getProfileUserDataPath())
const result = await transferActiveProfileProject(
args,
getProfileUserDataPath(),
store,
async () => {
await runBeforeProfileRelaunch(options.onBeforeRelaunch)
scheduleProfileRelaunch('profile-transfer', event.sender)
}
)
if (result.status === 'transferred') {
store.freezeWrites()
await runBeforeProfileRelaunch(options.onBeforeRelaunch)
setActiveOrcaProfile(args.targetProfileId)
scheduleProfileRelaunch('profile-transfer')
try {
setActiveOrcaProfile(args.targetProfileId)
} finally {
// The source has already changed and its writer cannot resume.
scheduleProfileRelaunch('profile-transfer', event.sender)
}
return { ...result, willRelaunch: true }
}
return result
}
await flushActiveProfileBeforeFileMutation(store)
return transferOrcaProfileProject(args, getProfileUserDataPath())
if (args.sourceProfileId !== current.activeProfileId) {
await store.flushPendingOrThrowAsync({ drainToStableGeneration: false })
return transferOrcaProfileProject(args, getProfileUserDataPath())
}
const maintenance = await flushActiveProfileBeforeFileMutation(store)
try {
return transferOrcaProfileProject(args, getProfileUserDataPath())
} finally {
await maintenance.resume()
}
}
)
+7 -6
View File
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { withDurableRuntimeStore } from '../runtime/runtime-durable-store-fixture'
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
import { SessionNotFoundError } from '../daemon/daemon-errors'
import { makePaneKey } from '../../shared/stable-pane-id'
@@ -102,7 +103,7 @@ describe('registerPtyHandlers', () => {
},
terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-proven-absent-owner' }
}
const store = {
const store = withDurableRuntimeStore({
getWorkspaceSession: vi.fn(() => session),
setWorkspaceSession: vi.fn((next) => {
session = next
@@ -113,7 +114,7 @@ describe('registerPtyHandlers', () => {
getFolderWorkspaces: vi.fn(() => []),
getProjectGroups: vi.fn(() => []),
getRepos: vi.fn(() => [])
}
})
const runtime = {
setPtyController: vi.fn(),
resolveTerminalPane: vi.fn(() => {
@@ -226,7 +227,7 @@ describe('registerPtyHandlers', () => {
},
terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-probe-blip-owner' }
}
const store = {
const store = withDurableRuntimeStore({
getWorkspaceSession: vi.fn(() => session),
setWorkspaceSession: vi.fn((next) => {
session = next
@@ -237,7 +238,7 @@ describe('registerPtyHandlers', () => {
getFolderWorkspaces: vi.fn(() => []),
getProjectGroups: vi.fn(() => []),
getRepos: vi.fn(() => [])
}
})
const runtime = {
setPtyController: vi.fn(),
resolveTerminalPane: vi.fn(() => {
@@ -350,7 +351,7 @@ describe('registerPtyHandlers', () => {
},
terminalPtyIncarnationsByPaneKey: {}
}
const store = {
const store = withDurableRuntimeStore({
getWorkspaceSession: vi.fn(() => session),
setWorkspaceSession: vi.fn((next) => {
session = next
@@ -361,7 +362,7 @@ describe('registerPtyHandlers', () => {
getFolderWorkspaces: vi.fn(() => []),
getProjectGroups: vi.fn(() => []),
getRepos: vi.fn(() => [])
}
})
let runtimeOwnsPane = true
const runtime = {
setPtyController: vi.fn(),
@@ -1,3 +1,4 @@
import { withDurableRuntimeStore } from '../runtime/runtime-durable-store-fixture'
import { describe, expect, it, vi } from 'vitest'
import { spawnMock, registerPtyMock } from './pty-ipc-mock-registry'
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
@@ -112,7 +113,7 @@ describe('registerPtyHandlers', () => {
},
terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-dead-ssh-owner' }
}
const store = {
const store = withDurableRuntimeStore({
getWorkspaceSession: vi.fn((requestedHostId?: string) => {
expect(requestedHostId).toBe(hostId)
return session
@@ -128,7 +129,7 @@ describe('registerPtyHandlers', () => {
removeSshRemotePtyLease: vi.fn(),
markSshRemotePtyLease: vi.fn(),
clearSshRemotePtyKillIntent: vi.fn()
}
})
const runtime = {
setPtyController: vi.fn(),
resolveTerminalPane: vi.fn(() => {
@@ -137,6 +137,7 @@ function installRestartHarness(
session = next
}),
flushOrThrow: vi.fn(),
runDurableMutation: vi.fn(async <T>(mutate: () => { value: T }) => mutate().value),
persistPtyBinding: vi.fn(),
getFolderWorkspace: vi.fn(() => undefined),
getFolderWorkspaces: vi.fn(() => []),
@@ -1,3 +1,4 @@
import { withDurableRuntimeStore } from '../runtime/runtime-durable-store-fixture'
import { describe, expect, it, vi } from 'vitest'
import { statSyncMock } from './pty-ipc-mock-registry'
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
@@ -272,7 +273,7 @@ describe('registerPtyHandlers', () => {
},
terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-dead-persisted-owner' }
}
const store = {
const store = withDurableRuntimeStore({
getWorkspaceSession: vi.fn(() => session),
setWorkspaceSession: vi.fn((next) => {
session = next
@@ -293,7 +294,7 @@ describe('registerPtyHandlers', () => {
]),
getProjectGroups: vi.fn(() => []),
getRepos: vi.fn(() => [])
}
})
const runtime = {
setPtyController: vi.fn(),
resolveTerminalPane: vi.fn(() => {
@@ -434,7 +435,7 @@ describe('registerPtyHandlers', () => {
},
terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-unproven-owner' }
}
const store = {
const store = withDurableRuntimeStore({
getWorkspaceSession: vi.fn(() => session),
setWorkspaceSession: vi.fn((next) => {
session = next
@@ -445,7 +446,7 @@ describe('registerPtyHandlers', () => {
getFolderWorkspaces: vi.fn(() => []),
getProjectGroups: vi.fn(() => []),
getRepos: vi.fn(() => [])
}
})
const runtime = {
setPtyController: vi.fn(),
resolveTerminalPane: vi.fn(() => {
@@ -256,7 +256,10 @@ describe('registerPtyHandlers', () => {
})
).rejects.toThrow(/ORCA_TERMINAL_SESSION_STATE_SAVE_FAILED/)
expect(remoteShutdown).toHaveBeenCalledWith(appPtyId, { immediate: true })
expect(remoteShutdown).toHaveBeenCalledWith(appPtyId, {
immediate: true,
expectedIncarnationId: incarnationId
})
expect(store.upsertSshRemotePtyLease).not.toHaveBeenCalled()
expect(store.removeSshRemotePtyLease).not.toHaveBeenCalled()
expect(openCodeClearPtyMock).toHaveBeenCalledWith(appPtyId)
@@ -49,8 +49,14 @@ export function beginPtySpawnForWorktree(
}
} catch (error) {
// Why: worktree ID and cwd can be different roots; release earlier admissions before rejecting.
finishes.toReversed().forEach((finish) => finish())
for (let index = finishes.length - 1; index >= 0; index -= 1) {
finishes[index]!()
}
throw error
}
return () => finishes.toReversed().forEach((finish) => finish())
return () => {
for (let index = finishes.length - 1; index >= 0; index -= 1) {
finishes[index]!()
}
}
}
+61 -83
View File
@@ -1,29 +1,25 @@
import { toSshExecutionHostId } from '../../../../shared/execution-host'
import { markNativeWindowsConptyPty } from '../../../runtime/terminal-model-query-authority'
import { closeStartupQueryAuthorityForPty, getRelayPtyId } from '../provider/registry'
import { createTerminalSessionStateSaveFailureMessage } from '../../../../shared/terminal-session-state-save-failure'
import { recordCodexPaneAccountForSpawn } from '../host-env/codex-home'
import { persistAdmittedStablePaneBinding } from '../pane/stable-owner'
import { claimSshPaneLease } from '../pane/ssh-pane-lease-claim'
import {
pendingByPaneKey,
pendingPtyIdBySerializerGeneration,
rendererSerializerReadiness
} from '../pane/serializer-state'
import { ptyOwnership, ptyIncarnationById, deletePtyOwnership } from '../provider/ownership-state'
import { ptyOwnership, ptyIncarnationById } from '../provider/ownership-state'
import { ptySizes } from '../delivery/visibility-state'
import { resolveCommittedPtySize, type PtyGrid } from '../delivery/attached-pty-size'
import { clearProviderPtyState } from '../provider/state-cleanup'
import { discardUnpersistedPtySpawn } from '../pane/spawn-registration'
import { spawnCommitBindingOrigin } from '../../../persistence/loading-store/pty-binding-span'
import type { PtyIpcSpawnState } from './spawn-state'
export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{
rendererPreSignaled: boolean
rendererAlreadyRegistered: boolean
committedSize: PtyGrid
}> {
export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<PtyGrid> {
const args = ctx.args
try {
ctx.stablePaneBindingPersisted = persistAdmittedStablePaneBinding({
ctx.stablePaneBindingPersisted = await persistAdmittedStablePaneBinding({
store: ctx.deps.store,
owner: ctx.stablePaneOwner,
result: ctx.result,
@@ -40,6 +36,53 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{
agentSessionOperationOutcome: 'unknown' as const
})
}
const committedSize = resolveCommittedPtySize({
result: ctx.result,
requested: { cols: args.cols, rows: args.rows },
cachedBeforeAttach: ctx.sessionSizeBeforeAttach
})
const relayResultId = getRelayPtyId(args.connectionId, ctx.result.id)
// Persist the binding before acknowledging spawn so the renderer debounce cannot orphan history.
if (
ctx.deps.store &&
typeof args.worktreeId === 'string' &&
typeof args.tabId === 'string' &&
ctx.validatedLeafId !== null &&
!ctx.stablePaneBindingPersisted
) {
try {
const binding = {
worktreeId: args.worktreeId,
tabId: args.tabId,
leafId: ctx.validatedLeafId,
ptyId: ctx.result.id,
...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}),
...(ctx.cwd ? { startupCwd: ctx.cwd } : {}),
origin: spawnCommitBindingOrigin(ctx.result)
}
const persisted = args.connectionId
? await ctx.deps.store.persistPtyBinding(binding, toSshExecutionHostId(args.connectionId))
: await ctx.deps.store.persistPtyBinding(binding)
if (persisted === false) {
throw new Error('terminal_pane_owner_changed')
}
} catch (err) {
console.error('[pty] failed to persist PTY binding after spawn:', err)
await discardUnpersistedPtySpawn(ctx.provider, ctx.result, () => {
if (args.connectionId && ctx.deps.store) {
ctx.deps.store.removeSshRemotePtyLease(args.connectionId, relayResultId)
}
})
throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), {
agentSessionOperationOutcome: 'unknown' as const
})
}
}
return committedSize
}
export function publishPtyIpcSpawnCommit(ctx: PtyIpcSpawnState, committedSize: PtyGrid): void {
const args = ctx.args
ctx.spawnTiming.log(ctx.result.id, {
daemon: ctx.isDaemonHostSpawn,
reattach: ctx.result.isReattach ?? false
@@ -59,7 +102,7 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{
ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId)
}
if (ctx.initiallyHidden) {
// Why marked synchronously here: provider data events dispatch on later tasks, so this still lands ahead of the first byte's delivery decision (idempotent if already marked pre-spawn).
// Refresh the pre-spawn hidden mark only after this incarnation survives its save.
ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.result.id, true)
if (ctx.preSpawnHiddenMarkId !== null && ctx.preSpawnHiddenMarkId !== ctx.result.id) {
// Defense: never strand a mark on an id the provider renamed.
@@ -69,88 +112,24 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{
ctx.deps.syncPtyBackgroundedDelivery(ctx.result.id, 'spawn')
closeStartupQueryAuthorityForPty(ctx.result.id)
}
// Why: record the native-Windows-ConPTY determination before the headless seed so the emulator's DA1 override exists from byte zero.
if (ctx.nativeWindowsConptySpawn) {
markNativeWindowsConptyPty(ctx.result.id)
}
const relayResultId = getRelayPtyId(args.connectionId, ctx.result.id)
if (ctx.deps.store && args.connectionId) {
// Why: remote PTYs live in the SSH relay grace window after Orca detaches; persist IDs immediately so reconnect reattaches instead of spawning a fresh shell.
ctx.deps.store.upsertSshRemotePtyLease({
targetId: args.connectionId,
ptyId: relayResultId,
...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}),
...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}),
...(ctx.validatedLeafId ? { leafId: ctx.validatedLeafId } : {}),
state: 'attached',
lastAttachedAt: Date.now()
})
}
if (ctx.preAllocatedHandle && !ctx.stablePaneOwner?.handle) {
if (ctx.deps.runtime?.registerPreAllocatedHandleForPty) {
ctx.deps.runtime.registerPreAllocatedHandleForPty(ctx.result.id, ctx.preAllocatedHandle)
ctx.agentTeamsLeaderHandle = null
}
}
const committedSize = resolveCommittedPtySize({
result: ctx.result,
requested: { cols: args.cols, rows: args.rows },
cachedBeforeAttach: ctx.sessionSizeBeforeAttach
})
ptySizes.set(ctx.result.id, committedSize)
if (ctx.effectiveSessionAppId !== undefined && ctx.effectiveSessionAppId !== ctx.result.id) {
ptySizes.delete(ctx.effectiveSessionAppId)
}
// Why: patch the load-bearing ptyId binding synchronously so a force-quit in the renderer's ~450 ms debounce window can't orphan daemon history or an SSH relay lease (Issue #217).
if (
ctx.deps.store &&
typeof args.worktreeId === 'string' &&
typeof args.tabId === 'string' &&
ctx.validatedLeafId !== null &&
!ctx.stablePaneBindingPersisted
) {
try {
const binding = {
worktreeId: args.worktreeId,
tabId: args.tabId,
leafId: ctx.validatedLeafId,
ptyId: ctx.result.id,
...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}),
...(ctx.cwd ? { startupCwd: ctx.cwd } : {}),
origin: spawnCommitBindingOrigin(ctx.result)
}
if (args.connectionId) {
ctx.deps.store.persistPtyBinding(binding, toSshExecutionHostId(args.connectionId))
} else {
ctx.deps.store.persistPtyBinding(binding)
}
} catch (err) {
console.error('[pty] failed to persist PTY binding after spawn:', err)
if (!ctx.result.isReattach) {
try {
await ctx.provider.shutdown(ctx.result.id, { immediate: true })
} catch (shutdownErr) {
console.warn('[pty] failed to clean up PTY after persistence failure:', shutdownErr)
}
clearProviderPtyState(ctx.result.id)
deletePtyOwnership(ctx.result.id)
}
if (!ctx.result.isReattach && args.connectionId && ctx.deps.store) {
ctx.deps.store.removeSshRemotePtyLease(args.connectionId, relayResultId)
}
throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), {
agentSessionOperationOutcome: 'unknown' as const
})
}
}
// Why here and not at the upsert: this path leases before it binds, so supersession fenced on the
// pane's binding still named the predecessor and bailed on every reconnect — one more reattachable
// lease, and one more `pty.attach`, per reconnect forever. Runs after whichever binding write this
// commit made, so the lease/binding order no longer decides.
if (ctx.deps.store && args.connectionId && ctx.validatedLeafId !== null) {
ctx.deps.store.supersedeSshRemotePtyLeasesForBoundPane(args.connectionId, ctx.validatedLeafId)
}
// Why: when the renderer has declared it will own the serializer for this paneKey, suppress the daemon-snapshot seed so its hydration path is sole authority (keyed on paneKey since the ptyId isn't known yet). See docs/mobile-prefer-renderer-scrollback.md.
claimSshPaneLease({
store: ctx.deps.store,
connectionId: args.connectionId,
ptyId: ctx.result.id,
worktreeId: args.worktreeId,
tabId: args.tabId,
leafId: ctx.validatedLeafId ?? undefined
})
const rendererPreSignaled = ctx.validatedPaneKey
? pendingByPaneKey.has(ctx.validatedPaneKey)
: false
@@ -166,5 +145,4 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{
pendingPtyIdBySerializerGeneration.set(pending.gen, ctx.result.id)
}
}
return { rendererPreSignaled, rendererAlreadyRegistered, committedSize }
}
@@ -9,7 +9,7 @@ import { toAppSshPtyId } from '../../../providers/ssh-pty-id'
import { toSshExecutionHostId } from '../../../../shared/execution-host'
import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types'
import { createPtyIpcSpawnState } from './spawn-state'
import { persistPtyIpcSpawnCommit } from './spawn-commit-persist'
import { persistPtyIpcSpawnCommit, publishPtyIpcSpawnCommit } from './spawn-commit-persist'
vi.mock('electron', () => ({
app: { getPath: () => testState.dir },
@@ -20,15 +20,7 @@ const TARGET = 'ssh-1'
const WORKTREE = 'repo1::/worktree'
const TAB = 'tab-1'
/**
* Drives the shipped IPC spawn commit rather than the store primitives it calls.
*
* The store-level suite could not catch this: it exercised bind-then-upsert, and this path does the
* opposite — it writes the lease row first so a force-quit in the renderer's debounce window cannot
* strand a running remote shell without one, then binds the pane. Supersession is fenced on the
* pane's binding, so under this real order it bailed on the predecessor every time and never re-ran,
* and each reconnect left one more reattachable lease for `reattachKnownPtys` to `pty.attach`.
*/
/** Exercises the shipped binding-then-publication order so reconnects retire earlier leases. */
async function commitSshSpawn(
store: ReturnType<typeof createStore>,
args: { relayPtyId: string; leafId: string }
@@ -45,7 +37,7 @@ async function commitSshSpawn(
const ctx = createPtyIpcSpawnState(deps, spawnArgs)
ctx.result = { id: toAppSshPtyId(TARGET, args.relayPtyId) }
ctx.validatedLeafId = args.leafId
await persistPtyIpcSpawnCommit(ctx)
publishPtyIpcSpawnCommit(ctx, await persistPtyIpcSpawnCommit(ctx))
}
/** One pane's layout, so the two host partitions can be given different bindings for one leaf. */
@@ -150,7 +142,7 @@ describe('the IPC spawn commit keeps one reattachable lease per SSH pane', () =>
state: 'attached'
})
expect(bulkReattachPtyIds(store)).toEqual(['pty2:aaa:1', 'pty2:bbb:1'])
store.persistPtyBinding({
await store.persistPtyBinding({
worktreeId: WORKTREE,
tabId: TAB,
leafId: TEST_LEAF_1,
@@ -226,7 +218,7 @@ describe('the IPC spawn commit keeps one reattachable lease per SSH pane', () =>
})
// Production's writer for an SSH pane binding, and the whole point: it updates ONLY the host
// partition, so `local` is left naming the predecessor until the renderer republishes.
store.persistPtyBinding(
await store.persistPtyBinding(
{ worktreeId: WORKTREE, tabId: TAB, leafId: TEST_LEAF_1, ptyId: successor },
hostId
)
+40 -73
View File
@@ -4,13 +4,7 @@ import { markClaudePtySpawned } from '../../../claude-accounts/live-pty-gate'
import { registerPty } from '../../../memory/pty-registry'
import type { PtySpawnResult } from '../../../providers/types'
import { clearMigrationUnsupportedPtysForPaneKey } from '../../../agent-hooks/migration-unsupported-pty-state'
import { track } from '../../../telemetry/client'
import { getCohortAtEmit } from '../../../telemetry/cohort-classifier'
import {
agentKindSchema,
launchSourceSchema,
requestKindSchema
} from '../../../../shared/telemetry-events'
import { recordPtySpawnTelemetry } from '../pane/spawn-telemetry'
import {
shouldSkipCodexHomeEnvForWindowsShell,
codexReattachedHomeRouteField
@@ -23,65 +17,22 @@ import {
admitRendererAgentLaunchAuthority
} from '../pane/launch-authority'
import type { PtyIpcSpawnState } from './spawn-state'
import { persistPtyIpcSpawnCommit } from './spawn-commit-persist'
import { persistPtyIpcSpawnCommit, publishPtyIpcSpawnCommit } from './spawn-commit-persist'
import { admitPtyReattachOwnership, registerPersistedPtySpawn } from '../pane/spawn-registration'
import { reflowHeadlessTerminalToCommittedGrid } from '../delivery/attached-pty-size'
import { seedHeadlessTerminalFromSpawnResult } from '../pane/terminal-spawn-restore'
import { markNativeWindowsConptyPty } from '../../../runtime/terminal-model-query-authority'
export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawnResult> {
const args = ctx.args
const { rendererPreSignaled, rendererAlreadyRegistered, committedSize } =
await persistPtyIpcSpawnCommit(ctx)
// Why: seed the headless emulator before registerPty so concurrent live PTY data lands on top of the seed, not replacing it (mobile keeps the daemon-restored scrollback).
// Skip when the renderer will be authoritative — its xterm buffer is richer than the daemon snapshot.
if (ctx.deps.runtime && !rendererPreSignaled && !rendererAlreadyRegistered) {
const snapshotSeedSize =
typeof ctx.result.snapshotCols === 'number' && typeof ctx.result.snapshotRows === 'number'
? { cols: ctx.result.snapshotCols, rows: ctx.result.snapshotRows }
: undefined
if (typeof ctx.result.snapshot === 'string' && ctx.result.snapshot.length > 0) {
// Why kitty flags ride seed metadata: the snapshot omits them, but the re-seeded emulator must answer hidden `CSI ? u` with the running app's flags (terminal-query-authority.md).
ctx.deps.runtime.seedHeadlessTerminal(ctx.result.id, ctx.result.snapshot, snapshotSeedSize, {
...(typeof ctx.result.snapshotKittyKeyboardFlags === 'number'
? { kittyKeyboardFlags: ctx.result.snapshotKittyKeyboardFlags }
: {}),
...(ctx.result.snapshotTerminalOwner
? { terminalOwner: ctx.result.snapshotTerminalOwner }
: {})
})
} else if (
ctx.result.coldRestore &&
typeof ctx.result.coldRestore.scrollback === 'string' &&
ctx.result.coldRestore.scrollback.length > 0
) {
const coldRestoreSeedSize =
typeof ctx.result.coldRestore.cols === 'number' &&
typeof ctx.result.coldRestore.rows === 'number'
? { cols: ctx.result.coldRestore.cols, rows: ctx.result.coldRestore.rows }
: undefined
ctx.deps.runtime.seedHeadlessTerminal(
ctx.result.id,
ctx.result.coldRestore.scrollback,
coldRestoreSeedSize,
{
cwd: ctx.result.coldRestore.cwd,
oscLinks: ctx.result.coldRestore.oscLinks,
preferProviderIfExisting: true
}
)
} else if (typeof ctx.result.replay === 'string' && ctx.result.replay.length > 0) {
// Why: relay reattach replay is the only restore main never ingests; skip this seed and park-reveal would replace it with a suffix fragment.
ctx.deps.runtime.seedHeadlessTerminal(ctx.result.id, ctx.result.replay)
}
admitPtyReattachOwnership(ctx.deps.runtime, ctx.result, args.connectionId)
if (ctx.nativeWindowsConptySpawn) {
markNativeWindowsConptyPty(ctx.result.id)
}
// Why after the seed: a seed skips an existing model, and live bytes may have lazily created
// one at the 80x24 default before the spawn reply revealed the session's real grid.
reflowHeadlessTerminalToCommittedGrid({
result: ctx.result,
committedSize,
reflowHeadlessTerminalToPtyGrid: ctx.deps.runtime?.reflowHeadlessTerminalToPtyGrid?.bind(
ctx.deps.runtime
)
})
// Seed before the first disk await so live output appends to the restored history.
seedHeadlessTerminalFromSpawnResult(ctx.deps.runtime, ctx.result, ctx.validatedPaneKey)
seedTerminalRestoreRecordsFromSpawnResult(ctx.deps.runtime, ctx.result)
const committedSize = await persistPtyIpcSpawnCommit(ctx)
if (
typeof args.worktreeId === 'string' &&
args.worktreeId.length > 0 &&
@@ -101,7 +52,9 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawn
launchAgent: ctx.result.launchAgent,
incarnationId: ctx.result.incarnationId
})
ctx.deps.runtime?.registerPty(
const rejectedRegistration = registerPersistedPtySpawn(
ctx.deps.runtime,
ctx.deps.store,
ctx.result.id,
args.worktreeId,
args.connectionId ?? null,
@@ -123,6 +76,21 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawn
? shouldSkipCodexHomeEnvForWindowsShell(ctx.effectiveShellOverride, ctx.cwd)
: undefined
)
if (rejectedRegistration) {
await rejectedRegistration.catch((error: unknown) => {
if (!(error instanceof Error) || error.message !== 'agent_session_exited_during_start') {
throw error
}
})
ctx.deps.runtime?.cancelPendingPtyRegistration?.(ctx.result.id, ctx.result.incarnationId)
ctx.pendingRegistrationPtyId = null
// The renderer drains this incarnation's buffered output and exit without publishing it live.
return resolvePaneSpawnReservation(
ctx.paneSpawnReservationKey,
ctx.paneSpawnReservation,
ctx.result
)
}
ctx.pendingRegistrationPtyId = null
} else if (ctx.pendingRegistrationPtyId) {
ctx.deps.runtime?.cancelPendingPtyRegistration?.(
@@ -131,6 +99,15 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawn
)
ctx.pendingRegistrationPtyId = null
}
publishPtyIpcSpawnCommit(ctx, committedSize)
// Admission must precede reflow: a replaced spawn cannot resize its successor's model.
reflowHeadlessTerminalToCommittedGrid({
result: ctx.result,
committedSize,
reflowHeadlessTerminalToPtyGrid: ctx.deps.runtime?.reflowHeadlessTerminalToPtyGrid?.bind(
ctx.deps.runtime
)
})
// Why: seed after registerPty binds the worktree — including on
// desktop, where the renderer-authority gate above skips the emulator
// seed but the list/read records still live main-side.
@@ -201,17 +178,7 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawn
}
// Why: telemetry-plan.md§Agent launch semantics — fire agent_started only after spawn resolved; safeParse each field so a spoofed IPC payload can't poison the event (missing required field skips it).
if (args.telemetry && !ctx.stablePaneOwner) {
const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind)
const launchSourceParse = launchSourceSchema.safeParse(args.telemetry.launch_source)
const requestKindParse = requestKindSchema.safeParse(args.telemetry.request_kind)
if (agentKindParse.success && launchSourceParse.success && requestKindParse.success) {
track('agent_started', {
agent_kind: agentKindParse.data,
launch_source: launchSourceParse.data,
request_kind: requestKindParse.data,
...getCohortAtEmit()
})
}
recordPtySpawnTelemetry(args.telemetry)
}
const response = {
...ctx.result,
@@ -0,0 +1,81 @@
import type { Store } from '../../../persistence'
import type { OrcaRuntimeService } from '../../../runtime/orca-runtime'
import type { IPtyProvider, PtySpawnResult } from '../../../providers/types'
import { isCurrentPtyExit, ptyIncarnationById, ptyOwnership } from '../provider/ownership-state'
import { clearProviderPtyState } from '../provider/state-cleanup'
import { retirePersistedStablePaneOwner } from './stable-owner'
export function admitPtyReattachOwnership(
runtime: OrcaRuntimeService | undefined,
result: PtySpawnResult,
connectionId: string | null | undefined
): void {
if (!result.isReattach && result.agentSessionEnsure?.disposition !== 'adopted') {
return
}
runtime?.assertPtyRegistrationAllowed?.(result.id, result.incarnationId)
// A failed local save must not strand a live process already admitted by its host.
ptyOwnership.set(result.id, connectionId ?? ptyOwnership.get(result.id) ?? null)
if (result.incarnationId) {
ptyIncarnationById.set(result.id, result.incarnationId)
}
}
export async function discardUnpersistedPtySpawn(
provider: IPtyProvider,
result: PtySpawnResult,
onDiscarded?: () => void
): Promise<void> {
if (
result.isReattach ||
result.agentSessionEnsure?.disposition === 'adopted' ||
!isCurrentPtyExit(result)
) {
return
}
try {
await provider.shutdown(result.id, {
immediate: true,
...(result.incarnationId ? { expectedIncarnationId: result.incarnationId } : {})
})
} catch (error) {
console.warn('[pty] failed to clean up PTY after persistence failure:', error)
}
// A replacement may arrive while the execution host finishes shutting down the predecessor.
if (isCurrentPtyExit(result)) {
clearProviderPtyState(result.id)
ptyOwnership.delete(result.id)
onDiscarded?.()
}
}
// Successful registration must not yield before the remaining spawn publication.
export function registerPersistedPtySpawn(
runtime: OrcaRuntimeService | undefined,
store: Store | undefined,
...args: Parameters<OrcaRuntimeService['registerPty']>
): Promise<never> | undefined {
try {
runtime?.registerPty(...args)
} catch (error) {
const [ptyId, worktreeId, connectionId, binding] = args
// An exit during the binding write precedes runtime surface registration.
if (
error instanceof Error &&
error.message === 'agent_session_exited_during_start' &&
runtime?.getPtyLivenessVerdict?.(ptyId)?.status === 'exited' &&
binding
) {
return retirePersistedStablePaneOwner(
store,
{ ...binding, ptyId, persistedIncarnationId: binding.incarnationId },
worktreeId,
connectionId
).then(() => {
throw error
})
}
throw error
}
return undefined
}
+24
View File
@@ -0,0 +1,24 @@
import { track } from '../../../telemetry/client'
import { getCohortAtEmit } from '../../../telemetry/cohort-classifier'
import {
agentKindSchema,
launchSourceSchema,
requestKindSchema
} from '../../../../shared/telemetry-events'
import type { PtySpawnIpcArgs } from '../ipc/spawn-types'
export function recordPtySpawnTelemetry(
telemetry: NonNullable<PtySpawnIpcArgs['telemetry']>
): void {
const agentKind = agentKindSchema.safeParse(telemetry.agent_kind)
const launchSource = launchSourceSchema.safeParse(telemetry.launch_source)
const requestKind = requestKindSchema.safeParse(telemetry.request_kind)
if (agentKind.success && launchSource.success && requestKind.success) {
track('agent_started', {
agent_kind: agentKind.data,
launch_source: launchSource.data,
request_kind: requestKind.data,
...getCohortAtEmit()
})
}
}
+43 -33
View File
@@ -1,3 +1,5 @@
import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from '../../../persistence/restoring-sessions/workspace-session-write-rollback'
import { cloneWorkspaceSessionState } from '../../../persistence/restoring-sessions/session-owner-fields'
import { toSshExecutionHostId } from '../../../../shared/execution-host'
import { makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id'
import { UNVERIFIED_PROCESS_EXIT_CODE } from '../../../../shared/terminal-exit-cause'
@@ -98,8 +100,7 @@ export function resolveStablePaneOwner(
}
const registeredConnectionId = ptyOwnership.get(ptyId)
const parsedSshId = registeredConnectionId === undefined ? parseAppSshPtyId(ptyId) : null
const ownerConnectionId = registeredConnectionId ?? parsedSshId?.connectionId ?? null
if (ownerConnectionId !== (connectionId ?? null)) {
if ((registeredConnectionId ?? parsedSshId?.connectionId ?? null) !== (connectionId ?? null)) {
throw new Error('terminal_pane_owner_host_mismatch')
}
const runtimeIncarnationId = ptyIncarnationById.get(ptyId)
@@ -121,41 +122,50 @@ export function resolveStablePaneOwner(
}
}
export function retirePersistedStablePaneOwner(
export async function retirePersistedStablePaneOwner(
store: Store | undefined,
owner: StablePaneOwner,
worktreeId: string,
connectionId: string | null | undefined
): boolean {
): Promise<boolean> {
if (!store) {
return false
}
const paneKey = makePaneKey(owner.tabId, owner.leafId)
const hostId = connectionId ? toSshExecutionHostId(connectionId) : undefined
const current = resolvePersistedStablePaneOwner(store, paneKey, worktreeId, connectionId)
if (!current) {
// Why: persistence already dropped this pane binding (an earlier stop retired it while the
// runtime kept history), so there is nothing left to clear — that is a completed retirement,
// not a competing owner. Reporting failure here strands the pane after its PTY is proven dead.
return true
}
if (current.ptyId !== owner.ptyId || current.incarnationId !== owner.persistedIncarnationId) {
return false
}
const session = store.getWorkspaceSession(hostId)
const retired = retireTerminalSurfaceFromPersistence(session, {
worktreeId,
parentTabId: owner.tabId,
leafId: owner.leafId,
ptyId: owner.ptyId,
...(current.incarnationId ? { incarnationId: current.incarnationId } : {})
return store.runDurableMutation(() => {
const paneKey = makePaneKey(owner.tabId, owner.leafId)
const hostId = connectionId ? toSshExecutionHostId(connectionId) : undefined
const current = resolvePersistedStablePaneOwner(store, paneKey, worktreeId, connectionId)
if (!current) {
// A renderer removal may still be waiting for its debounced write.
return { value: true, persist: 'if-dirty' }
}
if (current.ptyId !== owner.ptyId || current.incarnationId !== owner.persistedIncarnationId) {
return { value: false, persist: false }
}
const session = cloneWorkspaceSessionState(store.getWorkspaceSession(hostId))
const retired = retireTerminalSurfaceFromPersistence(session, {
worktreeId,
parentTabId: owner.tabId,
leafId: owner.leafId,
ptyId: owner.ptyId,
...(current.incarnationId ? { incarnationId: current.incarnationId } : {})
})
if (retired === session) {
return { value: false, persist: false }
}
store.setWorkspaceSession(retired, hostId)
const staged = cloneWorkspaceSessionState(store.getWorkspaceSession(hostId))
return {
value: true,
rollback: () => {
const current = store.getWorkspaceSession(hostId)
const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite(session, staged, current)
if (rolledBack !== current) {
store.setWorkspaceSession(rolledBack, hostId)
}
}
}
})
if (retired === session) {
return false
}
store.setWorkspaceSession(retired, hostId)
store.flushOrThrow()
return true
}
export type StablePaneSpawnContext = {
@@ -181,19 +191,19 @@ export function stablePanePersistenceFence(
: undefined
}
export function persistAdmittedStablePaneBinding(args: {
export async function persistAdmittedStablePaneBinding(args: {
store: Store | undefined
owner: StablePaneOwner | null
result: PtySpawnResult
worktreeId: string | undefined
startupCwd: string | undefined
connectionId: string | null | undefined
}): boolean {
}): Promise<boolean> {
const expectedBinding = stablePanePersistenceFence(args.owner)
if (!args.store || !args.owner || !args.worktreeId || !expectedBinding) {
return false
}
const persisted = args.store.persistPtyBinding(
const persisted = await args.store.persistPtyBinding(
{
worktreeId: args.worktreeId,
tabId: args.owner.tabId,
@@ -270,7 +280,7 @@ export async function attachStablePaneOwner(
ptyOwnership.delete(owner.ptyId)
if (
args.worktreeId &&
!retirePersistedStablePaneOwner(args.store, owner, args.worktreeId, args.connectionId)
!(await retirePersistedStablePaneOwner(args.store, owner, args.worktreeId, args.connectionId))
) {
throw new Error('terminal_pane_owner_changed')
}
@@ -9,6 +9,7 @@
// same rule here, and pin that the marked half — the one refusal the relay backed with a pid probe
// — still earns the certificate, so a genuinely dead PTY is not left `unverifiable` forever.
import { describe, expect, it, vi } from 'vitest'
import { withDurableRuntimeStore } from '../../../runtime/runtime-durable-store-fixture'
import { getDefaultWorkspaceSession } from '../../../../shared/constants'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { SSH_EXIT_UNCONFIRMED_REASON } from '../../../../shared/pty-liveness-verdict'
@@ -54,7 +55,8 @@ function paneStore(): { store: Store; read: () => WorkspaceSessionState } {
} as unknown as WorkspaceSessionState
return {
read: () => session,
store: {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies the persistence methods used by stable-pane retirement and exit bookkeeping.
store: withDurableRuntimeStore({
getWorkspaceSession: () => session,
setWorkspaceSession: (next: WorkspaceSessionState) => {
session = next
@@ -75,7 +77,7 @@ function paneStore(): { store: Store; read: () => WorkspaceSessionState } {
removeWorktreeMeta: () => {},
getSettings: () => ({ workspaceDir: '/tmp/workspaces' }),
getProjects: () => []
} as unknown as Store
}) as unknown as Store
}
}
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { withDurableRuntimeStore } from '../../../runtime/runtime-durable-store-fixture'
import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types'
import { TerminalSessionOwnerUnverifiedError } from '../../../daemon/daemon-errors'
import {
@@ -70,13 +71,14 @@ function sessionStore(leaves: string[]): { store: Store; read: () => WorkspaceSe
} as unknown as WorkspaceSessionState
return {
read: () => session,
store: {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies every persistence method used by stable-pane retirement.
store: withDurableRuntimeStore({
getWorkspaceSession: () => session,
setWorkspaceSession: (next: WorkspaceSessionState) => {
session = next
},
flushOrThrow: () => {}
} as unknown as Store
}) as unknown as Store
}
}
@@ -0,0 +1,46 @@
import type { PtySpawnResult } from '../../../providers/types'
import type { OrcaRuntimeService } from '../../../runtime/orca-runtime'
import { pendingByPaneKey, rendererSerializerReadiness } from './serializer-state'
export function seedHeadlessTerminalFromSpawnResult(
runtime: OrcaRuntimeService | undefined,
result: PtySpawnResult,
paneKey: string | null
): void {
// A mounted renderer owns richer history than the provider snapshot.
if (
!runtime ||
(paneKey && pendingByPaneKey.has(paneKey)) ||
(result.isReattach === true && rendererSerializerReadiness.has(result.id))
) {
return
}
if (typeof result.snapshot === 'string' && result.snapshot.length > 0) {
const size =
typeof result.snapshotCols === 'number' && typeof result.snapshotRows === 'number'
? { cols: result.snapshotCols, rows: result.snapshotRows }
: undefined
runtime.seedHeadlessTerminal(result.id, result.snapshot, size, {
...(typeof result.snapshotKittyKeyboardFlags === 'number'
? { kittyKeyboardFlags: result.snapshotKittyKeyboardFlags }
: {}),
...(result.snapshotTerminalOwner ? { terminalOwner: result.snapshotTerminalOwner } : {})
})
} else if (
result.coldRestore &&
typeof result.coldRestore.scrollback === 'string' &&
result.coldRestore.scrollback.length > 0
) {
const size =
typeof result.coldRestore.cols === 'number' && typeof result.coldRestore.rows === 'number'
? { cols: result.coldRestore.cols, rows: result.coldRestore.rows }
: undefined
runtime.seedHeadlessTerminal(result.id, result.coldRestore.scrollback, size, {
cwd: result.coldRestore.cwd,
oscLinks: result.coldRestore.oscLinks,
preferProviderIfExisting: true
})
} else if (typeof result.replay === 'string' && result.replay.length > 0) {
runtime.seedHeadlessTerminal(result.id, result.replay)
}
}
+78 -93
View File
@@ -1,5 +1,5 @@
import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id'
import { ptyOwnership, ptyIncarnationById, deletePtyOwnership } from '../provider/ownership-state'
import { ptyOwnership, ptyIncarnationById } from '../provider/ownership-state'
import { ptySizes } from '../delivery/visibility-state'
import { commitRuntimePtySize } from './spawn-commit-pty-size'
import {
@@ -16,13 +16,8 @@ import {
rendererSerializerReadiness
} from '../pane/serializer-state'
import { seedTerminalRestoreRecordsFromSpawnResult } from '../pane/agent-session-owners'
import { track } from '../../../telemetry/client'
import { getCohortAtEmit } from '../../../telemetry/cohort-classifier'
import {
agentKindSchema,
launchSourceSchema,
requestKindSchema
} from '../../../../shared/telemetry-events'
import { seedHeadlessTerminalFromSpawnResult } from '../pane/terminal-spawn-restore'
import { recordPtySpawnTelemetry } from '../pane/spawn-telemetry'
import { persistAdmittedStablePaneBinding } from '../pane/stable-owner'
import { claimSshPaneLease } from '../pane/ssh-pane-lease-claim'
import {
@@ -31,17 +26,34 @@ import {
} from '../../../runtime/terminal-model-query-authority'
import { toSshExecutionHostId } from '../../../../shared/execution-host'
import { createTerminalSessionStateSaveFailureMessage } from '../../../../shared/terminal-session-state-save-failure'
import { clearProviderPtyState } from '../provider/state-cleanup'
import { resolvePaneSpawnReservation } from '../pane/spawn-reservation'
import { admitProviderReattachLaunchIdentity } from '../pane/launch-authority'
import { spawnCommitBindingOrigin } from '../../../persistence/loading-store/pty-binding-span'
import type { RuntimePtySpawnState } from './spawn-state'
import {
admitPtyReattachOwnership,
discardUnpersistedPtySpawn,
registerPersistedPtySpawn
} from '../pane/spawn-registration'
export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
const args = ctx.args
admitPtyReattachOwnership(ctx.deps.runtime, ctx.result, args.connectionId)
const providerReattachLaunchIdentity = admitProviderReattachLaunchIdentity(ctx.result)
if (
isNativeWindowsLocalPtySpawn({
connectionId: args.connectionId,
cwd: args.cwd,
shellOverride: ctx.daemonShellOverride
})
) {
markNativeWindowsConptyPty(ctx.result.id)
}
// Seed before the first disk await so live output appends to the restored history.
seedHeadlessTerminalFromSpawnResult(ctx.deps.runtime, ctx.result, ctx.spawnIdentityPaneKey)
seedTerminalRestoreRecordsFromSpawnResult(ctx.deps.runtime, ctx.result)
try {
ctx.stablePaneBindingPersisted = persistAdmittedStablePaneBinding({
ctx.stablePaneBindingPersisted = await persistAdmittedStablePaneBinding({
store: ctx.hostSessionBinding?.store,
owner: ctx.stablePaneOwner,
result: ctx.result,
@@ -63,12 +75,9 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
// reply omits isReattach; derive it once so the size commit and the reservation agree.
const adoptedResult = { ...ctx.result, isReattach: true }
const owner = ctx.result.agentSessionEnsure.owner
ptyOwnership.set(ctx.result.id, args.connectionId ?? ptyOwnership.get(ctx.result.id) ?? null)
ctx.deps.runtime?.registerPreAllocatedHandleForPty(ctx.result.id, owner.surface.terminalHandle)
if (ctx.result.incarnationId) {
ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId)
}
ctx.deps.runtime?.registerPty(
const rejectedRegistration = registerPersistedPtySpawn(
ctx.deps.runtime,
ctx.hostSessionBinding?.store ?? ctx.deps.store,
ctx.result.id,
owner.surface.worktreeId,
args.connectionId ?? null,
@@ -80,6 +89,14 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
...(providerReattachLaunchIdentity ? { providerReattachLaunchIdentity } : {})
}
)
if (rejectedRegistration) {
await rejectedRegistration
}
ptyOwnership.set(ctx.result.id, args.connectionId ?? ptyOwnership.get(ctx.result.id) ?? null)
ctx.deps.runtime?.registerPreAllocatedHandleForPty(ctx.result.id, owner.surface.terminalHandle)
if (ctx.result.incarnationId) {
ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId)
}
if (!args.connectionId) {
ctx.deps.options?.onCodexHomePtySpawned?.({
id: ctx.result.id,
@@ -108,81 +125,29 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
agentSessionEnsure: ctx.result.agentSessionEnsure
}
}
ptyOwnership.set(ctx.result.id, args.connectionId ?? null)
if (ctx.result.incarnationId) {
ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId)
}
// Why: record the native-Windows-local-PTY determination before any byte reaches the emulator, so its ConPTY DA1 override exists from byte zero.
if (
isNativeWindowsLocalPtySpawn({
connectionId: args.connectionId,
cwd: args.cwd,
shellOverride: ctx.daemonShellOverride
})
) {
markNativeWindowsConptyPty(ctx.result.id)
}
const persistSshLease = (): void =>
claimSshPaneLease({
store: ctx.deps.store,
connectionId: args.connectionId,
ptyId: ctx.result.id,
worktreeId: args.worktreeId,
tabId: args.tabId,
leafId: args.leafId
})
if (!ctx.hostSessionBinding) {
persistSshLease()
}
commitRuntimePtySize(ctx, ctx.result)
if (ctx.effectiveSessionAppId !== undefined && ctx.effectiveSessionAppId !== ctx.result.id) {
ptySizes.delete(ctx.effectiveSessionAppId)
}
recordCodexPaneAccountForSpawn({
ptyId: ctx.result.id,
isDaemonHostSpawn: ctx.isDaemonHostSpawn,
isReattach: ctx.result.isReattach === true,
pinnedByResume: ctx.codexResumeHomeSelected,
launchCodexHomePath: ctx.selectedCodexHomePath,
launchEnv: args.env,
target: ctx.codexSelectionTarget,
settings: ctx.deps.getSettings?.()
})
if (ctx.hostSessionBinding && !ctx.stablePaneBindingPersisted) {
try {
const { store, worktreeId, tabId, leafId, expectedSourceBinding } = ctx.hostSessionBinding
const binding = {
worktreeId: ctx.hostSessionBinding.worktreeId,
tabId: ctx.hostSessionBinding.tabId,
leafId: ctx.hostSessionBinding.leafId,
worktreeId,
tabId,
leafId,
ptyId: ctx.result.id,
hostAdmittedMembership: true,
...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}),
...(ctx.cwd ? { startupCwd: ctx.cwd } : {}),
...(ctx.hostSessionBinding.expectedSourceBinding
? { expectedSourceBinding: ctx.hostSessionBinding.expectedSourceBinding }
: {}),
origin: spawnCommitBindingOrigin(ctx.result, ctx.hostSessionBinding.expectedSourceBinding)
...(expectedSourceBinding ? { expectedSourceBinding } : {}),
origin: spawnCommitBindingOrigin(ctx.result, expectedSourceBinding)
}
const persisted = args.connectionId
? ctx.hostSessionBinding.store.persistPtyBinding(
binding,
toSshExecutionHostId(args.connectionId)
)
: ctx.hostSessionBinding.store.persistPtyBinding(binding)
? await store.persistPtyBinding(binding, toSshExecutionHostId(args.connectionId))
: await store.persistPtyBinding(binding)
if (persisted === false) {
throw new Error('terminal_split_source_not_found')
}
} catch (err) {
console.error('[pty] failed to persist runtime PTY binding after spawn:', err)
if (!ctx.result.isReattach) {
deletePtyOwnership(ctx.result.id)
try {
await ctx.provider.shutdown(ctx.result.id, { immediate: true })
} catch (shutdownErr) {
console.warn('[pty] failed to clean up PTY after persistence failure:', shutdownErr)
}
clearProviderPtyState(ctx.result.id)
}
await discardUnpersistedPtySpawn(ctx.provider, ctx.result)
if (err instanceof Error && err.message === 'terminal_split_source_not_found') {
throw err
}
@@ -190,13 +155,11 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
agentSessionOperationOutcome: 'unknown' as const
})
}
persistSshLease()
}
if (args.preAllocatedHandle && !ctx.stablePaneOwner?.handle) {
ctx.deps.runtime?.registerPreAllocatedHandleForPty(ctx.result.id, args.preAllocatedHandle)
}
if (args.worktreeId) {
ctx.deps.runtime?.registerPty(
const rejectedRegistration = registerPersistedPtySpawn(
ctx.deps.runtime,
ctx.hostSessionBinding?.store ?? ctx.deps.store,
ctx.result.id,
args.worktreeId,
args.connectionId ?? null,
@@ -217,10 +180,42 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
? shouldSkipCodexHomeEnvForWindowsShell(ctx.daemonShellOverride, ctx.cwd)
: undefined
)
if (rejectedRegistration) {
await rejectedRegistration
}
} else {
// Why: non-worktree PTYs have no later surface-registration phase to clear admission intent.
ctx.deps.runtime?.cancelPendingPtyRegistration?.(ctx.result.id, ctx.result.incarnationId)
}
if (args.preAllocatedHandle && !ctx.stablePaneOwner?.handle) {
ctx.deps.runtime?.registerPreAllocatedHandleForPty(ctx.result.id, args.preAllocatedHandle)
}
ptyOwnership.set(ctx.result.id, args.connectionId ?? null)
if (ctx.result.incarnationId) {
ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId)
}
claimSshPaneLease({
store: ctx.deps.store,
connectionId: args.connectionId,
ptyId: ctx.result.id,
worktreeId: args.worktreeId,
tabId: args.tabId,
leafId: args.leafId
})
commitRuntimePtySize(ctx, ctx.result)
if (ctx.effectiveSessionAppId !== undefined && ctx.effectiveSessionAppId !== ctx.result.id) {
ptySizes.delete(ctx.effectiveSessionAppId)
}
recordCodexPaneAccountForSpawn({
ptyId: ctx.result.id,
isDaemonHostSpawn: ctx.isDaemonHostSpawn,
isReattach: ctx.result.isReattach === true,
pinnedByResume: ctx.codexResumeHomeSelected,
launchCodexHomePath: ctx.selectedCodexHomePath,
launchEnv: args.env,
target: ctx.codexSelectionTarget,
settings: ctx.deps.getSettings?.()
})
// Why: runtime-controller creates (headless serve, CLI, splits) adopt surviving daemon sessions too; without this seed their records stay blank.
seedTerminalRestoreRecordsFromSpawnResult(ctx.deps.runtime, ctx.result)
// Why: arms main's per-PTY Command Code output detector from the launch command (renderer startupCommand parity).
@@ -231,17 +226,7 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
markClaudePtySpawned(ctx.result.id)
}
if (args.telemetry && !ctx.stablePaneOwner) {
const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind)
const launchSourceParse = launchSourceSchema.safeParse(args.telemetry.launch_source)
const requestKindParse = requestKindSchema.safeParse(args.telemetry.request_kind)
if (agentKindParse.success && launchSourceParse.success && requestKindParse.success) {
track('agent_started', {
agent_kind: agentKindParse.data,
launch_source: launchSourceParse.data,
request_kind: requestKindParse.data,
...getCohortAtEmit()
})
}
recordPtySpawnTelemetry(args.telemetry)
}
// Why: runtime-owned CLI PTYs bypass the renderer pty:spawn handler; record paneKey here too since hook titles and cache cleanup need this reverse lookup.
const paneKey = rememberPaneKeyForPty(ctx.result.id, ctx.env?.ORCA_PANE_KEY)
+13 -8
View File
@@ -31,17 +31,22 @@ export function registerSessionHandlers(store: Store): void {
ipcMain.handle('session:flush', () => {
// Why: durable lifecycle RPCs must propagate disk failures instead of
// returning success through Store.flush(), which intentionally only logs.
store.flushOrThrow()
return store.flushPendingOrThrowAsync()
})
// Synchronous variant for the renderer's beforeunload handler.
// sendSync blocks the renderer until this returns, guaranteeing the
// data (including terminal scrollback buffers) is persisted to disk
// before the window closes — regardless of before-quit ordering.
// Older renderers block on the reply; main remains free to await the writer.
ipcMain.on('session:set-sync', (event, args: WorkspaceSessionState, hostId?: string | null) => {
store.setWorkspaceSession(args, hostId)
store.flush()
event.returnValue = true
void (async () => {
try {
store.setWorkspaceSession(args, hostId)
await store.flushPendingOrThrowAsync({ drainToStableGeneration: false })
} catch (error) {
console.error('[persistence] Failed to flush legacy session checkpoint:', error)
} finally {
// This legacy response has always been best effort, including on disk errors.
event.returnValue = true
}
})()
})
ipcMain.on(
@@ -0,0 +1,127 @@
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { getDefaultPersistedState } from '../../shared/constants'
import { ORCA_PROFILE_INDEX_SCHEMA_VERSION } from '../../shared/orca-profiles'
import { createWorkerMaintenanceFixture } from '../persistence/loading-store/profile-state-maintenance-fixture'
import { ProfileStateSqliteAuthority } from '../persistence/profile-state/profile-state-sqlite-authority'
import { transferActiveProfileProject } from './profile-active-transfer'
import * as domainState from './profile-project-domain-state'
import {
profileHasPendingProjectMove,
recoverPendingProfileProjectMoves
} from './profile-project-move-intent'
import { readProfileStateWithRevision } from './profile-project-state-file'
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../telemetry/cohort-classifier', () => ({
getCohortAtEmit: () => ({ nth_repo_added: 2 })
}))
vi.mock('../ssh/ssh-config-parser', () => ({
loadUserSshConfig: () => ({ hosts: [] }),
sshConfigHostsToTargets: () => []
}))
async function fixture() {
const root = mkdtempSync(join(tmpdir(), 'orca-worker-profile-transfer-'))
writeFileSync(
join(root, 'orca-profile-index.json'),
JSON.stringify({
schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION,
activeProfileId: 'source',
profiles: ['source', 'target'].map((id) => ({
id,
name: id,
avatar: { kind: 'initials', initials: id[0], color: 'neutral' },
kind: 'local',
createdAt: 1,
updatedAt: 1,
lastOpenedAt: 1
}))
})
)
const current = await createWorkerMaintenanceFixture({
directory: join(root, 'profiles', 'source'),
profileId: 'source',
cleanupRoot: root
})
const target = new ProfileStateSqliteAuthority(
join(root, 'profiles', 'target', 'profile-state.db'),
'target'
)
try {
target.writeSerializedState(Buffer.from(JSON.stringify(getDefaultPersistedState(root))))
} finally {
target.close()
}
const args = {
sourceProfileId: 'source',
targetProfileId: 'target',
repoId: 'repo-remote',
mode: 'move'
} as const
const read = (id: string) => readProfileStateWithRevision(id, root)
return { ...current, root, args, read }
}
describe('active profile transfers with the live writer', () => {
it('resumes the exact source revision after a validation failure', async () => {
const { store, root, args, read } = await fixture()
const reopen = vi.fn(async () => {})
await expect(
transferActiveProfileProject({ ...args, repoId: 'missing' }, root, store, reopen)
).rejects.toThrow('unknown_source_repo')
expect(reopen).not.toHaveBeenCalled()
store.updateSettings({ theme: 'dark' })
await store.flushPendingOrThrowAsync()
expect(read('source').state.settings.theme).toBe('dark')
})
it('keeps the source frozen after moving a remote project and its persisted state', async () => {
const { store, root, args, read } = await fixture()
const result = await transferActiveProfileProject(args, root, store, async () => {})
expect(result.status).toBe('transferred')
expect(read('source').state.repos.some((repo) => repo.id === args.repoId)).toBe(false)
expect(read('target').state.repos.some((repo) => repo.id === args.repoId)).toBe(true)
const source = read('source')
store.updateSettings({ theme: 'dark' })
await expect(store.flushPendingOrThrowAsync()).rejects.toThrow('finalized')
expect(read('source')).toEqual(source)
})
it('resumes when a copy makes a later move a duplicate', async () => {
const { store, root, args, read } = await fixture()
await transferActiveProfileProject({ ...args, mode: 'copy' }, root, store, async () => {})
const result = await transferActiveProfileProject(args, root, store, async () => {})
expect(result.status).toBe('duplicate-target')
store.updateSettings({ theme: 'dark' })
await store.flushPendingOrThrowAsync()
expect(read('source').state.settings.theme).toBe('dark')
expect(read('source').state.repos.some((repo) => repo.id === args.repoId)).toBe(true)
})
it('leaves an interrupted move frozen until journal recovery runs with the writer closed', async () => {
const { store, root, args, read } = await fixture()
const original = domainState.writeProfileProjectDomainChanges
const fault = vi
.spyOn(domainState, 'writeProfileProjectDomainChanges')
.mockImplementation((id, ...rest) => {
if (id === args.sourceProfileId) {
throw new Error('source commit interrupted')
}
return original(id, ...rest)
})
const reopen = vi.fn(async () => {})
await expect(transferActiveProfileProject(args, root, store, reopen)).rejects.toThrow(
'source commit interrupted'
)
expect(reopen).toHaveBeenCalledOnce()
expect(profileHasPendingProjectMove('source', root)).toBe(true)
await expect(store.flushPendingOrThrowAsync()).rejects.toThrow('finalized')
fault.mockRestore()
expect(recoverPendingProfileProjectMoves(root)).toBe(1)
expect(read('source').state.repos.some((repo) => repo.id === args.repoId)).toBe(false)
expect(read('target').state.repos.some((repo) => repo.id === args.repoId)).toBe(true)
})
})
@@ -0,0 +1,232 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getDefaultPersistedState } from '../../shared/constants'
import { ORCA_PROFILE_INDEX_SCHEMA_VERSION } from '../../shared/orca-profiles'
import { openProfileStateDatabase } from '../persistence/profile-state/profile-state-database'
import { importProfileStateJson } from '../persistence/profile-state/profile-state-documents'
import { ProfileStateSqliteAuthority } from '../persistence/profile-state/profile-state-sqlite-authority'
import * as stateFiles from './profile-project-state-file'
import * as domainState from './profile-project-domain-state'
import * as moveIntents from './profile-project-move-intent'
import { transferActiveProfileProject } from './profile-active-transfer'
import { transferOrcaProfileProject } from './profile-project-transfer'
vi.mock('electron', () => ({
app: {
getPath: () => tmpdir(),
getName: () => 'orca-test',
getVersion: () => '0.0.0-test',
isPackaged: false,
on: () => {},
whenReady: () => Promise.resolve()
},
safeStorage: { isEncryptionAvailable: () => false },
ipcMain: { on: () => {}, handle: () => {} },
BrowserWindow: { getAllWindows: () => [] }
}))
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../telemetry/cohort-classifier', () => ({
getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 }))
}))
vi.mock('../ssh/ssh-config-parser', () => ({
loadUserSshConfig: vi.fn(() => ({ hosts: [] })),
sshConfigHostsToTargets: vi.fn(() => [])
}))
const { Store } = await import('../persistence/loading-store/store')
const stores: InstanceType<typeof Store>[] = []
let directory: string
const args = {
sourceProfileId: 'source',
targetProfileId: 'target',
repoId: 'repo-1',
mode: 'move'
} as const
function snapshot(profileId: string) {
return stateFiles.readProfileStateWithRevision(profileId, directory)
}
function openStore() {
const profileDirectory = join(directory, 'profiles', 'source')
const store = new Store({
dataFile: join(profileDirectory, 'orca-data.json'),
profileStateAuthority: new ProfileStateSqliteAuthority(
join(profileDirectory, 'profile-state.db'),
'source'
)
})
stores.push(store)
store.flushOrThrow()
return store
}
function interruptSourceCommit() {
const originalWrite = domainState.writeProfileProjectDomainChanges
return vi
.spyOn(domainState, 'writeProfileProjectDomainChanges')
.mockImplementation((profileId, ...rest) => {
if (profileId === 'source') {
throw new Error('source commit interrupted')
}
return originalWrite(profileId, ...rest)
})
}
beforeEach(() => {
directory = mkdtempSync(join(tmpdir(), 'orca-active-profile-transfer-'))
vi.spyOn(ProfileStateSqliteAuthority.prototype, 'scheduleBackup').mockImplementation(() => {})
writeFileSync(
join(directory, 'orca-profile-index.json'),
JSON.stringify({
schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION,
activeProfileId: 'source',
profiles: ['source', 'target'].map((id) => ({
id,
name: id,
avatar: { kind: 'initials', initials: id[0], color: 'neutral' },
kind: 'local',
createdAt: 1,
updatedAt: 1,
lastOpenedAt: 1
}))
})
)
for (const profileId of ['source', 'target']) {
const profileDirectory = join(directory, 'profiles', profileId)
mkdirSync(profileDirectory, { recursive: true })
const db = openProfileStateDatabase(join(profileDirectory, 'profile-state.db'), profileId).db
try {
importProfileStateJson(
db,
JSON.stringify({
...getDefaultPersistedState('/home/test'),
repos:
profileId === 'source'
? [{ id: 'repo-1', path: '/projects/folder', kind: 'folder', addedAt: 1 }]
: []
})
)
} finally {
db.close()
}
}
})
afterEach(() => {
for (const store of stores.splice(0)) {
store.freezeWrites()
}
vi.restoreAllMocks()
rmSync(directory, { recursive: true, force: true })
})
describe('active profile transfer recovery', () => {
it.each(['source commit', 'intent cleanup'] as const)(
'fences an existing SQLite Store after interrupted %s until recovery and reopen',
async (failure) => {
const store = openStore()
const before = snapshot('source')
const interrupted =
failure === 'source commit'
? interruptSourceCommit()
: vi.spyOn(moveIntents, 'removeProfileProjectMoveIntent').mockImplementation(() => {
throw new Error('intent cleanup interrupted')
})
const reopen = vi.fn(async () => {
const retained = snapshot('source')
store.updateSettings({ theme: 'light' })
expect(() => store.flushOrThrow()).toThrow('final persistence')
expect(snapshot('source')).toEqual(retained)
})
await expect(transferActiveProfileProject(args, directory, store, reopen)).rejects.toThrow(
`${failure} interrupted`
)
expect(reopen).toHaveBeenCalledOnce()
expect(snapshot('source').revision).toBe(
(before.revision ?? 0) + (failure === 'source commit' ? 0 : 1)
)
expect(snapshot('target').state.repos).toHaveLength(1)
interrupted.mockRestore()
expect(moveIntents.recoverPendingProfileProjectMoves(directory)).toBe(1)
expect(moveIntents.recoverPendingProfileProjectMoves(directory)).toBe(0)
expect(snapshot('source').state.repos).toHaveLength(0)
expect(snapshot('target').state.repos).toHaveLength(1)
const reloaded = openStore()
reloaded.updateSettings({ theme: 'light' })
reloaded.flushOrThrow()
expect(snapshot('source').state.settings.theme).toBe('light')
expect(moveIntents.recoverPendingProfileProjectMoves(directory)).toBe(0)
}
)
it('leaves an unchanged SQLite Store writable after validation fails', async () => {
const store = openStore()
const before = snapshot('source')
const reopen = vi.fn(async () => {})
await expect(
transferActiveProfileProject({ ...args, repoId: 'missing' }, directory, store, reopen)
).rejects.toThrow('unknown_source_repo')
expect(reopen).not.toHaveBeenCalled()
store.updateSettings({ theme: 'light' })
store.flushOrThrow()
expect(snapshot('source').revision).toBe((before.revision ?? 0) + 1)
expect(snapshot('source').state.settings.theme).toBe('light')
})
it('keeps writes fenced when reopening after a partial transfer fails', async () => {
const store = openStore()
const before = snapshot('source')
const interrupted = interruptSourceCommit()
await expect(
transferActiveProfileProject(args, directory, store, async () => {
throw new Error('reopen failed')
})
).rejects.toThrow('reopen failed')
store.updateSettings({ theme: 'light' })
expect(() => store.flushOrThrow()).toThrow('final persistence')
expect(snapshot('source')).toEqual(before)
interrupted.mockRestore()
expect(moveIntents.recoverPendingProfileProjectMoves(directory)).toBe(1)
})
it('reopens before an outstanding move can change the active Store behind its revision', async () => {
const store = openStore()
const interrupted = interruptSourceCommit()
expect(() => transferOrcaProfileProject(args, directory)).toThrow('source commit interrupted')
interrupted.mockRestore()
const reopen = vi.fn(async () => {
expect(moveIntents.recoverPendingProfileProjectMoves(directory)).toBe(1)
})
await expect(transferActiveProfileProject(args, directory, store, reopen)).rejects.toThrow(
'active_source_orca_profile_move_requires_recovery'
)
const recovered = snapshot('source')
store.updateSettings({ theme: 'light' })
expect(() => store.flushOrThrow()).toThrow('final persistence')
expect(snapshot('source')).toEqual(recovered)
expect(reopen).toHaveBeenCalledOnce()
})
it('keeps the Store frozen when an unreadable intent cannot identify its participants', async () => {
const store = openStore()
const before = snapshot('source')
const intentDirectory = join(directory, 'profile-move-intents')
mkdirSync(intentDirectory)
writeFileSync(join(intentDirectory, '11111111-1111-4111-8111-111111111111.json'), '{')
const reopen = vi.fn(async () => {
moveIntents.recoverPendingProfileProjectMoves(directory)
})
await expect(transferActiveProfileProject(args, directory, store, reopen)).rejects.toThrow(
'Profile move intent is unreadable'
)
store.updateSettings({ theme: 'light' })
expect(() => store.flushOrThrow()).toThrow('final persistence')
expect(snapshot('source')).toEqual(before)
expect(reopen).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,39 @@
import type {
TransferOrcaProfileProjectArgs,
TransferOrcaProfileProjectResult
} from '../../shared/orca-profiles'
import type { Store } from '../persistence/loading-store/store'
import { transferOrcaProfileProject } from './profile-project-transfer'
import { hasOrcaProfileStateDatabase } from './profile-storage-paths'
import { profileHasPendingProjectMove } from './profile-project-move-intent'
import { flushActiveProfileBeforeFileMutation } from './profile-persistence-deadline'
/** Keep the active Store stopped until file mutation either succeeds or proves unchanged. */
export async function transferActiveProfileProject(
args: TransferOrcaProfileProjectArgs,
userDataPath: string,
store: Pick<Store, 'beginProfileMaintenance'>,
reopenSource: () => Promise<void>
): Promise<TransferOrcaProfileProjectResult> {
const hadDatabase = hasOrcaProfileStateDatabase(args.sourceProfileId, userDataPath)
const pendingMove = profileHasPendingProjectMove(args.sourceProfileId, userDataPath)
const maintenance = await flushActiveProfileBeforeFileMutation(store, { flush: !pendingMove })
let result: TransferOrcaProfileProjectResult
try {
if (profileHasPendingProjectMove(args.sourceProfileId, userDataPath)) {
throw new Error('active_source_orca_profile_move_requires_recovery')
}
result = transferOrcaProfileProject(args, userDataPath)
} catch (error) {
const needsRecovery =
(!hadDatabase && hasOrcaProfileStateDatabase(args.sourceProfileId, userDataPath)) ||
profileHasPendingProjectMove(args.sourceProfileId, userDataPath)
// Further writes would invalidate a retained move's recovery revision.
await (needsRecovery ? reopenSource() : maintenance.resume())
throw error
}
if (result.status !== 'transferred' || args.mode !== 'move') {
await maintenance.resume()
}
return result
}
@@ -38,6 +38,7 @@ function activeProfile(linked: boolean): ActiveOrcaProfileState {
profile,
index: { schemaVersion: 1, activeProfileId: profile.id, profiles: [profile] },
dataFile: '',
stateDatabaseFile: '',
profileDirectory: ''
}
}
@@ -4,6 +4,7 @@ import { existsSync, mkdtempSync, readFileSync, writeFileSync, mkdirSync } from
import { removeTreeSync } from '../../shared/windows-transient-lock-removal'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { openProfileStateDatabase } from '../persistence/profile-state/profile-state-database'
import {
createDefaultLocalOrcaProfile,
DEFAULT_LOCAL_ORCA_PROFILE_ID,
@@ -66,6 +67,9 @@ describe('profile index store', () => {
expect(activeProfile.dataFile).toBe(
join(testState.dir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID, 'orca-data.json')
)
expect(activeProfile.stateDatabaseFile).toBe(
join(testState.dir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID, 'profile-state.db')
)
expect(readJson(activeProfile.dataFile)).toEqual(legacyState)
expect(readJson(`${activeProfile.dataFile}.bak.0`)).toEqual(legacyBackup)
expect(
@@ -115,9 +119,59 @@ describe('profile index store', () => {
expect(activeProfile.profile.id).toBe(profileId)
expect(activeProfile.dataFile).toBe(join(profileDirectory, 'orca-data.json'))
expect(activeProfile.stateDatabaseFile).toBe(join(profileDirectory, 'profile-state.db'))
expect(readJson(activeProfile.dataFile)).toEqual(profileData)
})
it('does not copy legacy JSON into a database-only default profile', async () => {
writeFileSync(
join(testState.dir, 'orca-data.json'),
JSON.stringify({ settings: { theme: 'legacy' } }),
'utf-8'
)
const profileDirectory = join(testState.dir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID)
mkdirSync(profileDirectory, { recursive: true })
const database = openProfileStateDatabase(
join(profileDirectory, 'profile-state.db'),
DEFAULT_LOCAL_ORCA_PROFILE_ID
)
database.db.close()
const { ensureActiveOrcaProfile } = await loadProfileIndexStore()
const activeProfile = ensureActiveOrcaProfile()
expect(activeProfile.stateDatabaseFile).toBe(join(profileDirectory, 'profile-state.db'))
expect(existsSync(activeProfile.dataFile)).toBe(false)
expect(readFileSync(join(testState.dir, 'orca-data.json'), 'utf-8')).toContain('legacy')
})
it.each([
'orca-data.json.sqlite-export.1.json',
'profile-state.db.backup.1789999999999-00000000-0000-4000-8000-000000000000.db',
'profile-state.db-wal',
'profile-state.db-shm',
'profile-state.db-journal'
])('does not seed a stale mirror when %s exists without the database', async (artifact) => {
writeFileSync(
join(testState.dir, 'orca-data.json'),
JSON.stringify({ settings: { theme: 'legacy' } }),
'utf-8'
)
const profileDirectory = join(testState.dir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID)
mkdirSync(profileDirectory, { recursive: true })
writeFileSync(
join(profileDirectory, artifact),
JSON.stringify({ settings: { theme: 'migrated' } }),
'utf-8'
)
const { ensureActiveOrcaProfile } = await loadProfileIndexStore()
const activeProfile = ensureActiveOrcaProfile()
expect(existsSync(activeProfile.dataFile)).toBe(false)
expect(readFileSync(join(testState.dir, 'orca-data.json'), 'utf-8')).toContain('legacy')
})
it('creates an empty local profile without copying legacy state into it', async () => {
writeFileSync(
join(testState.dir, 'orca-data.json'),
+27 -33
View File
@@ -22,23 +22,24 @@ import {
type OrcaProfileSummary
} from '../../shared/orca-profiles'
import {
getOrcaProfileBrowserSessionMetaFile,
getOrcaProfileDataFile,
getOrcaProfileDirectory,
getOrcaProfileIndexPath,
getProfileUserDataPath,
LEGACY_BACKUP_COUNT,
legacyBackupPath,
legacyBrowserSessionMetaPath,
legacyDataFilePath,
profileBackupPath
getOrcaProfileStateDatabaseFile,
hasOrcaProfileStateDatabase,
getProfileUserDataPath
} from './profile-storage-paths'
import { copyLegacyStateToProfile } from './profile-legacy-state-import'
import { profileStateJsonExportPaths } from '../persistence/profile-state/profile-state-export-path'
import { profileStateDatabaseBackups } from '../persistence/profile-state/profile-state-backup-path'
export {
getOrcaProfileBrowserSessionMetaFile,
getOrcaProfileDataFile,
getOrcaProfileDirectory,
getOrcaProfileIndexPath,
getOrcaProfileStateDatabaseFile,
hasOrcaProfileStateDatabase,
getOrcaProfilesDirectory,
initOrcaProfilePaths
} from './profile-storage-paths'
@@ -47,6 +48,7 @@ export type ActiveOrcaProfileState = {
index: OrcaProfileIndex
profile: OrcaProfileSummary
dataFile: string
stateDatabaseFile: string
profileDirectory: string
}
@@ -136,30 +138,6 @@ export function writeProfileIndex(indexPath: string, index: OrcaProfileIndex): v
bestEffortFsyncDirectorySync(dirname(indexPath))
}
function copyIfPresent(source: string, target: string): void {
if (!existsSync(source) || existsSync(target)) {
return
}
mkdirSync(dirname(target), { recursive: true })
// Why: tmp+rename so a crash mid-copy cannot leave a truncated target that
// the exists() guard above would then treat as a completed migration.
const tmpTarget = `${target}.tmp`
copyFileSync(source, tmpTarget)
renameSync(tmpTarget, target)
}
function copyLegacyStateToProfile(userDataPath: string, profileId: string): void {
const profileDataFile = getOrcaProfileDataFile(profileId, userDataPath)
copyIfPresent(legacyDataFilePath(userDataPath), profileDataFile)
copyIfPresent(
legacyBrowserSessionMetaPath(userDataPath),
getOrcaProfileBrowserSessionMetaFile(profileId, userDataPath)
)
for (let i = 0; i < LEGACY_BACKUP_COUNT; i++) {
copyIfPresent(legacyBackupPath(userDataPath, i), profileBackupPath(profileDataFile, i))
}
}
// Why: a brand-new profile has no data file, which the telemetry cohort
// migration reads as a fresh install and defaults to opted-in. Copying the
// active profile's consent block keeps an opted-out user opted out (and keeps
@@ -230,7 +208,22 @@ export function ensureActiveOrcaProfile(
const profileDirectory = getOrcaProfileDirectory(activeProfile.id, userDataPath)
mkdirSync(profileDirectory, { recursive: true })
if (activeProfile.id === DEFAULT_LOCAL_ORCA_PROFILE_ID) {
const profileDatabaseFile = getOrcaProfileStateDatabaseFile(activeProfile.id, userDataPath)
const profileDataFile = getOrcaProfileDataFile(activeProfile.id, userDataPath)
let hasRetainedProfileStateExport = false
try {
hasRetainedProfileStateExport =
profileStateJsonExportPaths(profileDataFile).length > 0 ||
profileStateDatabaseBackups(profileDatabaseFile).length > 0
} catch {
// An unreadable profile directory must never trigger a fallback copy of legacy state.
hasRetainedProfileStateExport = true
}
if (
activeProfile.id === DEFAULT_LOCAL_ORCA_PROFILE_ID &&
!hasOrcaProfileStateDatabase(activeProfile.id, userDataPath) &&
!hasRetainedProfileStateExport
) {
copyLegacyStateToProfile(userDataPath, activeProfile.id)
}
@@ -241,7 +234,8 @@ export function ensureActiveOrcaProfile(
return {
index,
profile: activeProfile,
dataFile: getOrcaProfileDataFile(activeProfile.id, userDataPath),
dataFile: profileDataFile,
stateDatabaseFile: profileDatabaseFile,
profileDirectory
}
}
@@ -0,0 +1,35 @@
import { copyFileSync, existsSync, mkdirSync, renameSync } from 'node:fs'
import { dirname } from 'node:path'
import {
getOrcaProfileBrowserSessionMetaFile,
getOrcaProfileDataFile,
LEGACY_BACKUP_COUNT,
legacyBackupPath,
legacyBrowserSessionMetaPath,
legacyDataFilePath,
profileBackupPath
} from './profile-storage-paths'
function copyIfPresent(source: string, target: string): void {
if (!existsSync(source) || existsSync(target)) {
return
}
mkdirSync(dirname(target), { recursive: true })
// Why: tmp+rename so a crash mid-copy cannot leave a truncated target that
// the exists() guard above would then treat as a completed migration.
const tmpTarget = `${target}.tmp`
copyFileSync(source, tmpTarget)
renameSync(tmpTarget, target)
}
export function copyLegacyStateToProfile(userDataPath: string, profileId: string): void {
const profileDataFile = getOrcaProfileDataFile(profileId, userDataPath)
copyIfPresent(legacyDataFilePath(userDataPath), profileDataFile)
copyIfPresent(
legacyBrowserSessionMetaPath(userDataPath),
getOrcaProfileBrowserSessionMetaFile(profileId, userDataPath)
)
for (let i = 0; i < LEGACY_BACKUP_COUNT; i++) {
copyIfPresent(legacyBackupPath(userDataPath, i), profileBackupPath(profileDataFile, i))
}
}
@@ -0,0 +1,37 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ProfileStateMaintenance } from '../persistence/loading-store/profile-state-authority'
import type { ProfileStateMaintenanceOptions } from '../persistence/loading-store/profile-state-maintenance'
import { flushActiveProfileBeforeFileMutation } from './profile-persistence-deadline'
afterEach(() => vi.useRealTimers())
describe('profile persistence deadline', () => {
it('allows the writer request deadline to finish before imposing maintenance cancellation', async () => {
vi.useFakeTimers()
const result = Promise.withResolvers<ProfileStateMaintenance>()
const beginProfileMaintenance = vi.fn(
(_options?: ProfileStateMaintenanceOptions) => result.promise
)
const pending = flushActiveProfileBeforeFileMutation({ beginProfileMaintenance })
await vi.advanceTimersByTimeAsync(30_000)
expect(beginProfileMaintenance.mock.calls[0]?.[0]?.signal?.aborted).not.toBe(true)
const handle = { resume: vi.fn(async () => {}) }
result.resolve(handle)
await expect(pending).resolves.toBe(handle)
expect(handle.resume).not.toHaveBeenCalled()
})
it('resumes a clean pause that finishes after the caller times out', async () => {
vi.useFakeTimers()
const result = Promise.withResolvers<ProfileStateMaintenance>()
const beginProfileMaintenance = vi.fn(() => result.promise)
const pending = flushActiveProfileBeforeFileMutation({ beginProfileMaintenance })
const rejected = expect(pending).rejects.toThrow('orca_profile_persistence_timeout')
await vi.advanceTimersByTimeAsync(60_000)
await rejected
const handle = { resume: vi.fn(async () => {}) }
result.resolve(handle)
await vi.advanceTimersByTimeAsync(0)
expect(handle.resume).toHaveBeenCalledOnce()
})
})
@@ -1,8 +1,33 @@
import type { Store } from '../persistence'
import type { ProfileStateMaintenance } from '../persistence/loading-store/profile-state-authority'
import type { ProfileStateMaintenanceOptions } from '../persistence/loading-store/profile-state-maintenance'
const PROFILE_PERSISTENCE_TIMEOUT_MS = 20_000
const PROFILE_PERSISTENCE_TIMEOUT_MS = 60_000
export async function flushActiveProfileBeforeFileMutation(store: Store): Promise<void> {
export async function flushActiveProfileBeforeFileMutation(
store: Pick<Store, 'beginProfileMaintenance'>,
options: Pick<ProfileStateMaintenanceOptions, 'flush'> = {}
): Promise<ProfileStateMaintenance> {
return withinProfilePersistenceDeadline((signal) =>
store.beginProfileMaintenance({ ...options, signal }).then(async (handle) => {
if (signal.aborted && options.flush !== false) {
await handle.resume()
throw new Error('orca_profile_persistence_timeout')
}
return handle
})
)
}
export function flushActiveProfileBeforeRelaunch(
store: Pick<Store, 'flushPendingOrThrowAsync'>
): Promise<void> {
return withinProfilePersistenceDeadline((signal) => store.flushPendingOrThrowAsync({ signal }))
}
async function withinProfilePersistenceDeadline<T>(
operation: (signal: AbortSignal) => Promise<T>
): Promise<T> {
const controller = new AbortController()
let timeout: ReturnType<typeof setTimeout> | null = null
const deadline = new Promise<never>((_resolve, reject) => {
@@ -12,7 +37,7 @@ export async function flushActiveProfileBeforeFileMutation(store: Store): Promis
}, PROFILE_PERSISTENCE_TIMEOUT_MS)
})
try {
await Promise.race([store.flushPendingOrThrowAsync({ signal: controller.signal }), deadline])
return await Promise.race([operation(controller.signal), deadline])
} finally {
if (timeout) {
clearTimeout(timeout)
@@ -0,0 +1,144 @@
import { hashProfileStateJson } from '../persistence/profile-state/profile-state-documents'
import {
isRecord,
type ProfileStateParsedDocument
} from '../persistence/profile-state/profile-state-document-validation'
import { prepareProfileStateDomainMutation } from '../persistence/profile-state/profile-state-domain-write-validation'
import type { ProfileStateDomainMutation } from '../persistence/profile-state/profile-state-domain-writes'
import type { PersistedState } from '../../shared/persisted-state-types'
export type ProfileProjectDomainDigest = { domain: string; hash: string }
export type ProfileProjectDomainChanges = {
expectedRevision: number
before: ProfileProjectDomainDigest[]
afterHash: string
replacements: { domain: string; payload: string | null }[]
}
export function profileProjectDomainFingerprint(
domains: readonly ProfileProjectDomainDigest[]
): string {
const pairs = domains.map(({ domain, hash }) => [domain, hash])
pairs.sort(([left = ''], [right = '']) => (left < right ? -1 : left > right ? 1 : 0))
return hashProfileStateJson(`orca-profile-move-domains-v2:${JSON.stringify(pairs)}`)
}
export function profileProjectDomainDigests(
documents: readonly ProfileStateParsedDocument[]
): ProfileProjectDomainDigest[] {
return documents.map(({ domain, contentHash }) => ({ domain, hash: contentHash }))
}
export function prepareProfileProjectDomainChanges(
revision: number,
documents: readonly ProfileStateParsedDocument[],
state: PersistedState
): ProfileProjectDomainChanges {
const originals = new Map(documents.map((document) => [document.domain, document]))
const replacements: ProfileProjectDomainChanges['replacements'] = []
for (const [domain, value] of Object.entries(state)) {
const original = originals.get(domain)
// Transfer projections retain unchanged values; do not serialize unrelated history/output.
if (original && Object.is(original.value, value)) {
continue
}
const payload = JSON.stringify(value) ?? null
if (
payload === null
? original !== undefined
: hashProfileStateJson(payload) !== original?.contentHash
) {
replacements.push({ domain, payload })
}
}
for (const domain of originals.keys()) {
if (!Object.hasOwn(state, domain)) {
replacements.push({ domain, payload: null })
}
}
const before = profileProjectDomainDigests(documents)
return {
expectedRevision: revision,
before,
afterHash: profileProjectDomainFingerprint(applyDomainDigests(before, replacements)),
replacements
}
}
function applyDomainDigests(
before: readonly ProfileProjectDomainDigest[],
replacements: readonly ProfileStateDomainMutation[]
): ProfileProjectDomainDigest[] {
const digests = new Map(before.map(({ domain, hash }) => [domain, hash]))
for (const { domain, payload } of replacements) {
if (payload === null) {
digests.delete(domain)
} else {
digests.set(domain, hashProfileStateJson(payload))
}
}
return [...digests].map(([domain, hash]) => ({ domain, hash }))
}
export function validateProfileProjectDomainChanges(
value: unknown
): asserts value is ProfileProjectDomainChanges {
if (
!isRecord(value) ||
typeof value.expectedRevision !== 'number' ||
!Number.isSafeInteger(value.expectedRevision) ||
value.expectedRevision < 0 ||
!Number.isSafeInteger(value.expectedRevision + 1) ||
!Array.isArray(value.before) ||
!Array.isArray(value.replacements) ||
value.replacements.length === 0 ||
!isHash(value.afterHash)
) {
throw new Error('Profile move domain changes are malformed')
}
const before: ProfileProjectDomainDigest[] = []
const domains = new Set<string>()
for (const digest of value.before) {
if (
!isRecord(digest) ||
typeof digest.domain !== 'string' ||
!isHash(digest.hash) ||
domains.has(digest.domain)
) {
throw new Error('Profile move domain manifest is malformed')
}
domains.add(digest.domain)
before.push({ domain: digest.domain, hash: digest.hash })
}
const replacements: ProfileStateDomainMutation[] = []
domains.clear()
for (const replacement of value.replacements) {
if (
!isRecord(replacement) ||
!isDomain(replacement.domain) ||
(replacement.payload !== null && typeof replacement.payload !== 'string') ||
domains.has(replacement.domain)
) {
throw new Error('Profile move domain replacement is malformed')
}
domains.add(replacement.domain)
const mutation = { domain: replacement.domain, payload: replacement.payload }
prepareProfileStateDomainMutation(mutation)
replacements.push(mutation)
}
if (
profileProjectDomainFingerprint(applyDomainDigests(before, replacements)) !== value.afterHash ||
profileProjectDomainFingerprint(before) === value.afterHash
) {
throw new Error('Profile move domain changes do not match their fingerprint')
}
}
function isDomain(value: unknown): value is string {
return typeof value === 'string' && value.length > 0
}
function isHash(value: unknown): value is string {
return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value)
}
@@ -0,0 +1,58 @@
import { randomUUID } from 'node:crypto'
import type { ProfileProjectDomainMoveIntent } from './profile-project-move-record'
export type { ProfileProjectDomainMoveIntent } from './profile-project-move-record'
import {
profileProjectDomainDigests,
profileProjectDomainFingerprint,
type ProfileProjectDomainChanges
} from './profile-project-domain-changes'
import {
readProfileProjectTransferState,
type ReadProfileProjectTransferResult
} from './profile-project-domain-state'
export function createProfileProjectDomainMoveIntent(args: {
sourceProfileId: string
targetProfileId: string
source: ProfileProjectDomainChanges
target: ProfileProjectDomainChanges
}): ProfileProjectDomainMoveIntent {
return { version: 2, id: randomUUID(), ...args }
}
export function readProfileProjectDomainMoveState(
userDataPath: string,
intent: ProfileProjectDomainMoveIntent
): { sourceBefore: boolean; sourceAfter: boolean; targetBefore: boolean; targetAfter: boolean } {
const source = readProfileProjectTransferState(intent.sourceProfileId, userDataPath)
const target = readProfileProjectTransferState(intent.targetProfileId, userDataPath)
if (source.documents === undefined || target.documents === undefined) {
throw new Error(`Profile move ${intent.id} no longer has two SQLite participants`)
}
return {
sourceBefore: matches(
source,
intent.source.expectedRevision,
profileProjectDomainFingerprint(intent.source.before)
),
targetBefore: matches(
target,
intent.target.expectedRevision,
profileProjectDomainFingerprint(intent.target.before)
),
sourceAfter: matches(source, intent.source.expectedRevision + 1, intent.source.afterHash),
targetAfter: matches(target, intent.target.expectedRevision + 1, intent.target.afterHash)
}
}
function matches(
snapshot: ReadProfileProjectTransferResult,
revision: number,
hash: string
): boolean {
return (
snapshot.revision === revision &&
snapshot.documents !== undefined &&
profileProjectDomainFingerprint(profileProjectDomainDigests(snapshot.documents)) === hash
)
}
@@ -0,0 +1,84 @@
import {
openProfileStateDatabase,
openProfileStateDatabaseReadOnly
} from '../persistence/profile-state/profile-state-database'
import {
readProfileStateDocuments,
readProfileStateRevision
} from '../persistence/profile-state/profile-state-documents'
import type { ProfileStateParsedDocument } from '../persistence/profile-state/profile-state-document-validation'
import { writeProfileStateDomains } from '../persistence/profile-state/profile-state-domain-writes'
import { withProfileStateReadSnapshot } from '../persistence/profile-state/profile-state-read-snapshot'
import { getOrcaProfileStateDatabaseFile } from './profile-storage-paths'
import {
normalizeProfileProjectState,
profileStateStorage,
readProfileStateWithRevision,
type ReadProfileStateResult
} from './profile-project-state-file'
import {
validateProfileProjectDomainChanges,
type ProfileProjectDomainChanges
} from './profile-project-domain-changes'
export type ReadProfileProjectTransferResult = ReadProfileStateResult & {
documents?: readonly ProfileStateParsedDocument[]
}
/** Keep checked domain values for transfer without joining and reparsing the complete profile. */
export function readProfileProjectTransferState(
profileId: string,
userDataPath: string
): ReadProfileProjectTransferResult {
if (profileStateStorage(profileId, userDataPath) === 'json') {
return readProfileStateWithRevision(profileId, userDataPath)
}
const opened = openProfileStateDatabaseReadOnly(
getOrcaProfileStateDatabaseFile(profileId, userDataPath),
profileId
)
try {
return withProfileStateReadSnapshot(opened.db, () => {
const revision = readProfileStateRevision(opened.db)
const documents = readProfileStateDocuments(opened.db, {
profileRevision: revision,
representation: 'parsed'
})
return {
revision,
documents,
state: normalizeProfileProjectState(
Object.fromEntries(documents.map(({ domain, value }) => [domain, value]))
)
}
})
} finally {
opened.db.close()
}
}
export function writeProfileProjectDomainChanges(
profileId: string,
userDataPath: string,
changes: ProfileProjectDomainChanges
): void {
validateProfileProjectDomainChanges(changes)
if (profileStateStorage(profileId, userDataPath) !== 'sqlite') {
throw new Error('Profile domain transfer requires an established SQLite participant')
}
const opened = openProfileStateDatabase(
getOrcaProfileStateDatabaseFile(profileId, userDataPath),
profileId
)
try {
const result = writeProfileStateDomains(opened.db, {
expectedRevision: changes.expectedRevision,
replacements: changes.replacements.map(({ domain, payload }) => ({ domain, payload }))
})
if (!result.changed || result.revision !== changes.expectedRevision + 1) {
throw new Error('Profile domain transfer did not commit its expected revision')
}
} finally {
opened.db.close()
}
}
@@ -0,0 +1,546 @@
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { buildSync } from 'esbuild'
import { runProcess } from '../../shared/child-process/run-process'
import { getDefaultPersistedState } from '../../shared/constants'
import { ORCA_PROFILE_INDEX_SCHEMA_VERSION } from '../../shared/orca-profiles'
import type { Repo } from '../../shared/repo-types'
import { openProfileStateDatabase } from '../persistence/profile-state/profile-state-database'
import {
hashProfileStateJson,
importProfileStateJson,
readProfileStateSnapshot
} from '../persistence/profile-state/profile-state-documents'
import { writeProfileStateDomain } from '../persistence/profile-state/profile-state-domain-writes'
import {
prepareProfileProjectDomainChanges,
profileProjectDomainFingerprint,
validateProfileProjectDomainChanges
} from './profile-project-domain-changes'
import * as domainState from './profile-project-domain-state'
import { createProfileProjectDomainMoveIntent } from './profile-project-domain-move-intent'
import {
persistProfileProjectMoveIntent,
recoverPendingProfileProjectMoves
} from './profile-project-move-intent'
import { normalizeProfileProjectState } from './profile-project-state-file'
import { removeSourceRepo } from './profile-project-source-removal'
import {
applyPayloadToTarget,
createTargetRepo,
createTransferPayload
} from './profile-project-transfer-payload'
import { transferOrcaProfileProject } from './profile-project-transfer'
let root: string
let crashRoot: string
let crashBundle: string
let crashScript: string
const repo: Repo = {
id: 'repo-1',
path: '/project',
displayName: 'Project',
badgeColor: 'neutral',
addedAt: 1,
kind: 'git',
connectionId: null
}
function dbPath(id: string): string {
return join(root, 'profiles', id, 'profile-state.db')
}
function withDatabase<T>(
id: string,
action: (db: ReturnType<typeof openProfileStateDatabase>['db']) => T
): T {
const opened = openProfileStateDatabase(dbPath(id), id)
try {
return action(opened.db)
} finally {
opened.db.close()
}
}
function seed(id: string, repos: Repo[] = []): void {
mkdirSync(join(root, 'profiles', id), { recursive: true })
withDatabase(id, (db) =>
importProfileStateJson(
db,
JSON.stringify({
futureOpaque: { z: ['\ud800', null, id], a: 'x'.repeat(100_000) },
['']: { keep: true },
['__proto__']: { inert: true },
settings: { opencodeSessionCookie: 'enc:v1:sealed-inactive', unknownSetting: [2, 1] },
repos,
projects: null,
projectHostSetups: null,
workspaceSessionsByHostId: null,
automationRuns: [],
futureNull: null,
futureDelete: { remove: true }
})
)
)
}
function raw(id: string) {
return withDatabase(id, (db) => readProfileStateSnapshot(db))
}
function transfer(mode: 'copy' | 'move' = 'move') {
return transferOrcaProfileProject(
{ sourceProfileId: 'source', targetProfileId: 'target', repoId: repo.id, mode },
root
)
}
function frozen<T>(value: T): T {
if (value !== null && typeof value === 'object') {
for (const nested of Object.values(value)) {
frozen(nested)
}
Object.freeze(value)
}
return value
}
function preparedMove() {
const source = domainState.readProfileProjectTransferState('source', root)
const target = domainState.readProfileProjectTransferState('target', root)
if (
source.revision === undefined ||
target.revision === undefined ||
!source.documents ||
!target.documents
) {
throw new Error('Missing SQL fixture')
}
const sourceRepo = source.state.repos[0]
if (!sourceRepo) {
throw new Error('Missing source repo')
}
const targetRepo = createTargetRepo(sourceRepo, target.state, false)
const payload = createTransferPayload({
sourceState: source.state,
sourceRepo,
targetRepo,
includeSessions: true
})
const sourceAfter = removeSourceRepo(source.state, sourceRepo.id)
const targetAfter = applyPayloadToTarget(target.state, payload)
const intent = createProfileProjectDomainMoveIntent({
sourceProfileId: 'source',
targetProfileId: 'target',
source: prepareProfileProjectDomainChanges(source.revision, source.documents, sourceAfter),
target: prepareProfileProjectDomainChanges(target.revision, target.documents, targetAfter)
})
return { intent, sourceAfter, targetAfter }
}
beforeAll(() => {
crashRoot = mkdtempSync(join(tmpdir(), 'orca-domain-move-crash-api-'))
crashBundle = join(crashRoot, 'api.cjs')
crashScript = join(crashRoot, 'crash.cjs')
buildSync({
stdin: {
contents:
"export { transferOrcaProfileProject } from './src/main/orca-profiles/profile-project-transfer'",
loader: 'ts',
resolveDir: process.cwd()
},
outfile: crashBundle,
bundle: true,
platform: 'node',
format: 'cjs',
packages: 'external'
})
writeFileSync(
crashScript,
`
const fs = require('node:fs')
const path = require('node:path')
const [bundle, root, stage] = process.argv.slice(2)
const barrier = label => {
if (label !== stage) return
fs.writeSync(1, label + '\\n')
process.kill(process.pid, 'SIGKILL')
throw new Error('SIGKILL returned')
}
const sqlite = require('node:sqlite')
const exec = sqlite.DatabaseSync.prototype.exec
const writers = new WeakSet()
sqlite.DatabaseSync.prototype.exec = function(sql) {
const writing = writers.has(this)
const participant = writing ? this.prepare("SELECT value FROM profile_state_meta WHERE key = 'profile_id'").get().value : ''
if (writing && sql === 'COMMIT') barrier(participant + '-before-commit')
const result = exec.call(this, sql)
if (sql === 'BEGIN IMMEDIATE') writers.add(this)
if (sql === 'COMMIT' || sql === 'ROLLBACK') writers.delete(this)
if (writing && sql === 'COMMIT') barrier(participant + '-committed')
return result
}
let published = false
let removed = false
const rename = fs.renameSync
fs.renameSync = (from, to) => {
const intent = path.dirname(to) === path.join(root, 'profile-move-intents') && to.endsWith('.json')
if (intent) barrier('intent-before-publish')
rename(from, to)
if (intent) { published = true; barrier('intent-published') }
}
const rm = fs.rmSync
fs.rmSync = (target, ...rest) => {
const intent = path.dirname(target) === path.join(root, 'profile-move-intents') && target.endsWith('.json')
if (intent) barrier('cleanup-before-remove')
rm(target, ...rest)
if (intent) { removed = true; barrier('cleanup-removed') }
}
const fsync = fs.fsyncSync
fs.fsyncSync = fd => {
fsync(fd)
if (fs.fstatSync(fd).isDirectory()) {
if (removed) barrier('cleanup-durable')
else if (published) barrier('intent-durable')
}
}
require(bundle).transferOrcaProfileProject({ sourceProfileId: 'source', targetProfileId: 'target', repoId: 'repo-1', mode: 'move' }, root)
throw new Error('Crash boundary not reached: ' + stage)
`
)
})
afterAll(() => rmSync(crashRoot, { recursive: true, force: true }))
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'orca-domain-transfer-'))
writeFileSync(
join(root, 'orca-profile-index.json'),
JSON.stringify({
schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION,
activeProfileId: 'source',
profiles: ['source', 'target'].map((id) => ({
id,
name: id,
avatar: { kind: 'initials', initials: id[0], color: 'neutral' },
kind: 'local',
createdAt: 1,
updatedAt: 1,
lastOpenedAt: 1
}))
})
)
seed('source', [repo])
seed('target')
})
afterEach(() => {
vi.restoreAllMocks()
rmSync(root, { recursive: true, force: true })
})
describe('profile domain transfers', () => {
it.each(['copy', 'move'] as const)(
'%s preserves the full normalized projection and unchanged physical rows',
(mode) => {
const beforeSource = domainState.readProfileProjectTransferState('source', root).state
const beforeTarget = domainState.readProfileProjectTransferState('target', root).state
const physicalBefore = withDatabase('target', (db) =>
db
.prepare(
"SELECT * FROM profile_state_documents WHERE domain IN ('futureOpaque', '', '__proto__', 'futureNull') ORDER BY domain"
)
.all()
)
const result = transfer(mode)
expect(result.status).toBe('transferred')
const afterTarget = JSON.parse(raw('target').json)
const targetRepo: Repo = afterTarget.repos[0]
const payload = createTransferPayload({
sourceState: beforeSource,
sourceRepo: repo,
targetRepo,
includeSessions: mode === 'move'
})
expect(afterTarget).toEqual(
JSON.parse(JSON.stringify(applyPayloadToTarget(beforeTarget, payload)))
)
expect(afterTarget.settings.opencodeSessionCookie).toBe('enc:v1:sealed-inactive')
expect(
withDatabase('target', (db) =>
db
.prepare(
"SELECT * FROM profile_state_documents WHERE domain IN ('futureOpaque', '', '__proto__', 'futureNull') ORDER BY domain"
)
.all()
)
).toEqual(physicalBefore)
expect(raw('target').revision).toBe(2)
expect(raw('source').revision).toBe(mode === 'move' ? 2 : 1)
if (mode === 'move') {
expect(JSON.parse(raw('source').json)).toEqual(
JSON.parse(JSON.stringify(removeSourceRepo(beforeSource, repo.id)))
)
}
}
)
it.each(['git', 'folder', 'ssh'] as const)(
'normalization and %s projections do not mutate raw nested values',
(kind) => {
const snapshot = domainState.readProfileProjectTransferState('source', root)
const input = Object.fromEntries(
(snapshot.documents ?? []).map(({ domain, value }) => [domain, value])
)
input.repos = [
{
...repo,
kind: kind === 'folder' ? 'folder' : 'git',
connectionId: kind === 'ssh' ? 'remote' : null
}
]
input.projects = [
{
id: 'old-project',
displayName: 'Previous',
badgeColor: 'neutral',
sourceRepoIds: ['repo-1'],
createdAt: 1,
updatedAt: 1,
localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }
}
]
input.workspaceSession = {
...getDefaultPersistedState('/test').workspaceSession,
tabsByWorktree: {
'repo-1::/project/branch': [
{
id: 'tab',
ptyId: 'pty',
worktreeId: 'repo-1::/project/branch',
title: 'Shell',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
}
}
if (kind === 'ssh') {
input.workspaceSessionsByHostId = { 'runtime:remote': input.workspaceSession }
}
const before = JSON.stringify(input)
frozen(input)
const source = frozen(normalizeProfileProjectState(input))
const target = frozen(normalizeProfileProjectState({ repos: [] }))
const sourceRepo = source.repos[0]
if (!sourceRepo) {
throw new Error('Missing source repo')
}
const payload = frozen(
createTransferPayload({
sourceState: source,
sourceRepo,
targetRepo: createTargetRepo(sourceRepo, target, false),
includeSessions: true
})
)
expect(() => applyPayloadToTarget(target, payload)).not.toThrow()
expect(() => removeSourceRepo(source, sourceRepo.id)).not.toThrow()
expect(JSON.stringify(input)).toBe(before)
expect(Object.entries(source).find(([domain]) => domain === 'futureOpaque')?.[1]).toBe(
input.futureOpaque
)
}
)
it('journals only changed domains and replays target-first with exact revisions', () => {
const { intent, sourceAfter, targetAfter } = preparedMove()
expect(intent.source.replacements.map(({ domain }) => domain)).not.toContain('futureOpaque')
expect(JSON.stringify(intent).length).toBeLessThan(40_000)
persistProfileProjectMoveIntent(root, intent)
domainState.writeProfileProjectDomainChanges('target', root, intent.target)
expect(recoverPendingProfileProjectMoves(root)).toBe(1)
expect(JSON.parse(raw('source').json)).toEqual(JSON.parse(JSON.stringify(sourceAfter)))
expect(JSON.parse(raw('target').json)).toEqual(JSON.parse(JSON.stringify(targetAfter)))
expect(raw('source').revision).toBe(2)
expect(raw('target').revision).toBe(2)
expect(recoverPendingProfileProjectMoves(root)).toBe(0)
})
it.each(['source', 'target'] as const)(
'refuses an unrelated %s write and retains the move intent',
(participant) => {
const { intent } = preparedMove()
persistProfileProjectMoveIntent(root, intent)
domainState.writeProfileProjectDomainChanges('target', root, intent.target)
withDatabase(participant, (db) =>
writeProfileStateDomain(db, {
expectedRevision: participant === 'source' ? 1 : 2,
domain: 'unrelated',
payload: 'true'
})
)
expect(() => recoverPendingProfileProjectMoves(root)).toThrow(/conflicts|unrecognized/)
expect(readdirSync(join(root, 'profile-move-intents'))).toContain(`${intent.id}.json`)
expect(JSON.parse(raw('source').json).repos).toHaveLength(1)
}
)
it('leaves conflicted moves between inactive profiles for those profiles to recover', () => {
const { intent } = preparedMove()
persistProfileProjectMoveIntent(root, intent)
domainState.writeProfileProjectDomainChanges('target', root, intent.target)
withDatabase('source', (db) =>
writeProfileStateDomain(db, {
expectedRevision: 1,
domain: 'unrelated',
payload: 'true'
})
)
expect(recoverPendingProfileProjectMoves(root, 'third-profile')).toBe(0)
expect(() => recoverPendingProfileProjectMoves(root, 'source')).toThrow(/conflicts/)
expect(readdirSync(join(root, 'profile-move-intents'))).toContain(`${intent.id}.json`)
})
it('refuses a malformed move record even when its header names inactive profiles', () => {
const { intent } = preparedMove()
persistProfileProjectMoveIntent(root, intent)
const path = join(root, 'profile-move-intents', `${intent.id}.json`)
writeFileSync(path, JSON.stringify({ ...intent, source: null }))
expect(() => recoverPendingProfileProjectMoves(root, 'third-profile')).toThrow('malformed')
expect(JSON.parse(readFileSync(path, 'utf8')).source).toBeNull()
})
it('refuses independently hashed malformed unrelated data before a copy writes anything', () => {
const before = raw('target').json
withDatabase('source', (db) =>
db
.prepare(
'UPDATE profile_state_documents SET payload = ?, content_hash = ? WHERE domain = ?'
)
.run('null,"extra":true', hashProfileStateJson('null,"extra":true'), 'futureNull')
)
expect(() => transfer('copy')).toThrow(/JSON/)
expect(raw('target').json).toBe(before)
})
it('distinguishes deletions and null while ignoring extra executable mutation options', () => {
const snapshot = domainState.readProfileProjectTransferState('target', root)
if (!snapshot.documents || snapshot.revision === undefined) {
throw new Error('Missing SQL fixture')
}
const after = { ...snapshot.state, futureDelete: undefined, futureNull: null, addedNull: null }
const changes = prepareProfileProjectDomainChanges(snapshot.revision, snapshot.documents, after)
const poisoned = {
...changes,
automationRunsAfter: [{}],
replacements: changes.replacements.map((replacement) => ({
...replacement,
domainVersion: -1
}))
}
domainState.writeProfileProjectDomainChanges('target', root, poisoned)
const saved = JSON.parse(raw('target').json)
expect(saved.futureNull).toBeNull()
expect(saved.addedNull).toBeNull()
expect(saved).not.toHaveProperty('futureDelete')
expect(saved.automationRuns).toEqual([])
})
it.each(['payload', 'afterHash', 'duplicate', 'revision', 'before'] as const)(
'rejects %s tampering before recovery modifies either participant',
(kind) => {
const { intent } = preparedMove()
persistProfileProjectMoveIntent(root, intent)
domainState.writeProfileProjectDomainChanges('target', root, intent.target)
const before = raw('source').json
if (kind === 'payload') {
intent.source.replacements[0]!.payload = 'null'
}
if (kind === 'afterHash') {
intent.source.afterHash = 'a'.repeat(64)
}
if (kind === 'duplicate') {
intent.source.replacements.push(intent.source.replacements[0]!)
}
if (kind === 'revision') {
intent.source.expectedRevision = Number.MAX_SAFE_INTEGER
}
if (kind === 'before') {
intent.source.before.push(intent.source.before[0]!)
}
writeFileSync(join(root, 'profile-move-intents', `${intent.id}.json`), JSON.stringify(intent))
expect(() => recoverPendingProfileProjectMoves(root)).toThrow()
expect(raw('source').json).toBe(before)
expect(readFileSync(join(root, 'profile-move-intents', `${intent.id}.json`), 'utf8')).toBe(
JSON.stringify(intent)
)
}
)
it('canonicalizes only domain ordering in fingerprints', () => {
const a = { domain: 'a', hash: hashProfileStateJson('{"x":1,"y":2}') }
const b = { domain: 'b', hash: hashProfileStateJson('[2,1]') }
expect(profileProjectDomainFingerprint([a, b])).toBe(profileProjectDomainFingerprint([b, a]))
expect(profileProjectDomainFingerprint([a, b])).not.toBe(
profileProjectDomainFingerprint([{ ...a, hash: hashProfileStateJson('{"y":2,"x":1}') }, b])
)
const { intent } = preparedMove()
expect(() => validateProfileProjectDomainChanges(intent.source)).not.toThrow()
})
const crashStages = [
'intent-before-publish',
'intent-published',
...(process.platform === 'win32' ? [] : ['intent-durable']),
'target-before-commit',
'target-committed',
'source-before-commit',
'source-committed',
'cleanup-before-remove',
'cleanup-removed',
...(process.platform === 'win32' ? [] : ['cleanup-durable'])
]
it.each(crashStages)('recovers exact state after actual SIGKILL at %s', async (stage) => {
const sourceBefore = raw('source').json
const targetBefore = raw('target').json
const { sourceAfter, targetAfter } = preparedMove()
const child = await runProcess({
program: process.execPath,
args: [crashScript, crashBundle, root, stage],
env: {
...process.env,
ORCA_BACKGROUND_LAUNCH: '1',
NODE_PATH: join(process.cwd(), 'node_modules')
},
timeoutMs: 10_000,
maxOutputBytes: 16_384
})
expect(child.timedOut, child.stderr).toBe(false)
expect(child.stdout, child.stderr).toBe(`${stage}\n`)
expect(child.code).not.toBe(0)
if (process.platform !== 'win32') {
expect(child.signal).toBe('SIGKILL')
}
recoverPendingProfileProjectMoves(root)
const targetCommitted = ![
'intent-before-publish',
'intent-published',
'intent-durable',
'target-before-commit'
].includes(stage)
expect(JSON.parse(raw('source').json)).toEqual(
targetCommitted ? JSON.parse(JSON.stringify(sourceAfter)) : JSON.parse(sourceBefore)
)
expect(JSON.parse(raw('target').json)).toEqual(
targetCommitted ? JSON.parse(JSON.stringify(targetAfter)) : JSON.parse(targetBefore)
)
expect(raw('source').revision).toBe(targetCommitted ? 2 : 1)
expect(raw('target').revision).toBe(targetCommitted ? 2 : 1)
expect(recoverPendingProfileProjectMoves(root)).toBe(0)
})
})
@@ -0,0 +1,124 @@
import { randomUUID } from 'node:crypto'
import { mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'
import { bestEffortFsyncDirectorySync, fsyncFileSync } from '../../shared/secure-file'
import { hashProfileStateJson } from '../persistence/profile-state/profile-state-documents'
import {
readProfileStateWithRevision,
writeSerializedProfileState,
type ReadProfileStateResult
} from './profile-project-state-file'
import { getOrcaProfileMoveIntentDirectory } from './profile-storage-paths'
import { readProfileProjectDomainMoveState } from './profile-project-domain-move-intent'
import { writeProfileProjectDomainChanges } from './profile-project-domain-state'
import {
profileProjectMoveIntentPath,
readPendingProfileProjectMoveIntents,
validateProfileProjectMoveIntent,
type ProfileProjectMoveIntent,
type ProfileProjectMoveIntentV1
} from './profile-project-move-record'
export { profileHasPendingProjectMove } from './profile-project-move-record'
export type {
ProfileProjectMoveIdentity,
ProfileProjectMoveIntent
} from './profile-project-move-record'
export function persistProfileProjectMoveIntent(
userDataPath: string,
intent: ProfileProjectMoveIntent
): void {
validateProfileProjectMoveIntent(intent)
const directory = getOrcaProfileMoveIntentDirectory(userDataPath)
mkdirSync(directory, { recursive: true, mode: 0o700 })
const path = profileProjectMoveIntentPath(userDataPath, intent.id)
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`
writeFileSync(temporaryPath, JSON.stringify(intent), { encoding: 'utf8', mode: 0o600 })
fsyncFileSync(temporaryPath)
renameSync(temporaryPath, path)
bestEffortFsyncDirectorySync(directory)
}
export function removeProfileProjectMoveIntent(userDataPath: string, intentId: string): void {
rmSync(profileProjectMoveIntentPath(userDataPath, intentId), { force: true })
bestEffortFsyncDirectorySync(getOrcaProfileMoveIntentDirectory(userDataPath))
}
export function recoverPendingProfileProjectMoves(
userDataPath: string,
profileId?: string
): number {
const intents = readPendingProfileProjectMoveIntents(userDataPath).filter(
(intent) =>
profileId === undefined ||
intent.sourceProfileId === profileId ||
intent.targetProfileId === profileId
)
for (const intent of intents) {
recoverProfileProjectMoveIntent(userDataPath, intent)
}
return intents.length
}
function recoverProfileProjectMoveIntent(
userDataPath: string,
intent: ProfileProjectMoveIntent
): void {
const { sourceBefore, targetBefore, sourceAfter, targetAfter } =
intent.version === 2
? readProfileProjectDomainMoveState(userDataPath, intent)
: readLegacyMoveState(userDataPath, intent)
if (sourceAfter && targetAfter) {
removeProfileProjectMoveIntent(userDataPath, intent.id)
return
}
if (sourceBefore && targetBefore) {
removeProfileProjectMoveIntent(userDataPath, intent.id)
return
}
if (sourceBefore && targetAfter) {
if (intent.version === 2) {
writeProfileProjectDomainChanges(intent.sourceProfileId, userDataPath, intent.source)
} else {
writeSerializedProfileState(intent.sourceProfileId, userDataPath, intent.sourceAfterJson, {
expectedRevision: intent.expectedSourceRevision
})
}
removeProfileProjectMoveIntent(userDataPath, intent.id)
return
}
if (sourceBefore && !targetAfter && !targetBefore) {
throw new Error(`Profile move ${intent.id} has an unrecognized target state`)
}
if (targetAfter && !sourceAfter) {
// Preserve the journal when an independent write makes replay unsafe.
throw new Error(`Profile move ${intent.id} conflicts with a source profile write`)
}
if (sourceAfter && targetBefore) {
throw new Error(`Profile move ${intent.id} has a source commit without its target commit`)
}
throw new Error(`Profile move ${intent.id} has an unrecognized participant state`)
}
function readLegacyMoveState(userDataPath: string, intent: ProfileProjectMoveIntentV1) {
const source = readProfileStateWithRevision(intent.sourceProfileId, userDataPath)
const target = readProfileStateWithRevision(intent.targetProfileId, userDataPath)
if (source.revision === undefined || target.revision === undefined) {
throw new Error(`Profile move ${intent.id} no longer has two SQLite participants`)
}
return {
sourceBefore: matches(source, intent.expectedSourceRevision, intent.sourceBeforeHash),
targetBefore: matches(target, intent.expectedTargetRevision, intent.targetBeforeHash),
sourceAfter: matches(source, intent.expectedSourceRevision + 1, intent.sourceAfterHash),
targetAfter: matches(target, intent.expectedTargetRevision + 1, intent.targetAfterHash)
}
}
function matches(snapshot: ReadProfileStateResult, revision: number, hash: string): boolean {
return (
snapshot.revision === revision &&
snapshot.serialized !== undefined &&
hashProfileStateJson(snapshot.serialized) === hash
)
}
@@ -0,0 +1,141 @@
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { basename, join } from 'node:path'
import { hashProfileStateJson } from '../persistence/profile-state/profile-state-documents'
import { isRecord } from '../persistence/profile-state/profile-state-document-validation'
import { getOrcaProfileMoveIntentDirectory } from './profile-storage-paths'
import {
validateProfileProjectDomainChanges,
type ProfileProjectDomainChanges
} from './profile-project-domain-changes'
const PROFILE_MOVE_INTENT_VERSION = 1
const INTENT_FILE_PATTERN = /^[0-9a-f-]{36}\.json$/
const PROFILE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/
export type ProfileProjectMoveIdentity = {
id: string
sourceProfileId: string
targetProfileId: string
}
export type ProfileProjectMoveIntentV1 = ProfileProjectMoveIdentity & {
version: typeof PROFILE_MOVE_INTENT_VERSION
expectedSourceRevision: number
expectedTargetRevision: number
sourceBeforeHash: string
targetBeforeHash: string
sourceAfterHash: string
targetAfterHash: string
sourceAfterJson: string
targetAfterJson: string
}
export type ProfileProjectDomainMoveIntent = ProfileProjectMoveIdentity & {
version: 2
source: ProfileProjectDomainChanges
target: ProfileProjectDomainChanges
}
export type ProfileProjectMoveIntent = ProfileProjectMoveIntentV1 | ProfileProjectDomainMoveIntent
export function profileHasPendingProjectMove(profileId: string, userDataPath: string): boolean {
try {
return readPendingProfileProjectMoveIntents(userDataPath).some(
(intent) => intent.sourceProfileId === profileId || intent.targetProfileId === profileId
)
} catch {
// An unreadable intent cannot rule this profile out as a participant.
return true
}
}
export function readPendingProfileProjectMoveIntents(
userDataPath: string
): ProfileProjectMoveIntent[] {
const directory = getOrcaProfileMoveIntentDirectory(userDataPath)
return existsSync(directory)
? readdirSync(directory)
.filter((file) => INTENT_FILE_PATTERN.test(file))
.map((file) => readProfileProjectMoveIntent(join(directory, file)))
: []
}
export function profileProjectMoveIntentPath(userDataPath: string, intentId: string): string {
if (!/^[0-9a-f-]{36}$/.test(intentId)) {
throw new Error('Invalid profile move intent ID')
}
return join(getOrcaProfileMoveIntentDirectory(userDataPath), `${intentId}.json`)
}
function readProfileProjectMoveIntent(path: string): ProfileProjectMoveIntent {
let parsed: unknown
try {
parsed = JSON.parse(readFileSync(path, 'utf8'))
} catch (error) {
throw new Error(
`Profile move intent is unreadable: ${path}: ${error instanceof Error ? error.message : String(error)}`
)
}
if (!isRecord(parsed)) {
throw new Error(`Profile move intent is malformed: ${path}`)
}
validateProfileProjectMoveIntent(parsed)
if (basename(path) !== `${parsed.id}.json`) {
throw new Error(`Profile move intent ID does not match its file: ${path}`)
}
return parsed
}
export function validateProfileProjectMoveIntent(
value: unknown
): asserts value is ProfileProjectMoveIntent {
validateMoveIdentity(value)
if (value.version === 2) {
validateProfileProjectDomainChanges(value.source)
validateProfileProjectDomainChanges(value.target)
return
}
const intent = value
const expectedSourceRevision = intent.expectedSourceRevision
const expectedTargetRevision = intent.expectedTargetRevision
if (
intent.version !== PROFILE_MOVE_INTENT_VERSION ||
!Number.isSafeInteger(expectedSourceRevision) ||
!Number.isSafeInteger(expectedTargetRevision) ||
typeof expectedSourceRevision !== 'number' ||
typeof expectedTargetRevision !== 'number' ||
expectedSourceRevision < 0 ||
expectedTargetRevision < 0 ||
!isHash(intent.sourceBeforeHash) ||
!isHash(intent.targetBeforeHash) ||
!isHash(intent.sourceAfterHash) ||
!isHash(intent.targetAfterHash) ||
typeof intent.sourceAfterJson !== 'string' ||
typeof intent.targetAfterJson !== 'string' ||
hashProfileStateJson(intent.sourceAfterJson) !== intent.sourceAfterHash ||
hashProfileStateJson(intent.targetAfterJson) !== intent.targetAfterHash
) {
throw new Error('Profile move intent is malformed')
}
}
function validateMoveIdentity(
value: unknown
): asserts value is ProfileProjectMoveIdentity & Record<string, unknown> {
if (
!isRecord(value) ||
typeof value.id !== 'string' ||
!/^[0-9a-f-]{36}$/.test(value.id) ||
typeof value.sourceProfileId !== 'string' ||
typeof value.targetProfileId !== 'string' ||
!PROFILE_ID_PATTERN.test(value.sourceProfileId) ||
!PROFILE_ID_PATTERN.test(value.targetProfileId) ||
value.sourceProfileId === value.targetProfileId
) {
throw new Error('Profile move intent is malformed')
}
}
function isHash(value: unknown): value is string {
return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value)
}
@@ -15,29 +15,116 @@ import type { Repo } from '../../shared/repo-types'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import type { SparsePreset } from '../../shared/worktree/create-types'
import type { RetiredNameRegistry } from '../../shared/worktree/retired-name-registry'
import { getOrcaProfileDataFile } from './profile-index-store'
import { getOrcaProfileDataFile, getOrcaProfileStateDatabaseFile } from './profile-index-store'
import {
importProfileStateJson,
profileStateJsonMatchesAcceptance,
readProfileStateRevision,
readProfileStateSnapshot
} from '../persistence/profile-state/profile-state-documents'
import {
openProfileStateDatabase,
openProfileStateDatabaseReadOnly
} from '../persistence/profile-state/profile-state-database'
import { parseProfileStateRoot } from '../persistence/profile-state/profile-state-document-validation'
import { assertProfileStateCanInitialize } from '../persistence/profile-state/profile-state-recovery-required'
import { hasProfileStateDatabaseFiles } from '../persistence/profile-state/profile-state-storage-classification'
export type TransferProfileState = PersistedState
function isRecord<T>(value: unknown): value is Record<string, T> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
export type ReadProfileStateResult = {
state: TransferProfileState
/** SQLite profile revision observed with the state snapshot; absent for legacy JSON. */
revision?: number
/** Exact compact JSON projection observed with the state snapshot. */
serialized?: string
}
function arrayOrEmpty<T>(value: unknown): T[] {
return Array.isArray(value) ? value : []
}
/** A profile must have one unambiguous transfer source. */
export class AmbiguousProfileStateStorageError extends Error {
readonly code = 'ambiguous_profile_state_storage' as const
function recordOrEmpty<T>(value: unknown): Record<string, T> {
return isRecord<T>(value) ? value : {}
}
export function readProfileState(profileId: string, userDataPath: string): TransferProfileState {
const defaults = getDefaultPersistedState(homedir())
const dataFile = getOrcaProfileDataFile(profileId, userDataPath)
if (!existsSync(dataFile)) {
return structuredClone(defaults)
constructor(profileId: string, message?: string) {
super(message ?? `Profile ${profileId} has both SQLite and legacy JSON state`)
this.name = 'AmbiguousProfileStateStorageError'
}
const parsed: Partial<PersistedState> = JSON.parse(readFileSync(dataFile, 'utf-8'))
}
export type ProfileStateStorage = 'json' | 'sqlite'
export function profileStateStorage(profileId: string, userDataPath: string): ProfileStateStorage {
const dataFile = getOrcaProfileDataFile(profileId, userDataPath)
const databaseFile = getOrcaProfileStateDatabaseFile(profileId, userDataPath)
const hasJson = existsSync(dataFile)
const hasDatabase = hasProfileStateDatabaseFiles(databaseFile)
if (hasJson && hasDatabase) {
assertAcceptedLegacyJsonMirror(profileId, dataFile, databaseFile)
return 'sqlite'
}
if (!hasDatabase) {
assertProfileStateCanInitialize({ dataFile, databaseFile, profileId })
}
return hasDatabase ? 'sqlite' : 'json'
}
/**
* A migrated profile may retain its JSON export during the rollback window.
* Select SQLite only when its acceptance marker still names the exact export;
* any edit, missing marker, or corrupt database remains fail-closed.
*/
function assertAcceptedLegacyJsonMirror(
profileId: string,
dataFile: string,
databaseFile: string
): void {
const rawJson = readFileSync(dataFile, 'utf-8')
const opened = openProfileStateDatabaseReadOnly(databaseFile, profileId)
try {
if (!profileStateJsonMatchesAcceptance(opened.db, rawJson)) {
throw new AmbiguousProfileStateStorageError(profileId)
}
} finally {
opened.db.close()
}
}
/** Read one profile state and retain the SQLite revision that fenced that snapshot. */
export function readProfileStateWithRevision(
profileId: string,
userDataPath: string
): ReadProfileStateResult {
const storage = profileStateStorage(profileId, userDataPath)
if (storage === 'json') {
const dataFile = getOrcaProfileDataFile(profileId, userDataPath)
const serialized = existsSync(dataFile) ? readFileSync(dataFile, 'utf-8') : undefined
return { state: parseProfileState(serialized), ...(serialized ? { serialized } : {}) }
}
const databaseFile = getOrcaProfileStateDatabaseFile(profileId, userDataPath)
const opened = openProfileStateDatabaseReadOnly(databaseFile, profileId)
try {
const snapshot = readProfileStateSnapshot(opened.db)
return {
state: parseProfileState(snapshot.json),
revision: snapshot.revision,
serialized: snapshot.json
}
} finally {
opened.db.close()
}
}
function parseProfileState(rawJson: string | undefined): TransferProfileState {
if (rawJson === undefined) {
return structuredClone(getDefaultPersistedState(homedir()))
}
return normalizeProfileProjectState(parseProfileStateRoot(rawJson))
}
export function normalizeProfileProjectState(
parsed: Partial<PersistedState>
): TransferProfileState {
const defaults = getDefaultPersistedState(homedir())
return rebuildRepoBackedProjectState({
...defaults,
...parsed,
@@ -91,15 +178,56 @@ export function readProfileState(profileId: string, userDataPath: string): Trans
})
}
function isRecord<T>(value: unknown): value is Record<string, T> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function arrayOrEmpty<T>(value: unknown): T[] {
return Array.isArray(value) ? value : []
}
function recordOrEmpty<T>(value: unknown): Record<string, T> {
return isRecord<T>(value) ? value : {}
}
export function readProfileState(profileId: string, userDataPath: string): TransferProfileState {
return readProfileStateWithRevision(profileId, userDataPath).state
}
export function writeProfileState(
profileId: string,
userDataPath: string,
state: TransferProfileState
state: TransferProfileState,
options: { expectedRevision?: number } = {}
): void {
writeSerializedProfileState(profileId, userDataPath, JSON.stringify(state), options)
}
/** Write an already validated JSON projection while preserving its exact bytes in SQLite. */
export function writeSerializedProfileState(
profileId: string,
userDataPath: string,
serialized: string,
options: { expectedRevision?: number } = {}
): void {
const storage = profileStateStorage(profileId, userDataPath)
if (storage === 'sqlite') {
const databaseFile = getOrcaProfileStateDatabaseFile(profileId, userDataPath)
const opened = openProfileStateDatabase(databaseFile, profileId)
try {
importProfileStateJson(opened.db, serialized, {
expectedRevision: options.expectedRevision ?? readProfileStateRevision(opened.db)
})
} finally {
opened.db.close()
}
return
}
const dataFile = getOrcaProfileDataFile(profileId, userDataPath)
mkdirSync(dirname(dataFile), { recursive: true })
const tmpPath = `${dataFile}.${process.pid}.${randomUUID()}.tmp`
writeFileSync(tmpPath, JSON.stringify(state, null, 2), 'utf-8')
writeFileSync(tmpPath, serialized, 'utf-8')
renameSync(tmpPath, dataFile)
}

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