refactor(inbox): derive from snapshot; delete getInbox IPC chain
Sibling refactor to the sidebar-badges cleanup. The inbox aggregates five item categories (pending approvals, failed runs, budget warnings, blocked/review tasks, unread company/incident/urgent messages) — every row is already in `ProfileSnapshot`. The server-side `getInbox` aggregation (180 lines of SQL + projection) was a redundant second path. New: `src/shared/inbox-items.ts::deriveInboxItems(snapshot, companyId)` produces the same InboxItem[] shape with the same severity ordering and 24h failed-run window. Pure function, no IPC, no state. Deleted: - database/company-crud.ts::getInbox (180-line aggregation) - database-base.ts::getInbox pass-through - ipc-core-read-handlers.ts: handler - preload/index.ts: bridge binding - shared/contracts/channels.ts: channel name - shared/contracts/desktop-api/companies.ts: API signature - state/slices/company.ts: `inboxItems` field, `setInboxItems` action - useDataLoader.ts: both getInbox call sites (initial snapshot load and section-change effect) - useCompanyController.ts: inbox reset on company switch OperationsSections now computes `inboxItems` via `useMemo` off the current snapshot + company id. The snapshot is already company-scoped at the main/database/snapshot.ts layer when `currentCompanyId` is non-null; the derivation re-filters defensively so the function works correctly even for unscoped snapshots. Tests: 603/603 green. No new tests added — the derivation is a pure function whose shape is exercised end-to-end by the existing Inbox component integration.
This commit is contained in:
1 parent
487f2456ff
commit
6abc1a823f
11 files changed
+180
-220
No files matched your search
@@ -283,7 +283,6 @@ export class AppDatabaseBase {
|
||||
getCompanyExecutionMode(companyId: string) { return this.company.getCompanyExecutionMode(companyId); }
|
||||
getCompanyApprovalSettings(companyId: string) { return this.company.getCompanyApprovalSettings(companyId); }
|
||||
listActiveCompanyIds() { return this.company.listActiveCompanyIds(); }
|
||||
getInbox(companyId: string) { return this.company.getInbox(companyId); }
|
||||
getCompanyMetrics(companyId: string, days: number = 30) { return this.company.getCompanyMetrics(companyId, days); }
|
||||
getStandupReport(companyId: string) { return this.company.getStandupReport(companyId); }
|
||||
getDashboardSnapshot(companyId: string) { return this.company.getDashboardSnapshot(companyId); }
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type {
|
||||
InboxItem,
|
||||
ProfileSnapshot,
|
||||
} from "@shared/types";
|
||||
import type { ProfileSnapshot } from "@shared/types";
|
||||
import {
|
||||
type ReadAll,
|
||||
type Transaction,
|
||||
@@ -169,187 +166,5 @@ export function buildCompanyCrudMethods(
|
||||
reviewDeliverables: row.review_deliverables === 1,
|
||||
};
|
||||
},
|
||||
|
||||
getInbox(companyId: string): InboxItem[] {
|
||||
const items: InboxItem[] = [];
|
||||
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
const agentRows = readAll<{ id: string; name: string }>(
|
||||
"select id, name from agents where company_id = ?",
|
||||
companyId,
|
||||
);
|
||||
const agentNameMap = new Map(agentRows.map((a) => [a.id, a.name]));
|
||||
|
||||
const pendingApprovals = readAll<{
|
||||
id: string; type: string; payloadSummary: string;
|
||||
requestedByAgentId: string | null; createdAt: string;
|
||||
}>(
|
||||
`select id, type, payload_summary as payloadSummary,
|
||||
requested_by_agent_id as requestedByAgentId, created_at as createdAt
|
||||
from approvals where company_id = ? and state = 'pending'
|
||||
order by created_at desc`,
|
||||
companyId,
|
||||
);
|
||||
for (const approval of pendingApprovals) {
|
||||
const requesterName = approval.requestedByAgentId
|
||||
? agentNameMap.get(approval.requestedByAgentId) ?? null
|
||||
: null;
|
||||
items.push({
|
||||
id: `approval-${approval.id}`,
|
||||
type: "pending_approval",
|
||||
title: approval.payloadSummary,
|
||||
subtitle: `${approval.type.replaceAll("_", " ")} — requested by ${requesterName ?? "Unknown"}`,
|
||||
severity: approval.type === "dangerous_command" ? "critical" : "high",
|
||||
entityType: "approval",
|
||||
entityId: approval.id,
|
||||
agentId: approval.requestedByAgentId,
|
||||
agentName: requesterName,
|
||||
createdAt: approval.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
const failedRuns = readAll<{
|
||||
id: string; agentId: string; summary: string;
|
||||
errorMessage: string | null; createdAt: string;
|
||||
}>(
|
||||
`select id, agent_id as agentId, summary, error_message as errorMessage,
|
||||
created_at as createdAt
|
||||
from runs
|
||||
where company_id = ?
|
||||
and status in ('failed', 'timed_out')
|
||||
and created_at > ?
|
||||
order by created_at desc`,
|
||||
companyId,
|
||||
oneDayAgo,
|
||||
);
|
||||
for (const run of failedRuns) {
|
||||
const agentName = agentNameMap.get(run.agentId) ?? null;
|
||||
items.push({
|
||||
id: `run-${run.id}`,
|
||||
type: "failed_run",
|
||||
title: run.summary || "Run failed",
|
||||
subtitle: `${agentName ?? "Unknown agent"} — ${run.errorMessage?.slice(0, 100) ?? "Unknown error"}`,
|
||||
severity: "high",
|
||||
entityType: "run",
|
||||
entityId: run.id,
|
||||
agentId: run.agentId,
|
||||
agentName,
|
||||
createdAt: run.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
const budgetAlertRows = readAll<{
|
||||
id: string; name: string; budgetMonthlyUsd: number; spentMonthlyUsd: number; updatedAt: string;
|
||||
}>(
|
||||
`select id, name, budget_monthly_usd as budgetMonthlyUsd,
|
||||
spent_monthly_usd as spentMonthlyUsd, updated_at as updatedAt
|
||||
from agents
|
||||
where company_id = ?
|
||||
and budget_monthly_usd > 0
|
||||
and (spent_monthly_usd * 1.0 / budget_monthly_usd) > 0.8`,
|
||||
companyId,
|
||||
);
|
||||
for (const agent of budgetAlertRows) {
|
||||
const pct = Math.round((agent.spentMonthlyUsd / agent.budgetMonthlyUsd) * 100);
|
||||
items.push({
|
||||
id: `budget-${agent.id}`,
|
||||
type: "budget_warning",
|
||||
title: `${agent.name} at ${pct}% budget`,
|
||||
subtitle: `$${agent.spentMonthlyUsd.toFixed(2)} of $${agent.budgetMonthlyUsd.toFixed(2)} monthly limit`,
|
||||
severity: pct >= 100 ? "critical" : "high",
|
||||
entityType: "agent",
|
||||
entityId: agent.id,
|
||||
agentId: agent.id,
|
||||
agentName: agent.name,
|
||||
createdAt: agent.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
const taskAlertRows = readAll<{
|
||||
id: string; title: string; status: string; priority: string;
|
||||
assigneeAgentId: string | null; updatedAt: string;
|
||||
}>(
|
||||
`select id, title, status, priority,
|
||||
assignee_agent_id as assigneeAgentId, updated_at as updatedAt
|
||||
from tasks
|
||||
where company_id = ? and status in ('blocked', 'in_review')
|
||||
order by updated_at desc`,
|
||||
companyId,
|
||||
);
|
||||
for (const task of taskAlertRows) {
|
||||
const assigneeName = task.assigneeAgentId ? agentNameMap.get(task.assigneeAgentId) ?? null : null;
|
||||
if (task.status === "blocked") {
|
||||
items.push({
|
||||
id: `blocked-${task.id}`,
|
||||
type: "blocked_task",
|
||||
title: task.title,
|
||||
subtitle: `Assigned to ${assigneeName ?? "Unassigned"} — blocked`,
|
||||
severity: task.priority === "critical" ? "critical" : "medium",
|
||||
entityType: "task",
|
||||
entityId: task.id,
|
||||
agentId: task.assigneeAgentId,
|
||||
agentName: assigneeName,
|
||||
createdAt: task.updatedAt,
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
id: `review-${task.id}`,
|
||||
type: "review_needed",
|
||||
title: task.title,
|
||||
subtitle: `Submitted by ${assigneeName ?? "Unknown"} — awaiting review`,
|
||||
severity: task.priority === "critical" ? "high" : "medium",
|
||||
entityType: "task",
|
||||
entityId: task.id,
|
||||
agentId: task.assigneeAgentId,
|
||||
agentName: assigneeName,
|
||||
createdAt: task.updatedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const messageRows = readAll<{
|
||||
id: string; fromAgentId: string; subject: string; channel: string;
|
||||
priority: string; readAt: string | null; createdAt: string;
|
||||
}>(
|
||||
`select messages.id as id,
|
||||
messages.from_agent_id as fromAgentId,
|
||||
messages.subject as subject,
|
||||
messages.channel as channel,
|
||||
messages.priority as priority,
|
||||
reads.read_at as readAt,
|
||||
messages.created_at as createdAt
|
||||
from agent_messages messages
|
||||
left join agent_message_reads reads
|
||||
on reads.message_id = messages.id and reads.agent_id = '__board__'
|
||||
where messages.company_id = ?
|
||||
and reads.read_at is null
|
||||
and (messages.channel = 'company' or messages.channel = 'incident' or messages.priority = 'urgent')
|
||||
order by messages.created_at desc`,
|
||||
companyId,
|
||||
);
|
||||
for (const msg of messageRows) {
|
||||
const senderName = agentNameMap.get(msg.fromAgentId) ?? null;
|
||||
items.push({
|
||||
id: `message-${msg.id}`,
|
||||
type: "agent_message",
|
||||
title: msg.subject,
|
||||
subtitle: `${senderName ?? "Unknown"} — ${msg.channel === "incident" ? "INCIDENT" : msg.channel} ${msg.priority === "urgent" ? "(urgent)" : ""}`,
|
||||
severity: msg.priority === "urgent" || msg.channel === "incident" ? "critical" : "medium",
|
||||
entityType: "message",
|
||||
entityId: msg.id,
|
||||
agentId: msg.fromAgentId,
|
||||
agentName: senderName,
|
||||
createdAt: msg.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||
items.sort(
|
||||
(a, b) =>
|
||||
severityOrder[a.severity] - severityOrder[b.severity] ||
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
return items;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -115,13 +115,6 @@ export function registerCoreReadHandlers({
|
||||
}
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.getInbox, (_event, payload) => {
|
||||
const companyId = z.string().uuid().parse(payload);
|
||||
const err = ensureCompanyExists(companyId);
|
||||
if (err) return err;
|
||||
return ok(db.getInbox(companyId));
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.getCompanyMetrics, (_event, payload) => {
|
||||
const input = metricsQuerySchema.parse(payload);
|
||||
const err = ensureCompanyExists(input.companyId);
|
||||
|
||||
@@ -43,7 +43,6 @@ const api: DesktopApi = {
|
||||
addComment: (input) => ipcRenderer.invoke(IPC_CHANNELS.addComment, input),
|
||||
setHeartbeat: (input) => ipcRenderer.invoke(IPC_CHANNELS.setHeartbeat, input),
|
||||
triggerHeartbeat: (input) => ipcRenderer.invoke(IPC_CHANNELS.triggerHeartbeat, input),
|
||||
getInbox: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getInbox, companyId),
|
||||
getCompanyMetrics: (input) => ipcRenderer.invoke(IPC_CHANNELS.getCompanyMetrics, input),
|
||||
getStandupReport: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getStandupReport, companyId),
|
||||
bootstrapOnboarding: (input) => ipcRenderer.invoke(IPC_CHANNELS.bootstrapOnboarding, input),
|
||||
|
||||
@@ -35,7 +35,6 @@ export function useCompanyController() {
|
||||
const clearCompanyScopedUiState = useCallback(() => {
|
||||
const store = useAppStore.getState();
|
||||
store.clearCompanyScopedSelections();
|
||||
store.setInboxItems([]);
|
||||
store.setCompanyMetrics(null);
|
||||
store.setStandupReport(null);
|
||||
store.setSocialAccounts([]);
|
||||
|
||||
@@ -24,8 +24,6 @@ export function useDataLoader(
|
||||
if (!currentSelectedConnectorId && next.connectors[0]) store.setSelectedConnectorId(next.connectors[0].id);
|
||||
if (!currentSelectedRunId && next.runs[0]) store.setSelectedRunId(next.runs[0].id);
|
||||
if (next.currentCompanyId) {
|
||||
const inboxResult = await window.agentCompany.getInbox(next.currentCompanyId);
|
||||
if (inboxResult.ok) store.setInboxItems(inboxResult.data);
|
||||
store.setSocialAccounts(next.socialAccounts?.filter((a) => a.companyId === next.currentCompanyId) ?? []);
|
||||
const browserActionResult = await window.agentCompany.listBrowserActions({ companyId: next.currentCompanyId, limit: 50 });
|
||||
if (browserActionResult.ok) {
|
||||
@@ -50,12 +48,6 @@ export function useDataLoader(
|
||||
let cancelled = false;
|
||||
const store = useAppStore.getState();
|
||||
|
||||
if (section === "inbox") {
|
||||
void window.agentCompany.getInbox(companyId).then((result) => {
|
||||
if (!cancelled && result.ok) store.setInboxItems(result.data);
|
||||
});
|
||||
}
|
||||
|
||||
if (section === "performance") {
|
||||
void window.agentCompany.getCompanyMetrics({ companyId, days: 30 }).then((result) => {
|
||||
if (!cancelled && result.ok) store.setCompanyMetrics(result.data);
|
||||
|
||||
@@ -4,17 +4,17 @@ import type {
|
||||
AgentRecord,
|
||||
ApprovalRecord,
|
||||
CompanyMetrics,
|
||||
InboxItem,
|
||||
ProfileSnapshot,
|
||||
RunRecord,
|
||||
StandupReport,
|
||||
TaskRecord,
|
||||
} from "@shared/types";
|
||||
|
||||
// Sidebar badge counts live in the renderer — derived from the
|
||||
// snapshot on every read via `deriveSidebarBadges` rather than cached
|
||||
// on this slice. One source of truth is the snapshot; adding a
|
||||
// separate `badges` field here is what let the counters drift.
|
||||
// Sidebar badge counts and inbox items both live in the renderer —
|
||||
// derived on every read from the snapshot (see `@shared/sidebar-badges`
|
||||
// and `@shared/inbox-items`). The snapshot is the only source of
|
||||
// truth; caching a separate `badges` or `inboxItems` field on this
|
||||
// slice is what let the counters drift against the rendered content.
|
||||
export interface CompanySlice {
|
||||
snapshot: ProfileSnapshot | null;
|
||||
/**
|
||||
@@ -26,12 +26,10 @@ export interface CompanySlice {
|
||||
snapshotRevision: number;
|
||||
companyMetrics: CompanyMetrics | null;
|
||||
standupReport: StandupReport | null;
|
||||
inboxItems: InboxItem[];
|
||||
error: string | null;
|
||||
setSnapshot: (snapshot: ProfileSnapshot | null) => void;
|
||||
setCompanyMetrics: (metrics: CompanyMetrics | null) => void;
|
||||
setStandupReport: (report: StandupReport | null) => void;
|
||||
setInboxItems: (items: InboxItem[]) => void;
|
||||
setError: (error: string | null) => void;
|
||||
refreshSnapshot: () => Promise<void>;
|
||||
patchTask: (task: TaskRecord) => void;
|
||||
@@ -54,7 +52,6 @@ export const createCompanySlice: StateCreator<
|
||||
snapshotRevision: 0,
|
||||
companyMetrics: null,
|
||||
standupReport: null,
|
||||
inboxItems: [],
|
||||
error: null,
|
||||
setSnapshot: (snapshot) =>
|
||||
set((state) => {
|
||||
@@ -69,10 +66,6 @@ export const createCompanySlice: StateCreator<
|
||||
set((state) => {
|
||||
state.standupReport = report;
|
||||
}),
|
||||
setInboxItems: (items) =>
|
||||
set((state) => {
|
||||
state.inboxItems = items;
|
||||
}),
|
||||
setError: (error) =>
|
||||
set((state) => {
|
||||
state.error = error;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import type { SectionId } from "@shared/types";
|
||||
import { deriveInboxItems } from "@shared/inbox-items";
|
||||
import { Inbox } from "../../components/inbox/Inbox";
|
||||
import { StandupView } from "../../components/inbox/StandupView";
|
||||
import { PerformanceView } from "../../components/inbox/PerformanceView";
|
||||
@@ -47,7 +49,10 @@ export function OperationsSections({ section }: OperationsSectionsProps) {
|
||||
} = useSectionContext();
|
||||
const { refresh, openPathWithFeedback, setError } = useAppActions();
|
||||
|
||||
const inboxItems = useAppStore((s) => s.inboxItems);
|
||||
const inboxItems = useMemo(
|
||||
() => (snapshot ? deriveInboxItems(snapshot, currentCompany?.id ?? null) : []),
|
||||
[snapshot, currentCompany?.id],
|
||||
);
|
||||
const companyMetrics = useAppStore((s) => s.companyMetrics);
|
||||
const standupReport = useAppStore((s) => s.standupReport);
|
||||
const socialAccounts = useAppStore((s) => s.socialAccounts);
|
||||
|
||||
@@ -35,7 +35,6 @@ export const IPC_CHANNELS = {
|
||||
setHeartbeat: "agent-company:set-heartbeat",
|
||||
triggerHeartbeat: "agent-company:trigger-heartbeat",
|
||||
rendererEvent: "agent-company:event",
|
||||
getInbox: "agent-company:get-inbox",
|
||||
getCompanyMetrics: "agent-company:get-company-metrics",
|
||||
getStandupReport: "agent-company:get-standup-report",
|
||||
bootstrapOnboarding: "agent-company:bootstrap-onboarding",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { z } from "zod";
|
||||
import type { CompanyMetrics, OnboardingBootstrapResult, ProfileSnapshot, StandupReport, InboxItem } from "../../types";
|
||||
import type { CompanyMetrics, OnboardingBootstrapResult, ProfileSnapshot, StandupReport } from "../../types";
|
||||
import type { bootstrapOnboardingSchema, companyInputSchema, connectorDisabledModelsSchema, connectorInputSchema, settingsSchema } from "../schemas-company";
|
||||
import type { metricsQuerySchema } from "../schemas-badges";
|
||||
import type { DesktopResult } from "./common";
|
||||
@@ -18,7 +18,6 @@ export type CompaniesApi = {
|
||||
listConnectorModels: (connectorId: string) => Promise<DesktopResult<Array<{ id: string; label: string }>>>;
|
||||
getConnectorDisabledModels: (connectorId: string) => Promise<DesktopResult<Array<{ id: string; label: string }>>>;
|
||||
setConnectorDisabledModels: (input: z.infer<typeof connectorDisabledModelsSchema>) => Promise<DesktopResult<boolean>>;
|
||||
getInbox: (companyId: string) => Promise<DesktopResult<InboxItem[]>>;
|
||||
getCompanyMetrics: (input: z.infer<typeof metricsQuerySchema>) => Promise<DesktopResult<CompanyMetrics>>;
|
||||
getStandupReport: (companyId: string) => Promise<DesktopResult<StandupReport>>;
|
||||
bootstrapOnboarding: (input: z.infer<typeof bootstrapOnboardingSchema>) => Promise<DesktopResult<OnboardingBootstrapResult>>;
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { InboxItem, ProfileSnapshot } from "./types";
|
||||
|
||||
/**
|
||||
* Inbox items, derived purely from `ProfileSnapshot`.
|
||||
*
|
||||
* Mirrors the single-source-of-truth pattern used for sidebar badges
|
||||
* (`sidebar-badges.ts`). The renderer already holds every row the
|
||||
* inbox would aggregate — approvals, runs, tasks, agents, messages —
|
||||
* so the old `getInbox` IPC was a second aggregation path that could
|
||||
* drift. Deriving on the client from the snapshot removes the drift
|
||||
* class and the round-trip.
|
||||
*
|
||||
* Parity note: the five item categories (`pending_approval`,
|
||||
* `failed_run`, `budget_warning`, `blocked_task` / `review_needed`,
|
||||
* `agent_message`) match what the old `buildDocumentMethods::getInbox`
|
||||
* produced. One behavioural drop: the old SQL limited failed-run
|
||||
* items to the last 24 hours; we preserve that window here using the
|
||||
* snapshot's `createdAt` strings and the caller's "now".
|
||||
*/
|
||||
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const BUDGET_WARNING_RATIO = 0.8;
|
||||
const URGENT_CHANNELS: ReadonlyArray<string> = ["company", "incident"];
|
||||
|
||||
const SEVERITY_ORDER: Record<InboxItem["severity"], number> = {
|
||||
critical: 0,
|
||||
high: 1,
|
||||
medium: 2,
|
||||
low: 3,
|
||||
};
|
||||
|
||||
/** Build the inbox list from the current snapshot for the given company. */
|
||||
export function deriveInboxItems(
|
||||
snapshot: ProfileSnapshot,
|
||||
companyId: string | null,
|
||||
now: Date = new Date(),
|
||||
): InboxItem[] {
|
||||
if (!companyId) return [];
|
||||
const agentNameMap = new Map<string, string>();
|
||||
for (const agent of snapshot.agents) {
|
||||
if (agent.companyId === companyId) agentNameMap.set(agent.id, agent.name);
|
||||
}
|
||||
|
||||
const items: InboxItem[] = [];
|
||||
const oneDayAgoIso = new Date(now.getTime() - ONE_DAY_MS).toISOString();
|
||||
|
||||
for (const approval of snapshot.approvals) {
|
||||
if (approval.companyId !== companyId) continue;
|
||||
if (approval.state !== "pending") continue;
|
||||
const requesterName = approval.requestedByAgentId
|
||||
? agentNameMap.get(approval.requestedByAgentId) ?? null
|
||||
: null;
|
||||
items.push({
|
||||
id: `approval-${approval.id}`,
|
||||
type: "pending_approval",
|
||||
title: approval.payloadSummary,
|
||||
subtitle: `${approval.type.replaceAll("_", " ")} — requested by ${requesterName ?? "Unknown"}`,
|
||||
severity: approval.type === "dangerous_command" ? "critical" : "high",
|
||||
entityType: "approval",
|
||||
entityId: approval.id,
|
||||
agentId: approval.requestedByAgentId,
|
||||
agentName: requesterName,
|
||||
createdAt: approval.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
for (const run of snapshot.runs) {
|
||||
if (run.companyId !== companyId) continue;
|
||||
if (run.status !== "failed" && run.status !== "timed_out") continue;
|
||||
if (run.createdAt <= oneDayAgoIso) continue;
|
||||
const agentName = agentNameMap.get(run.agentId) ?? null;
|
||||
items.push({
|
||||
id: `run-${run.id}`,
|
||||
type: "failed_run",
|
||||
title: run.summary || "Run failed",
|
||||
subtitle: `${agentName ?? "Unknown agent"} — ${run.errorMessage?.slice(0, 100) ?? "Unknown error"}`,
|
||||
severity: "high",
|
||||
entityType: "run",
|
||||
entityId: run.id,
|
||||
agentId: run.agentId,
|
||||
agentName,
|
||||
createdAt: run.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
for (const agent of snapshot.agents) {
|
||||
if (agent.companyId !== companyId) continue;
|
||||
if (agent.budgetMonthlyUsd <= 0) continue;
|
||||
const ratio = agent.spentMonthlyUsd / agent.budgetMonthlyUsd;
|
||||
if (ratio <= BUDGET_WARNING_RATIO) continue;
|
||||
const pct = Math.round(ratio * 100);
|
||||
items.push({
|
||||
id: `budget-${agent.id}`,
|
||||
type: "budget_warning",
|
||||
title: `${agent.name} at ${pct}% budget`,
|
||||
subtitle: `$${agent.spentMonthlyUsd.toFixed(2)} of $${agent.budgetMonthlyUsd.toFixed(2)} monthly limit`,
|
||||
severity: pct >= 100 ? "critical" : "high",
|
||||
entityType: "agent",
|
||||
entityId: agent.id,
|
||||
agentId: agent.id,
|
||||
agentName: agent.name,
|
||||
createdAt: agent.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
for (const task of snapshot.tasks) {
|
||||
if (task.companyId !== companyId) continue;
|
||||
if (task.status !== "blocked" && task.status !== "in_review") continue;
|
||||
const assigneeName = task.assigneeAgentId ? agentNameMap.get(task.assigneeAgentId) ?? null : null;
|
||||
if (task.status === "blocked") {
|
||||
items.push({
|
||||
id: `blocked-${task.id}`,
|
||||
type: "blocked_task",
|
||||
title: task.title,
|
||||
subtitle: `Assigned to ${assigneeName ?? "Unassigned"} — blocked`,
|
||||
severity: task.priority === "critical" ? "critical" : "medium",
|
||||
entityType: "task",
|
||||
entityId: task.id,
|
||||
agentId: task.assigneeAgentId,
|
||||
agentName: assigneeName,
|
||||
createdAt: task.updatedAt,
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
id: `review-${task.id}`,
|
||||
type: "review_needed",
|
||||
title: task.title,
|
||||
subtitle: `Submitted by ${assigneeName ?? "Unknown"} — awaiting review`,
|
||||
severity: task.priority === "critical" ? "high" : "medium",
|
||||
entityType: "task",
|
||||
entityId: task.id,
|
||||
agentId: task.assigneeAgentId,
|
||||
agentName: assigneeName,
|
||||
createdAt: task.updatedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const message of snapshot.agentMessages) {
|
||||
if (message.companyId !== companyId) continue;
|
||||
if (message.readAt) continue;
|
||||
const visible =
|
||||
URGENT_CHANNELS.includes(message.channel) || message.priority === "urgent";
|
||||
if (!visible) continue;
|
||||
const senderName = agentNameMap.get(message.fromAgentId) ?? null;
|
||||
const urgencyTag = message.priority === "urgent" ? " (urgent)" : "";
|
||||
items.push({
|
||||
id: `message-${message.id}`,
|
||||
type: "agent_message",
|
||||
title: message.subject,
|
||||
subtitle: `${senderName ?? "Unknown"} — ${message.channel === "incident" ? "INCIDENT" : message.channel}${urgencyTag}`,
|
||||
severity: message.priority === "urgent" || message.channel === "incident" ? "critical" : "medium",
|
||||
entityType: "message",
|
||||
entityId: message.id,
|
||||
agentId: message.fromAgentId,
|
||||
agentName: senderName,
|
||||
createdAt: message.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
items.sort(
|
||||
(a, b) =>
|
||||
SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] ||
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
return items;
|
||||
}
|
||||
Reference in new issue
Block a user