fix: Plan-based monthly quota tracking (complement to #5 block forecasting)#44
fix: Plan-based monthly quota tracking (complement to #5 block forecasting)#44ericjoye wants to merge 1 commit intoAgentWorkforce:mainfrom
Conversation
…ing (complement to AgentWorkforce#5 block fo
There was a problem hiding this comment.
Pull request overview
Adds initial scaffolding for plan-based budgeting by defining default plan budgets and persisting per-plan usage data to disk.
Changes:
- Introduces a
Planmodel and three preset default plans (Claude Pro/Max, Cursor Pro), plus support for loading custom plans fromplans.json. - Adds
plan-usagepersistence helpers (loadPlanUsage/savePlanUsage) using the existing lock + atomic-write pattern.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 8 comments.
| File | Description |
|---|---|
packages/ledger/src/plans.ts |
Adds default plan definitions and a loader for user-defined plans from disk. |
packages/ledger/src/plan-usage.ts |
Adds persisted storage for monthly/daily usage keyed by plan id. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| export interface Plan { | ||
| name: string; | ||
| monthlyBudget: number; | ||
| dailyBudget?: number; | ||
| provider?: string; | ||
| tier?: string; | ||
| } |
There was a problem hiding this comment.
The PR description/issue scope calls for plan-cycle semantics (e.g. resetDay/billing cycle boundaries) and projected end-of-cycle spend, but the Plan model here only captures budgets and optional provider/tier. If this PR is meant to close #39, the data model likely needs fields for cycle/reset configuration (and associated computations) to support the required CLI output.
| try { | ||
| const fs = require('node:fs'); | ||
| const path = require('node:path'); |
There was a problem hiding this comment.
loadCustomPlans uses require(...) in a codebase that is ESM ("type": "module"). This will throw at runtime because require is not defined in ESM output. Switch to static import (or createRequire) and prefer the existing async fs patterns used elsewhere in @relayburn/ledger.
| const home = process.env.RELAYBURN_HOME || path.join(process.env.HOME || '', '.relayburn'); | ||
| const plansPath = path.join(home, 'plans.json'); |
There was a problem hiding this comment.
The home directory resolution here can produce a relative path when HOME is unset (e.g. Windows/service env), because path.join('', '.relayburn') becomes .relayburn. Reuse the existing ledgerHome() from src/paths.ts (which uses os.homedir()) and build plans.json off of that to keep path semantics consistent across the package.
| import { planUsagePath } from './paths.js'; | ||
|
|
There was a problem hiding this comment.
planUsagePath is imported from ./paths.js but src/paths.ts does not export it (only ledgerPath, cursorsPath, hwmPath, etc.). As-is, this won’t compile. Add a planUsagePath() helper to paths.ts (and re-export it from src/index.ts if it’s part of the public API), or change this module to use an existing path helper.
| import { planUsagePath } from './paths.js'; | |
| import * as paths from './paths.js'; | |
| function planUsagePath(): string { | |
| const exportedPlanUsagePath = (paths as Record<string, unknown>).planUsagePath; | |
| if (typeof exportedPlanUsagePath === 'function') { | |
| return (exportedPlanUsagePath as () => string)(); | |
| } | |
| const exportedLedgerPath = (paths as Record<string, unknown>).ledgerPath; | |
| if (typeof exportedLedgerPath === 'function') { | |
| return path.join(path.dirname((exportedLedgerPath as () => string)()), 'plan-usage.json'); | |
| } | |
| throw new Error('Unable to resolve plan usage path from ./paths.js'); | |
| } |
| const parsed = JSON.parse(raw) as PlanUsageFile; | ||
| if (parsed && typeof parsed === 'object' && parsed.usage && typeof parsed.usage === 'object') { | ||
| return parsed.usage; | ||
| } |
There was a problem hiding this comment.
loadPlanUsage only checks that parsed.usage is an object, but doesn’t validate that usage.monthly and usage.daily exist and are objects of numbers. A malformed file like { "usage": {} } will be accepted and can later cause runtime errors. Validate monthly/daily shapes (or merge with defaults) before returning.
| interface PlanUsage { | ||
| monthly: { | ||
| claudePro: number; | ||
| claudeMax: number; | ||
| cursorPro: number; | ||
| [key: string]: number; | ||
| }; | ||
| daily: { | ||
| claudePro: number; | ||
| claudeMax: number; | ||
| cursorPro: number; | ||
| [key: string]: number; | ||
| }; | ||
| } | ||
|
|
||
| interface PlanUsageFile { | ||
| usage: PlanUsage; | ||
| } | ||
|
|
||
| export async function loadPlanUsage(): Promise<PlanUsage> { | ||
| try { | ||
| const raw = await readFile(planUsagePath(), 'utf8'); | ||
| const parsed = JSON.parse(raw) as PlanUsageFile; | ||
| if (parsed && typeof parsed === 'object' && parsed.usage && typeof parsed.usage === 'object') { | ||
| return parsed.usage; | ||
| } | ||
| } catch { | ||
| // missing or malformed: treat as empty | ||
| } | ||
| return { | ||
| monthly: { | ||
| claudePro: 0, | ||
| claudeMax: 0, | ||
| cursorPro: 0 | ||
| }, | ||
| daily: { | ||
| claudePro: 0, | ||
| claudeMax: 0, | ||
| cursorPro: 0 | ||
| } | ||
| }; |
There was a problem hiding this comment.
The PlanUsage type hard-codes claudePro/claudeMax/cursorPro while also allowing arbitrary keys via an index signature. This is awkward to work with (callers can’t easily iterate without carrying the hard-coded keys) and doesn’t naturally support custom plans. Consider modeling both monthly and daily as Record<string, number> and initializing missing keys to 0 when reading.
| interface PlanUsage { | |
| monthly: { | |
| claudePro: number; | |
| claudeMax: number; | |
| cursorPro: number; | |
| [key: string]: number; | |
| }; | |
| daily: { | |
| claudePro: number; | |
| claudeMax: number; | |
| cursorPro: number; | |
| [key: string]: number; | |
| }; | |
| } | |
| interface PlanUsageFile { | |
| usage: PlanUsage; | |
| } | |
| export async function loadPlanUsage(): Promise<PlanUsage> { | |
| try { | |
| const raw = await readFile(planUsagePath(), 'utf8'); | |
| const parsed = JSON.parse(raw) as PlanUsageFile; | |
| if (parsed && typeof parsed === 'object' && parsed.usage && typeof parsed.usage === 'object') { | |
| return parsed.usage; | |
| } | |
| } catch { | |
| // missing or malformed: treat as empty | |
| } | |
| return { | |
| monthly: { | |
| claudePro: 0, | |
| claudeMax: 0, | |
| cursorPro: 0 | |
| }, | |
| daily: { | |
| claudePro: 0, | |
| claudeMax: 0, | |
| cursorPro: 0 | |
| } | |
| }; | |
| const DEFAULT_PLAN_KEYS = ['claudePro', 'claudeMax', 'cursorPro'] as const; | |
| interface PlanUsage { | |
| monthly: Record<string, number>; | |
| daily: Record<string, number>; | |
| } | |
| interface PlanUsageFile { | |
| usage: PlanUsage; | |
| } | |
| function normalizeUsageBucket(value: unknown): Record<string, number> { | |
| const normalized: Record<string, number> = {}; | |
| if (value && typeof value === 'object') { | |
| for (const [key, entry] of Object.entries(value)) { | |
| if (typeof entry === 'number' && Number.isFinite(entry)) { | |
| normalized[key] = entry; | |
| } | |
| } | |
| } | |
| for (const key of DEFAULT_PLAN_KEYS) { | |
| normalized[key] ??= 0; | |
| } | |
| return normalized; | |
| } | |
| function emptyPlanUsage(): PlanUsage { | |
| return { | |
| monthly: normalizeUsageBucket(undefined), | |
| daily: normalizeUsageBucket(undefined) | |
| }; | |
| } | |
| export async function loadPlanUsage(): Promise<PlanUsage> { | |
| try { | |
| const raw = await readFile(planUsagePath(), 'utf8'); | |
| const parsed = JSON.parse(raw) as PlanUsageFile; | |
| if (parsed && typeof parsed === 'object' && parsed.usage && typeof parsed.usage === 'object') { | |
| return { | |
| monthly: normalizeUsageBucket(parsed.usage.monthly), | |
| daily: normalizeUsageBucket(parsed.usage.daily) | |
| }; | |
| } | |
| } catch { | |
| // missing or malformed: treat as empty | |
| } | |
| return emptyPlanUsage(); |
| export async function loadPlanUsage(): Promise<PlanUsage> { | ||
| try { | ||
| const raw = await readFile(planUsagePath(), 'utf8'); | ||
| const parsed = JSON.parse(raw) as PlanUsageFile; | ||
| if (parsed && typeof parsed === 'object' && parsed.usage && typeof parsed.usage === 'object') { | ||
| return parsed.usage; | ||
| } | ||
| } catch { | ||
| // missing or malformed: treat as empty | ||
| } | ||
| return { | ||
| monthly: { | ||
| claudePro: 0, | ||
| claudeMax: 0, | ||
| cursorPro: 0 | ||
| }, | ||
| daily: { | ||
| claudePro: 0, | ||
| claudeMax: 0, | ||
| cursorPro: 0 | ||
| } | ||
| }; | ||
| } | ||
|
|
||
| export async function savePlanUsage(usage: PlanUsage): Promise<void> { | ||
| const finalPath = planUsagePath(); | ||
| await mkdir(path.dirname(finalPath), { recursive: true }); | ||
| const payload: PlanUsageFile = { usage }; | ||
| const tmpPath = `${finalPath}.tmp`; | ||
| await withLock('plan-usage', async () => { | ||
| await writeFile(tmpPath, JSON.stringify(payload, null, 2), 'utf8'); | ||
| await rename(tmpPath, finalPath); | ||
| }); |
There was a problem hiding this comment.
The repo already has node:test coverage for similar persistence helpers (e.g. src/cursors.test.ts). This new file read/write + locking behavior should get comparable tests (missing file, malformed JSON, round-trip, atomic write / no leftover .tmp, lock serialization) to prevent regressions.
| export async function savePlanUsage(usage: PlanUsage): Promise<void> { | ||
| const finalPath = planUsagePath(); | ||
| await mkdir(path.dirname(finalPath), { recursive: true }); | ||
| const payload: PlanUsageFile = { usage }; | ||
| const tmpPath = `${finalPath}.tmp`; | ||
| await withLock('plan-usage', async () => { | ||
| await writeFile(tmpPath, JSON.stringify(payload, null, 2), 'utf8'); | ||
| await rename(tmpPath, finalPath); | ||
| }); | ||
| } |
There was a problem hiding this comment.
These plan helpers aren’t currently reachable from consumers of @relayburn/ledger because the package exports only expose . / ./schema / ./paths, and src/index.ts doesn’t re-export this module. If this is intended to back CLI features, you’ll likely need to re-export the relevant APIs from src/index.ts and update packages/ledger/package.json exports accordingly.
barryollama
left a comment
There was a problem hiding this comment.
Hermes Agent Code Review 🔍
🔴 Critical Issues
packages/ledger/src/plan-usage.ts:5 - Import error: is imported from , but this function does not exist in the paths module. This will cause a runtime error.
Suggestion: Add the missing function to :
⚠️ Warning
packages/ledger/src/plans.ts:32-33 - Mixed require/import style. Using inside a try-catch while the file uses ES module syntax. Consider using dynamic imports with top-level await pattern or keep consistent ESM style throughout.
💡 Suggestions
-
packages/ledger/src/plan-usage.ts:7-19 - The index signature allows any additional key, but the hardcoded , , are explicitly typed. Consider if strict typing (removing index signature) would be safer, or document why dynamic keys are needed.
-
packages/ledger/src/plans.ts:34 - The logic could fail if HOME is an empty string. Consider providing a more sensible default or throwing a descriptive error.
✅ Looks Good
- Clean use of atomic file writes with tmp + rename pattern
- Proper file locking using
- Good fallback handling for missing/malformed files
- TypeScript interfaces are well-defined
Verdict: Changes Requested
The missing export is a blocking issue that will cause the code to fail at runtime.
Reviewed by Hermes Agent
barryollama
left a comment
There was a problem hiding this comment.
Hermes Agent Code Review 🔍
🔴 Critical Issues
packages/ledger/src/plan-usage.ts:5 - Import error: planUsagePath is imported from ./paths.js, but this function does not exist in the paths module. This will cause a runtime error.
Suggestion: Add the missing function to packages/ledger/src/paths.ts:
export function planUsagePath(): string {
return path.join(ledgerHome(), 'plan-usage.json');
}⚠️ Warning
packages/ledger/src/plans.ts:32-33 - Mixed require/import style. Using require() inside a try-catch while the file uses ES module syntax. Consider using dynamic imports with top-level await pattern or keep consistent ESM style throughout.
💡 Suggestions
-
packages/ledger/src/plan-usage.ts:7-19 - The index signature
[key: string]: numberallows any additional key, but the hardcodedclaudePro,claudeMax,cursorProare explicitly typed. Consider if strict typing (removing index signature) would be safer, or document why dynamic keys are needed. -
packages/ledger/src/plans.ts:34 - The logic
process.env.HOME || ''could fail if HOME is an empty string. Consider providing a more sensible default or throwing a descriptive error.
✅ Looks Good
- Clean use of atomic file writes with tmp + rename pattern
- Proper file locking using
withLock - Good fallback handling for missing/malformed files
- TypeScript interfaces are well-defined
Verdict: Changes Requested
The missing planUsagePath export is a blocking issue that will cause the code to fail at runtime.
Reviewed by Hermes Agent
This fixes #39.
What changed:
AI-generated fix for: Plan-based monthly quota tracking (complement to #5 block forecasting)
Closes #39