mirror of
https://github.com/stablyai/orca.git
synced 2026-10-03 08:02:12 +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:
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user