mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
b156be8d8d
commit
fa47adc33a
@ -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.
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
142
desktop/src/components/layout/AppShell.test.tsx
Normal file
142
desktop/src/components/layout/AppShell.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
58
desktop/src/lib/persistenceMigrations.test.ts
Normal file
58
desktop/src/lib/persistenceMigrations.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
126
desktop/src/lib/persistenceMigrations.ts
Normal file
126
desktop/src/lib/persistenceMigrations.ts
Normal 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
|
||||
}
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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",
|
||||
|
||||
77
scripts/pr/changed-files.test.ts
Normal file
77
scripts/pr/changed-files.test.ts
Normal 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',
|
||||
])
|
||||
})
|
||||
})
|
||||
70
scripts/pr/changed-files.ts
Normal file
70
scripts/pr/changed-files.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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>) {
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
47
scripts/quality-gate/persistence-upgrade.ts
Normal file
47
scripts/quality-gate/persistence-upgrade.ts
Normal 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')
|
||||
@ -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')
|
||||
|
||||
122
src/server/__tests__/persistence-upgrade.test.ts
Normal file
122
src/server/__tests__/persistence-upgrade.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@ -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')
|
||||
|
||||
194
src/server/services/persistentStorageMigrations.ts
Normal file
194
src/server/services/persistentStorageMigrations.ts
Normal 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
|
||||
}
|
||||
@ -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> {
|
||||
|
||||
89
src/server/services/recoverableJsonFile.ts
Normal file
89
src/server/services/recoverableJsonFile.ts
Normal 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
|
||||
}
|
||||
@ -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'
|
||||
}
|
||||
|
||||
/** 设置权限模式 */
|
||||
|
||||
@ -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),
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user