feat: add live H1-H7 trace explorer
This commit is contained in:
@@ -42,6 +42,7 @@ export const PATHS = {
|
||||
incidents: env('CASAN_CP_INCIDENTS', '.specify/logs/level5/incidents.jsonl'),
|
||||
alerts: env('CASAN_DASHBOARD_ALERTS', '.specify/agentops/alerts.log'),
|
||||
traceDir: env('CASAN_CP_TRACE_DIR', '.specify/logs/trace'),
|
||||
traceEventDir: env('CASAN_CP_TRACE_EVENT_DIR', '.specify/logs/trace-events'),
|
||||
traceability: env('CASAN_CP_TRACEABILITY', 'docs/output/casan/traceability-matrix.json'),
|
||||
drift: env('CASAN_CP_DRIFT', 'docs/output/casan/level5-evidence/09-drift-report.json'),
|
||||
businessKpi: env('CASAN_CP_KPI', 'docs/output/casan/level5-evidence/14-business-kpi-report.json'),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Read-only Ops Console API. Every handler returns the standard ok() envelope. No writes,
|
||||
// no auth (loopback-bound, "Đọc ≠ Ghi"); management/RBAC is Plan-13 Track 2/3 (future).
|
||||
import { Controller, Get, Inject, Param, Query } from '@nestjs/common';
|
||||
import { Controller, Get, Inject, Param, Query, Req, Res } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { ok } from '../common/api-response.js';
|
||||
import { TelemetryService } from './telemetry.service.js';
|
||||
|
||||
@@ -27,6 +28,38 @@ export class TelemetryController {
|
||||
return ok(this.svc.run(traceId));
|
||||
}
|
||||
|
||||
@Get('runs/:traceId/graph')
|
||||
traceGraph(@Param('traceId') traceId: string) {
|
||||
return ok(this.svc.traceGraph(traceId));
|
||||
}
|
||||
|
||||
@Get('runs/:traceId/events')
|
||||
traceEvents(@Param('traceId') traceId: string, @Req() req: Request, @Res() res: Response) {
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.flushHeaders();
|
||||
|
||||
let previous = '';
|
||||
const publish = () => {
|
||||
const graph = this.svc.traceGraph(traceId);
|
||||
const serialized = JSON.stringify(graph);
|
||||
if (serialized !== previous) {
|
||||
res.write(`event: trace\ndata: ${serialized}\n\n`);
|
||||
previous = serialized;
|
||||
} else {
|
||||
res.write(': heartbeat\n\n');
|
||||
}
|
||||
};
|
||||
const timer = setInterval(publish, 750);
|
||||
const close = () => {
|
||||
clearInterval(timer);
|
||||
if (!res.writableEnded) res.end();
|
||||
};
|
||||
req.on('close', close);
|
||||
publish();
|
||||
}
|
||||
|
||||
@Get('governance')
|
||||
governance() {
|
||||
return ok(this.svc.governance());
|
||||
|
||||
@@ -9,6 +9,36 @@ import { APP_ROOT, PATHS, isStale, metricsAgeSeconds, STALE_AFTER_S } from '../c
|
||||
import { readJsonl, readJson, readHead, readTrace } from './telemetry.reader.js';
|
||||
|
||||
type Row = Record<string, any>;
|
||||
export type HarnessGateStatus = 'queued' | 'running' | 'pass' | 'warning' | 'blocked' | 'error' | 'skipped';
|
||||
export interface HarnessTraceEvent {
|
||||
timestamp: string;
|
||||
trace_id: string;
|
||||
gate_id: string;
|
||||
status: HarnessGateStatus;
|
||||
reason: string;
|
||||
evidence: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface HarnessGateNode {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: HarnessGateStatus;
|
||||
reason: string;
|
||||
updated_at: string | null;
|
||||
evidence: Record<string, unknown>;
|
||||
events: HarnessTraceEvent[];
|
||||
}
|
||||
|
||||
const HARNESS_GATES = [
|
||||
{ id: 'H1-context', title: 'H1 · Context', description: 'Prompt contract, mode and risk classification' },
|
||||
{ id: 'H2-tool', title: 'H2 · Tool', description: 'Allowlisted source and tool preparation' },
|
||||
{ id: 'H3-eval', title: 'H3 · Eval', description: 'Grounded synthesis and quality evaluation' },
|
||||
{ id: 'H4-security', title: 'H4 · Security', description: 'Input and output security boundary' },
|
||||
{ id: 'H5-governance', title: 'H5 · Governance', description: 'Decision policy and append-only audit' },
|
||||
{ id: 'H6-agentops', title: 'H6 · AgentOps', description: 'Runtime, token, cost and failure telemetry' },
|
||||
{ id: 'H7-orchestration', title: 'H7 · Orchestration', description: 'Final governed outcome and certification' },
|
||||
] as const;
|
||||
type ChatReplayResult = {
|
||||
ok: boolean;
|
||||
decision: string;
|
||||
@@ -27,6 +57,22 @@ const recent = (rows: Row[], n: number) => rows.slice(-n).reverse();
|
||||
const arr = (v: any): any[] => (Array.isArray(v) ? v : []);
|
||||
const pct = (part: number, total: number) => (total > 0 ? Math.round((part / total) * 1000) / 10 : 0);
|
||||
|
||||
function gateStatus(value: unknown): HarnessGateStatus {
|
||||
const status = String(value ?? '').toLowerCase();
|
||||
if (['success', 'pass', 'passed', 'allow', 'allowed', 'answered'].includes(status)) return 'pass';
|
||||
if (['warn', 'warning'].includes(status)) return 'warning';
|
||||
if (['block', 'blocked', 'deny', 'denied'].includes(status)) return 'blocked';
|
||||
if (['fail', 'failed', 'error'].includes(status)) return 'error';
|
||||
if (status === 'running') return 'running';
|
||||
if (status === 'skipped') return 'skipped';
|
||||
return 'queued';
|
||||
}
|
||||
|
||||
function safeTraceId(traceId: string): string {
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(traceId)) return '';
|
||||
return traceId;
|
||||
}
|
||||
|
||||
function artifact(path: string, source: string, verifiedWhenPresent = true) {
|
||||
const present = existsSync(path);
|
||||
const runAt = present ? statSync(path).mtime.toISOString() : null;
|
||||
@@ -227,6 +273,82 @@ export class TelemetryService {
|
||||
return trace ? { found: true, trace } : { found: false, trace: null };
|
||||
}
|
||||
|
||||
traceGraph(traceId: string) {
|
||||
const safeId = safeTraceId(traceId);
|
||||
const events = safeId
|
||||
? readJsonl<HarnessTraceEvent>(join(PATHS.traceEventDir, `${safeId}.jsonl`))
|
||||
.filter((event) => event.trace_id === safeId && HARNESS_GATES.some((gate) => gate.id === event.gate_id))
|
||||
.map((event) => ({
|
||||
timestamp: String(event.timestamp ?? ''),
|
||||
trace_id: safeId,
|
||||
gate_id: String(event.gate_id ?? ''),
|
||||
status: gateStatus(event.status),
|
||||
reason: String(event.reason ?? ''),
|
||||
evidence: event.evidence && typeof event.evidence === 'object' ? event.evidence : {},
|
||||
}))
|
||||
: [];
|
||||
|
||||
if (safeId && events.length === 0) {
|
||||
const legacyTrace = readTrace(PATHS.traceDir, safeId);
|
||||
if (legacyTrace) {
|
||||
const gateId = String(legacyTrace.harness ?? '');
|
||||
if (HARNESS_GATES.some((gate) => gate.id === gateId)) {
|
||||
events.push({
|
||||
timestamp: String(legacyTrace.timestamp ?? ''),
|
||||
trace_id: safeId,
|
||||
gate_id: gateId,
|
||||
status: gateStatus(legacyTrace.status ?? legacyTrace.action),
|
||||
reason: `Legacy ${gateId} trace`,
|
||||
evidence: {
|
||||
mode: legacyTrace.mode ?? null,
|
||||
action: legacyTrace.action ?? null,
|
||||
risk_level: legacyTrace.risk_level ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const metric of readJsonl<Row>(PATHS.metrics).filter((row) => row.trace_id === safeId)) {
|
||||
events.push({
|
||||
timestamp: String(metric.timestamp ?? ''),
|
||||
trace_id: safeId,
|
||||
gate_id: 'H6-agentops',
|
||||
status: gateStatus(metric.status),
|
||||
reason: String(metric.step ?? 'Legacy runtime metric'),
|
||||
evidence: {
|
||||
latency_ms: metric.latency_ms ?? null,
|
||||
total_tokens: metric.total_tokens ?? null,
|
||||
cost_estimate: metric.cost_estimate ?? null,
|
||||
synthesis_mode: metric.synthesis_mode ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const nodes: HarnessGateNode[] = HARNESS_GATES.map((gate) => {
|
||||
const gateEvents = events.filter((event) => event.gate_id === gate.id);
|
||||
const latest = gateEvents[gateEvents.length - 1];
|
||||
return {
|
||||
...gate,
|
||||
status: latest?.status ?? 'queued',
|
||||
reason: latest?.reason ?? 'Waiting for evidence',
|
||||
updated_at: latest?.timestamp || null,
|
||||
evidence: latest?.evidence ?? {},
|
||||
events: gateEvents,
|
||||
};
|
||||
});
|
||||
const lastEvent = events[events.length - 1];
|
||||
const outcome = nodes.find((node) => node.id === 'H7-orchestration');
|
||||
return {
|
||||
found: events.length > 0,
|
||||
trace_id: safeId || traceId,
|
||||
updated_at: lastEvent?.timestamp || null,
|
||||
terminal: outcome?.status === 'pass' || outcome?.status === 'blocked' || outcome?.status === 'error',
|
||||
progress: nodes.filter((node) => node.status !== 'queued').length,
|
||||
nodes,
|
||||
events,
|
||||
};
|
||||
}
|
||||
|
||||
governance() {
|
||||
const audit = readJsonl(PATHS.audit);
|
||||
const byDecision: Record<string, number> = {};
|
||||
|
||||
Reference in New Issue
Block a user