diff --git a/packages/analyze/CHANGELOG.md b/packages/analyze/CHANGELOG.md index 51b390b..11bafe7 100644 --- a/packages/analyze/CHANGELOG.md +++ b/packages/analyze/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `planUsageFromArchive(plan, { pricing, db, now })` ([#91](https://github.com/AgentWorkforce/burn/issues/91)) — computes `PlanUsage` for a plan via one `SUM(...) GROUP BY (source, model)` query against the archive's `turns` table instead of a full ledger scan. Returns the same shape as `computePlanUsage` so callers can swap paths cleanly. Reuses `costForTurn`'s source-aware reasoning override, so Codex `output_tokens` is not double-billed against `usage.reasoning`. + ## [0.31.0] - 2026-04-27 ### Added diff --git a/packages/analyze/src/index.ts b/packages/analyze/src/index.ts index 455e92f..79ea502 100644 --- a/packages/analyze/src/index.ts +++ b/packages/analyze/src/index.ts @@ -73,8 +73,12 @@ export { findContextFiles, loadContextFile, } from './context-md.js'; -export { computePlanUsage, cycleBounds } from './plan-usage.js'; -export type { ComputePlanUsageOptions, PlanUsage } from './plan-usage.js'; +export { computePlanUsage, cycleBounds, planUsageFromArchive } from './plan-usage.js'; +export type { + ComputePlanUsageFromArchiveOptions, + ComputePlanUsageOptions, + PlanUsage, +} from './plan-usage.js'; export { emptyFidelitySummary, hasMinimumFidelity, diff --git a/packages/analyze/src/plan-usage.test.ts b/packages/analyze/src/plan-usage.test.ts index e3ef4cc..d93c808 100644 --- a/packages/analyze/src/plan-usage.test.ts +++ b/packages/analyze/src/plan-usage.test.ts @@ -1,10 +1,11 @@ import { strict as assert } from 'node:assert'; +import { DatabaseSync } from 'node:sqlite'; import { describe, it } from 'node:test'; import type { Plan } from '@relayburn/ledger'; -import type { TurnRecord } from '@relayburn/reader'; +import type { SourceKind, TurnRecord } from '@relayburn/reader'; -import { computePlanUsage, cycleBounds } from './plan-usage.js'; +import { computePlanUsage, cycleBounds, planUsageFromArchive } from './plan-usage.js'; import type { PricingTable } from './pricing.js'; const PRICING: PricingTable = { @@ -205,3 +206,320 @@ describe('computePlanUsage', () => { assert.equal(u.spentUsd, 3); }); }); + +// Minimal subset of the real `archive.sqlite` `turns` schema — just the +// columns `planUsageFromArchive` reads. Built per-test in :memory: so we +// don't need a real archive build / RELAYBURN_HOME shuffle. +const ARCHIVE_TURNS_DDL = ` + CREATE TABLE turns ( + source TEXT NOT NULL, + session_id TEXT NOT NULL, + message_id TEXT NOT NULL, + ts TEXT NOT NULL, + model TEXT NOT NULL, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + reasoning_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_create_5m_tokens INTEGER NOT NULL DEFAULT 0, + cache_create_1h_tokens INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (source, session_id, message_id) + ); + CREATE INDEX idx_turns_ts ON turns(ts); +`; + +interface ArchiveTurnRow { + source: SourceKind; + ts: string; + model: string; + inputTokens?: number; + outputTokens?: number; + reasoningTokens?: number; + cacheReadTokens?: number; + cacheCreate5mTokens?: number; + cacheCreate1hTokens?: number; +} + +function makeArchive(rows: ArchiveTurnRow[]): DatabaseSync { + const db = new DatabaseSync(':memory:'); + db.exec(ARCHIVE_TURNS_DDL); + const insert = db.prepare(` + INSERT INTO turns ( + source, session_id, message_id, ts, model, + input_tokens, output_tokens, reasoning_tokens, + cache_read_tokens, cache_create_5m_tokens, cache_create_1h_tokens + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + for (let i = 0; i < rows.length; i++) { + const r = rows[i]!; + insert.run( + r.source, + `s-${i}`, + `m-${i}`, + r.ts, + r.model, + r.inputTokens ?? 0, + r.outputTokens ?? 0, + r.reasoningTokens ?? 0, + r.cacheReadTokens ?? 0, + r.cacheCreate5mTokens ?? 0, + r.cacheCreate1hTokens ?? 0, + ); + } + return db; +} + +describe('planUsageFromArchive', () => { + const now = new Date('2026-04-15T00:00:00.000Z'); // 14 days into a calendar cycle + + it('parity with computePlanUsage on the same fixture', () => { + const fixtureTurns: TurnRecord[] = [ + turn({ ts: '2026-04-05T00:00:00.000Z', inputTokens: 1_000_000 }), + turn({ ts: '2026-04-10T00:00:00.000Z', inputTokens: 1_000_000 }), + // Outside cycle: previous month + turn({ ts: '2026-03-25T00:00:00.000Z', inputTokens: 5_000_000 }), + ]; + const memUsage = computePlanUsage(plan, fixtureTurns, { pricing: PRICING, now }); + + const db = makeArchive( + fixtureTurns.map((t) => ({ + source: t.source, + ts: t.ts, + model: t.model, + inputTokens: t.usage.input, + outputTokens: t.usage.output, + })), + ); + try { + const archiveUsage = planUsageFromArchive(plan, { pricing: PRICING, db, now }); + // Byte-identical PlanUsage shape on the parity fixture. + assert.deepEqual(archiveUsage, memUsage); + } finally { + db.close(); + } + }); + + it('reset-day boundary: turn at cycleEnd lands in the next cycle', () => { + // resetDay=1 cycle for `now=2026-04-15` is [2026-04-01, 2026-05-01). + // A turn at exactly 2026-05-01T00:00:00.000Z must NOT count toward this + // cycle (matches the `< cycleEndMs` half-open in `computePlanUsage`). + const db = makeArchive([ + // boundary-low: first instant of the cycle → counted + { + source: 'claude-code', + ts: '2026-04-01T00:00:00.000Z', + model: 'claude-sonnet-4-6', + inputTokens: 1_000_000, + }, + // strictly inside + { + source: 'claude-code', + ts: '2026-04-14T23:59:59.999Z', + model: 'claude-sonnet-4-6', + inputTokens: 1_000_000, + }, + // boundary-high: cycle end → next cycle, must be excluded + { + source: 'claude-code', + ts: '2026-05-01T00:00:00.000Z', + model: 'claude-sonnet-4-6', + inputTokens: 1_000_000, + }, + // far past + { + source: 'claude-code', + ts: '2026-03-31T23:59:59.999Z', + model: 'claude-sonnet-4-6', + inputTokens: 1_000_000, + }, + ]); + try { + const u = planUsageFromArchive(plan, { pricing: PRICING, db, now }); + // 2 in-window × $3 = $6 + assert.equal(u.spentUsd, 6); + } finally { + db.close(); + } + }); + + it('claude provider only counts claude-code/anthropic-api turns', () => { + const db = makeArchive([ + { + source: 'claude-code', + ts: '2026-04-05T00:00:00.000Z', + model: 'claude-sonnet-4-6', + inputTokens: 1_000_000, + }, + { + source: 'anthropic-api', + ts: '2026-04-06T00:00:00.000Z', + model: 'claude-sonnet-4-6', + inputTokens: 1_000_000, + }, + { + source: 'codex', + ts: '2026-04-07T00:00:00.000Z', + model: 'claude-sonnet-4-6', + inputTokens: 1_000_000, + }, + ]); + try { + const u = planUsageFromArchive(plan, { pricing: PRICING, db, now }); + // 2 claude turns × $3 = $6, codex excluded + assert.equal(u.spentUsd, 6); + } finally { + db.close(); + } + }); + + it('cursor provider returns $0 without issuing a query against unknown sources', () => { + const cursorPlan: Plan = { ...plan, provider: 'cursor', id: 'cursor-pro' }; + const db = makeArchive([ + // Even if some hypothetical source called 'cursor' lived in the table, + // the helper short-circuits on the empty source list — see + // `providerSources('cursor')`. + { + source: 'claude-code', + ts: '2026-04-05T00:00:00.000Z', + model: 'claude-sonnet-4-6', + inputTokens: 5_000_000, + }, + ]); + try { + const u = planUsageFromArchive(cursorPlan, { pricing: PRICING, db, now }); + assert.equal(u.spentUsd, 0); + assert.equal(u.projectedEndOfCycleUsd, 0); + } finally { + db.close(); + } + }); + + it('custom provider counts every source', () => { + const customPlan: Plan = { ...plan, provider: 'custom' }; + const db = makeArchive([ + { + source: 'claude-code', + ts: '2026-04-05T00:00:00.000Z', + model: 'claude-sonnet-4-6', + inputTokens: 1_000_000, + }, + { + source: 'codex', + ts: '2026-04-06T00:00:00.000Z', + model: 'claude-sonnet-4-6', + inputTokens: 1_000_000, + }, + { + source: 'opencode', + ts: '2026-04-07T00:00:00.000Z', + model: 'claude-sonnet-4-6', + inputTokens: 1_000_000, + }, + ]); + try { + const u = planUsageFromArchive(customPlan, { pricing: PRICING, db, now }); + assert.equal(u.spentUsd, 9); + } finally { + db.close(); + } + }); + + it('flags limitedData when fewer than 7 days have elapsed', () => { + const earlyNow = new Date('2026-04-04T00:00:00.000Z'); + const db = makeArchive([]); + try { + const u = planUsageFromArchive(plan, { pricing: PRICING, db, now: earlyNow }); + assert.equal(u.daysElapsed, 3); + assert.equal(u.limitedData, true); + } finally { + db.close(); + } + }); + + it('does not double-bill Codex reasoning tokens (uses same source override as costForTurn)', () => { + const customPlan: Plan = { ...plan, provider: 'custom' }; + // 1M output × $15 = $15. With reasoning double-billed at the output rate + // we'd see $15 + $15 = $30; the source-aware override keeps it at $15. + const db = makeArchive([ + { + source: 'codex', + ts: '2026-04-05T00:00:00.000Z', + model: 'claude-sonnet-4-6', + outputTokens: 1_000_000, + reasoningTokens: 1_000_000, + }, + ]); + try { + const u = planUsageFromArchive(customPlan, { pricing: PRICING, db, now }); + assert.equal(u.spentUsd, 15); + } finally { + db.close(); + } + }); + + it('honors a custom resetDay (anniversary cycle)', () => { + const anniversaryPlan: Plan = { ...plan, resetDay: 15 }; + // now = April 20, cycle started April 15, ends May 15 + const db = makeArchive([ + // inside + { + source: 'claude-code', + ts: '2026-04-16T00:00:00.000Z', + model: 'claude-sonnet-4-6', + inputTokens: 1_000_000, + }, + // before cycle start — excluded + { + source: 'claude-code', + ts: '2026-04-10T00:00:00.000Z', + model: 'claude-sonnet-4-6', + inputTokens: 5_000_000, + }, + ]); + try { + const u = planUsageFromArchive(anniversaryPlan, { + pricing: PRICING, + db, + now: new Date('2026-04-20T00:00:00.000Z'), + }); + assert.equal(u.spentUsd, 3); + } finally { + db.close(); + } + }); + + it('groups by (source, model) so multi-model spend aggregates correctly', () => { + const customPlan: Plan = { ...plan, provider: 'custom' }; + const pricing: PricingTable = { + ...PRICING, + 'gpt-5-mini': { + input: 1, + output: 5, + cacheRead: 0.1, + cacheWrite: 1.25, + reasoningMode: 'same_as_output', + }, + }; + const db = makeArchive([ + { + source: 'claude-code', + ts: '2026-04-05T00:00:00.000Z', + model: 'claude-sonnet-4-6', + inputTokens: 2_000_000, + }, + { + source: 'codex', + ts: '2026-04-06T00:00:00.000Z', + model: 'gpt-5-mini', + outputTokens: 1_000_000, + }, + ]); + try { + const u = planUsageFromArchive(customPlan, { pricing, db, now }); + // 2M input × $3 = $6 (claude) + 1M output × $5 = $5 (gpt-5-mini) = $11 + assert.equal(u.spentUsd, 11); + } finally { + db.close(); + } + }); +}); diff --git a/packages/analyze/src/plan-usage.ts b/packages/analyze/src/plan-usage.ts index 93904bb..2f3e471 100644 --- a/packages/analyze/src/plan-usage.ts +++ b/packages/analyze/src/plan-usage.ts @@ -1,5 +1,7 @@ -import type { Plan } from '@relayburn/ledger'; -import type { TurnRecord } from '@relayburn/reader'; +import type { DatabaseSync } from 'node:sqlite'; + +import type { Plan, PlanProvider } from '@relayburn/ledger'; +import type { SourceKind, TurnRecord } from '@relayburn/reader'; import { costForTurn } from './cost.js'; import type { PricingTable } from './pricing.js'; @@ -157,3 +159,171 @@ function matchesProvider(provider: Plan['provider'], turn: TurnRecord): boolean return true; } } + +export interface ComputePlanUsageFromArchiveOptions { + pricing: PricingTable; + /** Open archive handle. Caller owns the lifecycle. */ + db: DatabaseSync; + now?: Date; +} + +interface BucketRow { + source: string; + model: string; + input: number | bigint; + output: number | bigint; + reasoning: number | bigint; + cache_read: number | bigint; + cache_5m: number | bigint; + cache_1h: number | bigint; +} + +/** + * Compute `PlanUsage` for `plan` against the archive's `turns` table — one SQL + * aggregate per call instead of a full ledger scan. Returns the same shape as + * `computePlanUsage` so callers can treat the two interchangeably. + * + * The SQL groups by `(source, model)` because cost derivation needs both: the + * per-source `reasoningModeForSource` override (Codex bills reasoning inside + * `output_tokens`) and the per-model pricing rate live at different join + * points and we want them composed exactly the way `costForTurn` would have. + * + * `cycleStart` / `cycleEnd` come from the same `cycleBounds` helper as the + * in-memory path, so reset-day boundaries match byte-for-byte. + */ +export function planUsageFromArchive( + plan: Plan, + opts: ComputePlanUsageFromArchiveOptions, +): PlanUsage { + const now = opts.now ?? new Date(); + const { cycleStart, cycleEnd } = cycleBounds(plan.resetDay, now); + const cycleStartIso = cycleStart.toISOString(); + const cycleEndIso = cycleEnd.toISOString(); + const nowMs = now.getTime(); + const cycleStartMs = cycleStart.getTime(); + const cycleEndMs = cycleEnd.getTime(); + + const sources = providerSources(plan.provider); + // Matches `matchesProvider`'s "no rows" outcome: a provider whose source + // list is empty (e.g. `cursor`, where the synthetic source is not in + // `SourceKind`) should produce $0 spend without issuing a query whose + // `IN ()` would be a SQL syntax error in some dialects. + const rows: BucketRow[] = sources === null + ? runQuery(opts.db, cycleStartIso, cycleEndIso, undefined) + : sources.length === 0 + ? [] + : runQuery(opts.db, cycleStartIso, cycleEndIso, sources); + + let spent = 0; + for (const row of rows) { + // Reuse `costForTurn`'s source-aware reasoning override by going through + // `costForUsage` with an explicit override. Keeps Codex `output_tokens` + // from being double-billed against `usage.reasoning`. + const synthetic: TurnRecord = { + v: 1, + source: row.source as SourceKind, + sessionId: '', + messageId: '', + turnIndex: 0, + ts: cycleStartIso, + model: row.model, + usage: { + input: Number(row.input), + output: Number(row.output), + reasoning: Number(row.reasoning), + cacheRead: Number(row.cache_read), + cacheCreate5m: Number(row.cache_5m), + cacheCreate1h: Number(row.cache_1h), + }, + toolCalls: [], + }; + const cost = costForTurn(synthetic, opts.pricing); + if (cost) spent += cost.total; + } + + const elapsedMs = Math.max(0, nowMs - cycleStartMs); + const cycleMs = Math.max(1, cycleEndMs - cycleStartMs); + const daysElapsed = Math.floor(elapsedMs / MS_PER_DAY); + const daysInCycle = Math.max(1, Math.round(cycleMs / MS_PER_DAY)); + + const fractionElapsed = elapsedMs / cycleMs; + const projected = fractionElapsed > 0 ? spent / fractionElapsed : spent; + + const overBudget = projected > plan.budgetUsd; + + let runwayDays: number | null = null; + if (overBudget && elapsedMs > 0) { + const dailyRate = spent / (elapsedMs / MS_PER_DAY); + if (dailyRate > 0) { + const remaining = Math.max(0, plan.budgetUsd - spent); + runwayDays = Math.floor(remaining / dailyRate); + } + } + + return { + plan, + cycleStart, + cycleEnd, + spentUsd: spent, + daysElapsed, + daysInCycle, + projectedEndOfCycleUsd: projected, + overBudget, + runwayDays, + resetAt: cycleEnd.toISOString(), + limitedData: daysElapsed < LIMITED_DATA_DAYS, + }; +} + +/** + * Translate a plan's provider into the `turns.source` values the SQL query + * should match. `null` means "every source" (custom plans). An empty array + * means "no source the ledger can produce" (cursor — see `matchesProvider`). + */ +function providerSources(provider: PlanProvider): SourceKind[] | null { + switch (provider) { + case 'claude': + return ['claude-code', 'anthropic-api']; + case 'cursor': + // Same rationale as `matchesProvider`: no `SourceKind` value matches + // 'cursor' so we never emit a query at all. + return []; + case 'custom': + return null; + } +} + +function runQuery( + db: DatabaseSync, + cycleStartIso: string, + cycleEndIso: string, + sources: readonly SourceKind[] | undefined, +): BucketRow[] { + // Use a half-open window `[start, end)` to match the in-memory path's + // `ts < cycleEndMs` boundary, so a turn timestamped exactly at the next + // cycle's start lands in the next cycle (not this one). + const baseSql = ` + SELECT + source, + model, + COALESCE(SUM(input_tokens), 0) AS input, + COALESCE(SUM(output_tokens), 0) AS output, + COALESCE(SUM(reasoning_tokens), 0) AS reasoning, + COALESCE(SUM(cache_read_tokens), 0) AS cache_read, + COALESCE(SUM(cache_create_5m_tokens), 0) AS cache_5m, + COALESCE(SUM(cache_create_1h_tokens), 0) AS cache_1h + FROM turns + WHERE ts >= ? AND ts < ?`; + if (sources === undefined) { + const stmt = db.prepare(`${baseSql} GROUP BY source, model`); + return stmt.all(cycleStartIso, cycleEndIso) as unknown as BucketRow[]; + } + // node:sqlite parameter binding doesn't expand arrays, so build the + // placeholders inline. `sources` is a closed enum (`SourceKind`) so this + // is not a SQL-injection vector. + const placeholders = sources.map(() => '?').join(', '); + const stmt = db.prepare( + `${baseSql} AND source IN (${placeholders}) GROUP BY source, model`, + ); + return stmt.all(cycleStartIso, cycleEndIso, ...sources) as unknown as BucketRow[]; +} diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 9f8563b..be9a944 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **`burn plans` (list view) reads spend from the archive** ([#91](https://github.com/AgentWorkforce/burn/issues/91)). The list path now issues one `SUM(...) GROUP BY (source, model)` aggregate per plan against `archive.sqlite` instead of walking the full ledger once per plan. Output is byte-identical to the legacy `queryAll()` reduce path on the parity fixture (text and `--json`); `limitedData` flagging, reset-day boundaries, multi-plan ordering, and built-in presets all carry over. Pass `--no-archive` (or set `RELAYBURN_ARCHIVE=0`) to opt back into the in-memory reduce while the migration shakes out. + ## [0.31.0] - 2026-04-27 ### Changed diff --git a/packages/cli/src/commands/plans.test.ts b/packages/cli/src/commands/plans.test.ts index 016d139..8482a74 100644 --- a/packages/cli/src/commands/plans.test.ts +++ b/packages/cli/src/commands/plans.test.ts @@ -4,10 +4,12 @@ import { tmpdir } from 'node:os'; import * as path from 'node:path'; import { after, beforeEach, describe, it } from 'node:test'; -import { loadPlans } from '@relayburn/ledger'; +import { appendTurns, loadPlans, savePlans } from '@relayburn/ledger'; +import type { Plan } from '@relayburn/ledger'; +import type { TurnRecord } from '@relayburn/reader'; import type { ParsedArgs } from '../args.js'; -import { runPlans } from './plans.js'; +import { runPlans, statusForPlans } from './plans.js'; function args(positional: string[] = [], flags: Record = {}): ParsedArgs { return { positional, flags, tags: {}, passthrough: [] }; @@ -39,17 +41,32 @@ async function captureStdio( describe('burn plans CLI', () => { let tmp: string; + let tmpHome: string; const originalRelay = process.env['RELAYBURN_HOME']; + const originalHome = process.env['HOME']; + const originalArchive = process.env['RELAYBURN_ARCHIVE']; beforeEach(async () => { tmp = await mkdtemp(path.join(tmpdir(), 'burn-plans-cli-')); + tmpHome = await mkdtemp(path.join(tmpdir(), 'burn-plans-cli-home-')); process.env['RELAYBURN_HOME'] = tmp; + // Isolate `homedir()` so `ingestAll`'s scans of ~/.claude/projects, + // ~/.codex/sessions, and ~/.local/share/opencode/storage land in an + // empty temp dir — the dev's real session data must not leak into the + // test ledger and contaminate parity numbers. + process.env['HOME'] = tmpHome; + delete process.env['RELAYBURN_ARCHIVE']; }); after(async () => { if (originalRelay !== undefined) process.env['RELAYBURN_HOME'] = originalRelay; else delete process.env['RELAYBURN_HOME']; + if (originalHome !== undefined) process.env['HOME'] = originalHome; + else delete process.env['HOME']; + if (originalArchive !== undefined) process.env['RELAYBURN_ARCHIVE'] = originalArchive; + else delete process.env['RELAYBURN_ARCHIVE']; await rm(tmp, { recursive: true, force: true }); + await rm(tmpHome, { recursive: true, force: true }); }); it('list with no plans points the user at `add`', async () => { @@ -163,4 +180,188 @@ describe('burn plans CLI', () => { assert.equal(result, 2); assert.match(stderr, /unknown subcommand "noop"/); }); + + // --- archive-vs-fallback parity (issue #91) ----------------------------- + // + // The migration to `planUsageFromArchive` must produce byte-identical + // output to the legacy `queryAll()` reduce path. We pin the parity here + // through `runPlans` so the CLI surface (text + `--json`) is exercised + // end-to-end, not just the analyze-layer helper. + + function turn(opts: { + ts: string; + inputTokens?: number; + outputTokens?: number; + source?: TurnRecord['source']; + model?: string; + sessionId?: string; + messageId?: string; + }): TurnRecord { + return { + v: 1, + source: opts.source ?? 'claude-code', + sessionId: opts.sessionId ?? 's-parity', + messageId: opts.messageId ?? `m-${opts.ts}`, + turnIndex: 0, + ts: opts.ts, + model: opts.model ?? 'claude-sonnet-4-5', + usage: { + input: opts.inputTokens ?? 0, + output: opts.outputTokens ?? 0, + reasoning: 0, + cacheRead: 0, + cacheCreate5m: 0, + cacheCreate1h: 0, + }, + toolCalls: [], + }; + } + + // Build a fixture that straddles the cycle boundary, irrespective of the + // wall-clock day this test runs on. We anchor turns to "yesterday" / + // "two days ago" relative to `now` (so they always land in the current + // resetDay=1 cycle), plus a turn one day before the cycle start (which + // both code paths must exclude identically). + function fixtureTurnsForNow(now: Date): TurnRecord[] { + const yesterday = new Date(now.getTime() - 1 * 24 * 60 * 60 * 1000); + const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000); + // First-of-the-cycle anchor — stamp a turn that's clearly inside the + // current cycle even on the 1st of the month. + const cycleStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); + const cycleStartPlusHour = new Date(cycleStart.getTime() + 60 * 60 * 1000); + // One day before cycle start — must be excluded from current cycle. + const beforeCycle = new Date(cycleStart.getTime() - 24 * 60 * 60 * 1000); + return [ + turn({ ts: cycleStartPlusHour.toISOString(), inputTokens: 1_000_000, messageId: 'm-cycle-anchor' }), + turn({ ts: twoDaysAgo.toISOString(), inputTokens: 500_000, messageId: 'm-two-days' }), + turn({ ts: yesterday.toISOString(), inputTokens: 250_000, messageId: 'm-yesterday' }), + // Excluded — last cycle's spend should not influence either path. + turn({ ts: beforeCycle.toISOString(), inputTokens: 9_999_999, messageId: 'm-prev-cycle' }), + ]; + } + + async function seedPlansAndTurns(plans: Plan[]): Promise { + await savePlans(plans); + await appendTurns(fixtureTurnsForNow(new Date())); + } + + it('list output is byte-identical between archive and --no-archive paths', async () => { + const claudePro: Plan = { + id: 'claude-pro', + provider: 'claude', + name: 'Claude Pro', + budgetUsd: 20, + resetDay: 1, + }; + await seedPlansAndTurns([claudePro]); + + const archiveRun = await captureStdio(() => runPlans(args([]))); + const fallbackRun = await captureStdio(() => runPlans(args([], { 'no-archive': true }))); + + assert.equal(archiveRun.result, 0); + assert.equal(fallbackRun.result, 0); + assert.equal( + archiveRun.stdout, + fallbackRun.stdout, + 'archive path must render the same table as the queryAll fallback', + ); + }); + + it('--json output is byte-identical between archive and --no-archive paths', async () => { + const claudePro: Plan = { + id: 'claude-pro', + provider: 'claude', + name: 'Claude Pro', + budgetUsd: 20, + resetDay: 1, + }; + const customWork: Plan = { + id: 'work-api', + provider: 'custom', + name: 'Work Anthropic API', + budgetUsd: 500, + resetDay: 1, + }; + await seedPlansAndTurns([claudePro, customWork]); + + const archiveRun = await captureStdio(() => runPlans(args([], { json: true }))); + const fallbackRun = await captureStdio(() => + runPlans(args([], { json: true, 'no-archive': true })), + ); + + assert.equal(archiveRun.result, 0); + assert.equal(fallbackRun.result, 0); + + const archiveJson = JSON.parse(archiveRun.stdout); + const fallbackJson = JSON.parse(fallbackRun.stdout); + // Drop Date-typed fields that JSON.stringify renders as ISO strings — + // they round-trip identically anyway, but this is the level the issue + // calls out: "Output is byte-identical to the pre-migration + // implementation." + assert.deepEqual(archiveJson, fallbackJson); + // Spot-check that we exercised both plans, not just an empty list. + // JSON shape is `{ plans: [{ usage: { plan: {...}, spentUsd, ... } }] }`. + assert.equal(archiveJson.plans.length, 2); + assert.ok( + archiveJson.plans.find( + (p: { usage: { plan: { id: string } } }) => p.usage.plan.id === 'claude-pro', + ), + ); + assert.ok( + archiveJson.plans.find( + (p: { usage: { plan: { id: string } } }) => p.usage.plan.id === 'work-api', + ), + ); + }); + + it('RELAYBURN_ARCHIVE=0 env knob is honored as the queryAll fallback', async () => { + const claudePro: Plan = { + id: 'claude-pro', + provider: 'claude', + name: 'Claude Pro', + budgetUsd: 20, + resetDay: 1, + }; + await seedPlansAndTurns([claudePro]); + + // Run with the env knob set; compare to the explicit `--no-archive` run. + process.env['RELAYBURN_ARCHIVE'] = '0'; + const envRun = await captureStdio(() => runPlans(args([], { json: true }))); + delete process.env['RELAYBURN_ARCHIVE']; + const flagRun = await captureStdio(() => + runPlans(args([], { json: true, 'no-archive': true })), + ); + + assert.equal(envRun.result, 0); + assert.equal(flagRun.result, 0); + assert.deepEqual(JSON.parse(envRun.stdout), JSON.parse(flagRun.stdout)); + }); + + it('statusForPlans archive path materializes the same spentUsd as the fallback', async () => { + // Direct statusForPlans parity check — bypasses CLI table formatting and + // pins the underlying number, so a regression in `planUsageFromArchive`'s + // SUM/GROUP BY can't hide behind table whitespace. + const claudePro: Plan = { + id: 'claude-pro', + provider: 'claude', + name: 'Claude Pro', + budgetUsd: 20, + resetDay: 1, + }; + await seedPlansAndTurns([claudePro]); + + const archiveStatus = await statusForPlans([claudePro], { useArchive: true }); + const fallbackStatus = await statusForPlans([claudePro], { useArchive: false }); + + assert.equal(archiveStatus.length, 1); + assert.equal(fallbackStatus.length, 1); + assert.equal(archiveStatus[0]!.usage.spentUsd, fallbackStatus[0]!.usage.spentUsd); + assert.equal( + archiveStatus[0]!.usage.projectedEndOfCycleUsd, + fallbackStatus[0]!.usage.projectedEndOfCycleUsd, + ); + assert.equal(archiveStatus[0]!.usage.daysElapsed, fallbackStatus[0]!.usage.daysElapsed); + assert.equal(archiveStatus[0]!.usage.daysInCycle, fallbackStatus[0]!.usage.daysInCycle); + assert.equal(archiveStatus[0]!.usage.limitedData, fallbackStatus[0]!.usage.limitedData); + }); }); diff --git a/packages/cli/src/commands/plans.ts b/packages/cli/src/commands/plans.ts index 822e04d..7a43325 100644 --- a/packages/cli/src/commands/plans.ts +++ b/packages/cli/src/commands/plans.ts @@ -1,13 +1,19 @@ import { BUILTIN_PRESETS, + buildArchive, findPreset, loadPlans, + openArchive, plansPath, queryAll, savePlans, } from '@relayburn/ledger'; import type { Plan, PlanProvider } from '@relayburn/ledger'; -import { computePlanUsage, loadPricing } from '@relayburn/analyze'; +import { + computePlanUsage, + loadPricing, + planUsageFromArchive, +} from '@relayburn/analyze'; import type { PlanUsage } from '@relayburn/analyze'; import type { ParsedArgs } from '../args.js'; @@ -58,7 +64,7 @@ export async function runPlans(args: ParsedArgs): Promise { async function runList(args: ParsedArgs): Promise { const json = args.flags['json'] === true; const plans = await loadPlans(); - const statuses = await statusForPlans(plans); + const statuses = await statusForPlans(plans, { useArchive: shouldUseArchive(args) }); if (json) { process.stdout.write(JSON.stringify({ plans: statuses }, null, 2) + '\n'); @@ -236,14 +242,48 @@ export interface PlanStatus { usage: PlanUsage; } +export interface StatusForPlansOptions { + /** + * When true, aggregate spend with one SQL query per plan against the + * archive (`archive.sqlite`). When false (default), use the legacy + * `queryAll()` + in-memory reduce path. Defaults to `false` so callers + * have to opt in explicitly: `burn plans` wires this to `shouldUseArchive` + * (the `--no-archive` flag + `RELAYBURN_ARCHIVE` env var); `burn limits` + * stays on the legacy path until it's migrated separately. See #91. + */ + useArchive?: boolean; +} + // Shared by `burn plans` (list view) and `burn limits` (composite view) so // both surfaces show identical numbers. -export async function statusForPlans(plans: Plan[]): Promise { +export async function statusForPlans( + plans: Plan[], + opts: StatusForPlansOptions = {}, +): Promise { if (plans.length === 0) return []; await ingestAll(); const pricing = await loadPricing(); - // Pull the widest cycle window across plans so we only walk the ledger - // once. Cheaper than per-plan queryAll for users with several plans. + const useArchive = opts.useArchive ?? false; + + if (useArchive) { + // Materialize the ledger tail into the archive once before any plan + // queries so `SELECT SUM(...) FROM turns` sees every turn the legacy + // `queryAll()` path would have. Cheap when up to date (idempotent). + await buildArchive(); + const db = await openArchive(); + try { + const now = new Date(); + return plans.map((plan) => ({ + usage: planUsageFromArchive(plan, { pricing, db, now }), + })); + } finally { + db.close(); + } + } + + // Fallback: in-memory reduce. Walk the ledger once across the widest cycle + // window so we still beat per-plan re-scanning when several plans share a + // common cycle. const oldestStart = plans .map((p) => { const usageStub = computePlanUsage(p, [], { pricing, now: new Date() }); @@ -255,6 +295,18 @@ export async function statusForPlans(plans: Plan[]): Promise { return plans.map((plan) => ({ usage: computePlanUsage(plan, turns, { pricing }) })); } +/** + * `--no-archive` flag (or `RELAYBURN_ARCHIVE=0`) opts back into the legacy + * `queryAll()` reduce path. Kept while we shake out the archive migration — + * see issue #91 / #78. + */ +function shouldUseArchive(args: ParsedArgs): boolean { + if (args.flags['no-archive'] === true) return false; + const env = process.env['RELAYBURN_ARCHIVE']; + if (env === '0' || env === 'false') return false; + return true; +} + function isProvider(s: string): s is PlanProvider { return s === 'claude' || s === 'cursor' || s === 'custom'; }