import type { AppDatabase } from "./database"; import type { NotificationOptions } from "./notification-service"; import type { BudgetPolicyRecord, BudgetStatusSummary } from "@shared/types"; interface BudgetEnforcementDependencies { db: AppDatabase; logger: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void }; notify: (options: NotificationOptions) => void; publishDomainChanged: () => void; } export interface BudgetGateResult { allowed: boolean; blockedBy: Array<{ policyId: string; scopeType: string; scopeId: string; spent: number; limit: number; }>; } /** * Returns a human-readable label for a budget policy scope. * Looks up entity names from the database for agent and project scopes. */ function getScopeLabel( db: AppDatabase, policy: BudgetPolicyRecord, logger: { warn: (...args: unknown[]) => void }, ): string { if (policy.scopeType === "agent") { try { const agent = db.core.entity.getAgent(policy.scopeId); return `Agent "${agent.name}"`; } catch (err) { logger.warn(`[budget] getAgent failed for scope ${policy.scopeId}:`, err instanceof Error ? err.message : String(err)); return `Agent ${policy.scopeId.slice(0, 8)}`; } } if (policy.scopeType === "project") { try { const row = db.queryAll<{ name: string }>("select name from projects where id = ?", policy.scopeId); return row.length > 0 ? `Project "${row[0].name}"` : `Project ${policy.scopeId.slice(0, 8)}`; } catch (err) { logger.warn(`[budget] project name lookup failed for scope ${policy.scopeId}:`, err instanceof Error ? err.message : String(err)); return `Project ${policy.scopeId.slice(0, 8)}`; } } return "Company budget"; } export function createBudgetEnforcementService({ db, logger, notify, publishDomainChanged, }: BudgetEnforcementDependencies) { /** * Evaluates whether a new run should be allowed based on active budget policies. * * Per D-01 (strictest-wins): if ANY policy scope is exhausted, the run is blocked. * Per D-02: if no policies exist, the run is allowed (unlimited). * Per D-04/D-05: this only blocks NEW dispatches -- in-flight runs are unaffected. */ function evaluateBudgetGate( companyId: string, agentId: string, projectId: string | null, ): BudgetGateResult { const policies = db.core.budget.getActivePoliciesForRun(companyId, agentId, projectId); if (policies.length === 0) { return { allowed: true, blockedBy: [] }; } const blockedBy: BudgetGateResult["blockedBy"] = []; for (const policy of policies) { if (!policy.hardStopEnabled) continue; const spent = db.core.budget.getSpentInWindow(policy); if (spent >= policy.amount) { blockedBy.push({ policyId: policy.id, scopeType: policy.scopeType, scopeId: policy.scopeId, spent, limit: policy.amount, }); } } return { allowed: blockedBy.length === 0, blockedBy }; } /** * Checks all active policies for the given run context and creates/updates * budget incidents at warn and hard-stop thresholds. * * Per D-07: fires both warn and hard-stop in the same code path. * Per D-08: deduplicates incidents via upsertBudgetIncident. */ function checkAndCreateIncidents( companyId: string, agentId: string, projectId: string | null, ): void { const policies = db.core.budget.getActivePoliciesForRun(companyId, agentId, projectId); for (const policy of policies) { // amount <= 0 disables the policy (and would otherwise divide by zero below). if (policy.amount <= 0) continue; const spent = db.core.budget.getSpentInWindow(policy); const utilization = spent / policy.amount; const warnThreshold = policy.warnPercent / 100; const scopeLabel = getScopeLabel(db, policy, logger); if (utilization >= warnThreshold) { db.core.budget.upsertBudgetIncident(policy, "warn_threshold", spent); if (policy.notifyEnabled) { notify({ title: "Budget Warning", body: `${scopeLabel} at ${Math.round(utilization * 100)}% of budget ($${(spent / 100).toFixed(2)} / $${(policy.amount / 100).toFixed(2)})`, urgency: "informational", navigation: { section: "costs" }, batchKey: "budget_warning", }); } } if (utilization >= 1.0 && policy.hardStopEnabled) { db.core.budget.upsertBudgetIncident(policy, "hard_stop", spent); if (policy.notifyEnabled) { notify({ title: "Budget Exhausted", body: `${scopeLabel} budget exhausted - new runs blocked`, urgency: "critical", navigation: { section: "costs" }, batchKey: "budget_exhaustion", }); } } } publishDomainChanged(); } /** * Checks if a policy's limit was increased above the observed spend amount, * and auto-resolves open incidents if so. * * Per D-06: fires auto_resolved_limit_increase status. */ function checkAutoResolve(companyId: string, policyId: string): void { const policy = db.core.budget.getBudgetPolicy(policyId); if (!policy) return; const spent = db.core.budget.getSpentInWindow(policy); let resolved = false; // If spend is below hard-stop threshold, resolve all open incidents if (spent < policy.amount) { const count = db.core.budget.resolveIncidentsForPolicy(policyId, "auto_resolved_limit_increase"); if (count > 0) resolved = true; } // If spend is below warn threshold, resolve warn incidents specifically const warnAmount = Math.floor((policy.amount * policy.warnPercent) / 100); if (spent < warnAmount) { const count = db.core.budget.resolveWarnIncidentsForPolicy(policyId, "auto_resolved_limit_increase"); if (count > 0) resolved = true; } if (resolved) { logger.info(`Budget auto-resolve: policy ${policyId} incidents resolved (spent=${spent}, limit=${policy.amount})`); publishDomainChanged(); } } /** * Closes incidents whose calendar month window has ended. * Per D-06: window rollover cleanup. */ function autoCloseExpiredIncidents(companyId: string): number { const count = db.core.budget.autoCloseExpiredIncidents(companyId); if (count > 0) { logger.info(`Budget auto-close: ${count} expired incident(s) for company ${companyId}`); publishDomainChanged(); } return count; } /** * Returns a summary of all budget policies and their current spend status. */ function getBudgetStatus(companyId: string): BudgetStatusSummary { const policies = db.core.budget.listBudgetPolicies(companyId); const openIncidents = db.core.budget.listBudgetIncidents(companyId, "open"); return { policies: policies.map((policy) => { const spent = db.core.budget.getSpentInWindow(policy); const hasOpenIncident = openIncidents.some((i) => i.policyId === policy.id); return { policyId: policy.id, scopeType: policy.scopeType, scopeId: policy.scopeId, amount: policy.amount, spent, warnPercent: policy.warnPercent, hardStopEnabled: policy.hardStopEnabled, hasOpenIncident, }; }), }; } return { evaluateBudgetGate, checkAndCreateIncidents, checkAutoResolve, autoCloseExpiredIncidents, getBudgetStatus, }; }