mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-08 20:57:51 +00:00
feat(traces): add optional coarse traces for agent runs (US-808)
TraceService: - 4 coarse step types: init, execute, complete, error - Trace ID = Attempt ID for natural correlation - Traces stored as JSON files in .veritas-kanban/traces/ - Off by default (enable via POST /api/traces/enable) API Endpoints: - GET /api/traces/status — Check if tracing is enabled - POST /api/traces/enable — Enable tracing - POST /api/traces/disable — Disable tracing - GET /api/traces/:attemptId — Get a specific trace - GET /api/traces/task/:taskId — List all traces for a task AgentService Integration: - Automatically records trace steps when tracing is enabled - init: worktree setup, prompt building - execute: agent process running - complete: post-run processing - error: captures error details
This commit is contained in:
parent
b7c5b64649
commit
e6faa846bf
4 changed files with 358 additions and 0 deletions
|
|
@ -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) => {
|
||||
|
|
|
|||
82
server/src/routes/traces.ts
Normal file
82
server/src/routes/traces.ts
Normal file
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
252
server/src/services/trace-service.ts
Normal file
252
server/src/services/trace-service.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, Trace>();
|
||||
|
||||
export class TraceService {
|
||||
private tracesDir: string;
|
||||
private enabled: boolean = false;
|
||||
|
||||
constructor() {
|
||||
this.tracesDir = TRACES_DIR;
|
||||
this.init();
|
||||
}
|
||||
|
||||
private async init(): Promise<void> {
|
||||
// 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<string, unknown>
|
||||
): 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<Trace | null> {
|
||||
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<Trace | null> {
|
||||
// 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<Trace[]> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue