mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 00:03:15 +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:
@@ -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'
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
]
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user