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

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

Validate migration, worker failures, maintenance, cross-profile moves and terminal lifetime races with unit, integration and end-to-end coverage.
This commit is contained in:
OrcaWin
2026-09-25 22:47:33 -07:00
committed by GitHub
parent f0a3610928
commit 82412dab8b
490 changed files with 41290 additions and 3485 deletions
+5
View File
@@ -183,6 +183,11 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [
keys: ['agent hooks prepare-codex', 'agent hooks status', 'agent hooks off', 'agent hooks on'],
load: async () => (await import('./handlers/agent-hooks.js')).AGENT_HOOK_HANDLERS
},
{
name: 'profile-state',
keys: ['profile state exports', 'profile state rollback'],
load: async () => (await import('./handlers/profile-state.js')).PROFILE_STATE_HANDLERS
},
{
name: 'diagnostics',
keys: ['diagnostics memory'],
@@ -0,0 +1,42 @@
import { afterEach, expect, it, vi } from 'vitest'
import { main } from '../index'
const { prepare } = vi.hoisted(() => ({ prepare: vi.fn() }))
vi.mock('../runtime-client', () => ({
RuntimeClient: class {
async call() {
return { result: { settings: { agentStatusHooksEnabled: true } } }
}
},
RuntimeClientError: Error,
getDefaultUserDataPath: () => '/unused/user-data'
}))
vi.mock('../../main/codex/managed-home-shell-preflight', () => ({
prepareManagedCodexHomeBeforeShellLaunch: prepare
}))
vi.mock('../../main/persistence/profile-state/profile-state-offline-settings', () => {
throw new Error('Offline profile settings loaded during online preparation')
})
vi.mock('../../main/persistence/profile-state/profile-state-access', () => {
throw new Error('Profile admission loaded during online preparation')
})
vi.mock('../profile-state-location', () => {
throw new Error('Profile location loaded during online preparation')
})
afterEach(() => {
vi.unstubAllEnvs()
vi.restoreAllMocks()
process.exitCode = undefined
})
it('prepares Codex through the runtime without loading offline profile storage', async () => {
vi.stubEnv('WSL_DISTRO_NAME', '')
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
await main(['agent', 'hooks', 'prepare-codex'])
expect(error).not.toHaveBeenCalled()
expect(prepare).toHaveBeenCalledWith({
userDataPath: '/unused/user-data',
hooksEnabled: true
})
})
+288 -2
View File
@@ -1,9 +1,28 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import * as fs from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getDefaultPersistedState } from '../../shared/constants'
import type { PersistedState } from '../../shared/persisted-state-types'
import {
getOrcaProfileDataFile,
getOrcaProfileStateDatabaseFile
} from '../../main/orca-profiles/profile-storage-paths'
import { openProfileStateDatabase } from '../../main/persistence/profile-state/profile-state-database'
import {
exportProfileStateJson,
hashProfileStateJson,
importProfileStateJson
} from '../../main/persistence/profile-state/profile-state-documents'
import { ProfileStateSqliteAuthority } from '../../main/persistence/profile-state/profile-state-sqlite-authority'
import {
acquireProfileStateMaintenance,
acquireProfileStateRuntimeAdmission,
type ProfileStateRuntimeAdmission
} from '../../main/persistence/profile-state/profile-state-access'
vi.mock('node:fs', async (original) => ({ ...(await original<typeof fs>()) }))
const {
applyAgentStatusHooksEnabledMock,
@@ -74,6 +93,14 @@ function writeDataFile(userDataPath: string, state: PersistedState): void {
writeFileSync(join(userDataPath, 'orca-data.json'), JSON.stringify(state, null, 2), 'utf-8')
}
function writeActiveProfileIndex(userDataPath: string, profileId: string): void {
writeFileSync(
join(userDataPath, 'orca-profile-index.json'),
JSON.stringify({ activeProfileId: profileId, profiles: [{ id: profileId }] }),
'utf-8'
)
}
async function runAgentHooksOff(userDataPath: string): Promise<void> {
getDefaultUserDataPathMock.mockReturnValue(userDataPath)
await main(['agent', 'hooks', 'off', '--json'], userDataPath)
@@ -84,7 +111,7 @@ describe('agent hooks CLI handler', () => {
beforeEach(() => {
userDataPath = mkdtempSync(join(tmpdir(), 'orca-agent-hooks-cli-'))
applyAgentStatusHooksEnabledMock.mockReturnValue([])
applyAgentStatusHooksEnabledMock.mockReset().mockReturnValue([])
callMock.mockReset()
getCliStatusMock.mockClear()
getManagedAgentHookStatusesMock.mockReturnValue([])
@@ -109,6 +136,98 @@ describe('agent hooks CLI handler', () => {
expect(persisted.settings.agentStatusHooksEnabled).toBe(false)
})
it.each(['root-json', 'profile-json', 'sqlite'] as const)(
'refuses offline %s mutation when startup wins after the stopped-status response',
async (backend) => {
const profileId = 'startup-race'
const directory =
backend === 'root-json' ? userDataPath : join(userDataPath, 'profiles', profileId)
mkdirSync(directory, { recursive: true })
const dataFile = join(directory, 'orca-data.json')
const raw = JSON.stringify({
settings: { agentStatusHooksEnabled: true },
unknown: { retained: null }
})
writeFileSync(dataFile, raw)
if (backend !== 'root-json') {
writeActiveProfileIndex(userDataPath, profileId)
}
const databaseFile = join(directory, 'profile-state.db')
if (backend === 'sqlite') {
const opened = openProfileStateDatabase(databaseFile, profileId)
try {
importProfileStateJson(opened.db, raw, {
acceptedLegacyJsonHash: hashProfileStateJson(raw)
})
} finally {
opened.db.close()
}
}
const stopped = await getCliStatusMock()
let runtime: ProfileStateRuntimeAdmission | undefined
getCliStatusMock.mockImplementationOnce(async () => {
runtime = acquireProfileStateRuntimeAdmission(userDataPath)
return stopped
})
try {
await runAgentHooksOff(userDataPath)
expect(process.exitCode).toBe(1)
expect(applyAgentStatusHooksEnabledMock).not.toHaveBeenCalled()
expect(readFileSync(dataFile, 'utf8')).toBe(raw)
if (backend === 'sqlite') {
const opened = openProfileStateDatabase(databaseFile, profileId)
try {
expect(JSON.parse(exportProfileStateJson(opened.db))).toEqual(JSON.parse(raw))
} finally {
opened.db.close()
}
}
} finally {
runtime?.release()
}
process.exitCode = undefined
await runAgentHooksOff(userDataPath)
expect(process.exitCode).not.toBe(1)
expect(applyAgentStatusHooksEnabledMock).toHaveBeenCalledOnce()
}
)
it.each(['root-json', 'profile-json'] as const)(
'excludes startup and other offline writers through %s publication',
async (backend) => {
const profileId = 'offline-first'
const directory =
backend === 'root-json' ? userDataPath : join(userDataPath, 'profiles', profileId)
mkdirSync(directory, { recursive: true })
if (backend === 'profile-json') {
writeActiveProfileIndex(userDataPath, profileId)
}
const dataFile = join(directory, 'orca-data.json')
writeFileSync(dataFile, JSON.stringify({ settings: { agentStatusHooksEnabled: true } }))
const rename = fs.renameSync
let checkedPublication = false
vi.spyOn(fs, 'renameSync').mockImplementation((source, target) => {
if (target === dataFile) {
checkedPublication = true
expect(() => acquireProfileStateRuntimeAdmission(userDataPath)).toThrow()
expect(() => acquireProfileStateMaintenance(userDataPath)).toThrow()
}
return rename(source, target)
})
await runAgentHooksOff(userDataPath)
expect(checkedPublication).toBe(true)
expect(process.exitCode).not.toBe(1)
const runtime = acquireProfileStateRuntimeAdmission(userDataPath)
try {
expect(JSON.parse(readFileSync(dataFile, 'utf8')).settings.agentStatusHooksEnabled).toBe(
false
)
} finally {
runtime.release()
}
}
)
it('keeps missing new card style off when updating offline settings', async () => {
const existing = getDefaultPersistedState(userDataPath)
delete existing.settings.experimentalNewWorktreeCardStyle
@@ -129,6 +248,31 @@ describe('agent hooks CLI handler', () => {
expect(readDataFile(userDataPath).settings.experimentalNewWorktreeCardStyle).toBe(true)
})
it.each(['on', 'off', 'status', 'prepare-codex'])(
'refuses explicit remote selection before local hook command %s',
async (command) => {
const state = getDefaultPersistedState(userDataPath)
writeDataFile(userDataPath, state)
const before = readFileSync(join(userDataPath, 'orca-data.json'), 'utf8')
getDefaultUserDataPathMock.mockReturnValue(userDataPath)
for (const selector of ['environment', 'pairing-code']) {
process.exitCode = undefined
await main(
['agent', 'hooks', command, `--${selector}`, 'unreachable-host', '--json'],
userDataPath
)
expect(process.exitCode).toBe(1)
expect(getCliStatusMock).not.toHaveBeenCalled()
expect(callMock).not.toHaveBeenCalled()
expect(applyAgentStatusHooksEnabledMock).not.toHaveBeenCalled()
expect(prepareManagedCodexHomeBeforeShellLaunchMock).not.toHaveBeenCalled()
expect(readFileSync(join(userDataPath, 'orca-data.json'), 'utf8')).toBe(before)
}
}
)
it('prepares managed Codex trust with the current hooks setting', async () => {
const state = getDefaultPersistedState(userDataPath)
state.settings.agentStatusHooksEnabled = false
@@ -231,4 +375,146 @@ describe('agent hooks CLI handler', () => {
timeoutMs: 1_000
})
})
it('updates an established SQLite profile without rewriting its JSON export', async () => {
const profileId = 'work-profile'
const profileDirectory = join(userDataPath, 'profiles', profileId)
const dataFile = getOrcaProfileDataFile(profileId, userDataPath)
const databaseFile = getOrcaProfileStateDatabaseFile(profileId, userDataPath)
const raw = JSON.stringify({
settings: {
agentStatusHooksEnabled: true,
disabledTuiAgents: ['codex'],
opencodeSessionCookie: 'encrypted-ciphertext'
},
unknownDomain: { preserved: true }
})
mkdirSync(profileDirectory, { recursive: true })
writeFileSync(dataFile, raw, 'utf-8')
writeActiveProfileIndex(userDataPath, profileId)
const opened = openProfileStateDatabase(databaseFile, profileId)
try {
importProfileStateJson(opened.db, raw, {
acceptedLegacyJsonHash: hashProfileStateJson(raw)
})
} finally {
opened.db.close()
}
await runAgentHooksOff(userDataPath)
expect(readFileSync(dataFile, 'utf-8')).toBe(raw)
const readBack = openProfileStateDatabase(databaseFile, profileId)
try {
expect(JSON.parse(exportProfileStateJson(readBack.db))).toMatchObject({
settings: {
agentStatusHooksEnabled: false,
opencodeSessionCookie: 'encrypted-ciphertext',
disabledTuiAgents: ['codex']
},
unknownDomain: { preserved: true }
})
} finally {
readBack.db.close()
}
})
it.each(['update-failed', 'unreachable', 'status-failed'] as const)(
'preserves a live SQLite writer when runtime contact is %s',
async (failure) => {
const profileId = 'live-profile'
const profileDirectory = join(userDataPath, 'profiles', profileId)
mkdirSync(profileDirectory, { recursive: true })
writeActiveProfileIndex(userDataPath, profileId)
const authority = new ProfileStateSqliteAuthority(
getOrcaProfileStateDatabaseFile(profileId, userDataPath),
profileId
)
authority.writeSerializedState(
Buffer.from(JSON.stringify({ settings: { agentStatusHooksEnabled: true } }))
)
if (failure === 'status-failed') {
getCliStatusMock.mockRejectedValueOnce(new Error('status transport unavailable'))
} else {
getCliStatusMock.mockResolvedValueOnce({
id: 'test-status',
ok: true,
result: {
app: { running: true, pid: null },
runtime: {
state: failure === 'unreachable' ? 'starting' : 'ready',
reachable: failure !== 'unreachable',
runtimeId: null
},
graph: { state: 'ready' }
},
_meta: { runtimeId: 'test' }
})
callMock.mockRejectedValueOnce(new Error('settings request timed out'))
}
try {
await runAgentHooksOff(userDataPath)
expect(process.exitCode).toBe(1)
expect(applyAgentStatusHooksEnabledMock).not.toHaveBeenCalled()
expect(() =>
authority.writeSerializedDomains([
{ domain: 'ui', payload: '{"marker":"still-writable"}' }
])
).not.toThrow()
const persisted = JSON.parse(authority.readSerializedState() ?? '{}')
expect(persisted).toMatchObject({
settings: { agentStatusHooksEnabled: true },
ui: { marker: 'still-writable' }
})
} finally {
authority.close()
}
}
)
it('keeps a JSON-only active profile on the legacy path without creating SQLite', async () => {
const profileId = 'json-profile'
const profileDirectory = join(userDataPath, 'profiles', profileId)
mkdirSync(profileDirectory, { recursive: true })
writeDataFile(profileDirectory, getDefaultPersistedState(userDataPath))
writeActiveProfileIndex(userDataPath, profileId)
await runAgentHooksOff(userDataPath)
expect(existsSync(getOrcaProfileStateDatabaseFile(profileId, userDataPath))).toBe(false)
expect(
JSON.parse(readFileSync(getOrcaProfileDataFile(profileId, userDataPath), 'utf-8')).settings
.agentStatusHooksEnabled
).toBe(false)
})
it('fails closed when a profile has corrupt SQLite alongside legacy JSON', async () => {
const profileId = 'corrupt-profile'
const profileDirectory = join(userDataPath, 'profiles', profileId)
mkdirSync(profileDirectory, { recursive: true })
const state = getDefaultPersistedState(userDataPath)
writeDataFile(profileDirectory, state)
writeActiveProfileIndex(userDataPath, profileId)
const databaseFile = getOrcaProfileStateDatabaseFile(profileId, userDataPath)
writeFileSync(databaseFile, 'not sqlite', 'utf-8')
const before = readFileSync(getOrcaProfileDataFile(profileId, userDataPath), 'utf-8')
await runAgentHooksOff(userDataPath)
expect(process.exitCode).toBe(1)
expect(readFileSync(getOrcaProfileDataFile(profileId, userDataPath), 'utf-8')).toBe(before)
})
it('fails closed when a profile index is present but unreadable', async () => {
const legacy = getDefaultPersistedState(userDataPath)
writeDataFile(userDataPath, legacy)
writeFileSync(join(userDataPath, 'orca-profile-index.json'), '{ torn', 'utf-8')
const before = readFileSync(join(userDataPath, 'orca-data.json'), 'utf-8')
await runAgentHooksOff(userDataPath)
expect(process.exitCode).toBe(1)
expect(readFileSync(join(userDataPath, 'orca-data.json'), 'utf-8')).toBe(before)
})
})
+85 -48
View File
@@ -4,6 +4,7 @@ import { dirname, join } from 'node:path'
import { randomUUID } from 'node:crypto'
import type { CommandHandler } from '../dispatch'
import { printResult } from '../format'
import { rejectRemoteSelectionFlags } from '../remote-selection-flag-rejection'
import {
RuntimeClientError,
type RuntimeClient,
@@ -16,6 +17,7 @@ import { normalizeDisabledTuiAgents } from '../../shared/tui-agent-selection'
import type { GlobalSettings } from '../../shared/global-settings-types'
import type { PersistedState } from '../../shared/persisted-state-types'
import { prepareManagedCodexHomeBeforeShellLaunch } from '../../main/codex/managed-home-shell-preflight'
import type { ProfileStateOfflineLocation } from '../../main/persistence/profile-state/profile-state-offline-settings'
type AgentHookCommandResult = {
enabled: boolean
@@ -27,28 +29,15 @@ type AgentHookCommandResult = {
// Covers managed-home verification, WSL identity, trust grant, and bounded app-server reap.
const WSL_CODEX_PREPARE_TIMEOUT_MS = 50_000
function getDataPath(): string {
const userDataPath = getDefaultUserDataPath()
const indexPath = join(userDataPath, 'orca-profile-index.json')
for (const candidate of [indexPath, `${indexPath}.bak`]) {
try {
const parsed: unknown = JSON.parse(readFileSync(candidate, 'utf-8'))
if (!isRecord(parsed) || !Array.isArray(parsed.profiles)) {
continue
}
const profileId = parsed.activeProfileId
if (
typeof profileId === 'string' &&
/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(profileId) &&
parsed.profiles.some((profile) => isRecord(profile) && profile.id === profileId)
) {
return join(userDataPath, 'profiles', profileId, 'orca-data.json')
}
} catch {
// Try the profile-index backup, then the legacy pre-profile path.
}
}
return join(userDataPath, 'orca-data.json')
async function getDataPath(): Promise<string> {
return (
(await getProfileStateLocation())?.dataFile ?? join(getDefaultUserDataPath(), 'orca-data.json')
)
}
async function getProfileStateLocation(): Promise<ProfileStateOfflineLocation | undefined> {
const { getActiveProfileStateLocation } = await import('../profile-state-location.js')
return getActiveProfileStateLocation()
}
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -92,11 +81,29 @@ function writePersistedState(dataPath: string, state: PersistedState): void {
}
}
function readHookSettingsFromDisk(): Pick<
GlobalSettings,
'agentStatusHooksEnabled' | 'disabledTuiAgents'
async function readHookSettingsFromDisk(): Promise<
Pick<GlobalSettings, 'agentStatusHooksEnabled' | 'disabledTuiAgents'>
> {
const state = readPersistedState(getDataPath())
const { acquireProfileStateRuntimeAdmission } =
await import('../../main/persistence/profile-state/profile-state-access.js')
const admission = acquireProfileStateRuntimeAdmission(getDefaultUserDataPath())
try {
return await readAdmittedHookSettingsFromDisk()
} finally {
admission.release()
}
}
async function readAdmittedHookSettingsFromDisk(): Promise<
Pick<GlobalSettings, 'agentStatusHooksEnabled' | 'disabledTuiAgents'>
> {
const profileStateLocation = await getProfileStateLocation()
if (profileStateLocation) {
const { readAgentHookSettingsFromProfileState } =
await import('../../main/persistence/profile-state/profile-state-offline-settings.js')
return readAgentHookSettingsFromProfileState(profileStateLocation)
}
const state = readPersistedState(await getDataPath())
return {
agentStatusHooksEnabled: state.settings?.agentStatusHooksEnabled !== false,
disabledTuiAgents: normalizeDisabledTuiAgents(state.settings?.disabledTuiAgents)
@@ -123,11 +130,32 @@ async function readHookSettings(
return readHookSettingsFromDisk()
}
function updateEnabledOnDisk(enabled: boolean): {
async function updateEnabledOnDisk(enabled: boolean): Promise<{
settingsPath: string
settings: Pick<GlobalSettings, 'agentCmdOverrides' | 'disabledTuiAgents'>
} {
const dataPath = getDataPath()
}> {
const { acquireProfileStateMaintenance } =
await import('../../main/persistence/profile-state/profile-state-access.js')
// A stopped-status response cannot exclude first migration racing this JSON write.
const maintenance = acquireProfileStateMaintenance(getDefaultUserDataPath())
try {
return await updateAdmittedEnabledOnDisk(enabled)
} finally {
maintenance.release()
}
}
async function updateAdmittedEnabledOnDisk(enabled: boolean): Promise<{
settingsPath: string
settings: Pick<GlobalSettings, 'agentCmdOverrides' | 'disabledTuiAgents'>
}> {
const profileStateLocation = await getProfileStateLocation()
if (profileStateLocation) {
const { updateAgentHookSettingsFromProfileState } =
await import('../../main/persistence/profile-state/profile-state-offline-settings.js')
return updateAgentHookSettingsFromProfileState(profileStateLocation, enabled)
}
const dataPath = await getDataPath()
const state = readPersistedState(dataPath)
state.settings = {
...getDefaultPersistedState(homedir()).settings,
@@ -145,20 +173,18 @@ function updateEnabledOnDisk(enabled: boolean): {
}
async function updateRunningRuntime(client: RuntimeClient, enabled: boolean): Promise<boolean> {
try {
const status = await client.getCliStatus()
if (!status.result.runtime.reachable) {
return false
const status = await client.getCliStatus()
if (!status.result.runtime.reachable) {
if (status.result.app.running) {
throw new RuntimeClientError(
'runtime_error',
'Orca is running but unavailable. Retry when it responds, or stop Orca before changing agent hooks offline.'
)
}
await client.call(
'settings.update',
{ agentStatusHooksEnabled: enabled },
{ timeoutMs: 10_000 }
)
return true
} catch {
return false
}
await client.call('settings.update', { agentStatusHooksEnabled: enabled }, { timeoutMs: 10_000 })
return true
}
function localSuccess<TResult>(result: TResult): RuntimeRpcSuccess<TResult> {
@@ -193,8 +219,8 @@ async function setAgentHooksEnabled(
const { applyAgentStatusHooksEnabled, getManagedAgentHookStatuses } =
await import('../../main/agent-hooks/managed-agent-hook-controls.js')
const updatedRuntime = await updateRunningRuntime(client, enabled)
const offlineUpdate = updatedRuntime ? null : updateEnabledOnDisk(enabled)
const settingsPath = offlineUpdate?.settingsPath ?? getDataPath()
const offlineUpdate = updatedRuntime ? null : await updateEnabledOnDisk(enabled)
const settingsPath = offlineUpdate?.settingsPath ?? (await getDataPath())
const statuses = updatedRuntime
? getManagedAgentHookStatuses()
: await applyAgentStatusHooksEnabled(enabled, offlineUpdate?.settings)
@@ -207,7 +233,8 @@ async function setAgentHooksEnabled(
}
export const AGENT_HOOK_HANDLERS: Record<string, CommandHandler> = {
'agent hooks prepare-codex': async ({ client }) => {
'agent hooks prepare-codex': async ({ client, flags }) => {
rejectRemoteHookSelection(flags)
if (process.env.WSL_DISTRO_NAME?.trim()) {
try {
await client.call(
@@ -231,23 +258,33 @@ export const AGENT_HOOK_HANDLERS: Record<string, CommandHandler> = {
settings.agentStatusHooksEnabled && !settings.disabledTuiAgents.includes('codex')
})
},
'agent hooks status': async ({ json }) => {
'agent hooks status': async ({ json, flags }) => {
rejectRemoteHookSelection(flags)
const { getManagedAgentHookStatuses } =
await import('../../main/agent-hooks/managed-agent-hook-controls.js')
const result: AgentHookCommandResult = {
enabled: readHookSettingsFromDisk().agentStatusHooksEnabled,
settingsPath: getDataPath(),
enabled: (await readHookSettingsFromDisk()).agentStatusHooksEnabled,
settingsPath: await getDataPath(),
appliedBy: 'offline',
statuses: getManagedAgentHookStatuses()
}
printResult(localSuccess(result), json, formatAgentHookCommandResult)
},
'agent hooks off': async ({ client, json }) => {
'agent hooks off': async ({ client, json, flags }) => {
rejectRemoteHookSelection(flags)
const result = await setAgentHooksEnabled(client, false)
printResult(localSuccess(result), json, formatAgentHookCommandResult)
},
'agent hooks on': async ({ client, json }) => {
'agent hooks on': async ({ client, json, flags }) => {
rejectRemoteHookSelection(flags)
const result = await setAgentHooksEnabled(client, true)
printResult(localSuccess(result), json, formatAgentHookCommandResult)
}
}
function rejectRemoteHookSelection(flags: ReadonlyMap<string, string | boolean>): void {
rejectRemoteSelectionFlags(
flags,
'agent hooks; run this command on the machine whose hooks you want to manage.'
)
}
@@ -0,0 +1,245 @@
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import * as durableFileWrite from '../../main/durable-file-write'
import {
acquireProfileStateMaintenance,
acquireProfileStateRuntimeAdmission
} from '../../main/persistence/profile-state/profile-state-access'
import {
openProfileStateDatabase,
openProfileStateDatabaseReadOnly
} from '../../main/persistence/profile-state/profile-state-database'
import {
importProfileStateJson,
readProfileStateSnapshot
} from '../../main/persistence/profile-state/profile-state-documents'
import {
createProfileStateDatabaseBackupId,
profileStateDatabaseBackupPath
} from '../../main/persistence/profile-state/profile-state-backup-path'
import { writeProfileStateDatabaseSnapshotAsync } from '../../main/persistence/profile-state/profile-state-database-snapshot'
import { restoreProfileStateDatabaseBackup } from '../../main/persistence/profile-state/profile-state-database-recovery'
import {
assertNoRetainedProfileStateExports,
ProfileStateRecoveryRequiredError
} from '../../main/persistence/profile-state/profile-state-recovery-required'
import { RuntimeClient } from '../runtime-client'
import { PROFILE_STATE_HANDLERS } from './profile-state'
const mocks = vi.hoisted(() => ({ root: vi.fn(), status: vi.fn() }))
vi.mock('../runtime-client', () => ({
getDefaultUserDataPath: mocks.root,
RuntimeClient: class {
getCliStatus = mocks.status
},
RuntimeClientError: class extends Error {
constructor(
readonly code: string,
message: string
) {
super(message)
}
}
}))
const roots: string[] = []
const profileId = 'admission-recovery'
const backupState = {
settings: {
theme: 'restored',
httpProxyUrl: 'sealed:backup',
electronHttp1CompatibilityMode: true
},
extension: { unknown: [null, '\ud800', 'backup'] },
opaque: null
}
const liveState = {
settings: { theme: 'runtime-before-restore', httpProxyUrl: 'sealed:live' },
extension: { unknown: [null, '\ud800', 'live'] },
opaque: null
}
beforeEach(() => {
mocks.status.mockReset().mockResolvedValue({
result: { app: { running: false }, runtime: { reachable: false } }
})
vi.spyOn(console, 'log').mockImplementation(() => {})
})
afterEach(() => {
vi.restoreAllMocks()
for (const root of roots.splice(0)) {
rmSync(root, { recursive: true, force: true })
}
})
async function fixture() {
const root = mkdtempSync(join(tmpdir(), 'orca-recovery-admission-'))
roots.push(root)
const directory = join(root, 'profiles', profileId)
mkdirSync(directory, { recursive: true })
writeFileSync(
join(root, 'orca-profile-index.json'),
JSON.stringify({ activeProfileId: profileId, profiles: [{ id: profileId }] })
)
const databasePath = join(directory, 'profile-state.db')
const dataFile = join(directory, 'orca-data.json')
const backupId = createProfileStateDatabaseBackupId()
const backupPath = profileStateDatabaseBackupPath(databasePath, backupId)
const source = openProfileStateDatabase(databasePath, profileId)
try {
importProfileStateJson(source.db, JSON.stringify(backupState))
await writeProfileStateDatabaseSnapshotAsync(source.db, backupPath)
importProfileStateJson(source.db, JSON.stringify(liveState), { expectedRevision: 1 })
} finally {
source.db.close()
}
mocks.root.mockReturnValue(root)
return { root, directory, databasePath, dataFile, backupId, backupPath }
}
function rollback(profile: Awaited<ReturnType<typeof fixture>>): Promise<void> {
const handler = PROFILE_STATE_HANDLERS['profile state rollback']
if (handler === undefined) {
throw new Error('Profile rollback handler is missing')
}
return handler({
flags: new Map([['backup', profile.backupId]]),
client: new RuntimeClient(profile.root),
cwd: profile.root,
json: true
})
}
function state(databasePath: string) {
const opened = openProfileStateDatabaseReadOnly(databasePath, profileId)
try {
return readProfileStateSnapshot(opened.db)
} finally {
opened.db.close()
}
}
describe('offline recovery excludes runtime admission', () => {
it('refuses rollback without changing the database when a move journal is unresolved', async () => {
const profile = await fixture()
const before = readFileSync(profile.databasePath)
const intents = join(profile.root, 'profile-move-intents')
mkdirSync(intents)
const intentPath = join(intents, '00000000-0000-0000-0000-000000000001.json')
writeFileSync(intentPath, '{"partial":true}')
await expect(rollback(profile)).rejects.toThrow('pending project move')
expect(readFileSync(profile.databasePath)).toEqual(before)
expect(readFileSync(intentPath, 'utf8')).toBe('{"partial":true}')
})
it('refuses recovery before any mutation when a runtime has already entered', async () => {
const profile = await fixture()
const original = readFileSync(profile.databasePath)
const backup = readFileSync(profile.backupPath)
const admission = acquireProfileStateRuntimeAdmission(profile.root)
const runtime = openProfileStateDatabase(profile.databasePath, profileId)
try {
await expect(rollback(profile)).rejects.toThrow('in use')
expect(mocks.status).not.toHaveBeenCalled()
expect(JSON.parse(readProfileStateSnapshot(runtime.db).json)).toEqual(liveState)
expect(readFileSync(profile.databasePath)).toEqual(original)
expect(readFileSync(profile.backupPath)).toEqual(backup)
expect(
readdirSync(profile.directory).some((name) => name.startsWith('profile-state-corrupt'))
).toBe(false)
} finally {
runtime.db.close()
admission.release()
}
})
it('blocks startup between the stopped census and restoration while preserving complete original and restored state', async () => {
const profile = await fixture()
const original = readFileSync(profile.databasePath)
const backup = readFileSync(profile.backupPath)
mocks.status.mockImplementation(async () => {
const stopped = { result: { app: { running: false }, runtime: { reachable: false } } }
expect(() => acquireProfileStateRuntimeAdmission(profile.root)).toThrow('in use')
expect(() => acquireProfileStateMaintenance(profile.root)).toThrow('in use')
return stopped
})
await rollback(profile)
expect(mocks.status).toHaveBeenCalledOnce()
expect(JSON.parse(state(profile.databasePath).json)).toEqual(backupState)
expect(readFileSync(profile.backupPath)).toEqual(backup)
const quarantine = readdirSync(profile.directory).find((name) =>
name.startsWith('profile-state-corrupt')
)
expect(quarantine).toBeDefined()
if (quarantine === undefined) {
throw new Error('Recovery did not preserve a quarantine')
}
const quarantined = join(profile.directory, quarantine, 'profile-state.db')
expect(readFileSync(quarantined)).toEqual(original)
expect(JSON.parse(state(quarantined).json)).toEqual(liveState)
const admission = acquireProfileStateRuntimeAdmission(profile.root)
expect(JSON.parse(state(profile.databasePath).json)).toEqual(backupState)
admission.release()
})
it('keeps startup blocked after failed durable publication and permits an explicit successful retry', async () => {
const profile = await fixture()
const backup = readFileSync(profile.backupPath)
const rename = durableFileWrite.renameDurableSync
const failure = vi
.spyOn(durableFileWrite, 'renameDurableSync')
.mockImplementation((from, to) => {
if (to === profile.databasePath) {
throw new Error('injected recovery publication failure')
}
rename(from, to)
})
await expect(rollback(profile)).rejects.toThrow('injected recovery publication failure')
const admission = acquireProfileStateRuntimeAdmission(profile.root)
try {
expect(() =>
assertNoRetainedProfileStateExports({
dataFile: profile.dataFile,
databaseFile: profile.databasePath,
profileId
})
).toThrow(ProfileStateRecoveryRequiredError)
} finally {
admission.release()
}
expect(readFileSync(profile.backupPath)).toEqual(backup)
const quarantine = readdirSync(profile.directory).find((name) =>
name.startsWith('profile-state-corrupt')
)
if (quarantine === undefined) {
throw new Error('Recovery did not preserve original state')
}
expect(JSON.parse(state(join(profile.directory, quarantine, 'profile-state.db')).json)).toEqual(
liveState
)
failure.mockRestore()
await rollback(profile)
expect(JSON.parse(state(profile.databasePath).json)).toEqual(backupState)
acquireProfileStateRuntimeAdmission(profile.root).release()
})
it('rejects fabricated or released maintenance handles before replacing any database bytes', async () => {
const profile = await fixture()
const maintenance = acquireProfileStateMaintenance(profile.root)
const original = readFileSync(profile.databasePath)
const options = { ...profile, profileId }
expect(() =>
restoreProfileStateDatabaseBackup({ ...options, maintenance: { ...maintenance } })
).toThrow('acquired')
maintenance.release()
expect(() => restoreProfileStateDatabaseBackup({ ...options, maintenance })).toThrow('released')
expect(readFileSync(profile.databasePath)).toEqual(original)
})
})
+487
View File
@@ -0,0 +1,487 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { basename, join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import * as durableFileWrite from '../../main/durable-file-write'
import * as http1Marker from '../../main/startup/http1-compatibility-marker'
import { readPersistedHttp1CompatibilityMode } from '../../main/startup/http1-compatibility-profile-state'
import {
openProfileStateDatabase,
openProfileStateDatabaseReadOnly,
profileStateDatabaseFile
} from '../../main/persistence/profile-state/profile-state-database'
import {
exportProfileStateJson,
importProfileStateJson
} from '../../main/persistence/profile-state/profile-state-documents'
import {
createProfileStateDatabaseBackupId,
profileStateDatabaseBackupPath
} from '../../main/persistence/profile-state/profile-state-backup-path'
import { writeProfileStateDatabaseSnapshotAsync } from '../../main/persistence/profile-state/profile-state-database-snapshot'
import { profileStateJsonExportPath } from '../../main/persistence/profile-state/profile-state-export-path'
import { main } from '../index'
const { getCliStatusMock, getDefaultUserDataPathMock, runtimeClientConstructorMock } = vi.hoisted(
() => ({
getCliStatusMock: vi.fn(),
getDefaultUserDataPathMock: vi.fn(),
runtimeClientConstructorMock: vi.fn()
})
)
vi.mock('../runtime-client', () => {
class RuntimeClientError extends Error {
readonly code: string
readonly data: unknown
constructor(code: string, message: string, data?: unknown) {
super(message)
this.code = code
this.data = data
}
}
class RuntimeClient {
getCliStatus = getCliStatusMock
constructor(
_userDataPath?: string,
_requestTimeoutMs?: number,
remotePairingCode?: string | null,
environmentSelector?: string | null
) {
runtimeClientConstructorMock(remotePairingCode, environmentSelector)
}
}
return {
RuntimeClient,
RuntimeClientError,
getDefaultUserDataPath: getDefaultUserDataPathMock
}
})
const temporaryDirectories: string[] = []
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true })
}
vi.restoreAllMocks()
runtimeClientConstructorMock.mockReset()
process.exitCode = 0
})
function createProfile(): {
userDataPath: string
dataFile: string
databaseFile: string
exportPath: string
} {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-profile-state-cli-'))
temporaryDirectories.push(userDataPath)
const profileId = 'profile-cli-recovery'
const profileDirectory = join(userDataPath, 'profiles', profileId)
mkdirSync(profileDirectory, { recursive: true })
writeFileSync(
join(userDataPath, 'orca-profile-index.json'),
JSON.stringify({ activeProfileId: profileId, profiles: [{ id: profileId }] }),
'utf8'
)
const dataFile = join(profileDirectory, 'orca-data.json')
const databaseFile = profileStateDatabaseFile(profileDirectory)
const exportPath = profileStateJsonExportPath(dataFile, 1)
writeFileSync(dataFile, JSON.stringify({ settings: { theme: 'old' } }), 'utf8')
writeFileSync(
exportPath,
JSON.stringify({ settings: { theme: 'recovered', electronHttp1CompatibilityMode: true } }),
'utf8'
)
writeFileSync(databaseFile, 'damaged sqlite primary', 'utf8')
writeFileSync(`${databaseFile}-wal`, 'damaged wal sidecar', 'utf8')
return { userDataPath, dataFile, databaseFile, exportPath }
}
async function createDatabaseBackup(
profile: ReturnType<typeof createProfile>,
profileId = 'profile-cli-recovery'
) {
const id = createProfileStateDatabaseBackupId()
const path = profileStateDatabaseBackupPath(profile.databaseFile, id)
const source = openProfileStateDatabase(join(profile.userDataPath, 'backup-source.db'), profileId)
try {
importProfileStateJson(
source.db,
JSON.stringify({
settings: {
theme: 'sqlite-recovered',
electronHttp1CompatibilityMode: true,
httpProxyUrl: 'sealed:unchanged'
},
extensionState: { retained: true }
})
)
await writeProfileStateDatabaseSnapshotAsync(source.db, path)
} finally {
source.db.close()
}
return { id, path }
}
describe('profile-state CLI recovery', () => {
beforeEach(() => {
getCliStatusMock.mockResolvedValue({
id: 'status',
ok: true,
result: {
app: { running: false, pid: null },
runtime: { state: 'not_running', reachable: false, runtimeId: null },
graph: { state: 'not_running' }
},
_meta: { runtimeId: 'test' }
})
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
})
it('restores the selected export through the offline CLI command', async () => {
const profile = createProfile()
getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath)
await main(['profile', 'state', 'rollback', '--revision', '1', '--json'], profile.userDataPath)
expect(existsSync(profile.databaseFile)).toBe(false)
expect(readFileSync(profile.dataFile, 'utf8')).toBe(
JSON.stringify({ settings: { theme: 'recovered', electronHttp1CompatibilityMode: true } })
)
expect(
JSON.parse(readFileSync(join(profile.userDataPath, 'http1-compatibility.json'), 'utf8'))
).toMatchObject({
enabled: true,
profileId: 'profile-cli-recovery'
})
const output = vi.mocked(console.log).mock.calls.at(-1)?.[0]
expect(String(output)).toContain('quarantineDirectory')
expect(getCliStatusMock).toHaveBeenCalledOnce()
})
it('adopts current JSON through CLI with an honest source description', async () => {
const profile = createProfile()
getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath)
const original = readFileSync(profile.dataFile)
await main(['profile', 'state', 'rollback', '--current-json'], profile.userDataPath)
expect(readFileSync(profile.dataFile)).toEqual(original)
expect(existsSync(profile.databaseFile)).toBe(false)
const output = String(vi.mocked(console.log).mock.calls.at(-1)?.[0])
expect(output).toContain('source: current JSON')
expect(output).not.toContain('revision:')
})
it.each([
['--current-json', '--revision', '1'],
['--current-json', '--backup', '1'],
['--current-json=false']
])('rejects ambiguous current JSON arguments: %s', async (...flags) => {
getCliStatusMock.mockClear()
const profile = createProfile()
getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath)
await main(['profile', 'state', 'rollback', ...flags, '--json'], profile.userDataPath)
expect(process.exitCode).toBe(1)
expect(existsSync(profile.databaseFile)).toBe(true)
expect(getCliStatusMock).not.toHaveBeenCalled()
})
it('keeps profile-state recovery local when remote selection is configured', async () => {
const profile = createProfile()
getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath)
vi.stubEnv('ORCA_PAIRING_CODE', 'remote-pairing-code')
vi.stubEnv('ORCA_ENVIRONMENT', 'stale-environment')
await main(['profile', 'state', 'rollback', '--revision', '1', '--json'], profile.userDataPath)
expect(runtimeClientConstructorMock).toHaveBeenCalledWith(null, null)
expect(vi.mocked(console.log).mock.calls.at(-1)?.[0]).toContain('quarantineDirectory')
})
it.each([true, false])(
'recovers an absent database and archives every export (legacy JSON present: %s)',
async (hasJson) => {
const profile = createProfile()
getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath)
rmSync(profile.databaseFile)
rmSync(`${profile.databaseFile}-wal`)
if (!hasJson) {
rmSync(profile.dataFile)
}
const laterExport = profileStateJsonExportPath(profile.dataFile, 2)
writeFileSync(laterExport, JSON.stringify({ settings: { theme: 'later' } }))
const selectedBytes = readFileSync(profile.exportPath)
const laterBytes = readFileSync(laterExport)
await main(
['profile', 'state', 'rollback', '--revision', '1', '--json'],
profile.userDataPath
)
expect(process.exitCode).toBe(0)
expect(readFileSync(profile.dataFile)).toEqual(selectedBytes)
expect(existsSync(profile.exportPath)).toBe(false)
expect(existsSync(laterExport)).toBe(false)
const output: unknown = JSON.parse(String(vi.mocked(console.log).mock.calls.at(-1)?.[0]))
expect(output).toMatchObject({ ok: true, result: { removedDatabaseFiles: [] } })
if (
!output ||
typeof output !== 'object' ||
!('result' in output) ||
!output.result ||
typeof output.result !== 'object' ||
!('quarantineDirectory' in output.result) ||
typeof output.result.quarantineDirectory !== 'string'
) {
throw new Error('Expected rollback archive directory')
}
const archive = output.result.quarantineDirectory
expect(readFileSync(join(archive, basename(profile.exportPath)))).toEqual(selectedBytes)
expect(readFileSync(join(archive, basename(laterExport)))).toEqual(laterBytes)
expect(existsSync(join(archive, basename(profile.dataFile)))).toBe(hasJson)
expect(readPersistedHttp1CompatibilityMode(profile.userDataPath)).toBe(true)
}
)
it('preserves all live recovery sources when archiving an export fails', async () => {
const profile = createProfile()
getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath)
const unavailableExport = profileStateJsonExportPath(profile.dataFile, 2)
mkdirSync(unavailableExport)
const original = readFileSync(profile.dataFile)
await main(['profile', 'state', 'rollback', '--revision', '1', '--json'], profile.userDataPath)
expect(process.exitCode).toBe(1)
expect(readFileSync(profile.dataFile)).toEqual(original)
expect(existsSync(profile.exportPath)).toBe(true)
expect(existsSync(profile.databaseFile)).toBe(true)
expect(existsSync(`${profile.databaseFile}-wal`)).toBe(true)
})
it('falls back to restored settings when refreshing the marker fails', async () => {
const profile = createProfile()
getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath)
http1Marker.writeHttp1CompatibilityMarker(profile.userDataPath, false, 'profile-cli-recovery')
const writeFileDurableSync = durableFileWrite.writeFileDurableSync
vi.spyOn(durableFileWrite, 'writeFileDurableSync').mockImplementation(
(tmp, target, contents) => {
if (target === join(profile.userDataPath, http1Marker.HTTP1_COMPATIBILITY_MARKER_FILE)) {
throw new Error('injected marker write failure')
}
return writeFileDurableSync(tmp, target, contents)
}
)
await main(['profile', 'state', 'rollback', '--revision', '1', '--json'], profile.userDataPath)
expect(existsSync(profile.databaseFile)).toBe(false)
expect(
http1Marker.readHttp1CompatibilityMarker(profile.userDataPath, 'profile-cli-recovery')
).toBeNull()
expect(readPersistedHttp1CompatibilityMode(profile.userDataPath)).toBe(true)
expect(String(vi.mocked(console.log).mock.calls.at(-1)?.[0])).toContain('quarantineDirectory')
})
it('preserves SQLite authority when the old marker cannot be invalidated', async () => {
const profile = createProfile()
getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath)
// A directory at the marker path makes non-recursive removal fail on every supported OS.
mkdirSync(join(profile.userDataPath, http1Marker.HTTP1_COMPATIBILITY_MARKER_FILE))
await main(['profile', 'state', 'rollback', '--revision', '1', '--json'], profile.userDataPath)
expect(readFileSync(profile.databaseFile, 'utf8')).toBe('damaged sqlite primary')
expect(existsSync(profile.exportPath)).toBe(true)
expect(readFileSync(profile.dataFile, 'utf8')).toBe(
JSON.stringify({ settings: { theme: 'old' } })
)
expect(String(vi.mocked(console.log).mock.calls.at(-1)?.[0])).not.toContain(
'quarantineDirectory'
)
expect(process.exitCode).toBe(1)
})
it('does not invalidate the active setting for an invalid recovery export', async () => {
const profile = createProfile()
getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath)
http1Marker.writeHttp1CompatibilityMarker(profile.userDataPath, true, 'profile-cli-recovery')
writeFileSync(profile.exportPath, 'invalid JSON')
await main(['profile', 'state', 'rollback', '--revision', '1', '--json'], profile.userDataPath)
expect(existsSync(profile.databaseFile)).toBe(true)
expect(
http1Marker.readHttp1CompatibilityMarker(profile.userDataPath, 'profile-cli-recovery')
).toBe(true)
expect(process.exitCode).toBe(1)
})
it('rejects an explicit remote selector instead of silently ignoring it', async () => {
const profile = createProfile()
getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath)
await main(
['profile', 'state', 'rollback', '--revision', '1', '--environment', 'remote', '--json'],
profile.userDataPath
)
expect(existsSync(profile.databaseFile)).toBe(true)
expect(vi.mocked(console.log).mock.calls.at(-1)?.[0]).toContain(
'`--environment` does not retarget profile-state recovery'
)
})
it.each([['--revision', '1'], ['--current-json']])(
'refuses rollback while runtime is reachable: %s',
async (...flags) => {
const profile = createProfile()
getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath)
getCliStatusMock.mockResolvedValueOnce({
id: 'status',
ok: true,
result: {
app: { running: true, pid: 123 },
runtime: { state: 'ready', reachable: true, runtimeId: 'desktop' },
graph: { state: 'ready' }
},
_meta: { runtimeId: 'test' }
})
await main(['profile', 'state', 'rollback', ...flags], profile.userDataPath)
expect(existsSync(profile.databaseFile)).toBe(true)
expect(readFileSync(profile.dataFile, 'utf8')).toBe(
JSON.stringify({ settings: { theme: 'old' } })
)
expect(vi.mocked(console.error).mock.calls.at(-1)?.[0]).toContain('Stop Orca')
}
)
it('lists SQLite backups alongside JSON exports without opening the damaged primary', async () => {
const profile = createProfile()
const backup = await createDatabaseBackup(profile)
getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath)
getCliStatusMock.mockClear()
await main(['profile', 'state', 'exports', '--json'], profile.userDataPath)
const output: unknown = JSON.parse(String(vi.mocked(console.log).mock.calls.at(-1)?.[0]))
expect(output).toMatchObject({
ok: true,
result: { exportPaths: [profile.exportPath], backups: [{ id: backup.id, path: backup.path }] }
})
expect(getCliStatusMock).not.toHaveBeenCalled()
})
it.each([true, false])(
'restores SQLite backup authority with damaged database present=%s',
async (hasDatabase) => {
const profile = createProfile()
const backup = await createDatabaseBackup(profile)
getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath)
http1Marker.writeHttp1CompatibilityMarker(profile.userDataPath, false, 'profile-cli-recovery')
if (!hasDatabase) {
rmSync(profile.databaseFile)
rmSync(`${profile.databaseFile}-wal`)
rmSync(profile.dataFile)
}
await main(
['profile', 'state', 'rollback', '--backup', backup.id, '--json'],
profile.userDataPath
)
expect(process.exitCode).toBe(0)
expect(existsSync(profile.dataFile)).toBe(false)
expect(existsSync(backup.path)).toBe(true)
const restored = openProfileStateDatabaseReadOnly(
profile.databaseFile,
'profile-cli-recovery'
)
try {
expect(JSON.parse(exportProfileStateJson(restored.db))).toMatchObject({
settings: { theme: 'sqlite-recovered', httpProxyUrl: 'sealed:unchanged' },
extensionState: { retained: true }
})
} finally {
restored.db.close()
}
expect(
http1Marker.readHttp1CompatibilityMarker(profile.userDataPath, 'profile-cli-recovery')
).toBe(true)
expect(String(vi.mocked(console.log).mock.calls.at(-1)?.[0])).toContain('"storage": "sqlite"')
}
)
it('rejects a backup belonging to another profile before invalidating the startup marker', async () => {
const profile = createProfile()
const backup = await createDatabaseBackup(profile, 'foreign-profile')
getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath)
http1Marker.writeHttp1CompatibilityMarker(profile.userDataPath, true, 'profile-cli-recovery')
await main(
['profile', 'state', 'rollback', '--backup', backup.id, '--json'],
profile.userDataPath
)
expect(process.exitCode).toBe(1)
expect(readFileSync(profile.databaseFile, 'utf8')).toBe('damaged sqlite primary')
expect(
http1Marker.readHttp1CompatibilityMarker(profile.userDataPath, 'profile-cli-recovery')
).toBe(true)
})
it('requires an unambiguous retained backup selection', async () => {
const profile = createProfile()
const backup = await createDatabaseBackup(profile)
getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath)
await main(
['profile', 'state', 'rollback', '--backup', backup.id, '--revision', '1', '--json'],
profile.userDataPath
)
expect(process.exitCode).toBe(1)
expect(readFileSync(profile.databaseFile, 'utf8')).toBe('damaged sqlite primary')
expect(String(vi.mocked(console.log).mock.calls.at(-1)?.[0])).toContain('exactly one')
})
it('rejects escaping backup IDs without touching any recovery artifact', async () => {
const profile = createProfile()
getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath)
await main(
['profile', 'state', 'rollback', '--backup', '../../outside', '--json'],
profile.userDataPath
)
expect(process.exitCode).toBe(1)
expect(readFileSync(profile.databaseFile, 'utf8')).toBe('damaged sqlite primary')
expect(String(vi.mocked(console.log).mock.calls.at(-1)?.[0])).toContain('backup is unavailable')
})
it('refuses database backup restoration while the app is running', async () => {
const profile = createProfile()
const backup = await createDatabaseBackup(profile)
getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath)
getCliStatusMock.mockResolvedValueOnce({
result: { app: { running: true }, runtime: { reachable: false } }
})
await main(
['profile', 'state', 'rollback', '--backup', backup.id, '--json'],
profile.userDataPath
)
expect(process.exitCode).toBe(1)
expect(readFileSync(profile.databaseFile, 'utf8')).toBe('damaged sqlite primary')
expect(String(vi.mocked(console.log).mock.calls.at(-1)?.[0])).toContain('Stop Orca')
})
})
+153
View File
@@ -0,0 +1,153 @@
import type { CommandHandler } from '../dispatch'
import { printResult } from '../format'
import { rejectRemoteSelectionFlags } from '../remote-selection-flag-rejection'
import {
getDefaultUserDataPath,
RuntimeClientError,
type RuntimeClient,
type RuntimeRpcSuccess
} from '../runtime-client'
import {
getProfileStateExports,
rollbackProfileState
} from '../../main/persistence/profile-state/profile-state-recovery-command'
import { acquireProfileStateMaintenance } from '../../main/persistence/profile-state/profile-state-access'
import {
isProfileStateRecoveryCommandError,
type ProfileStateExportsResult,
type ProfileStateRollbackResult,
type ProfileStateRecoverySelector
} from '../../shared/profile-state-recovery-command'
import {
canLaunchProfileStateRecovery,
launchProfileStateRecovery
} from '../runtime/profile-state-recovery-launch'
function localSuccess<TResult>(result: TResult): RuntimeRpcSuccess<TResult> {
return {
id: 'local',
ok: true,
result,
_meta: { runtimeId: 'local' }
}
}
function formatExports(result: ProfileStateExportsResult): string {
return [
`profileId: ${result.profileId}`,
`dataFile: ${result.dataFile}`,
`databaseFile: ${result.databaseFile}`,
'JSON exports:',
...(result.exportPaths.length > 0 ? result.exportPaths : ['(none)']),
'SQLite backups:',
...(result.backups.length > 0
? result.backups.map((backup) => `${backup.id}: ${backup.path}`)
: ['(none)'])
].join('\n')
}
function formatRollback(result: ProfileStateRollbackResult): string {
return [
`profileId: ${result.profileId}`,
result.revision === null ? 'source: current JSON' : `revision: ${result.revision}`,
`storage: ${result.storage}`,
`restored: ${result.restoredPath}`,
`quarantine: ${result.quarantineDirectory}`,
`removedDatabaseFiles: ${result.removedDatabaseFiles.length}`
].join('\n')
}
function rejectProfileStateRemoteSelection(flags: ReadonlyMap<string, string | boolean>): void {
rejectRemoteSelectionFlags(
flags,
"profile-state recovery; it operates on this machine's active profile."
)
}
async function requireStoppedRuntime(client: RuntimeClient): Promise<void> {
const status = await client.getCliStatus()
if (status.result.runtime.reachable || status.result.app.running) {
throw new RuntimeClientError(
'runtime_error',
'Stop Orca before profile-state rollback so no process can write the SQLite database.'
)
}
}
function parseRevision(flags: Map<string, string | boolean>): number {
const rawRevision = flags.get('revision')
if (typeof rawRevision !== 'string' || rawRevision.length === 0) {
throw new RuntimeClientError('invalid_argument', 'Profile-state rollback requires --revision.')
}
const revision = Number(rawRevision)
if (!Number.isSafeInteger(revision) || revision < 1) {
throw new RuntimeClientError(
'invalid_argument',
`Invalid profile-state revision: ${rawRevision}`
)
}
return revision
}
export const PROFILE_STATE_HANDLERS: Record<string, CommandHandler> = {
'profile state exports': async ({ flags, json }) => {
rejectProfileStateRemoteSelection(flags)
const result = translateRecoveryError(() => getProfileStateExports(getDefaultUserDataPath()))
printResult(localSuccess(result), json, formatExports)
},
'profile state rollback': async ({ client, flags, json }) => {
rejectProfileStateRemoteSelection(flags)
const selector = parseSelector(flags)
const userDataPath = getDefaultUserDataPath()
let result: ProfileStateRollbackResult
if (canLaunchProfileStateRecovery()) {
await requireStoppedRuntime(client)
result = await launchProfileStateRecovery({ userDataPath, selector })
} else {
const maintenance = acquireProfileStateMaintenance(userDataPath)
try {
await requireStoppedRuntime(client)
result = translateRecoveryError(() =>
rollbackProfileState(userDataPath, selector, maintenance)
)
} finally {
maintenance.release()
}
}
printResult(localSuccess(result), json, formatRollback)
}
}
function parseSelector(flags: Map<string, string | boolean>): ProfileStateRecoverySelector {
if (['revision', 'backup', 'current-json'].filter((flag) => flags.has(flag)).length !== 1) {
throw new RuntimeClientError(
'invalid_argument',
'Select exactly one of --revision, --backup, or --current-json.'
)
}
if (flags.has('current-json')) {
if (flags.get('current-json') !== true) {
throw new RuntimeClientError('invalid_argument', '--current-json does not take a value.')
}
return { kind: 'current-json' }
}
if (!flags.has('backup')) {
return { kind: 'json', revision: parseRevision(flags) }
}
const backupId = flags.get('backup')
if (typeof backupId !== 'string' || backupId.length === 0) {
throw new RuntimeClientError('invalid_argument', 'Profile-state rollback requires --backup.')
}
return { kind: 'sqlite', backupId }
}
function translateRecoveryError<T>(operation: () => T): T {
try {
return operation()
} catch (error) {
if (isProfileStateRecoveryCommandError(error)) {
throw new RuntimeClientError(error.code, error.message)
}
throw error
}
}
+2 -1
View File
@@ -38,7 +38,8 @@ function shouldIgnoreRemoteSelection(commandPath: string[]): boolean {
commandPath[0] === 'serve' ||
commandPath[0] === 'agent' ||
commandPath[0] === 'vm' ||
commandPath[0] === 'agent-context'
commandPath[0] === 'agent-context' ||
commandPath[0] === 'profile'
)
}
-58
View File
@@ -1,58 +0,0 @@
import { readFileSync, readdirSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const REPO_ROOT = resolve(__dirname, '..', '..')
const CLI_ROOT = join(REPO_ROOT, 'src', 'cli')
function listCliSourceFiles(dir: string): string[] {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const path = join(dir, entry.name)
if (entry.isDirectory()) {
return listCliSourceFiles(path)
}
return entry.isFile() && entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts')
? [path]
: []
})
}
// 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
function findMainImports(): { file: string; module: string }[] {
return listCliSourceFiles(CLI_ROOT).flatMap((file) => {
const source = readFileSync(file, 'utf-8')
return [...source.matchAll(VALUE_IMPORT_FROM_MAIN)].map((match) => ({
file: file.slice(REPO_ROOT.length + 1),
module: match[1]
}))
})
}
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])
)
}
describe('CLI imports of main-process modules', () => {
// Why: electron-vite cleans out/main and emits only its declared entries, so a
// `src/main/*` module the CLI imports but the config omits is deleted by the
// build that runs after `build:cli` — keep source-level feedback ahead of the
// 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))
expect(missing).toEqual([])
})
it('finds the imports it is meant to guard', () => {
// Why: a broken matcher would make the guard above vacuously pass.
expect(findMainImports().length).toBeGreaterThanOrEqual(2)
expect(findElectronViteMainEntries().size).toBeGreaterThanOrEqual(2)
})
})
+13
View File
@@ -0,0 +1,13 @@
import { getActiveProfileStateLocation as resolveActiveProfileStateLocation } from '../main/persistence/profile-state/profile-state-active-location'
import { RuntimeClientError, getDefaultUserDataPath } from './runtime-client'
export function getActiveProfileStateLocation(userDataPath = getDefaultUserDataPath()) {
try {
return resolveActiveProfileStateLocation(userDataPath)
} catch (error) {
throw new RuntimeClientError(
'runtime_error',
error instanceof Error ? error.message : String(error)
)
}
}
+3 -1
View File
@@ -153,7 +153,9 @@ describe('RuntimeClient module-graph deferral', () => {
async (_name, argv, constructs) => {
vi.stubEnv('ORCA_PAIRING_CODE', 'pairing-code')
vi.stubEnv('ORCA_ENVIRONMENT', 'some-environment')
getCliStatusMock.mockResolvedValue({ result: { runtime: { reachable: false } } })
getCliStatusMock.mockResolvedValue({
result: { runtime: { reachable: false }, app: { running: false } }
})
await main(argv, '/tmp/repo')
+3 -3
View File
@@ -255,7 +255,7 @@ function waitForRecipeJson(child: ReturnType<typeof spawnProcess>): Promise<numb
})
}
function getExecutableAppArgs(executable: string): string[] {
export function getExecutableAppArgs(executable: string): string[] {
const args = process.env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT === '1' ? [resolveAppRoot()] : []
if (shouldDisableExtractedAppImageSandbox(executable)) {
args.push('--no-sandbox')
@@ -289,14 +289,14 @@ function getExecutableSpawnOptions(executable: string): Pick<SpawnOptions, 'shel
return process.platform === 'win32' && /\.(?:cmd|bat)$/i.test(executable) ? { shell: true } : {}
}
function resolveAppRoot(): string {
export function resolveAppRoot(): string {
// Why: dev-mode resource resolution in the Electron child may consult
// process.cwd(). Pin it to the app root so `orca serve` behaves the same
// regardless of the shell directory it was launched from.
return resolve(__dirname, '../../..')
}
function resolveForegroundOrcaExecutable(): string {
export function resolveForegroundOrcaExecutable(): string {
const overrideExecutable = process.env.ORCA_APP_EXECUTABLE
if (typeof overrideExecutable === 'string' && overrideExecutable.trim().length > 0) {
return overrideExecutable
@@ -0,0 +1,154 @@
import { realpathSync } from 'node:fs'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
PROFILE_STATE_RECOVERY_FLAG,
PROFILE_STATE_RECOVERY_RESULT_PREFIX
} from '../../shared/profile-state-recovery-command'
import {
canLaunchProfileStateRecovery,
launchProfileStateRecovery
} from './profile-state-recovery-launch'
const mocks = vi.hoisted(() => ({ run: vi.fn() }))
vi.mock('../../shared/child-process/run-process', () => ({ runProcess: mocks.run }))
vi.mock('./launch', () => ({
resolveForegroundOrcaExecutable: () => '/packaged/Orca',
resolveAppRoot: () => '/application',
getExecutableAppArgs: () => ['/application'],
stripElectronRunAsNode: (env: NodeJS.ProcessEnv) => {
const clean = { ...env }
delete clean.ELECTRON_RUN_AS_NODE
return clean
}
}))
const result = {
profileId: 'profile',
dataFile: '/root/orca-data.json',
databaseFile: '/root/profile-state.db',
exportPaths: [],
backups: [],
revision: 1,
quarantineDirectory: '/root/quarantine',
removedDatabaseFiles: [],
storage: 'json',
restoredPath: '/root/orca-data.json'
}
const request = { userDataPath: '.', selector: { kind: 'json', revision: 1 } } as const
beforeEach(() => {
mocks.run.mockReset().mockResolvedValue({
code: 0,
signal: null,
timedOut: false,
stdout: `${PROFILE_STATE_RECOVERY_RESULT_PREFIX}${JSON.stringify({ ok: true, result })}\n`,
stderr: ''
})
})
afterEach(() => vi.unstubAllEnvs())
describe('profile-state recovery launch', () => {
it('preserves direct participation only for plain Node without an explicit Electron executable', () => {
vi.stubEnv('ELECTRON_RUN_AS_NODE', undefined)
vi.stubEnv('ORCA_APP_EXECUTABLE', undefined)
expect(canLaunchProfileStateRecovery()).toBe(false)
vi.stubEnv('ELECTRON_RUN_AS_NODE', '1')
expect(canLaunchProfileStateRecovery()).toBe(true)
vi.stubEnv('ELECTRON_RUN_AS_NODE', undefined)
vi.stubEnv('ORCA_APP_EXECUTABLE', '/explicit/Orca')
expect(canLaunchProfileStateRecovery()).toBe(true)
})
it('uses a foreground-safe serve request and binds the canonical recovery root', async () => {
vi.stubEnv('ELECTRON_RUN_AS_NODE', '1')
vi.stubEnv('ORCA_USER_DATA_PATH', '/stale/root')
expect(await launchProfileStateRecovery(request)).toEqual(result)
expect(mocks.run).toHaveBeenCalledWith(
expect.objectContaining({
program: '/packaged/Orca',
args: [
'/application',
'--serve',
PROFILE_STATE_RECOVERY_FLAG,
JSON.stringify({ ...request, userDataPath: realpathSync('.') })
],
env: expect.objectContaining({
ORCA_BACKGROUND_LAUNCH: '1',
ORCA_USER_DATA_PATH: realpathSync('.')
}),
timeoutMs: null
})
)
expect(mocks.run.mock.calls[0][0].env).not.toHaveProperty('ELECTRON_RUN_AS_NODE')
})
it('round-trips current JSON selection without requiring an invented revision', async () => {
const current = { ...result, revision: null }
mocks.run.mockResolvedValue({
code: 0,
signal: null,
timedOut: false,
stdout: `${PROFILE_STATE_RECOVERY_RESULT_PREFIX}${JSON.stringify({ ok: true, result: current })}\n`,
stderr: ''
})
expect(
await launchProfileStateRecovery({ userDataPath: '.', selector: { kind: 'current-json' } })
).toEqual(current)
expect(mocks.run.mock.calls[0][0].args.at(-1)).toContain('"kind":"current-json"')
})
it('preserves a structured refusal from the lock owner', async () => {
mocks.run.mockResolvedValue({
code: 1,
stdout: `${PROFILE_STATE_RECOVERY_RESULT_PREFIX}${JSON.stringify({ ok: false, code: 'invalid_argument', message: 'Backup unavailable' })}`
})
await expect(launchProfileStateRecovery(request)).rejects.toMatchObject({
code: 'invalid_argument',
message: 'Backup unavailable'
})
})
it.each([
{ code: 1 },
{ signal: 'SIGKILL' },
{ timedOut: true },
{ outputTruncated: true },
{ stdout: '' },
{ stdout: `${PROFILE_STATE_RECOVERY_RESULT_PREFIX}{` },
{ stdout: `${PROFILE_STATE_RECOVERY_RESULT_PREFIX}{"ok":true,"result":{}}` },
{
stdout: `${PROFILE_STATE_RECOVERY_RESULT_PREFIX}{}\n${PROFILE_STATE_RECOVERY_RESULT_PREFIX}{}`
}
])('rejects incomplete or ambiguous child results %j', async (override) => {
const original = await mocks.run()
mocks.run.mockResolvedValue({ ...original, ...override })
await expect(launchProfileStateRecovery(request)).rejects.toMatchObject({
code: 'runtime_error'
})
})
it('propagates launch failure without retrying another recovery path', async () => {
mocks.run.mockRejectedValue(new Error('Executable unavailable'))
await expect(launchProfileStateRecovery(request)).rejects.toThrow('Executable unavailable')
expect(mocks.run).toHaveBeenCalledOnce()
})
it('retains bounded child diagnostics when recovery exits without a result', async () => {
mocks.run.mockResolvedValue({
code: null,
signal: 'SIGTRAP',
timedOut: false,
stdout: '',
stderr: `${'x'.repeat(5000)}\nsandbox unavailable\n`
})
await expect(launchProfileStateRecovery(request)).rejects.toMatchObject({
data: {
exitCode: null,
signal: 'SIGTRAP',
timedOut: false,
outputTruncated: false,
stderr: `${'x'.repeat(5000)}\nsandbox unavailable`.slice(-4096)
}
})
expect(mocks.run).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,78 @@
import { realpathSync } from 'node:fs'
import { runProcess } from '../../shared/child-process/run-process'
import {
PROFILE_STATE_RECOVERY_FLAG,
PROFILE_STATE_RECOVERY_RESULT_PREFIX,
profileStateRecoveryResponseSchema,
type ProfileStateRecoveryRequest,
type ProfileStateRollbackResult
} from '../../shared/profile-state-recovery-command'
import {
getExecutableAppArgs,
resolveAppRoot,
resolveForegroundOrcaExecutable,
stripElectronRunAsNode
} from './launch'
import { RuntimeClientError } from './types'
export function canLaunchProfileStateRecovery(): boolean {
return process.env.ELECTRON_RUN_AS_NODE === '1' || !!process.env.ORCA_APP_EXECUTABLE?.trim()
}
export async function launchProfileStateRecovery(
request: ProfileStateRecoveryRequest
): Promise<ProfileStateRollbackResult> {
const executable = resolveForegroundOrcaExecutable()
const userDataPath = realpathSync(request.userDataPath)
const response = await runProcess({
program: executable,
args: [
...getExecutableAppArgs(executable),
'--serve',
PROFILE_STATE_RECOVERY_FLAG,
JSON.stringify({ ...request, userDataPath })
],
cwd: resolveAppRoot(),
env: {
...stripElectronRunAsNode(process.env),
ORCA_BACKGROUND_LAUNCH: '1',
ORCA_USER_DATA_PATH: userDataPath
},
// Recovery may copy large backups; the lock owner must finish or be explicitly terminated.
timeoutMs: null
})
const lines = response.stdout
.split(/\r?\n/)
.filter((line) => line.startsWith(PROFILE_STATE_RECOVERY_RESULT_PREFIX))
if (!response.outputTruncated && lines.length === 1) {
let parsed: unknown
try {
parsed = JSON.parse(lines[0].slice(PROFILE_STATE_RECOVERY_RESULT_PREFIX.length))
} catch {
throw new RuntimeClientError(
'runtime_error',
'Orca recovery returned an invalid response. Inspect retained recovery artifacts before retrying.'
)
}
const result = profileStateRecoveryResponseSchema.safeParse(parsed)
if (result.success) {
if (!result.data.ok) {
throw new RuntimeClientError(result.data.code, result.data.message)
}
if (response.code === 0 && !response.signal && !response.timedOut) {
return result.data.result
}
}
}
throw new RuntimeClientError(
'runtime_error',
'Orca recovery did not complete successfully. Inspect retained recovery artifacts before retrying.',
{
exitCode: response.code,
signal: response.signal,
timedOut: response.timedOut,
outputTruncated: response.outputTruncated ?? false,
stderr: response.stderr.trim().slice(-4096)
}
)
}
+3 -1
View File
@@ -18,6 +18,7 @@ import { VM_COMMAND_SPECS } from './vm'
import { SKILL_COMMAND_SPECS } from './skills'
import { ARTIFACT_COMMAND_SPECS } from './artifacts'
import { SEARCH_COMMAND_SPECS } from './search'
import { PROFILE_STATE_COMMAND_SPECS } from './profile-state'
export const COMMAND_SPECS: CommandSpec[] = [
...CORE_COMMAND_SPECS,
@@ -38,5 +39,6 @@ export const COMMAND_SPECS: CommandSpec[] = [
...VM_COMMAND_SPECS,
...EMULATOR_COMMAND_SPECS,
...SKILL_COMMAND_SPECS,
...SEARCH_COMMAND_SPECS
...SEARCH_COMMAND_SPECS,
...PROFILE_STATE_COMMAND_SPECS
]
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { parseArgs, validateCommandAndFlags } from '../args'
import { PROFILE_STATE_COMMAND_SPECS } from './profile-state'
describe('profile state rollback discovery', () => {
it.each([
{ argv: ['profile', 'state', 'rollback', '--current-json'] },
{ argv: ['--current-json', 'profile', 'state', 'rollback'] },
{ argv: ['profile', '--current-json', 'state', 'rollback'] }
])('parses the current JSON selector as a boolean: $argv', ({ argv }) => {
const parsed = parseArgs(argv)
expect(parsed.commandPath).toEqual(['profile', 'state', 'rollback'])
expect(parsed.flags.get('current-json')).toBe(true)
expect(() => validateCommandAndFlags(PROFILE_STATE_COMMAND_SPECS, parsed)).not.toThrow()
})
it('explains that adoption selects one full state and preserves both copies', () => {
const spec = PROFILE_STATE_COMMAND_SPECS.find((item) => item.path.at(-1) === 'rollback')
expect(spec?.usage).toContain('--current-json')
expect(spec?.notes?.join('\n')).toContain('without merging; both copies are archived')
expect(spec?.examples).toContain('orca profile state rollback --current-json')
})
})
+30
View File
@@ -0,0 +1,30 @@
import type { CommandSpec } from '../args'
import { GLOBAL_FLAGS } from '../args'
export const PROFILE_STATE_COMMAND_SPECS: CommandSpec[] = [
{
path: ['profile', 'state', 'exports'],
summary: 'List retained SQLite backups and JSON exports for profile-state recovery',
usage: 'orca profile state exports [--json]',
allowedFlags: [...GLOBAL_FLAGS]
},
{
path: ['profile', 'state', 'rollback'],
destructive: true,
summary: 'Restore a SQLite backup, retained JSON export, or current JSON profile',
usage:
'orca profile state rollback (--backup <id> | --revision <revision> | --current-json) [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'revision', 'backup', 'current-json'],
notes: [
'Orca must be stopped. Recovery validates the selected artifact and archives the current database family, JSON, and retained recovery artifacts before replacing state.',
'--backup restores SQLite authority; --revision restores a JSON export for an older compatible runtime.',
'--current-json keeps the current orca-data.json, including edits from an older build. It replaces SQLite state without merging; both copies are archived. The next SQLite-capable start imports the selected JSON.'
],
examples: [
'orca profile state exports',
'orca profile state rollback --backup <id>',
'orca profile state rollback --revision 1',
'orca profile state rollback --current-json'
]
}
]