From 63e961f8d64ebe690d50dfdc728e411718f70100 Mon Sep 17 00:00:00 2001 From: Brad Groux Date: Wed, 28 Jan 2026 17:57:45 -0600 Subject: [PATCH] refactor: split god files into focused modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - metrics-service.ts (1727 lines) → 9 files in server/src/services/metrics/ - types.ts (270), helpers.ts (96), telemetry-reader.ts (99) - task-metrics.ts (232), run-metrics.ts (236), token-metrics.ts (225) - dashboard-metrics.ts (683), metrics-service.ts facade (108), index.ts (32) - api.ts (1038 lines) → 9 files in web/src/lib/api/ - helpers.ts (17), tasks.ts (166), config.ts (100), agent.ts (181) - diff.ts (181), entities.ts (245), time.ts (114) - managed-list.ts (64), index.ts (77) - All imports updated, barrel exports maintain backwards compatibility - TypeScript compiles cleanly for both server and web - All 740 tests pass (42 test files) --- .../telemetry/events-2026-01-28.ndjson | 63 + server/.veritas-kanban/activity.json | 22 + server/src/routes/metrics.ts | 13 +- server/src/services/digest-service.ts | 74 +- server/src/services/metrics-service.ts | 1727 ----------------- .../src/services/metrics/dashboard-metrics.ts | 683 +++++++ server/src/services/metrics/helpers.ts | 100 + server/src/services/metrics/index.ts | 32 + .../src/services/metrics/metrics-service.ts | 114 ++ server/src/services/metrics/run-metrics.ts | 233 +++ server/src/services/metrics/task-metrics.ts | 232 +++ .../src/services/metrics/telemetry-reader.ts | 99 + server/src/services/metrics/token-metrics.ts | 218 +++ server/src/services/metrics/types.ts | 270 +++ web/src/lib/api.ts | 1038 ---------- web/src/lib/api/agent.ts | 181 ++ web/src/lib/api/config.ts | 100 + web/src/lib/api/diff.ts | 191 ++ web/src/lib/api/entities.ts | 249 +++ web/src/lib/api/helpers.ts | 17 + web/src/lib/api/index.ts | 72 + web/src/lib/api/managed-list.ts | 66 + web/src/lib/api/tasks.ts | 177 ++ web/src/lib/api/time.ts | 114 ++ 24 files changed, 3281 insertions(+), 2804 deletions(-) delete mode 100644 server/src/services/metrics-service.ts create mode 100644 server/src/services/metrics/dashboard-metrics.ts create mode 100644 server/src/services/metrics/helpers.ts create mode 100644 server/src/services/metrics/index.ts create mode 100644 server/src/services/metrics/metrics-service.ts create mode 100644 server/src/services/metrics/run-metrics.ts create mode 100644 server/src/services/metrics/task-metrics.ts create mode 100644 server/src/services/metrics/telemetry-reader.ts create mode 100644 server/src/services/metrics/token-metrics.ts create mode 100644 server/src/services/metrics/types.ts delete mode 100644 web/src/lib/api.ts create mode 100644 web/src/lib/api/agent.ts create mode 100644 web/src/lib/api/config.ts create mode 100644 web/src/lib/api/diff.ts create mode 100644 web/src/lib/api/entities.ts create mode 100644 web/src/lib/api/helpers.ts create mode 100644 web/src/lib/api/index.ts create mode 100644 web/src/lib/api/managed-list.ts create mode 100644 web/src/lib/api/tasks.ts create mode 100644 web/src/lib/api/time.ts diff --git a/.veritas-kanban/telemetry/events-2026-01-28.ndjson b/.veritas-kanban/telemetry/events-2026-01-28.ndjson index 55b10ef6..98062a52 100644 --- a/.veritas-kanban/telemetry/events-2026-01-28.ndjson +++ b/.veritas-kanban/telemetry/events-2026-01-28.ndjson @@ -4001,3 +4001,66 @@ {"type":"task.status_changed","taskId":"task_20260128_iAxrNa","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_6MsjO8OnOMIe","timestamp":"2026-01-28T23:45:12.578Z"} {"type":"task.status_changed","taskId":"task_20260128_N5mwaB","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_NLgLJNS8ATPz","timestamp":"2026-01-28T23:47:28.826Z"} {"type":"task.status_changed","taskId":"task_20260128_c_jMj5","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_6kZeJYz0mNX8","timestamp":"2026-01-28T23:47:56.345Z"} +{"type":"task.status_changed","taskId":"task_20260128_iAxrNa","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_pJ4wiFYJdR_g","timestamp":"2026-01-28T23:51:56.491Z"} +{"type":"task.status_changed","taskId":"task_20260126_legacy1","status":"blocked","previousStatus":"review","id":"evt_uv5fEA15WH7q","timestamp":"2026-01-28T23:57:32.708Z"} +{"type":"task.created","taskId":"task_20260128_rf0JeA","status":"todo","id":"evt_1enJQKh_N0Mf","timestamp":"2026-01-28T23:57:32.740Z"} +{"type":"task.created","taskId":"task_20260128_ydt7Gv","status":"todo","id":"evt_Tl8SRUXbvpMu","timestamp":"2026-01-28T23:57:32.740Z"} +{"type":"task.created","taskId":"task_20260128_Weeg53","status":"todo","id":"evt_r7eTsmEAIWLW","timestamp":"2026-01-28T23:57:32.741Z"} +{"type":"task.created","taskId":"task_20260128_72YuBx","status":"todo","id":"evt_XpliaLAXYNVj","timestamp":"2026-01-28T23:57:32.745Z"} +{"type":"task.created","taskId":"task_20260128_vQA4vR","status":"todo","id":"evt_Ift8dRjSiYID","timestamp":"2026-01-28T23:57:32.751Z"} +{"type":"task.created","taskId":"task_20260128_Zk8UfG","status":"todo","id":"evt_BWw7_AjcMjVj","timestamp":"2026-01-28T23:57:32.753Z"} +{"type":"task.status_changed","taskId":"task_20260128_Zk8UfG","status":"in-progress","previousStatus":"todo","id":"evt_RkQPG64d3J3W","timestamp":"2026-01-28T23:57:32.758Z"} +{"type":"task.created","taskId":"task_20260128_OYrOTh","status":"todo","id":"evt_dAff7BaxMvPe","timestamp":"2026-01-28T23:57:32.761Z"} +{"type":"task.status_changed","taskId":"task_20260128_OYrOTh","status":"blocked","previousStatus":"todo","id":"evt_5Z6_tvoXNyhF","timestamp":"2026-01-28T23:57:32.764Z"} +{"type":"task.created","taskId":"task_20260128_JWTx6k","status":"todo","id":"evt_XP-_kAZlfPN6","timestamp":"2026-01-28T23:57:32.766Z"} +{"type":"task.status_changed","taskId":"task_20260128_JWTx6k","status":"done","previousStatus":"todo","id":"evt_kUTBi-Xg-ZCI","timestamp":"2026-01-28T23:57:32.768Z"} +{"type":"task.created","taskId":"task_20260128_K7pf9a","status":"todo","id":"evt_G-59bKnb9as3","timestamp":"2026-01-28T23:57:32.777Z"} +{"type":"task.created","taskId":"task_20260128_MFgnS5","project":"my-project","status":"todo","id":"evt_r-xzqg6KIr5t","timestamp":"2026-01-28T23:57:32.781Z"} +{"type":"task.created","taskId":"task_20260128_9glrB7","status":"todo","id":"evt_kwYqqORMgtMC","timestamp":"2026-01-28T23:57:32.786Z"} +{"type":"task.created","taskId":"task_20260128_1crnKQ","status":"todo","id":"evt_qfcl7-Y4lZ76","timestamp":"2026-01-28T23:57:32.787Z"} +{"type":"task.status_changed","taskId":"task_20260126_idem1","status":"blocked","previousStatus":"review","id":"evt_L0cmFzxhHgij","timestamp":"2026-01-28T23:57:32.788Z"} +{"type":"task.created","taskId":"task_20260128_r_JT5e","status":"todo","id":"evt_RHzItnxSMHLS","timestamp":"2026-01-28T23:57:32.790Z"} +{"type":"task.created","taskId":"task_20260128_iALED3","project":"test-project","status":"todo","id":"evt_wknQd5inHPgI","timestamp":"2026-01-28T23:57:32.792Z"} +{"type":"task.created","taskId":"task_20260128_6ZkyVl","status":"todo","id":"evt_UuNPiZYbaB3V","timestamp":"2026-01-28T23:57:32.795Z"} +{"type":"task.created","taskId":"task_20260128_Kp6gsg","status":"todo","id":"evt_LQUsgzmgA4KP","timestamp":"2026-01-28T23:57:32.808Z"} +{"type":"task.status_changed","taskId":"task_20260126_multi2","status":"blocked","previousStatus":"review","id":"evt_Nl5KC28wDtd6","timestamp":"2026-01-28T23:57:32.811Z"} +{"type":"task.created","taskId":"task_20260128_PnHGpd","status":"todo","id":"evt_bUH2z4RV6o0B","timestamp":"2026-01-28T23:57:32.812Z"} +{"type":"task.status_changed","taskId":"task_20260126_multi1","status":"blocked","previousStatus":"review","id":"evt_v7_HcOdTkpIK","timestamp":"2026-01-28T23:57:32.812Z"} +{"type":"task.created","taskId":"task_20260128_weS-aR","status":"todo","id":"evt_qoH4r8VcBvTW","timestamp":"2026-01-28T23:57:32.813Z"} +{"type":"task.created","taskId":"task_20260128_ryURvJ","status":"todo","id":"evt_ka3mzo5O6uNB","timestamp":"2026-01-28T23:57:32.817Z"} +{"type":"task.created","taskId":"task_20260128_mbRMTb","status":"todo","id":"evt_JwLV94VB_pOO","timestamp":"2026-01-28T23:57:32.820Z"} +{"type":"task.created","taskId":"task_20260128_oYot_N","status":"todo","id":"evt_vjeGQOmP1mPo","timestamp":"2026-01-28T23:57:32.823Z"} +{"type":"task.created","taskId":"task_20260128_WJ4Gel","status":"todo","id":"evt_fzL9ElQA56Lb","timestamp":"2026-01-28T23:57:32.825Z"} +{"type":"task.created","taskId":"task_20260128_TKWx3b","status":"todo","id":"evt__EyXJaK9hG63","timestamp":"2026-01-28T23:57:32.828Z"} +{"type":"task.created","taskId":"task_20260128_SFpx62","status":"todo","id":"evt_7es3zYLHojMU","timestamp":"2026-01-28T23:57:32.830Z"} +{"type":"task.status_changed","taskId":"task_20260128_weS-aR","status":"in-progress","previousStatus":"todo","id":"evt_vvIfA0vC44NH","timestamp":"2026-01-28T23:57:32.830Z"} +{"type":"task.status_changed","taskId":"task_20260128_SFpx62","status":"in-progress","previousStatus":"todo","id":"evt_c0xw-nXK3ArC","timestamp":"2026-01-28T23:57:32.834Z"} +{"type":"task.created","taskId":"task_20260128_eVra9p","status":"todo","id":"evt_W0CeymFEJ44x","timestamp":"2026-01-28T23:57:32.837Z"} +{"type":"task.created","taskId":"task_20260128_8pPT5I","status":"todo","id":"evt_pEPlae_43Wgc","timestamp":"2026-01-28T23:57:32.839Z"} +{"type":"task.created","taskId":"task_20260128_MBX_DF","status":"todo","id":"evt_XNbC0e-mt-Hd","timestamp":"2026-01-28T23:57:32.843Z"} +{"type":"task.created","taskId":"task_20260128_WOtQNQ","status":"todo","id":"evt_Rfg3bPSPMys0","timestamp":"2026-01-28T23:57:32.853Z"} +{"type":"task.created","taskId":"task_20260128_5E5wqf","status":"todo","id":"evt_WBgKslZ4nUSJ","timestamp":"2026-01-28T23:57:32.855Z"} +{"type":"task.created","taskId":"task_20260128_Y3LkMA","status":"todo","id":"evt_zq5SbgRcLE9r","timestamp":"2026-01-28T23:57:32.856Z"} +{"type":"task.created","taskId":"task_20260128_kPP2_G","status":"todo","id":"evt__gW-e1F2XwQv","timestamp":"2026-01-28T23:57:32.860Z"} +{"type":"task.created","taskId":"task_20260128_k2y6Rt","status":"todo","id":"evt_Vm45Js3ZqBz1","timestamp":"2026-01-28T23:57:32.865Z"} +{"type":"task.created","taskId":"task_20260128_q3adcU","status":"todo","id":"evt_NYEkPV6KGgP-","timestamp":"2026-01-28T23:57:32.866Z"} +{"type":"task.created","taskId":"task_20260128_x5rhxs","status":"todo","id":"evt_uIkyDL9HDKiL","timestamp":"2026-01-28T23:57:32.871Z"} +{"type":"task.created","taskId":"task_20260128_i5b92U","status":"todo","id":"evt_T8cD8XTcz4xL","timestamp":"2026-01-28T23:57:32.872Z"} +{"type":"task.created","taskId":"task_20260128_AH4A0H","status":"todo","id":"evt_O6lLOtYtn2Ek","timestamp":"2026-01-28T23:57:32.876Z"} +{"type":"task.created","taskId":"task_20260128_Fi-7WK","status":"todo","id":"evt_9Bb3AMsCL4mx","timestamp":"2026-01-28T23:57:32.881Z"} +{"type":"task.created","taskId":"task_20260128_sK979E","status":"todo","id":"evt_V_N-9SRiURv3","timestamp":"2026-01-28T23:57:32.886Z"} +{"type":"task.created","taskId":"task_20260128_HcbvVG","status":"todo","id":"evt_TkIHGDyZ5-tw","timestamp":"2026-01-28T23:57:32.895Z"} +{"type":"task.created","taskId":"task_20260128_4KcWor","status":"todo","id":"evt_l3kOMbmCM_CK","timestamp":"2026-01-28T23:57:32.895Z"} +{"type":"task.created","taskId":"task_20260128_wpoCJr","status":"todo","id":"evt_q37XlFX_whz8","timestamp":"2026-01-28T23:57:32.907Z"} +{"type":"task.restored","taskId":"task_20260126_arch1","status":"done","id":"evt_q7I6EPh9nlVR","timestamp":"2026-01-28T23:57:32.922Z"} +{"type":"task.status_changed","taskId":"task_20260126_arch1","status":"blocked","previousStatus":"done","id":"evt_19aNE8R7Hr-p","timestamp":"2026-01-28T23:57:32.923Z"} +{"type":"task.archived","taskId":"task_20260126_arch1","status":"blocked","id":"evt_EZ7EYdY1JiKl","timestamp":"2026-01-28T23:57:32.924Z"} +{"type":"task.created","taskId":"task_20260128_AoYm_G","status":"todo","id":"evt_osUtF3to1fI1","timestamp":"2026-01-28T23:57:32.925Z"} +{"type":"task.archived","taskId":"task_20260128_AoYm_G","status":"todo","id":"evt_wy3IM7cJdmn6","timestamp":"2026-01-28T23:57:32.928Z"} +{"type":"task.created","taskId":"task_20260128_mRQXIG","status":"todo","id":"evt_lLdu00ul_coP","timestamp":"2026-01-28T23:57:32.931Z"} +{"type":"task.created","taskId":"task_20260128_gBgFsr","status":"todo","id":"evt_lBuDKeGdYmc0","timestamp":"2026-01-28T23:57:32.931Z"} +{"type":"task.created","taskId":"task_20260128_zyESbP","status":"todo","id":"evt_-91FR_u6_0IY","timestamp":"2026-01-28T23:57:32.932Z"} +{"type":"task.created","taskId":"task_20260128_v5RiOq","project":"project-a","status":"todo","id":"evt_MnQA354fT8Sp","timestamp":"2026-01-28T23:57:32.935Z"} +{"type":"task.created","taskId":"task_20260128_llSpZs","project":"project-a","status":"todo","id":"evt_VxD2oiuN-UZ0","timestamp":"2026-01-28T23:57:32.936Z"} +{"type":"task.created","taskId":"task_20260128_X3rDN3","project":"project-b","status":"todo","id":"evt_7YQh7mnQXr-o","timestamp":"2026-01-28T23:57:32.937Z"} +{"type":"task.created","taskId":"task_20260128_PCyBmd","status":"todo","id":"evt_BQU2iI8x47rG","timestamp":"2026-01-28T23:57:32.961Z"} diff --git a/server/.veritas-kanban/activity.json b/server/.veritas-kanban/activity.json index 273b3006..c21af55d 100644 --- a/server/.veritas-kanban/activity.json +++ b/server/.veritas-kanban/activity.json @@ -1,4 +1,26 @@ [ + { + "id": "activity_1769644316506_acjekqrgc", + "type": "comment_added", + "taskId": "task_20260128_iAxrNa", + "taskTitle": "QUALITY: Add API documentation (OpenAPI/Swagger)", + "details": { + "author": "Veritas", + "preview": "Added OpenAPI/Swagger documentation. Installed swa..." + }, + "timestamp": "2026-01-28T23:51:56.506Z" + }, + { + "id": "activity_1769644316492_21euvqh9m", + "type": "status_changed", + "taskId": "task_20260128_iAxrNa", + "taskTitle": "QUALITY: Add API documentation (OpenAPI/Swagger)", + "details": { + "from": "in-progress", + "status": "done" + }, + "timestamp": "2026-01-28T23:51:56.492Z" + }, { "id": "activity_1769644076345_hjg8obc5o", "type": "status_changed", diff --git a/server/src/routes/metrics.ts b/server/src/routes/metrics.ts index 90562dc2..c2d481fa 100644 --- a/server/src/routes/metrics.ts +++ b/server/src/routes/metrics.ts @@ -1,5 +1,5 @@ import { Router, type Router as RouterType } from 'express'; -import { getMetricsService } from '../services/metrics-service.js'; +import { getMetricsService } from '../services/metrics/index.js'; import { asyncHandler } from '../middleware/async-handler.js'; import { validate, type ValidatedRequest } from '../middleware/validate.js'; import { @@ -118,13 +118,13 @@ router.get( const metrics = getMetricsService(); const period = (req.query.period as '7d' | '30d') || '7d'; const project = req.query.project as string | undefined; - + // Validate period if (period !== '7d' && period !== '30d') { res.status(400).json({ error: 'Period must be 7d or 30d' }); return; } - + const result = await metrics.getTrends(period, project); res.json(result); }) @@ -140,7 +140,12 @@ router.get( asyncHandler(async (req: ValidatedRequest, res) => { const metrics = getMetricsService(); const { project, tokenBudget, costBudget, warningThreshold } = req.validated.query!; - const result = await metrics.getBudgetMetrics(tokenBudget, costBudget, warningThreshold, project); + const result = await metrics.getBudgetMetrics( + tokenBudget, + costBudget, + warningThreshold, + project + ); res.json(result); }) ); diff --git a/server/src/services/digest-service.ts b/server/src/services/digest-service.ts index 90a1301f..7bf778ac 100644 --- a/server/src/services/digest-service.ts +++ b/server/src/services/digest-service.ts @@ -1,4 +1,10 @@ -import { getMetricsService, type MetricsService, type TaskMetrics, type RunMetrics, type TokenMetrics } from './metrics-service.js'; +import { + getMetricsService, + type MetricsService, + type TaskMetrics, + type RunMetrics, + type TokenMetrics, +} from './metrics/index.js'; import { getTelemetryService, type TelemetryService } from './telemetry-service.js'; import { TaskService } from './task-service.js'; import type { Task, TaskTelemetryEvent } from '@veritas-kanban/shared'; @@ -9,7 +15,7 @@ export interface DailyDigest { end: string; }; hasActivity: boolean; - + // Task stats tasks: { completed: number; @@ -17,10 +23,10 @@ export interface DailyDigest { inProgress: number; blocked: number; total: number; - completedTitles: string[]; // Top accomplishments - blockedTitles: string[]; // Blocked items + completedTitles: string[]; // Top accomplishments + blockedTitles: string[]; // Blocked items }; - + // Agent run stats runs: { total: number; @@ -34,7 +40,7 @@ export interface DailyDigest { successRate: number; }>; }; - + // Token usage stats tokens: { total: number; @@ -45,7 +51,7 @@ export interface DailyDigest { total: number; }>; }; - + // Failures and issues issues: { failedRuns: Array<{ @@ -100,34 +106,32 @@ export class DigestService { ]); // Get task events from last 24h - const taskEvents = events.filter(e => - e.type === 'task.created' || - e.type === 'task.status_changed' + const taskEvents = events.filter( + (e) => e.type === 'task.created' || e.type === 'task.status_changed' ) as TaskTelemetryEvent[]; // Count task changes - const createdCount = taskEvents.filter(e => e.type === 'task.created').length; - const completedCount = taskEvents.filter(e => - e.type === 'task.status_changed' && e.status === 'done' + const createdCount = taskEvents.filter((e) => e.type === 'task.created').length; + const completedCount = taskEvents.filter( + (e) => e.type === 'task.status_changed' && e.status === 'done' ).length; // Get current task list for titles const allTasks = await this.taskService.listTasks(); - + // Get recently completed tasks (status is done and updated in last 24h) - const recentlyCompleted = allTasks.filter(t => - t.status === 'done' && - new Date(t.updated).toISOString() >= since + const recentlyCompleted = allTasks.filter( + (t) => t.status === 'done' && new Date(t.updated).toISOString() >= since ); - + // Get blocked tasks - const blockedTasks = allTasks.filter(t => t.status === 'blocked'); - + const blockedTasks = allTasks.filter((t) => t.status === 'blocked'); + // Get in-progress tasks - const inProgressTasks = allTasks.filter(t => t.status === 'in-progress'); + const inProgressTasks = allTasks.filter((t) => t.status === 'in-progress'); // Determine if there's any activity - const hasActivity = + const hasActivity = createdCount > 0 || completedCount > 0 || metricsData.runs.runs > 0 || @@ -145,8 +149,8 @@ export class DigestService { inProgress: inProgressTasks.length, blocked: blockedTasks.length, total: allTasks.length, - completedTitles: recentlyCompleted.slice(0, 5).map(t => t.title), - blockedTitles: blockedTasks.slice(0, 5).map(t => t.title), + completedTitles: recentlyCompleted.slice(0, 5).map((t) => t.title), + blockedTitles: blockedTasks.slice(0, 5).map((t) => t.title), }, runs: { total: metricsData.runs.runs, @@ -154,7 +158,7 @@ export class DigestService { failures: metricsData.runs.failures, errors: metricsData.runs.errors, successRate: metricsData.runs.successRate, - byAgent: metricsData.runs.byAgent.map(a => ({ + byAgent: metricsData.runs.byAgent.map((a) => ({ agent: a.agent, runs: a.runs, successRate: a.successRate, @@ -164,13 +168,13 @@ export class DigestService { total: metricsData.tokens.totalTokens, input: metricsData.tokens.inputTokens, output: metricsData.tokens.outputTokens, - byAgent: metricsData.tokens.byAgent.map(a => ({ + byAgent: metricsData.tokens.byAgent.map((a) => ({ agent: a.agent, total: a.totalTokens, })), }, issues: { - failedRuns: failedRuns.slice(0, 5).map(r => ({ + failedRuns: failedRuns.slice(0, 5).map((r) => ({ agent: r.agent, taskId: r.taskId, error: r.errorMessage, @@ -192,7 +196,7 @@ export class DigestService { } const lines: string[] = []; - + // Header const startDate = new Date(digest.period.start).toLocaleDateString('en-US', { weekday: 'long', @@ -215,7 +219,7 @@ export class DigestService { // Top Accomplishments if (digest.tasks.completedTitles.length > 0) { lines.push('### 🏆 Accomplishments'); - digest.tasks.completedTitles.forEach(title => { + digest.tasks.completedTitles.forEach((title) => { lines.push(`- ${title}`); }); lines.push(''); @@ -227,10 +231,10 @@ export class DigestService { const successPct = (digest.runs.successRate * 100).toFixed(0); lines.push(`- **Total:** ${digest.runs.total} runs`); lines.push(`- **Success Rate:** ${successPct}%`); - + if (digest.runs.byAgent.length > 0) { lines.push('- **By Agent:**'); - digest.runs.byAgent.forEach(a => { + digest.runs.byAgent.forEach((a) => { const pct = (a.successRate * 100).toFixed(0); lines.push(` - ${a.agent}: ${a.runs} runs (${pct}% success)`); }); @@ -246,10 +250,10 @@ export class DigestService { const outputFormatted = this.formatNumber(digest.tokens.output); lines.push(`- **Total:** ${totalFormatted} tokens`); lines.push(`- **Input:** ${inputFormatted} | **Output:** ${outputFormatted}`); - + if (digest.tokens.byAgent.length > 0) { lines.push('- **By Agent:**'); - digest.tokens.byAgent.forEach(a => { + digest.tokens.byAgent.forEach((a) => { const formatted = this.formatNumber(a.total); lines.push(` - ${a.agent}: ${formatted}`); }); @@ -260,7 +264,7 @@ export class DigestService { // Blocked Items if (digest.tasks.blockedTitles.length > 0) { lines.push('## 🚫 Blocked Items'); - digest.tasks.blockedTitles.forEach(title => { + digest.tasks.blockedTitles.forEach((title) => { lines.push(`- ${title}`); }); lines.push(''); @@ -269,7 +273,7 @@ export class DigestService { // Failed Runs if (digest.issues.failedRuns.length > 0) { lines.push('## ⚠️ Failed Runs'); - digest.issues.failedRuns.forEach(run => { + digest.issues.failedRuns.forEach((run) => { const time = new Date(run.timestamp).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', diff --git a/server/src/services/metrics-service.ts b/server/src/services/metrics-service.ts deleted file mode 100644 index 75223bfa..00000000 --- a/server/src/services/metrics-service.ts +++ /dev/null @@ -1,1727 +0,0 @@ -import { createReadStream } from 'fs'; -import fs from 'fs/promises'; -import path from 'path'; -import readline from 'readline'; -import { createGunzip } from 'zlib'; -import { getTelemetryService, type TelemetryService } from './telemetry-service.js'; -import { TaskService } from './task-service.js'; -import type { - TaskStatus, - BlockedCategory, - RunTelemetryEvent, - TokenTelemetryEvent, - AnyTelemetryEvent, - TelemetryEventType, -} from '@veritas-kanban/shared'; - -export type MetricsPeriod = '24h' | '7d' | '30d'; - -export interface TaskMetrics { - byStatus: Record; - byBlockedReason: Record; - total: number; - completed: number; // done + archived - archived: number; -} - -export interface AgentBreakdown { - agent: string; - runs: number; - successes: number; - failures: number; - errors: number; - successRate: number; - avgDurationMs: number; - totalTokens: number; -} - -export interface RunMetrics { - period: MetricsPeriod; - runs: number; - successes: number; - failures: number; - errors: number; - errorRate: number; // (failures + errors) / runs - successRate: number; // successes / runs - byAgent: AgentBreakdown[]; -} - -export interface TokenMetrics { - period: MetricsPeriod; - totalTokens: number; - inputTokens: number; - outputTokens: number; - cacheTokens: number; - runs: number; - perSuccessfulRun: { - avg: number; - p50: number; - p95: number; - }; - byAgent: Array<{ - agent: string; - totalTokens: number; - inputTokens: number; - outputTokens: number; - cacheTokens: number; - runs: number; - }>; -} - -export interface DurationMetrics { - period: MetricsPeriod; - runs: number; - avgMs: number; - p50Ms: number; - p95Ms: number; - byAgent: Array<{ - agent: string; - runs: number; - avgMs: number; - p50Ms: number; - p95Ms: number; - }>; -} - -// Trend direction: positive means improvement (more runs, higher success, etc.) -export type TrendDirection = 'up' | 'down' | 'flat'; - -export interface TrendComparison { - runsTrend: TrendDirection; - runsChange: number; // percentage change - successRateTrend: TrendDirection; - successRateChange: number; - tokensTrend: TrendDirection; - tokensChange: number; - durationTrend: TrendDirection; - durationChange: number; -} - -// Internal accumulator types for streaming -interface RunAccumulator { - successes: number; - failures: number; - errors: number; - durations: number[]; - byAgent: Map< - string, - { - successes: number; - failures: number; - errors: number; - durations: number[]; - } - >; -} - -interface TokenAccumulator { - totalTokens: number; - inputTokens: number; - outputTokens: number; - cacheTokens: number; - tokensPerRun: number[]; - byAgent: Map< - string, - { - totalTokens: number; - inputTokens: number; - outputTokens: number; - cacheTokens: number; - runs: number; - } - >; -} - -// Default paths - resolve to project root -const PROJECT_ROOT = path.resolve(process.cwd(), '..'); -const TELEMETRY_DIR = path.join(PROJECT_ROOT, '.veritas-kanban', 'telemetry'); - -export interface FailedRunDetails { - timestamp: string; - taskId?: string; - project?: string; - agent: string; - success: boolean; - errorMessage?: string; - durationMs?: number; -} - -export class MetricsService { - private telemetry: TelemetryService; - private taskService: TaskService; - private telemetryDir: string; - - constructor(telemetryDir?: string) { - this.telemetry = getTelemetryService(); - this.taskService = new TaskService(); - this.telemetryDir = telemetryDir || TELEMETRY_DIR; - } - - /** - * Get timestamp for start of period - */ - private getPeriodStart(period: MetricsPeriod): string { - const now = new Date(); - switch (period) { - case '24h': - now.setHours(now.getHours() - 24); - break; - case '7d': - now.setDate(now.getDate() - 7); - break; - case '30d': - now.setDate(now.getDate() - 30); - break; - } - return now.toISOString(); - } - - /** - * Get timestamp range for previous period (for trend comparison) - */ - private getPreviousPeriodRange(period: MetricsPeriod): { since: string; until: string } { - const now = new Date(); - const periodMs = - period === '24h' - ? 24 * 60 * 60 * 1000 - : period === '7d' - ? 7 * 24 * 60 * 60 * 1000 - : 30 * 24 * 60 * 60 * 1000; - - const currentPeriodStart = new Date(now.getTime() - periodMs); - const previousPeriodStart = new Date(currentPeriodStart.getTime() - periodMs); - - return { - since: previousPeriodStart.toISOString(), - until: currentPeriodStart.toISOString(), - }; - } - - /** - * Calculate trend direction based on change - */ - private calculateTrend(current: number, previous: number, higherIsBetter = true): TrendDirection { - if (previous === 0) return current > 0 ? 'up' : 'flat'; - const changePercent = ((current - previous) / previous) * 100; - if (Math.abs(changePercent) < 5) return 'flat'; // Less than 5% change is flat - const isUp = current > previous; - // For metrics where lower is better (like duration), invert the logic - return higherIsBetter ? (isUp ? 'up' : 'down') : isUp ? 'down' : 'up'; - } - - /** - * Calculate percentage change - */ - private calculateChange(current: number, previous: number): number { - if (previous === 0) return current > 0 ? 100 : 0; - return Math.round(((current - previous) / previous) * 100); - } - - /** - * Calculate percentile from sorted array - */ - private percentile(sorted: number[], p: number): number { - if (sorted.length === 0) return 0; - const index = Math.ceil((p / 100) * sorted.length) - 1; - return sorted[Math.max(0, index)]; - } - - /** - * Get list of event files within a date range (includes .ndjson and .ndjson.gz) - */ - private async getEventFiles(since: string): Promise { - try { - const files = await fs.readdir(this.telemetryDir); - const eventFiles = files.filter( - (f) => f.startsWith('events-') && (f.endsWith('.ndjson') || f.endsWith('.ndjson.gz')) - ); - const sinceDate = since.slice(0, 10); - - return eventFiles - .filter((filename) => { - const match = filename.match(/events-(\d{4}-\d{2}-\d{2})\.ndjson(\.gz)?$/); - if (!match) return false; - return match[1] >= sinceDate; - }) - .map((f) => path.join(this.telemetryDir, f)); - } catch (error: any) { - if (error.code === 'ENOENT') { - return []; - } - throw error; - } - } - - /** - * Create a readline interface for an event file (handles both .ndjson and .ndjson.gz) - */ - private createLineReader(filePath: string): readline.Interface { - if (filePath.endsWith('.gz')) { - const fileStream = createReadStream(filePath); - const gunzip = createGunzip(); - const decompressed = fileStream.pipe(gunzip); - return readline.createInterface({ - input: decompressed, - crlfDelay: Infinity, - }); - } - const fileStream = createReadStream(filePath, { encoding: 'utf-8' }); - return readline.createInterface({ - input: fileStream, - crlfDelay: Infinity, - }); - } - - /** - * Stream events from NDJSON files with filtering - * Performance-optimized: reads line by line, filters early, accumulates in memory-efficient way - */ - private async streamEvents( - files: string[], - types: TelemetryEventType[], - since: string, - project: string | undefined, - accumulator: T, - handler: (event: AnyTelemetryEvent, acc: T) => void - ): Promise { - for (const filePath of files) { - try { - const rl = this.createLineReader(filePath); - - for await (const line of rl) { - if (!line.trim()) continue; - - try { - const event = JSON.parse(line) as AnyTelemetryEvent; - - // Early filtering for performance - if (!types.includes(event.type)) continue; - if (event.timestamp < since) continue; - if (project && event.project !== project) continue; - - handler(event, accumulator); - } catch { - // Skip malformed lines - continue; - } - } - } catch (error: any) { - if (error.code !== 'ENOENT') { - console.error(`[Metrics] Error reading ${filePath}:`, error.message); - } - } - } - - return accumulator; - } - - /** - * Get task counts by status - */ - async getTaskMetrics(project?: string): Promise { - const [activeTasks, archivedTasks] = await Promise.all([ - this.taskService.listTasks(), - this.taskService.listArchivedTasks(), - ]); - - // Filter by project if specified - const filteredActive = project ? activeTasks.filter((t) => t.project === project) : activeTasks; - const filteredArchived = project - ? archivedTasks.filter((t) => t.project === project) - : archivedTasks; - - // Count by status - const byStatus: Record = { - todo: 0, - 'in-progress': 0, - blocked: 0, - done: 0, - }; - - // Count by blocked reason - const byBlockedReason: Record = { - 'waiting-on-feedback': 0, - 'technical-snag': 0, - prerequisite: 0, - other: 0, - unspecified: 0, - }; - - for (const task of filteredActive) { - byStatus[task.status]++; - - // Count blocked reasons for blocked tasks - if (task.status === 'blocked') { - if (task.blockedReason?.category) { - byBlockedReason[task.blockedReason.category]++; - } else { - byBlockedReason['unspecified']++; - } - } - } - - const archived = filteredArchived.length; - const total = filteredActive.length + archived; - const completed = byStatus['done'] + archived; - - return { - byStatus, - byBlockedReason, - total, - completed, - archived, - }; - } - - /** - * Get run metrics (error rate, success rate) with per-agent breakdown - */ - async getRunMetrics(period: MetricsPeriod, project?: string): Promise { - const since = this.getPeriodStart(period); - const files = await this.getEventFiles(since); - - const accumulator: RunAccumulator = { - successes: 0, - failures: 0, - errors: 0, - durations: [], - byAgent: new Map(), - }; - - await this.streamEvents( - files, - ['run.completed', 'run.error'], - since, - project, - accumulator, - (event, acc) => { - const agent = (event as RunTelemetryEvent).agent || 'veritas'; - - if (!acc.byAgent.has(agent)) { - acc.byAgent.set(agent, { successes: 0, failures: 0, errors: 0, durations: [] }); - } - const agentAcc = acc.byAgent.get(agent)!; - - if (event.type === 'run.error') { - acc.errors++; - agentAcc.errors++; - } else if (event.type === 'run.completed') { - const runEvent = event as RunTelemetryEvent; - if (runEvent.success) { - acc.successes++; - agentAcc.successes++; - } else { - acc.failures++; - agentAcc.failures++; - } - if (runEvent.durationMs && runEvent.durationMs > 0) { - acc.durations.push(runEvent.durationMs); - agentAcc.durations.push(runEvent.durationMs); - } - } - } - ); - - const runs = accumulator.successes + accumulator.failures + accumulator.errors; - const errorRate = runs > 0 ? (accumulator.failures + accumulator.errors) / runs : 0; - const successRate = runs > 0 ? accumulator.successes / runs : 0; - - // Build per-agent breakdown - const byAgent: AgentBreakdown[] = []; - for (const [agent, data] of accumulator.byAgent.entries()) { - const agentRuns = data.successes + data.failures + data.errors; - const avgDuration = - data.durations.length > 0 - ? Math.round(data.durations.reduce((a, b) => a + b, 0) / data.durations.length) - : 0; - - byAgent.push({ - agent, - runs: agentRuns, - successes: data.successes, - failures: data.failures, - errors: data.errors, - successRate: agentRuns > 0 ? data.successes / agentRuns : 0, - avgDurationMs: avgDuration, - totalTokens: 0, // Will be populated if needed - }); - } - - // Sort by runs descending - byAgent.sort((a, b) => b.runs - a.runs); - - return { - period, - runs, - successes: accumulator.successes, - failures: accumulator.failures, - errors: accumulator.errors, - errorRate, - successRate, - byAgent, - }; - } - - /** - * Get token metrics with per-agent breakdown - */ - async getTokenMetrics(period: MetricsPeriod, project?: string): Promise { - const since = this.getPeriodStart(period); - const files = await this.getEventFiles(since); - - const accumulator: TokenAccumulator = { - totalTokens: 0, - inputTokens: 0, - outputTokens: 0, - cacheTokens: 0, - tokensPerRun: [], - byAgent: new Map(), - }; - - await this.streamEvents(files, ['run.tokens'], since, project, accumulator, (event, acc) => { - const tokenEvent = event as TokenTelemetryEvent; - const agent = tokenEvent.agent || 'veritas'; - // Calculate totalTokens if not provided - const totalTokens = - tokenEvent.totalTokens ?? tokenEvent.inputTokens + tokenEvent.outputTokens; - const cacheTokens = tokenEvent.cacheTokens ?? 0; - - acc.totalTokens += totalTokens; - acc.inputTokens += tokenEvent.inputTokens; - acc.outputTokens += tokenEvent.outputTokens; - acc.cacheTokens += cacheTokens; - acc.tokensPerRun.push(totalTokens); - - if (!acc.byAgent.has(agent)) { - acc.byAgent.set(agent, { - totalTokens: 0, - inputTokens: 0, - outputTokens: 0, - cacheTokens: 0, - runs: 0, - }); - } - const agentAcc = acc.byAgent.get(agent)!; - agentAcc.totalTokens += totalTokens; - agentAcc.inputTokens += tokenEvent.inputTokens; - agentAcc.outputTokens += tokenEvent.outputTokens; - agentAcc.cacheTokens += cacheTokens; - agentAcc.runs++; - }); - - // Sort for percentile calculations - accumulator.tokensPerRun.sort((a, b) => a - b); - - const runs = accumulator.tokensPerRun.length; - const avg = runs > 0 ? accumulator.totalTokens / runs : 0; - const p50 = this.percentile(accumulator.tokensPerRun, 50); - const p95 = this.percentile(accumulator.tokensPerRun, 95); - - // Build per-agent breakdown - const byAgent: TokenMetrics['byAgent'] = []; - for (const [agent, data] of accumulator.byAgent.entries()) { - byAgent.push({ - agent, - totalTokens: data.totalTokens, - inputTokens: data.inputTokens, - outputTokens: data.outputTokens, - cacheTokens: data.cacheTokens, - runs: data.runs, - }); - } - - // Sort by totalTokens descending - byAgent.sort((a, b) => b.totalTokens - a.totalTokens); - - return { - period, - totalTokens: accumulator.totalTokens, - inputTokens: accumulator.inputTokens, - outputTokens: accumulator.outputTokens, - cacheTokens: accumulator.cacheTokens, - runs, - perSuccessfulRun: { - avg: Math.round(avg), - p50, - p95, - }, - byAgent, - }; - } - - /** - * Get duration metrics with per-agent breakdown - */ - async getDurationMetrics(period: MetricsPeriod, project?: string): Promise { - const since = this.getPeriodStart(period); - const files = await this.getEventFiles(since); - - const accumulator = { - durations: [] as number[], - byAgent: new Map(), - }; - - await this.streamEvents(files, ['run.completed'], since, project, accumulator, (event, acc) => { - const runEvent = event as RunTelemetryEvent; - if (runEvent.durationMs !== undefined && runEvent.durationMs > 0) { - const agent = runEvent.agent || 'veritas'; - - acc.durations.push(runEvent.durationMs); - - if (!acc.byAgent.has(agent)) { - acc.byAgent.set(agent, []); - } - acc.byAgent.get(agent)!.push(runEvent.durationMs); - } - }); - - // Sort for percentile calculations - accumulator.durations.sort((a, b) => a - b); - - const runs = accumulator.durations.length; - const sum = accumulator.durations.reduce((a, b) => a + b, 0); - const avgMs = runs > 0 ? Math.round(sum / runs) : 0; - const p50Ms = this.percentile(accumulator.durations, 50); - const p95Ms = this.percentile(accumulator.durations, 95); - - // Build per-agent breakdown - const byAgent: DurationMetrics['byAgent'] = []; - for (const [agent, durations] of accumulator.byAgent.entries()) { - durations.sort((a, b) => a - b); - const agentSum = durations.reduce((a, b) => a + b, 0); - - byAgent.push({ - agent, - runs: durations.length, - avgMs: durations.length > 0 ? Math.round(agentSum / durations.length) : 0, - p50Ms: this.percentile(durations, 50), - p95Ms: this.percentile(durations, 95), - }); - } - - // Sort by runs descending - byAgent.sort((a, b) => b.runs - a.runs); - - return { - period, - runs, - avgMs, - p50Ms, - p95Ms, - byAgent, - }; - } - - /** - * Get all metrics in one call (for dashboard) - * Optimized: streams files once and extracts all metrics in single pass - */ - async getAllMetrics( - period: MetricsPeriod = '24h', - project?: string - ): Promise<{ - tasks: TaskMetrics; - runs: RunMetrics; - tokens: TokenMetrics; - duration: DurationMetrics; - trends: TrendComparison; - }> { - const since = this.getPeriodStart(period); - const files = await this.getEventFiles(since); - - // Combined accumulator for single-pass processing - const runAcc: RunAccumulator = { - successes: 0, - failures: 0, - errors: 0, - durations: [], - byAgent: new Map(), - }; - - const tokenAcc: TokenAccumulator = { - totalTokens: 0, - inputTokens: 0, - outputTokens: 0, - cacheTokens: 0, - tokensPerRun: [], - byAgent: new Map(), - }; - - // Single pass through all files - for (const filePath of files) { - try { - const rl = this.createLineReader(filePath); - - for await (const line of rl) { - if (!line.trim()) continue; - - try { - const event = JSON.parse(line) as AnyTelemetryEvent; - - // Early timestamp filter - if (event.timestamp < since) continue; - if (project && event.project !== project) continue; - - const eventType = event.type; - - // Process run events - if (eventType === 'run.completed' || eventType === 'run.error') { - const runEvent = event as RunTelemetryEvent; - const agent = runEvent.agent || 'veritas'; - - if (!runAcc.byAgent.has(agent)) { - runAcc.byAgent.set(agent, { successes: 0, failures: 0, errors: 0, durations: [] }); - } - const agentAcc = runAcc.byAgent.get(agent)!; - - if (eventType === 'run.error') { - runAcc.errors++; - agentAcc.errors++; - } else { - if (runEvent.success) { - runAcc.successes++; - agentAcc.successes++; - } else { - runAcc.failures++; - agentAcc.failures++; - } - if (runEvent.durationMs && runEvent.durationMs > 0) { - runAcc.durations.push(runEvent.durationMs); - agentAcc.durations.push(runEvent.durationMs); - } - } - } - - // Process token events - if (eventType === 'run.tokens') { - const tokenEvent = event as TokenTelemetryEvent; - const agent = tokenEvent.agent || 'veritas'; - // Calculate totalTokens if not provided - const totalTokens = - tokenEvent.totalTokens ?? tokenEvent.inputTokens + tokenEvent.outputTokens; - const cacheTokens = tokenEvent.cacheTokens ?? 0; - - tokenAcc.totalTokens += totalTokens; - tokenAcc.inputTokens += tokenEvent.inputTokens; - tokenAcc.outputTokens += tokenEvent.outputTokens; - tokenAcc.cacheTokens += cacheTokens; - tokenAcc.tokensPerRun.push(totalTokens); - - if (!tokenAcc.byAgent.has(agent)) { - tokenAcc.byAgent.set(agent, { - totalTokens: 0, - inputTokens: 0, - outputTokens: 0, - cacheTokens: 0, - runs: 0, - }); - } - const agentTokenAcc = tokenAcc.byAgent.get(agent)!; - agentTokenAcc.totalTokens += totalTokens; - agentTokenAcc.inputTokens += tokenEvent.inputTokens; - agentTokenAcc.outputTokens += tokenEvent.outputTokens; - agentTokenAcc.cacheTokens += cacheTokens; - agentTokenAcc.runs++; - } - } catch { - // Skip malformed lines - continue; - } - } - } catch (error: any) { - if (error.code !== 'ENOENT') { - console.error(`[Metrics] Error reading ${filePath}:`, error.message); - } - } - } - - // Get task metrics (separate query, always fast) - const tasks = await this.getTaskMetrics(project); - - // Build run metrics - const totalRuns = runAcc.successes + runAcc.failures + runAcc.errors; - const runByAgent: AgentBreakdown[] = []; - for (const [agent, data] of runAcc.byAgent.entries()) { - const agentRuns = data.successes + data.failures + data.errors; - const avgDuration = - data.durations.length > 0 - ? Math.round(data.durations.reduce((a, b) => a + b, 0) / data.durations.length) - : 0; - - runByAgent.push({ - agent, - runs: agentRuns, - successes: data.successes, - failures: data.failures, - errors: data.errors, - successRate: agentRuns > 0 ? data.successes / agentRuns : 0, - avgDurationMs: avgDuration, - totalTokens: tokenAcc.byAgent.get(agent)?.totalTokens || 0, - }); - } - runByAgent.sort((a, b) => b.runs - a.runs); - - const runs: RunMetrics = { - period, - runs: totalRuns, - successes: runAcc.successes, - failures: runAcc.failures, - errors: runAcc.errors, - errorRate: totalRuns > 0 ? (runAcc.failures + runAcc.errors) / totalRuns : 0, - successRate: totalRuns > 0 ? runAcc.successes / totalRuns : 0, - byAgent: runByAgent, - }; - - // Build token metrics - tokenAcc.tokensPerRun.sort((a, b) => a - b); - const tokenRuns = tokenAcc.tokensPerRun.length; - const tokenByAgent: TokenMetrics['byAgent'] = []; - for (const [agent, data] of tokenAcc.byAgent.entries()) { - tokenByAgent.push({ - agent, - totalTokens: data.totalTokens, - inputTokens: data.inputTokens, - outputTokens: data.outputTokens, - cacheTokens: data.cacheTokens, - runs: data.runs, - }); - } - tokenByAgent.sort((a, b) => b.totalTokens - a.totalTokens); - - const tokens: TokenMetrics = { - period, - totalTokens: tokenAcc.totalTokens, - inputTokens: tokenAcc.inputTokens, - outputTokens: tokenAcc.outputTokens, - cacheTokens: tokenAcc.cacheTokens, - runs: tokenRuns, - perSuccessfulRun: { - avg: tokenRuns > 0 ? Math.round(tokenAcc.totalTokens / tokenRuns) : 0, - p50: this.percentile(tokenAcc.tokensPerRun, 50), - p95: this.percentile(tokenAcc.tokensPerRun, 95), - }, - byAgent: tokenByAgent, - }; - - // Build duration metrics - runAcc.durations.sort((a, b) => a - b); - const durationByAgent: DurationMetrics['byAgent'] = []; - for (const [agent, data] of runAcc.byAgent.entries()) { - data.durations.sort((a, b) => a - b); - const agentSum = data.durations.reduce((a, b) => a + b, 0); - durationByAgent.push({ - agent, - runs: data.durations.length, - avgMs: data.durations.length > 0 ? Math.round(agentSum / data.durations.length) : 0, - p50Ms: this.percentile(data.durations, 50), - p95Ms: this.percentile(data.durations, 95), - }); - } - durationByAgent.sort((a, b) => b.runs - a.runs); - - const durationSum = runAcc.durations.reduce((a, b) => a + b, 0); - const duration: DurationMetrics = { - period, - runs: runAcc.durations.length, - avgMs: runAcc.durations.length > 0 ? Math.round(durationSum / runAcc.durations.length) : 0, - p50Ms: this.percentile(runAcc.durations, 50), - p95Ms: this.percentile(runAcc.durations, 95), - byAgent: durationByAgent, - }; - - // Calculate trends by comparing with previous period - const previousRange = this.getPreviousPeriodRange(period); - const previousFiles = await this.getEventFiles(previousRange.since); - - // Quick accumulator for previous period (runs, tokens, duration only) - let prevRuns = 0, - prevSuccesses = 0, - prevTokens = 0, - prevDurationSum = 0, - prevDurationCount = 0; - - for (const filePath of previousFiles) { - try { - const rl = this.createLineReader(filePath); - - for await (const line of rl) { - if (!line.trim()) continue; - try { - const event = JSON.parse(line) as AnyTelemetryEvent; - if (event.timestamp < previousRange.since || event.timestamp >= previousRange.until) - continue; - if (project && event.project !== project) continue; - - if (event.type === 'run.completed') { - const runEvent = event as RunTelemetryEvent; - prevRuns++; - if (runEvent.success) prevSuccesses++; - if (runEvent.durationMs && runEvent.durationMs > 0) { - prevDurationSum += runEvent.durationMs; - prevDurationCount++; - } - } else if (event.type === 'run.error') { - prevRuns++; - } else if (event.type === 'run.tokens') { - const tokenEvent = event as TokenTelemetryEvent; - prevTokens += - tokenEvent.totalTokens ?? tokenEvent.inputTokens + tokenEvent.outputTokens; - } - } catch { - // Intentionally silent: skip malformed NDJSON line - continue; - } - } - } catch (error: any) { - if (error.code !== 'ENOENT') { - console.error(`[Metrics] Error reading ${filePath}:`, error.message); - } - } - } - - const prevSuccessRate = prevRuns > 0 ? prevSuccesses / prevRuns : 0; - const prevAvgDuration = prevDurationCount > 0 ? prevDurationSum / prevDurationCount : 0; - - const trends: TrendComparison = { - runsTrend: this.calculateTrend(runs.runs, prevRuns, true), - runsChange: this.calculateChange(runs.runs, prevRuns), - successRateTrend: this.calculateTrend(runs.successRate, prevSuccessRate, true), - successRateChange: this.calculateChange(runs.successRate * 100, prevSuccessRate * 100), - tokensTrend: this.calculateTrend(tokens.totalTokens, prevTokens, false), // Lower is better - tokensChange: this.calculateChange(tokens.totalTokens, prevTokens), - durationTrend: this.calculateTrend(duration.avgMs, prevAvgDuration, false), // Lower is better - durationChange: this.calculateChange(duration.avgMs, prevAvgDuration), - }; - - return { tasks, runs, tokens, duration, trends }; - } - - /** - * Get historical trends data aggregated by day - */ - async getTrends(period: '7d' | '30d', project?: string): Promise { - const since = this.getPeriodStart(period); - const files = await this.getEventFiles(since); - - // Accumulator per day - const dailyData = new Map< - string, - { - runs: number; - successes: number; - failures: number; - errors: number; - totalTokens: number; - inputTokens: number; - outputTokens: number; - durations: number[]; - } - >(); - - // Initialize all days in the period - const startDate = new Date(since); - const endDate = new Date(); - for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) { - const dateStr = d.toISOString().slice(0, 10); - dailyData.set(dateStr, { - runs: 0, - successes: 0, - failures: 0, - errors: 0, - totalTokens: 0, - inputTokens: 0, - outputTokens: 0, - durations: [], - }); - } - - // Process all files - for (const filePath of files) { - try { - const rl = this.createLineReader(filePath); - - for await (const line of rl) { - if (!line.trim()) continue; - - try { - const event = JSON.parse(line) as AnyTelemetryEvent; - - // Early timestamp filter - if (event.timestamp < since) continue; - if (project && event.project !== project) continue; - - const dateStr = event.timestamp.slice(0, 10); - if (!dailyData.has(dateStr)) { - dailyData.set(dateStr, { - runs: 0, - successes: 0, - failures: 0, - errors: 0, - totalTokens: 0, - inputTokens: 0, - outputTokens: 0, - durations: [], - }); - } - const dayAcc = dailyData.get(dateStr)!; - - if (event.type === 'run.completed') { - const runEvent = event as RunTelemetryEvent; - dayAcc.runs++; - if (runEvent.success) { - dayAcc.successes++; - } else { - dayAcc.failures++; - } - if (runEvent.durationMs && runEvent.durationMs > 0) { - dayAcc.durations.push(runEvent.durationMs); - } - } else if (event.type === 'run.error') { - dayAcc.runs++; - dayAcc.errors++; - } else if (event.type === 'run.tokens') { - const tokenEvent = event as TokenTelemetryEvent; - const totalTokens = - tokenEvent.totalTokens ?? tokenEvent.inputTokens + tokenEvent.outputTokens; - dayAcc.totalTokens += totalTokens; - dayAcc.inputTokens += tokenEvent.inputTokens; - dayAcc.outputTokens += tokenEvent.outputTokens; - } - } catch { - // Intentionally silent: skip malformed NDJSON line - continue; - } - } - } catch (error: any) { - if (error.code !== 'ENOENT') { - console.error(`[Metrics] Error reading ${filePath}:`, error.message); - } - } - } - - // Convert to sorted array - const daily: DailyTrendPoint[] = []; - const sortedDates = [...dailyData.keys()].sort(); - - for (const date of sortedDates) { - const data = dailyData.get(date)!; - const avgDurationMs = - data.durations.length > 0 - ? Math.round(data.durations.reduce((a, b) => a + b, 0) / data.durations.length) - : 0; - - daily.push({ - date, - runs: data.runs, - successes: data.successes, - failures: data.failures, - errors: data.errors, - successRate: data.runs > 0 ? data.successes / data.runs : 0, - totalTokens: data.totalTokens, - inputTokens: data.inputTokens, - outputTokens: data.outputTokens, - avgDurationMs, - }); - } - - return { period, daily }; - } - - /** - * Get monthly budget metrics for the current month - */ - async getBudgetMetrics( - tokenBudget: number, - costBudget: number, - warningThreshold: number, - project?: string - ): Promise { - const now = new Date(); - const year = now.getFullYear(); - const month = now.getMonth(); - - // Calculate period boundaries - const periodStart = new Date(year, month, 1); - const periodEnd = new Date(year, month + 1, 0); // Last day of month - const daysInMonth = periodEnd.getDate(); - const daysElapsed = now.getDate(); - const daysRemaining = daysInMonth - daysElapsed; - - const since = periodStart.toISOString(); - const files = await this.getEventFiles(since); - - // Token accumulator - let totalTokens = 0; - let inputTokens = 0; - let outputTokens = 0; - - // Stream through files for current month only - for (const filePath of files) { - try { - const rl = this.createLineReader(filePath); - - for await (const line of rl) { - if (!line.trim()) continue; - - try { - const event = JSON.parse(line) as AnyTelemetryEvent; - - // Filter to current month and token events only - if (event.type !== 'run.tokens') continue; - if (event.timestamp < since) continue; - if (project && event.project !== project) continue; - - const tokenEvent = event as TokenTelemetryEvent; - const eventTotal = - tokenEvent.totalTokens ?? tokenEvent.inputTokens + tokenEvent.outputTokens; - - totalTokens += eventTotal; - inputTokens += tokenEvent.inputTokens; - outputTokens += tokenEvent.outputTokens; - } catch { - // Intentionally silent: skip malformed NDJSON line - continue; - } - } - } catch (error: any) { - if (error.code !== 'ENOENT') { - console.error(`[Metrics] Error reading ${filePath}:`, error.message); - } - } - } - - // Cost estimation (simplified pricing model) - // Input: $0.01 per 1K tokens, Output: $0.03 per 1K tokens - const estimatedCost = (inputTokens / 1000) * 0.01 + (outputTokens / 1000) * 0.03; - - // Burn rate calculations - const tokensPerDay = daysElapsed > 0 ? totalTokens / daysElapsed : 0; - const costPerDay = daysElapsed > 0 ? estimatedCost / daysElapsed : 0; - - // Projections - const projectedMonthlyTokens = Math.round(tokensPerDay * daysInMonth); - const projectedMonthlyCost = costPerDay * daysInMonth; - - // Budget percentages - const tokenBudgetUsed = tokenBudget > 0 ? (totalTokens / tokenBudget) * 100 : 0; - const costBudgetUsed = costBudget > 0 ? (estimatedCost / costBudget) * 100 : 0; - const projectedTokenOverage = - tokenBudget > 0 ? (projectedMonthlyTokens / tokenBudget) * 100 : 0; - const projectedCostOverage = costBudget > 0 ? (projectedMonthlyCost / costBudget) * 100 : 0; - - // Determine status based on highest usage percentage - let status: 'ok' | 'warning' | 'danger' = 'ok'; - const maxUsage = Math.max( - tokenBudget > 0 ? tokenBudgetUsed : 0, - costBudget > 0 ? costBudgetUsed : 0, - tokenBudget > 0 ? projectedTokenOverage : 0, - costBudget > 0 ? projectedCostOverage : 0 - ); - - if (maxUsage >= 100) { - status = 'danger'; - } else if (maxUsage >= warningThreshold) { - status = 'warning'; - } - - return { - periodStart: periodStart.toISOString().slice(0, 10), - periodEnd: periodEnd.toISOString().slice(0, 10), - daysInMonth, - daysElapsed, - daysRemaining, - totalTokens, - inputTokens, - outputTokens, - estimatedCost: Math.round(estimatedCost * 100) / 100, - tokensPerDay: Math.round(tokensPerDay), - costPerDay: Math.round(costPerDay * 100) / 100, - projectedMonthlyTokens, - projectedMonthlyCost: Math.round(projectedMonthlyCost * 100) / 100, - tokenBudget, - costBudget, - tokenBudgetUsed: Math.round(tokenBudgetUsed * 10) / 10, - costBudgetUsed: Math.round(costBudgetUsed * 10) / 10, - projectedTokenOverage: Math.round(projectedTokenOverage * 10) / 10, - projectedCostOverage: Math.round(projectedCostOverage * 10) / 10, - status, - }; - } - - /** - * Get agent comparison metrics for recommendations - * Aggregates performance data per agent with minimum run threshold - */ - async getAgentComparison( - period: MetricsPeriod, - project?: string, - minRuns = 3 - ): Promise { - const since = this.getPeriodStart(period); - const files = await this.getEventFiles(since); - - // Per-agent accumulator - const agentData = new Map< - string, - { - runs: number; - successes: number; - failures: number; - errors: number; - durations: number[]; - totalTokens: number; - inputTokens: number; - outputTokens: number; - costEstimate: number; - } - >(); - - // Process all files - for (const filePath of files) { - try { - const rl = this.createLineReader(filePath); - - for await (const line of rl) { - if (!line.trim()) continue; - - try { - const event = JSON.parse(line) as AnyTelemetryEvent; - - if (event.timestamp < since) continue; - if (project && event.project !== project) continue; - - const eventType = event.type; - - // Process run events - if (eventType === 'run.completed' || eventType === 'run.error') { - const runEvent = event as RunTelemetryEvent; - const agent = runEvent.agent || 'veritas'; - - if (!agentData.has(agent)) { - agentData.set(agent, { - runs: 0, - successes: 0, - failures: 0, - errors: 0, - durations: [], - totalTokens: 0, - inputTokens: 0, - outputTokens: 0, - costEstimate: 0, - }); - } - const acc = agentData.get(agent)!; - - if (eventType === 'run.error') { - acc.runs++; - acc.errors++; - } else { - acc.runs++; - if (runEvent.success) { - acc.successes++; - } else { - acc.failures++; - } - if (runEvent.durationMs && runEvent.durationMs > 0) { - acc.durations.push(runEvent.durationMs); - } - } - } - - // Process token events - if (eventType === 'run.tokens') { - const tokenEvent = event as TokenTelemetryEvent; - const agent = tokenEvent.agent || 'veritas'; - - if (!agentData.has(agent)) { - agentData.set(agent, { - runs: 0, - successes: 0, - failures: 0, - errors: 0, - durations: [], - totalTokens: 0, - inputTokens: 0, - outputTokens: 0, - costEstimate: 0, - }); - } - const acc = agentData.get(agent)!; - const totalTokens = - tokenEvent.totalTokens ?? tokenEvent.inputTokens + tokenEvent.outputTokens; - - acc.totalTokens += totalTokens; - acc.inputTokens += tokenEvent.inputTokens; - acc.outputTokens += tokenEvent.outputTokens; - // Cost estimate: $0.01/1K input, $0.03/1K output - acc.costEstimate += - (tokenEvent.inputTokens / 1000) * 0.01 + (tokenEvent.outputTokens / 1000) * 0.03; - } - } catch { - // Intentionally silent: skip malformed NDJSON line - continue; - } - } - } catch (error: any) { - if (error.code !== 'ENOENT') { - console.error(`[Metrics] Error reading ${filePath}:`, error.message); - } - } - } - - // Build comparison data for agents meeting minimum runs threshold - const agents: AgentComparisonData[] = []; - - for (const [agent, data] of agentData.entries()) { - if (data.runs < minRuns) continue; - - const avgDurationMs = - data.durations.length > 0 - ? Math.round(data.durations.reduce((a, b) => a + b, 0) / data.durations.length) - : 0; - const successRate = data.runs > 0 ? data.successes / data.runs : 0; - const avgTokensPerRun = data.runs > 0 ? Math.round(data.totalTokens / data.runs) : 0; - const avgCostPerRun = - data.runs > 0 ? Math.round((data.costEstimate / data.runs) * 100) / 100 : 0; - - agents.push({ - agent, - runs: data.runs, - successes: data.successes, - failures: data.failures + data.errors, - successRate: Math.round(successRate * 1000) / 10, // e.g., 95.5% - avgDurationMs, - avgTokensPerRun, - totalTokens: data.totalTokens, - avgCostPerRun, - totalCost: Math.round(data.costEstimate * 100) / 100, - }); - } - - // Sort by runs descending by default - agents.sort((a, b) => b.runs - a.runs); - - // Generate recommendations - const recommendations: AgentRecommendation[] = []; - - if (agents.length > 0) { - // Most reliable (highest success rate) - const mostReliable = [...agents].sort((a, b) => b.successRate - a.successRate)[0]; - if (mostReliable.successRate >= 80) { - recommendations.push({ - category: 'reliability', - agent: mostReliable.agent, - value: `${mostReliable.successRate}% success rate`, - reason: `Highest success rate among agents with ${minRuns}+ runs`, - }); - } - - // Fastest (lowest avg duration) - const fastest = [...agents] - .filter((a) => a.avgDurationMs > 0) - .sort((a, b) => a.avgDurationMs - b.avgDurationMs)[0]; - if (fastest) { - recommendations.push({ - category: 'speed', - agent: fastest.agent, - value: this.formatDurationForRecommendation(fastest.avgDurationMs), - reason: 'Shortest average run duration', - }); - } - - // Cheapest (lowest avg cost) - const cheapest = [...agents] - .filter((a) => a.avgCostPerRun > 0) - .sort((a, b) => a.avgCostPerRun - b.avgCostPerRun)[0]; - if (cheapest) { - recommendations.push({ - category: 'cost', - agent: cheapest.agent, - value: `$${cheapest.avgCostPerRun.toFixed(2)}/run`, - reason: 'Lowest average cost per run', - }); - } - - // Most efficient (tokens per successful run) - const efficientAgents = agents - .filter((a) => a.successes > 0) - .map((a) => ({ - ...a, - tokensPerSuccess: Math.round(a.totalTokens / a.successes), - })) - .sort((a, b) => a.tokensPerSuccess - b.tokensPerSuccess); - - if (efficientAgents.length > 0) { - const mostEfficient = efficientAgents[0]; - recommendations.push({ - category: 'efficiency', - agent: mostEfficient.agent, - value: `${this.formatTokensForRecommendation(mostEfficient.tokensPerSuccess)}/success`, - reason: 'Fewest tokens per successful run', - }); - } - } - - return { - period, - minRuns, - agents, - recommendations, - totalAgents: agentData.size, - qualifyingAgents: agents.length, - }; - } - - /** - * Format duration for recommendation display - */ - private formatDurationForRecommendation(ms: number): string { - if (ms < 60000) return `${Math.round(ms / 1000)}s`; - if (ms < 3600000) return `${Math.round(ms / 60000)}m`; - return `${(ms / 3600000).toFixed(1)}h`; - } - - /** - * Format tokens for recommendation display - */ - private formatTokensForRecommendation(tokens: number): string { - if (tokens < 1000) return `${tokens}`; - if (tokens < 1000000) return `${(tokens / 1000).toFixed(1)}K`; - return `${(tokens / 1000000).toFixed(2)}M`; - } - - /** - * Get sprint velocity metrics - * Calculates tasks completed per sprint with rolling average and trend - */ - async getVelocityMetrics(project?: string, limit = 10): Promise { - // Get all tasks (active + archived) to calculate velocity - const [activeTasks, archivedTasks] = await Promise.all([ - this.taskService.listTasks(), - this.taskService.listArchivedTasks(), - ]); - - // Load sprint labels from sprints.json for display - const sprintLabels = new Map(); - try { - const sprintsFile = path.join(PROJECT_ROOT, '.veritas-kanban', 'sprints.json'); - const sprintsData = await fs.readFile(sprintsFile, 'utf-8'); - const sprints = JSON.parse(sprintsData) as Array<{ id: string; label: string }>; - for (const s of sprints) { - sprintLabels.set(s.id, s.label); - } - } catch { - // No sprints file or can't read it - will use IDs as labels - } - - // Filter by project if specified - const allTasks = [...activeTasks, ...archivedTasks].filter( - (t) => !project || t.project === project - ); - - // Group tasks by sprint - const sprintData = new Map< - string, - { - completed: number; - total: number; - byType: Record; - } - >(); - - for (const task of allTasks) { - if (!task.sprint) continue; - - if (!sprintData.has(task.sprint)) { - sprintData.set(task.sprint, { completed: 0, total: 0, byType: {} }); - } - - const data = sprintData.get(task.sprint)!; - data.total++; - - // Count completed tasks (done or archived) - const isCompleted = task.status === 'done' || archivedTasks.some((a) => a.id === task.id); - if (isCompleted) { - data.completed++; - - // Track by type - const taskType = task.type || 'other'; - data.byType[taskType] = (data.byType[taskType] || 0) + 1; - } - } - - // Sort sprints by label (assumes sprint labels are sortable like "US-100", "US-200", etc.) - const sortedSprints = [...sprintData.entries()] - .sort((a, b) => { - // Extract numeric part for better sorting - const numA = parseInt(a[0].replace(/\D/g, ''), 10) || 0; - const numB = parseInt(b[0].replace(/\D/g, ''), 10) || 0; - return numA - numB; - }) - .slice(-limit); // Keep only the most recent sprints - - // Calculate velocity for each sprint - const sprints: SprintVelocityPoint[] = []; - const completedCounts: number[] = []; - - for (const [sprintId, data] of sortedSprints) { - completedCounts.push(data.completed); - - // Calculate rolling average (last 3 sprints) - const recentCompleted = completedCounts.slice(-3); - const rollingAverage = - recentCompleted.length > 0 - ? Math.round((recentCompleted.reduce((a, b) => a + b, 0) / recentCompleted.length) * 10) / - 10 - : 0; - - // Use display label if available, otherwise fall back to ID - const sprintLabel = sprintLabels.get(sprintId) || sprintId; - - sprints.push({ - sprint: sprintLabel, - completed: data.completed, - total: data.total, - rollingAverage, - byType: data.byType, - }); - } - - // Calculate overall metrics - const totalCompleted = completedCounts.reduce((a, b) => a + b, 0); - const averageVelocity = - sprints.length > 0 ? Math.round((totalCompleted / sprints.length) * 10) / 10 : 0; - - // Determine trend (comparing last 3 vs previous 3) - let trend: VelocityTrend = 'steady'; - if (sprints.length >= 4) { - const recentSprints = sprints.slice(-3); - const previousSprints = sprints.slice(-6, -3); - - if (previousSprints.length >= 2) { - const recentAvg = recentSprints.reduce((a, b) => a + b.completed, 0) / recentSprints.length; - const previousAvg = - previousSprints.reduce((a, b) => a + b.completed, 0) / previousSprints.length; - - const changePercent = previousAvg > 0 ? ((recentAvg - previousAvg) / previousAvg) * 100 : 0; - - if (changePercent > 10) { - trend = 'accelerating'; - } else if (changePercent < -10) { - trend = 'slowing'; - } - } - } - - // Get current sprint progress (find sprints with incomplete tasks) - let currentSprint: CurrentSprintProgress | undefined; - for (const [sprintId, data] of [...sprintData.entries()].reverse()) { - if (data.completed < data.total) { - // Use display label if available - const sprintLabel = sprintLabels.get(sprintId) || sprintId; - currentSprint = { - sprint: sprintLabel, - completed: data.completed, - total: data.total, - percentComplete: data.total > 0 ? Math.round((data.completed / data.total) * 100) : 0, - vsAverage: - averageVelocity > 0 - ? Math.round(((data.completed - averageVelocity) / averageVelocity) * 100) - : 0, - }; - break; - } - } - - return { - sprints, - averageVelocity, - trend, - currentSprint, - }; - } - - /** - * Get list of failed runs with details - */ - async getFailedRuns( - period: MetricsPeriod, - project?: string, - limit = 50 - ): Promise { - const since = this.getPeriodStart(period); - const files = await this.getEventFiles(since); - - const failedRuns: FailedRunDetails[] = []; - - for (const filePath of files) { - try { - const rl = this.createLineReader(filePath); - - for await (const line of rl) { - if (!line.trim()) continue; - - try { - const event = JSON.parse(line) as AnyTelemetryEvent; - - // Filter by type and time - if (event.type !== 'run.completed' && event.type !== 'run.error') continue; - if (event.timestamp < since) continue; - if (project && event.project !== project) continue; - - const runEvent = event as RunTelemetryEvent; - - // Only include failed runs - if ( - event.type === 'run.error' || - (event.type === 'run.completed' && !runEvent.success) - ) { - failedRuns.push({ - timestamp: event.timestamp, - taskId: runEvent.taskId, - project: runEvent.project, - agent: runEvent.agent || 'veritas', - success: false, - errorMessage: runEvent.error, - durationMs: runEvent.durationMs, - }); - } - } catch { - // Skip malformed lines - continue; - } - } - } catch (error: any) { - if (error.code !== 'ENOENT') { - console.error(`[Metrics] Error reading ${filePath}:`, error.message); - } - } - } - - // Sort by timestamp descending (most recent first) and limit - return failedRuns - .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) - .slice(0, limit); - } -} - -export interface DailyTrendPoint { - date: string; // YYYY-MM-DD - runs: number; - successes: number; - failures: number; - errors: number; - successRate: number; - totalTokens: number; - inputTokens: number; - outputTokens: number; - avgDurationMs: number; -} - -export interface TrendsData { - period: '7d' | '30d'; - daily: DailyTrendPoint[]; -} - -export interface BudgetMetrics { - periodStart: string; // Start of current month (YYYY-MM-DD) - periodEnd: string; // End of current month (YYYY-MM-DD) - daysInMonth: number; - daysElapsed: number; - daysRemaining: number; - - // Token usage - totalTokens: number; - inputTokens: number; - outputTokens: number; - - // Cost estimation (simplified: $0.01 per 1K tokens input, $0.03 per 1K output) - estimatedCost: number; - - // Burn rate calculations - tokensPerDay: number; // Average tokens per day so far - costPerDay: number; // Average cost per day - - // Projections - projectedMonthlyTokens: number; - projectedMonthlyCost: number; - - // Budget status - tokenBudget: number; // From settings (0 = no limit) - costBudget: number; // From settings (0 = no limit) - tokenBudgetUsed: number; // Percentage used (0-100+) - costBudgetUsed: number; // Percentage used (0-100+) - projectedTokenOverage: number; // Percentage of projected vs budget (0-100+) - projectedCostOverage: number; // Percentage of projected vs budget (0-100+) - - // Status indicator - status: 'ok' | 'warning' | 'danger'; // Based on warningThreshold -} - -/** Agent comparison data for a single agent */ -export interface AgentComparisonData { - agent: string; - runs: number; - successes: number; - failures: number; - successRate: number; // Percentage (0-100) - avgDurationMs: number; - avgTokensPerRun: number; - totalTokens: number; - avgCostPerRun: number; // Estimated cost per run - totalCost: number; // Total estimated cost -} - -/** Recommendation for best agent in a category */ -export interface AgentRecommendation { - category: 'reliability' | 'speed' | 'cost' | 'efficiency'; - agent: string; - value: string; // Human-readable value (e.g., "95.5%", "2.3m") - reason: string; // Explanation -} - -/** Full agent comparison result */ -export interface AgentComparisonResult { - period: MetricsPeriod; - minRuns: number; - agents: AgentComparisonData[]; - recommendations: AgentRecommendation[]; - totalAgents: number; // Total agents found (before minRuns filter) - qualifyingAgents: number; // Agents meeting minRuns threshold -} - -// Sprint velocity types -export type VelocityTrend = 'accelerating' | 'steady' | 'slowing'; - -export interface SprintVelocityPoint { - sprint: string; // Sprint identifier (e.g., "US-100") - completed: number; // Tasks completed in this sprint - total: number; // Total tasks in this sprint - rollingAverage: number; // 3-sprint rolling average at this point - byType: Record; // Breakdown by task type -} - -export interface CurrentSprintProgress { - sprint: string; - completed: number; - total: number; - percentComplete: number; // 0-100 - vsAverage: number; // Percentage vs historical average (-100 to +100+) -} - -export interface VelocityMetrics { - sprints: SprintVelocityPoint[]; // Sprint data (oldest to newest) - averageVelocity: number; // Overall average tasks per sprint - trend: VelocityTrend; // Current trend indicator - currentSprint?: CurrentSprintProgress; // Progress on current/active sprint -} - -// Singleton instance -let instance: MetricsService | null = null; - -export function getMetricsService(): MetricsService { - if (!instance) { - instance = new MetricsService(); - } - return instance; -} diff --git a/server/src/services/metrics/dashboard-metrics.ts b/server/src/services/metrics/dashboard-metrics.ts new file mode 100644 index 00000000..c49c1d5b --- /dev/null +++ b/server/src/services/metrics/dashboard-metrics.ts @@ -0,0 +1,683 @@ +/** + * Dashboard and composite metrics: all-in-one dashboard, trends, and agent comparison. + * These are performance-optimized methods that do single-pass file processing. + */ +import type { + RunTelemetryEvent, + TokenTelemetryEvent, + AnyTelemetryEvent, +} from '@veritas-kanban/shared'; +import { TaskService } from '../task-service.js'; +import { + getPeriodStart, + getPreviousPeriodRange, + calculateTrend, + calculateChange, + percentile, + formatDurationForRecommendation, + formatTokensForRecommendation, +} from './helpers.js'; +import { getEventFiles, createLineReader } from './telemetry-reader.js'; +import { computeTaskMetrics } from './task-metrics.js'; +import type { + MetricsPeriod, + TaskMetrics, + RunMetrics, + TokenMetrics, + DurationMetrics, + TrendComparison, + AgentBreakdown, + RunAccumulator, + TokenAccumulator, + DailyTrendPoint, + TrendsData, + AgentComparisonData, + AgentRecommendation, + AgentComparisonResult, +} from './types.js'; + +/** + * Get all metrics in one call (for dashboard). + * Optimized: streams files once and extracts all metrics in single pass. + */ +export async function computeAllMetrics( + taskService: TaskService, + telemetryDir: string, + period: MetricsPeriod = '24h', + project?: string +): Promise<{ + tasks: TaskMetrics; + runs: RunMetrics; + tokens: TokenMetrics; + duration: DurationMetrics; + trends: TrendComparison; +}> { + const since = getPeriodStart(period); + const files = await getEventFiles(telemetryDir, since); + + // Combined accumulator for single-pass processing + const runAcc: RunAccumulator = { + successes: 0, + failures: 0, + errors: 0, + durations: [], + byAgent: new Map(), + }; + + const tokenAcc: TokenAccumulator = { + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + cacheTokens: 0, + tokensPerRun: [], + byAgent: new Map(), + }; + + // Single pass through all files + for (const filePath of files) { + try { + const rl = createLineReader(filePath); + + for await (const line of rl) { + if (!line.trim()) continue; + + try { + const event = JSON.parse(line) as AnyTelemetryEvent; + + // Early timestamp filter + if (event.timestamp < since) continue; + if (project && event.project !== project) continue; + + const eventType = event.type; + + // Process run events + if (eventType === 'run.completed' || eventType === 'run.error') { + const runEvent = event as RunTelemetryEvent; + const agent = runEvent.agent || 'veritas'; + + if (!runAcc.byAgent.has(agent)) { + runAcc.byAgent.set(agent, { successes: 0, failures: 0, errors: 0, durations: [] }); + } + const agentAcc = runAcc.byAgent.get(agent)!; + + if (eventType === 'run.error') { + runAcc.errors++; + agentAcc.errors++; + } else { + if (runEvent.success) { + runAcc.successes++; + agentAcc.successes++; + } else { + runAcc.failures++; + agentAcc.failures++; + } + if (runEvent.durationMs && runEvent.durationMs > 0) { + runAcc.durations.push(runEvent.durationMs); + agentAcc.durations.push(runEvent.durationMs); + } + } + } + + // Process token events + if (eventType === 'run.tokens') { + const tokenEvent = event as TokenTelemetryEvent; + const agent = tokenEvent.agent || 'veritas'; + // Calculate totalTokens if not provided + const totalTokens = + tokenEvent.totalTokens ?? tokenEvent.inputTokens + tokenEvent.outputTokens; + const cacheTokens = tokenEvent.cacheTokens ?? 0; + + tokenAcc.totalTokens += totalTokens; + tokenAcc.inputTokens += tokenEvent.inputTokens; + tokenAcc.outputTokens += tokenEvent.outputTokens; + tokenAcc.cacheTokens += cacheTokens; + tokenAcc.tokensPerRun.push(totalTokens); + + if (!tokenAcc.byAgent.has(agent)) { + tokenAcc.byAgent.set(agent, { + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + cacheTokens: 0, + runs: 0, + }); + } + const agentTokenAcc = tokenAcc.byAgent.get(agent)!; + agentTokenAcc.totalTokens += totalTokens; + agentTokenAcc.inputTokens += tokenEvent.inputTokens; + agentTokenAcc.outputTokens += tokenEvent.outputTokens; + agentTokenAcc.cacheTokens += cacheTokens; + agentTokenAcc.runs++; + } + } catch { + // Skip malformed lines + continue; + } + } + } catch (error: any) { + if (error.code !== 'ENOENT') { + console.error(`[Metrics] Error reading ${filePath}:`, error.message); + } + } + } + + // Get task metrics (separate query, always fast) + const tasks = await computeTaskMetrics(taskService, project); + + // Build run metrics + const totalRuns = runAcc.successes + runAcc.failures + runAcc.errors; + const runByAgent: AgentBreakdown[] = []; + for (const [agent, data] of runAcc.byAgent.entries()) { + const agentRuns = data.successes + data.failures + data.errors; + const avgDuration = + data.durations.length > 0 + ? Math.round(data.durations.reduce((a, b) => a + b, 0) / data.durations.length) + : 0; + + runByAgent.push({ + agent, + runs: agentRuns, + successes: data.successes, + failures: data.failures, + errors: data.errors, + successRate: agentRuns > 0 ? data.successes / agentRuns : 0, + avgDurationMs: avgDuration, + totalTokens: tokenAcc.byAgent.get(agent)?.totalTokens || 0, + }); + } + runByAgent.sort((a, b) => b.runs - a.runs); + + const runs: RunMetrics = { + period, + runs: totalRuns, + successes: runAcc.successes, + failures: runAcc.failures, + errors: runAcc.errors, + errorRate: totalRuns > 0 ? (runAcc.failures + runAcc.errors) / totalRuns : 0, + successRate: totalRuns > 0 ? runAcc.successes / totalRuns : 0, + byAgent: runByAgent, + }; + + // Build token metrics + tokenAcc.tokensPerRun.sort((a, b) => a - b); + const tokenRuns = tokenAcc.tokensPerRun.length; + const tokenByAgent: TokenMetrics['byAgent'] = []; + for (const [agent, data] of tokenAcc.byAgent.entries()) { + tokenByAgent.push({ + agent, + totalTokens: data.totalTokens, + inputTokens: data.inputTokens, + outputTokens: data.outputTokens, + cacheTokens: data.cacheTokens, + runs: data.runs, + }); + } + tokenByAgent.sort((a, b) => b.totalTokens - a.totalTokens); + + const tokens: TokenMetrics = { + period, + totalTokens: tokenAcc.totalTokens, + inputTokens: tokenAcc.inputTokens, + outputTokens: tokenAcc.outputTokens, + cacheTokens: tokenAcc.cacheTokens, + runs: tokenRuns, + perSuccessfulRun: { + avg: tokenRuns > 0 ? Math.round(tokenAcc.totalTokens / tokenRuns) : 0, + p50: percentile(tokenAcc.tokensPerRun, 50), + p95: percentile(tokenAcc.tokensPerRun, 95), + }, + byAgent: tokenByAgent, + }; + + // Build duration metrics + runAcc.durations.sort((a, b) => a - b); + const durationByAgent: DurationMetrics['byAgent'] = []; + for (const [agent, data] of runAcc.byAgent.entries()) { + data.durations.sort((a, b) => a - b); + const agentSum = data.durations.reduce((a, b) => a + b, 0); + durationByAgent.push({ + agent, + runs: data.durations.length, + avgMs: data.durations.length > 0 ? Math.round(agentSum / data.durations.length) : 0, + p50Ms: percentile(data.durations, 50), + p95Ms: percentile(data.durations, 95), + }); + } + durationByAgent.sort((a, b) => b.runs - a.runs); + + const durationSum = runAcc.durations.reduce((a, b) => a + b, 0); + const duration: DurationMetrics = { + period, + runs: runAcc.durations.length, + avgMs: runAcc.durations.length > 0 ? Math.round(durationSum / runAcc.durations.length) : 0, + p50Ms: percentile(runAcc.durations, 50), + p95Ms: percentile(runAcc.durations, 95), + byAgent: durationByAgent, + }; + + // Calculate trends by comparing with previous period + const previousRange = getPreviousPeriodRange(period); + const previousFiles = await getEventFiles(telemetryDir, previousRange.since); + + // Quick accumulator for previous period (runs, tokens, duration only) + let prevRuns = 0, + prevSuccesses = 0, + prevTokens = 0, + prevDurationSum = 0, + prevDurationCount = 0; + + for (const filePath of previousFiles) { + try { + const rl = createLineReader(filePath); + + for await (const line of rl) { + if (!line.trim()) continue; + try { + const event = JSON.parse(line) as AnyTelemetryEvent; + if (event.timestamp < previousRange.since || event.timestamp >= previousRange.until) + continue; + if (project && event.project !== project) continue; + + if (event.type === 'run.completed') { + const runEvent = event as RunTelemetryEvent; + prevRuns++; + if (runEvent.success) prevSuccesses++; + if (runEvent.durationMs && runEvent.durationMs > 0) { + prevDurationSum += runEvent.durationMs; + prevDurationCount++; + } + } else if (event.type === 'run.error') { + prevRuns++; + } else if (event.type === 'run.tokens') { + const tokenEvent = event as TokenTelemetryEvent; + prevTokens += + tokenEvent.totalTokens ?? tokenEvent.inputTokens + tokenEvent.outputTokens; + } + } catch { + // Intentionally silent: skip malformed NDJSON line + continue; + } + } + } catch (error: any) { + if (error.code !== 'ENOENT') { + console.error(`[Metrics] Error reading ${filePath}:`, error.message); + } + } + } + + const prevSuccessRate = prevRuns > 0 ? prevSuccesses / prevRuns : 0; + const prevAvgDuration = prevDurationCount > 0 ? prevDurationSum / prevDurationCount : 0; + + const trends: TrendComparison = { + runsTrend: calculateTrend(runs.runs, prevRuns, true), + runsChange: calculateChange(runs.runs, prevRuns), + successRateTrend: calculateTrend(runs.successRate, prevSuccessRate, true), + successRateChange: calculateChange(runs.successRate * 100, prevSuccessRate * 100), + tokensTrend: calculateTrend(tokens.totalTokens, prevTokens, false), // Lower is better + tokensChange: calculateChange(tokens.totalTokens, prevTokens), + durationTrend: calculateTrend(duration.avgMs, prevAvgDuration, false), // Lower is better + durationChange: calculateChange(duration.avgMs, prevAvgDuration), + }; + + return { tasks, runs, tokens, duration, trends }; +} + +/** + * Get historical trends data aggregated by day + */ +export async function computeTrends( + telemetryDir: string, + period: '7d' | '30d', + project?: string +): Promise { + const since = getPeriodStart(period); + const files = await getEventFiles(telemetryDir, since); + + // Accumulator per day + const dailyData = new Map< + string, + { + runs: number; + successes: number; + failures: number; + errors: number; + totalTokens: number; + inputTokens: number; + outputTokens: number; + durations: number[]; + } + >(); + + // Initialize all days in the period + const startDate = new Date(since); + const endDate = new Date(); + for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) { + const dateStr = d.toISOString().slice(0, 10); + dailyData.set(dateStr, { + runs: 0, + successes: 0, + failures: 0, + errors: 0, + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + durations: [], + }); + } + + // Process all files + for (const filePath of files) { + try { + const rl = createLineReader(filePath); + + for await (const line of rl) { + if (!line.trim()) continue; + + try { + const event = JSON.parse(line) as AnyTelemetryEvent; + + // Early timestamp filter + if (event.timestamp < since) continue; + if (project && event.project !== project) continue; + + const dateStr = event.timestamp.slice(0, 10); + if (!dailyData.has(dateStr)) { + dailyData.set(dateStr, { + runs: 0, + successes: 0, + failures: 0, + errors: 0, + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + durations: [], + }); + } + const dayAcc = dailyData.get(dateStr)!; + + if (event.type === 'run.completed') { + const runEvent = event as RunTelemetryEvent; + dayAcc.runs++; + if (runEvent.success) { + dayAcc.successes++; + } else { + dayAcc.failures++; + } + if (runEvent.durationMs && runEvent.durationMs > 0) { + dayAcc.durations.push(runEvent.durationMs); + } + } else if (event.type === 'run.error') { + dayAcc.runs++; + dayAcc.errors++; + } else if (event.type === 'run.tokens') { + const tokenEvent = event as TokenTelemetryEvent; + const totalTokens = + tokenEvent.totalTokens ?? tokenEvent.inputTokens + tokenEvent.outputTokens; + dayAcc.totalTokens += totalTokens; + dayAcc.inputTokens += tokenEvent.inputTokens; + dayAcc.outputTokens += tokenEvent.outputTokens; + } + } catch { + // Intentionally silent: skip malformed NDJSON line + continue; + } + } + } catch (error: any) { + if (error.code !== 'ENOENT') { + console.error(`[Metrics] Error reading ${filePath}:`, error.message); + } + } + } + + // Convert to sorted array + const daily: DailyTrendPoint[] = []; + const sortedDates = [...dailyData.keys()].sort(); + + for (const date of sortedDates) { + const data = dailyData.get(date)!; + const avgDurationMs = + data.durations.length > 0 + ? Math.round(data.durations.reduce((a, b) => a + b, 0) / data.durations.length) + : 0; + + daily.push({ + date, + runs: data.runs, + successes: data.successes, + failures: data.failures, + errors: data.errors, + successRate: data.runs > 0 ? data.successes / data.runs : 0, + totalTokens: data.totalTokens, + inputTokens: data.inputTokens, + outputTokens: data.outputTokens, + avgDurationMs, + }); + } + + return { period, daily }; +} + +/** + * Get agent comparison metrics for recommendations. + * Aggregates performance data per agent with minimum run threshold. + */ +export async function computeAgentComparison( + telemetryDir: string, + period: MetricsPeriod, + project?: string, + minRuns = 3 +): Promise { + const since = getPeriodStart(period); + const files = await getEventFiles(telemetryDir, since); + + // Per-agent accumulator + const agentData = new Map< + string, + { + runs: number; + successes: number; + failures: number; + errors: number; + durations: number[]; + totalTokens: number; + inputTokens: number; + outputTokens: number; + costEstimate: number; + } + >(); + + // Process all files + for (const filePath of files) { + try { + const rl = createLineReader(filePath); + + for await (const line of rl) { + if (!line.trim()) continue; + + try { + const event = JSON.parse(line) as AnyTelemetryEvent; + + if (event.timestamp < since) continue; + if (project && event.project !== project) continue; + + const eventType = event.type; + + // Process run events + if (eventType === 'run.completed' || eventType === 'run.error') { + const runEvent = event as RunTelemetryEvent; + const agent = runEvent.agent || 'veritas'; + + if (!agentData.has(agent)) { + agentData.set(agent, { + runs: 0, + successes: 0, + failures: 0, + errors: 0, + durations: [], + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + costEstimate: 0, + }); + } + const acc = agentData.get(agent)!; + + if (eventType === 'run.error') { + acc.runs++; + acc.errors++; + } else { + acc.runs++; + if (runEvent.success) { + acc.successes++; + } else { + acc.failures++; + } + if (runEvent.durationMs && runEvent.durationMs > 0) { + acc.durations.push(runEvent.durationMs); + } + } + } + + // Process token events + if (eventType === 'run.tokens') { + const tokenEvent = event as TokenTelemetryEvent; + const agent = tokenEvent.agent || 'veritas'; + + if (!agentData.has(agent)) { + agentData.set(agent, { + runs: 0, + successes: 0, + failures: 0, + errors: 0, + durations: [], + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + costEstimate: 0, + }); + } + const acc = agentData.get(agent)!; + const totalTokens = + tokenEvent.totalTokens ?? tokenEvent.inputTokens + tokenEvent.outputTokens; + + acc.totalTokens += totalTokens; + acc.inputTokens += tokenEvent.inputTokens; + acc.outputTokens += tokenEvent.outputTokens; + // Cost estimate: $0.01/1K input, $0.03/1K output + acc.costEstimate += + (tokenEvent.inputTokens / 1000) * 0.01 + (tokenEvent.outputTokens / 1000) * 0.03; + } + } catch { + // Intentionally silent: skip malformed NDJSON line + continue; + } + } + } catch (error: any) { + if (error.code !== 'ENOENT') { + console.error(`[Metrics] Error reading ${filePath}:`, error.message); + } + } + } + + // Build comparison data for agents meeting minimum runs threshold + const agents: AgentComparisonData[] = []; + + for (const [agent, data] of agentData.entries()) { + if (data.runs < minRuns) continue; + + const avgDurationMs = + data.durations.length > 0 + ? Math.round(data.durations.reduce((a, b) => a + b, 0) / data.durations.length) + : 0; + const successRate = data.runs > 0 ? data.successes / data.runs : 0; + const avgTokensPerRun = data.runs > 0 ? Math.round(data.totalTokens / data.runs) : 0; + const avgCostPerRun = + data.runs > 0 ? Math.round((data.costEstimate / data.runs) * 100) / 100 : 0; + + agents.push({ + agent, + runs: data.runs, + successes: data.successes, + failures: data.failures + data.errors, + successRate: Math.round(successRate * 1000) / 10, // e.g., 95.5% + avgDurationMs, + avgTokensPerRun, + totalTokens: data.totalTokens, + avgCostPerRun, + totalCost: Math.round(data.costEstimate * 100) / 100, + }); + } + + // Sort by runs descending by default + agents.sort((a, b) => b.runs - a.runs); + + // Generate recommendations + const recommendations: AgentRecommendation[] = []; + + if (agents.length > 0) { + // Most reliable (highest success rate) + const mostReliable = [...agents].sort((a, b) => b.successRate - a.successRate)[0]; + if (mostReliable.successRate >= 80) { + recommendations.push({ + category: 'reliability', + agent: mostReliable.agent, + value: `${mostReliable.successRate}% success rate`, + reason: `Highest success rate among agents with ${minRuns}+ runs`, + }); + } + + // Fastest (lowest avg duration) + const fastest = [...agents] + .filter((a) => a.avgDurationMs > 0) + .sort((a, b) => a.avgDurationMs - b.avgDurationMs)[0]; + if (fastest) { + recommendations.push({ + category: 'speed', + agent: fastest.agent, + value: formatDurationForRecommendation(fastest.avgDurationMs), + reason: 'Shortest average run duration', + }); + } + + // Cheapest (lowest avg cost) + const cheapest = [...agents] + .filter((a) => a.avgCostPerRun > 0) + .sort((a, b) => a.avgCostPerRun - b.avgCostPerRun)[0]; + if (cheapest) { + recommendations.push({ + category: 'cost', + agent: cheapest.agent, + value: `$${cheapest.avgCostPerRun.toFixed(2)}/run`, + reason: 'Lowest average cost per run', + }); + } + + // Most efficient (tokens per successful run) + const efficientAgents = agents + .filter((a) => a.successes > 0) + .map((a) => ({ + ...a, + tokensPerSuccess: Math.round(a.totalTokens / a.successes), + })) + .sort((a, b) => a.tokensPerSuccess - b.tokensPerSuccess); + + if (efficientAgents.length > 0) { + const mostEfficient = efficientAgents[0]; + recommendations.push({ + category: 'efficiency', + agent: mostEfficient.agent, + value: `${formatTokensForRecommendation(mostEfficient.tokensPerSuccess)}/success`, + reason: 'Fewest tokens per successful run', + }); + } + } + + return { + period, + minRuns, + agents, + recommendations, + totalAgents: agentData.size, + qualifyingAgents: agents.length, + }; +} diff --git a/server/src/services/metrics/helpers.ts b/server/src/services/metrics/helpers.ts new file mode 100644 index 00000000..af0990d1 --- /dev/null +++ b/server/src/services/metrics/helpers.ts @@ -0,0 +1,100 @@ +/** + * Shared utility functions for metrics calculations. + */ +import path from 'path'; +import type { MetricsPeriod, TrendDirection } from './types.js'; + +// Default paths - resolve to project root +export const PROJECT_ROOT = path.resolve(process.cwd(), '..'); +export const TELEMETRY_DIR = path.join(PROJECT_ROOT, '.veritas-kanban', 'telemetry'); + +/** + * Get timestamp for start of period + */ +export function getPeriodStart(period: MetricsPeriod): string { + const now = new Date(); + switch (period) { + case '24h': + now.setHours(now.getHours() - 24); + break; + case '7d': + now.setDate(now.getDate() - 7); + break; + case '30d': + now.setDate(now.getDate() - 30); + break; + } + return now.toISOString(); +} + +/** + * Get timestamp range for previous period (for trend comparison) + */ +export function getPreviousPeriodRange(period: MetricsPeriod): { since: string; until: string } { + const now = new Date(); + const periodMs = + period === '24h' + ? 24 * 60 * 60 * 1000 + : period === '7d' + ? 7 * 24 * 60 * 60 * 1000 + : 30 * 24 * 60 * 60 * 1000; + + const currentPeriodStart = new Date(now.getTime() - periodMs); + const previousPeriodStart = new Date(currentPeriodStart.getTime() - periodMs); + + return { + since: previousPeriodStart.toISOString(), + until: currentPeriodStart.toISOString(), + }; +} + +/** + * Calculate trend direction based on change + */ +export function calculateTrend( + current: number, + previous: number, + higherIsBetter = true +): TrendDirection { + if (previous === 0) return current > 0 ? 'up' : 'flat'; + const changePercent = ((current - previous) / previous) * 100; + if (Math.abs(changePercent) < 5) return 'flat'; // Less than 5% change is flat + const isUp = current > previous; + // For metrics where lower is better (like duration), invert the logic + return higherIsBetter ? (isUp ? 'up' : 'down') : isUp ? 'down' : 'up'; +} + +/** + * Calculate percentage change + */ +export function calculateChange(current: number, previous: number): number { + if (previous === 0) return current > 0 ? 100 : 0; + return Math.round(((current - previous) / previous) * 100); +} + +/** + * Calculate percentile from sorted array + */ +export function percentile(sorted: number[], p: number): number { + if (sorted.length === 0) return 0; + const index = Math.ceil((p / 100) * sorted.length) - 1; + return sorted[Math.max(0, index)]; +} + +/** + * Format duration for recommendation display + */ +export function formatDurationForRecommendation(ms: number): string { + if (ms < 60000) return `${Math.round(ms / 1000)}s`; + if (ms < 3600000) return `${Math.round(ms / 60000)}m`; + return `${(ms / 3600000).toFixed(1)}h`; +} + +/** + * Format tokens for recommendation display + */ +export function formatTokensForRecommendation(tokens: number): string { + if (tokens < 1000) return `${tokens}`; + if (tokens < 1000000) return `${(tokens / 1000).toFixed(1)}K`; + return `${(tokens / 1000000).toFixed(2)}M`; +} diff --git a/server/src/services/metrics/index.ts b/server/src/services/metrics/index.ts new file mode 100644 index 00000000..06e398e9 --- /dev/null +++ b/server/src/services/metrics/index.ts @@ -0,0 +1,32 @@ +/** + * Metrics module barrel export. + * Re-exports everything for backwards compatibility with the original metrics-service.ts. + */ + +// Class and singleton +export { MetricsService, getMetricsService } from './metrics-service.js'; + +// All types +export type { + MetricsPeriod, + TaskMetrics, + AgentBreakdown, + RunMetrics, + TokenMetrics, + DurationMetrics, + TrendDirection, + TrendComparison, + RunAccumulator, + TokenAccumulator, + FailedRunDetails, + DailyTrendPoint, + TrendsData, + BudgetMetrics, + AgentComparisonData, + AgentRecommendation, + AgentComparisonResult, + VelocityTrend, + SprintVelocityPoint, + CurrentSprintProgress, + VelocityMetrics, +} from './types.js'; diff --git a/server/src/services/metrics/metrics-service.ts b/server/src/services/metrics/metrics-service.ts new file mode 100644 index 00000000..0c8686f4 --- /dev/null +++ b/server/src/services/metrics/metrics-service.ts @@ -0,0 +1,114 @@ +/** + * MetricsService - Thin facade that delegates to focused metric modules. + * Maintains the original class API for backwards compatibility. + */ +import { getTelemetryService } from '../telemetry-service.js'; +import { TaskService } from '../task-service.js'; +import { TELEMETRY_DIR } from './helpers.js'; +import { computeTaskMetrics, computeVelocityMetrics } from './task-metrics.js'; +import { computeRunMetrics, computeDurationMetrics, computeFailedRuns } from './run-metrics.js'; +import { computeTokenMetrics, computeBudgetMetrics } from './token-metrics.js'; +import { computeAllMetrics, computeTrends, computeAgentComparison } from './dashboard-metrics.js'; +import type { + MetricsPeriod, + TaskMetrics, + RunMetrics, + TokenMetrics, + DurationMetrics, + TrendComparison, + TrendsData, + BudgetMetrics, + AgentComparisonResult, + VelocityMetrics, + FailedRunDetails, +} from './types.js'; + +export class MetricsService { + private taskService: TaskService; + private telemetryDir: string; + + constructor(telemetryDir?: string) { + // Keep TelemetryService init for potential future use + getTelemetryService(); + this.taskService = new TaskService(); + this.telemetryDir = telemetryDir || TELEMETRY_DIR; + } + + async getTaskMetrics(project?: string): Promise { + return computeTaskMetrics(this.taskService, project); + } + + async getRunMetrics(period: MetricsPeriod, project?: string): Promise { + return computeRunMetrics(this.telemetryDir, period, project); + } + + async getTokenMetrics(period: MetricsPeriod, project?: string): Promise { + return computeTokenMetrics(this.telemetryDir, period, project); + } + + async getDurationMetrics(period: MetricsPeriod, project?: string): Promise { + return computeDurationMetrics(this.telemetryDir, period, project); + } + + async getAllMetrics( + period: MetricsPeriod = '24h', + project?: string + ): Promise<{ + tasks: TaskMetrics; + runs: RunMetrics; + tokens: TokenMetrics; + duration: DurationMetrics; + trends: TrendComparison; + }> { + return computeAllMetrics(this.taskService, this.telemetryDir, period, project); + } + + async getTrends(period: '7d' | '30d', project?: string): Promise { + return computeTrends(this.telemetryDir, period, project); + } + + async getBudgetMetrics( + tokenBudget: number, + costBudget: number, + warningThreshold: number, + project?: string + ): Promise { + return computeBudgetMetrics( + this.telemetryDir, + tokenBudget, + costBudget, + warningThreshold, + project + ); + } + + async getAgentComparison( + period: MetricsPeriod, + project?: string, + minRuns = 3 + ): Promise { + return computeAgentComparison(this.telemetryDir, period, project, minRuns); + } + + async getVelocityMetrics(project?: string, limit = 10): Promise { + return computeVelocityMetrics(this.taskService, project, limit); + } + + async getFailedRuns( + period: MetricsPeriod, + project?: string, + limit = 50 + ): Promise { + return computeFailedRuns(this.telemetryDir, period, project, limit); + } +} + +// Singleton instance +let instance: MetricsService | null = null; + +export function getMetricsService(): MetricsService { + if (!instance) { + instance = new MetricsService(); + } + return instance; +} diff --git a/server/src/services/metrics/run-metrics.ts b/server/src/services/metrics/run-metrics.ts new file mode 100644 index 00000000..d1b5a52e --- /dev/null +++ b/server/src/services/metrics/run-metrics.ts @@ -0,0 +1,233 @@ +/** + * Run-related metrics: run success/error rates, duration, and failed run details. + */ +import type { RunTelemetryEvent } from '@veritas-kanban/shared'; +import { getPeriodStart, percentile } from './helpers.js'; +import { getEventFiles, streamEvents, createLineReader } from './telemetry-reader.js'; +import type { + MetricsPeriod, + RunMetrics, + AgentBreakdown, + RunAccumulator, + DurationMetrics, + FailedRunDetails, +} from './types.js'; + +/** + * Get run metrics (error rate, success rate) with per-agent breakdown + */ +export async function computeRunMetrics( + telemetryDir: string, + period: MetricsPeriod, + project?: string +): Promise { + const since = getPeriodStart(period); + const files = await getEventFiles(telemetryDir, since); + + const accumulator: RunAccumulator = { + successes: 0, + failures: 0, + errors: 0, + durations: [], + byAgent: new Map(), + }; + + await streamEvents( + files, + ['run.completed', 'run.error'], + since, + project, + accumulator, + (event, acc) => { + const agent = (event as RunTelemetryEvent).agent || 'veritas'; + + if (!acc.byAgent.has(agent)) { + acc.byAgent.set(agent, { successes: 0, failures: 0, errors: 0, durations: [] }); + } + const agentAcc = acc.byAgent.get(agent)!; + + if (event.type === 'run.error') { + acc.errors++; + agentAcc.errors++; + } else if (event.type === 'run.completed') { + const runEvent = event as RunTelemetryEvent; + if (runEvent.success) { + acc.successes++; + agentAcc.successes++; + } else { + acc.failures++; + agentAcc.failures++; + } + if (runEvent.durationMs && runEvent.durationMs > 0) { + acc.durations.push(runEvent.durationMs); + agentAcc.durations.push(runEvent.durationMs); + } + } + } + ); + + const runs = accumulator.successes + accumulator.failures + accumulator.errors; + const errorRate = runs > 0 ? (accumulator.failures + accumulator.errors) / runs : 0; + const successRate = runs > 0 ? accumulator.successes / runs : 0; + + // Build per-agent breakdown + const byAgent: AgentBreakdown[] = []; + for (const [agent, data] of accumulator.byAgent.entries()) { + const agentRuns = data.successes + data.failures + data.errors; + const avgDuration = + data.durations.length > 0 + ? Math.round(data.durations.reduce((a, b) => a + b, 0) / data.durations.length) + : 0; + + byAgent.push({ + agent, + runs: agentRuns, + successes: data.successes, + failures: data.failures, + errors: data.errors, + successRate: agentRuns > 0 ? data.successes / agentRuns : 0, + avgDurationMs: avgDuration, + totalTokens: 0, // Will be populated if needed + }); + } + + // Sort by runs descending + byAgent.sort((a, b) => b.runs - a.runs); + + return { + period, + runs, + successes: accumulator.successes, + failures: accumulator.failures, + errors: accumulator.errors, + errorRate, + successRate, + byAgent, + }; +} + +/** + * Get duration metrics with per-agent breakdown + */ +export async function computeDurationMetrics( + telemetryDir: string, + period: MetricsPeriod, + project?: string +): Promise { + const since = getPeriodStart(period); + const files = await getEventFiles(telemetryDir, since); + + const accumulator = { + durations: [] as number[], + byAgent: new Map(), + }; + + await streamEvents(files, ['run.completed'], since, project, accumulator, (event, acc) => { + const runEvent = event as RunTelemetryEvent; + if (runEvent.durationMs !== undefined && runEvent.durationMs > 0) { + const agent = runEvent.agent || 'veritas'; + + acc.durations.push(runEvent.durationMs); + + if (!acc.byAgent.has(agent)) { + acc.byAgent.set(agent, []); + } + acc.byAgent.get(agent)!.push(runEvent.durationMs); + } + }); + + // Sort for percentile calculations + accumulator.durations.sort((a, b) => a - b); + + const runs = accumulator.durations.length; + const sum = accumulator.durations.reduce((a, b) => a + b, 0); + const avgMs = runs > 0 ? Math.round(sum / runs) : 0; + const p50Ms = percentile(accumulator.durations, 50); + const p95Ms = percentile(accumulator.durations, 95); + + // Build per-agent breakdown + const byAgent: DurationMetrics['byAgent'] = []; + for (const [agent, durations] of accumulator.byAgent.entries()) { + durations.sort((a, b) => a - b); + const agentSum = durations.reduce((a, b) => a + b, 0); + + byAgent.push({ + agent, + runs: durations.length, + avgMs: durations.length > 0 ? Math.round(agentSum / durations.length) : 0, + p50Ms: percentile(durations, 50), + p95Ms: percentile(durations, 95), + }); + } + + // Sort by runs descending + byAgent.sort((a, b) => b.runs - a.runs); + + return { + period, + runs, + avgMs, + p50Ms, + p95Ms, + byAgent, + }; +} + +/** + * Get list of failed runs with details + */ +export async function computeFailedRuns( + telemetryDir: string, + period: MetricsPeriod, + project?: string, + limit = 50 +): Promise { + const since = getPeriodStart(period); + const files = await getEventFiles(telemetryDir, since); + + const failedRuns: FailedRunDetails[] = []; + + for (const filePath of files) { + try { + const rl = createLineReader(filePath); + + for await (const line of rl) { + if (!line.trim()) continue; + + try { + const event = JSON.parse(line) as RunTelemetryEvent; + + // Filter by type and time + if (event.type !== 'run.completed' && event.type !== 'run.error') continue; + if (event.timestamp < since) continue; + if (project && event.project !== project) continue; + + // Only include failed runs + if (event.type === 'run.error' || (event.type === 'run.completed' && !event.success)) { + failedRuns.push({ + timestamp: event.timestamp, + taskId: event.taskId, + project: event.project, + agent: event.agent || 'veritas', + success: false, + errorMessage: event.error, + durationMs: event.durationMs, + }); + } + } catch { + // Skip malformed lines + continue; + } + } + } catch (error: any) { + if (error.code !== 'ENOENT') { + console.error(`[Metrics] Error reading ${filePath}:`, error.message); + } + } + } + + // Sort by timestamp descending (most recent first) and limit + return failedRuns + .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) + .slice(0, limit); +} diff --git a/server/src/services/metrics/task-metrics.ts b/server/src/services/metrics/task-metrics.ts new file mode 100644 index 00000000..7dd4269a --- /dev/null +++ b/server/src/services/metrics/task-metrics.ts @@ -0,0 +1,232 @@ +/** + * Task-related metrics: task counts by status and sprint velocity. + */ +import fs from 'fs/promises'; +import path from 'path'; +import type { TaskStatus, BlockedCategory } from '@veritas-kanban/shared'; +import { TaskService } from '../task-service.js'; +import { PROJECT_ROOT } from './helpers.js'; +import type { + TaskMetrics, + MetricsPeriod, + VelocityTrend, + SprintVelocityPoint, + CurrentSprintProgress, + VelocityMetrics, +} from './types.js'; + +/** + * Get task counts by status + */ +export async function computeTaskMetrics( + taskService: TaskService, + project?: string +): Promise { + const [activeTasks, archivedTasks] = await Promise.all([ + taskService.listTasks(), + taskService.listArchivedTasks(), + ]); + + // Filter by project if specified + const filteredActive = project ? activeTasks.filter((t) => t.project === project) : activeTasks; + const filteredArchived = project + ? archivedTasks.filter((t) => t.project === project) + : archivedTasks; + + // Count by status + const byStatus: Record = { + todo: 0, + 'in-progress': 0, + blocked: 0, + done: 0, + }; + + // Count by blocked reason + const byBlockedReason: Record = { + 'waiting-on-feedback': 0, + 'technical-snag': 0, + prerequisite: 0, + other: 0, + unspecified: 0, + }; + + for (const task of filteredActive) { + byStatus[task.status]++; + + // Count blocked reasons for blocked tasks + if (task.status === 'blocked') { + if (task.blockedReason?.category) { + byBlockedReason[task.blockedReason.category]++; + } else { + byBlockedReason['unspecified']++; + } + } + } + + const archived = filteredArchived.length; + const total = filteredActive.length + archived; + const completed = byStatus['done'] + archived; + + return { + byStatus, + byBlockedReason, + total, + completed, + archived, + }; +} + +/** + * Get sprint velocity metrics. + * Calculates tasks completed per sprint with rolling average and trend. + */ +export async function computeVelocityMetrics( + taskService: TaskService, + project?: string, + limit = 10 +): Promise { + // Get all tasks (active + archived) to calculate velocity + const [activeTasks, archivedTasks] = await Promise.all([ + taskService.listTasks(), + taskService.listArchivedTasks(), + ]); + + // Load sprint labels from sprints.json for display + const sprintLabels = new Map(); + try { + const sprintsFile = path.join(PROJECT_ROOT, '.veritas-kanban', 'sprints.json'); + const sprintsData = await fs.readFile(sprintsFile, 'utf-8'); + const sprints = JSON.parse(sprintsData) as Array<{ id: string; label: string }>; + for (const s of sprints) { + sprintLabels.set(s.id, s.label); + } + } catch { + // No sprints file or can't read it - will use IDs as labels + } + + // Filter by project if specified + const allTasks = [...activeTasks, ...archivedTasks].filter( + (t) => !project || t.project === project + ); + + // Group tasks by sprint + const sprintData = new Map< + string, + { + completed: number; + total: number; + byType: Record; + } + >(); + + for (const task of allTasks) { + if (!task.sprint) continue; + + if (!sprintData.has(task.sprint)) { + sprintData.set(task.sprint, { completed: 0, total: 0, byType: {} }); + } + + const data = sprintData.get(task.sprint)!; + data.total++; + + // Count completed tasks (done or archived) + const isCompleted = task.status === 'done' || archivedTasks.some((a) => a.id === task.id); + if (isCompleted) { + data.completed++; + + // Track by type + const taskType = task.type || 'other'; + data.byType[taskType] = (data.byType[taskType] || 0) + 1; + } + } + + // Sort sprints by label (assumes sprint labels are sortable like "US-100", "US-200", etc.) + const sortedSprints = [...sprintData.entries()] + .sort((a, b) => { + // Extract numeric part for better sorting + const numA = parseInt(a[0].replace(/\D/g, ''), 10) || 0; + const numB = parseInt(b[0].replace(/\D/g, ''), 10) || 0; + return numA - numB; + }) + .slice(-limit); // Keep only the most recent sprints + + // Calculate velocity for each sprint + const sprints: SprintVelocityPoint[] = []; + const completedCounts: number[] = []; + + for (const [sprintId, data] of sortedSprints) { + completedCounts.push(data.completed); + + // Calculate rolling average (last 3 sprints) + const recentCompleted = completedCounts.slice(-3); + const rollingAverage = + recentCompleted.length > 0 + ? Math.round((recentCompleted.reduce((a, b) => a + b, 0) / recentCompleted.length) * 10) / + 10 + : 0; + + // Use display label if available, otherwise fall back to ID + const sprintLabel = sprintLabels.get(sprintId) || sprintId; + + sprints.push({ + sprint: sprintLabel, + completed: data.completed, + total: data.total, + rollingAverage, + byType: data.byType, + }); + } + + // Calculate overall metrics + const totalCompleted = completedCounts.reduce((a, b) => a + b, 0); + const averageVelocity = + sprints.length > 0 ? Math.round((totalCompleted / sprints.length) * 10) / 10 : 0; + + // Determine trend (comparing last 3 vs previous 3) + let trend: VelocityTrend = 'steady'; + if (sprints.length >= 4) { + const recentSprints = sprints.slice(-3); + const previousSprints = sprints.slice(-6, -3); + + if (previousSprints.length >= 2) { + const recentAvg = recentSprints.reduce((a, b) => a + b.completed, 0) / recentSprints.length; + const previousAvg = + previousSprints.reduce((a, b) => a + b.completed, 0) / previousSprints.length; + + const changePercent = previousAvg > 0 ? ((recentAvg - previousAvg) / previousAvg) * 100 : 0; + + if (changePercent > 10) { + trend = 'accelerating'; + } else if (changePercent < -10) { + trend = 'slowing'; + } + } + } + + // Get current sprint progress (find sprints with incomplete tasks) + let currentSprint: CurrentSprintProgress | undefined; + for (const [sprintId, data] of [...sprintData.entries()].reverse()) { + if (data.completed < data.total) { + // Use display label if available + const sprintLabel = sprintLabels.get(sprintId) || sprintId; + currentSprint = { + sprint: sprintLabel, + completed: data.completed, + total: data.total, + percentComplete: data.total > 0 ? Math.round((data.completed / data.total) * 100) : 0, + vsAverage: + averageVelocity > 0 + ? Math.round(((data.completed - averageVelocity) / averageVelocity) * 100) + : 0, + }; + break; + } + } + + return { + sprints, + averageVelocity, + trend, + currentSprint, + }; +} diff --git a/server/src/services/metrics/telemetry-reader.ts b/server/src/services/metrics/telemetry-reader.ts new file mode 100644 index 00000000..3c8abe72 --- /dev/null +++ b/server/src/services/metrics/telemetry-reader.ts @@ -0,0 +1,99 @@ +/** + * Telemetry file I/O utilities. + * Handles reading NDJSON event files (plain and gzipped) with streaming support. + */ +import { createReadStream } from 'fs'; +import fs from 'fs/promises'; +import path from 'path'; +import readline from 'readline'; +import { createGunzip } from 'zlib'; +import type { AnyTelemetryEvent, TelemetryEventType, StreamEventHandler } from './types.js'; + +/** + * Get list of event files within a date range (includes .ndjson and .ndjson.gz) + */ +export async function getEventFiles(telemetryDir: string, since: string): Promise { + try { + const files = await fs.readdir(telemetryDir); + const eventFiles = files.filter( + (f) => f.startsWith('events-') && (f.endsWith('.ndjson') || f.endsWith('.ndjson.gz')) + ); + const sinceDate = since.slice(0, 10); + + return eventFiles + .filter((filename) => { + const match = filename.match(/events-(\d{4}-\d{2}-\d{2})\.ndjson(\.gz)?$/); + if (!match) return false; + return match[1] >= sinceDate; + }) + .map((f) => path.join(telemetryDir, f)); + } catch (error: any) { + if (error.code === 'ENOENT') { + return []; + } + throw error; + } +} + +/** + * Create a readline interface for an event file (handles both .ndjson and .ndjson.gz) + */ +export function createLineReader(filePath: string): readline.Interface { + if (filePath.endsWith('.gz')) { + const fileStream = createReadStream(filePath); + const gunzip = createGunzip(); + const decompressed = fileStream.pipe(gunzip); + return readline.createInterface({ + input: decompressed, + crlfDelay: Infinity, + }); + } + const fileStream = createReadStream(filePath, { encoding: 'utf-8' }); + return readline.createInterface({ + input: fileStream, + crlfDelay: Infinity, + }); +} + +/** + * Stream events from NDJSON files with filtering. + * Performance-optimized: reads line by line, filters early, accumulates in memory-efficient way. + */ +export async function streamEvents( + files: string[], + types: TelemetryEventType[], + since: string, + project: string | undefined, + accumulator: T, + handler: StreamEventHandler +): Promise { + for (const filePath of files) { + try { + const rl = createLineReader(filePath); + + for await (const line of rl) { + if (!line.trim()) continue; + + try { + const event = JSON.parse(line) as AnyTelemetryEvent; + + // Early filtering for performance + if (!types.includes(event.type)) continue; + if (event.timestamp < since) continue; + if (project && event.project !== project) continue; + + handler(event, accumulator); + } catch { + // Skip malformed lines + continue; + } + } + } catch (error: any) { + if (error.code !== 'ENOENT') { + console.error(`[Metrics] Error reading ${filePath}:`, error.message); + } + } + } + + return accumulator; +} diff --git a/server/src/services/metrics/token-metrics.ts b/server/src/services/metrics/token-metrics.ts new file mode 100644 index 00000000..739ddbba --- /dev/null +++ b/server/src/services/metrics/token-metrics.ts @@ -0,0 +1,218 @@ +/** + * Token-related metrics: token usage and budget tracking. + */ +import type { TokenTelemetryEvent, AnyTelemetryEvent } from '@veritas-kanban/shared'; +import { getPeriodStart, percentile } from './helpers.js'; +import { getEventFiles, streamEvents, createLineReader } from './telemetry-reader.js'; +import type { MetricsPeriod, TokenMetrics, TokenAccumulator, BudgetMetrics } from './types.js'; + +/** + * Get token metrics with per-agent breakdown + */ +export async function computeTokenMetrics( + telemetryDir: string, + period: MetricsPeriod, + project?: string +): Promise { + const since = getPeriodStart(period); + const files = await getEventFiles(telemetryDir, since); + + const accumulator: TokenAccumulator = { + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + cacheTokens: 0, + tokensPerRun: [], + byAgent: new Map(), + }; + + await streamEvents(files, ['run.tokens'], since, project, accumulator, (event, acc) => { + const tokenEvent = event as TokenTelemetryEvent; + const agent = tokenEvent.agent || 'veritas'; + // Calculate totalTokens if not provided + const totalTokens = tokenEvent.totalTokens ?? tokenEvent.inputTokens + tokenEvent.outputTokens; + const cacheTokens = tokenEvent.cacheTokens ?? 0; + + acc.totalTokens += totalTokens; + acc.inputTokens += tokenEvent.inputTokens; + acc.outputTokens += tokenEvent.outputTokens; + acc.cacheTokens += cacheTokens; + acc.tokensPerRun.push(totalTokens); + + if (!acc.byAgent.has(agent)) { + acc.byAgent.set(agent, { + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + cacheTokens: 0, + runs: 0, + }); + } + const agentAcc = acc.byAgent.get(agent)!; + agentAcc.totalTokens += totalTokens; + agentAcc.inputTokens += tokenEvent.inputTokens; + agentAcc.outputTokens += tokenEvent.outputTokens; + agentAcc.cacheTokens += cacheTokens; + agentAcc.runs++; + }); + + // Sort for percentile calculations + accumulator.tokensPerRun.sort((a, b) => a - b); + + const runs = accumulator.tokensPerRun.length; + const avg = runs > 0 ? accumulator.totalTokens / runs : 0; + const p50 = percentile(accumulator.tokensPerRun, 50); + const p95 = percentile(accumulator.tokensPerRun, 95); + + // Build per-agent breakdown + const byAgent: TokenMetrics['byAgent'] = []; + for (const [agent, data] of accumulator.byAgent.entries()) { + byAgent.push({ + agent, + totalTokens: data.totalTokens, + inputTokens: data.inputTokens, + outputTokens: data.outputTokens, + cacheTokens: data.cacheTokens, + runs: data.runs, + }); + } + + // Sort by totalTokens descending + byAgent.sort((a, b) => b.totalTokens - a.totalTokens); + + return { + period, + totalTokens: accumulator.totalTokens, + inputTokens: accumulator.inputTokens, + outputTokens: accumulator.outputTokens, + cacheTokens: accumulator.cacheTokens, + runs, + perSuccessfulRun: { + avg: Math.round(avg), + p50, + p95, + }, + byAgent, + }; +} + +/** + * Get monthly budget metrics for the current month + */ +export async function computeBudgetMetrics( + telemetryDir: string, + tokenBudget: number, + costBudget: number, + warningThreshold: number, + project?: string +): Promise { + const now = new Date(); + const year = now.getFullYear(); + const month = now.getMonth(); + + // Calculate period boundaries + const periodStart = new Date(year, month, 1); + const periodEnd = new Date(year, month + 1, 0); // Last day of month + const daysInMonth = periodEnd.getDate(); + const daysElapsed = now.getDate(); + const daysRemaining = daysInMonth - daysElapsed; + + const since = periodStart.toISOString(); + const files = await getEventFiles(telemetryDir, since); + + // Token accumulator + let totalTokens = 0; + let inputTokens = 0; + let outputTokens = 0; + + // Stream through files for current month only + for (const filePath of files) { + try { + const rl = createLineReader(filePath); + + for await (const line of rl) { + if (!line.trim()) continue; + + try { + const event = JSON.parse(line) as AnyTelemetryEvent; + + // Filter to current month and token events only + if (event.type !== 'run.tokens') continue; + if (event.timestamp < since) continue; + if (project && event.project !== project) continue; + + const tokenEvent = event as TokenTelemetryEvent; + const eventTotal = + tokenEvent.totalTokens ?? tokenEvent.inputTokens + tokenEvent.outputTokens; + + totalTokens += eventTotal; + inputTokens += tokenEvent.inputTokens; + outputTokens += tokenEvent.outputTokens; + } catch { + // Intentionally silent: skip malformed NDJSON line + continue; + } + } + } catch (error: any) { + if (error.code !== 'ENOENT') { + console.error(`[Metrics] Error reading ${filePath}:`, error.message); + } + } + } + + // Cost estimation (simplified pricing model) + // Input: $0.01 per 1K tokens, Output: $0.03 per 1K tokens + const estimatedCost = (inputTokens / 1000) * 0.01 + (outputTokens / 1000) * 0.03; + + // Burn rate calculations + const tokensPerDay = daysElapsed > 0 ? totalTokens / daysElapsed : 0; + const costPerDay = daysElapsed > 0 ? estimatedCost / daysElapsed : 0; + + // Projections + const projectedMonthlyTokens = Math.round(tokensPerDay * daysInMonth); + const projectedMonthlyCost = costPerDay * daysInMonth; + + // Budget percentages + const tokenBudgetUsed = tokenBudget > 0 ? (totalTokens / tokenBudget) * 100 : 0; + const costBudgetUsed = costBudget > 0 ? (estimatedCost / costBudget) * 100 : 0; + const projectedTokenOverage = tokenBudget > 0 ? (projectedMonthlyTokens / tokenBudget) * 100 : 0; + const projectedCostOverage = costBudget > 0 ? (projectedMonthlyCost / costBudget) * 100 : 0; + + // Determine status based on highest usage percentage + let status: 'ok' | 'warning' | 'danger' = 'ok'; + const maxUsage = Math.max( + tokenBudget > 0 ? tokenBudgetUsed : 0, + costBudget > 0 ? costBudgetUsed : 0, + tokenBudget > 0 ? projectedTokenOverage : 0, + costBudget > 0 ? projectedCostOverage : 0 + ); + + if (maxUsage >= 100) { + status = 'danger'; + } else if (maxUsage >= warningThreshold) { + status = 'warning'; + } + + return { + periodStart: periodStart.toISOString().slice(0, 10), + periodEnd: periodEnd.toISOString().slice(0, 10), + daysInMonth, + daysElapsed, + daysRemaining, + totalTokens, + inputTokens, + outputTokens, + estimatedCost: Math.round(estimatedCost * 100) / 100, + tokensPerDay: Math.round(tokensPerDay), + costPerDay: Math.round(costPerDay * 100) / 100, + projectedMonthlyTokens, + projectedMonthlyCost: Math.round(projectedMonthlyCost * 100) / 100, + tokenBudget, + costBudget, + tokenBudgetUsed: Math.round(tokenBudgetUsed * 10) / 10, + costBudgetUsed: Math.round(costBudgetUsed * 10) / 10, + projectedTokenOverage: Math.round(projectedTokenOverage * 10) / 10, + projectedCostOverage: Math.round(projectedCostOverage * 10) / 10, + status, + }; +} diff --git a/server/src/services/metrics/types.ts b/server/src/services/metrics/types.ts new file mode 100644 index 00000000..4223b6b2 --- /dev/null +++ b/server/src/services/metrics/types.ts @@ -0,0 +1,270 @@ +/** + * Type definitions for the metrics service. + * All metric interfaces and type aliases used across the metrics modules. + */ +import type { + TaskStatus, + BlockedCategory, + TelemetryEventType, + AnyTelemetryEvent, +} from '@veritas-kanban/shared'; + +// ── Period & Core Types ───────────────────────────────────────────── + +export type MetricsPeriod = '24h' | '7d' | '30d'; + +export interface TaskMetrics { + byStatus: Record; + byBlockedReason: Record; + total: number; + completed: number; // done + archived + archived: number; +} + +export interface AgentBreakdown { + agent: string; + runs: number; + successes: number; + failures: number; + errors: number; + successRate: number; + avgDurationMs: number; + totalTokens: number; +} + +export interface RunMetrics { + period: MetricsPeriod; + runs: number; + successes: number; + failures: number; + errors: number; + errorRate: number; // (failures + errors) / runs + successRate: number; // successes / runs + byAgent: AgentBreakdown[]; +} + +export interface TokenMetrics { + period: MetricsPeriod; + totalTokens: number; + inputTokens: number; + outputTokens: number; + cacheTokens: number; + runs: number; + perSuccessfulRun: { + avg: number; + p50: number; + p95: number; + }; + byAgent: Array<{ + agent: string; + totalTokens: number; + inputTokens: number; + outputTokens: number; + cacheTokens: number; + runs: number; + }>; +} + +export interface DurationMetrics { + period: MetricsPeriod; + runs: number; + avgMs: number; + p50Ms: number; + p95Ms: number; + byAgent: Array<{ + agent: string; + runs: number; + avgMs: number; + p50Ms: number; + p95Ms: number; + }>; +} + +// ── Trend Types ───────────────────────────────────────────────────── + +// Trend direction: positive means improvement (more runs, higher success, etc.) +export type TrendDirection = 'up' | 'down' | 'flat'; + +export interface TrendComparison { + runsTrend: TrendDirection; + runsChange: number; // percentage change + successRateTrend: TrendDirection; + successRateChange: number; + tokensTrend: TrendDirection; + tokensChange: number; + durationTrend: TrendDirection; + durationChange: number; +} + +// ── Internal Accumulator Types ────────────────────────────────────── + +export interface RunAccumulator { + successes: number; + failures: number; + errors: number; + durations: number[]; + byAgent: Map< + string, + { + successes: number; + failures: number; + errors: number; + durations: number[]; + } + >; +} + +export interface TokenAccumulator { + totalTokens: number; + inputTokens: number; + outputTokens: number; + cacheTokens: number; + tokensPerRun: number[]; + byAgent: Map< + string, + { + totalTokens: number; + inputTokens: number; + outputTokens: number; + cacheTokens: number; + runs: number; + } + >; +} + +// ── Failed Runs ───────────────────────────────────────────────────── + +export interface FailedRunDetails { + timestamp: string; + taskId?: string; + project?: string; + agent: string; + success: boolean; + errorMessage?: string; + durationMs?: number; +} + +// ── Daily Trends ──────────────────────────────────────────────────── + +export interface DailyTrendPoint { + date: string; // YYYY-MM-DD + runs: number; + successes: number; + failures: number; + errors: number; + successRate: number; + totalTokens: number; + inputTokens: number; + outputTokens: number; + avgDurationMs: number; +} + +export interface TrendsData { + period: '7d' | '30d'; + daily: DailyTrendPoint[]; +} + +// ── Budget Metrics ────────────────────────────────────────────────── + +export interface BudgetMetrics { + periodStart: string; // Start of current month (YYYY-MM-DD) + periodEnd: string; // End of current month (YYYY-MM-DD) + daysInMonth: number; + daysElapsed: number; + daysRemaining: number; + + // Token usage + totalTokens: number; + inputTokens: number; + outputTokens: number; + + // Cost estimation (simplified: $0.01 per 1K tokens input, $0.03 per 1K output) + estimatedCost: number; + + // Burn rate calculations + tokensPerDay: number; // Average tokens per day so far + costPerDay: number; // Average cost per day + + // Projections + projectedMonthlyTokens: number; + projectedMonthlyCost: number; + + // Budget status + tokenBudget: number; // From settings (0 = no limit) + costBudget: number; // From settings (0 = no limit) + tokenBudgetUsed: number; // Percentage used (0-100+) + costBudgetUsed: number; // Percentage used (0-100+) + projectedTokenOverage: number; // Percentage of projected vs budget (0-100+) + projectedCostOverage: number; // Percentage of projected vs budget (0-100+) + + // Status indicator + status: 'ok' | 'warning' | 'danger'; // Based on warningThreshold +} + +// ── Agent Comparison ──────────────────────────────────────────────── + +/** Agent comparison data for a single agent */ +export interface AgentComparisonData { + agent: string; + runs: number; + successes: number; + failures: number; + successRate: number; // Percentage (0-100) + avgDurationMs: number; + avgTokensPerRun: number; + totalTokens: number; + avgCostPerRun: number; // Estimated cost per run + totalCost: number; // Total estimated cost +} + +/** Recommendation for best agent in a category */ +export interface AgentRecommendation { + category: 'reliability' | 'speed' | 'cost' | 'efficiency'; + agent: string; + value: string; // Human-readable value (e.g., "95.5%", "2.3m") + reason: string; // Explanation +} + +/** Full agent comparison result */ +export interface AgentComparisonResult { + period: MetricsPeriod; + minRuns: number; + agents: AgentComparisonData[]; + recommendations: AgentRecommendation[]; + totalAgents: number; // Total agents found (before minRuns filter) + qualifyingAgents: number; // Agents meeting minRuns threshold +} + +// ── Sprint Velocity ───────────────────────────────────────────────── + +export type VelocityTrend = 'accelerating' | 'steady' | 'slowing'; + +export interface SprintVelocityPoint { + sprint: string; // Sprint identifier (e.g., "US-100") + completed: number; // Tasks completed in this sprint + total: number; // Total tasks in this sprint + rollingAverage: number; // 3-sprint rolling average at this point + byType: Record; // Breakdown by task type +} + +export interface CurrentSprintProgress { + sprint: string; + completed: number; + total: number; + percentComplete: number; // 0-100 + vsAverage: number; // Percentage vs historical average (-100 to +100+) +} + +export interface VelocityMetrics { + sprints: SprintVelocityPoint[]; // Sprint data (oldest to newest) + averageVelocity: number; // Overall average tasks per sprint + trend: VelocityTrend; // Current trend indicator + currentSprint?: CurrentSprintProgress; // Progress on current/active sprint +} + +// ── Stream Event Handler ──────────────────────────────────────────── + +export type StreamEventHandler = (event: AnyTelemetryEvent, acc: T) => void; + +// Re-export shared types used by consumers +export type { TelemetryEventType, AnyTelemetryEvent }; diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts deleted file mode 100644 index 7b72c11f..00000000 --- a/web/src/lib/api.ts +++ /dev/null @@ -1,1038 +0,0 @@ -import type { - Task, - CreateTaskInput, - UpdateTaskInput, - AppConfig, - RepoConfig, - AgentConfig, - AgentType, - TaskTemplate, - CreateTemplateInput, - UpdateTemplateInput, - TaskTypeConfig, - SprintConfig, - FeatureSettings, - Attachment, -} from '@veritas-kanban/shared'; -import { API_BASE } from './config'; - -async function handleResponse(response: Response): Promise { - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Unknown error' })); - throw new Error(error.error || `HTTP ${response.status}`); - } - if (response.status === 204) { - return undefined as T; - } - return response.json(); -} - -export const api = { - tasks: { - list: async (): Promise => { - const response = await fetch(`${API_BASE}/tasks`); - return handleResponse(response); - }, - - listArchived: async (): Promise => { - const response = await fetch(`${API_BASE}/tasks/archived`); - return handleResponse(response); - }, - - get: async (id: string): Promise => { - const response = await fetch(`${API_BASE}/tasks/${id}`); - return handleResponse(response); - }, - - create: async (input: CreateTaskInput): Promise => { - const response = await fetch(`${API_BASE}/tasks`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(input), - }); - return handleResponse(response); - }, - - update: async (id: string, input: UpdateTaskInput): Promise => { - const response = await fetch(`${API_BASE}/tasks/${id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(input), - }); - return handleResponse(response); - }, - - delete: async (id: string): Promise => { - const response = await fetch(`${API_BASE}/tasks/${id}`, { - method: 'DELETE', - }); - return handleResponse(response); - }, - - archive: async (id: string): Promise => { - const response = await fetch(`${API_BASE}/tasks/${id}/archive`, { - method: 'POST', - }); - return handleResponse(response); - }, - - bulkArchive: async (sprint: string): Promise<{ archived: string[]; count: number }> => { - const response = await fetch(`${API_BASE}/tasks/bulk-archive`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sprint }), - }); - return handleResponse(response); - }, - - restore: async (id: string): Promise => { - const response = await fetch(`${API_BASE}/tasks/${id}/restore`, { - method: 'POST', - }); - return handleResponse(response); - }, - - getArchiveSuggestions: async (): Promise => { - const response = await fetch(`${API_BASE}/tasks/archive/suggestions`); - return handleResponse(response); - }, - - archiveSprint: async (sprint: string): Promise<{ archived: number; taskIds: string[] }> => { - const response = await fetch(`${API_BASE}/tasks/archive/sprint/${encodeURIComponent(sprint)}`, { - method: 'POST', - }); - return handleResponse<{ archived: number; taskIds: string[] }>(response); - }, - - addSubtask: async (taskId: string, title: string): Promise => { - const response = await fetch(`${API_BASE}/tasks/${taskId}/subtasks`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title }), - }); - return handleResponse(response); - }, - - updateSubtask: async (taskId: string, subtaskId: string, updates: { title?: string; completed?: boolean }): Promise => { - const response = await fetch(`${API_BASE}/tasks/${taskId}/subtasks/${subtaskId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(updates), - }); - return handleResponse(response); - }, - - deleteSubtask: async (taskId: string, subtaskId: string): Promise => { - const response = await fetch(`${API_BASE}/tasks/${taskId}/subtasks/${subtaskId}`, { - method: 'DELETE', - }); - return handleResponse(response); - }, - - addComment: async (taskId: string, author: string, text: string): Promise => { - const response = await fetch(`${API_BASE}/tasks/${taskId}/comments`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ author, text }), - }); - return handleResponse(response); - }, - - editComment: async (taskId: string, commentId: string, text: string): Promise => { - const response = await fetch(`${API_BASE}/tasks/${taskId}/comments/${commentId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text }), - }); - return handleResponse(response); - }, - - deleteComment: async (taskId: string, commentId: string): Promise => { - const response = await fetch(`${API_BASE}/tasks/${taskId}/comments/${commentId}`, { - method: 'DELETE', - }); - return handleResponse(response); - }, - - getBlockingStatus: async (taskId: string): Promise<{ - isBlocked: boolean; - blockers: Array<{ id: string; title: string; status: string }>; - completedBlockers: Array<{ id: string; title: string }>; - }> => { - const response = await fetch(`${API_BASE}/tasks/${taskId}/blocking-status`); - return handleResponse(response); - }, - - reorder: async (orderedIds: string[]): Promise<{ updated: number }> => { - const response = await fetch(`${API_BASE}/tasks/reorder`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ orderedIds }), - }); - return handleResponse<{ updated: number }>(response); - }, - - applyTemplate: async (taskId: string, templateId: string, templateName: string, fieldsChanged: string[]): Promise => { - const response = await fetch(`${API_BASE}/tasks/${taskId}/apply-template`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ templateId, templateName, fieldsChanged }), - }); - return handleResponse(response); - }, - }, - - settings: { - getFeatures: async (): Promise => { - const response = await fetch(`${API_BASE}/settings/features`); - return handleResponse(response); - }, - - updateFeatures: async (patch: Partial): Promise => { - const response = await fetch(`${API_BASE}/settings/features`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(patch), - }); - return handleResponse(response); - }, - }, - - config: { - get: async (): Promise => { - const response = await fetch(`${API_BASE}/config`); - return handleResponse(response); - }, - - repos: { - list: async (): Promise => { - const response = await fetch(`${API_BASE}/config/repos`); - return handleResponse(response); - }, - - add: async (repo: RepoConfig): Promise => { - const response = await fetch(`${API_BASE}/config/repos`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(repo), - }); - return handleResponse(response); - }, - - update: async (name: string, updates: Partial): Promise => { - const response = await fetch(`${API_BASE}/config/repos/${encodeURIComponent(name)}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(updates), - }); - return handleResponse(response); - }, - - remove: async (name: string): Promise => { - const response = await fetch(`${API_BASE}/config/repos/${encodeURIComponent(name)}`, { - method: 'DELETE', - }); - return handleResponse(response); - }, - - validate: async (path: string): Promise<{ valid: boolean; branches: string[] }> => { - const response = await fetch(`${API_BASE}/config/repos/validate`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ path }), - }); - return handleResponse<{ valid: boolean; branches: string[] }>(response); - }, - - branches: async (name: string): Promise => { - const response = await fetch(`${API_BASE}/config/repos/${encodeURIComponent(name)}/branches`); - return handleResponse(response); - }, - }, - - agents: { - update: async (agents: AgentConfig[]): Promise => { - const response = await fetch(`${API_BASE}/config/agents`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(agents), - }); - return handleResponse(response); - }, - - setDefault: async (agent: AgentType): Promise => { - const response = await fetch(`${API_BASE}/config/default-agent`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ agent }), - }); - return handleResponse(response); - }, - }, - }, - - worktree: { - create: async (taskId: string): Promise => { - const response = await fetch(`${API_BASE}/tasks/${taskId}/worktree`, { - method: 'POST', - }); - return handleResponse(response); - }, - - status: async (taskId: string): Promise => { - const response = await fetch(`${API_BASE}/tasks/${taskId}/worktree`); - return handleResponse(response); - }, - - delete: async (taskId: string, force: boolean = false): Promise => { - const response = await fetch(`${API_BASE}/tasks/${taskId}/worktree?force=${force}`, { - method: 'DELETE', - }); - return handleResponse(response); - }, - - rebase: async (taskId: string): Promise => { - const response = await fetch(`${API_BASE}/tasks/${taskId}/worktree/rebase`, { - method: 'POST', - }); - return handleResponse(response); - }, - - merge: async (taskId: string): Promise => { - const response = await fetch(`${API_BASE}/tasks/${taskId}/worktree/merge`, { - method: 'POST', - }); - return handleResponse(response); - }, - - getOpenCommand: async (taskId: string): Promise<{ command: string }> => { - const response = await fetch(`${API_BASE}/tasks/${taskId}/worktree/open`); - return handleResponse<{ command: string }>(response); - }, - }, - - agent: { - // Global agent status (not per-task) - globalStatus: async (): Promise => { - const response = await fetch(`${API_BASE}/agent/status`); - return handleResponse(response); - }, - - start: async (taskId: string, agent?: AgentType): Promise => { - const response = await fetch(`${API_BASE}/agents/${taskId}/start`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ agent }), - }); - return handleResponse(response); - }, - - sendMessage: async (taskId: string, message: string): Promise => { - const response = await fetch(`${API_BASE}/agents/${taskId}/message`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ message }), - }); - return handleResponse(response); - }, - - stop: async (taskId: string): Promise => { - const response = await fetch(`${API_BASE}/agents/${taskId}/stop`, { - method: 'POST', - }); - return handleResponse(response); - }, - - status: async (taskId: string): Promise => { - const response = await fetch(`${API_BASE}/agents/${taskId}/status`); - return handleResponse(response); - }, - - listAttempts: async (taskId: string): Promise => { - const response = await fetch(`${API_BASE}/agents/${taskId}/attempts`); - return handleResponse(response); - }, - - getLog: async (taskId: string, attemptId: string): Promise => { - const response = await fetch(`${API_BASE}/agents/${taskId}/attempts/${attemptId}/log`); - if (!response.ok) { - throw new Error('Failed to fetch log'); - } - return response.text(); - }, - }, - - diff: { - getSummary: async (taskId: string): Promise => { - const response = await fetch(`${API_BASE}/diff/${taskId}`); - return handleResponse(response); - }, - - getFileDiff: async (taskId: string, filePath: string): Promise => { - const response = await fetch(`${API_BASE}/diff/${taskId}/file?path=${encodeURIComponent(filePath)}`); - return handleResponse(response); - }, - - getFullDiff: async (taskId: string): Promise => { - const response = await fetch(`${API_BASE}/diff/${taskId}/full`); - return handleResponse(response); - }, - }, - - templates: { - list: async (): Promise => { - const response = await fetch(`${API_BASE}/templates`); - return handleResponse(response); - }, - - get: async (id: string): Promise => { - const response = await fetch(`${API_BASE}/templates/${id}`); - return handleResponse(response); - }, - - create: async (input: CreateTemplateInput): Promise => { - const response = await fetch(`${API_BASE}/templates`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(input), - }); - return handleResponse(response); - }, - - update: async (id: string, input: UpdateTemplateInput): Promise => { - const response = await fetch(`${API_BASE}/templates/${id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(input), - }); - return handleResponse(response); - }, - - delete: async (id: string): Promise => { - const response = await fetch(`${API_BASE}/templates/${id}`, { - method: 'DELETE', - }); - return handleResponse(response); - }, - }, - - taskTypes: { - list: async (): Promise => { - const response = await fetch(`${API_BASE}/task-types`); - return handleResponse(response); - }, - - get: async (id: string): Promise => { - const response = await fetch(`${API_BASE}/task-types/${id}`); - return handleResponse(response); - }, - - create: async (input: { label: string; icon: string; color?: string }): Promise => { - const response = await fetch(`${API_BASE}/task-types`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(input), - }); - return handleResponse(response); - }, - - update: async (id: string, patch: Partial): Promise => { - const response = await fetch(`${API_BASE}/task-types/${id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(patch), - }); - return handleResponse(response); - }, - - delete: async (id: string, force = false): Promise => { - const url = force - ? `${API_BASE}/task-types/${id}?force=true` - : `${API_BASE}/task-types/${id}`; - const response = await fetch(url, { - method: 'DELETE', - }); - return handleResponse(response); - }, - - canDelete: async (id: string): Promise<{ allowed: boolean; referenceCount: number; isDefault: boolean }> => { - const response = await fetch(`${API_BASE}/task-types/${id}/can-delete`); - return handleResponse(response); - }, - - reorder: async (orderedIds: string[]): Promise => { - const response = await fetch(`${API_BASE}/task-types/reorder`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ orderedIds }), - }); - return handleResponse(response); - }, - }, - - sprints: { - list: async (): Promise => { - const response = await fetch(`${API_BASE}/sprints`); - return handleResponse(response); - }, - - get: async (id: string): Promise => { - const response = await fetch(`${API_BASE}/sprints/${id}`); - return handleResponse(response); - }, - - create: async (input: { label: string; description?: string }): Promise => { - const response = await fetch(`${API_BASE}/sprints`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(input), - }); - return handleResponse(response); - }, - - update: async (id: string, patch: Partial): Promise => { - const response = await fetch(`${API_BASE}/sprints/${id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(patch), - }); - return handleResponse(response); - }, - - delete: async (id: string, force = false): Promise => { - const url = force - ? `${API_BASE}/sprints/${id}?force=true` - : `${API_BASE}/sprints/${id}`; - const response = await fetch(url, { - method: 'DELETE', - }); - return handleResponse(response); - }, - - canDelete: async (id: string): Promise<{ allowed: boolean; referenceCount: number; isDefault: boolean }> => { - const response = await fetch(`${API_BASE}/sprints/${id}/can-delete`); - return handleResponse(response); - }, - - reorder: async (orderedIds: string[]): Promise => { - const response = await fetch(`${API_BASE}/sprints/reorder`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ orderedIds }), - }); - return handleResponse(response); - }, - }, - - activity: { - list: async (limit: number = 50): Promise => { - const response = await fetch(`${API_BASE}/activity?limit=${limit}`); - return handleResponse(response); - }, - - clear: async (): Promise => { - const response = await fetch(`${API_BASE}/activity`, { - method: 'DELETE', - }); - return handleResponse(response); - }, - }, - - attachments: { - list: async (taskId: string): Promise => { - const response = await fetch(`${API_BASE}/tasks/${taskId}/attachments`); - return handleResponse(response); - }, - - upload: async (taskId: string, formData: FormData): Promise => { - const response = await fetch(`${API_BASE}/tasks/${taskId}/attachments`, { - method: 'POST', - body: formData, - }); - return handleResponse(response); - }, - - delete: async (taskId: string, attachmentId: string): Promise => { - const response = await fetch(`${API_BASE}/tasks/${taskId}/attachments/${attachmentId}`, { - method: 'DELETE', - }); - return handleResponse(response); - }, - - getTaskContext: async (taskId: string): Promise => { - const response = await fetch(`${API_BASE}/tasks/${taskId}/context`); - return handleResponse(response); - }, - }, - - conflicts: { - getStatus: async (taskId: string): Promise => { - const response = await fetch(`${API_BASE}/conflicts/${taskId}`); - return handleResponse(response); - }, - - getFile: async (taskId: string, filePath: string): Promise => { - const response = await fetch(`${API_BASE}/conflicts/${taskId}/file?path=${encodeURIComponent(filePath)}`); - return handleResponse(response); - }, - - resolve: async ( - taskId: string, - filePath: string, - resolution: 'ours' | 'theirs' | 'manual', - manualContent?: string - ): Promise => { - const response = await fetch(`${API_BASE}/conflicts/${taskId}/resolve?path=${encodeURIComponent(filePath)}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ resolution, manualContent }), - }); - return handleResponse(response); - }, - - abort: async (taskId: string): Promise<{ success: boolean }> => { - const response = await fetch(`${API_BASE}/conflicts/${taskId}/abort`, { - method: 'POST', - }); - return handleResponse<{ success: boolean }>(response); - }, - - continue: async (taskId: string, message?: string): Promise<{ success: boolean; error?: string }> => { - const response = await fetch(`${API_BASE}/conflicts/${taskId}/continue`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ message }), - }); - return handleResponse<{ success: boolean; error?: string }>(response); - }, - }, - - github: { - getStatus: async (): Promise => { - const response = await fetch(`${API_BASE}/github/status`); - return handleResponse(response); - }, - - createPR: async (input: CreatePRInput): Promise => { - const response = await fetch(`${API_BASE}/github/pr`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(input), - }); - return handleResponse(response); - }, - - openPR: async (taskId: string): Promise => { - const response = await fetch(`${API_BASE}/github/pr/${taskId}/open`, { - method: 'POST', - }); - return handleResponse(response); - }, - }, - - preview: { - getStatus: async (taskId: string): Promise => { - const response = await fetch(`${API_BASE}/preview/${taskId}`); - return handleResponse(response); - }, - - getOutput: async (taskId: string, lines: number = 50): Promise<{ output: string[] }> => { - const response = await fetch(`${API_BASE}/preview/${taskId}/output?lines=${lines}`); - return handleResponse<{ output: string[] }>(response); - }, - - start: async (taskId: string): Promise => { - const response = await fetch(`${API_BASE}/preview/${taskId}/start`, { - method: 'POST', - }); - return handleResponse(response); - }, - - stop: async (taskId: string): Promise => { - const response = await fetch(`${API_BASE}/preview/${taskId}/stop`, { - method: 'POST', - }); - return handleResponse(response); - }, - }, - - time: { - getSummary: async (): Promise => { - const response = await fetch(`${API_BASE}/tasks/time/summary`); - return handleResponse(response); - }, - - start: async (taskId: string): Promise => { - const response = await fetch(`${API_BASE}/tasks/${taskId}/time/start`, { - method: 'POST', - }); - return handleResponse(response); - }, - - stop: async (taskId: string): Promise => { - const response = await fetch(`${API_BASE}/tasks/${taskId}/time/stop`, { - method: 'POST', - }); - return handleResponse(response); - }, - - addEntry: async (taskId: string, duration: number, description?: string): Promise => { - const response = await fetch(`${API_BASE}/tasks/${taskId}/time/entry`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ duration, description }), - }); - return handleResponse(response); - }, - - deleteEntry: async (taskId: string, entryId: string): Promise => { - const response = await fetch(`${API_BASE}/tasks/${taskId}/time/entry/${entryId}`, { - method: 'DELETE', - }); - return handleResponse(response); - }, - }, - - statusHistory: { - list: async (limit: number = 100, offset: number = 0): Promise => { - const response = await fetch(`${API_BASE}/status-history?limit=${limit}&offset=${offset}`); - return handleResponse(response); - }, - - getDailySummary: async (date?: string): Promise => { - const url = date - ? `${API_BASE}/status-history/summary/daily?date=${date}` - : `${API_BASE}/status-history/summary/daily`; - const response = await fetch(url); - return handleResponse(response); - }, - - getWeeklySummary: async (): Promise => { - const response = await fetch(`${API_BASE}/status-history/summary/weekly`); - return handleResponse(response); - }, - - getByDateRange: async (startDate: string, endDate: string): Promise => { - const response = await fetch( - `${API_BASE}/status-history/range?startDate=${encodeURIComponent(startDate)}&endDate=${encodeURIComponent(endDate)}` - ); - return handleResponse(response); - }, - - clear: async (): Promise => { - const response = await fetch(`${API_BASE}/status-history`, { - method: 'DELETE', - }); - return handleResponse(response); - }, - }, -}; - -// Types for archive suggestions -export interface ArchiveSuggestion { - sprint: string; - taskCount: number; - tasks: Task[]; -} - -// Types for diff -export interface FileChange { - path: string; - status: 'added' | 'modified' | 'deleted' | 'renamed'; - additions: number; - deletions: number; - oldPath?: string; -} - -export interface DiffSummary { - files: FileChange[]; - totalAdditions: number; - totalDeletions: number; - totalFiles: number; -} - -export interface DiffHunk { - oldStart: number; - oldLines: number; - newStart: number; - newLines: number; - lines: DiffLine[]; -} - -export interface DiffLine { - type: 'context' | 'add' | 'delete'; - content: string; - oldNumber?: number; - newNumber?: number; -} - -export interface FileDiff { - path: string; - status: 'added' | 'modified' | 'deleted' | 'renamed'; - hunks: DiffHunk[]; - language: string; - additions: number; - deletions: number; -} - -// Types for agent -export interface AgentStatus { - taskId: string; - attemptId: string; - agent: AgentType; - status: string; - pid?: number; - startedAt?: string; -} - -export interface AgentStatusResponse { - running: boolean; - taskId?: string; - attemptId?: string; - agent?: AgentType; - status?: string; - pid?: number; -} - -export interface AgentOutput { - type: 'stdout' | 'stderr' | 'stdin' | 'system'; - content: string; - timestamp: string; -} - -// Global agent status (not per-task) -export interface GlobalAgentStatus { - status: 'idle' | 'working' | 'thinking' | 'sub-agent' | 'error'; - subAgentCount: number; - activeTask?: string; - activeTaskTitle?: string; - lastUpdated: string; - error?: string; -} - -// Types for worktree -export interface WorktreeInfo { - path: string; - branch: string; - baseBranch: string; - aheadBehind: { - ahead: number; - behind: number; - }; - hasChanges: boolean; - changedFiles: number; -} - -// Types for activity -export type ActivityType = - | 'task_created' - | 'task_updated' - | 'status_changed' - | 'agent_started' - | 'agent_stopped' - | 'agent_completed' - | 'task_archived' - | 'task_deleted' - | 'worktree_created' - | 'worktree_merged'; - -export interface Activity { - id: string; - type: ActivityType; - taskId: string; - taskTitle: string; - details?: Record; - timestamp: string; -} - -// Types for attachments -export interface AttachmentUploadResponse { - success: boolean; - attachments: Attachment[]; - task: unknown; -} - -export interface TaskContext { - taskId: string; - title: string; - description: string; - type: string; - status: string; - priority: string; - project?: string; - tags?: string[]; - attachments: { - count: number; - documents: { filename: string; text: string }[]; - images: string[]; - }; - created: string; - updated: string; -} - -// Types for conflicts -export interface ConflictStatus { - hasConflicts: boolean; - conflictingFiles: string[]; - rebaseInProgress: boolean; - mergeInProgress: boolean; -} - -export interface ConflictMarker { - startLine: number; - separatorLine: number; - endLine: number; - oursLines: string[]; - theirsLines: string[]; -} - -export interface ConflictFile { - path: string; - content: string; - oursContent: string; - theirsContent: string; - baseContent: string; - markers: ConflictMarker[]; -} - -export interface ResolveResult { - success: boolean; - remainingConflicts: string[]; -} - -// Types for GitHub -export interface GitHubStatus { - installed: boolean; - authenticated: boolean; - user?: string; -} - -export interface PRInfo { - url: string; - number: number; - title: string; - state: string; - draft: boolean; - headBranch: string; - baseBranch: string; -} - -export interface CreatePRInput { - taskId: string; - title?: string; - body?: string; - targetBranch?: string; - draft?: boolean; -} - -// Types for preview -export interface PreviewServer { - taskId: string; - repoName: string; - pid: number; - port: number; - url: string; - status: 'starting' | 'running' | 'stopped' | 'error'; - startedAt: string; - output: string[]; - error?: string; -} - -// Types for time tracking -export interface TimeSummary { - byProject: { project: string; totalSeconds: number; taskCount: number }[]; - total: number; -} - -// Status history types -export type AgentStatusState = 'idle' | 'working' | 'thinking' | 'sub-agent' | 'error'; - -export interface StatusHistoryEntry { - id: string; - timestamp: string; - previousStatus: AgentStatusState; - newStatus: AgentStatusState; - taskId?: string; - taskTitle?: string; - subAgentCount?: number; - durationMs?: number; -} - -export interface StatusPeriod { - status: AgentStatusState; - startTime: string; - endTime: string; - durationMs: number; - taskId?: string; - taskTitle?: string; -} - -export interface DailySummary { - date: string; - activeMs: number; - idleMs: number; - errorMs: number; - transitions: number; - periods: StatusPeriod[]; -} - -// Managed List API helpers -export const managedList = { - /** - * Create API helpers for a managed list endpoint - */ - createHelpers: (endpoint: string) => ({ - list: async (includeHidden = false): Promise => { - const url = includeHidden - ? `${API_BASE}${endpoint}?includeHidden=true` - : `${API_BASE}${endpoint}`; - const response = await fetch(url); - return handleResponse(response); - }, - - get: async (id: string): Promise => { - const response = await fetch(`${API_BASE}${endpoint}/${id}`); - return handleResponse(response); - }, - - create: async (input: any): Promise => { - const response = await fetch(`${API_BASE}${endpoint}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(input), - }); - return handleResponse(response); - }, - - update: async (id: string, patch: any): Promise => { - const response = await fetch(`${API_BASE}${endpoint}/${id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(patch), - }); - return handleResponse(response); - }, - - remove: async (id: string, force = false): Promise => { - const url = force - ? `${API_BASE}${endpoint}/${id}?force=true` - : `${API_BASE}${endpoint}/${id}`; - const response = await fetch(url, { method: 'DELETE' }); - return handleResponse(response); - }, - - canDelete: async (id: string): Promise<{ allowed: boolean; referenceCount: number; isDefault: boolean }> => { - const response = await fetch(`${API_BASE}${endpoint}/${id}/can-delete`); - return handleResponse(response); - }, - - reorder: async (orderedIds: string[]): Promise => { - const response = await fetch(`${API_BASE}${endpoint}/reorder`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ orderedIds }), - }); - return handleResponse(response); - }, - }), -}; diff --git a/web/src/lib/api/agent.ts b/web/src/lib/api/agent.ts new file mode 100644 index 00000000..497a0f80 --- /dev/null +++ b/web/src/lib/api/agent.ts @@ -0,0 +1,181 @@ +/** + * Agent, worktree, and preview API endpoints. + */ +import type { AgentType } from '@veritas-kanban/shared'; +import { API_BASE, handleResponse } from './helpers'; + +export const worktreeApi = { + create: async (taskId: string): Promise => { + const response = await fetch(`${API_BASE}/tasks/${taskId}/worktree`, { + method: 'POST', + }); + return handleResponse(response); + }, + + status: async (taskId: string): Promise => { + const response = await fetch(`${API_BASE}/tasks/${taskId}/worktree`); + return handleResponse(response); + }, + + delete: async (taskId: string, force: boolean = false): Promise => { + const response = await fetch(`${API_BASE}/tasks/${taskId}/worktree?force=${force}`, { + method: 'DELETE', + }); + return handleResponse(response); + }, + + rebase: async (taskId: string): Promise => { + const response = await fetch(`${API_BASE}/tasks/${taskId}/worktree/rebase`, { + method: 'POST', + }); + return handleResponse(response); + }, + + merge: async (taskId: string): Promise => { + const response = await fetch(`${API_BASE}/tasks/${taskId}/worktree/merge`, { + method: 'POST', + }); + return handleResponse(response); + }, + + getOpenCommand: async (taskId: string): Promise<{ command: string }> => { + const response = await fetch(`${API_BASE}/tasks/${taskId}/worktree/open`); + return handleResponse<{ command: string }>(response); + }, +}; + +export const agentApi = { + // Global agent status (not per-task) + globalStatus: async (): Promise => { + const response = await fetch(`${API_BASE}/agent/status`); + return handleResponse(response); + }, + + start: async (taskId: string, agent?: AgentType): Promise => { + const response = await fetch(`${API_BASE}/agents/${taskId}/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ agent }), + }); + return handleResponse(response); + }, + + sendMessage: async (taskId: string, message: string): Promise => { + const response = await fetch(`${API_BASE}/agents/${taskId}/message`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message }), + }); + return handleResponse(response); + }, + + stop: async (taskId: string): Promise => { + const response = await fetch(`${API_BASE}/agents/${taskId}/stop`, { + method: 'POST', + }); + return handleResponse(response); + }, + + status: async (taskId: string): Promise => { + const response = await fetch(`${API_BASE}/agents/${taskId}/status`); + return handleResponse(response); + }, + + listAttempts: async (taskId: string): Promise => { + const response = await fetch(`${API_BASE}/agents/${taskId}/attempts`); + return handleResponse(response); + }, + + getLog: async (taskId: string, attemptId: string): Promise => { + const response = await fetch(`${API_BASE}/agents/${taskId}/attempts/${attemptId}/log`); + if (!response.ok) { + throw new Error('Failed to fetch log'); + } + return response.text(); + }, +}; + +export const previewApi = { + getStatus: async (taskId: string): Promise => { + const response = await fetch(`${API_BASE}/preview/${taskId}`); + return handleResponse(response); + }, + + getOutput: async (taskId: string, lines: number = 50): Promise<{ output: string[] }> => { + const response = await fetch(`${API_BASE}/preview/${taskId}/output?lines=${lines}`); + return handleResponse<{ output: string[] }>(response); + }, + + start: async (taskId: string): Promise => { + const response = await fetch(`${API_BASE}/preview/${taskId}/start`, { + method: 'POST', + }); + return handleResponse(response); + }, + + stop: async (taskId: string): Promise => { + const response = await fetch(`${API_BASE}/preview/${taskId}/stop`, { + method: 'POST', + }); + return handleResponse(response); + }, +}; + +// Types +export interface AgentStatus { + taskId: string; + attemptId: string; + agent: AgentType; + status: string; + pid?: number; + startedAt?: string; +} + +export interface AgentStatusResponse { + running: boolean; + taskId?: string; + attemptId?: string; + agent?: AgentType; + status?: string; + pid?: number; +} + +export interface AgentOutput { + type: 'stdout' | 'stderr' | 'stdin' | 'system'; + content: string; + timestamp: string; +} + +// Global agent status (not per-task) +export interface GlobalAgentStatus { + status: 'idle' | 'working' | 'thinking' | 'sub-agent' | 'error'; + subAgentCount: number; + activeTask?: string; + activeTaskTitle?: string; + lastUpdated: string; + error?: string; +} + +export interface WorktreeInfo { + path: string; + branch: string; + baseBranch: string; + aheadBehind: { + ahead: number; + behind: number; + }; + hasChanges: boolean; + changedFiles: number; +} + +export interface PreviewServer { + taskId: string; + repoName: string; + pid: number; + port: number; + url: string; + status: 'starting' | 'running' | 'stopped' | 'error'; + startedAt: string; + output: string[]; + error?: string; +} diff --git a/web/src/lib/api/config.ts b/web/src/lib/api/config.ts new file mode 100644 index 00000000..b6de39ca --- /dev/null +++ b/web/src/lib/api/config.ts @@ -0,0 +1,100 @@ +/** + * Configuration and settings API endpoints. + */ +import type { + AppConfig, + RepoConfig, + AgentConfig, + AgentType, + FeatureSettings, +} from '@veritas-kanban/shared'; +import { API_BASE, handleResponse } from './helpers'; + +export const settingsApi = { + getFeatures: async (): Promise => { + const response = await fetch(`${API_BASE}/settings/features`); + return handleResponse(response); + }, + + updateFeatures: async (patch: Partial): Promise => { + const response = await fetch(`${API_BASE}/settings/features`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(patch), + }); + return handleResponse(response); + }, +}; + +export const configApi = { + get: async (): Promise => { + const response = await fetch(`${API_BASE}/config`); + return handleResponse(response); + }, + + repos: { + list: async (): Promise => { + const response = await fetch(`${API_BASE}/config/repos`); + return handleResponse(response); + }, + + add: async (repo: RepoConfig): Promise => { + const response = await fetch(`${API_BASE}/config/repos`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(repo), + }); + return handleResponse(response); + }, + + update: async (name: string, updates: Partial): Promise => { + const response = await fetch(`${API_BASE}/config/repos/${encodeURIComponent(name)}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updates), + }); + return handleResponse(response); + }, + + remove: async (name: string): Promise => { + const response = await fetch(`${API_BASE}/config/repos/${encodeURIComponent(name)}`, { + method: 'DELETE', + }); + return handleResponse(response); + }, + + validate: async (path: string): Promise<{ valid: boolean; branches: string[] }> => { + const response = await fetch(`${API_BASE}/config/repos/validate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path }), + }); + return handleResponse<{ valid: boolean; branches: string[] }>(response); + }, + + branches: async (name: string): Promise => { + const response = await fetch(`${API_BASE}/config/repos/${encodeURIComponent(name)}/branches`); + return handleResponse(response); + }, + }, + + agents: { + update: async (agents: AgentConfig[]): Promise => { + const response = await fetch(`${API_BASE}/config/agents`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(agents), + }); + return handleResponse(response); + }, + + setDefault: async (agent: AgentType): Promise => { + const response = await fetch(`${API_BASE}/config/default-agent`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ agent }), + }); + return handleResponse(response); + }, + }, +}; diff --git a/web/src/lib/api/diff.ts b/web/src/lib/api/diff.ts new file mode 100644 index 00000000..a0271139 --- /dev/null +++ b/web/src/lib/api/diff.ts @@ -0,0 +1,191 @@ +/** + * Diff, conflicts, and GitHub API endpoints. + */ +import { API_BASE, handleResponse } from './helpers'; + +export const diffApi = { + getSummary: async (taskId: string): Promise => { + const response = await fetch(`${API_BASE}/diff/${taskId}`); + return handleResponse(response); + }, + + getFileDiff: async (taskId: string, filePath: string): Promise => { + const response = await fetch( + `${API_BASE}/diff/${taskId}/file?path=${encodeURIComponent(filePath)}` + ); + return handleResponse(response); + }, + + getFullDiff: async (taskId: string): Promise => { + const response = await fetch(`${API_BASE}/diff/${taskId}/full`); + return handleResponse(response); + }, +}; + +export const conflictsApi = { + getStatus: async (taskId: string): Promise => { + const response = await fetch(`${API_BASE}/conflicts/${taskId}`); + return handleResponse(response); + }, + + getFile: async (taskId: string, filePath: string): Promise => { + const response = await fetch( + `${API_BASE}/conflicts/${taskId}/file?path=${encodeURIComponent(filePath)}` + ); + return handleResponse(response); + }, + + resolve: async ( + taskId: string, + filePath: string, + resolution: 'ours' | 'theirs' | 'manual', + manualContent?: string + ): Promise => { + const response = await fetch( + `${API_BASE}/conflicts/${taskId}/resolve?path=${encodeURIComponent(filePath)}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ resolution, manualContent }), + } + ); + return handleResponse(response); + }, + + abort: async (taskId: string): Promise<{ success: boolean }> => { + const response = await fetch(`${API_BASE}/conflicts/${taskId}/abort`, { + method: 'POST', + }); + return handleResponse<{ success: boolean }>(response); + }, + + continue: async ( + taskId: string, + message?: string + ): Promise<{ success: boolean; error?: string }> => { + const response = await fetch(`${API_BASE}/conflicts/${taskId}/continue`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message }), + }); + return handleResponse<{ success: boolean; error?: string }>(response); + }, +}; + +export const githubApi = { + getStatus: async (): Promise => { + const response = await fetch(`${API_BASE}/github/status`); + return handleResponse(response); + }, + + createPR: async (input: CreatePRInput): Promise => { + const response = await fetch(`${API_BASE}/github/pr`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }); + return handleResponse(response); + }, + + openPR: async (taskId: string): Promise => { + const response = await fetch(`${API_BASE}/github/pr/${taskId}/open`, { + method: 'POST', + }); + return handleResponse(response); + }, +}; + +// Diff types +export interface FileChange { + path: string; + status: 'added' | 'modified' | 'deleted' | 'renamed'; + additions: number; + deletions: number; + oldPath?: string; +} + +export interface DiffSummary { + files: FileChange[]; + totalAdditions: number; + totalDeletions: number; + totalFiles: number; +} + +export interface DiffHunk { + oldStart: number; + oldLines: number; + newStart: number; + newLines: number; + lines: DiffLine[]; +} + +export interface DiffLine { + type: 'context' | 'add' | 'delete'; + content: string; + oldNumber?: number; + newNumber?: number; +} + +export interface FileDiff { + path: string; + status: 'added' | 'modified' | 'deleted' | 'renamed'; + hunks: DiffHunk[]; + language: string; + additions: number; + deletions: number; +} + +// Conflict types +export interface ConflictStatus { + hasConflicts: boolean; + conflictingFiles: string[]; + rebaseInProgress: boolean; + mergeInProgress: boolean; +} + +export interface ConflictMarker { + startLine: number; + separatorLine: number; + endLine: number; + oursLines: string[]; + theirsLines: string[]; +} + +export interface ConflictFile { + path: string; + content: string; + oursContent: string; + theirsContent: string; + baseContent: string; + markers: ConflictMarker[]; +} + +export interface ResolveResult { + success: boolean; + remainingConflicts: string[]; +} + +// GitHub types +export interface GitHubStatus { + installed: boolean; + authenticated: boolean; + user?: string; +} + +export interface PRInfo { + url: string; + number: number; + title: string; + state: string; + draft: boolean; + headBranch: string; + baseBranch: string; +} + +export interface CreatePRInput { + taskId: string; + title?: string; + body?: string; + targetBranch?: string; + draft?: boolean; +} diff --git a/web/src/lib/api/entities.ts b/web/src/lib/api/entities.ts new file mode 100644 index 00000000..f5a4537a --- /dev/null +++ b/web/src/lib/api/entities.ts @@ -0,0 +1,249 @@ +/** + * Entity management API endpoints: templates, task types, sprints, activity, attachments. + */ +import type { + TaskTemplate, + CreateTemplateInput, + UpdateTemplateInput, + TaskTypeConfig, + SprintConfig, + Attachment, +} from '@veritas-kanban/shared'; +import { API_BASE, handleResponse } from './helpers'; + +export const templatesApi = { + list: async (): Promise => { + const response = await fetch(`${API_BASE}/templates`); + return handleResponse(response); + }, + + get: async (id: string): Promise => { + const response = await fetch(`${API_BASE}/templates/${id}`); + return handleResponse(response); + }, + + create: async (input: CreateTemplateInput): Promise => { + const response = await fetch(`${API_BASE}/templates`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }); + return handleResponse(response); + }, + + update: async (id: string, input: UpdateTemplateInput): Promise => { + const response = await fetch(`${API_BASE}/templates/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }); + return handleResponse(response); + }, + + delete: async (id: string): Promise => { + const response = await fetch(`${API_BASE}/templates/${id}`, { + method: 'DELETE', + }); + return handleResponse(response); + }, +}; + +export const taskTypesApi = { + list: async (): Promise => { + const response = await fetch(`${API_BASE}/task-types`); + return handleResponse(response); + }, + + get: async (id: string): Promise => { + const response = await fetch(`${API_BASE}/task-types/${id}`); + return handleResponse(response); + }, + + create: async (input: { + label: string; + icon: string; + color?: string; + }): Promise => { + const response = await fetch(`${API_BASE}/task-types`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }); + return handleResponse(response); + }, + + update: async (id: string, patch: Partial): Promise => { + const response = await fetch(`${API_BASE}/task-types/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(patch), + }); + return handleResponse(response); + }, + + delete: async (id: string, force = false): Promise => { + const url = force ? `${API_BASE}/task-types/${id}?force=true` : `${API_BASE}/task-types/${id}`; + const response = await fetch(url, { + method: 'DELETE', + }); + return handleResponse(response); + }, + + canDelete: async ( + id: string + ): Promise<{ allowed: boolean; referenceCount: number; isDefault: boolean }> => { + const response = await fetch(`${API_BASE}/task-types/${id}/can-delete`); + return handleResponse(response); + }, + + reorder: async (orderedIds: string[]): Promise => { + const response = await fetch(`${API_BASE}/task-types/reorder`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ orderedIds }), + }); + return handleResponse(response); + }, +}; + +export const sprintsApi = { + list: async (): Promise => { + const response = await fetch(`${API_BASE}/sprints`); + return handleResponse(response); + }, + + get: async (id: string): Promise => { + const response = await fetch(`${API_BASE}/sprints/${id}`); + return handleResponse(response); + }, + + create: async (input: { label: string; description?: string }): Promise => { + const response = await fetch(`${API_BASE}/sprints`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }); + return handleResponse(response); + }, + + update: async (id: string, patch: Partial): Promise => { + const response = await fetch(`${API_BASE}/sprints/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(patch), + }); + return handleResponse(response); + }, + + delete: async (id: string, force = false): Promise => { + const url = force ? `${API_BASE}/sprints/${id}?force=true` : `${API_BASE}/sprints/${id}`; + const response = await fetch(url, { + method: 'DELETE', + }); + return handleResponse(response); + }, + + canDelete: async ( + id: string + ): Promise<{ allowed: boolean; referenceCount: number; isDefault: boolean }> => { + const response = await fetch(`${API_BASE}/sprints/${id}/can-delete`); + return handleResponse(response); + }, + + reorder: async (orderedIds: string[]): Promise => { + const response = await fetch(`${API_BASE}/sprints/reorder`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ orderedIds }), + }); + return handleResponse(response); + }, +}; + +export const activityApi = { + list: async (limit: number = 50): Promise => { + const response = await fetch(`${API_BASE}/activity?limit=${limit}`); + return handleResponse(response); + }, + + clear: async (): Promise => { + const response = await fetch(`${API_BASE}/activity`, { + method: 'DELETE', + }); + return handleResponse(response); + }, +}; + +export const attachmentsApi = { + list: async (taskId: string): Promise => { + const response = await fetch(`${API_BASE}/tasks/${taskId}/attachments`); + return handleResponse(response); + }, + + upload: async (taskId: string, formData: FormData): Promise => { + const response = await fetch(`${API_BASE}/tasks/${taskId}/attachments`, { + method: 'POST', + body: formData, + }); + return handleResponse(response); + }, + + delete: async (taskId: string, attachmentId: string): Promise => { + const response = await fetch(`${API_BASE}/tasks/${taskId}/attachments/${attachmentId}`, { + method: 'DELETE', + }); + return handleResponse(response); + }, + + getTaskContext: async (taskId: string): Promise => { + const response = await fetch(`${API_BASE}/tasks/${taskId}/context`); + return handleResponse(response); + }, +}; + +// Activity types +export type ActivityType = + | 'task_created' + | 'task_updated' + | 'status_changed' + | 'agent_started' + | 'agent_stopped' + | 'agent_completed' + | 'task_archived' + | 'task_deleted' + | 'worktree_created' + | 'worktree_merged'; + +export interface Activity { + id: string; + type: ActivityType; + taskId: string; + taskTitle: string; + details?: Record; + timestamp: string; +} + +// Attachment types +export interface AttachmentUploadResponse { + success: boolean; + attachments: Attachment[]; + task: unknown; +} + +export interface TaskContext { + taskId: string; + title: string; + description: string; + type: string; + status: string; + priority: string; + project?: string; + tags?: string[]; + attachments: { + count: number; + documents: { filename: string; text: string }[]; + images: string[]; + }; + created: string; + updated: string; +} diff --git a/web/src/lib/api/helpers.ts b/web/src/lib/api/helpers.ts new file mode 100644 index 00000000..19adb344 --- /dev/null +++ b/web/src/lib/api/helpers.ts @@ -0,0 +1,17 @@ +/** + * Shared API helpers and base URL. + */ +import { API_BASE } from '../config'; + +export { API_BASE }; + +export async function handleResponse(response: Response): Promise { + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(error.error || `HTTP ${response.status}`); + } + if (response.status === 204) { + return undefined as T; + } + return response.json(); +} diff --git a/web/src/lib/api/index.ts b/web/src/lib/api/index.ts new file mode 100644 index 00000000..6c40322c --- /dev/null +++ b/web/src/lib/api/index.ts @@ -0,0 +1,72 @@ +/** + * API module barrel export. + * Assembles the full `api` object and re-exports all types for backwards compatibility. + */ + +// Import API sections +import { tasksApi } from './tasks'; +import { settingsApi, configApi } from './config'; +import { agentApi, worktreeApi, previewApi } from './agent'; +import { diffApi, conflictsApi, githubApi } from './diff'; +import { templatesApi, taskTypesApi, sprintsApi, activityApi, attachmentsApi } from './entities'; +import { timeApi, statusHistoryApi } from './time'; + +// Assemble the full API object (matches original structure exactly) +export const api = { + tasks: tasksApi, + settings: settingsApi, + config: configApi, + worktree: worktreeApi, + agent: agentApi, + diff: diffApi, + templates: templatesApi, + taskTypes: taskTypesApi, + sprints: sprintsApi, + activity: activityApi, + attachments: attachmentsApi, + conflicts: conflictsApi, + github: githubApi, + preview: previewApi, + time: timeApi, + statusHistory: statusHistoryApi, +}; + +// Re-export managed list helper +export { managedList } from './managed-list'; + +// Re-export all types from each module +export type { ArchiveSuggestion } from './tasks'; + +export type { + AgentStatus, + AgentStatusResponse, + AgentOutput, + GlobalAgentStatus, + WorktreeInfo, + PreviewServer, +} from './agent'; + +export type { + FileChange, + DiffSummary, + DiffHunk, + DiffLine, + FileDiff, + ConflictStatus, + ConflictMarker, + ConflictFile, + ResolveResult, + GitHubStatus, + PRInfo, + CreatePRInput, +} from './diff'; + +export type { ActivityType, Activity, AttachmentUploadResponse, TaskContext } from './entities'; + +export type { + TimeSummary, + AgentStatusState, + StatusHistoryEntry, + StatusPeriod, + DailySummary, +} from './time'; diff --git a/web/src/lib/api/managed-list.ts b/web/src/lib/api/managed-list.ts new file mode 100644 index 00000000..90999541 --- /dev/null +++ b/web/src/lib/api/managed-list.ts @@ -0,0 +1,66 @@ +/** + * Generic managed list API helper for CRUD endpoints with consistent patterns. + */ +import { API_BASE, handleResponse } from './helpers'; + +export const managedList = { + /** + * Create API helpers for a managed list endpoint + */ + createHelpers: (endpoint: string) => ({ + list: async (includeHidden = false): Promise => { + const url = includeHidden + ? `${API_BASE}${endpoint}?includeHidden=true` + : `${API_BASE}${endpoint}`; + const response = await fetch(url); + return handleResponse(response); + }, + + get: async (id: string): Promise => { + const response = await fetch(`${API_BASE}${endpoint}/${id}`); + return handleResponse(response); + }, + + create: async (input: any): Promise => { + const response = await fetch(`${API_BASE}${endpoint}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }); + return handleResponse(response); + }, + + update: async (id: string, patch: any): Promise => { + const response = await fetch(`${API_BASE}${endpoint}/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(patch), + }); + return handleResponse(response); + }, + + remove: async (id: string, force = false): Promise => { + const url = force + ? `${API_BASE}${endpoint}/${id}?force=true` + : `${API_BASE}${endpoint}/${id}`; + const response = await fetch(url, { method: 'DELETE' }); + return handleResponse(response); + }, + + canDelete: async ( + id: string + ): Promise<{ allowed: boolean; referenceCount: number; isDefault: boolean }> => { + const response = await fetch(`${API_BASE}${endpoint}/${id}/can-delete`); + return handleResponse(response); + }, + + reorder: async (orderedIds: string[]): Promise => { + const response = await fetch(`${API_BASE}${endpoint}/reorder`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ orderedIds }), + }); + return handleResponse(response); + }, + }), +}; diff --git a/web/src/lib/api/tasks.ts b/web/src/lib/api/tasks.ts new file mode 100644 index 00000000..c8b5d223 --- /dev/null +++ b/web/src/lib/api/tasks.ts @@ -0,0 +1,177 @@ +/** + * Task API endpoints: CRUD, archive, subtasks, comments, blocking, reorder. + */ +import type { Task, CreateTaskInput, UpdateTaskInput } from '@veritas-kanban/shared'; +import { API_BASE, handleResponse } from './helpers'; + +export const tasksApi = { + list: async (): Promise => { + const response = await fetch(`${API_BASE}/tasks`); + return handleResponse(response); + }, + + listArchived: async (): Promise => { + const response = await fetch(`${API_BASE}/tasks/archived`); + return handleResponse(response); + }, + + get: async (id: string): Promise => { + const response = await fetch(`${API_BASE}/tasks/${id}`); + return handleResponse(response); + }, + + create: async (input: CreateTaskInput): Promise => { + const response = await fetch(`${API_BASE}/tasks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }); + return handleResponse(response); + }, + + update: async (id: string, input: UpdateTaskInput): Promise => { + const response = await fetch(`${API_BASE}/tasks/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }); + return handleResponse(response); + }, + + delete: async (id: string): Promise => { + const response = await fetch(`${API_BASE}/tasks/${id}`, { + method: 'DELETE', + }); + return handleResponse(response); + }, + + archive: async (id: string): Promise => { + const response = await fetch(`${API_BASE}/tasks/${id}/archive`, { + method: 'POST', + }); + return handleResponse(response); + }, + + bulkArchive: async (sprint: string): Promise<{ archived: string[]; count: number }> => { + const response = await fetch(`${API_BASE}/tasks/bulk-archive`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sprint }), + }); + return handleResponse(response); + }, + + restore: async (id: string): Promise => { + const response = await fetch(`${API_BASE}/tasks/${id}/restore`, { + method: 'POST', + }); + return handleResponse(response); + }, + + getArchiveSuggestions: async (): Promise => { + const response = await fetch(`${API_BASE}/tasks/archive/suggestions`); + return handleResponse(response); + }, + + archiveSprint: async (sprint: string): Promise<{ archived: number; taskIds: string[] }> => { + const response = await fetch(`${API_BASE}/tasks/archive/sprint/${encodeURIComponent(sprint)}`, { + method: 'POST', + }); + return handleResponse<{ archived: number; taskIds: string[] }>(response); + }, + + addSubtask: async (taskId: string, title: string): Promise => { + const response = await fetch(`${API_BASE}/tasks/${taskId}/subtasks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title }), + }); + return handleResponse(response); + }, + + updateSubtask: async ( + taskId: string, + subtaskId: string, + updates: { title?: string; completed?: boolean } + ): Promise => { + const response = await fetch(`${API_BASE}/tasks/${taskId}/subtasks/${subtaskId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updates), + }); + return handleResponse(response); + }, + + deleteSubtask: async (taskId: string, subtaskId: string): Promise => { + const response = await fetch(`${API_BASE}/tasks/${taskId}/subtasks/${subtaskId}`, { + method: 'DELETE', + }); + return handleResponse(response); + }, + + addComment: async (taskId: string, author: string, text: string): Promise => { + const response = await fetch(`${API_BASE}/tasks/${taskId}/comments`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ author, text }), + }); + return handleResponse(response); + }, + + editComment: async (taskId: string, commentId: string, text: string): Promise => { + const response = await fetch(`${API_BASE}/tasks/${taskId}/comments/${commentId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text }), + }); + return handleResponse(response); + }, + + deleteComment: async (taskId: string, commentId: string): Promise => { + const response = await fetch(`${API_BASE}/tasks/${taskId}/comments/${commentId}`, { + method: 'DELETE', + }); + return handleResponse(response); + }, + + getBlockingStatus: async ( + taskId: string + ): Promise<{ + isBlocked: boolean; + blockers: Array<{ id: string; title: string; status: string }>; + completedBlockers: Array<{ id: string; title: string }>; + }> => { + const response = await fetch(`${API_BASE}/tasks/${taskId}/blocking-status`); + return handleResponse(response); + }, + + reorder: async (orderedIds: string[]): Promise<{ updated: number }> => { + const response = await fetch(`${API_BASE}/tasks/reorder`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ orderedIds }), + }); + return handleResponse<{ updated: number }>(response); + }, + + applyTemplate: async ( + taskId: string, + templateId: string, + templateName: string, + fieldsChanged: string[] + ): Promise => { + const response = await fetch(`${API_BASE}/tasks/${taskId}/apply-template`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ templateId, templateName, fieldsChanged }), + }); + return handleResponse(response); + }, +}; + +// Types +export interface ArchiveSuggestion { + sprint: string; + taskCount: number; + tasks: Task[]; +} diff --git a/web/src/lib/api/time.ts b/web/src/lib/api/time.ts new file mode 100644 index 00000000..a84558bb --- /dev/null +++ b/web/src/lib/api/time.ts @@ -0,0 +1,114 @@ +/** + * Time tracking and status history API endpoints. + */ +import type { Task } from '@veritas-kanban/shared'; +import { API_BASE, handleResponse } from './helpers'; + +export const timeApi = { + getSummary: async (): Promise => { + const response = await fetch(`${API_BASE}/tasks/time/summary`); + return handleResponse(response); + }, + + start: async (taskId: string): Promise => { + const response = await fetch(`${API_BASE}/tasks/${taskId}/time/start`, { + method: 'POST', + }); + return handleResponse(response); + }, + + stop: async (taskId: string): Promise => { + const response = await fetch(`${API_BASE}/tasks/${taskId}/time/stop`, { + method: 'POST', + }); + return handleResponse(response); + }, + + addEntry: async (taskId: string, duration: number, description?: string): Promise => { + const response = await fetch(`${API_BASE}/tasks/${taskId}/time/entry`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ duration, description }), + }); + return handleResponse(response); + }, + + deleteEntry: async (taskId: string, entryId: string): Promise => { + const response = await fetch(`${API_BASE}/tasks/${taskId}/time/entry/${entryId}`, { + method: 'DELETE', + }); + return handleResponse(response); + }, +}; + +export const statusHistoryApi = { + list: async (limit: number = 100, offset: number = 0): Promise => { + const response = await fetch(`${API_BASE}/status-history?limit=${limit}&offset=${offset}`); + return handleResponse(response); + }, + + getDailySummary: async (date?: string): Promise => { + const url = date + ? `${API_BASE}/status-history/summary/daily?date=${date}` + : `${API_BASE}/status-history/summary/daily`; + const response = await fetch(url); + return handleResponse(response); + }, + + getWeeklySummary: async (): Promise => { + const response = await fetch(`${API_BASE}/status-history/summary/weekly`); + return handleResponse(response); + }, + + getByDateRange: async (startDate: string, endDate: string): Promise => { + const response = await fetch( + `${API_BASE}/status-history/range?startDate=${encodeURIComponent(startDate)}&endDate=${encodeURIComponent(endDate)}` + ); + return handleResponse(response); + }, + + clear: async (): Promise => { + const response = await fetch(`${API_BASE}/status-history`, { + method: 'DELETE', + }); + return handleResponse(response); + }, +}; + +// Time types +export interface TimeSummary { + byProject: { project: string; totalSeconds: number; taskCount: number }[]; + total: number; +} + +// Status history types +export type AgentStatusState = 'idle' | 'working' | 'thinking' | 'sub-agent' | 'error'; + +export interface StatusHistoryEntry { + id: string; + timestamp: string; + previousStatus: AgentStatusState; + newStatus: AgentStatusState; + taskId?: string; + taskTitle?: string; + subAgentCount?: number; + durationMs?: number; +} + +export interface StatusPeriod { + status: AgentStatusState; + startTime: string; + endTime: string; + durationMs: number; + taskId?: string; + taskTitle?: string; +} + +export interface DailySummary { + date: string; + activeMs: number; + idleMs: number; + errorMs: number; + transitions: number; + periods: StatusPeriod[]; +}