refactor: split god files into focused modules

- 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)
This commit is contained in:
Brad Groux 2026-01-28 17:57:45 -06:00
parent 125430544a
commit 63e961f8d6
24 changed files with 3281 additions and 2804 deletions

View file

@ -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"}

View file

@ -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",

View file

@ -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<unknown, BudgetMetricsQuery>, 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);
})
);

View file

@ -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',

File diff suppressed because it is too large Load diff

View file

@ -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<TrendsData> {
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<AgentComparisonResult> {
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,
};
}

View file

@ -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`;
}

View file

@ -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';

View file

@ -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<TaskMetrics> {
return computeTaskMetrics(this.taskService, project);
}
async getRunMetrics(period: MetricsPeriod, project?: string): Promise<RunMetrics> {
return computeRunMetrics(this.telemetryDir, period, project);
}
async getTokenMetrics(period: MetricsPeriod, project?: string): Promise<TokenMetrics> {
return computeTokenMetrics(this.telemetryDir, period, project);
}
async getDurationMetrics(period: MetricsPeriod, project?: string): Promise<DurationMetrics> {
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<TrendsData> {
return computeTrends(this.telemetryDir, period, project);
}
async getBudgetMetrics(
tokenBudget: number,
costBudget: number,
warningThreshold: number,
project?: string
): Promise<BudgetMetrics> {
return computeBudgetMetrics(
this.telemetryDir,
tokenBudget,
costBudget,
warningThreshold,
project
);
}
async getAgentComparison(
period: MetricsPeriod,
project?: string,
minRuns = 3
): Promise<AgentComparisonResult> {
return computeAgentComparison(this.telemetryDir, period, project, minRuns);
}
async getVelocityMetrics(project?: string, limit = 10): Promise<VelocityMetrics> {
return computeVelocityMetrics(this.taskService, project, limit);
}
async getFailedRuns(
period: MetricsPeriod,
project?: string,
limit = 50
): Promise<FailedRunDetails[]> {
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;
}

View file

@ -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<RunMetrics> {
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<DurationMetrics> {
const since = getPeriodStart(period);
const files = await getEventFiles(telemetryDir, since);
const accumulator = {
durations: [] as number[],
byAgent: new Map<string, number[]>(),
};
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<FailedRunDetails[]> {
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);
}

View file

@ -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<TaskMetrics> {
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<TaskStatus, number> = {
todo: 0,
'in-progress': 0,
blocked: 0,
done: 0,
};
// Count by blocked reason
const byBlockedReason: Record<BlockedCategory | 'unspecified', number> = {
'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<VelocityMetrics> {
// 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<string, string>();
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<string, number>;
}
>();
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,
};
}

View file

@ -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<string[]> {
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<T>(
files: string[],
types: TelemetryEventType[],
since: string,
project: string | undefined,
accumulator: T,
handler: StreamEventHandler<T>
): Promise<T> {
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;
}

View file

@ -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<TokenMetrics> {
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<BudgetMetrics> {
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,
};
}

View file

@ -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<TaskStatus, number>;
byBlockedReason: Record<BlockedCategory | 'unspecified', number>;
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<string, number>; // 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<T> = (event: AnyTelemetryEvent, acc: T) => void;
// Re-export shared types used by consumers
export type { TelemetryEventType, AnyTelemetryEvent };

File diff suppressed because it is too large Load diff

181
web/src/lib/api/agent.ts Normal file
View file

@ -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<WorktreeInfo> => {
const response = await fetch(`${API_BASE}/tasks/${taskId}/worktree`, {
method: 'POST',
});
return handleResponse<WorktreeInfo>(response);
},
status: async (taskId: string): Promise<WorktreeInfo> => {
const response = await fetch(`${API_BASE}/tasks/${taskId}/worktree`);
return handleResponse<WorktreeInfo>(response);
},
delete: async (taskId: string, force: boolean = false): Promise<void> => {
const response = await fetch(`${API_BASE}/tasks/${taskId}/worktree?force=${force}`, {
method: 'DELETE',
});
return handleResponse<void>(response);
},
rebase: async (taskId: string): Promise<WorktreeInfo> => {
const response = await fetch(`${API_BASE}/tasks/${taskId}/worktree/rebase`, {
method: 'POST',
});
return handleResponse<WorktreeInfo>(response);
},
merge: async (taskId: string): Promise<void> => {
const response = await fetch(`${API_BASE}/tasks/${taskId}/worktree/merge`, {
method: 'POST',
});
return handleResponse<void>(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<GlobalAgentStatus> => {
const response = await fetch(`${API_BASE}/agent/status`);
return handleResponse<GlobalAgentStatus>(response);
},
start: async (taskId: string, agent?: AgentType): Promise<AgentStatus> => {
const response = await fetch(`${API_BASE}/agents/${taskId}/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agent }),
});
return handleResponse<AgentStatus>(response);
},
sendMessage: async (taskId: string, message: string): Promise<void> => {
const response = await fetch(`${API_BASE}/agents/${taskId}/message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message }),
});
return handleResponse<void>(response);
},
stop: async (taskId: string): Promise<void> => {
const response = await fetch(`${API_BASE}/agents/${taskId}/stop`, {
method: 'POST',
});
return handleResponse<void>(response);
},
status: async (taskId: string): Promise<AgentStatusResponse> => {
const response = await fetch(`${API_BASE}/agents/${taskId}/status`);
return handleResponse<AgentStatusResponse>(response);
},
listAttempts: async (taskId: string): Promise<string[]> => {
const response = await fetch(`${API_BASE}/agents/${taskId}/attempts`);
return handleResponse<string[]>(response);
},
getLog: async (taskId: string, attemptId: string): Promise<string> => {
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<PreviewServer | { status: 'stopped' }> => {
const response = await fetch(`${API_BASE}/preview/${taskId}`);
return handleResponse<PreviewServer | { status: 'stopped' }>(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<PreviewServer> => {
const response = await fetch(`${API_BASE}/preview/${taskId}/start`, {
method: 'POST',
});
return handleResponse<PreviewServer>(response);
},
stop: async (taskId: string): Promise<void> => {
const response = await fetch(`${API_BASE}/preview/${taskId}/stop`, {
method: 'POST',
});
return handleResponse<void>(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;
}

100
web/src/lib/api/config.ts Normal file
View file

@ -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<FeatureSettings> => {
const response = await fetch(`${API_BASE}/settings/features`);
return handleResponse<FeatureSettings>(response);
},
updateFeatures: async (patch: Partial<FeatureSettings>): Promise<FeatureSettings> => {
const response = await fetch(`${API_BASE}/settings/features`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
return handleResponse<FeatureSettings>(response);
},
};
export const configApi = {
get: async (): Promise<AppConfig> => {
const response = await fetch(`${API_BASE}/config`);
return handleResponse<AppConfig>(response);
},
repos: {
list: async (): Promise<RepoConfig[]> => {
const response = await fetch(`${API_BASE}/config/repos`);
return handleResponse<RepoConfig[]>(response);
},
add: async (repo: RepoConfig): Promise<AppConfig> => {
const response = await fetch(`${API_BASE}/config/repos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(repo),
});
return handleResponse<AppConfig>(response);
},
update: async (name: string, updates: Partial<RepoConfig>): Promise<AppConfig> => {
const response = await fetch(`${API_BASE}/config/repos/${encodeURIComponent(name)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
return handleResponse<AppConfig>(response);
},
remove: async (name: string): Promise<AppConfig> => {
const response = await fetch(`${API_BASE}/config/repos/${encodeURIComponent(name)}`, {
method: 'DELETE',
});
return handleResponse<AppConfig>(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<string[]> => {
const response = await fetch(`${API_BASE}/config/repos/${encodeURIComponent(name)}/branches`);
return handleResponse<string[]>(response);
},
},
agents: {
update: async (agents: AgentConfig[]): Promise<AppConfig> => {
const response = await fetch(`${API_BASE}/config/agents`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(agents),
});
return handleResponse<AppConfig>(response);
},
setDefault: async (agent: AgentType): Promise<AppConfig> => {
const response = await fetch(`${API_BASE}/config/default-agent`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agent }),
});
return handleResponse<AppConfig>(response);
},
},
};

191
web/src/lib/api/diff.ts Normal file
View file

@ -0,0 +1,191 @@
/**
* Diff, conflicts, and GitHub API endpoints.
*/
import { API_BASE, handleResponse } from './helpers';
export const diffApi = {
getSummary: async (taskId: string): Promise<DiffSummary> => {
const response = await fetch(`${API_BASE}/diff/${taskId}`);
return handleResponse<DiffSummary>(response);
},
getFileDiff: async (taskId: string, filePath: string): Promise<FileDiff> => {
const response = await fetch(
`${API_BASE}/diff/${taskId}/file?path=${encodeURIComponent(filePath)}`
);
return handleResponse<FileDiff>(response);
},
getFullDiff: async (taskId: string): Promise<FileDiff[]> => {
const response = await fetch(`${API_BASE}/diff/${taskId}/full`);
return handleResponse<FileDiff[]>(response);
},
};
export const conflictsApi = {
getStatus: async (taskId: string): Promise<ConflictStatus> => {
const response = await fetch(`${API_BASE}/conflicts/${taskId}`);
return handleResponse<ConflictStatus>(response);
},
getFile: async (taskId: string, filePath: string): Promise<ConflictFile> => {
const response = await fetch(
`${API_BASE}/conflicts/${taskId}/file?path=${encodeURIComponent(filePath)}`
);
return handleResponse<ConflictFile>(response);
},
resolve: async (
taskId: string,
filePath: string,
resolution: 'ours' | 'theirs' | 'manual',
manualContent?: string
): Promise<ResolveResult> => {
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<ResolveResult>(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<GitHubStatus> => {
const response = await fetch(`${API_BASE}/github/status`);
return handleResponse<GitHubStatus>(response);
},
createPR: async (input: CreatePRInput): Promise<PRInfo> => {
const response = await fetch(`${API_BASE}/github/pr`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
return handleResponse<PRInfo>(response);
},
openPR: async (taskId: string): Promise<void> => {
const response = await fetch(`${API_BASE}/github/pr/${taskId}/open`, {
method: 'POST',
});
return handleResponse<void>(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;
}

249
web/src/lib/api/entities.ts Normal file
View file

@ -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<TaskTemplate[]> => {
const response = await fetch(`${API_BASE}/templates`);
return handleResponse<TaskTemplate[]>(response);
},
get: async (id: string): Promise<TaskTemplate> => {
const response = await fetch(`${API_BASE}/templates/${id}`);
return handleResponse<TaskTemplate>(response);
},
create: async (input: CreateTemplateInput): Promise<TaskTemplate> => {
const response = await fetch(`${API_BASE}/templates`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
return handleResponse<TaskTemplate>(response);
},
update: async (id: string, input: UpdateTemplateInput): Promise<TaskTemplate> => {
const response = await fetch(`${API_BASE}/templates/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
return handleResponse<TaskTemplate>(response);
},
delete: async (id: string): Promise<void> => {
const response = await fetch(`${API_BASE}/templates/${id}`, {
method: 'DELETE',
});
return handleResponse<void>(response);
},
};
export const taskTypesApi = {
list: async (): Promise<TaskTypeConfig[]> => {
const response = await fetch(`${API_BASE}/task-types`);
return handleResponse<TaskTypeConfig[]>(response);
},
get: async (id: string): Promise<TaskTypeConfig> => {
const response = await fetch(`${API_BASE}/task-types/${id}`);
return handleResponse<TaskTypeConfig>(response);
},
create: async (input: {
label: string;
icon: string;
color?: string;
}): Promise<TaskTypeConfig> => {
const response = await fetch(`${API_BASE}/task-types`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
return handleResponse<TaskTypeConfig>(response);
},
update: async (id: string, patch: Partial<TaskTypeConfig>): Promise<TaskTypeConfig> => {
const response = await fetch(`${API_BASE}/task-types/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
return handleResponse<TaskTypeConfig>(response);
},
delete: async (id: string, force = false): Promise<void> => {
const url = force ? `${API_BASE}/task-types/${id}?force=true` : `${API_BASE}/task-types/${id}`;
const response = await fetch(url, {
method: 'DELETE',
});
return handleResponse<void>(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<TaskTypeConfig[]> => {
const response = await fetch(`${API_BASE}/task-types/reorder`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ orderedIds }),
});
return handleResponse<TaskTypeConfig[]>(response);
},
};
export const sprintsApi = {
list: async (): Promise<SprintConfig[]> => {
const response = await fetch(`${API_BASE}/sprints`);
return handleResponse<SprintConfig[]>(response);
},
get: async (id: string): Promise<SprintConfig> => {
const response = await fetch(`${API_BASE}/sprints/${id}`);
return handleResponse<SprintConfig>(response);
},
create: async (input: { label: string; description?: string }): Promise<SprintConfig> => {
const response = await fetch(`${API_BASE}/sprints`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
return handleResponse<SprintConfig>(response);
},
update: async (id: string, patch: Partial<SprintConfig>): Promise<SprintConfig> => {
const response = await fetch(`${API_BASE}/sprints/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
return handleResponse<SprintConfig>(response);
},
delete: async (id: string, force = false): Promise<void> => {
const url = force ? `${API_BASE}/sprints/${id}?force=true` : `${API_BASE}/sprints/${id}`;
const response = await fetch(url, {
method: 'DELETE',
});
return handleResponse<void>(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<SprintConfig[]> => {
const response = await fetch(`${API_BASE}/sprints/reorder`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ orderedIds }),
});
return handleResponse<SprintConfig[]>(response);
},
};
export const activityApi = {
list: async (limit: number = 50): Promise<Activity[]> => {
const response = await fetch(`${API_BASE}/activity?limit=${limit}`);
return handleResponse<Activity[]>(response);
},
clear: async (): Promise<void> => {
const response = await fetch(`${API_BASE}/activity`, {
method: 'DELETE',
});
return handleResponse<void>(response);
},
};
export const attachmentsApi = {
list: async (taskId: string): Promise<Attachment[]> => {
const response = await fetch(`${API_BASE}/tasks/${taskId}/attachments`);
return handleResponse<Attachment[]>(response);
},
upload: async (taskId: string, formData: FormData): Promise<AttachmentUploadResponse> => {
const response = await fetch(`${API_BASE}/tasks/${taskId}/attachments`, {
method: 'POST',
body: formData,
});
return handleResponse<AttachmentUploadResponse>(response);
},
delete: async (taskId: string, attachmentId: string): Promise<void> => {
const response = await fetch(`${API_BASE}/tasks/${taskId}/attachments/${attachmentId}`, {
method: 'DELETE',
});
return handleResponse<void>(response);
},
getTaskContext: async (taskId: string): Promise<TaskContext> => {
const response = await fetch(`${API_BASE}/tasks/${taskId}/context`);
return handleResponse<TaskContext>(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<string, unknown>;
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;
}

View file

@ -0,0 +1,17 @@
/**
* Shared API helpers and base URL.
*/
import { API_BASE } from '../config';
export { API_BASE };
export async function handleResponse<T>(response: Response): Promise<T> {
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();
}

72
web/src/lib/api/index.ts Normal file
View file

@ -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';

View file

@ -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: <T>(endpoint: string) => ({
list: async (includeHidden = false): Promise<T[]> => {
const url = includeHidden
? `${API_BASE}${endpoint}?includeHidden=true`
: `${API_BASE}${endpoint}`;
const response = await fetch(url);
return handleResponse<T[]>(response);
},
get: async (id: string): Promise<T> => {
const response = await fetch(`${API_BASE}${endpoint}/${id}`);
return handleResponse<T>(response);
},
create: async (input: any): Promise<T> => {
const response = await fetch(`${API_BASE}${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
return handleResponse<T>(response);
},
update: async (id: string, patch: any): Promise<T> => {
const response = await fetch(`${API_BASE}${endpoint}/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
return handleResponse<T>(response);
},
remove: async (id: string, force = false): Promise<void> => {
const url = force
? `${API_BASE}${endpoint}/${id}?force=true`
: `${API_BASE}${endpoint}/${id}`;
const response = await fetch(url, { method: 'DELETE' });
return handleResponse<void>(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<T[]> => {
const response = await fetch(`${API_BASE}${endpoint}/reorder`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ orderedIds }),
});
return handleResponse<T[]>(response);
},
}),
};

177
web/src/lib/api/tasks.ts Normal file
View file

@ -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<Task[]> => {
const response = await fetch(`${API_BASE}/tasks`);
return handleResponse<Task[]>(response);
},
listArchived: async (): Promise<Task[]> => {
const response = await fetch(`${API_BASE}/tasks/archived`);
return handleResponse<Task[]>(response);
},
get: async (id: string): Promise<Task> => {
const response = await fetch(`${API_BASE}/tasks/${id}`);
return handleResponse<Task>(response);
},
create: async (input: CreateTaskInput): Promise<Task> => {
const response = await fetch(`${API_BASE}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
return handleResponse<Task>(response);
},
update: async (id: string, input: UpdateTaskInput): Promise<Task> => {
const response = await fetch(`${API_BASE}/tasks/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
return handleResponse<Task>(response);
},
delete: async (id: string): Promise<void> => {
const response = await fetch(`${API_BASE}/tasks/${id}`, {
method: 'DELETE',
});
return handleResponse<void>(response);
},
archive: async (id: string): Promise<void> => {
const response = await fetch(`${API_BASE}/tasks/${id}/archive`, {
method: 'POST',
});
return handleResponse<void>(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<Task> => {
const response = await fetch(`${API_BASE}/tasks/${id}/restore`, {
method: 'POST',
});
return handleResponse<Task>(response);
},
getArchiveSuggestions: async (): Promise<ArchiveSuggestion[]> => {
const response = await fetch(`${API_BASE}/tasks/archive/suggestions`);
return handleResponse<ArchiveSuggestion[]>(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<Task> => {
const response = await fetch(`${API_BASE}/tasks/${taskId}/subtasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
return handleResponse<Task>(response);
},
updateSubtask: async (
taskId: string,
subtaskId: string,
updates: { title?: string; completed?: boolean }
): Promise<Task> => {
const response = await fetch(`${API_BASE}/tasks/${taskId}/subtasks/${subtaskId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
return handleResponse<Task>(response);
},
deleteSubtask: async (taskId: string, subtaskId: string): Promise<Task> => {
const response = await fetch(`${API_BASE}/tasks/${taskId}/subtasks/${subtaskId}`, {
method: 'DELETE',
});
return handleResponse<Task>(response);
},
addComment: async (taskId: string, author: string, text: string): Promise<Task> => {
const response = await fetch(`${API_BASE}/tasks/${taskId}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ author, text }),
});
return handleResponse<Task>(response);
},
editComment: async (taskId: string, commentId: string, text: string): Promise<Task> => {
const response = await fetch(`${API_BASE}/tasks/${taskId}/comments/${commentId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
});
return handleResponse<Task>(response);
},
deleteComment: async (taskId: string, commentId: string): Promise<Task> => {
const response = await fetch(`${API_BASE}/tasks/${taskId}/comments/${commentId}`, {
method: 'DELETE',
});
return handleResponse<Task>(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<void> => {
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<void>(response);
},
};
// Types
export interface ArchiveSuggestion {
sprint: string;
taskCount: number;
tasks: Task[];
}

114
web/src/lib/api/time.ts Normal file
View file

@ -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<TimeSummary> => {
const response = await fetch(`${API_BASE}/tasks/time/summary`);
return handleResponse<TimeSummary>(response);
},
start: async (taskId: string): Promise<Task> => {
const response = await fetch(`${API_BASE}/tasks/${taskId}/time/start`, {
method: 'POST',
});
return handleResponse<Task>(response);
},
stop: async (taskId: string): Promise<Task> => {
const response = await fetch(`${API_BASE}/tasks/${taskId}/time/stop`, {
method: 'POST',
});
return handleResponse<Task>(response);
},
addEntry: async (taskId: string, duration: number, description?: string): Promise<Task> => {
const response = await fetch(`${API_BASE}/tasks/${taskId}/time/entry`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ duration, description }),
});
return handleResponse<Task>(response);
},
deleteEntry: async (taskId: string, entryId: string): Promise<Task> => {
const response = await fetch(`${API_BASE}/tasks/${taskId}/time/entry/${entryId}`, {
method: 'DELETE',
});
return handleResponse<Task>(response);
},
};
export const statusHistoryApi = {
list: async (limit: number = 100, offset: number = 0): Promise<StatusHistoryEntry[]> => {
const response = await fetch(`${API_BASE}/status-history?limit=${limit}&offset=${offset}`);
return handleResponse<StatusHistoryEntry[]>(response);
},
getDailySummary: async (date?: string): Promise<DailySummary> => {
const url = date
? `${API_BASE}/status-history/summary/daily?date=${date}`
: `${API_BASE}/status-history/summary/daily`;
const response = await fetch(url);
return handleResponse<DailySummary>(response);
},
getWeeklySummary: async (): Promise<DailySummary[]> => {
const response = await fetch(`${API_BASE}/status-history/summary/weekly`);
return handleResponse<DailySummary[]>(response);
},
getByDateRange: async (startDate: string, endDate: string): Promise<StatusHistoryEntry[]> => {
const response = await fetch(
`${API_BASE}/status-history/range?startDate=${encodeURIComponent(startDate)}&endDate=${encodeURIComponent(endDate)}`
);
return handleResponse<StatusHistoryEntry[]>(response);
},
clear: async (): Promise<void> => {
const response = await fetch(`${API_BASE}/status-history`, {
method: 'DELETE',
});
return handleResponse<void>(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[];
}