feat(05-02): IPC handlers, preload bridge, and ServiceGraph wiring for budget enforcement
- Create ipc-budget-handlers.ts with 6 budget channel handlers (save/delete/list policies, list/resolve incidents, get status)
- Add budgetService to RegisterDomainHandlersDependencies interface
- Register budget handlers in ipc-domain-handlers.ts composition
- Add BudgetEnforcementService lazy slot in service-graph.ts with getOrCreateBudgetEnforcementService
- Pass budgetService to domain handler registration in main-runtime-support.ts
- Fix preload bridge to pass {companyId} object for listBudgetPolicies and getBudgetStatus
- D-06: checkAutoResolve called on saveBudgetPolicy to auto-resolve incidents on limit increase
This commit is contained in:
1 parent
72cb921069
commit
feea5c1fe9
6 files changed
+128
-2
No files matched your search
@@ -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<typeof createBudgetEnforcementService> | 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));
|
||||
});
|
||||
}
|
||||
@@ -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<void>;
|
||||
@@ -40,6 +41,7 @@ export interface RegisterDomainHandlersDependencies {
|
||||
getBrowserActions: () => BrowserActionControl | null;
|
||||
randomFallbackCompanyId: () => string;
|
||||
skillService: ReturnType<typeof createSkillService> | null;
|
||||
budgetService: ReturnType<typeof createBudgetEnforcementService> | null;
|
||||
}
|
||||
|
||||
export function ok<T>(data: T): DesktopResult<T> {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -169,6 +169,7 @@ export function createMainRuntimeSupport(state: MainRuntimeState) {
|
||||
getBrowserActions: () => requireServiceGraph().getBrowserActionService(),
|
||||
randomFallbackCompanyId: () => requireConnectorHealthService().randomFallbackCompanyId(),
|
||||
skillService: requireServiceGraph().getOrCreateSkillService(),
|
||||
budgetService: requireServiceGraph().getOrCreateBudgetEnforcementService(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<typeof createUpdaterService> | null = null;
|
||||
let appLifecycleService: ReturnType<typeof createAppLifecycleService> | null = null;
|
||||
let skillService: ReturnType<typeof createSkillService> | null = null;
|
||||
let budgetEnforcementService: ReturnType<typeof createBudgetEnforcementService> | 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,
|
||||
};
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
Reference in new issue
Block a user