diff --git a/desktop/src/__tests__/diagnosticsSettings.test.tsx b/desktop/src/__tests__/diagnosticsSettings.test.tsx index e5827770..3ee83d53 100644 --- a/desktop/src/__tests__/diagnosticsSettings.test.tsx +++ b/desktop/src/__tests__/diagnosticsSettings.test.tsx @@ -87,6 +87,7 @@ describe('Settings > Diagnostics tab', () => { diagnosticsApiMock.getStatus.mockResolvedValue({ 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, @@ -104,6 +105,10 @@ describe('Settings > Diagnostics tab', () => { severity: 'error', summary: 'CLI exited during startup with code 1', sessionId: 'session-1', + details: { + exitCode: 1, + capturedOutput: 'stderr:\nprovider rejected request', + }, }], }) diagnosticsApiMock.exportBundle.mockResolvedValue({ @@ -131,6 +136,7 @@ describe('Settings > Diagnostics tab', () => { expect(screen.getByRole('button', { name: /Copy Error Summary/i })).toBeInTheDocument() expect(screen.getByText('cli_start_failed')).toBeInTheDocument() expect(screen.getByText('CLI exited during startup with code 1')).toBeInTheDocument() + expect(screen.getByText('Details')).toBeInTheDocument() }) it('exports a diagnostics bundle from the settings page', async () => { @@ -159,6 +165,7 @@ describe('Settings > Diagnostics tab', () => { writeText: vi.fn().mockRejectedValue(new Error('clipboard blocked')), }, }) + const writeText = vi.mocked(navigator.clipboard.writeText) try { render() @@ -169,6 +176,7 @@ describe('Settings > Diagnostics tab', () => { await waitFor(() => { expect(execCommand).toHaveBeenCalledWith('copy') }) + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('capturedOutput')) const toasts = useUIStore.getState().toasts expect(toasts[toasts.length - 1]?.message).toBe('Error summary copied.') } finally { diff --git a/desktop/src/api/client.test.ts b/desktop/src/api/client.test.ts new file mode 100644 index 00000000..35a4c3d1 --- /dev/null +++ b/desktop/src/api/client.test.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { api, rawRecordDiagnosticEvent } from './client' + +describe('api diagnostics reporting', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('reports non-diagnostics API failures without request bodies', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch') + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ message: 'Nope' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) + + await expect(api.post('/api/providers/test', { apiKey: 'sk-should-not-report' })).rejects.toThrow('Nope') + + expect(fetchMock).toHaveBeenCalledTimes(2) + const diagnosticCall = fetchMock.mock.calls[1] + expect(diagnosticCall).toBeDefined() + const [diagnosticUrl, diagnosticInit] = diagnosticCall! + expect(String(diagnosticUrl)).toContain('/api/diagnostics/events') + const body = JSON.parse(String((diagnosticInit as RequestInit).body)) + expect(body.type).toBe('client_api_request_failed') + expect(body.details.path).toBe('/api/providers/test') + expect(JSON.stringify(body)).not.toContain('sk-should-not-report') + }) + + it('does not recursively report diagnostics endpoint failures', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch') + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ message: 'diagnostics down' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + })) + + await expect(api.get('/api/diagnostics/status')).rejects.toThrow('diagnostics down') + + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('can report raw client exceptions', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch') + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) + + await rawRecordDiagnosticEvent({ + type: 'client_window_error', + severity: 'error', + summary: 'boom', + details: { filename: 'App.tsx' }, + }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + const call = fetchMock.mock.calls[0] + expect(call).toBeDefined() + const [, init] = call! + const body = JSON.parse(String((init as RequestInit).body)) + expect(body.type).toBe('client_window_error') + }) +}) diff --git a/desktop/src/api/client.ts b/desktop/src/api/client.ts index 883d14c7..9d5c8207 100644 --- a/desktop/src/api/client.ts +++ b/desktop/src/api/client.ts @@ -8,6 +8,7 @@ const ENV_BASE_URL = const DEFAULT_BASE_URL = ENV_BASE_URL || 'http://127.0.0.1:3456' let baseUrl = DEFAULT_BASE_URL +const DIAGNOSTICS_PATH = '/api/diagnostics/events' function getErrorMessage(status: number, body: unknown) { if (body && typeof body === 'object' && 'message' in body && typeof body.message === 'string') { @@ -71,12 +72,52 @@ async function request(method: string, path: string, body?: unknown, options? } catch (err) { clearTimeout(timeout) if (controller.signal.aborted) { - throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`) + const timeoutError = new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`) + reportApiFailure(method, path, timeoutError) + throw timeoutError } + reportApiFailure(method, path, err) throw err } } +function reportApiFailure(method: string, path: string, error: unknown) { + if (path.startsWith('/api/diagnostics')) return + + const details: Record = { + method, + path, + errorName: error instanceof Error ? error.name : typeof error, + message: error instanceof Error ? error.message : String(error), + } + + if (error instanceof ApiError) { + details.status = error.status + details.response = error.body + } + + void rawRecordDiagnosticEvent({ + type: 'client_api_request_failed', + severity: 'warn', + summary: `${method} ${path} failed: ${details.message}`, + details, + }) +} + +export function rawRecordDiagnosticEvent(event: { + type: string + severity?: 'debug' | 'info' | 'warn' | 'error' + summary: string + sessionId?: string + details?: unknown +}) { + return fetch(`${baseUrl}${DIAGNOSTICS_PATH}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(event), + }).catch(() => undefined) +} + export const api = { get: (path: string, options?: { timeout?: number }) => request('GET', path, undefined, options), post: (path: string, body?: unknown, options?: { timeout?: number }) => request('POST', path, body, options), diff --git a/desktop/src/api/diagnostics.ts b/desktop/src/api/diagnostics.ts index 3fabac21..4fb34757 100644 --- a/desktop/src/api/diagnostics.ts +++ b/desktop/src/api/diagnostics.ts @@ -12,9 +12,18 @@ export type DiagnosticEvent = { details?: unknown } +export type DiagnosticEventInput = { + type: string + severity?: DiagnosticSeverity + summary: string + sessionId?: string + details?: unknown +} + export type DiagnosticsStatus = { logDir: string diagnosticsPath: string + cliDiagnosticsPath: string runtimeErrorsPath: string exportDir: string retentionDays: number @@ -34,6 +43,7 @@ export type DiagnosticsBundle = { export const diagnosticsApi = { getStatus: () => api.get('/api/diagnostics/status'), getEvents: (limit = 100) => api.get<{ events: DiagnosticEvent[] }>(`/api/diagnostics/events?limit=${limit}`), + 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'), clear: () => api.delete<{ ok: true }>('/api/diagnostics'), diff --git a/desktop/src/components/ErrorBoundary.tsx b/desktop/src/components/ErrorBoundary.tsx new file mode 100644 index 00000000..97b192a9 --- /dev/null +++ b/desktop/src/components/ErrorBoundary.tsx @@ -0,0 +1,40 @@ +import React from 'react' +import { t } from '../i18n' +import { reportReactError } from '../lib/diagnosticsCapture' + +type Props = { + children: React.ReactNode +} + +type State = { + hasError: boolean +} + +export class ErrorBoundary extends React.Component { + state: State = { hasError: false } + + static getDerivedStateFromError(): State { + return { hasError: true } + } + + componentDidCatch(error: unknown, errorInfo: React.ErrorInfo) { + void reportReactError(error, errorInfo) + } + + render() { + if (this.state.hasError) { + return ( +
+
+
{t('errorBoundary.title')}
+
+ {t('errorBoundary.description')} +
+
+
+ ) + } + + return this.props.children + } +} diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index bed1923f..ef9280fc 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -139,6 +139,9 @@ export const en = { 'settings.diagnostics.confirmClear': 'Clear all local diagnostic logs and exported bundles?', 'settings.diagnostics.cleared': 'Diagnostics cleared.', 'settings.diagnostics.clearFailed': 'Failed to clear diagnostics.', + 'settings.diagnostics.eventDetails': 'Details', + 'errorBoundary.title': 'Something went wrong.', + 'errorBoundary.description': 'The error was recorded in Diagnostics.', // Settings > Claude Official Login 'settings.claudeOfficialLogin.intro': 'Using official Claude models requires signing in to your Claude.ai account. Click the button below to open the official Claude login page in your browser; you\'ll be returned here after authorizing.', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 841c851e..2b068049 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -141,6 +141,9 @@ export const zh: Record = { 'settings.diagnostics.confirmClear': '确定清理所有本地诊断日志和已导出的诊断包?', 'settings.diagnostics.cleared': '诊断日志已清理。', 'settings.diagnostics.clearFailed': '清理诊断日志失败。', + 'settings.diagnostics.eventDetails': '详情', + 'errorBoundary.title': '出现异常。', + 'errorBoundary.description': '错误已记录到诊断日志。', // Settings > Claude Official Login 'settings.claudeOfficialLogin.intro': '使用官方 Claude 模型需要登录你的 Claude.ai 账号。点击下方按钮,浏览器会打开 Claude 官方登录页面,授权后自动回到这里。', diff --git a/desktop/src/lib/diagnosticsCapture.ts b/desktop/src/lib/diagnosticsCapture.ts new file mode 100644 index 00000000..ef683451 --- /dev/null +++ b/desktop/src/lib/diagnosticsCapture.ts @@ -0,0 +1,65 @@ +import React from 'react' +import { rawRecordDiagnosticEvent } from '../api/client' + +let installed = false + +export function installClientDiagnosticsCapture() { + if (installed || typeof window === 'undefined') return + installed = true + + window.addEventListener('error', (event) => { + void reportClientError('client_window_error', event.message || 'Window error', { + filename: event.filename, + lineno: event.lineno, + colno: event.colno, + error: normalizeError(event.error), + }) + }) + + window.addEventListener('unhandledrejection', (event) => { + void reportClientError('client_unhandled_rejection', summarizeUnknown(event.reason), { + reason: normalizeError(event.reason), + }) + }) +} + +export function reportReactError(error: unknown, errorInfo: React.ErrorInfo) { + return reportClientError('client_react_error_boundary', summarizeUnknown(error), { + error: normalizeError(error), + componentStack: errorInfo.componentStack, + }) +} + +function reportClientError(type: string, summary: string, details: Record) { + return rawRecordDiagnosticEvent({ + type, + severity: 'error', + summary, + details: { + url: window.location.href, + userAgent: navigator.userAgent, + ...details, + }, + }) +} + +function summarizeUnknown(value: unknown): string { + if (value instanceof Error) return value.message || value.name + if (typeof value === 'string') return value + try { + return JSON.stringify(value) + } catch { + return String(value) + } +} + +function normalizeError(value: unknown): unknown { + if (value instanceof Error) { + return { + name: value.name, + message: value.message, + stack: value.stack, + } + } + return value +} diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx index 16610ff6..af49510a 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -1,13 +1,18 @@ import React from 'react' import ReactDOM from 'react-dom/client' import { App } from './App' +import { ErrorBoundary } from './components/ErrorBoundary' import './theme/globals.css' +import { installClientDiagnosticsCapture } from './lib/diagnosticsCapture' import { initializeTheme } from './stores/uiStore' initializeTheme() +installClientDiagnosticsCapture() ReactDOM.createRoot(document.getElementById('root')!).render( - + + + , ) diff --git a/desktop/src/pages/DiagnosticsSettings.tsx b/desktop/src/pages/DiagnosticsSettings.tsx index 83b2dd47..c899fe33 100644 --- a/desktop/src/pages/DiagnosticsSettings.tsx +++ b/desktop/src/pages/DiagnosticsSettings.tsx @@ -43,7 +43,7 @@ export function DiagnosticsSettings() { return events .filter((event) => event.severity === 'error' || event.severity === 'warn') .slice(0, 20) - .map((event) => `[${event.timestamp}] ${event.severity.toUpperCase()} ${event.type}${event.sessionId ? ` session=${event.sessionId}` : ''}: ${event.summary}`) + .map(formatEventForCopy) .join('\n') }, [events]) @@ -172,7 +172,11 @@ export function DiagnosticsSettings() { ) : (
{events.map((event) => ( - + ))}
)} @@ -190,13 +194,20 @@ function Metric({ label, value }: { label: string; value: string }) { ) } -function EventRow({ event }: { event: DiagnosticEvent }) { +function EventRow({ + event, + detailsLabel, +}: { + event: DiagnosticEvent + detailsLabel: string +}) { const severityClass = event.severity === 'error' ? 'text-[var(--color-error)]' : event.severity === 'warn' ? 'text-[var(--color-warning)]' : 'text-[var(--color-text-tertiary)]' + const detailsText = formatDetails(event.details) return (
@@ -212,7 +223,34 @@ function EventRow({ event }: { event: DiagnosticEvent }) { )}
{event.summary}
+ {detailsText && ( +
+ + {detailsLabel} + +
+              {detailsText}
+            
+
+ )} ) } + +function formatDetails(details: unknown): string { + if (details === null || details === undefined) return '' + if (typeof details === 'string') return details + try { + return JSON.stringify(details, null, 2) + } catch { + return String(details) + } +} + +function formatEventForCopy(event: DiagnosticEvent): string { + const header = `[${event.timestamp}] ${event.severity.toUpperCase()} ${event.type}${event.sessionId ? ` session=${event.sessionId}` : ''}` + const details = formatDetails(event.details) + if (!details) return `${header}: ${event.summary}` + return `${header}: ${event.summary}\nDetails:\n${details}` +} diff --git a/src/server/__tests__/conversation-service.test.ts b/src/server/__tests__/conversation-service.test.ts index 7cd2a7ae..6782ba54 100644 --- a/src/server/__tests__/conversation-service.test.ts +++ b/src/server/__tests__/conversation-service.test.ts @@ -14,6 +14,7 @@ describe('ConversationService', () => { let originalEntrypoint: string | undefined let originalOAuthToken: string | undefined let originalProviderManagedByHost: string | undefined + let originalDiagnosticsFile: string | undefined beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-haha-conversation-service-')) @@ -24,6 +25,7 @@ describe('ConversationService', () => { originalEntrypoint = process.env.CLAUDE_CODE_ENTRYPOINT originalOAuthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN originalProviderManagedByHost = process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST + originalDiagnosticsFile = process.env.CLAUDE_CODE_DIAGNOSTICS_FILE process.env.CLAUDE_CONFIG_DIR = tmpDir process.env.ANTHROPIC_AUTH_TOKEN = 'test-token' @@ -34,6 +36,7 @@ describe('ConversationService', () => { // buildChildEnv injects it or not without interference from the shell env. delete process.env.CLAUDE_CODE_ENTRYPOINT delete process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST + delete process.env.CLAUDE_CODE_DIAGNOSTICS_FILE }) afterEach(async () => { @@ -58,6 +61,9 @@ describe('ConversationService', () => { if (originalProviderManagedByHost === undefined) delete process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST else process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST = originalProviderManagedByHost + if (originalDiagnosticsFile === undefined) delete process.env.CLAUDE_CODE_DIAGNOSTICS_FILE + else process.env.CLAUDE_CODE_DIAGNOSTICS_FILE = originalDiagnosticsFile + await fs.rm(tmpDir, { recursive: true, force: true }) }) @@ -68,6 +74,8 @@ describe('ConversationService', () => { expect(env.ANTHROPIC_AUTH_TOKEN).toBe('test-token') expect(env.ANTHROPIC_BASE_URL).toBe('https://example.invalid/anthropic') expect(env.ANTHROPIC_MODEL).toBe('test-model') + expect(env.CLAUDE_CODE_DIAGNOSTICS_FILE).toBe(path.join(tmpDir, 'cc-haha', 'diagnostics', 'cli-diagnostics.jsonl')) + await expect(fs.stat(path.dirname(env.CLAUDE_CODE_DIAGNOSTICS_FILE))).resolves.toBeTruthy() }) test('strips inherited provider env when desktop provider config exists', async () => { diff --git a/src/server/__tests__/diagnostics-service.test.ts b/src/server/__tests__/diagnostics-service.test.ts index 2e4346db..db86cd6b 100644 --- a/src/server/__tests__/diagnostics-service.test.ts +++ b/src/server/__tests__/diagnostics-service.test.ts @@ -52,6 +52,7 @@ describe('DiagnosticsService', () => { const runtime = await fs.readFile(path.join(tmpDir, 'cc-haha', 'diagnostics', 'runtime-errors.log'), 'utf-8') expect(runtime).toContain('cli_start_failed') + expect(runtime).toContain('"nested"') expect(runtime).toContain('[REDACTED]') expect(runtime).not.toContain('sk-secret-token') }) @@ -82,13 +83,21 @@ describe('DiagnosticsService', () => { summary: 'provider failed with token=provider-secret', details: { accessToken: 'provider-secret' }, }) + await fs.writeFile( + path.join(tmpDir, 'cc-haha', 'diagnostics', 'cli-diagnostics.jsonl'), + '{"event":"cli_streaming_idle_timeout","data":{"authorization":"Bearer provider-secret"}}\n', + 'utf-8', + ) const bundle = await service.exportBundle() expect(bundle.path).toEndWith('.tar.gz') const archiveText = gunzipSync(await fs.readFile(bundle.path)).toString('utf-8') expect(archiveText).toContain('README.txt') + expect(archiveText).toContain('recent-errors.md') + expect(archiveText).toContain('cli-diagnostics.jsonl') expect(archiveText).toContain('providers-summary.json') expect(archiveText).toContain('sessions-summary.json') + expect(archiveText).toContain('cli_streaming_idle_timeout') expect(archiveText).toContain('Test Provider') expect(archiveText).toContain('api.example.com') expect(archiveText).not.toContain('sk-provider-secret') @@ -108,8 +117,9 @@ describe('diagnostics API', () => { const statusReq = makeRequest('GET', '/api/diagnostics/status') const statusRes = await handleDiagnosticsApi(statusReq.req, statusReq.url, statusReq.segments) expect(statusRes.status).toBe(200) - const status = await statusRes.json() as { logDir: string; recentErrorCount: number } + const status = await statusRes.json() as { logDir: string; cliDiagnosticsPath: string; recentErrorCount: number } expect(status.logDir).toContain(path.join('cc-haha', 'diagnostics')) + expect(status.cliDiagnosticsPath).toContain('cli-diagnostics.jsonl') expect(status.recentErrorCount).toBe(1) const eventsReq = makeRequest('GET', '/api/diagnostics/events?limit=10') @@ -118,6 +128,28 @@ describe('diagnostics API', () => { const events = await eventsRes.json() as { events: Array<{ type: string }> } expect(events.events[0].type).toBe('api_unhandled_error') + const clientEventReq = new Request('http://localhost:3456/api/diagnostics/events', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: 'client_unhandled_rejection', + severity: 'error', + summary: 'frontend exploded token=client-secret', + details: { accessToken: 'client-secret', stack: 'Error: boom' }, + }), + }) + const clientEventUrl = new URL(clientEventReq.url) + const clientEventRes = await handleDiagnosticsApi( + clientEventReq, + clientEventUrl, + clientEventUrl.pathname.split('/').filter(Boolean), + ) + expect(clientEventRes.status).toBe(200) + const clientEvents = await service.readRecentEvents(10) + expect(clientEvents[0].type).toBe('client_unhandled_rejection') + expect(JSON.stringify(clientEvents[0])).toContain('[REDACTED]') + expect(JSON.stringify(clientEvents[0])).not.toContain('client-secret') + const exportReq = makeRequest('POST', '/api/diagnostics/export') const exportRes = await handleDiagnosticsApi(exportReq.req, exportReq.url, exportReq.segments) expect(exportRes.status).toBe(200) diff --git a/src/server/api/diagnostics.ts b/src/server/api/diagnostics.ts index ea32513c..7bf3c4aa 100644 --- a/src/server/api/diagnostics.ts +++ b/src/server/api/diagnostics.ts @@ -3,6 +3,7 @@ * * GET /api/diagnostics/status — log directory, retention and counters * GET /api/diagnostics/events — recent sanitized diagnostic events + * 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 * DELETE /api/diagnostics — clear diagnostics files @@ -34,6 +35,26 @@ export async function handleDiagnosticsApi( return Response.json({ events }) } + if (action === 'events' && req.method === 'POST') { + const body = await parseJsonBody(req) + const type = typeof body.type === 'string' && body.type.trim() + ? body.type.trim().slice(0, 128) + : 'client_diagnostic_event' + const severity = isDiagnosticSeverity(body.severity) ? body.severity : 'error' + const summary = typeof body.summary === 'string' && body.summary.trim() + ? body.summary + : type + const sessionId = typeof body.sessionId === 'string' ? body.sessionId : undefined + await diagnosticsService.recordEvent({ + type, + severity, + summary, + sessionId, + details: body.details, + }) + return Response.json({ ok: true }) + } + if (action === 'export' && req.method === 'POST') { return Response.json({ bundle: await diagnosticsService.exportBundle() }) } @@ -48,3 +69,16 @@ export async function handleDiagnosticsApi( return errorResponse(error) } } + +async function parseJsonBody(req: Request): Promise> { + try { + const body = await req.json() + return body && typeof body === 'object' ? body as Record : {} + } catch { + throw ApiError.badRequest('Invalid JSON body') + } +} + +function isDiagnosticSeverity(value: unknown): value is 'debug' | 'info' | 'warn' | 'error' { + return value === 'debug' || value === 'info' || value === 'warn' || value === 'error' +} diff --git a/src/server/index.ts b/src/server/index.ts index 967a7591..082fe682 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -51,6 +51,7 @@ const HOST = SERVER_OPTIONS.host export function startServer(port = PORT, host = HOST) { enableConfigs() diagnosticsService.installConsoleCapture() + diagnosticsService.installProcessCapture() ProviderService.setServerPort(port) const localConnectHost = host === '0.0.0.0' || host === '127.0.0.1' || host === 'localhost' diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index 78fcc515..deced161 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -17,6 +17,10 @@ import { resolveClaudeCliLauncher, } from '../../utils/desktopBundledCli.js' +const MAX_CAPTURED_PROCESS_LINES = 80 +const MAX_CAPTURED_SDK_MESSAGES = 40 +const MAX_CAPTURED_SDK_SUMMARY = 20 + type AttachmentRef = { type: 'file' | 'image' name?: string @@ -496,8 +500,8 @@ export class ConversationService { try { const msg = JSON.parse(line) session.sdkMessages.push(msg) - if (session.sdkMessages.length > 40) { - session.sdkMessages.splice(0, 20) + if (session.sdkMessages.length > MAX_CAPTURED_SDK_MESSAGES) { + session.sdkMessages.splice(0, session.sdkMessages.length - MAX_CAPTURED_SDK_MESSAGES) } const sdkError = this.extractSdkErrorEvent(msg) if (sdkError) { @@ -604,8 +608,8 @@ export class ConversationService { const lines = streamName === 'stderr' ? session.stderrLines : session.stdoutLines lines.push(this.redactProcessOutput(line)) - if (lines.length > 20) { - lines.splice(0, 10) + if (lines.length > MAX_CAPTURED_PROCESS_LINES) { + lines.splice(0, lines.length - MAX_CAPTURED_PROCESS_LINES) } } } @@ -782,10 +786,18 @@ export class ConversationService { explicitProviderEnv.ANTHROPIC_MODEL = options.model.trim() } + const cliDiagnosticsPath = diagnosticsService.getCliDiagnosticsPath() + try { + fs.mkdirSync(path.dirname(cliDiagnosticsPath), { recursive: true }) + } catch { + // Diagnostics must never block session startup. + } + return { ...cleanEnv, CLAUDE_CODE_ENABLE_TASKS: '1', CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING: '1', + CLAUDE_CODE_DIAGNOSTICS_FILE: cliDiagnosticsPath, CALLER_DIR: workDir, PWD: workDir, ...(sdkUrl @@ -1122,6 +1134,9 @@ export class ConversationService { sdkType: message.type, error: typeof message.error === 'string' ? message.error : undefined, isApiErrorMessage: message.isApiErrorMessage === true, + messageText: this.extractAssistantText(message) + ? this.redactProcessOutput(this.extractAssistantText(message)) + : undefined, errorDetails: typeof message.errorDetails === 'string' ? this.redactProcessOutput(message.errorDetails) @@ -1141,6 +1156,15 @@ export class ConversationService { sdkType: message.type, subtype: message.subtype, isError: true, + result: + typeof message.result === 'string' + ? this.redactProcessOutput(message.result) + : undefined, + status: + typeof message.status === 'string' + ? this.redactProcessOutput(message.status) + : undefined, + usage: message.usage, }, } } @@ -1149,17 +1173,36 @@ export class ConversationService { } private summarizeSdkMessages(messages: any[]): unknown[] { - return messages.slice(-10).map((message) => { + return messages.slice(-MAX_CAPTURED_SDK_SUMMARY).map((message) => { if (!message || typeof message !== 'object') { return message } + 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 + 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' ? message.result : undefined, - message: typeof message.message === 'string' ? message.message : 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, } }) } diff --git a/src/server/services/diagnosticsService.ts b/src/server/services/diagnosticsService.ts index b57189f8..c6b4b229 100644 --- a/src/server/services/diagnosticsService.ts +++ b/src/server/services/diagnosticsService.ts @@ -27,6 +27,7 @@ export type DiagnosticEvent = { export type DiagnosticsStatus = { logDir: string diagnosticsPath: string + cliDiagnosticsPath: string runtimeErrorsPath: string exportDir: string retentionDays: number @@ -46,6 +47,7 @@ export type DiagnosticsExportResult = { const RETENTION_DAYS = 7 const MAX_BYTES = 50 * 1024 * 1024 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 @@ -53,6 +55,7 @@ const SENSITIVE_KEY_RE = /(api[_-]?key|auth[_-]?token|access[_-]?token|refresh[_ export class DiagnosticsService { private consoleCaptureInstalled = false + private processCaptureInstalled = false private originalConsoleError: typeof console.error | null = null private originalConsoleWarn: typeof console.warn | null = null @@ -64,6 +67,10 @@ export class DiagnosticsService { return path.join(this.getLogDir(), 'diagnostics.jsonl') } + getCliDiagnosticsPath(): string { + return path.join(this.getLogDir(), 'cli-diagnostics.jsonl') + } + getRuntimeErrorsPath(): string { return path.join(this.getLogDir(), 'runtime-errors.log') } @@ -87,7 +94,7 @@ export class DiagnosticsService { await this.ensureLogDir() await fs.appendFile(this.getDiagnosticsPath(), JSON.stringify(event) + '\n', 'utf-8') if (event.severity === 'warn' || event.severity === 'error') { - await fs.appendFile(this.getRuntimeErrorsPath(), this.formatRuntimeLogLine(event), 'utf-8') + await fs.appendFile(this.getRuntimeErrorsPath(), this.formatRuntimeLogEntry(event), 'utf-8') } await this.enforceRetention().catch(() => {}) } catch { @@ -128,6 +135,29 @@ export class DiagnosticsService { this.originalConsoleWarn = null } + installProcessCapture(): void { + if (this.processCaptureInstalled) return + this.processCaptureInstalled = true + + process.on('uncaughtException', (error) => { + void this.recordEvent({ + type: 'server_uncaught_exception', + severity: 'error', + summary: error.message || 'Uncaught exception', + details: { error }, + }) + }) + + process.on('unhandledRejection', (reason) => { + void this.recordEvent({ + type: 'server_unhandled_rejection', + severity: 'error', + summary: this.formatUnknownReason(reason), + details: { reason }, + }) + }) + } + async getStatus(): Promise { await this.ensureLogDir() const events = await this.readRecentEvents(500) @@ -136,6 +166,7 @@ export class DiagnosticsService { return { logDir: this.getLogDir(), diagnosticsPath: this.getDiagnosticsPath(), + cliDiagnosticsPath: this.getCliDiagnosticsPath(), runtimeErrorsPath: this.getRuntimeErrorsPath(), exportDir: this.getExportDir(), retentionDays: RETENTION_DAYS, @@ -194,9 +225,17 @@ export class DiagnosticsService { name: 'diagnostics.jsonl', content: events.map((event) => JSON.stringify(this.sanitizeValue(event))).join('\n') + (events.length ? '\n' : ''), }, + { + name: 'recent-errors.md', + content: this.buildRecentErrorsSummary(events), + }, { name: 'runtime-errors.log', - content: await this.readSanitizedTextFile(this.getRuntimeErrorsPath()), + content: await this.readSanitizedTextFile(this.getRuntimeErrorsPath(), MAX_TEXT_FILE_EXPORT_LENGTH), + }, + { + name: 'cli-diagnostics.jsonl', + content: await this.readSanitizedTextFile(this.getCliDiagnosticsPath(), MAX_TEXT_FILE_EXPORT_LENGTH), }, { name: 'providers-summary.json', @@ -305,8 +344,26 @@ export class DiagnosticsService { }).join(' ')) } - private formatRuntimeLogLine(event: DiagnosticEvent): string { - return `[${event.timestamp}] ${event.severity.toUpperCase()} ${event.type}${event.sessionId ? ` session=${event.sessionId}` : ''}: ${event.summary}\n` + private formatUnknownReason(reason: unknown): string { + if (reason instanceof Error) return reason.message || reason.name + if (typeof reason === 'string') return this.sanitizeString(reason) + try { + return this.sanitizeString(JSON.stringify(this.sanitizeValue(reason))) + } catch { + return this.sanitizeString(String(reason)) + } + } + + private formatRuntimeLogEntry(event: DiagnosticEvent): string { + const lines = [ + `[${event.timestamp}] ${event.severity.toUpperCase()} ${event.type}${event.sessionId ? ` session=${event.sessionId}` : ''}`, + `summary: ${event.summary}`, + ] + if (event.details !== undefined) { + lines.push('details:') + lines.push(JSON.stringify(event.details, null, 2)) + } + return `${lines.join('\n')}\n\n` } private buildReadme(): string { @@ -320,13 +377,51 @@ export class DiagnosticsService { 'Files:', '- app-info.json: runtime and platform summary.', '- diagnostics.jsonl: sanitized structured diagnostic events.', - '- runtime-errors.log: sanitized warning/error summaries.', + '- recent-errors.md: human-readable warning/error timeline for GitHub issues.', + '- runtime-errors.log: sanitized warning/error timeline with captured runtime details.', + '- cli-diagnostics.jsonl: sanitized no-PII CLI internal diagnostics emitted by the child process.', '- 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 { + const errorEvents = events + .filter((event) => event.severity === 'error' || event.severity === 'warn') + .slice(0, 50) + + const lines = [ + '# cc-haha recent diagnostics', + '', + `Generated: ${new Date().toISOString()}`, + `Events included: ${errorEvents.length}`, + '', + ] + + if (errorEvents.length === 0) { + lines.push('No recent warnings or errors were recorded.') + lines.push('') + return lines.join('\n') + } + + for (const event of errorEvents) { + 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') + lines.push(JSON.stringify(event.details, null, 2)) + lines.push('```') + } + lines.push('') + } + + return lines.join('\n') + } + private buildAppInfo(): Record { return this.sanitizeValue({ appVersion: process.env.APP_VERSION || '999.0.0-local', @@ -404,9 +499,12 @@ export class DiagnosticsService { } } - private async readSanitizedTextFile(filePath: string): Promise { + private async readSanitizedTextFile( + filePath: string, + maxLength = 2 * MAX_STRING_LENGTH, + ): Promise { try { - return this.sanitizeString(await fs.readFile(filePath, 'utf-8'), 2 * MAX_STRING_LENGTH) + return this.sanitizeString(await fs.readFile(filePath, 'utf-8'), maxLength) } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') return '' throw err