mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
fix(diagnostics): harden diagnostics and recovery flows
Make diagnostics evidence bounded, share-safe, and resilient to concurrent writers and corrupt segments. Improve Doctor repair safeguards, Electron runtime recovery, Settings visibility, accessibility, and issue reporting.
This commit is contained in:
parent
347dbc3368
commit
5871188084
13
.github/ISSUE_TEMPLATE/bug_report.md
vendored
13
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -13,6 +13,7 @@ assignees: ''
|
||||
- [ ] 我已经升级到最新版本后复现过这个问题
|
||||
- [ ] 我已经搜索过[现有 issues](https://github.com/NanmiCoder/cc-haha/issues),确认没有重复问题
|
||||
- [ ] 我已经隐藏截图和日志中的 API Key、Token、Cookie 等敏感信息
|
||||
- [ ] 我已在分享前检查自动生成的诊断报告或诊断包,确认其中没有不应公开的私密元数据
|
||||
|
||||
## 问题描述
|
||||
<!-- 请用 1-3 句话说明实际发生了什么、你原本期望发生什么 -->
|
||||
@ -56,6 +57,16 @@ assignees: ''
|
||||
在此粘贴错误信息和日志
|
||||
```
|
||||
|
||||
## 诊断信息(可选)
|
||||
<!--
|
||||
桌面端可在「设置 -> 诊断」中复制 Event ID;复制的 Issue 报告是 Markdown,请粘贴到 Issue 正文中。导出的诊断包是文件,请作为附件上传。
|
||||
自动生成的报告和诊断包采用尽力脱敏;请在公开分享前自行检查其中的路径、主机名及其他私密元数据。
|
||||
-->
|
||||
|
||||
- 相关 Event IDs:
|
||||
- 已粘贴到 Issue 正文的诊断报告: 是 / 否
|
||||
- 诊断包文件附件:
|
||||
|
||||
## 截图或录屏
|
||||
<!-- 请提供错误截图、配置截图或复现录屏;截图前请隐藏敏感信息 -->
|
||||
|
||||
@ -65,5 +76,3 @@ assignees: ''
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import path from 'node:path'
|
||||
import { ELECTRON_EVENT_CHANNELS, ELECTRON_INTERNAL_CHANNELS, ELECTRON_IPC_CHANNELS, type ElectronIpcChannel } from './ipc/channels'
|
||||
import { isElectronIpcChannel, validateElectronIpcPayload } from './ipc/capabilities'
|
||||
import { ElectronServerRuntime } from './services/serverRuntime'
|
||||
import { electronHostDiagnosticsFile } from './services/sidecarManager'
|
||||
import { openDialog, saveDialog } from './services/dialogs'
|
||||
import { openExternalUrl, openSystemPath, openSystemSettingsUrl } from './services/shell'
|
||||
import {
|
||||
@ -139,6 +140,7 @@ function getServerRuntime() {
|
||||
desktopRoot: unpackedRoot(),
|
||||
appRoot: appRoot(),
|
||||
h5DistDir: path.join(unpackedRoot(), 'dist'),
|
||||
diagnosticsFile: electronHostDiagnosticsFile(process.env),
|
||||
resolveSystemProxy: (url) => session.defaultSession.resolveProxy(url),
|
||||
})
|
||||
return serverRuntime
|
||||
|
||||
334
desktop/electron/services/serverRuntime.test.ts
Normal file
334
desktop/electron/services/serverRuntime.test.ts
Normal file
@ -0,0 +1,334 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SidecarChild, SidecarPlan } from './sidecarManager'
|
||||
import { ElectronServerRuntime } from './serverRuntime'
|
||||
|
||||
const sidecarMocks = {
|
||||
nextPort: 49321,
|
||||
spawnError: null as Error | null,
|
||||
serverChildren: [] as FakeSidecarChild[],
|
||||
adapterChildren: [] as FakeSidecarChild[],
|
||||
serverPlans: [] as SidecarPlan[],
|
||||
appendHostDiagnostic: vi.fn(),
|
||||
waitForServerImpl: () => Promise.resolve(),
|
||||
onAdapterSpawn: null as (() => void) | null,
|
||||
spawnSidecar: vi.fn((plan: SidecarPlan) => {
|
||||
if (plan.args[0] === 'server' && sidecarMocks.spawnError) throw sidecarMocks.spawnError
|
||||
const child = new FakeSidecarChild()
|
||||
if (plan.args[0] === 'server') {
|
||||
sidecarMocks.serverChildren.push(child)
|
||||
sidecarMocks.serverPlans.push(plan)
|
||||
} else {
|
||||
sidecarMocks.adapterChildren.push(child)
|
||||
sidecarMocks.onAdapterSpawn?.()
|
||||
}
|
||||
return child as unknown as SidecarChild
|
||||
}),
|
||||
}
|
||||
|
||||
let isolatedConfigDir = ''
|
||||
|
||||
class FakeSidecarChild extends EventEmitter {
|
||||
readonly stdout = new PassThrough()
|
||||
readonly stderr = new PassThrough()
|
||||
readonly kill = vi.fn()
|
||||
}
|
||||
|
||||
function createRuntime(options: { appRoot?: string, diagnosticsFile?: string } = {}) {
|
||||
return new ElectronServerRuntime({
|
||||
desktopRoot: '/isolated/desktop',
|
||||
appRoot: options.appRoot,
|
||||
diagnosticsFile: options.diagnosticsFile,
|
||||
env: { CLAUDE_CONFIG_DIR: isolatedConfigDir },
|
||||
deps: {
|
||||
appendHostDiagnostic: sidecarMocks.appendHostDiagnostic,
|
||||
preferredServerPorts: () => [],
|
||||
reserveServerPort: async () => sidecarMocks.nextPort++,
|
||||
spawnSidecar: sidecarMocks.spawnSidecar,
|
||||
waitForServer: async () => await sidecarMocks.waitForServerImpl(),
|
||||
writeLastServerPort: () => undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForServerChildren(count: number): Promise<void> {
|
||||
for (let attempt = 0; attempt < 20 && sidecarMocks.serverChildren.length !== count; attempt++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
expect(sidecarMocks.serverChildren).toHaveLength(count)
|
||||
}
|
||||
|
||||
describe('ElectronServerRuntime', () => {
|
||||
beforeEach(() => {
|
||||
isolatedConfigDir = mkdtempSync(path.join(tmpdir(), 'cc-haha-electron-runtime-'))
|
||||
sidecarMocks.nextPort = 49321
|
||||
sidecarMocks.spawnError = null
|
||||
sidecarMocks.serverChildren.length = 0
|
||||
sidecarMocks.adapterChildren.length = 0
|
||||
sidecarMocks.serverPlans.length = 0
|
||||
sidecarMocks.appendHostDiagnostic.mockClear()
|
||||
sidecarMocks.waitForServerImpl = () => Promise.resolve()
|
||||
sidecarMocks.onAdapterSpawn = null
|
||||
sidecarMocks.spawnSidecar.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(isolatedConfigDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('restarts after the active healthy server exits and ignores its late exit', async () => {
|
||||
const runtime = createRuntime({
|
||||
appRoot: '/isolated/app',
|
||||
})
|
||||
|
||||
const firstUrl = await runtime.getServerUrl()
|
||||
const firstChild = sidecarMocks.serverChildren[0]!
|
||||
const firstAdapters = [...sidecarMocks.adapterChildren]
|
||||
expect(firstAdapters).toHaveLength(5)
|
||||
firstChild.emit('exit', 7, null)
|
||||
|
||||
const [secondUrl, coalescedUrl] = await Promise.all([
|
||||
runtime.getServerUrl(),
|
||||
runtime.getServerUrl(),
|
||||
])
|
||||
const secondChild = sidecarMocks.serverChildren[1]!
|
||||
firstChild.emit('exit', 9, 'SIGTERM')
|
||||
|
||||
expect(firstUrl).toBe('http://127.0.0.1:49321')
|
||||
expect(secondUrl).toBe('http://127.0.0.1:49322')
|
||||
expect(coalescedUrl).toBe(secondUrl)
|
||||
expect(sidecarMocks.serverChildren).toHaveLength(2)
|
||||
expect(sidecarMocks.adapterChildren).toHaveLength(10)
|
||||
for (const adapter of firstAdapters) expect(adapter.kill).toHaveBeenCalledTimes(1)
|
||||
for (const adapter of sidecarMocks.adapterChildren.slice(5)) {
|
||||
expect(adapter.kill).not.toHaveBeenCalled()
|
||||
}
|
||||
expect(await runtime.getServerUrl()).toBe(secondUrl)
|
||||
expect(secondChild).toBeDefined()
|
||||
})
|
||||
|
||||
it('passes the isolated Electron host diagnostics file to the server sidecar', async () => {
|
||||
const runtime = createRuntime({
|
||||
diagnosticsFile: '/isolated/user-data/diagnostics/electron-host.log',
|
||||
})
|
||||
|
||||
await runtime.startServer()
|
||||
|
||||
expect(sidecarMocks.serverPlans[0]!.env.CC_HAHA_ELECTRON_DIAGNOSTICS_FILE)
|
||||
.toBe('/isolated/user-data/diagnostics/electron-host.log')
|
||||
expect(sidecarMocks.serverPlans[0]!.env.CLAUDE_CONFIG_DIR).toBe(isolatedConfigDir)
|
||||
expect(sidecarMocks.serverPlans[0]!.env.CLAUDE_CONFIG_DIR)
|
||||
.not.toBe(path.join(homedir(), '.claude'))
|
||||
})
|
||||
|
||||
it('persists a server startup failure through the sanitized host-log boundary', async () => {
|
||||
sidecarMocks.spawnError = new Error('spawn failed')
|
||||
const runtime = createRuntime({
|
||||
diagnosticsFile: '/isolated/user-data/diagnostics/electron-host.log',
|
||||
})
|
||||
|
||||
await expect(runtime.startServer()).rejects.toThrow('spawn failed')
|
||||
|
||||
expect(sidecarMocks.appendHostDiagnostic).toHaveBeenCalledWith(
|
||||
'/isolated/user-data/diagnostics/electron-host.log',
|
||||
expect.stringContaining('[startup-error] spawn failed'),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects an in-flight start when the child exits before health publication', async () => {
|
||||
sidecarMocks.waitForServerImpl = () => new Promise(() => undefined)
|
||||
const runtime = createRuntime()
|
||||
|
||||
const starting = runtime.startServer()
|
||||
await waitForServerChildren(1)
|
||||
sidecarMocks.serverChildren[0]!.emit('exit', 17, null)
|
||||
|
||||
await expect(starting).rejects.toThrow('code=17, signal=null')
|
||||
sidecarMocks.waitForServerImpl = () => Promise.resolve()
|
||||
await expect(runtime.getServerUrl()).resolves.toBe('http://127.0.0.1:49322')
|
||||
expect(sidecarMocks.serverChildren).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('kills the attempted server child when the health wait rejects', async () => {
|
||||
sidecarMocks.waitForServerImpl = () => Promise.reject(new Error('health wait timed out'))
|
||||
const runtime = createRuntime()
|
||||
|
||||
await expect(runtime.startServer()).rejects.toThrow('health wait timed out')
|
||||
|
||||
expect(sidecarMocks.serverChildren).toHaveLength(1)
|
||||
expect(sidecarMocks.serverChildren[0]!.kill).toHaveBeenCalledTimes(1)
|
||||
expect(sidecarMocks.adapterChildren).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('kills an unpublished server exactly once when stopAll runs during health wait', async () => {
|
||||
let releaseHealth!: () => void
|
||||
sidecarMocks.waitForServerImpl = () => new Promise<void>(resolve => {
|
||||
releaseHealth = resolve
|
||||
})
|
||||
const runtime = createRuntime()
|
||||
|
||||
const starting = runtime.startServer()
|
||||
await waitForServerChildren(1)
|
||||
runtime.stopAll(true)
|
||||
|
||||
expect(sidecarMocks.serverChildren[0]!.kill).toHaveBeenCalledTimes(1)
|
||||
await expect(starting).rejects.toThrow('stopped')
|
||||
releaseHealth()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(sidecarMocks.serverChildren).toHaveLength(1)
|
||||
expect(sidecarMocks.adapterChildren).toHaveLength(0)
|
||||
expect(sidecarMocks.serverChildren[0]!.kill).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stops active adapters immediately when the server exits without restart demand', async () => {
|
||||
const runtime = createRuntime()
|
||||
await runtime.startServer()
|
||||
const activeAdapters = [...sidecarMocks.adapterChildren]
|
||||
|
||||
sidecarMocks.serverChildren[0]!.emit('exit', 19, null)
|
||||
|
||||
for (const adapter of activeAdapters) {
|
||||
expect(adapter.kill).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
expect(sidecarMocks.serverChildren).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('stops active adapters immediately when the server emits a process error', async () => {
|
||||
const runtime = createRuntime()
|
||||
await runtime.startServer()
|
||||
const activeAdapters = [...sidecarMocks.adapterChildren]
|
||||
|
||||
sidecarMocks.serverChildren[0]!.emit('error', new Error('active server failed'))
|
||||
|
||||
for (const adapter of activeAdapters) {
|
||||
expect(adapter.kill).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not let a stale server exit stop replacement adapters', async () => {
|
||||
const runtime = createRuntime()
|
||||
await runtime.startServer()
|
||||
const firstServer = sidecarMocks.serverChildren[0]!
|
||||
firstServer.emit('exit', 20, null)
|
||||
await runtime.getServerUrl()
|
||||
const replacementAdapters = sidecarMocks.adapterChildren.slice(5)
|
||||
|
||||
firstServer.emit('exit', 21, 'SIGTERM')
|
||||
|
||||
expect(replacementAdapters).toHaveLength(5)
|
||||
for (const adapter of replacementAdapters) {
|
||||
expect(adapter.kill).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it('stops the current adapter generation after an explicit adapter restart', async () => {
|
||||
const runtime = createRuntime()
|
||||
await runtime.startServer()
|
||||
const firstAdapters = [...sidecarMocks.adapterChildren]
|
||||
|
||||
await runtime.restartAdaptersSidecars()
|
||||
const restartedAdapters = sidecarMocks.adapterChildren.slice(5)
|
||||
sidecarMocks.serverChildren[0]!.emit('exit', 22, null)
|
||||
|
||||
for (const adapter of firstAdapters) {
|
||||
expect(adapter.kill).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
for (const adapter of restartedAdapters) {
|
||||
expect(adapter.kill).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('coalesces overlapping manual adapter restarts into one live generation', async () => {
|
||||
const runtime = createRuntime()
|
||||
await runtime.startServer()
|
||||
const originalAdapters = [...sidecarMocks.adapterChildren]
|
||||
|
||||
const firstRestart = runtime.restartAdaptersSidecars()
|
||||
const secondRestart = runtime.restartAdaptersSidecars()
|
||||
|
||||
expect(secondRestart).toBe(firstRestart)
|
||||
await Promise.all([firstRestart, secondRestart])
|
||||
expect(sidecarMocks.adapterChildren).toHaveLength(10)
|
||||
for (const adapter of originalAdapters) {
|
||||
expect(adapter.kill).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
for (const adapter of sidecarMocks.adapterChildren.slice(5)) {
|
||||
expect(adapter.kill).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it('cancels a manual adapter restart when its server exits after the first spawn', async () => {
|
||||
const runtime = createRuntime()
|
||||
await runtime.startServer()
|
||||
const firstServer = sidecarMocks.serverChildren[0]!
|
||||
const originalAdapters = [...sidecarMocks.adapterChildren]
|
||||
sidecarMocks.onAdapterSpawn = () => {
|
||||
sidecarMocks.onAdapterSpawn = null
|
||||
firstServer.emit('exit', 23, null)
|
||||
}
|
||||
|
||||
await runtime.restartAdaptersSidecars()
|
||||
|
||||
expect(sidecarMocks.adapterChildren).toHaveLength(6)
|
||||
for (const adapter of originalAdapters) {
|
||||
expect(adapter.kill).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
expect(sidecarMocks.adapterChildren[5]!.kill).toHaveBeenCalledTimes(1)
|
||||
|
||||
await expect(runtime.getServerUrl()).resolves.toBe('http://127.0.0.1:49322')
|
||||
expect(sidecarMocks.serverChildren).toHaveLength(2)
|
||||
expect(sidecarMocks.adapterChildren).toHaveLength(11)
|
||||
for (const adapter of sidecarMocks.adapterChildren.slice(6)) {
|
||||
expect(adapter.kill).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects when the published child exits during adapter startup', async () => {
|
||||
const runtime = createRuntime()
|
||||
sidecarMocks.onAdapterSpawn = () => {
|
||||
sidecarMocks.onAdapterSpawn = null
|
||||
sidecarMocks.serverChildren[0]!.emit('exit', 18, 'SIGTERM')
|
||||
}
|
||||
|
||||
await expect(runtime.startServer()).rejects.toThrow('code=18, signal=SIGTERM')
|
||||
|
||||
expect(sidecarMocks.adapterChildren).toHaveLength(1)
|
||||
expect(sidecarMocks.adapterChildren[0]!.kill).toHaveBeenCalledTimes(1)
|
||||
|
||||
await expect(runtime.getServerUrl()).resolves.toBe('http://127.0.0.1:49322')
|
||||
expect(sidecarMocks.serverChildren).toHaveLength(2)
|
||||
expect(sidecarMocks.adapterChildren).toHaveLength(6)
|
||||
for (const adapter of sidecarMocks.adapterChildren.slice(1)) {
|
||||
expect(adapter.kill).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it('handles an asynchronous child process error without crashing Electron', async () => {
|
||||
sidecarMocks.waitForServerImpl = () => new Promise(() => undefined)
|
||||
const runtime = createRuntime({
|
||||
diagnosticsFile: '/isolated/user-data/diagnostics/electron-host.log',
|
||||
})
|
||||
|
||||
const starting = runtime.startServer()
|
||||
await waitForServerChildren(1)
|
||||
expect(() => sidecarMocks.serverChildren[0]!.emit(
|
||||
'error',
|
||||
new Error('spawn error OPENAI_API_KEY=unsafe-value'),
|
||||
)).not.toThrow()
|
||||
|
||||
const rejection = await starting.then(
|
||||
() => null,
|
||||
error => error as Error,
|
||||
)
|
||||
expect(rejection?.message).toContain('spawn error')
|
||||
expect(rejection?.message).not.toContain('unsafe-value')
|
||||
expect(sidecarMocks.appendHostDiagnostic).toHaveBeenCalledWith(
|
||||
'/isolated/user-data/diagnostics/electron-host.log',
|
||||
expect.stringContaining('[process-error] sidecar process error: spawn error'),
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -1,7 +1,9 @@
|
||||
import path from 'node:path'
|
||||
import {
|
||||
appendHostDiagnostic,
|
||||
createAdapterPlan,
|
||||
createServerPlan,
|
||||
ELECTRON_DIAGNOSTICS_FILE_ENV,
|
||||
formatStartupError,
|
||||
killSidecar,
|
||||
mergeProxyEnv,
|
||||
@ -10,6 +12,7 @@ import {
|
||||
proxyUrlFromElectronProxyRules,
|
||||
pushStartupLog,
|
||||
reserveServerPort,
|
||||
sanitizeHostDiagnostic,
|
||||
SERVER_BIND_HOST,
|
||||
SERVER_CONTROL_HOST,
|
||||
SERVER_STARTUP_TIMEOUT_MS,
|
||||
@ -25,24 +28,91 @@ type ServerRuntimeOptions = {
|
||||
desktopRoot: string
|
||||
appRoot?: string
|
||||
h5DistDir?: string
|
||||
diagnosticsFile?: string
|
||||
env?: NodeJS.ProcessEnv
|
||||
deps?: Partial<ServerRuntimeDeps>
|
||||
resolveSystemProxy?: (url: string) => Promise<string>
|
||||
}
|
||||
|
||||
type ServerRuntimeDeps = {
|
||||
appendHostDiagnostic: typeof appendHostDiagnostic
|
||||
preferredServerPorts: typeof preferredServerPorts
|
||||
reserveServerPort: typeof reserveServerPort
|
||||
spawnSidecar: typeof spawnSidecar
|
||||
waitForServer: typeof waitForServer
|
||||
writeLastServerPort: typeof writeLastServerPort
|
||||
}
|
||||
|
||||
const DEFAULT_SERVER_RUNTIME_DEPS: ServerRuntimeDeps = {
|
||||
appendHostDiagnostic,
|
||||
preferredServerPorts,
|
||||
reserveServerPort,
|
||||
spawnSidecar,
|
||||
waitForServer,
|
||||
writeLastServerPort,
|
||||
}
|
||||
|
||||
type ServerStartState = {
|
||||
child: SidecarChild
|
||||
adapterChildren: SidecarChild[]
|
||||
childStopped: boolean
|
||||
readonly failure: Error | null
|
||||
failurePromise: Promise<never>
|
||||
fail: (error: Error) => void
|
||||
}
|
||||
|
||||
type ActiveServer = {
|
||||
url: string
|
||||
child: SidecarChild
|
||||
adapterChildren: SidecarChild[]
|
||||
}
|
||||
|
||||
function createServerStartState(child: SidecarChild): ServerStartState {
|
||||
let failure: Error | null = null
|
||||
let rejectFailure!: (error: Error) => void
|
||||
const failurePromise = new Promise<never>((_resolve, reject) => {
|
||||
rejectFailure = reject
|
||||
})
|
||||
return {
|
||||
child,
|
||||
adapterChildren: [],
|
||||
childStopped: false,
|
||||
get failure() {
|
||||
return failure
|
||||
},
|
||||
failurePromise,
|
||||
fail(error) {
|
||||
if (failure) return
|
||||
failure = error
|
||||
rejectFailure(error)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export class ElectronServerRuntime {
|
||||
private readonly desktopRoot: string
|
||||
private readonly appRoot: string
|
||||
private readonly h5DistDir: string
|
||||
private readonly diagnosticsFile?: string
|
||||
private readonly baseEnv: NodeJS.ProcessEnv
|
||||
private readonly deps: ServerRuntimeDeps
|
||||
private readonly resolveSystemProxy?: (url: string) => Promise<string>
|
||||
private sidecarEnvPromise: Promise<NodeJS.ProcessEnv> | null = null
|
||||
private server: { url: string, child: SidecarChild } | null = null
|
||||
private server: ActiveServer | null = null
|
||||
private adapters: SidecarChild[] = []
|
||||
private startupError: string | null = null
|
||||
private restartAfterExit = false
|
||||
private startPromise: Promise<string> | null = null
|
||||
private startingServer: ServerStartState | null = null
|
||||
private adapterRestartPromise: Promise<void> | null = null
|
||||
|
||||
constructor(options: ServerRuntimeOptions) {
|
||||
this.desktopRoot = options.desktopRoot
|
||||
this.appRoot = options.appRoot ?? options.desktopRoot
|
||||
this.h5DistDir = options.h5DistDir ?? path.join(options.desktopRoot, 'dist')
|
||||
this.diagnosticsFile = options.diagnosticsFile
|
||||
this.baseEnv = options.env ?? process.env
|
||||
this.deps = { ...DEFAULT_SERVER_RUNTIME_DEPS, ...options.deps }
|
||||
this.resolveSystemProxy = options.resolveSystemProxy
|
||||
}
|
||||
|
||||
@ -50,6 +120,7 @@ export class ElectronServerRuntime {
|
||||
if (this.server) return this.server.url
|
||||
if (this.startPromise) return this.startPromise
|
||||
|
||||
this.restartAfterExit = false
|
||||
this.startPromise = this.startServerOnce()
|
||||
try {
|
||||
return await this.startPromise
|
||||
@ -60,17 +131,41 @@ export class ElectronServerRuntime {
|
||||
|
||||
async getServerUrl(): Promise<string> {
|
||||
if (this.server) return this.server.url
|
||||
if (this.startupError) throw new Error(this.startupError)
|
||||
if (this.startPromise) return await this.startServer()
|
||||
if (this.startupError && !this.restartAfterExit) throw new Error(this.startupError)
|
||||
return await this.startServer()
|
||||
}
|
||||
|
||||
async restartAdaptersSidecars(): Promise<void> {
|
||||
this.stopAdaptersSidecars()
|
||||
restartAdaptersSidecars(): Promise<void> {
|
||||
if (this.adapterRestartPromise) return this.adapterRestartPromise
|
||||
const operation = this.restartAdaptersSidecarsOnce()
|
||||
const tracked = operation.finally(() => {
|
||||
if (this.adapterRestartPromise === tracked) this.adapterRestartPromise = null
|
||||
})
|
||||
this.adapterRestartPromise = tracked
|
||||
return tracked
|
||||
}
|
||||
|
||||
private async restartAdaptersSidecarsOnce(): Promise<void> {
|
||||
const serverUrl = await this.getServerUrl()
|
||||
await this.startAdaptersSidecars(serverUrl)
|
||||
const server = this.server
|
||||
if (!server || server.url !== serverUrl) return
|
||||
this.stopAdapterChildren(server.adapterChildren)
|
||||
await this.startAdaptersSidecars(serverUrl, undefined, server)
|
||||
}
|
||||
|
||||
stopAll(sync = false) {
|
||||
const starting = this.startingServer
|
||||
if (starting) {
|
||||
this.startingServer = null
|
||||
this.stopAdaptersForStart(starting, sync)
|
||||
if (this.server?.child === starting.child) this.server = null
|
||||
starting.fail(new Error('server startup stopped'))
|
||||
if (!starting.childStopped) {
|
||||
starting.childStopped = true
|
||||
killSidecar(starting.child, sync)
|
||||
}
|
||||
}
|
||||
this.stopAdaptersSidecars(sync)
|
||||
if (this.server) {
|
||||
killSidecar(this.server.child, sync)
|
||||
@ -81,36 +176,84 @@ export class ElectronServerRuntime {
|
||||
private async startServerOnce(): Promise<string> {
|
||||
// Prefer the configured fixed port, then the previous run's port, so
|
||||
// phone bookmarks / QR codes / reverse proxies survive restarts (#767).
|
||||
const port = await reserveServerPort(SERVER_BIND_HOST, preferredServerPorts())
|
||||
const port = await this.deps.reserveServerPort(
|
||||
SERVER_BIND_HOST,
|
||||
this.deps.preferredServerPorts(this.baseEnv),
|
||||
)
|
||||
const url = `http://${SERVER_CONTROL_HOST}:${port}`
|
||||
const logs: string[] = []
|
||||
let startState: ServerStartState | null = null
|
||||
const env = await this.resolveSidecarBaseEnv()
|
||||
const plan = createServerPlan({
|
||||
desktopRoot: this.desktopRoot,
|
||||
appRoot: this.appRoot,
|
||||
port,
|
||||
h5DistDir: this.h5DistDir,
|
||||
env,
|
||||
env: this.diagnosticsFile
|
||||
? { ...env, [ELECTRON_DIAGNOSTICS_FILE_ENV]: this.diagnosticsFile }
|
||||
: env,
|
||||
})
|
||||
|
||||
try {
|
||||
const child = spawnSidecar(plan)
|
||||
this.captureLogs(child, 'claude-server', logs)
|
||||
await waitForServer(SERVER_CONTROL_HOST, port, SERVER_STARTUP_TIMEOUT_MS)
|
||||
writeLastServerPort(port)
|
||||
this.server = { url, child }
|
||||
const child = this.deps.spawnSidecar(plan)
|
||||
startState = createServerStartState(child)
|
||||
this.startingServer = startState
|
||||
this.captureLogs(child, 'claude-server', logs, (code, signal) => {
|
||||
this.handleServerExit(child, code, signal, logs)
|
||||
}, error => {
|
||||
this.handleServerError(child, error, logs)
|
||||
})
|
||||
await Promise.race([
|
||||
this.deps.waitForServer(SERVER_CONTROL_HOST, port, SERVER_STARTUP_TIMEOUT_MS),
|
||||
startState.failurePromise,
|
||||
])
|
||||
if (startState.failure) throw startState.failure
|
||||
this.deps.writeLastServerPort(port, this.baseEnv)
|
||||
this.server = { url, child, adapterChildren: startState.adapterChildren }
|
||||
const activeServer = this.server
|
||||
this.startupError = null
|
||||
await this.startAdaptersSidecars(url)
|
||||
this.stopAdaptersSidecars()
|
||||
await Promise.race([
|
||||
this.startAdaptersSidecars(url, startState, activeServer),
|
||||
startState.failurePromise,
|
||||
])
|
||||
if (startState.failure) throw startState.failure
|
||||
return url
|
||||
} catch (error) {
|
||||
if (startState) {
|
||||
this.stopAdaptersForStart(startState)
|
||||
if (this.server?.child === startState.child) this.server = null
|
||||
if (!startState.childStopped) {
|
||||
startState.childStopped = true
|
||||
killSidecar(startState.child)
|
||||
}
|
||||
}
|
||||
if (startState?.failure) {
|
||||
throw new Error(this.startupError ?? startState.failure.message)
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
this.deps.appendHostDiagnostic(this.diagnosticsFile, `[claude-server] [startup-error] ${message}`)
|
||||
this.startupError = formatStartupError(message, logs)
|
||||
throw new Error(this.startupError)
|
||||
} finally {
|
||||
if (this.startingServer === startState) this.startingServer = null
|
||||
}
|
||||
}
|
||||
|
||||
private async startAdaptersSidecars(serverUrl: string): Promise<void> {
|
||||
private async startAdaptersSidecars(
|
||||
serverUrl: string,
|
||||
startState?: ServerStartState,
|
||||
activeServer?: ActiveServer,
|
||||
): Promise<void> {
|
||||
const env = await this.resolveSidecarBaseEnv()
|
||||
const isCurrentGeneration = () => {
|
||||
if (startState?.failure) return false
|
||||
if (activeServer && this.server !== activeServer) return false
|
||||
return true
|
||||
}
|
||||
if (!isCurrentGeneration()) return
|
||||
const ownedAdapters = startState?.adapterChildren
|
||||
?? activeServer?.adapterChildren
|
||||
for (const [label, flag] of [
|
||||
['feishu', '--feishu'],
|
||||
['telegram', '--telegram'],
|
||||
@ -118,8 +261,9 @@ export class ElectronServerRuntime {
|
||||
['dingtalk', '--dingtalk'],
|
||||
['whatsapp', '--whatsapp'],
|
||||
] as const) {
|
||||
if (!isCurrentGeneration()) break
|
||||
try {
|
||||
const child = spawnSidecar(createAdapterPlan({
|
||||
const child = this.deps.spawnSidecar(createAdapterPlan({
|
||||
desktopRoot: this.desktopRoot,
|
||||
appRoot: this.appRoot,
|
||||
h5DistDir: this.h5DistDir,
|
||||
@ -127,8 +271,13 @@ export class ElectronServerRuntime {
|
||||
flag,
|
||||
env,
|
||||
}))
|
||||
if (!isCurrentGeneration()) {
|
||||
killSidecar(child)
|
||||
break
|
||||
}
|
||||
this.captureLogs(child, `claude-adapters:${label}`)
|
||||
this.adapters.push(child)
|
||||
ownedAdapters?.push(child)
|
||||
} catch (error) {
|
||||
console.error(`[desktop] failed to start ${label} adapter sidecar`, error)
|
||||
}
|
||||
@ -136,29 +285,104 @@ export class ElectronServerRuntime {
|
||||
}
|
||||
|
||||
private stopAdaptersSidecars(sync = false) {
|
||||
for (const child of this.adapters.splice(0)) {
|
||||
const children = this.adapters.splice(0)
|
||||
this.removeOwnedAdapters(this.server?.adapterChildren, children)
|
||||
this.removeOwnedAdapters(this.startingServer?.adapterChildren, children)
|
||||
for (const child of children) {
|
||||
killSidecar(child, sync)
|
||||
}
|
||||
}
|
||||
|
||||
private captureLogs(child: SidecarChild, label: string, startupLogs?: string[]) {
|
||||
private removeOwnedAdapters(owned: SidecarChild[] | undefined, removed: SidecarChild[]) {
|
||||
if (!owned?.length || !removed.length) return
|
||||
const removedSet = new Set(removed)
|
||||
const retained = owned.filter(child => !removedSet.has(child))
|
||||
owned.splice(0, owned.length, ...retained)
|
||||
}
|
||||
|
||||
private stopAdaptersForStart(startState: ServerStartState, sync = false) {
|
||||
this.stopAdapterChildren(startState.adapterChildren, sync)
|
||||
}
|
||||
|
||||
private captureLogs(
|
||||
child: SidecarChild,
|
||||
label: string,
|
||||
startupLogs?: string[],
|
||||
onExit?: (code: number | null, signal: NodeJS.Signals | null) => void,
|
||||
onError?: (error: Error) => void,
|
||||
) {
|
||||
child.stdout.on('data', chunk => {
|
||||
const line = String(chunk).trimEnd()
|
||||
if (!line) return
|
||||
console.log(`[${label}] ${line}`)
|
||||
this.deps.appendHostDiagnostic(this.diagnosticsFile, `[${label}] [stdout] ${line}`)
|
||||
if (startupLogs) pushStartupLog(startupLogs, `[stdout] ${line}`)
|
||||
})
|
||||
child.stderr.on('data', chunk => {
|
||||
const line = String(chunk).trimEnd()
|
||||
if (!line) return
|
||||
console.error(`[${label}] ${line}`)
|
||||
this.deps.appendHostDiagnostic(this.diagnosticsFile, `[${label}] [stderr] ${line}`)
|
||||
if (startupLogs) pushStartupLog(startupLogs, `[stderr] ${line}`)
|
||||
})
|
||||
child.on('exit', (code, signal) => {
|
||||
const line = `sidecar exited (code=${code}, signal=${signal})`
|
||||
console.log(`[${label}] ${line}`)
|
||||
this.deps.appendHostDiagnostic(this.diagnosticsFile, `[${label}] [exit] ${line}`)
|
||||
if (startupLogs) pushStartupLog(startupLogs, `[exit] ${line}`)
|
||||
onExit?.(code, signal)
|
||||
})
|
||||
child.on('error', error => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
const line = `sidecar process error: ${message}`
|
||||
console.error(`[${label}] ${sanitizeHostDiagnostic(line)}`)
|
||||
this.deps.appendHostDiagnostic(this.diagnosticsFile, `[${label}] [process-error] ${line}`)
|
||||
if (startupLogs) pushStartupLog(startupLogs, `[process-error] ${line}`)
|
||||
onError?.(error instanceof Error ? error : new Error(message))
|
||||
})
|
||||
}
|
||||
|
||||
private handleServerExit(
|
||||
child: SidecarChild,
|
||||
code: number | null,
|
||||
signal: NodeJS.Signals | null,
|
||||
logs: string[],
|
||||
) {
|
||||
this.handleServerFailure(
|
||||
child,
|
||||
`server sidecar exited after spawn (code=${code}, signal=${signal})`,
|
||||
logs,
|
||||
)
|
||||
}
|
||||
|
||||
private handleServerError(child: SidecarChild, error: Error, logs: string[]) {
|
||||
this.handleServerFailure(
|
||||
child,
|
||||
`server sidecar process error after spawn: ${sanitizeHostDiagnostic(error.message)}`,
|
||||
logs,
|
||||
)
|
||||
}
|
||||
|
||||
private handleServerFailure(child: SidecarChild, message: string, logs: string[]) {
|
||||
const active = this.server?.child === child
|
||||
const starting = this.startingServer?.child === child
|
||||
if (!active && !starting) return
|
||||
if (active) {
|
||||
const adapterChildren = this.server!.adapterChildren
|
||||
this.server = null
|
||||
this.stopAdapterChildren(adapterChildren)
|
||||
}
|
||||
this.restartAfterExit = true
|
||||
this.startupError = formatStartupError(message, logs)
|
||||
if (starting) this.startingServer?.fail(new Error(message))
|
||||
}
|
||||
|
||||
private stopAdapterChildren(children: SidecarChild[], sync = false) {
|
||||
for (const child of children.splice(0)) {
|
||||
const index = this.adapters.indexOf(child)
|
||||
if (index >= 0) this.adapters.splice(index, 1)
|
||||
killSidecar(child, sync)
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveSidecarBaseEnv(): Promise<NodeJS.ProcessEnv> {
|
||||
@ -167,17 +391,17 @@ export class ElectronServerRuntime {
|
||||
}
|
||||
|
||||
private async resolveSidecarBaseEnvOnce(): Promise<NodeJS.ProcessEnv> {
|
||||
if (!this.resolveSystemProxy) return this.applyPowerShellOverride(process.env)
|
||||
if (!this.resolveSystemProxy) return this.applyPowerShellOverride(this.baseEnv)
|
||||
|
||||
try {
|
||||
const rules = await this.resolveSystemProxy('https://auth.openai.com/')
|
||||
return this.applyPowerShellOverride(mergeProxyEnv(
|
||||
process.env,
|
||||
this.baseEnv,
|
||||
proxyUrlFromElectronProxyRules(rules),
|
||||
))
|
||||
} catch (error) {
|
||||
console.error('[desktop] failed to resolve system proxy for sidecars', error)
|
||||
return this.applyPowerShellOverride(process.env)
|
||||
return this.applyPowerShellOverride(this.baseEnv)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2,13 +2,17 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import net from 'node:net'
|
||||
import http from 'node:http'
|
||||
import path from 'node:path'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import {
|
||||
appendHostDiagnostic,
|
||||
buildSidecarEnv,
|
||||
createAdapterPlan,
|
||||
createServerPlan,
|
||||
electronHostDiagnosticsFile,
|
||||
httpToWebSocketUrl,
|
||||
HOST_DIAGNOSTICS_BYTE_LIMIT,
|
||||
HOST_DIAGNOSTICS_LINE_LIMIT,
|
||||
killSidecar,
|
||||
mergeProxyEnv,
|
||||
parseH5FixedPort,
|
||||
@ -51,6 +55,23 @@ function close(server: http.Server): Promise<void> {
|
||||
}
|
||||
|
||||
describe('Electron sidecar manager', () => {
|
||||
it('places the Electron host log in the active server diagnostics directory', () => {
|
||||
const portableDir = path.join(tmpdir(), 'cc-haha-portable-diagnostics')
|
||||
|
||||
expect(electronHostDiagnosticsFile(
|
||||
{ CLAUDE_CONFIG_DIR: portableDir },
|
||||
path.join(tmpdir(), 'unused-home'),
|
||||
)).toBe(path.join(portableDir, 'cc-haha', 'diagnostics', 'electron-host.log'))
|
||||
})
|
||||
|
||||
it('resolves the default Electron host log without consulting real user state', () => {
|
||||
const isolatedHome = path.resolve(path.sep, '__cc_haha_injected_test_home__')
|
||||
|
||||
expect(electronHostDiagnosticsFile({}, isolatedHome)).toBe(
|
||||
path.join(isolatedHome, '.claude', 'cc-haha', 'diagnostics', 'electron-host.log'),
|
||||
)
|
||||
})
|
||||
|
||||
it('maps host platform to existing sidecar target triples', () => {
|
||||
expect(resolveHostTriple('darwin', 'arm64')).toBe('aarch64-apple-darwin')
|
||||
expect(resolveHostTriple('darwin', 'x64')).toBe('x86_64-apple-darwin')
|
||||
@ -160,6 +181,95 @@ describe('Electron sidecar manager', () => {
|
||||
expect(logs[0]).toBe('line 5')
|
||||
})
|
||||
|
||||
it('sanitizes the bounded startup tail before it reaches an error surface', () => {
|
||||
const logs: string[] = []
|
||||
pushStartupLog(
|
||||
logs,
|
||||
`Bearer startup.secret sk-proj-STARTUPSECRETVALUE https://alice:password@example.com ${homedir()}/project`,
|
||||
)
|
||||
|
||||
expect(logs[0]).toContain('Bearer [REDACTED]')
|
||||
expect(logs[0]).toContain('https://[REDACTED]@example.com/')
|
||||
expect(logs[0]).toContain('[HOME]/project')
|
||||
expect(logs[0]).not.toContain('startup.secret')
|
||||
expect(logs[0]).not.toContain('sk-proj-STARTUPSECRETVALUE')
|
||||
expect(logs[0]).not.toContain(homedir())
|
||||
})
|
||||
|
||||
it('appends only a bounded sanitized Electron host-log tail', () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), 'cc-haha-electron-host-'))
|
||||
const logPath = path.join(dir, 'electron-host.log')
|
||||
const homeDir = path.join(dir, 'private-home')
|
||||
try {
|
||||
for (let index = 0; index < HOST_DIAGNOSTICS_LINE_LIMIT + 5; index++) {
|
||||
appendHostDiagnostic(logPath, `line ${index}`, { homeDir })
|
||||
}
|
||||
appendHostDiagnostic(
|
||||
logPath,
|
||||
`Authorization: Bearer bearer.secret api_key=sk-ant-api03-PRIVATE ANTHROPIC_API_KEY=anthropic-secret OPENAI_API_KEY="openai-secret" MINIMAX_AUTH_TOKEN='minimax-secret' https://alice:password@example.com/private ${homeDir}/project`,
|
||||
{ homeDir },
|
||||
)
|
||||
|
||||
const contents = readFileSync(logPath, 'utf-8')
|
||||
const lines = contents.trimEnd().split('\n')
|
||||
expect(contents).toContain('Bearer [REDACTED]')
|
||||
expect(contents).toContain('api_key=[REDACTED]')
|
||||
expect(contents).toContain('ANTHROPIC_API_KEY=[REDACTED]')
|
||||
expect(contents).toContain('OPENAI_API_KEY=[REDACTED]')
|
||||
expect(contents).toContain('MINIMAX_AUTH_TOKEN=[REDACTED]')
|
||||
expect(contents).toContain('https://[REDACTED]@example.com/private')
|
||||
expect(contents).toContain('[HOME]/project')
|
||||
expect(contents).not.toContain('bearer.secret')
|
||||
expect(contents).not.toContain('sk-ant-api03-PRIVATE')
|
||||
expect(contents).not.toContain('anthropic-secret')
|
||||
expect(contents).not.toContain('openai-secret')
|
||||
expect(contents).not.toContain('minimax-secret')
|
||||
expect(contents).not.toContain('alice:password')
|
||||
expect(contents).not.toContain(homeDir)
|
||||
expect(lines).toHaveLength(HOST_DIAGNOSTICS_LINE_LIMIT)
|
||||
expect(lines[0]).toBe('line 6')
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('bounds and re-sanitizes an oversized pre-existing host diagnostics file', () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), 'cc-haha-electron-host-existing-'))
|
||||
const logPath = path.join(dir, 'electron-host.log')
|
||||
const homeDir = path.join(dir, 'private-home')
|
||||
try {
|
||||
writeFileSync(
|
||||
logPath,
|
||||
`${'oversized-old-data '.repeat(HOST_DIAGNOSTICS_BYTE_LIMIT)}\nOPENAI_API_KEY=old-secret ${homeDir}/private\n`,
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
appendHostDiagnostic(logPath, 'latest safe diagnostic', { homeDir })
|
||||
|
||||
const contents = readFileSync(logPath, 'utf-8')
|
||||
expect(statSync(logPath).size).toBeLessThanOrEqual(HOST_DIAGNOSTICS_BYTE_LIMIT)
|
||||
expect(contents.trimEnd().split('\n').length).toBeLessThanOrEqual(HOST_DIAGNOSTICS_LINE_LIMIT)
|
||||
expect(contents).toContain('latest safe diagnostic')
|
||||
expect(contents).toContain('OPENAI_API_KEY=[REDACTED]')
|
||||
expect(contents).not.toContain('old-secret')
|
||||
expect(contents).not.toContain(homeDir)
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not crash Electron when the host diagnostics destination cannot be written', () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), 'cc-haha-electron-host-failure-'))
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
try {
|
||||
expect(() => appendHostDiagnostic(dir, 'sidecar failed')).not.toThrow()
|
||||
expect(errorSpy).toHaveBeenCalledWith('[desktop] failed to persist Electron host diagnostics')
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('maps http urls to adapter websocket urls', () => {
|
||||
expect(httpToWebSocketUrl('http://127.0.0.1:3456')).toBe('ws://127.0.0.1:3456')
|
||||
expect(httpToWebSocketUrl('https://example.com')).toBe('wss://example.com')
|
||||
|
||||
@ -1,5 +1,16 @@
|
||||
import { spawn, spawnSync, type ChildProcessByStdio } from 'node:child_process'
|
||||
import { mkdirSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import {
|
||||
closeSync,
|
||||
existsSync,
|
||||
fstatSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
readSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import type { Readable } from 'node:stream'
|
||||
import net from 'node:net'
|
||||
import os from 'node:os'
|
||||
@ -10,6 +21,10 @@ export const SERVER_BIND_HOST = '0.0.0.0'
|
||||
export const SERVER_CONTROL_HOST = '127.0.0.1'
|
||||
export const SERVER_STARTUP_TIMEOUT_MS = 30_000
|
||||
export const SERVER_STARTUP_LOG_LIMIT = 80
|
||||
export const HOST_DIAGNOSTICS_LINE_LIMIT = 80
|
||||
export const HOST_DIAGNOSTICS_BYTE_LIMIT = 256 * 1024
|
||||
export const ELECTRON_DIAGNOSTICS_FILE_ENV = 'CC_HAHA_ELECTRON_DIAGNOSTICS_FILE'
|
||||
const HOST_DIAGNOSTICS_LINE_BYTE_LIMIT = 4096
|
||||
// Shared with the Tauri shell (src-tauri/src/lib.rs) so both desktop builds
|
||||
// reuse the same sticky port across restarts (issue #767).
|
||||
export const SERVER_STATE_FILE = 'desktop-server-state.json'
|
||||
@ -128,8 +143,18 @@ export async function reserveServerPort(
|
||||
return await reserveLocalPort(bindHost)
|
||||
}
|
||||
|
||||
export function claudeConfigDir(env: NodeJS.ProcessEnv = process.env): string {
|
||||
return env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
||||
export function claudeConfigDir(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
homeDir = os.homedir(),
|
||||
): string {
|
||||
return env.CLAUDE_CONFIG_DIR || path.join(homeDir, '.claude')
|
||||
}
|
||||
|
||||
export function electronHostDiagnosticsFile(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
homeDir = os.homedir(),
|
||||
): string {
|
||||
return path.join(claudeConfigDir(env, homeDir), 'cc-haha', 'diagnostics', 'electron-host.log')
|
||||
}
|
||||
|
||||
/** Parse h5Access.fixedPort out of cc-haha/settings.json contents. */
|
||||
@ -238,12 +263,101 @@ function sleep(ms: number): Promise<void> {
|
||||
}
|
||||
|
||||
export function pushStartupLog(logs: string[], line: string) {
|
||||
const trimmed = line.trimEnd()
|
||||
const trimmed = sanitizeHostDiagnostic(line, os.homedir())
|
||||
if (!trimmed) return
|
||||
if (logs.length >= SERVER_STARTUP_LOG_LIMIT) logs.shift()
|
||||
logs.push(trimmed)
|
||||
}
|
||||
|
||||
export function appendHostDiagnostic(
|
||||
filePath: string | undefined,
|
||||
line: string,
|
||||
{ homeDir = os.homedir() }: { homeDir?: string } = {},
|
||||
): void {
|
||||
if (!filePath) return
|
||||
const tempPath = `${filePath}.${process.pid}.tmp`
|
||||
try {
|
||||
const sanitized = sanitizeHostDiagnostic(line, homeDir)
|
||||
if (!sanitized) return
|
||||
const existing = readHostDiagnosticsTail(filePath)
|
||||
const lines = existing.trimEnd()
|
||||
? existing.trimEnd().split('\n').map(entry => sanitizeHostDiagnostic(entry, homeDir)).filter(Boolean)
|
||||
: []
|
||||
lines.push(sanitized)
|
||||
const boundedLines: string[] = []
|
||||
let retainedBytes = 0
|
||||
for (const entry of lines.slice(-HOST_DIAGNOSTICS_LINE_LIMIT).reverse()) {
|
||||
const entryBytes = Buffer.byteLength(entry, 'utf-8') + 1
|
||||
if (retainedBytes + entryBytes > HOST_DIAGNOSTICS_BYTE_LIMIT) break
|
||||
boundedLines.unshift(entry)
|
||||
retainedBytes += entryBytes
|
||||
}
|
||||
mkdirSync(path.dirname(filePath), { recursive: true })
|
||||
writeFileSync(tempPath, `${boundedLines.join('\n')}\n`, {
|
||||
encoding: 'utf-8',
|
||||
mode: 0o600,
|
||||
})
|
||||
renameSync(tempPath, filePath)
|
||||
} catch {
|
||||
try {
|
||||
rmSync(tempPath, { force: true })
|
||||
} catch {
|
||||
// Best-effort cleanup must not mask the original diagnostics failure.
|
||||
}
|
||||
console.error('[desktop] failed to persist Electron host diagnostics')
|
||||
}
|
||||
}
|
||||
|
||||
function readHostDiagnosticsTail(filePath: string): string {
|
||||
let descriptor: number | undefined
|
||||
try {
|
||||
descriptor = openSync(filePath, 'r')
|
||||
const size = fstatSync(descriptor).size
|
||||
const length = Math.min(size, HOST_DIAGNOSTICS_BYTE_LIMIT)
|
||||
const buffer = Buffer.alloc(length)
|
||||
const bytesRead = readSync(descriptor, buffer, 0, length, size - length)
|
||||
const tail = buffer.subarray(0, bytesRead).toString('utf-8')
|
||||
if (size <= length) return tail
|
||||
const firstNewline = tail.indexOf('\n')
|
||||
return firstNewline >= 0 ? tail.slice(firstNewline + 1) : ''
|
||||
} catch {
|
||||
return ''
|
||||
} finally {
|
||||
if (descriptor !== undefined) closeSync(descriptor)
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeHostDiagnostic(line: string, homeDir = os.homedir()): string {
|
||||
let sanitized = line
|
||||
.replace(/[\r\n]+/g, ' ')
|
||||
.replace(/https?:\/\/[^\s<>"')\]}]+/gi, candidate => sanitizeUrlUserinfo(candidate))
|
||||
.replace(/\bBearer\s+[^\s,;]+/gi, 'Bearer [REDACTED]')
|
||||
.replace(
|
||||
/\b((?:(?:[a-z0-9]+_)*(?:api[_-]?key|auth[_-]?token|access[_-]?token|refresh[_-]?token|session[_-]?token|password|secret))\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;]+)/gi,
|
||||
'$1[REDACTED]',
|
||||
)
|
||||
.replace(/\b(?:sk-(?:ant-api03-|proj-)?|ghp_)[A-Za-z0-9_-]{8,}\b/g, '[REDACTED]')
|
||||
.trimEnd()
|
||||
if (homeDir) sanitized = sanitized.replaceAll(homeDir, '[HOME]')
|
||||
return truncateUtf8(sanitized, HOST_DIAGNOSTICS_LINE_BYTE_LIMIT)
|
||||
}
|
||||
|
||||
function truncateUtf8(value: string, maxBytes: number): string {
|
||||
const buffer = Buffer.from(value, 'utf-8')
|
||||
if (buffer.byteLength <= maxBytes) return value
|
||||
return buffer.subarray(0, maxBytes).toString('utf-8').replace(/\uFFFD$/, '')
|
||||
}
|
||||
|
||||
function sanitizeUrlUserinfo(candidate: string): string {
|
||||
try {
|
||||
const url = new URL(candidate)
|
||||
if (!url.username && !url.password) return candidate
|
||||
return `${url.protocol}//[REDACTED]@${url.host}${url.pathname}${url.search}${url.hash}`
|
||||
} catch {
|
||||
return '[REDACTED_URL]'
|
||||
}
|
||||
}
|
||||
|
||||
export function formatStartupError(message: string, logs: string[]): string {
|
||||
const logText = logs.length > 0
|
||||
? logs.join('\n')
|
||||
|
||||
@ -1,28 +1,64 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
import { Settings } from '../pages/Settings'
|
||||
import { SAFE_DOCTOR_STORAGE_KEYS } from '../lib/doctorRepair'
|
||||
import { useSessionStore } from '../stores/sessionStore'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
import { useUIStore } from '../stores/uiStore'
|
||||
|
||||
const diagnosticsApiMock = vi.hoisted(() => ({
|
||||
getStatus: vi.fn(),
|
||||
getEvents: vi.fn(),
|
||||
getIssueReport: vi.fn(),
|
||||
exportBundle: vi.fn(),
|
||||
openLogDir: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
}))
|
||||
|
||||
const doctorRepairMock = vi.hoisted(() => ({
|
||||
runDoctorRepair: vi.fn(),
|
||||
const doctorApiMock = vi.hoisted(() => ({
|
||||
report: vi.fn(),
|
||||
}))
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((nextResolve, nextReject) => {
|
||||
resolve = nextResolve
|
||||
reject = nextReject
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function doctorReport(path: string) {
|
||||
return {
|
||||
report: {
|
||||
generatedAt: '2026-07-11T00:00:00.000Z',
|
||||
items: [{
|
||||
id: `finding:${path}`,
|
||||
label: 'Finding',
|
||||
kind: 'json' as const,
|
||||
scope: 'user' as const,
|
||||
path,
|
||||
protected: true,
|
||||
exists: true,
|
||||
status: 'invalid_schema' as const,
|
||||
bytes: 42,
|
||||
}],
|
||||
protectedSkips: [],
|
||||
summary: { total: 1, protectedCount: 1, neutralCount: 0, missingCount: 0, invalidCount: 1 },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('../api/diagnostics', () => ({
|
||||
diagnosticsApi: diagnosticsApiMock,
|
||||
}))
|
||||
|
||||
vi.mock('../lib/doctorRepair', () => doctorRepairMock)
|
||||
vi.mock('../api/doctor', () => ({
|
||||
doctorApi: doctorApiMock,
|
||||
}))
|
||||
|
||||
vi.mock('../stores/providerStore', () => ({
|
||||
useProviderStore: () => ({
|
||||
@ -100,23 +136,35 @@ describe('Settings > Diagnostics tab', () => {
|
||||
retentionDays: 7,
|
||||
maxBytes: 50 * 1024 * 1024,
|
||||
totalBytes: 4096,
|
||||
eventCount: 2,
|
||||
eventCount: 600,
|
||||
physicalLineCount: 601,
|
||||
corruptLineCount: 1,
|
||||
storageLimitExceeded: false,
|
||||
recentErrorCount: 1,
|
||||
lastEventAt: '2026-05-02T00:00:00.000Z',
|
||||
})
|
||||
diagnosticsApiMock.getEvents.mockResolvedValue({
|
||||
events: [{
|
||||
id: 'event-1',
|
||||
timestamp: '2026-05-02T00:00:00.000Z',
|
||||
type: 'cli_start_failed',
|
||||
severity: 'error',
|
||||
summary: 'CLI exited during startup with code 1',
|
||||
sessionId: 'session-1',
|
||||
details: {
|
||||
exitCode: 1,
|
||||
capturedOutput: 'stderr:\nprovider rejected request',
|
||||
events: [
|
||||
{
|
||||
id: 'event-1',
|
||||
timestamp: '2026-05-02T00:00:00.000Z',
|
||||
type: 'cli_start_failed',
|
||||
severity: 'error',
|
||||
summary: 'CLI exited during startup with code 1',
|
||||
sessionId: 'session-1',
|
||||
details: {
|
||||
exitCode: 1,
|
||||
capturedOutput: 'stderr:\nprovider rejected request',
|
||||
},
|
||||
},
|
||||
}],
|
||||
...Array.from({ length: 99 }, (_, index) => ({
|
||||
id: `event-${index + 2}`,
|
||||
timestamp: '2026-05-02T00:00:00.000Z',
|
||||
type: `runtime_event_${index + 2}`,
|
||||
severity: 'info' as const,
|
||||
summary: `Runtime event ${index + 2}`,
|
||||
})),
|
||||
],
|
||||
})
|
||||
diagnosticsApiMock.exportBundle.mockResolvedValue({
|
||||
bundle: {
|
||||
@ -125,22 +173,60 @@ describe('Settings > Diagnostics tab', () => {
|
||||
bytes: 1024,
|
||||
},
|
||||
})
|
||||
diagnosticsApiMock.getIssueReport.mockResolvedValue({
|
||||
report: '## Diagnostic report\n\n- Event IDs: event-1\n- Private metadata: review before sharing',
|
||||
})
|
||||
diagnosticsApiMock.openLogDir.mockResolvedValue({ ok: true })
|
||||
diagnosticsApiMock.clear.mockResolvedValue({ ok: true })
|
||||
doctorRepairMock.runDoctorRepair.mockResolvedValue({
|
||||
local: {
|
||||
removedKeys: ['cc-haha-open-tabs', 'cc-haha-session-runtime'],
|
||||
missingKeys: ['cc-haha-theme', 'cc-haha-locale', 'cc-haha.persistence.schemaVersion'],
|
||||
failedKeys: [],
|
||||
doctorApiMock.report.mockResolvedValue({
|
||||
report: {
|
||||
generatedAt: '2026-07-11T00:00:00.000Z',
|
||||
items: [
|
||||
{
|
||||
id: 'cc-haha-providers',
|
||||
label: 'Managed providers',
|
||||
kind: 'json',
|
||||
scope: 'user',
|
||||
path: '~/.claude/cc-haha/providers.json',
|
||||
protected: true,
|
||||
exists: true,
|
||||
status: 'invalid_schema',
|
||||
bytes: 42,
|
||||
error: 'providers.0.presetId: expected string',
|
||||
},
|
||||
{
|
||||
id: 'project-skills',
|
||||
label: 'Project skills',
|
||||
kind: 'directory',
|
||||
scope: 'project',
|
||||
path: '<project>/.claude/skills',
|
||||
protected: true,
|
||||
exists: true,
|
||||
status: 'ok',
|
||||
bytes: 0,
|
||||
},
|
||||
],
|
||||
protectedSkips: [],
|
||||
summary: { total: 2, protectedCount: 2, neutralCount: 0, missingCount: 0, invalidCount: 1 },
|
||||
},
|
||||
server: {
|
||||
ok: true,
|
||||
},
|
||||
serverError: null,
|
||||
})
|
||||
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
useUIStore.setState({ activeSettingsTab: 'providers', pendingSettingsTab: null, toasts: [] })
|
||||
useSessionStore.setState({
|
||||
sessions: [{
|
||||
id: 'session-1',
|
||||
title: 'Session',
|
||||
createdAt: '2026-07-11T00:00:00.000Z',
|
||||
modifiedAt: '2026-07-11T00:00:00.000Z',
|
||||
messageCount: 0,
|
||||
projectPath: '/workspace/project',
|
||||
projectRoot: '/workspace/project',
|
||||
workDir: '/workspace/project',
|
||||
workDirExists: true,
|
||||
}],
|
||||
activeSessionId: 'session-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('shows diagnostics status, actions, and recent events', async () => {
|
||||
@ -155,6 +241,80 @@ describe('Settings > Diagnostics tab', () => {
|
||||
expect(screen.getByText('cli_start_failed')).toBeInTheDocument()
|
||||
expect(screen.getByText('CLI exited during startup with code 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Details')).toBeInTheDocument()
|
||||
expect(screen.getByText('600 complete events')).toBeInTheDocument()
|
||||
expect(screen.getByText(/evidence of 1 corrupt diagnostic record/)).toBeInTheDocument()
|
||||
expect(screen.getByText('100 visible events')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Event ID:')).toHaveLength(100)
|
||||
expect(screen.getByText('event-1')).toBeInTheDocument()
|
||||
expect(screen.getByText(/best-effort/i)).toHaveTextContent(/review.*private metadata/i)
|
||||
|
||||
const eventRow = screen.getByText('cli_start_failed').closest('.grid')
|
||||
expect(eventRow).toHaveClass('grid-cols-1', 'md:grid-cols-[120px_92px_1fr]')
|
||||
})
|
||||
|
||||
it('describes persisted corruption evidence accurately when current logs have no physical lines', async () => {
|
||||
diagnosticsApiMock.getStatus.mockResolvedValueOnce({
|
||||
logDir: '/tmp/claude/cc-haha/diagnostics',
|
||||
diagnosticsPath: '/tmp/claude/cc-haha/diagnostics/diagnostics.jsonl',
|
||||
cliDiagnosticsPath: '/tmp/claude/cc-haha/diagnostics/cli-diagnostics.jsonl',
|
||||
runtimeErrorsPath: '/tmp/claude/cc-haha/diagnostics/runtime-errors.log',
|
||||
exportDir: '/tmp/claude/cc-haha/diagnostics/exports',
|
||||
retentionDays: 7,
|
||||
maxBytes: 50 * 1024 * 1024,
|
||||
totalBytes: 0,
|
||||
eventCount: 0,
|
||||
physicalLineCount: 0,
|
||||
corruptLineCount: 2,
|
||||
storageLimitExceeded: false,
|
||||
recentErrorCount: 0,
|
||||
lastEventAt: null,
|
||||
})
|
||||
|
||||
render(<Settings />)
|
||||
fireEvent.click(screen.getByText('Diagnostics'))
|
||||
|
||||
const warning = await screen.findByRole('alert')
|
||||
expect(warning).toHaveTextContent('Detected or retained evidence of 2 corrupt diagnostic records.')
|
||||
expect(warning).toHaveTextContent('Current diagnostic files contain 0 physical lines.')
|
||||
expect(warning).not.toHaveTextContent(/among 0 physical lines/i)
|
||||
})
|
||||
|
||||
it('explains temporary target overflow while active diagnostic segments are still open', async () => {
|
||||
diagnosticsApiMock.getStatus.mockResolvedValueOnce({
|
||||
logDir: '/tmp/claude/cc-haha/diagnostics',
|
||||
diagnosticsPath: '/tmp/claude/cc-haha/diagnostics/diagnostics.jsonl',
|
||||
cliDiagnosticsPath: '/tmp/claude/cc-haha/diagnostics/cli-diagnostics.jsonl',
|
||||
runtimeErrorsPath: '/tmp/claude/cc-haha/diagnostics/runtime-errors.log',
|
||||
exportDir: '/tmp/claude/cc-haha/diagnostics/exports',
|
||||
retentionDays: 7,
|
||||
maxBytes: 50 * 1024 * 1024,
|
||||
totalBytes: 52 * 1024 * 1024,
|
||||
eventCount: 10,
|
||||
physicalLineCount: 10,
|
||||
corruptLineCount: 0,
|
||||
storageLimitExceeded: true,
|
||||
recentErrorCount: 0,
|
||||
lastEventAt: '2026-07-11T00:00:00.000Z',
|
||||
})
|
||||
|
||||
render(<Settings />)
|
||||
fireEvent.click(screen.getByText('Diagnostics'))
|
||||
|
||||
const warning = await screen.findByRole('alert')
|
||||
expect(warning).toHaveTextContent('One or more diagnostic surfaces or active writers temporarily exceed their own retention target.')
|
||||
expect(warning).toHaveTextContent('Cleanup will occur as their segments close or age out.')
|
||||
expect(warning).not.toHaveTextContent(/50 MB|strict|hard cap/i)
|
||||
})
|
||||
|
||||
it('marks the active settings tab and its decorative icon accessibly', async () => {
|
||||
render(<Settings />)
|
||||
|
||||
const diagnosticsTab = screen.getByRole('button', { name: 'Diagnostics' })
|
||||
fireEvent.click(diagnosticsTab)
|
||||
await screen.findByText('Log directory')
|
||||
|
||||
expect(diagnosticsTab).toHaveAttribute('aria-current', 'page')
|
||||
expect(diagnosticsTab.querySelector('.material-symbols-outlined')).toHaveAttribute('aria-hidden', 'true')
|
||||
})
|
||||
|
||||
it('exports a diagnostics bundle from the settings page', async () => {
|
||||
@ -239,9 +399,111 @@ describe('Settings > Diagnostics tab', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('runs Doctor from Diagnostics without clearing unrelated desktop state', async () => {
|
||||
window.localStorage.setItem('cc-haha-open-tabs', '{"activeTabId":"__settings__"}')
|
||||
window.localStorage.setItem('cc-haha-theme', 'dark')
|
||||
it('copies the share-safe issue Markdown with the legacy clipboard fallback', async () => {
|
||||
const originalClipboard = navigator.clipboard
|
||||
const originalExecCommand = document.execCommand
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
configurable: true,
|
||||
value: vi.fn().mockReturnValue(true),
|
||||
})
|
||||
const execCommand = vi.mocked(document.execCommand)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: {
|
||||
writeText: vi.fn().mockRejectedValue(new Error('clipboard blocked')),
|
||||
},
|
||||
})
|
||||
const writeText = vi.mocked(navigator.clipboard.writeText)
|
||||
|
||||
try {
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('Diagnostics'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Copy issue report/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(execCommand).toHaveBeenCalledWith('copy')
|
||||
})
|
||||
expect(diagnosticsApiMock.getIssueReport).toHaveBeenCalledTimes(1)
|
||||
expect(writeText).toHaveBeenCalledWith('## Diagnostic report\n\n- Event IDs: event-1\n- Private metadata: review before sharing')
|
||||
expect(useUIStore.getState().toasts.at(-1)?.message).toBe('Issue report copied.')
|
||||
} finally {
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
configurable: true,
|
||||
value: originalExecCommand,
|
||||
})
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: originalClipboard,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('copies the exact Event ID and reports success', async () => {
|
||||
const originalClipboard = navigator.clipboard
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
|
||||
try {
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('Diagnostics'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Copy event ID: event-1' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(writeText).toHaveBeenCalledWith('event-1')
|
||||
})
|
||||
expect(writeText).toHaveBeenCalledTimes(1)
|
||||
expect(useUIStore.getState().toasts.at(-1)?.message).toBe('Event ID copied.')
|
||||
} finally {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: originalClipboard,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('reports a meaningful error when Event ID copy fails', async () => {
|
||||
const originalClipboard = navigator.clipboard
|
||||
const originalExecCommand = document.execCommand
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText: vi.fn().mockRejectedValue(new Error('clipboard blocked')) },
|
||||
})
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
configurable: true,
|
||||
value: vi.fn().mockReturnValue(false),
|
||||
})
|
||||
|
||||
try {
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('Diagnostics'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Copy event ID: event-1' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useUIStore.getState().toasts.at(-1)?.message).toBe('Failed to copy event ID.')
|
||||
})
|
||||
} finally {
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
configurable: true,
|
||||
value: originalExecCommand,
|
||||
})
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: originalClipboard,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('checks findings first and confirms before resetting only safe desktop state', async () => {
|
||||
window.localStorage.clear()
|
||||
for (const key of SAFE_DOCTOR_STORAGE_KEYS) {
|
||||
window.localStorage.setItem(key, `${key}-value`)
|
||||
}
|
||||
window.localStorage.setItem('cc-haha-chat-history', 'keep')
|
||||
|
||||
render(<Settings />)
|
||||
@ -250,11 +512,256 @@ describe('Settings > Diagnostics tab', () => {
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Run Doctor/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(doctorRepairMock.runDoctorRepair).toHaveBeenCalled()
|
||||
expect(doctorApiMock.report).toHaveBeenCalledWith('/workspace/project')
|
||||
})
|
||||
expect(window.localStorage.getItem('cc-haha-theme')).toBe('cc-haha-theme-value')
|
||||
expect(screen.getByText('~/.claude/cc-haha/providers.json')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Invalid schema/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/User and active project/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('Healthy: 1 · Not configured: 0 · Missing: 0 · Invalid: 1')).toBeInTheDocument()
|
||||
expect(screen.queryByText('<project>/.claude/skills')).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/cc-haha-app-zoom/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/cc-haha-ui-zoom/)).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Reset safe UI state/i }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Reset safe UI state' })
|
||||
expect(window.localStorage.getItem('cc-haha-theme')).toBe('cc-haha-theme-value')
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /Reset safe UI state/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(doctorApiMock.report).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
for (const key of SAFE_DOCTOR_STORAGE_KEYS) {
|
||||
expect(window.localStorage.getItem(key)).toBeNull()
|
||||
}
|
||||
expect(window.localStorage.getItem('cc-haha-chat-history')).toBe('keep')
|
||||
expect(screen.getByText(/Removed keys:.*cc-haha-app-zoom/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('counts not-configured optional checks separately and excludes them from findings', async () => {
|
||||
doctorApiMock.report.mockResolvedValueOnce({
|
||||
report: {
|
||||
generatedAt: '2026-07-11T00:00:00.000Z',
|
||||
items: [
|
||||
{
|
||||
id: 'user-settings',
|
||||
label: 'User settings',
|
||||
kind: 'json' as const,
|
||||
scope: 'user' as const,
|
||||
path: '~/.claude/settings.json',
|
||||
protected: true,
|
||||
exists: true,
|
||||
status: 'ok' as const,
|
||||
bytes: 2,
|
||||
},
|
||||
{
|
||||
id: 'adapters',
|
||||
label: 'Adapters config',
|
||||
kind: 'json' as const,
|
||||
scope: 'user' as const,
|
||||
path: '~/.claude/adapters.json',
|
||||
protected: true,
|
||||
exists: false,
|
||||
status: 'not_configured' as const,
|
||||
bytes: 0,
|
||||
},
|
||||
{
|
||||
id: 'cc-haha-providers',
|
||||
label: 'Managed providers',
|
||||
kind: 'json' as const,
|
||||
scope: 'user' as const,
|
||||
path: '~/.claude/cc-haha/providers.json',
|
||||
protected: true,
|
||||
exists: true,
|
||||
status: 'invalid_schema' as const,
|
||||
bytes: 10,
|
||||
},
|
||||
],
|
||||
protectedSkips: [],
|
||||
summary: { total: 3, protectedCount: 3, neutralCount: 1, missingCount: 0, invalidCount: 1 },
|
||||
},
|
||||
})
|
||||
|
||||
const toasts = useUIStore.getState().toasts
|
||||
expect(toasts[toasts.length - 1]?.message).toContain('Doctor')
|
||||
expect(window.localStorage.getItem('cc-haha-chat-history')).toBe('keep')
|
||||
render(<Settings />)
|
||||
fireEvent.click(screen.getByText('Diagnostics'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Run Doctor/i }))
|
||||
|
||||
expect(await screen.findByText('Healthy: 1 · Not configured: 1 · Missing: 0 · Invalid: 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('~/.claude/cc-haha/providers.json')).toBeInTheDocument()
|
||||
expect(screen.queryByText('~/.claude/adapters.json')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses user-only Doctor scope when the active work directory is unavailable', async () => {
|
||||
useSessionStore.setState((state) => ({
|
||||
sessions: state.sessions.map((session) => ({ ...session, workDirExists: false })),
|
||||
}))
|
||||
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('Diagnostics'))
|
||||
expect(await screen.findByText(/User only/i)).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: /Run Doctor/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(doctorApiMock.report).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
it('clears an existing Doctor report when the active cwd changes', async () => {
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('Diagnostics'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Run Doctor/i }))
|
||||
expect(await screen.findByText('~/.claude/cc-haha/providers.json')).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
useSessionStore.setState((state) => ({
|
||||
sessions: [
|
||||
...state.sessions,
|
||||
{
|
||||
...state.sessions[0]!,
|
||||
id: 'session-missing-workdir',
|
||||
workDir: '/workspace/missing',
|
||||
projectRoot: '/workspace/missing',
|
||||
workDirExists: false,
|
||||
},
|
||||
],
|
||||
activeSessionId: 'session-missing-workdir',
|
||||
}))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('~/.claude/cc-haha/providers.json')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText(/User only/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps newer Doctor loading active when an older response resolves first', async () => {
|
||||
const oldRequest = deferred<ReturnType<typeof doctorReport>>()
|
||||
const newRequest = deferred<ReturnType<typeof doctorReport>>()
|
||||
doctorApiMock.report
|
||||
.mockReturnValueOnce(oldRequest.promise)
|
||||
.mockReturnValueOnce(newRequest.promise)
|
||||
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('Diagnostics'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Run Doctor/i }))
|
||||
expect(doctorApiMock.report).toHaveBeenCalledWith('/workspace/project')
|
||||
|
||||
await act(async () => {
|
||||
useSessionStore.setState((state) => ({
|
||||
sessions: [
|
||||
...state.sessions,
|
||||
{
|
||||
...state.sessions[0]!,
|
||||
id: 'session-new',
|
||||
workDir: '/workspace/new',
|
||||
projectRoot: '/workspace/new',
|
||||
},
|
||||
],
|
||||
activeSessionId: 'session-new',
|
||||
}))
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Run Doctor/i })).not.toBeDisabled()
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: /Run Doctor/i }))
|
||||
expect(doctorApiMock.report).toHaveBeenCalledWith('/workspace/new')
|
||||
expect(screen.getByRole('button', { name: /Run Doctor/i })).toBeDisabled()
|
||||
|
||||
await act(async () => {
|
||||
oldRequest.resolve(doctorReport('<project>/old-finding.json'))
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(screen.queryByText('<project>/old-finding.json')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Run Doctor/i })).toBeDisabled()
|
||||
|
||||
await act(async () => {
|
||||
newRequest.resolve(doctorReport('<project>/new-finding.json'))
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(await screen.findByText('<project>/new-finding.json')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Run Doctor/i })).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('preserves reset results and releases reset loading when cwd changes during refresh', async () => {
|
||||
const stalledRefresh = deferred<ReturnType<typeof doctorReport>>()
|
||||
doctorApiMock.report.mockReturnValueOnce(stalledRefresh.promise)
|
||||
window.localStorage.clear()
|
||||
for (const key of SAFE_DOCTOR_STORAGE_KEYS) {
|
||||
window.localStorage.setItem(key, `${key}-value`)
|
||||
}
|
||||
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('Diagnostics'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Reset safe UI state/i }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Reset safe UI state' })
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /Reset safe UI state/i }))
|
||||
|
||||
expect(await screen.findByText(/Removed keys:.*cc-haha-app-zoom/)).toBeInTheDocument()
|
||||
expect(screen.getByText('Failed keys: None')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Reset safe UI state/i })).toBeDisabled()
|
||||
|
||||
await act(async () => {
|
||||
useSessionStore.setState((state) => ({
|
||||
sessions: [
|
||||
...state.sessions,
|
||||
{
|
||||
...state.sessions[0]!,
|
||||
id: 'session-reset-new',
|
||||
workDir: '/workspace/reset-new',
|
||||
projectRoot: '/workspace/reset-new',
|
||||
},
|
||||
],
|
||||
activeSessionId: 'session-reset-new',
|
||||
}))
|
||||
})
|
||||
|
||||
expect(screen.getByText(/Removed keys:.*cc-haha-app-zoom/)).toBeInTheDocument()
|
||||
expect(screen.getByText('Failed keys: None')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Reset safe UI state/i })).not.toBeDisabled()
|
||||
|
||||
await act(async () => {
|
||||
stalledRefresh.resolve(doctorReport('<project>/stale-reset-finding.json'))
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(screen.queryByText('<project>/stale-reset-finding.json')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ignores a stale reset refresh rejection after cwd changes', async () => {
|
||||
const stalledRefresh = deferred<ReturnType<typeof doctorReport>>()
|
||||
doctorApiMock.report.mockReturnValueOnce(stalledRefresh.promise)
|
||||
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('Diagnostics'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Reset safe UI state/i }))
|
||||
fireEvent.click(within(await screen.findByRole('dialog', { name: 'Reset safe UI state' }))
|
||||
.getByRole('button', { name: /Reset safe UI state/i }))
|
||||
|
||||
await act(async () => {
|
||||
useSessionStore.setState((state) => ({
|
||||
sessions: [
|
||||
...state.sessions,
|
||||
{
|
||||
...state.sessions[0]!,
|
||||
id: 'session-reject-new',
|
||||
workDir: '/workspace/reject-new',
|
||||
projectRoot: '/workspace/reject-new',
|
||||
},
|
||||
],
|
||||
activeSessionId: 'session-reject-new',
|
||||
}))
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
stalledRefresh.reject(new Error('stale reset refresh failed'))
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(useUIStore.getState().toasts.map((toast) => toast.message)).not.toContain('stale reset refresh failed')
|
||||
expect(screen.getByRole('button', { name: /Reset safe UI state/i })).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -30,6 +30,9 @@ export type DiagnosticsStatus = {
|
||||
maxBytes: number
|
||||
totalBytes: number
|
||||
eventCount: number
|
||||
physicalLineCount: number
|
||||
corruptLineCount: number
|
||||
storageLimitExceeded: boolean
|
||||
recentErrorCount: number
|
||||
lastEventAt: string | null
|
||||
}
|
||||
@ -43,6 +46,7 @@ export type DiagnosticsBundle = {
|
||||
export const diagnosticsApi = {
|
||||
getStatus: () => api.get<DiagnosticsStatus>('/api/diagnostics/status'),
|
||||
getEvents: (limit = 100) => api.get<{ events: DiagnosticEvent[] }>(`/api/diagnostics/events?limit=${limit}`),
|
||||
getIssueReport: () => api.get<{ report: string }>('/api/diagnostics/issue-report'),
|
||||
recordEvent: (event: DiagnosticEventInput) => api.post<{ ok: true }>('/api/diagnostics/events', event, { timeout: 5_000 }),
|
||||
exportBundle: () => api.post<{ bundle: DiagnosticsBundle }>('/api/diagnostics/export', undefined, { timeout: 60_000 }),
|
||||
openLogDir: () => api.post<{ ok: true }>('/api/diagnostics/open-log-dir'),
|
||||
|
||||
@ -8,7 +8,7 @@ export type DoctorReportItem = {
|
||||
path: string
|
||||
protected: boolean
|
||||
exists: boolean
|
||||
status: 'ok' | 'missing' | 'invalid_json' | 'invalid_jsonl' | 'unreadable'
|
||||
status: 'ok' | 'not_configured' | 'missing' | 'invalid_json' | 'invalid_jsonl' | 'invalid_schema' | 'unreadable'
|
||||
bytes: number
|
||||
entryCount?: number
|
||||
lineCount?: number
|
||||
@ -27,49 +27,15 @@ export type DoctorReport = {
|
||||
summary: {
|
||||
total: number
|
||||
protectedCount: number
|
||||
neutralCount: number
|
||||
missingCount: number
|
||||
invalidCount: number
|
||||
}
|
||||
}
|
||||
|
||||
export type DoctorRepairResult = {
|
||||
dryRun: true
|
||||
mutated: false
|
||||
operations: Array<{
|
||||
id: string
|
||||
path: string
|
||||
action: 'would_repair'
|
||||
}>
|
||||
skips: Array<{
|
||||
id: string
|
||||
path: string
|
||||
reason: 'protected'
|
||||
}>
|
||||
summary: {
|
||||
operationCount: number
|
||||
skipCount: number
|
||||
}
|
||||
}
|
||||
|
||||
export type DoctorReportRepairResponse = {
|
||||
ok: boolean
|
||||
report: DoctorReport
|
||||
repair: DoctorRepairResult
|
||||
}
|
||||
|
||||
export const doctorApi = {
|
||||
report: () => api.get<{ report: DoctorReport }>('/api/doctor/report', { timeout: 3_000 }),
|
||||
repair: () => api.post<{ result: DoctorRepairResult }>('/api/doctor/repair', {}, { timeout: 3_000 }),
|
||||
reportAndRepair: async (): Promise<DoctorReportRepairResponse> => {
|
||||
const [{ report }, { result }] = await Promise.all([
|
||||
doctorApi.report(),
|
||||
doctorApi.repair(),
|
||||
])
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
report,
|
||||
repair: result,
|
||||
}
|
||||
report: (cwd?: string) => {
|
||||
const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''
|
||||
return api.get<{ report: DoctorReport }>(`/api/doctor/report${query}`, { timeout: 3_000 })
|
||||
},
|
||||
}
|
||||
|
||||
@ -1,9 +1,16 @@
|
||||
import { Stethoscope } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Button } from '../shared/Button'
|
||||
import { RotateCcw, Stethoscope } from 'lucide-react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { DoctorReport, DoctorReportItem } from '../../api/doctor'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { runDoctorRepair, type DoctorRepairResult } from '../../lib/doctorRepair'
|
||||
import {
|
||||
runDoctorCheck,
|
||||
runLocalDoctorRepair,
|
||||
type LocalDoctorRepairResult,
|
||||
} from '../../lib/doctorRepair'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useUIStore } from '../../stores/uiStore'
|
||||
import { Button } from '../shared/Button'
|
||||
import { ConfirmDialog } from '../shared/ConfirmDialog'
|
||||
|
||||
type DoctorPanelProps = {
|
||||
compact?: boolean
|
||||
@ -12,32 +19,104 @@ type DoctorPanelProps = {
|
||||
export function DoctorPanel({ compact = false }: DoctorPanelProps) {
|
||||
const t = useTranslation()
|
||||
const addToast = useUIStore((s) => s.addToast)
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const [result, setResult] = useState<DoctorRepairResult | null>(null)
|
||||
const activeSessionId = useSessionStore((s) => s.activeSessionId)
|
||||
const sessions = useSessionStore((s) => s.sessions)
|
||||
const activeSession = useMemo(
|
||||
() => sessions.find((session) => session.id === activeSessionId),
|
||||
[activeSessionId, sessions],
|
||||
)
|
||||
const cwd = activeSession?.workDirExists === false
|
||||
? undefined
|
||||
: activeSession?.workDir ?? activeSession?.projectRoot ?? undefined
|
||||
const requestSequence = useRef(0)
|
||||
const cwdRef = useRef(cwd)
|
||||
cwdRef.current = cwd
|
||||
const [runningRequestId, setRunningRequestId] = useState<number | null>(null)
|
||||
const [resettingRequestId, setResettingRequestId] = useState<number | null>(null)
|
||||
const [resetConfirmOpen, setResetConfirmOpen] = useState(false)
|
||||
const [reportResult, setReportResult] = useState<{ cwd?: string; report: DoctorReport } | null>(null)
|
||||
const [resetResult, setResetResult] = useState<LocalDoctorRepairResult | null>(null)
|
||||
const report = reportResult && reportResult.cwd === cwd ? reportResult.report : null
|
||||
|
||||
useEffect(() => {
|
||||
requestSequence.current += 1
|
||||
setRunningRequestId(null)
|
||||
setResettingRequestId(null)
|
||||
setReportResult(null)
|
||||
}, [cwd])
|
||||
|
||||
const beginReportRequest = () => {
|
||||
const requestId = ++requestSequence.current
|
||||
const requestCwd = cwd
|
||||
return {
|
||||
requestId,
|
||||
requestCwd,
|
||||
response: runDoctorCheck({ cwd: requestCwd }),
|
||||
}
|
||||
}
|
||||
|
||||
const isCurrentRequest = (requestId: number, requestCwd?: string) => {
|
||||
return requestSequence.current === requestId && cwdRef.current === requestCwd
|
||||
}
|
||||
|
||||
const handleRunDoctor = async () => {
|
||||
setIsRunning(true)
|
||||
const request = beginReportRequest()
|
||||
setResettingRequestId(null)
|
||||
setRunningRequestId(request.requestId)
|
||||
try {
|
||||
const nextResult = await runDoctorRepair()
|
||||
setResult(nextResult)
|
||||
addToast({
|
||||
type: nextResult.local.failedKeys.length === 0 ? 'success' : 'warning',
|
||||
message: getDoctorToastMessage(t, nextResult),
|
||||
})
|
||||
const nextReport = await request.response
|
||||
if (!isCurrentRequest(request.requestId, request.requestCwd)) return
|
||||
setReportResult({ cwd: request.requestCwd, report: nextReport })
|
||||
addToast({ type: 'success', message: t('settings.diagnostics.doctorCheckCompleted') })
|
||||
} catch (error) {
|
||||
if (!isCurrentRequest(request.requestId, request.requestCwd)) return
|
||||
addToast({
|
||||
type: 'error',
|
||||
message: error instanceof Error ? error.message : t('settings.diagnostics.doctorFailed'),
|
||||
})
|
||||
} finally {
|
||||
setIsRunning(false)
|
||||
setRunningRequestId((current) => current === request.requestId ? null : current)
|
||||
}
|
||||
}
|
||||
|
||||
const statusText = result ? getDoctorStatusMessage(t, result) : null
|
||||
const handleResetSafeState = async () => {
|
||||
let requestId: number | null = null
|
||||
const requestCwd = cwd
|
||||
try {
|
||||
const result = runLocalDoctorRepair()
|
||||
setResetResult(result)
|
||||
setResetConfirmOpen(false)
|
||||
addToast({
|
||||
type: result.failedKeys.length === 0 ? 'success' : 'warning',
|
||||
message: result.failedKeys.length === 0
|
||||
? t('settings.diagnostics.doctorResetCompleted')
|
||||
: t('settings.diagnostics.doctorPartial', { count: String(result.failedKeys.length) }),
|
||||
})
|
||||
const request = beginReportRequest()
|
||||
requestId = request.requestId
|
||||
setRunningRequestId(null)
|
||||
setResettingRequestId(request.requestId)
|
||||
const nextReport = await request.response
|
||||
if (!isCurrentRequest(request.requestId, request.requestCwd)) return
|
||||
setReportResult({ cwd: request.requestCwd, report: nextReport })
|
||||
} catch (error) {
|
||||
if (requestId !== null && !isCurrentRequest(requestId, requestCwd)) return
|
||||
addToast({
|
||||
type: 'error',
|
||||
message: error instanceof Error ? error.message : t('settings.diagnostics.doctorFailed'),
|
||||
})
|
||||
} finally {
|
||||
setResettingRequestId((current) => current === requestId ? null : current)
|
||||
}
|
||||
}
|
||||
|
||||
const unhealthyItems = report?.items.filter(
|
||||
(item) => item.status !== 'ok' && item.status !== 'not_configured',
|
||||
) ?? []
|
||||
const healthyCount = report?.items.filter((item) => item.status === 'ok').length ?? 0
|
||||
|
||||
return (
|
||||
<section className={`rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] ${compact ? 'p-3' : 'p-4'} `}>
|
||||
<section className={`rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] ${compact ? 'p-3' : 'p-4'}`}>
|
||||
<div className={`flex ${compact ? 'flex-col gap-3' : 'items-start justify-between gap-4'}`}>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-[var(--color-text-primary)]">{t('settings.diagnostics.doctorTitle')}</div>
|
||||
@ -48,59 +127,109 @@ export function DoctorPanel({ compact = false }: DoctorPanelProps) {
|
||||
{t('settings.diagnostics.doctorProtectedData')}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`flex ${compact ? 'justify-start' : 'justify-end'} shrink-0`}>
|
||||
<div className={`flex flex-wrap gap-2 ${compact ? 'justify-start' : 'justify-end'} shrink-0`}>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleRunDoctor}
|
||||
loading={isRunning}
|
||||
loading={runningRequestId !== null}
|
||||
icon={<Stethoscope className="h-4 w-4" aria-hidden="true" />}
|
||||
>
|
||||
{t('settings.diagnostics.runDoctor')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setResetConfirmOpen(true)}
|
||||
loading={resettingRequestId !== null}
|
||||
icon={<RotateCcw className="h-4 w-4" aria-hidden="true" />}
|
||||
>
|
||||
{t('settings.diagnostics.resetSafeUiState')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-[11px] leading-relaxed text-[var(--color-text-tertiary)]">
|
||||
{t('settings.diagnostics.doctorSafeKeys')}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-[var(--color-text-secondary)]">
|
||||
{t('settings.diagnostics.doctorScope')}: {cwd
|
||||
? t('settings.diagnostics.doctorScopeProject')
|
||||
: t('settings.diagnostics.doctorScopeUser')}
|
||||
</div>
|
||||
|
||||
{statusText ? (
|
||||
<div className="mt-2 rounded-md border border-[var(--color-border)] bg-[var(--color-bg-secondary)] px-2.5 py-2 text-xs text-[var(--color-text-secondary)]">
|
||||
{statusText}
|
||||
{report ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg-secondary)] px-2.5 py-2 text-xs text-[var(--color-text-secondary)]">
|
||||
{t('settings.diagnostics.doctorSummary', {
|
||||
healthy: String(healthyCount),
|
||||
neutral: String(report.summary.neutralCount),
|
||||
missing: String(report.summary.missingCount),
|
||||
invalid: String(report.summary.invalidCount),
|
||||
})}
|
||||
</div>
|
||||
{unhealthyItems.length === 0 ? (
|
||||
<div className="text-xs text-[var(--color-text-tertiary)]">
|
||||
{t('settings.diagnostics.doctorNoFindings')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5" aria-label={t('settings.diagnostics.doctorFindings')}>
|
||||
{unhealthyItems.map((item) => <DoctorFinding key={item.id} item={item} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{resetResult ? (
|
||||
<div className="mt-2 rounded-md border border-[var(--color-border)] bg-[var(--color-bg-secondary)] px-2.5 py-2 text-xs text-[var(--color-text-secondary)]">
|
||||
<div>{t('settings.diagnostics.doctorRemovedKeys')}: {formatKeys(resetResult.removedKeys, t('settings.diagnostics.doctorNoKeys'))}</div>
|
||||
<div className="mt-1">{t('settings.diagnostics.doctorFailedKeys')}: {formatKeys(resetResult.failedKeys, t('settings.diagnostics.doctorNoKeys'))}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ConfirmDialog
|
||||
open={resetConfirmOpen}
|
||||
onClose={() => {
|
||||
if (resettingRequestId === null) setResetConfirmOpen(false)
|
||||
}}
|
||||
onConfirm={handleResetSafeState}
|
||||
title={t('settings.diagnostics.resetSafeUiState')}
|
||||
body={t('settings.diagnostics.confirmResetSafeUiState')}
|
||||
confirmLabel={t('settings.diagnostics.resetSafeUiState')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
confirmVariant="danger"
|
||||
loading={resettingRequestId !== null}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function getDoctorToastMessage(
|
||||
t: ReturnType<typeof useTranslation>,
|
||||
result: DoctorRepairResult,
|
||||
): string {
|
||||
if (result.local.failedKeys.length > 0) {
|
||||
return t('settings.diagnostics.doctorPartial', { count: String(result.local.failedKeys.length) })
|
||||
}
|
||||
return t('settings.diagnostics.doctorCompleted')
|
||||
function DoctorFinding({ item }: { item: DoctorReportItem }) {
|
||||
const t = useTranslation()
|
||||
return (
|
||||
<div className="rounded-md border border-[var(--color-border)] px-2.5 py-2 text-xs">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-mono text-[var(--color-text-secondary)] break-all">{item.path}</span>
|
||||
<span className="font-medium text-[var(--color-warning)]">
|
||||
{getStatusLabel(t, item.status)}
|
||||
</span>
|
||||
</div>
|
||||
{item.error ? <div className="mt-1 text-[var(--color-text-tertiary)] break-words">{item.error}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getDoctorStatusMessage(
|
||||
t: ReturnType<typeof useTranslation>,
|
||||
result: DoctorRepairResult,
|
||||
): string {
|
||||
const clearedCount = result.local.removedKeys.length
|
||||
const base = t('settings.diagnostics.doctorResultLocal', { count: String(clearedCount) })
|
||||
|
||||
if (result.local.failedKeys.length > 0) {
|
||||
return `${base} ${t('settings.diagnostics.doctorResultFailedKeys', { count: String(result.local.failedKeys.length) })}`
|
||||
function getStatusLabel(t: ReturnType<typeof useTranslation>, status: DoctorReportItem['status']): string {
|
||||
switch (status) {
|
||||
case 'not_configured': return t('settings.diagnostics.doctorStatusNotConfigured')
|
||||
case 'missing': return t('settings.diagnostics.doctorStatusMissing')
|
||||
case 'invalid_json': return t('settings.diagnostics.doctorStatusInvalidJson')
|
||||
case 'invalid_jsonl': return t('settings.diagnostics.doctorStatusInvalidJsonl')
|
||||
case 'invalid_schema': return t('settings.diagnostics.doctorStatusInvalidSchema')
|
||||
case 'unreadable': return t('settings.diagnostics.doctorStatusUnreadable')
|
||||
default: return t('settings.diagnostics.doctorStatusHealthy')
|
||||
}
|
||||
|
||||
if (result.server) {
|
||||
return `${base} ${t('settings.diagnostics.doctorServerRan')}`
|
||||
}
|
||||
|
||||
if (result.serverError) {
|
||||
return `${base} ${t('settings.diagnostics.doctorServerUnavailable')}`
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
|
||||
function formatKeys(keys: string[], emptyLabel: string): string {
|
||||
return keys.length > 0 ? keys.join(', ') : emptyLabel
|
||||
}
|
||||
|
||||
46
desktop/src/components/shared/Toast.test.tsx
Normal file
46
desktop/src/components/shared/Toast.test.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useUIStore } from '../../stores/uiStore'
|
||||
import { ToastContainer } from './Toast'
|
||||
|
||||
describe('ToastContainer accessibility', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
useUIStore.setState({ toasts: [] })
|
||||
})
|
||||
|
||||
it('announces success and info messages politely as statuses', () => {
|
||||
useUIStore.setState({
|
||||
toasts: [
|
||||
{ id: 'success', type: 'success', message: 'Saved' },
|
||||
{ id: 'info', type: 'info', message: 'Refreshing' },
|
||||
],
|
||||
})
|
||||
|
||||
render(<ToastContainer />)
|
||||
|
||||
expect(screen.getByText('Saved').closest('[role]')).toHaveAttribute('role', 'status')
|
||||
expect(screen.getByText('Saved').closest('[role]')).toHaveAttribute('aria-live', 'polite')
|
||||
expect(screen.getByText('Refreshing').closest('[role]')).toHaveAttribute('role', 'status')
|
||||
})
|
||||
|
||||
it('announces warning and error messages assertively as alerts', () => {
|
||||
useSettingsStore.setState({ locale: 'zh' })
|
||||
useUIStore.setState({
|
||||
toasts: [
|
||||
{ id: 'warning', type: 'warning', message: 'Check settings' },
|
||||
{ id: 'error', type: 'error', message: 'Save failed' },
|
||||
],
|
||||
})
|
||||
|
||||
render(<ToastContainer />)
|
||||
|
||||
expect(screen.getByText('Check settings').closest('[role]')).toHaveAttribute('role', 'alert')
|
||||
expect(screen.getByText('Check settings').closest('[role]')).toHaveAttribute('aria-live', 'assertive')
|
||||
expect(screen.getByText('Save failed').closest('[role]')).toHaveAttribute('role', 'alert')
|
||||
expect(screen.getAllByRole('button', { name: '关闭通知' })).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@ -1,4 +1,5 @@
|
||||
import { useUIStore, type Toast as ToastType } from '../../stores/uiStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
const typeStyles: Record<ToastType['type'], string> = {
|
||||
success: 'border-l-4 border-l-[var(--color-success)]',
|
||||
@ -8,10 +9,15 @@ const typeStyles: Record<ToastType['type'], string> = {
|
||||
}
|
||||
|
||||
function ToastItem({ toast }: { toast: ToastType }) {
|
||||
const t = useTranslation()
|
||||
const removeToast = useUIStore((s) => s.removeToast)
|
||||
const isUrgent = toast.type === 'warning' || toast.type === 'error'
|
||||
|
||||
return (
|
||||
<div
|
||||
role={isUrgent ? 'alert' : 'status'}
|
||||
aria-live={isUrgent ? 'assertive' : 'polite'}
|
||||
aria-atomic="true"
|
||||
className={`
|
||||
bg-[var(--color-surface)] rounded-[var(--radius-md)] shadow-[var(--shadow-dropdown)]
|
||||
px-4 py-3 text-sm text-[var(--color-text-primary)]
|
||||
@ -23,6 +29,7 @@ function ToastItem({ toast }: { toast: ToastType }) {
|
||||
<span>{toast.message}</span>
|
||||
<button
|
||||
onClick={() => removeToast(toast.id)}
|
||||
aria-label={t('common.dismissNotification')}
|
||||
className="text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] text-lg leading-none"
|
||||
>
|
||||
×
|
||||
|
||||
@ -18,6 +18,7 @@ export const en = {
|
||||
'common.error': 'Error',
|
||||
'common.copyFailed': 'Copy failed.',
|
||||
'common.copied': 'Copied',
|
||||
'common.dismissNotification': 'Dismiss notification',
|
||||
|
||||
// ─── Sidebar ──────────────────────────────────────
|
||||
'sidebar.newSession': 'New session',
|
||||
@ -316,6 +317,12 @@ export const en = {
|
||||
'settings.diagnostics.refresh': 'Refresh',
|
||||
'settings.diagnostics.totalSize': 'Log size',
|
||||
'settings.diagnostics.events': 'Events',
|
||||
'settings.diagnostics.completeEvents': 'Complete events',
|
||||
'settings.diagnostics.completeEventsValue': '{count} complete events',
|
||||
'settings.diagnostics.visibleEvents': 'Visible events',
|
||||
'settings.diagnostics.visibleEventsValue': '{count} visible events',
|
||||
'settings.diagnostics.corruptLinesWarning': 'Detected or retained evidence of {count} corrupt diagnostic records. Current diagnostic files contain {physical} physical lines. Event totals and recent history may be incomplete.',
|
||||
'settings.diagnostics.storageLimitExceededWarning': 'One or more diagnostic surfaces or active writers temporarily exceed their own retention target. Cleanup will occur as their segments close or age out.',
|
||||
'settings.diagnostics.recentErrors': '24h warnings',
|
||||
'settings.diagnostics.retention': 'Retention',
|
||||
'settings.diagnostics.retentionValue': '{days} days / {size}',
|
||||
@ -323,9 +330,12 @@ export const en = {
|
||||
'settings.diagnostics.openDirectory': 'Open',
|
||||
'settings.diagnostics.exportBundle': 'Export Bundle',
|
||||
'settings.diagnostics.copySummary': 'Copy Error Summary',
|
||||
'settings.diagnostics.copyIssueReport': 'Copy issue report',
|
||||
'settings.diagnostics.issueReportCopied': 'Issue report copied.',
|
||||
'settings.diagnostics.issueReportCopyFailed': 'Failed to copy issue report.',
|
||||
'settings.diagnostics.clearLogs': 'Clear Logs',
|
||||
'settings.diagnostics.recentEvents': 'Recent Events',
|
||||
'settings.diagnostics.privacyNote': 'Exported diagnostics are sanitized and do not include chat content, file contents, full environment variables, or API keys.',
|
||||
'settings.diagnostics.privacyNote': 'Issue reports and exported bundles use best-effort sanitization to omit chat and file contents, full environment variables, and API keys. Review them for private metadata before sharing.',
|
||||
'settings.diagnostics.noEvents': 'No diagnostic events yet.',
|
||||
'settings.diagnostics.noRecentErrors': 'No recent warnings or errors.',
|
||||
'settings.diagnostics.loadFailed': 'Failed to load diagnostics.',
|
||||
@ -338,18 +348,37 @@ export const en = {
|
||||
'settings.diagnostics.cleared': 'Diagnostics cleared.',
|
||||
'settings.diagnostics.clearFailed': 'Failed to clear diagnostics.',
|
||||
'settings.diagnostics.eventDetails': 'Details',
|
||||
'settings.diagnostics.eventId': 'Event ID',
|
||||
'settings.diagnostics.copyEventId': 'Copy event ID',
|
||||
'settings.diagnostics.eventIdCopied': 'Event ID copied.',
|
||||
'settings.diagnostics.eventIdCopyFailed': 'Failed to copy event ID.',
|
||||
'settings.diagnostics.doctorTitle': 'Doctor',
|
||||
'settings.diagnostics.doctorDescription': 'Safe desktop repair for startup and UI state problems.',
|
||||
'settings.diagnostics.doctorProtectedData': 'Never touches chat history, model config, skills, MCP, IM, or OAuth.',
|
||||
'settings.diagnostics.doctorSafeKeys': 'Clears only: cc-haha-open-tabs, cc-haha-session-runtime, cc-haha-theme, cc-haha-locale, and cc-haha.persistence.schemaVersion.',
|
||||
'settings.diagnostics.doctorDescription': 'Check protected user and project state without changing it.',
|
||||
'settings.diagnostics.doctorProtectedData': 'Chat history, model config, skills, MCP, IM, and OAuth remain protected.',
|
||||
'settings.diagnostics.doctorSafeKeys': 'Reset removes only: cc-haha-open-tabs, cc-haha-session-runtime, cc-haha-theme, cc-haha-locale, cc-haha-app-zoom, cc-haha-ui-zoom, and cc-haha.persistence.schemaVersion.',
|
||||
'settings.diagnostics.runDoctor': 'Run Doctor',
|
||||
'settings.diagnostics.doctorCompleted': 'Doctor completed.',
|
||||
'settings.diagnostics.doctorPartial': 'Doctor completed with {count} local cleanup issue(s).',
|
||||
'settings.diagnostics.resetSafeUiState': 'Reset safe UI state',
|
||||
'settings.diagnostics.confirmResetSafeUiState': 'Reset only the listed regenerable desktop UI keys? Protected user and project data will not be changed.',
|
||||
'settings.diagnostics.doctorCheckCompleted': 'Doctor check completed.',
|
||||
'settings.diagnostics.doctorResetCompleted': 'Safe UI state reset completed.',
|
||||
'settings.diagnostics.doctorPartial': 'Safe UI state reset completed with {count} failed key(s).',
|
||||
'settings.diagnostics.doctorFailed': 'Doctor failed.',
|
||||
'settings.diagnostics.doctorResultLocal': 'Cleared {count} safe UI key(s).',
|
||||
'settings.diagnostics.doctorResultFailedKeys': '{count} key(s) could not be removed.',
|
||||
'settings.diagnostics.doctorServerRan': 'Server Doctor repair also ran.',
|
||||
'settings.diagnostics.doctorServerUnavailable': 'Server Doctor was unavailable, so only local repair ran.',
|
||||
'settings.diagnostics.doctorScope': 'Scope',
|
||||
'settings.diagnostics.doctorScopeProject': 'User and active project',
|
||||
'settings.diagnostics.doctorScopeUser': 'User only',
|
||||
'settings.diagnostics.doctorSummary': 'Healthy: {healthy} · Not configured: {neutral} · Missing: {missing} · Invalid: {invalid}',
|
||||
'settings.diagnostics.doctorFindings': 'Doctor findings',
|
||||
'settings.diagnostics.doctorNoFindings': 'No missing or invalid items found.',
|
||||
'settings.diagnostics.doctorStatusHealthy': 'Healthy',
|
||||
'settings.diagnostics.doctorStatusNotConfigured': 'Not configured',
|
||||
'settings.diagnostics.doctorStatusMissing': 'Missing',
|
||||
'settings.diagnostics.doctorStatusInvalidJson': 'Invalid JSON',
|
||||
'settings.diagnostics.doctorStatusInvalidJsonl': 'Invalid JSONL',
|
||||
'settings.diagnostics.doctorStatusInvalidSchema': 'Invalid schema',
|
||||
'settings.diagnostics.doctorStatusUnreadable': 'Unreadable',
|
||||
'settings.diagnostics.doctorRemovedKeys': 'Removed keys',
|
||||
'settings.diagnostics.doctorFailedKeys': 'Failed keys',
|
||||
'settings.diagnostics.doctorNoKeys': 'None',
|
||||
'errorBoundary.title': 'Something went wrong.',
|
||||
'errorBoundary.description': 'The error was recorded in Diagnostics.',
|
||||
|
||||
|
||||
@ -20,6 +20,7 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'common.error': 'エラー',
|
||||
'common.copyFailed': 'コピーに失敗しました。',
|
||||
'common.copied': 'コピーしました',
|
||||
'common.dismissNotification': '通知を閉じる',
|
||||
|
||||
// ─── Sidebar ──────────────────────────────────────
|
||||
'sidebar.newSession': '新しいセッション',
|
||||
@ -318,6 +319,12 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'settings.diagnostics.refresh': '更新',
|
||||
'settings.diagnostics.totalSize': 'ログサイズ',
|
||||
'settings.diagnostics.events': 'イベント',
|
||||
'settings.diagnostics.completeEvents': '完全なイベント',
|
||||
'settings.diagnostics.completeEventsValue': '完全なイベント {count} 件',
|
||||
'settings.diagnostics.visibleEvents': '表示中のイベント',
|
||||
'settings.diagnostics.visibleEventsValue': '表示中のイベント {count} 件',
|
||||
'settings.diagnostics.corruptLinesWarning': '{count} 件の破損した診断レコードが検出されたか、その証跡が保持されています。現在の診断ファイルには {physical} 物理行があります。イベント総数と最近の履歴は不完全な可能性があります。',
|
||||
'settings.diagnostics.storageLimitExceededWarning': '1 つ以上の診断領域または実行中のライターが、それぞれの保持目標を一時的に超えています。各セグメントが閉じるか保持期限を過ぎるとクリーンアップされます。',
|
||||
'settings.diagnostics.recentErrors': '24 時間の警告',
|
||||
'settings.diagnostics.retention': '保持期間',
|
||||
'settings.diagnostics.retentionValue': '{days} 日 / {size}',
|
||||
@ -325,9 +332,12 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'settings.diagnostics.openDirectory': '開く',
|
||||
'settings.diagnostics.exportBundle': 'バンドルをエクスポート',
|
||||
'settings.diagnostics.copySummary': 'エラー概要をコピー',
|
||||
'settings.diagnostics.copyIssueReport': 'Issue レポートをコピー',
|
||||
'settings.diagnostics.issueReportCopied': 'Issue レポートをコピーしました。',
|
||||
'settings.diagnostics.issueReportCopyFailed': 'Issue レポートをコピーできませんでした。',
|
||||
'settings.diagnostics.clearLogs': 'ログをクリア',
|
||||
'settings.diagnostics.recentEvents': '最近のイベント',
|
||||
'settings.diagnostics.privacyNote': 'エクスポートされる診断情報はサニタイズされており、チャット内容、ファイル内容、すべての環境変数、API キーは含まれません。',
|
||||
'settings.diagnostics.privacyNote': 'Issue レポートとエクスポートバンドルはベストエフォートでサニタイズされ、チャットやファイルの内容、完全な環境変数、API キーを省略します。共有前に非公開メタデータがないか確認してください。',
|
||||
'settings.diagnostics.noEvents': '診断イベントはまだありません。',
|
||||
'settings.diagnostics.noRecentErrors': '最近の警告やエラーはありません。',
|
||||
'settings.diagnostics.loadFailed': '診断情報の読み込みに失敗しました。',
|
||||
@ -340,18 +350,37 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'settings.diagnostics.cleared': '診断情報をクリアしました。',
|
||||
'settings.diagnostics.clearFailed': '診断情報のクリアに失敗しました。',
|
||||
'settings.diagnostics.eventDetails': '詳細',
|
||||
'settings.diagnostics.eventId': 'イベント ID',
|
||||
'settings.diagnostics.copyEventId': 'イベント ID をコピー',
|
||||
'settings.diagnostics.eventIdCopied': 'イベント ID をコピーしました。',
|
||||
'settings.diagnostics.eventIdCopyFailed': 'イベント ID をコピーできませんでした。',
|
||||
'settings.diagnostics.doctorTitle': 'Doctor',
|
||||
'settings.diagnostics.doctorDescription': '起動および UI 状態の問題に対する安全なデスクトップ修復。',
|
||||
'settings.diagnostics.doctorProtectedData': 'チャット履歴、モデル設定、スキル、MCP、IM、OAuth には一切影響しません。',
|
||||
'settings.diagnostics.doctorSafeKeys': 'クリアされるのは次のみ: cc-haha-open-tabs、cc-haha-session-runtime、cc-haha-theme、cc-haha-locale、cc-haha.persistence.schemaVersion。',
|
||||
'settings.diagnostics.doctorDescription': '保護されたユーザーとプロジェクトの状態を変更せずに確認します。',
|
||||
'settings.diagnostics.doctorProtectedData': 'チャット履歴、モデル設定、スキル、MCP、IM、OAuth は常に保護されます。',
|
||||
'settings.diagnostics.doctorSafeKeys': 'リセットで削除されるのは次のみ: cc-haha-open-tabs、cc-haha-session-runtime、cc-haha-theme、cc-haha-locale、cc-haha-app-zoom、cc-haha-ui-zoom、cc-haha.persistence.schemaVersion。',
|
||||
'settings.diagnostics.runDoctor': 'Doctor を実行',
|
||||
'settings.diagnostics.doctorCompleted': 'Doctor が完了しました。',
|
||||
'settings.diagnostics.doctorPartial': 'Doctor は完了しましたが、{count} 件のローカルクリーンアップの問題があります。',
|
||||
'settings.diagnostics.resetSafeUiState': '安全な UI 状態をリセット',
|
||||
'settings.diagnostics.confirmResetSafeUiState': '上記の再生成可能なデスクトップ UI キーだけをリセットしますか?保護されたユーザーとプロジェクトのデータは変更されません。',
|
||||
'settings.diagnostics.doctorCheckCompleted': 'Doctor のチェックが完了しました。',
|
||||
'settings.diagnostics.doctorResetCompleted': '安全な UI 状態のリセットが完了しました。',
|
||||
'settings.diagnostics.doctorPartial': '安全な UI 状態のリセットは完了しましたが、{count} 件のキーで失敗しました。',
|
||||
'settings.diagnostics.doctorFailed': 'Doctor が失敗しました。',
|
||||
'settings.diagnostics.doctorResultLocal': '{count} 件の安全な UI キーをクリアしました。',
|
||||
'settings.diagnostics.doctorResultFailedKeys': '{count} 件のキーを削除できませんでした。',
|
||||
'settings.diagnostics.doctorServerRan': 'サーバー側の Doctor 修復も実行されました。',
|
||||
'settings.diagnostics.doctorServerUnavailable': 'サーバー側の Doctor が利用できなかったため、ローカル修復のみ実行されました。',
|
||||
'settings.diagnostics.doctorScope': '範囲',
|
||||
'settings.diagnostics.doctorScopeProject': 'ユーザーとアクティブなプロジェクト',
|
||||
'settings.diagnostics.doctorScopeUser': 'ユーザーのみ',
|
||||
'settings.diagnostics.doctorSummary': '正常: {healthy} · 未設定: {neutral} · 不足: {missing} · 無効: {invalid}',
|
||||
'settings.diagnostics.doctorFindings': 'Doctor の検出結果',
|
||||
'settings.diagnostics.doctorNoFindings': '不足または無効な項目はありません。',
|
||||
'settings.diagnostics.doctorStatusHealthy': '正常',
|
||||
'settings.diagnostics.doctorStatusNotConfigured': '未設定',
|
||||
'settings.diagnostics.doctorStatusMissing': '不足',
|
||||
'settings.diagnostics.doctorStatusInvalidJson': '無効な JSON',
|
||||
'settings.diagnostics.doctorStatusInvalidJsonl': '無効な JSONL',
|
||||
'settings.diagnostics.doctorStatusInvalidSchema': '無効なスキーマ',
|
||||
'settings.diagnostics.doctorStatusUnreadable': '読み取り不可',
|
||||
'settings.diagnostics.doctorRemovedKeys': '削除したキー',
|
||||
'settings.diagnostics.doctorFailedKeys': '失敗したキー',
|
||||
'settings.diagnostics.doctorNoKeys': 'なし',
|
||||
'errorBoundary.title': '問題が発生しました。',
|
||||
'errorBoundary.description': 'エラーは診断に記録されました。',
|
||||
|
||||
|
||||
@ -20,6 +20,7 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'common.error': '오류',
|
||||
'common.copyFailed': '복사하지 못했습니다.',
|
||||
'common.copied': '복사됨',
|
||||
'common.dismissNotification': '알림 닫기',
|
||||
|
||||
// ─── Sidebar ──────────────────────────────────────
|
||||
'sidebar.newSession': '새 세션',
|
||||
@ -318,6 +319,12 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'settings.diagnostics.refresh': '새로 고침',
|
||||
'settings.diagnostics.totalSize': '로그 크기',
|
||||
'settings.diagnostics.events': '이벤트',
|
||||
'settings.diagnostics.completeEvents': '정상 이벤트',
|
||||
'settings.diagnostics.completeEventsValue': '정상 이벤트 {count}개',
|
||||
'settings.diagnostics.visibleEvents': '표시된 이벤트',
|
||||
'settings.diagnostics.visibleEventsValue': '표시된 이벤트 {count}개',
|
||||
'settings.diagnostics.corruptLinesWarning': '손상된 진단 레코드 {count}개의 증거가 감지되었거나 보존되어 있습니다. 현재 진단 파일에는 물리적 줄 {physical}개가 있습니다. 이벤트 합계와 최근 기록이 불완전할 수 있습니다.',
|
||||
'settings.diagnostics.storageLimitExceededWarning': '하나 이상의 진단 영역 또는 활성 작성 프로세스가 각자의 보존 목표를 일시적으로 초과했습니다. 해당 세그먼트가 닫히거나 보존 기간이 지나면 정리됩니다.',
|
||||
'settings.diagnostics.recentErrors': '24시간 경고',
|
||||
'settings.diagnostics.retention': '보존 기간',
|
||||
'settings.diagnostics.retentionValue': '{days}일 / {size}',
|
||||
@ -325,9 +332,12 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'settings.diagnostics.openDirectory': '열기',
|
||||
'settings.diagnostics.exportBundle': '번들 내보내기',
|
||||
'settings.diagnostics.copySummary': '오류 요약 복사',
|
||||
'settings.diagnostics.copyIssueReport': '이슈 보고서 복사',
|
||||
'settings.diagnostics.issueReportCopied': '이슈 보고서를 복사했습니다.',
|
||||
'settings.diagnostics.issueReportCopyFailed': '이슈 보고서를 복사하지 못했습니다.',
|
||||
'settings.diagnostics.clearLogs': '로그 지우기',
|
||||
'settings.diagnostics.recentEvents': '최근 이벤트',
|
||||
'settings.diagnostics.privacyNote': '내보낸 진단 정보는 정제되며 채팅 내용, 파일 내용, 전체 환경 변수, API 키는 포함하지 않습니다.',
|
||||
'settings.diagnostics.privacyNote': '이슈 보고서와 내보낸 번들은 최선의 방식으로 정리되어 채팅 및 파일 내용, 전체 환경 변수와 API 키를 생략합니다. 공유 전에 비공개 메타데이터가 없는지 검토하세요.',
|
||||
'settings.diagnostics.noEvents': '아직 진단 이벤트가 없습니다.',
|
||||
'settings.diagnostics.noRecentErrors': '최근 경고나 오류가 없습니다.',
|
||||
'settings.diagnostics.loadFailed': '진단 정보를 불러오지 못했습니다.',
|
||||
@ -340,18 +350,37 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'settings.diagnostics.cleared': '진단 정보를 지웠습니다.',
|
||||
'settings.diagnostics.clearFailed': '진단 정보를 지우지 못했습니다.',
|
||||
'settings.diagnostics.eventDetails': '세부 정보',
|
||||
'settings.diagnostics.eventId': '이벤트 ID',
|
||||
'settings.diagnostics.copyEventId': '이벤트 ID 복사',
|
||||
'settings.diagnostics.eventIdCopied': '이벤트 ID를 복사했습니다.',
|
||||
'settings.diagnostics.eventIdCopyFailed': '이벤트 ID를 복사하지 못했습니다.',
|
||||
'settings.diagnostics.doctorTitle': 'Doctor',
|
||||
'settings.diagnostics.doctorDescription': '시작 및 UI 상태 문제를 위한 안전한 데스크톱 복구입니다.',
|
||||
'settings.diagnostics.doctorProtectedData': '채팅 기록, 모델 구성, 스킬, MCP, IM, OAuth는 절대 건드리지 않습니다.',
|
||||
'settings.diagnostics.doctorSafeKeys': '다음만 지웁니다: cc-haha-open-tabs, cc-haha-session-runtime, cc-haha-theme, cc-haha-locale, cc-haha.persistence.schemaVersion.',
|
||||
'settings.diagnostics.doctorDescription': '보호된 사용자 및 프로젝트 상태를 변경하지 않고 확인합니다.',
|
||||
'settings.diagnostics.doctorProtectedData': '채팅 기록, 모델 구성, 스킬, MCP, IM, OAuth는 항상 보호됩니다.',
|
||||
'settings.diagnostics.doctorSafeKeys': '재설정 시 다음 항목만 제거합니다: cc-haha-open-tabs, cc-haha-session-runtime, cc-haha-theme, cc-haha-locale, cc-haha-app-zoom, cc-haha-ui-zoom, cc-haha.persistence.schemaVersion.',
|
||||
'settings.diagnostics.runDoctor': 'Doctor 실행',
|
||||
'settings.diagnostics.doctorCompleted': 'Doctor가 완료되었습니다.',
|
||||
'settings.diagnostics.doctorPartial': 'Doctor가 완료되었지만 {count}건의 로컬 정리 문제가 있습니다.',
|
||||
'settings.diagnostics.resetSafeUiState': '안전한 UI 상태 재설정',
|
||||
'settings.diagnostics.confirmResetSafeUiState': '위에 나열된 재생성 가능한 데스크톱 UI 키만 재설정할까요? 보호된 사용자 및 프로젝트 데이터는 변경되지 않습니다.',
|
||||
'settings.diagnostics.doctorCheckCompleted': 'Doctor 확인이 완료되었습니다.',
|
||||
'settings.diagnostics.doctorResetCompleted': '안전한 UI 상태 재설정이 완료되었습니다.',
|
||||
'settings.diagnostics.doctorPartial': '안전한 UI 상태 재설정이 완료되었지만 {count}개의 키를 처리하지 못했습니다.',
|
||||
'settings.diagnostics.doctorFailed': 'Doctor가 실패했습니다.',
|
||||
'settings.diagnostics.doctorResultLocal': '{count}개의 안전한 UI 키를 지웠습니다.',
|
||||
'settings.diagnostics.doctorResultFailedKeys': '{count}개의 키를 제거하지 못했습니다.',
|
||||
'settings.diagnostics.doctorServerRan': '서버 측 Doctor 복구도 실행되었습니다.',
|
||||
'settings.diagnostics.doctorServerUnavailable': '서버 측 Doctor를 사용할 수 없어 로컬 복구만 실행되었습니다.',
|
||||
'settings.diagnostics.doctorScope': '범위',
|
||||
'settings.diagnostics.doctorScopeProject': '사용자 및 활성 프로젝트',
|
||||
'settings.diagnostics.doctorScopeUser': '사용자만',
|
||||
'settings.diagnostics.doctorSummary': '정상: {healthy} · 구성 안 됨: {neutral} · 누락: {missing} · 유효하지 않음: {invalid}',
|
||||
'settings.diagnostics.doctorFindings': 'Doctor 발견 항목',
|
||||
'settings.diagnostics.doctorNoFindings': '누락되거나 유효하지 않은 항목이 없습니다.',
|
||||
'settings.diagnostics.doctorStatusHealthy': '정상',
|
||||
'settings.diagnostics.doctorStatusNotConfigured': '구성 안 됨',
|
||||
'settings.diagnostics.doctorStatusMissing': '누락',
|
||||
'settings.diagnostics.doctorStatusInvalidJson': '유효하지 않은 JSON',
|
||||
'settings.diagnostics.doctorStatusInvalidJsonl': '유효하지 않은 JSONL',
|
||||
'settings.diagnostics.doctorStatusInvalidSchema': '유효하지 않은 스키마',
|
||||
'settings.diagnostics.doctorStatusUnreadable': '읽을 수 없음',
|
||||
'settings.diagnostics.doctorRemovedKeys': '제거된 키',
|
||||
'settings.diagnostics.doctorFailedKeys': '실패한 키',
|
||||
'settings.diagnostics.doctorNoKeys': '없음',
|
||||
'errorBoundary.title': '문제가 발생했습니다.',
|
||||
'errorBoundary.description': '오류가 진단에 기록되었습니다.',
|
||||
|
||||
|
||||
@ -20,6 +20,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'common.error': '錯誤',
|
||||
'common.copyFailed': '複製失敗。',
|
||||
'common.copied': '已複製',
|
||||
'common.dismissNotification': '關閉通知',
|
||||
|
||||
// ─── Sidebar ──────────────────────────────────────
|
||||
'sidebar.newSession': '新建會話',
|
||||
@ -318,6 +319,12 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.diagnostics.refresh': '重新整理',
|
||||
'settings.diagnostics.totalSize': '日誌大小',
|
||||
'settings.diagnostics.events': '事件數',
|
||||
'settings.diagnostics.completeEvents': '完整事件',
|
||||
'settings.diagnostics.completeEventsValue': '{count} 個完整事件',
|
||||
'settings.diagnostics.visibleEvents': '目前可見',
|
||||
'settings.diagnostics.visibleEventsValue': '{count} 個可見事件',
|
||||
'settings.diagnostics.corruptLinesWarning': '偵測到或保留了 {count} 筆損壞診斷記錄的證據。目前診斷檔案包含 {physical} 個實體行,事件總數與最近記錄可能不完整。',
|
||||
'settings.diagnostics.storageLimitExceededWarning': '一個或多個診斷區域或活動寫入程序暫時超出各自的保留目標;其分段關閉或過期後將進行清理。',
|
||||
'settings.diagnostics.recentErrors': '24h 警告',
|
||||
'settings.diagnostics.retention': '保留策略',
|
||||
'settings.diagnostics.retentionValue': '{days} 天 / {size}',
|
||||
@ -325,9 +332,12 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.diagnostics.openDirectory': '開啟',
|
||||
'settings.diagnostics.exportBundle': '匯出診斷包',
|
||||
'settings.diagnostics.copySummary': '複製錯誤摘要',
|
||||
'settings.diagnostics.copyIssueReport': '複製 Issue 報告',
|
||||
'settings.diagnostics.issueReportCopied': 'Issue 報告已複製。',
|
||||
'settings.diagnostics.issueReportCopyFailed': '複製 Issue 報告失敗。',
|
||||
'settings.diagnostics.clearLogs': '清理日誌',
|
||||
'settings.diagnostics.recentEvents': '最近事件',
|
||||
'settings.diagnostics.privacyNote': '匯出的診斷包會脫敏,不包含聊天內容、檔案內容、完整環境變數或 API Key。',
|
||||
'settings.diagnostics.privacyNote': 'Issue 報告與匯出套件會盡力遮蔽,省略聊天與檔案內容、完整環境變數及 API Key;分享前仍請檢查是否含有私密中繼資料。',
|
||||
'settings.diagnostics.noEvents': '暫無診斷事件。',
|
||||
'settings.diagnostics.noRecentErrors': '最近沒有警告或錯誤。',
|
||||
'settings.diagnostics.loadFailed': '載入診斷資訊失敗。',
|
||||
@ -340,18 +350,37 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.diagnostics.cleared': '診斷日誌已清理。',
|
||||
'settings.diagnostics.clearFailed': '清理診斷日誌失敗。',
|
||||
'settings.diagnostics.eventDetails': '詳情',
|
||||
'settings.diagnostics.eventId': '事件 ID',
|
||||
'settings.diagnostics.copyEventId': '複製事件 ID',
|
||||
'settings.diagnostics.eventIdCopied': '事件 ID 已複製。',
|
||||
'settings.diagnostics.eventIdCopyFailed': '複製事件 ID 失敗。',
|
||||
'settings.diagnostics.doctorTitle': 'Doctor',
|
||||
'settings.diagnostics.doctorDescription': '用於啟動和介面狀態問題的安全桌面修復。',
|
||||
'settings.diagnostics.doctorProtectedData': '絕不會觸碰聊天曆史、模型配置、Skills、MCP、IM 或 OAuth。',
|
||||
'settings.diagnostics.doctorSafeKeys': '只會清理:cc-haha-open-tabs、cc-haha-session-runtime、cc-haha-theme、cc-haha-locale 和 cc-haha.persistence.schemaVersion。',
|
||||
'settings.diagnostics.doctorDescription': '檢查受保護的使用者與專案狀態,不會進行修改。',
|
||||
'settings.diagnostics.doctorProtectedData': '聊天歷史、模型配置、Skills、MCP、IM 和 OAuth 始終受保護。',
|
||||
'settings.diagnostics.doctorSafeKeys': '重設只會移除:cc-haha-open-tabs、cc-haha-session-runtime、cc-haha-theme、cc-haha-locale、cc-haha-app-zoom、cc-haha-ui-zoom 和 cc-haha.persistence.schemaVersion。',
|
||||
'settings.diagnostics.runDoctor': '執行 Doctor',
|
||||
'settings.diagnostics.doctorCompleted': 'Doctor 已執行完成。',
|
||||
'settings.diagnostics.doctorPartial': 'Doctor 已完成,但有 {count} 個本地清理項處理失敗。',
|
||||
'settings.diagnostics.resetSafeUiState': '重設安全 UI 狀態',
|
||||
'settings.diagnostics.confirmResetSafeUiState': '僅重設上方列出的可再生桌面 UI 鍵?受保護的使用者與專案資料不會被修改。',
|
||||
'settings.diagnostics.doctorCheckCompleted': 'Doctor 檢查已完成。',
|
||||
'settings.diagnostics.doctorResetCompleted': '安全 UI 狀態重設已完成。',
|
||||
'settings.diagnostics.doctorPartial': '安全 UI 狀態重設已完成,但有 {count} 個鍵處理失敗。',
|
||||
'settings.diagnostics.doctorFailed': 'Doctor 執行失敗。',
|
||||
'settings.diagnostics.doctorResultLocal': '已清理 {count} 個安全 UI 鍵。',
|
||||
'settings.diagnostics.doctorResultFailedKeys': '有 {count} 個鍵無法移除。',
|
||||
'settings.diagnostics.doctorServerRan': '服務端 Doctor 修復也已執行。',
|
||||
'settings.diagnostics.doctorServerUnavailable': '服務端 Doctor 不可用,因此只執行了本地修復。',
|
||||
'settings.diagnostics.doctorScope': '範圍',
|
||||
'settings.diagnostics.doctorScopeProject': '使用者與目前專案',
|
||||
'settings.diagnostics.doctorScopeUser': '僅使用者',
|
||||
'settings.diagnostics.doctorSummary': '健康:{healthy} · 未設定:{neutral} · 缺少:{missing} · 無效:{invalid}',
|
||||
'settings.diagnostics.doctorFindings': 'Doctor 發現項目',
|
||||
'settings.diagnostics.doctorNoFindings': '未發現缺少或無效項目。',
|
||||
'settings.diagnostics.doctorStatusHealthy': '健康',
|
||||
'settings.diagnostics.doctorStatusNotConfigured': '未設定',
|
||||
'settings.diagnostics.doctorStatusMissing': '缺少',
|
||||
'settings.diagnostics.doctorStatusInvalidJson': 'JSON 無效',
|
||||
'settings.diagnostics.doctorStatusInvalidJsonl': 'JSONL 無效',
|
||||
'settings.diagnostics.doctorStatusInvalidSchema': 'Schema 無效',
|
||||
'settings.diagnostics.doctorStatusUnreadable': '無法讀取',
|
||||
'settings.diagnostics.doctorRemovedKeys': '已移除的鍵',
|
||||
'settings.diagnostics.doctorFailedKeys': '失敗的鍵',
|
||||
'settings.diagnostics.doctorNoKeys': '無',
|
||||
'errorBoundary.title': '出現異常。',
|
||||
'errorBoundary.description': '錯誤已記錄到診斷日誌。',
|
||||
|
||||
|
||||
@ -20,6 +20,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'common.error': '错误',
|
||||
'common.copyFailed': '复制失败。',
|
||||
'common.copied': '已复制',
|
||||
'common.dismissNotification': '关闭通知',
|
||||
|
||||
// ─── Sidebar ──────────────────────────────────────
|
||||
'sidebar.newSession': '新建会话',
|
||||
@ -318,6 +319,12 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.diagnostics.refresh': '刷新',
|
||||
'settings.diagnostics.totalSize': '日志大小',
|
||||
'settings.diagnostics.events': '事件数',
|
||||
'settings.diagnostics.completeEvents': '完整事件',
|
||||
'settings.diagnostics.completeEventsValue': '{count} 个完整事件',
|
||||
'settings.diagnostics.visibleEvents': '当前可见',
|
||||
'settings.diagnostics.visibleEventsValue': '{count} 个可见事件',
|
||||
'settings.diagnostics.corruptLinesWarning': '检测到或保留了 {count} 条损坏诊断记录的证据。当前诊断文件包含 {physical} 个物理行,事件总数和最近记录可能不完整。',
|
||||
'settings.diagnostics.storageLimitExceededWarning': '一个或多个诊断区域或活动写入进程暂时超出各自的保留目标;其分段关闭或过期后将进行清理。',
|
||||
'settings.diagnostics.recentErrors': '24h 警告',
|
||||
'settings.diagnostics.retention': '保留策略',
|
||||
'settings.diagnostics.retentionValue': '{days} 天 / {size}',
|
||||
@ -325,9 +332,12 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.diagnostics.openDirectory': '打开',
|
||||
'settings.diagnostics.exportBundle': '导出诊断包',
|
||||
'settings.diagnostics.copySummary': '复制错误摘要',
|
||||
'settings.diagnostics.copyIssueReport': '复制 Issue 报告',
|
||||
'settings.diagnostics.issueReportCopied': 'Issue 报告已复制。',
|
||||
'settings.diagnostics.issueReportCopyFailed': '复制 Issue 报告失败。',
|
||||
'settings.diagnostics.clearLogs': '清理日志',
|
||||
'settings.diagnostics.recentEvents': '最近事件',
|
||||
'settings.diagnostics.privacyNote': '导出的诊断包会脱敏,不包含聊天内容、文件内容、完整环境变量或 API Key。',
|
||||
'settings.diagnostics.privacyNote': 'Issue 报告和导出包会尽力脱敏,省略聊天与文件内容、完整环境变量和 API Key;分享前仍请检查是否含有私密元数据。',
|
||||
'settings.diagnostics.noEvents': '暂无诊断事件。',
|
||||
'settings.diagnostics.noRecentErrors': '最近没有警告或错误。',
|
||||
'settings.diagnostics.loadFailed': '加载诊断信息失败。',
|
||||
@ -340,18 +350,37 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.diagnostics.cleared': '诊断日志已清理。',
|
||||
'settings.diagnostics.clearFailed': '清理诊断日志失败。',
|
||||
'settings.diagnostics.eventDetails': '详情',
|
||||
'settings.diagnostics.eventId': '事件 ID',
|
||||
'settings.diagnostics.copyEventId': '复制事件 ID',
|
||||
'settings.diagnostics.eventIdCopied': '事件 ID 已复制。',
|
||||
'settings.diagnostics.eventIdCopyFailed': '复制事件 ID 失败。',
|
||||
'settings.diagnostics.doctorTitle': 'Doctor',
|
||||
'settings.diagnostics.doctorDescription': '用于启动和界面状态问题的安全桌面修复。',
|
||||
'settings.diagnostics.doctorProtectedData': '绝不会触碰聊天历史、模型配置、Skills、MCP、IM 或 OAuth。',
|
||||
'settings.diagnostics.doctorSafeKeys': '只会清理:cc-haha-open-tabs、cc-haha-session-runtime、cc-haha-theme、cc-haha-locale 和 cc-haha.persistence.schemaVersion。',
|
||||
'settings.diagnostics.doctorDescription': '检查受保护的用户与项目状态,不会进行修改。',
|
||||
'settings.diagnostics.doctorProtectedData': '聊天历史、模型配置、Skills、MCP、IM 和 OAuth 始终受保护。',
|
||||
'settings.diagnostics.doctorSafeKeys': '重置只会移除:cc-haha-open-tabs、cc-haha-session-runtime、cc-haha-theme、cc-haha-locale、cc-haha-app-zoom、cc-haha-ui-zoom 和 cc-haha.persistence.schemaVersion。',
|
||||
'settings.diagnostics.runDoctor': '运行 Doctor',
|
||||
'settings.diagnostics.doctorCompleted': 'Doctor 已执行完成。',
|
||||
'settings.diagnostics.doctorPartial': 'Doctor 已完成,但有 {count} 个本地清理项处理失败。',
|
||||
'settings.diagnostics.resetSafeUiState': '重置安全 UI 状态',
|
||||
'settings.diagnostics.confirmResetSafeUiState': '仅重置上面列出的可再生桌面 UI 键?受保护的用户与项目数据不会被修改。',
|
||||
'settings.diagnostics.doctorCheckCompleted': 'Doctor 检查已完成。',
|
||||
'settings.diagnostics.doctorResetCompleted': '安全 UI 状态重置已完成。',
|
||||
'settings.diagnostics.doctorPartial': '安全 UI 状态重置已完成,但有 {count} 个键处理失败。',
|
||||
'settings.diagnostics.doctorFailed': 'Doctor 执行失败。',
|
||||
'settings.diagnostics.doctorResultLocal': '已清理 {count} 个安全 UI 键。',
|
||||
'settings.diagnostics.doctorResultFailedKeys': '有 {count} 个键无法移除。',
|
||||
'settings.diagnostics.doctorServerRan': '服务端 Doctor 修复也已执行。',
|
||||
'settings.diagnostics.doctorServerUnavailable': '服务端 Doctor 不可用,因此只执行了本地修复。',
|
||||
'settings.diagnostics.doctorScope': '范围',
|
||||
'settings.diagnostics.doctorScopeProject': '用户与当前项目',
|
||||
'settings.diagnostics.doctorScopeUser': '仅用户',
|
||||
'settings.diagnostics.doctorSummary': '健康:{healthy} · 未配置:{neutral} · 缺失:{missing} · 无效:{invalid}',
|
||||
'settings.diagnostics.doctorFindings': 'Doctor 发现项',
|
||||
'settings.diagnostics.doctorNoFindings': '未发现缺失或无效项。',
|
||||
'settings.diagnostics.doctorStatusHealthy': '健康',
|
||||
'settings.diagnostics.doctorStatusNotConfigured': '未配置',
|
||||
'settings.diagnostics.doctorStatusMissing': '缺失',
|
||||
'settings.diagnostics.doctorStatusInvalidJson': 'JSON 无效',
|
||||
'settings.diagnostics.doctorStatusInvalidJsonl': 'JSONL 无效',
|
||||
'settings.diagnostics.doctorStatusInvalidSchema': 'Schema 无效',
|
||||
'settings.diagnostics.doctorStatusUnreadable': '无法读取',
|
||||
'settings.diagnostics.doctorRemovedKeys': '已移除的键',
|
||||
'settings.diagnostics.doctorFailedKeys': '失败的键',
|
||||
'settings.diagnostics.doctorNoKeys': '无',
|
||||
'errorBoundary.title': '出现异常。',
|
||||
'errorBoundary.description': '错误已记录到诊断日志。',
|
||||
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const doctorApiMock = vi.hoisted(() => ({
|
||||
reportAndRepair: vi.fn(),
|
||||
report: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../api/doctor', () => ({
|
||||
doctorApi: doctorApiMock,
|
||||
}))
|
||||
|
||||
import { SAFE_DOCTOR_STORAGE_KEYS, runLocalDoctorRepair, runDoctorRepair } from './doctorRepair'
|
||||
import { SAFE_DOCTOR_STORAGE_KEYS, runDoctorCheck, runLocalDoctorRepair } from './doctorRepair'
|
||||
|
||||
describe('doctorRepair', () => {
|
||||
beforeEach(() => {
|
||||
@ -50,16 +50,22 @@ describe('doctorRepair', () => {
|
||||
expect(result.failedKeys).toEqual(expect.arrayContaining([...SAFE_DOCTOR_STORAGE_KEYS]))
|
||||
})
|
||||
|
||||
it('keeps local repair successful when the server doctor endpoint is unavailable', async () => {
|
||||
it('checks the server report for the active cwd without clearing desktop state', async () => {
|
||||
window.localStorage.clear()
|
||||
window.localStorage.setItem('cc-haha-theme', 'dark')
|
||||
doctorApiMock.reportAndRepair.mockRejectedValueOnce(new Error('Failed to fetch'))
|
||||
doctorApiMock.report.mockResolvedValueOnce({
|
||||
report: {
|
||||
generatedAt: '2026-07-11T00:00:00.000Z',
|
||||
items: [],
|
||||
protectedSkips: [],
|
||||
summary: { total: 0, protectedCount: 0, missingCount: 0, invalidCount: 0 },
|
||||
},
|
||||
})
|
||||
|
||||
const result = await runDoctorRepair({ storage: window.localStorage })
|
||||
const report = await runDoctorCheck({ cwd: '/workspace/project' })
|
||||
|
||||
expect(doctorApiMock.reportAndRepair).toHaveBeenCalled()
|
||||
expect(result.local.removedKeys).toContain('cc-haha-theme')
|
||||
expect(result.server).toBeNull()
|
||||
expect(result.serverError).toBe('Failed to fetch')
|
||||
expect(doctorApiMock.report).toHaveBeenCalledWith('/workspace/project')
|
||||
expect(report.summary.total).toBe(0)
|
||||
expect(window.localStorage.getItem('cc-haha-theme')).toBe('dark')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { doctorApi, type DoctorReportRepairResponse } from '../api/doctor'
|
||||
import { doctorApi, type DoctorReport } from '../api/doctor'
|
||||
import { APP_ZOOM_STORAGE_KEY, LEGACY_UI_ZOOM_STORAGE_KEY } from './appZoom'
|
||||
import { DESKTOP_PERSISTENCE_VERSION_KEY } from './persistenceMigrations'
|
||||
|
||||
@ -20,12 +20,6 @@ export type LocalDoctorRepairResult = {
|
||||
failedKeys: string[]
|
||||
}
|
||||
|
||||
export type DoctorRepairResult = {
|
||||
local: LocalDoctorRepairResult
|
||||
server: DoctorReportRepairResponse | null
|
||||
serverError: string | null
|
||||
}
|
||||
|
||||
function getDefaultDoctorStorage(): DoctorStorage | null {
|
||||
try {
|
||||
return globalThis.localStorage ?? null
|
||||
@ -63,23 +57,7 @@ export function runLocalDoctorRepair(storage: DoctorStorage | null = getDefaultD
|
||||
return { removedKeys, missingKeys, failedKeys }
|
||||
}
|
||||
|
||||
export async function runDoctorRepair(options?: {
|
||||
includeServer?: boolean
|
||||
storage?: DoctorStorage | null
|
||||
}): Promise<DoctorRepairResult> {
|
||||
const local = runLocalDoctorRepair(options?.storage)
|
||||
if (options?.includeServer === false) {
|
||||
return { local, server: null, serverError: null }
|
||||
}
|
||||
|
||||
try {
|
||||
const server = await doctorApi.reportAndRepair()
|
||||
return { local, server, serverError: null }
|
||||
} catch (error) {
|
||||
return {
|
||||
local,
|
||||
server: null,
|
||||
serverError: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
}
|
||||
export async function runDoctorCheck(options: { cwd?: string } = {}): Promise<DoctorReport> {
|
||||
const { report } = await doctorApi.report(options.cwd)
|
||||
return report
|
||||
}
|
||||
|
||||
@ -15,6 +15,7 @@ export function DiagnosticsSettings() {
|
||||
const [events, setEvents] = useState<DiagnosticEvent[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
const [isCopyingIssueReport, setIsCopyingIssueReport] = useState(false)
|
||||
const [isClearing, setIsClearing] = useState(false)
|
||||
const [clearConfirmOpen, setClearConfirmOpen] = useState(false)
|
||||
const [lastExportPath, setLastExportPath] = useState<string | null>(null)
|
||||
@ -91,6 +92,27 @@ export function DiagnosticsSettings() {
|
||||
addToast({ type: 'error', message: t('settings.diagnostics.copyFailed') })
|
||||
}
|
||||
|
||||
const handleCopyIssueReport = async () => {
|
||||
setIsCopyingIssueReport(true)
|
||||
try {
|
||||
const { report } = await diagnosticsApi.getIssueReport()
|
||||
const copied = await copyTextToClipboard(report)
|
||||
addToast({
|
||||
type: copied ? 'success' : 'error',
|
||||
message: copied
|
||||
? t('settings.diagnostics.issueReportCopied')
|
||||
: t('settings.diagnostics.issueReportCopyFailed'),
|
||||
})
|
||||
} catch (error) {
|
||||
addToast({
|
||||
type: 'error',
|
||||
message: error instanceof Error ? error.message : t('settings.diagnostics.issueReportCopyFailed'),
|
||||
})
|
||||
} finally {
|
||||
setIsCopyingIssueReport(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = async () => {
|
||||
setIsClearing(true)
|
||||
try {
|
||||
@ -112,24 +134,40 @@ export function DiagnosticsSettings() {
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl">
|
||||
<div className="flex items-start justify-between gap-4 mb-5">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4 mb-5">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">{t('settings.diagnostics.title')}</h2>
|
||||
<p className="text-sm text-[var(--color-text-tertiary)] mt-0.5">{t('settings.diagnostics.description')}</p>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={load} loading={isLoading}>
|
||||
<span className="material-symbols-outlined text-[16px]">refresh</span>
|
||||
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">refresh</span>
|
||||
{t('settings.diagnostics.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-5">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3 mb-5">
|
||||
<Metric label={t('settings.diagnostics.totalSize')} value={status ? formatBytes(status.totalBytes) : '-'} />
|
||||
<Metric label={t('settings.diagnostics.events')} value={status ? String(status.eventCount) : '-'} />
|
||||
<Metric label={t('settings.diagnostics.completeEvents')} value={status ? t('settings.diagnostics.completeEventsValue', { count: status.eventCount }) : '-'} />
|
||||
<Metric label={t('settings.diagnostics.visibleEvents')} value={t('settings.diagnostics.visibleEventsValue', { count: events.length })} />
|
||||
<Metric label={t('settings.diagnostics.recentErrors')} value={status ? String(status.recentErrorCount) : '-'} />
|
||||
<Metric label={t('settings.diagnostics.retention')} value={status ? t('settings.diagnostics.retentionValue', { days: String(status.retentionDays), size: formatBytes(status.maxBytes) }) : '-'} />
|
||||
</div>
|
||||
|
||||
{status && status.corruptLineCount > 0 ? (
|
||||
<div role="alert" className="mb-5 rounded-lg border border-[var(--color-warning)]/40 bg-[var(--color-warning)]/10 px-3 py-2 text-xs text-[var(--color-warning)]">
|
||||
{t('settings.diagnostics.corruptLinesWarning', {
|
||||
count: status.corruptLineCount,
|
||||
physical: status.physicalLineCount,
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{status?.storageLimitExceeded ? (
|
||||
<div role="alert" className="mb-5 rounded-lg border border-[var(--color-warning)]/40 bg-[var(--color-warning)]/10 px-3 py-2 text-xs text-[var(--color-warning)]">
|
||||
{t('settings.diagnostics.storageLimitExceededWarning')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mb-5">
|
||||
<DoctorPanel />
|
||||
</div>
|
||||
@ -141,25 +179,29 @@ export function DiagnosticsSettings() {
|
||||
<div className="text-xs text-[var(--color-text-tertiary)] font-mono break-all mt-0.5">{status?.logDir ?? '-'}</div>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={handleOpenDir}>
|
||||
<span className="material-symbols-outlined text-[16px]">folder_open</span>
|
||||
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">folder_open</span>
|
||||
{t('settings.diagnostics.openDirectory')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="px-4 py-3 flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" onClick={handleExport} loading={isExporting}>
|
||||
<span className="material-symbols-outlined text-[16px]">archive</span>
|
||||
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">archive</span>
|
||||
{t('settings.diagnostics.exportBundle')}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={handleCopySummary}>
|
||||
<span className="material-symbols-outlined text-[16px]">content_copy</span>
|
||||
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">content_copy</span>
|
||||
{t('settings.diagnostics.copySummary')}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={handleCopyIssueReport} loading={isCopyingIssueReport}>
|
||||
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">assignment</span>
|
||||
{t('settings.diagnostics.copyIssueReport')}
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={() => setClearConfirmOpen(true)} loading={isClearing}>
|
||||
<span className="material-symbols-outlined text-[16px]">delete</span>
|
||||
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">delete</span>
|
||||
{t('settings.diagnostics.clearLogs')}
|
||||
</Button>
|
||||
{lastExportPath && (
|
||||
<span className="text-xs text-[var(--color-text-tertiary)] font-mono break-all">
|
||||
<span className="w-full text-xs text-[var(--color-text-tertiary)] font-mono break-all">
|
||||
{lastExportPath}
|
||||
</span>
|
||||
)}
|
||||
@ -183,6 +225,11 @@ export function DiagnosticsSettings() {
|
||||
key={event.id}
|
||||
event={event}
|
||||
detailsLabel={t('settings.diagnostics.eventDetails')}
|
||||
eventIdLabel={t('settings.diagnostics.eventId')}
|
||||
copyEventIdLabel={t('settings.diagnostics.copyEventId')}
|
||||
eventIdCopiedLabel={t('settings.diagnostics.eventIdCopied')}
|
||||
eventIdCopyFailedLabel={t('settings.diagnostics.eventIdCopyFailed')}
|
||||
addToast={addToast}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -218,9 +265,19 @@ function Metric({ label, value }: { label: string; value: string }) {
|
||||
function EventRow({
|
||||
event,
|
||||
detailsLabel,
|
||||
eventIdLabel,
|
||||
copyEventIdLabel,
|
||||
eventIdCopiedLabel,
|
||||
eventIdCopyFailedLabel,
|
||||
addToast,
|
||||
}: {
|
||||
event: DiagnosticEvent
|
||||
detailsLabel: string
|
||||
eventIdLabel: string
|
||||
copyEventIdLabel: string
|
||||
eventIdCopiedLabel: string
|
||||
eventIdCopyFailedLabel: string
|
||||
addToast: ReturnType<typeof useUIStore.getState>['addToast']
|
||||
}) {
|
||||
const severityClass =
|
||||
event.severity === 'error'
|
||||
@ -231,7 +288,7 @@ function EventRow({
|
||||
const detailsText = formatDetails(event.details)
|
||||
|
||||
return (
|
||||
<div className="px-4 py-3 grid grid-cols-[120px_92px_1fr] gap-3 items-start">
|
||||
<div className="px-4 py-3 grid grid-cols-1 md:grid-cols-[120px_92px_1fr] gap-3 items-start">
|
||||
<div className="text-xs text-[var(--color-text-tertiary)] font-mono">
|
||||
{new Date(event.timestamp).toLocaleString()}
|
||||
</div>
|
||||
@ -244,6 +301,19 @@ function EventRow({
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--color-text-secondary)] mt-1 break-words">{event.summary}</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-1 inline-flex max-w-full items-center gap-1 text-[11px] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]"
|
||||
aria-label={`${copyEventIdLabel}: ${event.id}`}
|
||||
onClick={async () => {
|
||||
const copied = await copyTextToClipboard(event.id)
|
||||
addToast({ type: copied ? 'success' : 'error', message: copied ? eventIdCopiedLabel : eventIdCopyFailedLabel })
|
||||
}}
|
||||
>
|
||||
<span>{eventIdLabel}:</span>
|
||||
<span className="font-mono truncate">{event.id}</span>
|
||||
<span className="material-symbols-outlined text-[13px]" aria-hidden="true">content_copy</span>
|
||||
</button>
|
||||
{detailsText && (
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-xs text-[var(--color-text-tertiary)] select-none">
|
||||
|
||||
@ -261,13 +261,14 @@ function TabButton({ icon, label, active, onClick }: { icon: string; label: stri
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={`w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-left transition-colors ${
|
||||
active
|
||||
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)] font-medium'
|
||||
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">{icon}</span>
|
||||
<span className="material-symbols-outlined text-[18px]" aria-hidden="true">{icon}</span>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
|
||||
@ -1052,6 +1052,35 @@ describe('ConversationService', () => {
|
||||
expect(completionObserved).toBe(true)
|
||||
expect(service.hasSession(sessionId)).toBe(false)
|
||||
})
|
||||
|
||||
test('summarizes SDK diagnostics with transport metadata only', () => {
|
||||
const service = new ConversationService()
|
||||
const summarized = (service as any).summarizeSdkMessages([{
|
||||
type: 'assistant',
|
||||
subtype: 'api_error',
|
||||
is_error: true,
|
||||
status: 'failed',
|
||||
result: 'PRIVATE_SDK_RESULT',
|
||||
error: 'PRIVATE_SDK_ERROR',
|
||||
errorDetails: 'PRIVATE_ERROR_DETAILS',
|
||||
message: {
|
||||
content: [{ type: 'text', text: 'PRIVATE_ASSISTANT_REPLY' }],
|
||||
},
|
||||
}])
|
||||
|
||||
expect(summarized).toEqual([{
|
||||
type: 'assistant',
|
||||
subtype: 'api_error',
|
||||
is_error: true,
|
||||
status: 'failed',
|
||||
errorCategory: 'api_error',
|
||||
}])
|
||||
const serialized = JSON.stringify(summarized)
|
||||
expect(serialized).not.toContain('PRIVATE_SDK_RESULT')
|
||||
expect(serialized).not.toContain('PRIVATE_SDK_ERROR')
|
||||
expect(serialized).not.toContain('PRIVATE_ERROR_DETAILS')
|
||||
expect(serialized).not.toContain('PRIVATE_ASSISTANT_REPLY')
|
||||
})
|
||||
})
|
||||
|
||||
function sanitizeMemoryPath(value: string): string {
|
||||
|
||||
@ -62,6 +62,61 @@ async function waitForHttp(url: string, timeoutMs: number): Promise<void> {
|
||||
throw new Error(`Timed out waiting for ${url}${lastError ? ` (${lastError})` : ''}`)
|
||||
}
|
||||
|
||||
function readTarEntry(archive: Buffer, entryName: string): string {
|
||||
const tar = gunzipSync(archive)
|
||||
let offset = 0
|
||||
while (offset + 512 <= tar.length) {
|
||||
const header = tar.subarray(offset, offset + 512)
|
||||
if (header.every((byte) => byte === 0)) break
|
||||
const name = header.subarray(0, 100).toString('utf-8').replace(/\0.*$/, '')
|
||||
const sizeText = header.subarray(124, 136).toString('ascii').replace(/\0.*$/, '').trim()
|
||||
const size = Number.parseInt(sizeText || '0', 8)
|
||||
const contentStart = offset + 512
|
||||
if (name === entryName) {
|
||||
return tar.subarray(contentStart, contentStart + size).toString('utf-8')
|
||||
}
|
||||
offset = contentStart + Math.ceil(size / 512) * 512
|
||||
}
|
||||
throw new Error(`Missing tar entry: ${entryName}`)
|
||||
}
|
||||
|
||||
async function assertAppendSurvivesCliCleanup(
|
||||
service: DiagnosticsService,
|
||||
appendTarget: string,
|
||||
marker: string,
|
||||
): Promise<void> {
|
||||
await fs.mkdir(service.getLogDir(), { recursive: true })
|
||||
await fs.writeFile(appendTarget, 'old cli event\n')
|
||||
const staleDate = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000)
|
||||
await fs.utimes(appendTarget, staleDate, staleDate)
|
||||
const originalRename = fs.rename
|
||||
const originalRm = fs.rm
|
||||
let injected = false
|
||||
const injectAppend = async () => {
|
||||
if (injected) return
|
||||
injected = true
|
||||
await fs.appendFile(appendTarget, `${marker}\n`)
|
||||
}
|
||||
const renameSpy = spyOn(fs, 'rename').mockImplementation(async (from, to) => {
|
||||
if (from === appendTarget) await injectAppend()
|
||||
return originalRename(from, to)
|
||||
})
|
||||
const rmSpy = spyOn(fs, 'rm').mockImplementation(async (target, options) => {
|
||||
if (target === appendTarget) await injectAppend()
|
||||
return originalRm(target, options)
|
||||
})
|
||||
try {
|
||||
await service.getStatus()
|
||||
} finally {
|
||||
renameSpy.mockRestore()
|
||||
rmSpy.mockRestore()
|
||||
}
|
||||
|
||||
expect(injected).toBe(true)
|
||||
const bundle = await service.exportBundle()
|
||||
expect(readTarEntry(await fs.readFile(bundle.path), 'cli-diagnostics.jsonl')).toContain(marker)
|
||||
}
|
||||
|
||||
describe('DiagnosticsService', () => {
|
||||
test('writes sanitized structured events and runtime error summaries', async () => {
|
||||
const service = new DiagnosticsService()
|
||||
@ -123,6 +178,154 @@ describe('DiagnosticsService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('counts all retained events and reports corrupt lines outside the visible window', async () => {
|
||||
const service = new DiagnosticsService()
|
||||
const events = Array.from({ length: 600 }, (_, index) => ({
|
||||
id: String(index),
|
||||
timestamp: new Date().toISOString(),
|
||||
type: index < 100 ? 'real_failure' : 'normal_exit',
|
||||
severity: index < 100 ? 'error' : 'info',
|
||||
summary: index < 100 ? 'boom' : 'clean',
|
||||
}))
|
||||
await fs.mkdir(path.dirname(service.getDiagnosticsPath()), { recursive: true })
|
||||
await fs.writeFile(service.getDiagnosticsPath(), `${events.map(JSON.stringify).join('\n')}\n{broken`)
|
||||
|
||||
const status = await service.getStatus()
|
||||
expect(status.eventCount).toBe(600)
|
||||
expect(status.recentErrorCount).toBe(100)
|
||||
expect(status.physicalLineCount).toBe(601)
|
||||
expect(status.corruptLineCount).toBe(1)
|
||||
expect(await service.readRecentEvents(100)).toHaveLength(100)
|
||||
})
|
||||
|
||||
test('counts blank and invalid event-shaped lines as corrupt physical lines', async () => {
|
||||
const service = new DiagnosticsService()
|
||||
const validEvent = {
|
||||
id: 'valid',
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'valid_event',
|
||||
severity: 'info',
|
||||
summary: 'valid',
|
||||
}
|
||||
await fs.mkdir(path.dirname(service.getDiagnosticsPath()), { recursive: true })
|
||||
await fs.writeFile(service.getDiagnosticsPath(), [
|
||||
JSON.stringify(validEvent),
|
||||
'',
|
||||
'null',
|
||||
'[]',
|
||||
JSON.stringify({ ...validEvent, severity: 'fatal' }),
|
||||
JSON.stringify({ ...validEvent, timestamp: 'not-a-date' }),
|
||||
JSON.stringify({ ...validEvent, summary: 42 }),
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
const status = await service.getStatus()
|
||||
expect(status.eventCount).toBe(1)
|
||||
expect(status.physicalLineCount).toBe(7)
|
||||
expect(status.corruptLineCount).toBe(6)
|
||||
expect((await service.readRecentEvents()).map((event) => event.id)).toEqual(['valid'])
|
||||
})
|
||||
|
||||
test('preserves corrupt-line evidence across the next retention rewrite', async () => {
|
||||
const service = new DiagnosticsService()
|
||||
const staleEvent = {
|
||||
id: 'stale-before-corruption',
|
||||
timestamp: new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
type: 'stale',
|
||||
severity: 'info',
|
||||
summary: 'old',
|
||||
}
|
||||
await fs.mkdir(path.dirname(service.getDiagnosticsPath()), { recursive: true })
|
||||
await fs.writeFile(service.getDiagnosticsPath(), `${JSON.stringify(staleEvent)}\n{PRIVATE_CORRUPT_PAYLOAD\n`)
|
||||
|
||||
await service.recordEvent({ type: 'fresh_after_corruption', summary: 'fresh' })
|
||||
|
||||
const firstStatus = await service.getStatus()
|
||||
expect(firstStatus.corruptLineCount).toBe(1)
|
||||
expect((await service.readRecentEvents()).map((event) => event.type)).toEqual(['fresh_after_corruption'])
|
||||
expect(await fs.readFile(service.getDiagnosticsPath(), 'utf-8')).not.toContain('PRIVATE_CORRUPT_PAYLOAD')
|
||||
|
||||
await service.recordEvent({ type: 'second_fresh', summary: 'second' })
|
||||
expect((await service.getStatus()).corruptLineCount).toBe(1)
|
||||
})
|
||||
|
||||
test('does not double count corrupt evidence when the structured rewrite fails and retries', async () => {
|
||||
const service = new DiagnosticsService()
|
||||
const validEvent = {
|
||||
id: 'valid-before-retry',
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'valid',
|
||||
severity: 'info',
|
||||
summary: 'valid',
|
||||
}
|
||||
await fs.mkdir(path.dirname(service.getDiagnosticsPath()), { recursive: true })
|
||||
await fs.writeFile(service.getDiagnosticsPath(), `${JSON.stringify(validEvent)}\n{broken-once\n`)
|
||||
const originalRename = fs.rename
|
||||
let failedRewrite = false
|
||||
const renameSpy = spyOn(fs, 'rename').mockImplementation(async (from, to) => {
|
||||
if (!failedRewrite && to === service.getDiagnosticsPath()) {
|
||||
failedRewrite = true
|
||||
throw Object.assign(new Error('forced diagnostics rewrite failure'), { code: 'EIO' })
|
||||
}
|
||||
return originalRename(from, to)
|
||||
})
|
||||
try {
|
||||
await service.recordEvent({ type: 'first_retry_probe', summary: 'first' })
|
||||
expect((await service.getStatus()).corruptLineCount).toBe(1)
|
||||
await service.recordEvent({ type: 'second_retry_probe', summary: 'second' })
|
||||
} finally {
|
||||
renameSpy.mockRestore()
|
||||
}
|
||||
|
||||
expect((await service.getStatus()).corruptLineCount).toBe(1)
|
||||
expect((await service.readRecentEvents()).map((event) => event.type)).toContain('second_retry_probe')
|
||||
})
|
||||
|
||||
test('recovers pending corrupt evidence after rewrite succeeds but commit fails, then counts new corruption once', async () => {
|
||||
const service = new DiagnosticsService()
|
||||
const validEvent = {
|
||||
id: 'valid-before-commit-crash',
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'valid',
|
||||
severity: 'info',
|
||||
summary: 'valid',
|
||||
}
|
||||
await fs.mkdir(path.dirname(service.getDiagnosticsPath()), { recursive: true })
|
||||
await fs.writeFile(service.getDiagnosticsPath(), `${JSON.stringify(validEvent)}\n{first-corrupt\n`)
|
||||
const evidencePath = path.join(service.getLogDir(), 'corruption-evidence.json')
|
||||
const originalRename = fs.rename
|
||||
let failedCommit = false
|
||||
const renameSpy = spyOn(fs, 'rename').mockImplementation(async (from, to) => {
|
||||
if (!failedCommit && to === evidencePath) {
|
||||
failedCommit = true
|
||||
throw Object.assign(new Error('forced evidence commit failure'), { code: 'EIO' })
|
||||
}
|
||||
return originalRename(from, to)
|
||||
})
|
||||
try {
|
||||
await service.recordEvent({ type: 'rewrite_succeeds_commit_fails', summary: 'first' })
|
||||
await fs.appendFile(service.getDiagnosticsPath(), '{second-corrupt\n')
|
||||
await service.recordEvent({ type: 'retry_after_new_corruption', summary: 'second' })
|
||||
} finally {
|
||||
renameSpy.mockRestore()
|
||||
}
|
||||
|
||||
expect((await service.getStatus()).corruptLineCount).toBe(2)
|
||||
expect((await service.readRecentEvents()).map((event) => event.type)).toContain('retry_after_new_corruption')
|
||||
})
|
||||
|
||||
test('returns a failed write result when the diagnostics directory cannot be created', async () => {
|
||||
const service = new DiagnosticsService()
|
||||
const blockedConfigDir = path.join(tmpDir, 'config-file')
|
||||
await fs.writeFile(blockedConfigDir, 'not-a-directory')
|
||||
process.env.CLAUDE_CONFIG_DIR = blockedConfigDir
|
||||
|
||||
const result = await service.recordEvent({ type: 'write_probe', severity: 'error', summary: 'boom' })
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.error).not.toContain(tmpDir)
|
||||
})
|
||||
|
||||
test('exports a single diagnostics tarball without provider secrets', async () => {
|
||||
const service = new DiagnosticsService()
|
||||
await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true })
|
||||
@ -135,7 +338,7 @@ describe('DiagnosticsService', () => {
|
||||
name: 'Test Provider',
|
||||
presetId: 'custom',
|
||||
apiKey: 'sk-provider-secret',
|
||||
baseUrl: 'https://api.example.com/anthropic',
|
||||
baseUrl: 'https://user:pass@api.example.com/private?token=x',
|
||||
apiFormat: 'anthropic',
|
||||
models: { main: 'main-model', haiku: 'haiku-model', sonnet: 'sonnet-model', opus: 'opus-model' },
|
||||
}],
|
||||
@ -166,10 +369,384 @@ describe('DiagnosticsService', () => {
|
||||
expect(archiveText).toContain('cli_streaming_idle_timeout')
|
||||
expect(archiveText).toContain('Test Provider')
|
||||
expect(archiveText).toContain('api.example.com')
|
||||
expect(archiveText).toContain('projected diagnostic metadata/details')
|
||||
expect(archiveText).not.toContain('captured runtime details')
|
||||
expect(archiveText).not.toContain('user:pass')
|
||||
expect(archiveText).not.toContain('/private')
|
||||
expect(archiveText).not.toContain('token=x')
|
||||
expect(archiveText).not.toContain('sk-provider-secret')
|
||||
expect(archiveText).not.toContain('provider-secret')
|
||||
})
|
||||
|
||||
test('leaves exported runtime-errors.log empty when only info events exist', async () => {
|
||||
const service = new DiagnosticsService()
|
||||
await service.recordEvent({
|
||||
type: 'informational_probe',
|
||||
severity: 'info',
|
||||
summary: 'routine status',
|
||||
})
|
||||
|
||||
const bundle = await service.exportBundle()
|
||||
|
||||
expect(readTarEntry(await fs.readFile(bundle.path), 'runtime-errors.log')).toBe('')
|
||||
})
|
||||
|
||||
test('exports share projections without captured SDK or assistant content', async () => {
|
||||
const service = new DiagnosticsService()
|
||||
await service.recordEvent({
|
||||
type: 'sdk_api_error',
|
||||
severity: 'error',
|
||||
summary: 'PRIVATE_ASSISTANT_REPLY',
|
||||
details: {
|
||||
errorCode: 'API_ERROR',
|
||||
status: 'failed',
|
||||
capturedOutput: 'PRIVATE_CAPTURED_OUTPUT',
|
||||
sdkMessages: [{ result: 'PRIVATE_SDK_RESULT' }],
|
||||
bareToken: 'sk-ant-api03-BARESECRET',
|
||||
},
|
||||
})
|
||||
|
||||
const bundle = await service.exportBundle()
|
||||
const archiveText = gunzipSync(await fs.readFile(bundle.path)).toString('utf-8')
|
||||
|
||||
expect(archiveText).toContain('event')
|
||||
expect(archiveText).toContain('omittedFields')
|
||||
expect(archiveText).toContain('API_ERROR')
|
||||
expect(archiveText).not.toContain('PRIVATE_ASSISTANT_REPLY')
|
||||
expect(archiveText).not.toContain('PRIVATE_CAPTURED_OUTPUT')
|
||||
expect(archiveText).not.toContain('PRIVATE_SDK_RESULT')
|
||||
expect(archiveText).not.toContain('sk-ant-api03-BARESECRET')
|
||||
})
|
||||
|
||||
test('bounds shared event payloads and issue-report event ids to recent evidence', async () => {
|
||||
const service = new DiagnosticsService()
|
||||
const events = Array.from({ length: 5_100 }, (_, index) => ({
|
||||
id: `event-${index}`,
|
||||
timestamp: new Date(Date.now() + index).toISOString(),
|
||||
type: 'bounded_export_probe',
|
||||
severity: 'error',
|
||||
summary: `private summary ${index}`,
|
||||
}))
|
||||
await fs.mkdir(path.dirname(service.getDiagnosticsPath()), { recursive: true })
|
||||
await fs.writeFile(service.getDiagnosticsPath(), `${events.map(JSON.stringify).join('\n')}\n`)
|
||||
|
||||
const bundle = await service.exportBundle()
|
||||
const exportedEvents = readTarEntry(await fs.readFile(bundle.path), 'diagnostics.jsonl')
|
||||
.trim().split('\n').filter(Boolean)
|
||||
expect(exportedEvents).toHaveLength(5_000)
|
||||
expect(exportedEvents[0]).toContain('event-5099')
|
||||
expect(exportedEvents.at(-1)).toContain('event-100')
|
||||
expect(exportedEvents.join('\n')).not.toContain('event-99"')
|
||||
|
||||
const report = await service.buildIssueReport()
|
||||
expect(report).toContain('event-5099')
|
||||
expect(report).not.toContain('event-4999')
|
||||
const eventIdsLine = report.split('\n').find((line) => line.startsWith('- Event IDs:')) ?? ''
|
||||
expect(eventIdsLine.length).toBeLessThan(8_000)
|
||||
})
|
||||
|
||||
test('includes a sanitized bounded Electron host log in status and exports', async () => {
|
||||
const service = new DiagnosticsService()
|
||||
const electronHostPath = path.join(service.getLogDir(), 'electron-host.log')
|
||||
await fs.mkdir(service.getLogDir(), { recursive: true })
|
||||
await fs.writeFile(
|
||||
electronHostPath,
|
||||
`${'old host line\n'.repeat(500_000)}latest failure token=ELECTRON_SECRET /Users/alice/private/project\n`,
|
||||
)
|
||||
|
||||
const status = await service.getStatus()
|
||||
expect(status.electronHostPath).toBe(electronHostPath)
|
||||
expect((await fs.stat(electronHostPath)).size).toBeLessThanOrEqual(5 * 1024 * 1024)
|
||||
|
||||
const bundle = await service.exportBundle()
|
||||
const hostLog = readTarEntry(await fs.readFile(bundle.path), 'electron-host.log')
|
||||
expect(hostLog).toContain('latest failure')
|
||||
expect(hostLog).not.toContain('ELECTRON_SECRET')
|
||||
expect(hostLog).not.toContain('/Users/alice')
|
||||
expect(Buffer.byteLength(hostLog)).toBeLessThanOrEqual(256 * 1024 + 64)
|
||||
})
|
||||
|
||||
test('bounds active diagnostic surfaces and recent exports under the advertised directory cap', async () => {
|
||||
const service = new DiagnosticsService()
|
||||
await fs.mkdir(service.getExportDir(), { recursive: true })
|
||||
const oversized = `${'diagnostic line\n'.repeat(450_000)}latest marker\n`
|
||||
const closedCliSegment = `${service.getCliDiagnosticsPath()}.123.1.jsonl`
|
||||
await Promise.all([
|
||||
fs.writeFile(service.getRuntimeErrorsPath(), oversized),
|
||||
fs.writeFile(closedCliSegment, oversized),
|
||||
fs.writeFile(path.join(service.getLogDir(), 'electron-host.log'), oversized),
|
||||
...Array.from({ length: 20 }, (_, index) => fs.writeFile(
|
||||
path.join(service.getExportDir(), `recent-${String(index).padStart(2, '0')}.tar.gz`),
|
||||
Buffer.alloc(1024 * 1024, index),
|
||||
)),
|
||||
])
|
||||
|
||||
const status = await service.getStatus()
|
||||
expect(status.totalBytes).toBeLessThanOrEqual(status.maxBytes)
|
||||
for (const filePath of [
|
||||
service.getRuntimeErrorsPath(),
|
||||
path.join(service.getLogDir(), 'electron-host.log'),
|
||||
]) {
|
||||
expect((await fs.stat(filePath)).size).toBeLessThanOrEqual(5 * 1024 * 1024)
|
||||
}
|
||||
await expect(fs.stat(closedCliSegment)).rejects.toThrow()
|
||||
const exports = await fs.readdir(service.getExportDir())
|
||||
const exportStats = await Promise.all(exports.map((name) => fs.stat(path.join(service.getExportDir(), name))))
|
||||
expect(exportStats.reduce((sum, stat) => sum + stat.size, 0)).toBeLessThanOrEqual(15 * 1024 * 1024)
|
||||
})
|
||||
|
||||
test('caps closed CLI segments globally and reports active multiprocess overflow honestly', async () => {
|
||||
const service = new DiagnosticsService()
|
||||
await fs.mkdir(service.getLogDir(), { recursive: true })
|
||||
await Promise.all([
|
||||
...Array.from({ length: 8 }, (_, index) => fs.writeFile(
|
||||
`${service.getCliDiagnosticsPath()}.${index}.closed.jsonl`,
|
||||
Buffer.alloc(1024 * 1024, index),
|
||||
)),
|
||||
...Array.from({ length: 55 }, (_, index) => fs.writeFile(
|
||||
`${service.getCliDiagnosticsPath()}.unknown-${index}.current.jsonl`,
|
||||
Buffer.alloc(1024 * 1024, index),
|
||||
)),
|
||||
])
|
||||
|
||||
const status = await service.getStatus()
|
||||
const files = await fs.readdir(service.getLogDir())
|
||||
const closed = files.filter((name) => name.includes('.closed.jsonl'))
|
||||
const closedStats = await Promise.all(closed.map((name) => fs.stat(path.join(service.getLogDir(), name))))
|
||||
expect(closedStats.reduce((sum, stat) => sum + stat.size, 0)).toBeLessThanOrEqual(5 * 1024 * 1024)
|
||||
expect(status.totalBytes).toBeGreaterThan(status.maxBytes)
|
||||
expect(status.storageLimitExceeded).toBe(true)
|
||||
})
|
||||
|
||||
test('reclaims dead current CLI segments but preserves the live current process segment', async () => {
|
||||
const service = new DiagnosticsService()
|
||||
await fs.mkdir(service.getLogDir(), { recursive: true })
|
||||
const livePath = `${service.getCliDiagnosticsPath()}.${process.pid}.current.jsonl`
|
||||
const deadPath = `${service.getCliDiagnosticsPath()}.99999999.current.jsonl`
|
||||
await fs.writeFile(livePath, 'live-current\n')
|
||||
await fs.writeFile(deadPath, 'dead-current\n')
|
||||
|
||||
await service.getStatus()
|
||||
|
||||
await expect(fs.readFile(livePath, 'utf-8')).resolves.toContain('live-current')
|
||||
await expect(fs.stat(deadPath)).rejects.toThrow()
|
||||
|
||||
const staleDate = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000)
|
||||
await fs.utimes(livePath, staleDate, staleDate)
|
||||
await service.getStatus()
|
||||
await expect(fs.stat(livePath)).rejects.toThrow()
|
||||
})
|
||||
|
||||
test('reclaims stale legacy and unknown-PID current files by retention lease while preserving recent legacy', async () => {
|
||||
const service = new DiagnosticsService()
|
||||
await fs.mkdir(service.getLogDir(), { recursive: true })
|
||||
const legacyPath = service.getCliDiagnosticsPath()
|
||||
const unknownCurrentPath = `${service.getCliDiagnosticsPath()}.not-a-pid.current.jsonl`
|
||||
await fs.writeFile(legacyPath, 'stale legacy\n')
|
||||
await fs.writeFile(unknownCurrentPath, 'stale unknown pid\n')
|
||||
const staleDate = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000)
|
||||
await fs.utimes(legacyPath, staleDate, staleDate)
|
||||
await fs.utimes(unknownCurrentPath, staleDate, staleDate)
|
||||
|
||||
await service.getStatus()
|
||||
|
||||
await expect(fs.stat(legacyPath)).rejects.toThrow()
|
||||
await expect(fs.stat(unknownCurrentPath)).rejects.toThrow()
|
||||
await fs.writeFile(legacyPath, Buffer.alloc(6 * 1024 * 1024, 1))
|
||||
const recentLegacyStatus = await service.getStatus()
|
||||
await expect(fs.stat(legacyPath)).resolves.toBeTruthy()
|
||||
expect(recentLegacyStatus.storageLimitExceeded).toBe(true)
|
||||
await fs.utimes(legacyPath, staleDate, staleDate)
|
||||
const reclaimedStatus = await service.getStatus()
|
||||
await expect(fs.stat(legacyPath)).rejects.toThrow()
|
||||
expect(reclaimedStatus.storageLimitExceeded).toBe(true)
|
||||
const settledStatus = await service.getStatus()
|
||||
expect(settledStatus.storageLimitExceeded).toBe(false)
|
||||
})
|
||||
|
||||
test('never replaces an active legacy CLI diagnostics file during retention', async () => {
|
||||
const service = new DiagnosticsService()
|
||||
const cliPath = service.getCliDiagnosticsPath()
|
||||
await fs.mkdir(path.dirname(cliPath), { recursive: true })
|
||||
await fs.writeFile(cliPath, `${'old cli line\n'.repeat(500_000)}before-retention\n`)
|
||||
const originalRename = fs.rename
|
||||
let cliRenameAttempted = false
|
||||
const renameSpy = spyOn(fs, 'rename').mockImplementation(async (from, to) => {
|
||||
if (to === cliPath) cliRenameAttempted = true
|
||||
return originalRename(from, to)
|
||||
})
|
||||
try {
|
||||
await Promise.all([
|
||||
service.getStatus(),
|
||||
fs.appendFile(cliPath, 'append-during-retention\n'),
|
||||
])
|
||||
} finally {
|
||||
renameSpy.mockRestore()
|
||||
}
|
||||
|
||||
expect(cliRenameAttempted).toBe(false)
|
||||
expect(await fs.readFile(cliPath, 'utf-8')).toContain('append-during-retention')
|
||||
})
|
||||
|
||||
test('re-lists CLI segments when the writer rotates between export listing and reading', async () => {
|
||||
const service = new DiagnosticsService()
|
||||
await fs.mkdir(service.getLogDir(), { recursive: true })
|
||||
const currentPath = `${service.getCliDiagnosticsPath()}.${process.pid}.current.jsonl`
|
||||
const completedPath = `${service.getCliDiagnosticsPath()}.${process.pid}.rotated.jsonl`
|
||||
await fs.writeFile(currentPath, '{"event":"ROTATED_BETWEEN_LIST_AND_READ"}\n')
|
||||
const originalOpen = fs.open
|
||||
let rotated = false
|
||||
const openSpy = spyOn(fs, 'open').mockImplementation(async (filePath, ...args) => {
|
||||
if (!rotated && filePath === currentPath) {
|
||||
rotated = true
|
||||
await fs.rename(currentPath, completedPath)
|
||||
}
|
||||
return originalOpen(filePath, ...args)
|
||||
})
|
||||
try {
|
||||
const bundle = await service.exportBundle()
|
||||
const cliLog = readTarEntry(await fs.readFile(bundle.path), 'cli-diagnostics.jsonl')
|
||||
expect(cliLog).toContain('ROTATED_BETWEEN_LIST_AND_READ')
|
||||
} finally {
|
||||
openSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test('quarantines a stale PID-current segment so an append immediately before cleanup survives', async () => {
|
||||
const service = new DiagnosticsService()
|
||||
const currentPath = `${service.getCliDiagnosticsPath()}.${process.pid}.current.jsonl`
|
||||
await assertAppendSurvivesCliCleanup(service, currentPath, 'PID_CURRENT_NEW_EVENT')
|
||||
})
|
||||
|
||||
test('quarantines a stale legacy append target so an append immediately before cleanup survives', async () => {
|
||||
const service = new DiagnosticsService()
|
||||
await assertAppendSurvivesCliCleanup(service, service.getCliDiagnosticsPath(), 'LEGACY_NEW_EVENT')
|
||||
})
|
||||
|
||||
test('compacts stale structured events without deleting the active diagnostics file', async () => {
|
||||
const service = new DiagnosticsService()
|
||||
const staleEvent = {
|
||||
id: 'stale',
|
||||
timestamp: new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
type: 'stale',
|
||||
severity: 'info',
|
||||
summary: 'old',
|
||||
}
|
||||
await fs.mkdir(path.dirname(service.getDiagnosticsPath()), { recursive: true })
|
||||
await fs.writeFile(service.getDiagnosticsPath(), `${JSON.stringify(staleEvent)}\n`)
|
||||
|
||||
await service.recordEvent({ type: 'fresh', severity: 'info', summary: 'new' })
|
||||
|
||||
expect((await service.readRecentEvents()).map((event) => event.type)).toEqual(['fresh'])
|
||||
await expect(fs.stat(service.getDiagnosticsPath())).resolves.toBeTruthy()
|
||||
})
|
||||
|
||||
test('keeps the newest complete structured lines when the active file exceeds the byte cap', async () => {
|
||||
const service = new DiagnosticsService()
|
||||
const oversizedSummary = 'x'.repeat(51 * 1024 * 1024)
|
||||
const oversizedEvent = {
|
||||
id: 'oversized',
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'oversized',
|
||||
severity: 'info',
|
||||
summary: oversizedSummary,
|
||||
}
|
||||
await fs.mkdir(path.dirname(service.getDiagnosticsPath()), { recursive: true })
|
||||
await fs.writeFile(service.getDiagnosticsPath(), `${JSON.stringify(oversizedEvent)}\n`)
|
||||
|
||||
await service.recordEvent({ type: 'fresh', severity: 'info', summary: 'new' })
|
||||
|
||||
expect((await service.readRecentEvents()).map((event) => event.type)).toEqual(['fresh'])
|
||||
await expect(fs.stat(service.getDiagnosticsPath())).resolves.toBeTruthy()
|
||||
})
|
||||
|
||||
test('serializes concurrent writes with forced compaction so neither event is lost', async () => {
|
||||
const service = new DiagnosticsService()
|
||||
const oversizedEvent = {
|
||||
id: 'oversized',
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'oversized',
|
||||
severity: 'info',
|
||||
summary: 'x'.repeat(51 * 1024 * 1024),
|
||||
}
|
||||
await fs.mkdir(path.dirname(service.getDiagnosticsPath()), { recursive: true })
|
||||
await fs.writeFile(service.getDiagnosticsPath(), `${JSON.stringify(oversizedEvent)}\n`)
|
||||
|
||||
let releaseFirstRename!: () => void
|
||||
let firstRenameReached!: () => void
|
||||
const firstRenameGate = new Promise<void>((resolve) => { releaseFirstRename = resolve })
|
||||
const firstRenameSignal = new Promise<void>((resolve) => { firstRenameReached = resolve })
|
||||
const originalRename = fs.rename
|
||||
let renameCount = 0
|
||||
const renameSpy = spyOn(fs, 'rename').mockImplementation(async (...args) => {
|
||||
renameCount += 1
|
||||
if (renameCount === 1) {
|
||||
firstRenameReached()
|
||||
await firstRenameGate
|
||||
return originalRename(...args)
|
||||
}
|
||||
return originalRename(...args)
|
||||
})
|
||||
|
||||
try {
|
||||
const firstWrite = service.recordEvent({ type: 'concurrent_first', summary: 'first' })
|
||||
await firstRenameSignal
|
||||
const secondWrite = service.recordEvent({ type: 'concurrent_second', summary: 'second' })
|
||||
await Bun.sleep(100)
|
||||
expect(renameCount).toBe(1)
|
||||
releaseFirstRename()
|
||||
await Promise.all([firstWrite, secondWrite])
|
||||
} finally {
|
||||
releaseFirstRename()
|
||||
renameSpy.mockRestore()
|
||||
}
|
||||
|
||||
expect((await service.readRecentEvents()).map((event) => event.type)).toEqual([
|
||||
'concurrent_second',
|
||||
'concurrent_first',
|
||||
])
|
||||
})
|
||||
|
||||
test('exports the tail of oversized CLI text logs', async () => {
|
||||
const service = new DiagnosticsService()
|
||||
const cliPath = service.getCliDiagnosticsPath()
|
||||
await fs.mkdir(path.dirname(cliPath), { recursive: true })
|
||||
await fs.writeFile(
|
||||
cliPath,
|
||||
`OLDEST_PREFIX_MARKER\n${'routine diagnostic line\n'.repeat(15_000)}LATEST_FAILURE_MARKER\n`,
|
||||
)
|
||||
|
||||
const bundle = await service.exportBundle()
|
||||
const archiveText = gunzipSync(await fs.readFile(bundle.path)).toString('utf-8')
|
||||
|
||||
expect(archiveText).toContain('[TRUNCATED OLDER CONTENT]')
|
||||
expect(archiveText).toContain('LATEST_FAILURE_MARKER')
|
||||
expect(archiveText).not.toContain('OLDEST_PREFIX_MARKER')
|
||||
})
|
||||
|
||||
test('redacts a secret whose key is split across the tail-read byte boundary', async () => {
|
||||
const service = new DiagnosticsService()
|
||||
const cliPath = service.getCliDiagnosticsPath()
|
||||
const secret = 'BOUNDARY_SPLIT_SECRET'
|
||||
const marker = 'LATEST_FAILURE_MARKER\n'
|
||||
const suffixWithoutKey = `${secret}\n\n${marker}`
|
||||
const paddingBytes = 256 * 1024 - Buffer.byteLength(suffixWithoutKey)
|
||||
const safeLine = 'safe line\n'
|
||||
const suffixPadding = safeLine.repeat(Math.floor(paddingBytes / Buffer.byteLength(safeLine))) +
|
||||
'z'.repeat(paddingBytes % Buffer.byteLength(safeLine))
|
||||
await fs.mkdir(path.dirname(cliPath), { recursive: true })
|
||||
await fs.writeFile(
|
||||
cliPath,
|
||||
`${'old-prefix'.repeat(600)}\ntoken=${secret}\n${suffixPadding}\n${marker}`,
|
||||
)
|
||||
|
||||
const bundle = await service.exportBundle()
|
||||
const archiveText = gunzipSync(await fs.readFile(bundle.path)).toString('utf-8')
|
||||
|
||||
expect(archiveText).toContain('LATEST_FAILURE_MARKER')
|
||||
expect(archiveText).not.toContain(secret)
|
||||
})
|
||||
|
||||
test('keeps fatal startup errors visible on stderr while recording diagnostics', async () => {
|
||||
const port = await getPort()
|
||||
const serverArgs = ['bun', '--no-env-file', 'run', 'src/server/index.ts', '--host', '127.0.0.1', '--port', String(port)]
|
||||
@ -257,6 +834,9 @@ describe('diagnostics API', () => {
|
||||
clientEventUrl.pathname.split('/').filter(Boolean),
|
||||
)
|
||||
expect(clientEventRes.status).toBe(200)
|
||||
const clientEventBody = await clientEventRes.json() as { ok: boolean; eventId: string }
|
||||
expect(clientEventBody.ok).toBe(true)
|
||||
expect(clientEventBody.eventId).toBeString()
|
||||
const clientEvents = await service.readRecentEvents(10)
|
||||
expect(clientEvents[0].type).toBe('client_unhandled_rejection')
|
||||
expect(JSON.stringify(clientEvents[0])).toContain('[REDACTED]')
|
||||
@ -273,4 +853,76 @@ describe('diagnostics API', () => {
|
||||
expect(clearRes.status).toBe(200)
|
||||
expect(await service.readRecentEvents()).toEqual([])
|
||||
})
|
||||
|
||||
test('returns a server error when a client diagnostic event cannot be written', async () => {
|
||||
const recordSpy = spyOn(diagnosticsService, 'recordEvent').mockResolvedValue({
|
||||
ok: false,
|
||||
error: 'diagnostic write failed',
|
||||
})
|
||||
try {
|
||||
const request = new Request('http://localhost:3456/api/diagnostics/events', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type: 'write_probe', summary: 'boom' }),
|
||||
})
|
||||
const url = new URL(request.url)
|
||||
|
||||
const response = await handleDiagnosticsApi(
|
||||
request,
|
||||
url,
|
||||
url.pathname.split('/').filter(Boolean),
|
||||
)
|
||||
|
||||
expect(response.status).toBeGreaterThanOrEqual(500)
|
||||
} finally {
|
||||
recordSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test('returns a deterministic share-safe GitHub issue report', async () => {
|
||||
await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true })
|
||||
await fs.writeFile(
|
||||
path.join(tmpDir, 'cc-haha', 'providers.json'),
|
||||
JSON.stringify({
|
||||
activeId: 'provider-issue-report',
|
||||
providers: [{
|
||||
id: 'provider-issue-report',
|
||||
name: 'Issue Report Provider',
|
||||
apiFormat: 'anthropic',
|
||||
baseUrl: 'https://user:pass@api.example.com/private?token=x',
|
||||
models: { main: 'main-model' },
|
||||
}],
|
||||
}),
|
||||
'utf-8',
|
||||
)
|
||||
await diagnosticsService.recordEvent({
|
||||
type: 'sdk_result_error',
|
||||
severity: 'error',
|
||||
summary: 'PRIVATE_ASSISTANT_REPLY',
|
||||
details: {
|
||||
errorCode: 'CLI_EXITED',
|
||||
status: 'failed',
|
||||
sdkMessages: [{ result: 'PRIVATE_SDK_RESULT' }],
|
||||
},
|
||||
})
|
||||
const request = makeRequest('GET', '/api/diagnostics/issue-report')
|
||||
|
||||
const response = await handleDiagnosticsApi(request.req, request.url, request.segments)
|
||||
const body = await response.json() as { report: string }
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.report).toContain('## 问题描述')
|
||||
expect(body.report).toContain('## 运行环境')
|
||||
expect(body.report).toContain('## Provider / 模型')
|
||||
expect(body.report).toContain('## 诊断关联')
|
||||
expect(body.report).toContain('## 复现步骤')
|
||||
expect(body.report).toContain('## 错误摘要')
|
||||
expect(body.report).toContain('CLI_EXITED')
|
||||
expect(body.report).toContain('api.example.com')
|
||||
expect(body.report).not.toContain('user:pass')
|
||||
expect(body.report).not.toContain('/private')
|
||||
expect(body.report).not.toContain('token=x')
|
||||
expect(body.report).not.toContain('PRIVATE_ASSISTANT_REPLY')
|
||||
expect(body.report).not.toContain('PRIVATE_SDK_RESULT')
|
||||
})
|
||||
})
|
||||
|
||||
@ -72,6 +72,71 @@ function makeRequest(
|
||||
}
|
||||
|
||||
describe('DoctorService', () => {
|
||||
test('treats absent optional user features on a fresh install as not configured', async () => {
|
||||
const freshHomeDir = path.join(tmpDir, 'fresh-home')
|
||||
const freshConfigDir = path.join(freshHomeDir, '.claude')
|
||||
const service = new DoctorService({ configDir: freshConfigDir, homeDir: freshHomeDir })
|
||||
|
||||
const report = await service.getReport()
|
||||
|
||||
expect(report.items.length).toBeGreaterThan(0)
|
||||
expect(report.items.every((item) => item.status === 'not_configured')).toBe(true)
|
||||
expect(report.summary).toEqual(expect.objectContaining({
|
||||
total: report.items.length,
|
||||
neutralCount: report.items.length,
|
||||
missingCount: 0,
|
||||
invalidCount: 0,
|
||||
}))
|
||||
})
|
||||
|
||||
test('treats absent optional project settings, skills, and MCP as not configured', async () => {
|
||||
const freshProjectRoot = path.join(tmpDir, 'fresh-project')
|
||||
const service = new DoctorService({ configDir, homeDir, projectRoot: freshProjectRoot })
|
||||
|
||||
const report = await service.getReport()
|
||||
const projectItems = report.items.filter((item) => item.scope === 'project')
|
||||
|
||||
expect(projectItems.map((item) => item.id)).toEqual([
|
||||
'project-settings',
|
||||
'project-skills',
|
||||
'project-mcp',
|
||||
])
|
||||
expect(projectItems.every((item) => item.status === 'not_configured')).toBe(true)
|
||||
expect(report.summary.neutralCount).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
test('still reports a configured optional feature when its file is malformed', async () => {
|
||||
await fs.writeFile(path.join(configDir, 'adapters.json'), '{broken', 'utf-8')
|
||||
const service = new DoctorService({ configDir, homeDir })
|
||||
|
||||
const report = await service.getReport()
|
||||
const adapters = report.items.find((item) => item.id === 'adapters')
|
||||
|
||||
expect(adapters?.status).toBe('invalid_json')
|
||||
expect(report.summary.invalidCount).toBeGreaterThanOrEqual(1)
|
||||
expect(report.summary.neutralCount).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('reports schema-invalid managed providers without exposing parsed contents', async () => {
|
||||
const schemaConfigDir = path.join(homeDir, '.schema-test-claude')
|
||||
await fs.mkdir(path.join(schemaConfigDir, 'cc-haha'), { recursive: true })
|
||||
await fs.writeFile(
|
||||
path.join(schemaConfigDir, 'cc-haha', 'providers.json'),
|
||||
JSON.stringify({ activeId: null, providers: [{ id: 'provider-1' }] }),
|
||||
'utf-8',
|
||||
)
|
||||
const service = new DoctorService({ configDir: schemaConfigDir, homeDir })
|
||||
|
||||
const report = await service.getReport()
|
||||
const providers = report.items.find((item) => item.id === 'cc-haha-providers')
|
||||
|
||||
expect(providers?.status).toBe('invalid_schema')
|
||||
expect(providers?.error).toContain('providers.0.presetId')
|
||||
expect(providers?.error).not.toContain('provider-1')
|
||||
expect(providers?.error).not.toContain(tmpDir)
|
||||
expect(report.summary.invalidCount).toBe(1)
|
||||
})
|
||||
|
||||
test('report redacts filesystem paths and lists protected skipped items', async () => {
|
||||
const service = new DoctorService({ configDir, homeDir, projectRoot })
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
*
|
||||
* GET /api/diagnostics/status — log directory, retention and counters
|
||||
* GET /api/diagnostics/events — recent sanitized diagnostic events
|
||||
* GET /api/diagnostics/issue-report — share-safe GitHub issue report Markdown
|
||||
* POST /api/diagnostics/events — append a sanitized client diagnostic event
|
||||
* POST /api/diagnostics/export — write a sanitized tar.gz bundle
|
||||
* POST /api/diagnostics/open-log-dir — open the diagnostics directory
|
||||
@ -35,6 +36,10 @@ export async function handleDiagnosticsApi(
|
||||
return Response.json({ events })
|
||||
}
|
||||
|
||||
if (action === 'issue-report' && req.method === 'GET') {
|
||||
return Response.json({ report: await diagnosticsService.buildIssueReport() })
|
||||
}
|
||||
|
||||
if (action === 'events' && req.method === 'POST') {
|
||||
const body = await parseJsonBody(req)
|
||||
const type = typeof body.type === 'string' && body.type.trim()
|
||||
@ -46,14 +51,15 @@ export async function handleDiagnosticsApi(
|
||||
? body.summary
|
||||
: type
|
||||
const sessionId = typeof body.sessionId === 'string' ? body.sessionId : undefined
|
||||
await diagnosticsService.recordEvent({
|
||||
const result = await diagnosticsService.recordEvent({
|
||||
type,
|
||||
severity,
|
||||
summary,
|
||||
sessionId,
|
||||
details: body.details,
|
||||
})
|
||||
return Response.json({ ok: true })
|
||||
if (!result.ok) throw ApiError.internal(result.error)
|
||||
return Response.json({ ok: true, eventId: result.event.id })
|
||||
}
|
||||
|
||||
if (action === 'export' && req.method === 'POST') {
|
||||
|
||||
@ -1680,38 +1680,31 @@ export class ConversationService {
|
||||
private summarizeSdkMessages(messages: any[]): unknown[] {
|
||||
return messages.slice(-MAX_CAPTURED_SDK_SUMMARY).map((message) => {
|
||||
if (!message || typeof message !== 'object') {
|
||||
return message
|
||||
return { type: 'unknown' }
|
||||
}
|
||||
const content = Array.isArray(message.message?.content)
|
||||
? message.message.content.map((block: unknown) => {
|
||||
if (!block || typeof block !== 'object') return block
|
||||
const typedBlock = block as Record<string, unknown>
|
||||
return {
|
||||
type: typedBlock.type,
|
||||
text:
|
||||
typeof typedBlock.text === 'string'
|
||||
? this.redactProcessOutput(typedBlock.text)
|
||||
: undefined,
|
||||
}
|
||||
})
|
||||
: undefined
|
||||
return {
|
||||
type: message.type,
|
||||
subtype: message.subtype,
|
||||
is_error: message.is_error,
|
||||
status: typeof message.status === 'string' ? message.status : undefined,
|
||||
result: typeof message.result === 'string' ? this.redactProcessOutput(message.result) : undefined,
|
||||
error: typeof message.error === 'string' ? this.redactProcessOutput(message.error) : undefined,
|
||||
errorDetails:
|
||||
typeof message.errorDetails === 'string'
|
||||
? this.redactProcessOutput(message.errorDetails)
|
||||
: undefined,
|
||||
message: typeof message.message === 'string' ? this.redactProcessOutput(message.message) : undefined,
|
||||
content,
|
||||
type: typeof message.type === 'string' ? message.type : 'unknown',
|
||||
...(typeof message.subtype === 'string' ? { subtype: message.subtype } : {}),
|
||||
...(typeof message.is_error === 'boolean' ? { is_error: message.is_error } : {}),
|
||||
...(this.isSafeSdkStatus(message.status) ? { status: message.status } : {}),
|
||||
...(this.sdkErrorCategory(message) ? { errorCategory: this.sdkErrorCategory(message) } : {}),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private isSafeSdkStatus(value: unknown): value is string {
|
||||
return typeof value === 'string' && /^(?:failed|error|success|completed|cancelled|canceled|pending|running)$/i.test(value)
|
||||
}
|
||||
|
||||
private sdkErrorCategory(message: any): string | undefined {
|
||||
if (message?.type === 'assistant' && (message.isApiErrorMessage === true || message.error !== undefined)) {
|
||||
return 'api_error'
|
||||
}
|
||||
if (message?.type === 'result' && message.is_error === true) return 'result_error'
|
||||
if (message?.type === 'auth_status') return 'authentication'
|
||||
return undefined
|
||||
}
|
||||
|
||||
private async buildUserContent(
|
||||
content: string,
|
||||
sessionId: string,
|
||||
|
||||
@ -1,8 +1,14 @@
|
||||
import * as fs from 'node:fs/promises'
|
||||
import { createHash } from 'node:crypto'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { gzipSync } from 'node:zlib'
|
||||
import type { Dirent } from 'node:fs'
|
||||
import {
|
||||
buildDiagnosticsIssueReport,
|
||||
projectDiagnosticEventForSharing,
|
||||
type SharedDiagnosticEvent,
|
||||
} from './diagnosticsShare.js'
|
||||
|
||||
export type DiagnosticSeverity = 'debug' | 'info' | 'warn' | 'error'
|
||||
|
||||
@ -29,15 +35,38 @@ export type DiagnosticsStatus = {
|
||||
diagnosticsPath: string
|
||||
cliDiagnosticsPath: string
|
||||
runtimeErrorsPath: string
|
||||
electronHostPath: string
|
||||
exportDir: string
|
||||
retentionDays: number
|
||||
maxBytes: number
|
||||
totalBytes: number
|
||||
storageLimitExceeded: boolean
|
||||
eventCount: number
|
||||
physicalLineCount: number
|
||||
corruptLineCount: number
|
||||
recentErrorCount: number
|
||||
lastEventAt: string | null
|
||||
}
|
||||
|
||||
export type DiagnosticWriteResult =
|
||||
| { ok: true; event: DiagnosticEvent }
|
||||
| { ok: false; error: string }
|
||||
|
||||
type DiagnosticsScanResult = {
|
||||
events: DiagnosticEvent[]
|
||||
physicalLineCount: number
|
||||
corruptLineCount: number
|
||||
rawCorruptLineCount: number
|
||||
sourceBytes: number
|
||||
sourceDigest: string
|
||||
}
|
||||
|
||||
type PendingCorruptionEvidence = {
|
||||
corruptLineCount: number
|
||||
sourceBytes: number
|
||||
sourceDigest: string
|
||||
}
|
||||
|
||||
export type DiagnosticsExportResult = {
|
||||
path: string
|
||||
fileName: string
|
||||
@ -46,11 +75,18 @@ export type DiagnosticsExportResult = {
|
||||
|
||||
const RETENTION_DAYS = 7
|
||||
const MAX_BYTES = 50 * 1024 * 1024
|
||||
const MAX_DIAGNOSTICS_BYTES = 20 * 1024 * 1024
|
||||
const MAX_AUXILIARY_LOG_BYTES = 5 * 1024 * 1024
|
||||
const MAX_CLI_COMPLETED_SEGMENTS_BYTES = 5 * 1024 * 1024
|
||||
const MAX_EXPORT_DIRECTORY_BYTES = 14 * 1024 * 1024
|
||||
const MAX_SHARED_EVENTS = 5_000
|
||||
const MAX_ISSUE_REPORT_EVENTS = 100
|
||||
const MAX_STRING_LENGTH = 4096
|
||||
const MAX_TEXT_FILE_EXPORT_LENGTH = 256 * 1024
|
||||
const MAX_ARRAY_ITEMS = 40
|
||||
const MAX_OBJECT_KEYS = 80
|
||||
const MAX_EVENTS_IN_EXPORT = 5000
|
||||
const RETENTION_SWEEP_INTERVAL_MS = 60 * 1000
|
||||
const TRUNCATED_OLDER_CONTENT_MARKER = '[TRUNCATED OLDER CONTENT]\n'
|
||||
const SENSITIVE_KEY_RE = /(api[_-]?key|auth[_-]?token|access[_-]?token|refresh[_-]?token|session[_-]?token|\btoken\b|secret|password|authorization|cookie|oauth)/i
|
||||
|
||||
export class DiagnosticsService {
|
||||
@ -58,6 +94,8 @@ export class DiagnosticsService {
|
||||
private processCaptureInstalled = false
|
||||
private originalConsoleError: typeof console.error | null = null
|
||||
private originalConsoleWarn: typeof console.warn | null = null
|
||||
private lastRetentionSweepAt = 0
|
||||
private writeQueue: Promise<void> = Promise.resolve()
|
||||
|
||||
getLogDir(): string {
|
||||
return path.join(this.getConfigDir(), 'cc-haha', 'diagnostics')
|
||||
@ -75,17 +113,31 @@ export class DiagnosticsService {
|
||||
return path.join(this.getLogDir(), 'runtime-errors.log')
|
||||
}
|
||||
|
||||
getElectronHostPath(): string {
|
||||
return path.join(this.getLogDir(), 'electron-host.log')
|
||||
}
|
||||
|
||||
private getCorruptionEvidencePath(): string {
|
||||
return path.join(this.getLogDir(), 'corruption-evidence.json')
|
||||
}
|
||||
|
||||
private getPendingCorruptionEvidencePath(): string {
|
||||
return path.join(this.getLogDir(), 'corruption-evidence.pending.json')
|
||||
}
|
||||
|
||||
getExportDir(): string {
|
||||
return path.join(this.getLogDir(), 'exports')
|
||||
}
|
||||
|
||||
async recordEvent(input: DiagnosticEventInput): Promise<void> {
|
||||
async recordEvent(input: DiagnosticEventInput): Promise<DiagnosticWriteResult> {
|
||||
// Test isolation: never let a test run write into the user's real
|
||||
// ~/.claude/cc-haha/diagnostics. Tests that genuinely exercise diagnostics
|
||||
// set CLAUDE_CONFIG_DIR to a tmp dir; anything else under NODE_ENV=test is
|
||||
// a leak (e.g. a fire-and-forget recordEvent resolving after a test's
|
||||
// afterEach restored CLAUDE_CONFIG_DIR) and must be dropped.
|
||||
if (process.env.NODE_ENV === 'test' && !process.env.CLAUDE_CONFIG_DIR) return
|
||||
if (process.env.NODE_ENV === 'test' && !process.env.CLAUDE_CONFIG_DIR) {
|
||||
return { ok: false, error: 'Diagnostics are disabled without an isolated config directory during tests' }
|
||||
}
|
||||
|
||||
const event: DiagnosticEvent = {
|
||||
id: crypto.randomUUID(),
|
||||
@ -99,6 +151,12 @@ export class DiagnosticsService {
|
||||
...(input.details !== undefined ? { details: this.sanitizeValue(input.details) } : {}),
|
||||
}
|
||||
|
||||
const operation = this.writeQueue.then(() => this.writeEvent(event))
|
||||
this.writeQueue = operation.then(() => undefined, () => undefined)
|
||||
return operation
|
||||
}
|
||||
|
||||
private async writeEvent(event: DiagnosticEvent): Promise<DiagnosticWriteResult> {
|
||||
try {
|
||||
await this.ensureLogDir()
|
||||
await fs.appendFile(this.getDiagnosticsPath(), JSON.stringify(event) + '\n', 'utf-8')
|
||||
@ -106,8 +164,10 @@ export class DiagnosticsService {
|
||||
await fs.appendFile(this.getRuntimeErrorsPath(), this.formatRuntimeLogEntry(event), 'utf-8')
|
||||
}
|
||||
await this.enforceRetention().catch(() => {})
|
||||
} catch {
|
||||
return { ok: true, event }
|
||||
} catch (error) {
|
||||
// Diagnostics must never break the product path.
|
||||
return { ok: false, error: this.sanitizeWriteError(error) }
|
||||
}
|
||||
}
|
||||
|
||||
@ -183,7 +243,9 @@ export class DiagnosticsService {
|
||||
|
||||
async getStatus(): Promise<DiagnosticsStatus> {
|
||||
await this.ensureLogDir()
|
||||
const events = await this.readRecentEvents(500)
|
||||
await this.runRetentionSerialized(true, false)
|
||||
const scan = await this.scanDiagnosticsFile()
|
||||
const events = scan.events
|
||||
const totalBytes = await this.getDirectorySize(this.getLogDir())
|
||||
const cutoff = Date.now() - 24 * 60 * 60 * 1000
|
||||
return {
|
||||
@ -191,50 +253,85 @@ export class DiagnosticsService {
|
||||
diagnosticsPath: this.getDiagnosticsPath(),
|
||||
cliDiagnosticsPath: this.getCliDiagnosticsPath(),
|
||||
runtimeErrorsPath: this.getRuntimeErrorsPath(),
|
||||
electronHostPath: this.getElectronHostPath(),
|
||||
exportDir: this.getExportDir(),
|
||||
retentionDays: RETENTION_DAYS,
|
||||
maxBytes: MAX_BYTES,
|
||||
totalBytes,
|
||||
storageLimitExceeded: totalBytes > MAX_BYTES || await this.isManagedSurfaceOverLimit(),
|
||||
eventCount: events.length,
|
||||
physicalLineCount: scan.physicalLineCount,
|
||||
corruptLineCount: scan.corruptLineCount,
|
||||
recentErrorCount: events.filter((event) =>
|
||||
(event.severity === 'error' || event.severity === 'warn') &&
|
||||
Date.parse(event.timestamp) >= cutoff
|
||||
).length,
|
||||
lastEventAt: events[0]?.timestamp ?? null,
|
||||
lastEventAt: events.at(-1)?.timestamp ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
async readRecentEvents(limit = 100): Promise<DiagnosticEvent[]> {
|
||||
const boundedLimit = Math.max(1, Math.min(limit, 1000))
|
||||
const scan = await this.scanDiagnosticsFile()
|
||||
return scan.events.slice(-boundedLimit).reverse()
|
||||
}
|
||||
|
||||
private async scanDiagnosticsFile(): Promise<DiagnosticsScanResult> {
|
||||
let raw = ''
|
||||
try {
|
||||
raw = await fs.readFile(this.getDiagnosticsPath(), 'utf-8')
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
const persistedCorruptLineCount = await this.readPersistedCorruptLineCount()
|
||||
return {
|
||||
events: [],
|
||||
physicalLineCount: 0,
|
||||
corruptLineCount: persistedCorruptLineCount,
|
||||
rawCorruptLineCount: 0,
|
||||
sourceBytes: 0,
|
||||
sourceDigest: this.hashBuffer(Buffer.alloc(0)),
|
||||
}
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
return raw
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.slice(-boundedLimit)
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line) as DiagnosticEvent
|
||||
} catch {
|
||||
return null
|
||||
const lines = raw.length === 0 ? [] : raw.split('\n')
|
||||
if (raw.endsWith('\n')) lines.pop()
|
||||
const events: DiagnosticEvent[] = []
|
||||
let corruptLineCount = 0
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const parsed = JSON.parse(line) as unknown
|
||||
if (!this.isDiagnosticEvent(parsed)) {
|
||||
corruptLineCount += 1
|
||||
continue
|
||||
}
|
||||
})
|
||||
.filter((event): event is DiagnosticEvent => event !== null)
|
||||
.reverse()
|
||||
events.push(parsed)
|
||||
} catch {
|
||||
corruptLineCount += 1
|
||||
}
|
||||
}
|
||||
const sourceBuffer = Buffer.from(raw, 'utf-8')
|
||||
const persistedCorruptLineCount = await this.readPersistedCorruptLineCount(sourceBuffer, corruptLineCount)
|
||||
return {
|
||||
events,
|
||||
physicalLineCount: lines.length,
|
||||
corruptLineCount: persistedCorruptLineCount + corruptLineCount,
|
||||
rawCorruptLineCount: corruptLineCount,
|
||||
sourceBytes: sourceBuffer.byteLength,
|
||||
sourceDigest: this.hashBuffer(sourceBuffer),
|
||||
}
|
||||
}
|
||||
|
||||
async exportBundle(): Promise<DiagnosticsExportResult> {
|
||||
await this.ensureLogDir()
|
||||
await this.runRetentionSerialized(true, true)
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const fileName = `cc-haha-diagnostics-${timestamp}.tar.gz`
|
||||
const outPath = path.join(this.getExportDir(), fileName)
|
||||
const events = await this.readRecentEvents(MAX_EVENTS_IN_EXPORT)
|
||||
const scan = await this.scanDiagnosticsFile()
|
||||
const events = scan.events.slice(-MAX_SHARED_EVENTS).reverse()
|
||||
const sharedEvents = events.map(projectDiagnosticEventForSharing)
|
||||
const files = [
|
||||
{
|
||||
name: 'README.txt',
|
||||
@ -246,19 +343,23 @@ export class DiagnosticsService {
|
||||
},
|
||||
{
|
||||
name: 'diagnostics.jsonl',
|
||||
content: events.map((event) => JSON.stringify(this.sanitizeValue(event))).join('\n') + (events.length ? '\n' : ''),
|
||||
content: sharedEvents.map((event) => JSON.stringify(event)).join('\n') + (sharedEvents.length ? '\n' : ''),
|
||||
},
|
||||
{
|
||||
name: 'recent-errors.md',
|
||||
content: this.buildRecentErrorsSummary(events),
|
||||
content: this.buildRecentErrorsSummary(sharedEvents),
|
||||
},
|
||||
{
|
||||
name: 'runtime-errors.log',
|
||||
content: await this.readSanitizedTextFile(this.getRuntimeErrorsPath(), MAX_TEXT_FILE_EXPORT_LENGTH),
|
||||
content: this.buildSharedRuntimeLog(sharedEvents),
|
||||
},
|
||||
{
|
||||
name: 'cli-diagnostics.jsonl',
|
||||
content: await this.readSanitizedTextFile(this.getCliDiagnosticsPath(), MAX_TEXT_FILE_EXPORT_LENGTH),
|
||||
content: await this.readCliDiagnosticsForSharing(MAX_TEXT_FILE_EXPORT_LENGTH),
|
||||
},
|
||||
{
|
||||
name: 'electron-host.log',
|
||||
content: await this.readSanitizedTextFile(this.getElectronHostPath(), MAX_TEXT_FILE_EXPORT_LENGTH),
|
||||
},
|
||||
{
|
||||
name: 'providers-summary.json',
|
||||
@ -266,16 +367,30 @@ export class DiagnosticsService {
|
||||
},
|
||||
{
|
||||
name: 'sessions-summary.json',
|
||||
content: JSON.stringify(this.buildSessionsSummary(events), null, 2) + '\n',
|
||||
content: JSON.stringify(this.buildSessionsSummary(sharedEvents), null, 2) + '\n',
|
||||
},
|
||||
]
|
||||
|
||||
const archive = this.createTarGz(files)
|
||||
await fs.mkdir(this.getExportDir(), { recursive: true })
|
||||
await fs.writeFile(outPath, archive)
|
||||
await this.enforceExportRetention(Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1000)
|
||||
return { path: outPath, fileName, bytes: archive.byteLength }
|
||||
}
|
||||
|
||||
async buildIssueReport(): Promise<string> {
|
||||
await this.ensureLogDir()
|
||||
await this.runRetentionSerialized(true, true)
|
||||
const scan = await this.scanDiagnosticsFile()
|
||||
return buildDiagnosticsIssueReport({
|
||||
generatedAt: new Date().toISOString(),
|
||||
appInfo: this.buildAppInfo(),
|
||||
providersSummary: await this.buildProvidersSummary(),
|
||||
events: scan.events.slice(-MAX_ISSUE_REPORT_EVENTS).reverse().map(projectDiagnosticEventForSharing),
|
||||
corruptLineCount: scan.corruptLineCount,
|
||||
})
|
||||
}
|
||||
|
||||
async openLogDir(): Promise<void> {
|
||||
await this.ensureLogDir()
|
||||
const dir = this.getLogDir()
|
||||
@ -336,6 +451,9 @@ export class DiagnosticsService {
|
||||
.replace(/((?:api[_-]?key|auth[_-]?token|access[_-]?token|refresh[_-]?token|session[_-]?token|token|secret|password)\s*[:=]\s*)[^\s,;"'}]+/gi, '$1[REDACTED]')
|
||||
.replace(/(ANTHROPIC_(?:API_KEY|AUTH_TOKEN)\s*[:=]\s*)[^\s,;"'}]+/gi, '$1[REDACTED]')
|
||||
.replace(/([?&](?:api[_-]?key|token|auth|access_token|refresh_token|key)=)[^&\s]+/gi, '$1[REDACTED]')
|
||||
.replace(/\bAKIA[A-Z0-9]{16}\b/g, '[REDACTED_AWS_ACCESS_KEY]')
|
||||
.replace(/(?:\/Users|\/home|\/private|\/var\/folders|\/tmp)\/[^\s"'`]+/g, '[REDACTED_PATH]')
|
||||
.replace(/\b[A-Z]:\\(?:[^\s"'`]+\\)*[^\s"'`]*/gi, '[REDACTED_PATH]')
|
||||
|
||||
const home = os.homedir()
|
||||
if (home && sanitized.includes(home)) {
|
||||
@ -406,22 +524,25 @@ export class DiagnosticsService {
|
||||
'cc-haha diagnostics bundle',
|
||||
'',
|
||||
'This bundle is generated by the desktop app for debugging server and CLI startup/runtime failures.',
|
||||
'It intentionally excludes chat prompts, assistant replies, file contents, attachments, full environment variables, API keys, bearer tokens, cookies, and OAuth tokens.',
|
||||
'Content-bearing fields are omitted and secrets/personal data receive best-effort redaction.',
|
||||
'Included metadata may contain event ids, timestamps, event types, severity, session ids, error codes/status, provider/model identifiers, and provider hostnames.',
|
||||
'Paths under the current home directory are normalized to "~". Long fields are truncated.',
|
||||
'Review every file before sharing because automated redaction cannot guarantee removal of all sensitive data.',
|
||||
'',
|
||||
'Files:',
|
||||
'- app-info.json: runtime and platform summary.',
|
||||
'- diagnostics.jsonl: sanitized structured diagnostic events.',
|
||||
'- recent-errors.md: human-readable warning/error timeline for GitHub issues.',
|
||||
'- runtime-errors.log: sanitized warning/error timeline with captured runtime details.',
|
||||
'- runtime-errors.log: warning/error timeline with projected diagnostic metadata/details.',
|
||||
'- cli-diagnostics.jsonl: sanitized no-PII CLI internal diagnostics emitted by the child process.',
|
||||
'- electron-host.log: sanitized recent Electron host lifecycle diagnostics.',
|
||||
'- providers-summary.json: provider count, active id, base URL host, model ids, and API format without API keys.',
|
||||
'- sessions-summary.json: session ids observed in diagnostic events, without transcript content.',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
private buildRecentErrorsSummary(events: DiagnosticEvent[]): string {
|
||||
private buildRecentErrorsSummary(events: SharedDiagnosticEvent[]): string {
|
||||
const errorEvents = events
|
||||
.filter((event) => event.severity === 'error' || event.severity === 'warn')
|
||||
.slice(0, 50)
|
||||
@ -444,7 +565,6 @@ export class DiagnosticsService {
|
||||
lines.push(`## ${event.timestamp} ${event.severity.toUpperCase()} ${event.type}`)
|
||||
if (event.sessionId) lines.push(`session: ${event.sessionId}`)
|
||||
lines.push('')
|
||||
lines.push(event.summary)
|
||||
if (event.details !== undefined) {
|
||||
lines.push('')
|
||||
lines.push('```json')
|
||||
@ -457,6 +577,20 @@ export class DiagnosticsService {
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
private buildSharedRuntimeLog(events: SharedDiagnosticEvent[]): string {
|
||||
const errorEvents = events.filter((event) => event.severity === 'error' || event.severity === 'warn')
|
||||
return errorEvents
|
||||
.map((event) => {
|
||||
const lines = [
|
||||
`[${event.timestamp}] ${event.severity.toUpperCase()} ${event.type}${event.sessionId ? ` session=${event.sessionId}` : ''}`,
|
||||
]
|
||||
if (event.details !== undefined) lines.push(JSON.stringify(event.details, null, 2))
|
||||
if (event.omittedFields.length > 0) lines.push(`omittedFields: ${event.omittedFields.join(', ')}`)
|
||||
return lines.join('\n')
|
||||
})
|
||||
.join('\n\n') + (errorEvents.length > 0 ? '\n' : '')
|
||||
}
|
||||
|
||||
private buildAppInfo(): Record<string, unknown> {
|
||||
return this.sanitizeValue({
|
||||
appVersion: process.env.APP_VERSION || '999.0.0-local',
|
||||
@ -464,7 +598,6 @@ export class DiagnosticsService {
|
||||
arch: process.arch,
|
||||
node: process.version,
|
||||
bun: typeof Bun !== 'undefined' ? Bun.version : null,
|
||||
cwd: process.cwd(),
|
||||
uptimeSeconds: Math.round(process.uptime()),
|
||||
generatedAt: new Date().toISOString(),
|
||||
}) as Record<string, unknown>
|
||||
@ -502,13 +635,13 @@ export class DiagnosticsService {
|
||||
if (!value.trim()) return null
|
||||
try {
|
||||
const url = new URL(value)
|
||||
return { protocol: url.protocol, host: url.host, pathname: url.pathname }
|
||||
return { hostname: url.hostname }
|
||||
} catch {
|
||||
return { value: this.sanitizeString(value, 512) }
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private buildSessionsSummary(events: DiagnosticEvent[]): Record<string, unknown> {
|
||||
private buildSessionsSummary(events: SharedDiagnosticEvent[]): Record<string, unknown> {
|
||||
const sessions = new Map<string, { eventCount: number; lastEventAt: string; severities: Set<DiagnosticSeverity> }>()
|
||||
for (const event of events) {
|
||||
if (!event.sessionId) continue
|
||||
@ -538,32 +671,345 @@ export class DiagnosticsService {
|
||||
filePath: string,
|
||||
maxLength = 2 * MAX_STRING_LENGTH,
|
||||
): Promise<string> {
|
||||
let file: fs.FileHandle | undefined
|
||||
try {
|
||||
return this.sanitizeString(await fs.readFile(filePath, 'utf-8'), maxLength)
|
||||
file = await fs.open(filePath, 'r')
|
||||
const stat = await file.stat()
|
||||
if (stat.size <= maxLength) {
|
||||
return this.sanitizeString(await file.readFile('utf-8'), maxLength)
|
||||
}
|
||||
|
||||
const readLength = Math.min(stat.size, maxLength + MAX_STRING_LENGTH)
|
||||
const start = stat.size - readLength
|
||||
const buffer = Buffer.alloc(readLength)
|
||||
await file.read(buffer, 0, readLength, start)
|
||||
let rawTail = buffer.toString('utf-8')
|
||||
if (start > 0) {
|
||||
const firstCompleteLine = rawTail.indexOf('\n')
|
||||
rawTail = firstCompleteLine === -1 ? '' : rawTail.slice(firstCompleteLine + 1)
|
||||
}
|
||||
const sanitizedTail = this.sanitizeString(rawTail, Number.MAX_SAFE_INTEGER)
|
||||
return TRUNCATED_OLDER_CONTENT_MARKER + sanitizedTail.slice(-maxLength)
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return ''
|
||||
throw err
|
||||
} finally {
|
||||
await file?.close()
|
||||
}
|
||||
}
|
||||
|
||||
private async enforceRetention(): Promise<void> {
|
||||
const dir = this.getLogDir()
|
||||
const cutoff = Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1000
|
||||
const files = await this.listFiles(dir)
|
||||
private async readCliDiagnosticsForSharing(maxLength: number): Promise<string> {
|
||||
const baseName = path.basename(this.getCliDiagnosticsPath())
|
||||
const successfullyReadPaths = new Set<string>()
|
||||
const seenContentDigests = new Set<string>()
|
||||
const chunks: string[] = []
|
||||
// A writer may atomically rotate current -> completed between listing and
|
||||
// opening. Re-list a fixed number of times so the moved inode is normally
|
||||
// picked up under its new name without ever locking or mutating it.
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
const files = (await this.listFiles(this.getLogDir()))
|
||||
.filter((file) => path.dirname(file.path) === this.getLogDir())
|
||||
.filter((file) => {
|
||||
const name = path.basename(file.path)
|
||||
return name === baseName || (name.startsWith(`${baseName}.`) && name.endsWith('.jsonl'))
|
||||
})
|
||||
.sort((left, right) => left.mtimeMs - right.mtimeMs)
|
||||
for (const file of files) {
|
||||
if (successfullyReadPaths.has(file.path)) continue
|
||||
const content = await this.readSanitizedTextFile(file.path, maxLength)
|
||||
if (!content) continue
|
||||
successfullyReadPaths.add(file.path)
|
||||
const digest = this.hashBuffer(Buffer.from(content, 'utf-8'))
|
||||
if (seenContentDigests.has(digest)) continue
|
||||
seenContentDigests.add(digest)
|
||||
chunks.push(content)
|
||||
}
|
||||
}
|
||||
const combined = chunks.join('')
|
||||
return combined.length > maxLength
|
||||
? TRUNCATED_OLDER_CONTENT_MARKER + combined.slice(-maxLength)
|
||||
: combined
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (file.mtimeMs < cutoff) {
|
||||
await fs.rm(file.path, { force: true })
|
||||
private async runRetentionSerialized(force: boolean, compactStructured: boolean): Promise<void> {
|
||||
const operation = this.writeQueue.then(() => this.enforceRetention(force, compactStructured))
|
||||
this.writeQueue = operation.then(() => undefined, () => undefined)
|
||||
await operation
|
||||
}
|
||||
|
||||
private async enforceRetention(force = false, compactStructured = true): Promise<void> {
|
||||
const diagnosticsPath = this.getDiagnosticsPath()
|
||||
const cutoff = Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1000
|
||||
const stat = await fs.stat(diagnosticsPath).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'ENOENT') return null
|
||||
throw error
|
||||
})
|
||||
const forceCompaction = (stat?.size ?? 0) > MAX_DIAGNOSTICS_BYTES
|
||||
const now = Date.now()
|
||||
if (!force && !forceCompaction && now - this.lastRetentionSweepAt < RETENTION_SWEEP_INTERVAL_MS) return
|
||||
if (stat) {
|
||||
const scan = await this.scanDiagnosticsFile()
|
||||
const hasStaleEvent = scan.events.some((event) => Date.parse(event.timestamp) < cutoff)
|
||||
if (compactStructured || forceCompaction || hasStaleEvent) {
|
||||
if (scan.rawCorruptLineCount > 0) {
|
||||
await this.writePendingCorruptLineCount({
|
||||
corruptLineCount: scan.corruptLineCount,
|
||||
sourceBytes: scan.sourceBytes,
|
||||
sourceDigest: scan.sourceDigest,
|
||||
})
|
||||
}
|
||||
const retained = scan.events
|
||||
.filter((event) => Date.parse(event.timestamp) >= cutoff)
|
||||
.sort((left, right) => Date.parse(left.timestamp) - Date.parse(right.timestamp))
|
||||
const newestLinesThatFit = this.keepNewestCompleteLinesWithinBytes(retained, MAX_DIAGNOSTICS_BYTES)
|
||||
await this.rewriteDiagnosticsAtomically(newestLinesThatFit)
|
||||
if (scan.rawCorruptLineCount > 0) await this.commitPendingCorruptLineCount()
|
||||
}
|
||||
}
|
||||
|
||||
const remaining = (await this.listFiles(dir)).sort((a, b) => a.mtimeMs - b.mtimeMs)
|
||||
let total = remaining.reduce((sum, file) => sum + file.size, 0)
|
||||
for (const file of remaining) {
|
||||
if (total <= MAX_BYTES) break
|
||||
await fs.rm(file.path, { force: true })
|
||||
total -= file.size
|
||||
await Promise.all([
|
||||
this.truncateTextFileToTail(this.getRuntimeErrorsPath(), MAX_AUXILIARY_LOG_BYTES),
|
||||
this.truncateTextFileToTail(this.getElectronHostPath(), MAX_AUXILIARY_LOG_BYTES),
|
||||
this.enforceCliCompletedSegmentRetention(cutoff),
|
||||
])
|
||||
await this.enforceExportRetention(cutoff)
|
||||
this.lastRetentionSweepAt = now
|
||||
}
|
||||
|
||||
private async readPersistedCorruptLineCount(
|
||||
sourceBuffer = Buffer.alloc(0),
|
||||
rawCorruptLineCount = 0,
|
||||
): Promise<number> {
|
||||
let committed = 0
|
||||
try {
|
||||
const parsed = JSON.parse(await fs.readFile(this.getCorruptionEvidencePath(), 'utf-8')) as unknown
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const count = (parsed as Record<string, unknown>).corruptLineCount
|
||||
committed = typeof count === 'number' && Number.isSafeInteger(count) && count >= 0 ? count : 0
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error
|
||||
}
|
||||
const pending = await this.readPendingCorruptEvidence()
|
||||
if (!pending) return committed
|
||||
const pendingSourceStillPresent = sourceBuffer.byteLength >= pending.sourceBytes &&
|
||||
this.hashBuffer(sourceBuffer.subarray(0, pending.sourceBytes)) === pending.sourceDigest
|
||||
if (rawCorruptLineCount > 0 && pendingSourceStillPresent) return committed
|
||||
return Math.max(committed, pending.corruptLineCount)
|
||||
}
|
||||
|
||||
private async readPendingCorruptEvidence(): Promise<PendingCorruptionEvidence | null> {
|
||||
try {
|
||||
const parsed = JSON.parse(await fs.readFile(this.getPendingCorruptionEvidencePath(), 'utf-8')) as unknown
|
||||
if (!parsed || typeof parsed !== 'object') return null
|
||||
const record = parsed as Record<string, unknown>
|
||||
return typeof record.corruptLineCount === 'number' && Number.isSafeInteger(record.corruptLineCount) && record.corruptLineCount >= 0 &&
|
||||
typeof record.sourceBytes === 'number' && Number.isSafeInteger(record.sourceBytes) && record.sourceBytes >= 0 &&
|
||||
typeof record.sourceDigest === 'string'
|
||||
? record as PendingCorruptionEvidence
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private async writePendingCorruptLineCount(evidence: PendingCorruptionEvidence): Promise<void> {
|
||||
await fs.writeFile(
|
||||
this.getPendingCorruptionEvidencePath(),
|
||||
`${JSON.stringify(evidence)}\n`,
|
||||
{ encoding: 'utf-8', mode: 0o600 },
|
||||
)
|
||||
}
|
||||
|
||||
private async commitPendingCorruptLineCount(): Promise<void> {
|
||||
await fs.rename(this.getPendingCorruptionEvidencePath(), this.getCorruptionEvidencePath())
|
||||
}
|
||||
|
||||
private hashBuffer(buffer: Buffer): string {
|
||||
return createHash('sha256').update(buffer).digest('hex')
|
||||
}
|
||||
|
||||
private async truncateTextFileToTail(filePath: string, maxBytes: number): Promise<void> {
|
||||
let file: fs.FileHandle | undefined
|
||||
try {
|
||||
file = await fs.open(filePath, 'r')
|
||||
const stat = await file.stat()
|
||||
if (stat.size <= maxBytes) return
|
||||
const readLength = Math.min(stat.size, maxBytes + MAX_STRING_LENGTH)
|
||||
const buffer = Buffer.alloc(readLength)
|
||||
await file.read(buffer, 0, readLength, stat.size - readLength)
|
||||
await file.close()
|
||||
file = undefined
|
||||
let tail = buffer.toString('utf-8')
|
||||
const firstCompleteLine = tail.indexOf('\n')
|
||||
tail = firstCompleteLine === -1 ? tail.slice(-maxBytes) : tail.slice(firstCompleteLine + 1)
|
||||
while (Buffer.byteLength(tail, 'utf-8') > maxBytes) tail = tail.slice(1)
|
||||
const temporaryPath = `${filePath}.${crypto.randomUUID()}.tmp`
|
||||
try {
|
||||
await fs.writeFile(temporaryPath, tail, { encoding: 'utf-8', mode: 0o600 })
|
||||
await fs.rename(temporaryPath, filePath)
|
||||
} finally {
|
||||
await fs.rm(temporaryPath, { force: true })
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
} finally {
|
||||
await file?.close()
|
||||
}
|
||||
}
|
||||
|
||||
private async enforceExportRetention(cutoff: number): Promise<void> {
|
||||
const files = (await this.listFiles(this.getExportDir())).sort((left, right) => left.mtimeMs - right.mtimeMs)
|
||||
let retainedBytes = files.reduce((sum, file) => sum + file.size, 0)
|
||||
for (const file of files) {
|
||||
if (file.mtimeMs >= cutoff && retainedBytes <= MAX_EXPORT_DIRECTORY_BYTES) continue
|
||||
await fs.rm(file.path, { force: true })
|
||||
retainedBytes -= file.size
|
||||
}
|
||||
}
|
||||
|
||||
private async enforceCliCompletedSegmentRetention(cutoff: number): Promise<void> {
|
||||
const baseName = path.basename(this.getCliDiagnosticsPath())
|
||||
const cliFiles = (await this.listFiles(this.getLogDir()))
|
||||
.filter((file) => path.dirname(file.path) === this.getLogDir())
|
||||
.filter((file) => {
|
||||
const name = path.basename(file.path)
|
||||
return name === baseName || (name.startsWith(`${baseName}.`) && name.endsWith('.jsonl'))
|
||||
})
|
||||
const removed = new Set<string>()
|
||||
for (const file of cliFiles) {
|
||||
const name = path.basename(file.path)
|
||||
if (name.includes('.reclaimed.')) {
|
||||
if (file.mtimeMs < cutoff) {
|
||||
await fs.rm(file.path, { force: true })
|
||||
removed.add(file.path)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (name === baseName) {
|
||||
if (file.mtimeMs < cutoff) {
|
||||
await this.quarantineCliAppendTarget(file.path)
|
||||
removed.add(file.path)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (!name.endsWith('.current.jsonl')) continue
|
||||
const pidMatch = name.match(new RegExp(`^${this.escapeRegExp(baseName)}\\.(\\d+)\\.current\\.jsonl$`))
|
||||
const liveness = pidMatch ? this.getPidLiveness(Number(pidMatch[1])) : 'unknown'
|
||||
if (liveness === 'dead' || file.mtimeMs < cutoff) {
|
||||
await this.quarantineCliAppendTarget(file.path)
|
||||
removed.add(file.path)
|
||||
}
|
||||
}
|
||||
const recentLegacyBytes = cliFiles
|
||||
.filter((file) => path.basename(file.path) === baseName && !removed.has(file.path))
|
||||
.reduce((sum, file) => sum + file.size, 0)
|
||||
const completed = cliFiles
|
||||
.filter((file) => !removed.has(file.path))
|
||||
.filter((file) => {
|
||||
const name = path.basename(file.path)
|
||||
return name !== baseName && !name.endsWith('.current.jsonl') && !name.includes('.reclaimed.')
|
||||
})
|
||||
.sort((left, right) => left.mtimeMs - right.mtimeMs)
|
||||
let retainedBytes = recentLegacyBytes + completed.reduce((sum, file) => sum + file.size, 0)
|
||||
for (const file of completed) {
|
||||
if (file.mtimeMs >= cutoff && retainedBytes <= MAX_CLI_COMPLETED_SEGMENTS_BYTES) continue
|
||||
await fs.rm(file.path, { force: true })
|
||||
retainedBytes -= file.size
|
||||
}
|
||||
}
|
||||
|
||||
private async quarantineCliAppendTarget(filePath: string): Promise<void> {
|
||||
const currentSuffix = '.current.jsonl'
|
||||
const stem = filePath.endsWith(currentSuffix)
|
||||
? filePath.slice(0, -currentSuffix.length)
|
||||
: filePath
|
||||
const quarantinePath = `${stem}.reclaimed.${crypto.randomUUID()}.jsonl`
|
||||
try {
|
||||
await fs.rename(filePath, quarantinePath)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
}
|
||||
|
||||
private getPidLiveness(pid: number): 'alive' | 'dead' | 'unknown' {
|
||||
if (!Number.isSafeInteger(pid) || pid <= 0) return 'unknown'
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return 'alive'
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code === 'ESRCH') return 'dead'
|
||||
if (code === 'EPERM') return 'alive'
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
private escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
private async isManagedSurfaceOverLimit(): Promise<boolean> {
|
||||
const files = await this.listFiles(this.getLogDir())
|
||||
const sizeOf = (filePath: string) => files.find((file) => file.path === filePath)?.size ?? 0
|
||||
const cliBaseName = path.basename(this.getCliDiagnosticsPath())
|
||||
const cliBytes = files
|
||||
.filter((file) => path.dirname(file.path) === this.getLogDir())
|
||||
.filter((file) => {
|
||||
const name = path.basename(file.path)
|
||||
return name === cliBaseName || (name.startsWith(`${cliBaseName}.`) && name.endsWith('.jsonl'))
|
||||
})
|
||||
.reduce((sum, file) => sum + file.size, 0)
|
||||
const exportBytes = files
|
||||
.filter((file) => file.path.startsWith(`${this.getExportDir()}${path.sep}`))
|
||||
.reduce((sum, file) => sum + file.size, 0)
|
||||
return sizeOf(this.getDiagnosticsPath()) > MAX_DIAGNOSTICS_BYTES ||
|
||||
sizeOf(this.getRuntimeErrorsPath()) > MAX_AUXILIARY_LOG_BYTES ||
|
||||
sizeOf(this.getElectronHostPath()) > MAX_AUXILIARY_LOG_BYTES ||
|
||||
cliBytes > MAX_CLI_COMPLETED_SEGMENTS_BYTES ||
|
||||
exportBytes > MAX_EXPORT_DIRECTORY_BYTES
|
||||
}
|
||||
|
||||
private keepNewestCompleteLinesWithinBytes(events: DiagnosticEvent[], maxBytes: number): string[] {
|
||||
const kept: string[] = []
|
||||
let totalBytes = 0
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
const line = JSON.stringify(events[index])
|
||||
const lineBytes = Buffer.byteLength(line, 'utf-8') + 1
|
||||
if (lineBytes > maxBytes - totalBytes) break
|
||||
kept.push(line)
|
||||
totalBytes += lineBytes
|
||||
}
|
||||
return kept.reverse()
|
||||
}
|
||||
|
||||
private async rewriteDiagnosticsAtomically(lines: string[]): Promise<void> {
|
||||
const diagnosticsPath = this.getDiagnosticsPath()
|
||||
const temporaryPath = `${diagnosticsPath}.${crypto.randomUUID()}.tmp`
|
||||
const content = lines.length > 0 ? `${lines.join('\n')}\n` : ''
|
||||
try {
|
||||
await fs.writeFile(temporaryPath, content, 'utf-8')
|
||||
await fs.rename(temporaryPath, diagnosticsPath)
|
||||
} finally {
|
||||
await fs.rm(temporaryPath, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
private sanitizeWriteError(error: unknown): string {
|
||||
const raw = error instanceof Error ? error.message : String(error)
|
||||
return this.sanitizeString(raw).split(this.getConfigDir()).join('$CLAUDE_CONFIG_DIR')
|
||||
}
|
||||
|
||||
private isDiagnosticEvent(value: unknown): value is DiagnosticEvent {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false
|
||||
const event = value as Record<string, unknown>
|
||||
return typeof event.id === 'string' &&
|
||||
typeof event.timestamp === 'string' &&
|
||||
Number.isFinite(Date.parse(event.timestamp)) &&
|
||||
typeof event.type === 'string' &&
|
||||
(event.severity === 'debug' || event.severity === 'info' || event.severity === 'warn' || event.severity === 'error') &&
|
||||
typeof event.summary === 'string' &&
|
||||
(event.sessionId === undefined || typeof event.sessionId === 'string')
|
||||
}
|
||||
|
||||
private async getDirectorySize(dir: string): Promise<number> {
|
||||
|
||||
177
src/server/services/diagnosticsShare.test.ts
Normal file
177
src/server/services/diagnosticsShare.test.ts
Normal file
@ -0,0 +1,177 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import type { DiagnosticEvent } from './diagnosticsService.js'
|
||||
import {
|
||||
buildDiagnosticsIssueReport,
|
||||
projectDiagnosticEventForSharing,
|
||||
} from './diagnosticsShare.js'
|
||||
|
||||
describe('projectDiagnosticEventForSharing', () => {
|
||||
test('reduces native and serialized errors to metadata without messages, stacks, paths, prompts, or cloud keys', () => {
|
||||
const nativeError = new Error('PRIVATE_PROMPT at /Users/alice/private/project AKIAIOSFODNN7EXAMPLE')
|
||||
nativeError.name = 'PRIVATE_PROMPT_CONTENT'
|
||||
nativeError.stack = 'ProviderRequestError: PRIVATE_STACK\n at /Users/alice/private/project/index.ts:42:1'
|
||||
const event: DiagnosticEvent = {
|
||||
id: 'event-AKIAIOSFODNN7EXAMPLE',
|
||||
timestamp: '2026-07-11T09:10:11.000Z',
|
||||
type: 'provider_error',
|
||||
severity: 'error',
|
||||
summary: 'PRIVATE_SUMMARY',
|
||||
sessionId: '/Users/alice/private/session',
|
||||
details: {
|
||||
nativeError,
|
||||
serializedError: {
|
||||
name: 'PRIVATE_SERIALIZED_ERROR_NAME',
|
||||
message: 'PRIVATE_SERIALIZED_MESSAGE wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
||||
stack: 'PRIVATE_SERIALIZED_STACK /home/alice/private.txt',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const projected = projectDiagnosticEventForSharing(event)
|
||||
const serialized = JSON.stringify(projected)
|
||||
|
||||
expect(projected.details).toEqual({
|
||||
nativeError: { name: 'UnknownError' },
|
||||
serializedError: { name: 'UnknownError' },
|
||||
})
|
||||
for (const privateValue of [
|
||||
'PRIVATE_PROMPT',
|
||||
'PRIVATE_STACK',
|
||||
'PRIVATE_SERIALIZED_MESSAGE',
|
||||
'PRIVATE_SERIALIZED_STACK',
|
||||
'PRIVATE_PROMPT_CONTENT',
|
||||
'PRIVATE_SERIALIZED_ERROR_NAME',
|
||||
'/Users/alice',
|
||||
'/home/alice',
|
||||
'AKIAIOSFODNN7EXAMPLE',
|
||||
'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
||||
]) {
|
||||
expect(serialized).not.toContain(privateValue)
|
||||
}
|
||||
expect(projected.omittedFields).toContain('details.nativeError.message')
|
||||
expect(projected.omittedFields).toContain('details.nativeError.stack')
|
||||
expect(projected.omittedFields).toContain('details.serializedError.message')
|
||||
expect(projected.omittedFields).toContain('details.serializedError.stack')
|
||||
})
|
||||
|
||||
test('keeps diagnostic metadata while omitting content and personal data', () => {
|
||||
const error = new Error('request failed for sk-proj-PROJECTSECRET at user@example.com')
|
||||
const event: DiagnosticEvent = {
|
||||
id: 'event-share-safe-1',
|
||||
timestamp: '2026-07-11T09:10:11.000Z',
|
||||
type: 'sdk_result_error',
|
||||
severity: 'error',
|
||||
summary: 'PRIVATE_ASSISTANT_REPLY',
|
||||
sessionId: 'session-safe-id',
|
||||
details: {
|
||||
errorCode: 'CLI_START_FAILED',
|
||||
status: 'failed',
|
||||
content: 'PRIVATE_CONTENT',
|
||||
prompt: 'PRIVATE_PROMPT',
|
||||
response: 'PRIVATE_RESPONSE',
|
||||
capturedOutput: 'PRIVATE_CAPTURED_OUTPUT',
|
||||
sdkMessages: [{ message: { content: [{ type: 'text', text: 'PRIVATE_ASSISTANT_REPLY' }] } }],
|
||||
toolInput: 'PRIVATE_TOOL_INPUT',
|
||||
toolOutput: 'PRIVATE_TOOL_OUTPUT',
|
||||
assistantText: 'PRIVATE_ASSISTANT_TEXT',
|
||||
bareAnthropicToken: 'sk-ant-api03-BARESECRET',
|
||||
projectToken: 'sk-proj-PROJECTSECRET',
|
||||
githubToken: 'ghp_GITHUBSECRET',
|
||||
endpoint: 'https://private-user:private-pass@example.com/private/path?token=query-secret',
|
||||
email: 'user@example.com',
|
||||
error,
|
||||
},
|
||||
}
|
||||
|
||||
const projected = projectDiagnosticEventForSharing(event)
|
||||
const serialized = JSON.stringify(projected)
|
||||
|
||||
for (const privateValue of [
|
||||
'PRIVATE_CONTENT',
|
||||
'PRIVATE_PROMPT',
|
||||
'PRIVATE_RESPONSE',
|
||||
'PRIVATE_CAPTURED_OUTPUT',
|
||||
'PRIVATE_ASSISTANT_REPLY',
|
||||
'PRIVATE_TOOL_INPUT',
|
||||
'PRIVATE_TOOL_OUTPUT',
|
||||
'PRIVATE_ASSISTANT_TEXT',
|
||||
'sk-ant-api03-BARESECRET',
|
||||
'sk-proj-PROJECTSECRET',
|
||||
'ghp_GITHUBSECRET',
|
||||
'private-user',
|
||||
'private-pass',
|
||||
'/private/path',
|
||||
'query-secret',
|
||||
'user@example.com',
|
||||
]) {
|
||||
expect(serialized).not.toContain(privateValue)
|
||||
}
|
||||
expect(projected.id).toBe(event.id)
|
||||
expect(projected.type).toBe(event.type)
|
||||
expect(projected.severity).toBe(event.severity)
|
||||
expect(projected.details).toMatchObject({ errorCode: 'CLI_START_FAILED', status: 'failed' })
|
||||
expect(projected.omittedFields).toContain('summary')
|
||||
expect(projected.omittedFields).toContain('details.sdkMessages')
|
||||
expect(projected.omittedFields).toContain('details.error.message')
|
||||
expect(projected.omittedFields).toContain('details.error.stack')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildDiagnosticsIssueReport', () => {
|
||||
test('builds a deterministic share-safe GitHub issue template', () => {
|
||||
const report = buildDiagnosticsIssueReport({
|
||||
generatedAt: '2026-07-11T09:10:11.000Z',
|
||||
appInfo: {
|
||||
appVersion: '0.4.7',
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
bun: '1.2.18',
|
||||
node: 'v22.17.0',
|
||||
},
|
||||
providersSummary: {
|
||||
activeId: 'provider-1',
|
||||
count: 1,
|
||||
providers: [{
|
||||
id: 'provider-1',
|
||||
name: 'Test Provider',
|
||||
apiFormat: 'anthropic',
|
||||
baseUrl: { hostname: 'api.example.com' },
|
||||
models: { main: 'main-model' },
|
||||
}],
|
||||
},
|
||||
events: [{
|
||||
id: 'event-report-1',
|
||||
timestamp: '2026-07-11T09:00:00.000Z',
|
||||
type: 'sdk_api_error',
|
||||
severity: 'error',
|
||||
details: {
|
||||
errorCode: 'API_ERROR',
|
||||
status: 'failed',
|
||||
content: 'PRIVATE_ASSISTANT_REPLY',
|
||||
},
|
||||
omittedFields: ['summary', 'details.sdkMessages'],
|
||||
}],
|
||||
corruptLineCount: 2,
|
||||
})
|
||||
|
||||
expect(report).toContain('## 问题描述')
|
||||
expect(report).toContain('期望行为')
|
||||
expect(report).toContain('出现频率')
|
||||
expect(report).toContain('## 运行环境')
|
||||
expect(report).toContain('- App: 0.4.7')
|
||||
expect(report).toContain('- OS/Arch: darwin / arm64')
|
||||
expect(report).toContain('- Bun/Node: 1.2.18 / v22.17.0')
|
||||
expect(report).toContain('- 安装来源: <!-- 请补充 -->')
|
||||
expect(report).toContain('## Provider / 模型')
|
||||
expect(report).toContain('api.example.com')
|
||||
expect(report).toContain('main-model')
|
||||
expect(report).toContain('## 诊断关联')
|
||||
expect(report).toContain('- Event IDs: event-report-1')
|
||||
expect(report).toContain('- Corrupt diagnostic lines: 2')
|
||||
expect(report).toContain('检测到 2 行损坏的诊断记录')
|
||||
expect(report).toContain('## 复现步骤')
|
||||
expect(report).toContain('## 错误摘要')
|
||||
expect(report).toContain('2026-07-11T09:00:00.000Z')
|
||||
expect(report).not.toContain('PRIVATE_ASSISTANT_REPLY')
|
||||
})
|
||||
})
|
||||
263
src/server/services/diagnosticsShare.ts
Normal file
263
src/server/services/diagnosticsShare.ts
Normal file
@ -0,0 +1,263 @@
|
||||
import type { DiagnosticEvent, DiagnosticSeverity } from './diagnosticsService.js'
|
||||
|
||||
export type SharedDiagnosticEvent = {
|
||||
id: string
|
||||
timestamp: string
|
||||
type: string
|
||||
severity: DiagnosticSeverity
|
||||
sessionId?: string
|
||||
details?: Record<string, unknown>
|
||||
omittedFields: string[]
|
||||
}
|
||||
|
||||
export type DiagnosticsIssueReportInput = {
|
||||
generatedAt: string
|
||||
appInfo: Record<string, unknown>
|
||||
providersSummary: Record<string, unknown>
|
||||
events: SharedDiagnosticEvent[]
|
||||
corruptLineCount: number
|
||||
}
|
||||
|
||||
const CONTENT_BEARING_KEYS = new Set([
|
||||
'assistanttext',
|
||||
'body',
|
||||
'capturedoutput',
|
||||
'content',
|
||||
'filecontent',
|
||||
'filecontents',
|
||||
'input',
|
||||
'message',
|
||||
'messagetext',
|
||||
'output',
|
||||
'prompt',
|
||||
'response',
|
||||
'result',
|
||||
'sdkmessages',
|
||||
'text',
|
||||
'toolinput',
|
||||
'tooloutput',
|
||||
'transcript',
|
||||
])
|
||||
|
||||
const SAFE_SCALAR_KEYS = new Set([
|
||||
'code',
|
||||
'errorcategory',
|
||||
'errorcode',
|
||||
'is_error',
|
||||
'isapierrormessage',
|
||||
'iserror',
|
||||
'name',
|
||||
'sdkType'.toLowerCase(),
|
||||
'status',
|
||||
'subtype',
|
||||
])
|
||||
|
||||
const SAFE_METADATA_VALUE_RE = /^[a-z0-9][a-z0-9_.:/ -]{0,127}$/i
|
||||
const URL_RE = /https?:\/\/[^\s<>"')\]}]+/gi
|
||||
const EMAIL_RE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi
|
||||
const SECRET_RE = /\b(?:sk-ant-api03-|sk-proj-|ghp_)[A-Za-z0-9_-]+\b/g
|
||||
const BEARER_RE = /\bBearer\s+[A-Za-z0-9._~+/-]+/gi
|
||||
const AWS_ACCESS_KEY_RE = /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g
|
||||
const PRIVATE_PATH_RE = /(?:\/Users|\/home|\/private|\/var\/folders|\/tmp)\/[^\s<>"')\]}]+/g
|
||||
const WINDOWS_PATH_RE = /\b[A-Z]:\\(?:[^\s<>"')\]}]+\\)*[^\s<>"')\]}]*/gi
|
||||
const SAFE_ERROR_NAMES = new Set([
|
||||
'AggregateError',
|
||||
'Error',
|
||||
'EvalError',
|
||||
'RangeError',
|
||||
'ReferenceError',
|
||||
'SyntaxError',
|
||||
'TypeError',
|
||||
'URIError',
|
||||
])
|
||||
|
||||
export function projectDiagnosticEventForSharing(event: DiagnosticEvent): SharedDiagnosticEvent {
|
||||
const omittedFields = ['summary']
|
||||
const details = projectDetails(event.details, 'details', omittedFields)
|
||||
return {
|
||||
id: sanitizeSharedString(event.id),
|
||||
timestamp: sanitizeSharedString(event.timestamp),
|
||||
type: sanitizeSharedString(event.type),
|
||||
severity: event.severity,
|
||||
...(event.sessionId ? { sessionId: sanitizeSharedString(event.sessionId) } : {}),
|
||||
...(details ? { details } : {}),
|
||||
omittedFields: [...new Set(omittedFields)].sort(),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDiagnosticsIssueReport(input: DiagnosticsIssueReportInput): string {
|
||||
const eventIds = input.events.map((event) => event.id).join(', ') || 'None'
|
||||
const providers = Array.isArray(input.providersSummary.providers)
|
||||
? input.providersSummary.providers as Array<Record<string, unknown>>
|
||||
: []
|
||||
const providerLines = providers.length > 0
|
||||
? providers.map(formatProviderLine)
|
||||
: ['- 未配置 Provider']
|
||||
const recentErrors = input.events
|
||||
.filter((event) => event.severity === 'error' || event.severity === 'warn')
|
||||
.slice(0, 50)
|
||||
const errorLines = recentErrors.length > 0
|
||||
? recentErrors.map(formatErrorLine)
|
||||
: ['- 未记录最近的警告或错误。']
|
||||
const corruptionWarning = input.corruptLineCount > 0
|
||||
? `> 警告:检测到 ${input.corruptLineCount} 行损坏的诊断记录,以下信息可能不完整。`
|
||||
: '> 未检测到损坏的诊断记录。'
|
||||
|
||||
return [
|
||||
`<!-- Generated: ${sanitizeSharedString(input.generatedAt)} -->`,
|
||||
'',
|
||||
'## 问题描述',
|
||||
'<!-- 请补充 -->',
|
||||
'',
|
||||
'- 期望行为: <!-- 请补充 -->',
|
||||
'- 出现频率: <!-- 请补充 -->',
|
||||
'',
|
||||
'## 运行环境',
|
||||
`- App: ${formatMetadata(input.appInfo.appVersion)}`,
|
||||
`- OS/Arch: ${formatMetadata(input.appInfo.platform)} / ${formatMetadata(input.appInfo.arch)}`,
|
||||
`- Bun/Node: ${formatMetadata(input.appInfo.bun)} / ${formatMetadata(input.appInfo.node)}`,
|
||||
'- 安装来源: <!-- 请补充 -->',
|
||||
'',
|
||||
'## Provider / 模型',
|
||||
...providerLines,
|
||||
'',
|
||||
'## 诊断关联',
|
||||
`- Event IDs: ${eventIds}`,
|
||||
`- Corrupt diagnostic lines: ${input.corruptLineCount}`,
|
||||
'',
|
||||
corruptionWarning,
|
||||
'',
|
||||
'## 复现步骤',
|
||||
'1. <!-- 请补充 -->',
|
||||
'',
|
||||
'## 错误摘要',
|
||||
...errorLines,
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function projectDetails(
|
||||
value: unknown,
|
||||
path: string,
|
||||
omittedFields: string[],
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
if (value !== undefined) omittedFields.push(path)
|
||||
return undefined
|
||||
}
|
||||
if (value instanceof Error) {
|
||||
omittedFields.push(`${path}.message`, `${path}.stack`)
|
||||
return { error: projectError(value) }
|
||||
}
|
||||
|
||||
const projected: Record<string, unknown> = {}
|
||||
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
||||
const entryPath = `${path}.${key}`
|
||||
const normalizedKey = key.toLowerCase()
|
||||
if (CONTENT_BEARING_KEYS.has(normalizedKey)) {
|
||||
omittedFields.push(entryPath)
|
||||
continue
|
||||
}
|
||||
if (entry instanceof Error) {
|
||||
omittedFields.push(`${entryPath}.message`, `${entryPath}.stack`)
|
||||
projected[key] = projectError(entry)
|
||||
continue
|
||||
}
|
||||
if (isSerializedError(entry)) {
|
||||
omittedFields.push(`${entryPath}.message`, `${entryPath}.stack`)
|
||||
projected[key] = projectSerializedError(entry)
|
||||
continue
|
||||
}
|
||||
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
|
||||
const nested = projectDetails(entry, entryPath, omittedFields)
|
||||
if (nested && Object.keys(nested).length > 0) projected[key] = nested
|
||||
continue
|
||||
}
|
||||
if (SAFE_SCALAR_KEYS.has(normalizedKey) && isScalar(entry)) {
|
||||
projected[key] = projectSafeMetadataScalar(entry)
|
||||
continue
|
||||
}
|
||||
omittedFields.push(entryPath)
|
||||
}
|
||||
return Object.keys(projected).length > 0 ? projected : undefined
|
||||
}
|
||||
|
||||
function projectError(error: Error): Record<string, string> {
|
||||
return {
|
||||
name: projectErrorName(error.name),
|
||||
}
|
||||
}
|
||||
|
||||
function isSerializedError(value: unknown): value is { name: string; message: string; stack?: string } {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
|
||||
const record = value as Record<string, unknown>
|
||||
const keys = Object.keys(record)
|
||||
return typeof record.name === 'string' && typeof record.message === 'string' &&
|
||||
keys.every((key) => key === 'name' || key === 'message' || key === 'stack')
|
||||
}
|
||||
|
||||
function projectSerializedError(error: { name: string; message: string; stack?: string }): Record<string, string> {
|
||||
return {
|
||||
name: projectErrorName(error.name),
|
||||
}
|
||||
}
|
||||
|
||||
function projectErrorName(name: string): string {
|
||||
return SAFE_ERROR_NAMES.has(name) ? name : 'UnknownError'
|
||||
}
|
||||
|
||||
function isScalar(value: unknown): value is string | number | boolean | null {
|
||||
return value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
|
||||
}
|
||||
|
||||
function projectSafeMetadataScalar(value: string | number | boolean | null): string | number | boolean | null {
|
||||
if (typeof value !== 'string') return value
|
||||
const sanitized = sanitizeSharedString(value)
|
||||
return SAFE_METADATA_VALUE_RE.test(sanitized) ? sanitized : '[REDACTED]'
|
||||
}
|
||||
|
||||
function sanitizeSharedString(value: string): string {
|
||||
return value
|
||||
.replace(URL_RE, (candidate) => {
|
||||
try {
|
||||
return new URL(candidate).hostname
|
||||
} catch {
|
||||
return '[REDACTED_URL]'
|
||||
}
|
||||
})
|
||||
.replace(SECRET_RE, '[REDACTED]')
|
||||
.replace(BEARER_RE, 'Bearer [REDACTED]')
|
||||
.replace(EMAIL_RE, '[REDACTED_EMAIL]')
|
||||
.replace(AWS_ACCESS_KEY_RE, '[REDACTED_AWS_ACCESS_KEY]')
|
||||
.replace(PRIVATE_PATH_RE, '[REDACTED_PATH]')
|
||||
.replace(WINDOWS_PATH_RE, '[REDACTED_PATH]')
|
||||
}
|
||||
|
||||
function formatMetadata(value: unknown): string {
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
return sanitizeSharedString(String(value))
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
function formatProviderLine(provider: Record<string, unknown>): string {
|
||||
const baseUrl = provider.baseUrl && typeof provider.baseUrl === 'object'
|
||||
? provider.baseUrl as Record<string, unknown>
|
||||
: {}
|
||||
const models = provider.models && typeof provider.models === 'object' && !Array.isArray(provider.models)
|
||||
? Object.entries(provider.models as Record<string, unknown>)
|
||||
.filter(([, value]) => typeof value === 'string')
|
||||
.map(([key, value]) => `${sanitizeSharedString(key)}=${sanitizeSharedString(String(value))}`)
|
||||
.join(', ')
|
||||
: 'None'
|
||||
return `- ${formatMetadata(provider.name ?? provider.id)} | ${formatMetadata(provider.apiFormat)} | Host: ${formatMetadata(baseUrl.hostname)} | Models: ${models || 'None'}`
|
||||
}
|
||||
|
||||
function formatErrorLine(event: SharedDiagnosticEvent): string {
|
||||
const details = event.details ?? {}
|
||||
const metadata = [
|
||||
typeof details.errorCode === 'string' ? `errorCode=${details.errorCode}` : '',
|
||||
typeof details.status === 'string' ? `status=${details.status}` : '',
|
||||
].filter(Boolean).join(', ')
|
||||
return `- ${event.timestamp} [${event.severity.toUpperCase()}] ${event.type} (${event.id})${metadata ? ` — ${metadata}` : ''}`
|
||||
}
|
||||
@ -3,10 +3,11 @@ import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import type { Dirent } from 'node:fs'
|
||||
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
|
||||
import { ProvidersIndexSchema } from '../types/provider.js'
|
||||
import { diagnosticsService } from './diagnosticsService.js'
|
||||
|
||||
export type DoctorItemKind = 'json' | 'jsonl' | 'directory'
|
||||
export type DoctorItemStatus = 'ok' | 'missing' | 'invalid_json' | 'invalid_jsonl' | 'unreadable'
|
||||
export type DoctorItemStatus = 'ok' | 'not_configured' | 'missing' | 'invalid_json' | 'invalid_jsonl' | 'invalid_schema' | 'unreadable'
|
||||
export type DoctorSkipReason = 'protected'
|
||||
|
||||
export type DoctorReportItem = {
|
||||
@ -38,6 +39,7 @@ export type DoctorReport = {
|
||||
summary: {
|
||||
total: number
|
||||
protectedCount: number
|
||||
neutralCount: number
|
||||
missingCount: number
|
||||
invalidCount: number
|
||||
}
|
||||
@ -79,6 +81,7 @@ type DoctorTarget = {
|
||||
scope: 'user' | 'project'
|
||||
filePath: string
|
||||
protected: true
|
||||
required: boolean
|
||||
}
|
||||
|
||||
export class DoctorService {
|
||||
@ -112,10 +115,12 @@ export class DoctorService {
|
||||
summary: {
|
||||
total: items.length,
|
||||
protectedCount: protectedSkips.length,
|
||||
neutralCount: items.filter((item) => item.status === 'not_configured').length,
|
||||
missingCount: items.filter((item) => item.status === 'missing').length,
|
||||
invalidCount: items.filter((item) =>
|
||||
item.status === 'invalid_json' ||
|
||||
item.status === 'invalid_jsonl' ||
|
||||
item.status === 'invalid_schema' ||
|
||||
item.status === 'unreadable'
|
||||
).length,
|
||||
},
|
||||
@ -273,6 +278,21 @@ export class DoctorService {
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (target.id === 'cc-haha-providers') {
|
||||
const result = ProvidersIndexSchema.safeParse(parsed)
|
||||
if (!result.success) {
|
||||
const error = result.error.issues
|
||||
.map((issue) => `${issue.path.join('.') || '<root>'}: ${issue.message}`)
|
||||
.join('; ')
|
||||
return {
|
||||
...this.baseItem(target),
|
||||
exists: true,
|
||||
status: 'invalid_schema',
|
||||
bytes,
|
||||
error: this.sanitizeText(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
...this.baseItem(target),
|
||||
exists: true,
|
||||
@ -345,7 +365,7 @@ export class DoctorService {
|
||||
return {
|
||||
...this.baseItem(target),
|
||||
exists: false,
|
||||
status: 'missing',
|
||||
status: target.required ? 'missing' : 'not_configured',
|
||||
bytes: 0,
|
||||
}
|
||||
}
|
||||
@ -453,7 +473,7 @@ export class DoctorService {
|
||||
scope: 'user' | 'project',
|
||||
filePath: string,
|
||||
): DoctorTarget {
|
||||
return { id, label, kind: 'json', scope, filePath, protected: true }
|
||||
return { id, label, kind: 'json', scope, filePath, protected: true, required: false }
|
||||
}
|
||||
|
||||
private jsonlTarget(
|
||||
@ -462,7 +482,7 @@ export class DoctorService {
|
||||
scope: 'user' | 'project',
|
||||
filePath: string,
|
||||
): DoctorTarget {
|
||||
return { id, label, kind: 'jsonl', scope, filePath, protected: true }
|
||||
return { id, label, kind: 'jsonl', scope, filePath, protected: true, required: false }
|
||||
}
|
||||
|
||||
private directoryTarget(
|
||||
@ -471,7 +491,7 @@ export class DoctorService {
|
||||
scope: 'user' | 'project',
|
||||
filePath: string,
|
||||
): DoctorTarget {
|
||||
return { id, label, kind: 'directory', scope, filePath, protected: true }
|
||||
return { id, label, kind: 'directory', scope, filePath, protected: true, required: false }
|
||||
}
|
||||
|
||||
private withAlias(alias: string, filePath: string, root: string): string {
|
||||
|
||||
38
src/utils/diagLogs.test.ts
Normal file
38
src/utils/diagLogs.test.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import * as fs from 'node:fs'
|
||||
import * as fsp from 'node:fs/promises'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { logForDiagnosticsNoPII } from './diagLogs.js'
|
||||
|
||||
let tmpDir: string
|
||||
let originalPath: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'cc-haha-diag-writer-'))
|
||||
originalPath = process.env.CLAUDE_CODE_DIAGNOSTICS_FILE
|
||||
process.env.CLAUDE_CODE_DIAGNOSTICS_FILE = path.join(tmpDir, 'cli-diagnostics.jsonl')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalPath === undefined) delete process.env.CLAUDE_CODE_DIAGNOSTICS_FILE
|
||||
else process.env.CLAUDE_CODE_DIAGNOSTICS_FILE = originalPath
|
||||
await fsp.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('logForDiagnosticsNoPII', () => {
|
||||
test('owns a per-process segment and rotates it without replacing a shared append target', async () => {
|
||||
const basePath = process.env.CLAUDE_CODE_DIAGNOSTICS_FILE!
|
||||
const activePath = `${basePath}.${process.pid}.current.jsonl`
|
||||
fs.writeFileSync(activePath, 'x'.repeat(1024 * 1024))
|
||||
|
||||
logForDiagnosticsNoPII('error', 'after_rotation', { code: 'ROTATED' })
|
||||
|
||||
expect(fs.existsSync(basePath)).toBe(false)
|
||||
expect(fs.readFileSync(activePath, 'utf-8')).toContain('after_rotation')
|
||||
const completedSegments = (await fsp.readdir(tmpDir)).filter((name) =>
|
||||
name.startsWith(`cli-diagnostics.jsonl.${process.pid}.`) && !name.includes('.current.'),
|
||||
)
|
||||
expect(completedSegments).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@ -1,4 +1,4 @@
|
||||
import { dirname } from 'path'
|
||||
import { basename, dirname } from 'path'
|
||||
import { getFsImplementation } from './fsOperations.js'
|
||||
import { jsonStringify } from './slowOperations.js'
|
||||
|
||||
@ -11,6 +11,10 @@ type DiagnosticLogEntry = {
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
const MAX_SEGMENT_BYTES = 1024 * 1024
|
||||
const MAX_COMPLETED_SEGMENTS = 4
|
||||
let segmentSequence = 0
|
||||
|
||||
/**
|
||||
* Logs diagnostic information to a logfile. This information is sent
|
||||
* via the environment manager to session-ingress to monitor issues from
|
||||
@ -29,8 +33,8 @@ export function logForDiagnosticsNoPII(
|
||||
event: string,
|
||||
data?: Record<string, unknown>,
|
||||
): void {
|
||||
const logFile = getDiagnosticLogFile()
|
||||
if (!logFile) {
|
||||
const baseLogFile = getDiagnosticLogFile()
|
||||
if (!baseLogFile) {
|
||||
return
|
||||
}
|
||||
|
||||
@ -43,12 +47,15 @@ export function logForDiagnosticsNoPII(
|
||||
|
||||
const fs = getFsImplementation()
|
||||
const line = jsonStringify(entry) + '\n'
|
||||
const logFile = `${baseLogFile}.${process.pid}.current.jsonl`
|
||||
try {
|
||||
rotateOwnedSegmentIfNeeded(fs, baseLogFile, logFile, Buffer.byteLength(line))
|
||||
fs.appendFileSync(logFile, line)
|
||||
} catch {
|
||||
// If append fails, try creating the directory first
|
||||
try {
|
||||
fs.mkdirSync(dirname(logFile))
|
||||
rotateOwnedSegmentIfNeeded(fs, baseLogFile, logFile, Buffer.byteLength(line))
|
||||
fs.appendFileSync(logFile, line)
|
||||
} catch {
|
||||
// Silently fail if logging is not possible
|
||||
@ -56,6 +63,26 @@ export function logForDiagnosticsNoPII(
|
||||
}
|
||||
}
|
||||
|
||||
function rotateOwnedSegmentIfNeeded(
|
||||
fs: ReturnType<typeof getFsImplementation>,
|
||||
baseLogFile: string,
|
||||
activeLogFile: string,
|
||||
incomingBytes: number,
|
||||
): void {
|
||||
if (!fs.existsSync(activeLogFile)) return
|
||||
if (fs.statSync(activeLogFile).size + incomingBytes <= MAX_SEGMENT_BYTES) return
|
||||
segmentSequence += 1
|
||||
const completedPath = `${baseLogFile}.${process.pid}.${Date.now()}-${segmentSequence}.jsonl`
|
||||
fs.renameSync(activeLogFile, completedPath)
|
||||
const prefix = `${basename(baseLogFile)}.${process.pid}.`
|
||||
const completed = fs.readdirStringSync(dirname(baseLogFile))
|
||||
.filter((name) => name.startsWith(prefix) && name.endsWith('.jsonl') && !name.endsWith('.current.jsonl'))
|
||||
.sort()
|
||||
for (const staleName of completed.slice(0, -MAX_COMPLETED_SEGMENTS)) {
|
||||
fs.unlinkSync(`${dirname(baseLogFile)}/${staleName}`)
|
||||
}
|
||||
}
|
||||
|
||||
function getDiagnosticLogFile(): string | undefined {
|
||||
return process.env.CLAUDE_CODE_DIAGNOSTICS_FILE
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user