mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-08 12:47:51 +00:00
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:
parent
125430544a
commit
63e961f8d6
24 changed files with 3281 additions and 2804 deletions
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
})
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
683
server/src/services/metrics/dashboard-metrics.ts
Normal file
683
server/src/services/metrics/dashboard-metrics.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
100
server/src/services/metrics/helpers.ts
Normal file
100
server/src/services/metrics/helpers.ts
Normal 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`;
|
||||
}
|
||||
32
server/src/services/metrics/index.ts
Normal file
32
server/src/services/metrics/index.ts
Normal 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';
|
||||
114
server/src/services/metrics/metrics-service.ts
Normal file
114
server/src/services/metrics/metrics-service.ts
Normal 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;
|
||||
}
|
||||
233
server/src/services/metrics/run-metrics.ts
Normal file
233
server/src/services/metrics/run-metrics.ts
Normal 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);
|
||||
}
|
||||
232
server/src/services/metrics/task-metrics.ts
Normal file
232
server/src/services/metrics/task-metrics.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
99
server/src/services/metrics/telemetry-reader.ts
Normal file
99
server/src/services/metrics/telemetry-reader.ts
Normal 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;
|
||||
}
|
||||
218
server/src/services/metrics/token-metrics.ts
Normal file
218
server/src/services/metrics/token-metrics.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
270
server/src/services/metrics/types.ts
Normal file
270
server/src/services/metrics/types.ts
Normal 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 };
|
||||
1038
web/src/lib/api.ts
1038
web/src/lib/api.ts
File diff suppressed because it is too large
Load diff
181
web/src/lib/api/agent.ts
Normal file
181
web/src/lib/api/agent.ts
Normal 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
100
web/src/lib/api/config.ts
Normal 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
191
web/src/lib/api/diff.ts
Normal 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
249
web/src/lib/api/entities.ts
Normal 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;
|
||||
}
|
||||
17
web/src/lib/api/helpers.ts
Normal file
17
web/src/lib/api/helpers.ts
Normal 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
72
web/src/lib/api/index.ts
Normal 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';
|
||||
66
web/src/lib/api/managed-list.ts
Normal file
66
web/src/lib/api/managed-list.ts
Normal 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
177
web/src/lib/api/tasks.ts
Normal 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
114
web/src/lib/api/time.ts
Normal 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[];
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue