cc-haha/desktop/src/lib/doctorRepair.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

83 lines
2.0 KiB
TypeScript

import { doctorApi, type DoctorReportRepairResponse } from '../api/doctor'
import { DESKTOP_PERSISTENCE_VERSION_KEY } from './persistenceMigrations'
export const SAFE_DOCTOR_STORAGE_KEYS = [
'cc-haha-open-tabs',
'cc-haha-session-runtime',
'cc-haha-theme',
'cc-haha-locale',
DESKTOP_PERSISTENCE_VERSION_KEY,
] as const
type DoctorStorage = Pick<Storage, 'getItem' | 'removeItem'>
export type LocalDoctorRepairResult = {
removedKeys: string[]
missingKeys: string[]
failedKeys: string[]
}
export type DoctorRepairResult = {
local: LocalDoctorRepairResult
server: DoctorReportRepairResponse | null
serverError: string | null
}
function getDefaultDoctorStorage(): DoctorStorage | null {
try {
return globalThis.localStorage ?? null
} catch {
return null
}
}
export function runLocalDoctorRepair(storage: DoctorStorage | null = getDefaultDoctorStorage()): LocalDoctorRepairResult {
if (!storage) {
return {
removedKeys: [],
missingKeys: [...SAFE_DOCTOR_STORAGE_KEYS],
failedKeys: [],
}
}
const removedKeys: string[] = []
const missingKeys: string[] = []
const failedKeys: string[] = []
for (const key of SAFE_DOCTOR_STORAGE_KEYS) {
try {
if (storage.getItem(key) === null) {
missingKeys.push(key)
continue
}
storage.removeItem(key)
removedKeys.push(key)
} catch {
failedKeys.push(key)
}
}
return { removedKeys, missingKeys, failedKeys }
}
export async function runDoctorRepair(options?: {
includeServer?: boolean
storage?: DoctorStorage | null
}): Promise<DoctorRepairResult> {
const local = runLocalDoctorRepair(options?.storage)
if (options?.includeServer === false) {
return { local, server: null, serverError: null }
}
try {
const server = await doctorApi.reportAndRepair()
return { local, server, serverError: null }
} catch (error) {
return {
local,
server: null,
serverError: error instanceof Error ? error.message : String(error),
}
}
}