Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,11 @@ CREATE INDEX idx_budget_period ON budget_tracking(period, period_start);
| `templates delete <name>` | Delete a custom template (v1.1) |
| `templates export <name>` | Export a template to JSON (v1.1) |
| `templates import` | Import a template from JSON (v1.1) |
| `config set <key> <value>` | Set a configuration value (v1.1) |
| `config get <key>` | Get a configuration value (v1.1) |
| `config list` | Display all configuration values (v1.1) |
| `budget status` | Show budget and spending status (v1.1) |
| `budget-status` | Alias for `budget status` (backward compat) |

## MCP Server Tools (v0.4)

Expand Down
377 changes: 377 additions & 0 deletions src/budget-tracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,377 @@
/**
* Budget tracking and enforcement for parallel-cc
*
* Manages cost tracking by period, budget limits, and spending reports.
*/

import type { SessionDB } from './db.js';
import type { ConfigManager } from './config.js';
import type {
BudgetPeriod,
BudgetTracking,
BudgetStatus
} from './types.js';

/**
* Result of a budget check
*/
export interface BudgetCheckResult {
allowed: boolean;
currentSpent: number;
estimatedCost: number;
remaining?: number;
limit?: number;
message?: string;
}

/**
* Budget warning when threshold is reached
*/
export interface BudgetThresholdWarning {
threshold: number;
percentUsed: number;
currentSpent: number;
limit: number;
message: string;
}

/**
* Result of session budget validation
*/
export interface SessionBudgetValidation {
valid: boolean;
message?: string;
warning?: string;
}

/**
* BudgetTracker - Manages budget tracking and enforcement
*
* Features:
* - Period-based spending tracking (daily, weekly, monthly)
* - Budget limit enforcement with configurable thresholds
* - Session cost recording
* - Budget status reports
*
* Note: Uses integer percentages (0-100) internally for Set keys
* to avoid floating-point precision issues.
*/
export class BudgetTracker {
private db: SessionDB;
private configManager: ConfigManager;
// Use integer percentages (0-100) as Set keys to avoid float precision issues
private warningsIssued: Set<number> = new Set();
// Track current period to reset warnings on period rollover
private currentPeriodKey: string | null = null;

constructor(db: SessionDB, configManager: ConfigManager) {
this.db = db;
this.configManager = configManager;
}

/**
* Calculate the start date for a given period
*
* **IMPORTANT: UTC-based calculation**
* All period calculations use UTC dates to ensure consistency across timezones.
* This means:
* - Daily periods reset at midnight UTC (not local time)
* - Weekly periods start on Monday UTC
* - Monthly periods start on the 1st UTC
*
* Users in different timezones may see period boundaries at different local times.
* For example, a user in PST (-8) will see daily periods reset at 4pm local time.
*
* @param period - Budget period type
* @param date - Reference date (default: now)
* @returns ISO date string (YYYY-MM-DD) in UTC
*/
getPeriodStart(period: BudgetPeriod, date: Date = new Date()): string {
const year = date.getUTCFullYear();
const month = date.getUTCMonth();
const day = date.getUTCDate();

switch (period) {
case 'daily':
return `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;

case 'weekly': {
// Get Monday of the current week
const dayOfWeek = date.getUTCDay();
const daysFromMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
const monday = new Date(date);
monday.setUTCDate(day - daysFromMonday);
return `${monday.getUTCFullYear()}-${String(monday.getUTCMonth() + 1).padStart(2, '0')}-${String(monday.getUTCDate()).padStart(2, '0')}`;
}

case 'monthly':
return `${year}-${String(month + 1).padStart(2, '0')}-01`;

default:
throw new Error(`Unknown budget period: ${period}`);
}
}

/**
* Get or create a budget tracking record for the current period
*
* Automatically resets warning tracking when the period rolls over
* to a new time window.
*
* @param period - Budget period type
* @returns Budget tracking record
*/
getOrCreatePeriodRecord(period: BudgetPeriod): BudgetTracking {
const periodStart = this.getPeriodStart(period);
const budgetConfig = this.configManager.getBudgetConfig();

// Check for period rollover and reset warnings if needed
const periodKey = `${period}:${periodStart}`;
if (this.currentPeriodKey !== null && this.currentPeriodKey !== periodKey) {
// Period has rolled over - reset warning tracking
this.warningsIssued.clear();
}
this.currentPeriodKey = periodKey;

// Get budget limit for this period type
let budgetLimit: number | undefined;
if (period === 'monthly' && budgetConfig.monthlyLimit !== undefined) {
budgetLimit = budgetConfig.monthlyLimit;
}

return this.db.getOrCreateBudgetTrackingRecord(period, periodStart, budgetLimit);
}

/**
* Record a cost to a period
*
* @param cost - Cost in USD
* @param period - Budget period type (default: monthly)
*/
recordCost(cost: number, period: BudgetPeriod = 'monthly'): void {
if (cost < 0) {
throw new Error('Cost must be a non-negative number');
}

if (cost === 0) {
return;
}

const record = this.getOrCreatePeriodRecord(period);
this.db.updateBudgetSpent(record.id, cost);
}

/**
* Record cost for a specific session
*
* Updates both the session's cost_estimate and the monthly budget tracking.
*
* @param sessionId - Session ID
* @param cost - Cost in USD
*/
recordSessionCost(sessionId: string, cost: number): void {
// Update session cost estimate
this.db.updateSessionCost(sessionId, cost);

// Update monthly tracking
this.recordCost(cost, 'monthly');
}

/**
* Check if adding a cost would exceed the monthly budget
*
* @param estimatedCost - Estimated cost to add
* @returns Budget check result
*/
checkMonthlyBudget(estimatedCost: number): BudgetCheckResult {
const budgetConfig = this.configManager.getBudgetConfig();
const monthlyLimit = budgetConfig.monthlyLimit;

const currentSpent = this.getCurrentSpending('monthly');

// No limit configured
if (monthlyLimit === undefined || monthlyLimit === null) {
return {
allowed: true,
currentSpent,
estimatedCost
};
}

const remaining = monthlyLimit - currentSpent;
const wouldExceed = currentSpent + estimatedCost > monthlyLimit;

return {
allowed: !wouldExceed,
currentSpent,
estimatedCost,
remaining,
limit: monthlyLimit,
message: wouldExceed
? `Adding $${estimatedCost.toFixed(2)} would exceed monthly budget. ` +
`Current: $${currentSpent.toFixed(2)}, Limit: $${monthlyLimit.toFixed(2)}, ` +
`Remaining: $${remaining.toFixed(2)}`
: undefined
};
}

/**
* Get current spending for a period
*
* @param period - Budget period type
* @returns Current spending amount
*/
getCurrentSpending(period: BudgetPeriod): number {
const record = this.getOrCreatePeriodRecord(period);
return record.spent;
}

/**
* Generate a budget status report
*
* @param period - Budget period type (default: monthly)
* @returns Budget status report
*/
generateBudgetStatus(period: BudgetPeriod = 'monthly'): BudgetStatus {
const periodRecord = this.getOrCreatePeriodRecord(period);
const budgetConfig = this.configManager.getBudgetConfig();

// Get E2B sessions with full data (including cost fields)
const e2bSessions = this.db.listE2BSessions();

// Build session info - get full session data to include cost fields
const sessions = e2bSessions.map(e2bSession => {
// Get full session data which includes cost fields
const fullSession = this.db.getSessionById(e2bSession.id);
return {
sessionId: e2bSession.id,
sandboxId: e2bSession.sandbox_id,
budgetLimit: fullSession?.budget_limit ?? undefined,
costEstimate: fullSession?.cost_estimate ?? undefined,
status: e2bSession.status ?? undefined,
createdAt: e2bSession.created_at
};
});

// Calculate totals
const totalSpent = periodRecord.spent;
const limit = period === 'monthly' ? budgetConfig.monthlyLimit : undefined;
const remaining = limit !== undefined ? limit - totalSpent : undefined;

return {
currentPeriod: {
period,
start: periodRecord.periodStart,
limit,
spent: totalSpent,
remaining
},
sessions,
totalSpent,
remainingBudget: remaining
};
}

/**
* Get warning thresholds from config
*
* @returns Array of threshold percentages (e.g., [0.5, 0.8])
*/
getWarningThresholds(): number[] {
const budgetConfig = this.configManager.getBudgetConfig();
return budgetConfig.warningThresholds ?? [0.5, 0.8];
}

/**
* Check if any warning thresholds have been crossed
*
* Uses integer percentages (0-100) for Set keys to avoid
* floating-point precision issues with values like 0.8.
*
* @returns Warning info if threshold crossed, null otherwise
*/
checkWarningThresholds(): BudgetThresholdWarning | null {
const budgetConfig = this.configManager.getBudgetConfig();
const monthlyLimit = budgetConfig.monthlyLimit;

// No limit means no warnings
if (monthlyLimit === undefined || monthlyLimit === null || monthlyLimit === 0) {
return null;
}

const currentSpent = this.getCurrentSpending('monthly');
const percentUsed = currentSpent / monthlyLimit;
const thresholds = this.getWarningThresholds().sort((a, b) => b - a); // Sort descending

// Find the highest threshold that has been crossed but not yet warned about
for (const threshold of thresholds) {
// Convert to integer percentage (0-100) for reliable Set lookup
const thresholdInt = Math.round(threshold * 100);
if (percentUsed >= threshold && !this.warningsIssued.has(thresholdInt)) {
this.warningsIssued.add(thresholdInt);

return {
threshold,
percentUsed,
currentSpent,
limit: monthlyLimit,
message: `Budget warning: ${(percentUsed * 100).toFixed(0)}% of monthly limit used ` +
`($${currentSpent.toFixed(2)} / $${monthlyLimit.toFixed(2)})`
};
}
}

return null;
}

/**
* Get the default per-session budget from config
*
* @returns Per-session default or undefined if not configured
*/
getPerSessionDefault(): number | undefined {
const budgetConfig = this.configManager.getBudgetConfig();
return budgetConfig.perSessionDefault;
}

/**
* Validate a session budget amount
*
* @param budget - Proposed session budget
* @returns Validation result
*/
validateSessionBudget(budget: number): SessionBudgetValidation {
if (budget < 0) {
return {
valid: false,
message: 'Session budget must be a positive number'
};
}

// Check if this would likely exceed the monthly budget
const budgetConfig = this.configManager.getBudgetConfig();
const monthlyLimit = budgetConfig.monthlyLimit;

if (monthlyLimit !== undefined && monthlyLimit !== null) {
const currentSpent = this.getCurrentSpending('monthly');
const remaining = monthlyLimit - currentSpent;

if (budget > remaining) {
return {
valid: true, // Still valid, but warn
warning: `Session budget ($${budget.toFixed(2)}) would exceed remaining monthly budget ` +
`($${remaining.toFixed(2)}). Total monthly limit: $${monthlyLimit.toFixed(2)}`
};
}
}

return { valid: true };
}

/**
* Reset warning tracking (for new sessions)
*/
resetWarnings(): void {
this.warningsIssued.clear();
}
}
Loading