mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 08:02:35 +00:00
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:
@@ -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 = [
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
+22
-9
@@ -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') {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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'
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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())
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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]!()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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'),
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user