feat: let users choose JSON or SQLite when profile copies diverge (#23278)

Replace the copy-a-command startup dialog for diverged JSON/SQLite profile
state with Use SQLite / Use JSON buttons. The choice relaunches Orca into the
locked recovery preflight, applies it, then starts normally. Adds a
current-sqlite recovery selector (and --current-sqlite CLI flag) that archives
the diverged JSON and republishes it from SQLite.

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
This commit is contained in:
OrcaWin
2026-09-26 17:03:05 -07:00
committed by GitHub
co-authored by m4air
parent ee6281ff79
commit 47ddfbdc0d
16 changed files with 586 additions and 45 deletions
+35 -1
View File
@@ -178,10 +178,44 @@ describe('profile-state CLI recovery', () => {
expect(output).not.toContain('revision:')
})
it('keeps current SQLite through CLI and rewrites the diverged JSON', async () => {
const profile = createProfile()
getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath)
rmSync(profile.databaseFile)
rmSync(`${profile.databaseFile}-wal`)
rmSync(profile.exportPath)
const source = openProfileStateDatabase(profile.databaseFile, 'profile-cli-recovery')
try {
importProfileStateJson(source.db, JSON.stringify({ settings: { theme: 'sqlite' } }))
} finally {
source.db.close()
}
writeFileSync(profile.dataFile, JSON.stringify({ settings: { theme: 'older-build' } }))
await main(['profile', 'state', 'rollback', '--current-sqlite'], profile.userDataPath)
expect(JSON.parse(readFileSync(profile.dataFile, 'utf8'))).toEqual({
settings: { theme: 'sqlite' }
})
expect(String(vi.mocked(console.log).mock.calls.at(-1)?.[0])).toContain(
'source: current SQLite'
)
})
it('refuses to keep an unreadable SQLite and leaves JSON untouched', async () => {
const profile = createProfile()
getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath)
const json = readFileSync(profile.dataFile)
await main(['profile', 'state', 'rollback', '--current-sqlite', '--json'], profile.userDataPath)
expect(process.exitCode).toBe(1)
expect(readFileSync(profile.dataFile)).toEqual(json)
expect(readFileSync(profile.databaseFile, 'utf8')).toBe('damaged sqlite primary')
})
it.each([
['--current-json', '--revision', '1'],
['--current-json', '--backup', '1'],
['--current-json=false']
['--current-json', '--current-sqlite'],
['--current-json=false'],
['--current-sqlite=false']
])('rejects ambiguous current JSON arguments: %s', async (...flags) => {
getCliStatusMock.mockClear()
const profile = createProfile()
+12 -6
View File
@@ -50,6 +50,9 @@ function formatRollback(result: ProfileStateRollbackResult): string {
return [
`profileId: ${result.profileId}`,
result.revision === null ? 'source: current JSON' : `revision: ${result.revision}`,
...(result.storage === 'sqlite' && result.backupId === undefined
? ['source: current SQLite']
: []),
`storage: ${result.storage}`,
`restored: ${result.restoredPath}`,
`quarantine: ${result.quarantineDirectory}`,
@@ -119,17 +122,20 @@ export const PROFILE_STATE_HANDLERS: Record<string, CommandHandler> = {
}
function parseSelector(flags: Map<string, string | boolean>): ProfileStateRecoverySelector {
if (['revision', 'backup', 'current-json'].filter((flag) => flags.has(flag)).length !== 1) {
const selectors = ['revision', 'backup', 'current-json', 'current-sqlite'] as const
if (selectors.filter((flag) => flags.has(flag)).length !== 1) {
throw new RuntimeClientError(
'invalid_argument',
'Select exactly one of --revision, --backup, or --current-json.'
'Select exactly one of --revision, --backup, --current-json, or --current-sqlite.'
)
}
if (flags.has('current-json')) {
if (flags.get('current-json') !== true) {
throw new RuntimeClientError('invalid_argument', '--current-json does not take a value.')
for (const kind of ['current-json', 'current-sqlite'] as const) {
if (flags.has(kind)) {
if (flags.get(kind) !== true) {
throw new RuntimeClientError('invalid_argument', `--${kind} does not take a value.`)
}
return { kind }
}
return { kind: 'current-json' }
}
if (!flags.has('backup')) {
return { kind: 'json', revision: parseRevision(flags) }
+6
View File
@@ -14,6 +14,12 @@ describe('profile state rollback discovery', () => {
expect(() => validateCommandAndFlags(PROFILE_STATE_COMMAND_SPECS, parsed)).not.toThrow()
})
it('parses the current SQLite selector as a boolean', () => {
const parsed = parseArgs(['profile', 'state', 'rollback', '--current-sqlite'])
expect(parsed.flags.get('current-sqlite')).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')
+8 -5
View File
@@ -11,20 +11,23 @@ export const PROFILE_STATE_COMMAND_SPECS: CommandSpec[] = [
{
path: ['profile', 'state', 'rollback'],
destructive: true,
summary: 'Restore a SQLite backup, retained JSON export, or current JSON profile',
summary:
'Restore a SQLite backup or retained JSON export, or keep the current JSON or SQLite profile',
usage:
'orca profile state rollback (--backup <id> | --revision <revision> | --current-json) [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'revision', 'backup', 'current-json'],
'orca profile state rollback (--backup <id> | --revision <revision> | --current-json | --current-sqlite) [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'revision', 'backup', 'current-json', 'current-sqlite'],
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.'
'--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.',
'--current-sqlite keeps the current SQLite state and discards JSON edits from an older build. The JSON is archived, then rewritten from SQLite.'
],
examples: [
'orca profile state exports',
'orca profile state rollback --backup <id>',
'orca profile state rollback --revision 1',
'orca profile state rollback --current-json'
'orca profile state rollback --current-json',
'orca profile state rollback --current-sqlite'
]
}
]
+25 -1
View File
@@ -16,10 +16,16 @@ import { shouldActivateDesktopForSecondInstance } from './startup/single-instanc
import { resolveOpenedMarkdownDocuments } from './startup/os-opened-markdown-files'
import {
formatProfileStateStartupFailure,
isDivergedProfileStateFailure,
profileStateStartupFailureClass
} from './persistence/profile-state/profile-state-startup-failure'
import { recordDurableCrashBreadcrumb } from './crash-reporting/durable-crash-breadcrumb'
import { presentProfileStateStartupRecoveryDialog } from './persistence/profile-state/profile-state-startup-recovery-dialog'
import {
chooseProfileStateCopy,
presentProfileStateStartupRecoveryDialog,
readProfileStateCopySavedTimes
} from './persistence/profile-state/profile-state-startup-recovery-dialog'
import { profileStateDesktopRecoveryArgs } from './startup/profile-state-recovery-preflight'
function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): BrowserWindow {
return openMainWindowController(options)
@@ -132,6 +138,24 @@ if (preflightReady) {
console.error(`[profile-state] ${message}`)
if (!state.isServeMode && !isBackgroundLaunch()) {
try {
if (isDivergedProfileStateFailure(error)) {
const userDataPath = app.getPath('userData')
const choice = await chooseProfileStateCopy({
...readProfileStateCopySavedTimes(userDataPath),
showMessageBox: (options) => dialog.showMessageBox(options)
})
if (choice !== undefined) {
// Recovery needs both profile locks, which only a fresh process can own safely.
app.relaunch({
args: profileStateDesktopRecoveryArgs(process.argv, {
userDataPath,
selector: { kind: choice }
})
})
}
app.exit(1)
return
}
await presentProfileStateStartupRecoveryDialog({
message,
...(failureClass === 'recovery-required' || failureClass === 'ambiguous-authority'
@@ -84,7 +84,8 @@ export function bootstrapProfileStateAuthority(
: authority.readInitialState()
if (initialState === undefined) {
throw new ProfileStateAuthorityBootstrapError(
'Profile state has both JSON and SQLite storage without a matching acceptance marker'
'Profile state has both JSON and SQLite storage without a matching acceptance marker',
'diverged-json'
)
}
return { classification, authority, initialState, migrated: false }
@@ -0,0 +1,147 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, 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 {
acquireProfileStateMaintenance,
acquireProfileStateRuntimeAdmission
} from './profile-state-access'
import { rollbackProfileState } from './profile-state-recovery-command'
import {
bootstrapProfileStateAuthority,
ProfileStateAuthorityBootstrapError
} from './profile-state-authority-bootstrap'
import { migrateProfileStateToSqlite } from './profile-state-migration'
import { profileStateJsonExportPath } from './legacy-json/profile-state-export-path'
import { isDivergedProfileStateFailure } from './profile-state-startup-failure'
const roots: string[] = []
const profileId = 'current-sqlite-recovery'
const sqliteState = { settings: { theme: 'dark', electronHttp1CompatibilityMode: true } }
const editedJson = JSON.stringify({ settings: { theme: 'light' } })
beforeEach(() => {
vi.spyOn(console, 'log').mockImplementation(() => {})
})
afterEach(() => {
vi.restoreAllMocks()
for (const root of roots.splice(0)) {
rmSync(root, { recursive: true, force: true })
}
})
function fixture() {
const root = mkdtempSync(join(tmpdir(), 'orca-current-sqlite-'))
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 dataFile = join(directory, 'orca-data.json')
const databaseFile = join(directory, 'profile-state.db')
const originalJson = JSON.stringify(sqliteState)
writeFileSync(dataFile, originalJson)
migrateProfileStateToSqlite({
dataFile,
databaseFile,
profileId,
expectedLegacyJson: originalJson,
serializedState: originalJson
}).authority.close()
writeFileSync(dataFile, editedJson)
return { root, dataFile, databaseFile, profileId }
}
function rollback(profile: ReturnType<typeof fixture>) {
const maintenance = acquireProfileStateMaintenance(profile.root)
try {
return rollbackProfileState(profile.root, { kind: 'current-sqlite' }, maintenance)
} finally {
maintenance.release()
}
}
describe('keeping SQLite over JSON edited by an older build', () => {
it('tags the startup failure as a user-resolvable divergence', () => {
const profile = fixture()
let failure: unknown
try {
bootstrapProfileStateAuthority(profile)
} catch (error) {
failure = error
}
expect(failure).toBeInstanceOf(ProfileStateAuthorityBootstrapError)
expect(isDivergedProfileStateFailure(failure)).toBe(true)
expect(isDivergedProfileStateFailure(new ProfileStateAuthorityBootstrapError('other'))).toBe(
false
)
})
it('archives the diverged JSON and republishes JSON that SQLite accepts', () => {
const profile = fixture()
const databaseBefore = readFileSync(profile.databaseFile)
const result = rollback(profile)
expect(result).toMatchObject({
storage: 'sqlite',
restoredPath: profile.databaseFile,
revision: 1,
removedDatabaseFiles: []
})
expect(result.backupId).toBeUndefined()
expect(readFileSync(join(result.quarantineDirectory, 'orca-data.json'), 'utf8')).toBe(
editedJson
)
expect(readFileSync(join(result.quarantineDirectory, 'profile-state.db'))).toEqual(
databaseBefore
)
expect(JSON.parse(readFileSync(profile.dataFile, 'utf8'))).toEqual(sqliteState)
const reopened = bootstrapProfileStateAuthority(profile)
try {
expect(reopened.migrated).toBe(false)
expect(JSON.parse(reopened.authority?.readSerializedState() ?? 'null')).toEqual(sqliteState)
} finally {
reopened.authority?.close()
}
acquireProfileStateRuntimeAdmission(profile.root).release()
})
it('replaces JSON that an older build left unparseable', () => {
const profile = fixture()
writeFileSync(profile.dataFile, 'not json')
const result = rollback(profile)
expect(readFileSync(join(result.quarantineDirectory, 'orca-data.json'), 'utf8')).toBe(
'not json'
)
expect(JSON.parse(readFileSync(profile.dataFile, 'utf8'))).toEqual(sqliteState)
})
it('leaves both copies untouched when SQLite is unreadable', () => {
const profile = fixture()
writeFileSync(profile.databaseFile, 'broken database')
expect(() => rollback(profile)).toThrow()
expect(readFileSync(profile.databaseFile, 'utf8')).toBe('broken database')
expect(readFileSync(profile.dataFile, 'utf8')).toBe(editedJson)
})
it('restores the diverged JSON when republishing fails', () => {
const profile = fixture()
writeFileSync(profileStateJsonExportPath(profile.dataFile, 1), '{"conflicting":true}')
expect(() => rollback(profile)).toThrow('already exists with different content')
expect(readFileSync(profile.dataFile, 'utf8')).toBe(editedJson)
})
it('refuses while a profile owner holds admission', () => {
const profile = fixture()
const admission = acquireProfileStateRuntimeAdmission(profile.root)
try {
expect(() => rollback(profile)).toThrow('in use')
expect(readFileSync(profile.dataFile, 'utf8')).toBe(editedJson)
expect(existsSync(profile.databaseFile)).toBe(true)
} finally {
admission.release()
}
})
})
@@ -1,4 +1,4 @@
import { readFileSync } from 'node:fs'
import { existsSync, readFileSync, rmSync } from 'node:fs'
import {
ProfileStateRecoveryCommandError,
type ProfileStateRecoverySelector,
@@ -13,6 +13,9 @@ import {
import { profileStateDatabaseBackups } from './profile-state-backup-path'
import { restoreProfileStateJsonExport } from './legacy-json/profile-state-recovery'
import { restoreProfileStateDatabaseBackup } from './profile-state-database-recovery'
import { quarantineProfileStateDatabase } from './profile-state-database-quarantine'
import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority'
import { durableWriteTempPath, writeFileDurableSync } from '../../durable-file-write'
import type { ProfileStateMaintenance } from './profile-state-access'
import { readProfileStateDomain } from './profile-state-domain-reader'
import { isRecord } from './profile-state-document-validation'
@@ -54,6 +57,9 @@ export function rollbackProfileState(
if (selector.kind === 'sqlite') {
return restoreDatabaseBackup(userDataPath, result, selector.backupId, maintenance)
}
if (selector.kind === 'current-sqlite') {
return adoptCurrentDatabase(userDataPath, result, maintenance)
}
const revision = selector.kind === 'json' ? selector.revision : null
const exportPath =
revision === null ? result.dataFile : profileStateJsonExportPath(result.dataFile, revision)
@@ -104,14 +110,7 @@ function restoreDatabaseBackup(
profileId: result.profileId,
beforeRestore: () => invalidateHttp1CompatibilityMarker(userDataPath)
})
const settings = readProfileStateDomain(result.databaseFile, result.profileId, 'settings')
if (settings.kind !== 'unreadable') {
const enabled =
settings.kind === 'value' &&
isRecord(settings.value) &&
settings.value.electronHttp1CompatibilityMode === true
writeHttp1CompatibilityMarker(userDataPath, enabled, result.profileId)
}
syncHttp1CompatibilityMarkerFromDatabase(userDataPath, result)
return {
...result,
storage: 'sqlite',
@@ -123,6 +122,71 @@ function restoreDatabaseBackup(
}
}
/** Keep SQLite and replace diverged JSON (e.g. edited by an older build) with its export. */
function adoptCurrentDatabase(
userDataPath: string,
result: ProfileStateExportsResult,
maintenance: ProfileStateMaintenance
): ProfileStateRollbackResult {
maintenance.assertProfile(result.profileId, result.dataFile, result.databaseFile)
const authority = new ProfileStateSqliteAuthority(result.databaseFile, result.profileId)
try {
// Validate before archiving so an unreadable database leaves both copies untouched.
authority.readInitialState()
if (authority.revision === 0) {
throw new ProfileStateRecoveryCommandError(
'runtime_error',
'SQLite profile state is empty. Keep the current JSON instead.'
)
}
const quarantine = quarantineProfileStateDatabase(
result.databaseFile,
result.profileId,
undefined,
'profile-state-adopt-current-sqlite',
existsSync(result.dataFile) ? [result.dataFile] : []
)
invalidateHttp1CompatibilityMarker(userDataPath)
// A retained JSON the marker never accepted would fail the compatibility export's fence.
const divergedJson = existsSync(result.dataFile) ? readFileSync(result.dataFile) : undefined
rmSync(result.dataFile, { force: true })
let revision: number
try {
revision = authority.writeJsonCompatibilityExport(result.dataFile) ?? authority.revision
} catch (error) {
if (divergedJson !== undefined && !existsSync(result.dataFile)) {
writeFileDurableSync(durableWriteTempPath(result.dataFile), result.dataFile, divergedJson)
}
throw error
}
syncHttp1CompatibilityMarkerFromDatabase(userDataPath, result)
return {
...result,
storage: 'sqlite',
restoredPath: result.databaseFile,
revision,
quarantineDirectory: quarantine.directory,
removedDatabaseFiles: []
}
} finally {
authority.close()
}
}
function syncHttp1CompatibilityMarkerFromDatabase(
userDataPath: string,
result: ProfileStateExportsResult
): void {
const settings = readProfileStateDomain(result.databaseFile, result.profileId, 'settings')
if (settings.kind !== 'unreadable') {
const enabled =
settings.kind === 'value' &&
isRecord(settings.value) &&
settings.value.electronHttp1CompatibilityMode === true
writeHttp1CompatibilityMarker(userDataPath, enabled, result.profileId)
}
}
function syncHttp1CompatibilityMarkerAfterRollback(
userDataPath: string,
dataFile: string,
@@ -12,7 +12,11 @@ type ProfileStateRecoveryLocation = {
export class ProfileStateAuthorityBootstrapError extends Error {
readonly code = 'ambiguous-profile-state' as const
constructor(message: string) {
/** `diverged-json`: both copies are readable, so the user can pick one at startup. */
constructor(
message: string,
readonly divergence?: 'diverged-json'
) {
super(message)
this.name = 'ProfileStateAuthorityBootstrapError'
}
@@ -9,6 +9,7 @@ type ProfileStateRecoveryFailure = {
type ProfileStateAuthorityFailure = {
code: 'ambiguous-profile-state'
message: string
divergence?: unknown
}
export type ProfileStateStartupFailureClass =
@@ -46,6 +47,11 @@ export function profileStateStartupFailureClass(
return undefined
}
/** Both copies are readable and differ only because JSON changed outside SQLite. */
export function isDivergedProfileStateFailure(error: unknown): boolean {
return isProfileStateAuthorityFailure(error) && error.divergence === 'diverged-json'
}
/** Format profile-state startup failures without exposing a generic fatal-error path. */
export function formatProfileStateStartupFailure(error: unknown): string | undefined {
if (isProfileStateRecoveryFailure(error)) {
@@ -1,5 +1,8 @@
import { describe, expect, it, vi } from 'vitest'
import { presentProfileStateStartupRecoveryDialog } from './profile-state-startup-recovery-dialog'
import {
chooseProfileStateCopy,
presentProfileStateStartupRecoveryDialog
} from './profile-state-startup-recovery-dialog'
describe('profile state startup recovery dialog', () => {
it('offers a copyable offline export command and does not mutate state', async () => {
@@ -61,4 +64,36 @@ describe('profile state startup recovery dialog', () => {
)
expect(copyToClipboard).not.toHaveBeenCalled()
})
it.each([
[0, 'current-sqlite'],
[1, 'current-json'],
[2, undefined]
] as const)('maps choice button %i to %s', async (response, expected) => {
const showMessageBox = vi.fn().mockResolvedValue({ response })
await expect(
chooseProfileStateCopy({
sqliteSavedAt: new Date(1),
jsonSavedAt: new Date(2),
formatTime: (time) => `t${time.getTime()}`,
showMessageBox
})
).resolves.toBe(expected)
expect(showMessageBox).toHaveBeenCalledWith(
expect.objectContaining({
buttons: ['Use SQLite (Recommended)', 'Use JSON', 'Quit'],
defaultId: 0,
cancelId: 2
})
)
const { detail } = showMessageBox.mock.calls[0][0]
expect(detail).toContain('Last saved t1.')
expect(detail).toContain('Last saved t2.')
})
it('omits save times it could not read', async () => {
const showMessageBox = vi.fn().mockResolvedValue({ response: 2 })
await chooseProfileStateCopy({ showMessageBox })
expect(showMessageBox.mock.calls[0][0].detail).not.toContain('Last saved')
})
})
@@ -1,4 +1,7 @@
import { statSync } from 'node:fs'
import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron'
import { getActiveProfileStateLocation } from './profile-state-active-location'
import { profileStateDatabaseFiles } from './profile-state-storage-classification'
export type ProfileStateStartupRecoveryDialogDeps = {
message: string
@@ -28,3 +31,70 @@ export async function presentProfileStateStartupRecoveryDialog(
deps.copyToClipboard(deps.recoveryCommand)
}
}
export type ProfileStateCopyChoice = 'current-sqlite' | 'current-json'
export type ProfileStateCopyChoiceDialogDeps = {
sqliteSavedAt?: Date
jsonSavedAt?: Date
formatTime?: (time: Date) => string
showMessageBox: (options: MessageBoxOptions) => Promise<MessageBoxReturnValue>
}
/** Best-effort save times; SQLite's latest commit may live only in its WAL, and -shm changes on every open. */
export function readProfileStateCopySavedTimes(userDataPath: string): {
sqliteSavedAt?: Date
jsonSavedAt?: Date
} {
try {
const location = getActiveProfileStateLocation(userDataPath)
if (location === undefined) {
return {}
}
const sqliteTimes = profileStateDatabaseFiles(location.databaseFile)
.filter((path) => !path.endsWith('-shm'))
.map(modifiedAt)
.filter((time) => time !== undefined)
const sqliteSavedAt =
sqliteTimes.length === 0 ? undefined : new Date(Math.max(...sqliteTimes.map(Number)))
const jsonSavedAt = modifiedAt(location.dataFile)
return {
...(sqliteSavedAt === undefined ? {} : { sqliteSavedAt }),
...(jsonSavedAt === undefined ? {} : { jsonSavedAt })
}
} catch {
return {}
}
}
function modifiedAt(path: string): Date | undefined {
return statSync(path, { throwIfNoEntry: false })?.mtime
}
/** Ask which diverged copy to keep; undefined means quit without changing either. */
export async function chooseProfileStateCopy(
deps: ProfileStateCopyChoiceDialogDeps
): Promise<ProfileStateCopyChoice | undefined> {
const format = deps.formatTime ?? ((time: Date) => time.toLocaleString())
const savedAt = (time: Date | undefined): string =>
time === undefined ? '' : ` Last saved ${format(time)}.`
const { response } = await deps.showMessageBox({
type: 'warning',
buttons: ['Use SQLite (Recommended)', 'Use JSON', 'Quit'],
defaultId: 0,
cancelId: 2,
noLink: true,
title: 'Choose profile state',
message: 'This profile has two saved copies that don’t match.',
detail: [
'This usually happens after opening the profile in an older version of Orca.',
'',
`SQLite: what this version of Orca saved. Changes made in the older version are discarded.${savedAt(deps.sqliteSavedAt)}`,
'',
`JSON: includes changes made in the older version. Changes this version saved since then are discarded.${savedAt(deps.jsonSavedAt)}`,
'',
'Orca archives both copies before switching, then restarts.'
].join('\n')
})
return response === 0 ? 'current-sqlite' : response === 1 ? 'current-json' : undefined
}
@@ -12,6 +12,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
PROFILE_STATE_DESKTOP_RECOVERY_FLAG,
PROFILE_STATE_RECOVERY_FLAG,
PROFILE_STATE_RECOVERY_RESULT_PREFIX
} from '../../shared/profile-state-recovery-command'
@@ -31,17 +32,26 @@ import {
} from '../persistence/profile-state/profile-state-documents'
import { writeProfileStateDatabaseSnapshotAsync } from '../persistence/profile-state/profile-state-database-snapshot'
import * as marker from './http1-compatibility-marker'
import { runProfileStateRecoveryPreflight } from './profile-state-recovery-preflight'
import {
profileStateDesktopRecoveryArgs,
runProfileStateRecoveryPreflight
} from './profile-state-recovery-preflight'
const mocks = vi.hoisted(() => ({
setPath: vi.fn(),
requestSingleInstanceLock: vi.fn(),
on: vi.fn(),
exit: vi.fn(),
relaunch: vi.fn(),
whenReady: vi.fn(),
showMessageBox: vi.fn(),
background: vi.fn(),
output: vi.fn()
}))
vi.mock('electron', () => ({ app: mocks }))
vi.mock('electron', () => ({
app: mocks,
dialog: { showMessageBox: mocks.showMessageBox }
}))
vi.mock('../window/foreground-activation-policy', () => ({
applyBackgroundActivationPolicy: mocks.background
}))
@@ -63,6 +73,8 @@ const roots: string[] = []
beforeEach(() => {
vi.clearAllMocks()
mocks.requestSingleInstanceLock.mockReturnValue(true)
mocks.whenReady.mockResolvedValue(undefined)
mocks.showMessageBox.mockResolvedValue({ response: 0 })
vi.stubEnv('ORCA_USER_DATA_PATH', '/stale/inherited/root')
vi.stubEnv('ORCA_BYPASS_SINGLE_INSTANCE_LOCK', '1')
vi.stubEnv('ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK', '0')
@@ -277,4 +289,54 @@ describe('Electron recovery preflight', () => {
expect(mocks.requestSingleInstanceLock).not.toHaveBeenCalled()
expect(mocks.exit).toHaveBeenCalledWith(1)
})
describe('desktop choice relaunch', () => {
function desktopArgv(item: ReturnType<typeof fixture>) {
writeFileSync(item.dataFile, JSON.stringify(item.restored))
return [
'Orca',
...profileStateDesktopRecoveryArgs(['Orca', '--inspect', 'orca://share/1'], {
userDataPath: item.root,
selector: { kind: 'current-json' }
})
]
}
it('applies the choice without writing a CLI response, then relaunches ordinary startup', () => {
const item = fixture()
expect(runProfileStateRecoveryPreflight(desktopArgv(item))).toBe(true)
expect(JSON.parse(readFileSync(item.dataFile, 'utf8'))).toEqual(item.restored)
expect(existsSync(item.databaseFile)).toBe(false)
expect(mocks.output).not.toHaveBeenCalled()
expect(mocks.background).not.toHaveBeenCalled()
expect(mocks.relaunch).toHaveBeenCalledWith({ args: ['--inspect', 'orca://share/1'] })
expect(mocks.exit).toHaveBeenCalledWith(0)
acquireProfileStateRuntimeAdmission(item.root).release()
})
it('reports a failed choice instead of relaunching', async () => {
const item = fixture()
mocks.requestSingleInstanceLock.mockReturnValue(false)
vi.spyOn(console, 'error').mockImplementation(() => {})
expect(runProfileStateRecoveryPreflight(desktopArgv(item))).toBe(true)
await vi.waitFor(() => expect(mocks.exit).toHaveBeenCalledWith(1))
expect(mocks.relaunch).not.toHaveBeenCalled()
expect(mocks.showMessageBox).toHaveBeenCalledWith(
expect.objectContaining({ detail: expect.stringContaining('Stop Orca') })
)
expect(readFileSync(item.databaseFile, 'utf8')).toBe('broken database')
})
it.each([
['Orca', PROFILE_STATE_DESKTOP_RECOVERY_FLAG, '{}'],
['Orca', '--serve', PROFILE_STATE_DESKTOP_RECOVERY_FLAG, '{}'],
['Orca', PROFILE_STATE_DESKTOP_RECOVERY_FLAG, '{}', PROFILE_STATE_RECOVERY_FLAG, '{}']
])('fails closed for malformed desktop launch %j', async (...argv) => {
vi.spyOn(console, 'error').mockImplementation(() => {})
expect(runProfileStateRecoveryPreflight(argv)).toBe(true)
await vi.waitFor(() => expect(mocks.exit).toHaveBeenCalledWith(1))
expect(mocks.setPath).not.toHaveBeenCalled()
expect(mocks.relaunch).not.toHaveBeenCalled()
})
})
})
@@ -1,7 +1,8 @@
import { writeFileSync, realpathSync } from 'node:fs'
import { isAbsolute } from 'node:path'
import { app } from 'electron'
import { app, dialog } from 'electron'
import {
PROFILE_STATE_DESKTOP_RECOVERY_FLAG,
PROFILE_STATE_RECOVERY_FLAG,
PROFILE_STATE_RECOVERY_RESULT_PREFIX,
ProfileStateRecoveryCommandError,
@@ -16,21 +17,81 @@ import { acquireSingleInstanceLock } from './single-instance-lock'
/** The process owning both locks performs recovery before Electron can initialize a runtime. */
export function runProfileStateRecoveryPreflight(argv: readonly string[] = process.argv): boolean {
if (argv.includes(PROFILE_STATE_DESKTOP_RECOVERY_FLAG)) {
runDesktopRecovery(argv)
return true
}
const index = argv.indexOf(PROFILE_STATE_RECOVERY_FLAG)
if (index === -1) {
return false
}
process.env.ORCA_BACKGROUND_LAUNCH = '1'
applyBackgroundActivationPolicy()
let response: ProfileStateRecoveryResponse
const response =
!argv.includes('--serve') || argv.lastIndexOf(PROFILE_STATE_RECOVERY_FLAG) !== index
? invalidLaunch()
: runRecoveryRequest(argv[index + 1])
let exitCode = response.ok ? 0 : 1
try {
if (!argv.includes('--serve') || argv.lastIndexOf(PROFILE_STATE_RECOVERY_FLAG) !== index) {
throw new ProfileStateRecoveryCommandError(
'invalid_argument',
'Invalid profile-state recovery launch.'
)
writeFileSync(1, `${PROFILE_STATE_RECOVERY_RESULT_PREFIX}${JSON.stringify(response)}\n`)
} catch {
// The CLI may have exited while recovery held the locks; never open an Electron error dialog.
exitCode = 1
}
app.exit(exitCode)
return true
}
/** Build the relaunch argv that applies a startup-dialog choice before ordinary startup. */
export function profileStateDesktopRecoveryArgs(
argv: readonly string[],
request: { userDataPath: string; selector: { kind: 'current-json' | 'current-sqlite' } }
): string[] {
return [
...stripRecoveryArgs(argv.slice(1)),
PROFILE_STATE_DESKTOP_RECOVERY_FLAG,
JSON.stringify(request)
]
}
function runDesktopRecovery(argv: readonly string[]): void {
const index = argv.indexOf(PROFILE_STATE_DESKTOP_RECOVERY_FLAG)
const response =
argv.lastIndexOf(PROFILE_STATE_DESKTOP_RECOVERY_FLAG) !== index ||
argv.includes(PROFILE_STATE_RECOVERY_FLAG) ||
argv.includes('--serve')
? invalidLaunch()
: runRecoveryRequest(argv[index + 1])
if (response.ok) {
// Why relaunch: ordinary startup must run in a process that never held maintenance.
app.relaunch({ args: stripRecoveryArgs(argv.slice(1)) })
app.exit(0)
return
}
console.error(`[profile-state] Desktop recovery failed: ${response.message}`)
void app
.whenReady()
.then(() =>
dialog.showMessageBox({
type: 'error',
buttons: ['Quit'],
title: 'Orca profile state was not changed',
message: 'Orca could not apply the selected profile state.',
detail: `${response.message}\n\nReopen Orca to choose again.`
})
)
.catch((error: unknown) => console.warn('[profile-state] Recovery error dialog failed:', error))
.finally(() => app.exit(1))
}
function runRecoveryRequest(payload: string | undefined): ProfileStateRecoveryResponse {
try {
let raw: unknown
try {
raw = JSON.parse(payload ?? '')
} catch {
raw = undefined
}
const raw: unknown = JSON.parse(argv[index + 1] ?? '')
const parsed = profileStateRecoveryRequestSchema.safeParse(raw)
if (!parsed.success || !isAbsolute(parsed.data.userDataPath)) {
throw new ProfileStateRecoveryCommandError(
@@ -50,7 +111,7 @@ export function runProfileStateRecoveryPreflight(argv: readonly string[] = proce
'Stop Orca before profile-state rollback so no process can write the SQLite database.'
)
}
response = {
return {
ok: true,
result: rollbackProfileState(userDataPath, parsed.data.selector, maintenance)
}
@@ -58,19 +119,33 @@ export function runProfileStateRecoveryPreflight(argv: readonly string[] = proce
maintenance.release()
}
} catch (error) {
response = {
return {
ok: false,
code: isProfileStateRecoveryCommandError(error) ? error.code : 'runtime_error',
message: error instanceof Error ? error.message : String(error)
}
}
let exitCode = response.ok ? 0 : 1
try {
writeFileSync(1, `${PROFILE_STATE_RECOVERY_RESULT_PREFIX}${JSON.stringify(response)}\n`)
} catch {
// The CLI may have exited while recovery held the locks; never open an Electron error dialog.
exitCode = 1
}
function invalidLaunch(): ProfileStateRecoveryResponse {
return {
ok: false,
code: 'invalid_argument',
message: 'Invalid profile-state recovery launch.'
}
app.exit(exitCode)
return true
}
function stripRecoveryArgs(args: readonly string[]): string[] {
const kept: string[] = []
for (let i = 0; i < args.length; i++) {
if (
args[i] === PROFILE_STATE_DESKTOP_RECOVERY_FLAG ||
args[i] === PROFILE_STATE_RECOVERY_FLAG
) {
i++
continue
}
kept.push(args[i])
}
return kept
}
+1
View File
@@ -10,6 +10,7 @@ export const CLI_BOOLEAN_FLAGS = new Set([
'connect',
'current',
'current-json',
'current-sqlite',
'debug',
'dry-run',
'enter',
@@ -2,11 +2,14 @@ import { z } from 'zod'
export const PROFILE_STATE_RECOVERY_FLAG = '--profile-state-recovery'
export const PROFILE_STATE_RECOVERY_RESULT_PREFIX = '[profile-state-recovery] '
/** Desktop variant: applies a startup-dialog choice, then relaunches Orca normally. */
export const PROFILE_STATE_DESKTOP_RECOVERY_FLAG = '--profile-state-desktop-recovery'
const positiveInteger = z.number().int().positive().max(Number.MAX_SAFE_INTEGER)
const selectorSchema = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('json'), revision: positiveInteger }).strict(),
z.object({ kind: z.literal('current-json') }).strict(),
z.object({ kind: z.literal('current-sqlite') }).strict(),
z.object({ kind: z.literal('sqlite'), backupId: z.string().min(1) }).strict()
])