From 1c462d571c3f5094bc9ee4d4f671ad05b0061ecc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 26 Mar 2026 21:17:02 -0400 Subject: [PATCH] feat(05-03): policy-level BudgetSummary, incident-based BudgetAlertBanner, AgentEditor policy wiring - BudgetSummary shows policy-level utilization bars when companyId provided, falls back to agent-level - BudgetAlertBanner loads open incidents for policy-based alerts (warn_threshold/hard_stop), falls back to agent thresholds - AgentEditor reads budget from budget_policies and writes budget as policy on save (D-11) - Dashboard passes companyId through to BudgetAlertBanner --- src/renderer/App.tsx | 3 +- .../dashboard/BudgetAlertBanner.tsx | 112 +++++++++++++++--- .../components/dashboard/Dashboard.tsx | 5 +- .../components/execution/BudgetSummary.tsx | 106 +++++++++++++---- .../components/organization/AgentEditor.tsx | 37 +++++- 5 files changed, 217 insertions(+), 46 deletions(-) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 14e3df65..0f544e0f 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -794,6 +794,7 @@ export function App() { {section === "overview" ? ( - + } /> diff --git a/src/renderer/components/dashboard/BudgetAlertBanner.tsx b/src/renderer/components/dashboard/BudgetAlertBanner.tsx index 04ea80e3..1d723e06 100644 --- a/src/renderer/components/dashboard/BudgetAlertBanner.tsx +++ b/src/renderer/components/dashboard/BudgetAlertBanner.tsx @@ -1,12 +1,13 @@ import { useState, useEffect, useMemo, useCallback } from "react"; import { Warning, X } from "@phosphor-icons/react"; import { AnimatePresence, motion } from "framer-motion"; -import type { AgentRecord, SectionId } from "@shared/types"; +import type { AgentRecord, BudgetIncidentRecord, SectionId } from "@shared/types"; import { useReducedMotion } from "../ui"; export interface BudgetAlertBannerProps { agents: AgentRecord[]; onNavigate: (section: SectionId, entityId?: string) => void; + companyId?: string; } interface AgentOverThreshold { @@ -15,10 +16,21 @@ interface AgentOverThreshold { pct: number; } -export function BudgetAlertBanner({ agents, onNavigate }: BudgetAlertBannerProps) { +export function BudgetAlertBanner({ agents, onNavigate, companyId }: BudgetAlertBannerProps) { const reducedMotion = useReducedMotion(); const [dismissed, setDismissed] = useState(false); + const [incidents, setIncidents] = useState([]); + // Load policy-based incidents when companyId is provided + useEffect(() => { + if (!companyId) return; + void (async () => { + const result = await window.agentCompany.listBudgetIncidents({ companyId, status: "open" }); + if (result.ok) setIncidents(result.data); + })(); + }, [companyId]); + + // Fallback: agent-level threshold detection (existing logic) const agentsOverThreshold = useMemo(() => { return agents .filter((a) => a.budgetMonthlyUsd > 0 && a.spentMonthlyUsd / a.budgetMonthlyUsd >= 0.8) @@ -30,34 +42,102 @@ export function BudgetAlertBanner({ agents, onNavigate }: BudgetAlertBannerProps .sort((a, b) => b.pct - a.pct); }, [agents]); - // Reset dismissed state when the set of agents over threshold changes - const overThresholdKey = useMemo( - () => agentsOverThreshold.map((a) => a.id).sort().join(","), - [agentsOverThreshold], - ); + // Determine whether to use policy-based or agent-based alerts + const usePolicyAlerts = companyId && incidents.length > 0; + + // Reset dismissed state when the set of alerts changes + const alertKey = useMemo(() => { + if (usePolicyAlerts) { + return incidents.map((i) => i.id).sort().join(","); + } + return agentsOverThreshold.map((a) => a.id).sort().join(","); + }, [usePolicyAlerts, incidents, agentsOverThreshold]); useEffect(() => { setDismissed(false); - }, [overThresholdKey]); + }, [alertKey]); const handleDismiss = useCallback(() => setDismissed(true), []); const handleViewCosts = useCallback(() => onNavigate("costs"), [onNavigate]); - if (agentsOverThreshold.length === 0) return null; + // Nothing to show + if (!usePolicyAlerts && agentsOverThreshold.length === 0) return null; + if (dismissed) return null; - const content = ( + function getScopeName(scopeType: string, scopeId: string): string { + if (scopeType === "company") return "Company"; + if (scopeType === "agent") { + const agent = agents.find((a) => a.id === scopeId); + return agent ? agent.name : scopeId.slice(0, 8); + } + return scopeId.slice(0, 8); + } + + // Policy-based alert content + const policyContent = usePolicyAlerts ? ( +
+ {incidents.map((incident) => { + const isHardStop = incident.thresholdType === "hard_stop"; + const borderColor = isHardStop ? "border-[color:var(--danger)]" : "border-[color:var(--warn)]"; + const bgColor = isHardStop ? "bg-[color:var(--danger-soft)]" : "bg-[color:var(--warn-soft)]"; + const textColor = isHardStop ? "text-[color:var(--danger)]" : "text-[color:var(--warn)]"; + const pct = incident.amountLimit > 0 ? Math.round((incident.amountObserved / incident.amountLimit) * 100) : 0; + + return ( +
+
+
+ +
+
+
+ {isHardStop ? "Budget Hard Stop" : "Budget Warning"} +
+
+ {getScopeName(incident.scopeType, incident.scopeId)} — ${(incident.amountObserved / 100).toFixed(2)} / ${(incident.amountLimit / 100).toFixed(2)} ({pct}%) +
+ +
+
+
+ ); + })} +
+ +
+
+ ) : null; + + // Fallback agent-level alert content (existing logic) + const agentContent = !usePolicyAlerts ? (
- {/* Icon */}
- - {/* Content */}
Budget Alert @@ -88,8 +168,6 @@ export function BudgetAlertBanner({ agents, onNavigate }: BudgetAlertBannerProps View Costs
- - {/* Dismiss button */}
- ); + ) : null; - if (dismissed) return null; + const content = usePolicyAlerts ? policyContent : agentContent; if (reducedMotion) { return content; diff --git a/src/renderer/components/dashboard/Dashboard.tsx b/src/renderer/components/dashboard/Dashboard.tsx index 4494fba8..90762256 100644 --- a/src/renderer/components/dashboard/Dashboard.tsx +++ b/src/renderer/components/dashboard/Dashboard.tsx @@ -30,6 +30,7 @@ import { BudgetAlertBanner } from "./BudgetAlertBanner"; export interface DashboardProps { companyName?: string; + companyId?: string; agents: AgentRecord[]; tasks: TaskRecord[]; runs: RunRecord[]; @@ -44,7 +45,7 @@ export interface DashboardProps { export function Dashboard(props: DashboardProps) { const t = useT(); - const { companyName, agents, tasks, runs, approvals, activity, costs, messages = [], onNavigate } = props; + const { companyName, companyId, agents, tasks, runs, approvals, activity, costs, messages = [], onNavigate } = props; const agentMap = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents]); const taskMap = useMemo(() => new Map(tasks.map((t) => [t.id, t])), [tasks]); @@ -95,7 +96,7 @@ export function Dashboard(props: DashboardProps) { {/* Conditional banners */} - + {/* Key Metrics — 4 cards */}
diff --git a/src/renderer/components/execution/BudgetSummary.tsx b/src/renderer/components/execution/BudgetSummary.tsx index 82baa66a..e05266b9 100644 --- a/src/renderer/components/execution/BudgetSummary.tsx +++ b/src/renderer/components/execution/BudgetSummary.tsx @@ -1,11 +1,31 @@ +import { useEffect, useState } from "react" import { Warning } from "@phosphor-icons/react" -import type { ProfileSnapshot } from "@shared/types" +import type { BudgetStatusSummary, ProfileSnapshot } from "@shared/types" import { formatMoney } from "../../lib/formatters" import { ListFrame, ListRow } from "../ui" -export function BudgetSummary({ agents, costs }: { agents: ProfileSnapshot["agents"]; costs: ProfileSnapshot["costs"] }) { +interface BudgetSummaryProps { + agents: ProfileSnapshot["agents"] + costs: ProfileSnapshot["costs"] + companyId?: string +} + +export function BudgetSummary({ agents, costs, companyId }: BudgetSummaryProps) { + const [budgetStatus, setBudgetStatus] = useState(null) + + useEffect(() => { + if (!companyId) return + void (async () => { + const result = await window.agentCompany.getBudgetStatus(companyId) + if (result.ok) setBudgetStatus(result.data) + })() + }, [companyId]) + const knownSpend = costs.reduce((sum, entry) => sum + (entry.amountUsd ?? 0), 0) const unattributedCount = costs.filter((entry) => entry.unattributed).length + + const hasPolicies = budgetStatus && budgetStatus.policies.length > 0 + return (
@@ -18,30 +38,68 @@ export function BudgetSummary({ agents, costs }: { agents: ProfileSnapshot["agen
{String(unattributedCount)}
- - {agents.map((agent) => { - const pct = agent.budgetMonthlyUsd > 0 ? (agent.spentMonthlyUsd / agent.budgetMonthlyUsd) * 100 : 0 - const isWarning = pct > 80 && pct <= 100; const isDanger = pct > 100 - const barColor = isDanger ? "bg-[color:var(--danger)]" : isWarning ? "bg-[color:var(--warn)]" : "bg-[color:var(--success)]" - const nameColor = isDanger ? "text-[color:var(--danger)]" : isWarning ? "text-[color:var(--warn)]" : "text-[color:var(--text)]" - return ( - -
-
-
{agent.name}
- {isDanger ? : null} - {isWarning ? : null} + + {hasPolicies ? ( + + {budgetStatus.policies.map((status) => { + const pct = status.amount > 0 ? (status.spent / status.amount) * 100 : 0 + const isWarning = pct > 80 && pct <= 100 + const isDanger = pct > 100 + const barColor = isDanger ? "bg-[color:var(--danger)]" : isWarning ? "bg-[color:var(--warn)]" : "bg-[color:var(--success)]" + const nameColor = isDanger ? "text-[color:var(--danger)]" : isWarning ? "text-[color:var(--warn)]" : "text-[color:var(--text)]" + + const scopeLabel = status.scopeType === "company" + ? "Company" + : status.scopeType === "agent" + ? (agents.find((a) => a.id === status.scopeId)?.name ?? status.scopeId.slice(0, 8)) + : status.scopeId.slice(0, 8) + + return ( + +
+
+ + {status.scopeType} + +
{scopeLabel}
+ {status.hasOpenIncident ? : null} +
+
+
{formatMoney(status.spent / 100)} / {formatMoney(status.amount / 100)}
+ {Math.round(pct)}% +
-
-
{formatMoney(agent.spentMonthlyUsd)} / {formatMoney(agent.budgetMonthlyUsd)}
- {Math.round(pct)}% +
+ + ) + })} + + ) : ( + + {agents.map((agent) => { + const pct = agent.budgetMonthlyUsd > 0 ? (agent.spentMonthlyUsd / agent.budgetMonthlyUsd) * 100 : 0 + const isWarning = pct > 80 && pct <= 100; const isDanger = pct > 100 + const barColor = isDanger ? "bg-[color:var(--danger)]" : isWarning ? "bg-[color:var(--warn)]" : "bg-[color:var(--success)]" + const nameColor = isDanger ? "text-[color:var(--danger)]" : isWarning ? "text-[color:var(--warn)]" : "text-[color:var(--text)]" + return ( + +
+
+
{agent.name}
+ {isDanger ? : null} + {isWarning ? : null} +
+
+
{formatMoney(agent.spentMonthlyUsd)} / {formatMoney(agent.budgetMonthlyUsd)}
+ {Math.round(pct)}% +
-
-
- - ) - })} - +
+ + ) + })} + + )}
) } diff --git a/src/renderer/components/organization/AgentEditor.tsx b/src/renderer/components/organization/AgentEditor.tsx index c1bc6e93..7c63f842 100644 --- a/src/renderer/components/organization/AgentEditor.tsx +++ b/src/renderer/components/organization/AgentEditor.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from "react"; import { Pencil, Trash } from "@phosphor-icons/react"; -import type { AgentRecord, ConnectorRecord, WorkspaceRecord } from "@shared/types"; +import type { AgentRecord, BudgetPolicyRecord, ConnectorRecord, WorkspaceRecord } from "@shared/types"; import { getConnectorExecutionReadinessIssue, isConnectorExecutionReady } from "@shared/connector-policy"; import { unwrap } from "../../lib/desktop"; import { useAppStore } from "../../state/store"; @@ -87,6 +87,23 @@ export function AgentEditor({ } }, [companyId, skills.length, setSkills]); + // Load agent's budget policy (D-11: read from budget_policies) + const [agentPolicy, setAgentPolicy] = useState(null); + useEffect(() => { + void (async () => { + const result = await window.agentCompany.listBudgetPolicies(companyId); + if (result.ok) { + const policy = result.data.find( + (p) => p.scopeType === "agent" && p.scopeId === agent.id + ); + if (policy) { + setAgentPolicy(policy); + setBudget(String((policy.amount / 100).toFixed(2))); + } + } + })(); + }, [companyId, agent.id]); + const managerCandidates = agents.filter((a) => a.id !== agent.id); return ( @@ -194,6 +211,7 @@ export function AgentEditor({ if (thinkingEffort) meta.thinkingEffort = thinkingEffort; else delete meta.thinkingEffort; try { + const budgetDollars = Number(budget || 0); unwrap(await window.agentCompany.saveAgent({ id: agent.id, companyId, @@ -206,9 +224,24 @@ export function AgentEditor({ reportsTo: reportsTo || null, model: model || null, metadataJson: JSON.stringify(meta), - budgetMonthlyUsd: Number(budget || 0), + budgetMonthlyUsd: budgetDollars, capabilities, })); + // D-11: Also write to budget_policies for policy-based enforcement + if (budgetDollars > 0 && agent.id) { + await window.agentCompany.saveBudgetPolicy({ + companyId, + scopeType: "agent", + scopeId: agent.id, + metric: "billed_cents", + windowKind: "calendar_month_utc", + amount: Math.round(budgetDollars * 100), + warnPercent: agentPolicy?.warnPercent ?? 80, + hardStopEnabled: agentPolicy?.hardStopEnabled ?? true, + notifyEnabled: agentPolicy?.notifyEnabled ?? true, + isActive: true, + }); + } await onSaved(); } catch (error) { setSaveError(error instanceof Error ? error.message : "Failed to save agent.");