fix: prevent upgrade crashes from stale persistence

Desktop users can carry provider indexes, managed settings, localStorage state, and native update state from builds that no longer match current readers. This adds startup migrations and recovery paths before server and React state are consumed, plus a persistence upgrade gate so future storage protocol changes ship with old-format fixtures.

Constraint: Existing installs may contain malformed or legacy JSON/localStorage that must not block startup.
Constraint: Local verify should evaluate the current worktree diff rather than unrelated detached-worktree history.
Rejected: Treat invalid persisted state as fatal | reproduces white-screen and startup failure behavior for existing users.
Rejected: Bypass PR policy locally | hides real gate behavior and does not fix detached-worktree false positives.
Confidence: high
Scope-risk: moderate
Directive: Any local JSON, localStorage, or app config shape change must add a migration fixture and keep `bun run check:persistence-upgrade` green.
Tested: bun run check:persistence-upgrade; bun run check:policy; bun run check:desktop; bun run check:server; bun run check:native; bun run verify (9 passed, 1 coverage baseline failure)
Not-tested: Live provider baseline; existing user configs beyond covered fixtures
This commit is contained in:
程序员阿江(Relakkes) 2026-05-06 23:20:21 +08:00
parent b156be8d8d
commit fa47adc33a
29 changed files with 1247 additions and 142 deletions

View File

@ -41,6 +41,11 @@ Use TypeScript with 2-space indentation, ESM imports, and no semicolons to match
## Testing Guidelines
Desktop tests use Vitest with Testing Library in a `jsdom` environment. Name tests `*.test.ts` or `*.test.tsx`; colocate focused tests near the file or place broader coverage in `desktop/src/__tests__/`. Add regression tests for behavior changes and keep the coverage ratchet from dropping.
## Persistent Storage Compatibility
- Any change to local JSON, `localStorage`, or app config persistence formats must ship with a forward migration, an old-fixture regression test, and a persistence upgrade gate.
- `~/.claude/settings.json` is user-owned shared state: preserve unknown fields on read/write, merge additively, and never write a repo-owned global `schemaVersion` into it.
- If a persistence shape cannot be upgraded in place, the change is blocked until the upgrade path is explicit and tested.
## Feature Quality Contract
Every feature, bugfix, and behavior change must ship with proof that matches the changed surface. Treat this as the implementation contract for both human authors and AI coding agents.

View File

@ -54,9 +54,7 @@ mod macos_notifications {
error_buffer: *mut c_char,
error_buffer_len: usize,
) -> bool;
fn cchh_set_notification_response_callback(
callback: Option<extern "C" fn(*const c_char)>,
);
fn cchh_set_notification_response_callback(callback: Option<extern "C" fn(*const c_char)>);
}
#[derive(Clone, Serialize)]
@ -141,7 +139,10 @@ mod macos_notifications {
};
super::show_main_window(&app);
let _ = app.emit("desktop-notification-clicked", NotificationClickPayload { target });
let _ = app.emit(
"desktop-notification-clicked",
NotificationClickPayload { target },
);
}
pub fn install_click_handler(app: AppHandle) {
@ -174,7 +175,8 @@ mod macos_notifications {
title.as_ptr(),
body.as_ref()
.map_or(std::ptr::null(), |value| value.as_ptr()),
target.as_ref()
target
.as_ref()
.map_or(std::ptr::null(), |value| value.as_ptr()),
error_buffer.as_mut_ptr(),
ERROR_BUFFER_LEN,
@ -343,14 +345,28 @@ fn prepare_for_update_install(app: AppHandle) -> Result<(), String> {
Ok(())
}
fn mark_app_quitting(app: &AppHandle) {
#[tauri::command]
fn cancel_update_install(app: AppHandle) -> Result<(), String> {
clear_app_quitting(&app);
Ok(())
}
fn set_app_quitting(app: &AppHandle, next: bool) {
if let Some(state) = app.try_state::<AppExitState>() {
if let Ok(mut is_quitting) = state.is_quitting.lock() {
*is_quitting = true;
*is_quitting = next;
}
}
}
fn mark_app_quitting(app: &AppHandle) {
set_app_quitting(app, true);
}
fn clear_app_quitting(app: &AppHandle) {
set_app_quitting(app, false);
}
fn should_hide_to_tray(app: &AppHandle, label: &str) -> bool {
if label != MAIN_WINDOW_LABEL {
return false;
@ -1484,6 +1500,7 @@ pub fn run() {
get_server_url,
restart_adapters_sidecar,
prepare_for_update_install,
cancel_update_install,
terminal_spawn,
terminal_write,
terminal_resize,

View File

@ -0,0 +1,142 @@
import { render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
initializeDesktopServerUrl: vi.fn(),
fetchAll: vi.fn(),
restoreTabs: vi.fn(),
connectToSession: vi.fn(),
tabState: {
activeTabId: null as string | null,
tabs: [] as Array<{ sessionId: string; title: string; type: string; status: string }>,
},
}))
vi.mock('../../lib/desktopRuntime', () => ({
initializeDesktopServerUrl: mocks.initializeDesktopServerUrl,
isTauriRuntime: () => false,
}))
vi.mock('../../stores/settingsStore', () => ({
useSettingsStore: (selector: (state: { fetchAll: typeof mocks.fetchAll }) => unknown) =>
selector({ fetchAll: mocks.fetchAll }),
}))
vi.mock('../../stores/uiStore', () => ({
useUIStore: (selector: (state: { sidebarOpen: boolean }) => unknown) =>
selector({ sidebarOpen: true }),
}))
vi.mock('../../stores/tabStore', () => ({
SETTINGS_TAB_ID: '__settings__',
useTabStore: {
getState: () => ({
restoreTabs: mocks.restoreTabs,
activeTabId: mocks.tabState.activeTabId,
tabs: mocks.tabState.tabs,
openTab: vi.fn(),
}),
},
}))
vi.mock('../../stores/chatStore', () => ({
useChatStore: {
getState: () => ({
connectToSession: mocks.connectToSession,
}),
},
}))
vi.mock('../../hooks/useKeyboardShortcuts', () => ({
useKeyboardShortcuts: vi.fn(),
}))
vi.mock('../../i18n', () => ({
useTranslation: () => (key: string) => key,
}))
vi.mock('./Sidebar', () => ({
Sidebar: () => <aside>sidebar loaded</aside>,
}))
vi.mock('./ContentRouter', () => ({
ContentRouter: () => <section>content loaded</section>,
}))
vi.mock('./TabBar', () => ({
TabBar: () => <nav>tabs loaded</nav>,
}))
vi.mock('../shared/Toast', () => ({
ToastContainer: () => null,
}))
vi.mock('../shared/UpdateChecker', () => ({
UpdateChecker: () => <div>updates loaded</div>,
}))
import { AppShell } from './AppShell'
describe('AppShell boot flow', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.initializeDesktopServerUrl.mockResolvedValue('http://127.0.0.1:3456')
mocks.fetchAll.mockResolvedValue(undefined)
mocks.restoreTabs.mockResolvedValue(undefined)
mocks.tabState.activeTabId = null
mocks.tabState.tabs = []
})
it('renders the desktop chrome after server and settings bootstrap', async () => {
render(<AppShell />)
expect(screen.getByText('app.launching')).toBeInTheDocument()
expect(await screen.findByText('sidebar loaded')).toBeInTheDocument()
expect(screen.getByText('tabs loaded')).toBeInTheDocument()
expect(screen.getByText('content loaded')).toBeInTheDocument()
expect(screen.getByText('updates loaded')).toBeInTheDocument()
})
it('shows startup diagnostics instead of a blank shell when bootstrap fails', async () => {
mocks.fetchAll.mockRejectedValueOnce(new Error('settings file could not be read'))
render(<AppShell />)
expect(await screen.findByText('app.serverFailed')).toBeInTheDocument()
expect(screen.getByText('settings file could not be read')).toBeInTheDocument()
expect(screen.queryByText('sidebar loaded')).not.toBeInTheDocument()
})
it('keeps the app usable when persisted tab restore fails', async () => {
mocks.restoreTabs.mockRejectedValueOnce(new Error('old tab payload is invalid'))
render(<AppShell />)
expect(await screen.findByText('sidebar loaded')).toBeInTheDocument()
await waitFor(() => {
expect(mocks.restoreTabs).toHaveBeenCalled()
})
expect(screen.queryByText('app.serverFailed')).not.toBeInTheDocument()
})
it('reconnects the restored active session tab after boot', async () => {
mocks.tabState.activeTabId = 'session-1'
mocks.tabState.tabs = [
{
sessionId: 'session-1',
title: 'Existing session',
type: 'session',
status: 'idle',
},
]
render(<AppShell />)
await screen.findByText('sidebar loaded')
await waitFor(() => {
expect(mocks.connectToSession).toHaveBeenCalledWith('session-1')
})
})
})

View File

@ -0,0 +1,58 @@
import { beforeEach, describe, expect, test } from 'vitest'
import {
CURRENT_DESKTOP_PERSISTENCE_SCHEMA_VERSION,
DESKTOP_PERSISTENCE_VERSION_KEY,
runDesktopPersistenceMigrations,
} from './persistenceMigrations'
describe('desktop persistence migrations', () => {
beforeEach(() => {
window.localStorage.clear()
})
test('migrates legacy open-tab arrays into the current tab persistence shape', () => {
window.localStorage.setItem('cc-haha-open-tabs', JSON.stringify([
{ sessionId: 'session-1', title: 'Old tab' },
{ sessionId: '__terminal__legacy', title: 'Terminal 1', type: 'terminal' },
{ sessionId: 123, title: 'bad' },
]))
const report = runDesktopPersistenceMigrations()
expect(report.migratedKeys).toContain('cc-haha-open-tabs')
expect(JSON.parse(window.localStorage.getItem('cc-haha-open-tabs') || '{}')).toEqual({
openTabs: [{ sessionId: 'session-1', title: 'Old tab', type: 'session' }],
activeTabId: 'session-1',
})
expect(window.localStorage.getItem(DESKTOP_PERSISTENCE_VERSION_KEY)).toBe(String(CURRENT_DESKTOP_PERSISTENCE_SCHEMA_VERSION))
})
test('filters stale session runtime selections without clearing unrelated keys', () => {
window.localStorage.setItem('unrelated-user-key', 'keep')
window.localStorage.setItem('cc-haha-session-runtime', JSON.stringify({
good: { providerId: null, modelId: 'claude-sonnet' },
alsoGood: { providerId: 'provider-1', modelId: 'gpt-5.4' },
bad: { providerId: 'provider-2' },
}))
runDesktopPersistenceMigrations()
expect(JSON.parse(window.localStorage.getItem('cc-haha-session-runtime') || '{}')).toEqual({
alsoGood: { providerId: 'provider-1', modelId: 'gpt-5.4' },
good: { providerId: null, modelId: 'claude-sonnet' },
})
expect(window.localStorage.getItem('unrelated-user-key')).toBe('keep')
})
test('removes malformed known keys without throwing during startup', () => {
window.localStorage.setItem('cc-haha-open-tabs', '{"openTabs":')
window.localStorage.setItem('cc-haha-theme', 'sepia')
const report = runDesktopPersistenceMigrations()
expect(report.migratedKeys).toContain('cc-haha-open-tabs')
expect(report.migratedKeys).toContain('cc-haha-theme')
expect(window.localStorage.getItem('cc-haha-open-tabs')).toBeNull()
expect(window.localStorage.getItem('cc-haha-theme')).toBeNull()
})
})

View File

@ -0,0 +1,126 @@
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)
}
}
export function runDesktopPersistenceMigrations(storage: StorageLike | null = globalThis.localStorage ?? null): DesktopMigrationReport {
const report: DesktopMigrationReport = { migratedKeys: [] }
if (!storage) return report
migrateTabs(storage, report)
migrateSessionRuntime(storage, report)
normalizeEnumKey(storage, THEME_STORAGE_KEY, ['light', 'dark'], report)
normalizeEnumKey(storage, LOCALE_STORAGE_KEY, ['zh', 'en'], report)
storage.setItem(DESKTOP_PERSISTENCE_VERSION_KEY, String(CURRENT_DESKTOP_PERSISTENCE_SCHEMA_VERSION))
return report
}

View File

@ -1,11 +1,15 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { App } from './App'
import { ErrorBoundary } from './components/ErrorBoundary'
import './theme/globals.css'
import { installClientDiagnosticsCapture } from './lib/diagnosticsCapture'
import { initializeTheme } from './stores/uiStore'
import { runDesktopPersistenceMigrations } from './lib/persistenceMigrations'
runDesktopPersistenceMigrations()
const [{ App }, { ErrorBoundary }, { installClientDiagnosticsCapture }, { initializeTheme }] = await Promise.all([
import('./App'),
import('./components/ErrorBoundary'),
import('./lib/diagnosticsCapture'),
import('./stores/uiStore'),
])
initializeTheme()
installClientDiagnosticsCapture()

View File

@ -132,4 +132,33 @@ describe('updateStore', () => {
expect(useUpdateStore.getState().status).toBe('restarting')
expect(relaunch).toHaveBeenCalledTimes(1)
})
it('clears the native exit guard when install fails after sidecars stop', async () => {
const download = vi.fn(async (onEvent?: (event: unknown) => void) => {
onEvent?.({ event: 'Started', data: { contentLength: 100 } })
onEvent?.({ event: 'Finished' })
})
const install = vi.fn().mockRejectedValue(new Error('installer failed'))
check.mockResolvedValue({
version: '0.2.0',
body: 'Notes',
download,
install,
close: vi.fn().mockResolvedValue(undefined),
})
invoke.mockResolvedValue(undefined)
vi.resetModules()
const { useUpdateStore } = await import('./updateStore')
await useUpdateStore.getState().checkForUpdates()
await useUpdateStore.getState().installUpdate()
expect(invoke).toHaveBeenNthCalledWith(1, 'prepare_for_update_install')
expect(invoke).toHaveBeenNthCalledWith(2, 'cancel_update_install')
expect(useUpdateStore.getState().status).toBe('available')
expect(useUpdateStore.getState().error).toContain('installer failed')
expect(useUpdateStore.getState().shouldPrompt).toBe(true)
})
})

View File

@ -189,6 +189,7 @@ export const useUpdateStore = create<UpdateStore>((set, get) => ({
totalBytes: null,
}))
let prepareInstallAttempted = false
try {
writeDismissedUpdateVersion(null)
const { invoke } = await import('@tauri-apps/api/core')
@ -227,6 +228,7 @@ export const useUpdateStore = create<UpdateStore>((set, get) => ({
}
})
prepareInstallAttempted = true
await invoke('prepare_for_update_install')
await update.install()
@ -238,6 +240,14 @@ export const useUpdateStore = create<UpdateStore>((set, get) => ({
await relaunch()
} catch (error) {
if (prepareInstallAttempted) {
try {
const { invoke } = await import('@tauri-apps/api/core')
await invoke('cancel_update_install')
} catch {
// Best effort: keep the update prompt recoverable even if native reset fails.
}
}
set((state) => ({
...state,
status: 'available',

View File

@ -11,12 +11,13 @@
"start": "bun run ./bin/claude-haha",
"check:pr": "bun run scripts/pr/check-pr.ts",
"check:impact": "bun run scripts/pr/impact-report.ts",
"check:policy": "bun test scripts/pr/change-policy.test.ts scripts/pr/pr-triage-workflow.test.ts scripts/pr/pr-quality-workflow.test.ts scripts/pr/release-workflow.test.ts scripts/pr/quality-contract.test.ts scripts/git-hooks/install.test.ts scripts/quality-gate/quarantine.test.ts scripts/quality-gate/coverage.test.ts scripts/quality-gate/provider-smoke/execute.test.ts scripts/quality-gate/desktop-smoke/execute.test.ts scripts/quality-gate/providerTargets.test.ts scripts/quality-gate/runner.test.ts && bun run check:quarantine",
"check:policy": "bun test scripts/pr/change-policy.test.ts scripts/pr/changed-files.test.ts scripts/pr/pr-triage-workflow.test.ts scripts/pr/pr-quality-workflow.test.ts scripts/pr/release-workflow.test.ts scripts/pr/quality-contract.test.ts scripts/git-hooks/install.test.ts scripts/quality-gate/quarantine.test.ts scripts/quality-gate/coverage.test.ts scripts/quality-gate/provider-smoke/execute.test.ts scripts/quality-gate/desktop-smoke/execute.test.ts scripts/quality-gate/providerTargets.test.ts scripts/quality-gate/runner.test.ts && bun run check:quarantine",
"check:server": "bun run scripts/pr/run-server-tests.ts",
"check:desktop": "cd desktop && bun run lint && bun run test -- --run && bun run build",
"check:adapters": "cd adapters && bun test",
"check:native": "cd desktop && bun run build:sidecars && cd src-tauri && cargo check",
"check:docs": "npm ci --loglevel=error && npm run --loglevel=error docs:build",
"check:persistence-upgrade": "bun run scripts/quality-gate/persistence-upgrade.ts",
"check:quarantine": "bun run scripts/quality-gate/quarantine.ts --enforce-review-date",
"check:coverage": "bun run scripts/quality-gate/coverage.ts",
"hooks:install": "bun run scripts/git-hooks/install.ts",

View File

@ -0,0 +1,77 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { changedFilesForLocalPrCheck } from './changed-files'
let originalCwd: string
let originalBaseRef: string | undefined
let tempDir: string
function runGit(args: string[]) {
const result = Bun.spawnSync(['git', ...args], {
cwd: tempDir,
stdout: 'pipe',
stderr: 'pipe',
})
if (result.exitCode !== 0) {
throw new Error(new TextDecoder().decode(result.stderr) || new TextDecoder().decode(result.stdout))
}
}
function writeFile(relativePath: string, content: string) {
const filePath = join(tempDir, relativePath)
mkdirSync(join(filePath, '..'), { recursive: true })
writeFileSync(filePath, content)
}
function commit(message: string) {
runGit(['add', '.'])
runGit(['commit', '-m', message])
}
describe('changedFilesForLocalPrCheck', () => {
beforeEach(() => {
originalCwd = process.cwd()
originalBaseRef = process.env.PR_BASE_REF
delete process.env.PR_BASE_REF
tempDir = mkdtempSync(join(tmpdir(), 'cc-haha-changed-files-'))
process.chdir(tempDir)
runGit(['init', '-b', 'main'])
runGit(['config', 'user.email', 'test@example.com'])
runGit(['config', 'user.name', 'Test User'])
writeFile('README.md', '# test\n')
commit('base')
})
afterEach(() => {
process.chdir(originalCwd)
if (originalBaseRef === undefined) {
delete process.env.PR_BASE_REF
} else {
process.env.PR_BASE_REF = originalBaseRef
}
rmSync(tempDir, { recursive: true, force: true })
})
test('uses only local changes in a dirty detached worktree', async () => {
writeFile('scripts/quality-gate/coverage-thresholds.json', '{}\n')
commit('historical policy change')
runGit(['checkout', '--detach', 'HEAD'])
writeFile('src/server/current.ts', 'export const current = true\n')
await expect(changedFilesForLocalPrCheck()).resolves.toEqual(['src/server/current.ts'])
})
test('keeps branch commits and local changes on a normal branch', async () => {
runGit(['checkout', '-b', 'feature/test'])
writeFile('src/server/committed.ts', 'export const committed = true\n')
commit('feature change')
writeFile('desktop/src/local.ts', 'export const local = true\n')
await expect(changedFilesForLocalPrCheck()).resolves.toEqual([
'src/server/committed.ts',
'desktop/src/local.ts',
])
})
})

View File

@ -0,0 +1,70 @@
async function output(cmd: string[]) {
const proc = Bun.spawn(cmd, {
stdout: 'pipe',
stderr: 'pipe',
})
const stdout = await new Response(proc.stdout).text()
const stderr = await new Response(proc.stderr).text()
const code = await proc.exited
if (code !== 0) {
throw new Error(stderr || stdout || `Command failed: ${cmd.join(' ')}`)
}
return stdout.trim()
}
async function outputOrEmpty(cmd: string[]) {
try {
return await output(cmd)
} catch {
return ''
}
}
function splitFiles(output: string) {
return output.split(/\r?\n/).filter(Boolean)
}
function unique(files: string[]) {
return [...new Set(files.filter(Boolean))]
}
export async function localChangedFiles() {
const staged = await outputOrEmpty(['git', 'diff', '--name-only', '--cached'])
const unstaged = await outputOrEmpty(['git', 'diff', '--name-only'])
const untracked = await outputOrEmpty(['git', 'ls-files', '--others', '--exclude-standard'])
return unique([
...splitFiles(staged),
...splitFiles(unstaged),
...splitFiles(untracked),
])
}
export async function changedFilesForLocalPrCheck(explicitFiles: string[] = []) {
if (explicitFiles.length > 0) {
return unique(explicitFiles)
}
const localFiles = await localChangedFiles()
const explicitBase = process.env.PR_BASE_REF?.trim()
const branch = await outputOrEmpty(['git', 'branch', '--show-current'])
if (!explicitBase && !branch && localFiles.length > 0) {
return localFiles
}
const base = explicitBase || 'origin/main'
try {
const diff = await output(['git', 'diff', '--name-only', `${base}...HEAD`])
return unique([...splitFiles(diff), ...localFiles])
} catch {
try {
const diff = await output(['git', 'diff', '--name-only', 'main...HEAD'])
return unique([...splitFiles(diff), ...localFiles])
} catch {
return localFiles
}
}
}

View File

@ -1,6 +1,7 @@
#!/usr/bin/env bun
import { evaluateChangePolicy } from './change-policy'
import { changedFilesForLocalPrCheck } from './changed-files'
async function run(cmd: string[], options: { optional?: boolean } = {}) {
console.log(`\n$ ${cmd.join(' ')}`)
@ -17,58 +18,9 @@ async function run(cmd: string[], options: { optional?: boolean } = {}) {
return code
}
async function output(cmd: string[]) {
const proc = Bun.spawn(cmd, {
stdout: 'pipe',
stderr: 'pipe',
})
const stdout = await new Response(proc.stdout).text()
const stderr = await new Response(proc.stderr).text()
const code = await proc.exited
if (code !== 0) {
throw new Error(stderr || stdout || `Command failed: ${cmd.join(' ')}`)
}
return stdout.trim()
}
async function outputOrEmpty(cmd: string[]) {
try {
return await output(cmd)
} catch {
return ''
}
}
async function localChangedFiles() {
const staged = await outputOrEmpty(['git', 'diff', '--name-only', '--cached'])
const unstaged = await outputOrEmpty(['git', 'diff', '--name-only'])
const untracked = await outputOrEmpty(['git', 'ls-files', '--others', '--exclude-standard'])
return [...staged.split(/\r?\n/), ...unstaged.split(/\r?\n/), ...untracked.split(/\r?\n/)]
.filter(Boolean)
}
async function changedFiles() {
const explicit = process.argv.slice(2).filter((arg) => !arg.startsWith('--'))
if (explicit.length > 0) {
return explicit
}
const base = process.env.PR_BASE_REF ?? 'origin/main'
const localFiles = await localChangedFiles()
try {
const diff = await output(['git', 'diff', '--name-only', `${base}...HEAD`])
return [...new Set([...diff.split(/\r?\n/), ...localFiles].filter(Boolean))]
} catch {
try {
const diff = await output(['git', 'diff', '--name-only', 'main...HEAD'])
return [...new Set([...diff.split(/\r?\n/), ...localFiles].filter(Boolean))]
} catch {
return [...new Set(localFiles)]
}
}
return changedFilesForLocalPrCheck(explicit)
}
const files = await changedFiles()

View File

@ -1,39 +1,7 @@
#!/usr/bin/env bun
import { evaluateChangePolicy } from './change-policy'
async function output(cmd: string[]) {
const proc = Bun.spawn(cmd, {
stdout: 'pipe',
stderr: 'pipe',
})
const stdout = await new Response(proc.stdout).text()
const stderr = await new Response(proc.stderr).text()
const code = await proc.exited
if (code !== 0) {
throw new Error(stderr || stdout || `Command failed: ${cmd.join(' ')}`)
}
return stdout.trim()
}
async function outputOrEmpty(cmd: string[]) {
try {
return await output(cmd)
} catch {
return ''
}
}
async function localChangedFiles() {
const staged = await outputOrEmpty(['git', 'diff', '--name-only', '--cached'])
const unstaged = await outputOrEmpty(['git', 'diff', '--name-only'])
const untracked = await outputOrEmpty(['git', 'ls-files', '--others', '--exclude-standard'])
return [...staged.split(/\r?\n/), ...unstaged.split(/\r?\n/), ...untracked.split(/\r?\n/)]
.filter(Boolean)
}
import { changedFilesForLocalPrCheck } from './changed-files'
function parseListArg(name: string) {
const index = process.argv.indexOf(name)
@ -51,23 +19,7 @@ function parseListArg(name: string) {
async function changedFiles() {
const files = parseListArg('--files')
if (files.length > 0) {
return files
}
const base = process.env.PR_BASE_REF ?? 'origin/main'
const localFiles = await localChangedFiles()
try {
const diff = await output(['git', 'diff', '--name-only', `${base}...HEAD`])
return [...new Set([...diff.split(/\r?\n/), ...localFiles].filter(Boolean))]
} catch {
try {
const diff = await output(['git', 'diff', '--name-only', 'main...HEAD'])
return [...new Set([...diff.split(/\r?\n/), ...localFiles].filter(Boolean))]
} catch {
return [...new Set(localFiles)]
}
}
return changedFilesForLocalPrCheck(files)
}
function commandList(result: ReturnType<typeof evaluateChangePolicy>) {

View File

@ -6,6 +6,10 @@ describe('feature quality contract', () => {
const agents = readFileSync('AGENTS.md', 'utf8')
expect(agents).toContain('## Feature Quality Contract')
expect(agents).toContain('## Persistent Storage Compatibility')
expect(agents).toContain('Any change to local JSON, `localStorage`, or app config persistence formats must ship with a forward migration')
expect(agents).toContain('`~/.claude/settings.json` is user-owned shared state')
expect(agents).toContain('persistence upgrade gate')
expect(agents).toContain('Production code changes under `desktop/src`, `src/server`, `src/tools`, `src/utils`, or `adapters` must include a same-area test file')
expect(agents).toContain('Coverage is part of the feature, not an afterthought.')
expect(agents).toContain('changed executable production line must meet the changed-line coverage gate')
@ -38,6 +42,7 @@ describe('feature quality contract', () => {
expect(packageJson.scripts?.verify).toBe('bun run quality:pr')
expect(packageJson.scripts?.['quality:verify']).toBe('bun run quality:pr')
expect(packageJson.scripts?.['check:persistence-upgrade']).toBe('bun run scripts/quality-gate/persistence-upgrade.ts')
expect(contributing).toContain('bun run verify')
expect(contributing).toContain('AI Coding Agent 修复循环')
expect(englishContributing).toContain('bun run verify')

View File

@ -4,6 +4,7 @@ import {
evaluateThresholds,
parseChangedLinesFromDiff,
parseLcov,
prefixRelativeLcovSourcePaths,
} from './coverage'
describe('coverage gate helpers', () => {
@ -59,6 +60,28 @@ describe('coverage gate helpers', () => {
expect(summary.functions.pct).toBe(100)
})
test('prefixes package-relative lcov source paths for changed-line coverage', () => {
const lcov = prefixRelativeLcovSourcePaths([
'TN:',
'SF:src/stores/updateStore.ts',
'LF:1',
'LH:1',
'end_of_record',
'SF:/repo/desktop/src/main.tsx',
'LF:1',
'LH:0',
'end_of_record',
'SF:desktop/src/App.tsx',
'LF:1',
'LH:1',
'end_of_record',
].join('\n'), 'desktop')
expect(lcov).toContain('SF:desktop/src/stores/updateStore.ts')
expect(lcov).toContain('SF:/repo/desktop/src/main.tsx')
expect(lcov).toContain('SF:desktop/src/App.tsx')
})
test('evaluates changed executable line coverage', () => {
const changedLines = parseChangedLinesFromDiff([
'diff --git a/src/server/routes.ts b/src/server/routes.ts',

View File

@ -190,6 +190,21 @@ function normalizeCoveragePath(path: string, rootDir = ROOT_DIR) {
return normalized.replace(/^\.\//, '')
}
export function prefixRelativeLcovSourcePaths(content: string, prefix: string) {
const normalizedPrefix = prefix.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '')
return content.replace(/^SF:(.+)$/gm, (line, sourcePath: string) => {
const normalizedSourcePath = String(sourcePath).replace(/\\/g, '/').replace(/^\.\//, '')
if (
normalizedSourcePath.startsWith('/') ||
/^[A-Za-z]:\//.test(normalizedSourcePath) ||
normalizedSourcePath.startsWith(`${normalizedPrefix}/`)
) {
return line
}
return `SF:${normalizedPrefix}/${normalizedSourcePath}`
})
}
function matchesScope(file: string, scope: CoverageScope) {
const normalized = file.replace(/\\/g, '/')
if (!scope.includePrefixes.some((prefix) => normalized.startsWith(prefix))) {
@ -750,7 +765,7 @@ export async function runCoverageGate(options: {
suites.push(adapters)
const adaptersLcovPath = join(outputDir, 'adapters', 'lcov.info')
if (adapters.status === 'passed' && existsSync(adaptersLcovPath)) {
const adaptersLcov = readFileSync(adaptersLcovPath, 'utf8')
const adaptersLcov = prefixRelativeLcovSourcePaths(readFileSync(adaptersLcovPath, 'utf8'), 'adapters')
for (const [file, coverage] of lcovLineCoverage(adaptersLcov, 'adapters', ADAPTERS_SCOPE, rootDir)) {
coverageByFile.set(file, coverage)
}
@ -778,7 +793,7 @@ export async function runCoverageGate(options: {
suites.push(desktop)
const desktopLcovPath = join(outputDir, 'desktop', 'lcov.info')
if (desktop.status === 'passed' && existsSync(desktopLcovPath)) {
const desktopLcov = readFileSync(desktopLcovPath, 'utf8')
const desktopLcov = prefixRelativeLcovSourcePaths(readFileSync(desktopLcovPath, 'utf8'), 'desktop')
for (const [file, coverage] of lcovLineCoverage(desktopLcov, 'desktop', DESKTOP_SCOPE, rootDir)) {
coverageByFile.set(file, coverage)
}

View File

@ -81,6 +81,15 @@ export function lanesForMode(mode: QualityGateMode, baselineTargets: BaselineTar
requiredForModes: ['pr'],
category: 'docs',
},
{
id: 'persistence-upgrade',
title: 'Persistence upgrade checks',
description: 'Validate local JSON and desktop localStorage migrations against old-version fixtures.',
kind: 'command',
command: ['bun', 'run', 'check:persistence-upgrade'],
requiredForModes: ['pr', 'release'],
category: 'governance',
},
{
id: 'quarantine',
title: 'Quarantine governance',

View File

@ -0,0 +1,47 @@
#!/usr/bin/env bun
type Check = {
title: string
command: string[]
cwd?: string
}
const rootDir = process.cwd()
const checks: Check[] = [
{
title: 'Server persistent JSON migrations',
command: ['bun', 'test', 'src/server/__tests__/persistence-upgrade.test.ts'],
},
{
title: 'Desktop localStorage migrations',
command: ['bun', 'run', 'test', '--', 'src/lib/persistenceMigrations.test.ts'],
cwd: 'desktop',
},
]
async function runCheck(check: Check): Promise<number> {
const cwd = check.cwd ? `${rootDir}/${check.cwd}` : rootDir
console.log(`\n[persistence-upgrade] ${check.title}`)
console.log(`$ ${check.command.join(' ')}`)
const proc = Bun.spawn(check.command, {
cwd,
stdout: 'inherit',
stderr: 'inherit',
})
return proc.exited
}
let failures = 0
for (const check of checks) {
const code = await runCheck(check)
if (code !== 0) {
failures += 1
}
}
if (failures > 0) {
console.error(`\n[persistence-upgrade] failed checks: ${failures}`)
process.exit(1)
}
console.log('\n[persistence-upgrade] all checks passed')

View File

@ -18,6 +18,7 @@ describe('quality gate modes', () => {
expect(lanes).toContain('adapter-checks')
expect(lanes).toContain('native-checks')
expect(lanes).toContain('docs-checks')
expect(lanes).toContain('persistence-upgrade')
expect(lanes).toContain('quarantine')
expect(lanes).toContain('coverage')
expect(lanes.some((lane) => lane.startsWith('baseline:'))).toBe(false)
@ -39,6 +40,7 @@ describe('quality gate modes', () => {
const lanes = lanesForMode('release').map((lane) => lane.id)
expect(lanes).toContain('pr-checks')
expect(lanes).toContain('policy-checks')
expect(lanes).toContain('persistence-upgrade')
expect(lanes).toContain('quarantine')
expect(lanes).toContain('coverage')
expect(lanes).toContain('baseline:failing-unit:current-runtime')

View File

@ -0,0 +1,122 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import * as fs from 'fs/promises'
import * as os from 'os'
import * as path from 'path'
import { ProviderService } from '../services/providerService.js'
import {
CURRENT_PROVIDER_INDEX_SCHEMA_VERSION,
ensurePersistentStorageUpgraded,
resetPersistentStorageMigrationsForTests,
} from '../services/persistentStorageMigrations.js'
let tempDir: string
async function listFiles(dir: string) {
try {
return await fs.readdir(dir)
} catch {
return []
}
}
describe('persistent storage upgrade migrations', () => {
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-haha-persistence-'))
process.env.CLAUDE_CONFIG_DIR = tempDir
resetPersistentStorageMigrationsForTests()
})
afterEach(async () => {
resetPersistentStorageMigrationsForTests()
delete process.env.CLAUDE_CONFIG_DIR
await fs.rm(tempDir, { recursive: true, force: true })
})
test('migrates legacy providers index and writes a backup before changing it', async () => {
const ccHahaDir = path.join(tempDir, 'cc-haha')
await fs.mkdir(ccHahaDir, { recursive: true })
await fs.writeFile(
path.join(ccHahaDir, 'providers.json'),
JSON.stringify({
activeProviderId: 'provider-1',
rootFutureField: { keep: true },
providers: [{
id: 'provider-1',
presetId: 'custom',
name: 'Legacy Provider',
apiKey: 'token',
baseUrl: 'https://example.test',
models: { main: 'model-main', haiku: '', sonnet: '', opus: '' },
extraFutureField: 'keep-me',
}],
}, null, 2),
'utf-8',
)
const report = await ensurePersistentStorageUpgraded()
expect(report.failures).toEqual([])
expect(report.migratedEntries).toContain('cc-haha/providers.json')
const migrated = JSON.parse(await fs.readFile(path.join(ccHahaDir, 'providers.json'), 'utf-8')) as {
schemaVersion?: number
activeId?: string | null
activeProviderId?: string
rootFutureField?: unknown
providers?: Array<Record<string, unknown>>
}
expect(migrated.schemaVersion).toBe(CURRENT_PROVIDER_INDEX_SCHEMA_VERSION)
expect(migrated.activeId).toBe('provider-1')
expect(migrated.activeProviderId).toBeUndefined()
expect(migrated.rootFutureField).toEqual({ keep: true })
expect(migrated.providers?.[0]?.extraFutureField).toBe('keep-me')
const backups = (await listFiles(ccHahaDir)).filter((file) => file.startsWith('providers.json.bak-before-migration-'))
expect(backups.length).toBe(1)
const service = new ProviderService()
const { providers, activeId } = await service.listProviders()
expect(providers).toHaveLength(1)
expect(activeId).toBe('provider-1')
await service.updateProvider('provider-1', { name: 'Renamed Provider' })
const rewritten = JSON.parse(await fs.readFile(path.join(ccHahaDir, 'providers.json'), 'utf-8')) as {
rootFutureField?: unknown
providers?: Array<Record<string, unknown>>
}
expect(rewritten.rootFutureField).toEqual({ keep: true })
expect(rewritten.providers?.[0]?.extraFutureField).toBe('keep-me')
})
test('does not write repo-owned schema metadata into shared user settings', async () => {
await fs.writeFile(
path.join(tempDir, 'settings.json'),
JSON.stringify({
defaultMode: 'acceptEdits',
userOwnedFutureField: { nested: true },
}, null, 2),
'utf-8',
)
const report = await ensurePersistentStorageUpgraded()
expect(report.failures).toEqual([])
const settings = JSON.parse(await fs.readFile(path.join(tempDir, 'settings.json'), 'utf-8')) as Record<string, unknown>
expect(settings.schemaVersion).toBeUndefined()
expect(settings.userOwnedFutureField).toEqual({ nested: true })
})
test('quarantines malformed managed settings instead of blocking startup', async () => {
const ccHahaDir = path.join(tempDir, 'cc-haha')
await fs.mkdir(ccHahaDir, { recursive: true })
await fs.writeFile(path.join(ccHahaDir, 'settings.json'), '{"env":', 'utf-8')
const report = await ensurePersistentStorageUpgraded()
expect(report.failures).toEqual([])
expect(report.migratedEntries).toContain('cc-haha/settings.json')
expect(JSON.parse(await fs.readFile(path.join(ccHahaDir, 'settings.json'), 'utf-8'))).toEqual({})
const quarantined = (await listFiles(ccHahaDir)).filter((file) => file.startsWith('settings.json.invalid-'))
expect(quarantined.length).toBe(1)
})
})

View File

@ -94,6 +94,38 @@ describe('ProviderService', () => {
expect(result).toEqual({ providers: [], activeId: null })
})
test('should recover from a malformed providers index after an upgrade', async () => {
await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true })
await fs.writeFile(path.join(tmpDir, 'cc-haha', 'providers.json'), '{not json', 'utf-8')
const svc = new ProviderService()
const result = await svc.listProviders()
const files = await fs.readdir(path.join(tmpDir, 'cc-haha'))
expect(result).toEqual({ providers: [], activeId: null })
expect(files.some((name) => name.startsWith('providers.json.invalid-'))).toBe(true)
})
test('should normalize a legacy activeProviderId field', async () => {
await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true })
const provider = {
id: 'legacy-provider',
...sampleInput({ name: 'Legacy Provider' }),
}
await fs.writeFile(
path.join(tmpDir, 'cc-haha', 'providers.json'),
JSON.stringify({ activeProviderId: provider.id, providers: [provider] }),
'utf-8',
)
const svc = new ProviderService()
const result = await svc.listProviders()
expect(result.activeId).toBe(provider.id)
expect(result.providers).toHaveLength(1)
expect(result.providers[0].name).toBe('Legacy Provider')
})
test('should return all added providers', async () => {
const svc = new ProviderService()
await svc.addProvider(sampleInput({ name: 'Provider A' }))
@ -592,6 +624,23 @@ describe('ProviderService', () => {
expect(env.ANTHROPIC_BASE_URL).toBe('https://api.example.com')
})
test('should recover malformed managed settings before activation sync', async () => {
await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true })
await fs.writeFile(path.join(tmpDir, 'cc-haha', 'settings.json'), '{not json', 'utf-8')
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput())
await svc.activateProvider(provider.id)
const settings = await readSettings()
const env = settings.env as Record<string, string>
const files = await fs.readdir(path.join(tmpDir, 'cc-haha'))
expect(env.ANTHROPIC_BASE_URL).toBe('https://api.example.com')
expect(files.some((name) => name.startsWith('settings.json.invalid-'))).toBe(true)
})
test('should throw 404 for non-existent provider id', async () => {
const svc = new ProviderService()

View File

@ -184,6 +184,17 @@ describe('SettingsService', () => {
expect(settings).toEqual({})
})
it('should recover from malformed user settings after an upgrade', async () => {
await fs.writeFile(path.join(tmpDir, 'settings.json'), '{not json', 'utf-8')
const svc = new SettingsService()
const settings = await svc.getUserSettings()
const files = await fs.readdir(tmpDir)
expect(settings).toEqual({})
expect(files.some((name) => name.startsWith('settings.json.invalid-'))).toBe(true)
})
it('should write and read user settings', async () => {
const svc = new SettingsService()
await svc.updateUserSettings({ theme: 'dark', model: 'claude-opus-4-7' })
@ -235,6 +246,19 @@ describe('SettingsService', () => {
expect(mode).toBe('default')
})
it('should ignore stale invalid permission modes from older installs', async () => {
await fs.writeFile(
path.join(tmpDir, 'settings.json'),
JSON.stringify({ defaultMode: 'legacy-yolo' }),
'utf-8',
)
const svc = new SettingsService()
const mode = await svc.getPermissionMode()
expect(mode).toBe('default')
})
it('should set and get permission mode', async () => {
const svc = new SettingsService()
await svc.setPermissionMode('plan')
@ -542,6 +566,19 @@ describe('Models API', () => {
expect(body.available).toEqual(['low', 'medium', 'high', 'max'])
})
it('GET /api/effort should fall back when stored effort is stale', async () => {
const settingsSvc = new SettingsService()
await settingsSvc.updateUserSettings({ effort: 'turbo' })
const { req, url, segments } = makeRequest('GET', '/api/effort')
const res = await handleModelsApi(req, url, segments)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.level).toBe('medium')
expect(body.available).toEqual(['low', 'medium', 'high', 'max'])
})
it('PUT /api/effort should set effort level', async () => {
const { req, url, segments } = makeRequest('PUT', '/api/effort', { level: 'high' })
const res = await handleModelsApi(req, url, segments)

View File

@ -145,6 +145,12 @@ function getStandaloneModelList(): ApiModelInfo[] {
return models
}
function normalizeEffortLevel(value: unknown): (typeof EFFORT_LEVELS)[number] {
return typeof value === 'string' && EFFORT_LEVELS.includes(value as (typeof EFFORT_LEVELS)[number])
? value as (typeof EFFORT_LEVELS)[number]
: DEFAULT_EFFORT
}
// ─── Router ───────────────────────────────────────────────────────────────────
export async function handleModelsApi(
@ -282,7 +288,7 @@ async function handleCurrentModel(req: Request): Promise<Response> {
async function handleEffort(req: Request): Promise<Response> {
if (req.method === 'GET') {
const settings = await settingsService.getUserSettings()
const level = (settings.effort as string) || DEFAULT_EFFORT
const level = normalizeEffortLevel(settings.effort)
return Response.json({ level, available: EFFORT_LEVELS })
}

View File

@ -18,6 +18,7 @@ import { handleHahaOpenAIOAuthCallback } from './api/haha-openai-oauth.js'
import { ensureDesktopCliLauncherInstalled } from './services/desktopCliLauncherService.js'
import { enableConfigs } from '../utils/config.js'
import { diagnosticsService } from './services/diagnosticsService.js'
import { ensurePersistentStorageUpgraded } from './services/persistentStorageMigrations.js'
function readArgValue(flag: string): string | undefined {
const args = process.argv.slice(2)
@ -75,6 +76,7 @@ export function startServer(port = PORT, host = HOST) {
idleTimeout: 60,
async fetch(req, server) {
await ensurePersistentStorageUpgraded()
const url = new URL(req.url)
const origin = req.headers.get('Origin')

View File

@ -0,0 +1,194 @@
import * as fs from 'fs/promises'
import * as os from 'os'
import * as path from 'path'
import { randomBytes } from 'node:crypto'
export const CURRENT_PROVIDER_INDEX_SCHEMA_VERSION = 1
type MigrationReport = {
migratedEntries: string[]
failures: string[]
}
type JsonObject = Record<string, unknown>
let migrationPromise: Promise<MigrationReport> | null = null
let migrationConfigDir: string | null = null
function getConfigDir(): string {
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
}
function isRecord(value: unknown): value is JsonObject {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
function isProviderModels(value: unknown): value is JsonObject {
return (
isRecord(value) &&
typeof value.main === 'string' &&
typeof value.haiku === 'string' &&
typeof value.sonnet === 'string' &&
typeof value.opus === 'string'
)
}
function isSavedProvider(value: unknown): value is JsonObject {
return (
isRecord(value) &&
typeof value.id === 'string' &&
typeof value.presetId === 'string' &&
typeof value.name === 'string' &&
typeof value.apiKey === 'string' &&
typeof value.baseUrl === 'string' &&
isProviderModels(value.models)
)
}
function errnoCode(error: unknown): string | undefined {
return error && typeof error === 'object' && 'code' in error && typeof error.code === 'string'
? error.code
: undefined
}
function stableStringify(value: unknown): string {
return JSON.stringify(value, null, 2) + '\n'
}
async function readJsonFile(filePath: string): Promise<{ missing: boolean; value: unknown; raw: string }> {
try {
const raw = await fs.readFile(filePath, 'utf-8')
return { missing: false, value: JSON.parse(raw), raw }
} catch (error) {
if (errnoCode(error) === 'ENOENT') {
return { missing: true, value: undefined, raw: '' }
}
throw error
}
}
async function backupFile(filePath: string, suffix: string): Promise<void> {
const backupPath = `${filePath}.${suffix}-${Date.now()}-${randomBytes(3).toString('hex')}`
await fs.copyFile(filePath, backupPath)
}
async function writeJsonFile(filePath: string, value: unknown): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true })
const tmpPath = `${filePath}.tmp.${Date.now()}-${randomBytes(3).toString('hex')}`
try {
await fs.writeFile(tmpPath, stableStringify(value), 'utf-8')
await fs.rename(tmpPath, filePath)
} catch (error) {
await fs.unlink(tmpPath).catch(() => {})
throw error
}
}
async function quarantineMalformedFile(filePath: string): Promise<void> {
const invalidPath = `${filePath}.invalid-${Date.now()}-${randomBytes(3).toString('hex')}`
await fs.rename(filePath, invalidPath)
}
function migrateProvidersIndex(value: unknown): JsonObject {
if (!isRecord(value) || !Array.isArray(value.providers)) {
return {
schemaVersion: CURRENT_PROVIDER_INDEX_SCHEMA_VERSION,
activeId: null,
providers: [],
}
}
const { activeProviderId: _legacyActiveProviderId, ...rest } = value
const providers = value.providers.filter(isSavedProvider)
const rawActiveId =
typeof value.activeId === 'string'
? value.activeId
: typeof _legacyActiveProviderId === 'string'
? _legacyActiveProviderId
: null
const activeId = rawActiveId && providers.some((provider) => provider.id === rawActiveId)
? rawActiveId
: null
return {
...rest,
schemaVersion: CURRENT_PROVIDER_INDEX_SCHEMA_VERSION,
activeId,
providers,
}
}
function migrateManagedSettings(value: unknown): JsonObject {
if (!isRecord(value)) return {}
if (value.env !== undefined && !isRecord(value.env)) {
return { ...value, env: {} }
}
return value
}
async function migrateJsonEntry(
filePath: string,
entryName: string,
report: MigrationReport,
migrate: (value: unknown) => JsonObject,
): Promise<void> {
try {
const current = await readJsonFile(filePath)
if (current.missing) return
const migrated = migrate(current.value)
if (stableStringify(migrated) === stableStringify(current.value)) return
await backupFile(filePath, 'bak-before-migration')
await writeJsonFile(filePath, migrated)
report.migratedEntries.push(entryName)
} catch (error) {
if (error instanceof SyntaxError) {
try {
await quarantineMalformedFile(filePath)
await writeJsonFile(filePath, {})
report.migratedEntries.push(entryName)
return
} catch (recoveryError) {
report.failures.push(`${entryName}: ${recoveryError instanceof Error ? recoveryError.message : String(recoveryError)}`)
return
}
}
report.failures.push(`${entryName}: ${error instanceof Error ? error.message : String(error)}`)
}
}
async function runPersistentStorageMigrations(configDir: string): Promise<MigrationReport> {
const report: MigrationReport = { migratedEntries: [], failures: [] }
const ccHahaDir = path.join(configDir, 'cc-haha')
await migrateJsonEntry(
path.join(ccHahaDir, 'providers.json'),
'cc-haha/providers.json',
report,
migrateProvidersIndex,
)
await migrateJsonEntry(
path.join(ccHahaDir, 'settings.json'),
'cc-haha/settings.json',
report,
migrateManagedSettings,
)
return report
}
export function ensurePersistentStorageUpgraded(): Promise<MigrationReport> {
const configDir = getConfigDir()
if (!migrationPromise || migrationConfigDir !== configDir) {
migrationConfigDir = configDir
migrationPromise = runPersistentStorageMigrations(configDir)
}
return migrationPromise
}
export function resetPersistentStorageMigrationsForTests(): void {
migrationPromise = null
migrationConfigDir = null
}

View File

@ -10,6 +10,7 @@ import * as fs from 'fs/promises'
import * as path from 'path'
import * as os from 'os'
import { ApiError } from '../middleware/errorHandler.js'
import { normalizeJsonObject, readRecoverableJsonFile } from './recoverableJsonFile.js'
import { anthropicToOpenaiChat } from '../proxy/transform/anthropicToOpenaiChat.js'
import { anthropicToOpenaiResponses } from '../proxy/transform/anthropicToOpenaiResponses.js'
import { openaiChatToAnthropic } from '../proxy/transform/openaiChatToAnthropic.js'
@ -17,6 +18,10 @@ import { openaiResponsesToAnthropic } from '../proxy/transform/openaiResponsesTo
import type { AnthropicRequest, AnthropicResponse } from '../proxy/transform/types.js'
import { PROVIDER_PRESETS } from '../config/providerPresets.js'
import { MODEL_CONTEXT_WINDOWS_ENV_KEY } from '../../utils/model/modelContextWindows.js'
import {
CURRENT_PROVIDER_INDEX_SCHEMA_VERSION,
ensurePersistentStorageUpgraded,
} from './persistentStorageMigrations.js'
import type {
SavedProvider,
ProvidersIndex,
@ -46,9 +51,64 @@ const MANAGED_ENV_KEYS = [
const CUSTOM_PROVIDER_MODEL_CAPABILITIES = 'thinking,effort,adaptive_thinking,max_effort'
const DEFAULT_INDEX: ProvidersIndex = { activeId: null, providers: [] }
const DEFAULT_INDEX: ProvidersIndex = {
schemaVersion: CURRENT_PROVIDER_INDEX_SCHEMA_VERSION,
activeId: null,
providers: [],
}
const AUTH_ENV_KEYS = new Set(['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN'])
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
function isProviderModels(value: unknown): value is SavedProvider['models'] {
return (
isRecord(value) &&
typeof value.main === 'string' &&
typeof value.haiku === 'string' &&
typeof value.sonnet === 'string' &&
typeof value.opus === 'string'
)
}
function isSavedProvider(value: unknown): value is SavedProvider {
if (!isRecord(value)) return false
return (
typeof value.id === 'string' &&
typeof value.presetId === 'string' &&
typeof value.name === 'string' &&
typeof value.apiKey === 'string' &&
typeof value.baseUrl === 'string' &&
isProviderModels(value.models)
)
}
function normalizeProvidersIndex(value: unknown): ProvidersIndex | null {
if (!isRecord(value) || !Array.isArray(value.providers)) {
return null
}
const { activeProviderId: _legacyActiveProviderId, ...rest } = value
const providers = value.providers.filter(isSavedProvider)
const rawActiveId =
typeof value.activeId === 'string'
? value.activeId
: typeof _legacyActiveProviderId === 'string'
? _legacyActiveProviderId
: null
const activeId = rawActiveId && providers.some((provider) => provider.id === rawActiveId)
? rawActiveId
: null
return {
...rest,
schemaVersion: CURRENT_PROVIDER_INDEX_SCHEMA_VERSION,
activeId,
providers,
}
}
function getPresetDefaultEnv(presetId: string): Record<string, string> {
return PROVIDER_PRESETS.find((preset) => preset.id === presetId)?.defaultEnv ?? {}
}
@ -133,15 +193,13 @@ export class ProviderService {
}
private async readIndex(): Promise<ProvidersIndex> {
try {
const raw = await fs.readFile(this.getIndexPath(), 'utf-8')
return JSON.parse(raw) as ProvidersIndex
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return { ...DEFAULT_INDEX, providers: [] }
}
throw ApiError.internal(`Failed to read providers index: ${err}`)
}
await ensurePersistentStorageUpgraded()
return readRecoverableJsonFile({
filePath: this.getIndexPath(),
label: 'providers index',
defaultValue: DEFAULT_INDEX,
normalize: normalizeProvidersIndex,
})
}
private async writeIndex(index: ProvidersIndex): Promise<void> {
@ -160,13 +218,13 @@ export class ProviderService {
}
private async readSettings(): Promise<Record<string, unknown>> {
try {
const raw = await fs.readFile(this.getSettingsPath(), 'utf-8')
return JSON.parse(raw) as Record<string, unknown>
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {}
throw ApiError.internal(`Failed to read settings.json: ${err}`)
}
await ensurePersistentStorageUpgraded()
return readRecoverableJsonFile({
filePath: this.getSettingsPath(),
label: 'cc-haha managed settings',
defaultValue: {},
normalize: normalizeJsonObject,
})
}
private async writeSettings(settings: Record<string, unknown>): Promise<void> {

View File

@ -0,0 +1,89 @@
import * as fs from 'fs/promises'
import { randomBytes } from 'node:crypto'
import { ApiError } from '../middleware/errorHandler.js'
type RecoverableJsonFileOptions<T> = {
filePath: string
label: string
defaultValue: T
normalize: (value: unknown) => T | null
}
function cloneDefault<T>(value: T): T {
if (value && typeof value === 'object') {
return JSON.parse(JSON.stringify(value)) as T
}
return value
}
function errnoCode(error: unknown): string | undefined {
return error && typeof error === 'object' && 'code' in error && typeof error.code === 'string'
? error.code
: undefined
}
async function quarantineInvalidJsonFile(
filePath: string,
label: string,
reason: string,
): Promise<void> {
const backupPath = `${filePath}.invalid-${Date.now()}-${randomBytes(3).toString('hex')}`
try {
await fs.rename(filePath, backupPath)
console.warn(`[desktop] Recovered invalid ${label}; moved ${filePath} to ${backupPath}: ${reason}`)
} catch (error) {
console.warn(
`[desktop] Recovered invalid ${label} from ${filePath}, but failed to quarantine it: ${
error instanceof Error ? error.message : String(error)
}`,
)
}
}
export function normalizeJsonObject(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null
}
return value as Record<string, unknown>
}
export async function readRecoverableJsonFile<T>({
filePath,
label,
defaultValue,
normalize,
}: RecoverableJsonFileOptions<T>): Promise<T> {
let raw: string
try {
raw = await fs.readFile(filePath, 'utf-8')
} catch (error) {
if (errnoCode(error) === 'ENOENT') {
return cloneDefault(defaultValue)
}
throw ApiError.internal(`Failed to read ${label} from ${filePath}: ${error}`)
}
if (raw.trim() === '') {
return cloneDefault(defaultValue)
}
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch (error) {
await quarantineInvalidJsonFile(
filePath,
label,
error instanceof Error ? error.message : String(error),
)
return cloneDefault(defaultValue)
}
const normalized = normalize(parsed)
if (normalized === null) {
await quarantineInvalidJsonFile(filePath, label, 'unexpected JSON shape')
return cloneDefault(defaultValue)
}
return normalized
}

View File

@ -13,6 +13,8 @@ import { randomBytes } from 'node:crypto'
import * as path from 'path'
import * as os from 'os'
import { ApiError } from '../middleware/errorHandler.js'
import { normalizeJsonObject, readRecoverableJsonFile } from './recoverableJsonFile.js'
import { ensurePersistentStorageUpgraded } from './persistentStorageMigrations.js'
const VALID_PERMISSION_MODES = [
'default',
@ -57,15 +59,13 @@ export class SettingsService {
/** 安全读取 JSON 文件,文件不存在时返回空对象 */
private async readJsonFile(filePath: string): Promise<Record<string, unknown>> {
try {
const raw = await fs.readFile(filePath, 'utf-8')
return JSON.parse(raw) as Record<string, unknown>
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return {}
}
throw ApiError.internal(`Failed to read settings from ${filePath}: ${err}`)
}
await ensurePersistentStorageUpgraded()
return readRecoverableJsonFile({
filePath,
label: 'settings',
defaultValue: {},
normalize: normalizeJsonObject,
})
}
/** 获取合并后的设置user + project */
@ -178,7 +178,10 @@ export class SettingsService {
/** 获取当前权限模式 */
async getPermissionMode(): Promise<string> {
const settings = await this.getUserSettings()
return (settings.defaultMode as string) || 'default'
const mode = settings.defaultMode
return typeof mode === 'string' && VALID_PERMISSION_MODES.includes(mode as PermissionMode)
? mode
: 'default'
}
/** 设置权限模式 */

View File

@ -51,6 +51,7 @@ export const SavedProviderSchema = z.object({
})
export const ProvidersIndexSchema = z.object({
schemaVersion: z.number().int().positive().optional(),
activeId: z.string().nullable(),
providers: z.array(SavedProviderSchema),
})