From 14da6b5ede95d67b130f37db22cbed7b896b8c24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 22 Mar 2026 14:29:43 -0400 Subject: [PATCH] feat(03-03): add budget alert OS notifications and scheduled post recovery - Add notify() calls at 80% budget threshold warning and 100% exhaustion auto-pause - Compute percentage as Math.round(utilization * 100) for accurate display - Add scheduled social post timer re-registration on app startup - Add unit tests for budget threshold detection and notification logic --- src/main/automation-service.ts | 56 +++++++++++- src/main/bootstrap-application.ts | 32 +++++++ tests/unit/budget-alert.test.ts | 147 ++++++++++++++++++++++++++++++ 3 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 tests/unit/budget-alert.test.ts diff --git a/src/main/automation-service.ts b/src/main/automation-service.ts index d76d4ee2..d1d6173f 100644 --- a/src/main/automation-service.ts +++ b/src/main/automation-service.ts @@ -816,7 +816,7 @@ export function createAutomationService({ if (isSuccess && effectiveStatus === "in_progress") { // Check if non-code task produced a deliverable before auto-advancing - const taskType = ((task as Record).task_type as string) ?? "general"; + const taskType = ((task as unknown as Record).task_type as string) ?? "general"; const needsDeliverable = taskType === "research" || taskType === "content"; if (needsDeliverable) { @@ -907,6 +907,19 @@ export function createAutomationService({ ...automationContext, agentId: agent.id, }); + // Fire OS notification for budget threshold warning + const pct = Math.round(utilization * 100); + notify( + "Budget Alert", + `${agent.name} is at ${pct}% of monthly budget ($${agent.spentMonthlyUsd.toFixed(2)} / $${agent.budgetMonthlyUsd.toFixed(2)})`, + ); + if (utilization >= 1.0) { + // Fire OS notification for budget exhaustion (agent auto-paused by database) + notify( + "Budget Alert", + `${agent.name} paused -- monthly budget exhausted ($${agent.spentMonthlyUsd.toFixed(2)} / $${agent.budgetMonthlyUsd.toFixed(2)})`, + ); + } } } } catch { @@ -981,6 +994,47 @@ export function createAutomationService({ } } + if (approval.type === "social_post" && decision === "approved" && approval.payloadJson) { + try { + const draft = JSON.parse(approval.payloadJson) as { + socialAccountId: string; + platform: string; + content: string; + mediaUrls: string[]; + scheduledAt: string | null; + }; + + const dispatchSocialAction = () => { + const actionId = db.saveBrowserAction({ + companyId: approval.companyId, + socialAccountId: draft.socialAccountId, + agentId: approval.requestedByAgentId ?? "", + actionType: "post", + payloadJson: JSON.stringify({ content: draft.content, mediaUrls: draft.mediaUrls, scheduledAt: draft.scheduledAt }), + status: "queued", + approvalId: approval.id, + }); + publishDomainChanged(); + void dispatchBrowserAction(actionId, approval.companyId).catch((error) => { + logger.error(`[automation] Failed to dispatch approved social post ${actionId}: ${error instanceof Error ? error.message : String(error)}`); + }); + }; + + if (draft.scheduledAt) { + const delay = new Date(draft.scheduledAt).getTime() - Date.now(); + if (delay > 0) { + setTimeout(dispatchSocialAction, delay); + } else { + dispatchSocialAction(); + } + } else { + dispatchSocialAction(); + } + } catch (err) { + logger.error("[automation] Failed to dispatch approved social post:", err); + } + } + if (approval.payloadJson) { try { const payload = JSON.parse(approval.payloadJson) as { workflowId?: string; stepId?: string }; diff --git a/src/main/bootstrap-application.ts b/src/main/bootstrap-application.ts index b75a2696..e607b000 100644 --- a/src/main/bootstrap-application.ts +++ b/src/main/bootstrap-application.ts @@ -176,6 +176,38 @@ export async function bootstrapApplication({ }); } + // Re-register timers for scheduled social posts (survives app restart). + // Scan all companies for queued browser actions with a future scheduledAt time + // and set up setTimeout to dispatch them at the right moment. + try { + const snapshot = db.listSnapshot(); + for (const company of snapshot.companies) { + const queuedActions = db.listBrowserActions(company.id, { status: "queued" }); + for (const action of queuedActions) { + if (action.payloadJson) { + try { + const payload = JSON.parse(action.payloadJson) as { scheduledAt?: string }; + if (payload.scheduledAt) { + const delay = new Date(payload.scheduledAt).getTime() - Date.now(); + if (delay > 0) { + setTimeout(() => { + const browserActions = serviceGraph.getBrowserActionService(); + if (browserActions) { + void browserActions.dispatchBrowserAction(action.id, company.id); + } + }, delay); + log.info(`[bootstrap] Re-registered scheduled post timer: action ${action.id} in ${Math.round(delay / 1000)}s`); + } + // If delay <= 0, the action is overdue -- normal dispatch cycle will pick it up + } + } catch { /* ignore malformed payload */ } + } + } + } + } catch (error) { + log.warn(`[bootstrap] Failed to re-register scheduled post timers: ${error instanceof Error ? error.message : String(error)}`); + } + // Show the window immediately so the user sees the app launch without delay. await serviceGraph.getDesktopShellService()?.ensureWindow(); serviceGraph.getAppLifecycleService()?.register(hasSingleInstanceLock); diff --git a/tests/unit/budget-alert.test.ts b/tests/unit/budget-alert.test.ts new file mode 100644 index 00000000..70e42fae --- /dev/null +++ b/tests/unit/budget-alert.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, vi } from "vitest"; + +/** + * Tests for budget alert notification logic. + * + * Budget alerts fire at two thresholds: + * 1. 80% utilization -> warning notification with agent name and percentage + * 2. 100% utilization -> paused notification indicating budget exhaustion + * 3. No notification when budgetMonthlyUsd is 0 (unlimited budget) + * 4. Percentage is calculated as Math.round((spentMonthlyUsd / budgetMonthlyUsd) * 100) + */ + +/** + * Extract and test the budget notification logic that lives in automation-service.ts + * handleRunFinished -> budget threshold section. + * + * The actual code pattern in automation-service.ts: + * if (agent.budgetMonthlyUsd > 0) { + * const utilization = agent.spentMonthlyUsd / agent.budgetMonthlyUsd; + * if (utilization >= 0.8) { + * // 80% threshold warning + * const pct = Math.round(utilization * 100); + * notify("Budget Alert", `${agent.name} is at ${pct}% of monthly budget ...`); + * if (utilization >= 1.0) { + * // 100% exhaustion notification + * notify("Budget Alert", `${agent.name} paused -- monthly budget exhausted ...`); + * } + * } + * } + */ +function simulateBudgetNotification( + notify: (title: string, body: string) => void, + agent: { name: string; budgetMonthlyUsd: number; spentMonthlyUsd: number }, +) { + if (agent.budgetMonthlyUsd > 0) { + const utilization = agent.spentMonthlyUsd / agent.budgetMonthlyUsd; + if (utilization >= 0.8) { + const pct = Math.round(utilization * 100); + notify( + "Budget Alert", + `${agent.name} is at ${pct}% of monthly budget ($${agent.spentMonthlyUsd.toFixed(2)} / $${agent.budgetMonthlyUsd.toFixed(2)})`, + ); + if (utilization >= 1.0) { + notify( + "Budget Alert", + `${agent.name} paused -- monthly budget exhausted ($${agent.spentMonthlyUsd.toFixed(2)} / $${agent.budgetMonthlyUsd.toFixed(2)})`, + ); + } + } + } +} + +describe("Budget Alert Notifications", () => { + it("fires warning notification at 80% budget utilization", () => { + const notify = vi.fn(); + simulateBudgetNotification(notify, { + name: "Alice", + budgetMonthlyUsd: 100, + spentMonthlyUsd: 85, + }); + expect(notify).toHaveBeenCalledTimes(1); + expect(notify).toHaveBeenCalledWith( + "Budget Alert", + expect.stringContaining("Alice"), + ); + expect(notify).toHaveBeenCalledWith( + "Budget Alert", + expect.stringContaining("85%"), + ); + expect(notify).toHaveBeenCalledWith( + "Budget Alert", + expect.stringContaining("% of monthly budget"), + ); + }); + + it("fires paused notification at 100% budget utilization", () => { + const notify = vi.fn(); + simulateBudgetNotification(notify, { + name: "Bob", + budgetMonthlyUsd: 50, + spentMonthlyUsd: 55, + }); + // Both the 80% and 100% notifications should fire + expect(notify).toHaveBeenCalledTimes(2); + // Second call should be the exhaustion notification + expect(notify).toHaveBeenCalledWith( + "Budget Alert", + expect.stringContaining("paused"), + ); + expect(notify).toHaveBeenCalledWith( + "Budget Alert", + expect.stringContaining("budget exhausted"), + ); + }); + + it("correctly calculates percentage as Math.round(utilization * 100)", () => { + const notify = vi.fn(); + // 83.333...% should round to 83% + simulateBudgetNotification(notify, { + name: "Charlie", + budgetMonthlyUsd: 60, + spentMonthlyUsd: 50, + }); + expect(notify).toHaveBeenCalledWith( + "Budget Alert", + expect.stringContaining("83%"), + ); + }); + + it("does not fire notification when budgetMonthlyUsd is 0 (unlimited)", () => { + const notify = vi.fn(); + simulateBudgetNotification(notify, { + name: "Diana", + budgetMonthlyUsd: 0, + spentMonthlyUsd: 500, + }); + expect(notify).not.toHaveBeenCalled(); + }); + + it("does not fire notification when utilization is below 80%", () => { + const notify = vi.fn(); + simulateBudgetNotification(notify, { + name: "Eve", + budgetMonthlyUsd: 100, + spentMonthlyUsd: 50, + }); + expect(notify).not.toHaveBeenCalled(); + }); + + it("fires both notifications at exactly 100%", () => { + const notify = vi.fn(); + simulateBudgetNotification(notify, { + name: "Frank", + budgetMonthlyUsd: 100, + spentMonthlyUsd: 100, + }); + expect(notify).toHaveBeenCalledTimes(2); + // First call: 80% threshold warning + expect(notify.mock.calls[0]![0]).toBe("Budget Alert"); + expect(notify.mock.calls[0]![1]).toContain("100%"); + expect(notify.mock.calls[0]![1]).toContain("% of monthly budget"); + // Second call: exhaustion notification + expect(notify.mock.calls[1]![0]).toBe("Budget Alert"); + expect(notify.mock.calls[1]![1]).toContain("paused"); + expect(notify.mock.calls[1]![1]).toContain("budget exhausted"); + }); +});