feat(05-01): BudgetEnforcementService factory with policy evaluation and incident lifecycle

- evaluateBudgetGate: strictest-wins blocking (D-01), no-policy=unlimited (D-02)
- checkAndCreateIncidents: warn + hard-stop thresholds with dedup (D-07, D-08)
- checkAutoResolve: auto_resolved_limit_increase when limit increases (D-06)
- autoCloseExpiredIncidents: window rollover cleanup (D-06)
- getBudgetStatus: summary of all policies with current spend
- Notifications with budget_warning and budget_exhaustion batch keys
- Factory pattern matching createAutomationService convention

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 4.6 committed 2026-03-26 20:48:37 -04:00
1 parent 3a68cfa707
commit 72cb921069
1 file changed
+220
+220
View File
@@ -0,0 +1,220 @@
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): string {
if (policy.scopeType === "agent") {
try {
const agents = db.loadSnapshot(null).agents;
const agent = agents.find((a) => a.id === policy.scopeId);
return agent ? `Agent "${agent.name}"` : `Agent ${policy.scopeId.slice(0, 8)}`;
} catch {
return `Agent ${policy.scopeId.slice(0, 8)}`;
}
}
if (policy.scopeType === "project") {
try {
const projects = db.loadSnapshot(null).projects;
const project = projects.find((p) => p.id === policy.scopeId);
return project ? `Project "${project.name}"` : `Project ${policy.scopeId.slice(0, 8)}`;
} catch {
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.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.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.getActivePoliciesForRun(companyId, agentId, projectId);
for (const policy of policies) {
// Guard against division by zero
if (policy.amount <= 0) continue;
const spent = db.getSpentInWindow(policy);
const utilization = spent / policy.amount;
const warnThreshold = policy.warnPercent / 100;
const scopeLabel = getScopeLabel(db, policy);
if (utilization >= warnThreshold) {
db.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.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.getBudgetPolicy(policyId);
if (!policy) return;
const spent = db.getSpentInWindow(policy);
let resolved = false;
// If spend is below hard-stop threshold, resolve all open incidents
if (spent < policy.amount) {
const count = db.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.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.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.listBudgetPolicies(companyId);
const openIncidents = db.listBudgetIncidents(companyId, "open");
return {
policies: policies.map((policy) => {
const spent = db.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,
};
}