cc-haha/src/server/api/traces.ts
程序员阿江(Relakkes) 46f612abd5 feat: allow deleting trace sessions
Adds an idempotent DELETE /api/traces/:sessionId path that removes only the local trace JSONL file and invalidates trace read cache. The Trace list now exposes a trash action behind a confirmation dialog that states chat history is not deleted.

Tested: bun test src/server/__tests__/trace-capture.test.ts

Tested: cd desktop && bun run test -- src/pages/TraceList.test.tsx --run

Tested: bun run check:server

Tested: bun run check:desktop

Tested: Browser smoke with temporary CLAUDE_CONFIG_DIR verified the Trace list delete button and confirmation dialog on ?traceWindow=1.

Constraint: GitHub issue #868 remains open until a release ships and post-release retest passes.

Confidence: high

Scope-risk: moderate
2026-06-23 16:20:11 +08:00

201 lines
6.1 KiB
TypeScript

import { ApiError, errorResponse } from '../middleware/errorHandler.js'
import { sessionService } from '../services/sessionService.js'
import {
readTraceCaptureSettings,
traceCaptureService,
updateTraceCaptureSettings,
type TraceSessionFileItem,
type TraceSessionListItem,
} from '../services/traceCaptureService.js'
export type TraceSessionListApiItem = TraceSessionListItem & {
session: {
id: string
title: string
projectPath: string
workDir: string
} | null
}
function methodNotAllowed(method: string, route: string): ApiError {
return new ApiError(405, `Method ${method} not allowed on ${route}`, 'METHOD_NOT_ALLOWED')
}
async function parseJsonBody(req: Request): Promise<Record<string, unknown>> {
try {
const body = await req.json()
if (!body || typeof body !== 'object' || Array.isArray(body)) {
throw ApiError.badRequest('Invalid JSON body')
}
return body as Record<string, unknown>
} catch (error) {
if (error instanceof ApiError) throw error
throw ApiError.badRequest('Invalid JSON body')
}
}
export async function handleTracesApi(
req: Request,
url: URL,
segments: string[],
): Promise<Response> {
try {
const sub = segments[2]
switch (sub) {
case undefined:
if (req.method !== 'GET') throw methodNotAllowed(req.method, '/api/traces')
return await listTraces(url)
case 'settings':
if (req.method === 'GET') {
return Response.json(await readTraceCaptureSettings())
}
if (req.method === 'PUT') {
const body = await parseJsonBody(req)
const input: { enabled?: boolean } = {}
if (typeof body.enabled === 'boolean') input.enabled = body.enabled
return Response.json(await updateTraceCaptureSettings(input))
}
throw methodNotAllowed(req.method, '/api/traces/settings')
default:
if (req.method === 'DELETE' && segments.length === 3) {
return await deleteTraceSession(sub)
}
throw ApiError.notFound(`Unknown traces endpoint: ${sub}`)
}
} catch (error) {
return errorResponse(error)
}
}
async function deleteTraceSession(segment: string): Promise<Response> {
const sessionId = decodePathSegment(segment).trim()
if (!sessionId) throw ApiError.badRequest('Trace session id is required')
const result = await traceCaptureService.deleteSessionTrace(sessionId)
return Response.json(result)
}
async function listTraces(url: URL): Promise<Response> {
const limit = parseTraceListLimit(url.searchParams.get('limit'))
const offset = parseTraceListOffset(url.searchParams.get('offset'))
const query = url.searchParams.get('q')?.trim() || ''
if (query) {
return listSearchedTraces(query, limit, offset)
}
const result = await traceCaptureService.listSessionTraces({ limit, offset })
const decorated = await Promise.all(result.traces.map(decorateTraceListItem))
return Response.json({
...result,
traces: decorated,
})
}
async function listSearchedTraces(query: string, limit: number, offset: number): Promise<Response> {
const fileResult = await traceCaptureService.listSessionTraceFiles()
const candidates = await Promise.all(fileResult.files.map(decorateTraceFileCandidate))
const filtered = candidates.filter((item) => traceListItemMatchesQuery(item, query))
const pageCandidates = filtered.slice(offset, offset + limit)
const pageSessionIds = pageCandidates.map((item) => item.sessionId)
if (pageSessionIds.length === 0) {
return Response.json({
traces: [],
total: filtered.length,
storageDir: fileResult.storageDir,
settings: fileResult.settings,
})
}
const pageResult = await traceCaptureService.listSessionTraces({
sessionIds: pageSessionIds,
limit: pageSessionIds.length,
offset: 0,
})
const itemsBySessionId = new Map(pageResult.traces.map((item) => [item.sessionId, item]))
const traces = pageCandidates.flatMap((candidate) => {
const trace = itemsBySessionId.get(candidate.sessionId)
if (!trace) return []
return [{
...trace,
session: candidate.session,
}]
})
return Response.json({
...pageResult,
total: filtered.length,
storageDir: fileResult.storageDir,
settings: fileResult.settings,
traces,
})
}
async function decorateTraceListItem(item: TraceSessionListItem): Promise<TraceSessionListApiItem> {
const session = await sessionService.getSession(item.sessionId).catch(() => null)
return {
...item,
session: session
? {
id: session.id,
title: session.title,
projectPath: session.projectPath,
workDir: session.workDir,
}
: null,
}
}
async function decorateTraceFileCandidate(item: TraceSessionFileItem): Promise<Pick<TraceSessionListApiItem, 'sessionId' | 'session'>> {
const session = await sessionService.getSession(item.sessionId).catch(() => null)
return {
sessionId: item.sessionId,
session: session
? {
id: session.id,
title: session.title,
projectPath: session.projectPath,
workDir: session.workDir,
}
: null,
}
}
function traceListItemMatchesQuery(item: Pick<TraceSessionListApiItem, 'sessionId' | 'session'>, query: string): boolean {
const terms = query.toLowerCase().split(/\s+/).filter(Boolean)
if (terms.length === 0) return true
const haystack = [
item.sessionId,
item.session?.title,
item.session?.projectPath,
item.session?.workDir,
]
.filter((value): value is string => Boolean(value))
.join('\n')
.toLowerCase()
return terms.every((term) => haystack.includes(term))
}
function decodePathSegment(value: string): string {
try {
return decodeURIComponent(value)
} catch {
throw ApiError.badRequest('Invalid trace session id')
}
}
function parseTraceListLimit(value: string | null): number {
const parsed = Number.parseInt(value || '50', 10)
if (!Number.isFinite(parsed) || parsed <= 0) return 50
return Math.min(Math.max(Math.round(parsed), 1), 200)
}
function parseTraceListOffset(value: string | null): number {
const parsed = Number.parseInt(value || '0', 10)
if (!Number.isFinite(parsed) || parsed <= 0) return 0
return parsed
}