diff --git a/DESIGN.md b/DESIGN.md index cc9ee66..3f0fcf4 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -34,8 +34,15 @@ Primitives: `apps/web/components/status/status-badges.tsx` and `apps/web/types/s ## Components -- `CompanyOsShell` application chrome -- Metric tiles, empty/error/skeleton states +- `CompanyOsShell` application chrome: grouped sidebar with an active-row + indicator, and a top bar carrying organisation context, search (⌘K), + approvals bell, theme, and the signed-in actor +- `Panel` / `PanelLink` titled dashboard containers +- Metric tiles (value, measured 24h delta, seven-day sparkline), + empty/error/skeleton states +- `Progress` ratio bar and `components/os/charts.tsx` (sparkline, hourly run + activity lines, work-status donut) — chart colour comes from tokens and is + always paired with a legend label - Approval cards in Governance Inbox - Work item tables and board mode - Agent roster / dossier (existing agents views) @@ -54,5 +61,8 @@ never as a chat bubble product. ## Data rules - Server-backed queries and mutations only for authoritative state +- Trends, sparklines, rates, and chart series are computed from stored rows; + a tile with no history shows no trend rather than a decorative arrow, and a + count and the series beneath it must measure the same window - Theme preference may use localStorage; operational state must not - Fixture adapters must be labelled `source: fixture` in UI and types diff --git a/apps/web/components/os/charts.tsx b/apps/web/components/os/charts.tsx new file mode 100644 index 0000000..85dd5e1 --- /dev/null +++ b/apps/web/components/os/charts.tsx @@ -0,0 +1,323 @@ +"use client"; + +import { useId } from "react"; +import { + CartesianGrid, + Cell, + Line, + LineChart, + Pie, + PieChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { cn } from "@/lib/utils"; + +/** + * Charts read their colours from the token layer so dark and light stay in + * step, and every series is also labelled in the legend — colour alone never + * carries meaning. + */ +export const SERIES_COLOURS = { + completed: "var(--color-success)", + running: "var(--color-info)", + failed: "var(--color-error)", + cancelled: "var(--color-faint)", + accent: "var(--color-accent)", + agent: "var(--color-agent)", + warning: "var(--color-warning)", +} as const; + +function TooltipCard({ + title, + rows, +}: { + title: string; + rows: Array<{ label: string; value: string; colour: string }>; +}) { + return ( +
+

{title}

+ +
+ ); +} + +/** + * Small inline trend line for a stat tile. Deliberately hand-rolled: a tile + * sparkline needs no axes, tooltip, or layout engine. + */ +export function Sparkline({ + values, + tone = "neutral", + label, + className, +}: { + values: number[]; + tone?: "neutral" | "positive" | "negative" | "warning"; + label: string; + className?: string; +}) { + const gradientFreeId = useId(); + if (values.length < 2) return null; + + const width = 96; + const height = 28; + const max = Math.max(...values); + const min = Math.min(...values); + const span = max - min || 1; + const step = width / (values.length - 1); + const points = values.map((value, index) => { + const x = index * step; + const y = height - ((value - min) / span) * (height - 4) - 2; + return `${x.toFixed(2)},${y.toFixed(2)}`; + }); + + const stroke = + tone === "positive" + ? "var(--color-success)" + : tone === "negative" + ? "var(--color-error)" + : tone === "warning" + ? "var(--color-warning)" + : "var(--color-agent)"; + + return ( + + {label} + + + ); +} + +export type RunActivitySeriesKey = + | "completed" + | "running" + | "failed" + | "cancelled"; + +export const RUN_ACTIVITY_SERIES: Array<{ + key: RunActivitySeriesKey; + label: string; + colour: string; +}> = [ + { key: "completed", label: "Completed", colour: SERIES_COLOURS.completed }, + { key: "running", label: "In flight", colour: SERIES_COLOURS.running }, + { key: "failed", label: "Failed", colour: SERIES_COLOURS.failed }, + { key: "cancelled", label: "Cancelled", colour: SERIES_COLOURS.cancelled }, +]; + +type ActivityDatum = { + bucket: string; + completed: number; + running: number; + failed: number; + cancelled: number; +}; + +/** Hourly agent-run volume. One line per terminal state. */ +export function RunActivityChart({ + data, + visible, +}: { + data: ActivityDatum[]; + visible: RunActivitySeriesKey[]; +}) { + const seriesKeys = RUN_ACTIVITY_SERIES.filter((series) => + visible.includes(series.key), + ); + const points = data.map((point) => ({ + ...point, + axis: new Date(point.bucket).toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }), + })); + + return ( +
+ + + + + + {seriesKeys.map((series) => ( + + ))} + + + + {points.map((point) => ( + + + {seriesKeys.map((series) => ( + + ))} + + ))} + +
Agent run activity by hour
Time + {series.label} +
{point.axis}{point[series.key]}
+ + + + + + { + if (!active || !payload?.length) return null; + return ( + ({ + label: String(entry.name ?? entry.dataKey), + value: String(entry.value ?? 0), + colour: String(entry.color ?? "var(--color-muted)"), + }))} + /> + ); + }} + /> + {seriesKeys.map((series) => ( + + ))} + + +
+ ); +} + +export const STATUS_SLICE_COLOURS: Record = { + backlog: "var(--color-faint)", + ready: "var(--color-info)", + in_progress: "var(--color-accent)", + review: "var(--color-warning)", + done: "var(--color-success)", +}; + +/** Work-item status split. The centre states the total it is a split of. */ +export function StatusDonut({ + slices, + total, + totalLabel, +}: { + slices: Array<{ status: string; label: string; count: number }>; + total: number; + totalLabel: string; +}) { + return ( +
+ + + + {slices.map((slice) => ( + + ))} + + { + if (!active || !payload?.length) return null; + const entry = payload[0]; + if (!entry) return null; + const count = Number(entry.value ?? 0); + return ( + 0 ? Math.round((count / total) * 100) : 0}%)`, + colour: String( + entry.payload?.fill ?? "var(--color-muted)", + ), + }, + ]} + /> + ); + }} + /> + + +
+

+ {total} +

+

{totalLabel}

+
+
+ ); +} diff --git a/apps/web/components/os/company-os-shell.tsx b/apps/web/components/os/company-os-shell.tsx index f347707..f39da2f 100644 --- a/apps/web/components/os/company-os-shell.tsx +++ b/apps/web/components/os/company-os-shell.tsx @@ -11,6 +11,7 @@ import { } from "react"; import { Activity, + Bell, BookOpen, Bot, Cable, @@ -31,6 +32,7 @@ import { X, } from "lucide-react"; import { authClient } from "@muster/auth/client"; +import { Avatar } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { OsCommandPalette } from "@/components/os/os-command-palette"; @@ -92,6 +94,15 @@ const navGroups: Array<{ heading: string; items: NavItem[] }> = [ const navItems: NavItem[] = navGroups.flatMap((group) => group.items); +/** Two letters is enough to tell operators apart without a photo service. */ +function initialsOf(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return "??"; + const first = parts[0]?.[0] ?? ""; + const second = parts.length > 1 ? (parts.at(-1)?.[0] ?? "") : (parts[0]?.[1] ?? ""); + return `${first}${second}`.toUpperCase(); +} + function NavLink({ href, label, @@ -120,14 +131,26 @@ function NavLink({ aria-current={active ? "page" : undefined} title={collapsed ? label : undefined} className={cn( - "flex min-h-9 items-center gap-2 rounded-md px-2.5 text-sm font-medium transition-colors", + "relative flex min-h-9 items-center gap-2.5 rounded-md px-2.5 text-sm font-medium transition-colors", active - ? "bg-muted text-foreground" - : "text-muted-foreground hover:bg-muted/70 hover:text-foreground", + ? "bg-[var(--color-accent-soft)] text-foreground" + : "text-muted-foreground hover:bg-[var(--color-paper-3)] hover:text-foreground", collapsed && "justify-center px-2", )} > - + {active ? ( + + ) : null} + {!collapsed ? {label} : null} {!collapsed && badge && badge > 0 ? ( @@ -154,60 +177,45 @@ function Sidebar({ ); } @@ -286,7 +315,7 @@ export function CompanyOsShell({ children }: { children: ReactNode }) { } return ( -
+
-
+
- + + - {pendingApprovals > 0 ? ( - - - {pendingApprovals} - pending approvals - - ) : null} + 0 + ? `${pendingApprovals} pending approvals` + : "Approvals" + } + className="relative inline-grid size-9 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-[var(--color-paper-3)] hover:text-foreground" + > + + {pendingApprovals > 0 ? ( + + {pendingApprovals > 99 ? "99+" : pendingApprovals} + + ) : null} + + <> + {data ? ( + + Updated {relativeTime(data.generatedAt)} + + ) : null} + + + New work item + + } /> @@ -46,210 +210,378 @@ export function CommandView() { {query.isLoading ? : null} - {query.data ? ( + {data ? ( <> - {query.data.notes.length > 0 ? ( -
- {query.data.notes.join(" · ")} -
+ {data.notes.length > 0 ? ( +

+ {data.notes.join(" · ")} +

) : null}

Top metrics

-
- {query.data.metrics.map((metric) => ( +
+ {data.metrics.map((metric) => ( ))}
-
-
+ + {RUN_ACTIVITY_SERIES.map((series) => { + const on = visibleSeries.includes(series.key); + return ( + + ); + })} +
+ } > -
-

- Attention queue -

- - {query.data.attention.length} - -
-
- {query.data.attention.length === 0 ? ( -
- -
- ) : ( - query.data.attention.map((item) => ( -
-
-
-
-

- {item.href ? ( - - {item.title} - - ) : ( - item.title - )} -

- - - {item.type.replaceAll("_", " ")} - -
-

- {item.sourceSystem} - {item.owner ? ` · ${item.owner}` : ""} · {item.age} -

-

- Next: {item.recommendedAction} -

-
-
-
- )) - )} -
- + {runTotal === 0 ? ( + + ) : ( + + )} + -
-
-

- Operational risk radar -

-

- Heuristic summaries from live counts — not a composite score. -

-
-
    - {query.data.riskRadar.map((cell) => ( -
  • + ) : ( + <> + +
      + {taskStatus.map((slice) => ( +
    • + + + {slice.label} + + + {slice.count} + + + {Math.round((slice.count / taskTotal) * 100)}% + +
    • + ))} +
    + + )} + + + View all} + > +
    + {( + [ + ["mine", "Assigned to me", assignedToMe.length], + ["unassigned", "Unassigned", unassigned.length], + ] as const + ).map(([key, label, total]) => ( +
  • + {label} + + {total} + + ))} -
-
+
+ {shownTasks.length === 0 ? ( +
+ +
+ ) : ( +
+ {shownTasks.slice(0, 6).map((task) => ( + + ))} +
+ )} + -
-
+ + {data.attention.length} + + } + > + {data.attention.length === 0 ? ( +
+ +
+ ) : ( +
+ {data.attention.slice(0, 6).map((item) => ( + + ))} +
+ )} +
+ + View all} > -
-

- Agent status -

- - View all - -
-
- {query.data.agents.length === 0 ? ( -

- No agents visible for this session. -

- ) : ( - query.data.agents.map((agent) => ( + {data.agentActivity.length === 0 ? ( +
+ +
+ ) : ( +
+ {data.agentActivity.map((agent) => (
- - {agent.name} - - - {agent.status} - - - {agent.runtime} - - - {agent.lastRunStatus - ? `${agent.lastRunStatus}${ - agent.lastRunAt - ? ` · ${relativeTime(agent.lastRunAt)}` - : "" - }` - : "No recent run"} - + +
+ + {agent.name} + +

+ {agent.runtime} · {agent.status} +

+
+
+

+ {agent.runs} + + runs + +

+ {agent.successRate === null ? ( +

+ No settled runs +

+ ) : ( + <> +

+ {Math.round(agent.successRate * 100)}% success +

+ = 0.9 + ? "success" + : agent.successRate >= 0.6 + ? "warning" + : "error" + } + /> + + )} +
- )) - )} -
-
+ ))} +
+ )} + -
Full audit} > -
-

- Live activity -

- - Full audit - -
-
- {query.data.activity.length === 0 ? ( -

- No recent audit events (or not authorised). -

- ) : ( - query.data.activity.map((event) => ( -
-
- - {relativeTime(event.timestamp)} - - {event.actor} - - {event.action} - + {data.activity.length === 0 ? ( +
+ +
+ ) : ( +
    + {data.activity.slice(0, 7).map((event) => ( +
  • + +
    +

    + {event.actor}{" "} + + {event.action} + +

    +

    + {event.target} +

    -

    - {event.target} + + {relativeTime(event.timestamp)} + +

  • + ))} +
+ )} + +
+ + View all} + > + {data.integrations.length === 0 ? ( + + ) : ( +
    + {data.integrations.map((integration) => ( +
  • +
    +

    + {integration.name} +

    +

    + {integration.detail}

    - )) - )} -
-
- + + + ))} + + )} + + + +
    + {data.riskRadar.map((cell) => ( +
  • +
    + + {cell.label} + + +
    +

    + {cell.summary} +

    +
  • + ))} +
+
) : null} diff --git a/apps/web/lib/command-summary-domain.ts b/apps/web/lib/command-summary-domain.ts index 7d276b6..62e1e91 100644 --- a/apps/web/lib/command-summary-domain.ts +++ b/apps/web/lib/command-summary-domain.ts @@ -1,4 +1,14 @@ -import { and, desc, eq, gt, inArray, isNull } from "drizzle-orm"; +import { + and, + count, + desc, + eq, + gt, + gte, + inArray, + isNull, + or, +} from "drizzle-orm"; import { hasCapability, type AuthorisationSubject, @@ -8,11 +18,17 @@ import { getControlPlaneStatus } from "./control-plane-status.ts"; import { relativeTime } from "./utils.ts"; import type { ActivityEvent, + AgentActivityRow, AttentionItem, CommandMetric, + IntegrationHealthChip, + MetricTrend, + MyTaskRow, RiskRadarCell, + RunActivityPoint, + TaskStatusSlice, } from "@/types/os"; -import { toHealthState } from "@/types/status"; +import { toHealthState, toOperationalState } from "@/types/status"; export type CommandSummary = { generatedAt: string; @@ -20,6 +36,16 @@ export type CommandSummary = { attention: AttentionItem[]; riskRadar: RiskRadarCell[]; activity: ActivityEvent[]; + /** Live status distribution of every non-archived work item. */ + taskStatus: TaskStatusSlice[]; + /** Agent runs bucketed by hour over the last 24 hours. */ + runActivity: RunActivityPoint[]; + /** Per-agent run volume and success rate over the last 7 days. */ + agentActivity: AgentActivityRow[]; + /** Open work items the session's actor owns, plus the unassigned queue. */ + myTasks: MyTaskRow[]; + /** Control-plane components, one chip each. */ + integrations: IntegrationHealthChip[]; agents: Array<{ id: string; name: string; @@ -36,6 +62,265 @@ export type CommandSummary = { notes: string[]; }; +const DAY_MS = 86_400_000; +const HOUR_MS = 3_600_000; +const TREND_WINDOW_DAYS = 7; + +/** Oldest → newest daily counts, so a sparkline reads left to right. */ +function dailySeries(timestamps: Date[], now: number): number[] { + const buckets = new Array(TREND_WINDOW_DAYS).fill(0); + for (const at of timestamps) { + const age = now - at.getTime(); + if (age < 0 || age >= TREND_WINDOW_DAYS * DAY_MS) continue; + const index = TREND_WINDOW_DAYS - 1 - Math.floor(age / DAY_MS); + buckets[index] = (buckets[index] ?? 0) + 1; + } + return buckets; +} + +/** + * Compares the last 24 hours with the 24 before it. Returns undefined when + * both windows are empty — an arrow drawn over no events is decoration. + */ +function dayOverDayTrend( + timestamps: Date[], + now: number, + label: string, + improving: MetricTrend["improving"], +): MetricTrend | undefined { + let current = 0; + let previous = 0; + for (const at of timestamps) { + const age = now - at.getTime(); + if (age < 0) continue; + if (age < DAY_MS) current += 1; + else if (age < 2 * DAY_MS) previous += 1; + } + if (current === 0 && previous === 0) return undefined; + const delta = current - previous; + return { + delta, + direction: delta > 0 ? "up" : delta < 0 ? "down" : "flat", + label, + improving, + }; +} + +/** Optional-property spread so an absent trend stays absent, not undefined. */ +function trendField(trend: MetricTrend | undefined) { + return trend ? { trend } : {}; +} + +const TASK_STATUS_LABELS: Record = { + backlog: "Backlog", + ready: "Ready", + in_progress: "In progress", + review: "Review", + done: "Done", +}; + +const TASK_PRIORITY_SEVERITY = { + urgent: "critical", + high: "high", + normal: "medium", + low: "low", +} as const; + +const OPEN_TASK_STATUSES = ["backlog", "ready", "in_progress", "review"] as const; + +const PRIORITY_RANK: Record = { + urgent: 0, + high: 1, + normal: 2, + low: 3, +}; + +type AgentRunRow = { + agentId: string; + status: string; + startedAt: Date | null; + completedAt: Date | null; +}; + +type AgentRunTally = { + runs: number; + succeeded: number; + settled: number; + lastRunAt: Date | null; +}; + +type QueueTaskRow = { + id: string; + title: string; + status: string; + priority: string; + assignedActorId: string | null; + dueAt: Date | null; + updatedAt: Date; +}; + +type AgentPanelRow = { + id: string; + name: string; + status: string; + killSwitch: boolean; + runtime: string; + lastRun: { + status: string; + startedAt: string | null; + completedAt: string | null; + } | null; +}; + +/** 24 hourly buckets ending with the hour in progress. */ +function buildRunActivity( + now: number, + recentAgentRuns: AgentRunRow[], +): { runActivity: RunActivityPoint[]; runsByAgent: Map } { + const firstBucket = Math.floor(now / HOUR_MS) * HOUR_MS - 23 * HOUR_MS; + const runActivity: RunActivityPoint[] = Array.from({ length: 24 }, (_, i) => { + const start = new Date(firstBucket + i * HOUR_MS); + return { + bucket: start.toISOString(), + completed: 0, + failed: 0, + running: 0, + cancelled: 0, + }; + }); + + const runsByAgent = new Map(); + + for (const run of recentAgentRuns) { + const startedAt = run.startedAt; + if (!startedAt) continue; + const state = toOperationalState(run.status); + + const index = Math.floor((startedAt.getTime() - firstBucket) / HOUR_MS); + const point = index >= 0 && index < 24 ? runActivity[index] : undefined; + if (point) { + if (state === "completed") point.completed += 1; + else if (state === "failed") point.failed += 1; + else if (state === "cancelled") point.cancelled += 1; + else point.running += 1; + } + + const tally = runsByAgent.get(run.agentId) ?? { + runs: 0, + succeeded: 0, + settled: 0, + lastRunAt: null, + }; + tally.runs += 1; + if (state === "completed") { + tally.succeeded += 1; + tally.settled += 1; + } else if (state === "failed") { + tally.settled += 1; + } + const seenAt = run.completedAt ?? startedAt; + if (!tally.lastRunAt || seenAt > tally.lastRunAt) tally.lastRunAt = seenAt; + runsByAgent.set(run.agentId, tally); + } + + return { runActivity, runsByAgent }; +} + +function buildAgentActivity( + agentRows: AgentPanelRow[], + runsByAgent: Map, +): AgentActivityRow[] { + return agentRows + .map((agent) => { + const tally = runsByAgent.get(agent.id); + return { + id: agent.id, + name: agent.name, + status: agent.killSwitch ? "isolated" : agent.status, + runtime: agent.runtime, + runs: tally?.runs ?? 0, + succeeded: tally?.succeeded ?? 0, + // A rate over zero settled runs would be an invented 100%. + successRate: + tally && tally.settled > 0 ? tally.succeeded / tally.settled : null, + lastRunAt: + tally?.lastRunAt?.toISOString() ?? + agent.lastRun?.completedAt ?? + agent.lastRun?.startedAt ?? + null, + }; + }) + .sort((a, b) => b.runs - a.runs || a.name.localeCompare(b.name)) + .slice(0, 6); +} + +function buildMyTasks( + queueTasks: QueueTaskRow[], + actorId: string, +): MyTaskRow[] { + return queueTasks + .sort( + (a, b) => + (PRIORITY_RANK[a.priority] ?? 9) - (PRIORITY_RANK[b.priority] ?? 9) || + b.updatedAt.getTime() - a.updatedAt.getTime(), + ) + .slice(0, 20) + .map((task) => ({ + id: task.id, + title: task.title, + status: toOperationalState(task.status), + rawStatus: task.status, + priority: task.priority, + severity: + TASK_PRIORITY_SEVERITY[ + task.priority as keyof typeof TASK_PRIORITY_SEVERITY + ] ?? "medium", + sourceSystem: "Muster", + updatedAt: task.updatedAt.toISOString(), + dueAt: task.dueAt?.toISOString() ?? null, + assignedToMe: task.assignedActorId === actorId, + })); +} + +function buildIntegrations( + controlPlane: Awaited> | null, +): IntegrationHealthChip[] { + if (!controlPlane) return []; + return [ + { + id: "kelpie", + name: controlPlane.kelpie.displayName ?? "Kelpie", + health: toHealthState(controlPlane.kelpie.status), + detail: controlPlane.kelpie.lastSyncAt + ? `Synced ${relativeTime(controlPlane.kelpie.lastSyncAt)}` + : "No sync recorded", + }, + { + id: "slack", + name: "Slack", + health: toHealthState(controlPlane.slack.status), + detail: `Workspace ${controlPlane.slack.status}`, + }, + { + id: "mcp", + name: "MCP", + health: toHealthState(controlPlane.mcp.status), + detail: `${controlPlane.mcp.activeInstallations} active installation${ + controlPlane.mcp.activeInstallations === 1 ? "" : "s" + }`, + }, + { + id: "codex", + name: "Codex runtime", + health: toHealthState(controlPlane.codex.status), + detail: + controlPlane.codex.detail ?? + controlPlane.codex.runtime ?? + `Runtime ${controlPlane.codex.status}`, + }, + ]; +} + export async function getCommandSummary( subject: AuthorisationSubject, ): Promise { @@ -133,6 +418,8 @@ export async function getCommandSummary( title: schema.tasks.title, status: schema.tasks.status, priority: schema.tasks.priority, + assignedActorId: schema.tasks.assignedActorId, + dueAt: schema.tasks.dueAt, createdAt: schema.tasks.createdAt, updatedAt: schema.tasks.updatedAt, }) @@ -141,18 +428,135 @@ export async function getCommandSummary( and( eq(schema.tasks.organisationId, subject.organisationId), isNull(schema.tasks.archivedAt), - inArray(schema.tasks.status, [ - "backlog", - "ready", - "in_progress", - "review", - ]), + inArray(schema.tasks.status, [...OPEN_TASK_STATUSES]), ), ) .orderBy(desc(schema.tasks.updatedAt)) .limit(50) : []; + // Dedicated queue source: mine + unassigned open tasks, not filtered from the + // attention-capped openTasks list (which can omit eligible rows past 50). + const queueTasks = canReadTasks + ? await db + .select({ + id: schema.tasks.id, + title: schema.tasks.title, + status: schema.tasks.status, + priority: schema.tasks.priority, + assignedActorId: schema.tasks.assignedActorId, + dueAt: schema.tasks.dueAt, + updatedAt: schema.tasks.updatedAt, + }) + .from(schema.tasks) + .where( + and( + eq(schema.tasks.organisationId, subject.organisationId), + isNull(schema.tasks.archivedAt), + inArray(schema.tasks.status, [...OPEN_TASK_STATUSES]), + or( + eq(schema.tasks.assignedActorId, subject.actorId), + isNull(schema.tasks.assignedActorId), + ), + ), + ) + .orderBy(desc(schema.tasks.updatedAt)) + : []; + + const now = Date.now(); + const windowStart = new Date(now - TREND_WINDOW_DAYS * DAY_MS); + + // Independent reads — run concurrently on the request path. + // recentTasks/recentApprovals: desc + limit keeps the newest 2k rows so the + // series reflects recent activity (truncation drops the oldest). + const [ + taskStatusCounts, + recentTasks, + recentApprovals, + recentMissionRuns, + recentAgentRuns, + ] = await Promise.all([ + canReadTasks + ? db + .select({ status: schema.tasks.status, total: count() }) + .from(schema.tasks) + .where( + and( + eq(schema.tasks.organisationId, subject.organisationId), + isNull(schema.tasks.archivedAt), + ), + ) + .groupBy(schema.tasks.status) + : Promise.resolve( + [] as Array<{ status: string; total: number | string }>, + ), + canReadTasks + ? db + .select({ createdAt: schema.tasks.createdAt }) + .from(schema.tasks) + .where( + and( + eq(schema.tasks.organisationId, subject.organisationId), + isNull(schema.tasks.archivedAt), + gte(schema.tasks.createdAt, windowStart), + ), + ) + .orderBy(desc(schema.tasks.createdAt)) + .limit(2_000) + : Promise.resolve([] as Array<{ createdAt: Date | null }>), + canApprove + ? db + .select({ requestedAt: schema.approvals.requestedAt }) + .from(schema.approvals) + .where( + and( + eq(schema.approvals.organisationId, subject.organisationId), + gte(schema.approvals.requestedAt, windowStart), + ), + ) + .orderBy(desc(schema.approvals.requestedAt)) + .limit(2_000) + : Promise.resolve([] as Array<{ requestedAt: Date | null }>), + canReadWorkflows + ? db + .select({ + status: schema.governedMissionRuns.status, + createdAt: schema.governedMissionRuns.createdAt, + }) + .from(schema.governedMissionRuns) + .where( + and( + eq( + schema.governedMissionRuns.organisationId, + subject.organisationId, + ), + gte(schema.governedMissionRuns.createdAt, windowStart), + ), + ) + .limit(2_000) + : Promise.resolve( + [] as Array<{ status: string; createdAt: Date | null }>, + ), + canReadAgents || canAdmin + ? db + .select({ + agentId: schema.agentRuns.agentId, + status: schema.agentRuns.status, + startedAt: schema.agentRuns.startedAt, + completedAt: schema.agentRuns.completedAt, + }) + .from(schema.agentRuns) + .where( + and( + eq(schema.agentRuns.organisationId, subject.organisationId), + gte(schema.agentRuns.startedAt, windowStart), + ), + ) + .orderBy(desc(schema.agentRuns.startedAt)) + .limit(5_000) + : Promise.resolve([] as AgentRunRow[]), + ]); + // Blocked and approval-stalled pack handoffs are operational debt: an agent // asked for help and nothing is moving. Surface them, do not bury them. const stalledHandoffs = canReadAgents @@ -293,6 +697,45 @@ export async function getCommandSummary( ].filter((s) => s === "degraded" || s === "unavailable").length : 0; + const taskCreatedAt = recentTasks + .map((row) => row.createdAt) + .filter((at): at is Date => at instanceof Date); + const approvalRequestedAt = recentApprovals + .map((row) => row.requestedAt) + .filter((at): at is Date => at instanceof Date); + const failedMissionRunAt = recentMissionRuns + .filter((row) => row.status === "failed") + .map((row) => row.createdAt) + .filter((at): at is Date => at instanceof Date); + const failedAgentRunAt = recentAgentRuns + .filter((row) => toOperationalState(row.status) === "failed") + .map((row) => row.completedAt ?? row.startedAt) + .filter((at): at is Date => at instanceof Date); + // Tiles state a window, not "ever": a count and the series under it have to + // be measuring the same thing or the tile contradicts itself. + const failedAgentRuns24h = failedAgentRunAt.filter( + (at) => now - at.getTime() < DAY_MS, + ).length; + const failedMissionRuns7d = failedMissionRunAt.length; + + const statusOrder = ["backlog", "ready", "in_progress", "review", "done"]; + const taskStatus: TaskStatusSlice[] = taskStatusCounts + .map((row) => ({ + status: row.status, + label: TASK_STATUS_LABELS[row.status] ?? row.status, + count: Number(row.total), + })) + .sort( + (a, b) => + (statusOrder.indexOf(a.status) + 1 || 99) - + (statusOrder.indexOf(b.status) + 1 || 99), + ); + + const { runActivity, runsByAgent } = buildRunActivity(now, recentAgentRuns); + const agentActivity = buildAgentActivity(agentRows, runsByAgent); + const myTasks = buildMyTasks(queueTasks, subject.actorId); + const integrations = buildIntegrations(controlPlane); + const metrics: CommandMetric[] = [ { id: "pending-approvals", @@ -301,6 +744,20 @@ export async function getCommandSummary( tone: pendingApprovals.length > 0 ? "warning" : "default", href: "/approvals", ...(canApprove ? {} : { hint: "Requires workflows.approve" }), + ...(canApprove + ? { + series: dailySeries(approvalRequestedAt, now), + seriesLabel: "Approvals requested per day, last 7 days", + } + : {}), + ...trendField( + dayOverDayTrend( + approvalRequestedAt, + now, + "requested vs previous 24h", + "down", + ), + ), }, { id: "high-priority", @@ -331,17 +788,32 @@ export async function getCommandSummary( }, { id: "failed-agent-runs", - label: "Failed agent runs", - value: failedAgentRuns, - tone: failedAgentRuns > 0 ? "danger" : "default", + label: "Failed agent runs (24h)", + value: failedAgentRuns24h, + tone: failedAgentRuns24h > 0 ? "danger" : "default", href: "/agents", + series: dailySeries(failedAgentRunAt, now), + seriesLabel: "Failed agent runs per day, last 7 days", + ...trendField( + dayOverDayTrend(failedAgentRunAt, now, "failures vs previous 24h", "down"), + ), }, { id: "failed-missions", - label: "Failed mission runs", - value: failedRuns.length, - tone: failedRuns.length > 0 ? "danger" : "default", + label: "Failed mission runs (7d)", + value: failedMissionRuns7d, + tone: failedMissionRuns7d > 0 ? "danger" : "default", href: "/missions", + series: dailySeries(failedMissionRunAt, now), + seriesLabel: "Failed mission runs per day, last 7 days", + ...trendField( + dayOverDayTrend( + failedMissionRunAt, + now, + "failures vs previous 24h", + "down", + ), + ), }, { id: "blocked-handoffs", @@ -356,6 +828,15 @@ export async function getCommandSummary( label: "Open work items", value: openTasks.length, href: "/operations", + ...(canReadTasks + ? { + series: dailySeries(taskCreatedAt, now), + seriesLabel: "Work items opened per day, last 7 days", + } + : {}), + ...trendField( + dayOverDayTrend(taskCreatedAt, now, "opened vs previous 24h", "neutral"), + ), }, ]; @@ -558,6 +1039,11 @@ export async function getCommandSummary( attention: attention.slice(0, 40), riskRadar, activity, + taskStatus, + runActivity, + agentActivity, + myTasks, + integrations, agents: agentRows.map((agent) => ({ id: agent.id, name: agent.name, diff --git a/apps/web/types/os.ts b/apps/web/types/os.ts index b257c8c..c0b7096 100644 --- a/apps/web/types/os.ts +++ b/apps/web/types/os.ts @@ -31,6 +31,20 @@ export type SessionContext = { customer: { id: string; name: string } | null; }; +/** + * Change against the immediately preceding window of the same length. Only + * present where the database can answer the comparison — a tile with no + * honest history shows no trend rather than a decorative arrow. + */ +export type MetricTrend = { + delta: number; + direction: "up" | "down" | "flat"; + /** What the comparison was, e.g. "vs previous 24h". */ + label: string; + /** Which direction is the good news, so colour never guesses. */ + improving: "up" | "down" | "neutral"; +}; + export type CommandMetric = { id: string; label: string; @@ -38,6 +52,63 @@ export type CommandMetric = { hint?: string; tone?: "default" | "warning" | "danger" | "success"; href?: string; + trend?: MetricTrend; + /** Oldest → newest daily counts behind the tile. Omitted when unknown. */ + series?: number[]; + /** What the series counts, for the sparkline's accessible description. */ + seriesLabel?: string; +}; + +/** Live distribution of work items by status — the donut is not a sample. */ +export type TaskStatusSlice = { + status: string; + label: string; + count: number; +}; + +/** Hourly agent-run buckets over the last 24 hours. */ +export type RunActivityPoint = { + /** ISO timestamp for the start of the bucket. */ + bucket: string; + completed: number; + failed: number; + running: number; + cancelled: number; +}; + +export type AgentActivityRow = { + id: string; + name: string; + status: string; + runtime: string; + runs: number; + succeeded: number; + /** + * Null when the agent has no settled runs in the window (completed or + * failed). Matches the producer's denominator — not merely completed runs. + */ + successRate: number | null; + lastRunAt: string | null; +}; + +export type MyTaskRow = { + id: string; + title: string; + status: OperationalState; + rawStatus: string; + priority: string; + severity: Severity; + sourceSystem: string; + updatedAt: string; + dueAt: string | null; + assignedToMe: boolean; +}; + +export type IntegrationHealthChip = { + id: string; + name: string; + health: HealthState; + detail: string; }; export type AttentionItem = {