Repair only the damaged parts of a profile instead of rolling back the whole profile (#30931)

* fix(profile-state): repair only the damaged sections instead of rolling back the whole profile

One profile-state row whose checksum no longer matches made Orca refuse to open the
whole profile, and the only recovery swapped in a whole backup, losing every newer
change in every section.

Startup now checks each section on its own. When only some sections are damaged, the
dialog offers "Repair and Restart": Orca copies the damaged database aside, then replaces
just those sections with the newest backup copy that passes its checks, keeping every
intact section's current value, in one checked write. Headless starts name the damaged
sections and `orca profile state rollback --repair`. Whole-file damage keeps the existing
backup rollback.

Also turns on checkpoint_fullfsync for the profile database: on macOS plain fsync leaves
the drive cache unflushed, which can leave a long row's pages from two versions after a
power loss or forced shutdown.

Tracked in STA-10893.

* test(profile-state): pin the repair dialog's text and buttons

* fix(profile-state): list the repair module for the CLI typecheck; keep section labels out of the layout-writer count

* fix: validate section repair admission before decoding payloads

* fix(profile-state): share backup handles during section repair
This commit is contained in:
Brennan Benson
2026-10-21 16:22:44 -07:00
committed by GitHub
parent 926decd2f1
commit 10d410455e
21 changed files with 926 additions and 17 deletions
+24 -3
View File
@@ -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<ProfileStateRollbackResult['repairedSections']>[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<string, string | boolean>): void {
rejectRemoteSelectionFlags(
flags,
@@ -130,14 +144,21 @@ export const PROFILE_STATE_HANDLERS: Record<string, CommandHandler> = {
}
function parseSelector(flags: Map<string, string | boolean>): 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.`)
+5 -2
View File
@@ -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 <id> | --revision <revision> | --current-json | --current-sqlite | --latest-json) [--profile-id <id>] [--json]',
'orca profile state rollback (--repair | --backup <id> | --revision <revision> | --current-json | --current-sqlite | --latest-json) [--profile-id <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 <id>',
'orca profile state rollback --revision 1',
'orca profile state rollback --latest-json',
+29 -1
View File
@@ -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'
@@ -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',
@@ -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,
@@ -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
}
}
@@ -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(
@@ -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)
@@ -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 {
@@ -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,
@@ -0,0 +1,34 @@
const SECTION_LABELS: ReadonlyMap<string, string> = 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))
}
@@ -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<string, unknown> = {}) {
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<T>(
databaseFile: string,
write: (db: ReturnType<typeof openWritableProfileStateDatabase>['db']) => T
): T {
const opened = openWritableProfileStateDatabase(databaseFile, profileId)
try {
return write(opened.db)
} finally {
opened.db.close()
}
}
function startupFailure(profile: Parameters<typeof bootstrapProfileStateAuthority>[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<ReturnType<typeof profileWithNewerSaves>>,
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()
}
})
})
@@ -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<string, unknown>
}
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 <id>\`.`
)
}
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<string, ProfileStateRepairSection>()
for (const backup of backups) {
const pending = [...scan.damaged].filter((domain) => !resolved.has(domain))
if (pending.length === 0) {
break
}
let candidate: ReturnType<typeof openProfileStateDatabaseReadOnly>
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<string, unknown>; damaged: Set<string> }
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<string, unknown>()
const damaged = new Set<string>()
// 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<typeof validateProfileStateDocumentRow>
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 }
}
@@ -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 <id>`.'
].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'
@@ -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')
})
}
)
})
@@ -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<MessageBoxReturnValue>
}
/** Ask before replacing damaged sections with older copies; false means quit unchanged. */
export async function chooseProfileStateRepair(
deps: ProfileStateRepairChoiceDialogDeps
): Promise<boolean> {
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
}
@@ -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
@@ -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<void> {
// 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,
@@ -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<typeof fixture>) {
writeFileSync(item.dataFile, JSON.stringify(item.restored))
return [
@@ -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.`
})
+14 -2
View File
@@ -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<typeof selectorSchema>
export type ProfileStateRecoveryRepairSection = z.infer<typeof repairSectionSchema>
export type ProfileStateRecoveryRequest = z.infer<typeof profileStateRecoveryRequestSchema>
export type ProfileStateRecoveryResponse = z.infer<typeof profileStateRecoveryResponseSchema>
export type ProfileStateExportsResult = z.infer<typeof exportsSchema>