feat: add fleet cost burndown chart

- coordinator.costBurndown() aggregates completed run cost (insights.costUsd)
  by UTC day over a window, returning a gap-free cumulative series + ceiling
- repository.listRunsByProduct() cross-partition run query
- GET /fleet/budgets/:productId/burndown?days=N route
- fleet-client.getBudgetBurndown() + CostBurndown/BurndownPoint types
- BurndownChart on the budget page: cumulative daily bars with a dashed
  ceiling overlay; bars turn red past the ceiling; degrades gracefully
- Tests: +2 coordinator, +1 routes, +2 fleet-client (fleet 147, web 216)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Saravanakumar D 2026-05-30 18:25:27 -07:00
parent 3f850b7b6f
commit 89860e39f9
8 changed files with 253 additions and 3 deletions

View File

@ -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();
});
});
});

View File

@ -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<FleetBudget | null | undefined>(undefined);
const [burndown, setBurndown] = useState<CostBurndown | null>(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() {
</div>
</div>
)}
{/* Cost burndown */}
{productId && burndown && burndown.days.length > 0 && <BurndownChart burndown={burndown} />}
</div>
);
}
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 (
<section className="rounded-lg border p-6 max-w-3xl">
<div className="flex items-center justify-between mb-1">
<h2 className="font-semibold">Cost Burndown</h2>
<span className="text-sm text-muted-foreground">
${totalUsd.toFixed(2)} over {days.length} days
</span>
</div>
<p className="text-xs text-muted-foreground mb-4">
Cumulative spend per day{ceilingUsd ? ' vs. budget ceiling (dashed)' : ''}.
</p>
<div className="relative h-40 flex items-end gap-1" aria-label="Cost burndown chart">
{ceilingPct !== null && (
<div
className="absolute left-0 right-0 border-t border-dashed border-red-500/70"
style={{ bottom: `${ceilingPct}%` }}
aria-label={`Budget ceiling $${ceilingUsd?.toFixed(2)}`}
/>
)}
{days.map(d => {
const heightPct = (d.cumulativeUsd / maxValue) * 100;
const overCeiling = ceilingUsd !== null && d.cumulativeUsd >= ceilingUsd;
return (
<div key={d.date} className="flex-1 min-w-0 group relative" style={{ height: '100%' }}>
<div className="absolute bottom-0 left-0 right-0 flex items-end h-full">
<div
className={`w-full rounded-t ${overCeiling ? 'bg-red-500' : 'bg-blue-500'}`}
style={{ height: `${Math.max(heightPct, d.cumulativeUsd > 0 ? 2 : 0)}%` }}
title={`${d.date}: $${d.cumulativeUsd.toFixed(2)} cumulative ($${d.costUsd.toFixed(2)} that day)`}
/>
</div>
</div>
);
})}
</div>
<div className="flex justify-between text-xs text-muted-foreground mt-2">
<span>{days[0]?.date}</span>
<span>{days[days.length - 1]?.date}</span>
</div>
</section>
);
}

View File

@ -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<FleetBudget> {
export async function resumeBudget(productId: string): Promise<FleetBudget> {
return apiFetch(`/budgets/${productId}/resume`, { method: 'POST' });
}
export async function getBudgetBurndown(
productId: string,
days?: number
): Promise<CostBurndown | null> {
const qs = days ? `?days=${days}` : '';
return apiFetchOptional(`/budgets/${productId}/burndown${qs}`);
}

View File

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

View File

@ -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<CostBurndown> {
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<string, number>();
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;

View File

@ -188,6 +188,11 @@ export async function listRunsByJob(jobId: string): Promise<FleetRunDoc[]> {
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<FleetRunDoc[]> {
return runs().findMany({ filter: { productId } });
}
// ── Leases ──────────────────────────────────────────────────────────────────
export async function getLease(jobId: string): Promise<FleetLeaseDoc | null> {

View File

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

View File

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