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