diff --git a/src/cli/handlers/profile-state.ts b/src/cli/handlers/profile-state.ts index cd7b7304159..3c934f8c725 100644 --- a/src/cli/handlers/profile-state.ts +++ b/src/cli/handlers/profile-state.ts @@ -56,11 +56,25 @@ function formatRollback(result: ProfileStateRollbackResult): string { : []), `storage: ${result.storage}`, `restored: ${result.restoredPath}`, + ...(result.repairedSections ?? []).map(formatRepairedSection), `quarantine: ${result.quarantineDirectory}`, `removedDatabaseFiles: ${result.removedDatabaseFiles.length}` ].join('\n') } +function formatRepairedSection({ + domain, + backupId, + backupCreatedAtMs +}: NonNullable[number]): string { + if (backupId === null) { + return `repaired ${domain}: no readable backup, starts empty` + } + const savedAt = + backupCreatedAtMs === undefined ? '' : ` saved ${new Date(backupCreatedAtMs).toLocaleString()}` + return `repaired ${domain}: from backup ${backupId}${savedAt}` +} + function rejectProfileStateRemoteSelection(flags: ReadonlyMap): void { rejectRemoteSelectionFlags( flags, @@ -130,14 +144,21 @@ export const PROFILE_STATE_HANDLERS: Record = { } function parseSelector(flags: Map): ProfileStateRecoverySelector { - const selectors = ['revision', 'backup', 'current-json', 'current-sqlite', 'latest-json'] as const + const selectors = [ + 'repair', + 'revision', + 'backup', + 'current-json', + 'current-sqlite', + 'latest-json' + ] as const if (selectors.filter((flag) => flags.has(flag)).length !== 1) { throw new RuntimeClientError( 'invalid_argument', - 'Select exactly one of --revision, --backup, --current-json, --current-sqlite, or --latest-json.' + 'Select exactly one of --repair, --revision, --backup, --current-json, --current-sqlite, or --latest-json.' ) } - for (const kind of ['current-json', 'current-sqlite', 'latest-json'] as const) { + for (const kind of ['repair', 'current-json', 'current-sqlite', 'latest-json'] as const) { if (flags.has(kind)) { if (flags.get(kind) !== true) { throw new RuntimeClientError('invalid_argument', `--${kind} does not take a value.`) diff --git a/src/cli/specs/profile-state.ts b/src/cli/specs/profile-state.ts index 900881090ca..af6f23588d3 100644 --- a/src/cli/specs/profile-state.ts +++ b/src/cli/specs/profile-state.ts @@ -12,11 +12,12 @@ export const PROFILE_STATE_COMMAND_SPECS: CommandSpec[] = [ path: ['profile', 'state', 'rollback'], destructive: true, summary: - 'Restore a SQLite backup or retained JSON export, or keep the current JSON or SQLite profile', + 'Repair damaged sections, restore a SQLite backup or retained JSON export, or keep the current JSON or SQLite profile', usage: - 'orca profile state rollback (--backup | --revision | --current-json | --current-sqlite | --latest-json) [--profile-id ] [--json]', + 'orca profile state rollback (--repair | --backup | --revision | --current-json | --current-sqlite | --latest-json) [--profile-id ] [--json]', allowedFlags: [ ...GLOBAL_FLAGS, + 'repair', 'revision', 'backup', 'current-json', @@ -27,6 +28,7 @@ export const PROFILE_STATE_COMMAND_SPECS: CommandSpec[] = [ notes: [ '--profile-id selects another profile without changing the active profile. Prepare each profile before installing a JSON-only build.', 'Orca must be stopped. Recovery validates the selected artifact and archives the current database family, JSON, and retained recovery artifacts before replacing state.', + '--repair replaces only the damaged sections of the SQLite profile, each from the newest backup with a readable copy, and keeps everything else. A copy of the damaged database is archived first.', '--backup restores SQLite authority; --revision restores a JSON export for an older compatible runtime.', '--latest-json exports the latest SQLite state and restores it as JSON before launching an older JSON-only build. The current database and recovery artifacts are archived.', '--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.', @@ -34,6 +36,7 @@ export const PROFILE_STATE_COMMAND_SPECS: CommandSpec[] = [ ], examples: [ 'orca profile state exports', + 'orca profile state rollback --repair', 'orca profile state rollback --backup ', 'orca profile state rollback --revision 1', 'orca profile state rollback --latest-json', diff --git a/src/main/index.ts b/src/main/index.ts index 67d0dfe7ed6..0771f03cd44 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -23,10 +23,13 @@ import { import { recordDurableCrashBreadcrumb } from './crash-reporting/durable-crash-breadcrumb' import { chooseProfileStateCopy, + chooseProfileStateRepair, presentProfileStateStartupRecoveryDialog, readProfileStateCopySavedTimes } from './persistence/profile-state/profile-state-startup-recovery-dialog' import { profileStateDesktopRecoveryArgs } from './startup/profile-state-recovery-preflight' +import { ProfileStateRecoveryRequiredError } from './persistence/profile-state/profile-state-recovery-required' +import { getProfileUserDataPath } from './orca-profiles/profile-storage-paths' import { leaseAppImageGenerationForCurrentProcess } from './cli/appimage-generation-lease' function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): BrowserWindow { @@ -150,8 +153,8 @@ if (preflightReady) { console.error(`[profile-state] ${message}`) if (!state.isServeMode && !isBackgroundLaunch()) { try { + const userDataPath = getProfileUserDataPath() if (isDivergedProfileStateFailure(error)) { - const userDataPath = app.getPath('userData') const choice = await chooseProfileStateCopy({ ...readProfileStateCopySavedTimes(userDataPath), showMessageBox: (options) => dialog.showMessageBox(options) @@ -168,6 +171,31 @@ if (preflightReady) { app.exit(1) return } + if ( + error instanceof ProfileStateRecoveryRequiredError && + error.repairableSections !== undefined + ) { + const sections = error.repairableSections + if ( + await chooseProfileStateRepair({ + sections, + showMessageBox: (options) => dialog.showMessageBox(options) + }) + ) { + app.relaunch({ + args: profileStateDesktopRecoveryArgs(process.argv, { + userDataPath, + profileId: error.profileId, + selector: { + kind: 'repair', + sections: sections.map(({ domain, backupId }) => ({ domain, backupId })) + } + }) + }) + } + app.exit(1) + return + } await presentProfileStateStartupRecoveryDialog({ message, ...(failureClass === 'recovery-required' || failureClass === 'ambiguous-authority' diff --git a/src/main/orcad/orcad-profile-state-startup.test.ts b/src/main/orcad/orcad-profile-state-startup.test.ts index 32939c2e078..4bd581f2055 100644 --- a/src/main/orcad/orcad-profile-state-startup.test.ts +++ b/src/main/orcad/orcad-profile-state-startup.test.ts @@ -57,6 +57,7 @@ describe('orcad profile-state startup', () => { expect(initSshHostKeyStoreFileMock).toHaveBeenCalledWith('/tmp/profile/orca-data.json') expect(createProfileStateStoreForStartupMock).toHaveBeenCalledWith({ + userDataPath: '/tmp/user-data', dataFile: '/tmp/profile/orca-data.json', databaseFile: '/tmp/profile/profile-state.db', profileId: 'profile-1', diff --git a/src/main/orcad/orcad-profile-state-startup.ts b/src/main/orcad/orcad-profile-state-startup.ts index 74362f6c2ee..0f997c8e390 100644 --- a/src/main/orcad/orcad-profile-state-startup.ts +++ b/src/main/orcad/orcad-profile-state-startup.ts @@ -26,6 +26,7 @@ export async function createOrcadProfileStateStartup( // "empty", so a server that pairs and lists nothing looks healthy. As the runtime authority, // orcad must not load as 'desktop', which would orphan its own scheduled automations. const result = await createProfileStateStoreForStartup({ + userDataPath, dataFile: profile.dataFile, databaseFile: profile.stateDatabaseFile, profileId: profile.profile.id, diff --git a/src/main/persistence/profile-state/profile-state-authority-bootstrap.ts b/src/main/persistence/profile-state/profile-state-authority-bootstrap.ts index 8f784714687..563ed85f69c 100644 --- a/src/main/persistence/profile-state/profile-state-authority-bootstrap.ts +++ b/src/main/persistence/profile-state/profile-state-authority-bootstrap.ts @@ -7,6 +7,11 @@ import type { ProfileStateAuthorityInitialState } from '../loading-store/profile import { isProfileStateSqliteAvailable, openProfileStateDatabase } from './profile-state-database' import { ProfileStateDatabaseOpenError } from './profile-state-database-errors' import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { ProfileStateDocumentCorruptionError } from './profile-state-document-validation' +import { + planProfileStateSectionRepair, + type ProfileStateRepairSection +} from './profile-state-section-repair' import { migrateProfileStateToSqlite } from './profile-state-migration' import { prepareLegacyProfileState } from './legacy-json/profile-state-legacy-import' import { @@ -39,6 +44,7 @@ export type ProfileStateAuthorityBootstrapResult = { ) export type ProfileStateAuthorityBootstrapOptions = { + userDataPath?: string dataFile: string databaseFile: string profileId: string @@ -99,7 +105,25 @@ export function bootstrapProfileStateAuthority( ) { throw error } - throw new ProfileStateRecoveryRequiredError(options, error) + throw new ProfileStateRecoveryRequiredError( + options, + error, + error instanceof ProfileStateDocumentCorruptionError + ? findRepairableSections(options) + : undefined + ) + } +} + +/** Diagnosis only: a failure to plan keeps the whole-file recovery guidance. */ +function findRepairableSections( + options: ProfileStateAuthorityBootstrapOptions +): readonly ProfileStateRepairSection[] | undefined { + try { + return planProfileStateSectionRepair(options)?.sections + } catch (error) { + console.warn('[profile-state] Could not tell which profile sections are damaged:', error) + return undefined } } diff --git a/src/main/persistence/profile-state/profile-state-database.ts b/src/main/persistence/profile-state/profile-state-database.ts index 13ea8316ccf..137d5219d20 100644 --- a/src/main/persistence/profile-state/profile-state-database.ts +++ b/src/main/persistence/profile-state/profile-state-database.ts @@ -219,10 +219,11 @@ function configureProfileStatePragmas(db: Database.Database): void { } db.pragma(`busy_timeout = ${PROFILE_STATE_BUSY_TIMEOUT_MS}`) db.pragma('foreign_keys = ON') - // Profile commits are user-visible state. Keep the same power-loss contract - // as the current temp-file + fsync writer rather than the orchestration DB's - // cache-oriented NORMAL setting. + // Profile commits are user-visible state, so FULL rather than the orchestration DB's NORMAL. + // On macOS fsync leaves the drive cache unflushed, so a checkpoint cut by power loss can leave + // a row's pages from two versions; checkpoint fullfsync uses F_FULLFSYNC (a no-op elsewhere). db.pragma('synchronous = FULL') + db.pragma('checkpoint_fullfsync = ON') } function migrateProfileStateSchema( diff --git a/src/main/persistence/profile-state/profile-state-recovery-command.ts b/src/main/persistence/profile-state/profile-state-recovery-command.ts index 6e724319037..56281957527 100644 --- a/src/main/persistence/profile-state/profile-state-recovery-command.ts +++ b/src/main/persistence/profile-state/profile-state-recovery-command.ts @@ -27,6 +27,7 @@ import { } from './legacy-json/profile-state-authority-exports' import { writeVersionedProfileStateExport } from './legacy-json/profile-state-versioned-export' import { readProfileStateDomain } from './profile-state-domain-reader' +import { repairProfileStateSections } from './profile-state-section-repair' import { isRecord } from './profile-state-document-validation' import { profileHasPendingProjectMove } from '../../orca-profiles/profile-project-move-record' import { @@ -73,6 +74,24 @@ export function rollbackProfileState( if (selector.kind === 'current-sqlite') { return adoptCurrentDatabase(userDataPath, result, maintenance) } + if (selector.kind === 'repair') { + const repaired = repairProfileStateSections({ + maintenance, + location: { ...result, userDataPath }, + approved: selector.sections, + beforeRepair: () => invalidateActiveProfileMarker(userDataPath, result.profileId) + }) + syncHttp1CompatibilityMarkerFromDatabase(userDataPath, result) + return { + ...result, + storage: 'sqlite', + restoredPath: result.databaseFile, + revision: repaired.revision, + quarantineDirectory: repaired.quarantineDirectory, + removedDatabaseFiles: [], + repairedSections: repaired.sections + } + } const revision = selector.kind === 'latest-json' ? exportLatestProfileStateJson(result, maintenance) diff --git a/src/main/persistence/profile-state/profile-state-recovery-required.ts b/src/main/persistence/profile-state/profile-state-recovery-required.ts index 23fc2344d43..ba613f834e3 100644 --- a/src/main/persistence/profile-state/profile-state-recovery-required.ts +++ b/src/main/persistence/profile-state/profile-state-recovery-required.ts @@ -3,6 +3,8 @@ import { hasStateBackup } from './legacy-json/profile-state-legacy-backup-path' import { profileStateJsonExportPaths } from './legacy-json/profile-state-export-path' import { profileStateDatabaseBackups } from './profile-state-backup-path' import { hasProfileStateAuthorityMarker } from './profile-state-authority-marker' +import type { ProfileStateRepairSection } from './profile-state-section-repair' +import { profileStateSectionLabels } from './profile-state-section-labels' type ProfileStateRecoveryLocation = { dataFile: string @@ -40,15 +42,28 @@ export class ProfileStateRecoveryRequiredError extends Error { readonly databaseFile: string readonly exportPaths: readonly string[] readonly backupPaths: readonly string[] + readonly profileId: string + /** Set when only these sections are damaged, so the rest of the profile can be kept. */ + readonly repairableSections?: readonly ProfileStateRepairSection[] - constructor(options: ProfileStateRecoveryLocation, cause: unknown) { + constructor( + options: ProfileStateRecoveryLocation, + cause: unknown, + repairableSections?: readonly ProfileStateRepairSection[] + ) { super( - `SQLite profile state could not be read; choose a retained backup or JSON export to recover the profile`, + repairableSections + ? `Damaged profile sections: ${profileStateSectionLabels(repairableSections.map(({ domain }) => domain)).join(', ')}; run \`orca profile state rollback --repair --profile-id ${options.profileId}\` to replace only those sections from backups` + : `SQLite profile state could not be read; choose a retained backup or JSON export to recover the profile`, { cause } ) this.name = 'ProfileStateRecoveryRequiredError' this.dataFile = options.dataFile this.databaseFile = options.databaseFile + this.profileId = options.profileId + if (repairableSections) { + this.repairableSections = repairableSections + } // Recovery guidance must survive a permissions failure while enumerating the directory. // The startup error still names the canonical paths and remains typed for fail-closed handling. try { diff --git a/src/main/persistence/profile-state/profile-state-revision.ts b/src/main/persistence/profile-state/profile-state-revision.ts index 6c64714606f..ec4dd57454d 100644 --- a/src/main/persistence/profile-state/profile-state-revision.ts +++ b/src/main/persistence/profile-state/profile-state-revision.ts @@ -1,5 +1,9 @@ import type Database from '../../sqlite/sync-database' import { PROFILE_STATE_META_REVISION } from './profile-state-database-schema' +import { + PROFILE_STATE_AUTOMATION_RUNS_META_TABLE, + PROFILE_STATE_AUTOMATION_RUNS_TABLE +} from './profile-state-automation-runs-model' import { isRecord, ProfileStateDocumentCorruptionError } from './profile-state-document-validation' export function readProfileStateRevision(db: Database.Database): number { @@ -16,6 +20,24 @@ export function readProfileStateRevision(db: Database.Database): number { return revision } +/** Recovery admission must see every valid counter before any payload failure can hide one. */ +export function assertProfileStateRepairRevision(db: Database.Database, revision: number): void { + const ahead = db + .prepare( + `SELECT 1 FROM ( + SELECT revision FROM profile_state_documents + UNION ALL SELECT revision FROM ${PROFILE_STATE_AUTOMATION_RUNS_TABLE} + UNION ALL SELECT revision FROM ${PROFILE_STATE_AUTOMATION_RUNS_META_TABLE} + ) WHERE typeof(revision) IN ('integer', 'real') + AND revision BETWEEN 1 AND ? AND revision = CAST(revision AS INTEGER) + AND revision > ? LIMIT 1` + ) + .get(Number.MAX_SAFE_INTEGER, revision) + if (ahead !== undefined) { + throw new ProfileStateDocumentCorruptionError('Profile state rows exceed the profile revision') + } +} + /** Unchanged domains may lag the profile revision, but cannot lead it. */ export function assertProfileStateDocumentRevision( revision: number, diff --git a/src/main/persistence/profile-state/profile-state-section-labels.ts b/src/main/persistence/profile-state/profile-state-section-labels.ts new file mode 100644 index 00000000000..ecdfd2c52c5 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-section-labels.ts @@ -0,0 +1,34 @@ +const SECTION_LABELS: ReadonlyMap = new Map([ + ['repos', 'Projects'], + ['projects', 'Projects'], + ['projectHostSetups', 'Projects'], + ['projectGroups', 'Project groups'], + ['folderWorkspaces', 'Folder workspaces'], + ['folderWorkspaceDiffComments', 'Review comments'], + ['worktreeMeta', 'Workspace details'], + ['worktreeMetaByIdentity', 'Workspace details'], + ['worktreeIdentityAliases', 'Workspace details'], + ['worktreeLineageById', 'Workspace details'], + ['workspaceLineageByChildKey', 'Workspace details'], + ['settings', 'Settings'], + ['ui', 'Window layout'], + ['remoteLayoutSnapshots', 'Open tabs and terminals'], + ['sshTargets', 'SSH hosts'], + ['automations', 'Automations'], + ['automationRuns', 'Automation history'], + ['onboarding', 'Onboarding progress'] +]) + +/** Sections whose loss makes Orca forget projects, which also drops their workspace details. */ +const PROJECT_LIST_SECTIONS = new Set(['repos', 'projects', 'folderWorkspaces']) + +/** Plain names for damaged sections, deduplicated, for recovery dialogs and messages. */ +export function profileStateSectionLabels(domains: readonly string[]): string[] { + return [ + ...new Set(domains.map((domain) => SECTION_LABELS.get(domain) ?? 'Other saved app state')) + ] +} + +export function includesProjectListSection(domains: readonly string[]): boolean { + return domains.some((domain) => PROJECT_LIST_SECTIONS.has(domain)) +} diff --git a/src/main/persistence/profile-state/profile-state-section-repair.test.ts b/src/main/persistence/profile-state/profile-state-section-repair.test.ts new file mode 100644 index 00000000000..e328180ac34 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-section-repair.test.ts @@ -0,0 +1,334 @@ +import { 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 } from './profile-state-access' +import { bootstrapProfileStateAuthority } from './profile-state-authority-bootstrap' +import { + createProfileStateDatabaseBackupId, + profileStateDatabaseBackupPath +} from './profile-state-backup-path' +import { writeProfileStateBackup } from './profile-state-backup-job' +import { openWritableProfileStateDatabase } from './profile-state-database' +import * as database from './profile-state-database' +import { profileProjectMoveIntentPath } from '../../orca-profiles/profile-project-move-record' +import { hashProfileStatePayload } from './profile-state-document-validation' +import { writeProfileStateDomains } from './profile-state-domain-writes' +import { migrateProfileStateToSqlite } from './profile-state-migration' +import { rollbackProfileState } from './profile-state-recovery-command' +import { ProfileStateRecoveryRequiredError } from './profile-state-recovery-required' +import { formatProfileStateStartupFailure } from './profile-state-startup-failure' +import { + profileStateRecoveryRequestSchema, + profileStateRecoveryResponseSchema, + type ProfileStateRecoverySelector +} from '../../../shared/profile-state-recovery-command' + +const roots: string[] = [] +const profileId = 'section-repair' + +beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => {}) +}) +afterEach(() => { + vi.restoreAllMocks() + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +/** A profile with a backup at revision 1, then newer saves to two sections at revision 2. */ +async function profileWithNewerSaves(extra: Record = {}) { + const root = mkdtempSync(join(tmpdir(), 'orca-section-repair-')) + 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 initialJson = JSON.stringify({ + ...extra, + repos: ['a'], + ui: { width: 1 }, + automationRuns: [{ id: 'r1', n: 1 }] + }) + writeFileSync(dataFile, initialJson) + migrateProfileStateToSqlite({ + dataFile, + databaseFile, + profileId, + expectedLegacyJson: initialJson, + serializedState: initialJson + }).authority.close() + const backupId = createProfileStateDatabaseBackupId(Date.now() - 60_000) + await writeProfileStateBackup({ + databasePath: databaseFile, + profileId, + targetPath: profileStateDatabaseBackupPath(databaseFile, backupId) + }) + withDatabase(databaseFile, (db) => + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [ + { domain: 'repos', payload: JSON.stringify(['a', 'b']) }, + { domain: 'ui', payload: JSON.stringify({ width: 2 }) } + ] + }) + ) + return { root, userDataPath: root, dataFile, databaseFile, profileId, backupId } +} + +function withDatabase( + databaseFile: string, + write: (db: ReturnType['db']) => T +): T { + const opened = openWritableProfileStateDatabase(databaseFile, profileId) + try { + return write(opened.db) + } finally { + opened.db.close() + } +} + +function startupFailure(profile: Parameters[0]) { + try { + bootstrapProfileStateAuthority(profile).authority?.close() + } catch (error) { + if (error instanceof ProfileStateRecoveryRequiredError) { + return error + } + throw error + } + throw new Error('expected the profile to need recovery') +} + +function rollback( + profile: Awaited>, + selector: ProfileStateRecoverySelector +) { + const maintenance = acquireProfileStateMaintenance(profile.root) + try { + return rollbackProfileState(profile.root, selector, maintenance) + } finally { + maintenance.release() + } +} + +describe('repairing damaged profile-state sections', () => { + it.each([ + { + section: 'repos', + damage: + 'UPDATE profile_state_documents SET payload = \'["a","b","c"]\' WHERE domain = \'repos\'', + repaired: { repos: ['a'], ui: { width: 2 }, automationRuns: [{ id: 'r1', n: 1 }] } + }, + { + section: 'automationRuns', + damage: `UPDATE profile_state_automation_runs SET payload = '{"id":"r1","n":9}' WHERE run_id = 'r1'`, + repaired: { repos: ['a', 'b'], ui: { width: 2 }, automationRuns: [{ id: 'r1', n: 1 }] } + }, + ...[ + "updated_at = 'broken'", + "revision = '999999999999999999999x'", + 'revision = 2.5', + 'revision = -1', + "revision = X'33'" + ].map((metadata) => ({ + section: 'repos', + damage: `UPDATE profile_state_documents SET ${metadata} WHERE domain = 'repos'`, + repaired: { repos: ['a'], ui: { width: 2 }, automationRuns: [{ id: 'r1', n: 1 }] } + })), + ...['__proto__', ''].map((section) => ({ + section, + damage: `UPDATE profile_state_documents SET payload = '{}' WHERE domain = '${section}'`, + repaired: { + [section]: { saved: true }, + repos: ['a', 'b'], + ui: { width: 2 }, + automationRuns: [{ id: 'r1', n: 1 }] + } + })) + ])( + 'replaces only damaged $section from a backup and keeps newer intact sections', + async ({ section, damage, repaired }) => { + const profile = await profileWithNewerSaves( + section === '__proto__' || section === '' ? { [section]: { saved: true } } : {} + ) + withDatabase(profile.databaseFile, (db) => db.exec(damage)) + const damagedBytes = readFileSync(profile.databaseFile) + + const failure = startupFailure(profile) + expect(failure.message).toContain( + section === 'repos' + ? 'Projects' + : section === 'automationRuns' + ? 'Automation history' + : 'Other saved app state' + ) + expect(failure.repairableSections).toEqual([ + { domain: section, backupId: profile.backupId, backupCreatedAtMs: expect.any(Number) } + ]) + expect(formatProfileStateStartupFailure(failure)).toContain( + `orca profile state rollback --repair --profile-id ${profileId}` + ) + + expect(() => + rollback(profile, { kind: 'repair', sections: [{ domain: section, backupId: null }] }) + ).toThrow('changed since you chose') + expect(readFileSync(profile.databaseFile)).toEqual(damagedBytes) + + const request = profileStateRecoveryRequestSchema.parse({ + userDataPath: profile.root, + profileId, + selector: { kind: 'repair', sections: [{ domain: section, backupId: profile.backupId }] } + }) + const result = rollback(profile, request.selector) + expect( + profileStateRecoveryResponseSchema.parse(JSON.parse(JSON.stringify({ ok: true, result }))) + ).toEqual({ ok: true, result }) + expect(result).toMatchObject({ storage: 'sqlite', revision: 3, removedDatabaseFiles: [] }) + expect(result.repairedSections).toEqual(failure.repairableSections) + expect(readFileSync(join(result.quarantineDirectory, 'profile-state.db'))).toEqual( + damagedBytes + ) + + const reopened = bootstrapProfileStateAuthority(profile) + try { + expect(JSON.parse(reopened.authority?.readSerializedState() ?? 'null')).toEqual(repaired) + } finally { + reopened.authority?.close() + } + } + ) + + it.each([ + "UPDATE profile_state_meta SET value = '1' WHERE key = 'revision'", + "DELETE FROM profile_state_meta WHERE key = 'revision'", + "UPDATE profile_state_documents SET revision = 3, payload = 'bad' WHERE domain = 'repos'", + "UPDATE profile_state_documents SET payload = 'bad' WHERE domain = 'repos'; UPDATE profile_state_documents SET revision = 3 WHERE domain = 'ui'", + 'UPDATE profile_state_automation_runs SET revision = 3', + "INSERT INTO profile_state_automation_runs SELECT 'r2', 1, payload, content_hash, 3, updated_at FROM profile_state_automation_runs; UPDATE profile_state_automation_runs SET payload = 'bad' WHERE run_id = 'r1'", + 'UPDATE profile_state_automation_runs_meta SET revision = 3' + ])( + 'keeps revision ambiguity on whole-file recovery regardless of payload faults: %s', + async (damage) => { + const profile = await profileWithNewerSaves() + withDatabase(profile.databaseFile, (db) => db.exec(damage)) + const before = readFileSync(profile.databaseFile) + expect(startupFailure(profile).repairableSections).toBeUndefined() + expect(() => rollback(profile, { kind: 'repair' })).toThrow( + "Orca can't repair this profile one section at a time" + ) + expect(readFileSync(profile.databaseFile)).toEqual(before) + } + ) + + it.each(['pending', 'unreadable', 'no-root'])( + 'does not offer a repair without move admission: %s', + async (kind) => { + const profile = await profileWithNewerSaves() + withDatabase(profile.databaseFile, (db) => + db.exec("UPDATE profile_state_documents SET payload = 'bad' WHERE domain = 'repos'") + ) + if (kind !== 'no-root') { + const id = '11111111-1111-1111-1111-111111111111' + mkdirSync(join(profile.root, 'profile-move-intents')) + const hash = hashProfileStatePayload('{}') + writeFileSync( + profileProjectMoveIntentPath(profile.root, id), + kind === 'unreadable' + ? 'bad' + : JSON.stringify({ + version: 1, + id, + sourceProfileId: profileId, + targetProfileId: 'other', + expectedSourceRevision: 2, + expectedTargetRevision: 0, + sourceBeforeHash: hash, + targetBeforeHash: hash, + sourceAfterHash: hash, + targetAfterHash: hash, + sourceAfterJson: '{}', + targetAfterJson: '{}' + }) + ) + } + const before = readFileSync(profile.databaseFile) + const failure = startupFailure({ + ...profile, + userDataPath: kind === 'no-root' ? undefined : profile.root + }) + expect(failure.repairableSections).toBeUndefined() + expect(failure.message).not.toContain('--repair') + if (kind !== 'no-root') { + expect(() => rollback(profile, { kind: 'repair' })).toThrow('pending project move') + } + expect(readFileSync(profile.databaseFile)).toEqual(before) + } + ) + + it('resolves independent sources and absence newest-first, opening each candidate once', async () => { + const profile = await profileWithNewerSaves({ absent: true }) + const newest = createProfileStateDatabaseBackupId(Date.now()) + const newestPath = profileStateDatabaseBackupPath(profile.databaseFile, newest) + await writeProfileStateBackup({ + databasePath: profile.databaseFile, + profileId, + targetPath: newestPath + }) + withDatabase(newestPath, (db) => { + db.exec("DELETE FROM profile_state_documents WHERE domain = 'absent'") + db.exec("UPDATE profile_state_documents SET payload = 'bad' WHERE domain = 'ui'") + }) + withDatabase(profile.databaseFile, (db) => { + writeProfileStateDomains(db, { + expectedRevision: 2, + replacements: [{ domain: 'automationRuns', payload: '[{"id":"r1","n":2}]' }] + }) + db.exec("UPDATE profile_state_documents SET payload = 'bad' WHERE domain IN ('absent', 'ui')") + db.prepare( + "UPDATE profile_state_documents SET payload = 'bad', content_hash = ? WHERE domain = 'repos'" + ).run(hashProfileStatePayload('["a"]')) + }) + const backupPaths = [ + newestPath, + profileStateDatabaseBackupPath(profile.databaseFile, profile.backupId) + ] + const observations: { path: string; close: () => void }[] = [] + const open = database.openProfileStateDatabaseReadOnly + vi.spyOn(database, 'openProfileStateDatabaseReadOnly').mockImplementation((...args) => { + const opened = open(...args) + if (backupPaths.includes(args[0])) { + observations.push({ path: args[0], close: vi.spyOn(opened.db, 'close') }) + } + return opened + }) + const sections = startupFailure(profile).repairableSections + expect(sections).toEqual([ + { domain: 'absent', backupId: newest, backupCreatedAtMs: expect.any(Number) }, + { domain: 'repos', backupId: newest, backupCreatedAtMs: expect.any(Number) }, + { domain: 'ui', backupId: profile.backupId, backupCreatedAtMs: expect.any(Number) } + ]) + expect(observations.map(({ path }) => path)).toEqual(backupPaths) + const result = rollback(profile, { kind: 'repair', sections: sections && [...sections] }) + expect(result.repairedSections).toEqual(sections) + expect(observations.map(({ path }) => path)).toEqual([...backupPaths, ...backupPaths]) + for (const { close } of observations) { + expect(close).toHaveBeenCalledTimes(1) + } + const reopened = bootstrapProfileStateAuthority(profile) + try { + expect(JSON.parse(reopened.authority?.readSerializedState() ?? 'null')).toEqual({ + repos: ['a', 'b'], + ui: { width: 1 }, + automationRuns: [{ id: 'r1', n: 2 }] + }) + } finally { + reopened.authority?.close() + } + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-section-repair.ts b/src/main/persistence/profile-state/profile-state-section-repair.ts new file mode 100644 index 00000000000..3488920017e --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-section-repair.ts @@ -0,0 +1,258 @@ +import { existsSync, readFileSync } from 'node:fs' +import type Database from '../../sqlite/sync-database' +import { + ProfileStateRecoveryCommandError, + type ProfileStateRecoveryRepairSection +} from '../../../shared/profile-state-recovery-command' +import type { ProfileStateMaintenance } from './profile-state-access' +import { profileHasPendingProjectMove } from '../../orca-profiles/profile-project-move-record' +import { readProfileStateAutomationRunsDocument } from './profile-state-automation-runs' +import { profileStateDatabaseBackups } from './profile-state-backup-path' +import { + openProfileStateDatabaseReadOnly, + openWritableProfileStateDatabase +} from './profile-state-database' +import { quarantineProfileStateDatabase } from './profile-state-database-quarantine' +import { importProfileStateJson } from './profile-state-documents' +import { + ProfileStateDocumentCorruptionError, + isRecord, + validateProfileStateDocumentRow, + type ProfileStateParsedDocument +} from './profile-state-document-validation' +import { readProfileStateDomainsWithRevisionFromDatabase } from './profile-state-domain-reader' +import { withProfileStateReadSnapshot } from './profile-state-read-snapshot' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { + assertProfileStateRepairRevision, + readProfileStateRevision +} from './profile-state-revision' + +export type ProfileStateRepairSection = { + domain: string + /** The newest retained backup holding a readable copy, or null when the section starts empty. */ + backupId: string | null + backupCreatedAtMs?: number +} + +export type ProfileStateSectionRepairPlan = { + profileId: string + revision: number + sections: readonly ProfileStateRepairSection[] + /** The whole profile after repair: every intact live section plus each damaged section's backup value. */ + repairedState: Record +} + +type ProfileStateLocation = { databaseFile: string; profileId: string; userDataPath?: string } + +export type ProfileStateSectionRepairResult = { + revision: number + quarantineDirectory: string + sections: readonly ProfileStateRepairSection[] +} + +/** + * Replace only the damaged sections, keeping every intact section's current value. Refuses when + * the damage or the chosen backups differ from what the user approved. + */ +export function repairProfileStateSections(options: { + maintenance: ProfileStateMaintenance + location: ProfileStateLocation & { dataFile: string } + approved?: readonly ProfileStateRecoveryRepairSection[] + beforeRepair: () => void +}): ProfileStateSectionRepairResult { + const { location } = options + options.maintenance.assertProfile(location.profileId, location.dataFile, location.databaseFile) + let plan: ProfileStateSectionRepairPlan | undefined + let reason = 'no damaged sections were found' + try { + plan = planProfileStateSectionRepair(location) + } catch (error) { + reason = error instanceof Error ? error.message : String(error) + } + if (plan === undefined) { + throw new ProfileStateRecoveryCommandError( + 'runtime_error', + `Orca can't repair this profile one section at a time (${reason}). Nothing was changed. Run \`orca profile state exports\` and restore a whole backup with \`orca profile state rollback --backup \`.` + ) + } + if (options.approved !== undefined && !sameSections(options.approved, plan.sections)) { + throw new ProfileStateRecoveryCommandError( + 'runtime_error', + 'The damaged sections or their backups changed since you chose to repair. Nothing was changed.' + ) + } + // The pre-repair file is the only copy of the damaged bytes, so no copy means no repair. + const quarantine = quarantineProfileStateDatabase( + location.databaseFile, + location.profileId, + undefined, + 'profile-state-repair-damaged-sections' + ) + options.beforeRepair() + const opened = openWritableProfileStateDatabase(location.databaseFile, location.profileId) + let revision: number + try { + revision = importProfileStateJson(opened.db, JSON.stringify(plan.repairedState), { + expectedRevision: plan.revision + }) + } finally { + opened.db.close() + } + assertProfileOpens(location, quarantine.directory) + return { revision, quarantineDirectory: quarantine.directory, sections: plan.sections } +} + +/** Run the read ordinary startup runs, so a repair that still won't open says so now. */ +function assertProfileOpens( + location: ProfileStateLocation & { dataFile: string }, + quarantineDirectory: string +): void { + const authority = new ProfileStateSqliteAuthority(location.databaseFile, location.profileId) + try { + const opened = existsSync(location.dataFile) + ? authority.readAcceptedState(readFileSync(location.dataFile, 'utf8')) + : authority.readInitialState() + if (opened === undefined) { + throw new Error('the retained JSON copy no longer matches') + } + } catch (error) { + throw new ProfileStateRecoveryCommandError( + 'runtime_error', + `Orca replaced the damaged sections, but the profile still can't be opened (${error instanceof Error ? error.message : String(error)}). The profile as it was before the repair is kept in ${quarantineDirectory}.` + ) + } finally { + authority.close() + } +} + +function sameSections( + approved: readonly ProfileStateRecoveryRepairSection[], + planned: readonly ProfileStateRepairSection[] +): boolean { + const key = (sections: readonly ProfileStateRecoveryRepairSection[]): string => + JSON.stringify(sections.map(({ domain, backupId }) => `${domain}\0${backupId}`).sort()) + return key(approved) === key(planned) +} + +/** + * Plan a repair when only some sections fail their checks; undefined means the damage isn't + * confined to sections. Whole-file damage (open, schema, profile revision) throws. + */ +export function planProfileStateSectionRepair( + location: ProfileStateLocation +): ProfileStateSectionRepairPlan | undefined { + if ( + location.userDataPath === undefined || + profileHasPendingProjectMove(location.profileId, location.userDataPath) + ) { + return undefined + } + const opened = openProfileStateDatabaseReadOnly(location.databaseFile, location.profileId) + let scan: SectionScan | undefined + try { + scan = withProfileStateReadSnapshot(opened.db, () => scanProfileStateSections(opened.db)) + } finally { + opened.db.close() + } + if (scan === undefined || scan.damaged.size === 0) { + return undefined + } + const repairedState = scan.intact + const backups = profileStateDatabaseBackups(location.databaseFile) + const resolved = new Map() + for (const backup of backups) { + const pending = [...scan.damaged].filter((domain) => !resolved.has(domain)) + if (pending.length === 0) { + break + } + let candidate: ReturnType + try { + candidate = openProfileStateDatabaseReadOnly(backup.path, location.profileId) + } catch { + continue + } + try { + for (const domain of pending) { + const read = readProfileStateDomainsWithRevisionFromDatabase(candidate.db, [domain]) + if (read.kind === 'unreadable') { + continue + } + if (read.values.has(domain)) { + repairedState.set(domain, read.values.get(domain)) + } + resolved.set(domain, { + domain, + backupId: backup.id, + backupCreatedAtMs: backup.createdAtMs + }) + } + } finally { + candidate.db.close() + } + } + const sections = [...scan.damaged].map( + (domain) => resolved.get(domain) ?? { domain, backupId: null } + ) + return { + profileId: location.profileId, + revision: scan.revision, + sections, + repairedState: Object.fromEntries(repairedState) + } +} + +type SectionScan = { revision: number; intact: Map; damaged: Set } + +function scanProfileStateSections(db: Database.Database): SectionScan | undefined { + const revision = readProfileStateRevision(db) + // Without a committed profile revision nothing can tell a damaged row from a damaged counter. + if (revision === 0) { + return undefined + } + assertProfileStateRepairRevision(db, revision) + const intact = new Map() + const damaged = new Set() + // Undefined: history is stored in its document row. Null: absent, or damaged. + let normalizedRuns: ProfileStateParsedDocument | null | undefined + try { + normalizedRuns = readProfileStateAutomationRunsDocument(db, revision, 'parsed') + } catch (error) { + if (!(error instanceof ProfileStateDocumentCorruptionError)) { + throw error + } + damaged.add('automationRuns') + normalizedRuns = null + } + const rows = db + .prepare( + `SELECT domain, payload, domain_version, revision, updated_at, content_hash + FROM profile_state_documents ORDER BY rowid` + ) + .iterate() + for (const row of rows) { + const domain = isRecord(row) && typeof row.domain === 'string' ? row.domain : undefined + let document: ReturnType + try { + document = validateProfileStateDocumentRow(row, { retainParsedValue: true }) + } catch (error) { + if (!(error instanceof ProfileStateDocumentCorruptionError) || domain === undefined) { + throw error + } + damaged.add(domain) + continue + } + if (document.domain === 'automationRuns' && normalizedRuns !== undefined) { + // Normalized history is authoritative; its document row is only a 'null' placeholder. + if (document.payload !== 'null') { + damaged.add('automationRuns') + } + continue + } + intact.set(document.domain, document.value) + } + if (normalizedRuns && !damaged.has('automationRuns')) { + intact.set('automationRuns', normalizedRuns.value) + } + return { revision, intact, damaged } +} diff --git a/src/main/persistence/profile-state/profile-state-startup-failure.ts b/src/main/persistence/profile-state/profile-state-startup-failure.ts index 4c8710cbca2..294a55250ca 100644 --- a/src/main/persistence/profile-state/profile-state-startup-failure.ts +++ b/src/main/persistence/profile-state/profile-state-startup-failure.ts @@ -1,9 +1,13 @@ +import { profileStateSectionLabels } from './profile-state-section-labels' + type ProfileStateRecoveryFailure = { code: 'profile-state-recovery-required' dataFile: string databaseFile: string exportPaths: readonly string[] backupPaths?: readonly string[] + profileId?: unknown + repairableSections?: unknown } type ProfileStateAuthorityFailure = { @@ -62,6 +66,15 @@ export function formatProfileStateStartupFailure(error: unknown): string | undef error.exportPaths.length === 0 ? ' (none found)' : error.exportPaths.map((path) => ` ${path}`).join('\n') + const damagedSections = repairableSectionDomains(error) + if (damagedSections !== undefined && typeof error.profileId === 'string') { + return [ + `Part of this profile's saved state is damaged: ${profileStateSectionLabels(damagedSections).join(', ')}. Everything else is intact.`, + `Stop Orca, then run \`orca profile state rollback --repair --profile-id ${error.profileId}\` to replace only the damaged parts from backups and keep everything else. Orca keeps a copy of the damaged file.`, + `SQLite path: ${error.databaseFile}`, + 'To restore a whole backup instead, run `orca profile state exports`, then `orca profile state rollback --backup `.' + ].join('\n') + } return [ 'Orca cannot safely open the active profile because its SQLite state is unreadable.', `Legacy JSON path: ${error.dataFile}`, @@ -122,6 +135,17 @@ function isProfileStateRecoveryFailure(error: unknown): error is ProfileStateRec ) } +function repairableSectionDomains(error: ProfileStateRecoveryFailure): string[] | undefined { + const sections = error.repairableSections + if (!Array.isArray(sections) || sections.length === 0) { + return undefined + } + const domains = sections.map((section: unknown) => + isRecord(section) && typeof section.domain === 'string' ? section.domain : undefined + ) + return domains.every((domain): domain is string => domain !== undefined) ? domains : undefined +} + function isProfileStateAuthorityFailure(error: unknown): error is ProfileStateAuthorityFailure { return ( isRecord(error) && error.code === 'ambiguous-profile-state' && typeof error.message === 'string' diff --git a/src/main/persistence/profile-state/profile-state-startup-recovery-dialog.test.ts b/src/main/persistence/profile-state/profile-state-startup-recovery-dialog.test.ts index 80c8687bbcc..63d4b8ad22b 100644 --- a/src/main/persistence/profile-state/profile-state-startup-recovery-dialog.test.ts +++ b/src/main/persistence/profile-state/profile-state-startup-recovery-dialog.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { chooseProfileStateCopy, + chooseProfileStateRepair, presentProfileStateStartupRecoveryDialog } from './profile-state-startup-recovery-dialog' @@ -96,4 +97,43 @@ describe('profile state startup recovery dialog', () => { await chooseProfileStateCopy({ showMessageBox }) expect(showMessageBox.mock.calls[0][0].detail).not.toContain('Last saved') }) + + it.each([ + { response: 0, repair: true }, + { response: 1, repair: false } + ])( + 'asks before repairing damaged sections (response $response)', + async ({ response, repair }) => { + const showMessageBox = vi.fn().mockResolvedValue({ response }) + await expect( + chooseProfileStateRepair({ + sections: [ + { domain: 'repos', backupId: '1-a', backupCreatedAtMs: 5 }, + { domain: 'settings', backupId: null } + ], + formatTime: (time) => `t${time.getTime()}`, + showMessageBox + }) + ).resolves.toBe(repair) + expect(showMessageBox).toHaveBeenCalledWith({ + type: 'warning', + buttons: ['Repair and Restart', 'Quit'], + defaultId: 0, + cancelId: 1, + noLink: true, + title: 'Repair profile', + message: "Part of this profile's saved state is damaged.", + detail: [ + 'Projects: restored from the backup saved t5.', + 'Settings: no readable backup, so it will start empty.', + '', + 'Everything else stays as it is now.', + '', + 'Projects added after that backup will be removed from Orca, along with their workspace details. Files on disk are not touched.', + '', + 'Orca keeps a copy of the damaged file in its data folder, then restarts.' + ].join('\n') + }) + } + ) }) diff --git a/src/main/persistence/profile-state/profile-state-startup-recovery-dialog.ts b/src/main/persistence/profile-state/profile-state-startup-recovery-dialog.ts index 9201ab54e17..54409a6ad82 100644 --- a/src/main/persistence/profile-state/profile-state-startup-recovery-dialog.ts +++ b/src/main/persistence/profile-state/profile-state-startup-recovery-dialog.ts @@ -2,6 +2,11 @@ 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' +import type { ProfileStateRepairSection } from './profile-state-section-repair' +import { + includesProjectListSection, + profileStateSectionLabels +} from './profile-state-section-labels' export type ProfileStateStartupRecoveryDialogDeps = { message: string @@ -98,3 +103,53 @@ export async function chooseProfileStateCopy( }) return response === 0 ? 'current-sqlite' : response === 1 ? 'current-json' : undefined } + +export type ProfileStateRepairChoiceDialogDeps = { + sections: readonly ProfileStateRepairSection[] + formatTime?: (time: Date) => string + showMessageBox: (options: MessageBoxOptions) => Promise +} + +/** Ask before replacing damaged sections with older copies; false means quit unchanged. */ +export async function chooseProfileStateRepair( + deps: ProfileStateRepairChoiceDialogDeps +): Promise { + const format = deps.formatTime ?? ((time: Date) => time.toLocaleString()) + const lines = deps.sections.map(({ domain, backupCreatedAtMs }) => { + const [label] = profileStateSectionLabels([domain]) + return backupCreatedAtMs === undefined + ? `${label}: no readable backup, so it will start empty.` + : `${label}: restored from the backup saved ${format(new Date(backupCreatedAtMs))}.` + }) + const projectSections = deps.sections.filter(({ domain }) => includesProjectListSection([domain])) + const projectNote = + projectSections.length === 0 + ? [] + : projectSections.some(({ backupCreatedAtMs }) => backupCreatedAtMs === undefined) + ? [ + 'Orca will forget your projects and their workspace details. Files on disk are not touched.', + '' + ] + : [ + 'Projects added after that backup will be removed from Orca, along with their workspace details. Files on disk are not touched.', + '' + ] + const { response } = await deps.showMessageBox({ + type: 'warning', + buttons: ['Repair and Restart', 'Quit'], + defaultId: 0, + cancelId: 1, + noLink: true, + title: 'Repair profile', + message: "Part of this profile's saved state is damaged.", + detail: [ + ...new Set(lines), + '', + 'Everything else stays as it is now.', + '', + ...projectNote, + 'Orca keeps a copy of the damaged file in its data folder, then restarts.' + ].join('\n') + }) + return response === 0 +} diff --git a/src/main/persistence/profile-state/profile-state-store-factory.ts b/src/main/persistence/profile-state/profile-state-store-factory.ts index 90d21fc2e42..19117ef02bf 100644 --- a/src/main/persistence/profile-state/profile-state-store-factory.ts +++ b/src/main/persistence/profile-state/profile-state-store-factory.ts @@ -6,6 +6,7 @@ import { bootstrapProfileStateAuthority } from './profile-state-authority-bootst import type { ProfileStateStorageClassification } from './profile-state-storage-classification' export type ProfileStateStoreFactoryOptions = { + userDataPath?: string dataFile: string databaseFile: string profileId: string diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index 807aff8be07..8f3de3f0523 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -15,6 +15,7 @@ import { getAppEnvironment } from '../../shared/app-environment' import { loadWslMoveFence } from '../persistence/moving-wsl-projects/wsl-move-scope-guard' import { runGatedWslMoveDesktopRewritePass } from '../persistence/moving-wsl-projects/wsl-move-desktop-rewrite' import { createProfileStateStoreForStartup } from '../persistence/profile-state/profile-state-startup-authority' +import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { initializeBrowserClientHostId } from '../browser/browser-client-host-id' import { scheduleSecretProtectionGapReport } from '../host/deferred-secret-protection-report' import { withholdUnreachableKeychainForServe } from '../host/serve-keychain-guard' @@ -134,6 +135,7 @@ export async function initializeReadyFoundation(): Promise { // read has to have happened by then or the renderer and the browser-host lease disagree. initializeBrowserClientHostId(profile.profileDirectory) const profileState = await createProfileStateStoreForStartup({ + userDataPath: getProfileUserDataPath(), dataFile: profile.dataFile, databaseFile: profile.stateDatabaseFile, profileId: profile.profile.id, diff --git a/src/main/startup/profile-state-recovery-preflight.test.ts b/src/main/startup/profile-state-recovery-preflight.test.ts index 0170025ff21..3c8a94af36c 100644 --- a/src/main/startup/profile-state-recovery-preflight.test.ts +++ b/src/main/startup/profile-state-recovery-preflight.test.ts @@ -14,7 +14,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { PROFILE_STATE_DESKTOP_RECOVERY_FLAG, PROFILE_STATE_RECOVERY_FLAG, - PROFILE_STATE_RECOVERY_RESULT_PREFIX + PROFILE_STATE_RECOVERY_RESULT_PREFIX, + type ProfileStateRecoveryRequest } from '../../shared/profile-state-recovery-command' import { acquireProfileStateRuntimeAdmission } from '../persistence/profile-state/profile-state-access' import { profileStateJsonExportPath } from '../persistence/profile-state/legacy-json/profile-state-export-path' @@ -291,6 +292,18 @@ describe('Electron recovery preflight', () => { }) describe('desktop choice relaunch', () => { + it('carries the captured root, failing profile and approved repair sources unchanged', () => { + const request: ProfileStateRecoveryRequest = { + userDataPath: '/captured/orca', + profileId: 'damaged-profile', + selector: { kind: 'repair', sections: [{ domain: 'repos', backupId: null }] } + } + const args = profileStateDesktopRecoveryArgs(['Orca', '--inspect'], request) + expect(JSON.parse(args[args.indexOf(PROFILE_STATE_DESKTOP_RECOVERY_FLAG) + 1])).toEqual( + request + ) + }) + function desktopArgv(item: ReturnType) { writeFileSync(item.dataFile, JSON.stringify(item.restored)) return [ diff --git a/src/main/startup/profile-state-recovery-preflight.ts b/src/main/startup/profile-state-recovery-preflight.ts index d7c9f29f69c..db50960a685 100644 --- a/src/main/startup/profile-state-recovery-preflight.ts +++ b/src/main/startup/profile-state-recovery-preflight.ts @@ -8,6 +8,7 @@ import { ProfileStateRecoveryCommandError, isProfileStateRecoveryCommandError, profileStateRecoveryRequestSchema, + type ProfileStateRecoveryRequest, type ProfileStateRecoveryResponse } from '../../shared/profile-state-recovery-command' import { acquireProfileStateMaintenance } from '../persistence/profile-state/profile-state-access' @@ -45,7 +46,7 @@ export function runProfileStateRecoveryPreflight(argv: readonly string[] = proce /** 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' } } + request: ProfileStateRecoveryRequest ): string[] { return [ ...stripRecoveryArgs(argv.slice(1)), @@ -75,7 +76,7 @@ function runDesktopRecovery(argv: readonly string[]): void { dialog.showMessageBox({ type: 'error', buttons: ['Quit'], - title: 'Orca profile state was not changed', + title: 'Orca could not finish recovering this profile', message: 'Orca could not apply the selected profile state.', detail: `${response.message}\n\nReopen Orca to choose again.` }) diff --git a/src/shared/profile-state-recovery-command.ts b/src/shared/profile-state-recovery-command.ts index ac6224c9dde..79ca1421857 100644 --- a/src/shared/profile-state-recovery-command.ts +++ b/src/shared/profile-state-recovery-command.ts @@ -6,12 +6,19 @@ export const PROFILE_STATE_RECOVERY_RESULT_PREFIX = '[profile-state-recovery] ' export const PROFILE_STATE_DESKTOP_RECOVERY_FLAG = '--profile-state-desktop-recovery' const positiveInteger = z.number().int().positive().max(Number.MAX_SAFE_INTEGER) +const repairSectionSchema = z + .object({ domain: z.string(), backupId: z.string().min(1).nullable() }) + .strict() 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('latest-json') }).strict(), - z.object({ kind: z.literal('sqlite'), backupId: z.string().min(1) }).strict() + z.object({ kind: z.literal('sqlite'), backupId: z.string().min(1) }).strict(), + // Sections: the plan the user approved; omitted, the CLI repairs whatever it finds. + z + .object({ kind: z.literal('repair'), sections: z.array(repairSectionSchema).optional() }) + .strict() ]) export const profileStateRecoveryRequestSchema = z @@ -41,7 +48,11 @@ const rollbackSchema = exportsSchema.extend({ removedDatabaseFiles: z.array(z.string()).readonly(), storage: z.enum(['json', 'sqlite']), restoredPath: z.string(), - backupId: z.string().optional() + backupId: z.string().optional(), + repairedSections: z + .array(repairSectionSchema.extend({ backupCreatedAtMs: positiveInteger.optional() })) + .readonly() + .optional() }) export const profileStateRecoveryResponseSchema = z.discriminatedUnion('ok', [ @@ -54,6 +65,7 @@ export const profileStateRecoveryResponseSchema = z.discriminatedUnion('ok', [ ]) export type ProfileStateRecoverySelector = z.infer +export type ProfileStateRecoveryRepairSection = z.infer export type ProfileStateRecoveryRequest = z.infer export type ProfileStateRecoveryResponse = z.infer export type ProfileStateExportsResult = z.infer