diff --git a/dashboards/tracker-web/src/__tests__/fleet-client.test.ts b/dashboards/tracker-web/src/__tests__/fleet-client.test.ts index d14d3344..5f02539b 100644 --- a/dashboards/tracker-web/src/__tests__/fleet-client.test.ts +++ b/dashboards/tracker-web/src/__tests__/fleet-client.test.ts @@ -23,6 +23,7 @@ import { getJobExplain, listFactories, getBudget, + getBudgetBurndown, upsertBudget, pauseBudget, resumeBudget, @@ -213,5 +214,24 @@ describe('fleet-client', () => { expect.objectContaining({ method: 'POST' }) ); }); + + it('getBudgetBurndown fetches the series with a days query', async () => { + fetchSpy.mockResolvedValue({ + productId: 'p1', + ceilingUsd: 50, + window: 'monthly', + totalUsd: 10, + days: [{ date: '2024-01-01', costUsd: 10, cumulativeUsd: 10 }], + }); + const res = await getBudgetBurndown('p1', 30); + expect(res?.totalUsd).toBe(10); + expect(fetchSpy).toHaveBeenCalledWith('/budgets/p1/burndown?days=30', expect.anything()); + }); + + it('getBudgetBurndown returns null on 404', async () => { + fetchSpy.mockRejectedValue(new Error('404 Not Found')); + const res = await getBudgetBurndown('p1'); + expect(res).toBeNull(); + }); }); }); diff --git a/dashboards/tracker-web/src/app/dashboard/fleet/budget/page.tsx b/dashboards/tracker-web/src/app/dashboard/fleet/budget/page.tsx index 6b0bb5ef..c0e72598 100644 --- a/dashboards/tracker-web/src/app/dashboard/fleet/budget/page.tsx +++ b/dashboards/tracker-web/src/app/dashboard/fleet/budget/page.tsx @@ -4,11 +4,19 @@ import { useEffect, useState, useCallback } from 'react'; import { PageHeader } from '@bytelyst/dashboard-components'; import { Button } from '@/components/ui/Primitives'; import { useAuth } from '@/lib/auth-context'; -import { getBudget, pauseBudget, resumeBudget, type FleetBudget } from '@/lib/fleet-client'; +import { + getBudget, + getBudgetBurndown, + pauseBudget, + resumeBudget, + type FleetBudget, + type CostBurndown, +} from '@/lib/fleet-client'; export default function FleetBudgetPage() { const { token } = useAuth(); const [budget, setBudget] = useState(undefined); + const [burndown, setBurndown] = useState(null); const [acting, setActing] = useState(false); const productId = @@ -20,8 +28,9 @@ export default function FleetBudgetPage() { return; } try { - const b = await getBudget(productId); + const [b, bd] = await Promise.all([getBudget(productId), getBudgetBurndown(productId, 30)]); setBudget(b); + setBurndown(bd); } catch { setBudget(null); } @@ -152,6 +161,62 @@ export default function FleetBudgetPage() { )} + + {/* Cost burndown */} + {productId && burndown && burndown.days.length > 0 && } ); } + +function BurndownChart({ burndown }: { burndown: CostBurndown }) { + const { days, ceilingUsd, totalUsd } = burndown; + const maxValue = Math.max( + ceilingUsd ?? 0, + ...days.map(d => d.cumulativeUsd), + 1 // avoid divide-by-zero + ); + const ceilingPct = ceilingUsd ? (ceilingUsd / maxValue) * 100 : null; + + return ( +
+
+

Cost Burndown

+ + ${totalUsd.toFixed(2)} over {days.length} days + +
+

+ Cumulative spend per day{ceilingUsd ? ' vs. budget ceiling (dashed)' : ''}. +

+ +
+ {ceilingPct !== null && ( +
+ )} + {days.map(d => { + const heightPct = (d.cumulativeUsd / maxValue) * 100; + const overCeiling = ceilingUsd !== null && d.cumulativeUsd >= ceilingUsd; + return ( +
+
+
0 ? 2 : 0)}%` }} + title={`${d.date}: $${d.cumulativeUsd.toFixed(2)} cumulative ($${d.costUsd.toFixed(2)} that day)`} + /> +
+
+ ); + })} +
+
+ {days[0]?.date} + {days[days.length - 1]?.date} +
+
+ ); +} diff --git a/dashboards/tracker-web/src/lib/fleet-client.ts b/dashboards/tracker-web/src/lib/fleet-client.ts index 46e6a439..ab98b6e8 100644 --- a/dashboards/tracker-web/src/lib/fleet-client.ts +++ b/dashboards/tracker-web/src/lib/fleet-client.ts @@ -77,6 +77,20 @@ export interface FleetBudget { updatedAt: string; } +export interface BurndownPoint { + date: string; + costUsd: number; + cumulativeUsd: number; +} + +export interface CostBurndown { + productId: string; + ceilingUsd: number | null; + window: string | null; + totalUsd: number; + days: BurndownPoint[]; +} + export interface DagNode { id: string; idempotencyKey: string; @@ -232,3 +246,11 @@ export async function pauseBudget(productId: string): Promise { export async function resumeBudget(productId: string): Promise { return apiFetch(`/budgets/${productId}/resume`, { method: 'POST' }); } + +export async function getBudgetBurndown( + productId: string, + days?: number +): Promise { + const qs = days ? `?days=${days}` : ''; + return apiFetchOptional(`/budgets/${productId}/burndown${qs}`); +} diff --git a/services/platform-service/src/modules/fleet/coordinator.test.ts b/services/platform-service/src/modules/fleet/coordinator.test.ts index d4a3abc5..c6da7971 100644 --- a/services/platform-service/src/modules/fleet/coordinator.test.ts +++ b/services/platform-service/src/modules/fleet/coordinator.test.ts @@ -900,4 +900,55 @@ describe('fleet coordinator — Phase 3 per-product budgets', () => { expect(await coord.explainJob('missing', PID)).toBeNull(); }); + + // ── Phase 3: COST BURNDOWN ── + it('costBurndown: aggregates run cost by UTC day with a gap-free cumulative series', async () => { + process.env.FLEET_BUDGETS = '1'; + await coord.upsertBudget(PID, 50, 'monthly'); + const { job } = await coord.submitJob(PID, input()); + const today = new Date().toISOString(); + const yesterday = new Date(Date.now() - 86_400_000).toISOString(); + + await repo.createRun({ + id: `${job.id}:run:1`, + productId: PID, + jobId: job.id, + attempt: 1, + engine: 'codex', + startedAt: yesterday, + endedAt: yesterday, + insights: { costUsd: 4 }, + }); + await repo.createRun({ + id: `${job.id}:run:2`, + productId: PID, + jobId: job.id, + attempt: 2, + engine: 'codex', + startedAt: today, + endedAt: today, + insights: { costUsd: 6 }, + }); + + const burndown = await coord.costBurndown(PID, 7); + expect(burndown.ceilingUsd).toBe(50); + expect(burndown.days).toHaveLength(7); // gap-free window + expect(burndown.totalUsd).toBe(10); + // cumulative is monotonic non-decreasing and ends at the total + const last = burndown.days[burndown.days.length - 1]; + expect(last.cumulativeUsd).toBe(10); + for (let i = 1; i < burndown.days.length; i++) { + expect(burndown.days[i].cumulativeUsd).toBeGreaterThanOrEqual( + burndown.days[i - 1].cumulativeUsd + ); + } + }); + + it('costBurndown: no budget + no runs yields a zeroed series and null ceiling', async () => { + const burndown = await coord.costBurndown('emptyproduct', 5); + expect(burndown.ceilingUsd).toBeNull(); + expect(burndown.days).toHaveLength(5); + expect(burndown.totalUsd).toBe(0); + expect(burndown.days.every(d => d.costUsd === 0)).toBe(true); + }); }); diff --git a/services/platform-service/src/modules/fleet/coordinator.ts b/services/platform-service/src/modules/fleet/coordinator.ts index 89403c32..2dd2f346 100644 --- a/services/platform-service/src/modules/fleet/coordinator.ts +++ b/services/platform-service/src/modules/fleet/coordinator.ts @@ -1087,7 +1087,69 @@ export async function accrueSpend( return repo.updateBudget(productId, updates); } -// ── Reaper (§25.3) ──────────────────────────────────────────────────────────── +// ── Cost burndown (§14 Phase 3 — spend-over-time vs ceiling) ────────────────── + +/** One day of the burndown series. */ +export interface BurndownPoint { + date: string; // YYYY-MM-DD (UTC) + costUsd: number; // spend attributed to that day + cumulativeUsd: number; // running total up to and including that day +} + +export interface CostBurndown { + productId: string; + ceilingUsd: number | null; // budget ceiling, if configured + window: BudgetWindow | null; + totalUsd: number; + days: BurndownPoint[]; +} + +/** + * Aggregate completed run cost by UTC day for the last `days` days (default 30), + * returning a cumulative burndown series plus the budget ceiling for overlay. + * Read-only. Costs come from each run's `insights.costUsd`; a run is attributed + * to the day it ended (or started, if still in flight without an end time). + */ +export async function costBurndown(productId: string, days = 30): Promise { + const budget = await repo.getBudget(productId); + const runs = await repo.listRunsByProduct(productId); + + const windowDays = Math.max(1, Math.min(days, 365)); + const dayMs = 86_400_000; + const todayUtc = Math.floor(Date.now() / dayMs) * dayMs; + const startUtc = todayUtc - (windowDays - 1) * dayMs; + + // Initialise every day in the window to zero so the series has no gaps. + const byDay = new Map(); + for (let t = startUtc; t <= todayUtc; t += dayMs) { + byDay.set(new Date(t).toISOString().slice(0, 10), 0); + } + + for (const run of runs) { + const cost = run.insights?.costUsd; + if (!cost || cost <= 0) continue; + const when = Date.parse(run.endedAt ?? run.startedAt); + if (Number.isNaN(when) || when < startUtc) continue; + const key = new Date(when).toISOString().slice(0, 10); + byDay.set(key, (byDay.get(key) ?? 0) + cost); + } + + const sortedKeys = [...byDay.keys()].sort(); + let cumulative = 0; + const series: BurndownPoint[] = sortedKeys.map(date => { + const costUsd = byDay.get(date) ?? 0; + cumulative += costUsd; + return { date, costUsd, cumulativeUsd: cumulative }; + }); + + return { + productId, + ceilingUsd: budget?.ceilingUsd ?? null, + window: budget?.window ?? null, + totalUsd: cumulative, + days: series, + }; +} export interface ReapResult { reaped: number; diff --git a/services/platform-service/src/modules/fleet/repository.ts b/services/platform-service/src/modules/fleet/repository.ts index 2a952830..9e427f79 100644 --- a/services/platform-service/src/modules/fleet/repository.ts +++ b/services/platform-service/src/modules/fleet/repository.ts @@ -188,6 +188,11 @@ export async function listRunsByJob(jobId: string): Promise { return runs().findMany({ filter: { jobId }, sort: { attempt: 1 } }); } +/** All runs for a product (cross-partition) — used for cost aggregation. */ +export async function listRunsByProduct(productId: string): Promise { + return runs().findMany({ filter: { productId } }); +} + // ── Leases ────────────────────────────────────────────────────────────────── export async function getLease(jobId: string): Promise { diff --git a/services/platform-service/src/modules/fleet/routes.test.ts b/services/platform-service/src/modules/fleet/routes.test.ts index bcd6ace4..3d7557fd 100644 --- a/services/platform-service/src/modules/fleet/routes.test.ts +++ b/services/platform-service/src/modules/fleet/routes.test.ts @@ -189,4 +189,17 @@ describe('fleetRoutes', () => { const missing = await app.inject({ method: 'GET', url: '/api/fleet/jobs/nope/explain' }); expect(missing.statusCode).toBe(404); }); + + it('GET /fleet/budgets/:productId/burndown returns a gap-free daily series', async () => { + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/fleet/budgets/lysnrai/burndown?days=7', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.productId).toBe('lysnrai'); + expect(body.days).toHaveLength(7); + expect(body.totalUsd).toBe(0); + }); }); diff --git a/services/platform-service/src/modules/fleet/routes.ts b/services/platform-service/src/modules/fleet/routes.ts index c0f87d95..85c4f305 100644 --- a/services/platform-service/src/modules/fleet/routes.ts +++ b/services/platform-service/src/modules/fleet/routes.ts @@ -365,6 +365,18 @@ export async function fleetRoutes(app: FastifyInstance) { return budget; }); + // ── Cost burndown — spend-over-time vs ceiling (§14) ── + app.get('/fleet/budgets/:productId/burndown', async req => { + await extractAuth(req); + const { productId } = req.params as { productId: string }; + const { days } = req.query as { days?: string }; + const parsedDays = days ? Number.parseInt(days, 10) : undefined; + return coordinator.costBurndown( + productId, + Number.isFinite(parsedDays) ? parsedDays : undefined + ); + }); + app.put('/fleet/budgets/:productId', async (req, reply) => { await extractAuth(req); const { productId } = req.params as { productId: string };