diff --git a/server/src/index.ts b/server/src/index.ts index 89da9c60..91359f08 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -16,6 +16,7 @@ import previewRoutes from './routes/preview.js'; import conflictRoutes from './routes/conflicts.js'; import telemetryRoutes from './routes/telemetry.js'; import metricsRoutes from './routes/metrics.js'; +import tracesRoutes from './routes/traces.js'; import { getTelemetryService } from './services/telemetry-service.js'; import type { AgentOutput } from './services/agent-service.js'; @@ -46,6 +47,7 @@ app.use('/api/preview', previewRoutes); app.use('/api/conflicts', conflictRoutes); app.use('/api/telemetry', telemetryRoutes); app.use('/api/metrics', metricsRoutes); +app.use('/api/traces', tracesRoutes); // Initialize telemetry service (runs retention cleanup) getTelemetryService().init().catch((err) => { diff --git a/server/src/routes/traces.ts b/server/src/routes/traces.ts new file mode 100644 index 00000000..7d229883 --- /dev/null +++ b/server/src/routes/traces.ts @@ -0,0 +1,82 @@ +import { Router, type Router as RouterType } from 'express'; +import { getTraceService } from '../services/trace-service.js'; + +const router: RouterType = Router(); + +/** + * GET /api/traces/status + * Get tracing status (enabled/disabled) + */ +router.get('/status', async (_req, res, next) => { + try { + const traceService = getTraceService(); + res.json({ + enabled: traceService.isEnabled(), + }); + } catch (error) { + next(error); + } +}); + +/** + * POST /api/traces/enable + * Enable tracing + */ +router.post('/enable', async (_req, res, next) => { + try { + const traceService = getTraceService(); + traceService.setEnabled(true); + res.json({ enabled: true }); + } catch (error) { + next(error); + } +}); + +/** + * POST /api/traces/disable + * Disable tracing + */ +router.post('/disable', async (_req, res, next) => { + try { + const traceService = getTraceService(); + traceService.setEnabled(false); + res.json({ enabled: false }); + } catch (error) { + next(error); + } +}); + +/** + * GET /api/traces/:attemptId + * Get a trace by attempt ID + */ +router.get('/:attemptId', async (req, res, next) => { + try { + const traceService = getTraceService(); + const trace = await traceService.getTrace(req.params.attemptId); + + if (!trace) { + return res.status(404).json({ error: 'Trace not found' }); + } + + res.json(trace); + } catch (error) { + next(error); + } +}); + +/** + * GET /api/traces/task/:taskId + * List all traces for a task + */ +router.get('/task/:taskId', async (req, res, next) => { + try { + const traceService = getTraceService(); + const traces = await traceService.listTraces(req.params.taskId); + res.json(traces); + } catch (error) { + next(error); + } +}); + +export default router; diff --git a/server/src/services/agent-service.ts b/server/src/services/agent-service.ts index cc0b3471..c4333c9f 100644 --- a/server/src/services/agent-service.ts +++ b/server/src/services/agent-service.ts @@ -6,6 +6,7 @@ import { nanoid } from 'nanoid'; import { ConfigService } from './config-service.js'; import { TaskService } from './task-service.js'; import { getTelemetryService, type TelemetryService } from './telemetry-service.js'; +import { getTraceService, type TraceService } from './trace-service.js'; import type { Task, AgentType, TaskAttempt, AttemptStatus, RunTelemetryEvent } from '@veritas-kanban/shared'; const PROJECT_ROOT = path.resolve(process.cwd(), '..'); @@ -43,12 +44,14 @@ export class AgentService { private configService: ConfigService; private taskService: TaskService; private telemetry: TelemetryService; + private traceService: TraceService; private logsDir: string; constructor() { this.configService = new ConfigService(); this.taskService = new TaskService(); this.telemetry = getTelemetryService(); + this.traceService = getTraceService(); this.logsDir = LOGS_DIR; this.ensureLogsDir(); } @@ -99,6 +102,10 @@ export class AgentService { const startedAt = new Date().toISOString(); const logPath = path.join(this.logsDir, `${taskId}_${attemptId}.md`); + // Start trace (if enabled) + this.traceService.startTrace(attemptId, taskId, agent, task.project); + this.traceService.startStep(attemptId, 'init', { worktreePath: task.git.worktreePath }); + // Build prompt from task const prompt = this.buildPrompt(task); @@ -155,6 +162,10 @@ export class AgentService { project: task.project, }); + // End init step, start execute step (tracing) + this.traceService.endStep(attemptId, 'init'); + this.traceService.startStep(attemptId, 'execute', { pid: childProcess.pid }); + // Handle stdout childProcess.stdout?.on('data', async (data: Buffer) => { const content = data.toString(); @@ -216,6 +227,12 @@ export class AgentService { success, }); + // End execute step, start complete step, then complete trace + this.traceService.endStep(attemptId, 'execute'); + this.traceService.startStep(attemptId, 'complete', { exitCode: code }); + this.traceService.endStep(attemptId, 'complete'); + await this.traceService.completeTrace(attemptId, success ? 'completed' : 'failed'); + // Emit completion emitter.emit('complete', { code, signal, status }); @@ -239,6 +256,11 @@ export class AgentService { success: false, }); + // Record error step and complete trace with error status + this.traceService.startStep(attemptId, 'error', { error: error.message }); + this.traceService.endStep(attemptId, 'error'); + await this.traceService.completeTrace(attemptId, 'error'); + emitter.emit('error', error); await this.appendToLog(logPath, 'system', `\n---\nAgent error: ${error.message}`); runningAgents.delete(taskId); diff --git a/server/src/services/trace-service.ts b/server/src/services/trace-service.ts new file mode 100644 index 00000000..b9dfcea5 --- /dev/null +++ b/server/src/services/trace-service.ts @@ -0,0 +1,252 @@ +import fs from 'fs/promises'; +import path from 'path'; +import type { AgentType } from '@veritas-kanban/shared'; +import { getTelemetryService } from './telemetry-service.js'; + +const PROJECT_ROOT = path.resolve(process.cwd(), '..'); +const TRACES_DIR = path.join(PROJECT_ROOT, '.veritas-kanban', 'traces'); + +export type TraceStepType = 'init' | 'execute' | 'complete' | 'error'; + +export interface TraceStep { + type: TraceStepType; + startedAt: string; + endedAt?: string; + durationMs?: number; + metadata?: Record; +} + +export interface Trace { + traceId: string; // Same as attemptId + taskId: string; + agent: AgentType; + project?: string; + startedAt: string; + endedAt?: string; + totalDurationMs?: number; + steps: TraceStep[]; + status: 'running' | 'completed' | 'failed' | 'error'; +} + +// In-memory store for active traces +const activeTraces = new Map(); + +export class TraceService { + private tracesDir: string; + private enabled: boolean = false; + + constructor() { + this.tracesDir = TRACES_DIR; + this.init(); + } + + private async init(): Promise { + // Check if traces are enabled in telemetry config + const telemetry = getTelemetryService(); + const config = telemetry.getConfig(); + this.enabled = config.traces ?? false; + + if (this.enabled) { + await fs.mkdir(this.tracesDir, { recursive: true }); + } + } + + /** + * Check if tracing is enabled + */ + isEnabled(): boolean { + return this.enabled; + } + + /** + * Enable or disable tracing + */ + setEnabled(enabled: boolean): void { + this.enabled = enabled; + if (enabled) { + fs.mkdir(this.tracesDir, { recursive: true }).catch(() => {}); + } + } + + /** + * Start a new trace for an agent run + */ + startTrace( + attemptId: string, + taskId: string, + agent: AgentType, + project?: string + ): Trace | null { + if (!this.enabled) return null; + + const trace: Trace = { + traceId: attemptId, + taskId, + agent, + project, + startedAt: new Date().toISOString(), + steps: [], + status: 'running', + }; + + activeTraces.set(attemptId, trace); + return trace; + } + + /** + * Start a step within a trace + */ + startStep( + attemptId: string, + stepType: TraceStepType, + metadata?: Record + ): TraceStep | null { + if (!this.enabled) return null; + + const trace = activeTraces.get(attemptId); + if (!trace) return null; + + const step: TraceStep = { + type: stepType, + startedAt: new Date().toISOString(), + metadata, + }; + + trace.steps.push(step); + return step; + } + + /** + * End the current step of a given type + */ + endStep(attemptId: string, stepType: TraceStepType): void { + if (!this.enabled) return; + + const trace = activeTraces.get(attemptId); + if (!trace) return; + + // Find the last step of this type that hasn't ended + const step = [...trace.steps] + .reverse() + .find(s => s.type === stepType && !s.endedAt); + + if (step) { + step.endedAt = new Date().toISOString(); + step.durationMs = new Date(step.endedAt).getTime() - new Date(step.startedAt).getTime(); + } + } + + /** + * Complete a trace (success or failure) + */ + async completeTrace( + attemptId: string, + status: 'completed' | 'failed' | 'error' + ): Promise { + if (!this.enabled) return null; + + const trace = activeTraces.get(attemptId); + if (!trace) return null; + + trace.endedAt = new Date().toISOString(); + trace.totalDurationMs = new Date(trace.endedAt).getTime() - new Date(trace.startedAt).getTime(); + trace.status = status; + + // Close any open steps + for (const step of trace.steps) { + if (!step.endedAt) { + step.endedAt = trace.endedAt; + step.durationMs = new Date(step.endedAt).getTime() - new Date(step.startedAt).getTime(); + } + } + + // Save to disk + await this.saveTrace(trace); + + // Remove from active traces + activeTraces.delete(attemptId); + + return trace; + } + + /** + * Get an active trace + */ + getActiveTrace(attemptId: string): Trace | null { + return activeTraces.get(attemptId) || null; + } + + /** + * Get a completed trace from disk + */ + async getTrace(attemptId: string): Promise { + // Check active traces first + const active = activeTraces.get(attemptId); + if (active) return active; + + // Try to load from disk + try { + const filepath = path.join(this.tracesDir, `${attemptId}.json`); + const content = await fs.readFile(filepath, 'utf-8'); + return JSON.parse(content) as Trace; + } catch { + return null; + } + } + + /** + * List all traces for a task + */ + async listTraces(taskId: string): Promise { + const traces: Trace[] = []; + + // Add active traces for this task + for (const trace of activeTraces.values()) { + if (trace.taskId === taskId) { + traces.push(trace); + } + } + + // Load completed traces from disk + try { + const files = await fs.readdir(this.tracesDir); + for (const file of files) { + if (!file.endsWith('.json')) continue; + + try { + const content = await fs.readFile(path.join(this.tracesDir, file), 'utf-8'); + const trace = JSON.parse(content) as Trace; + if (trace.taskId === taskId && !activeTraces.has(trace.traceId)) { + traces.push(trace); + } + } catch { + // Skip invalid files + } + } + } catch { + // Directory might not exist + } + + // Sort by startedAt descending + return traces.sort((a, b) => b.startedAt.localeCompare(a.startedAt)); + } + + /** + * Save a trace to disk + */ + private async saveTrace(trace: Trace): Promise { + await fs.mkdir(this.tracesDir, { recursive: true }); + const filepath = path.join(this.tracesDir, `${trace.traceId}.json`); + await fs.writeFile(filepath, JSON.stringify(trace, null, 2), 'utf-8'); + } +} + +// Singleton instance +let instance: TraceService | null = null; + +export function getTraceService(): TraceService { + if (!instance) { + instance = new TraceService(); + } + return instance; +}