cc-haha/src/server/api/diagnostics.ts
程序员阿江(Relakkes) f2437bf724 feat: Make diagnostics actionable for runtime failures
Diagnostics exports previously preserved only compact summaries, which made user-uploaded issue bundles hard to debug. This records richer sanitized details from server, CLI, SDK, browser, React, and desktop API failure paths while keeping request bodies and secrets out of client-side reports.

Constraint: Diagnostic bundles must be useful for GitHub issues without including chat contents, file contents, full environments, or API keys.
Rejected: Only expand the Settings UI summary | the exported bundle would still miss hidden runtime failures.
Confidence: high
Scope-risk: moderate
Directive: Keep diagnostics write paths best-effort and non-blocking; do not add request-body capture without a redaction review.
Tested: bun test src/server/__tests__/diagnostics-service.test.ts src/server/__tests__/conversation-service.test.ts
Tested: cd desktop && bun run test src/api/client.test.ts src/__tests__/diagnosticsSettings.test.tsx --run
Tested: bun run check:server
Tested: bun run check:desktop
Tested: agent-browser E2E on ports 37652/41752 captured client_unhandled_rejection and exported a redacted tar.gz bundle
2026-05-05 17:57:50 +08:00

85 lines
2.9 KiB
TypeScript

/**
* Diagnostics REST API
*
* 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
*/
import { diagnosticsService } from '../services/diagnosticsService.js'
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
export async function handleDiagnosticsApi(
req: Request,
url: URL,
segments: string[],
): Promise<Response> {
try {
const action = segments[2]
if (!action && req.method === 'DELETE') {
await diagnosticsService.clear()
return Response.json({ ok: true })
}
if (action === 'status' && req.method === 'GET') {
return Response.json(await diagnosticsService.getStatus())
}
if (action === 'events' && req.method === 'GET') {
const limit = Number.parseInt(url.searchParams.get('limit') || '100', 10)
const events = await diagnosticsService.readRecentEvents(Number.isFinite(limit) ? limit : 100)
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() })
}
if (action === 'open-log-dir' && req.method === 'POST') {
await diagnosticsService.openLogDir()
return Response.json({ ok: true })
}
throw new ApiError(404, `Unknown diagnostics endpoint: ${action ?? '(root)'}`, 'NOT_FOUND')
} catch (error) {
return errorResponse(error)
}
}
async function parseJsonBody(req: Request): Promise<Record<string, unknown>> {
try {
const body = await req.json()
return body && typeof body === 'object' ? body as Record<string, unknown> : {}
} 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'
}