diff --git a/src/main/ipc-budget-handlers.ts b/src/main/ipc-budget-handlers.ts new file mode 100644 index 00000000..fd8726c7 --- /dev/null +++ b/src/main/ipc-budget-handlers.ts @@ -0,0 +1,99 @@ +import { + IPC_CHANNELS, + budgetPolicyInputSchema, + listBudgetPoliciesSchema, + deleteBudgetPolicySchema, + listBudgetIncidentsSchema, + resolveBudgetIncidentSchema, + getBudgetStatusSchema, +} from "@shared/contracts"; +import { fail, ok, type RegisterDomainHandlersDependencies } from "./ipc-domain-common"; +import type { createBudgetEnforcementService } from "./budget-enforcement-service"; +import type { AppDatabase } from "./database"; + +interface BudgetHandlerDependencies extends Pick< + RegisterDomainHandlersDependencies, + "registerHandle" | "ensureCompanyExists" | "publishDomainChanged" +> { + budgetService: ReturnType | null; + db: AppDatabase; +} + +export function registerBudgetHandlers({ + registerHandle, + ensureCompanyExists, + publishDomainChanged, + budgetService, + db, +}: BudgetHandlerDependencies) { + function requireBudgetService() { + if (!budgetService) { + return fail("SERVICE_NOT_READY", "Budget enforcement service is not initialized."); + } + return null; + } + + // ---- Save (create/update) a budget policy ---- + registerHandle(IPC_CHANNELS.saveBudgetPolicy, async (_event, payload) => { + const parsed = budgetPolicyInputSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + const id = db.saveBudgetPolicy(parsed); + // D-06: Auto-resolve incidents if limit was increased above current spend + if (budgetService) { + try { + budgetService.checkAutoResolve(parsed.companyId, id); + } catch { + // Best effort -- service may not be ready + } + } + publishDomainChanged(); + return ok(id); + }); + + // ---- Delete a budget policy ---- + registerHandle(IPC_CHANNELS.deleteBudgetPolicy, async (_event, payload) => { + const parsed = deleteBudgetPolicySchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + db.deleteBudgetPolicy(parsed.companyId, parsed.policyId); + publishDomainChanged(); + return ok(true); + }); + + // ---- List all budget policies for a company ---- + registerHandle(IPC_CHANNELS.listBudgetPolicies, async (_event, payload) => { + const parsed = listBudgetPoliciesSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + return ok(db.listBudgetPolicies(parsed.companyId)); + }); + + // ---- List budget incidents ---- + registerHandle(IPC_CHANNELS.listBudgetIncidents, async (_event, payload) => { + const parsed = listBudgetIncidentsSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + return ok(db.listBudgetIncidents(parsed.companyId, parsed.status)); + }); + + // ---- Resolve a budget incident manually ---- + registerHandle(IPC_CHANNELS.resolveBudgetIncident, async (_event, payload) => { + const parsed = resolveBudgetIncidentSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + db.resolveBudgetIncident(parsed.incidentId, parsed.companyId); + publishDomainChanged(); + return ok(true); + }); + + // ---- Get budget status summary ---- + registerHandle(IPC_CHANNELS.getBudgetStatus, async (_event, payload) => { + const parsed = getBudgetStatusSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + const serviceError = requireBudgetService(); + if (serviceError) return serviceError; + return ok(budgetService!.getBudgetStatus(parsed.companyId)); + }); +} diff --git a/src/main/ipc-domain-common.ts b/src/main/ipc-domain-common.ts index 33a1a55a..ca4f8ee0 100644 --- a/src/main/ipc-domain-common.ts +++ b/src/main/ipc-domain-common.ts @@ -4,6 +4,7 @@ import type { DesktopResult } from "@shared/contracts"; import type { bootstrapOnboardingSchema } from "@shared/contracts"; import type { AppDatabase } from "./database"; import type { createSkillService } from "./skill-service"; +import type { createBudgetEnforcementService } from "./budget-enforcement-service"; export interface BrowserActionControl { clearSocialAccountData: (socialAccountId: string, screenshotPaths?: string[]) => Promise; @@ -40,6 +41,7 @@ export interface RegisterDomainHandlersDependencies { getBrowserActions: () => BrowserActionControl | null; randomFallbackCompanyId: () => string; skillService: ReturnType | null; + budgetService: ReturnType | null; } export function ok(data: T): DesktopResult { diff --git a/src/main/ipc-domain-handlers.ts b/src/main/ipc-domain-handlers.ts index 7269adcd..58f36f6e 100644 --- a/src/main/ipc-domain-handlers.ts +++ b/src/main/ipc-domain-handlers.ts @@ -6,6 +6,7 @@ import { registerContentOperationsHandlers } from "./ipc-content-operations-hand import { registerOnboardingHandlers } from "./ipc-onboarding-handlers"; import { registerRecoveryHandlers } from "./ipc-recovery-handlers"; import { registerSkillHandlers } from "./ipc-skill-handlers"; +import { registerBudgetHandlers } from "./ipc-budget-handlers"; export function registerDomainHandlers(dependencies: RegisterDomainHandlersDependencies) { registerOnboardingHandlers(dependencies); @@ -21,4 +22,11 @@ export function registerDomainHandlers(dependencies: RegisterDomainHandlersDepen skillService: dependencies.skillService, db: dependencies.db, }); + registerBudgetHandlers({ + registerHandle: dependencies.registerHandle, + ensureCompanyExists: dependencies.ensureCompanyExists, + publishDomainChanged: dependencies.publishDomainChanged, + budgetService: dependencies.budgetService, + db: dependencies.db, + }); } diff --git a/src/main/main-runtime-support.ts b/src/main/main-runtime-support.ts index 5e932b5d..fc030b72 100644 --- a/src/main/main-runtime-support.ts +++ b/src/main/main-runtime-support.ts @@ -169,6 +169,7 @@ export function createMainRuntimeSupport(state: MainRuntimeState) { getBrowserActions: () => requireServiceGraph().getBrowserActionService(), randomFallbackCompanyId: () => requireConnectorHealthService().randomFallbackCompanyId(), skillService: requireServiceGraph().getOrCreateSkillService(), + budgetService: requireServiceGraph().getOrCreateBudgetEnforcementService(), }); } diff --git a/src/main/service-graph.ts b/src/main/service-graph.ts index 4a4e692b..84019152 100644 --- a/src/main/service-graph.ts +++ b/src/main/service-graph.ts @@ -13,6 +13,7 @@ import { createUpdaterService } from "./updater-service"; import { createWorkerService } from "./worker-service"; import { createAppLifecycleService } from "./app-lifecycle-service"; import { createSkillService } from "./skill-service"; +import { createBudgetEnforcementService } from "./budget-enforcement-service"; import type { DesktopEvent, RunRecord } from "@shared/types"; import type { AutomationEventContext } from "./automation-service"; import type { NotificationOptions } from "./notification-service"; @@ -70,6 +71,7 @@ export function createServiceGraph({ let updaterService: ReturnType | null = null; let appLifecycleService: ReturnType | null = null; let skillService: ReturnType | null = null; + let budgetEnforcementService: ReturnType | null = null; function getOrCreateSkillService() { if (!skillService) { @@ -82,6 +84,18 @@ export function createServiceGraph({ return skillService; } + function getOrCreateBudgetEnforcementService() { + if (!budgetEnforcementService) { + budgetEnforcementService = createBudgetEnforcementService({ + db, + logger, + notify, + publishDomainChanged, + }); + } + return budgetEnforcementService; + } + const wakeAgentIfPossible =(agentId: string | null | undefined, companyId: string, trigger: string) => { if (!orchestrationService) { throw new Error("Orchestration service is not initialized."); @@ -264,6 +278,7 @@ export function createServiceGraph({ updaterService = null; appLifecycleService = null; skillService = null; + budgetEnforcementService = null; db.close(); // Force exit after grace period — covers all platforms (macOS Dock, Windows tray, Linux) // Worker child processes (claude/codex CLI) may keep the event loop alive indefinitely @@ -284,5 +299,6 @@ export function createServiceGraph({ getUpdaterService: () => updaterService, getAppLifecycleService: () => appLifecycleService, getOrCreateSkillService, + getOrCreateBudgetEnforcementService, }; } diff --git a/src/preload/index.ts b/src/preload/index.ts index 2f0a6dbc..404304aa 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -97,10 +97,10 @@ const api: DesktopApi = { listWakeupRequests: (input) => ipcRenderer.invoke(IPC_CHANNELS.listWakeupRequests, input), saveBudgetPolicy: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveBudgetPolicy, input), deleteBudgetPolicy: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteBudgetPolicy, input), - listBudgetPolicies: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.listBudgetPolicies, companyId), + listBudgetPolicies: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.listBudgetPolicies, { companyId }), listBudgetIncidents: (input) => ipcRenderer.invoke(IPC_CHANNELS.listBudgetIncidents, input), resolveBudgetIncident: (input) => ipcRenderer.invoke(IPC_CHANNELS.resolveBudgetIncident, input), - getBudgetStatus: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getBudgetStatus, companyId), + getBudgetStatus: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getBudgetStatus, { companyId }), recoveryRetry: (taskId: string, agentId: string, companyId: string) => ipcRenderer.invoke("recovery:retry", { taskId, agentId, companyId }), recoveryDismiss: (taskId: string) =>