From fa47adc33a18221c791f26f240e9e527555b471a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Wed, 6 May 2026 23:20:21 +0800 Subject: [PATCH] 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 --- AGENTS.md | 5 + desktop/src-tauri/src/lib.rs | 31 ++- .../src/components/layout/AppShell.test.tsx | 142 +++++++++++++ desktop/src/lib/persistenceMigrations.test.ts | 58 ++++++ desktop/src/lib/persistenceMigrations.ts | 126 ++++++++++++ desktop/src/main.tsx | 12 +- desktop/src/stores/updateStore.test.ts | 29 +++ desktop/src/stores/updateStore.ts | 10 + package.json | 3 +- scripts/pr/changed-files.test.ts | 77 +++++++ scripts/pr/changed-files.ts | 70 +++++++ scripts/pr/check-pr.ts | 52 +---- scripts/pr/impact-report.ts | 52 +---- scripts/pr/quality-contract.test.ts | 5 + scripts/quality-gate/coverage.test.ts | 23 +++ scripts/quality-gate/coverage.ts | 19 +- scripts/quality-gate/modes.ts | 9 + scripts/quality-gate/persistence-upgrade.ts | 47 +++++ scripts/quality-gate/runner.test.ts | 2 + .../__tests__/persistence-upgrade.test.ts | 122 +++++++++++ src/server/__tests__/providers.test.ts | 49 +++++ src/server/__tests__/settings.test.ts | 37 ++++ src/server/api/models.ts | 8 +- src/server/index.ts | 2 + .../services/persistentStorageMigrations.ts | 194 ++++++++++++++++++ src/server/services/providerService.ts | 92 +++++++-- src/server/services/recoverableJsonFile.ts | 89 ++++++++ src/server/services/settingsService.ts | 23 ++- src/server/types/provider.ts | 1 + 29 files changed, 1247 insertions(+), 142 deletions(-) create mode 100644 desktop/src/components/layout/AppShell.test.tsx create mode 100644 desktop/src/lib/persistenceMigrations.test.ts create mode 100644 desktop/src/lib/persistenceMigrations.ts create mode 100644 scripts/pr/changed-files.test.ts create mode 100644 scripts/pr/changed-files.ts create mode 100644 scripts/quality-gate/persistence-upgrade.ts create mode 100644 src/server/__tests__/persistence-upgrade.test.ts create mode 100644 src/server/services/persistentStorageMigrations.ts create mode 100644 src/server/services/recoverableJsonFile.ts diff --git a/AGENTS.md b/AGENTS.md index 1f914dbe..b211a99e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 6b3d1af2..abc6a5ba 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -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, - ); + fn cchh_set_notification_response_callback(callback: Option); } #[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::() { 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, diff --git a/desktop/src/components/layout/AppShell.test.tsx b/desktop/src/components/layout/AppShell.test.tsx new file mode 100644 index 00000000..171c1a45 --- /dev/null +++ b/desktop/src/components/layout/AppShell.test.tsx @@ -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: () => , +})) + +vi.mock('./ContentRouter', () => ({ + ContentRouter: () =>
content loaded
, +})) + +vi.mock('./TabBar', () => ({ + TabBar: () => , +})) + +vi.mock('../shared/Toast', () => ({ + ToastContainer: () => null, +})) + +vi.mock('../shared/UpdateChecker', () => ({ + UpdateChecker: () =>
updates loaded
, +})) + +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() + + 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() + + 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() + + 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() + + await screen.findByText('sidebar loaded') + await waitFor(() => { + expect(mocks.connectToSession).toHaveBeenCalledWith('session-1') + }) + }) +}) diff --git a/desktop/src/lib/persistenceMigrations.test.ts b/desktop/src/lib/persistenceMigrations.test.ts new file mode 100644 index 00000000..2c53f821 --- /dev/null +++ b/desktop/src/lib/persistenceMigrations.test.ts @@ -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() + }) +}) diff --git a/desktop/src/lib/persistenceMigrations.ts b/desktop/src/lib/persistenceMigrations.ts new file mode 100644 index 00000000..be47ad7f --- /dev/null +++ b/desktop/src/lib/persistenceMigrations.ts @@ -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 + +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 { + 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 => 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 +} diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx index af49510a..b239923f 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -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() diff --git a/desktop/src/stores/updateStore.test.ts b/desktop/src/stores/updateStore.test.ts index 2440ba26..2d9cbf37 100644 --- a/desktop/src/stores/updateStore.test.ts +++ b/desktop/src/stores/updateStore.test.ts @@ -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) + }) }) diff --git a/desktop/src/stores/updateStore.ts b/desktop/src/stores/updateStore.ts index b610c25c..1feaa5cd 100644 --- a/desktop/src/stores/updateStore.ts +++ b/desktop/src/stores/updateStore.ts @@ -189,6 +189,7 @@ export const useUpdateStore = create((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((set, get) => ({ } }) + prepareInstallAttempted = true await invoke('prepare_for_update_install') await update.install() @@ -238,6 +240,14 @@ export const useUpdateStore = create((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', diff --git a/package.json b/package.json index d80485ed..66ed1fa9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/pr/changed-files.test.ts b/scripts/pr/changed-files.test.ts new file mode 100644 index 00000000..66f78718 --- /dev/null +++ b/scripts/pr/changed-files.test.ts @@ -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', + ]) + }) +}) diff --git a/scripts/pr/changed-files.ts b/scripts/pr/changed-files.ts new file mode 100644 index 00000000..5ddb17b5 --- /dev/null +++ b/scripts/pr/changed-files.ts @@ -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 + } + } +} diff --git a/scripts/pr/check-pr.ts b/scripts/pr/check-pr.ts index fb97a2d8..b3867573 100644 --- a/scripts/pr/check-pr.ts +++ b/scripts/pr/check-pr.ts @@ -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() diff --git a/scripts/pr/impact-report.ts b/scripts/pr/impact-report.ts index 4f848e65..8789d5c0 100644 --- a/scripts/pr/impact-report.ts +++ b/scripts/pr/impact-report.ts @@ -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) { diff --git a/scripts/pr/quality-contract.test.ts b/scripts/pr/quality-contract.test.ts index a640987c..c7ba5055 100644 --- a/scripts/pr/quality-contract.test.ts +++ b/scripts/pr/quality-contract.test.ts @@ -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') diff --git a/scripts/quality-gate/coverage.test.ts b/scripts/quality-gate/coverage.test.ts index 4f33286a..cc79287e 100644 --- a/scripts/quality-gate/coverage.test.ts +++ b/scripts/quality-gate/coverage.test.ts @@ -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', diff --git a/scripts/quality-gate/coverage.ts b/scripts/quality-gate/coverage.ts index c5d9380c..0207763a 100644 --- a/scripts/quality-gate/coverage.ts +++ b/scripts/quality-gate/coverage.ts @@ -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) } diff --git a/scripts/quality-gate/modes.ts b/scripts/quality-gate/modes.ts index b7eb9c67..86ffd5a3 100644 --- a/scripts/quality-gate/modes.ts +++ b/scripts/quality-gate/modes.ts @@ -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', diff --git a/scripts/quality-gate/persistence-upgrade.ts b/scripts/quality-gate/persistence-upgrade.ts new file mode 100644 index 00000000..9057917b --- /dev/null +++ b/scripts/quality-gate/persistence-upgrade.ts @@ -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 { + 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') diff --git a/scripts/quality-gate/runner.test.ts b/scripts/quality-gate/runner.test.ts index 91f4e83c..abee948f 100644 --- a/scripts/quality-gate/runner.test.ts +++ b/scripts/quality-gate/runner.test.ts @@ -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') diff --git a/src/server/__tests__/persistence-upgrade.test.ts b/src/server/__tests__/persistence-upgrade.test.ts new file mode 100644 index 00000000..ebebddf4 --- /dev/null +++ b/src/server/__tests__/persistence-upgrade.test.ts @@ -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> + } + 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> + } + 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 + 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) + }) +}) diff --git a/src/server/__tests__/providers.test.ts b/src/server/__tests__/providers.test.ts index 2a5f01e8..09367a0f 100644 --- a/src/server/__tests__/providers.test.ts +++ b/src/server/__tests__/providers.test.ts @@ -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 + 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() diff --git a/src/server/__tests__/settings.test.ts b/src/server/__tests__/settings.test.ts index 5737b009..035d69a6 100644 --- a/src/server/__tests__/settings.test.ts +++ b/src/server/__tests__/settings.test.ts @@ -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) diff --git a/src/server/api/models.ts b/src/server/api/models.ts index 29f07bbe..eadb2867 100644 --- a/src/server/api/models.ts +++ b/src/server/api/models.ts @@ -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 { async function handleEffort(req: Request): Promise { 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 }) } diff --git a/src/server/index.ts b/src/server/index.ts index 082fe682..45e1911e 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -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') diff --git a/src/server/services/persistentStorageMigrations.ts b/src/server/services/persistentStorageMigrations.ts new file mode 100644 index 00000000..e4188961 --- /dev/null +++ b/src/server/services/persistentStorageMigrations.ts @@ -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 + +let migrationPromise: Promise | 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 { + const backupPath = `${filePath}.${suffix}-${Date.now()}-${randomBytes(3).toString('hex')}` + await fs.copyFile(filePath, backupPath) +} + +async function writeJsonFile(filePath: string, value: unknown): Promise { + 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 { + 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 { + 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 { + 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 { + const configDir = getConfigDir() + if (!migrationPromise || migrationConfigDir !== configDir) { + migrationConfigDir = configDir + migrationPromise = runPersistentStorageMigrations(configDir) + } + return migrationPromise +} + +export function resetPersistentStorageMigrationsForTests(): void { + migrationPromise = null + migrationConfigDir = null +} diff --git a/src/server/services/providerService.ts b/src/server/services/providerService.ts index f0d55f33..2d1891bc 100644 --- a/src/server/services/providerService.ts +++ b/src/server/services/providerService.ts @@ -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 { + 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 { return PROVIDER_PRESETS.find((preset) => preset.id === presetId)?.defaultEnv ?? {} } @@ -133,15 +193,13 @@ export class ProviderService { } private async readIndex(): Promise { - 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 { @@ -160,13 +218,13 @@ export class ProviderService { } private async readSettings(): Promise> { - try { - const raw = await fs.readFile(this.getSettingsPath(), 'utf-8') - return JSON.parse(raw) as Record - } 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): Promise { diff --git a/src/server/services/recoverableJsonFile.ts b/src/server/services/recoverableJsonFile.ts new file mode 100644 index 00000000..88e3df68 --- /dev/null +++ b/src/server/services/recoverableJsonFile.ts @@ -0,0 +1,89 @@ +import * as fs from 'fs/promises' +import { randomBytes } from 'node:crypto' +import { ApiError } from '../middleware/errorHandler.js' + +type RecoverableJsonFileOptions = { + filePath: string + label: string + defaultValue: T + normalize: (value: unknown) => T | null +} + +function cloneDefault(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 { + 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 | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + return value as Record +} + +export async function readRecoverableJsonFile({ + filePath, + label, + defaultValue, + normalize, +}: RecoverableJsonFileOptions): Promise { + 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 +} diff --git a/src/server/services/settingsService.ts b/src/server/services/settingsService.ts index c2470715..84f5c529 100644 --- a/src/server/services/settingsService.ts +++ b/src/server/services/settingsService.ts @@ -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> { - try { - const raw = await fs.readFile(filePath, 'utf-8') - return JSON.parse(raw) as Record - } 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 { 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' } /** 设置权限模式 */ diff --git a/src/server/types/provider.ts b/src/server/types/provider.ts index f727f8e5..a3022036 100644 --- a/src/server/types/provider.ts +++ b/src/server/types/provider.ts @@ -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), })