cc-haha/desktop/src/lib/persistenceMigrations.ts
程序员阿江(Relakkes) 7ae885a114 fix: add safe Doctor rescue path for desktop upgrades
Online upgrades can strand users on stale desktop UI state or malformed local persistence. This adds a deny-by-default Doctor path that resets only regenerable desktop UI state, reports protected local files with redacted metadata, and keeps protected repair as a dry-run no-op until a reviewed backup-first flow exists.

Constraint: Chat transcripts, model/provider config, Skills, MCP, IM bindings, adapter sessions, OAuth tokens, plugins, and team/session records are user-owned protected state.
Rejected: Automatically rewrite malformed protected JSON | unsafe without schema-specific migrations and backups.
Rejected: Continue relying only on startup migrations | users need an explicit recovery action after a white screen.
Confidence: high
Scope-risk: moderate
Directive: Keep Doctor repair deny-by-default; do not mutate protected state without an explicit reviewed backup-first manual repair flow.
Tested: bun test src/server/__tests__/doctor-service.test.ts
Tested: cd desktop && bun run test src/components/ErrorBoundary.test.tsx src/lib/doctorRepair.test.ts src/__tests__/diagnosticsSettings.test.tsx src/components/layout/StartupErrorView.test.tsx
Tested: cd desktop && bun run lint
Tested: bun run check:server
Tested: bun run verify (failed only existing agent-utils coverage baseline; changed-lines coverage 97.62%)
Not-tested: Packaged desktop manual Doctor click path.
2026-05-07 00:04:06 +08:00

151 lines
4.7 KiB
TypeScript

export const CURRENT_DESKTOP_PERSISTENCE_SCHEMA_VERSION = 1
export const DESKTOP_PERSISTENCE_VERSION_KEY = 'cc-haha.persistence.schemaVersion'
type DesktopMigrationReport = {
migratedKeys: string[]
}
type StorageLike = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>
const TAB_STORAGE_KEY = 'cc-haha-open-tabs'
const SESSION_RUNTIME_STORAGE_KEY = 'cc-haha-session-runtime'
const THEME_STORAGE_KEY = 'cc-haha-theme'
const LOCALE_STORAGE_KEY = 'cc-haha-locale'
function readJson(storage: StorageLike, key: string): unknown {
const raw = storage.getItem(key)
if (!raw) return null
return JSON.parse(raw)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
function writeJson(storage: StorageLike, key: string, value: unknown): void {
storage.setItem(key, JSON.stringify(value))
}
function migrateTabs(storage: StorageLike, report: DesktopMigrationReport): void {
const raw = storage.getItem(TAB_STORAGE_KEY)
if (!raw) return
try {
const parsed = readJson(storage, TAB_STORAGE_KEY)
const rawTabs = Array.isArray(parsed)
? parsed
: isRecord(parsed) && Array.isArray(parsed.openTabs)
? parsed.openTabs
: []
const openTabs = rawTabs
.filter((tab): tab is Record<string, unknown> => isRecord(tab))
.filter((tab) => typeof tab.sessionId === 'string' && typeof tab.title === 'string')
.filter((tab) => tab.type !== 'terminal' && !String(tab.sessionId).startsWith('__terminal__'))
.map((tab) => ({
sessionId: tab.sessionId as string,
title: tab.title as string,
type: tab.type === 'settings' || tab.type === 'scheduled' ? tab.type : 'session',
}))
const activeTabId =
isRecord(parsed) &&
typeof parsed.activeTabId === 'string' &&
openTabs.some((tab) => tab.sessionId === parsed.activeTabId)
? parsed.activeTabId
: (openTabs[0]?.sessionId ?? null)
if (openTabs.length === 0) {
storage.removeItem(TAB_STORAGE_KEY)
} else {
writeJson(storage, TAB_STORAGE_KEY, { openTabs, activeTabId })
}
} catch {
storage.removeItem(TAB_STORAGE_KEY)
}
report.migratedKeys.push(TAB_STORAGE_KEY)
}
function migrateSessionRuntime(storage: StorageLike, report: DesktopMigrationReport): void {
const raw = storage.getItem(SESSION_RUNTIME_STORAGE_KEY)
if (!raw) return
try {
const parsed = readJson(storage, SESSION_RUNTIME_STORAGE_KEY)
if (!isRecord(parsed)) {
storage.removeItem(SESSION_RUNTIME_STORAGE_KEY)
report.migratedKeys.push(SESSION_RUNTIME_STORAGE_KEY)
return
}
const next = Object.fromEntries(
Object.entries(parsed).filter(([, selection]) => (
isRecord(selection) &&
typeof selection.modelId === 'string' &&
(selection.providerId === null || typeof selection.providerId === 'string')
)),
)
if (Object.keys(next).length === 0) {
storage.removeItem(SESSION_RUNTIME_STORAGE_KEY)
} else {
writeJson(storage, SESSION_RUNTIME_STORAGE_KEY, next)
}
if (JSON.stringify(next) !== JSON.stringify(parsed)) {
report.migratedKeys.push(SESSION_RUNTIME_STORAGE_KEY)
}
} catch {
storage.removeItem(SESSION_RUNTIME_STORAGE_KEY)
report.migratedKeys.push(SESSION_RUNTIME_STORAGE_KEY)
}
}
function normalizeEnumKey(
storage: StorageLike,
key: string,
allowedValues: string[],
report: DesktopMigrationReport,
): void {
const value = storage.getItem(key)
if (value !== null && !allowedValues.includes(value)) {
storage.removeItem(key)
report.migratedKeys.push(key)
}
}
function runMigrationStep(
report: DesktopMigrationReport,
fallbackKey: string,
step: () => void,
): void {
try {
step()
} catch {
report.migratedKeys.push(fallbackKey)
}
}
function getDefaultStorage(): StorageLike | null {
try {
return globalThis.localStorage ?? null
} catch {
return null
}
}
export function runDesktopPersistenceMigrations(storage: StorageLike | null = getDefaultStorage()): DesktopMigrationReport {
const report: DesktopMigrationReport = { migratedKeys: [] }
if (!storage) return report
runMigrationStep(report, TAB_STORAGE_KEY, () => migrateTabs(storage, report))
runMigrationStep(report, SESSION_RUNTIME_STORAGE_KEY, () => migrateSessionRuntime(storage, report))
runMigrationStep(report, THEME_STORAGE_KEY, () => normalizeEnumKey(storage, THEME_STORAGE_KEY, ['light', 'dark'], report))
runMigrationStep(report, LOCALE_STORAGE_KEY, () => normalizeEnumKey(storage, LOCALE_STORAGE_KEY, ['zh', 'en'], report))
try {
storage.setItem(DESKTOP_PERSISTENCE_VERSION_KEY, String(CURRENT_DESKTOP_PERSISTENCE_SCHEMA_VERSION))
} catch {
report.migratedKeys.push(DESKTOP_PERSISTENCE_VERSION_KEY)
}
return report
}