From 8051e89e7437d08fa80cf7b8f9271bb7a9e3eebf Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 15 Jun 2026 19:05:15 -0700 Subject: [PATCH 01/21] docs(plan): Command Center dashboard + SDLC gap-fill plan --- ...-feat-command-center-and-sdlc-gaps-plan.md | 787 ++++++++++++++++++ 1 file changed, 787 insertions(+) create mode 100644 docs/plans/2026-06-15-001-feat-command-center-and-sdlc-gaps-plan.md diff --git a/docs/plans/2026-06-15-001-feat-command-center-and-sdlc-gaps-plan.md b/docs/plans/2026-06-15-001-feat-command-center-and-sdlc-gaps-plan.md new file mode 100644 index 000000000..7b8a3e364 --- /dev/null +++ b/docs/plans/2026-06-15-001-feat-command-center-and-sdlc-gaps-plan.md @@ -0,0 +1,787 @@ +--- +title: "feat: Command Center dashboard + software-delivery-loop gap-fill" +type: feat +status: active +date: 2026-06-15 +depth: deep +origin: none (solo plan; external research from a competitor product, see Sources) +--- + +# feat: Command Center dashboard + software-delivery-loop gap-fill + +## Summary + +Build a **Command Center** for the Fusion dashboard — a combined **historical analytics** +surface (tokens, tools, activity, productivity, ecosystem, per-agent/per-node breakdowns +over selectable date ranges, with CSV + OpenTelemetry export) **and a live Mission-Control +panel** (concurrent sessions, active nodes, what each agent is doing right now, SDLC funnel +throughput). Then close the gaps between Fusion and an end-to-end software-delivery system — +the **Signal → Triage → Plan → Execute → Validate → Ship → Monitor** loop — by adding the +stages Fusion does not yet cover (external signal ingestion, monitoring/incident response, +persistent knowledge layer) and the cross-cutting capabilities the loop implies (a **Fusion Model Router** that auto-selects the +cheapest-capable model per task, auto-triage of inbound issues **and PRs**, auto-resolution of +PR review comments, and surfacing external signals as dashboard metrics). + +This plan is intentionally large because the user asked for full implementation coverage of +all gaps. It is organized into three phases so it can land incrementally: **Phase A** (metrics +foundation) and **Phase B** (the Command Center itself) deliver the branch's headline feature; +**Phase C** (SDLC gap-fill) is sequenced after, and several of its units are large enough that +the plan flags them as candidates to spin into their own brainstorm before execution. + +--- + +## Problem Frame + +Fusion is the model- and surface-agnostic orchestration layer for a developer driving many +agent sessions across nodes and surfaces (see `STRATEGY.md`). Two problems: + +1. **There is no observability surface.** A developer juggling 10+ agents across machines has + no single place to answer "how much am I spending, on which models, across which nodes, + what's running right now, and what did all this work actually ship?" Fusion captures the + raw data (per-task token columns, agent runs, activity log, commit associations, PRs, CLI + sessions) but exposes only fragmentary panels (`ReliabilityView`, `AgentTokenStatsPanel`). + Fusion's own `STRATEGY.md` key metrics (concurrent sessions, active nodes, ecosystem + breadth, task completion rate, LOC shipped) are precisely what such a view should make + observable. + +2. **Fusion covers the middle of the SDLC loop but not the ends.** The end-to-end delivery + loop is self-reinforcing: *Signal → Triage → Plan → Execute → Validate → Ship → Monitor*. + Fusion is strong on Plan/Execute/Validate/Ship and has partial Triage (GitHub issue + ingestion). It has **no Signal ingestion beyond GitHub, no Monitor stage, and no persistent + knowledge layer** — the parts that make the loop close and compound. + +This plan addresses both: the Command Center (Phases A–B) and the missing stages (Phase C). + +--- + +## Requirements + +Traceability is to Fusion's `STRATEGY.md` key metrics (KM) and the external feature set the +user asked us to match (external research, no formal requirements doc). + +- **R1 — Historical analytics.** Surface token consumption (by model, provider, node, agent, + time), tool usage and autonomy ratio, activity (sessions/messages/active-nodes over time), + productivity (files, commits, PRs, LOC), and ecosystem breadth (unique models + plugins). + (KM: all five.) +- **R2 — Date-range filtering.** All analytics support a selectable range (presets + custom), + mirroring `agent-token-usage.ts`'s windowed aggregation extended to arbitrary ranges. +- **R3 — Live Mission Control.** A real-time panel: concurrent agent sessions, active nodes, + per-agent current activity, and an SDLC funnel (triage→todo→in-progress→in-review→done) + with live throughput. (KM: concurrent agent sessions, active nodes, task completion rate.) +- **R4 — Export.** CSV export of any analytics table, and OpenTelemetry (OTLP) export of the + metrics, for shipping to Datadog/Grafana/etc. +- **R5 — Analytics API.** Programmatic endpoints (activity, tokens, tools, productivity) so an + agent can pull metrics. +- **R6 — Cost.** Derive USD cost from token counts × a model pricing map (Fusion stores tokens + but not cost today). +- **R7 — Signal ingestion.** Ingest external signals beyond GitHub (error trackers / alerting: + Sentry, Datadog, PagerDuty, generic webhook) into triageable tasks. +- **R8 — Triage stage.** Auto-classify and decompose incoming signals/issues into board tasks. +- **R9 — Monitor stage.** Track deployments and production incidents, compute MTTR, and feed + Monitor signals back into the funnel (closing the loop). +- **R10 — Knowledge layer.** A persistent, incrementally-refreshed knowledge index downstream + agents can query. +- **R13 — Fusion Model Router.** Automatic per-task / per-request model selection across + providers (route routine steps to fast/cheap models, reserve stronger models for hard + reasoning), with fallback, prompt-cache awareness, and respect for existing model controls — + a direct expression of Fusion's model-agnostic thesis. +- **R14 — Auto-triage of incoming issues *and* pull requests.** Triage applies to inbound PRs + (external contributions, dependabot, etc.), not just issues/signals — classify, label, and + route or open a follow-up task. +- **R15 — Auto-resolution of PR review comments.** Build on Fusion's existing **Review-response + loop** so PR review threads are acted on automatically (fix + push + reply, or disagree with + reasoning) as a first-class, surfaced capability. +- **R16 — External signals in dashboard metrics.** The Command Center surfaces external signals + (errors, alerts, incidents from R7 sources) as a metric area and in Mission Control, not only + as task-creating triggers. + +--- + +## Key Technical Decisions + +### KTD1 — Mirror the built-in view pattern; no router, no plugin +Register `command-center` as a `BuiltInTaskView` (`useViewState.ts`), lazy-load `CommandCenter` +in `App.tsx`, and add the nav entry in `Header.tsx`, exactly mirroring `reliability`. The +dashboard has **no URL router** (view state is `?view=` + `localStorage`); do not introduce +one. Ship as a built-in view (optionally behind an `experimentalFeatures.commandCenter` flag +like `insights`/`memoryView`), **not** a plugin — it is core product surface. + +### KTD2 — Aggregation lives in `packages/core`; the route is a thin adapter +Put all metric math in new `packages/core/src/*-analytics.ts` modules (so engine/CLI can reuse +it), mirroring `agent-token-usage.ts`. The dashboard exposes it via an `ApiRouteRegistrar` +(`register-command-center-routes.ts`) registered in `routes.ts`, mirroring +`register-usage-routes.ts`. Do **not** import the engine into the React frontend; everything +goes over HTTP/SSE. + +### KTD3 — A queryable telemetry/events table is required (the data is not all queryable today) +Token counts live on `tasks` (queryable) but **tool calls live in per-task JSONL agent logs** +and messages/sessions are spread across `chat_room_messages`/`cli_sessions`. The Tools area +and autonomy ratio need a queryable source. Decision: introduce a `usage_events` table in +`packages/core/src/db.ts` (migration in the same file) fed from the **store-level event seams** +(task-execution `appendAgentLog`, heartbeat/run `appendRunLog`, and the CLI/chat +`chat_room_messages` writer), rather than parsing JSONL at query time. This is the single +highest-risk change — see Risks for the SCHEMA_VERSION trap. **Tool calls are NOT all funneled +through one writer:** task execution, heartbeat agents (`appendRunLog`, callback-mode — bypasses +the file store), and CLI/chat (`chat_room_messages`, not tool-granular today) are distinct paths, +so a dual-write at `agent-log-file-store.ts` alone would silently undercount. The design must +instrument all three OR explicitly scope `usage_events` to task-execution and document the +exclusion. *Alternative (open — see Open Questions):* a lazy-materialization / cache table +populated on first query per time-bucket — viable because R1/R2/R5 state no sub-second +requirement, and it avoids coupling the agent hot-path write to a SQLite transaction. + +### KTD4 — Charting: extend the house "hand-rolled CSS bars" style, do not add a chart lib (default) +The codebase has **zero** charting dependencies and a strong convention of hand-built CSS-bar +histograms (`ReliabilityView.tsx:199-207`). Default to extending that style with a small set of +reusable primitives (bar, sparkline, stacked bar, funnel) under +`packages/dashboard/app/components/command-center/charts/`. Adding a dependency (Recharts) is a +notable departure requiring a changeset + maintainer sign-off; surfaced as a call-out, not +assumed. *Rationale:* keeps bundle lean, matches existing code, avoids a lazy-loaded chart +vendor in a view that already lazy-loads. Revisit only if a chart type (e.g. multi-series time +series) proves impractical by hand. + +### KTD5 — Live data uses push + poll convergence, throttled deltas +The Mission-Control panel follows the documented live-data pattern: an SSE event triggers an +immediate refetch while a poll interval (e.g. 5s) runs as a fallback **only while work is +in-flight**; high-frequency updates are throttled server-side. Historical analytics use plain +query + SWR (no streaming machinery). (See `docs/solutions/architecture-patterns/observable-long-running-agent-turns-through-blocking-plugin-route-seam.md`.) + +### KTD6 — Cost via a versioned pricing map, never persisted as truth +Cost is derived at read time from token columns × a `model-pricing.ts` map (input/output/cache +rates per `modelProvider`+`modelId`), not stored. Unknown models surface tokens with cost +marked unavailable rather than guessing. Keeps historical rows correct when prices change and +avoids a migration to backfill cost. The map carries a `pricingAsOf` date and per-entry source +link; the UI shows "prices as of " and marks entries older than a threshold low-confidence, +so stale-but-present rates (which the unknown-model guard does not catch) are visible rather than +silently wrong. + +### KTD7 — SDLC stages map onto existing workflow columns + new trait-tagged columns +Phase C does not invent a parallel pipeline. Signal/Triage/Monitor attach to the existing +workflow-column system (`Column`, `Trait`, `Workflow Extension` in `CONCEPTS.md`): a `signal` +intake column, a `triage` trait that auto-decomposes, and a `monitor` trait that watches +deployments. This reuses the workflow runtime rather than forking lifecycle policy. + +### KTD8 — Signal ingestion reuses the GitHub ingestion seam +Sentry/Datadog/PagerDuty/webhook ingestion mirrors the existing GitHub source path +(`github-source-issue-close.ts`, `github-poll.ts`, `github-webhooks.ts`) behind a common +`SignalSource` adapter interface, so each provider is a small adapter rather than bespoke wiring. + +### KTD9 — Model Router is a selection layer over existing agent/model resolution, not a new executor +The Fusion Model Router slots into the existing **effective-agent / model-pair resolution** path +(`CONCEPTS.md` Effective agent, Workflow Setting model lanes) as a routing policy that *chooses* +the `(provider, model)` before a session starts (session routing) and may re-route per request +for routine sub-steps. It does **not** add an executor kind — it picks which existing +CLI/provider runs. It respects column-agent overrides and model controls (an org/project that +restricts a model restricts the router's ability to pick it), and reuses the U3 pricing map + +U1 telemetry to make cost/latency-aware decisions and to measure its own savings. Routing rules +are declarative (task complexity signal → model tier) with a safe fallback to the configured +default pair when the router is disabled or a pick is unavailable. This is a natural fit for the +`ecosystem breadth` strategy metric and feeds the Command Center directly. + +### KTD10 — PR-comment auto-resolution extends the existing Review-response loop, not a rebuild +Fusion already has a **Review-response loop** (entry point `packages/engine/src/pr-response-run.ts`). R15 makes it a +first-class, default-surfaced capability rather than new machinery: ensure it triggers on +PR-entity review threads, expose its activity in the Command Center / Mission Control, and gate +it consistently with the merge/auto-merge model. Do not re-implement the loop. + +--- + +## High-Level Technical Design + +### The software delivery loop: Fusion today vs. the gaps this plan fills + +```mermaid +flowchart LR + subgraph Loop["Software delivery loop (SDLC)"] + Signal["Signal\n(R7 — GAP*)"] --> Triage["Triage\n(R8 — partial)"] + Triage --> Plan["Plan\n(have: CE, missions)"] + Plan --> Execute["Execute\n(have: CLI sessions, nodes)"] + Execute --> Validate["Validate\n(have: validator, review loop)"] + Validate --> Ship["Ship\n(have: merge, PR entity)"] + Ship --> Monitor["Monitor\n(R9 — GAP)"] + Monitor -.feeds.-> Signal + end + Knowledge["Knowledge layer (R10 — GAP)"] -.enriches every stage.-> Loop + CC["Command Center (R1–R6) — observes the whole loop"] -.reads telemetry from.-> Loop + style Signal fill:#3b82f6,color:#fff + style Monitor fill:#3b82f6,color:#fff + style Knowledge fill:#3b82f6,color:#fff + style CC fill:#1e40af,color:#fff +``` +*GAP\* = GitHub-only today; other sources are the gap. Blue = net-new in this plan.* + +### Command Center data flow (Phase A → B) + +```mermaid +flowchart TD + subgraph Sources["Existing data (packages/core, SQLite + JSONL)"] + T["tasks (token cols, model, files, timing)"] + AR["agentRuns / agentHeartbeats / agentTaskSessions"] + AL["activityLog"] + CC0["task_commit_associations"] + PR["pull_requests"] + CS["cli_sessions / chat_room_messages"] + JL["per-task JSONL agent logs (tool calls)"] + end + JL -->|U1: writer also appends| UE[("usage_events (new table)")] + CS -->|U1| UE + Sources --> AGG["U2: *-analytics.ts aggregators in packages/core\n(date-range windows, group-by model/node/agent)"] + UE --> AGG + AGG --> PRICE["U3: model-pricing.ts → cost (KTD6)"] + PRICE --> API["U9: register-command-center-routes.ts (ApiRouteRegistrar)\n/api/command-center/{tokens,tools,activity,productivity,live}"] + API -->|HTTP + SWR| HV["U5: Command Center historical areas"] + API -->|SSE + poll (KTD5)| LV["U6b: Mission-Control live panel (frontend)"] + API --> CSV["U8: CSV export"] + API --> OTEL["U10: OTLP exporter"] + AGG --> FUNNEL["U7: SDLC funnel (activityLog transitions)"] + HV --> VIEW["U4: Command Center shell (lazy view, nav entry)"] + LV --> VIEW + FUNNEL --> VIEW +``` + +--- + +## Output Structure + +New files this plan introduces (repo-relative; existing files edited are listed per unit): + +``` +packages/core/src/ + usage-events.ts # U1 write/query the new events table + model-pricing.ts # U3 pricing map + cost derivation + token-analytics.ts # U2 extends agent-token-usage windows → ranges + tool-analytics.ts # U2 tool calls by category, autonomy ratio + activity-analytics.ts # U2 sessions/messages/active-nodes/stickiness + productivity-analytics.ts # U2 files/commits/PRs/LOC, language dist + command-center-live.ts # U6a live snapshot: sessions, nodes, funnel + otel-metrics.ts # U10 OTLP metric mapping (pure mapping; wiring is in dashboard) + model-router.ts # U17 routing policy + rule evaluation +packages/dashboard/src/routes/ + register-command-center-routes.ts # U9 analytics + live + export endpoints + register-signal-routes.ts # U11 inbound signal webhooks +packages/dashboard/src/ + command-center-csv.ts # U8 CSV serialization + signal-source.ts # U11 SignalSource adapter interface + registry (mirrors github-* — dashboard) + signal-sources/{sentry,datadog,pagerduty,webhook}.ts # U11 adapters + knowledge-index.ts # U14 knowledge store + refresh (mirrors insights-routes — dashboard) + monitor-routes.ts # U13 deployment/incident tracking +packages/dashboard/app/components/command-center/ + CommandCenter.tsx # U4 shell + sub-view tabs + CommandCenter.css # U4 + charts/{Bar,StackedBar,Sparkline,Funnel}.tsx + .css # U4 chart primitives + areas/{TokensArea,ToolsArea,ActivityArea,ProductivityArea,EcosystemArea,SignalsArea}.tsx # U5 + MissionControlPanel.tsx # U6b live ops + SdlcFunnel.tsx # U7 funnel/throughput + DateRangePicker.tsx # U5/B shared range control +``` + +> **Package note (feasibility).** New modules that *mirror an existing precedent* must live in +> the same package as that precedent. The GitHub ingestion path, `reliability-metrics.ts`, +> `subtask-breakdown.ts`, `runtime-provider-probes.ts`, and `pr-conflict-resolver.ts` all live in +> `packages/dashboard/src`, **not** `packages/core` — so `signal-source.ts` (mirrors `github-*`), +> `knowledge-index.ts` (mirrors `insights-routes.ts`), the OTel wiring, and `monitor-routes.ts` +> belong in dashboard. Pure, reusable aggregation (`*-analytics.ts`, `model-pricing.ts`, +> `command-center-live.ts`, the OTLP *mapping*, `model-router.ts`) stays in `packages/core` per +> KTD2. If a core module needs GitHub-ingestion code, expose it through a core-level seam rather +> than importing dashboard into core. + +--- + +## Implementation Units + +### Phase A — Metrics foundation + +#### U1. Queryable usage-events telemetry table +**Goal:** Create a normalized, queryable source for tool calls, messages, and session +lifecycle so the Tools/Activity areas and OTel export do not have to parse JSONL at query time. +**Requirements:** R1, R3, R5 (substrate). +**Dependencies:** none. +**Files:** +- `packages/core/src/db.ts` — add the `usage_events` table, a new `applyMigration(N, ...)` block, and bump `SCHEMA_VERSION` to N (currently 117). `applyMigration`, `SCHEMA_VERSION`, `MIGRATION_ONLY_TABLE_SCHEMAS`, and `SCHEMA_COMPAT_FINGERPRINT` all live in `db.ts`, **not** `db-migrate.ts` (which is the legacy-data import path) — see Risks. +- `packages/core/src/usage-events.ts` (new: append + range query helpers) +- a dedicated `emitUsageEvent(...)` capture call invoked from the layer where `model`/`provider`/`nodeId`/`category` are already in scope — the **executor / session-run layer** — **not** by overloading `store.appendAgentLog` / `store.appendRunLog`, whose signatures and the `AgentLogEntry` they persist carry none of those fields (widening them is a high-fanout ~20+ call-site change across `engine/src/merger.ts`, `executor.ts`, etc.). `agent-log-file-store.ts` is likewise unusable (pure-FS, no DB handle). The field-carrying mechanism (dedicated call vs signature-widening vs hot-path lookup) is recorded as an Open Question. +- `packages/core/src/__tests__/usage-events.test.ts`, and the `db.ts` migration test (extend) +**Approach:** Columns: `id`, `ts`, `kind` (`tool_call|tool_result|tool_error|user_message|session_start|session_stop`), `taskId`, `agentId`, `nodeId`, `model`, `provider`, `toolName`, `category`, `meta` (JSON). **v1 scope:** task-execution + run-log events (which can carry model/provider/node from the session context). The chat path (`ChatStore`/`chat_room_messages`, which has no model/provider at its write site) contributes **message counts only** — chat-origin rows are model/provider-null by design, documented in U2. **`nodeId`** is sourced from the run/session context (`agentRuns`/`cli_sessions`), not the `tasks` row (which has no `nodeId`); events with no node context record `nodeId` null. **Mapping:** the agent-log `type` value `tool` maps to `kind: tool_call` (there is no `tool_call` in `AgentLogType`, which is `text|tool|thinking|tool_result|tool_error`); `user_message`/`session_start`/`session_stop` originate from `cli_sessions`/`chat_room_messages`. **`meta` safety:** capped at a fixed byte size (~4 KB, rejected at write); carries only non-sensitive descriptors (error code, category, duration) — **never** tool arguments/content or credential-class fields — with a documented retention/age-out policy. Reads come from SQLite. Index `(ts)`, `(taskId)`, `(agentId)`. +**Patterns to follow:** `packages/core/src/agent-token-usage.ts` (range scans), the `applyMigration` shape in `db.ts`, and the schema-version learning doc. +**Test scenarios:** +- Happy: a `tool`-type agent-log entry inserts one `usage_events` row with `kind: tool_call` and correct `category`. +- Completeness: a heartbeat-run (`appendRunLog`) tool call and a chat tool call either appear in `usage_events` or are asserted intentionally absent per the documented scope (guards the multi-path undercount). +- Edge: a chat-session event with no `taskId` records with `taskId` null and `agentId` set. +- Migration: seed a DB **at the previous schema version**, run migrate, assert the table exists and `SCHEMA_VERSION` equals the highest migration target (fresh-DB tests cannot catch the early-return bug). +- Error: malformed event is skipped without throwing and without aborting the underlying write. +- Edge: a `meta` payload exceeding the byte cap is rejected at write; tool-argument content never lands in `meta`. +- Integration: a real task execution that calls 3 tools yields 3 `tool_call` rows queryable by range, with `model`/`provider`/`nodeId` populated from the session context. + +#### U2. Core analytics aggregators (date-range windows) +**Goal:** Pure, reusable aggregation over tasks + `usage_events` producing the six measurement +areas for an arbitrary date range, grouped by model/provider/node/agent. +**Requirements:** R1, R2. +**Dependencies:** U1. +**Files:** +- `packages/core/src/token-analytics.ts`, `tool-analytics.ts`, `activity-analytics.ts`, + `productivity-analytics.ts` (new) +- `packages/core/src/__tests__/{token,tool,activity,productivity}-analytics.test.ts` +**Approach:** Each exports `aggregate({from, to, groupBy})`. Tokens: sum `tasks.tokenUsage*` +columns filtered by `tokenUsageLastUsedAt` in range. Tools: count `usage_events` by +`category`; **autonomy ratio = tool_call count / human-intervention events** — NOT raw user +messages, which trend to zero for autonomous task execution. The denominator's three components +have distinct, named sources (they are not one queryable thing): **approvals** from +`approval_request_audit_events` (filter to `created`/`approved`); **user-authored steers** from +the `SteeringComment[]` JSON on the task row, filtered to `author === "user"` (agent-authored +steers excluded — note this re-introduces a per-task JSON read, so mirror steers into +`usage_events` if range-querying proves costly); **waiting-on-input** is a task *status*, not a +counted event — drop it unless a concrete answer event is defined. A fully-autonomous session +(zero interventions) reports tool-calls-per-session instead of ∞. Activity: distinct +active nodes/agents per day, sessions from `cli_sessions`, messages from `usage_events`, +**stickiness = DAU/MAU**. Productivity: `tasks.modifiedFiles` count + language distribution, +`task_commit_associations` count, `pull_requests` count; LOC from commit diff stats if +available else flagged unavailable. Generalize `agent-token-usage.ts`'s 24h/7d/all-time windows +to `(from,to)`. +**Patterns to follow:** `packages/core/src/agent-token-usage.ts`. +**Test scenarios:** +- Happy: a known fixture of 5 tasks across 2 models returns correct per-model token totals. +- Edge: empty range returns zeroed structures, not nulls; a range boundary task (exactly at `from`) is included per documented inclusivity. +- Edge: a fully-autonomous session (zero human-intervention events) reports tool-calls-per-session, not ∞ or a divide-by-zero; validated against both an autonomous and an interactive fixture. +- Edge: the intervention denominator counts a user-authored steer and an approval but NOT an agent-authored steer. +- Productivity: LOC unavailable when commit diff stats are missing is reported as `null` + `unavailable: true`, not `0`. + +#### U3. Model pricing → cost derivation +**Goal:** Derive USD cost from token counts without persisting cost. +**Requirements:** R6. +**Dependencies:** U2. +**Files:** `packages/core/src/model-pricing.ts` (new), `packages/core/src/__tests__/model-pricing.test.ts`; consumed by `token-analytics.ts`. +**Approach:** A map keyed by `provider:model` → `{inputPer1M, outputPer1M, cacheReadPer1M, cacheWritePer1M, source}` plus a top-level `pricingAsOf` date. `costFor(usage, model)` returns `{usd, unavailable, stale}`. Unknown model → `unavailable: true`, never a guessed price. +**Patterns to follow:** plain data module; colocate with token-analytics. +**Test scenarios:** +- Happy: known model + token counts yields expected USD to cent precision. +- Edge: unknown model returns `unavailable: true` with `usd: null`. +- Edge: cache tokens priced at cache rate, not input rate. +- Edge: the map carries a `pricingAsOf` date and entries older than the threshold return `stale: true`. + +### Phase B — Command Center dashboard + +#### U4. Command Center shell, nav registration, and chart primitives +**Goal:** Register the `command-center` view end-to-end and build the reusable CSS-bar chart +primitives the areas render with. +**Requirements:** R1 (shell), KTD1, KTD4. +**Dependencies:** none (can start parallel to A; renders real data once A lands). +**Files:** +- `packages/dashboard/app/hooks/useViewState.ts` (add `command-center` to union + array) +- `packages/dashboard/app/App.tsx` (lazy import + prefetch + render branch, mirror `reliability` at App.tsx:1818-1826) +- `packages/dashboard/app/components/Header.tsx` (nav button mirroring reliability at :1223-1234; add `command-center` to active-check at :1095; optional `experimentalFeatures.commandCenter` gate) +- `packages/dashboard/app/components/MobileNavBar.tsx` (mobile parity) +- `packages/dashboard/app/components/command-center/CommandCenter.tsx` + `.css`, `charts/{Bar,StackedBar,Sparkline,Funnel}.tsx` + `.css`, `DateRangePicker.tsx` +- i18n strings under the `app` namespace +- `packages/dashboard/app/components/command-center/__tests__/charts.test.tsx`, `CommandCenter.test.tsx` +**Approach:** Shell renders sub-view tabs (Overview / Tokens / Tools / Activity / Productivity / Ecosystem / Mission Control). Chart primitives are hand-rolled CSS-bar components. Use `--duration-*` tokens (never `--transition-*`) for any loader/pulse animation. +**Overview tab content:** one headline stat card per area (total tokens + cost, autonomy ratio, active nodes, tasks done, unique models, open signals) plus a compact live Mission-Control strip; the date-range picker applies to the cards but not the live strip. A single "no usage data yet" empty state when nothing exists. +**Tab a11y:** sub-tabs use the ARIA tabs pattern (`role=tablist/tab/tabpanel`, arrow-key roving tabindex, Enter/Space activates, Tab moves into the active panel); the DateRangePicker returns focus to its trigger on dismiss. +**Patterns to follow:** `ReliabilityView.tsx` (skeleton, loading/error/empty), `AgentsView.tsx` (sub-view toggles), the reliability nav button. +**Execution note:** Build the chart primitives test-first — they are pure and the CSS-token trap is invisible without a real-browser assertion. +**Test scenarios:** +- Happy: selecting the Command Center nav entry renders the shell with the Overview tab active; `?view=command-center` deep-links to it. +- Edge: empty data renders the documented empty state per area, not a crash. +- CSS (real browser): `getComputedStyle(barEl).animationName !== "none"` for any animated loader (guards the IACVT token trap); extend `animation-duration-tokens.css.test.ts` for new CSS. +- Edge: chart bar with a zero value renders a 0-width bar with accessible label, not NaN width. +- A11y: a keyboard user can arrow between tabs, activate with Enter/Space, and Tab into the panel without losing focus; the date-range picker returns focus to its trigger on dismiss. + +#### U5. Historical analytics areas + date-range filtering +**Goal:** Render the measurement areas from the Phase A aggregators with a shared date-range +control, **including an External Signals area** (errors/alerts/incidents from R7 sources). +**Requirements:** R1, R2, R6, R16. +**Dependencies:** U2, U3, U4, U9; the Signals area depends on U11 data (degrades to empty until U11 lands). +**Files:** `packages/dashboard/app/components/command-center/areas/{TokensArea,ToolsArea,ActivityArea,ProductivityArea,EcosystemArea,SignalsArea}.tsx`, `DateRangePicker.tsx`; tests alongside. +**Approach:** Each area fetches its endpoint via the `api()` helper with the selected range, +renders stat cards + tables + CSS-bar charts. **Productivity framing (A5):** present LOC and +tool-count as *volume* proxies alongside outcome counters (tasks reaching done, PRs merged, +incidents resolved); do not frame high LOC/tool counts as inherently positive. +**Ecosystem area:** unique-active-model count + per-model session count as a bar chart, plugin +activation count, and a sparkline of distinct models/day; empty state when no third-party models +or plugins have been used. Reuses the tokens endpoint grouped by model where possible. +**External Signals area (R16):** signal volume by source/severity over the range, open vs +resolved, and MTTR (from U13) — wired so external signals are visible as dashboard metrics, not +only as task triggers. Until U11/U13 land, the area renders its empty state. +**SWR trap:** key any selection/drill-down reset effect on a derived value (e.g. +`rows.map(r => r.id).join(" ")`), never the array identity, or it resets every revalidation. +**Patterns to follow:** `AgentTokenStatsPanel.tsx` (token tables/totals), `ReliabilityView.tsx`. +**Test scenarios:** +- Happy: Tokens area shows per-model totals + cost; changing the range refetches and re-renders. +- Tools: autonomy ratio displayed; tool categories shown as a sorted bar chart. +- Edge (SWR): a revalidation that returns content-identical rows with new identity does **not** reset the user's column sort / selected row (regression: seed cache, defer fetch, interact, resolve with `JSON.parse(JSON.stringify(original))`, assert state survives). +- Edge: custom range with `from > to` is rejected client-side with a message. +- Productivity: unavailable LOC shows "—" with a tooltip, not `0`. +- Signals (R16): with U11 fixture data, the External Signals area shows volume by source/severity and open-vs-resolved; with no signal data it renders the empty state, not an error. + +#### U6. Live Mission-Control panel +**Goal:** Real-time view of concurrent sessions, active nodes, per-agent current activity, and +the live SDLC funnel. +**Requirements:** R3. +**Dependencies:** U4, and U9 for the endpoint. **To break the U6↔U9 cycle, U6 splits in two:** U6a = the core `command-center-live.ts` snapshot composer (no deps); U6b = the `MissionControlPanel` frontend (deps U4, U9). U9's live branch depends on U6a, not the U6 frontend. +**Files:** `packages/dashboard/app/components/command-center/MissionControlPanel.tsx`, `packages/core/src/command-center-live.ts` (live snapshot, U6a), live branch in `register-command-center-routes.ts`; tests alongside. +**Approach:** `command-center-live.ts` composes a snapshot from `agentHeartbeats`/`agentRuns`/`cli_sessions`/`tasks` (current column counts). Frontend follows **push + poll convergence (KTD5)**: subscribe to the existing SSE bus, refetch on event, poll every ~5s **only while any session is in-flight**, stop polling when idle. Server throttles emits (~500ms) and sends deltas, not full snapshots, for high-churn fields. +**Patterns to follow:** `app/sse-bus.ts`, the observable-long-running-agent-turns learning doc, `AgentsOverviewBar.tsx`. +**Test scenarios:** +- Happy: a newly-started session appears in the live panel within one poll/SSE cycle; ending it removes it. +- Edge: with zero active sessions, polling is not running (assert no interval scheduled when idle). +- Integration: SSE event triggers an immediate refetch (push) even between poll ticks. +- Edge: a node going stale (no heartbeat past threshold) is shown as inactive, not dropped silently. + +#### U7. SDLC funnel + throughput visualization +**Goal:** A funnel/Sankey-style visualization of tasks across columns with throughput (e.g. +tasks/day reaching done) and completion rate, both live and over a range. +**Requirements:** R1, R3 (KM: task completion rate). +**Dependencies:** U2, U4. +**Files:** `packages/dashboard/app/components/command-center/SdlcFunnel.tsx` + `.css`; aggregation in `activity-analytics.ts`; tests alongside. +**Approach:** Map the workflow columns (`triage→todo→in-progress→in-review→done`) to funnel +stages using `activityLog` transitions; show counts per stage and conversion between stages. +Reuse the `Funnel` chart primitive from U4. +**Patterns to follow:** the hand-rolled bar style; `activityLog` event types in `types.ts`. +**Test scenarios:** +- Happy: a fixture of tasks distributed across columns renders correct per-stage counts. +- Edge: workflow-defined custom columns (not the default enum) are mapped by trait, not by hardcoded names. +- Edge: completion rate over a range divides done-in-range by entered-in-range, documented and tested for the zero-denominator case. + +#### U8. CSV export +**Goal:** Export any analytics table as CSV. +**Requirements:** R4. +**Dependencies:** U2. +**Files:** `packages/dashboard/src/command-center-csv.ts` (new), export branch in `register-command-center-routes.ts`; export buttons in the area components; tests alongside. +**Approach:** A route variant sets `Content-Type: text/csv` + `Content-Disposition: attachment`. Server-side serialization of the same aggregator output. **Honors `getScopedStore(req)` before aggregation, exactly like U9's JSON endpoints — no cross-project leak via the export path.** No precedent exists — net-new. +**Test scenarios:** +- Happy: token endpoint with `?format=csv` returns well-formed CSV with a header row and the attachment header. +- Edge: values containing commas/quotes/newlines are RFC-4180 quoted. +- Edge: empty result returns header-only CSV, not a 204. +- Security: a project-A request cannot retrieve project-B data via CSV export (mirrors the U9 scoping test). + +#### U9. Analytics API endpoints +**Goal:** Programmatic endpoints backing the view and usable by agents. +**Requirements:** R5. +**Dependencies:** U2, U3, U6a (the `command-center-live.ts` snapshot composer — not the U6 frontend, which breaks the cycle). +**Files:** `packages/dashboard/src/routes/register-command-center-routes.ts` (new), registered in `packages/dashboard/src/routes.ts` near the other registrars (~:1991); tests in `packages/dashboard/src/__tests__/`. +**Approach:** `GET /api/command-center/{tokens,tools,activity,productivity}` (range + group-by params), +`GET /api/command-center/live` (snapshot), all thin adapters over Phase A aggregators. **Verify the +Vite proxy:** confirm `vite.config.ts`'s negative-lookahead `/api` proxy routes these to the +backend while leaving app source modules on Vite — `curl` both a real endpoint and a `?import` +source path. **Auth:** all routes inherit the dashboard's standard session/auth middleware via the +`ApiRouteRegistrar` (same as `register-usage-routes.ts`); machine/agent callers use the existing +credential model — **no analytics endpoint, including `/live`, is unauthenticated**, and every +endpoint (JSON, `/live`, and the CSV variant) applies `getScopedStore(req)` before aggregation. +**Patterns to follow:** `register-usage-routes.ts` (registrar shape), `ApiRoutesContext` in `routes/types.ts`. +**Test scenarios:** +- Happy: each endpoint returns the aggregator output with correct shape for a fixture DB. +- Edge: missing/invalid range params default to a documented window (e.g. last 7d), not a 500. +- Security: an unauthenticated request to each endpoint (including `/live`) returns 401. +- Security: project scoping — `getScopedStore(req)` is honored on the JSON and `/live` endpoints so cross-project data does not leak. +- Integration (proxy): real endpoint proxies to backend; a same-prefix `.ts?import` source path stays on Vite. + +#### U10. OpenTelemetry (OTLP) metrics export +**Goal:** Export the metrics over OTLP so teams can ship to Datadog/Grafana/etc. +**Requirements:** R4. +**Dependencies:** U2, U3. +**Files:** `packages/core/src/otel-metrics.ts` (new), wiring in the dashboard server (opt-in via config/env), changeset; tests alongside. Adds an OTel SDK dependency (changeset + sign-off). +**Approach:** Map aggregator outputs to OTLP metric instruments (counters/gauges) on a periodic +export, endpoint + headers from config. Disabled by default. **The endpoint is validated on write +(https-only in production; warn loudly on http); auth headers (Datadog/Grafana tokens) are stored +via the same secret-storage strategy as other credentials and are never logged or included in +diagnostic output.** +**Test scenarios:** +- Happy: with an OTLP collector stub, token/cost/activity metrics are exported with expected + metric names + attributes (model, node, provider). +- Edge: disabled by default — no exporter starts without explicit config. +- Security: an `http://` endpoint emits a warning; auth header values are redacted from any log output. +- Error: collector unreachable logs and backs off; it never crashes the server or blocks requests. + +### Phase C — Software-delivery-loop gap-fill + +> **Scope note:** Phase C closes the Signal/Triage/Monitor/Knowledge gaps. +> Per the user's request these are specified as buildable units, but **U11 and U14 are +> each large enough to merit their own `ce-brainstorm` before execution** — they are flagged +> inline. Sequence Phase C after Phases A–B ship. + +#### U11. External signal ingestion (Sentry / Datadog / PagerDuty / webhook) +**Goal:** Ingest signals beyond GitHub into triageable tasks via a common adapter seam. +**Requirements:** R7, KTD8. +**Dependencies:** none (independent of the Command Center); benefits from U13. +**Files:** `packages/dashboard/src/signal-source.ts` (adapter interface + registry — mirrors the GitHub path, lives in dashboard), `packages/dashboard/src/signal-sources/{sentry,datadog,pagerduty,webhook}.ts`, `packages/dashboard/src/routes/register-signal-routes.ts` (inbound webhooks), config/settings entries; tests alongside. +**Approach:** A `SignalSource` interface (`verify(req)`, `normalize(payload) → Signal`) mirroring +the GitHub source path. The normalized `Signal` includes a **`groupingKey`** populated from the +provider's native primitive (Sentry `issue.id`, PagerDuty `incident.id`, …) for U13's storm guard; +the generic webhook requires the caller to supply one or falls back to `source + normalized-title`. +Inbound webhooks land normalized `Signal`s that create tasks in a `signal`/`triage` column. Each +provider is a thin adapter. +**Security (mandatory, not deferred to the brainstorm):** every adapter's `verify(req)` performs +HMAC signature verification against a per-provider secret stored in encrypted settings/env (never +source-controlled); a missing or invalid secret rejects with 401 — **the generic webhook is never +an unauthenticated task-creation endpoint.** Add a replay window (reject timestamps outside ±5 min) +plus delivery-id nonce dedup; treat any URLs in payloads as SSRF-untrusted. Enforce a request body +size cap (~1 MB), per-source rate limiting, and field-length caps on normalized `Signal` fields; +`meta` JSON from external sources is stored as data and never rendered as raw HTML in the dashboard. +**Patterns to follow:** `github-source-issue-close.ts`, `github-webhooks.ts`, `github-poll.ts`. +**Execution note:** Characterize the existing GitHub ingestion path first, then factor the +shared seam — do not break GitHub ingestion while generalizing it. +**Flag:** Candidate for its own brainstorm (provider auth models, dedup, rate limits differ per provider). **Defer the `SignalSource` registry/interface extraction until a second provider exists** — for the first delivery, implement one provider (generic webhook) as a standalone module mirroring `github-webhooks.ts`, then extract the shared interface once the brainstorm settles the auth/dedup/rate-limit shape and two providers coexist. +**Test scenarios:** +- Happy: a valid Sentry webhook creates one triage task with normalized title/severity/link. +- Security: an unsigned/invalid-signature webhook (including the generic webhook with no secret) is rejected with 401 and creates no task. +- Security: a replayed valid payload (timestamp outside the window or duplicate delivery-id nonce) is rejected. +- Edge: duplicate delivery (same external id) is deduped, not double-created (mirror `github-tracking-dedup.ts`). +- Edge: an oversized payload (>1 MB) is rejected; per-source rate limit caps a flood. +- Error: a malformed payload returns 4xx and creates no task. + +#### U12. Triage stage — auto-classify + decompose, for issues *and* pull requests +**Goal:** Auto-classify incoming signals/issues **and inbound pull requests** and decompose or +route them into board tasks. +**Requirements:** R8, R14, KTD7. +**Dependencies:** U11; reuses existing breakdown + GitHub PR ingestion. +**Files:** a `triage` trait/handler via the workflow-extension system, `packages/dashboard/src/subtask-breakdown.ts` reuse, PR-source wiring near `github-poll.ts`/`github-webhooks.ts`; tests alongside. +**Approach:** A triage column trait runs a classify+decompose pass (priority/area/labels), using +the existing subtask-breakdown machinery, then routes to `todo`. Express as a `Trait` with an +`onEnter` hook (see `CONCEPTS.md` Trait), not a hardcoded branch. **PRs:** inbound PRs (external +contributors, dependabot) are classified and either labeled/routed for review or used to open a +follow-up task; PR triage reuses the `pull_requests` / PR-entity model rather than minting issues. +**Patterns to follow:** `subtask-breakdown.ts`, `mission-interview.ts`, `github-webhooks.ts`, the Trait/Workflow-Extension model. +**Test scenarios:** +- Happy (issue): a signal-created task entering `triage` is classified and decomposed into N todo tasks linked back to the signal. +- Happy (PR): an inbound PR is classified (e.g. dependency-bump vs feature) and routed to review or a follow-up task, linked to its PR entity. +- Edge: a signal too small to decompose passes through as a single task, not zero. +- Edge: a PR Fusion itself opened is **not** re-triaged as inbound (no self-loop). +- Error: classifier failure parks the item in triage with a diagnostic, does not drop it. + +#### U13. Monitor stage (deployments, incidents, MTTR) — closes the loop +**Goal:** Track deployments and production incidents, compute MTTR, and feed Monitor signals +back to Signal/Triage. +**Requirements:** R9, KTD7. +**Dependencies:** U11 (signals), U2 (so MTTR surfaces in the Command Center). +**Files:** `packages/dashboard/src/monitor-routes.ts`, a `deployments`/`incidents` table (db.ts + migration), a `monitor` column trait, MTTR aggregation in `activity-analytics.ts`, Command Center surfacing; tests alongside. +**Approach:** Record deploys (from CI/Ship events) and incidents (from U11 signals). MTTR = +incident-open → incident-resolved. A `monitor` trait watches post-ship and can auto-open a +fix task on a regression signal, closing the loop back to Triage. **Storm/dedup guard (required — +production signals are bursty):** grouping requires a **`groupingKey`** that each U11 adapter's +`normalize()` populates from its provider's native primitive (Sentry `issue.id`/`event.fingerprint`, +PagerDuty `incident.id`, Datadog monitor/aggregation key) — there is no Fusion error-fingerprint +concept, and the content-hash `computeContentFingerprint` (task title/description) is wrong for +bursty alerts. The **generic webhook has no native key**: require the caller to supply one, else +fall back to `source + normalized-title` with a documented coarser cooldown. With the key: a +threshold/sustained-duration gate precedes task creation; a cooldown attaches re-firing signals to +the existing fix task (reuse `findLatestByDedupeKey`); a circuit-breaker caps auto-created tasks per +window; a Fusion-opened fix task never re-triggers (no self-loop, mirroring U12). **Deploy/incident +ingestion auth:** the CI→`monitor-routes` endpoint requires a shared secret / bearer token (stored +in encrypted settings, never unauthenticated, 401 on missing/invalid), and payload URLs are +SSRF-untrusted — mirroring U11. The MTTR aggregator lives in `activity-analytics.ts` +(`packages/core`); deployment/incident recording and `monitor-routes.ts` live in +`packages/dashboard/src` (the aggregator is the core seam the route consumes). +**Patterns to follow:** `reliability-metrics.ts` (metric aggregation + endpoint), KTD7 traits. +**Test scenarios:** +- Happy: an incident opened then resolved yields a correct MTTR in the Monitor metrics. +- Integration: a post-ship error signal auto-creates a single linked fix task in triage (loop closure). +- Storm: a 100-event burst sharing one `groupingKey` yields exactly one fix task; a flapping alert yields no new task; an already-open fix task absorbs repeat signals. +- Edge: the generic webhook with no supplied grouping key falls back deterministically (source + normalized-title), not per-event. +- Security: an unauthenticated deploy/incident POST to `monitor-routes` returns 401 and records nothing. +- Edge: an unresolved incident contributes to "open incidents," not to MTTR. +- Edge: deploy with no following incident counts toward deploy frequency / change-fail rate denominator. + +#### U14. Persistent knowledge index +**Goal:** A persistent, incrementally-refreshed knowledge layer downstream agents can query. +**Framing:** this is a *delta* over the existing `insights`/`memoryView` surfaces, which already +provide part of this — characterize what they lack before building. If the delta is small, extend +those surfaces rather than introducing a greenfield store; the new-table spec below applies only if +the brainstorm concludes a separate store is warranted. +**Requirements:** R10. +**Dependencies:** none; integrates with existing `insights`/`memoryView`. +**Files:** `packages/dashboard/src/knowledge-index.ts` (mirrors `insights-routes.ts` — lives in dashboard), a knowledge store table (`db.ts` + migration), refresh hook on task completion, a dashboard surface reusing the `memoryView`/`InsightsView` patterns; tests alongside. +**Approach:** Index repo + task/PR history into queryable knowledge pages, refreshed +incrementally on task completion (not full re-index). Expose a query API agents can call — +**under the same session/auth middleware and `getScopedStore(req)` scoping as U9** (the index +holds sensitive repo/commit/PR content, so it is an information-disclosure surface, not an open +endpoint). +**Patterns to follow:** `InsightsView.tsx` + `insights-routes.ts`, the `memoryView` experimental flag. +**Flag:** Candidate for its own brainstorm (indexing strategy, storage/embedding choice, refresh cost). +**Test scenarios:** +- Happy: completing a task adds/updates a knowledge page; a keyword query returns it. +- Edge: incremental refresh updates only affected pages, not the whole index (assert unaffected pages' timestamps unchanged). +- Integration: an agent query endpoint returns relevant pages for a known fixture. +- Security: an unauthenticated query returns 401; a project-A caller cannot retrieve project-B pages (mirrors U9 scoping). + +#### U17. Fusion Model Router +**Goal:** Automatic per-task / per-request model selection across providers, optimizing +cost/latency while preserving frontier quality on hard work. +**Requirements:** R13, KTD9. +**Dependencies:** U1 (telemetry to measure savings), U3 (pricing); independent of the view UI. +**Files:** `packages/core/src/model-router.ts` (routing policy + rule evaluation), wiring into +the effective-agent / model-pair resolution path, a router config/settings surface, a Command +Center readout of router decisions + realized savings; tests alongside. +**Approach:** Per KTD9 — a selection layer, not an executor. **Session-level routing only for this +unit:** pick the `(provider, model)` pair at session start. *Per-request mid-session re-routing is +deferred* (it needs its own design pass on streaming continuity, context-window compatibility, and +prompt-cache invalidation — see Deferred). **Routing signal (load-bearing, must be settled before +build):** no structured `complexity`/`difficulty` field exists on tasks or steps today, and prompt +size alone is a weak proxy (short-but-hard vs long-but-boilerplate). The classifier signal must be +defined and validated against real Fusion task data, and paired with a **quality guardrail** +(escalation/retry to the strong tier on cheap-tier failure) and a **quality-regression metric** — +not only the cost-savings readout — so the router cannot report savings while silently degrading +output. **The gate is exitable two ways:** the unit does not ship until the brainstorm produces a +validated signal, OR it ships a deliberately-conservative v0 that routes only an allowlist of +mechanical traits (dependabot bumps, lint-only fixes) to the cheap tier and everything else to the +default pair. It must NOT be read as "build the full classifier now" with prompt-size as the de +facto signal. **Resolution lanes:** enumerate which lanes the router governs (execution, planning, +validation, …; `model-resolution.ts` exposes a distinct resolver per lane) and test each — it must +neither leak into ungoverned lanes nor return a forbidden pair in any governed lane. Respects +column-agent overrides and org/project/user model controls (cannot pick a restricted model). Safe +fallback to the configured default pair when disabled or a pick is unavailable. Emits its decisions +(including the counterfactual model that *would* have run) to U1 so the Command Center can show +adoption and realized cost delta versus always-premium. +**Patterns to follow:** the Effective-agent / Workflow-Setting model-lane resolution and +`model-resolution.ts` lanes; `runtime-provider-probes.ts` for provider availability. +**Execution note:** Implement the resolution-seam integration test-first — routing must never +hand back a pair the model controls forbid. +**Flag:** Candidate for its own brainstorm — the routing signal and quality guardrail are +load-bearing and unproven, making this at least as design-heavy as U11/U14. It is also the most +strategy-aligned new capability after the Command Center (it directly expresses the model-agnostic +thesis and feeds `ecosystem breadth`), so it should not be deferred or rejected alongside the +competitor-parity units — elevate it on its own merits. +**Test scenarios:** +- Happy: a routine step routes to the cheap tier; a deep-reasoning task routes to the strong tier. +- Edge: a column-agent `override` binding wins over the router (router defers). +- Security/governance: a model restricted by project policy is never selected, even if it scores best. +- Edge: router disabled → resolution is byte-identical to today's default-pair behavior (no regression). +- Integration: router decisions appear in `usage_events` and the Command Center shows realized cost savings vs premium-only. + +#### U18. PR review-comment auto-resolution (surface + harden the Review-response loop) +**Goal:** Make automatic resolution of PR review comments a first-class, surfaced capability +built on the existing Review-response loop. +**Requirements:** R15, KTD10. +**Dependencies:** none (extends existing PR-entity + review-response machinery); benefits from U6b. +**Files:** wiring/config around the existing Review-response loop — the real entry point is +`packages/engine/src/pr-response-run.ts` (plus the `ce-resolve-pr-feedback` skill), **not** a +`pr-comment-resolver` module by that name — and the PR entity (`CONCEPTS.md` PR entity, +Review-response loop); Command Center / Mission-Control surfacing of in-flight resolutions; tests +alongside. Note the `packages/engine` home of the loop. +**Approach:** Per KTD10 — do not rebuild the loop. Ensure it triggers on PR-entity review +threads (human + bot), is gated consistently with the auto-merge model, and exposes its +activity (threads acted on, fixed vs disagreed) to the Command Center. Make default-on behavior +explicit and configurable. +**Patterns to follow:** the existing `ce-resolve-pr-feedback` skill seam, `pr-conflict-resolver.ts`, the Review-response loop description in `CONCEPTS.md`. +**Test scenarios:** +- Happy: a new review thread dispatches a resolver that fixes, pushes to the PR branch, and replies to the thread. +- Edge: the resolver disagrees → posts reasoning and leaves the thread open (no silent push). +- Edge: auto-resolution respects the auto-merge gate (disabled → resolves but does not merge). +- Integration: in-flight resolutions appear in Mission Control and counts roll into Command Center metrics. + +--- + +## Scope Boundaries + +**In scope:** The Command Center (combined historical analytics + live Mission Control, +including an External Signals metric area), its metrics foundation, export (CSV + OTel), the +Analytics API, and buildable units for every SDLC gap (Signal ingestion, Triage of issues +**and PRs**, Monitor, Knowledge, the **Fusion Model Router**, +and **auto-resolution of PR review comments**). + +### Deferred to Follow-Up Work +- **Per-unit brainstorms for U11 and U14** before execution — each has substantial design + surface (provider auth/dedup; indexing/embedding strategy) that this + plan scopes but does not fully resolve. +- **Per-request mid-session model re-routing (U17)** — this unit ships session-level routing only; + per-request re-routing needs its own design pass on streaming continuity, context-window + compatibility, and prompt-cache invalidation. +- **Recharts (or any chart-lib) adoption** — only if KTD4's hand-rolled approach proves + impractical for a needed chart type; would be a separate changeset + sign-off. +- **Human "Users" analytics** — Fusion's notion of a human user is thin (`assigneeUserId`); + the Users/per-person area is modeled here as **per-agent**. A per-human breakdown waits until + multi-user (the `Pluggable multi-user` track in `STRATEGY.md`) lands. + +### Out of scope +- Replacing or forking the workflow runtime — Phase C attaches to it via traits/extensions. +- A URL router for the dashboard — the `?view=` + `localStorage` model is preserved. + +--- + +## Risks & Dependencies + +- **SCHEMA_VERSION migration trap (high).** U1/U13/U14 add tables. `applyMigration`, + `SCHEMA_VERSION` (currently 117), `MIGRATION_ONLY_TABLE_SCHEMAS`, and `SCHEMA_COMPAT_FINGERPRINT` + all live in `packages/core/src/db.ts`, **not** `db-migrate.ts` (the legacy-data path). Every + `applyMigration(N)` **must** bump `SCHEMA_VERSION` to N in the same change, or the migrate loop early-returns and + the migration silently never runs on already-upgraded DBs (fresh DBs mask it). Also update + `MIGRATION_ONLY_TABLE_SCHEMAS`/`SCHEMA_COMPAT_FINGERPRINT`, add a **seed-at-previous-version** + migration test, and run the version-literal sweep across **plugin** workspaces too, not just + `packages/`. (`docs/solutions/database-issues/schema-version-constant-must-equal-highest-migration.md`.) +- **Vite `/api` proxy regex (medium).** New endpoints must be verified against the + negative-lookahead proxy in `vite.config.ts` so app source modules aren't proxied; `curl` + both a real endpoint and a `?import` source path. (`docs/solutions/integration-issues/vite-api-source-modules-proxied-to-backend.md`.) +- **CSS IACVT token trap (high).** Chart/loader animations must use `--duration-*` tokens, not + `--transition-*` (which are duration+easing pairs); misuse silently drops the whole + declaration. Extend `animation-duration-tokens.css.test.ts`; verify in a real browser. + (`docs/solutions/ui-bugs/css-animation-frozen-by-transition-token-shape-mismatch.md`.) +- **SWR identity-reset trap (medium).** View state keyed on revalidated array identity resets + every poll tick; key on derived semantic values. (`docs/solutions/ui-bugs/skill-autocomplete-highlight-reset-on-swr-revalidation.md`.) +- **Browser verification hazard (process).** Verify with `fn dashboard --dev` on a **free, + non-4040** port with `FUSION_CLIENT_DIR=$PWD/packages/dashboard/dist/client` after a fresh + build; never `fn daemon`/`fn serve` (engine + shared DB). If a chart renders empty, check the + served bundle hash first. (`docs/solutions/developer-experience/browser-testing-dashboard-from-worktree-safely.md`; aligns with the port-4040 kill-guard.) +- **Phase C breadth.** Three units are brainstorm-candidates; do not let Phase C block the + Command Center shipping from Phases A–B. + +--- + +## Sources & Research + +- **External analytics product** (six measurement areas — tokens, tools, activity, productivity, + users, agent readiness — plus CSV/OTel/API): factory.ai/news/factory-analytics. +- **External model router** (per-task/per-request auto model selection, ~20–25% cost reduction, + respects org/project/user model controls): docs.factory.ai/web/factory-router → grounds R13/U17. +- **External end-to-end delivery loop + mission-control framing**: factory.ai homepage + (Signal→Triage→Plan→Execute→Validate→Ship→Monitor) and that product's release notes + (mission control, sessions, knowledge wiki, computer use, missions, subagents). +- The X thread that prompted this work (x.com/factoryai/status/2066588050617249904) was + paywalled (HTTP 402); its subject was reconstructed from the public pages above. +- **Fusion grounding**: `STRATEGY.md` (key metrics), `CONCEPTS.md` (Column/Trait/Workflow + Extension, Effective agent, Task lifecycle), and repo research into `packages/dashboard` + + `packages/core` (data model, view registration, `ApiRouteRegistrar`, existing + `ReliabilityView`/`AgentTokenStatsPanel`/`agent-token-usage.ts`). +- **Institutional learnings**: the six `docs/solutions/` entries cited in Risks. + +--- + +## Deferred / Open Questions + +### From 2026-06-15 review + +These are genuine forks the review surfaced that depend on your priorities — left open rather than +decided here. (The factual/feasibility/security findings from the same review were applied inline.) + +- **Plan scope — ship A–B alone, or bundle Phase C?** Three reviewers flagged that Phases A–B + (the Command Center) have a clean, strategy-aligned premise, while Phase C's stages were derived + from a competitor's feature set rather than observed Fusion user pain, and bundling them means + approving the dashboard implicitly blesses the broader SDLC-platform direction. Options: (a) ship + A–B as the plan of record and split Phase C into its own strategy-grounded brainstorm; (b) keep + one plan but state explicitly that approving it is not approving Phase C's direction; (c) proceed + as one plan (current state, per your "build all gaps" instruction). *No change made — your call.* +- **Positioning: neutral orchestrator vs opinionated delivery system.** An opinionated + Signal→…→Monitor pipeline (Monitor stage, MTTR, role/lifecycle features) pulls against + `STRATEGY.md`'s "neutral by design, plugin ecosystem" thesis. Should the Monitor/Signal/Knowledge + stages be core product surface or live in the plugin ecosystem the strategy names as its + extension mechanism? +- **`usage_events` table vs lazy-materialization (KTD3 / U1).** The plan now notes both; the + architecture choice (always-on events table + multi-path instrumentation, vs a cache table + materialized on first query) is unresolved. R1/R2/R5 state no sub-second requirement, which keeps + lazy-materialization on the table. +- **`usage_events` field-carrying mechanism (U1).** `appendAgentLog`/`appendRunLog` have DB handles + but their signatures (and `AgentLogEntry`) carry none of `model/provider/nodeId/category`. The + plan proposes a dedicated `emitUsageEvent` call from the session layer; the alternatives are + widening the log signatures (~20+ call sites) or a per-write DB lookup. **Resolved (2026-06-15): + dedicated `emitUsageEvent(...)` call from the executor/session layer — do not widen the log method + signatures.** +- **OTel export (U10) timing.** CSV (U8) already satisfies R4 for the Command Center's developer + audience; OTLP targets an ops team running a collector. Build now, or defer U10 until a concrete + consumer exists (avoids adding the OTel SDK dependency for a default-disabled feature)? +- **Mission Control placement (D2).** Dedicated tab only (polling stops when inactive), a + persistent live strip across all tabs (SSE always subscribed), or embedded in Overview? Changes + the polling architecture U6 implements. +- **Date-range picker affordance (D3).** Preset labels/windows, calendar vs free-text custom range, + explicit Apply vs update-on-select, and the in-flight refetch state per area. +- **Mobile layout of dense charts (D4).** How charts/tabs reflow on narrow/landscape phones + (collapse to sparklines, hide behind a toggle, horizontal-scroll tab strip) — the mobile + breakpoint includes landscape (`max-height: 480px`). +- **AgentTokenStatsPanel consolidation (D5).** Deprecate it once the Tokens tab ships, keep it as a + linked inline summary, or keep it standalone with explicitly different scope (lifetime vs + windowed) — and document which data source each uses so the numbers don't silently diverge. From ab9fdc4136ba4b14db29c100128e52b52b7bee43 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 15 Jun 2026 19:20:38 -0700 Subject: [PATCH 02/21] =?UTF-8?q?feat(telemetry):=20U1=20=E2=80=94=20query?= =?UTF-8?q?able=20usage=5Fevents=20table=20+=20emitUsageEvent=20capture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema migration 117→118 adds usage_events; events captured via a dedicated emitUsageEvent seam wired through AgentLogger tool hooks + executor session context (model/provider/nodeId), not by widening log signatures. meta is size-capped and carries only non-sensitive descriptors. --- .../core/src/__tests__/db-migrate.test.ts | 30 +- packages/core/src/__tests__/db.test.ts | 44 +-- .../core/src/__tests__/goals-schema.test.ts | 2 +- .../core/src/__tests__/insight-store.test.ts | 10 +- .../__tests__/merge-request-record.test.ts | 2 +- .../core/src/__tests__/mission-store.test.ts | 2 +- packages/core/src/__tests__/run-audit.test.ts | 4 +- .../src/__tests__/store-merge-queue.test.ts | 2 +- .../core/src/__tests__/task-documents.test.ts | 2 +- .../core/src/__tests__/usage-events.test.ts | 204 +++++++++++++ packages/core/src/db.ts | 57 +++- packages/core/src/index.ts | 13 + packages/core/src/store.ts | 17 ++ packages/core/src/usage-events.ts | 280 ++++++++++++++++++ .../engine/src/__tests__/agent-logger.test.ts | 92 ++++++ packages/engine/src/agent-logger.ts | 77 +++++ packages/engine/src/executor.ts | 11 + .../src/store/__tests__/roadmap-store.test.ts | 4 +- 18 files changed, 801 insertions(+), 52 deletions(-) create mode 100644 packages/core/src/__tests__/usage-events.test.ts create mode 100644 packages/core/src/usage-events.ts diff --git a/packages/core/src/__tests__/db-migrate.test.ts b/packages/core/src/__tests__/db-migrate.test.ts index 3b97bdf49..5f91c3094 100644 --- a/packages/core/src/__tests__/db-migrate.test.ts +++ b/packages/core/src/__tests__/db-migrate.test.ts @@ -715,7 +715,7 @@ describe("schema migration", () => { const row = db.prepare("SELECT deletedAt FROM tasks WHERE id = 'FN-legacy'").get() as { deletedAt: string | null }; expect(row.deletedAt).toBeNull(); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); db.close(); }); @@ -748,7 +748,7 @@ describe("schema migration", () => { { id: "WS-001", mode: "prompt", gateMode: "advisory" }, { id: "WS-002", mode: "script", gateMode: "advisory" }, ]); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); db.close(); }); @@ -798,7 +798,7 @@ describe("schema migration", () => { reviewerContextRetryCount: 0, reviewerFallbackRetryCount: 0, }); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); db.close(); }); @@ -827,7 +827,7 @@ describe("schema migration", () => { const columns = db.prepare("PRAGMA table_info(milestones)").all() as Array<{ name: string }>; expect(columns.map((column) => column.name)).toContain("acceptanceCriteria"); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); db.close(); }); @@ -868,7 +868,7 @@ describe("schema migration", () => { const missionColumns = db.prepare("PRAGMA table_info(missions)").all() as Array<{ name: string }>; expect(missionColumns.map((column) => column.name)).toContain("autoMerge"); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); db.close(); }); @@ -902,7 +902,7 @@ describe("schema migration", () => { { id: "WS-002", mode: "script", enabled: 1, gateMode: "advisory" }, { id: "WS-003", mode: "prompt", enabled: 0, gateMode: "advisory" }, ]); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); db.close(); }); @@ -939,7 +939,7 @@ describe("schema migration", () => { const indexes = db.prepare("PRAGMA index_list(mission_goals)").all() as Array<{ name: string }>; expect(indexes.some((index) => index.name === "idxMissionGoalsGoalId")).toBe(true); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); db.close(); }); @@ -1000,7 +1000,7 @@ describe("schema migration", () => { expect(customFieldsColumn).toBeDefined(); expect(customFieldsColumn?.dflt_value).toBe("'{}'"); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); db.close(); }); @@ -1038,7 +1038,7 @@ describe("schema migration", () => { const indexes = db.prepare("PRAGMA index_list(workflow_settings)").all() as Array<{ name: string }>; expect(indexes.some((index) => index.name === "idx_workflow_settings_project")).toBe(true); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); db.close(); }); @@ -1120,7 +1120,7 @@ describe("schema migration", () => { expect(indexNames).toContain("idx_cli_sessions_chatSessionId"); expect(indexNames).toContain("idx_cli_sessions_project_state"); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); db.close(); }); @@ -1152,7 +1152,7 @@ describe("schema migration", () => { .all() as Array<{ name: string }>; expect(columns.map((column) => column.name)).toContain("cliExecutorAdapterId"); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); db.close(); }); @@ -1162,7 +1162,7 @@ describe("schema migration", () => { const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>; expect(tables.map((row) => row.name)).toContain("cli_sessions"); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); db.close(); }); @@ -1219,20 +1219,20 @@ describe("schema migration", () => { .get() as { migrated_fragment_id: string | null }; expect(stepRow.migrated_fragment_id).toBeNull(); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); db.close(); }); it("migration 109 is idempotent on re-init", () => { const db = new Database(fusionDir); db.init(); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); db.close(); // Re-open the same on-disk DB: already at 109, the 109 block must be a no-op. const reopened = new Database(fusionDir); reopened.init(); - expect(reopened.getSchemaVersion()).toBe(117); + expect(reopened.getSchemaVersion()).toBe(118); const workflowColumns = reopened.prepare("PRAGMA table_info(workflows)").all() as Array<{ name: string }>; expect(workflowColumns.filter((c) => c.name === "kind")).toHaveLength(1); const stepColumns = reopened.prepare("PRAGMA table_info(workflow_steps)").all() as Array<{ name: string }>; diff --git a/packages/core/src/__tests__/db.test.ts b/packages/core/src/__tests__/db.test.ts index d66104100..aa49d59aa 100644 --- a/packages/core/src/__tests__/db.test.ts +++ b/packages/core/src/__tests__/db.test.ts @@ -334,7 +334,7 @@ describe("Database", () => { }); it("seeds schema version", () => { - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); }); it("includes tokenUsageCacheWriteTokens on freshly initialized tasks table", () => { @@ -393,7 +393,7 @@ describe("Database", () => { it("is idempotent - calling init() twice does not fail", () => { expect(() => db.init()).not.toThrow(); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); }); it("does not overwrite existing config on re-init", () => { // Update the config @@ -1463,7 +1463,7 @@ describe("schema migrations", () => { db.init(); // Verify version bumped to 29 (includes v1→v2 through v26→v29) - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); // Verify new columns exist and existing data is intact const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; @@ -1488,15 +1488,15 @@ describe("schema migrations", () => { const db = new Database(fusionDir); db.init(); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); // Re-init should not fail db.init(); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); // Re-init should not fail db.init(); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); db.close(); }); @@ -1531,7 +1531,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; expect(cols.map((col) => col.name)).toContain("priority"); @@ -1572,7 +1572,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const colNames = cols.map((col) => col.name); @@ -1644,7 +1644,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const colNames = cols.map((col) => col.name); @@ -1884,7 +1884,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>; expect(cols.map((col) => col.name)).toContain("attachments"); @@ -1958,7 +1958,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>; expect(tables).toEqual([{ name: "agentRatings" }]); @@ -1982,7 +1982,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>; expect(tables).toEqual([{ name: "mission_events" }]); @@ -2086,7 +2086,7 @@ describe("schema migrations", () => { db.init(); // Verify version bumped to 29 - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); // Verify new columns exist and existing data is intact const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; @@ -2305,7 +2305,7 @@ describe("schema migrations", () => { localDb.init(); - expect(localDb.getSchemaVersion()).toBe(117); + expect(localDb.getSchemaVersion()).toBe(118); const columns = localDb.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; expect(columns.map((column) => column.name)).toContain("tokenUsageCacheWriteTokens"); @@ -2616,7 +2616,7 @@ describe("createDatabase factory", () => { const db = createDatabase(fusionDir); db.init(); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); expect(db.getLastModified()).toBeGreaterThan(0); db.close(); @@ -2770,7 +2770,7 @@ describe("migration v77 task token budget columns", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(117); + expect(migrated.getSchemaVersion()).toBe(118); const rows = migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const names = new Set(rows.map((row) => row.name)); expect(names.has("tokenBudgetSoftAlertedAt")).toBe(true); @@ -2801,7 +2801,7 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => { const fresh = new Database(fusion); try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(117); + expect(fresh.getSchemaVersion()).toBe(118); const names = new Set( (fresh.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name), ); @@ -2829,7 +2829,7 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(117); + expect(migrated.getSchemaVersion()).toBe(118); const names = new Set( (migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name), ); @@ -2855,7 +2855,7 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => { const fresh = new Database(fusion); try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(117); + expect(fresh.getSchemaVersion()).toBe(118); const table = fresh .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'") .get() as { name: string } | undefined; @@ -2889,7 +2889,7 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(117); + expect(migrated.getSchemaVersion()).toBe(118); const table = migrated .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'") .get() as { name: string } | undefined; @@ -2930,7 +2930,7 @@ describe("migration v67 drops orphan project auth tables", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(117); + expect(migrated.getSchemaVersion()).toBe(118); const tables = migrated .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'") .all() as Array<{ name: string }>; @@ -2957,7 +2957,7 @@ describe("migration v67 drops orphan project auth tables", () => { try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(117); + expect(fresh.getSchemaVersion()).toBe(118); const tables = fresh .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'") .all() as Array<{ name: string }>; diff --git a/packages/core/src/__tests__/goals-schema.test.ts b/packages/core/src/__tests__/goals-schema.test.ts index c18b43ff6..75cf8c431 100644 --- a/packages/core/src/__tests__/goals-schema.test.ts +++ b/packages/core/src/__tests__/goals-schema.test.ts @@ -91,6 +91,6 @@ describe("goals schema", () => { }); it("reports schema version 101", () => { - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); }); }); diff --git a/packages/core/src/__tests__/insight-store.test.ts b/packages/core/src/__tests__/insight-store.test.ts index 49fb26341..84f57c78a 100644 --- a/packages/core/src/__tests__/insight-store.test.ts +++ b/packages/core/src/__tests__/insight-store.test.ts @@ -1000,7 +1000,7 @@ describe("Migration: pre-33 DB upgrade", () => { // Step 1: Create a fresh database at v33 (runs all migrations up to 33) const db1 = createDatabase(legacyDir); db1.init(); - expect(db1.getSchemaVersion()).toBe(117); + expect(db1.getSchemaVersion()).toBe(118); db1.close(); // Step 2: Manually downgrade to version 32 and drop insight tables @@ -1035,7 +1035,7 @@ describe("Migration: pre-33 DB upgrade", () => { expect(tableNamesBefore).not.toContain("project_insight_runs"); // Now run init — this triggers the v32→v33 migration db3.init(); - expect(db3.getSchemaVersion()).toBe(117); + expect(db3.getSchemaVersion()).toBe(118); // Step 4: Verify insight tables exist after migration const tablesAfter = db3.prepare( @@ -1066,12 +1066,12 @@ describe("Migration: pre-33 DB upgrade", () => { try { const db1 = createDatabase(testDir); db1.init(); - expect(db1.getSchemaVersion()).toBe(117); + expect(db1.getSchemaVersion()).toBe(118); db1.close(); const db2 = createDatabase(testDir); expect(() => db2.init()).not.toThrow(); - expect(db2.getSchemaVersion()).toBe(117); + expect(db2.getSchemaVersion()).toBe(118); db2.close(); } finally { rmSync(testDir, { recursive: true, force: true }); @@ -1085,7 +1085,7 @@ describe("Migration: pre-33 DB upgrade", () => { // Step 1: Create a fresh DB and run migrations const db1 = createDatabase(compatDir); db1.init(); - expect(db1.getSchemaVersion()).toBe(117); + expect(db1.getSchemaVersion()).toBe(118); // Step 2: Strip lifecycle and cancelledAt columns by recreating the // table without them. This simulates a DB that was created before the diff --git a/packages/core/src/__tests__/merge-request-record.test.ts b/packages/core/src/__tests__/merge-request-record.test.ts index 0872baa07..f68511ee1 100644 --- a/packages/core/src/__tests__/merge-request-record.test.ts +++ b/packages/core/src/__tests__/merge-request-record.test.ts @@ -38,7 +38,7 @@ describe("TaskStore merge request record + completion handoff marker", () => { .all() as Array<{ name: string }>; expect(tableRows).toEqual([{ name: "completion_handoff_markers" }, { name: "merge_requests" }]); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); }); it("upserts merge request records", async () => { diff --git a/packages/core/src/__tests__/mission-store.test.ts b/packages/core/src/__tests__/mission-store.test.ts index a78da744c..71b9dd675 100644 --- a/packages/core/src/__tests__/mission-store.test.ts +++ b/packages/core/src/__tests__/mission-store.test.ts @@ -3746,7 +3746,7 @@ describe("MissionStore", () => { describe("Loop State & Validator Run Schema (v31)", () => { it("schema version is 101 after migration", () => { - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); }); it("mission_features table has loop state columns", () => { diff --git a/packages/core/src/__tests__/run-audit.test.ts b/packages/core/src/__tests__/run-audit.test.ts index 814b23a1a..68faeb4cd 100644 --- a/packages/core/src/__tests__/run-audit.test.ts +++ b/packages/core/src/__tests__/run-audit.test.ts @@ -583,8 +583,8 @@ describe("Run Audit", () => { expect(indexNames).toContain("idxRunAuditEventsTimestamp"); }); - it("schema version is bumped to 117", () => { - expect(db.getSchemaVersion()).toBe(117); + it("schema version is bumped to 118", () => { + expect(db.getSchemaVersion()).toBe(118); }); }); }); diff --git a/packages/core/src/__tests__/store-merge-queue.test.ts b/packages/core/src/__tests__/store-merge-queue.test.ts index 9a128a7ba..f4311184d 100644 --- a/packages/core/src/__tests__/store-merge-queue.test.ts +++ b/packages/core/src/__tests__/store-merge-queue.test.ts @@ -60,7 +60,7 @@ describe("TaskStore merge queue", () => { expect.arrayContaining(["idx_mergeQueue_lease_ready", "idx_mergeQueue_leaseExpiresAt"]), ); - expect(store.getDatabase().getSchemaVersion()).toBe(117); + expect(store.getDatabase().getSchemaVersion()).toBe(118); }); it("migrates a legacy v88 database and preserves task rows", async () => { diff --git a/packages/core/src/__tests__/task-documents.test.ts b/packages/core/src/__tests__/task-documents.test.ts index b1fa2e2e2..ec9257507 100644 --- a/packages/core/src/__tests__/task-documents.test.ts +++ b/packages/core/src/__tests__/task-documents.test.ts @@ -51,7 +51,7 @@ describe("TaskStore task documents", () => { expect(tableNames.has("task_documents")).toBe(true); expect(tableNames.has("task_document_revisions")).toBe(true); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); const index = db .prepare( diff --git a/packages/core/src/__tests__/usage-events.test.ts b/packages/core/src/__tests__/usage-events.test.ts new file mode 100644 index 000000000..d17587b55 --- /dev/null +++ b/packages/core/src/__tests__/usage-events.test.ts @@ -0,0 +1,204 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { Database, SCHEMA_VERSION } from "../db.js"; +import { + emitUsageEvent, + queryUsageEvents, + countUsageEventsBy, + categorizeToolName, + USAGE_EVENT_META_MAX_BYTES, +} from "../usage-events.js"; + +function makeTmpDir(): string { + return mkdtempSync(join(tmpdir(), "kb-usage-events-test-")); +} + +describe("usage_events", () => { + let tmpDir: string; + let fusionDir: string; + let db: Database; + + beforeEach(() => { + tmpDir = makeTmpDir(); + fusionDir = join(tmpDir, ".fusion"); + db = new Database(fusionDir); + db.init(); + }); + + afterEach(async () => { + db.close(); + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("creates usage_events table with expected columns on fresh init", () => { + const columns = db.prepare("PRAGMA table_info(usage_events)").all() as Array<{ name: string }>; + expect(columns.map((c) => c.name)).toEqual([ + "id", + "ts", + "kind", + "taskId", + "agentId", + "nodeId", + "model", + "provider", + "toolName", + "category", + "meta", + ]); + }); + + it("creates the ts/taskId/agentId indexes on fresh init", () => { + const indexes = ( + db + .prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='usage_events'") + .all() as Array<{ name: string }> + ).map((r) => r.name); + expect(indexes).toContain("idxUsageEventsTs"); + expect(indexes).toContain("idxUsageEventsTaskId"); + expect(indexes).toContain("idxUsageEventsAgentId"); + }); + + it("inserts one row for a tool_call event with correct category", () => { + const ok = emitUsageEvent(db, { + kind: "tool_call", + taskId: "T-1", + agentId: "A-1", + nodeId: "node-1", + model: "claude-sonnet-4-5", + provider: "anthropic", + toolName: "Read", + }); + expect(ok).toBe(true); + + const rows = queryUsageEvents(db, { taskId: "T-1" }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + kind: "tool_call", + taskId: "T-1", + agentId: "A-1", + nodeId: "node-1", + model: "claude-sonnet-4-5", + provider: "anthropic", + toolName: "Read", + }); + }); + + it("categorizes tool names into coarse buckets", () => { + expect(categorizeToolName("Read")).toBe("read"); + expect(categorizeToolName("Grep")).toBe("read"); + expect(categorizeToolName("Edit")).toBe("edit"); + expect(categorizeToolName("Write")).toBe("edit"); + expect(categorizeToolName("Bash")).toBe("execute"); + expect(categorizeToolName("WebFetch")).toBe("network"); + expect(categorizeToolName("Unknown")).toBe("other"); + expect(categorizeToolName(undefined)).toBe("other"); + expect(categorizeToolName(null)).toBe("other"); + }); + + it("rejects a meta payload over the byte cap at write (event skipped, nothing inserted)", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const huge = "x".repeat(USAGE_EVENT_META_MAX_BYTES + 100); + const ok = emitUsageEvent(db, { + kind: "tool_error", + taskId: "T-cap", + meta: { blob: huge }, + }); + expect(ok).toBe(false); + expect(queryUsageEvents(db, { taskId: "T-cap" })).toHaveLength(0); + warn.mockRestore(); + }); + + it("never lets tool-argument content land in meta (caller controls meta; arg helpers are not stored)", () => { + // The write helper only persists what the caller puts in `meta`. A caller + // that follows the contract (descriptors only) leaves no tool args behind. + emitUsageEvent(db, { + kind: "tool_call", + taskId: "T-safe", + toolName: "Bash", + category: "execute", + meta: { durationMs: 12 }, + }); + const rows = queryUsageEvents(db, { taskId: "T-safe" }); + expect(rows).toHaveLength(1); + expect(rows[0].meta).toEqual({ durationMs: 12 }); + // No tool-argument/content fields are present. + const metaKeys = Object.keys(rows[0].meta ?? {}); + expect(metaKeys).not.toContain("command"); + expect(metaKeys).not.toContain("args"); + expect(metaKeys).not.toContain("content"); + }); + + it("skips a malformed event (unknown kind) without throwing", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const ok = emitUsageEvent(db, { + // @ts-expect-error intentionally invalid kind + kind: "not_a_real_kind", + taskId: "T-bad", + }); + expect(ok).toBe(false); + expect(queryUsageEvents(db, { taskId: "T-bad" })).toHaveLength(0); + warn.mockRestore(); + }); + + it("range-queries by inclusive ts bounds, ordered ascending", () => { + emitUsageEvent(db, { kind: "tool_call", taskId: "T-r", toolName: "Read", ts: "2026-01-01T00:00:00.000Z" }); + emitUsageEvent(db, { kind: "tool_call", taskId: "T-r", toolName: "Edit", ts: "2026-01-02T00:00:00.000Z" }); + emitUsageEvent(db, { kind: "tool_call", taskId: "T-r", toolName: "Bash", ts: "2026-01-03T00:00:00.000Z" }); + + const rows = queryUsageEvents(db, { + from: "2026-01-02T00:00:00.000Z", + to: "2026-01-03T00:00:00.000Z", + }); + expect(rows.map((r) => r.toolName)).toEqual(["Edit", "Bash"]); + }); + + it("counts events grouped by a column over a range", () => { + emitUsageEvent(db, { kind: "tool_call", toolName: "Read", category: "read" }); + emitUsageEvent(db, { kind: "tool_call", toolName: "Grep", category: "read" }); + emitUsageEvent(db, { kind: "tool_call", toolName: "Bash", category: "execute" }); + + const byCategory = countUsageEventsBy(db, "category"); + const map = new Map(byCategory.map((r) => [r.key, r.count])); + expect(map.get("read")).toBe(2); + expect(map.get("execute")).toBe(1); + }); + + it("records a chat-style event with null taskId and a set agentId", () => { + emitUsageEvent(db, { kind: "user_message", taskId: null, agentId: "A-chat" }); + const rows = queryUsageEvents(db, { kind: "user_message" }); + expect(rows).toHaveLength(1); + expect(rows[0].taskId).toBeNull(); + expect(rows[0].agentId).toBe("A-chat"); + }); + + // Migration: seed a DB at the PREVIOUS schema version, run migrate, assert + // the table exists and SCHEMA_VERSION equals the highest migration target. + // Fresh-DB tests cannot catch the early-return bug this guards. + it("creates usage_events when migrating from the previous schema version", () => { + db.exec("DROP INDEX IF EXISTS idxUsageEventsTs"); + db.exec("DROP INDEX IF EXISTS idxUsageEventsTaskId"); + db.exec("DROP INDEX IF EXISTS idxUsageEventsAgentId"); + db.exec("DROP TABLE IF EXISTS usage_events"); + db.prepare("UPDATE __meta SET value = ? WHERE key = 'schemaVersion'").run(String(SCHEMA_VERSION - 1)); + + (db as unknown as { migrate: () => void }).migrate(); + + const table = db + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='usage_events'") + .get() as { name: string } | undefined; + expect(table?.name).toBe("usage_events"); + expect(db.getSchemaVersion()).toBe(SCHEMA_VERSION); + + // The migrated table is writable and queryable. + emitUsageEvent(db, { kind: "session_start", taskId: "T-mig", agentId: "A-mig" }); + expect(queryUsageEvents(db, { taskId: "T-mig" })).toHaveLength(1); + }); + + it("SCHEMA_VERSION matches the highest applied migration on a fresh DB", () => { + expect(db.getSchemaVersion()).toBe(SCHEMA_VERSION); + }); +}); diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index 3ac67d7b6..78332279f 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -162,7 +162,7 @@ export function isFts5CorruptionError(error: unknown): boolean { // ── Schema Definition ──────────────────────────────────────────────── -const SCHEMA_VERSION = 117; +const SCHEMA_VERSION = 118; const TASKS_FTS_AUTOMERGE = 8; const TASKS_FTS_CRISISMERGE = 16; @@ -1207,6 +1207,29 @@ CREATE TABLE IF NOT EXISTS todo_items ( CREATE INDEX IF NOT EXISTS idxTodoListsProjectId ON todo_lists(projectId); CREATE INDEX IF NOT EXISTS idxTodoItemsListId ON todo_items(listId); CREATE INDEX IF NOT EXISTS idxTodoItemsSortOrder ON todo_items(listId, sortOrder); + +-- Normalized, queryable telemetry of agent activity (tool calls, messages, +-- session lifecycle). Fed by emitUsageEvent from the executor/session layer so +-- analytics never has to parse per-task JSONL agent logs at query time. +-- The meta column carries only non-sensitive descriptors (error code, +-- category, duration) -- never tool arguments/content/credentials -- and is +-- capped at write (see usage-events.ts). +CREATE TABLE IF NOT EXISTS usage_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TEXT NOT NULL, + kind TEXT NOT NULL, + taskId TEXT, + agentId TEXT, + nodeId TEXT, + model TEXT, + provider TEXT, + toolName TEXT, + category TEXT, + meta TEXT +); +CREATE INDEX IF NOT EXISTS idxUsageEventsTs ON usage_events(ts); +CREATE INDEX IF NOT EXISTS idxUsageEventsTaskId ON usage_events(taskId); +CREATE INDEX IF NOT EXISTS idxUsageEventsAgentId ON usage_events(agentId); `; const TABLE_LEVEL_CONSTRAINT_PREFIXES = new Set([ @@ -4718,6 +4741,38 @@ export class Database { }); } + // Migration 118: Queryable usage_events telemetry table (tool calls, + // messages, session lifecycle). Mirrors the SCHEMA_SQL definition above so + // a fresh-from-SCHEMA_SQL DB and a migrated DB converge on the same table. + if (version < 118) { + this.applyMigration(118, () => { + this.db.exec(` + CREATE TABLE IF NOT EXISTS usage_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TEXT NOT NULL, + kind TEXT NOT NULL, + taskId TEXT, + agentId TEXT, + nodeId TEXT, + model TEXT, + provider TEXT, + toolName TEXT, + category TEXT, + meta TEXT + ) + `); + this.db.exec(` + CREATE INDEX IF NOT EXISTS idxUsageEventsTs ON usage_events(ts) + `); + this.db.exec(` + CREATE INDEX IF NOT EXISTS idxUsageEventsTaskId ON usage_events(taskId) + `); + this.db.exec(` + CREATE INDEX IF NOT EXISTS idxUsageEventsAgentId ON usage_events(agentId) + `); + }); + } + } /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dbe4fbc7c..362127af7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -517,6 +517,19 @@ export { computeRetrySummary, RETRY_STORM_WARNING_RATIO } from "./retry-summary. export { RetryStormError, serializeRetryStormError } from "./retry-storm-error.js"; export { aggregateAgentTokenUsage } from "./agent-token-usage.js"; export type { AgentTokenUsageSummary, AgentTokenUsageWindowSummary } from "./agent-token-usage.js"; +export { + emitUsageEvent, + queryUsageEvents, + countUsageEventsBy, + categorizeToolName, + USAGE_EVENT_META_MAX_BYTES, +} from "./usage-events.js"; +export type { + UsageEvent, + UsageEventInput, + UsageEventKind, + UsageEventRangeQuery, +} from "./usage-events.js"; export { STALLED_REVIEW_REENQUEUE_THRESHOLD, STALLED_REVIEW_INVALID_TRANSITION_THRESHOLD, diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 9917d7f6d..76c88c365 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -142,6 +142,7 @@ import { readAgentLogEntriesByTimeRange, } from "./agent-log-file-store.js"; import { truncateAgentLogDetail } from "./agent-log-constants.js"; +import { emitUsageEvent as emitUsageEventToDb, type UsageEventInput } from "./usage-events.js"; import { validateNodeOverrideChange } from "./node-override-guard.js"; import { sanitizeTitle, summarizeTitle } from "./ai-summarize.js"; import { extractTaskIdTokens, normalizeTitleForTaskId } from "./task-title-id-drift.js"; @@ -11679,6 +11680,22 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} } } + /** + * Append a normalized telemetry row to `usage_events` (tool calls, messages, + * session lifecycle) for the Command Center analytics layer. Callers in the + * executor/session layer pass `model`/`provider`/`nodeId`/`category` from the + * session context (see usage-events.ts / KTD3). + * + * **Fail-soft**: the underlying helper swallows malformed events and write + * errors, so this never throws and never aborts the agent-log write or the + * agent hot path. + * + * @returns `true` if a row was inserted, `false` if the event was skipped. + */ + emitUsageEvent(event: UsageEventInput): boolean { + return emitUsageEventToDb(this.db, event); + } + /** * Flush all buffered agent log entries to per-task JSONL files. * Called when the buffer is full or on a timer. diff --git a/packages/core/src/usage-events.ts b/packages/core/src/usage-events.ts new file mode 100644 index 000000000..a2532df2a --- /dev/null +++ b/packages/core/src/usage-events.ts @@ -0,0 +1,280 @@ +import type { Database } from "./db.js"; + +/** + * Queryable telemetry of agent activity (tool calls, messages, session + * lifecycle), persisted to the `usage_events` table (db.ts schema). This is the + * normalized source the Command Center analytics layer reads from, so it does + * not have to parse per-task JSONL agent logs at query time. + * + * Events are appended via {@link emitUsageEvent} from the executor/session layer + * where `model`/`provider`/`nodeId`/`category` are already in scope (see + * KTD3/U1). The append helper is intentionally fail-soft: a malformed event or a + * write error is swallowed so it never aborts the underlying agent-log write or + * the agent hot path. + */ + +/** + * The kind of activity an event records. + * + * - `tool_call` — an agent invoked a tool (agent-log `type: "tool"` maps here; + * `AgentLogType` has no `tool_call` member). + * - `tool_result` / `tool_error` — the tool completed / failed. + * - `user_message` — a human-authored message (chat/CLI sessions). + * - `session_start` / `session_stop` — session lifecycle. + */ +export type UsageEventKind = + | "tool_call" + | "tool_result" + | "tool_error" + | "user_message" + | "session_start" + | "session_stop"; + +const USAGE_EVENT_KINDS: ReadonlySet = new Set([ + "tool_call", + "tool_result", + "tool_error", + "user_message", + "session_start", + "session_stop", +]); + +/** + * Maximum serialized byte size of a `meta` payload. Events whose `meta` + * exceeds this cap are rejected at write (the whole event is skipped) rather + * than truncated, so an oversized payload can never silently land partial data. + */ +export const USAGE_EVENT_META_MAX_BYTES = 4096; + +/** An event to append to `usage_events`. */ +export interface UsageEventInput { + kind: UsageEventKind; + /** ISO-8601 timestamp. Defaults to now when omitted. */ + ts?: string; + taskId?: string | null; + agentId?: string | null; + /** Workflow/session node this event belongs to; null when no node context. */ + nodeId?: string | null; + model?: string | null; + provider?: string | null; + toolName?: string | null; + category?: string | null; + /** + * Non-sensitive descriptors only (error code, category, duration). NEVER tool + * arguments/content or credential-class fields. Capped at + * {@link USAGE_EVENT_META_MAX_BYTES}; over the cap, the event is rejected. + */ + meta?: Record | null; +} + +/** A row read back from `usage_events`. */ +export interface UsageEvent { + id: number; + ts: string; + kind: UsageEventKind; + taskId: string | null; + agentId: string | null; + nodeId: string | null; + model: string | null; + provider: string | null; + toolName: string | null; + category: string | null; + meta: Record | null; +} + +interface UsageEventRow { + id: number; + ts: string; + kind: string; + taskId: string | null; + agentId: string | null; + nodeId: string | null; + model: string | null; + provider: string | null; + toolName: string | null; + category: string | null; + meta: string | null; +} + +/** + * Coarse tool category derived from a tool name, for the Tools analytics area. + * Pure and side-effect free; callers may also pass an explicit `category`. + */ +export function categorizeToolName(toolName: string | null | undefined): string { + if (!toolName) return "other"; + const name = toolName.toLowerCase(); + if (name === "read" || name === "grep" || name === "glob" || name === "ls" || name.includes("search")) { + return "read"; + } + if (name === "edit" || name === "write" || name === "multiedit" || name.includes("notebook")) { + return "edit"; + } + if (name === "bash" || name.includes("exec") || name.includes("command") || name.includes("terminal")) { + return "execute"; + } + if (name.includes("web") || name.includes("fetch") || name.includes("http")) { + return "network"; + } + return "other"; +} + +/** + * Validate and serialize a `meta` payload. Returns the serialized JSON string, + * or throws if it exceeds the byte cap. `null`/`undefined` serialize to `null`. + */ +function serializeMeta(meta: Record | null | undefined): string | null { + if (meta === undefined || meta === null) return null; + const serialized = JSON.stringify(meta); + if (serialized === undefined) return null; + if (Buffer.byteLength(serialized, "utf8") > USAGE_EVENT_META_MAX_BYTES) { + throw new Error( + `usage_events meta payload exceeds ${USAGE_EVENT_META_MAX_BYTES} bytes (got ${Buffer.byteLength(serialized, "utf8")})`, + ); + } + return serialized; +} + +/** + * Append a single usage event. **Fail-soft**: a malformed event (unknown kind), + * an oversized `meta`, or any DB error is logged and swallowed — it must never + * throw, so it cannot abort the underlying agent-log write or the hot path. + * + * @returns `true` if the row was inserted, `false` if the event was skipped. + */ +export function emitUsageEvent(db: Database, event: UsageEventInput): boolean { + try { + if (!event || !USAGE_EVENT_KINDS.has(event.kind)) { + return false; + } + const ts = event.ts ?? new Date().toISOString(); + const meta = serializeMeta(event.meta); + db.prepare( + `INSERT INTO usage_events + (ts, kind, taskId, agentId, nodeId, model, provider, toolName, category, meta) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + ts, + event.kind, + event.taskId ?? null, + event.agentId ?? null, + event.nodeId ?? null, + event.model ?? null, + event.provider ?? null, + event.toolName ?? null, + event.category ?? null, + meta, + ); + return true; + } catch (err) { + console.warn("[fusion] emitUsageEvent skipped a malformed/failed event:", err); + return false; + } +} + +/** Filters for {@link queryUsageEvents}. All bounds are inclusive. */ +export interface UsageEventRangeQuery { + /** ISO-8601 lower bound (inclusive). */ + from?: string; + /** ISO-8601 upper bound (inclusive). */ + to?: string; + kind?: UsageEventKind; + taskId?: string; + agentId?: string; +} + +function rowToUsageEvent(row: UsageEventRow): UsageEvent { + let meta: Record | null = null; + if (row.meta) { + try { + meta = JSON.parse(row.meta) as Record; + } catch { + meta = null; + } + } + return { + id: row.id, + ts: row.ts, + kind: row.kind as UsageEventKind, + taskId: row.taskId, + agentId: row.agentId, + nodeId: row.nodeId, + model: row.model, + provider: row.provider, + toolName: row.toolName, + category: row.category, + meta, + }; +} + +/** + * Range-scan `usage_events` ordered by timestamp ascending. Mirrors the + * windowed-scan shape of `agent-token-usage.ts`, generalized to an arbitrary + * `(from, to)` range with optional kind/task/agent filters. + */ +export function queryUsageEvents(db: Database, query: UsageEventRangeQuery = {}): UsageEvent[] { + const clauses: string[] = []; + const params: Array = []; + if (query.from !== undefined) { + clauses.push("ts >= ?"); + params.push(query.from); + } + if (query.to !== undefined) { + clauses.push("ts <= ?"); + params.push(query.to); + } + if (query.kind !== undefined) { + clauses.push("kind = ?"); + params.push(query.kind); + } + if (query.taskId !== undefined) { + clauses.push("taskId = ?"); + params.push(query.taskId); + } + if (query.agentId !== undefined) { + clauses.push("agentId = ?"); + params.push(query.agentId); + } + const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""; + const rows = db + .prepare(`SELECT * FROM usage_events ${where} ORDER BY ts ASC, id ASC`) + .all(...params) as UsageEventRow[]; + return rows.map(rowToUsageEvent); +} + +/** + * Count `usage_events` grouped by a single column over a range. Convenience for + * the analytics aggregators (e.g. tool calls by `category`). + */ +export function countUsageEventsBy( + db: Database, + column: "kind" | "category" | "toolName" | "model" | "provider" | "nodeId" | "agentId", + query: UsageEventRangeQuery = {}, +): Array<{ key: string | null; count: number }> { + const clauses: string[] = []; + const params: Array = []; + if (query.from !== undefined) { + clauses.push("ts >= ?"); + params.push(query.from); + } + if (query.to !== undefined) { + clauses.push("ts <= ?"); + params.push(query.to); + } + if (query.kind !== undefined) { + clauses.push("kind = ?"); + params.push(query.kind); + } + if (query.taskId !== undefined) { + clauses.push("taskId = ?"); + params.push(query.taskId); + } + if (query.agentId !== undefined) { + clauses.push("agentId = ?"); + params.push(query.agentId); + } + const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""; + const rows = db + .prepare(`SELECT ${column} AS key, COUNT(*) AS count FROM usage_events ${where} GROUP BY ${column}`) + .all(...params) as Array<{ key: string | null; count: number }>; + return rows; +} diff --git a/packages/engine/src/__tests__/agent-logger.test.ts b/packages/engine/src/__tests__/agent-logger.test.ts index 1503a0909..961abda4f 100644 --- a/packages/engine/src/__tests__/agent-logger.test.ts +++ b/packages/engine/src/__tests__/agent-logger.test.ts @@ -496,4 +496,96 @@ describe("AgentLogger", () => { ); }); }); + + // ── usage_events emission (U1) ───────────────────────────────────── + describe("usage_events emission", () => { + function createUsageStore() { + return { + appendAgentLog: vi.fn().mockResolvedValue(undefined), + emitUsageEvent: vi.fn().mockReturnValue(true), + } as unknown as TaskStore & { emitUsageEvent: ReturnType }; + } + + it("emits a tool_call usage event with model/provider/nodeId on tool start", () => { + const store = createUsageStore(); + const logger = new AgentLogger({ store, taskId: "FN-UE-1", agent: "executor" }); + logger.setUsageContext({ + model: "claude-sonnet-4-5", + provider: "anthropic", + nodeId: "node-x", + agentId: "A-1", + }); + + logger.onToolStart("Read", { path: "secret/credentials.env" }); + + expect(store.emitUsageEvent).toHaveBeenCalledTimes(1); + const event = store.emitUsageEvent.mock.calls[0][0]; + expect(event).toMatchObject({ + kind: "tool_call", + taskId: "FN-UE-1", + agentId: "A-1", + nodeId: "node-x", + model: "claude-sonnet-4-5", + provider: "anthropic", + toolName: "Read", + category: "read", + }); + // The tool-argument content (the file path) MUST NOT appear in meta. + const meta = (event.meta ?? {}) as Record; + expect(JSON.stringify(meta)).not.toContain("credentials.env"); + }); + + it("does not emit usage events when no usage context is set", () => { + const store = createUsageStore(); + const logger = new AgentLogger({ store, taskId: "FN-UE-2" }); + logger.onToolStart("Bash", { command: "ls" }); + expect(store.emitUsageEvent).not.toHaveBeenCalled(); + }); + + it("integration: a session calling 3 tools yields 3 tool_call rows with model/provider/nodeId", () => { + const store = createUsageStore(); + const logger = new AgentLogger({ store, taskId: "FN-UE-3", agent: "executor" }); + logger.setUsageContext({ + model: "gpt-5", + provider: "openai", + nodeId: "local", + agentId: "A-3", + }); + + logger.onToolStart("Read", { path: "a.ts" }); + logger.onToolStart("Edit", { path: "a.ts" }); + logger.onToolStart("Bash", { command: "pnpm test" }); + + const toolCalls = store.emitUsageEvent.mock.calls + .map((c) => c[0]) + .filter((e) => e.kind === "tool_call"); + expect(toolCalls).toHaveLength(3); + expect(toolCalls.map((e) => e.toolName)).toEqual(["Read", "Edit", "Bash"]); + for (const event of toolCalls) { + expect(event.model).toBe("gpt-5"); + expect(event.provider).toBe("openai"); + expect(event.nodeId).toBe("local"); + expect(event.agentId).toBe("A-3"); + } + }); + + it("emits tool_result with a duration descriptor and no result payload", () => { + const store = createUsageStore(); + const logger = new AgentLogger({ store, taskId: "FN-UE-4" }); + logger.setUsageContext({ model: "m", provider: "p", nodeId: "n", agentId: "a" }); + + logger.onToolStart("Bash", { command: "echo hi" }); + logger.onToolEnd("Bash", false, "super-secret-output"); + + const endEvent = store.emitUsageEvent.mock.calls + .map((c) => c[0]) + .find((e) => e.kind === "tool_result"); + expect(endEvent).toBeDefined(); + expect(endEvent.toolName).toBe("Bash"); + const meta = (endEvent.meta ?? {}) as Record; + expect(meta).toHaveProperty("durationMs"); + // The tool result payload MUST NOT leak into meta. + expect(JSON.stringify(meta)).not.toContain("super-secret-output"); + }); + }); }); diff --git a/packages/engine/src/agent-logger.ts b/packages/engine/src/agent-logger.ts index 5360353d1..5692b2d94 100644 --- a/packages/engine/src/agent-logger.ts +++ b/packages/engine/src/agent-logger.ts @@ -1,6 +1,24 @@ import type { TaskStore, AgentLogEntry, AgentRole } from "@fusion/core"; +import { categorizeToolName } from "@fusion/core"; import { createLogger } from "./logger.js"; +/** + * Session-context fields that let the logger emit normalized `usage_events` + * telemetry (KTD3/U1) alongside its agent-log writes. Populated by the + * executor/session layer where `model`/`provider`/`nodeId` are resolved; when + * absent, no usage events are emitted (the agent-log behavior is unchanged). + */ +export interface AgentLoggerUsageContext { + /** Resolved model id for the running session, when known. */ + model?: string | null; + /** Resolved provider for the running session, when known. */ + provider?: string | null; + /** Workflow/session node the session is routed to, when known. */ + nodeId?: string | null; + /** The agent id producing the activity, when known. */ + agentId?: string | null; +} + /** Default byte threshold before an automatic flush. */ const FLUSH_SIZE_BYTES = 1024; /** Default timer interval (ms) for periodic flush of small writes. */ @@ -68,6 +86,12 @@ export interface AgentLoggerOptions { flushSizeBytes?: number; /** Timer interval (ms) for periodic flush. Defaults to 500. */ flushIntervalMs?: number; + /** + * When provided (with `store` + `taskId`), tool start/end callbacks also emit + * normalized `usage_events` telemetry carrying the session's model/provider/ + * node context. Omit to leave agent-log behavior unchanged. + */ + usageContext?: AgentLoggerUsageContext; } /** @@ -113,6 +137,9 @@ export class AgentLogger { private readonly log = createLogger("agent-logger"); private readonly persistAgentToolOutput: boolean; private readonly persistAgentThinkingLog: boolean; + private usageContext?: AgentLoggerUsageContext; + /** Tracks tool start times so tool_result/tool_error can record a duration. */ + private readonly toolStartedAt = new Map(); constructor(options: AgentLoggerOptions) { this.store = options.store; @@ -125,6 +152,7 @@ export class AgentLogger { this.flushIntervalMs = options.flushIntervalMs ?? FLUSH_INTERVAL_MS; this.persistAgentToolOutput = options.persistAgentToolOutput !== false; this.persistAgentThinkingLog = options.persistAgentThinkingLog === true; + this.usageContext = options.usageContext; // Bind callbacks so they can be passed directly as function references this.onText = this.onText.bind(this); @@ -133,6 +161,39 @@ export class AgentLogger { this.onToolEnd = this.onToolEnd.bind(this); } + /** + * Set (or update) the session context used to emit `usage_events` telemetry. + * The executor resolves `model`/`provider`/`nodeId` after the logger is + * constructed, so it calls this once those are known. + */ + setUsageContext(context: AgentLoggerUsageContext | undefined): void { + this.usageContext = context; + } + + /** + * Emit a normalized tool `usage_events` row through the task store, if a store, + * taskId, and usage context are all available. Fail-soft via store.emitUsageEvent. + */ + private emitToolUsageEvent( + kind: "tool_call" | "tool_result" | "tool_error", + toolName: string, + meta?: Record, + ): void { + const ctx = this.usageContext; + if (!ctx || !this.store || !this.taskId) return; + this.store.emitUsageEvent({ + kind, + taskId: this.taskId, + agentId: ctx.agentId ?? null, + nodeId: ctx.nodeId ?? null, + model: ctx.model ?? null, + provider: ctx.provider ?? null, + toolName, + category: categorizeToolName(toolName), + ...(meta !== undefined && { meta }), + }); + } + /** * Callback for agent text deltas. Buffers text and flushes on size * threshold or after a timer interval. Compatible with `AgentOptions.onText`. @@ -178,6 +239,10 @@ export class AgentLogger { this.flushThinkingBuffer(); const detail = summarizeToolArgs(name, args); this.writeEntry(name, "tool", detail, `Failed to log tool start "${name}" for ${this.taskId}`); + // agent-log type "tool" maps to usage_events kind "tool_call". meta carries + // only non-sensitive descriptors (category) — never the tool arguments. + this.toolStartedAt.set(name, Date.now()); + this.emitToolUsageEvent("tool_call", name); } /** @@ -196,6 +261,18 @@ export class AgentLogger { detail = typeof result === "string" ? result : JSON.stringify(result); } this.writeEntry(name, type, detail, `Failed to log tool end "${name}" (${type}) for ${this.taskId}`); + // Record completion as tool_result/tool_error with a duration descriptor. + // meta NEVER includes the tool result payload — only non-sensitive metrics. + const startedAt = this.toolStartedAt.get(name); + if (startedAt !== undefined) this.toolStartedAt.delete(name); + const meta: Record = {}; + if (startedAt !== undefined) meta.durationMs = Date.now() - startedAt; + if (isError) meta.isError = true; + this.emitToolUsageEvent( + isError ? "tool_error" : "tool_result", + name, + Object.keys(meta).length > 0 ? meta : undefined, + ); } /** diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 6fae544a6..72743b4bb 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -7689,6 +7689,17 @@ export class TaskExecutor { const executorFallbackModelId = settings.fallbackModelId; const executorThinkingLevel = detail.thinkingLevel ?? settings.defaultThinkingLevel; + // U1 telemetry: now that the session model/provider/node are resolved, + // give the agent logger the context it needs to emit usage_events tool + // rows (KTD3). nodeId is sourced from the routed/effective node, null + // when the task has no node context. + agentLogger.setUsageContext({ + model: executorModelId ?? null, + provider: executorProvider ?? null, + nodeId: detail.effectiveNodeId ?? detail.nodeId ?? null, + agentId: engineRunContext.agentId ?? null, + }); + // Determine whether we're resuming a previous session (pause/resume) // or starting fresh. Use file-based sessions so conversation state // persists across pause/unpause cycles. Resume is allowed only when diff --git a/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts b/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts index 55de7b198..48bd18099 100644 --- a/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts +++ b/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts @@ -743,10 +743,10 @@ describe("RoadmapStore", () => { }); describe("schema version", () => { - it("schema version is 117 after init", () => { + it("schema version is 118 after init", () => { // Tracks @fusion/core's SCHEMA_VERSION (the roadmap store layers on core's // Database). Bump this in lockstep when core adds a migration. - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); }); }); From 90cfbfb4fcfc866d2c143eabdb40790afcf53710 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 15 Jun 2026 19:23:17 -0700 Subject: [PATCH 03/21] =?UTF-8?q?feat(dashboard):=20U4=20=E2=80=94=20Comma?= =?UTF-8?q?nd=20Center=20view=20shell,=20nav,=20and=20chart=20primitives?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New command-center built-in view (lazy-loaded, ARIA-tabbed) with hand-rolled CSS-bar chart primitives (Bar/StackedBar/Sparkline/Funnel), DateRangePicker, and Overview tab. Animations use --duration-* tokens (IACVT-safe). --- AGENTS.md | 3 +- packages/dashboard/app/App.tsx | 12 + .../__tests__/lazy-loaded-views-docs.test.ts | 6 +- packages/dashboard/app/components/Header.tsx | 16 +- .../dashboard/app/components/MobileNavBar.tsx | 12 + .../command-center/CommandCenter.css | 143 +++++++++++ .../command-center/CommandCenter.tsx | 226 ++++++++++++++++++ .../command-center/DateRangePicker.css | 55 +++++ .../command-center/DateRangePicker.tsx | 169 +++++++++++++ .../__tests__/CommandCenter.test.tsx | 85 +++++++ .../command-center/__tests__/charts.test.tsx | 144 +++++++++++ .../components/command-center/charts/Bar.tsx | 58 +++++ .../command-center/charts/Funnel.tsx | 68 ++++++ .../command-center/charts/Sparkline.tsx | 43 ++++ .../command-center/charts/StackedBar.tsx | 60 +++++ .../command-center/charts/charts.css | 179 ++++++++++++++ packages/dashboard/app/hooks/useViewState.ts | 3 +- packages/i18n/locales/en/app.json | 53 +++- 18 files changed, 1319 insertions(+), 16 deletions(-) create mode 100644 packages/dashboard/app/components/command-center/CommandCenter.css create mode 100644 packages/dashboard/app/components/command-center/CommandCenter.tsx create mode 100644 packages/dashboard/app/components/command-center/DateRangePicker.css create mode 100644 packages/dashboard/app/components/command-center/DateRangePicker.tsx create mode 100644 packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx create mode 100644 packages/dashboard/app/components/command-center/__tests__/charts.test.tsx create mode 100644 packages/dashboard/app/components/command-center/charts/Bar.tsx create mode 100644 packages/dashboard/app/components/command-center/charts/Funnel.tsx create mode 100644 packages/dashboard/app/components/command-center/charts/Sparkline.tsx create mode 100644 packages/dashboard/app/components/command-center/charts/StackedBar.tsx create mode 100644 packages/dashboard/app/components/command-center/charts/charts.css diff --git a/AGENTS.md b/AGENTS.md index 6181ff700..7bdd5c8da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -215,7 +215,7 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme ### Lazy-Loaded Heavy Views -These 20 views are lazy-loaded via `React.lazy()` with ``. +These 21 views are lazy-loaded via `React.lazy()` with ``. Keep this AGENTS inventory in sync with App lazy imports and `packages/dashboard/app/__tests__/lazy-loaded-views-docs.test.ts`. - `AgentsView` @@ -229,6 +229,7 @@ Keep this AGENTS inventory in sync with App lazy imports and `packages/dashboard - `SkillsView` - `ResearchView` - `ReliabilityView` +- `CommandCenter` - `EvalsView` - `TodoView` - `GoalsView` diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index ce408595b..c7ecf47c0 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -116,6 +116,7 @@ const SkillsView = lazy(() => import("./components/SkillsView").then((m) => ({ d const MemoryView = lazy(() => import("./components/MemoryView").then((m) => ({ default: m.MemoryView }))); const SecretsView = lazy(() => import("./components/SecretsView").then((m) => ({ default: m.SecretsView }))); const ReliabilityView = lazy(() => import("./components/ReliabilityView").then((m) => ({ default: m.ReliabilityView }))); +const CommandCenter = lazy(() => import("./components/command-center/CommandCenter").then((m) => ({ default: m.CommandCenter }))); const DevServerView = lazy(() => import("./components/DevServerView").then((m) => ({ default: m.DevServerView }))); const _TodoView = lazy(() => import("./components/TodoView").then((m) => ({ default: m.TodoView }))); const GoalsView = lazy(() => import("./components/GoalsView").then((m) => ({ default: m.GoalsView }))); @@ -146,6 +147,7 @@ function prefetchLazyViews() { void import("./components/MemoryView"); void import("./components/SecretsView"); void import("./components/ReliabilityView"); + void import("./components/command-center/CommandCenter"); void import("./components/DevServerView"); void import("./components/TodoView"); void import("./components/GoalsView"); @@ -1825,6 +1827,16 @@ function AppInner() { ); } + if (taskView === "command-center") { + return ( + + + + + + ); + } + if (taskView === "devserver" || taskView === "dev-server") { if (!settingsLoaded || !devServerEnabled) { return null; diff --git a/packages/dashboard/app/__tests__/lazy-loaded-views-docs.test.ts b/packages/dashboard/app/__tests__/lazy-loaded-views-docs.test.ts index bff329f1d..53a86f982 100644 --- a/packages/dashboard/app/__tests__/lazy-loaded-views-docs.test.ts +++ b/packages/dashboard/app/__tests__/lazy-loaded-views-docs.test.ts @@ -14,6 +14,7 @@ const EXPECTED_DOCUMENTED_VIEWS = new Set([ "SkillsView", "ResearchView", "ReliabilityView", + "CommandCenter", "EvalsView", "TodoView", "GoalsView", @@ -37,6 +38,7 @@ const EXPECTED_APP_LEVEL_VIEWS = new Set([ "MemoryView", "SecretsView", "ReliabilityView", + "CommandCenter", "DevServerView", "TodoView", "GoalsView", @@ -83,11 +85,11 @@ describe("AGENTS lazy-loaded views inventory", () => { const section = extractLazyLoadedSection(agentsDoc); const countMatch = section.match(/These\s+(\d+)\s+views\s+are lazy-loaded/); expect(countMatch).toBeTruthy(); - expect(Number(countMatch?.[1])).toBe(20); + expect(Number(countMatch?.[1])).toBe(21); const documentedViews = extractBacktickedNamesFromBullets(section); expect(new Set(documentedViews)).toEqual(EXPECTED_DOCUMENTED_VIEWS); - expect(documentedViews).toHaveLength(20); + expect(documentedViews).toHaveLength(21); expect(section).toContain("`ResearchView`"); expect(section).toContain("`TodoView`"); diff --git a/packages/dashboard/app/components/Header.tsx b/packages/dashboard/app/components/Header.tsx index a649aff43..48a31c166 100644 --- a/packages/dashboard/app/components/Header.tsx +++ b/packages/dashboard/app/components/Header.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useCallback, useMemo, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Monitor, Server, Workflow, Bot, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail, MessageSquare, ChevronDown, Check, Zap, Sparkles, FileText, Brain, CheckSquare, Lock } from "lucide-react"; +import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Monitor, Server, Workflow, Bot, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail, MessageSquare, ChevronDown, Check, Zap, Sparkles, FileText, Brain, CheckSquare, Lock, Gauge } from "lucide-react"; import "./Header.css"; // ProjectSelector styles used by the imported standalone component. import "./ProjectSelector.css"; @@ -1092,7 +1092,7 @@ export function Header({ <> + {experimentalFeatures?.devServerView && ( + + {experimentalFeatures?.evalsView && ( + ); + })} + + +
+ {renderActiveTab()} +
+ + ); +} diff --git a/packages/dashboard/app/components/command-center/DateRangePicker.css b/packages/dashboard/app/components/command-center/DateRangePicker.css new file mode 100644 index 000000000..8a2acb763 --- /dev/null +++ b/packages/dashboard/app/components/command-center/DateRangePicker.css @@ -0,0 +1,55 @@ +.cc-date-range { + position: relative; + display: inline-block; +} + +.cc-date-range-trigger { + display: inline-flex; + align-items: center; + gap: var(--space-1, 0.25rem); +} + +.cc-date-range-popover { + position: absolute; + top: calc(100% + var(--space-1, 0.25rem)); + right: 0; + z-index: 20; + min-width: 16rem; + padding: var(--space-3, 0.75rem); + background: var(--surface-1, #1c1c1c); + border: 1px solid var(--border-subtle, rgba(127, 127, 127, 0.25)); + border-radius: var(--radius-md, 8px); + box-shadow: var(--shadow-md, 0 6px 24px rgba(0, 0, 0, 0.35)); + display: flex; + flex-direction: column; + gap: var(--space-3, 0.75rem); +} + +.cc-date-range-presets { + display: flex; + flex-wrap: wrap; + gap: var(--space-2, 0.5rem); +} + +.cc-date-range-custom { + display: flex; + flex-direction: column; + gap: var(--space-2, 0.5rem); +} + +.cc-date-range-field { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2, 0.5rem); + font-size: var(--font-size-sm, 0.85rem); + color: var(--text-secondary, #888); +} + +.cc-date-range-field input { + background: var(--surface-2, rgba(127, 127, 127, 0.12)); + border: 1px solid var(--border-subtle, rgba(127, 127, 127, 0.25)); + border-radius: var(--radius-sm, 4px); + color: var(--text-primary, #ddd); + padding: var(--space-1, 0.25rem) var(--space-2, 0.5rem); +} diff --git a/packages/dashboard/app/components/command-center/DateRangePicker.tsx b/packages/dashboard/app/components/command-center/DateRangePicker.tsx new file mode 100644 index 000000000..126e15528 --- /dev/null +++ b/packages/dashboard/app/components/command-center/DateRangePicker.tsx @@ -0,0 +1,169 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Calendar } from "lucide-react"; +import "./DateRangePicker.css"; + +export interface DateRange { + /** ISO date string (YYYY-MM-DD) or null for an open lower bound. */ + from: string | null; + /** ISO date string (YYYY-MM-DD) or null for an open upper bound (now). */ + to: string | null; + /** Identifier for the active preset, or "custom". */ + preset: string; +} + +export interface DateRangePreset { + id: string; + label: string; + /** Days back from now; null = all time. */ + days: number | null; +} + +export interface DateRangePickerProps { + value: DateRange; + onChange: (range: DateRange) => void; + presets?: DateRangePreset[]; +} + +export function defaultPresets(t: (key: string, fallback: string) => string): DateRangePreset[] { + return [ + { id: "24h", label: t("commandCenter.range.last24h", "Last 24h"), days: 1 }, + { id: "7d", label: t("commandCenter.range.last7d", "Last 7 days"), days: 7 }, + { id: "30d", label: t("commandCenter.range.last30d", "Last 30 days"), days: 30 }, + { id: "all", label: t("commandCenter.range.allTime", "All time"), days: null }, + ]; +} + +export function rangeFromPreset(preset: DateRangePreset): DateRange { + if (preset.days === null) { + return { from: null, to: null, preset: preset.id }; + } + const from = new Date(Date.now() - preset.days * 86_400_000); + return { from: from.toISOString().slice(0, 10), to: null, preset: preset.id }; +} + +export function DateRangePicker({ value, onChange, presets }: DateRangePickerProps) { + const { t } = useTranslation("app"); + const resolvedPresets = presets ?? defaultPresets(t); + const [open, setOpen] = useState(false); + const [customError, setCustomError] = useState(null); + const triggerRef = useRef(null); + const popoverRef = useRef(null); + + const close = useCallback(() => { + setOpen(false); + // Return focus to the trigger on dismiss. + triggerRef.current?.focus(); + }, []); + + useEffect(() => { + if (!open) { + return; + } + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + close(); + } + }; + const onPointerDown = (e: PointerEvent) => { + if ( + popoverRef.current && + !popoverRef.current.contains(e.target as Node) && + triggerRef.current && + !triggerRef.current.contains(e.target as Node) + ) { + setOpen(false); + } + }; + document.addEventListener("keydown", onKeyDown); + document.addEventListener("pointerdown", onPointerDown); + return () => { + document.removeEventListener("keydown", onKeyDown); + document.removeEventListener("pointerdown", onPointerDown); + }; + }, [open, close]); + + const activeLabel = + resolvedPresets.find((p) => p.id === value.preset)?.label ?? + t("commandCenter.range.custom", "Custom range"); + + const applyCustom = useCallback( + (from: string | null, to: string | null) => { + if (from && to && from > to) { + setCustomError(t("commandCenter.range.invalidRange", "Start date must be on or before end date")); + return; + } + setCustomError(null); + onChange({ from, to, preset: "custom" }); + }, + [onChange, t], + ); + + return ( +
+ + {open ? ( +
+
+ {resolvedPresets.map((preset) => ( + + ))} +
+
+ + + {customError ? ( +
+ {customError} +
+ ) : null} +
+
+ ) : null} +
+ ); +} diff --git a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx new file mode 100644 index 000000000..3dce4fd02 --- /dev/null +++ b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx @@ -0,0 +1,85 @@ +import { describe, it, expect } from "vitest"; +import { render, screen, fireEvent, within } from "@testing-library/react"; +import { CommandCenter } from "../CommandCenter"; + +describe("CommandCenter shell", () => { + it("renders with the Overview tab active by default", () => { + render(); + const overviewTab = screen.getByTestId("command-center-tab-overview"); + expect(overviewTab.getAttribute("aria-selected")).toBe("true"); + expect(screen.getByTestId("command-center-panel-overview")).toBeTruthy(); + }); + + it("renders the documented empty state when there is no data (no crash)", () => { + render(); + expect(screen.getByTestId("command-center-empty")).toBeTruthy(); + }); + + it("exposes the ARIA tabs pattern (tablist + tabs + tabpanel)", () => { + render(); + const tablist = screen.getByRole("tablist"); + const tabs = within(tablist).getAllByRole("tab"); + expect(tabs.length).toBe(7); + // roving tabindex: exactly one tab is focusable. + const focusable = tabs.filter((tab) => tab.getAttribute("tabindex") === "0"); + expect(focusable.length).toBe(1); + expect(screen.getByRole("tabpanel")).toBeTruthy(); + }); + + it("activates a tab on click and updates aria-selected", () => { + render(); + fireEvent.click(screen.getByTestId("command-center-tab-tokens")); + expect(screen.getByTestId("command-center-tab-tokens").getAttribute("aria-selected")).toBe("true"); + expect(screen.getByTestId("command-center-tab-overview").getAttribute("aria-selected")).toBe("false"); + expect(screen.getByTestId("command-center-panel-tokens")).toBeTruthy(); + }); + + it("supports arrow-key navigation between tabs (roving tabindex)", () => { + render(); + const overviewTab = screen.getByTestId("command-center-tab-overview"); + overviewTab.focus(); + fireEvent.keyDown(overviewTab, { key: "ArrowRight" }); + const tokensTab = screen.getByTestId("command-center-tab-tokens"); + expect(tokensTab.getAttribute("aria-selected")).toBe("true"); + expect(document.activeElement).toBe(tokensTab); + }); + + it("wraps with ArrowLeft from the first tab to the last", () => { + render(); + const overviewTab = screen.getByTestId("command-center-tab-overview"); + overviewTab.focus(); + fireEvent.keyDown(overviewTab, { key: "ArrowLeft" }); + const last = screen.getByTestId("command-center-tab-mission-control"); + expect(last.getAttribute("aria-selected")).toBe("true"); + expect(document.activeElement).toBe(last); + }); + + it("activates with Enter and Space", () => { + render(); + const toolsTab = screen.getByTestId("command-center-tab-tools"); + fireEvent.keyDown(toolsTab, { key: "Enter" }); + expect(toolsTab.getAttribute("aria-selected")).toBe("true"); + + const activityTab = screen.getByTestId("command-center-tab-activity"); + fireEvent.keyDown(activityTab, { key: " " }); + expect(activityTab.getAttribute("aria-selected")).toBe("true"); + }); + + it("makes the active tabpanel focusable (Tab moves into the panel)", () => { + render(); + const panel = screen.getByTestId("command-center-panel-overview"); + expect(panel.getAttribute("tabindex")).toBe("0"); + expect(panel.getAttribute("role")).toBe("tabpanel"); + }); + + it("renders a date-range picker that returns focus to its trigger on dismiss", () => { + render(); + const trigger = screen.getByTestId("cc-date-range-trigger"); + fireEvent.click(trigger); + expect(screen.getByTestId("cc-date-range-popover")).toBeTruthy(); + // Escape dismisses and returns focus to the trigger. + fireEvent.keyDown(document, { key: "Escape" }); + expect(screen.queryByTestId("cc-date-range-popover")).toBeNull(); + expect(document.activeElement).toBe(trigger); + }); +}); diff --git a/packages/dashboard/app/components/command-center/__tests__/charts.test.tsx b/packages/dashboard/app/components/command-center/__tests__/charts.test.tsx new file mode 100644 index 000000000..e49164211 --- /dev/null +++ b/packages/dashboard/app/components/command-center/__tests__/charts.test.tsx @@ -0,0 +1,144 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { readFileSync } from "fs"; +import { resolve } from "path"; +import { Bar } from "../charts/Bar"; +import { StackedBar } from "../charts/StackedBar"; +import { Sparkline } from "../charts/Sparkline"; +import { Funnel } from "../charts/Funnel"; + +function widthOf(el: HTMLElement): string { + return el.style.width; +} + +function heightOf(el: HTMLElement): string { + return el.style.height; +} + +describe("Bar", () => { + it("renders a fill per datum and an accessible label", () => { + render( + , + ); + expect(screen.getByRole("list", { name: "tokens by model" })).toBeTruthy(); + expect(screen.getByLabelText("gpt-4: 100")).toBeTruthy(); + expect(screen.getByLabelText("sonnet: 50")).toBeTruthy(); + }); + + it("renders the largest value at 100% and scales the rest", () => { + render(); + expect(widthOf(screen.getByLabelText("a: 100"))).toBe("100%"); + expect(widthOf(screen.getByLabelText("b: 25"))).toBe("25%"); + }); + + it("renders a 0-width bar for a zero value, never NaN", () => { + render(); + const zero = screen.getByLabelText("zero: 0"); + expect(widthOf(zero)).toBe("0%"); + expect(widthOf(zero)).not.toContain("NaN"); + }); + + it("renders 0-width bars for an all-zero dataset without dividing by zero", () => { + render(); + expect(widthOf(screen.getByLabelText("a: 0"))).toBe("0%"); + expect(widthOf(screen.getByLabelText("b: 0"))).toBe("0%"); + }); + + it("treats a non-finite value as zero width", () => { + render(); + const el = screen.getByLabelText("nan: 0"); + expect(widthOf(el)).toBe("0%"); + }); +}); + +describe("StackedBar", () => { + it("renders proportional segments and a legend", () => { + render( + , + ); + expect(screen.getByRole("img", { name: "status split" })).toBeTruthy(); + expect(widthOf(screen.getByLabelText("done: 75"))).toBe("75%"); + expect(widthOf(screen.getByLabelText("open: 25"))).toBe("25%"); + }); + + it("renders 0-width slices for an all-zero set, never NaN", () => { + render(); + expect(widthOf(screen.getByLabelText("a: 0"))).toBe("0%"); + expect(widthOf(screen.getByLabelText("b: 0"))).toBe("0%"); + }); +}); + +describe("Sparkline", () => { + it("renders one bar per value with proportional heights", () => { + render(); + const sparkline = screen.getByRole("img", { name: "models per day" }); + const bars = sparkline.querySelectorAll(".cc-sparkline-bar"); + expect(bars.length).toBe(3); + expect(heightOf(bars[0])).toBe("50%"); + expect(heightOf(bars[1])).toBe("100%"); + expect(heightOf(bars[2])).toBe("0%"); + }); + + it("renders 0-height bars for all-zero values without NaN", () => { + render(); + const bars = screen.getByRole("img", { name: "empty" }).querySelectorAll(".cc-sparkline-bar"); + expect(heightOf(bars[0])).toBe("0%"); + expect(heightOf(bars[1])).toBe("0%"); + }); +}); + +describe("Funnel", () => { + it("renders stages with conversion from the prior stage", () => { + render( + , + ); + expect(widthOf(screen.getByLabelText("triage: 100"))).toBe("100%"); + expect(widthOf(screen.getByLabelText("todo: 50"))).toBe("50%"); + // first stage has no conversion label; subsequent stages do (todo=50%, done=50%) + expect(screen.getAllByText("50%").length).toBe(2); + }); + + it("shows a — conversion when the prior stage is zero, never NaN%", () => { + render(); + expect(screen.getByText("—")).toBeTruthy(); + expect(widthOf(screen.getByLabelText("a: 0"))).toBe("0%"); + }); +}); + +/** + * Real-browser-style guard for the IACVT token trap: the chart CSS must drive + * its loader animation off a bare --duration-* token, never a --transition-* + * duration+easing pair (which silently resolves to animation: none). + */ +describe("chart CSS animation tokens", () => { + const cssPath = resolve(__dirname, "../charts/charts.css"); + const css = readFileSync(cssPath, "utf8"); + + it("uses a --duration-* token in the loader animation, not --transition-*", () => { + const animationLines = css.split("\n").filter((line) => /animation\s*:/.test(line)); + expect(animationLines.length).toBeGreaterThan(0); + for (const line of animationLines) { + expect(line).not.toMatch(/var\(--transition-/); + expect(line).toMatch(/var\(--duration-/); + } + }); +}); diff --git a/packages/dashboard/app/components/command-center/charts/Bar.tsx b/packages/dashboard/app/components/command-center/charts/Bar.tsx new file mode 100644 index 000000000..b0f4df840 --- /dev/null +++ b/packages/dashboard/app/components/command-center/charts/Bar.tsx @@ -0,0 +1,58 @@ +import "./charts.css"; + +export interface BarDatum { + label: string; + value: number; + /** Optional display string for the value (defaults to the number). */ + valueLabel?: string; +} + +export interface BarProps { + data: BarDatum[]; + /** + * Max value mapped to 100% width. Defaults to the largest datum value. + * Always coerced to at least 1 so a zero-only dataset never divides by zero. + */ + max?: number; + /** Accessible label for the whole chart. */ + ariaLabel?: string; +} + +function safeWidthPercent(value: number, max: number): number { + if (!Number.isFinite(value) || value <= 0) { + return 0; + } + const denom = Number.isFinite(max) && max > 0 ? max : 1; + return Math.max(0, Math.min(100, (value / denom) * 100)); +} + +/** + * Hand-rolled CSS horizontal bar chart. Zero-value bars render a 0-width bar + * with an accessible label rather than NaN. + */ +export function Bar({ data, max, ariaLabel }: BarProps) { + const computedMax = max ?? data.reduce((m, d) => (d.value > m ? d.value : m), 0); + + return ( +
    + {data.map((d) => { + const width = safeWidthPercent(d.value, computedMax); + const valueText = d.valueLabel ?? String(Number.isFinite(d.value) ? d.value : 0); + return ( +
  • + {d.label} +
    +
    +
    + {valueText} +
  • + ); + })} +
+ ); +} diff --git a/packages/dashboard/app/components/command-center/charts/Funnel.tsx b/packages/dashboard/app/components/command-center/charts/Funnel.tsx new file mode 100644 index 000000000..2b99e1a5a --- /dev/null +++ b/packages/dashboard/app/components/command-center/charts/Funnel.tsx @@ -0,0 +1,68 @@ +import "./charts.css"; + +export interface FunnelStage { + label: string; + value: number; +} + +export interface FunnelProps { + stages: FunnelStage[]; + /** Accessible label for the whole funnel. */ + ariaLabel?: string; +} + +function safeWidthPercent(value: number, max: number): number { + if (!Number.isFinite(value) || value <= 0) { + return 0; + } + const denom = Number.isFinite(max) && max > 0 ? max : 1; + return Math.max(0, Math.min(100, (value / denom) * 100)); +} + +function conversionLabel(value: number, prev: number | null): string | null { + if (prev === null) { + return null; + } + if (!Number.isFinite(prev) || prev <= 0) { + return "—"; + } + const pct = Math.max(0, Math.min(100, (value / prev) * 100)); + return `${pct.toFixed(0)}%`; +} + +/** + * Hand-rolled CSS funnel. The first (largest) stage anchors 100% width; each + * stage shows its count and conversion from the prior stage. Zero values render + * a 0-width bar and a "—" conversion, never NaN. + */ +export function Funnel({ stages, ariaLabel }: FunnelProps) { + const max = stages.reduce((m, s) => (s.value > m ? s.value : m), 0); + + return ( +
    + {stages.map((s, i) => { + const width = safeWidthPercent(s.value, max); + const prev = i > 0 ? stages[i - 1].value : null; + const conversion = conversionLabel(s.value, prev); + const valueText = String(Number.isFinite(s.value) ? s.value : 0); + return ( +
  1. +
    + {s.label} + {conversion !== null ? {conversion} : null} +
    +
    +
    +
    + {valueText} +
  2. + ); + })} +
+ ); +} diff --git a/packages/dashboard/app/components/command-center/charts/Sparkline.tsx b/packages/dashboard/app/components/command-center/charts/Sparkline.tsx new file mode 100644 index 000000000..b346e3748 --- /dev/null +++ b/packages/dashboard/app/components/command-center/charts/Sparkline.tsx @@ -0,0 +1,43 @@ +import "./charts.css"; + +export interface SparklineProps { + values: number[]; + /** Accessible label for the whole sparkline. */ + ariaLabel?: string; + /** Max value mapped to full height. Defaults to the largest value. */ + max?: number; +} + +function safeHeightPercent(value: number, max: number): number { + if (!Number.isFinite(value) || value <= 0) { + return 0; + } + const denom = Number.isFinite(max) && max > 0 ? max : 1; + return Math.max(0, Math.min(100, (value / denom) * 100)); +} + +/** + * Hand-rolled CSS-bar sparkline (mini vertical bar chart). Zero / non-finite + * values render a 0-height bar, never a NaN height. + */ +export function Sparkline({ values, ariaLabel, max }: SparklineProps) { + const computedMax = max ?? values.reduce((m, v) => (v > m ? v : m), 0); + + return ( +
+ {values.map((v, i) => { + const height = safeHeightPercent(v, computedMax); + return ( + + ); +} diff --git a/packages/dashboard/app/components/command-center/charts/StackedBar.tsx b/packages/dashboard/app/components/command-center/charts/StackedBar.tsx new file mode 100644 index 000000000..8cc239874 --- /dev/null +++ b/packages/dashboard/app/components/command-center/charts/StackedBar.tsx @@ -0,0 +1,60 @@ +import "./charts.css"; + +export interface StackedSegment { + label: string; + value: number; + /** CSS color (e.g. a var(--...) token). */ + color?: string; +} + +export interface StackedBarProps { + segments: StackedSegment[]; + /** Accessible label for the whole bar. */ + ariaLabel?: string; +} + +function safeShare(value: number, total: number): number { + if (!Number.isFinite(value) || value <= 0) { + return 0; + } + if (!Number.isFinite(total) || total <= 0) { + return 0; + } + return Math.max(0, Math.min(100, (value / total) * 100)); +} + +/** + * Hand-rolled single horizontal stacked bar. A zero-value segment renders a + * 0-width slice (still keyed + labelled) rather than NaN. An all-zero set + * renders an empty track. + */ +export function StackedBar({ segments, ariaLabel }: StackedBarProps) { + const total = segments.reduce((sum, s) => (Number.isFinite(s.value) && s.value > 0 ? sum + s.value : sum), 0); + + return ( +
+
+ {segments.map((s) => { + const share = safeShare(s.value, total); + return ( +
+ ); + })} +
+
    + {segments.map((s) => ( +
  • +
  • + ))} +
+
+ ); +} diff --git a/packages/dashboard/app/components/command-center/charts/charts.css b/packages/dashboard/app/components/command-center/charts/charts.css new file mode 100644 index 000000000..fc99dab51 --- /dev/null +++ b/packages/dashboard/app/components/command-center/charts/charts.css @@ -0,0 +1,179 @@ +/* Command Center hand-rolled CSS-bar chart primitives. + * + * Animation durations MUST use --duration-* tokens (bare durations). + * NEVER use --transition-* here: those are duration+easing pairs and silently + * invalidate animation declarations (see animation-duration-tokens.css.test.ts). + */ + +/* ---- Bar ---- */ +.cc-bar-chart { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-2, 0.5rem); +} + +.cc-bar-row { + display: grid; + grid-template-columns: minmax(6rem, 12rem) 1fr auto; + align-items: center; + gap: var(--space-2, 0.5rem); +} + +.cc-bar-label { + font-size: var(--font-size-sm, 0.85rem); + color: var(--text-secondary, #888); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cc-bar-track { + position: relative; + height: 0.75rem; + background: var(--surface-2, rgba(127, 127, 127, 0.12)); + border-radius: var(--radius-sm, 4px); + overflow: hidden; +} + +.cc-bar-fill { + height: 100%; + background: var(--color-accent, #4f8cff); + border-radius: var(--radius-sm, 4px); + transition: width var(--transition-normal); +} + +.cc-bar-value { + font-size: var(--font-size-sm, 0.85rem); + font-variant-numeric: tabular-nums; + color: var(--text-primary, #ddd); +} + +/* ---- StackedBar ---- */ +.cc-stacked-bar { + display: flex; + flex-direction: column; + gap: var(--space-2, 0.5rem); +} + +.cc-stacked-track { + display: flex; + height: 0.75rem; + background: var(--surface-2, rgba(127, 127, 127, 0.12)); + border-radius: var(--radius-sm, 4px); + overflow: hidden; +} + +.cc-stacked-segment { + height: 100%; + background: var(--color-accent, #4f8cff); + transition: width var(--transition-normal); +} + +.cc-stacked-legend { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: var(--space-3, 0.75rem); +} + +.cc-stacked-legend-item { + display: flex; + align-items: center; + gap: var(--space-1, 0.25rem); + font-size: var(--font-size-sm, 0.85rem); + color: var(--text-secondary, #888); +} + +.cc-stacked-swatch { + width: 0.6rem; + height: 0.6rem; + border-radius: 2px; + background: var(--color-accent, #4f8cff); + display: inline-block; +} + +/* ---- Sparkline ---- */ +.cc-sparkline { + display: flex; + align-items: flex-end; + gap: 1px; + height: 2rem; +} + +.cc-sparkline-bar { + flex: 1 1 0; + min-width: 1px; + background: var(--color-accent, #4f8cff); + border-radius: 1px; + transition: height var(--transition-normal); +} + +/* ---- Funnel ---- */ +.cc-funnel { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-2, 0.5rem); +} + +.cc-funnel-stage { + display: flex; + flex-direction: column; + gap: var(--space-1, 0.25rem); +} + +.cc-funnel-header { + display: flex; + justify-content: space-between; + font-size: var(--font-size-sm, 0.85rem); + color: var(--text-secondary, #888); +} + +.cc-funnel-conversion { + font-variant-numeric: tabular-nums; +} + +.cc-funnel-track { + height: 1rem; + background: var(--surface-2, rgba(127, 127, 127, 0.12)); + border-radius: var(--radius-sm, 4px); + overflow: hidden; +} + +.cc-funnel-fill { + height: 100%; + background: var(--color-accent, #4f8cff); + border-radius: var(--radius-sm, 4px); + transition: width var(--transition-normal); +} + +.cc-funnel-value { + font-size: var(--font-size-sm, 0.85rem); + font-variant-numeric: tabular-nums; + color: var(--text-primary, #ddd); +} + +/* ---- Loading shimmer (used by chart skeletons) ---- */ +.cc-chart-skeleton { + height: 0.75rem; + border-radius: var(--radius-sm, 4px); + background: var(--surface-2, rgba(127, 127, 127, 0.12)); + animation: cc-chart-pulse var(--duration-slow) ease-in-out infinite; +} + +@keyframes cc-chart-pulse { + 0%, + 100% { + opacity: 0.4; + } + 50% { + opacity: 0.9; + } +} diff --git a/packages/dashboard/app/hooks/useViewState.ts b/packages/dashboard/app/hooks/useViewState.ts index 9dee09b82..51b0f2ad1 100644 --- a/packages/dashboard/app/hooks/useViewState.ts +++ b/packages/dashboard/app/hooks/useViewState.ts @@ -5,7 +5,7 @@ import { getScopedItem, setScopedItem } from "../utils/projectStorage"; import { getPluginViewId, isPluginViewId, isPluginViewRegistered } from "../plugins/pluginViewRegistry"; export type ViewMode = "overview" | "project"; -export type BuiltInTaskView = "board" | "list" | "graph" | "agents" | "missions" | "chat" | "documents" | "research" | "evals" | "goalsView" | "skills" | "mailbox" | "insights" | "memory" | "reliability" | "secrets" | "devserver" | "dev-server" | "stash-recovery" | "pull-requests"; +export type BuiltInTaskView = "board" | "list" | "graph" | "agents" | "missions" | "chat" | "documents" | "research" | "evals" | "goalsView" | "skills" | "mailbox" | "insights" | "memory" | "reliability" | "command-center" | "secrets" | "devserver" | "dev-server" | "stash-recovery" | "pull-requests"; export type PluginTaskView = `plugin:${string}:${string}`; export type TaskView = BuiltInTaskView | PluginTaskView; @@ -26,6 +26,7 @@ const BUILT_IN_TASK_VIEWS: readonly BuiltInTaskView[] = [ "insights", "memory", "reliability", + "command-center", "secrets", "devserver", "dev-server", diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 3d97e8d4d..f765f0ea8 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -2454,7 +2454,8 @@ "viewProjects": "View Projects", "viewUsage": "View usage", "workflows": "Workflows", - "workingBranch": "Working branch" + "workingBranch": "Working branch", + "commandCenterView": "Command Center" }, "health": { "activeTasks": "Active Tasks", @@ -3469,7 +3470,8 @@ "terminal": "Terminal", "todos": "Todos", "usage": "Usage", - "workflows": "Workflows" + "workflows": "Workflows", + "commandCenter": "Command Center" }, "newTaskModal": { "addDependencies": "Add dependencies", @@ -6187,10 +6189,10 @@ "yes": "Yes" }, "taskFields": { + "unset": "—", "moreFields": "Additional fields", "orphaned": "Orphaned fields", - "saveFailed": "Failed to save field", - "unset": "—" + "saveFailed": "Failed to save field" }, "taskForm": { "addDependencies": "Add dependencies", @@ -7008,12 +7010,6 @@ "sha256": "SHA-256", "version": "Version" }, - "taskFields": { - "unset": "—", - "moreFields": "Additional fields", - "orphaned": "Orphaned fields", - "saveFailed": "Failed to save field" - }, "workflowEditor": { "cliAgent": { "executorOption": "CLI agent", @@ -7055,5 +7051,42 @@ "mobileKeyArrowDown": "Cursor down", "mobileKeyArrowLeft": "Cursor left", "mobileKeyArrowRight": "Cursor right" + }, + "commandCenter": { + "heading": "Command Center", + "loading": "Loading command center...", + "empty": "No usage data yet. Run some agents to populate the Command Center.", + "areaPending": "This area renders once metrics data is available.", + "tablistLabel": "Command Center sections", + "tabs": { + "overview": "Overview", + "tokens": "Tokens", + "tools": "Tools", + "activity": "Activity", + "productivity": "Productivity", + "ecosystem": "Ecosystem", + "missionControl": "Mission Control" + }, + "overview": { + "tokensCost": "Tokens & cost", + "autonomy": "Autonomy ratio", + "activeNodes": "Active nodes", + "tasksDone": "Tasks done", + "uniqueModels": "Unique models", + "openSignals": "Open signals", + "liveStrip": "Live activity", + "liveStripPending": "Live Mission Control loads with active sessions." + }, + "range": { + "last24h": "Last 24h", + "last7d": "Last 7 days", + "last30d": "Last 30 days", + "allTime": "All time", + "custom": "Custom range", + "dialogLabel": "Select date range", + "from": "From", + "to": "To", + "invalidRange": "Start date must be on or before end date" + } } } From 951c6ef5cd3f023fcda3a161c6b7a354d3849a81 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 15 Jun 2026 19:23:23 -0700 Subject: [PATCH 04/21] =?UTF-8?q?feat(signals):=20U11=20=E2=80=94=20extern?= =?UTF-8?q?al=20signal=20ingestion=20(Sentry/Datadog/PagerDuty/webhook)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SignalSource adapter seam with mandatory HMAC verification, replay window + nonce dedup, SSRF-safe URL handling, body-size/rate-limit/field caps, and a groupingKey on every normalized Signal for U13's storm guard. Inbound webhooks create triage tasks via the existing store (no schema change). --- .changeset/u11-external-signal-ingestion.md | 9 + packages/dashboard/README.md | 26 ++ .../__tests__/register-signal-routes.test.ts | 342 ++++++++++++++++++ .../src/__tests__/signal-source.test.ts | 124 +++++++ packages/dashboard/src/routes.ts | 5 + .../src/routes/register-signal-routes.ts | 238 ++++++++++++ packages/dashboard/src/signal-source.ts | 300 +++++++++++++++ .../dashboard/src/signal-sources/datadog.ts | 103 ++++++ .../dashboard/src/signal-sources/pagerduty.ts | 95 +++++ .../dashboard/src/signal-sources/sentry.ts | 117 ++++++ .../dashboard/src/signal-sources/webhook.ts | 97 +++++ 11 files changed, 1456 insertions(+) create mode 100644 .changeset/u11-external-signal-ingestion.md create mode 100644 packages/dashboard/src/__tests__/register-signal-routes.test.ts create mode 100644 packages/dashboard/src/__tests__/signal-source.test.ts create mode 100644 packages/dashboard/src/routes/register-signal-routes.ts create mode 100644 packages/dashboard/src/signal-source.ts create mode 100644 packages/dashboard/src/signal-sources/datadog.ts create mode 100644 packages/dashboard/src/signal-sources/pagerduty.ts create mode 100644 packages/dashboard/src/signal-sources/sentry.ts create mode 100644 packages/dashboard/src/signal-sources/webhook.ts diff --git a/.changeset/u11-external-signal-ingestion.md b/.changeset/u11-external-signal-ingestion.md new file mode 100644 index 000000000..80872ce62 --- /dev/null +++ b/.changeset/u11-external-signal-ingestion.md @@ -0,0 +1,9 @@ +--- +"@runfusion/fusion": minor +--- + +Ingest external signals (Sentry / Datadog / PagerDuty / generic webhook) into triage tasks via a common `SignalSource` adapter seam (U11, KTD8). + +- New `POST /api/signals/:provider` endpoints, mirroring the GitHub ingestion path. Verified, normalized signals create a task in the `triage` column via the existing task store. +- Generic webhook is the must-work path; Sentry/Datadog/PagerDuty are thin adapters with provider-specific HMAC verification + payload normalization. Each normalized `Signal` carries a `groupingKey` (Sentry `issue.id`, PagerDuty `incident.id`, Datadog monitor key; the generic webhook requires a caller-supplied key or falls back to `source + normalized-title`) for the downstream storm guard. +- Security (mandatory): per-provider HMAC against an env-sourced secret (never source-controlled) with 401 on missing/invalid secret or signature — the generic webhook is never an unauthenticated task-creation endpoint; ±5 min replay window + delivery-id nonce dedup; persistent external-id dedup; ~1 MB body cap; per-source rate limit; field-length + meta-byte caps; SSRF-untrusted handling of payload URLs; `meta` stored as data, never rendered as raw HTML. diff --git a/packages/dashboard/README.md b/packages/dashboard/README.md index bb6b7e543..070414a72 100644 --- a/packages/dashboard/README.md +++ b/packages/dashboard/README.md @@ -770,6 +770,32 @@ For real-time PR/issue badge updates, configure a GitHub App instead of relying **Fallback Behavior:** When webhook delivery is unavailable, the 5-minute refresh endpoints (`/api/tasks/:id/pr/status`, `/api/tasks/:id/issue/status`) continue to work as the fallback path. Staleness is computed from persisted `lastCheckedAt` timestamps only (no in-memory poller state). +### External Signal Ingestion (Sentry / Datadog / PagerDuty / generic webhook) + +Inbound signals from error trackers and alerting tools are ingested into triage +tasks via `POST /api/signals/:provider`. Every endpoint requires a valid HMAC +signature against a per-provider secret — there is no unauthenticated +task-creation endpoint. Secrets come from the environment and are never +source-controlled: + +- `FUSION_SIGNAL_WEBHOOK_SECRET` — generic webhook (`POST /api/signals/webhook`). + Sign the raw body with HMAC-SHA256 in `X-Fusion-Signature` (hex, optional + `sha256=` prefix) and send `X-Fusion-Timestamp` (epoch ms) for the replay + window. Payload: `{ id, title, body?, severity?, link?, groupingKey?, timestamp?, meta? }`. + If `groupingKey` is omitted it falls back to `source + normalized-title`. +- `FUSION_SIGNAL_SENTRY_SECRET` — Sentry (`POST /api/signals/sentry`), verifies + `Sentry-Hook-Signature`; `groupingKey` = Sentry `issue.id`. +- `FUSION_SIGNAL_DATADOG_SECRET` — Datadog (`POST /api/signals/datadog`), + verifies `X-Datadog-Signature`; `groupingKey` = monitor `aggreg_key`/`alert_id`. +- `FUSION_SIGNAL_PAGERDUTY_SECRET` — PagerDuty (`POST /api/signals/pagerduty`), + verifies `X-PagerDuty-Signature` (`v1=`); `groupingKey` = `incident.id`. + +**Security:** mandatory HMAC (401 on missing/invalid secret or signature), +replay window (±5 min) + delivery-id nonce dedup, persistent external-id dedup, +~1 MB body cap (413), per-source rate limit (429), field-length caps on +normalized fields, and SSRF-untrusted handling of payload URLs (stored as data, +never fetched). The `meta` JSON is stored as data and never rendered as raw HTML. + ### Multi-Instance Deployments When running the dashboard on multiple instances behind a load balancer, badge updates can be shared across instances using Redis pub/sub. This ensures that a PR/issue badge change detected on instance A is delivered to subscribed WebSocket clients on instance B. diff --git a/packages/dashboard/src/__tests__/register-signal-routes.test.ts b/packages/dashboard/src/__tests__/register-signal-routes.test.ts new file mode 100644 index 000000000..b4e8d8652 --- /dev/null +++ b/packages/dashboard/src/__tests__/register-signal-routes.test.ts @@ -0,0 +1,342 @@ +// @vitest-environment node + +import { createHmac } from "node:crypto"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { Task, TaskStore } from "@fusion/core"; +import { DeliveryNonceCache, type SignalSource } from "../signal-source.js"; +import { + ingestSignal, + resolveSignalSecret, + signalToTaskInput, + getSignalSource, +} from "../routes/register-signal-routes.js"; +import { webhookSource } from "../signal-sources/webhook.js"; +import { sentrySource } from "../signal-sources/sentry.js"; +import { datadogSource } from "../signal-sources/datadog.js"; +import { pagerdutySource } from "../signal-sources/pagerduty.js"; + +function sign(body: string, secret: string): string { + return createHmac("sha256", secret).update(Buffer.from(body)).digest("hex"); +} + +/** Minimal fake task store implementing only what the ingestion path uses. */ +function makeStore() { + const tasks: Task[] = []; + let counter = 0; + const store = { + async listTasks() { + return tasks; + }, + async createTask(input: Parameters[0]) { + const task = { + id: `FN-${++counter}`, + title: input.title, + description: input.description, + column: input.column, + source: input.source, + } as unknown as Task; + tasks.push(task); + return task; + }, + _tasks: tasks, + }; + return store as unknown as TaskStore & { _tasks: Task[] }; +} + +const SECRETS: Record = { + FUSION_SIGNAL_WEBHOOK_SECRET: "wh-secret", + FUSION_SIGNAL_SENTRY_SECRET: "sentry-secret", + FUSION_SIGNAL_DATADOG_SECRET: "datadog-secret", + FUSION_SIGNAL_PAGERDUTY_SECRET: "pd-secret", +}; + +const savedEnv: Record = {}; + +beforeEach(() => { + for (const [k, v] of Object.entries(SECRETS)) { + savedEnv[k] = process.env[k]; + process.env[k] = v; + } +}); + +afterEach(() => { + for (const k of Object.keys(SECRETS)) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } +}); + +function ctxFor(source: SignalSource, payload: object, headers: Record) { + const rawBody = Buffer.from(JSON.stringify(payload)); + const lower: Record = {}; + for (const [k, v] of Object.entries(headers)) lower[k.toLowerCase()] = v; + return { rawBody, headers: lower, body: payload }; +} + +describe("getSignalSource registry", () => { + it("resolves all four providers and rejects unknown", () => { + expect(getSignalSource("webhook")).toBe(webhookSource); + expect(getSignalSource("sentry")).toBe(sentrySource); + expect(getSignalSource("datadog")).toBe(datadogSource); + expect(getSignalSource("pagerduty")).toBe(pagerdutySource); + expect(getSignalSource("bogus")).toBeUndefined(); + }); +}); + +describe("ingestSignal — generic webhook (must-work path)", () => { + it("creates one triage task for a valid signed payload", async () => { + const store = makeStore(); + const ts = Date.now(); + const payload = { id: "evt-1", title: "Disk full", severity: "critical", link: "https://ops.example.com/a" }; + const { rawBody, headers, body } = ctxFor(webhookSource, payload, { + "x-fusion-signature": sign(JSON.stringify(payload), SECRETS.FUSION_SIGNAL_WEBHOOK_SECRET), + "x-fusion-timestamp": String(ts), + }); + + const res = await ingestSignal({ + source: webhookSource, + store, + rawBody, + headers, + body, + nonceCache: new DeliveryNonceCache(), + }); + + expect(res.status).toBe(201); + expect(res.taskId).toBe("FN-1"); + expect(store._tasks).toHaveLength(1); + expect(store._tasks[0].column).toBe("triage"); + const meta = store._tasks[0].source?.sourceMetadata as Record; + expect(meta.signalSource).toBe("webhook"); + expect(meta.signalDeliveryId).toBe("evt-1"); + expect(meta.signalGroupingKey).toBe("webhook:disk full"); + }); + + it("rejects with 401 and creates no task when no secret is configured", async () => { + delete process.env.FUSION_SIGNAL_WEBHOOK_SECRET; + const store = makeStore(); + const payload = { id: "x", title: "y" }; + const { rawBody, headers, body } = ctxFor(webhookSource, payload, { + "x-fusion-signature": "whatever", + "x-fusion-timestamp": String(Date.now()), + }); + const res = await ingestSignal({ + source: webhookSource, + store, + rawBody, + headers, + body, + nonceCache: new DeliveryNonceCache(), + }); + expect(res.status).toBe(401); + expect(store._tasks).toHaveLength(0); + }); + + it("rejects with 401 on an invalid signature", async () => { + const store = makeStore(); + const payload = { id: "x", title: "y" }; + const { rawBody, headers, body } = ctxFor(webhookSource, payload, { + "x-fusion-signature": sign("tampered", SECRETS.FUSION_SIGNAL_WEBHOOK_SECRET), + "x-fusion-timestamp": String(Date.now()), + }); + const res = await ingestSignal({ + source: webhookSource, + store, + rawBody, + headers, + body, + nonceCache: new DeliveryNonceCache(), + }); + expect(res.status).toBe(401); + expect(store._tasks).toHaveLength(0); + }); + + it("rejects a stale timestamp (replay window)", async () => { + const store = makeStore(); + const payload = { id: "x", title: "y" }; + const stale = Date.now() - 10 * 60_000; + const { rawBody, headers, body } = ctxFor(webhookSource, payload, { + "x-fusion-signature": sign(JSON.stringify(payload), SECRETS.FUSION_SIGNAL_WEBHOOK_SECRET), + "x-fusion-timestamp": String(stale), + }); + const res = await ingestSignal({ + source: webhookSource, + store, + rawBody, + headers, + body, + nonceCache: new DeliveryNonceCache(), + }); + expect(res.status).toBe(401); + expect(store._tasks).toHaveLength(0); + }); + + it("rejects a replayed delivery nonce", async () => { + const store = makeStore(); + const nonceCache = new DeliveryNonceCache(); + const payload = { id: "dup", title: "y" }; + const headersInput = { + "x-fusion-signature": sign(JSON.stringify(payload), SECRETS.FUSION_SIGNAL_WEBHOOK_SECRET), + "x-fusion-timestamp": String(Date.now()), + }; + const first = ctxFor(webhookSource, payload, headersInput); + const r1 = await ingestSignal({ source: webhookSource, store, ...first, nonceCache }); + expect(r1.status).toBe(201); + const second = ctxFor(webhookSource, payload, headersInput); + const r2 = await ingestSignal({ source: webhookSource, store, ...second, nonceCache }); + expect(r2.status).toBe(401); + expect(store._tasks).toHaveLength(1); + }); + + it("dedupes a duplicate external id against existing tasks (no double-create)", async () => { + const store = makeStore(); + const payload = { id: "same-id", title: "y" }; + const mk = () => + ctxFor(webhookSource, payload, { + "x-fusion-signature": sign(JSON.stringify(payload), SECRETS.FUSION_SIGNAL_WEBHOOK_SECRET), + "x-fusion-timestamp": String(Date.now()), + }); + // Two separate nonce caches simulate a process restart (nonce dedup reset), + // so the persistent external-id dedup is what must catch the duplicate. + const r1 = await ingestSignal({ source: webhookSource, store, ...mk(), nonceCache: new DeliveryNonceCache() }); + expect(r1.status).toBe(201); + const r2 = await ingestSignal({ source: webhookSource, store, ...mk(), nonceCache: new DeliveryNonceCache() }); + expect(r2.status).toBe(200); + expect(r2.deduped).toBe(true); + expect(r2.taskId).toBe("FN-1"); + expect(store._tasks).toHaveLength(1); + }); + + it("returns 400 with no task on a malformed payload", async () => { + const store = makeStore(); + const payload = { nope: true }; // missing id/title + const { rawBody, headers, body } = ctxFor(webhookSource, payload, { + "x-fusion-signature": sign(JSON.stringify(payload), SECRETS.FUSION_SIGNAL_WEBHOOK_SECRET), + "x-fusion-timestamp": String(Date.now()), + }); + const res = await ingestSignal({ + source: webhookSource, + store, + rawBody, + headers, + body, + nonceCache: new DeliveryNonceCache(), + }); + expect(res.status).toBe(400); + expect(store._tasks).toHaveLength(0); + }); +}); + +describe("ingestSignal — Sentry adapter", () => { + it("creates one triage task with normalized title/severity/link + groupingKey from issue.id", async () => { + const store = makeStore(); + const payload = { + data: { + issue: { + id: "1234", + title: "TypeError: undefined is not a function", + level: "fatal", + web_url: "https://sentry.io/issues/1234", + shortId: "WEB-12", + project: "web", + }, + }, + timestamp: Date.now(), + }; + const raw = JSON.stringify(payload); + const res = await ingestSignal({ + source: sentrySource, + store, + rawBody: Buffer.from(raw), + headers: { "sentry-hook-signature": sign(raw, SECRETS.FUSION_SIGNAL_SENTRY_SECRET) }, + body: payload, + nonceCache: new DeliveryNonceCache(), + }); + expect(res.status).toBe(201); + const task = store._tasks[0]; + const meta = task.source?.sourceMetadata as Record; + expect(meta.signalGroupingKey).toBe("1234"); + expect(meta.signalSeverity).toBe("critical"); + expect(task.title).toContain("TypeError"); + }); + + it("rejects an unsigned Sentry webhook with 401", async () => { + const store = makeStore(); + const payload = { data: { issue: { id: "1", title: "x" } } }; + const res = await ingestSignal({ + source: sentrySource, + store, + rawBody: Buffer.from(JSON.stringify(payload)), + headers: {}, + body: payload, + nonceCache: new DeliveryNonceCache(), + }); + expect(res.status).toBe(401); + expect(store._tasks).toHaveLength(0); + }); +}); + +describe("ingestSignal — Datadog & PagerDuty adapters (groupingKey from native primitive)", () => { + it("Datadog uses aggreg_key as groupingKey", async () => { + const store = makeStore(); + const payload = { aggreg_key: "agg-7", event_id: "ev-7", title: "High CPU", alert_type: "error" }; + const raw = JSON.stringify(payload); + const res = await ingestSignal({ + source: datadogSource, + store, + rawBody: Buffer.from(raw), + headers: { "x-datadog-signature": sign(raw, SECRETS.FUSION_SIGNAL_DATADOG_SECRET) }, + body: payload, + nonceCache: new DeliveryNonceCache(), + }); + expect(res.status).toBe(201); + const meta = store._tasks[0].source?.sourceMetadata as Record; + expect(meta.signalGroupingKey).toBe("agg-7"); + expect(meta.signalDeliveryId).toBe("ev-7"); + }); + + it("PagerDuty uses incident.id as groupingKey", async () => { + const store = makeStore(); + const payload = { + event: { + id: "evt-pd-1", + event_type: "incident.triggered", + occurred_at: new Date().toISOString(), + data: { id: "PINC1", title: "DB down", urgency: "high", html_url: "https://pd.example.com/i/PINC1", status: "triggered" }, + }, + }; + const raw = JSON.stringify(payload); + const res = await ingestSignal({ + source: pagerdutySource, + store, + rawBody: Buffer.from(raw), + headers: { "x-pagerduty-signature": `v1=${sign(raw, SECRETS.FUSION_SIGNAL_PAGERDUTY_SECRET)}` }, + body: payload, + nonceCache: new DeliveryNonceCache(), + }); + expect(res.status).toBe(201); + const meta = store._tasks[0].source?.sourceMetadata as Record; + expect(meta.signalGroupingKey).toBe("PINC1"); + expect(meta.signalDeliveryId).toBe("evt-pd-1"); + }); +}); + +describe("helpers", () => { + it("resolveSignalSecret reads the provider env var", () => { + expect(resolveSignalSecret(webhookSource)).toBe("wh-secret"); + expect(resolveSignalSecret(webhookSource, {})).toBeUndefined(); + }); + + it("signalToTaskInput maps to a triage task with provenance metadata", () => { + const input = signalToTaskInput({ + source: "webhook", + externalId: "e", + groupingKey: "g", + title: "t", + severity: "critical", + }); + expect(input.column).toBe("triage"); + expect(input.priority).toBe("high"); + expect(input.source?.sourceType).toBe("api"); + }); +}); diff --git a/packages/dashboard/src/__tests__/signal-source.test.ts b/packages/dashboard/src/__tests__/signal-source.test.ts new file mode 100644 index 000000000..6a613f454 --- /dev/null +++ b/packages/dashboard/src/__tests__/signal-source.test.ts @@ -0,0 +1,124 @@ +// @vitest-environment node + +import { createHmac } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { + DeliveryNonceCache, + SignalRateLimiter, + applySignalCaps, + fallbackGroupingKey, + isSafeExternalUrl, + isWithinReplayWindow, + normalizeTitleForGrouping, + verifyHmacSignature, + type Signal, + SIGNAL_FIELD_CAPS, +} from "../signal-source.js"; + +function sign(body: string, secret: string): string { + return createHmac("sha256", secret).update(Buffer.from(body)).digest("hex"); +} + +describe("verifyHmacSignature", () => { + it("accepts a matching signature and rejects a wrong one", () => { + const body = Buffer.from(JSON.stringify({ a: 1 })); + const secret = "s3cr3t"; + const good = createHmac("sha256", secret).update(body).digest("hex"); + expect(verifyHmacSignature(body, good, secret)).toBe(true); + expect(verifyHmacSignature(body, good, "wrong")).toBe(false); + expect(verifyHmacSignature(body, undefined, secret)).toBe(false); + expect(verifyHmacSignature(body, "deadbeef", secret)).toBe(false); + }); +}); + +describe("isWithinReplayWindow", () => { + it("accepts recent timestamps and rejects stale or missing ones", () => { + const now = 1_000_000_000_000; + expect(isWithinReplayWindow(now, now)).toBe(true); + expect(isWithinReplayWindow(now - 4 * 60_000, now)).toBe(true); + expect(isWithinReplayWindow(now - 6 * 60_000, now)).toBe(false); + expect(isWithinReplayWindow(undefined, now)).toBe(false); + }); +}); + +describe("DeliveryNonceCache", () => { + it("rejects a replayed nonce within the window", () => { + const cache = new DeliveryNonceCache(1000); + expect(cache.check("a", 0)).toBe(true); + expect(cache.check("a", 500)).toBe(false); + // After TTL the nonce is evictable again. + expect(cache.check("a", 2000)).toBe(true); + }); +}); + +describe("SignalRateLimiter", () => { + it("caps a flood per source", () => { + const limiter = new SignalRateLimiter(1000, 3); + expect(limiter.allow("x", 0)).toBe(true); + expect(limiter.allow("x", 1)).toBe(true); + expect(limiter.allow("x", 2)).toBe(true); + expect(limiter.allow("x", 3)).toBe(false); + // A different source is independent. + expect(limiter.allow("y", 3)).toBe(true); + // After the window slides, capacity returns. + expect(limiter.allow("x", 2000)).toBe(true); + }); +}); + +describe("isSafeExternalUrl (SSRF guard)", () => { + it("rejects loopback, private, and non-http schemes; accepts public https", () => { + expect(isSafeExternalUrl("https://sentry.io/issues/1")).toBe(true); + expect(isSafeExternalUrl("http://example.com")).toBe(true); + expect(isSafeExternalUrl("https://localhost/x")).toBe(false); + expect(isSafeExternalUrl("http://127.0.0.1")).toBe(false); + expect(isSafeExternalUrl("http://10.0.0.5")).toBe(false); + expect(isSafeExternalUrl("http://192.168.1.1")).toBe(false); + expect(isSafeExternalUrl("http://169.254.169.254")).toBe(false); + expect(isSafeExternalUrl("file:///etc/passwd")).toBe(false); + expect(isSafeExternalUrl("javascript:alert(1)")).toBe(false); + expect(isSafeExternalUrl(undefined)).toBe(false); + }); +}); + +describe("grouping key fallback", () => { + it("derives source + normalized title", () => { + expect(normalizeTitleForGrouping(" Some ERROR ")).toBe("some error"); + expect(fallbackGroupingKey("webhook", "Disk Full!")).toBe("webhook:disk full!"); + }); +}); + +describe("applySignalCaps", () => { + it("truncates long fields and drops oversized meta + unsafe links", () => { + const signal: Signal = { + source: "webhook", + externalId: "e1", + groupingKey: "g1", + title: "x".repeat(SIGNAL_FIELD_CAPS.title + 50), + body: "y".repeat(SIGNAL_FIELD_CAPS.body + 50), + severity: "error", + link: "http://127.0.0.1/internal", + meta: { big: "z".repeat(SIGNAL_FIELD_CAPS.metaBytes + 100) }, + }; + const capped = applySignalCaps(signal); + expect(capped.title.length).toBe(SIGNAL_FIELD_CAPS.title); + expect(capped.body?.length).toBe(SIGNAL_FIELD_CAPS.body); + expect(capped.link).toBeUndefined(); // unsafe internal URL dropped + expect(capped.meta).toBeUndefined(); // oversized meta dropped + }); + + it("keeps a safe external link and small meta", () => { + const capped = applySignalCaps({ + source: "sentry", + externalId: "e1", + groupingKey: "g1", + title: "boom", + severity: "critical", + link: "https://sentry.io/issues/42", + meta: { project: "web" }, + }); + expect(capped.link).toBe("https://sentry.io/issues/42"); + expect(capped.meta).toEqual({ project: "web" }); + }); +}); + +export { sign }; diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index f5e9ca011..7ccbae277 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -168,6 +168,7 @@ import { registerProxyRoutes } from "./routes/register-proxy-routes.js"; import { registerModelRoutes } from "./routes/register-model-routes.js"; import { registerCustomProviderRoutes } from "./routes/register-custom-provider-routes.js"; import { registerUsageRoutes } from "./routes/register-usage-routes.js"; +import { registerSignalRoutes } from "./routes/register-signal-routes.js"; import { registerAuthRoutes } from "./routes/register-auth-routes.js"; import { registerRuntimeProviderRoutes } from "./routes/register-runtime-provider-routes.js"; import { registerFnBinaryRoutes } from "./routes/register-fn-binary-routes.js"; @@ -1989,6 +1990,10 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout }); registerUsageRoutes(routeContext); + // U11 — inbound external signal webhooks (Sentry/Datadog/PagerDuty/generic). + // Each route HMAC-verifies against a per-provider secret; never an + // unauthenticated task-creation endpoint. + registerSignalRoutes(routeContext); registerUpdateCheckRoutes(routeContext); registerDiagnosticsRoutes(routeContext); // CLI Agent Executor hook ingestion (U17) — per-session token auth, exempt from diff --git a/packages/dashboard/src/routes/register-signal-routes.ts b/packages/dashboard/src/routes/register-signal-routes.ts new file mode 100644 index 000000000..ec2b7f629 --- /dev/null +++ b/packages/dashboard/src/routes/register-signal-routes.ts @@ -0,0 +1,238 @@ +import type { Request, Response } from "express"; +import type { Task, TaskStore } from "@fusion/core"; +import { ApiError, badRequest, rateLimited, unauthorized } from "../api-error.js"; +import { + DeliveryNonceCache, + SIGNAL_MAX_BODY_BYTES, + SignalRateLimiter, + type Signal, + type SignalProvider, + type SignalSource, +} from "../signal-source.js"; +import { webhookSource } from "../signal-sources/webhook.js"; +import { sentrySource } from "../signal-sources/sentry.js"; +import { datadogSource } from "../signal-sources/datadog.js"; +import { pagerdutySource } from "../signal-sources/pagerduty.js"; +import type { ApiRouteRegistrar } from "./types.js"; + +/** + * U11 — inbound external-signal webhook routes. + * + * Mounts `POST /api/signals/:provider` for each supported provider. Each request + * is HMAC-verified by the provider adapter against a per-provider secret sourced + * from the environment (never source-controlled). Verified, normalized signals + * create a task in the `triage` column via the scoped task store, mirroring the + * GitHub ingestion path. + * + * Security applied here (mandatory, not deferred): + * - mandatory HMAC verify → 401 on missing/invalid secret or signature + * - body-size cap (~1 MB) → 413 + * - per-source rate limit → 429 + * - delivery-id nonce dedup (replay) → 401 + * - persistent external-id dedup against existing tasks → 200, no new task + * - field-length caps + meta-byte cap applied in the adapter (applySignalCaps) + * - URLs are SSRF-untrusted (stored as data only; unsafe links dropped) + * - `meta` stored as JSON data, never rendered as raw HTML + */ + +/** Thin registry — kept minimal per scope discipline (no heavy abstraction). */ +const SIGNAL_SOURCES: Record = { + webhook: webhookSource, + sentry: sentrySource, + datadog: datadogSource, + pagerduty: pagerdutySource, +}; + +export function getSignalSource(provider: string): SignalSource | undefined { + return SIGNAL_SOURCES[provider as SignalProvider]; +} + +/** + * Resolve a provider's HMAC secret. Env var is the canonical, never + * source-controlled source. An optional resolver (e.g. encrypted settings) can + * be supplied for deployments that store secrets there. + */ +export function resolveSignalSecret( + source: SignalSource, + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + const value = env[source.secretEnvVar]; + return value && value.length > 0 ? value : undefined; +} + +const SIGNAL_DELIVERY_META_KEY = "signalDeliveryId"; +const SIGNAL_GROUPING_META_KEY = "signalGroupingKey"; +const SIGNAL_SOURCE_META_KEY = "signalSource"; + +/** + * Persistent delivery dedup: has a task already been created for this provider + + * external id? Scans recent tasks for the provenance marker. Mirrors the spirit + * of `github-tracking-dedup.ts` for the inbound path. + */ +async function findExistingSignalTask( + store: TaskStore, + provider: SignalProvider, + externalId: string, +): Promise { + const tasks = await store.listTasks({ slim: true, includeArchived: true }); + return tasks.find((t) => { + const meta = t.source?.sourceMetadata as Record | undefined; + return ( + meta?.[SIGNAL_SOURCE_META_KEY] === provider && + meta?.[SIGNAL_DELIVERY_META_KEY] === externalId + ); + }); +} + +/** Build a task-create input from a normalized signal. */ +export function signalToTaskInput(signal: Signal): Parameters[0] { + const lines: string[] = []; + if (signal.body) lines.push(signal.body); + if (signal.link) lines.push(`\nSource: ${signal.link}`); + lines.push(`\nSeverity: ${signal.severity}`); + const description = `${signal.title}\n\n${lines.join("\n")}`.trim(); + + return { + title: signal.title, + description, + column: "triage", + priority: signal.severity === "critical" ? "high" : undefined, + source: { + // Reuse the existing `api` source type — signals arrive over the API + // webhook surface. Provenance is carried in sourceMetadata so we do not + // need a core schema/type change for U11. + sourceType: "api", + sourceMetadata: { + [SIGNAL_SOURCE_META_KEY]: signal.source, + [SIGNAL_DELIVERY_META_KEY]: signal.externalId, + [SIGNAL_GROUPING_META_KEY]: signal.groupingKey, + signalSeverity: signal.severity, + signalLink: signal.link, + // `meta` is stored as data only and never rendered as raw HTML. + signalMeta: signal.meta, + }, + }, + }; +} + +/** + * Pure ingestion core: verify → dedup → normalize → create task. Exposed for + * unit testing without the full express app. + */ +export interface SignalIngestDeps { + source: SignalSource; + store: TaskStore; + rawBody: Buffer; + headers: Record; + body: unknown; + nonceCache: DeliveryNonceCache; +} + +export interface SignalIngestResult { + status: number; + taskId?: string; + deduped?: boolean; + error?: string; +} + +export async function ingestSignal(deps: SignalIngestDeps): Promise { + const { source, store, rawBody, headers, body, nonceCache } = deps; + const secret = resolveSignalSecret(source); + + // 1. Mandatory HMAC verification. Missing/invalid secret or signature → 401. + const verification = source.verify({ rawBody, headers, secret }); + if (!verification.valid) { + return { status: verification.status ?? 401, error: verification.error ?? "Unauthorized" }; + } + + // 2. Normalize (malformed payload → throw → caller maps to 4xx, no task). + let signal: Signal | null; + try { + signal = source.normalize(body, { rawBody, headers, secret }); + } catch (err) { + return { status: 400, error: err instanceof Error ? err.message : "Malformed payload" }; + } + if (!signal) { + // Valid-but-not-actionable (e.g. ping/health) → accepted, no task. + return { status: 200 }; + } + + // 3. Replay nonce dedup (same delivery id within the replay window) → 401. + if (!nonceCache.check(`${signal.source}:${signal.externalId}`)) { + return { status: 401, error: "Replayed delivery rejected" }; + } + + // 4. Persistent external-id dedup → 200 with the existing task, no new task. + const existing = await findExistingSignalTask(store, signal.source, signal.externalId); + if (existing) { + return { status: 200, taskId: existing.id, deduped: true }; + } + + // 5. Create the triage task. + const task = await store.createTask(signalToTaskInput(signal)); + return { status: 201, taskId: task.id }; +} + +export const registerSignalRoutes: ApiRouteRegistrar = (ctx) => { + const { router, getScopedStore } = ctx; + + // Shared per-process state for replay dedup + rate limiting. + const nonceCache = new DeliveryNonceCache(); + const rateLimiter = new SignalRateLimiter(); + + router.post("/signals/:provider", async (req: Request, res: Response) => { + const provider = Array.isArray(req.params.provider) + ? req.params.provider[0] + : req.params.provider; + + const source = getSignalSource(provider); + if (!source) { + throw badRequest(`Unknown signal provider: ${String(provider)}`); + } + + // Body-size cap (~1 MB) → 413. + const rawBody = (req as Request & { rawBody?: Buffer }).rawBody; + if (rawBody && rawBody.byteLength > SIGNAL_MAX_BODY_BYTES) { + throw new ApiError(413, "Signal payload too large"); + } + + // Per-source rate limit → 429. + if (!rateLimiter.allow(source.provider)) { + throw rateLimited(`Rate limit exceeded for signal source: ${source.provider}`); + } + + if (!rawBody) { + // Without the raw body we cannot HMAC-verify — never create a task. + throw unauthorized("Raw body not available for signature verification"); + } + + const headers: Record = {}; + for (const [key, value] of Object.entries(req.headers)) { + headers[key.toLowerCase()] = Array.isArray(value) ? value[0] : value; + } + + const store = await getScopedStore(req); + + const result = await ingestSignal({ + source, + store, + rawBody, + headers, + body: req.body, + nonceCache, + }); + + if (result.status === 401) { + throw unauthorized(result.error ?? "Unauthorized"); + } + if (result.status === 400) { + throw badRequest(result.error ?? "Malformed payload"); + } + + res.status(result.status).json({ + ok: result.status < 400, + taskId: result.taskId, + deduped: result.deduped ?? false, + }); + }); +}; diff --git a/packages/dashboard/src/signal-source.ts b/packages/dashboard/src/signal-source.ts new file mode 100644 index 000000000..b0319f7d4 --- /dev/null +++ b/packages/dashboard/src/signal-source.ts @@ -0,0 +1,300 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + +/** + * U11 — External signal ingestion seam. + * + * This module defines the common `SignalSource` adapter interface plus the + * shared security primitives (HMAC verification, replay window, nonce dedup, + * body-size cap, field-length caps, SSRF-untrusted URL handling) that every + * provider adapter reuses. It mirrors the GitHub ingestion path + * (`github-webhooks.ts`) which also lives in `packages/dashboard/src`. + * + * Scope discipline (per the plan): the generic webhook adapter is the + * must-work path. Sentry/Datadog/PagerDuty are thin adapters that supply + * provider-specific HMAC verification + payload normalization. We deliberately + * keep the registry thin — adapters are looked up by a small map, no heavy + * abstraction until more providers exist. + */ + +/** Normalized severity for an ingested signal. */ +export type SignalSeverity = "critical" | "error" | "warning" | "info"; + +/** Supported external signal providers. */ +export type SignalProvider = "sentry" | "datadog" | "pagerduty" | "webhook"; + +/** + * Field-length caps applied to every normalized {@link Signal} before it is + * turned into a task. External input is never trusted — caps bound storage and + * prevent abuse via oversized fields. + */ +export const SIGNAL_FIELD_CAPS = { + title: 300, + body: 8_000, + /** Cap on the serialized `meta` JSON (bytes). */ + metaBytes: 4_096, + groupingKey: 256, + link: 2_048, +} as const; + +/** Maximum accepted request body size for any signal webhook (bytes, ~1 MB). */ +export const SIGNAL_MAX_BODY_BYTES = 1_048_576; + +/** Replay window: reject signed payloads whose timestamp is outside ±5 min. */ +export const SIGNAL_REPLAY_WINDOW_MS = 5 * 60 * 1_000; + +/** + * A normalized external signal. Provider adapters map their native payloads + * onto this shape. Downstream (U13 storm guard) groups re-firing signals by + * {@link Signal.groupingKey}. + */ +export interface Signal { + /** Source provider that produced this signal. */ + source: SignalProvider; + /** Stable provider-specific external id (used for delivery dedup). */ + externalId: string; + /** + * Grouping primitive used by the storm guard to collapse re-firing signals + * (Sentry issue.id, PagerDuty incident.id, Datadog monitor key). The generic + * webhook requires the caller to supply one; otherwise it falls back to + * `source + normalized-title` (see {@link fallbackGroupingKey}). + */ + groupingKey: string; + /** Short human-visible title. */ + title: string; + /** Optional longer description / detail. */ + body?: string; + /** Normalized severity. */ + severity: SignalSeverity; + /** + * Optional canonical URL back to the source. Treated as SSRF-untrusted: it is + * stored as data and only rendered as an external link, never fetched server + * side. See {@link isSafeExternalUrl}. + */ + link?: string; + /** Provider event timestamp (epoch ms), used for the replay window. */ + timestamp?: number; + /** + * Non-rendered descriptor data carried from the source. Stored as JSON data + * only — never rendered as raw HTML in the dashboard. Capped to + * {@link SIGNAL_FIELD_CAPS.metaBytes}. + */ + meta?: Record; +} + +/** Context passed to an adapter's {@link SignalSource.verify}. */ +export interface SignalVerifyContext { + /** Raw request body bytes (required for HMAC). */ + rawBody: Buffer; + /** Lower-cased request headers. */ + headers: Record; + /** Per-provider secret resolved from env / encrypted settings. */ + secret: string | undefined; +} + +/** Result of an adapter's signature verification. */ +export interface SignalVerifyResult { + valid: boolean; + /** HTTP status to return on failure (always 401 for auth failures). */ + status?: number; + error?: string; +} + +/** + * A provider adapter. Kept intentionally thin: a mandatory `verify(ctx)` (HMAC) + * plus a `normalize(payload)` that yields a {@link Signal} (or `null` for a + * payload that is valid but not actionable, e.g. a ping/health event). + */ +export interface SignalSource { + readonly provider: SignalProvider; + /** + * The env var name carrying this provider's HMAC secret. Secrets are NEVER + * source-controlled; they come from the environment (or encrypted settings). + */ + readonly secretEnvVar: string; + /** Mandatory HMAC signature verification against a per-provider secret. */ + verify(ctx: SignalVerifyContext): SignalVerifyResult; + /** + * Normalize a parsed payload into a {@link Signal}. Throws (or returns null) + * for malformed/non-actionable payloads — callers translate a throw into a + * 4xx with no task created. + */ + normalize(payload: unknown, ctx: SignalVerifyContext): Signal | null; +} + +// ── Shared security helpers ──────────────────────────────────────────────── + +/** + * Constant-time comparison of a computed HMAC against a provided signature. + * `signatureHex` may carry a `sha256=` / `v1=` style prefix-stripped value. + */ +export function verifyHmacSignature( + rawBody: Buffer, + signatureHex: string | undefined, + secret: string, +): boolean { + if (!signatureHex) return false; + const expected = createHmac("sha256", secret).update(rawBody).digest("hex"); + if (signatureHex.length !== expected.length) return false; + try { + return timingSafeEqual(Buffer.from(signatureHex), Buffer.from(expected)); + } catch { + return false; + } +} + +/** True when the provider event timestamp is inside the replay window. */ +export function isWithinReplayWindow( + timestampMs: number | undefined, + nowMs: number = Date.now(), + windowMs: number = SIGNAL_REPLAY_WINDOW_MS, +): boolean { + if (timestampMs === undefined || !Number.isFinite(timestampMs)) { + // No timestamp → cannot bound replay; reject to stay safe. + return false; + } + return Math.abs(nowMs - timestampMs) <= windowMs; +} + +/** + * In-memory delivery-id nonce store with TTL eviction. Used to reject replayed + * deliveries (same external/delivery id) within the replay window. Mirrors the + * spirit of `github-tracking-dedup.ts` for the inbound path. + */ +export class DeliveryNonceCache { + private readonly seen = new Map(); + constructor(private readonly ttlMs: number = SIGNAL_REPLAY_WINDOW_MS) {} + + /** Returns true if this is a fresh delivery; false if a replay. */ + check(nonce: string, nowMs: number = Date.now()): boolean { + this.evict(nowMs); + if (this.seen.has(nonce)) return false; + this.seen.set(nonce, nowMs); + return true; + } + + private evict(nowMs: number): void { + for (const [key, ts] of this.seen) { + if (nowMs - ts > this.ttlMs) this.seen.delete(key); + } + } + + /** Test/diagnostic helper. */ + size(): number { + return this.seen.size; + } +} + +/** + * Per-source sliding-window rate limiter (in-memory). Caps a flood of inbound + * signals from a single provider. + */ +export class SignalRateLimiter { + private readonly hits = new Map(); + constructor( + private readonly windowMs: number = 60_000, + private readonly max: number = 120, + ) {} + + /** Returns true if the request is allowed; false if over the cap. */ + allow(key: string, nowMs: number = Date.now()): boolean { + const cutoff = nowMs - this.windowMs; + const arr = (this.hits.get(key) ?? []).filter((t) => t > cutoff); + if (arr.length >= this.max) { + this.hits.set(key, arr); + return false; + } + arr.push(nowMs); + this.hits.set(key, arr); + return true; + } +} + +/** + * SSRF guard for URLs found in payloads. We never fetch these URLs; this only + * gates whether a link is safe to store/surface as an external link. Rejects + * non-http(s) schemes and obvious internal/loopback/private hosts. + */ +export function isSafeExternalUrl(url: string | undefined): boolean { + if (!url) return false; + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return false; + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return false; + const host = parsed.hostname.toLowerCase(); + if ( + host === "localhost" || + host === "0.0.0.0" || + host === "::1" || + host.endsWith(".localhost") || + host.endsWith(".internal") || + host.endsWith(".local") + ) { + return false; + } + // IPv4 private / loopback / link-local ranges. + const ipv4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (ipv4) { + const [a, b] = [Number(ipv4[1]), Number(ipv4[2])]; + if (a === 10) return false; + if (a === 127) return false; + if (a === 169 && b === 254) return false; + if (a === 172 && b >= 16 && b <= 31) return false; + if (a === 192 && b === 168) return false; + } + return true; +} + +/** Truncate a string to a cap, trimming whitespace. */ +function capString(value: string, cap: number): string { + const trimmed = value.trim(); + return trimmed.length > cap ? trimmed.slice(0, cap) : trimmed; +} + +/** Normalize a title for the fallback grouping key (lower-case, collapsed). */ +export function normalizeTitleForGrouping(title: string): string { + return title.trim().toLowerCase().replace(/\s+/g, " "); +} + +/** + * Generic-webhook grouping-key fallback: `source + normalized-title` when the + * caller does not supply an explicit grouping key. + */ +export function fallbackGroupingKey(source: SignalProvider, title: string): string { + return `${source}:${normalizeTitleForGrouping(title)}`; +} + +/** + * Apply field-length caps and meta-byte cap to a normalized signal. Drops a + * `link` that is not an SSRF-safe external URL (kept as data only otherwise via + * caller choice — here we drop unsafe links entirely). Returns a new object. + */ +export function applySignalCaps(signal: Signal): Signal { + let meta = signal.meta; + if (meta) { + let serialized = ""; + try { + serialized = JSON.stringify(meta); + } catch { + serialized = ""; + } + if (!serialized || Buffer.byteLength(serialized, "utf8") > SIGNAL_FIELD_CAPS.metaBytes) { + // Oversized or unserializable meta is dropped rather than truncated mid-JSON. + meta = undefined; + } + } + const link = + signal.link && isSafeExternalUrl(signal.link) + ? capString(signal.link, SIGNAL_FIELD_CAPS.link) + : undefined; + return { + ...signal, + title: capString(signal.title, SIGNAL_FIELD_CAPS.title) || "(untitled signal)", + body: signal.body ? capString(signal.body, SIGNAL_FIELD_CAPS.body) : undefined, + groupingKey: capString(signal.groupingKey, SIGNAL_FIELD_CAPS.groupingKey), + link, + meta, + }; +} diff --git a/packages/dashboard/src/signal-sources/datadog.ts b/packages/dashboard/src/signal-sources/datadog.ts new file mode 100644 index 000000000..b87ed0498 --- /dev/null +++ b/packages/dashboard/src/signal-sources/datadog.ts @@ -0,0 +1,103 @@ +import { + applySignalCaps, + isWithinReplayWindow, + verifyHmacSignature, + type Signal, + type SignalSeverity, + type SignalSource, + type SignalVerifyContext, + type SignalVerifyResult, +} from "../signal-source.js"; + +/** + * Datadog adapter (scaffold). + * + * Datadog webhooks don't ship a built-in HMAC header, so the convention is to + * include a shared-secret HMAC the user templates into a custom header + * (`X-Datadog-Signature` = HMAC-SHA256(hex) of the raw body). `groupingKey` is + * the Datadog monitor/aggregation key (`alert_id` / `aggreg_key`). + */ + +function mapAlertType(value: unknown): SignalSeverity { + switch (value) { + case "error": + return "critical"; + case "warning": + case "warn": + return "warning"; + case "success": + case "recovery": + case "info": + return "info"; + default: + return "error"; + } +} + +export const datadogSource: SignalSource = { + provider: "datadog", + secretEnvVar: "FUSION_SIGNAL_DATADOG_SECRET", + + verify(ctx: SignalVerifyContext): SignalVerifyResult { + if (!ctx.secret) { + return { valid: false, status: 401, error: "Datadog signing secret is not configured" }; + } + const signature = ctx.headers["x-datadog-signature"]; + if (!signature) { + return { valid: false, status: 401, error: "Missing X-Datadog-Signature header" }; + } + if (!verifyHmacSignature(ctx.rawBody, signature, ctx.secret)) { + return { valid: false, status: 401, error: "Invalid signature" }; + } + const tsHeader = ctx.headers["x-datadog-timestamp"]; + if (tsHeader && !isWithinReplayWindow(Number(tsHeader))) { + return { valid: false, status: 401, error: "Timestamp outside replay window" }; + } + return { valid: true }; + }, + + normalize(payload: unknown): Signal | null { + if (!payload || typeof payload !== "object") { + throw new Error("Payload must be a JSON object"); + } + const p = payload as Record; + + const groupingKey = + (typeof p.aggreg_key === "string" && p.aggreg_key) || + (typeof p.alert_id === "string" && p.alert_id) || + (typeof p.id === "string" && p.id) || + ""; + if (!groupingKey) throw new Error("Missing Datadog aggreg_key/alert_id"); + + const externalId = + (typeof p.event_id === "string" && p.event_id) || + (typeof p.id === "string" && p.id) || + groupingKey; + + const title = + (typeof p.title === "string" && p.title) || + (typeof p.event_title === "string" && p.event_title) || + `Datadog alert ${groupingKey}`; + + const signal: Signal = { + source: "datadog", + externalId, + groupingKey, + title, + body: typeof p.body === "string" ? p.body : typeof p.text_only_msg === "string" ? p.text_only_msg : undefined, + severity: mapAlertType(p.alert_type), + link: typeof p.link === "string" ? p.link : typeof p.url === "string" ? p.url : undefined, + timestamp: + typeof p.date === "number" + ? p.date + : typeof p.last_updated === "number" + ? p.last_updated + : undefined, + meta: { + priority: typeof p.priority === "string" ? p.priority : undefined, + scope: typeof p.scope === "string" ? p.scope : undefined, + }, + }; + return applySignalCaps(signal); + }, +}; diff --git a/packages/dashboard/src/signal-sources/pagerduty.ts b/packages/dashboard/src/signal-sources/pagerduty.ts new file mode 100644 index 000000000..3ac224cce --- /dev/null +++ b/packages/dashboard/src/signal-sources/pagerduty.ts @@ -0,0 +1,95 @@ +import { + applySignalCaps, + isWithinReplayWindow, + verifyHmacSignature, + type Signal, + type SignalSeverity, + type SignalSource, + type SignalVerifyContext, + type SignalVerifyResult, +} from "../signal-source.js"; + +/** + * PagerDuty adapter (scaffold). + * + * PagerDuty v3 webhooks sign with `X-PagerDuty-Signature: v1=` = + * HMAC-SHA256 of the raw body using the subscription secret. `groupingKey` is + * the PagerDuty `incident.id` (native dedup primitive for U13's storm guard). + */ + +function mapUrgency(urgency: unknown, severity: unknown): SignalSeverity { + if (severity === "critical") return "critical"; + if (severity === "error") return "error"; + if (severity === "warning") return "warning"; + if (severity === "info") return "info"; + return urgency === "high" ? "critical" : "warning"; +} + +function parsePagerDutySignatureHeader(header: string | undefined): string | undefined { + if (!header) return undefined; + // Header may carry multiple comma-separated `v1=` signatures (key rotation). + for (const part of header.split(",")) { + const trimmed = part.trim(); + if (trimmed.startsWith("v1=")) return trimmed.slice("v1=".length); + } + return undefined; +} + +export const pagerdutySource: SignalSource = { + provider: "pagerduty", + secretEnvVar: "FUSION_SIGNAL_PAGERDUTY_SECRET", + + verify(ctx: SignalVerifyContext): SignalVerifyResult { + if (!ctx.secret) { + return { valid: false, status: 401, error: "PagerDuty signing secret is not configured" }; + } + const signature = parsePagerDutySignatureHeader(ctx.headers["x-pagerduty-signature"]); + if (!signature) { + return { valid: false, status: 401, error: "Missing X-PagerDuty-Signature header" }; + } + if (!verifyHmacSignature(ctx.rawBody, signature, ctx.secret)) { + return { valid: false, status: 401, error: "Invalid signature" }; + } + return { valid: true }; + }, + + normalize(payload: unknown): Signal | null { + if (!payload || typeof payload !== "object") { + throw new Error("Payload must be a JSON object"); + } + const p = payload as Record; + const event = (p.event as Record | undefined) ?? p; + const data = (event.data as Record | undefined) ?? event; + + const incidentId = + (typeof data.id === "string" && data.id) || + (typeof p.id === "string" && p.id) || + ""; + if (!incidentId) throw new Error("Missing PagerDuty incident.id"); + + const title = + (typeof data.title === "string" && data.title) || + (typeof data.summary === "string" && data.summary) || + `PagerDuty incident ${incidentId}`; + + const eventId = + typeof event.id === "string" ? event.id : incidentId; + + const signal: Signal = { + source: "pagerduty", + externalId: eventId, + groupingKey: incidentId, + title, + body: typeof data.description === "string" ? data.description : undefined, + severity: mapUrgency(data.urgency, data.severity), + link: typeof data.html_url === "string" ? data.html_url : undefined, + timestamp: + typeof event.occurred_at === "string" ? Date.parse(event.occurred_at) : undefined, + meta: { + eventType: typeof event.event_type === "string" ? event.event_type : undefined, + status: typeof data.status === "string" ? data.status : undefined, + }, + }; + return applySignalCaps(signal); + }, +}; diff --git a/packages/dashboard/src/signal-sources/sentry.ts b/packages/dashboard/src/signal-sources/sentry.ts new file mode 100644 index 000000000..c2fb3c801 --- /dev/null +++ b/packages/dashboard/src/signal-sources/sentry.ts @@ -0,0 +1,117 @@ +import { + applySignalCaps, + isWithinReplayWindow, + verifyHmacSignature, + type Signal, + type SignalSeverity, + type SignalSource, + type SignalVerifyContext, + type SignalVerifyResult, +} from "../signal-source.js"; + +/** + * Sentry adapter (scaffold). + * + * Sentry signs webhooks with `Sentry-Hook-Signature` = HMAC-SHA256(hex) of the + * raw request body using the integration's client secret. `groupingKey` is the + * Sentry `issue.id` (its native dedup primitive) — used by U13's storm guard. + */ + +function mapLevel(level: unknown): SignalSeverity { + switch (level) { + case "fatal": + case "critical": + return "critical"; + case "error": + return "error"; + case "warning": + return "warning"; + case "info": + case "debug": + return "info"; + default: + return "error"; + } +} + +export const sentrySource: SignalSource = { + provider: "sentry", + secretEnvVar: "FUSION_SIGNAL_SENTRY_SECRET", + + verify(ctx: SignalVerifyContext): SignalVerifyResult { + if (!ctx.secret) { + return { valid: false, status: 401, error: "Sentry signing secret is not configured" }; + } + const signature = ctx.headers["sentry-hook-signature"]; + if (!signature) { + return { valid: false, status: 401, error: "Missing Sentry-Hook-Signature header" }; + } + if (!verifyHmacSignature(ctx.rawBody, signature, ctx.secret)) { + return { valid: false, status: 401, error: "Invalid signature" }; + } + // Sentry sends `Sentry-Hook-Timestamp` (epoch ms) on installation events; + // when absent on issue events we fall back to the payload timestamp checked + // during normalize. Reject only when an explicit header is stale. + const tsHeader = ctx.headers["sentry-hook-timestamp"]; + if (tsHeader && !isWithinReplayWindow(Number(tsHeader))) { + return { valid: false, status: 401, error: "Timestamp outside replay window" }; + } + return { valid: true }; + }, + + normalize(payload: unknown): Signal | null { + if (!payload || typeof payload !== "object") { + throw new Error("Payload must be a JSON object"); + } + const p = payload as Record; + const data = (p.data as Record | undefined) ?? p; + const issue = + (data.issue as Record | undefined) ?? + (data.error as Record | undefined) ?? + (data.event as Record | undefined); + if (!issue || typeof issue !== "object") { + throw new Error("Missing Sentry issue/event data"); + } + + const issueId = + typeof issue.id === "string" + ? issue.id + : typeof issue.id === "number" + ? String(issue.id) + : ""; + if (!issueId) throw new Error("Missing Sentry issue.id"); + + const title = + (typeof issue.title === "string" && issue.title) || + (typeof issue.culprit === "string" && issue.culprit) || + `Sentry issue ${issueId}`; + + const link = + typeof issue.web_url === "string" + ? issue.web_url + : typeof issue.permalink === "string" + ? issue.permalink + : undefined; + + const signal: Signal = { + source: "sentry", + externalId: issueId, + groupingKey: issueId, + title, + body: typeof issue.culprit === "string" ? issue.culprit : undefined, + severity: mapLevel(issue.level), + link, + timestamp: + typeof p.timestamp === "number" + ? p.timestamp + : typeof issue.lastSeen === "string" + ? Date.parse(issue.lastSeen) + : undefined, + meta: { + project: typeof issue.project === "string" ? issue.project : undefined, + shortId: typeof issue.shortId === "string" ? issue.shortId : undefined, + }, + }; + return applySignalCaps(signal); + }, +}; diff --git a/packages/dashboard/src/signal-sources/webhook.ts b/packages/dashboard/src/signal-sources/webhook.ts new file mode 100644 index 000000000..1bea7cdb7 --- /dev/null +++ b/packages/dashboard/src/signal-sources/webhook.ts @@ -0,0 +1,97 @@ +import { + applySignalCaps, + fallbackGroupingKey, + isWithinReplayWindow, + verifyHmacSignature, + type Signal, + type SignalSource, + type SignalVerifyContext, + type SignalVerifyResult, + type SignalSeverity, +} from "../signal-source.js"; + +/** + * Generic webhook adapter — the must-work path (per the plan's scope + * discipline). It is NEVER an unauthenticated task-creation endpoint: a missing + * or invalid secret/signature is rejected with 401. + * + * Signature: HMAC-SHA256 of the raw body, hex-encoded, in the + * `X-Fusion-Signature` header (optionally `sha256=`-prefixed). + * Timestamp: `X-Fusion-Timestamp` (epoch ms) drives the replay window. + * + * Payload contract (JSON): + * { + * "id": "", // required + * "title": "", // required + * "body"?: "...", + * "severity"?: "critical|error|warning|info", + * "link"?: "https://...", + * "groupingKey"?: "", // else falls back to source+title + * "timestamp"?: , + * "meta"?: { ... } + * } + */ + +const SEVERITIES: SignalSeverity[] = ["critical", "error", "warning", "info"]; + +function coerceSeverity(value: unknown): SignalSeverity { + return typeof value === "string" && (SEVERITIES as string[]).includes(value) + ? (value as SignalSeverity) + : "warning"; +} + +function stripSig(header: string | undefined): string | undefined { + if (!header) return undefined; + return header.startsWith("sha256=") ? header.slice("sha256=".length) : header; +} + +export const webhookSource: SignalSource = { + provider: "webhook", + secretEnvVar: "FUSION_SIGNAL_WEBHOOK_SECRET", + + verify(ctx: SignalVerifyContext): SignalVerifyResult { + if (!ctx.secret) { + return { valid: false, status: 401, error: "Webhook signing secret is not configured" }; + } + const signature = stripSig(ctx.headers["x-fusion-signature"]); + if (!signature) { + return { valid: false, status: 401, error: "Missing signature header" }; + } + if (!verifyHmacSignature(ctx.rawBody, signature, ctx.secret)) { + return { valid: false, status: 401, error: "Invalid signature" }; + } + const tsHeader = ctx.headers["x-fusion-timestamp"]; + const ts = tsHeader ? Number(tsHeader) : undefined; + if (!isWithinReplayWindow(ts)) { + return { valid: false, status: 401, error: "Timestamp outside replay window" }; + } + return { valid: true }; + }, + + normalize(payload: unknown): Signal | null { + if (!payload || typeof payload !== "object") { + throw new Error("Payload must be a JSON object"); + } + const p = payload as Record; + const externalId = typeof p.id === "string" ? p.id.trim() : ""; + const title = typeof p.title === "string" ? p.title.trim() : ""; + if (!externalId) throw new Error("Missing required field: id"); + if (!title) throw new Error("Missing required field: title"); + + const supplied = typeof p.groupingKey === "string" ? p.groupingKey.trim() : ""; + const groupingKey = supplied || fallbackGroupingKey("webhook", title); + + const signal: Signal = { + source: "webhook", + externalId, + groupingKey, + title, + body: typeof p.body === "string" ? p.body : undefined, + severity: coerceSeverity(p.severity), + link: typeof p.link === "string" ? p.link : undefined, + timestamp: typeof p.timestamp === "number" ? p.timestamp : undefined, + meta: p.meta && typeof p.meta === "object" ? (p.meta as Record) : undefined, + }; + return applySignalCaps(signal); + }, +}; From 53bb1d8f37561f86a7d6dbf615b53db90fcd350e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 15 Jun 2026 19:27:45 -0700 Subject: [PATCH 05/21] =?UTF-8?q?feat(analytics):=20U2=20=E2=80=94=20core?= =?UTF-8?q?=20date-range=20aggregators=20(tokens/tools/activity/productivi?= =?UTF-8?q?ty)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reusable aggregate() over tasks + usage_events with model/provider/node/agent grouping. Autonomy ratio sources interventions from approval audit events + user-authored steers (agent steers excluded); fully-autonomous sessions report tool-calls-per-session, never divide-by-zero. LOC/MTTR seams flagged unavailable. --- .../src/__tests__/activity-analytics.test.ts | 91 ++++++++ .../__tests__/productivity-analytics.test.ts | 103 ++++++++ .../src/__tests__/token-analytics.test.ts | 157 +++++++++++++ .../core/src/__tests__/tool-analytics.test.ts | 146 ++++++++++++ packages/core/src/activity-analytics.ts | 193 +++++++++++++++ packages/core/src/index.ts | 31 +++ packages/core/src/productivity-analytics.ts | 175 ++++++++++++++ packages/core/src/token-analytics.ts | 175 ++++++++++++++ packages/core/src/tool-analytics.ts | 221 ++++++++++++++++++ 9 files changed, 1292 insertions(+) create mode 100644 packages/core/src/__tests__/activity-analytics.test.ts create mode 100644 packages/core/src/__tests__/productivity-analytics.test.ts create mode 100644 packages/core/src/__tests__/token-analytics.test.ts create mode 100644 packages/core/src/__tests__/tool-analytics.test.ts create mode 100644 packages/core/src/activity-analytics.ts create mode 100644 packages/core/src/productivity-analytics.ts create mode 100644 packages/core/src/token-analytics.ts create mode 100644 packages/core/src/tool-analytics.ts diff --git a/packages/core/src/__tests__/activity-analytics.test.ts b/packages/core/src/__tests__/activity-analytics.test.ts new file mode 100644 index 000000000..76f26085e --- /dev/null +++ b/packages/core/src/__tests__/activity-analytics.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { Database } from "../db.js"; +import { emitUsageEvent } from "../usage-events.js"; +import { aggregateActivityAnalytics } from "../activity-analytics.js"; + +function insertCliSession(db: Database, id: string, createdAt: string): void { + db.prepare( + `INSERT INTO cli_sessions + (id, purpose, projectId, adapterId, agentState, createdAt, updatedAt) + VALUES (?, 'task', 'proj-1', 'claude-local', 'running', ?, ?)`, + ).run(id, createdAt, createdAt); +} + +describe("activity-analytics", () => { + let tmpDir: string; + let db: Database; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "kb-activity-analytics-")); + db = new Database(join(tmpDir, ".fusion")); + db.init(); + }); + + afterEach(async () => { + db.close(); + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("counts sessions, messages, and distinct active nodes/agents over a range", () => { + insertCliSession(db, "s1", "2026-03-01T00:00:00.000Z"); + insertCliSession(db, "s2", "2026-03-02T00:00:00.000Z"); + // session outside range + insertCliSession(db, "s-old", "2025-01-01T00:00:00.000Z"); + + emitUsageEvent(db, { kind: "user_message", agentId: "agent-1", nodeId: "node-1", ts: "2026-03-01T00:00:00.000Z" }); + emitUsageEvent(db, { kind: "user_message", agentId: "agent-2", nodeId: "node-1", ts: "2026-03-01T01:00:00.000Z" }); + emitUsageEvent(db, { kind: "tool_call", agentId: "agent-2", nodeId: "node-2", ts: "2026-03-02T00:00:00.000Z" }); + + const result = aggregateActivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" }); + expect(result.sessions).toBe(2); + expect(result.messages).toBe(2); + expect(result.activeNodes).toBe(2); // node-1, node-2 + expect(result.activeAgents).toBe(2); // agent-1, agent-2 + }); + + it("produces a per-day breakdown ascending by day", () => { + emitUsageEvent(db, { kind: "user_message", agentId: "agent-1", nodeId: "node-1", ts: "2026-03-01T08:00:00.000Z" }); + emitUsageEvent(db, { kind: "tool_call", agentId: "agent-1", nodeId: "node-1", ts: "2026-03-01T09:00:00.000Z" }); + emitUsageEvent(db, { kind: "user_message", agentId: "agent-2", nodeId: "node-2", ts: "2026-03-02T08:00:00.000Z" }); + + const result = aggregateActivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" }); + expect(result.daily.map((d) => d.day)).toEqual(["2026-03-01", "2026-03-02"]); + expect(result.daily[0]).toMatchObject({ day: "2026-03-01", activeNodes: 1, activeAgents: 1, messages: 1 }); + expect(result.daily[1]).toMatchObject({ day: "2026-03-02", activeNodes: 1, activeAgents: 1, messages: 1 }); + }); + + it("computes stickiness = DAU/MAU", () => { + // Day 1: agents a,b active. Day 2: agent a active. MAU = {a,b} = 2. + // DAU = mean(2, 1) = 1.5. stickiness = 1.5 / 2 = 0.75. + emitUsageEvent(db, { kind: "tool_call", agentId: "a", nodeId: "n1", ts: "2026-03-01T00:00:00.000Z" }); + emitUsageEvent(db, { kind: "tool_call", agentId: "b", nodeId: "n1", ts: "2026-03-01T01:00:00.000Z" }); + emitUsageEvent(db, { kind: "tool_call", agentId: "a", nodeId: "n1", ts: "2026-03-02T00:00:00.000Z" }); + + const result = aggregateActivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" }); + expect(result.activeAgents).toBe(2); + expect(result.stickiness).toBeCloseTo(0.75, 5); + }); + + it("empty range returns zeroed structures, not nulls", () => { + insertCliSession(db, "s1", "2026-03-01T00:00:00.000Z"); + emitUsageEvent(db, { kind: "user_message", agentId: "a", nodeId: "n1", ts: "2026-03-01T00:00:00.000Z" }); + + const result = aggregateActivityAnalytics(db, { from: "2027-01-01T00:00:00.000Z", to: "2027-12-31T00:00:00.000Z" }); + expect(result.sessions).toBe(0); + expect(result.messages).toBe(0); + expect(result.activeNodes).toBe(0); + expect(result.activeAgents).toBe(0); + expect(result.daily).toEqual([]); + expect(result.stickiness).toBe(0); + }); + + it("leaves a clean MTTR seam for U13 (unavailable, not 0)", () => { + const result = aggregateActivityAnalytics(db, {}); + expect(result.mttr).toEqual({ value: null, unavailable: true }); + }); +}); diff --git a/packages/core/src/__tests__/productivity-analytics.test.ts b/packages/core/src/__tests__/productivity-analytics.test.ts new file mode 100644 index 000000000..f61622902 --- /dev/null +++ b/packages/core/src/__tests__/productivity-analytics.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { Database } from "../db.js"; +import { aggregateProductivityAnalytics } from "../productivity-analytics.js"; + +function insertTaskWithFiles(db: Database, id: string, files: string[], updatedAt: string): void { + db.prepare( + `INSERT INTO tasks (id, description, "column", createdAt, updatedAt, modifiedFiles) + VALUES (?, 'desc', 'todo', ?, ?, ?)`, + ).run(id, updatedAt, updatedAt, JSON.stringify(files)); +} + +function insertCommit(db: Database, id: string, sha: string, authoredAt: string): void { + db.prepare( + `INSERT INTO task_commit_associations + (id, taskLineageId, taskIdSnapshot, commitSha, commitSubject, authoredAt, + matchedBy, confidence, createdAt, updatedAt) + VALUES (?, 'lin-1', 't-1', ?, 'subj', ?, 'canonical-lineage-trailer', 'canonical', ?, ?)`, + ).run(id, sha, authoredAt, authoredAt, authoredAt); +} + +function insertPr(db: Database, id: string, createdAtMs: number): void { + db.prepare( + `INSERT INTO pull_requests + (id, sourceType, sourceId, repo, headBranch, state, createdAt, updatedAt) + VALUES (?, 'task', ?, 'org/repo', ?, 'open', ?, ?)`, + ).run(id, `src-${id}`, `branch-${id}`, createdAtMs, createdAtMs); +} + +describe("productivity-analytics", () => { + let tmpDir: string; + let db: Database; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "kb-productivity-analytics-")); + db = new Database(join(tmpDir, ".fusion")); + db.init(); + }); + + afterEach(async () => { + db.close(); + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("counts modified files and language distribution", () => { + insertTaskWithFiles(db, "t1", ["src/a.ts", "src/b.ts", "README.md"], "2026-03-01T00:00:00.000Z"); + insertTaskWithFiles(db, "t2", ["src/c.ts", "style.css"], "2026-03-02T00:00:00.000Z"); + + const result = aggregateProductivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" }); + expect(result.modifiedFiles).toBe(5); + const byLang = new Map(result.byLanguage.map((l) => [l.language, l.count])); + expect(byLang.get("ts")).toBe(3); + expect(byLang.get("md")).toBe(1); + expect(byLang.get("css")).toBe(1); + // sorted descending by count + expect(result.byLanguage[0]).toEqual({ language: "ts", count: 3 }); + }); + + it("counts commit associations and pull requests in range", () => { + insertCommit(db, "c1", "sha1", "2026-03-01T00:00:00.000Z"); + insertCommit(db, "c2", "sha2", "2026-03-02T00:00:00.000Z"); + insertCommit(db, "c-old", "sha-old", "2025-01-01T00:00:00.000Z"); + + insertPr(db, "pr1", Date.parse("2026-03-01T00:00:00.000Z")); + insertPr(db, "pr2", Date.parse("2026-03-10T00:00:00.000Z")); + insertPr(db, "pr-old", Date.parse("2025-01-01T00:00:00.000Z")); + + const result = aggregateProductivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" }); + expect(result.commits).toBe(2); + expect(result.pullRequests).toBe(2); + }); + + it("reports LOC as unavailable (null + unavailable:true), never 0", () => { + insertTaskWithFiles(db, "t1", ["src/a.ts"], "2026-03-01T00:00:00.000Z"); + const result = aggregateProductivityAnalytics(db, {}); + expect(result.loc).toEqual({ value: null, unavailable: true }); + expect(result.loc.value).not.toBe(0); + }); + + it("empty range returns zeroed structures, not nulls", () => { + insertTaskWithFiles(db, "t1", ["src/a.ts"], "2026-03-01T00:00:00.000Z"); + insertCommit(db, "c1", "sha1", "2026-03-01T00:00:00.000Z"); + insertPr(db, "pr1", Date.parse("2026-03-01T00:00:00.000Z")); + + const result = aggregateProductivityAnalytics(db, { from: "2027-01-01T00:00:00.000Z", to: "2027-12-31T00:00:00.000Z" }); + expect(result.modifiedFiles).toBe(0); + expect(result.byLanguage).toEqual([]); + expect(result.commits).toBe(0); + expect(result.pullRequests).toBe(0); + // LOC unavailable regardless of range + expect(result.loc).toEqual({ value: null, unavailable: true }); + }); + + it("includes a boundary task exactly at `from`", () => { + insertTaskWithFiles(db, "boundary", ["x.ts"], "2026-03-01T00:00:00.000Z"); + const result = aggregateProductivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" }); + expect(result.modifiedFiles).toBe(1); + }); +}); diff --git a/packages/core/src/__tests__/token-analytics.test.ts b/packages/core/src/__tests__/token-analytics.test.ts new file mode 100644 index 000000000..81e3d6e49 --- /dev/null +++ b/packages/core/src/__tests__/token-analytics.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { Database } from "../db.js"; +import { aggregateTokenAnalytics } from "../token-analytics.js"; + +interface TaskSeed { + id: string; + inputTokens?: number; + outputTokens?: number; + cachedTokens?: number; + cacheWriteTokens?: number; + totalTokens?: number | null; + lastUsedAt: string | null; + modelProvider?: string | null; + modelId?: string | null; + nodeId?: string | null; + agentId?: string | null; +} + +function insertTask(db: Database, t: TaskSeed): void { + db.prepare( + `INSERT INTO tasks + (id, description, "column", createdAt, updatedAt, + tokenUsageInputTokens, tokenUsageOutputTokens, tokenUsageCachedTokens, + tokenUsageCacheWriteTokens, tokenUsageTotalTokens, tokenUsageLastUsedAt, + modelProvider, modelId, checkoutNodeId, assignedAgentId) + VALUES (?, 'desc', 'todo', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + t.id, + t.inputTokens ?? null, + t.outputTokens ?? null, + t.cachedTokens ?? null, + t.cacheWriteTokens ?? null, + t.totalTokens === undefined ? null : t.totalTokens, + t.lastUsedAt, + t.modelProvider ?? null, + t.modelId ?? null, + t.nodeId ?? null, + t.agentId ?? null, + ); +} + +describe("token-analytics", () => { + let tmpDir: string; + let db: Database; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "kb-token-analytics-")); + db = new Database(join(tmpDir, ".fusion")); + db.init(); + }); + + afterEach(async () => { + db.close(); + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("returns correct per-model token totals for 5 tasks across 2 models", () => { + // 3 tasks on model-A, 2 on model-B, all within range. + insertTask(db, { id: "t1", inputTokens: 100, outputTokens: 50, totalTokens: 150, lastUsedAt: "2026-03-01T00:00:00.000Z", modelId: "model-A", modelProvider: "anthropic" }); + insertTask(db, { id: "t2", inputTokens: 200, outputTokens: 80, totalTokens: 280, lastUsedAt: "2026-03-02T00:00:00.000Z", modelId: "model-A", modelProvider: "anthropic" }); + insertTask(db, { id: "t3", inputTokens: 300, outputTokens: 20, totalTokens: 320, lastUsedAt: "2026-03-03T00:00:00.000Z", modelId: "model-A", modelProvider: "anthropic" }); + insertTask(db, { id: "t4", inputTokens: 10, outputTokens: 5, totalTokens: 15, lastUsedAt: "2026-03-04T00:00:00.000Z", modelId: "model-B", modelProvider: "openai" }); + insertTask(db, { id: "t5", inputTokens: 40, outputTokens: 60, totalTokens: 100, lastUsedAt: "2026-03-05T00:00:00.000Z", modelId: "model-B", modelProvider: "openai" }); + + const result = aggregateTokenAnalytics(db, { + from: "2026-03-01T00:00:00.000Z", + to: "2026-03-31T00:00:00.000Z", + groupBy: "model", + }); + + expect(result.totals.inputTokens).toBe(650); + expect(result.totals.outputTokens).toBe(215); + expect(result.totals.totalTokens).toBe(865); + expect(result.totals.nTasks).toBe(5); + + const groups = new Map(result.groups.map((g) => [g.key, g])); + expect(groups.get("model-A")!.inputTokens).toBe(600); + expect(groups.get("model-A")!.totalTokens).toBe(750); + expect(groups.get("model-A")!.nTasks).toBe(3); + expect(groups.get("model-B")!.inputTokens).toBe(50); + expect(groups.get("model-B")!.totalTokens).toBe(115); + expect(groups.get("model-B")!.nTasks).toBe(2); + // groups sorted descending by totalTokens + expect(result.groups[0].key).toBe("model-A"); + }); + + it("groups by provider, node, and agent", () => { + insertTask(db, { id: "t1", inputTokens: 100, totalTokens: 100, lastUsedAt: "2026-03-01T00:00:00.000Z", modelProvider: "anthropic", nodeId: "node-1", agentId: "agent-x" }); + insertTask(db, { id: "t2", inputTokens: 200, totalTokens: 200, lastUsedAt: "2026-03-02T00:00:00.000Z", modelProvider: "openai", nodeId: "node-1", agentId: "agent-y" }); + + const byProvider = aggregateTokenAnalytics(db, { groupBy: "provider" }); + expect(new Map(byProvider.groups.map((g) => [g.key, g.totalTokens]))).toEqual( + new Map([["anthropic", 100], ["openai", 200]]), + ); + + const byNode = aggregateTokenAnalytics(db, { groupBy: "node" }); + expect(byNode.groups).toHaveLength(1); + expect(byNode.groups[0].key).toBe("node-1"); + expect(byNode.groups[0].totalTokens).toBe(300); + + const byAgent = aggregateTokenAnalytics(db, { groupBy: "agent" }); + expect(new Map(byAgent.groups.map((g) => [g.key, g.totalTokens]))).toEqual( + new Map([["agent-x", 100], ["agent-y", 200]]), + ); + }); + + it("empty range returns zeroed structures, not nulls", () => { + insertTask(db, { id: "t1", inputTokens: 100, totalTokens: 100, lastUsedAt: "2026-03-01T00:00:00.000Z", modelId: "model-A" }); + + const result = aggregateTokenAnalytics(db, { + from: "2027-01-01T00:00:00.000Z", + to: "2027-12-31T00:00:00.000Z", + groupBy: "model", + }); + expect(result.totals).toEqual({ + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + cacheWriteTokens: 0, + totalTokens: 0, + nTasks: 0, + }); + expect(result.groups).toEqual([]); + }); + + it("includes a boundary task exactly at `from` (inclusive lower bound)", () => { + insertTask(db, { id: "boundary", inputTokens: 42, totalTokens: 42, lastUsedAt: "2026-03-01T00:00:00.000Z", modelId: "model-A" }); + + const result = aggregateTokenAnalytics(db, { + from: "2026-03-01T00:00:00.000Z", + to: "2026-03-31T00:00:00.000Z", + }); + expect(result.totals.nTasks).toBe(1); + expect(result.totals.inputTokens).toBe(42); + }); + + it("excludes tasks with no token usage (lastUsedAt null)", () => { + insertTask(db, { id: "no-usage", lastUsedAt: null, modelId: "model-A" }); + insertTask(db, { id: "has-usage", inputTokens: 5, totalTokens: 5, lastUsedAt: "2026-03-01T00:00:00.000Z", modelId: "model-A" }); + + const result = aggregateTokenAnalytics(db, {}); + expect(result.totals.nTasks).toBe(1); + expect(result.totals.inputTokens).toBe(5); + }); + + it("derives totalTokens from parts when the persisted total is null", () => { + insertTask(db, { id: "t1", inputTokens: 10, outputTokens: 20, cachedTokens: 5, cacheWriteTokens: 1, totalTokens: null, lastUsedAt: "2026-03-01T00:00:00.000Z", modelId: "model-A" }); + const result = aggregateTokenAnalytics(db, {}); + expect(result.totals.totalTokens).toBe(36); + }); +}); diff --git a/packages/core/src/__tests__/tool-analytics.test.ts b/packages/core/src/__tests__/tool-analytics.test.ts new file mode 100644 index 000000000..ac8dbbd37 --- /dev/null +++ b/packages/core/src/__tests__/tool-analytics.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { Database } from "../db.js"; +import { emitUsageEvent } from "../usage-events.js"; +import { aggregateToolAnalytics, countInterventions } from "../tool-analytics.js"; +import type { SteeringComment } from "../types.js"; + +function insertTaskWithSteers(db: Database, id: string, steers: SteeringComment[]): void { + db.prepare( + `INSERT INTO tasks (id, description, "column", createdAt, updatedAt, steeringComments) + VALUES (?, 'desc', 'todo', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', ?)`, + ).run(id, JSON.stringify(steers)); +} + +function insertApprovalRequest(db: Database, id: string): void { + db.prepare( + `INSERT INTO approval_requests + (id, status, requesterActorId, requesterActorType, requesterActorName, + targetActionCategory, targetActionOperation, targetActionSummary, + targetResourceType, targetResourceId, requestedAt, createdAt, updatedAt) + VALUES (?, 'pending', 'a', 'agent', 'A', 'cat', 'op', 'sum', 'res', 'r1', + '2026-03-01T00:00:00.000Z', '2026-03-01T00:00:00.000Z', '2026-03-01T00:00:00.000Z')`, + ).run(id); +} + +function insertApprovalEvent(db: Database, id: string, requestId: string, eventType: string, createdAt: string): void { + db.prepare( + `INSERT INTO approval_request_audit_events + (id, requestId, eventType, actorId, actorType, actorName, createdAt) + VALUES (?, ?, ?, 'u1', 'user', 'User', ?)`, + ).run(id, requestId, eventType, createdAt); +} + +describe("tool-analytics", () => { + let tmpDir: string; + let db: Database; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "kb-tool-analytics-")); + db = new Database(join(tmpDir, ".fusion")); + db.init(); + }); + + afterEach(async () => { + db.close(); + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("counts tool calls by category, sorted descending", () => { + emitUsageEvent(db, { kind: "tool_call", category: "read", ts: "2026-03-01T00:00:00.000Z" }); + emitUsageEvent(db, { kind: "tool_call", category: "read", ts: "2026-03-01T01:00:00.000Z" }); + emitUsageEvent(db, { kind: "tool_call", category: "edit", ts: "2026-03-01T02:00:00.000Z" }); + // a non-tool_call event is not counted + emitUsageEvent(db, { kind: "user_message", ts: "2026-03-01T03:00:00.000Z" }); + + const result = aggregateToolAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" }); + expect(result.toolCalls).toBe(3); + expect(result.byCategory).toEqual([ + { category: "read", count: 2 }, + { category: "edit", count: 1 }, + ]); + }); + + it("autonomy denominator counts a USER steer + an approval but NOT an agent steer", () => { + insertTaskWithSteers(db, "task-1", [ + { id: "s1", text: "do X", createdAt: "2026-03-02T00:00:00.000Z", author: "user" }, + { id: "s2", text: "agent note", createdAt: "2026-03-02T01:00:00.000Z", author: "agent" }, + ]); + insertApprovalRequest(db, "req-1"); + insertApprovalEvent(db, "ev-created", "req-1", "created", "2026-03-02T00:30:00.000Z"); + insertApprovalEvent(db, "ev-approved", "req-1", "approved", "2026-03-02T00:31:00.000Z"); + // a non-human eventType must NOT count + insertApprovalEvent(db, "ev-completed", "req-1", "completed", "2026-03-02T00:32:00.000Z"); + + const breakdown = countInterventions(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" }); + expect(breakdown.userSteers).toBe(1); // agent steer excluded + expect(breakdown.approvals).toBe(2); // created + approved, completed excluded + expect(breakdown.total).toBe(3); + }); + + it("autonomy ratio = toolCalls / interventions for an interactive session", () => { + // 12 tool calls, 3 interventions (1 user steer + 2 approvals) -> ratio 4 + for (let i = 0; i < 12; i++) { + emitUsageEvent(db, { kind: "tool_call", category: "read", ts: `2026-03-02T00:0${i % 6}:0${i % 6}.000Z` }); + } + emitUsageEvent(db, { kind: "session_start", ts: "2026-03-02T00:00:00.000Z" }); + insertTaskWithSteers(db, "task-1", [{ id: "s1", text: "x", createdAt: "2026-03-02T00:10:00.000Z", author: "user" }]); + insertApprovalRequest(db, "req-1"); + insertApprovalEvent(db, "ev-c", "req-1", "created", "2026-03-02T00:11:00.000Z"); + insertApprovalEvent(db, "ev-a", "req-1", "approved", "2026-03-02T00:12:00.000Z"); + + const result = aggregateToolAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" }); + expect(result.interventions.total).toBe(3); + expect(result.toolCalls).toBe(12); + expect(result.autonomyRatio).toBe(4); + expect(result.fullyAutonomous).toBe(false); + }); + + it("fully-autonomous session (zero interventions) reports tool-calls-per-session, not infinity", () => { + // 10 tool calls across 2 sessions, zero interventions -> 5 per session + for (let i = 0; i < 10; i++) { + emitUsageEvent(db, { kind: "tool_call", category: "execute", ts: "2026-03-02T00:00:00.000Z" }); + } + emitUsageEvent(db, { kind: "session_start", ts: "2026-03-02T00:00:00.000Z" }); + emitUsageEvent(db, { kind: "session_start", ts: "2026-03-02T01:00:00.000Z" }); + + const result = aggregateToolAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" }); + expect(result.interventions.total).toBe(0); + expect(result.fullyAutonomous).toBe(true); + expect(result.autonomyRatio).toBe(5); + expect(Number.isFinite(result.autonomyRatio)).toBe(true); + }); + + it("zero interventions and zero sessions does not divide by zero", () => { + for (let i = 0; i < 4; i++) { + emitUsageEvent(db, { kind: "tool_call", category: "read", ts: "2026-03-02T00:00:00.000Z" }); + } + const result = aggregateToolAnalytics(db, {}); + expect(result.sessions).toBe(0); + expect(result.fullyAutonomous).toBe(true); + // toolCalls / max(sessions, 1) = 4 / 1 + expect(result.autonomyRatio).toBe(4); + }); + + it("empty range returns zeroed structures, not nulls", () => { + const result = aggregateToolAnalytics(db, { from: "2027-01-01T00:00:00.000Z", to: "2027-12-31T00:00:00.000Z" }); + expect(result.toolCalls).toBe(0); + expect(result.byCategory).toEqual([]); + expect(result.sessions).toBe(0); + expect(result.interventions).toEqual({ approvals: 0, userSteers: 0, total: 0 }); + expect(result.autonomyRatio).toBe(0); + }); + + it("user steers outside the range are not counted", () => { + insertTaskWithSteers(db, "task-1", [ + { id: "s1", text: "old", createdAt: "2025-01-01T00:00:00.000Z", author: "user" }, + { id: "s2", text: "in range", createdAt: "2026-03-15T00:00:00.000Z", author: "user" }, + ]); + const breakdown = countInterventions(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" }); + expect(breakdown.userSteers).toBe(1); + }); +}); diff --git a/packages/core/src/activity-analytics.ts b/packages/core/src/activity-analytics.ts new file mode 100644 index 000000000..dbee0b1a2 --- /dev/null +++ b/packages/core/src/activity-analytics.ts @@ -0,0 +1,193 @@ +import type { Database } from "./db.js"; + +/** + * Activity analytics: distinct active nodes/agents per day, sessions, messages, + * and stickiness (DAU/MAU) over an arbitrary date range. + * + * Sessions come from `cli_sessions` (by `createdAt`); messages and node/agent + * activity come from `usage_events`. Inclusivity: `from`/`to` are inclusive, + * matching `usage-events.ts`. + * + * **MTTR seam (U13).** Mean-time-to-resolve aggregation is deliberately NOT + * implemented here yet — it depends on the deployments/incidents tables U13 + * introduces. {@link aggregateActivityAnalytics} returns an `mttr` field set to + * the documented "unavailable" sentinel so the shape is stable now and U13 can + * fill it in without changing callers. See {@link MttrSummary}. + */ + +export interface ActivityAnalyticsQuery { + /** ISO-8601 lower bound (inclusive). */ + from?: string; + /** ISO-8601 upper bound (inclusive). */ + to?: string; +} + +/** Distinct active nodes/agents and message count for a single UTC day. */ +export interface DailyActivity { + /** UTC date, `YYYY-MM-DD`. */ + day: string; + activeNodes: number; + activeAgents: number; + messages: number; +} + +/** + * MTTR summary placeholder. U13 will populate `value` (mean minutes to resolve) + * once deployments/incidents land; until then it is the documented unavailable + * sentinel — `null` value with `unavailable: true`, never `0`. + */ +export interface MttrSummary { + /** Mean minutes to resolve; null until U13 provides incident data. */ + value: number | null; + /** True when MTTR cannot be computed (no incident data source yet). */ + unavailable: boolean; +} + +export interface ActivityAnalytics { + from: string | null; + to: string | null; + /** Total `session_start` events from `cli_sessions` in range. */ + sessions: number; + /** Total `user_message` events in range. */ + messages: number; + /** Distinct nodes with any usage_event in range. */ + activeNodes: number; + /** Distinct agents with any usage_event in range. */ + activeAgents: number; + /** Per-day breakdown, ascending by day. */ + daily: DailyActivity[]; + /** + * Stickiness = DAU/MAU. DAU = mean distinct-active-agents-per-day over the + * range; MAU = distinct active agents over the whole range. 0 when MAU is 0. + */ + stickiness: number; + /** MTTR placeholder (U13 seam). */ + mttr: MttrSummary; +} + +interface CountRow { + count: number; +} + +interface DistinctRow { + count: number; +} + +interface DayAggRow { + day: string; + activeNodes: number; + activeAgents: number; + messages: number; +} + +function rangeClauses( + column: string, + query: ActivityAnalyticsQuery, +): { where: string; params: string[] } { + const clauses: string[] = []; + const params: string[] = []; + if (query.from !== undefined) { + clauses.push(`${column} >= ?`); + params.push(query.from); + } + if (query.to !== undefined) { + clauses.push(`${column} <= ?`); + params.push(query.to); + } + return { + where: clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "", + params, + }; +} + +/** + * Aggregate activity (sessions, messages, active nodes/agents, daily breakdown, + * stickiness) over a date range. Empty range yields zeroed structures and an + * empty `daily` array — never nulls. `mttr` is the U13 unavailable seam. + */ +export function aggregateActivityAnalytics( + db: Database, + query: ActivityAnalyticsQuery = {}, +): ActivityAnalytics { + // Sessions from cli_sessions (by createdAt). + const sessionRange = rangeClauses("createdAt", query); + const sessions = ( + db + .prepare(`SELECT COUNT(*) AS count FROM cli_sessions ${sessionRange.where}`) + .get(...sessionRange.params) as CountRow + ).count; + + // Messages from usage_events (kind = user_message). + const eventRange = rangeClauses("ts", query); + const eventWhereWith = (extra: string): string => + eventRange.where + ? `${eventRange.where} AND ${extra}` + : `WHERE ${extra}`; + + const messages = ( + db + .prepare( + `SELECT COUNT(*) AS count FROM usage_events ${eventWhereWith("kind = 'user_message'")}`, + ) + .get(...eventRange.params) as CountRow + ).count; + + // Distinct active nodes/agents over the whole range. + const activeNodes = ( + db + .prepare( + `SELECT COUNT(DISTINCT nodeId) AS count FROM usage_events ${eventWhereWith("nodeId IS NOT NULL")}`, + ) + .get(...eventRange.params) as DistinctRow + ).count; + const activeAgents = ( + db + .prepare( + `SELECT COUNT(DISTINCT agentId) AS count FROM usage_events ${eventWhereWith("agentId IS NOT NULL")}`, + ) + .get(...eventRange.params) as DistinctRow + ).count; + + // Per-day distinct nodes/agents + message count. substr(ts,1,10) is the UTC + // day key (ISO-8601 timestamps). + const dailyRows = db + .prepare( + `SELECT + substr(ts, 1, 10) AS day, + COUNT(DISTINCT nodeId) AS activeNodes, + COUNT(DISTINCT agentId) AS activeAgents, + SUM(CASE WHEN kind = 'user_message' THEN 1 ELSE 0 END) AS messages + FROM usage_events ${eventRange.where} + GROUP BY day + ORDER BY day ASC`, + ) + .all(...eventRange.params) as DayAggRow[]; + const daily: DailyActivity[] = dailyRows.map((r) => ({ + day: r.day, + activeNodes: r.activeNodes, + activeAgents: r.activeAgents, + messages: r.messages ?? 0, + })); + + // Stickiness = DAU/MAU. DAU = mean distinct-active-agents-per-day; MAU = + // distinct active agents over the range. + const dau = + daily.length > 0 + ? daily.reduce((sum, d) => sum + d.activeAgents, 0) / daily.length + : 0; + const mau = activeAgents; + const stickiness = mau > 0 ? dau / mau : 0; + + return { + from: query.from ?? null, + to: query.to ?? null, + sessions, + messages, + activeNodes, + activeAgents, + daily, + stickiness, + // U13 seam: no incident data source yet — unavailable, not 0. + mttr: { value: null, unavailable: true }, + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 362127af7..e9698c2c3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -530,6 +530,35 @@ export type { UsageEventKind, UsageEventRangeQuery, } from "./usage-events.js"; +export { aggregateTokenAnalytics } from "./token-analytics.js"; +export type { + TokenAnalytics, + TokenAnalyticsQuery, + TokenGroupBy, + TokenGroupSummary, + TokenTotals, +} from "./token-analytics.js"; +export { aggregateToolAnalytics, countInterventions } from "./tool-analytics.js"; +export type { + ToolAnalytics, + ToolAnalyticsQuery, + ToolCategoryCount, + InterventionBreakdown, +} from "./tool-analytics.js"; +export { aggregateActivityAnalytics } from "./activity-analytics.js"; +export type { + ActivityAnalytics, + ActivityAnalyticsQuery, + DailyActivity, + MttrSummary, +} from "./activity-analytics.js"; +export { aggregateProductivityAnalytics } from "./productivity-analytics.js"; +export type { + ProductivityAnalytics, + ProductivityAnalyticsQuery, + LanguageCount, + LocSummary, +} from "./productivity-analytics.js"; export { STALLED_REVIEW_REENQUEUE_THRESHOLD, STALLED_REVIEW_INVALID_TRANSITION_THRESHOLD, @@ -674,6 +703,8 @@ export { isPrEntityActionable, isPrEntityAutoMergeReady, autoMergeGateReason, + summarizePrThreadActivity, + type PrThreadActivity, } from "./pr-entity.js"; export { findVitestProcessIds, diff --git a/packages/core/src/productivity-analytics.ts b/packages/core/src/productivity-analytics.ts new file mode 100644 index 000000000..dcb5edba3 --- /dev/null +++ b/packages/core/src/productivity-analytics.ts @@ -0,0 +1,175 @@ +import type { Database } from "./db.js"; + +/** + * Productivity analytics: files modified (count + language distribution) from + * `tasks.modifiedFiles`, commit associations from `task_commit_associations`, + * pull requests from `pull_requests`, and LOC from commit diff stats. + * + * **LOC availability.** Fusion does not currently persist commit diff line + * stats (the `task_commit_associations` schema has no additions/deletions + * columns). LOC is therefore reported as the documented unavailable sentinel — + * `{ value: null, unavailable: true }` — **never `0`**, so a missing data source + * is never mistaken for "zero lines changed". When a diff-stats source is added, + * fill {@link LocSummary.value} and clear `unavailable`. + * + * Inclusivity: `from`/`to` bounds are inclusive. Tasks are filtered by + * `updatedAt` (the last time the task — and therefore its modifiedFiles — was + * touched); commit associations by `authoredAt`; PRs by `createdAt`. + */ + +export interface ProductivityAnalyticsQuery { + /** ISO-8601 lower bound (inclusive). */ + from?: string; + /** ISO-8601 upper bound (inclusive). */ + to?: string; +} + +/** A single language's modified-file count. */ +export interface LanguageCount { + /** Lowercased file extension (no dot), or `other` when none. */ + language: string; + count: number; +} + +/** + * LOC summary. `value` is null and `unavailable` true until a commit diff-stats + * source exists — never `0`. + */ +export interface LocSummary { + value: number | null; + unavailable: boolean; +} + +export interface ProductivityAnalytics { + from: string | null; + to: string | null; + /** Total modified-file paths across matched tasks. */ + modifiedFiles: number; + /** Modified files grouped by language (extension), descending by count. */ + byLanguage: LanguageCount[]; + /** Rows in `task_commit_associations` in range. */ + commits: number; + /** Rows in `pull_requests` in range. */ + pullRequests: number; + /** LOC from commit diff stats — unavailable until a source exists. */ + loc: LocSummary; +} + +interface CountRow { + count: number; +} + +interface ModifiedFilesRow { + modifiedFiles: string | null; +} + +/** Extract a coarse language key from a file path (its lowercased extension). */ +function languageOf(path: string): string { + const base = path.split("/").pop() ?? path; + const dot = base.lastIndexOf("."); + if (dot <= 0 || dot === base.length - 1) return "other"; + return base.slice(dot + 1).toLowerCase(); +} + +/** + * Aggregate productivity metrics over a date range. Empty range yields zeroed + * structures (not nulls); LOC is always the unavailable sentinel until a + * diff-stats source is wired. + */ +export function aggregateProductivityAnalytics( + db: Database, + query: ProductivityAnalyticsQuery = {}, +): ProductivityAnalytics { + // Modified files: read the JSON array off tasks updated in range. + const taskClauses: string[] = [ + "modifiedFiles IS NOT NULL", + "modifiedFiles NOT IN ('', '[]')", + ]; + const taskParams: string[] = []; + if (query.from !== undefined) { + taskClauses.push("updatedAt >= ?"); + taskParams.push(query.from); + } + if (query.to !== undefined) { + taskClauses.push("updatedAt <= ?"); + taskParams.push(query.to); + } + const taskRows = db + .prepare( + `SELECT modifiedFiles FROM tasks WHERE ${taskClauses.join(" AND ")}`, + ) + .all(...taskParams) as ModifiedFilesRow[]; + + let modifiedFiles = 0; + const langMap = new Map(); + for (const row of taskRows) { + if (!row.modifiedFiles) continue; + let files: unknown; + try { + files = JSON.parse(row.modifiedFiles); + } catch { + continue; + } + if (!Array.isArray(files)) continue; + for (const f of files) { + if (typeof f !== "string" || f.length === 0) continue; + modifiedFiles += 1; + const lang = languageOf(f); + langMap.set(lang, (langMap.get(lang) ?? 0) + 1); + } + } + const byLanguage: LanguageCount[] = [...langMap.entries()] + .map(([language, count]) => ({ language, count })) + .sort((a, b) => b.count - a.count); + + // Commits from task_commit_associations (by authoredAt). + const commitClauses: string[] = []; + const commitParams: string[] = []; + if (query.from !== undefined) { + commitClauses.push("authoredAt >= ?"); + commitParams.push(query.from); + } + if (query.to !== undefined) { + commitClauses.push("authoredAt <= ?"); + commitParams.push(query.to); + } + const commitWhere = + commitClauses.length > 0 ? `WHERE ${commitClauses.join(" AND ")}` : ""; + const commits = ( + db + .prepare( + `SELECT COUNT(*) AS count FROM task_commit_associations ${commitWhere}`, + ) + .get(...commitParams) as CountRow + ).count; + + // Pull requests. `pull_requests.createdAt` is an INTEGER epoch-ms column, so + // convert the ISO bounds to epoch ms for comparison. + const prClauses: string[] = []; + const prParams: number[] = []; + if (query.from !== undefined) { + prClauses.push("createdAt >= ?"); + prParams.push(Date.parse(query.from)); + } + if (query.to !== undefined) { + prClauses.push("createdAt <= ?"); + prParams.push(Date.parse(query.to)); + } + const prWhere = prClauses.length > 0 ? `WHERE ${prClauses.join(" AND ")}` : ""; + const pullRequests = ( + db + .prepare(`SELECT COUNT(*) AS count FROM pull_requests ${prWhere}`) + .get(...prParams) as CountRow + ).count; + + return { + from: query.from ?? null, + to: query.to ?? null, + modifiedFiles, + byLanguage, + commits, + pullRequests, + // No commit diff-stats source yet — unavailable, never 0. + loc: { value: null, unavailable: true }, + }; +} diff --git a/packages/core/src/token-analytics.ts b/packages/core/src/token-analytics.ts new file mode 100644 index 000000000..695cf5eb1 --- /dev/null +++ b/packages/core/src/token-analytics.ts @@ -0,0 +1,175 @@ +import type { Database } from "./db.js"; + +/** + * Token-consumption analytics over the `tasks` table, generalizing the fixed + * 24h/7d/all-time windows of `agent-token-usage.ts` to an arbitrary `(from, to)` + * range. Sums the `tokenUsage*` columns filtered by `tokenUsageLastUsedAt` and + * groups by model / provider / node / agent. + * + * Inclusivity: `from`/`to` bounds are **inclusive** (`>= from AND <= to`), + * matching `usage-events.ts` and the range-scan house style. A task whose + * `tokenUsageLastUsedAt` is exactly equal to `from` is therefore included. + * + * Pure read-only aggregation: takes a `Database` handle and returns plain data. + */ + +/** Dimension to group token totals by. */ +export type TokenGroupBy = "model" | "provider" | "node" | "agent"; + +/** Summed token counts for a group (or the grand total). */ +export interface TokenTotals { + inputTokens: number; + outputTokens: number; + cachedTokens: number; + cacheWriteTokens: number; + totalTokens: number; + /** Number of tasks that contributed to these totals. */ + nTasks: number; +} + +/** One group's token totals, keyed by the grouped dimension value. */ +export interface TokenGroupSummary extends TokenTotals { + /** The group key (model id, provider, nodeId, or agentId); null when unset. */ + key: string | null; +} + +/** Result of {@link aggregateTokenAnalytics}. */ +export interface TokenAnalytics { + from: string | null; + to: string | null; + groupBy: TokenGroupBy | null; + /** Grand total across all matched tasks. */ + totals: TokenTotals; + /** Per-group totals; empty array when no `groupBy` requested. */ + groups: TokenGroupSummary[]; +} + +export interface TokenAnalyticsQuery { + /** ISO-8601 lower bound (inclusive) on `tokenUsageLastUsedAt`. */ + from?: string; + /** ISO-8601 upper bound (inclusive) on `tokenUsageLastUsedAt`. */ + to?: string; + groupBy?: TokenGroupBy; +} + +function emptyTotals(): TokenTotals { + return { + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + cacheWriteTokens: 0, + totalTokens: 0, + nTasks: 0, + }; +} + +interface TaskTokenRow { + inputTokens: number | null; + outputTokens: number | null; + cachedTokens: number | null; + cacheWriteTokens: number | null; + totalTokens: number | null; + modelProvider: string | null; + modelId: string | null; + checkoutNodeId: string | null; + assignedAgentId: string | null; +} + +function groupKeyFor(row: TaskTokenRow, groupBy: TokenGroupBy): string | null { + switch (groupBy) { + case "model": + return row.modelId; + case "provider": + return row.modelProvider; + case "node": + return row.checkoutNodeId; + case "agent": + return row.assignedAgentId; + } +} + +function addRow(totals: TokenTotals, row: TaskTokenRow): void { + totals.inputTokens += row.inputTokens ?? 0; + totals.outputTokens += row.outputTokens ?? 0; + totals.cachedTokens += row.cachedTokens ?? 0; + totals.cacheWriteTokens += row.cacheWriteTokens ?? 0; + // Prefer the persisted total when present; otherwise derive it from the parts + // so callers always get a coherent `totalTokens` even on older rows. + const persistedTotal = row.totalTokens; + totals.totalTokens += + persistedTotal ?? + (row.inputTokens ?? 0) + + (row.outputTokens ?? 0) + + (row.cachedTokens ?? 0) + + (row.cacheWriteTokens ?? 0); + totals.nTasks += 1; +} + +/** + * Aggregate per-task token usage over a date range, optionally grouped. + * + * Tasks are matched by `tokenUsageLastUsedAt` within `[from, to]` (inclusive). + * Tasks with no token usage (`tokenUsageLastUsedAt IS NULL`) are excluded. An + * empty range yields zeroed `totals` and an empty `groups` array — never nulls. + */ +export function aggregateTokenAnalytics( + db: Database, + query: TokenAnalyticsQuery = {}, +): TokenAnalytics { + const clauses: string[] = ["tokenUsageLastUsedAt IS NOT NULL"]; + const params: string[] = []; + if (query.from !== undefined) { + clauses.push("tokenUsageLastUsedAt >= ?"); + params.push(query.from); + } + if (query.to !== undefined) { + clauses.push("tokenUsageLastUsedAt <= ?"); + params.push(query.to); + } + const where = `WHERE ${clauses.join(" AND ")}`; + + const rows = db + .prepare( + `SELECT + tokenUsageInputTokens AS inputTokens, + tokenUsageOutputTokens AS outputTokens, + tokenUsageCachedTokens AS cachedTokens, + tokenUsageCacheWriteTokens AS cacheWriteTokens, + tokenUsageTotalTokens AS totalTokens, + modelProvider, + modelId, + checkoutNodeId, + assignedAgentId + FROM tasks ${where}`, + ) + .all(...params) as TaskTokenRow[]; + + const totals = emptyTotals(); + const groupMap = new Map(); + const groupBy = query.groupBy; + + for (const row of rows) { + addRow(totals, row); + if (groupBy) { + const key = groupKeyFor(row, groupBy); + let group = groupMap.get(key); + if (!group) { + group = { key, ...emptyTotals() }; + groupMap.set(key, group); + } + addRow(group, row); + } + } + + const groups = [...groupMap.values()].sort( + (a, b) => b.totalTokens - a.totalTokens, + ); + + return { + from: query.from ?? null, + to: query.to ?? null, + groupBy: groupBy ?? null, + totals, + groups, + }; +} diff --git a/packages/core/src/tool-analytics.ts b/packages/core/src/tool-analytics.ts new file mode 100644 index 000000000..e1135648b --- /dev/null +++ b/packages/core/src/tool-analytics.ts @@ -0,0 +1,221 @@ +import type { Database } from "./db.js"; +import type { SteeringComment } from "./types.js"; + +/** + * Tool-usage analytics over `usage_events`, plus the **autonomy ratio**. + * + * Autonomy ratio = tool_call count / human-intervention count. The denominator + * is NOT raw user messages (which trend to zero for autonomous execution); it is + * the count of human interventions, which has **three distinct sources** — they + * are not one queryable table: + * + * 1. **Approvals** — rows in `approval_request_audit_events` whose `eventType` + * is `created` or `approved` (a human was asked to / did approve an action), + * timestamped by `createdAt`. + * 2. **User-authored steers** — entries in the `steeringComments` JSON column + * on the `tasks` row, filtered to `author === "user"` (agent-authored steers + * are excluded), timestamped by each comment's `createdAt`. + * 3. **Waiting-on-input** — a task *status*, not a counted event; intentionally + * DROPPED here (no concrete answer event is defined). + * + * A fully-autonomous session (zero interventions) must not divide by zero or + * report ∞: when `interventions === 0` the ratio falls back to + * tool-calls-per-session (`toolCalls / max(sessions, 1)`), and the result flags + * `interventions: 0` so callers can render it as "fully autonomous". + * + * Inclusivity: `from`/`to` bounds are inclusive, matching `usage-events.ts`. + */ + +export interface ToolAnalyticsQuery { + /** ISO-8601 lower bound (inclusive). */ + from?: string; + /** ISO-8601 upper bound (inclusive). */ + to?: string; +} + +/** Tool-call count for a single coarse category. */ +export interface ToolCategoryCount { + category: string; + count: number; +} + +/** Breakdown of the autonomy-ratio denominator by source. */ +export interface InterventionBreakdown { + /** `created`/`approved` rows in `approval_request_audit_events`. */ + approvals: number; + /** `steeringComments` entries with `author === "user"`. */ + userSteers: number; + /** Total human interventions (sum of the components above). */ + total: number; +} + +export interface ToolAnalytics { + from: string | null; + to: string | null; + /** Total `tool_call` events in range. */ + toolCalls: number; + /** Tool calls grouped by `category`, descending by count. */ + byCategory: ToolCategoryCount[]; + /** Distinct sessions (`session_start` events) in range. */ + sessions: number; + interventions: InterventionBreakdown; + /** + * Autonomy ratio. When `interventions.total > 0` this is + * `toolCalls / interventions.total`. When there are zero interventions it is + * tool-calls-per-session (`toolCalls / max(sessions, 1)`) and + * `fullyAutonomous` is true — never ∞ or NaN. + */ + autonomyRatio: number; + /** True when zero human interventions were recorded in range. */ + fullyAutonomous: boolean; +} + +interface CountRow { + count: number; +} + +interface CategoryRow { + category: string | null; + count: number; +} + +interface SteeringRow { + steeringComments: string | null; +} + +function inRange(ts: string, from?: string, to?: string): boolean { + if (from !== undefined && ts < from) return false; + if (to !== undefined && ts > to) return false; + return true; +} + +/** + * Count human interventions from the three named sources (waiting-on-input is a + * status, not counted). Returns the per-source breakdown plus the total. + */ +export function countInterventions( + db: Database, + query: ToolAnalyticsQuery = {}, +): InterventionBreakdown { + // Source 1: approvals. `approval_request_audit_events.createdAt` is the ts; + // count only the human-touch event types. + const approvalClauses: string[] = ["eventType IN ('created', 'approved')"]; + const approvalParams: string[] = []; + if (query.from !== undefined) { + approvalClauses.push("createdAt >= ?"); + approvalParams.push(query.from); + } + if (query.to !== undefined) { + approvalClauses.push("createdAt <= ?"); + approvalParams.push(query.to); + } + const approvals = ( + db + .prepare( + `SELECT COUNT(*) AS count FROM approval_request_audit_events WHERE ${approvalClauses.join(" AND ")}`, + ) + .get(...approvalParams) as CountRow + ).count; + + // Source 2: user-authored steers from the `steeringComments` JSON on tasks. + // This re-introduces a per-task JSON read (documented in U2). Only rows with a + // non-empty JSON array are scanned. + const steeringRows = db + .prepare( + `SELECT steeringComments FROM tasks + WHERE steeringComments IS NOT NULL AND steeringComments NOT IN ('', '[]')`, + ) + .all() as SteeringRow[]; + let userSteers = 0; + for (const row of steeringRows) { + if (!row.steeringComments) continue; + let parsed: SteeringComment[]; + try { + parsed = JSON.parse(row.steeringComments) as SteeringComment[]; + } catch { + continue; + } + if (!Array.isArray(parsed)) continue; + for (const comment of parsed) { + if (comment?.author !== "user") continue; + if (!inRange(comment.createdAt ?? "", query.from, query.to)) continue; + userSteers += 1; + } + } + + return { approvals, userSteers, total: approvals + userSteers }; +} + +/** + * Aggregate tool usage and the autonomy ratio over a date range. + * + * Empty range yields zeroed structures (not nulls) and `autonomyRatio: 0`. + */ +export function aggregateToolAnalytics( + db: Database, + query: ToolAnalyticsQuery = {}, +): ToolAnalytics { + const eventClauses: string[] = []; + const eventParams: string[] = []; + if (query.from !== undefined) { + eventClauses.push("ts >= ?"); + eventParams.push(query.from); + } + if (query.to !== undefined) { + eventClauses.push("ts <= ?"); + eventParams.push(query.to); + } + const rangeWhere = eventClauses.length > 0 ? `AND ${eventClauses.join(" AND ")}` : ""; + + const toolCalls = ( + db + .prepare( + `SELECT COUNT(*) AS count FROM usage_events WHERE kind = 'tool_call' ${rangeWhere}`, + ) + .get(...eventParams) as CountRow + ).count; + + const categoryRows = db + .prepare( + `SELECT category AS category, COUNT(*) AS count + FROM usage_events + WHERE kind = 'tool_call' ${rangeWhere} + GROUP BY category`, + ) + .all(...eventParams) as CategoryRow[]; + const byCategory: ToolCategoryCount[] = categoryRows + .map((r) => ({ category: r.category ?? "other", count: r.count })) + .sort((a, b) => b.count - a.count); + + const sessions = ( + db + .prepare( + `SELECT COUNT(*) AS count FROM usage_events WHERE kind = 'session_start' ${rangeWhere}`, + ) + .get(...eventParams) as CountRow + ).count; + + const interventions = countInterventions(db, query); + + let autonomyRatio: number; + let fullyAutonomous: boolean; + if (interventions.total > 0) { + autonomyRatio = toolCalls / interventions.total; + fullyAutonomous = false; + } else { + // Zero interventions: report tool-calls-per-session, never ∞ / divide-by-zero. + autonomyRatio = toolCalls / Math.max(sessions, 1); + fullyAutonomous = true; + } + + return { + from: query.from ?? null, + to: query.to ?? null, + toolCalls, + byCategory, + sessions, + interventions, + autonomyRatio, + fullyAutonomous, + }; +} From 4519732b20c697e594806f7fe7a2aec745769a5f Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 15 Jun 2026 19:31:54 -0700 Subject: [PATCH 06/21] =?UTF-8?q?feat(analytics):=20U3=20=E2=80=94=20model?= =?UTF-8?q?=20pricing=20map=20+=20cost=20derivation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit costFor() derives USD from token counts via a hand-maintained provider:model rate map with pricingAsOf + staleness flag; unknown models report unavailable rather than guessing. Cost wired additively into token-analytics per-task so it is correct for any groupBy. --- .../core/src/__tests__/model-pricing.test.ts | 168 +++++++++ packages/core/src/index.ts | 13 + packages/core/src/model-pricing.ts | 333 ++++++++++++++++++ packages/core/src/token-analytics.ts | 79 ++++- 4 files changed, 592 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/__tests__/model-pricing.test.ts create mode 100644 packages/core/src/model-pricing.ts diff --git a/packages/core/src/__tests__/model-pricing.test.ts b/packages/core/src/__tests__/model-pricing.test.ts new file mode 100644 index 000000000..ea116b31f --- /dev/null +++ b/packages/core/src/__tests__/model-pricing.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect } from "vitest"; + +import { + costFor, + lookupPricing, + MODEL_PRICING, + pricingAsOf, + PRICING_STALE_AFTER_MS, +} from "../model-pricing.js"; + +const ZERO = { + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + cacheWriteTokens: 0, +}; + +describe("model-pricing", () => { + it("exposes a pricingAsOf ISO date and a staleness threshold", () => { + expect(pricingAsOf).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(Number.isNaN(Date.parse(pricingAsOf))).toBe(false); + expect(PRICING_STALE_AFTER_MS).toBeGreaterThan(0); + }); + + it("prices a known model + token counts to cent precision", () => { + // claude-opus-4-8: input $5/1M, output $25/1M. + // 1,000,000 input + 200,000 output = 5.00 + 5.00 = 10.00 + const result = costFor( + { ...ZERO, inputTokens: 1_000_000, outputTokens: 200_000 }, + { provider: "anthropic", model: "claude-opus-4-8" }, + ); + expect(result.unavailable).toBe(false); + expect(result.usd).not.toBeNull(); + expect(result.usd).toBeCloseTo(10.0, 2); + }); + + it("returns unavailable + null usd for an unknown model (never guesses)", () => { + const result = costFor( + { ...ZERO, inputTokens: 1_000_000 }, + { provider: "acme", model: "totally-made-up-model" }, + ); + expect(result.unavailable).toBe(true); + expect(result.usd).toBeNull(); + }); + + it("prices cache tokens at the cache rate, not the input rate", () => { + // claude-opus-4-8: input $5/1M, cacheRead $0.5/1M, cacheWrite $6.25/1M. + const model = { provider: "anthropic", model: "claude-opus-4-8" }; + + const cacheRead = costFor( + { ...ZERO, cachedTokens: 1_000_000 }, + model, + ); + // At cache-read rate ($0.5), NOT the input rate ($5). + expect(cacheRead.usd).toBeCloseTo(0.5, 2); + expect(cacheRead.usd).not.toBeCloseTo(5.0, 2); + + const cacheWrite = costFor( + { ...ZERO, cacheWriteTokens: 1_000_000 }, + model, + ); + expect(cacheWrite.usd).toBeCloseTo(6.25, 2); + + // A pure-input baseline confirms input is the more expensive rate. + const input = costFor({ ...ZERO, inputTokens: 1_000_000 }, model); + expect(input.usd).toBeCloseTo(5.0, 2); + }); + + it("sums all four token kinds at their respective rates", () => { + // 100k input(5) + 100k output(25) + 100k cacheRead(0.5) + 100k cacheWrite(6.25) + // = 0.5 + 2.5 + 0.05 + 0.625 = 3.675 + const result = costFor( + { + inputTokens: 100_000, + outputTokens: 100_000, + cachedTokens: 100_000, + cacheWriteTokens: 100_000, + }, + { provider: "anthropic", model: "claude-opus-4-8" }, + ); + expect(result.usd).toBeCloseTo(3.675, 3); + }); + + it("flags stale when now is past the staleness threshold", () => { + const asOf = Date.parse(pricingAsOf); + const wayLater = asOf + PRICING_STALE_AFTER_MS + 24 * 60 * 60 * 1000; + const result = costFor( + { ...ZERO, inputTokens: 1_000_000 }, + { provider: "anthropic", model: "claude-opus-4-8" }, + wayLater, + ); + expect(result.stale).toBe(true); + // Cost is still computed for a stale-but-present entry. + expect(result.usd).toBeCloseTo(5.0, 2); + }); + + it("does not flag stale within the threshold or when now is omitted", () => { + const asOf = Date.parse(pricingAsOf); + const model = { provider: "anthropic", model: "claude-opus-4-8" }; + const usage = { ...ZERO, inputTokens: 1_000_000 }; + + // Just inside the window. + const fresh = costFor(usage, model, asOf + PRICING_STALE_AFTER_MS - 1000); + expect(fresh.stale).toBe(false); + + // No `now` → never stale (pure: module never reads the clock). + const noNow = costFor(usage, model); + expect(noNow.stale).toBe(false); + }); + + it("still reports stale for an unknown model when now is past threshold", () => { + const asOf = Date.parse(pricingAsOf); + const wayLater = asOf + PRICING_STALE_AFTER_MS + 1000; + const result = costFor( + { ...ZERO, inputTokens: 1_000_000 }, + { provider: "acme", model: "nope" }, + wayLater, + ); + expect(result.unavailable).toBe(true); + expect(result.usd).toBeNull(); + expect(result.stale).toBe(true); + }); + + describe("lookupPricing", () => { + it("resolves by provider:model", () => { + expect( + lookupPricing({ provider: "openai", model: "gpt-4o" }), + ).toBe(MODEL_PRICING["openai:gpt-4o"]); + }); + + it("is case-insensitive and trims", () => { + expect( + lookupPricing({ provider: " OpenAI ", model: " GPT-4o " }), + ).toBe(MODEL_PRICING["openai:gpt-4o"]); + }); + + it("falls back to a bare model id when provider is unset", () => { + expect(lookupPricing({ model: "gemini-2.5-pro" })).toBe( + MODEL_PRICING["google:gemini-2.5-pro"], + ); + }); + + it("returns undefined for empty / unknown input", () => { + expect(lookupPricing({})).toBeUndefined(); + expect(lookupPricing({ model: "" })).toBeUndefined(); + expect(lookupPricing({ provider: "x", model: "y" })).toBeUndefined(); + }); + }); + + it("seeds Anthropic, OpenAI, and Google providers", () => { + const providers = new Set( + Object.keys(MODEL_PRICING).map((k) => k.split(":")[0]), + ); + expect(providers).toContain("anthropic"); + expect(providers).toContain("openai"); + expect(providers).toContain("google"); + }); + + it("every entry has all four rates and a source", () => { + for (const [key, entry] of Object.entries(MODEL_PRICING)) { + expect(typeof entry.inputPer1M, key).toBe("number"); + expect(typeof entry.outputPer1M, key).toBe("number"); + expect(typeof entry.cacheReadPer1M, key).toBe("number"); + expect(typeof entry.cacheWritePer1M, key).toBe("number"); + expect(entry.source.length, key).toBeGreaterThan(0); + } + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e9698c2c3..cd14f0492 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -530,6 +530,19 @@ export type { UsageEventKind, UsageEventRangeQuery, } from "./usage-events.js"; +export { + costFor, + lookupPricing, + MODEL_PRICING, + pricingAsOf, + PRICING_STALE_AFTER_MS, +} from "./model-pricing.js"; +export type { + ModelPricing, + ModelRef, + UsageForCost, + CostResult, +} from "./model-pricing.js"; export { aggregateTokenAnalytics } from "./token-analytics.js"; export type { TokenAnalytics, diff --git a/packages/core/src/model-pricing.ts b/packages/core/src/model-pricing.ts new file mode 100644 index 000000000..6f81eeafa --- /dev/null +++ b/packages/core/src/model-pricing.ts @@ -0,0 +1,333 @@ +/** + * Model pricing → USD cost derivation (KTD6, U3). + * + * Cost is **derived at read time** from token counts × a hand-maintained + * pricing map; it is never persisted (so historical rows stay correct when + * prices change, and no backfill migration is needed). Unknown models surface + * tokens with cost marked `unavailable` rather than guessing a price. + * + * ⚠️ HAND-MAINTAINED MAP. The `MODEL_PRICING` table below is curated by humans + * from each provider's public pricing pages — it is NOT fetched at runtime. + * When you update a rate, bump {@link pricingAsOf} in the same change. The UI + * surfaces `pricingAsOf` ("prices as of ") and marks entries older than + * {@link PRICING_STALE_AFTER_MS} as low-confidence, so stale-but-present rates + * (which the unknown-model guard does not catch) are visible rather than + * silently wrong. + * + * Rates are USD **per 1,000,000 tokens**. + * + * Pure data module: no DB, no I/O, and no `Date.now()` at import time. Callers + * that care about staleness pass an explicit `now`; otherwise staleness is + * judged against {@link pricingAsOf} alone (i.e. never stale). + */ + +/** + * The date the rates in {@link MODEL_PRICING} were last verified, ISO-8601. + * Bump this whenever you edit a rate. Surfaced in the UI as "prices as of". + */ +export const pricingAsOf = "2026-06-15"; + +/** + * Pricing entries older than this (relative to a caller-supplied `now`) are + * flagged `stale: true`. 180 days ≈ two quarters — long enough that routine + * price churn doesn't fire constantly, short enough that a long-unmaintained + * map is surfaced. Compared against {@link pricingAsOf}, not per-entry dates. + */ +export const PRICING_STALE_AFTER_MS = 180 * 24 * 60 * 60 * 1000; + +/** A single model's per-1M-token rates plus a citation. */ +export interface ModelPricing { + /** USD per 1M uncached input tokens. */ + inputPer1M: number; + /** USD per 1M output tokens. */ + outputPer1M: number; + /** USD per 1M cache-read (cached) input tokens. */ + cacheReadPer1M: number; + /** USD per 1M cache-write tokens. */ + cacheWritePer1M: number; + /** Where the rate came from (provider pricing page / docs). */ + source: string; +} + +/** Token counts to price. Mirrors {@link TokenTotals} from token-analytics. */ +export interface UsageForCost { + inputTokens: number; + outputTokens: number; + /** Cache-read tokens (priced at the cache-read rate, NOT the input rate). */ + cachedTokens: number; + /** Cache-write tokens (priced at the cache-write rate). */ + cacheWriteTokens: number; +} + +/** Result of {@link costFor}. */ +export interface CostResult { + /** Derived USD cost, or `null` when no price is known for the model. */ + usd: number | null; + /** True when the model has no pricing entry (cost is a guess-free `null`). */ + unavailable: boolean; + /** True when the pricing map is older than the staleness threshold. */ + stale: boolean; +} + +/** + * Hand-maintained pricing table, keyed by `provider:model`. + * + * Keys are lowercased `${provider}:${model}`. Lookup also falls back to the + * bare model id (`:model`) so callers that only know the model still resolve. + * Model ids match the strings Fusion stores in `tasks.modelId` / + * `tasks.modelProvider` (see `runtime-provider-probes.ts` and grep for + * `modelId`/`modelProvider`): Anthropic Claude, OpenAI, Google Gemini. + * + * Sources (verified 2026-06-15, see `pricingAsOf`): + * - Anthropic: platform.claude.com/docs/en/pricing (per-MTok; cache read ≈ + * 0.1× input, 5-min cache write ≈ 1.25× input). + * - OpenAI: openai.com/api/pricing (cached input ≈ 0.5×/0.25× input; OpenAI + * has no separate cache-write charge, so cacheWrite = input rate). + * - Google Gemini: ai.google.dev/gemini-api/docs/pricing (context-cache read + * rate; no distinct cache-write token charge, so cacheWrite = input rate). + */ +export const MODEL_PRICING: Readonly> = { + // ── Anthropic Claude ──────────────────────────────────────────────── + // input / output / cacheRead(0.1×) / cacheWrite(1.25×, 5-min TTL) + "anthropic:claude-opus-4-8": { + inputPer1M: 5, + outputPer1M: 25, + cacheReadPer1M: 0.5, + cacheWritePer1M: 6.25, + source: "platform.claude.com/docs/en/pricing", + }, + "anthropic:claude-opus-4-7": { + inputPer1M: 5, + outputPer1M: 25, + cacheReadPer1M: 0.5, + cacheWritePer1M: 6.25, + source: "platform.claude.com/docs/en/pricing", + }, + "anthropic:claude-opus-4-6": { + inputPer1M: 5, + outputPer1M: 25, + cacheReadPer1M: 0.5, + cacheWritePer1M: 6.25, + source: "platform.claude.com/docs/en/pricing", + }, + "anthropic:claude-opus-4-5": { + inputPer1M: 5, + outputPer1M: 25, + cacheReadPer1M: 0.5, + cacheWritePer1M: 6.25, + source: "platform.claude.com/docs/en/pricing", + }, + "anthropic:claude-opus-4-1": { + inputPer1M: 15, + outputPer1M: 75, + cacheReadPer1M: 1.5, + cacheWritePer1M: 18.75, + source: "platform.claude.com/docs/en/pricing", + }, + "anthropic:claude-opus-4-20250514": { + inputPer1M: 15, + outputPer1M: 75, + cacheReadPer1M: 1.5, + cacheWritePer1M: 18.75, + source: "platform.claude.com/docs/en/pricing", + }, + "anthropic:claude-sonnet-4-6": { + inputPer1M: 3, + outputPer1M: 15, + cacheReadPer1M: 0.3, + cacheWritePer1M: 3.75, + source: "platform.claude.com/docs/en/pricing", + }, + "anthropic:claude-sonnet-4-5": { + inputPer1M: 3, + outputPer1M: 15, + cacheReadPer1M: 0.3, + cacheWritePer1M: 3.75, + source: "platform.claude.com/docs/en/pricing", + }, + "anthropic:claude-sonnet-4-20250514": { + inputPer1M: 3, + outputPer1M: 15, + cacheReadPer1M: 0.3, + cacheWritePer1M: 3.75, + source: "platform.claude.com/docs/en/pricing", + }, + "anthropic:claude-haiku-4-5": { + inputPer1M: 1, + outputPer1M: 5, + cacheReadPer1M: 0.1, + cacheWritePer1M: 1.25, + source: "platform.claude.com/docs/en/pricing", + }, + "anthropic:claude-haiku-4-5-20251001": { + inputPer1M: 1, + outputPer1M: 5, + cacheReadPer1M: 0.1, + cacheWritePer1M: 1.25, + source: "platform.claude.com/docs/en/pricing", + }, + "anthropic:claude-fable-5": { + inputPer1M: 10, + outputPer1M: 50, + cacheReadPer1M: 1, + cacheWritePer1M: 12.5, + source: "platform.claude.com/docs/en/pricing", + }, + + // ── OpenAI ────────────────────────────────────────────────────────── + // OpenAI has no separate cache-write charge → cacheWrite = input rate. + "openai:gpt-5": { + inputPer1M: 1.25, + outputPer1M: 10, + cacheReadPer1M: 0.125, + cacheWritePer1M: 1.25, + source: "openai.com/api/pricing", + }, + "openai:gpt-5-mini": { + inputPer1M: 0.25, + outputPer1M: 2, + cacheReadPer1M: 0.025, + cacheWritePer1M: 0.25, + source: "openai.com/api/pricing", + }, + "openai:gpt-4o": { + inputPer1M: 2.5, + outputPer1M: 10, + cacheReadPer1M: 1.25, + cacheWritePer1M: 2.5, + source: "openai.com/api/pricing", + }, + "openai:gpt-4o-mini": { + inputPer1M: 0.15, + outputPer1M: 0.6, + cacheReadPer1M: 0.075, + cacheWritePer1M: 0.15, + source: "openai.com/api/pricing", + }, + "openai:gpt-4.1": { + inputPer1M: 2, + outputPer1M: 8, + cacheReadPer1M: 0.5, + cacheWritePer1M: 2, + source: "openai.com/api/pricing", + }, + "openai:gpt-4-turbo": { + inputPer1M: 10, + outputPer1M: 30, + cacheReadPer1M: 10, + cacheWritePer1M: 10, + source: "openai.com/api/pricing", + }, + "openai:o1": { + inputPer1M: 15, + outputPer1M: 60, + cacheReadPer1M: 7.5, + cacheWritePer1M: 15, + source: "openai.com/api/pricing", + }, + "openai:o3-mini": { + inputPer1M: 1.1, + outputPer1M: 4.4, + cacheReadPer1M: 0.55, + cacheWritePer1M: 1.1, + source: "openai.com/api/pricing", + }, + + // ── Google Gemini ─────────────────────────────────────────────────── + // No distinct cache-write token charge → cacheWrite = input rate. + "google:gemini-2.5-pro": { + inputPer1M: 1.25, + outputPer1M: 10, + cacheReadPer1M: 0.31, + cacheWritePer1M: 1.25, + source: "ai.google.dev/gemini-api/docs/pricing", + }, + "google:gemini-2.5-flash": { + inputPer1M: 0.3, + outputPer1M: 2.5, + cacheReadPer1M: 0.075, + cacheWritePer1M: 0.3, + source: "ai.google.dev/gemini-api/docs/pricing", + }, + "google:gemini-2.0-flash": { + inputPer1M: 0.1, + outputPer1M: 0.4, + cacheReadPer1M: 0.025, + cacheWritePer1M: 0.1, + source: "ai.google.dev/gemini-api/docs/pricing", + }, + "google:gemini-2.0-pro": { + inputPer1M: 1.25, + outputPer1M: 10, + cacheReadPer1M: 0.31, + cacheWritePer1M: 1.25, + source: "ai.google.dev/gemini-api/docs/pricing", + }, +}; + +/** Reference to a model, by provider + id (either may be unset). */ +export interface ModelRef { + provider?: string | null; + model?: string | null; +} + +function normalize(s: string | null | undefined): string { + return (s ?? "").trim().toLowerCase(); +} + +/** + * Resolve a pricing entry for a model. Tries `provider:model` first, then the + * bare `:model` (provider-agnostic) fallback. Returns `undefined` for unknown + * models — callers must treat that as `unavailable`, never as a guessed price. + */ +export function lookupPricing(ref: ModelRef): ModelPricing | undefined { + const provider = normalize(ref.provider); + const model = normalize(ref.model); + if (!model) return undefined; + if (provider) { + const exact = MODEL_PRICING[`${provider}:${model}`]; + if (exact) return exact; + } + // Provider-agnostic fallback: scan for any entry whose model id matches. + for (const [key, entry] of Object.entries(MODEL_PRICING)) { + if (key.endsWith(`:${model}`)) return entry; + } + return undefined; +} + +/** True when the pricing map is older than the threshold relative to `now`. */ +function isStale(now: number | undefined): boolean { + if (now === undefined) return false; + const asOf = Date.parse(pricingAsOf); + if (Number.isNaN(asOf)) return false; + return now - asOf > PRICING_STALE_AFTER_MS; +} + +/** + * Derive USD cost for `usage` under `model`'s rates. + * + * - Unknown model → `{ usd: null, unavailable: true, stale }` (never guessed). + * - Cache-read tokens are priced at the cache-read rate, cache-write tokens at + * the cache-write rate — NOT the input rate. + * - `stale` is true when the (caller-supplied) `now` is more than + * {@link PRICING_STALE_AFTER_MS} past {@link pricingAsOf}. With no `now`, + * `stale` is always false. + */ +export function costFor( + usage: UsageForCost, + model: ModelRef, + now?: number, +): CostResult { + const stale = isStale(now); + const pricing = lookupPricing(model); + if (!pricing) { + return { usd: null, unavailable: true, stale }; + } + const usd = + (usage.inputTokens * pricing.inputPer1M + + usage.outputTokens * pricing.outputPer1M + + usage.cachedTokens * pricing.cacheReadPer1M + + usage.cacheWriteTokens * pricing.cacheWritePer1M) / + 1_000_000; + return { usd, unavailable: false, stale }; +} diff --git a/packages/core/src/token-analytics.ts b/packages/core/src/token-analytics.ts index 695cf5eb1..07303a9c9 100644 --- a/packages/core/src/token-analytics.ts +++ b/packages/core/src/token-analytics.ts @@ -1,4 +1,5 @@ import type { Database } from "./db.js"; +import { costFor, type CostResult } from "./model-pricing.js"; /** * Token-consumption analytics over the `tasks` table, generalizing the fixed @@ -31,6 +32,13 @@ export interface TokenTotals { export interface TokenGroupSummary extends TokenTotals { /** The group key (model id, provider, nodeId, or agentId); null when unset. */ key: string | null; + /** + * Derived USD cost for this group (U3). Each contributing task is priced at + * its own model's rates and summed, so the cost is meaningful for any + * `groupBy`. `usd` is null when none of the group's tasks had a known price; + * `unavailable` is true when at least one task's model was unpriced. + */ + cost: CostResult; } /** Result of {@link aggregateTokenAnalytics}. */ @@ -40,6 +48,12 @@ export interface TokenAnalytics { groupBy: TokenGroupBy | null; /** Grand total across all matched tasks. */ totals: TokenTotals; + /** + * Derived USD cost across all matched tasks (U3), each priced at its own + * model's rates. `usd` is null when no task had a known price; `unavailable` + * is true when at least one task's model had no pricing entry. + */ + cost: CostResult; /** Per-group totals; empty array when no `groupBy` requested. */ groups: TokenGroupSummary[]; } @@ -50,6 +64,11 @@ export interface TokenAnalyticsQuery { /** ISO-8601 upper bound (inclusive) on `tokenUsageLastUsedAt`. */ to?: string; groupBy?: TokenGroupBy; + /** + * Epoch ms "now" used only for pricing-staleness (U3). When omitted, derived + * cost is never marked stale. Pure: the module never reads the clock itself. + */ + now?: number; } function emptyTotals(): TokenTotals { @@ -88,6 +107,52 @@ function groupKeyFor(row: TaskTokenRow, groupBy: TokenGroupBy): string | null { } } +/** + * Running cost tally. Each task is priced at its own model, then summed: `usd` + * accumulates priced tasks, `anyUnavailable` records whether any task's model + * was unpriced, `anyStale` whether the pricing map was stale, and `anyPriced` + * whether at least one task had a known price. {@link finalizeCost} converts + * this to a {@link CostResult}. + */ +interface CostAccumulator { + usd: number; + anyPriced: boolean; + anyUnavailable: boolean; + anyStale: boolean; +} + +function emptyCostAccumulator(): CostAccumulator { + return { usd: 0, anyPriced: false, anyUnavailable: false, anyStale: false }; +} + +function addRowCost(acc: CostAccumulator, row: TaskTokenRow, now?: number): void { + const result = costFor( + { + inputTokens: row.inputTokens ?? 0, + outputTokens: row.outputTokens ?? 0, + cachedTokens: row.cachedTokens ?? 0, + cacheWriteTokens: row.cacheWriteTokens ?? 0, + }, + { provider: row.modelProvider, model: row.modelId }, + now, + ); + if (result.stale) acc.anyStale = true; + if (result.unavailable || result.usd === null) { + acc.anyUnavailable = true; + } else { + acc.usd += result.usd; + acc.anyPriced = true; + } +} + +function finalizeCost(acc: CostAccumulator): CostResult { + return { + usd: acc.anyPriced ? acc.usd : null, + unavailable: acc.anyUnavailable, + stale: acc.anyStale, + }; +} + function addRow(totals: TokenTotals, row: TaskTokenRow): void { totals.inputTokens += row.inputTokens ?? 0; totals.outputTokens += row.outputTokens ?? 0; @@ -145,22 +210,33 @@ export function aggregateTokenAnalytics( .all(...params) as TaskTokenRow[]; const totals = emptyTotals(); + const totalCost = emptyCostAccumulator(); const groupMap = new Map(); + const groupCostMap = new Map(); const groupBy = query.groupBy; + const now = query.now; for (const row of rows) { addRow(totals, row); + addRowCost(totalCost, row, now); if (groupBy) { const key = groupKeyFor(row, groupBy); let group = groupMap.get(key); if (!group) { - group = { key, ...emptyTotals() }; + group = { key, ...emptyTotals(), cost: { usd: null, unavailable: false, stale: false } }; groupMap.set(key, group); + groupCostMap.set(key, emptyCostAccumulator()); } addRow(group, row); + addRowCost(groupCostMap.get(key)!, row, now); } } + // Finalize per-group cost from each group's accumulator. + for (const [key, group] of groupMap) { + group.cost = finalizeCost(groupCostMap.get(key)!); + } + const groups = [...groupMap.values()].sort( (a, b) => b.totalTokens - a.totalTokens, ); @@ -170,6 +246,7 @@ export function aggregateTokenAnalytics( to: query.to ?? null, groupBy: groupBy ?? null, totals, + cost: finalizeCost(totalCost), groups, }; } From 113263f3620f06d171c34ea67ceb162d793ff116 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 15 Jun 2026 19:44:10 -0700 Subject: [PATCH 07/21] =?UTF-8?q?feat(command-center):=20U9+U6a=20?= =?UTF-8?q?=E2=80=94=20analytics=20API=20endpoints=20+=20live=20snapshot?= =?UTF-8?q?=20composer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit composeLiveSnapshot (core, U6a) feeds GET /api/command-center/live; register- command-center-routes exposes tokens/tools/activity/productivity/live as thin adapters over the U2 aggregators + U3 cost. All endpoints inherit session auth (401 unauth) and apply getScopedStore (no cross-project leak). Vite proxy verified. --- .../src/__tests__/command-center-live.test.ts | 150 ++++++++++ packages/core/src/command-center-live.ts | 185 ++++++++++++ packages/core/src/index.ts | 7 + ...egister-command-center-routes.auth.test.ts | 92 ++++++ .../register-command-center-routes.test.ts | 279 ++++++++++++++++++ packages/dashboard/src/routes.ts | 4 + .../routes/register-command-center-routes.ts | 195 ++++++++++++ 7 files changed, 912 insertions(+) create mode 100644 packages/core/src/__tests__/command-center-live.test.ts create mode 100644 packages/core/src/command-center-live.ts create mode 100644 packages/dashboard/src/__tests__/register-command-center-routes.auth.test.ts create mode 100644 packages/dashboard/src/__tests__/register-command-center-routes.test.ts create mode 100644 packages/dashboard/src/routes/register-command-center-routes.ts diff --git a/packages/core/src/__tests__/command-center-live.test.ts b/packages/core/src/__tests__/command-center-live.test.ts new file mode 100644 index 000000000..2cb71fc07 --- /dev/null +++ b/packages/core/src/__tests__/command-center-live.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { Database } from "../db.js"; +import { composeLiveSnapshot } from "../command-center-live.js"; + +function insertSession( + db: Database, + opts: { + id: string; + taskId?: string | null; + agentState: string; + terminationReason?: string | null; + worktreePath?: string | null; + purpose?: string; + }, +): void { + db.prepare( + `INSERT INTO cli_sessions + (id, taskId, purpose, projectId, adapterId, agentState, terminationReason, worktreePath, createdAt, updatedAt) + VALUES (?, ?, ?, 'proj-1', 'claude-local', ?, ?, ?, ?, ?)`, + ).run( + opts.id, + opts.taskId ?? null, + opts.purpose ?? "execute", + opts.agentState, + opts.terminationReason ?? null, + opts.worktreePath ?? null, + "2026-03-01T00:00:00.000Z", + "2026-03-01T00:00:00.000Z", + ); +} + +function insertAgent(db: Database, id: string): void { + db.prepare( + `INSERT INTO agents (id, name, role, state, createdAt, updatedAt) + VALUES (?, ?, 'executor', 'idle', ?, ?)`, + ).run(id, id, "2026-03-01T00:00:00.000Z", "2026-03-01T00:00:00.000Z"); +} + +function insertRun( + db: Database, + opts: { id: string; agentId: string; status: string; taskId?: string }, +): void { + db.prepare( + `INSERT INTO agentRuns (id, agentId, data, startedAt, endedAt, status) + VALUES (?, ?, ?, ?, ?, ?)`, + ).run( + opts.id, + opts.agentId, + JSON.stringify(opts.taskId ? { taskId: opts.taskId } : {}), + "2026-03-01T00:00:00.000Z", + opts.status === "active" ? null : "2026-03-01T01:00:00.000Z", + opts.status, + ); +} + +function insertTask(db: Database, id: string, column: string): void { + db.prepare( + `INSERT INTO tasks (id, description, "column", createdAt, updatedAt) + VALUES (?, 'desc', ?, ?, ?)`, + ).run(id, column, "2026-03-01T00:00:00.000Z", "2026-03-01T00:00:00.000Z"); +} + +describe("command-center-live", () => { + let tmpDir: string; + let db: Database; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "kb-cc-live-")); + db = new Database(join(tmpDir, ".fusion")); + db.init(); + }); + + afterEach(async () => { + db.close(); + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("composes an empty snapshot with zeroed counts (not nulls)", () => { + const snap = composeLiveSnapshot(db, Date.parse("2026-03-01T12:00:00.000Z")); + expect(snap.capturedAt).toBe("2026-03-01T12:00:00.000Z"); + expect(snap.activeSessions).toBe(0); + expect(snap.activeRuns).toBe(0); + expect(snap.activeNodes).toBe(0); + expect(snap.sessions).toEqual([]); + expect(snap.runs).toEqual([]); + expect(snap.columns).toEqual([]); + }); + + it("counts active sessions and active nodes, excluding terminal/terminated", () => { + insertSession(db, { id: "s1", agentState: "busy", worktreePath: "/wt/node-a" }); + insertSession(db, { id: "s2", agentState: "ready", worktreePath: "/wt/node-b" }); + // same worktree as s1 → one distinct node + insertSession(db, { id: "s3", agentState: "waitingOnInput", worktreePath: "/wt/node-a" }); + // terminal state → excluded + insertSession(db, { id: "s4", agentState: "done", worktreePath: "/wt/node-c" }); + // terminated → excluded even though state is non-terminal + insertSession(db, { + id: "s5", + agentState: "busy", + terminationReason: "userExited", + worktreePath: "/wt/node-d", + }); + + const snap = composeLiveSnapshot(db); + expect(snap.activeSessions).toBe(3); // s1, s2, s3 + expect(snap.activeNodes).toBe(2); // /wt/node-a, /wt/node-b + expect(snap.sessions.map((s) => s.id).sort()).toEqual(["s1", "s2", "s3"]); + }); + + it("counts active runs only and extracts taskId from run data", () => { + insertAgent(db, "agent-1"); + insertRun(db, { id: "r1", agentId: "agent-1", status: "active", taskId: "FN-1" }); + insertRun(db, { id: "r2", agentId: "agent-1", status: "completed", taskId: "FN-2" }); + insertRun(db, { id: "r3", agentId: "agent-1", status: "active" }); + + const snap = composeLiveSnapshot(db); + expect(snap.activeRuns).toBe(2); + expect(snap.runs.map((r) => r.id).sort()).toEqual(["r1", "r3"]); + const r1 = snap.runs.find((r) => r.id === "r1"); + expect(r1?.taskId).toBe("FN-1"); + const r3 = snap.runs.find((r) => r.id === "r3"); + expect(r3?.taskId).toBeNull(); + }); + + it("produces current per-column task counts", () => { + insertTask(db, "FN-1", "todo"); + insertTask(db, "FN-2", "todo"); + insertTask(db, "FN-3", "in-progress"); + insertTask(db, "FN-4", "done"); + + const snap = composeLiveSnapshot(db); + const byColumn = Object.fromEntries(snap.columns.map((c) => [c.column, c.count])); + expect(byColumn).toEqual({ todo: 2, "in-progress": 1, done: 1 }); + }); + + it("is a pure read — does not mutate the database", () => { + insertTask(db, "FN-1", "todo"); + composeLiveSnapshot(db); + composeLiveSnapshot(db); + const count = ( + db.prepare(`SELECT COUNT(*) AS count FROM tasks`).get() as { count: number } + ).count; + expect(count).toBe(1); + }); +}); diff --git a/packages/core/src/command-center-live.ts b/packages/core/src/command-center-live.ts new file mode 100644 index 000000000..8aa0f71b5 --- /dev/null +++ b/packages/core/src/command-center-live.ts @@ -0,0 +1,185 @@ +import type { Database } from "./db.js"; + +/** + * Live Mission-Control snapshot composer (U6a). + * + * Builds an instantaneous, point-in-time view of orchestration activity from the + * existing tables — `agentRuns` / `agentHeartbeats` (active heartbeat runs), + * `cli_sessions` (live CLI/chat sessions), and `tasks` (current per-column + * counts). It is a **pure read** over a {@link Database} handle: no clock, no + * network, no engine dependency, so the engine, CLI, and the dashboard route + * (U9) can all reuse it. The dashboard's `/api/command-center/live` endpoint is a + * thin adapter over this function (KTD2). + * + * "Live" here means *current state*, not a date range: it counts what is active + * right now (active runs, live sessions) and the present board distribution. The + * snapshot carries a `capturedAt` ISO timestamp so callers can label staleness. + * + * Active definitions: + * - **Active session** — a `cli_sessions` row whose `agentState` is not a + * terminal state (`done`/`dead`) and whose `terminationReason` is still null. + * - **Active run** — an `agentRuns` row with `status = 'active'` (matching the + * {@link import("./types.js").AgentHeartbeatRun} status union). + * - **Active node** — a distinct, non-null node id observed across active + * sessions (no `nodeId` column exists on `agentRuns`, so nodes are sourced + * from `cli_sessions`). + */ + +/** A single active CLI/chat session in the live snapshot. */ +export interface LiveSession { + id: string; + /** Bound task id, or null for an unbound (e.g. chat) session. */ + taskId: string | null; + purpose: string; + adapterId: string; + agentState: string; + /** Worktree/node path the session runs in, or null. */ + worktreePath: string | null; + updatedAt: string; +} + +/** A single active heartbeat run in the live snapshot. */ +export interface LiveRun { + id: string; + agentId: string; + taskId: string | null; + startedAt: string; +} + +/** Current task count for one board column. */ +export interface ColumnCount { + column: string; + count: number; +} + +/** The composed live Mission-Control snapshot. */ +export interface LiveSnapshot { + /** ISO-8601 timestamp this snapshot was composed. */ + capturedAt: string; + /** Number of active (non-terminal, non-terminated) CLI/chat sessions. */ + activeSessions: number; + /** Number of active heartbeat runs (`agentRuns.status = 'active'`). */ + activeRuns: number; + /** Distinct non-null nodes with at least one active session. */ + activeNodes: number; + /** The active sessions, most-recently-updated first. */ + sessions: LiveSession[]; + /** The active heartbeat runs, most-recently-started first. */ + runs: LiveRun[]; + /** Current per-column task counts (the SDLC funnel's live snapshot). */ + columns: ColumnCount[]; +} + +/** Terminal CLI agent states — a session in one of these is not "active". */ +const TERMINAL_SESSION_STATES = ["done", "dead"] as const; + +interface SessionRow { + id: string; + taskId: string | null; + purpose: string; + adapterId: string; + agentState: string; + worktreePath: string | null; + updatedAt: string; +} + +interface ColumnRow { + column: string; + count: number; +} + +interface CountRow { + count: number; +} + +/** + * Compose a live Mission-Control snapshot from the current database state. + * + * Pure and synchronous: takes a {@link Database} handle and returns plain data. + * `capturedAt` defaults to `new Date().toISOString()`; pass `now` (epoch ms) to + * make the timestamp deterministic in tests — no other value reads the clock. + */ +export function composeLiveSnapshot(db: Database, now?: number): LiveSnapshot { + const capturedAt = new Date(now ?? Date.now()).toISOString(); + + const terminalPlaceholders = TERMINAL_SESSION_STATES.map(() => "?").join(", "); + + // Active sessions: not in a terminal state and not terminated. + const sessionRows = db + .prepare( + `SELECT id, taskId, purpose, adapterId, agentState, worktreePath, updatedAt + FROM cli_sessions + WHERE agentState NOT IN (${terminalPlaceholders}) + AND terminationReason IS NULL + ORDER BY updatedAt DESC`, + ) + .all(...TERMINAL_SESSION_STATES) as SessionRow[]; + const sessions: LiveSession[] = sessionRows.map((r) => ({ + id: r.id, + taskId: r.taskId ?? null, + purpose: r.purpose, + adapterId: r.adapterId, + agentState: r.agentState, + worktreePath: r.worktreePath ?? null, + updatedAt: r.updatedAt, + })); + + // Active nodes: distinct non-null worktree paths across active sessions. + // (cli_sessions has no nodeId column; worktreePath is the per-node locator.) + const activeNodes = new Set( + sessions + .map((s) => s.worktreePath) + .filter((p): p is string => typeof p === "string" && p.length > 0), + ).size; + + // Active heartbeat runs. + const runRows = db + .prepare( + `SELECT id, agentId, startedAt, data + FROM agentRuns + WHERE status = 'active' + ORDER BY startedAt DESC`, + ) + .all() as Array<{ id: string; agentId: string; startedAt: string; data: string }>; + const runs: LiveRun[] = runRows.map((r) => { + let taskId: string | null = null; + try { + const data = JSON.parse(r.data) as { taskId?: string }; + if (typeof data.taskId === "string") taskId = data.taskId; + } catch { + // Malformed run data → leave taskId null rather than throw. + } + return { id: r.id, agentId: r.agentId, taskId, startedAt: r.startedAt }; + }); + + const activeRuns = ( + db + .prepare(`SELECT COUNT(*) AS count FROM agentRuns WHERE status = 'active'`) + .get() as CountRow + ).count; + + // Current per-column task counts. `column` is a reserved word in the schema, + // so it is quoted. + const columnRows = db + .prepare( + `SELECT "column" AS column, COUNT(*) AS count + FROM tasks + GROUP BY "column" + ORDER BY count DESC`, + ) + .all() as ColumnRow[]; + const columns: ColumnCount[] = columnRows.map((r) => ({ + column: r.column, + count: r.count, + })); + + return { + capturedAt, + activeSessions: sessions.length, + activeRuns, + activeNodes, + sessions, + runs, + columns, + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cd14f0492..329086268 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -572,6 +572,13 @@ export type { LanguageCount, LocSummary, } from "./productivity-analytics.js"; +export { composeLiveSnapshot } from "./command-center-live.js"; +export type { + LiveSnapshot, + LiveSession, + LiveRun, + ColumnCount, +} from "./command-center-live.js"; export { STALLED_REVIEW_REENQUEUE_THRESHOLD, STALLED_REVIEW_INVALID_TRANSITION_THRESHOLD, diff --git a/packages/dashboard/src/__tests__/register-command-center-routes.auth.test.ts b/packages/dashboard/src/__tests__/register-command-center-routes.auth.test.ts new file mode 100644 index 000000000..9dfd73fec --- /dev/null +++ b/packages/dashboard/src/__tests__/register-command-center-routes.auth.test.ts @@ -0,0 +1,92 @@ +// @vitest-environment node + +/** + * Auth integration for the Command Center endpoints: every endpoint, including + * `/live`, must be rejected with 401 when unauthenticated and accepted with a + * valid bearer token. Mirrors `auth-middleware-integration.test.ts` but exercises + * the U9 routes specifically (the registrar adds no auth of its own — it inherits + * the server-level middleware, which is exactly what this asserts). + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { EventEmitter } from "node:events"; +import type { Task, TaskStore } from "@fusion/core"; +import { request } from "../test-request.js"; +import { createServer } from "../server.js"; + +vi.mock("@fusion/core", async (importOriginal) => { + const { createCoreMock } = await import("../test/mockCoreEngine.js"); + return createCoreMock(() => importOriginal(), {}); +}); + +class MockStore extends EventEmitter { + getRootDir(): string { + return "/tmp/fn-cc-auth-test"; + } + + getFusionDir(): string { + return "/tmp/fn-cc-auth-test/.fusion"; + } + + getDatabase() { + return { + exec: vi.fn(), + prepare: vi.fn().mockReturnValue({ + run: vi.fn().mockReturnValue({ changes: 0 }), + get: vi.fn().mockReturnValue({ count: 0 }), + all: vi.fn().mockReturnValue([]), + }), + }; + } + + getDatabaseHealth() { + return { + healthy: true, + corruptionDetected: false, + corruptionErrors: [], + isRunning: false, + lastCheckedAt: null, + }; + } + + async listTasks(): Promise { + return []; + } +} + +const TOKEN = "fn_cc_test1234567890abcdef"; +const ENDPOINTS = [ + "/api/command-center/tokens", + "/api/command-center/tools", + "/api/command-center/activity", + "/api/command-center/productivity", + "/api/command-center/live", +]; + +describe("Command Center routes — auth", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("rejects unauthenticated requests to every endpoint (incl. /live) with 401", async () => { + const app = createServer(new MockStore() as unknown as TaskStore, { + daemon: { token: TOKEN }, + }); + for (const path of ENDPOINTS) { + const res = await request(app, "GET", path); + expect(res.status, `${path} should be 401 unauthenticated`).toBe(401); + } + }); + + it("accepts every endpoint (incl. /live) with a valid bearer token", async () => { + const app = createServer(new MockStore() as unknown as TaskStore, { + daemon: { token: TOKEN }, + }); + for (const path of ENDPOINTS) { + const res = await request(app, "GET", path, undefined, { + Authorization: `Bearer ${TOKEN}`, + }); + expect(res.status, `${path} should be 200 with token`).toBe(200); + } + }); +}); diff --git a/packages/dashboard/src/__tests__/register-command-center-routes.test.ts b/packages/dashboard/src/__tests__/register-command-center-routes.test.ts new file mode 100644 index 000000000..21def3601 --- /dev/null +++ b/packages/dashboard/src/__tests__/register-command-center-routes.test.ts @@ -0,0 +1,279 @@ +// @vitest-environment node + +import express, { type NextFunction, type Request, type Response } from "express"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { EventEmitter } from "node:events"; + +import { Database, emitUsageEvent } from "@fusion/core"; +import type { TaskStore } from "@fusion/core"; +import { request } from "../test-request.js"; +import { ApiError } from "../api-error.js"; +import { + registerCommandCenterRoutes, + resolveRange, + resolveGroupBy, + DEFAULT_WINDOW_DAYS, +} from "../routes/register-command-center-routes.js"; +import type { ApiRoutesContext } from "../routes/types.js"; + +/** Seed a temp DB with a token-bearing task and a tool-call usage event. */ +function seedDb(db: Database, opts: { taskId: string; model: string; tokens: number }): void { + db.prepare( + `INSERT INTO tasks + (id, description, "column", modelProvider, modelId, + tokenUsageInputTokens, tokenUsageOutputTokens, tokenUsageTotalTokens, + tokenUsageLastUsedAt, createdAt, updatedAt) + VALUES (?, 'desc', 'todo', 'anthropic', ?, ?, ?, ?, ?, ?, ?)`, + ).run( + opts.taskId, + opts.model, + opts.tokens, + opts.tokens, + opts.tokens * 2, + "2026-03-01T00:00:00.000Z", + "2026-03-01T00:00:00.000Z", + "2026-03-01T00:00:00.000Z", + ); + emitUsageEvent(db, { + kind: "tool_call", + taskId: opts.taskId, + agentId: "agent-1", + nodeId: "node-1", + category: "edit", + ts: "2026-03-01T00:00:00.000Z", + }); +} + +/** + * Build an express app with the registrar mounted, backed by per-project real + * DBs. The `getScopedStore` resolves the DB by the `projectId` query param, + * proving project scoping at the route boundary. + */ +function buildApp(stores: Record, fallback: TaskStore) { + const app = express(); + app.use(express.json()); + + const router = express.Router(); + const ctx = { + router, + getScopedStore: async (req: Request): Promise => { + const projectId = + typeof req.query.projectId === "string" ? req.query.projectId : undefined; + return projectId && stores[projectId] ? stores[projectId] : fallback; + }, + rethrowAsApiError: (error: unknown, fallbackMessage?: string): never => { + if (error instanceof ApiError) throw error; + throw new ApiError(500, fallbackMessage ?? "Internal error"); + }, + } as unknown as ApiRoutesContext; + + registerCommandCenterRoutes(ctx); + app.use("/api", router); + + // Minimal ApiError → HTTP status mapper (mirrors server.ts behaviour). + app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => { + if (err instanceof ApiError) { + res.status(err.statusCode).json({ error: err.message }); + return; + } + res.status(500).json({ error: "Internal error" }); + }); + + return app; +} + +/** A minimal TaskStore exposing only getDatabase(), which is all the routes use. */ +function storeFor(db: Database): TaskStore { + const store = new EventEmitter() as unknown as TaskStore & { getDatabase(): Database }; + store.getDatabase = () => db; + return store; +} + +describe("register-command-center-routes", () => { + let tmpDir: string; + let dbA: Database; + let dbB: Database; + let app: ReturnType; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "kb-cc-routes-")); + dbA = new Database(join(tmpDir, "a", ".fusion")); + dbA.init(); + dbB = new Database(join(tmpDir, "b", ".fusion")); + dbB.init(); + + // Project A: a known task + tool call. Project B: a *different* marker task. + seedDb(dbA, { taskId: "FN-A1", model: "claude-sonnet-4-5", tokens: 100 }); + seedDb(dbB, { taskId: "FN-B1", model: "claude-opus-4-5", tokens: 999 }); + + const storeA = storeFor(dbA); + const storeB = storeFor(dbB); + app = buildApp({ "proj-a": storeA, "proj-b": storeB }, storeA); + }); + + afterEach(() => { + dbA.close(); + dbB.close(); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("returns the token aggregator shape for a fixture DB", async () => { + const res = await request( + app, + "GET", + "/api/command-center/tokens?from=2026-02-01T00:00:00.000Z&to=2026-04-01T00:00:00.000Z&groupBy=model&projectId=proj-a", + ); + expect(res.status).toBe(200); + const body = res.body as Record; + expect(body).toHaveProperty("totals"); + expect(body).toHaveProperty("cost"); + expect(body).toHaveProperty("groups"); + expect(body.groupBy).toBe("model"); + expect((body.totals as { totalTokens: number }).totalTokens).toBe(200); + }); + + it("returns the tools / activity / productivity aggregator shapes", async () => { + const range = "from=2026-02-01T00:00:00.000Z&to=2026-04-01T00:00:00.000Z"; + const tools = await request(app, "GET", `/api/command-center/tools?${range}&projectId=proj-a`); + expect(tools.status).toBe(200); + expect(tools.body).toHaveProperty("autonomyRatio"); + expect((tools.body as { toolCalls: number }).toolCalls).toBe(1); + + const activity = await request(app, "GET", `/api/command-center/activity?${range}&projectId=proj-a`); + expect(activity.status).toBe(200); + expect(activity.body).toHaveProperty("stickiness"); + expect(activity.body).toHaveProperty("mttr"); + + const prod = await request(app, "GET", `/api/command-center/productivity?${range}&projectId=proj-a`); + expect(prod.status).toBe(200); + expect(prod.body).toHaveProperty("loc"); + expect(prod.body).toHaveProperty("byLanguage"); + }); + + it("returns the live snapshot shape", async () => { + const res = await request(app, "GET", "/api/command-center/live?projectId=proj-a"); + expect(res.status).toBe(200); + const body = res.body as Record; + expect(body).toHaveProperty("capturedAt"); + expect(body).toHaveProperty("activeSessions"); + expect(body).toHaveProperty("columns"); + // Project A seeded one 'todo' task. + expect(body.columns).toContainEqual({ column: "todo", count: 1 }); + }); + + it("invalid range params fall back to the default window, not a 500", async () => { + const res = await request( + app, + "GET", + "/api/command-center/tokens?from=not-a-date&to=also-bad&projectId=proj-a", + ); + expect(res.status).toBe(200); + const body = res.body as Record; + // Defaulted window is recent (last 7d), so the 2026-03 fixture is out of + // range → zeroed totals, but never a 500. + expect(body).toHaveProperty("totals"); + }); + + it("missing range params default rather than 500", async () => { + const res = await request(app, "GET", "/api/command-center/tokens?projectId=proj-a"); + expect(res.status).toBe(200); + expect(res.body).toHaveProperty("totals"); + }); + + it("project scoping — project-A request cannot read project-B data (JSON)", async () => { + const a = await request( + app, + "GET", + "/api/command-center/tokens?from=2026-02-01T00:00:00.000Z&to=2026-04-01T00:00:00.000Z&projectId=proj-a", + ); + const b = await request( + app, + "GET", + "/api/command-center/tokens?from=2026-02-01T00:00:00.000Z&to=2026-04-01T00:00:00.000Z&projectId=proj-b", + ); + // A's task had 100 input tokens (total 200); B's had 999 (total 1998). + expect((a.body as { totals: { totalTokens: number } }).totals.totalTokens).toBe(200); + expect((b.body as { totals: { totalTokens: number } }).totals.totalTokens).toBe(1998); + }); + + it("project scoping — /live is scoped per project", async () => { + // Add a distinguishing 'in-review' task only to project B. + dbB.prepare( + `INSERT INTO tasks (id, description, "column", createdAt, updatedAt) + VALUES ('FN-B2', 'd', 'in-review', '2026-03-01T00:00:00.000Z', '2026-03-01T00:00:00.000Z')`, + ).run(); + + const a = await request(app, "GET", "/api/command-center/live?projectId=proj-a"); + const b = await request(app, "GET", "/api/command-center/live?projectId=proj-b"); + const aColumns = (a.body as { columns: { column: string }[] }).columns.map((c) => c.column); + const bColumns = (b.body as { columns: { column: string }[] }).columns.map((c) => c.column); + expect(aColumns).not.toContain("in-review"); + expect(bColumns).toContain("in-review"); + }); +}); + +describe("resolveRange / resolveGroupBy (param parsing)", () => { + const NOW = Date.parse("2026-06-15T00:00:00.000Z"); + + it("uses valid, ordered ISO bounds as-is", () => { + const r = resolveRange( + { from: "2026-06-01T00:00:00.000Z", to: "2026-06-10T00:00:00.000Z" }, + NOW, + ); + expect(r.defaulted).toBe(false); + expect(r.from).toBe("2026-06-01T00:00:00.000Z"); + expect(r.to).toBe("2026-06-10T00:00:00.000Z"); + }); + + it("defaults to the last-7d window for missing params", () => { + const r = resolveRange({}, NOW); + expect(r.defaulted).toBe(true); + expect(r.to).toBe(new Date(NOW).toISOString()); + expect(r.from).toBe( + new Date(NOW - DEFAULT_WINDOW_DAYS * 24 * 60 * 60 * 1000).toISOString(), + ); + }); + + it("defaults when from > to (inverted range)", () => { + const r = resolveRange( + { from: "2026-06-10T00:00:00.000Z", to: "2026-06-01T00:00:00.000Z" }, + NOW, + ); + expect(r.defaulted).toBe(true); + }); + + it("defaults when a bound is unparseable", () => { + const r = resolveRange({ from: "garbage", to: "2026-06-10T00:00:00.000Z" }, NOW); + expect(r.defaulted).toBe(true); + }); + + it("accepts known groupBy values and ignores unknown ones", () => { + expect(resolveGroupBy({ groupBy: "model" })).toBe("model"); + expect(resolveGroupBy({ groupBy: "provider" })).toBe("provider"); + expect(resolveGroupBy({ groupBy: "bogus" })).toBeUndefined(); + expect(resolveGroupBy({})).toBeUndefined(); + }); +}); + +describe("vite /api proxy negative-lookahead (proxy verification)", () => { + // The exact key from packages/dashboard/vite.config.ts's server.proxy. Real + // /api endpoints must proxy to the backend; app source modules ending in a + // .ts/.tsx (?import) suffix must stay on the Vite dev server. + const PROXY_RE = new RegExp("^/api(?!/.*\\.[jt]sx?(?:\\?|$))(/|$)"); + + it("proxies the real command-center endpoints to the backend", () => { + expect(PROXY_RE.test("/api/command-center/tokens")).toBe(true); + expect(PROXY_RE.test("/api/command-center/live")).toBe(true); + expect(PROXY_RE.test("/api/command-center/activity?from=x&to=y")).toBe(true); + }); + + it("leaves .ts?import source module paths on Vite (not proxied)", () => { + expect(PROXY_RE.test("/api/command-center/foo.ts?import")).toBe(false); + expect(PROXY_RE.test("/api/command-center/Component.tsx?import")).toBe(false); + expect(PROXY_RE.test("/api/something.ts")).toBe(false); + }); +}); diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 7ccbae277..3b8a903a0 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -168,6 +168,7 @@ import { registerProxyRoutes } from "./routes/register-proxy-routes.js"; import { registerModelRoutes } from "./routes/register-model-routes.js"; import { registerCustomProviderRoutes } from "./routes/register-custom-provider-routes.js"; import { registerUsageRoutes } from "./routes/register-usage-routes.js"; +import { registerCommandCenterRoutes } from "./routes/register-command-center-routes.js"; import { registerSignalRoutes } from "./routes/register-signal-routes.js"; import { registerAuthRoutes } from "./routes/register-auth-routes.js"; import { registerRuntimeProviderRoutes } from "./routes/register-runtime-provider-routes.js"; @@ -1990,6 +1991,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout }); registerUsageRoutes(routeContext); + // U9 — Command Center analytics + live snapshot endpoints. Thin adapters over + // the core aggregators; inherit standard auth + getScopedStore project scoping. + registerCommandCenterRoutes(routeContext); // U11 — inbound external signal webhooks (Sentry/Datadog/PagerDuty/generic). // Each route HMAC-verifies against a per-provider secret; never an // unauthenticated task-creation endpoint. diff --git a/packages/dashboard/src/routes/register-command-center-routes.ts b/packages/dashboard/src/routes/register-command-center-routes.ts new file mode 100644 index 000000000..ee9664997 --- /dev/null +++ b/packages/dashboard/src/routes/register-command-center-routes.ts @@ -0,0 +1,195 @@ +import { + aggregateTokenAnalytics, + aggregateToolAnalytics, + aggregateActivityAnalytics, + aggregateProductivityAnalytics, + composeLiveSnapshot, + type TokenGroupBy, +} from "@fusion/core"; +import type { Request } from "express"; +import { ApiError } from "../api-error.js"; +import type { ApiRouteRegistrar } from "./types.js"; + +/** + * Command Center analytics API (U9). + * + * Thin HTTP adapters over the Phase-A core aggregators + * (`{token,tool,activity,productivity}-analytics.ts`) and the U6a live-snapshot + * composer (`command-center-live.ts`). All metric math lives in `@fusion/core` + * (KTD2); these handlers only parse the request, resolve the **project-scoped** + * store, and serialize the aggregator output. + * + * Security: + * - Every route inherits the dashboard's standard session/auth middleware via + * the {@link ApiRouteRegistrar} contract — exactly like `register-usage-routes.ts`. + * No analytics endpoint, including `/live`, is unauthenticated; an + * unauthenticated request is rejected with 401 by the server-level auth + * middleware before reaching these handlers. + * - Every endpoint (JSON and `/live`) resolves the database through + * `getScopedStore(req)` before aggregating, so a project-A caller can never + * read project-B data. + * + * Robustness: + * - Missing or invalid `from`/`to`/`groupBy` query params fall back to a + * documented default window (the last {@link DEFAULT_WINDOW_DAYS} days) and a + * no-grouping default — never a 500. See {@link resolveRange}. + */ + +/** Documented default analytics window when range params are absent/invalid. */ +export const DEFAULT_WINDOW_DAYS = 7; + +const VALID_GROUP_BY: ReadonlySet = new Set([ + "model", + "provider", + "node", + "agent", +]); + +/** A resolved, always-valid `[from, to]` ISO range. */ +export interface ResolvedRange { + from: string; + to: string; + /** True when the caller's params were missing/invalid and the default applied. */ + defaulted: boolean; +} + +function isValidIso(value: string): boolean { + const t = Date.parse(value); + return Number.isFinite(t); +} + +/** + * Resolve `from`/`to` query params into an always-valid ISO range. + * + * Both bounds must be present, parseable, and ordered (`from <= to`); otherwise + * the documented default window (last {@link DEFAULT_WINDOW_DAYS} days ending + * now) is used and `defaulted` is true. `now` is injectable for tests. + */ +export function resolveRange( + query: Request["query"], + now: number = Date.now(), +): ResolvedRange { + const rawFrom = typeof query.from === "string" ? query.from : undefined; + const rawTo = typeof query.to === "string" ? query.to : undefined; + + if ( + rawFrom !== undefined && + rawTo !== undefined && + isValidIso(rawFrom) && + isValidIso(rawTo) && + Date.parse(rawFrom) <= Date.parse(rawTo) + ) { + return { from: rawFrom, to: rawTo, defaulted: false }; + } + + const to = new Date(now).toISOString(); + const from = new Date(now - DEFAULT_WINDOW_DAYS * 24 * 60 * 60 * 1000).toISOString(); + return { from, to, defaulted: true }; +} + +/** Resolve the `groupBy` query param, ignoring unknown values. */ +export function resolveGroupBy(query: Request["query"]): TokenGroupBy | undefined { + const raw = typeof query.groupBy === "string" ? query.groupBy : undefined; + return raw !== undefined && VALID_GROUP_BY.has(raw) ? (raw as TokenGroupBy) : undefined; +} + +export const registerCommandCenterRoutes: ApiRouteRegistrar = (ctx) => { + const { router, getScopedStore, rethrowAsApiError } = ctx; + + /** + * GET /api/command-center/tokens + * Token consumption + derived USD cost (U2 + U3) over a date range. + * Query: from, to (ISO-8601), groupBy (model|provider|node|agent). + */ + router.get("/command-center/tokens", async (req, res) => { + try { + const store = await getScopedStore(req); + const range = resolveRange(req.query); + const groupBy = resolveGroupBy(req.query); + const result = aggregateTokenAnalytics(store.getDatabase(), { + from: range.from, + to: range.to, + groupBy, + now: Date.now(), + }); + res.json(result); + } catch (err: unknown) { + if (err instanceof ApiError) throw err; + rethrowAsApiError(err, "Failed to aggregate token analytics"); + } + }); + + /** + * GET /api/command-center/tools + * Tool-usage counts + autonomy ratio (U2) over a date range. + */ + router.get("/command-center/tools", async (req, res) => { + try { + const store = await getScopedStore(req); + const range = resolveRange(req.query); + const result = aggregateToolAnalytics(store.getDatabase(), { + from: range.from, + to: range.to, + }); + res.json(result); + } catch (err: unknown) { + if (err instanceof ApiError) throw err; + rethrowAsApiError(err, "Failed to aggregate tool analytics"); + } + }); + + /** + * GET /api/command-center/activity + * Sessions/messages/active-nodes/stickiness (U2) over a date range. + */ + router.get("/command-center/activity", async (req, res) => { + try { + const store = await getScopedStore(req); + const range = resolveRange(req.query); + const result = aggregateActivityAnalytics(store.getDatabase(), { + from: range.from, + to: range.to, + }); + res.json(result); + } catch (err: unknown) { + if (err instanceof ApiError) throw err; + rethrowAsApiError(err, "Failed to aggregate activity analytics"); + } + }); + + /** + * GET /api/command-center/productivity + * Files/commits/PRs/LOC (U2) over a date range. + */ + router.get("/command-center/productivity", async (req, res) => { + try { + const store = await getScopedStore(req); + const range = resolveRange(req.query); + const result = aggregateProductivityAnalytics(store.getDatabase(), { + from: range.from, + to: range.to, + }); + res.json(result); + } catch (err: unknown) { + if (err instanceof ApiError) throw err; + rethrowAsApiError(err, "Failed to aggregate productivity analytics"); + } + }); + + /** + * GET /api/command-center/live + * Live Mission-Control snapshot (U6a): active sessions/runs/nodes + current + * per-column task counts. No date range — current state only. Scoped + authed + * like every other endpoint. + */ + router.get("/command-center/live", async (req, res) => { + try { + const store = await getScopedStore(req); + const result = composeLiveSnapshot(store.getDatabase()); + res.json(result); + } catch (err: unknown) { + if (err instanceof ApiError) throw err; + rethrowAsApiError(err, "Failed to compose live snapshot"); + } + }); +}; From e617ce65d9763ffa37bc0f8804e85a66a82b2940 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 15 Jun 2026 19:44:17 -0700 Subject: [PATCH 08/21] =?UTF-8?q?feat(pr):=20U18=20=E2=80=94=20surface=20+?= =?UTF-8?q?=20gate=20auto-resolution=20of=20PR=20review=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds autoResolveReviewComments project setting (default on) gating the existing Review-response loop, a single-sourced summarizePrThreadActivity counter, and fixed/acted thread counts in the dashboard PR summary. Resolution stays independent of the auto-merge gate (disabled merge still resolves threads). --- packages/core/src/__tests__/pr-entity.test.ts | 42 ++++++- packages/core/src/pr-entity.ts | 46 ++++++- packages/core/src/settings-schema.ts | 3 + packages/core/src/types.ts | 7 ++ .../__tests__/routes-pull-requests.test.ts | 6 +- .../routes/register-pull-requests-routes.ts | 14 ++- .../engine/src/__tests__/pr-nodes.test.ts | 112 ++++++++++++++++++ packages/engine/src/pr-nodes.ts | 17 +++ 8 files changed, 240 insertions(+), 7 deletions(-) diff --git a/packages/core/src/__tests__/pr-entity.test.ts b/packages/core/src/__tests__/pr-entity.test.ts index 11d2c4b51..76fc53d45 100644 --- a/packages/core/src/__tests__/pr-entity.test.ts +++ b/packages/core/src/__tests__/pr-entity.test.ts @@ -5,8 +5,19 @@ import { isPrEntityActionable, isPrEntityActive, isPrEntityAutoMergeReady, + summarizePrThreadActivity, } from "../pr-entity.js"; -import type { PrEntity } from "../types.js"; +import type { PrEntity, PrThreadState } from "../types.js"; + +function thread(outcome: PrThreadState["outcome"], threadId = "th"): PrThreadState { + return { + prEntityId: "PR-1", + threadId, + headOid: "deadbeef", + outcome, + updatedAt: 1, + }; +} function entity(overrides: Partial = {}): PrEntity { return { @@ -88,3 +99,32 @@ describe("PR entity predicates", () => { expect(autoMergeGateReason({ ...ready, mergeable: "unknown" })).toBe("Waiting for checks"); }); }); + +describe("summarizePrThreadActivity (U18, R15)", () => { + it("counts fixed vs disagreed vs pending and derives acted/total", () => { + const activity = summarizePrThreadActivity([ + thread("fixed", "a"), + thread("fixed", "b"), + thread("disagreed", "c"), + thread("pending", "d"), + ]); + expect(activity).toEqual({ total: 4, acted: 3, fixed: 2, disagreed: 1, pending: 1 }); + }); + + it("empty input returns zeroed counts, not nulls", () => { + expect(summarizePrThreadActivity([])).toEqual({ + total: 0, + acted: 0, + fixed: 0, + disagreed: 0, + pending: 0, + }); + }); + + it("acted excludes pending (in-flight, not yet GitHub-confirmed)", () => { + const activity = summarizePrThreadActivity([thread("pending"), thread("pending", "x")]); + expect(activity.acted).toBe(0); + expect(activity.total).toBe(2); + expect(activity.pending).toBe(2); + }); +}); diff --git a/packages/core/src/pr-entity.ts b/packages/core/src/pr-entity.ts index 39f071a1d..09ac6b716 100644 --- a/packages/core/src/pr-entity.ts +++ b/packages/core/src/pr-entity.ts @@ -4,7 +4,7 @@ // and the reconcile all consult one definition and cannot drift — the same // discipline that put isBranchGroupMemberLanded in branch-group-completion.ts. -import type { PrEntity } from "./types.js"; +import type { PrEntity, PrThreadState } from "./types.js"; /** Non-terminal lifecycle states — the entity is "live". */ export function isPrEntityActive(entity: Pick): boolean { @@ -64,6 +64,50 @@ export function isPrEntityAutoMergeReady( return true; } +/** + * Aggregate Review-response-loop activity for a single PR entity (U18, R15). + * + * A lightweight, dependency-free read seam so the Command Center / Mission + * Control can surface what the Review-response loop actually did — threads acted + * on, and the fixed-vs-disagreed split — without each surface re-deriving the + * counts from raw `PrThreadState[]` (and silently disagreeing with one another). + * + * `acted` = fixed + disagreed (threads the loop reached a terminal verdict on). + * `pending` rows are in-flight (recorded before GitHub confirmed) and are NOT + * counted as acted-on. The same discipline that put `isPrEntityAutoMergeReady` + * in @fusion/core keeps this single-sourced. + */ +export interface PrThreadActivity { + /** Total threads with a recorded outcome (fixed + disagreed + pending). */ + total: number; + /** Threads the loop reached a terminal verdict on (fixed + disagreed). */ + acted: number; + /** Threads fixed (a change was pushed and the thread replied/resolved). */ + fixed: number; + /** Threads the loop disagreed on (reasoning posted, thread left open). */ + disagreed: number; + /** Threads recorded but not yet GitHub-confirmed (in-flight). */ + pending: number; +} + +export function summarizePrThreadActivity(threads: PrThreadState[]): PrThreadActivity { + let fixed = 0; + let disagreed = 0; + let pending = 0; + for (const t of threads) { + if (t.outcome === "fixed") fixed += 1; + else if (t.outcome === "disagreed") disagreed += 1; + else if (t.outcome === "pending") pending += 1; + } + return { + total: threads.length, + acted: fixed + disagreed, + fixed, + disagreed, + pending, + }; +} + /** * The live auto-merge gate reason shown next to the toggle (R11). Mirrors the * auto-merge-ready predicate ordering so every surface (the dashboard route and diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 3aefafdfd..5ba21535e 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -252,6 +252,9 @@ export const DEFAULT_PROJECT_SETTINGS = { groupOverlappingFiles: true, overlapIgnorePaths: [], autoMerge: true, + // U18 (R15): the Review-response loop is default-on. Independent of `autoMerge` — + // with this on but auto-merge off, review threads are resolved but the PR is not merged. + autoResolveReviewComments: true, testMode: undefined, mergeRequestContractShadowEnabled: false, mergeStrategy: "direct", diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 517e96c4e..f6564b334 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -3382,6 +3382,13 @@ export interface ProjectSettings { * be enforced server-side. Only applies when `mergeStrategy === "pull-request"`. * Default: false. */ requirePrApproval?: boolean; + /** When true (default), the Review-response loop automatically acts on PR review + * threads (human + bot): it dispatches an agent that fixes + pushes + replies, or + * disagrees with reasoning. When false, the loop is inert — review threads are left + * untouched for a human to handle. Independent of `autoMerge`: with auto-resolution + * on but auto-merge off, threads are still resolved but the PR is NOT merged (the + * human checkpoint remains merge). U18, R15. Default: true. */ + autoResolveReviewComments?: boolean; /** Direct-merge commit routing mode. * - "auto": squash single-substantive branches, preserve history for multi-substantive branches * - "always-squash": always use the legacy squash path for direct merges diff --git a/packages/dashboard/src/__tests__/routes-pull-requests.test.ts b/packages/dashboard/src/__tests__/routes-pull-requests.test.ts index 12d4b8e94..77d9f8bd6 100644 --- a/packages/dashboard/src/__tests__/routes-pull-requests.test.ts +++ b/packages/dashboard/src/__tests__/routes-pull-requests.test.ts @@ -75,6 +75,7 @@ describe("pull request routes", () => { threads = [ { prEntityId: "PR-1", threadId: "T1", headOid: "abc", outcome: "pending", updatedAt: Date.now() }, { prEntityId: "PR-1", threadId: "T2", headOid: "abc", outcome: "disagreed", updatedAt: Date.now() }, + { prEntityId: "PR-1", threadId: "T3", headOid: "abc", outcome: "fixed", updatedAt: Date.now() }, ]; }); @@ -85,12 +86,15 @@ describe("pull request routes", () => { expect(res.status).toBe(200); expect(res.body.pullRequests).toHaveLength(1); const pr = res.body.pullRequests[0]; - expect(pr.threads).toHaveLength(2); + expect(pr.threads).toHaveLength(3); expect(pr.summary.checksRollup).toBe("success"); expect(pr.summary.mergeable).toBe("clean"); expect(pr.summary.conflicting).toBe(false); expect(pr.summary.pendingThreads).toBe(1); expect(pr.summary.disagreedThreads).toBe(1); + // U18 (R15): Review-response activity exposed for the Command Center. + expect(pr.summary.fixedThreads).toBe(1); + expect(pr.summary.actedThreads).toBe(2); // fixed + disagreed, excludes pending }); it("GET list filters by repo and status", async () => { diff --git a/packages/dashboard/src/routes/register-pull-requests-routes.ts b/packages/dashboard/src/routes/register-pull-requests-routes.ts index 294168c3a..c94f3fecb 100644 --- a/packages/dashboard/src/routes/register-pull-requests-routes.ts +++ b/packages/dashboard/src/routes/register-pull-requests-routes.ts @@ -5,6 +5,7 @@ import { isPrEntityActionable, isPrEntityAutoMergeReady, autoMergeGateReason, + summarizePrThreadActivity, } from "@fusion/core"; import { badRequest, notFound, ApiError } from "../api-error.js"; @@ -82,8 +83,9 @@ export function isBackwardMoveBlockedByOpenPr(input: { * Pure derivation from authoritative entity state. */ export function buildPrSummary(entity: PrEntity, threads: PrThreadState[]) { - const pendingThreads = threads.filter((t) => t.outcome === "pending").length; - const disagreedThreads = threads.filter((t) => t.outcome === "disagreed").length; + // U18 (R15): single-source the Review-response activity counts from @fusion/core + // so the dashboard, the CLI, and the Command Center never derive divergent numbers. + const activity = summarizePrThreadActivity(threads); return { mergeable: entity.mergeable ?? "unknown", reviewDecision: entity.reviewDecision ?? null, @@ -94,8 +96,12 @@ export function buildPrSummary(entity: PrEntity, threads: PrThreadState[]) { autoMergeReady: isPrEntityAutoMergeReady(entity), actionable: isPrEntityActionable(entity), active: isPrEntityActive(entity), - pendingThreads, - disagreedThreads, + pendingThreads: activity.pending, + disagreedThreads: activity.disagreed, + // U18: threads the loop fixed, and the total it acted on (fixed + disagreed), + // exposed so the Command Center / Mission Control can read resolution activity. + fixedThreads: activity.fixed, + actedThreads: activity.acted, }; } diff --git a/packages/engine/src/__tests__/pr-nodes.test.ts b/packages/engine/src/__tests__/pr-nodes.test.ts index d5b6d1cc5..cb765474c 100644 --- a/packages/engine/src/__tests__/pr-nodes.test.ts +++ b/packages/engine/src/__tests__/pr-nodes.test.ts @@ -18,11 +18,14 @@ import { TaskStore } from "@fusion/core"; import type { TaskDetail, WorkflowIrNode } from "@fusion/core"; import { + buildRespondCallback, createPrNodeHandlers, type PrMergeCallResult, type PrNodeDeps, + type PrRespondGithubOps, type PrSourceDescriptor, } from "../pr-nodes.js"; +import type { PrEntity } from "@fusion/core"; import { createDefaultNodeHandlers, createNoopLegacySeams } from "../workflow-node-handlers.js"; import type { WorkflowNodeExecutionContext } from "../workflow-graph-executor.js"; @@ -242,3 +245,112 @@ describe("PR node handlers (U3)", () => { expect(result).toEqual({ outcome: "success", value: "open" }); }); }); + +// ── U18 (R15): the autoResolveReviewComments setting gates the loop ──────────── +// buildRespondCallback reads settings.autoResolveReviewComments. When false the +// loop is inert: it dispatches no agent, fetches no threads, pushes nothing, and +// replies to no thread — review threads are left for a human. Default (true / +// undefined) preserves today's always-on behavior. This is INDEPENDENT of the +// auto-merge gate (a separate graph node), so disabling auto-merge does not turn +// off resolution and enabling resolution does not force a merge. +describe("Review-response auto-resolution setting gate (U18)", () => { + let rootDir: string; + let store: TaskStore; + let entity: PrEntity; + + beforeEach(async () => { + rootDir = makeTmpDir(); + store = new TaskStore(rootDir, join(rootDir, ".fusion-global")); + await store.init(); + entity = store.ensurePrEntityForSource({ ...SOURCE, state: "open", prNumber: 9 }); + entity = store.updatePrEntity(entity.id, { headOid: "head-1", unverified: false }); + }); + + afterEach(async () => { + store.close(); + await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + }); + + function respondOps(over: Partial = {}): { + ops: PrRespondGithubOps; + calls: { getReviewThreads: number; replies: number; resolves: number }; + } { + const calls = { getReviewThreads: 0, replies: 0, resolves: 0 }; + const ops: PrRespondGithubOps = { + // Return NO actionable threads. The enabled-path tests only need to prove the + // gate let the loop through (getReviewThreads ran); with no actionable thread + // the run returns early WITHOUT dispatching the mutating agent — which keeps + // these unit tests off the real-AI-CLI path. A disabled loop never even gets + // here (it short-circuits before fetching threads). + getReviewThreads: async () => { + calls.getReviewThreads += 1; + return []; + }, + getViewerLogin: async () => "fusion-bot", + checkPrStillOpen: async () => ({ open: true, headOid: "head-1" }), + replyToThread: async () => { + calls.replies += 1; + }, + resolveThread: async () => { + calls.resolves += 1; + }, + getCwd: () => rootDir, + getTaskId: () => "T-1", + ...over, + }; + return { ops, calls }; + } + + it("disabled → loop is inert: no thread fetch, no reply, returns disagreed-only", async () => { + await store.updateSettings({ autoResolveReviewComments: false }); + const { ops, calls } = respondOps(); + const audited: string[] = []; + const respond = buildRespondCallback(() => store, ops, (reason) => audited.push(reason)); + + const result = await respond({ + task: { id: "T-1" } as unknown as TaskDetail, + node: { id: "r", kind: "pr-respond" } as WorkflowIrNode, + entity, + context: {}, + }); + + expect(result).toEqual({ value: "disagreed-only" }); + // Inert: the loop never even fetched threads, never replied, never resolved. + expect(calls.getReviewThreads).toBe(0); + expect(calls.replies).toBe(0); + expect(calls.resolves).toBe(0); + expect(audited).toContain("pr-respond-auto-resolve-disabled"); + }); + + it("default (setting unset) → loop runs: fetches threads (always-on preserved)", async () => { + // Do NOT touch the setting; the default is true. + const { ops, calls } = respondOps(); + const respond = buildRespondCallback(() => store, ops); + + await respond({ + task: { id: "T-1" } as unknown as TaskDetail, + node: { id: "r", kind: "pr-respond" } as WorkflowIrNode, + entity, + context: {}, + }); + + // The loop proceeded far enough to fetch review threads — it is NOT inert. + expect(calls.getReviewThreads).toBe(1); + }); + + it("explicitly enabled → loop runs (independent of auto-merge being off)", async () => { + await store.updateSettings({ autoResolveReviewComments: true, autoMerge: false }); + const { ops, calls } = respondOps(); + const respond = buildRespondCallback(() => store, ops); + + await respond({ + task: { id: "T-1" } as unknown as TaskDetail, + node: { id: "r", kind: "pr-respond" } as WorkflowIrNode, + entity, + context: {}, + }); + + // Resolution ran even though auto-merge is off — the two gates are independent. + expect(calls.getReviewThreads).toBe(1); + }); +}); diff --git a/packages/engine/src/pr-nodes.ts b/packages/engine/src/pr-nodes.ts index 40cbedb21..ad54d8e59 100644 --- a/packages/engine/src/pr-nodes.ts +++ b/packages/engine/src/pr-nodes.ts @@ -222,6 +222,23 @@ export function buildRespondCallback( // agent runner + git ops need its settings + worktree. Resolve at run time. const fullStore = store as unknown as import("@fusion/core").TaskStore; const settings = await fullStore.getSettings(); + + // U18 (R15): auto-resolution of review comments is a first-class, configurable, + // default-ON capability. When disabled, the loop is inert — it dispatches no + // agent, pushes nothing, and replies to no thread; review threads are left for a + // human. This is INDEPENDENT of the auto-merge gate (a separate graph node): with + // resolution on but auto-merge off, threads are still resolved but the PR is not + // merged. Default true preserves today's always-on behavior. `disagreed-only` is + // the benign routing value (loops back to await-review like the U3 inert default), + // so a disabled loop never advances the PR on its own. + if (settings.autoResolveReviewComments === false) { + audit?.( + "pr-respond-auto-resolve-disabled", + `entity ${entity.id}: autoResolveReviewComments off; leaving review threads for a human`, + ); + return { value: "disagreed-only" }; + } + const taskId = ops.getTaskId(entity); const cwd = ops.getCwd(entity); const runAgent = makePrResponseAgentRunner(settings, taskId, cwd); From cf468593358110849b4d7192e31c5041cccfbdbe Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 15 Jun 2026 19:57:20 -0700 Subject: [PATCH 09/21] =?UTF-8?q?feat(router):=20U17=20=E2=80=94=20Fusion?= =?UTF-8?q?=20Model=20Router=20(session-level=20selection=20layer)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit routeModel + conservative v0 allowlist (dependabot/lint → cheap tier) wired into execution/planning/validation lanes in model-resolution.ts; governance (isPermitted) and column-agent override are absolute, OFF by default. Routing decisions (with counterfactual) emit via the U1 usage_events seam. --- .../core/src/__tests__/model-router.test.ts | 246 +++++++++++++ packages/core/src/index.ts | 20 +- packages/core/src/model-resolution.ts | 77 ++++ packages/core/src/model-router.ts | 332 ++++++++++++++++++ packages/core/src/settings-schema.ts | 3 + packages/core/src/types.ts | 16 + 6 files changed, 693 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/__tests__/model-router.test.ts create mode 100644 packages/core/src/model-router.ts diff --git a/packages/core/src/__tests__/model-router.test.ts b/packages/core/src/__tests__/model-router.test.ts new file mode 100644 index 000000000..fbb1dc19f --- /dev/null +++ b/packages/core/src/__tests__/model-router.test.ts @@ -0,0 +1,246 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { Database } from "../db.js"; +import { queryUsageEvents } from "../usage-events.js"; +import { + routeModel, + routeModelAndEmit, + isMechanicalRoutableContext, + type RouteModelInput, +} from "../model-router.js"; +import { + resolveTaskExecutionModel, + resolveTaskPlanningModel, + resolveTaskValidatorModel, + routeTaskExecutionModel, + routeTaskPlanningModel, + routeTaskValidatorModel, +} from "../model-resolution.js"; +import type { Settings } from "../types.js"; + +const DEFAULT = { provider: "anthropic", modelId: "claude-opus-4-8" } as const; +const CHEAP = { provider: "anthropic", modelId: "claude-haiku-4-5" } as const; + +const routerSettings: Partial = { + modelRouterEnabled: true, + modelRouterCheapProvider: CHEAP.provider, + modelRouterCheapModelId: CHEAP.modelId, + // give the default-pair lanes a concrete value + defaultProvider: DEFAULT.provider, + defaultModelId: DEFAULT.modelId, +}; + +function baseInput(overrides: Partial = {}): RouteModelInput { + return { + lane: "execution", + defaultPair: { ...DEFAULT }, + settings: routerSettings, + context: { traits: ["dependabot"] }, + ...overrides, + }; +} + +describe("isMechanicalRoutableContext", () => { + it("matches dependabot/renovate sources", () => { + expect(isMechanicalRoutableContext({ source: "dependabot" })).toBe(true); + expect(isMechanicalRoutableContext({ source: "renovate" })).toBe(true); + }); + it("matches mechanical traits and labels", () => { + expect(isMechanicalRoutableContext({ traits: ["lint-only"] })).toBe(true); + expect(isMechanicalRoutableContext({ labels: ["dependencies"] })).toBe(true); + }); + it("matches conservative title keywords", () => { + expect(isMechanicalRoutableContext({ title: "Bump lodash from 4.17.20 to 4.17.21" })).toBe(true); + expect(isMechanicalRoutableContext({ title: "chore(deps): update eslint" })).toBe(true); + expect(isMechanicalRoutableContext({ title: "Lint-only fix for unused imports" })).toBe(true); + }); + it("does NOT match normal work (conservative default)", () => { + expect(isMechanicalRoutableContext({ title: "Implement OAuth login flow" })).toBe(false); + expect(isMechanicalRoutableContext({ traits: ["needs-review"] })).toBe(false); + expect(isMechanicalRoutableContext(undefined)).toBe(false); + expect(isMechanicalRoutableContext({})).toBe(false); + }); +}); + +describe("routeModel — core selection layer", () => { + it("allowlisted step → cheap tier with escalation seam to the default pair", () => { + const d = routeModel(baseInput()); + expect(d.routed).toBe(true); + expect(d.reason).toBe("cheap-tier"); + expect(d.selection).toEqual(CHEAP); + expect(d.counterfactual).toEqual(DEFAULT); + expect(d.escalation).toEqual(DEFAULT); + }); + + it("normal task → default pair (not routable)", () => { + const d = routeModel(baseInput({ context: { title: "Build a feature" } })); + expect(d.routed).toBe(false); + expect(d.reason).toBe("not-routable"); + expect(d.selection).toEqual(DEFAULT); + expect(d.counterfactual).toEqual(DEFAULT); + }); + + it("column-agent override wins — router defers even for an allowlisted step", () => { + const override = { provider: "openai", modelId: "gpt-5" }; + const d = routeModel(baseInput({ overridePair: override })); + expect(d.routed).toBe(false); + expect(d.reason).toBe("override"); + expect(d.selection).toEqual(override); + // counterfactual is still the default-pair, not the override + expect(d.counterfactual).toEqual(DEFAULT); + }); + + it("a project-policy-restricted model is NEVER selected even if it is the best pick", () => { + const isPermitted = (p: { provider?: string; modelId?: string }) => + !(p.provider === CHEAP.provider && p.modelId === CHEAP.modelId); + const d = routeModel(baseInput({ isPermitted })); + expect(d.routed).toBe(false); + expect(d.reason).toBe("cheap-forbidden"); + expect(d.selection).toEqual(DEFAULT); // fallback path also respects governance + }); + + it("governance is absolute — a forbidden override is NOT honored, falls through", () => { + const override = { provider: "openai", modelId: "gpt-5" }; + const isPermitted = (p: { provider?: string }) => p.provider !== "openai"; + // override forbidden + not routable → default + const d = routeModel(baseInput({ overridePair: override, isPermitted, context: { title: "x" } })); + expect(d.reason).toBe("not-routable"); + expect(d.selection).toEqual(DEFAULT); + }); + + it("router disabled → byte-identical to the default pair", () => { + const d = routeModel(baseInput({ settings: { ...routerSettings, modelRouterEnabled: false } })); + expect(d.routed).toBe(false); + expect(d.reason).toBe("disabled"); + expect(d.selection).toEqual(DEFAULT); + expect(d.escalation).toBeUndefined(); + }); + + it("cheap tier unconfigured → default pair", () => { + const d = routeModel( + baseInput({ settings: { modelRouterEnabled: true } }), + ); + expect(d.reason).toBe("cheap-unconfigured"); + expect(d.selection).toEqual(DEFAULT); + }); + + it("no usable default pair → reason no-default", () => { + const d = routeModel(baseInput({ defaultPair: {}, context: { title: "x" } })); + expect(d.reason).toBe("no-default"); + expect(d.selection).toEqual({}); + }); +}); + +describe("governed lanes vs ungoverned lanes (model-resolution wrappers)", () => { + const task = {}; + + it("execution lane: disabled router === resolveTaskExecutionModel (no regression)", () => { + const settings = { ...routerSettings, modelRouterEnabled: false }; + const direct = resolveTaskExecutionModel(task, settings); + const routed = routeTaskExecutionModel(task, settings).selection; + expect(routed).toEqual(direct); + }); + + it("planning lane: disabled router === resolveTaskPlanningModel", () => { + const settings = { ...routerSettings, modelRouterEnabled: false }; + expect(routeTaskPlanningModel(task, settings).selection).toEqual( + resolveTaskPlanningModel(task, settings), + ); + }); + + it("validation lane: disabled router === resolveTaskValidatorModel", () => { + const settings = { ...routerSettings, modelRouterEnabled: false }; + expect(routeTaskValidatorModel(task, settings).selection).toEqual( + resolveTaskValidatorModel(task, settings), + ); + }); + + it("each governed lane down-routes an allowlisted step and reports its lane", () => { + const opts = { context: { traits: ["dependabot"] } }; + const exec = routeTaskExecutionModel(task, routerSettings, opts); + const plan = routeTaskPlanningModel(task, routerSettings, opts); + const val = routeTaskValidatorModel(task, routerSettings, opts); + expect(exec.lane).toBe("execution"); + expect(plan.lane).toBe("planning"); + expect(val.lane).toBe("validation"); + for (const d of [exec, plan, val]) { + expect(d.routed).toBe(true); + expect(d.selection).toEqual(CHEAP); + } + }); + + it("each governed lane never returns a forbidden pair", () => { + const opts = { + context: { traits: ["dependabot"] }, + isPermitted: (p: { modelId?: string }) => p.modelId !== CHEAP.modelId, + }; + for (const fn of [routeTaskExecutionModel, routeTaskPlanningModel, routeTaskValidatorModel]) { + const d = fn(task, routerSettings, opts); + expect(d.selection.modelId).not.toBe(CHEAP.modelId); + } + }); + + it("ungoverned lanes (settings-only / title summarizer / project default) are untouched — no router wrappers exist for them", async () => { + const mod = await import("../model-resolution.js"); + // Only the three task lanes get router wrappers; ensure no extra ones leaked in. + expect(typeof mod.routeTaskExecutionModel).toBe("function"); + expect(typeof mod.routeTaskPlanningModel).toBe("function"); + expect(typeof mod.routeTaskValidatorModel).toBe("function"); + expect((mod as Record).routeProjectDefaultModel).toBeUndefined(); + expect((mod as Record).routeExecutionSettingsModel).toBeUndefined(); + expect((mod as Record).routeTitleSummarizerSettingsModel).toBeUndefined(); + }); +}); + +describe("routeModelAndEmit — telemetry with counterfactual", () => { + let tmpDir: string; + let db: Database; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "kb-model-router-test-")); + db = new Database(join(tmpDir, ".fusion")); + db.init(); + }); + + afterEach(async () => { + db.close(); + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("emits a routing decision with the counterfactual model into usage_events", () => { + const d = routeModelAndEmit(db, { ...baseInput(), taskId: "t1", nodeId: "n1" }); + expect(d.routed).toBe(true); + + const rows = queryUsageEvents(db, { kind: "session_start" }); + expect(rows).toHaveLength(1); + const row = rows[0]; + expect(row.category).toBe("model-router"); + expect(row.provider).toBe(CHEAP.provider); + expect(row.model).toBe(CHEAP.modelId); + expect(row.taskId).toBe("t1"); + expect(row.nodeId).toBe("n1"); + // The counterfactual model that WOULD have run absent the router: + expect(row.meta?.routed).toBe(true); + expect(row.meta?.reason).toBe("cheap-tier"); + expect(row.meta?.counterfactualProvider).toBe(DEFAULT.provider); + expect(row.meta?.counterfactualModelId).toBe(DEFAULT.modelId); + }); + + it("emits the counterfactual even when not routed (default pair selected)", () => { + routeModelAndEmit(db, { ...baseInput({ context: { title: "real work" } }), taskId: "t2" }); + const rows = queryUsageEvents(db, { kind: "session_start" }); + expect(rows).toHaveLength(1); + expect(rows[0].provider).toBe(DEFAULT.provider); + expect(rows[0].meta?.routed).toBe(false); + expect(rows[0].meta?.counterfactualModelId).toBe(DEFAULT.modelId); + }); + + it("emission is fail-soft and does not alter the decision when db is undefined", () => { + const d = routeModelAndEmit(undefined, baseInput()); + expect(d.selection).toEqual(CHEAP); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 329086268..d336658c3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1120,8 +1120,26 @@ export { resolveTitleSummarizerSettingsModel, resolveValidatorSettingsModel, TEST_MODE_RESOLVED, + routeTaskExecutionModel, + routeTaskPlanningModel, + routeTaskValidatorModel, } from "./model-resolution.js"; -export type { ResolvedModelSelection } from "./model-resolution.js"; +export type { ResolvedModelSelection, RouterLaneOptions } from "./model-resolution.js"; +export { + routeModel, + routeModelAndEmit, + isMechanicalRoutableContext, +} from "./model-router.js"; +export type { + RouterLane, + RouterReason, + RouterPair, + RouterTaskContext, + RouteModelInput, + RouterDecision, + RouterEscalation, + ModelGovernancePredicate, +} from "./model-router.js"; // ── Memory Compaction ───────────────────────────────────────────────── diff --git a/packages/core/src/model-resolution.ts b/packages/core/src/model-resolution.ts index 85b527c56..6159ea175 100644 --- a/packages/core/src/model-resolution.ts +++ b/packages/core/src/model-resolution.ts @@ -1,4 +1,11 @@ import type { Settings } from "./types.js"; +import type { + ModelGovernancePredicate, + RouterDecision, + RouterLane, + RouterTaskContext, +} from "./model-router.js"; +import { routeModel } from "./model-router.js"; export interface ResolvedModelSelection { provider?: string; @@ -183,3 +190,73 @@ export function resolveTaskPlanningModel( settings, ); } + +// ── Fusion Model Router lane wrappers (U17 / KTD9) ───────────────────────── +// +// These are the **governed** session-start lanes: execution, planning, and +// validation. Each first resolves the lane's default pair exactly as today (the +// router's counterfactual), then hands it to the selection layer. The router is +// OFF by default — when disabled it returns the default pair byte-identically, +// so these wrappers are safe drop-ins. The non-routed resolvers above remain +// untouched; the settings-only resolvers, `resolveProjectDefaultModel`, and +// `resolveTitleSummarizerSettingsModel` are **ungoverned** (no task signal / +// non-session purpose) and the router never touches them. + +/** Options shared by the router-aware lane resolvers. */ +export interface RouterLaneOptions { + /** Per-task per-lane override pair (e.g. a column-agent binding). When complete, + * the router defers to it. */ + overridePair?: ResolvedModelSelection | null; + /** Classification signal for the conservative v0 allowlist. */ + context?: RouterTaskContext; + /** Governance gate — the router never returns a pair this rejects. */ + isPermitted?: ModelGovernancePredicate; +} + +function routeLane( + lane: RouterLane, + defaultPair: ResolvedModelSelection, + settings: Partial | undefined, + options: RouterLaneOptions | undefined, +): RouterDecision { + return routeModel({ + lane, + defaultPair, + overridePair: options?.overridePair ?? null, + context: options?.context, + settings, + isPermitted: options?.isPermitted, + }); +} + +/** + * Router-aware execution-lane resolution. Returns the full {@link RouterDecision} + * (selection + counterfactual + reason) so the caller can emit telemetry and wire + * the escalation seam. With the router disabled, `decision.selection` equals + * {@link resolveTaskExecutionModel}. + */ +export function routeTaskExecutionModel( + task: TaskModelLike, + settings?: Partial, + options?: RouterLaneOptions, +): RouterDecision { + return routeLane("execution", resolveTaskExecutionModel(task, settings), settings, options); +} + +/** Router-aware planning-lane resolution. See {@link routeTaskExecutionModel}. */ +export function routeTaskPlanningModel( + task: TaskModelLike, + settings?: Partial, + options?: RouterLaneOptions, +): RouterDecision { + return routeLane("planning", resolveTaskPlanningModel(task, settings), settings, options); +} + +/** Router-aware validation-lane resolution. See {@link routeTaskExecutionModel}. */ +export function routeTaskValidatorModel( + task: TaskModelLike, + settings?: Partial, + options?: RouterLaneOptions, +): RouterDecision { + return routeLane("validation", resolveTaskValidatorModel(task, settings), settings, options); +} diff --git a/packages/core/src/model-router.ts b/packages/core/src/model-router.ts new file mode 100644 index 000000000..c61ec1ada --- /dev/null +++ b/packages/core/src/model-router.ts @@ -0,0 +1,332 @@ +/** + * Fusion Model Router (U17 / KTD9). + * + * A **selection layer** that picks a `(provider, model)` pair *before* a session + * starts. It is NOT a new executor: it never adds an executor kind, it only + * chooses which already-configured CLI/provider runs. Routing is **session-level + * only** for this unit — per-request mid-session re-routing is deferred (it needs + * its own design pass on streaming continuity / context-window compatibility / + * prompt-cache invalidation). + * + * ## Conservative v0 signal + * + * There is no validated `complexity`/`difficulty` field on tasks or steps today, + * and prompt size is a weak proxy. So v0 does NOT invent a classifier. It routes + * only an **allowlist of mechanical traits** (dependabot bumps, lint-only fixes) + * to a cheap tier; **everything else resolves to the configured default pair**. + * The signal is isolated behind {@link isMechanicalRoutableContext} so a + * validated classifier can replace it later without touching the governance, + * override, or fallback machinery. + * + * ## Governance, override, fallback (load-bearing — tested per lane) + * + * 1. **Override wins.** If a column-agent (or any caller-supplied) override pins a + * pair, the router defers and returns that pair unchanged. + * 2. **Governance is absolute.** The router NEVER returns a pair an org/project/ + * user model control forbids — including on the fallback path. A forbidden + * cheap pick is dropped and the router falls back; if the default pair is + * itself forbidden the router returns it untouched (governance of the default + * pair is the resolver/caller's job, not the router's to silently rewrite). + * 3. **Disabled / unavailable → default pair.** When the router is off, the cheap + * tier is unconfigured, or no pick is available, the result is byte-identical + * to the supplied default pair. + * + * ## Quality guardrail seam + * + * A cheap-tier pick carries an `escalation` describing the strong tier to retry + * with on cheap-tier failure (see {@link RouterDecision.escalation}). v0 wires + * the seam (the default pair is the escalation target) but does not itself run + * the retry loop — that lives in the executor/session layer that owns failure + * detection. + * + * ## Telemetry + * + * Every decision (including the **counterfactual** model that would have run + * absent the router) is emitted via the U1 {@link emitUsageEvent} seam so the + * Command Center can show adoption and realized cost delta versus always-premium. + * Emission is fail-soft and never alters the returned decision. + */ + +import type { Database } from "./db.js"; +import type { Settings } from "./types.js"; +import { emitUsageEvent } from "./usage-events.js"; +import type { ResolvedModelSelection } from "./model-resolution.js"; + +/** The resolution lanes the router governs. Ungoverned lanes are never touched. */ +export type RouterLane = "execution" | "planning" | "validation"; + +/** + * Why the router produced the pair it did. Surfaced in telemetry `meta` and + * usable by callers for diagnostics. + */ +export type RouterReason = + | "disabled" // router off → default pair + | "override" // a column-agent/caller override pinned the pair → defer + | "cheap-tier" // an allowlisted mechanical step routed to the cheap tier + | "cheap-unconfigured" // router on but no cheap pair configured → default + | "cheap-forbidden" // cheap pick forbidden by governance → default + | "not-routable" // step not on the mechanical allowlist → default + | "no-default"; // no usable default pair to fall back to + +/** + * A `(provider, model)` pair the router can choose. Mirrors + * {@link ResolvedModelSelection} but with both fields concrete when present. + */ +export interface RouterPair { + provider?: string; + modelId?: string; +} + +/** + * Predicate that returns `true` iff a pair is **permitted** by the active model + * controls (org/project/user governance). The router NEVER returns a pair for + * which this returns `false` on a routed pick. Supplied by the caller because + * governance schema lives outside core's resolution layer; when omitted, all + * pairs are permitted (no governance configured). + */ +export type ModelGovernancePredicate = (pair: RouterPair) => boolean; + +/** + * The signal the router classifies. Neutral, schema-light fields so the router + * does not depend on task schema that does not exist yet — callers populate from + * whatever trait/label/source data they have in scope. + */ +export interface RouterTaskContext { + /** Workflow trait flags on the task/column (e.g. `["dependabot", "lint-only"]`). */ + traits?: readonly string[]; + /** Labels on the task / source issue (e.g. `["dependencies", "lint"]`). */ + labels?: readonly string[]; + /** How the task was created (e.g. a `dependabot` / `renovate` source). */ + source?: string | null; + /** Task title — used only for conservative keyword matching on the allowlist. */ + title?: string | null; +} + +export interface RouteModelInput { + lane: RouterLane; + /** + * The pair resolution would return absent the router — the **counterfactual**. + * The router falls back to this and emits it as the counterfactual in telemetry. + */ + defaultPair: ResolvedModelSelection; + /** + * A column-agent (or other) override pair. When it carries both provider and + * model, the router defers to it unconditionally (override wins). + */ + overridePair?: ResolvedModelSelection | null; + /** The classification signal. */ + context?: RouterTaskContext; + settings?: Partial; + /** Governance gate. When omitted, all pairs are permitted. */ + isPermitted?: ModelGovernancePredicate; +} + +/** The strong-tier retry target for the quality guardrail. */ +export interface RouterEscalation { + provider?: string; + modelId?: string; +} + +export interface RouterDecision { + /** The pair to actually use. */ + selection: ResolvedModelSelection; + /** True iff the router down-routed to the cheap tier. */ + routed: boolean; + reason: RouterReason; + lane: RouterLane; + /** What would have run absent the router (always the supplied default pair). */ + counterfactual: ResolvedModelSelection; + /** + * Quality-guardrail seam: the strong tier to retry with if the cheap-tier pick + * fails. Present only when `routed` is true. v0 sets this to the counterfactual. + */ + escalation?: RouterEscalation; +} + +const DEPENDABOT_SOURCES: ReadonlySet = new Set([ + "dependabot", + "renovate", + "renovatebot", +]); + +const MECHANICAL_TRAITS: ReadonlySet = new Set([ + "dependabot", + "dependency-bump", + "deps", + "lint-only", + "lint-fix", + "lint", + "formatting", + "format-only", +]); + +const MECHANICAL_LABELS: ReadonlySet = new Set([ + "dependencies", + "dependabot", + "deps", + "lint", + "lint-only", + "formatting", + "style", +]); + +function normalize(s: string | null | undefined): string { + return (s ?? "").trim().toLowerCase(); +} + +function hasComplete(pair: ResolvedModelSelection | null | undefined): pair is { provider: string; modelId: string } { + return Boolean(pair?.provider && pair?.modelId); +} + +/** + * Conservative v0 classifier: is this step a mechanical, allowlisted candidate + * for the cheap tier? Pure and isolated so a validated classifier can replace it + * later. Returns `true` ONLY for clearly-mechanical signals; the default is + * `false` (→ default pair). + */ +export function isMechanicalRoutableContext(context: RouterTaskContext | undefined): boolean { + if (!context) return false; + + if (DEPENDABOT_SOURCES.has(normalize(context.source))) return true; + + for (const trait of context.traits ?? []) { + if (MECHANICAL_TRAITS.has(normalize(trait))) return true; + } + for (const label of context.labels ?? []) { + if (MECHANICAL_LABELS.has(normalize(label))) return true; + } + + // Conservative title keyword match: a dependabot/bump or lint-only chore. + const title = normalize(context.title); + if (title) { + if (/\bbump\b/.test(title) && /\bfrom\b/.test(title) && /\bto\b/.test(title)) return true; + if (title.startsWith("chore(deps)") || title.startsWith("build(deps)")) return true; + if (/\blint\b/.test(title) && /\b(only|fix|fixes)\b/.test(title)) return true; + } + + return false; +} + +/** Resolve the configured cheap-tier pair, or `undefined` when unconfigured. */ +function resolveCheapPair(settings: Partial | undefined): RouterPair | undefined { + const provider = settings?.modelRouterCheapProvider; + const modelId = settings?.modelRouterCheapModelId; + if (provider && modelId) return { provider, modelId }; + return undefined; +} + +function isRouterEnabled(settings: Partial | undefined): boolean { + return settings?.modelRouterEnabled === true; +} + +/** + * The core selection function. **Pure** (no DB, no telemetry) so it is trivially + * testable; {@link routeModelAndEmit} wraps it to also emit telemetry. + * + * Decision order (each rule is tested): + * 1. override pinned → defer (return override, `routed: false`) + * 2. router disabled → default pair + * 3. not mechanical → default pair + * 4. cheap tier unconfigured→ default pair + * 5. cheap pick forbidden → default pair (governance, incl. fallback path) + * 6. otherwise → cheap pick (with escalation seam) + * + * Governance also guards the override (an override forbidden by policy is NOT + * honored — governance is absolute) and is noted on the default-pair paths via + * `reason`, but the router never rewrites a forbidden default pair: governing the + * default is the resolver/caller's responsibility, the router only guarantees it + * does not *introduce* a forbidden pair. + */ +export function routeModel(input: RouteModelInput): RouterDecision { + const { lane, defaultPair, overridePair, context, settings } = input; + const isPermitted = input.isPermitted ?? (() => true); + const counterfactual: ResolvedModelSelection = { ...defaultPair }; + + const fallback = (reason: RouterReason): RouterDecision => ({ + selection: { ...defaultPair }, + routed: false, + reason: hasComplete(defaultPair) ? reason : "no-default", + lane, + counterfactual, + }); + + // 1. Override wins — but governance is absolute, so a forbidden override is not + // honored; it falls through to default resolution. + if (hasComplete(overridePair) && isPermitted({ provider: overridePair.provider, modelId: overridePair.modelId })) { + return { + selection: { provider: overridePair.provider, modelId: overridePair.modelId }, + routed: false, + reason: "override", + lane, + counterfactual, + }; + } + + // 2. Disabled → byte-identical default-pair behavior. + if (!isRouterEnabled(settings)) { + return fallback("disabled"); + } + + // 3. Conservative allowlist: only mechanical steps are routable. + if (!isMechanicalRoutableContext(context)) { + return fallback("not-routable"); + } + + // 4. Cheap tier must be configured. + const cheap = resolveCheapPair(settings); + if (!cheap || !hasComplete(cheap)) { + return fallback("cheap-unconfigured"); + } + + // 5. Governance is absolute — never return a forbidden cheap pick. + if (!isPermitted({ provider: cheap.provider, modelId: cheap.modelId })) { + return fallback("cheap-forbidden"); + } + + // 6. Route to the cheap tier, wiring the quality-guardrail escalation seam. + return { + selection: { provider: cheap.provider, modelId: cheap.modelId }, + routed: true, + reason: "cheap-tier", + lane, + counterfactual, + escalation: hasComplete(defaultPair) + ? { provider: defaultPair.provider, modelId: defaultPair.modelId } + : undefined, + }; +} + +/** + * {@link routeModel} plus fail-soft telemetry: emits one `session_start` usage + * event carrying the routing decision and the **counterfactual** model. Emission + * never alters or blocks the returned decision (the U1 seam is itself fail-soft). + */ +export function routeModelAndEmit( + db: Database | undefined, + input: RouteModelInput & { taskId?: string | null; agentId?: string | null; nodeId?: string | null }, +): RouterDecision { + const decision = routeModel(input); + if (db) { + emitUsageEvent(db, { + kind: "session_start", + taskId: input.taskId ?? null, + agentId: input.agentId ?? null, + nodeId: input.nodeId ?? null, + model: decision.selection.modelId ?? null, + provider: decision.selection.provider ?? null, + category: "model-router", + meta: { + router: true, + lane: decision.lane, + routed: decision.routed, + reason: decision.reason, + // The counterfactual model that WOULD have run absent the router. + counterfactualProvider: decision.counterfactual.provider ?? null, + counterfactualModelId: decision.counterfactual.modelId ?? null, + escalationProvider: decision.escalation?.provider ?? null, + escalationModelId: decision.escalation?.modelId ?? null, + }, + }); + } + return decision; +} diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 5ba21535e..060dd4cea 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -69,6 +69,9 @@ export const DEFAULT_GLOBAL_SETTINGS = { defaultProvider: undefined, defaultModelId: undefined, testMode: undefined, + modelRouterEnabled: undefined, + modelRouterCheapProvider: undefined, + modelRouterCheapModelId: undefined, mergeRequestContractShadowEnabled: false, fallbackProvider: undefined, fallbackModelId: undefined, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index f6564b334..ed4518b1c 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -2814,6 +2814,22 @@ export interface GlobalSettings { * of per-task or per-lane overrides. No network calls, zero token cost. * Project `testMode` takes precedence over the global value. */ testMode?: boolean; + /** Fusion Model Router opt-in (U17/KTD9). When true, a conservative selection + * layer may down-route an allowlist of mechanical steps (dependabot bumps, + * lint-only fixes) to a cheap model tier before a session starts; everything + * else resolves to the configured default pair. OFF by default — when unset or + * false, model resolution is byte-identical to its non-router behavior. + * Selection is governed: it never returns a pair the model controls forbid and + * always defers to a column-agent override. */ + modelRouterEnabled?: boolean; + /** Provider for the Model Router's cheap tier (U17). Used only when + * `modelRouterEnabled` is true and a step is allowlisted for down-routing. + * Must be set together with `modelRouterCheapModelId`; if either is unset the + * router falls back to the configured default pair. */ + modelRouterCheapProvider?: string; + /** Model ID for the Model Router's cheap tier (U17). See + * `modelRouterCheapProvider`. */ + modelRouterCheapModelId?: string; /** Phase-1 FN-5741 write-only shadow seam toggle. * When true, executor/self-healing/merger persist additive merge-request contract * records and completion-handoff markers without changing merge authority. From ed3c572a81f4961f784e57cecc0a599494caea45 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 15 Jun 2026 19:57:20 -0700 Subject: [PATCH 10/21] =?UTF-8?q?feat(command-center):=20U5=20=E2=80=94=20?= =?UTF-8?q?historical=20analytics=20areas=20+=20date-range=20filtering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tokens/Tools/Activity/Productivity/Ecosystem/Signals area components fetch the U9 endpoints and render via the U4 chart primitives, wired into the shell tabs. SWR reset effects key on a derived signature (not array identity) to survive revalidation. LOC/plugin gaps show unavailable sentinels; Signals degrades to empty until U11/U13 data lands. --- .../command-center/CommandCenter.tsx | 30 ++- .../__tests__/CommandCenter.test.tsx | 3 +- .../command-center/areas/ActivityArea.tsx | 69 ++++++ .../command-center/areas/AreaShell.tsx | 59 +++++ .../command-center/areas/EcosystemArea.tsx | 84 +++++++ .../command-center/areas/ProductivityArea.tsx | 94 +++++++ .../command-center/areas/SignalsArea.tsx | 129 ++++++++++ .../command-center/areas/TokensArea.tsx | 186 ++++++++++++++ .../command-center/areas/ToolsArea.tsx | 84 +++++++ .../areas/__tests__/areas.test.tsx | 232 ++++++++++++++++++ .../command-center/areas/areaShared.ts | 38 +++ .../components/command-center/areas/areas.css | 128 ++++++++++ .../command-center/areas/useAnalyticsArea.ts | 73 ++++++ 13 files changed, 1205 insertions(+), 4 deletions(-) create mode 100644 packages/dashboard/app/components/command-center/areas/ActivityArea.tsx create mode 100644 packages/dashboard/app/components/command-center/areas/AreaShell.tsx create mode 100644 packages/dashboard/app/components/command-center/areas/EcosystemArea.tsx create mode 100644 packages/dashboard/app/components/command-center/areas/ProductivityArea.tsx create mode 100644 packages/dashboard/app/components/command-center/areas/SignalsArea.tsx create mode 100644 packages/dashboard/app/components/command-center/areas/TokensArea.tsx create mode 100644 packages/dashboard/app/components/command-center/areas/ToolsArea.tsx create mode 100644 packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx create mode 100644 packages/dashboard/app/components/command-center/areas/areaShared.ts create mode 100644 packages/dashboard/app/components/command-center/areas/areas.css create mode 100644 packages/dashboard/app/components/command-center/areas/useAnalyticsArea.ts diff --git a/packages/dashboard/app/components/command-center/CommandCenter.tsx b/packages/dashboard/app/components/command-center/CommandCenter.tsx index 6ff3b842a..e5d5003ab 100644 --- a/packages/dashboard/app/components/command-center/CommandCenter.tsx +++ b/packages/dashboard/app/components/command-center/CommandCenter.tsx @@ -2,6 +2,12 @@ import { useCallback, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { AlertCircle, Gauge } from "lucide-react"; import { DateRangePicker, defaultPresets, rangeFromPreset, type DateRange } from "./DateRangePicker"; +import { TokensArea } from "./areas/TokensArea"; +import { ToolsArea } from "./areas/ToolsArea"; +import { ActivityArea } from "./areas/ActivityArea"; +import { ProductivityArea } from "./areas/ProductivityArea"; +import { EcosystemArea } from "./areas/EcosystemArea"; +import { SignalsArea } from "./areas/SignalsArea"; import "./CommandCenter.css"; type SubViewId = @@ -11,6 +17,7 @@ type SubViewId = | "activity" | "productivity" | "ecosystem" + | "signals" | "mission-control"; interface SubView { @@ -27,6 +34,7 @@ function useSubViews(): SubView[] { { id: "activity", label: t("commandCenter.tabs.activity", "Activity") }, { id: "productivity", label: t("commandCenter.tabs.productivity", "Productivity") }, { id: "ecosystem", label: t("commandCenter.tabs.ecosystem", "Ecosystem") }, + { id: "signals", label: t("commandCenter.tabs.signals", "Signals") }, { id: "mission-control", label: t("commandCenter.tabs.missionControl", "Mission Control") }, ]; } @@ -148,10 +156,26 @@ export function CommandCenter() { ); function renderActiveTab() { - if (activeTab === "overview") { - return ; + switch (activeTab) { + case "overview": + return ; + case "tokens": + return ; + case "tools": + return ; + case "activity": + return ; + case "productivity": + return ; + case "ecosystem": + return ; + case "signals": + return ; + // Mission Control (U6b) is wired in its own unit; placeholder until then. + case "mission-control": + default: + return ; } - return ; } if (isLoading) { diff --git a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx index 3dce4fd02..e6fb74176 100644 --- a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx @@ -19,7 +19,8 @@ describe("CommandCenter shell", () => { render(); const tablist = screen.getByRole("tablist"); const tabs = within(tablist).getAllByRole("tab"); - expect(tabs.length).toBe(7); + // Overview, Tokens, Tools, Activity, Productivity, Ecosystem, Signals, Mission Control. + expect(tabs.length).toBe(8); // roving tabindex: exactly one tab is focusable. const focusable = tabs.filter((tab) => tab.getAttribute("tabindex") === "0"); expect(focusable.length).toBe(1); diff --git a/packages/dashboard/app/components/command-center/areas/ActivityArea.tsx b/packages/dashboard/app/components/command-center/areas/ActivityArea.tsx new file mode 100644 index 000000000..e29bc49e3 --- /dev/null +++ b/packages/dashboard/app/components/command-center/areas/ActivityArea.tsx @@ -0,0 +1,69 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import type { ActivityAnalytics } from "@fusion/core"; +import type { DateRange } from "../DateRangePicker"; +import { Sparkline } from "../charts/Sparkline"; +import { AreaShell } from "./AreaShell"; +import { useAnalyticsArea } from "./useAnalyticsArea"; +import { formatCount } from "./areaShared"; + +/** + * Activity area: sessions / messages / active-nodes / stickiness (DAU/MAU) over + * the range, plus per-day sparklines for messages and active nodes. + */ +export function ActivityArea({ range }: { range: DateRange }) { + const { t } = useTranslation("app"); + const { data, isLoading, error } = useAnalyticsArea("/command-center/activity", range); + + const daily = useMemo(() => data?.daily ?? [], [data?.daily]); + const messagesSeries = useMemo(() => daily.map((d) => d.messages), [daily]); + const nodesSeries = useMemo(() => daily.map((d) => d.activeNodes), [daily]); + + const isEmpty = + !data || + (data.sessions === 0 && data.messages === 0 && data.activeNodes === 0 && data.activeAgents === 0); + + return ( + +
+

{t("commandCenter.activity.summaryTitle", "Summary")}

+
+
+
{t("commandCenter.activity.sessions", "Sessions")}
+
{formatCount(data?.sessions ?? 0)}
+
+
+
{t("commandCenter.activity.messages", "Messages")}
+
{formatCount(data?.messages ?? 0)}
+
+
+
{t("commandCenter.activity.activeNodes", "Active nodes")}
+
{formatCount(data?.activeNodes ?? 0)}
+
+
+
{t("commandCenter.activity.activeAgents", "Active agents")}
+
{formatCount(data?.activeAgents ?? 0)}
+
+
+
{t("commandCenter.activity.stickiness", "Stickiness")}
+
{data ? `${Math.round(data.stickiness * 100)}%` : "—"}
+ {t("commandCenter.activity.stickinessHint", "DAU / MAU")} +
+
+
+ +
+

{t("commandCenter.activity.messagesPerDay", "Messages / day")}

+ +
+ +
+

{t("commandCenter.activity.nodesPerDay", "Active nodes / day")}

+ +
+
+ ); +} diff --git a/packages/dashboard/app/components/command-center/areas/AreaShell.tsx b/packages/dashboard/app/components/command-center/areas/AreaShell.tsx new file mode 100644 index 000000000..5471547c9 --- /dev/null +++ b/packages/dashboard/app/components/command-center/areas/AreaShell.tsx @@ -0,0 +1,59 @@ +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { AlertCircle, Gauge, Loader2 } from "lucide-react"; +import "./areas.css"; + +export interface AreaShellProps { + /** Stable test id prefix, e.g. "tokens" → cc-area-tokens. */ + testId: string; + isLoading: boolean; + error: string | null; + /** True when the loaded data has nothing to display. */ + isEmpty: boolean; + /** Custom empty-state message; defaults to the shared "no data" copy. */ + emptyMessage?: string; + children: ReactNode; +} + +/** + * Shared loading / error / empty wrapper for the historical-analytics areas, + * mirroring `ReliabilityView`'s state handling. Renders children only once + * there is data to show; never crashes on an empty area (degrades to the + * empty state instead). + */ +export function AreaShell({ testId, isLoading, error, isEmpty, emptyMessage, children }: AreaShellProps) { + const { t } = useTranslation("app"); + + if (isLoading) { + return ( +
+ + {t("commandCenter.area.loading", "Loading…")} +
+ ); + } + + if (error !== null) { + return ( +
+ +

{error}

+
+ ); + } + + if (isEmpty) { + return ( +
+ +

{emptyMessage ?? t("commandCenter.area.empty", "No data for the selected range.")}

+
+ ); + } + + return ( +
+ {children} +
+ ); +} diff --git a/packages/dashboard/app/components/command-center/areas/EcosystemArea.tsx b/packages/dashboard/app/components/command-center/areas/EcosystemArea.tsx new file mode 100644 index 000000000..562254216 --- /dev/null +++ b/packages/dashboard/app/components/command-center/areas/EcosystemArea.tsx @@ -0,0 +1,84 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import type { TokenAnalytics } from "@fusion/core"; +import type { DateRange } from "../DateRangePicker"; +import { Bar } from "../charts/Bar"; +import { AreaShell } from "./AreaShell"; +import { useAnalyticsArea } from "./useAnalyticsArea"; +import { formatCount } from "./areaShared"; + +/** + * Ecosystem area: ecosystem breadth derived from the tokens endpoint grouped by + * model (per KTD/plan: "reuses the tokens endpoint grouped by model where + * possible"). Shows the unique-active-model count and a per-model activity bar + * (tasks per model as the activity proxy — token rows carry `nTasks`, not a + * session count). Plugin activation count and a distinct-models/day sparkline + * have no current endpoint, so they render their unavailable sentinel rather + * than a misleading 0. Empty state when no models have been used. + */ +export function EcosystemArea({ range }: { range: DateRange }) { + const { t } = useTranslation("app"); + const { data, isLoading, error } = useAnalyticsArea( + "/command-center/tokens?groupBy=model", + range, + ); + + const models = useMemo( + () => (data?.groups ?? []).filter((g) => (g.key ?? "").trim().length > 0), + [data?.groups], + ); + + const uniqueModels = models.length; + + const perModelBars = useMemo( + () => + [...models] + .sort((a, b) => b.nTasks - a.nTasks || (a.key ?? "").localeCompare(b.key ?? "")) + .slice(0, 12) + .map((g) => ({ + label: g.key ?? t("commandCenter.tokens.unknownModel", "(unknown)"), + value: g.nTasks, + valueLabel: formatCount(g.nTasks), + })), + [models, t], + ); + + const isEmpty = !data || uniqueModels === 0; + + return ( + +
+

{t("commandCenter.ecosystem.breadthTitle", "Ecosystem breadth")}

+
+
+
{t("commandCenter.ecosystem.uniqueModels", "Active models")}
+
{formatCount(uniqueModels)}
+
+
+
{t("commandCenter.ecosystem.plugins", "Plugin activations")}
+
+ + — + +
+
+
+
+ +
+

{t("commandCenter.ecosystem.perModelTitle", "Tasks per model")}

+ +
+
+ ); +} diff --git a/packages/dashboard/app/components/command-center/areas/ProductivityArea.tsx b/packages/dashboard/app/components/command-center/areas/ProductivityArea.tsx new file mode 100644 index 000000000..5751e5ec4 --- /dev/null +++ b/packages/dashboard/app/components/command-center/areas/ProductivityArea.tsx @@ -0,0 +1,94 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import type { ProductivityAnalytics } from "@fusion/core"; +import type { DateRange } from "../DateRangePicker"; +import { Bar } from "../charts/Bar"; +import { AreaShell } from "./AreaShell"; +import { useAnalyticsArea } from "./useAnalyticsArea"; +import { formatCount } from "./areaShared"; + +/** + * Productivity area. Per the plan's A5 framing, LOC and tool/file counts are + * presented as *volume* proxies, kept visually distinct from outcome counters + * (PRs, commits). Unavailable LOC renders the "—" sentinel with a tooltip, + * NEVER 0. + */ +export function ProductivityArea({ range }: { range: DateRange }) { + const { t } = useTranslation("app"); + const { data, isLoading, error } = useAnalyticsArea( + "/command-center/productivity", + range, + ); + + const languageBars = useMemo( + () => + (data?.byLanguage ?? []).slice(0, 12).map((l) => ({ + label: l.language, + value: l.count, + valueLabel: formatCount(l.count), + })), + [data?.byLanguage], + ); + + const isEmpty = + !data || + (data.modifiedFiles === 0 && data.commits === 0 && data.pullRequests === 0); + + const locUnavailable = !data || data.loc.unavailable || data.loc.value === null; + + return ( + +
+

{t("commandCenter.productivity.outcomesTitle", "Outcomes")}

+
+
+
{t("commandCenter.productivity.commits", "Commits")}
+
{formatCount(data?.commits ?? 0)}
+
+
+
{t("commandCenter.productivity.pullRequests", "Pull requests")}
+
{formatCount(data?.pullRequests ?? 0)}
+
+
+
+ +
+

{t("commandCenter.productivity.volumeTitle", "Volume (proxy)")}

+
+
+
{t("commandCenter.productivity.modifiedFiles", "Files modified")}
+
{formatCount(data?.modifiedFiles ?? 0)}
+ {t("commandCenter.productivity.volumeHint", "volume, not outcome")} +
+
+
{t("commandCenter.productivity.loc", "Lines changed")}
+
+ {locUnavailable ? ( + + — + + ) : ( + formatCount(data.loc.value ?? 0) + )} +
+ {t("commandCenter.productivity.volumeHint", "volume, not outcome")} +
+
+
+ +
+

+ {t("commandCenter.productivity.byLanguage", "Files by language")} +

+ +
+
+ ); +} diff --git a/packages/dashboard/app/components/command-center/areas/SignalsArea.tsx b/packages/dashboard/app/components/command-center/areas/SignalsArea.tsx new file mode 100644 index 000000000..cecab2e98 --- /dev/null +++ b/packages/dashboard/app/components/command-center/areas/SignalsArea.tsx @@ -0,0 +1,129 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { api } from "../../../api/legacy"; +import type { DateRange } from "../DateRangePicker"; +import { Bar } from "../charts/Bar"; +import { AreaShell } from "./AreaShell"; +import { rangeQuery, formatCount, isInvalidRange } from "./areaShared"; + +/** + * Shape the External Signals endpoint will return once U11/U13 land. Until then + * the endpoint does not exist, so this area degrades to its empty state — it + * must NOT surface a crash/error for the missing endpoint. + */ +export interface SignalsAnalytics { + totalSignals: number; + open: number; + resolved: number; + /** Mean time to resolve, minutes; null/unavailable until U13. */ + mttr: { value: number | null; unavailable: boolean }; + bySource: Array<{ source: string; count: number }>; + bySeverity: Array<{ severity: string; count: number }>; +} + +export function SignalsArea({ range }: { range: DateRange }) { + const { t } = useTranslation("app"); + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + const query = rangeQuery(range); + const invalid = isInvalidRange(range); + + useEffect(() => { + if (invalid) { + setIsLoading(false); + return; + } + let cancelled = false; + setIsLoading(true); + void (async () => { + try { + const result = await api(`/command-center/signals${query}`); + if (!cancelled) { + setData(result); + } + } catch { + // U11/U13 not wired yet (or no signals): degrade to the empty state, + // never an error. External-signal ingestion lands in Phase C. + if (!cancelled) { + setData(null); + } + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + })(); + return () => { + cancelled = true; + }; + }, [query, invalid]); + + const sourceBars = useMemo( + () => (data?.bySource ?? []).map((s) => ({ label: s.source, value: s.count, valueLabel: formatCount(s.count) })), + [data?.bySource], + ); + const severityBars = useMemo( + () => + (data?.bySeverity ?? []).map((s) => ({ label: s.severity, value: s.count, valueLabel: formatCount(s.count) })), + [data?.bySeverity], + ); + + const isEmpty = !data || data.totalSignals === 0; + + return ( + +
+

{t("commandCenter.signals.summaryTitle", "Summary")}

+
+
+
{t("commandCenter.signals.total", "Total signals")}
+
{formatCount(data?.totalSignals ?? 0)}
+
+
+
{t("commandCenter.signals.open", "Open")}
+
{formatCount(data?.open ?? 0)}
+
+
+
{t("commandCenter.signals.resolved", "Resolved")}
+
{formatCount(data?.resolved ?? 0)}
+
+
+
{t("commandCenter.signals.mttr", "MTTR")}
+
+ {data && data.mttr.value !== null && !data.mttr.unavailable ? ( + t("commandCenter.signals.mttrValue", "{{min}} min", { min: Math.round(data.mttr.value) }) + ) : ( + + — + + )} +
+
+
+
+ +
+

{t("commandCenter.signals.bySource", "By source")}

+ +
+ +
+

{t("commandCenter.signals.bySeverity", "By severity")}

+ +
+
+ ); +} diff --git a/packages/dashboard/app/components/command-center/areas/TokensArea.tsx b/packages/dashboard/app/components/command-center/areas/TokensArea.tsx new file mode 100644 index 000000000..1ea2d82ce --- /dev/null +++ b/packages/dashboard/app/components/command-center/areas/TokensArea.tsx @@ -0,0 +1,186 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { + CostResult, + TokenAnalytics, + TokenGroupSummary, +} from "@fusion/core"; +import type { DateRange } from "../DateRangePicker"; +import { Bar } from "../charts/Bar"; +import { AreaShell } from "./AreaShell"; +import { useAnalyticsArea } from "./useAnalyticsArea"; +import { formatCost, formatCount } from "./areaShared"; + +type SortKey = "key" | "totalTokens" | "cost"; + +function costSortValue(cost: CostResult): number { + return cost.unavailable || cost.usd === null ? -1 : cost.usd; +} + +function sortGroups(groups: TokenGroupSummary[], key: SortKey, dir: 1 | -1): TokenGroupSummary[] { + const sorted = [...groups]; + sorted.sort((a, b) => { + let cmp = 0; + if (key === "key") { + cmp = (a.key ?? "").localeCompare(b.key ?? ""); + } else if (key === "totalTokens") { + cmp = a.totalTokens - b.totalTokens; + } else { + cmp = costSortValue(a.cost) - costSortValue(b.cost); + } + if (cmp === 0) { + cmp = (a.key ?? "").localeCompare(b.key ?? ""); + } + return cmp * dir; + }); + return sorted; +} + +/** + * Tokens area: per-model token totals + derived USD cost, plus a bar chart of + * tokens by model. Grouped by model via the `?groupBy=model` endpoint param. + */ +export function TokensArea({ range }: { range: DateRange }) { + const { t } = useTranslation("app"); + const { data, isLoading, error } = useAnalyticsArea( + "/command-center/tokens?groupBy=model", + range, + ); + + const groups = useMemo(() => data?.groups ?? [], [data?.groups]); + + const [sortKey, setSortKey] = useState("totalTokens"); + const [sortDir, setSortDir] = useState<1 | -1>(-1); + + // SWR-identity guard: the set of group keys is the DERIVED value we key the + // sort-reset on. A revalidation that returns content-identical rows with a + // new array identity leaves this string unchanged, so the user's chosen sort + // survives. We only reset sort when the *set of models* actually changes. + const groupKeysSig = useMemo(() => groups.map((g) => g.key ?? "∅").join(" "), [groups]); + const firstSig = useRef(null); + useEffect(() => { + if (firstSig.current === null) { + firstSig.current = groupKeysSig; + return; + } + if (firstSig.current !== groupKeysSig) { + firstSig.current = groupKeysSig; + setSortKey("totalTokens"); + setSortDir(-1); + } + }, [groupKeysSig]); + + const sortedGroups = useMemo(() => sortGroups(groups, sortKey, sortDir), [groups, sortKey, sortDir]); + + const barData = useMemo( + () => + [...groups] + .sort((a, b) => b.totalTokens - a.totalTokens) + .slice(0, 12) + .map((g) => ({ + label: g.key ?? t("commandCenter.tokens.unknownModel", "(unknown)"), + value: g.totalTokens, + valueLabel: formatCount(g.totalTokens), + })), + [groups, t], + ); + + function toggleSort(key: SortKey) { + if (key === sortKey) { + setSortDir((d) => (d === 1 ? -1 : 1)); + } else { + setSortKey(key); + setSortDir(key === "key" ? 1 : -1); + } + } + + function caret(key: SortKey) { + if (key !== sortKey) return null; + return {sortDir === 1 ? "▲" : "▼"}; + } + + const totals = data?.totals; + const isEmpty = !data || (totals?.totalTokens ?? 0) === 0; + + return ( + +
+

{t("commandCenter.tokens.totalsTitle", "Totals")}

+
+
+
{t("commandCenter.tokens.totalTokens", "Total tokens")}
+
{formatCount(totals?.totalTokens ?? 0)}
+
+
+
{t("commandCenter.tokens.cost", "Estimated cost")}
+
+ {data ? formatCost(data.cost.usd, data.cost.unavailable) : "—"} +
+ {data?.cost.stale ? ( + {t("commandCenter.tokens.stalePricing", "pricing may be stale")} + ) : null} +
+
+
{t("commandCenter.tokens.tasks", "Tasks")}
+
{formatCount(totals?.nTasks ?? 0)}
+
+
+
+ +
+

{t("commandCenter.tokens.byModelChart", "Tokens by model")}

+ +
+ +
+

{t("commandCenter.tokens.tableTitle", "Per-model breakdown")}

+
+ + + + + + + + + + + + + {sortedGroups.map((g) => ( + + + + + + + + + ))} + +
toggleSort("key")} data-testid="cc-tokens-sort-key"> + {t("commandCenter.tokens.model", "Model")} + {caret("key")} + {t("commandCenter.tokens.input", "Input")}{t("commandCenter.tokens.output", "Output")}{t("commandCenter.tokens.cached", "Cached")} toggleSort("totalTokens")} data-testid="cc-tokens-sort-total"> + {t("commandCenter.tokens.total", "Total")} + {caret("totalTokens")} + toggleSort("cost")} data-testid="cc-tokens-sort-cost"> + {t("commandCenter.tokens.costCol", "Cost")} + {caret("cost")} +
{g.key ?? t("commandCenter.tokens.unknownModel", "(unknown)")}{formatCount(g.inputTokens)}{formatCount(g.outputTokens)}{formatCount(g.cachedTokens)}{formatCount(g.totalTokens)} + {g.cost.unavailable || g.cost.usd === null ? ( + + — + + ) : ( + formatCost(g.cost.usd, g.cost.unavailable) + )} +
+
+
+
+ ); +} diff --git a/packages/dashboard/app/components/command-center/areas/ToolsArea.tsx b/packages/dashboard/app/components/command-center/areas/ToolsArea.tsx new file mode 100644 index 000000000..ebfd14c7f --- /dev/null +++ b/packages/dashboard/app/components/command-center/areas/ToolsArea.tsx @@ -0,0 +1,84 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import type { ToolAnalytics } from "@fusion/core"; +import type { DateRange } from "../DateRangePicker"; +import { Bar } from "../charts/Bar"; +import { AreaShell } from "./AreaShell"; +import { useAnalyticsArea } from "./useAnalyticsArea"; +import { formatCount } from "./areaShared"; + +/** + * Tools area: autonomy ratio readout + tool categories rendered as a bar chart + * sorted descending by count (the endpoint already returns `byCategory` + * descending, but we re-sort defensively so display order never depends on + * server ordering). + */ +export function ToolsArea({ range }: { range: DateRange }) { + const { t } = useTranslation("app"); + const { data, isLoading, error } = useAnalyticsArea("/command-center/tools", range); + + const sortedCategories = useMemo( + () => [...(data?.byCategory ?? [])].sort((a, b) => b.count - a.count || a.category.localeCompare(b.category)), + [data?.byCategory], + ); + + const barData = useMemo( + () => + sortedCategories.map((c) => ({ + label: c.category, + value: c.count, + valueLabel: formatCount(c.count), + })), + [sortedCategories], + ); + + const isEmpty = !data || data.toolCalls === 0; + + const ratioLabel = data + ? data.fullyAutonomous + ? t("commandCenter.tools.ratioAutonomous", "{{ratio}} calls/session (fully autonomous)", { + ratio: data.autonomyRatio.toFixed(1), + }) + : `${data.autonomyRatio.toFixed(1)}:1` + : "—"; + + return ( + +
+

{t("commandCenter.tools.summaryTitle", "Summary")}

+
+
+
{t("commandCenter.tools.autonomyRatio", "Autonomy ratio")}
+
{ratioLabel}
+ + {t("commandCenter.tools.autonomyHint", "tool calls per human intervention")} + +
+
+
{t("commandCenter.tools.toolCalls", "Tool calls")}
+
{formatCount(data?.toolCalls ?? 0)}
+
+
+
{t("commandCenter.tools.interventions", "Interventions")}
+
{formatCount(data?.interventions.total ?? 0)}
+ + {t("commandCenter.tools.interventionBreakdown", "{{approvals}} approvals · {{steers}} steers", { + approvals: formatCount(data?.interventions.approvals ?? 0), + steers: formatCount(data?.interventions.userSteers ?? 0), + })} + +
+
+
{t("commandCenter.tools.sessions", "Sessions")}
+
{formatCount(data?.sessions ?? 0)}
+
+
+
+ +
+

{t("commandCenter.tools.categoriesTitle", "Tool categories")}

+ +
+
+ ); +} diff --git a/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx b/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx new file mode 100644 index 000000000..117f6306c --- /dev/null +++ b/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx @@ -0,0 +1,232 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor, within, act } from "@testing-library/react"; + +// Mock the api() helper so the areas fetch deterministic fixtures. +const apiMock = vi.fn(); +vi.mock("../../../../api/legacy", () => ({ + api: (path: string, opts?: RequestInit) => apiMock(path, opts), +})); + +import { TokensArea } from "../TokensArea"; +import { ToolsArea } from "../ToolsArea"; +import { ProductivityArea } from "../ProductivityArea"; +import { SignalsArea } from "../SignalsArea"; +import type { DateRange } from "../DateRangePicker"; + +const range7d: DateRange = { from: "2026-06-08", to: null, preset: "7d" }; +const customRange = (from: string, to: string): DateRange => ({ from, to, preset: "custom" }); + +function tokenFixture() { + return { + from: "2026-06-08", + to: null, + groupBy: "model", + totals: { + inputTokens: 1000, + outputTokens: 500, + cachedTokens: 200, + cacheWriteTokens: 0, + totalTokens: 1500, + nTasks: 5, + }, + cost: { usd: 12.5, unavailable: false, stale: false }, + groups: [ + { + key: "gpt-4o", + inputTokens: 600, + outputTokens: 300, + cachedTokens: 100, + cacheWriteTokens: 0, + totalTokens: 900, + nTasks: 3, + cost: { usd: 9.0, unavailable: false, stale: false }, + }, + { + key: "claude-sonnet", + inputTokens: 400, + outputTokens: 200, + cachedTokens: 100, + cacheWriteTokens: 0, + totalTokens: 600, + nTasks: 2, + cost: { usd: 3.5, unavailable: false, stale: false }, + }, + ], + }; +} + +beforeEach(() => { + apiMock.mockReset(); +}); + +describe("TokensArea", () => { + it("shows per-model totals + cost and renders rows", async () => { + apiMock.mockResolvedValue(tokenFixture()); + render(); + + await screen.findByTestId("cc-area-tokens"); + expect(screen.getByTestId("cc-tokens-total").textContent).toContain("1,500"); + expect(screen.getByTestId("cc-tokens-cost").textContent).toContain("$12.50"); + expect(screen.getByTestId("cc-tokens-row-gpt-4o")).toBeTruthy(); + expect(screen.getByTestId("cc-tokens-row-claude-sonnet")).toBeTruthy(); + }); + + it("refetches when the date range changes", async () => { + apiMock.mockResolvedValue(tokenFixture()); + const { rerender } = render(); + await screen.findByTestId("cc-area-tokens"); + expect(apiMock).toHaveBeenCalledTimes(1); + + rerender(); + await waitFor(() => expect(apiMock).toHaveBeenCalledTimes(2)); + const lastCall = apiMock.mock.calls.at(-1)?.[0] as string; + expect(lastCall).toContain("from=2026-05-01"); + }); + + it("renders the empty state with no token data (no crash)", async () => { + apiMock.mockResolvedValue({ + from: null, + to: null, + groupBy: "model", + totals: { inputTokens: 0, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 0, nTasks: 0 }, + cost: { usd: null, unavailable: true, stale: false }, + groups: [], + }); + render(); + await screen.findByTestId("cc-area-tokens-empty"); + }); + + // The critical SWR-identity regression: a revalidation that returns + // content-identical rows with a NEW object identity must NOT reset the user's + // chosen column sort. + it("preserves the user's sort across an SWR revalidation with new array identity", async () => { + const original = tokenFixture(); + // Defer the second resolution so we can interact before it lands. + let resolveSecond: ((v: unknown) => void) | null = null; + apiMock + .mockResolvedValueOnce(original) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecond = resolve; + }), + ); + + const { rerender } = render(); + await screen.findByTestId("cc-area-tokens"); + + // Default sort is total desc. Switch to sorting by model name ascending. + fireEvent.click(screen.getByTestId("cc-tokens-sort-key")); + const rowsAfterSort = screen.getAllByTestId(/cc-tokens-row-/).map((r) => r.getAttribute("data-testid")); + // claude-sonnet sorts before gpt-4o alphabetically. + expect(rowsAfterSort[0]).toBe("cc-tokens-row-claude-sonnet"); + + // Trigger a refetch (range value change → refetch) and resolve it with a + // DEEP COPY of the SAME content (new object identity, identical model set). + rerender(); + await waitFor(() => expect(resolveSecond).not.toBeNull()); + await act(async () => { + resolveSecond?.(JSON.parse(JSON.stringify(original))); + }); + + // Sort must survive: claude-sonnet still first. + await waitFor(() => { + const rows = screen.getAllByTestId(/cc-tokens-row-/).map((r) => r.getAttribute("data-testid")); + expect(rows[0]).toBe("cc-tokens-row-claude-sonnet"); + }); + }); + + it("rejects an inverted custom range client-side without fetching", async () => { + render(); + // No request should be issued for from > to. + await waitFor(() => expect(apiMock).not.toHaveBeenCalled()); + }); +}); + +describe("ToolsArea", () => { + it("shows autonomy ratio and sorted tool categories", async () => { + apiMock.mockResolvedValue({ + from: "2026-06-08", + to: null, + toolCalls: 30, + byCategory: [ + { category: "edit", count: 5 }, + { category: "read", count: 20 }, + { category: "shell", count: 5 }, + ], + sessions: 3, + interventions: { approvals: 2, userSteers: 1, total: 3 }, + autonomyRatio: 10, + fullyAutonomous: false, + }); + render(); + await screen.findByTestId("cc-area-tools"); + expect(screen.getByTestId("cc-tools-autonomy").textContent).toContain("10.0:1"); + + // Sorted descending by count: read (20) first. + const chart = screen.getByRole("list", { name: "Tool categories" }); + const labels = within(chart).getAllByRole("img").map((el) => el.getAttribute("aria-label")); + expect(labels[0]).toBe("read: 20"); + }); + + it("renders the empty state when there are no tool calls", async () => { + apiMock.mockResolvedValue({ + from: null, + to: null, + toolCalls: 0, + byCategory: [], + sessions: 0, + interventions: { approvals: 0, userSteers: 0, total: 0 }, + autonomyRatio: 0, + fullyAutonomous: true, + }); + render(); + await screen.findByTestId("cc-area-tools-empty"); + }); +}); + +describe("ProductivityArea", () => { + it("renders unavailable LOC as the dash sentinel, never 0", async () => { + apiMock.mockResolvedValue({ + from: "2026-06-08", + to: null, + modifiedFiles: 12, + byLanguage: [{ language: "ts", count: 12 }], + commits: 4, + pullRequests: 2, + loc: { value: null, unavailable: true }, + }); + render(); + await screen.findByTestId("cc-area-productivity"); + const loc = screen.getByTestId("cc-productivity-loc-unavailable"); + expect(loc.textContent).toBe("—"); + expect(loc.getAttribute("title")).toBeTruthy(); + // The commits outcome counter still shows a real number. + expect(screen.getByTestId("cc-productivity-commits").textContent).toContain("4"); + }); +}); + +describe("SignalsArea", () => { + it("renders the empty state (not an error) when the signals endpoint is missing", async () => { + apiMock.mockRejectedValue(new Error("API returned HTML instead of JSON (404)")); + render(); + await screen.findByTestId("cc-area-signals-empty"); + // Must not surface the error UI. + expect(screen.queryByTestId("cc-area-signals-error")).toBeNull(); + }); + + it("renders signal metrics when data is present", async () => { + apiMock.mockResolvedValue({ + totalSignals: 8, + open: 3, + resolved: 5, + mttr: { value: 42, unavailable: false }, + bySource: [{ source: "sentry", count: 8 }], + bySeverity: [{ severity: "error", count: 8 }], + }); + render(); + await screen.findByTestId("cc-area-signals"); + expect(screen.getByTestId("cc-signals-total").textContent).toContain("8"); + expect(screen.getByTestId("cc-signals-mttr").textContent).toContain("42"); + }); +}); diff --git a/packages/dashboard/app/components/command-center/areas/areaShared.ts b/packages/dashboard/app/components/command-center/areas/areaShared.ts new file mode 100644 index 000000000..837d965af --- /dev/null +++ b/packages/dashboard/app/components/command-center/areas/areaShared.ts @@ -0,0 +1,38 @@ +import type { DateRange } from "../DateRangePicker"; + +/** + * Build the `?from=&to=` query string for an analytics endpoint from a + * {@link DateRange}. Open bounds (null) are omitted so the server applies its + * documented default window. The picker already rejects `from > to` + * client-side, but we guard here too so a programmatic caller cannot send an + * inverted range. + */ +export function rangeQuery(range: DateRange): string { + const params = new URLSearchParams(); + if (range.from) { + params.set("from", range.from); + } + if (range.to) { + params.set("to", range.to); + } + const qs = params.toString(); + return qs ? `?${qs}` : ""; +} + +/** Format an integer with locale grouping (e.g. 12,345). */ +export function formatCount(n: number): string { + return Number.isFinite(n) ? Math.round(n).toLocaleString() : "0"; +} + +/** Format a USD cost result, returning the unavailable sentinel "—" when unknown. */ +export function formatCost(usd: number | null, unavailable: boolean): string { + if (unavailable || usd === null || !Number.isFinite(usd)) { + return "—"; + } + return `$${usd.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; +} + +/** True when the picker's custom range is invalid (from after to). */ +export function isInvalidRange(range: DateRange): boolean { + return Boolean(range.from && range.to && range.from > range.to); +} diff --git a/packages/dashboard/app/components/command-center/areas/areas.css b/packages/dashboard/app/components/command-center/areas/areas.css new file mode 100644 index 000000000..b7dcb3204 --- /dev/null +++ b/packages/dashboard/app/components/command-center/areas/areas.css @@ -0,0 +1,128 @@ +/* Command Center historical analytics areas (U5). + * Animation durations use --duration-* tokens only (never --transition-*). + */ + +.cc-area { + display: flex; + flex-direction: column; + gap: var(--space-4, 1rem); +} + +.cc-area-section { + display: flex; + flex-direction: column; + gap: var(--space-2, 0.5rem); +} + +.cc-area-section-title { + margin: 0; + font-size: var(--font-size-sm, 0.85rem); + font-weight: 600; + color: var(--text-secondary, #888); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +/* Reuse the shell's stat-grid/stat-card look. */ +.cc-area .cc-stat-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); + gap: var(--space-3, 0.75rem); +} + +.cc-stat-sub { + font-size: var(--font-size-xs, 0.75rem); + color: var(--text-secondary, #888); +} + +/* ---- Tables ---- */ +.cc-table-wrap { + overflow-x: auto; +} + +.cc-table { + width: 100%; + border-collapse: collapse; + font-size: var(--font-size-sm, 0.85rem); + font-variant-numeric: tabular-nums; +} + +.cc-table th, +.cc-table td { + padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem); + text-align: right; + border-bottom: 1px solid var(--border-subtle, rgba(127, 127, 127, 0.2)); + white-space: nowrap; +} + +.cc-table th:first-child, +.cc-table td:first-child { + text-align: left; +} + +.cc-table thead th { + color: var(--text-secondary, #888); + font-weight: 600; +} + +.cc-table th.cc-sortable { + cursor: pointer; + user-select: none; +} + +.cc-table th.cc-sortable:hover { + color: var(--text-primary, #ddd); +} + +.cc-table tbody tr { + cursor: pointer; +} + +.cc-table tbody tr.cc-row-selected { + background: var(--surface-2, rgba(127, 127, 127, 0.12)); +} + +.cc-table tbody tr.cc-row-selected td { + color: var(--text-primary, #ddd); +} + +.cc-sort-caret { + margin-left: var(--space-1, 0.25rem); + font-size: 0.7em; + color: var(--color-accent, #4f8cff); +} + +/* Unavailable sentinel ("—") with a help cursor for its tooltip. */ +.cc-unavailable { + color: var(--text-secondary, #888); + cursor: help; + border-bottom: 1px dotted var(--border-subtle, rgba(127, 127, 127, 0.4)); +} + +.cc-loading-inline { + display: flex; + align-items: center; + gap: var(--space-2, 0.5rem); + padding: var(--space-4, 1rem); + color: var(--text-secondary, #888); +} + +.cc-area-empty, +.cc-area-error { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-2, 0.5rem); + padding: var(--space-6, 2rem); + color: var(--text-secondary, #888); + text-align: center; +} + +.cc-area-error { + color: var(--color-error, #e5484d); +} + +.cc-pricing-note { + font-size: var(--font-size-xs, 0.75rem); + color: var(--text-secondary, #888); +} diff --git a/packages/dashboard/app/components/command-center/areas/useAnalyticsArea.ts b/packages/dashboard/app/components/command-center/areas/useAnalyticsArea.ts new file mode 100644 index 000000000..31d1a8880 --- /dev/null +++ b/packages/dashboard/app/components/command-center/areas/useAnalyticsArea.ts @@ -0,0 +1,73 @@ +import { useCallback, useEffect, useState } from "react"; +import { api } from "../../../api/legacy"; +import type { DateRange } from "../DateRangePicker"; +import { isInvalidRange, rangeQuery } from "./areaShared"; + +export interface AnalyticsAreaState { + data: T | null; + isLoading: boolean; + /** Non-null only for a hard error with no prior data to fall back on. */ + error: string | null; + reload: () => void; +} + +/** + * Fetch one Command Center analytics endpoint for the selected date range. + * + * - Refetches whenever the resolved range query changes (range change → refetch). + * - An inverted custom range (`from > to`) never fires a request; the area + * surfaces the picker's client-side rejection instead. + * - Keeps the previous `data` visible across a refetch so revalidation does not + * flash the empty/loading state (and so consumers' derived-keyed effects can + * distinguish a real content change from a re-fetch of identical content). + * + * NOTE on the SWR-identity trap: this hook intentionally replaces `data` + * identity on every successful fetch. Consumers MUST key any selection / sort / + * drill-down reset effect on a DERIVED value (e.g. `rows.map(r => r.id).join()`), + * never on the fetched object's identity, or that state resets on every tick. + */ +export function useAnalyticsArea( + endpoint: string, + range: DateRange, +): AnalyticsAreaState { + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const query = rangeQuery(range); + const invalid = isInvalidRange(range); + + const load = useCallback(async () => { + if (invalid) { + // Client-side rejection: do not call the server with an inverted range. + setIsLoading(false); + return; + } + setIsLoading(true); + setError(null); + try { + const result = await api(`${endpoint}${query}`); + setData(result); + } catch (loadError: unknown) { + setError(loadError instanceof Error ? loadError.message : "Failed to load analytics"); + } finally { + setIsLoading(false); + } + }, [endpoint, query, invalid]); + + useEffect(() => { + void load(); + }, [load]); + + const reload = useCallback(() => { + void load(); + }, [load]); + + return { + data, + isLoading, + // Only surface a blocking error when we have nothing to show. + error: error !== null && data === null ? error : null, + reload, + }; +} From 070ed98ca2df6c18db40b4bd421f0a293a89bcef Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 15 Jun 2026 20:05:14 -0700 Subject: [PATCH 11/21] =?UTF-8?q?feat(command-center):=20U6b=20=E2=80=94?= =?UTF-8?q?=20live=20Mission-Control=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MissionControlPanel renders the U6a /live snapshot with push+poll convergence (SSE-triggered refetch + 5s interval armed only while sessions are in-flight, cleared when idle), stale-node marking, and the live SDLC funnel. Wired into the shell's Mission Control tab. --- .../command-center/CommandCenter.tsx | 3 +- .../command-center/MissionControlPanel.css | 98 +++++ .../command-center/MissionControlPanel.tsx | 352 ++++++++++++++++++ .../__tests__/MissionControlPanel.test.tsx | 232 ++++++++++++ 4 files changed, 684 insertions(+), 1 deletion(-) create mode 100644 packages/dashboard/app/components/command-center/MissionControlPanel.css create mode 100644 packages/dashboard/app/components/command-center/MissionControlPanel.tsx create mode 100644 packages/dashboard/app/components/command-center/__tests__/MissionControlPanel.test.tsx diff --git a/packages/dashboard/app/components/command-center/CommandCenter.tsx b/packages/dashboard/app/components/command-center/CommandCenter.tsx index e5d5003ab..03a8b7e89 100644 --- a/packages/dashboard/app/components/command-center/CommandCenter.tsx +++ b/packages/dashboard/app/components/command-center/CommandCenter.tsx @@ -8,6 +8,7 @@ import { ActivityArea } from "./areas/ActivityArea"; import { ProductivityArea } from "./areas/ProductivityArea"; import { EcosystemArea } from "./areas/EcosystemArea"; import { SignalsArea } from "./areas/SignalsArea"; +import { MissionControlPanel } from "./MissionControlPanel"; import "./CommandCenter.css"; type SubViewId = @@ -171,8 +172,8 @@ export function CommandCenter() { return ; case "signals": return ; - // Mission Control (U6b) is wired in its own unit; placeholder until then. case "mission-control": + return ; default: return ; } diff --git a/packages/dashboard/app/components/command-center/MissionControlPanel.css b/packages/dashboard/app/components/command-center/MissionControlPanel.css new file mode 100644 index 000000000..dc9c72176 --- /dev/null +++ b/packages/dashboard/app/components/command-center/MissionControlPanel.css @@ -0,0 +1,98 @@ +/* + * Mission-Control live panel (U6b). Component-local styles. + * Animation durations use --duration-* tokens only (never --transition-*). + */ + +.cc-mission-control { + display: flex; + flex-direction: column; + gap: var(--space-4, 16px); +} + +.cc-mc-columns { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-4, 16px); +} + +.cc-mc-section { + display: flex; + flex-direction: column; + gap: var(--space-2, 8px); + min-width: 0; +} + +.cc-mc-muted { + color: var(--text-secondary, #888); + font-size: 0.85rem; + margin: 0; +} + +.cc-mc-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-1, 4px); +} + +.cc-mc-session, +.cc-mc-node { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2, 8px); + padding: var(--space-2, 8px); + border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08)); + border-radius: var(--radius-sm, 6px); + background: var(--surface-1, rgba(255, 255, 255, 0.02)); + min-width: 0; +} + +.cc-mc-node.inactive { + opacity: 0.55; +} + +.cc-mc-session-purpose, +.cc-mc-node-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.cc-mc-session-meta, +.cc-mc-node-meta { + display: flex; + align-items: center; + gap: var(--space-2, 8px); + flex-shrink: 0; +} + +.cc-mc-badge { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.03em; + padding: 2px 6px; + border-radius: var(--radius-sm, 6px); + background: var(--surface-2, rgba(59, 130, 246, 0.15)); + color: var(--text-primary, #ddd); +} + +.cc-mc-badge.inactive { + background: var(--surface-2, rgba(255, 255, 255, 0.06)); + color: var(--text-secondary, #999); +} + +.cc-mc-task, +.cc-mc-node-count { + font-size: 0.75rem; + color: var(--text-secondary, #999); +} + +@media (max-width: 768px), (max-height: 480px) { + .cc-mc-columns { + grid-template-columns: 1fr; + } +} diff --git a/packages/dashboard/app/components/command-center/MissionControlPanel.tsx b/packages/dashboard/app/components/command-center/MissionControlPanel.tsx new file mode 100644 index 000000000..aee96db44 --- /dev/null +++ b/packages/dashboard/app/components/command-center/MissionControlPanel.tsx @@ -0,0 +1,352 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { AlertCircle, Loader2, Radio } from "lucide-react"; +import type { LiveSnapshot, LiveSession, ColumnCount } from "@fusion/core"; +import { api } from "../../api/legacy"; +import { subscribeSse } from "../../sse-bus"; +import { Funnel, type FunnelStage } from "./charts/Funnel"; +import "./MissionControlPanel.css"; + +/** Poll cadence while work is in-flight (KTD5). */ +export const LIVE_POLL_INTERVAL_MS = 5_000; + +/** + * A node whose most recent active session was last updated longer ago than this + * is rendered as "inactive" rather than dropped from the list, so a node that + * goes quiet stays visible (greyed) until the next authoritative snapshot + * removes it. + */ +export const NODE_STALE_THRESHOLD_MS = 30_000; + +/** SSE events that should trigger an immediate refetch (push half of KTD5). */ +const LIVE_REFETCH_EVENTS = [ + "session:updated", + "session:completed", + "run:created", + "run:updated", + "run:completed", + "run:cancelled", + "run:failed", + "agent:stateChanged", + "task:moved", + "task:updated", + "task:created", + "task:deleted", +] as const; + +/** + * The ordered SDLC funnel stages. Columns are matched case-insensitively against + * these canonical stage ids; any column that does not map to a known stage is + * folded into an "other" bucket so custom workflow columns still contribute a + * count rather than being silently dropped. + */ +const FUNNEL_STAGES: Array<{ id: string; match: (column: string) => boolean }> = [ + { id: "triage", match: (c) => c === "triage" || c === "signal" || c === "backlog" }, + { id: "todo", match: (c) => c === "todo" || c === "to-do" || c === "to do" || c === "ready" }, + { id: "in-progress", match: (c) => c === "in-progress" || c === "in progress" || c === "doing" }, + { id: "in-review", match: (c) => c === "in-review" || c === "in review" || c === "review" }, + { id: "done", match: (c) => c === "done" || c === "complete" || c === "completed" || c === "shipped" }, +]; + +interface NodeView { + path: string; + label: string; + sessionCount: number; + inactive: boolean; +} + +export interface LiveSnapshotState { + snapshot: LiveSnapshot | null; + isLoading: boolean; + /** Non-null only for a hard error with no prior snapshot to fall back on. */ + error: string | null; + /** True while a poll interval is scheduled (work in-flight). Exposed for tests. */ + polling: boolean; + reload: () => void; +} + +/** + * Live snapshot hook implementing the push + poll convergence pattern (KTD5): + * + * - **Push:** subscribes to the shared SSE bus and refetches immediately on any + * session/run/task event — so a change lands within one event, not one poll. + * - **Poll:** schedules a 5s interval as a fallback, but **only while work is + * in-flight** (any active session or run). When the latest snapshot shows no + * active work, the interval is cleared and no new one is scheduled — so an idle + * panel does no background polling. The SSE subscription stays live so the next + * started session pushes in and re-arms polling. + * + * The decision to poll is derived from the freshest snapshot (kept in a ref so the + * interval callback always sees current state), re-evaluated after every fetch. + */ +export function useLiveSnapshot(): LiveSnapshotState { + const [snapshot, setSnapshot] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [polling, setPolling] = useState(false); + + const snapshotRef = useRef(null); + const pollTimerRef = useRef | null>(null); + const inFlightRef = useRef(false); + const mountedRef = useRef(true); + + // Stable callbacks below close over only refs + setState, so `load` (and the + // SSE subscription / poll interval that call it) never need to be recreated. + + const stopPolling = useCallback(() => { + if (pollTimerRef.current !== null) { + clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + setPolling(false); + } + }, []); + + const load = useCallback(async () => { + // Coalesce overlapping fetches (a poll tick and an SSE push racing). + if (inFlightRef.current) return; + inFlightRef.current = true; + try { + const result = await api("/command-center/live"); + if (!mountedRef.current) return; + snapshotRef.current = result; + setSnapshot(result); + setError(null); + } catch (loadError: unknown) { + if (!mountedRef.current) return; + setError(loadError instanceof Error ? loadError.message : "Failed to load live snapshot"); + } finally { + inFlightRef.current = false; + if (mountedRef.current) { + setIsLoading(false); + // Re-evaluate polling against the freshest snapshot after every fetch. + // "In-flight" = any active session or run. Idle → no interval exists. + const snap = snapshotRef.current; + const inFlight = !!snap && (snap.activeSessions > 0 || snap.activeRuns > 0); + if (inFlight) { + // Start the poll interval iff one is not already running. + if (pollTimerRef.current === null) { + pollTimerRef.current = setInterval(() => { + void load(); + }, LIVE_POLL_INTERVAL_MS); + setPolling(true); + } + } else { + stopPolling(); + } + } + } + }, [stopPolling]); + + useEffect(() => { + mountedRef.current = true; + void load(); + + const unsubscribe = subscribeSse("/api/events", { + events: Object.fromEntries( + LIVE_REFETCH_EVENTS.map((name) => [name, () => void load()]), + ), + // On reconnect we may have missed events while the stream was down — + // refetch authoritative state. + onReconnect: () => void load(), + }); + + return () => { + mountedRef.current = false; + unsubscribe(); + stopPolling(); + }; + }, [load, stopPolling]); + + const reload = useCallback(() => { + void load(); + }, [load]); + + return { + snapshot, + isLoading, + error: error !== null && snapshot === null ? error : null, + polling, + reload, + }; +} + +function nodeLabelFromPath(path: string): string { + const parts = path.split(/[/\\]/).filter(Boolean); + return parts.length > 0 ? parts[parts.length - 1] : path; +} + +/** Derive per-node views, marking nodes whose sessions are all stale as inactive. */ +function deriveNodes(sessions: LiveSession[], capturedAt: string): NodeView[] { + const capturedMs = Date.parse(capturedAt); + const byPath = new Map(); + for (const s of sessions) { + if (!s.worktreePath) continue; + const prev = byPath.get(s.worktreePath) ?? { count: 0, freshestMs: 0 }; + const ms = Date.parse(s.updatedAt); + byPath.set(s.worktreePath, { + count: prev.count + 1, + freshestMs: Number.isFinite(ms) ? Math.max(prev.freshestMs, ms) : prev.freshestMs, + }); + } + return Array.from(byPath.entries()) + .map(([path, info]) => { + const age = Number.isFinite(capturedMs) && info.freshestMs > 0 ? capturedMs - info.freshestMs : 0; + return { + path, + label: nodeLabelFromPath(path), + sessionCount: info.count, + inactive: age > NODE_STALE_THRESHOLD_MS, + }; + }) + .sort((a, b) => b.sessionCount - a.sessionCount); +} + +/** Map raw column counts onto the ordered SDLC funnel stages. */ +function deriveFunnelStages(columns: ColumnCount[], label: (id: string, fallback: string) => string): FunnelStage[] { + const totals = new Map(); + for (const stage of FUNNEL_STAGES) totals.set(stage.id, 0); + let other = 0; + for (const c of columns) { + const normalized = c.column.trim().toLowerCase(); + const stage = FUNNEL_STAGES.find((s) => s.match(normalized)); + if (stage) { + totals.set(stage.id, (totals.get(stage.id) ?? 0) + c.count); + } else { + other += c.count; + } + } + const stages: FunnelStage[] = FUNNEL_STAGES.map((s) => ({ + label: label(`commandCenter.missionControl.stage.${s.id}`, s.id), + value: totals.get(s.id) ?? 0, + })); + if (other > 0) { + stages.push({ label: label("commandCenter.missionControl.stage.other", "Other"), value: other }); + } + return stages; +} + +/** + * Live Mission-Control panel (U6b). Renders the live snapshot from + * `GET /api/command-center/live` with push + poll convergence (KTD5): SSE events + * trigger an immediate refetch, and a 5s poll runs only while work is in-flight. + */ +export function MissionControlPanel() { + const { t } = useTranslation("app"); + const { snapshot, isLoading, error } = useLiveSnapshot(); + + const sessions = useMemo(() => snapshot?.sessions ?? [], [snapshot?.sessions]); + const nodes = useMemo( + () => (snapshot ? deriveNodes(snapshot.sessions, snapshot.capturedAt) : []), + [snapshot], + ); + const stages = useMemo( + () => (snapshot ? deriveFunnelStages(snapshot.columns, t) : []), + [snapshot, t], + ); + + if (isLoading && !snapshot) { + return ( +
+ + {t("commandCenter.missionControl.loading", "Loading live activity…")} +
+ ); + } + + if (error !== null) { + return ( +
+ +

{error}

+
+ ); + } + + const hasActivity = (snapshot?.activeSessions ?? 0) > 0 || (snapshot?.activeRuns ?? 0) > 0; + + return ( +
+
+
+
{t("commandCenter.missionControl.activeSessions", "Active sessions")}
+
{snapshot?.activeSessions ?? 0}
+
+
+
{t("commandCenter.missionControl.activeRuns", "Active runs")}
+
{snapshot?.activeRuns ?? 0}
+
+
+
{t("commandCenter.missionControl.activeNodes", "Active nodes")}
+
{snapshot?.activeNodes ?? 0}
+
+
+ + {!hasActivity ? ( +
+ +

{t("commandCenter.missionControl.idle", "No active sessions. Live updates resume when work starts.")}

+
+ ) : null} + +
+
+

{t("commandCenter.missionControl.sessionsTitle", "Sessions")}

+ {sessions.length === 0 ? ( +

+ {t("commandCenter.missionControl.noSessions", "No active sessions.")} +

+ ) : ( +
    + {sessions.map((s) => ( +
  • + {s.purpose || s.adapterId} + + {s.agentState} + {s.taskId ? {s.taskId} : null} + +
  • + ))} +
+ )} +
+ +
+

{t("commandCenter.missionControl.nodesTitle", "Nodes")}

+ {nodes.length === 0 ? ( +

+ {t("commandCenter.missionControl.noNodes", "No active nodes.")} +

+ ) : ( +
    + {nodes.map((n) => ( +
  • + {n.label} + + {n.inactive ? ( + + {t("commandCenter.missionControl.inactive", "inactive")} + + ) : null} + + {t("commandCenter.missionControl.sessionCount", "{{count}} session", { count: n.sessionCount })} + + +
  • + ))} +
+ )} +
+
+ +
+

{t("commandCenter.missionControl.funnelTitle", "SDLC funnel (live)")}

+ +
+
+ ); +} diff --git a/packages/dashboard/app/components/command-center/__tests__/MissionControlPanel.test.tsx b/packages/dashboard/app/components/command-center/__tests__/MissionControlPanel.test.tsx new file mode 100644 index 000000000..fe0a3ea04 --- /dev/null +++ b/packages/dashboard/app/components/command-center/__tests__/MissionControlPanel.test.tsx @@ -0,0 +1,232 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, act } from "@testing-library/react"; +import type { LiveSnapshot } from "@fusion/core"; + +// Mock the api() helper so the panel fetches deterministic snapshots. +const apiMock = vi.fn(); +vi.mock("../../../api/legacy", () => ({ + api: (path: string, opts?: RequestInit) => apiMock(path, opts), +})); + +// Mock the SSE bus, capturing the subscription so tests can fire events and +// assert subscribe/unsubscribe behavior. +type SseEvents = Record void>; +let sseHandlers: SseEvents = {}; +let sseOnReconnect: (() => void) | undefined; +const unsubscribeMock = vi.fn(); +const subscribeMock = vi.fn((_url: string, sub: { events?: SseEvents; onReconnect?: () => void }) => { + sseHandlers = sub.events ?? {}; + sseOnReconnect = sub.onReconnect; + return unsubscribeMock; +}); +vi.mock("../../../sse-bus", () => ({ + subscribeSse: (url: string, sub: { events?: SseEvents; onReconnect?: () => void }) => subscribeMock(url, sub), +})); + +import { MissionControlPanel, LIVE_POLL_INTERVAL_MS, NODE_STALE_THRESHOLD_MS } from "../MissionControlPanel"; + +function snapshot(overrides: Partial = {}): LiveSnapshot { + return { + capturedAt: "2026-06-15T12:00:00.000Z", + activeSessions: 0, + activeRuns: 0, + activeNodes: 0, + sessions: [], + runs: [], + columns: [], + ...overrides, + }; +} + +function activeSession(id: string, overrides: Partial = {}) { + return { + id, + taskId: `task-${id}`, + purpose: `purpose-${id}`, + adapterId: "claude-local", + agentState: "active", + worktreePath: `/repo/wt-${id}`, + updatedAt: "2026-06-15T12:00:00.000Z", + ...overrides, + }; +} + +beforeEach(() => { + apiMock.mockReset(); + subscribeMock.mockClear(); + unsubscribeMock.mockClear(); + sseHandlers = {}; + sseOnReconnect = undefined; + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); +}); + +/** Flush microtasks (awaited promises) under fake timers. */ +async function flush() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe("MissionControlPanel — KTD5 push + poll convergence", () => { + it("renders an active session and removes it when it ends", async () => { + // Initial: one active session. + apiMock.mockResolvedValueOnce( + snapshot({ activeSessions: 1, activeNodes: 1, sessions: [activeSession("s1")] }), + ); + render(); + await flush(); + + expect(screen.getByTestId("mission-control-session-s1")).toBeTruthy(); + expect(screen.getByTestId("mission-control-active-sessions").textContent).toContain("1"); + + // Next fetch (via SSE push): the session ended → empty snapshot. + apiMock.mockResolvedValueOnce(snapshot()); + await act(async () => { + sseHandlers["session:completed"]?.({}); + }); + await flush(); + + expect(screen.queryByTestId("mission-control-session-s1")).toBeNull(); + expect(screen.getByTestId("mission-control-idle")).toBeTruthy(); + }); + + it("does NOT schedule a poll interval when idle (zero active sessions)", async () => { + apiMock.mockResolvedValue(snapshot()); // idle from the start + const setInterval = vi.spyOn(globalThis, "setInterval"); + render(); + await flush(); + + // No interval was ever scheduled because there is no work in-flight. + expect(setInterval).not.toHaveBeenCalled(); + + // Advancing well past the poll cadence triggers no further fetches. + apiMock.mockClear(); + await act(async () => { + vi.advanceTimersByTime(LIVE_POLL_INTERVAL_MS * 3); + }); + await flush(); + expect(apiMock).not.toHaveBeenCalled(); + + setInterval.mockRestore(); + }); + + it("schedules a poll interval only while work is in-flight and stops it when idle", async () => { + // In-flight snapshot → interval should arm. + apiMock.mockResolvedValueOnce( + snapshot({ activeSessions: 1, activeNodes: 1, sessions: [activeSession("s1")] }), + ); + render(); + await flush(); + + // Poll tick fires while in-flight → another fetch (now idle). + apiMock.mockResolvedValueOnce(snapshot()); + await act(async () => { + vi.advanceTimersByTime(LIVE_POLL_INTERVAL_MS); + }); + await flush(); + expect(screen.getByTestId("mission-control-idle")).toBeTruthy(); + + // Now idle: further ticks must NOT fetch (interval was cleared). + apiMock.mockClear(); + await act(async () => { + vi.advanceTimersByTime(LIVE_POLL_INTERVAL_MS * 3); + }); + await flush(); + expect(apiMock).not.toHaveBeenCalled(); + }); + + it("refetches immediately on an SSE push, even between poll ticks", async () => { + apiMock.mockResolvedValueOnce(snapshot()); // idle → no poll interval + render(); + await flush(); + apiMock.mockClear(); + + // A push event arrives with NO timer advance: it must trigger a refetch. + apiMock.mockResolvedValueOnce( + snapshot({ activeSessions: 1, activeNodes: 1, sessions: [activeSession("s2")] }), + ); + await act(async () => { + sseHandlers["run:created"]?.({}); + }); + await flush(); + + expect(apiMock).toHaveBeenCalledTimes(1); + expect(screen.getByTestId("mission-control-session-s2")).toBeTruthy(); + }); + + it("marks a node with no recent heartbeat as inactive rather than dropping it", async () => { + const capturedAt = "2026-06-15T12:00:00.000Z"; + const capturedMs = Date.parse(capturedAt); + const staleAt = new Date(capturedMs - NODE_STALE_THRESHOLD_MS - 5_000).toISOString(); + const freshAt = new Date(capturedMs - 1_000).toISOString(); + + apiMock.mockResolvedValueOnce( + snapshot({ + capturedAt, + activeSessions: 2, + activeNodes: 2, + sessions: [ + activeSession("fresh", { worktreePath: "/repo/fresh-node", updatedAt: freshAt }), + activeSession("stale", { worktreePath: "/repo/stale-node", updatedAt: staleAt }), + ], + }), + ); + render(); + await flush(); + + // Both nodes are still present (stale one not dropped). + const freshNode = screen.getByTestId("mission-control-node-fresh-node"); + const staleNode = screen.getByTestId("mission-control-node-stale-node"); + expect(freshNode.getAttribute("data-inactive")).toBe("false"); + expect(staleNode.getAttribute("data-inactive")).toBe("true"); + }); + + it("subscribes to the SSE bus on mount and unsubscribes on unmount", async () => { + apiMock.mockResolvedValue(snapshot()); + const { unmount } = render(); + await flush(); + + expect(subscribeMock).toHaveBeenCalledTimes(1); + expect(subscribeMock.mock.calls[0][0]).toBe("/api/events"); + expect(typeof sseOnReconnect).toBe("function"); + + unmount(); + expect(unsubscribeMock).toHaveBeenCalledTimes(1); + }); + + it("renders the live SDLC funnel from current column counts", async () => { + apiMock.mockResolvedValueOnce( + snapshot({ + activeSessions: 1, + sessions: [activeSession("s1")], + columns: [ + { column: "triage", count: 4 }, + { column: "in-progress", count: 2 }, + { column: "done", count: 7 }, + { column: "custom-column", count: 3 }, + ], + }), + ); + render(); + await flush(); + + const funnel = screen.getByTestId("mission-control-funnel"); + expect(funnel).toBeTruthy(); + // The unmapped "custom-column" folds into an "Other" stage, not dropped. + expect(funnel.textContent).toContain("3"); + expect(funnel.textContent).toContain("7"); + }); + + it("surfaces a hard error when the first fetch fails with no prior data", async () => { + apiMock.mockRejectedValueOnce(new Error("boom")); + render(); + await flush(); + expect(screen.getByTestId("mission-control-error")).toBeTruthy(); + }); +}); From 29acf148849b5b0e35b9905760e0d7790a299afd Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 15 Jun 2026 20:05:14 -0700 Subject: [PATCH 12/21] =?UTF-8?q?feat(command-center):=20U8=20=E2=80=94=20?= =?UTF-8?q?CSV=20export=20for=20analytics=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ?format=csv branch on tokens/tools/activity/productivity serializes the same scoped aggregator output (RFC-4180 quoting, attachment headers, header-only on empty). Scoping applied before aggregation — no cross-project leak via export. --- .../src/__tests__/command-center-csv.test.ts | 97 ++++++++++ .../register-command-center-routes.test.ts | 101 +++++++++++ packages/dashboard/src/command-center-csv.ts | 170 ++++++++++++++++++ .../routes/register-command-center-routes.ts | 53 +++++- 4 files changed, 418 insertions(+), 3 deletions(-) create mode 100644 packages/dashboard/src/__tests__/command-center-csv.test.ts create mode 100644 packages/dashboard/src/command-center-csv.ts diff --git a/packages/dashboard/src/__tests__/command-center-csv.test.ts b/packages/dashboard/src/__tests__/command-center-csv.test.ts new file mode 100644 index 000000000..797dee61f --- /dev/null +++ b/packages/dashboard/src/__tests__/command-center-csv.test.ts @@ -0,0 +1,97 @@ +// @vitest-environment node + +import { describe, expect, it } from "vitest"; +import { + serializeCsv, + tokenAnalyticsToTable, + type CsvTable, +} from "../command-center-csv.js"; +import type { TokenAnalytics } from "@fusion/core"; + +describe("serializeCsv (RFC-4180)", () => { + it("emits a header row and CRLF-terminated records", () => { + const table: CsvTable = { + header: ["a", "b"], + rows: [ + ["1", "2"], + ["3", "4"], + ], + }; + expect(serializeCsv(table)).toBe("a,b\r\n1,2\r\n3,4\r\n"); + }); + + it("emits a header-only document for an empty result (not empty)", () => { + const table: CsvTable = { header: ["x", "y"], rows: [] }; + expect(serializeCsv(table)).toBe("x,y\r\n"); + }); + + it("quotes fields containing commas, quotes, and newlines (RFC-4180)", () => { + const table: CsvTable = { + header: ["name", "note"], + rows: [ + ["a,b", 'he said "hi"'], + ["line1\nline2", "carriage\rreturn"], + ], + }; + const out = serializeCsv(table); + expect(out).toContain('"a,b","he said ""hi"""'); + expect(out).toContain('"line1\nline2","carriage\rreturn"'); + }); + + it("serializes null/undefined as empty fields and numbers/booleans as-is", () => { + const table: CsvTable = { + header: ["a", "b", "c", "d"], + rows: [[null, undefined, 42, true]], + }; + expect(serializeCsv(table)).toBe("a,b,c,d\r\n,,42,true\r\n"); + }); +}); + +describe("tokenAnalyticsToTable", () => { + function emptyResult(): TokenAnalytics { + return { + from: null, + to: null, + groupBy: null, + totals: { + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + cacheWriteTokens: 0, + totalTokens: 0, + nTasks: 0, + }, + cost: { usd: null, unavailable: false, stale: false }, + groups: [], + }; + } + + it("produces a single (total) row when no groupBy", () => { + const table = tokenAnalyticsToTable(emptyResult()); + expect(table.rows).toHaveLength(1); + expect(table.rows[0][0]).toBe("(total)"); + // Header-only output is impossible here — there is always a total row. + expect(serializeCsv(table).split("\r\n")[0]).toContain("totalTokens"); + }); + + it("produces one row per group when groupBy is set", () => { + const result = emptyResult(); + result.groupBy = "model"; + result.groups = [ + { + key: "claude-sonnet-4-5", + inputTokens: 10, + outputTokens: 20, + cachedTokens: 0, + cacheWriteTokens: 0, + totalTokens: 30, + nTasks: 1, + cost: { usd: 0.01, unavailable: false, stale: false }, + }, + ]; + const table = tokenAnalyticsToTable(result); + expect(table.rows).toHaveLength(1); + expect(table.rows[0][0]).toBe("claude-sonnet-4-5"); + expect(table.rows[0][5]).toBe(30); + }); +}); diff --git a/packages/dashboard/src/__tests__/register-command-center-routes.test.ts b/packages/dashboard/src/__tests__/register-command-center-routes.test.ts index 21def3601..5d0fa206e 100644 --- a/packages/dashboard/src/__tests__/register-command-center-routes.test.ts +++ b/packages/dashboard/src/__tests__/register-command-center-routes.test.ts @@ -200,6 +200,107 @@ describe("register-command-center-routes", () => { expect((b.body as { totals: { totalTokens: number } }).totals.totalTokens).toBe(1998); }); + it("?format=csv returns well-formed CSV with attachment header", async () => { + const res = await request( + app, + "GET", + "/api/command-center/tokens?from=2026-02-01T00:00:00.000Z&to=2026-04-01T00:00:00.000Z&groupBy=model&projectId=proj-a&format=csv", + ); + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toContain("text/csv"); + expect(res.headers["content-disposition"]).toBe( + 'attachment; filename="command-center-tokens.csv"', + ); + const csv = res.body as string; + const lines = csv.split("\r\n").filter((l) => l.length > 0); + // Header row + one model group row (claude-sonnet-4-5, total 200). + expect(lines[0]).toContain("totalTokens"); + expect(lines).toHaveLength(2); + expect(lines[1]).toContain("claude-sonnet-4-5"); + expect(lines[1]).toContain("200"); + }); + + it("?format=csv empty result returns header-only CSV, not a 204", async () => { + // Window with no data → header-only. + const res = await request( + app, + "GET", + "/api/command-center/tokens?from=2020-01-01T00:00:00.000Z&to=2020-01-02T00:00:00.000Z&projectId=proj-a&format=csv", + ); + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toContain("text/csv"); + const csv = res.body as string; + const lines = csv.split("\r\n").filter((l) => l.length > 0); + // No groupBy → always a single (total) row of zeros, plus the header. + expect(lines[0]).toContain("totalTokens"); + expect(lines[1]).toContain("(total)"); + expect(lines[1]).toContain(",0,"); + }); + + it("?format=csv RFC-4180 quotes values with commas/quotes/newlines", async () => { + // Seed a task whose model id contains a comma + quote + newline so the + // groupBy=model group key forces RFC-4180 quoting through the export path. + const nasty = 'mod,el "x"\nline2'; + dbA.prepare( + `INSERT INTO tasks + (id, description, "column", modelProvider, modelId, + tokenUsageInputTokens, tokenUsageOutputTokens, tokenUsageTotalTokens, + tokenUsageLastUsedAt, createdAt, updatedAt) + VALUES ('FN-A2', 'd', 'todo', 'anthropic', ?, 5, 5, 10, + '2026-03-01T00:00:00.000Z', '2026-03-01T00:00:00.000Z', + '2026-03-01T00:00:00.000Z')`, + ).run(nasty); + + const res = await request( + app, + "GET", + "/api/command-center/tokens?from=2026-02-01T00:00:00.000Z&to=2026-04-01T00:00:00.000Z&groupBy=model&projectId=proj-a&format=csv", + ); + expect(res.status).toBe(200); + const csv = res.body as string; + // The nasty key must appear quoted with the embedded quote doubled. + expect(csv).toContain('"mod,el ""x""\nline2"'); + }); + + it("?format=csv is project-scoped — A cannot retrieve B's data", async () => { + const a = await request( + app, + "GET", + "/api/command-center/tokens?from=2026-02-01T00:00:00.000Z&to=2026-04-01T00:00:00.000Z&projectId=proj-a&format=csv", + ); + const b = await request( + app, + "GET", + "/api/command-center/tokens?from=2026-02-01T00:00:00.000Z&to=2026-04-01T00:00:00.000Z&projectId=proj-b&format=csv", + ); + // A total = 200, B total = 1998. Neither CSV may contain the other's total. + expect(a.body as string).toContain("200"); + expect(a.body as string).not.toContain("1998"); + expect(b.body as string).toContain("1998"); + expect(b.body as string).not.toContain(",200,"); + }); + + it("?format=csv works for tools / activity / productivity endpoints", async () => { + const range = "from=2026-02-01T00:00:00.000Z&to=2026-04-01T00:00:00.000Z"; + for (const [path, filename] of [ + ["tools", "command-center-tools.csv"], + ["activity", "command-center-activity.csv"], + ["productivity", "command-center-productivity.csv"], + ]) { + const res = await request( + app, + "GET", + `/api/command-center/${path}?${range}&projectId=proj-a&format=csv`, + ); + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toContain("text/csv"); + expect(res.headers["content-disposition"]).toBe( + `attachment; filename="${filename}"`, + ); + expect((res.body as string).split("\r\n")[0].length).toBeGreaterThan(0); + } + }); + it("project scoping — /live is scoped per project", async () => { // Add a distinguishing 'in-review' task only to project B. dbB.prepare( diff --git a/packages/dashboard/src/command-center-csv.ts b/packages/dashboard/src/command-center-csv.ts new file mode 100644 index 000000000..e15cf5446 --- /dev/null +++ b/packages/dashboard/src/command-center-csv.ts @@ -0,0 +1,170 @@ +import type { + TokenAnalytics, + ToolAnalytics, + ActivityAnalytics, + ProductivityAnalytics, +} from "@fusion/core"; + +/** + * Command Center CSV serialization (U8). + * + * RFC-4180 serialization of the Phase-A analytics aggregator output so any + * analytics table can be exported as `text/csv`. Pure: these helpers take an + * already-aggregated, already-project-scoped result and emit a string. The + * route handler (`register-command-center-routes.ts`) is responsible for + * resolving the project-scoped store and running the aggregator first, exactly + * like the JSON path — there is no DB access here, so there is no scoping leak + * surface in this module. + * + * Format guarantees (RFC-4180): + * - A header row is always emitted, even for an empty result (header-only CSV, + * never a 204 / empty body). + * - Fields containing a comma, double-quote, CR, or LF are wrapped in double + * quotes; embedded double-quotes are doubled. + * - Records are terminated with CRLF (`\r\n`), consistently. + */ + +const CRLF = "\r\n"; + +/** A scalar cell value. `null`/`undefined` serialize to an empty field. */ +export type CsvCell = string | number | boolean | null | undefined; + +/** A logical table: a fixed header plus zero or more rows of cells. */ +export interface CsvTable { + header: readonly string[]; + rows: readonly (readonly CsvCell[])[]; +} + +/** Quote a single field per RFC-4180 when (and only when) required. */ +function quoteField(value: CsvCell): string { + if (value === null || value === undefined) return ""; + const s = typeof value === "string" ? value : String(value); + if (/[",\r\n]/.test(s)) { + return `"${s.replace(/"/g, '""')}"`; + } + return s; +} + +/** Serialize one record (array of cells) to a CSV line (no terminator). */ +function serializeRecord(record: readonly CsvCell[]): string { + return record.map(quoteField).join(","); +} + +/** + * Serialize a {@link CsvTable} to an RFC-4180 string. Always emits the header + * row; an empty `rows` yields a header-only document. The document is + * CRLF-terminated, including a trailing CRLF after the final record (RFC-4180 + * permits this and it keeps the empty/non-empty cases uniform). + */ +export function serializeCsv(table: CsvTable): string { + const lines: string[] = [serializeRecord(table.header)]; + for (const row of table.rows) { + lines.push(serializeRecord(row)); + } + return lines.join(CRLF) + CRLF; +} + +// --------------------------------------------------------------------------- +// Aggregator → CsvTable converters +// +// Each analytics result is a small nested object; we flatten the +// developer-meaningful fields into a tabular shape. For token analytics with a +// groupBy, each group becomes a row; otherwise the grand total is a single row. +// --------------------------------------------------------------------------- + +/** Token analytics → CSV. One row per group, or a single total row. */ +export function tokenAnalyticsToTable(result: TokenAnalytics): CsvTable { + const header = [ + "key", + "inputTokens", + "outputTokens", + "cachedTokens", + "cacheWriteTokens", + "totalTokens", + "nTasks", + "costUsd", + "costUnavailable", + ]; + + if (result.groupBy && result.groups.length > 0) { + const rows = result.groups.map((g) => [ + g.key, + g.inputTokens, + g.outputTokens, + g.cachedTokens, + g.cacheWriteTokens, + g.totalTokens, + g.nTasks, + g.cost.usd, + g.cost.unavailable, + ]); + return { header, rows }; + } + + const t = result.totals; + return { + header, + rows: [ + [ + "(total)", + t.inputTokens, + t.outputTokens, + t.cachedTokens, + t.cacheWriteTokens, + t.totalTokens, + t.nTasks, + result.cost.usd, + result.cost.unavailable, + ], + ], + }; +} + +/** Tool analytics → CSV. One row per category plus a summary row. */ +export function toolAnalyticsToTable(result: ToolAnalytics): CsvTable { + const header = ["category", "count"]; + const rows: CsvCell[][] = result.byCategory.map((c) => [c.category, c.count]); + // Always include the headline metrics so an empty byCategory is not empty. + rows.push(["(toolCalls)", result.toolCalls]); + rows.push(["(sessions)", result.sessions]); + rows.push(["(interventions)", result.interventions.total]); + rows.push(["(autonomyRatio)", result.autonomyRatio]); + rows.push(["(fullyAutonomous)", result.fullyAutonomous]); + return { header, rows }; +} + +/** Activity analytics → CSV. One row per day plus summary rows. */ +export function activityAnalyticsToTable(result: ActivityAnalytics): CsvTable { + const header = ["day", "messages", "activeNodes", "activeAgents"]; + const rows: CsvCell[][] = result.daily.map((d) => [ + d.day, + d.messages, + d.activeNodes, + d.activeAgents, + ]); + rows.push([ + "(total)", + result.messages, + result.activeNodes, + result.activeAgents, + ]); + rows.push(["(sessions)", result.sessions, "", ""]); + rows.push(["(stickiness)", result.stickiness, "", ""]); + return { header, rows }; +} + +/** Productivity analytics → CSV. One row per language plus summary rows. */ +export function productivityAnalyticsToTable( + result: ProductivityAnalytics, +): CsvTable { + const header = ["metric", "count"]; + const rows: CsvCell[][] = []; + for (const lang of result.byLanguage) { + rows.push([`language:${lang.language}`, lang.count]); + } + rows.push(["modifiedFiles", result.modifiedFiles]); + rows.push(["commits", result.commits]); + rows.push(["pullRequests", result.pullRequests]); + rows.push(["loc", result.loc.value ?? ""]); + return { header, rows }; +} diff --git a/packages/dashboard/src/routes/register-command-center-routes.ts b/packages/dashboard/src/routes/register-command-center-routes.ts index ee9664997..2fd8b313e 100644 --- a/packages/dashboard/src/routes/register-command-center-routes.ts +++ b/packages/dashboard/src/routes/register-command-center-routes.ts @@ -6,8 +6,16 @@ import { composeLiveSnapshot, type TokenGroupBy, } from "@fusion/core"; -import type { Request } from "express"; +import type { Request, Response } from "express"; import { ApiError } from "../api-error.js"; +import { + serializeCsv, + tokenAnalyticsToTable, + toolAnalyticsToTable, + activityAnalyticsToTable, + productivityAnalyticsToTable, + type CsvTable, +} from "../command-center-csv.js"; import type { ApiRouteRegistrar } from "./types.js"; /** @@ -25,9 +33,11 @@ import type { ApiRouteRegistrar } from "./types.js"; * No analytics endpoint, including `/live`, is unauthenticated; an * unauthenticated request is rejected with 401 by the server-level auth * middleware before reaching these handlers. - * - Every endpoint (JSON and `/live`) resolves the database through + * - Every endpoint (JSON, CSV, and `/live`) resolves the database through * `getScopedStore(req)` before aggregating, so a project-A caller can never - * read project-B data. + * read project-B data. The `?format=csv` branch (U8) serializes the SAME + * already-scoped aggregator output, so the export path has no separate + * scoping surface. * * Robustness: * - Missing or invalid `from`/`to`/`groupBy` query params fall back to a @@ -93,6 +103,23 @@ export function resolveGroupBy(query: Request["query"]): TokenGroupBy | undefine return raw !== undefined && VALID_GROUP_BY.has(raw) ? (raw as TokenGroupBy) : undefined; } +/** True when the caller asked for CSV via `?format=csv` (case-insensitive). */ +export function wantsCsv(query: Request["query"]): boolean { + const raw = typeof query.format === "string" ? query.format : undefined; + return raw !== undefined && raw.toLowerCase() === "csv"; +} + +/** + * Stream a {@link CsvTable} as an `attachment` download. Sets the RFC-4180 + * `text/csv` content-type (charset utf-8) and a `Content-Disposition` filename. + * Always sends a body — a header-only CSV for an empty result, never a 204. + */ +function sendCsv(res: Response, filename: string, table: CsvTable): void { + res.setHeader("Content-Type", "text/csv; charset=utf-8"); + res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); + res.send(serializeCsv(table)); +} + export const registerCommandCenterRoutes: ApiRouteRegistrar = (ctx) => { const { router, getScopedStore, rethrowAsApiError } = ctx; @@ -112,6 +139,10 @@ export const registerCommandCenterRoutes: ApiRouteRegistrar = (ctx) => { groupBy, now: Date.now(), }); + if (wantsCsv(req.query)) { + sendCsv(res, "command-center-tokens.csv", tokenAnalyticsToTable(result)); + return; + } res.json(result); } catch (err: unknown) { if (err instanceof ApiError) throw err; @@ -131,6 +162,10 @@ export const registerCommandCenterRoutes: ApiRouteRegistrar = (ctx) => { from: range.from, to: range.to, }); + if (wantsCsv(req.query)) { + sendCsv(res, "command-center-tools.csv", toolAnalyticsToTable(result)); + return; + } res.json(result); } catch (err: unknown) { if (err instanceof ApiError) throw err; @@ -150,6 +185,10 @@ export const registerCommandCenterRoutes: ApiRouteRegistrar = (ctx) => { from: range.from, to: range.to, }); + if (wantsCsv(req.query)) { + sendCsv(res, "command-center-activity.csv", activityAnalyticsToTable(result)); + return; + } res.json(result); } catch (err: unknown) { if (err instanceof ApiError) throw err; @@ -169,6 +208,14 @@ export const registerCommandCenterRoutes: ApiRouteRegistrar = (ctx) => { from: range.from, to: range.to, }); + if (wantsCsv(req.query)) { + sendCsv( + res, + "command-center-productivity.csv", + productivityAnalyticsToTable(result), + ); + return; + } res.json(result); } catch (err: unknown) { if (err instanceof ApiError) throw err; From f45dde22e5db0a45ea35b8bef4b43749f6328d76 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 15 Jun 2026 20:08:38 -0700 Subject: [PATCH 13/21] =?UTF-8?q?feat(triage):=20U12=20=E2=80=94=20triage?= =?UTF-8?q?=20trait=20for=20issues=20and=20pull=20requests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registers a built-in 'triage' onEnter trait (via TraitRegistry + registerTraitHookImpl) that classifies and decomposes incoming signals/issues into todo tasks, and routes inbound PRs (dependency-bump vs feature) without minting issues, with a Fusion-opened-PR self-loop guard. Reuses subtask-breakdown via a new decomposeForTriage seam. Follow-up: auto-firing the built-in async onEnter from store.moveTaskInternal (currently fires plugin: hooks only) — invoked via the registry seam meanwhile. --- .../src/__tests__/triage-trait.test.ts | 414 ++++++++++++++ packages/dashboard/src/subtask-breakdown.ts | 52 ++ packages/dashboard/src/triage-trait.ts | 505 ++++++++++++++++++ 3 files changed, 971 insertions(+) create mode 100644 packages/dashboard/src/__tests__/triage-trait.test.ts create mode 100644 packages/dashboard/src/triage-trait.ts diff --git a/packages/dashboard/src/__tests__/triage-trait.test.ts b/packages/dashboard/src/__tests__/triage-trait.test.ts new file mode 100644 index 000000000..7e760cf6a --- /dev/null +++ b/packages/dashboard/src/__tests__/triage-trait.test.ts @@ -0,0 +1,414 @@ +import { afterEach, describe, expect, it } from "vitest"; +import type { PrEntity, Task, TaskStore } from "@fusion/core"; +import { + getTraitRegistry, + __resetTraitRegistryForTests, +} from "@fusion/core"; +import { + TRIAGE_TRAIT_ID, + TRIAGE_DEFAULT_ROUTE_COLUMN, + TRIAGE_REVIEW_COLUMN, + classifyTriageItem, + resolveTriageSubject, + registerTriageTrait, + runTriageOnEnter, + __resetTriageTraitForTests, +} from "../triage-trait.js"; +import type { SubtaskItem } from "../subtask-breakdown.js"; + +// ── Fake store ────────────────────────────────────────────────────────────── + +interface FakeStore extends TaskStore { + _tasks: Task[]; + _prEntities: PrEntity[]; +} + +function makeStore(prEntities: PrEntity[] = []): FakeStore { + const tasks: Task[] = []; + let counter = 0; + const store = { + async createTask(input: Parameters[0]) { + const task = { + id: `FN-${++counter}`, + title: input.title, + description: input.description, + column: input.column, + priority: input.priority, + source: input.source, + } as unknown as Task; + tasks.push(task); + return task; + }, + async updateTask( + id: string, + updates: Parameters[1], + ) { + const task = tasks.find((t) => t.id === id); + if (!task) throw new Error(`task ${id} not found`); + if (updates.priority !== undefined && updates.priority !== null) { + task.priority = updates.priority; + } + const patch = (updates as { sourceMetadataPatch?: Record }) + .sourceMetadataPatch; + if (patch) { + task.source = { + sourceType: task.source?.sourceType ?? "api", + ...task.source, + sourceMetadata: { ...(task.source?.sourceMetadata ?? {}), ...patch }, + }; + } + return task; + }, + async moveTask(id: string, toColumn: string) { + const task = tasks.find((t) => t.id === id); + if (!task) throw new Error(`task ${id} not found`); + (task as { column: string }).column = toColumn; + return task; + }, + getPrEntity(id: string) { + return prEntities.find((p) => p.id === id) ?? null; + }, + getActivePrEntityBySource(sourceType: string, sourceId: string) { + return ( + prEntities.find( + (p) => + p.sourceType === sourceType && + p.sourceId === sourceId && + p.state !== "merged" && + p.state !== "closed", + ) ?? null + ); + }, + _tasks: tasks, + _prEntities: prEntities, + }; + return store as unknown as FakeStore; +} + +function makeTask(partial: Partial & { id: string; description: string }): Task { + return { + column: "triage", + ...partial, + } as unknown as Task; +} + +const decomposeTo = + (items: Array>) => + async (_d: string): Promise => + items.map((it, i) => ({ + id: it.id ?? `subtask-${i + 1}`, + title: it.title ?? `Sub ${i + 1}`, + description: it.description ?? "", + suggestedSize: it.suggestedSize ?? "M", + priority: it.priority, + dependsOn: it.dependsOn ?? [], + })); + +afterEach(() => { + __resetTriageTraitForTests(); + __resetTraitRegistryForTests(); +}); + +// ── classification ────────────────────────────────────────────────────────── + +describe("classifyTriageItem", () => { + it("classifies a dependency bump PR", () => { + const c = classifyTriageItem({ + kind: "pull_request", + title: "Bump lodash from 4.17.20 to 4.17.21", + prAuthor: "dependabot[bot]", + }); + expect(c.dependencyBump).toBe(true); + expect(c.area).toBe("dependency"); + expect(c.labels).toContain("automated"); + }); + + it("classifies a feature PR as not a dependency bump", () => { + const c = classifyTriageItem({ + kind: "pull_request", + title: "Add dark mode support", + prAuthor: "contributor", + }); + expect(c.dependencyBump).toBe(false); + expect(c.area).toBe("feature"); + }); + + it("maps critical severity to urgent priority", () => { + const c = classifyTriageItem({ kind: "signal", title: "DB down", severity: "critical" }); + expect(c.priority).toBe("urgent"); + expect(c.labels).toContain("signal"); + }); +}); + +// ── PR-vs-issue + self-loop guard ──────────────────────────────────────────── + +describe("resolveTriageSubject", () => { + it("treats a signal-sourced task as a triageable signal", () => { + const task = makeTask({ + id: "FN-1", + description: "err", + source: { sourceType: "api", sourceMetadata: { signalSource: "sentry" } }, + }); + const subj = resolveTriageSubject(task); + expect(subj.kind).toBe("signal"); + expect(subj.triageable).toBe(true); + }); + + it("treats an inbound PR with no Fusion entity as triageable", () => { + const task = makeTask({ + id: "FN-2", + description: "pr", + source: { sourceType: "api", sourceMetadata: { resourceType: "pr", prInbound: true } }, + }); + const subj = resolveTriageSubject(task, makeStore()); + expect(subj.kind).toBe("pull_request"); + expect(subj.triageable).toBe(true); + }); + + it("does NOT triage a PR Fusion itself opened (owned by a task PrEntity)", () => { + const store = makeStore([ + { + id: "PR-1", + sourceType: "task", + sourceId: "FN-3", + repo: "o/r", + headBranch: "feat", + state: "open", + } as unknown as PrEntity, + ]); + const task = makeTask({ + id: "FN-3", + description: "pr", + source: { sourceType: "api", sourceMetadata: { resourceType: "pr", prInbound: true } }, + }); + const subj = resolveTriageSubject(task, store); + expect(subj.kind).toBe("pull_request"); + expect(subj.triageable).toBe(false); + expect(subj.skipReason).toContain("self-loop"); + }); + + it("does NOT triage a PR not marked inbound", () => { + const task = makeTask({ + id: "FN-4", + description: "pr", + source: { sourceType: "api", sourceMetadata: { resourceType: "pr" } }, + }); + const subj = resolveTriageSubject(task, makeStore()); + expect(subj.triageable).toBe(false); + }); +}); + +// ── runTriageOnEnter scenarios ─────────────────────────────────────────────── + +describe("runTriageOnEnter", () => { + it("decomposes a signal task into N todo tasks linked to the signal", async () => { + const store = makeStore(); + const signal = makeTask({ + id: "FN-10", + title: "Outage report", + description: "Investigate the production outage and fix the root cause", + source: { sourceType: "api", sourceMetadata: { signalSource: "sentry", signalSeverity: "error" } }, + }); + store._tasks.push(signal); + + const outcome = await runTriageOnEnter(signal, { + store, + decompose: decomposeTo([{ title: "Diagnose" }, { title: "Fix" }, { title: "Verify" }]), + }); + + expect(outcome.kind).toBe("decomposed"); + if (outcome.kind !== "decomposed") throw new Error("unreachable"); + expect(outcome.childTaskIds).toHaveLength(3); + expect(outcome.routedColumn).toBe(TRIAGE_DEFAULT_ROUTE_COLUMN); + // children created in todo, linked back to the signal + const children = store._tasks.filter((t) => outcome.childTaskIds.includes(t.id)); + for (const c of children) { + expect(c.column).toBe("todo"); + expect(c.source?.sourceParentTaskId).toBe("FN-10"); + expect((c.source?.sourceMetadata as Record).triageParentTaskId).toBe("FN-10"); + } + // signal stamped as triaged + expect((signal.source?.sourceMetadata as Record).triageProcessedAt).toBeTruthy(); + }); + + it("passes a too-small signal through as a SINGLE task (not zero)", async () => { + const store = makeStore(); + const signal = makeTask({ + id: "FN-11", + title: "Tiny", + description: "trivial one-liner", + source: { sourceType: "api", sourceMetadata: { signalSource: "webhook" } }, + }); + store._tasks.push(signal); + + const outcome = await runTriageOnEnter(signal, { + store, + decompose: decomposeTo([{ title: "Only one" }]), + }); + + expect(outcome.kind).toBe("passthrough"); + if (outcome.kind !== "passthrough") throw new Error("unreachable"); + expect(outcome.taskId).toBe("FN-11"); + expect(outcome.routedColumn).toBe("todo"); + expect(signal.column).toBe("todo"); + // no children minted + expect(store._tasks).toHaveLength(1); + }); + + it("routes a dependency-bump PR to review (no issue minted)", async () => { + const store = makeStore(); + const pr = makeTask({ + id: "FN-12", + title: "Bump express from 4.18.0 to 4.18.2", + description: "dependabot bump", + source: { + sourceType: "api", + sourceMetadata: { resourceType: "pr", prInbound: true, prAuthor: "dependabot[bot]" }, + }, + }); + store._tasks.push(pr); + + const outcome = await runTriageOnEnter(pr, { store }); + + expect(outcome.kind).toBe("pr-review"); + expect(pr.column).toBe(TRIAGE_REVIEW_COLUMN); + // exactly one task (the PR itself); no follow-up, no issue + expect(store._tasks).toHaveLength(1); + }); + + it("opens a follow-up task for a feature PR linked to its PR entity", async () => { + const store = makeStore(); + const pr = makeTask({ + id: "FN-13", + title: "Add new export format", + description: "feature PR from external contributor", + source: { + sourceType: "api", + sourceMetadata: { resourceType: "pr", prInbound: true, prEntityId: "PR-99" }, + }, + }); + store._tasks.push(pr); + + const outcome = await runTriageOnEnter(pr, { store }); + + expect(outcome.kind).toBe("pr-follow-up"); + if (outcome.kind !== "pr-follow-up") throw new Error("unreachable"); + expect(pr.column).toBe(TRIAGE_REVIEW_COLUMN); + const followUp = store._tasks.find((t) => t.id === outcome.followUpTaskId); + expect(followUp).toBeDefined(); + expect(followUp!.source?.sourceParentTaskId).toBe("FN-13"); + expect((followUp!.source?.sourceMetadata as Record).prEntityId).toBe("PR-99"); + }); + + it("does NOT re-triage a PR Fusion itself opened (no self-loop)", async () => { + const store = makeStore([ + { + id: "PR-1", + sourceType: "task", + sourceId: "FN-14", + repo: "o/r", + headBranch: "feat", + state: "open", + } as unknown as PrEntity, + ]); + const pr = makeTask({ + id: "FN-14", + title: "feat: my own change", + description: "PR Fusion opened", + column: "triage", + source: { sourceType: "api", sourceMetadata: { resourceType: "pr", prInbound: true } }, + }); + store._tasks.push(pr); + + const outcome = await runTriageOnEnter(pr, { store }); + + expect(outcome.kind).toBe("skipped"); + if (outcome.kind !== "skipped") throw new Error("unreachable"); + expect(outcome.reason).toContain("self-loop"); + // no follow-up created, PR not moved out of triage + expect(store._tasks).toHaveLength(1); + expect(pr.column).toBe("triage"); + }); + + it("PARKS the item in triage on classifier/decompose failure (does not drop it)", async () => { + const store = makeStore(); + const signal = makeTask({ + id: "FN-15", + title: "Boom", + description: "will fail to decompose", + source: { sourceType: "api", sourceMetadata: { signalSource: "sentry" } }, + }); + store._tasks.push(signal); + + const outcome = await runTriageOnEnter(signal, { + store, + decompose: async () => { + throw new Error("classifier exploded"); + }, + }); + + expect(outcome.kind).toBe("parked"); + if (outcome.kind !== "parked") throw new Error("unreachable"); + expect(outcome.reason).toContain("classifier exploded"); + // still in triage, marker recorded, not dropped + expect(signal.column).toBe("triage"); + expect(store._tasks).toHaveLength(1); + expect((signal.source?.sourceMetadata as Record).triageError).toContain( + "classifier exploded", + ); + }); + + it("is idempotent: an already-triaged task is a no-op skip", async () => { + const store = makeStore(); + const signal = makeTask({ + id: "FN-16", + title: "done already", + description: "x", + source: { + sourceType: "api", + sourceMetadata: { signalSource: "sentry", triageProcessedAt: "2026-01-01T00:00:00Z" }, + }, + }); + store._tasks.push(signal); + + const outcome = await runTriageOnEnter(signal, { store, decompose: decomposeTo([{}, {}]) }); + expect(outcome.kind).toBe("skipped"); + expect(store._tasks).toHaveLength(1); + }); +}); + +// ── registry wiring ────────────────────────────────────────────────────────── + +describe("registerTriageTrait", () => { + it("registers a triage trait with an onEnter hook resolvable through the registry", async () => { + __resetTraitRegistryForTests(); + __resetTriageTraitForTests(); + registerTriageTrait(); + + const registry = getTraitRegistry(); + const def = registry.getTrait(TRIAGE_TRAIT_ID); + expect(def).toBeDefined(); + expect(def!.builtin).toBe(true); + expect(def!.hooks?.onEnter).toBe(true); + + const resolved = registry.resolveTraitHook(TRIAGE_TRAIT_ID, "onEnter"); + expect(resolved.warning).toBeUndefined(); + expect(typeof resolved.impl).toBe("function"); + + // The resolved impl runs the triage pass against the ctx-supplied deps. + const store = makeStore(); + const signal = makeTask({ + id: "FN-20", + title: "via hook", + description: "decompose me", + source: { sourceType: "api", sourceMetadata: { signalSource: "sentry" } }, + }); + store._tasks.push(signal); + + const result = await resolved.impl!({ + task: signal, + deps: { store, decompose: decomposeTo([{}, {}, {}]) }, + }); + expect((result as { kind: string }).kind).toBe("decomposed"); + }); +}); diff --git a/packages/dashboard/src/subtask-breakdown.ts b/packages/dashboard/src/subtask-breakdown.ts index d42c1c00c..6a90fa420 100644 --- a/packages/dashboard/src/subtask-breakdown.ts +++ b/packages/dashboard/src/subtask-breakdown.ts @@ -381,6 +381,58 @@ export class SubtaskStreamManager extends EventEmitter { export const subtaskStreamManager = new SubtaskStreamManager(); +/** + * U12 reuse seam: a one-shot decomposition for the triage trait. Reuses the same + * agent (`createFnAgent`), system prompt, JSON parsing, and deterministic + * fallback as the streaming subtask-breakdown flow, but returns the parsed + * {@link SubtaskItem}[] directly (no session/stream machinery). When the engine + * agent is unavailable, falls back to the deterministic 3-item breakdown so + * triage always yields ≥1 item (a too-small item is then routed as a single + * passthrough by the caller). + * + * Throws on a genuine generation/parse failure so the triage caller can PARK the + * item in triage with a diagnostic rather than silently dropping it. + */ +export async function decomposeForTriage( + description: string, + rootDir?: string, + promptOverrides?: PromptOverrideMap, +): Promise { + await ensureEngineReady(); + const cwd = rootDir ?? process.cwd(); + const systemPrompt = resolvePrompt("subtask-breakdown-system", promptOverrides) || SUBTASK_BREAKDOWN_PROMPT; + + if (!createFnAgent) { + return generateFallbackSubtasks(description); + } + + const agent: SubtaskAgent = await createFnAgent({ cwd, systemPrompt, tools: "readonly" }); + try { + await agent.session.prompt(description); + const messages = agent.session.state.messages as Array<{ + role: string; + content?: string | Array<{ type: string; text: string }>; + }>; + const lastAssistant = messages.filter((m) => m.role === "assistant").pop(); + let responseText = ""; + if (typeof lastAssistant?.content === "string") { + responseText = lastAssistant.content; + } else if (Array.isArray(lastAssistant?.content)) { + responseText = lastAssistant.content + .filter((item): item is { type: "text"; text: string } => item.type === "text") + .map((item) => item.text) + .join(""); + } + return parseSubtasks(responseText); + } finally { + try { + agent.session.dispose?.(); + } catch { + // ignore cleanup errors + } + } +} + export async function createSubtaskSession( initialDescription: string, _store?: TaskStore, diff --git a/packages/dashboard/src/triage-trait.ts b/packages/dashboard/src/triage-trait.ts new file mode 100644 index 000000000..c8d1bf873 --- /dev/null +++ b/packages/dashboard/src/triage-trait.ts @@ -0,0 +1,505 @@ +import type { + Task, + TaskCreateInput, + TaskPriority, + TaskStore, + TraitDefinition, +} from "@fusion/core"; +import { + getTraitRegistry, + registerTraitHookImpl, + type PromptOverrideMap, +} from "@fusion/core"; +import { createSessionDiagnostics } from "./ai-session-diagnostics.js"; +import { decomposeForTriage, type SubtaskItem } from "./subtask-breakdown.js"; + +/** + * U12 — Triage stage (auto-classify + decompose, for issues AND pull requests). + * + * Triage is expressed as a Trait with an `onEnter` hook (KTD7 / R8 / R14), NOT a + * hardcoded executor branch. A column carrying the `triage` trait runs a + * classify + decompose pass when a card enters it: + * + * - Signals / issues: classified (priority / area / labels), then decomposed + * into N `todo` child tasks linked back to the originating signal task. A + * signal too small to decompose passes through as a single task (routed to + * `todo`), never zero. + * + * - Inbound pull requests (external contributors, dependabot, …): classified + * (dependency-bump vs feature) and either routed for review (labeled, moved + * to the review/`in-review` column) or used to open a follow-up `todo` task + * linked to the PR entity. PR triage reuses the existing `pull_requests` / + * PR-entity model — it never mints issues for PRs. + * + * - Self-loop guard: a PR Fusion itself opened (an inbound==false PR, or one + * already owned by a non-terminal Fusion `PrEntity` for a task source) is + * NOT re-triaged. + * + * - Classifier failure parks the item in triage with a diagnostic — it is + * never dropped. + * + * The trait DEFINITION lives in core's vocabulary-free registry slot (registered + * here as a `builtin: true` def so plugins cannot override it). The IMPLEMENTATION + * lives in dashboard because it reuses `subtask-breakdown` (engine agents) — wired + * through the core→engine DI seam (`registerTraitHookImpl`), exactly like the + * default-workflow hooks. Core stays engine-free. + */ + +const diagnostics = createSessionDiagnostics("triage-trait"); + +/** Registry id of the triage trait. */ +export const TRIAGE_TRAIT_ID = "triage"; + +/** Column a triaged item is routed TO once decomposed/classified. */ +export const TRIAGE_DEFAULT_ROUTE_COLUMN = "todo"; +/** Column an inbound PR routed for review lands in. */ +export const TRIAGE_REVIEW_COLUMN = "in-review"; + +/** Metadata key marking a task as a triage product (a decomposed child). */ +const TRIAGE_PARENT_META_KEY = "triageParentTaskId"; +/** Metadata key recording that a task has been triaged (idempotency). */ +const TRIAGE_DONE_META_KEY = "triageProcessedAt"; +/** Metadata key on a PR-origin task carrying its PR entity id. */ +const TRIAGE_PR_ENTITY_META_KEY = "prEntityId"; + +/** + * The triage trait definition. Registered as a built-in (so a plugin cannot + * override the id) but intentionally NOT part of the 14-entry vocabulary table — + * it is a behavior trait shipped by U12, resolved through the same registry. + */ +export const TRIAGE_TRAIT_DEFINITION: TraitDefinition = { + id: TRIAGE_TRAIT_ID, + name: "Triage", + description: + "Auto-classify and decompose incoming signals/issues and inbound pull requests, then route to the board.", + builtin: true, + flags: { intake: true }, + hooks: { onEnter: true }, + configSchema: { + fields: [ + { key: "routeColumn", type: "string", description: "Column to route triaged items to (default 'todo')" }, + { key: "reviewColumn", type: "string", description: "Column inbound PRs routed for review land in" }, + { key: "maxSubtasks", type: "number", description: "Cap on decomposed child tasks" }, + ], + }, +}; + +// ── Classification (pure, deterministic) ──────────────────────────────────── + +export type TriageItemKind = "signal" | "issue" | "pull_request"; + +export interface TriageClassification { + priority: TaskPriority; + /** Coarse area bucket inferred from the title/body. */ + area: "bug" | "feature" | "dependency" | "docs" | "infra" | "chore" | "unknown"; + /** Suggested labels (deduped). */ + labels: string[]; + /** PR-only: a dependency bump (dependabot / renovate / `bump`). */ + dependencyBump: boolean; +} + +const DEP_BUMP_RE = /\b(bump|dependabot|renovate|update .* from .* to|upgrade dependenc)/i; +const BUG_RE = /\b(bug|error|crash|exception|fix|regress|fail|incident|outage|broken)\b/i; +const DOCS_RE = /\b(docs?|documentation|readme|typo)\b/i; +const INFRA_RE = /\b(ci|pipeline|deploy|infra|docker|k8s|kubernetes|build)\b/i; +const FEATURE_RE = /\b(feature|add|implement|support|enhanc)\b/i; + +function inferPriority(severity: unknown, text: string): TaskPriority { + const sev = typeof severity === "string" ? severity.toLowerCase() : ""; + if (sev === "critical") return "urgent"; + if (sev === "error") return "high"; + if (/\b(urgent|critical|sev-?1|p0)\b/i.test(text)) return "urgent"; + if (/\b(high|important|sev-?2|p1)\b/i.test(text)) return "high"; + if (sev === "warning") return "normal"; + return "normal"; +} + +/** Classify a triage item purely from its title/body + provenance. */ +export function classifyTriageItem(params: { + kind: TriageItemKind; + title: string; + body?: string; + severity?: unknown; + /** PR author login, when known (dependabot[bot], renovate[bot], …). */ + prAuthor?: string; +}): TriageClassification { + const { kind, title, body, severity, prAuthor } = params; + const text = `${title}\n${body ?? ""}`; + const labels: string[] = []; + + const author = (prAuthor ?? "").toLowerCase(); + const dependencyBump = + kind === "pull_request" && + (DEP_BUMP_RE.test(text) || author.includes("dependabot") || author.includes("renovate")); + + let area: TriageClassification["area"] = "unknown"; + if (dependencyBump) area = "dependency"; + else if (BUG_RE.test(text)) area = "bug"; + else if (DOCS_RE.test(text)) area = "docs"; + else if (INFRA_RE.test(text)) area = "infra"; + else if (FEATURE_RE.test(text)) area = "feature"; + else if (kind === "signal") area = "bug"; + + if (area !== "unknown") labels.push(area); + if (dependencyBump) labels.push("automated"); + if (kind === "signal") labels.push("signal"); + + return { + priority: inferPriority(severity, text), + area, + labels: [...new Set(labels)], + dependencyBump, + }; +} + +// ── PR-vs-issue + self-loop guard ─────────────────────────────────────────── + +/** + * Decide what kind of triage item a task represents, and (for PRs) whether it is + * an INBOUND PR (external contribution that warrants triage) or a Fusion-opened + * PR (which must NOT be re-triaged — no self-loop). + */ +export interface TriageSubject { + kind: TriageItemKind; + /** True only for PRs that should be triaged (inbound external PRs). */ + triageable: boolean; + /** Reason a PR was skipped (for diagnostics). */ + skipReason?: string; + severity?: unknown; + prAuthor?: string; + prEntityId?: string; +} + +/** + * Classify the task's subject from its source provenance. A PR-origin task is + * marked inbound only when its metadata says so AND no non-terminal Fusion + * PrEntity already owns it (a Fusion-opened PR is owned by a `task`-sourced + * entity → self-loop, skip). + */ +export function resolveTriageSubject(task: Task, store?: TaskStore): TriageSubject { + const meta = (task.source?.sourceMetadata ?? {}) as Record; + const signalSource = meta.signalSource; + const isPr = + meta.triageItemKind === "pull_request" || + meta.resourceType === "pr" || + typeof meta.prNumber === "number" || + typeof meta[TRIAGE_PR_ENTITY_META_KEY] === "string"; + + if (isPr) { + const inboundFlag = meta.prInbound === true || meta.inbound === true; + // Self-loop guard: a PR Fusion itself opened is owned by a non-terminal + // PrEntity whose sourceType is "task" (the task that produced the branch). + let fusionOwned = false; + const prEntityId = typeof meta[TRIAGE_PR_ENTITY_META_KEY] === "string" + ? (meta[TRIAGE_PR_ENTITY_META_KEY] as string) + : undefined; + if (store) { + try { + const entity = prEntityId + ? store.getPrEntity(prEntityId) + : store.getActivePrEntityBySource("task", task.id); + if (entity && entity.sourceType === "task" && entity.state !== "closed" && entity.state !== "merged") { + fusionOwned = true; + } + } catch { + // Read failure → fall back to the inbound flag only. + } + } + const triageable = inboundFlag && !fusionOwned; + return { + kind: "pull_request", + triageable, + skipReason: triageable + ? undefined + : fusionOwned + ? "fusion-opened-pr (no self-loop)" + : "not-marked-inbound", + severity: meta.signalSeverity, + prAuthor: typeof meta.prAuthor === "string" ? meta.prAuthor : undefined, + prEntityId, + }; + } + + return { + kind: signalSource ? "signal" : "issue", + triageable: true, + severity: meta.signalSeverity, + }; +} + +// ── Triage execution ──────────────────────────────────────────────────────── + +export interface TriageDeps { + store: TaskStore; + /** Working directory for the decomposition agent. */ + rootDir?: string; + promptOverrides?: PromptOverrideMap; + /** Override the decomposer (tests). Resolves to subtask items or throws. */ + decompose?: (description: string) => Promise; +} + +export type TriageOutcome = + | { kind: "decomposed"; childTaskIds: string[]; routedColumn: string } + | { kind: "passthrough"; taskId: string; routedColumn: string } + | { kind: "pr-review"; taskId: string; routedColumn: string } + | { kind: "pr-follow-up"; followUpTaskId: string; routedColumn: string } + | { kind: "skipped"; reason: string } + | { kind: "parked"; reason: string }; + +function alreadyTriaged(task: Task): boolean { + const meta = (task.source?.sourceMetadata ?? {}) as Record; + return typeof meta[TRIAGE_DONE_META_KEY] === "string"; +} + +/** Mark the originating task as triaged + apply classification labels/priority. + * Metadata is merged via `sourceMetadataPatch` (the store's merge seam), not by + * rebuilding `source`. */ +async function stampTriaged( + store: TaskStore, + task: Task, + classification: TriageClassification, + extra: Record = {}, +): Promise { + await store.updateTask(task.id, { + priority: classification.priority, + sourceMetadataPatch: { + [TRIAGE_DONE_META_KEY]: new Date().toISOString(), + triageArea: classification.area, + triageLabels: classification.labels, + ...extra, + }, + }); +} + +/** + * Run the triage onEnter pass for a task. Idempotent: an already-triaged task is + * a no-op skip. Routing/decomposition/PR handling per the plan's scenarios. + */ +export async function runTriageOnEnter(task: Task, deps: TriageDeps): Promise { + const { store } = deps; + if (alreadyTriaged(task)) { + return { kind: "skipped", reason: "already-triaged" }; + } + + const subject = resolveTriageSubject(task, store); + + // ── PR path ─────────────────────────────────────────────────────────────── + if (subject.kind === "pull_request") { + if (!subject.triageable) { + // Self-loop / non-inbound PR: do not re-triage. Mark processed so a later + // re-entry is a clean no-op, but take no decomposition action. + try { + await stampTriaged( + store, + task, + classifyTriageItem({ kind: "pull_request", title: task.title ?? task.description, body: task.description }), + { triageSkipped: subject.skipReason }, + ); + } catch (err) { + diagnostics.errorFromException("Failed to stamp skipped PR", err, { taskId: task.id }); + } + return { kind: "skipped", reason: subject.skipReason ?? "not-triageable" }; + } + + let classification: TriageClassification; + try { + classification = classifyTriageItem({ + kind: "pull_request", + title: task.title ?? task.description, + body: task.description, + severity: subject.severity, + prAuthor: subject.prAuthor, + }); + } catch (err) { + return parkInTriage(store, task, err, "pr-classify"); + } + + try { + if (classification.dependencyBump) { + // Dependency bumps are mechanical → route straight to review. + await stampTriaged(store, task, classification, { triagePrRoute: "review" }); + await store.moveTask(task.id, TRIAGE_REVIEW_COLUMN); + return { kind: "pr-review", taskId: task.id, routedColumn: TRIAGE_REVIEW_COLUMN }; + } + // Feature/other inbound PR → open a follow-up review task linked to the PR + // entity (we route the PR card to review and create the follow-up todo). + await stampTriaged(store, task, classification, { triagePrRoute: "follow-up" }); + const followUp = await store.createTask( + buildFollowUpTaskInput(task, classification, subject.prEntityId), + ); + await store.moveTask(task.id, TRIAGE_REVIEW_COLUMN); + return { kind: "pr-follow-up", followUpTaskId: followUp.id, routedColumn: TRIAGE_REVIEW_COLUMN }; + } catch (err) { + return parkInTriage(store, task, err, "pr-route"); + } + } + + // ── Signal / issue path ───────────────────────────────────────────────────── + let classification: TriageClassification; + try { + classification = classifyTriageItem({ + kind: subject.kind, + title: task.title ?? task.description, + body: task.description, + severity: subject.severity, + }); + } catch (err) { + return parkInTriage(store, task, err, "classify"); + } + + let subtasks: SubtaskItem[]; + try { + const decompose = deps.decompose ?? ((d: string) => decomposeForTriage(d, deps.rootDir, deps.promptOverrides)); + subtasks = await decompose(task.description); + } catch (err) { + return parkInTriage(store, task, err, "decompose"); + } + + const routeColumn = TRIAGE_DEFAULT_ROUTE_COLUMN; + + // Too small to decompose → pass through as a single task (NOT zero). + if (subtasks.length <= 1) { + try { + await stampTriaged(store, task, classification, { triageDecomposed: false }); + await store.moveTask(task.id, routeColumn); + } catch (err) { + return parkInTriage(store, task, err, "passthrough"); + } + return { kind: "passthrough", taskId: task.id, routedColumn: routeColumn }; + } + + // Decompose into N child todo tasks linked back to the signal. + try { + const childIds: string[] = []; + for (const sub of subtasks) { + const child = await store.createTask( + buildChildTaskInput(task, sub, classification, routeColumn), + ); + childIds.push(child.id); + } + await stampTriaged(store, task, classification, { + triageDecomposed: true, + triageChildTaskIds: childIds, + }); + return { kind: "decomposed", childTaskIds: childIds, routedColumn: routeColumn }; + } catch (err) { + return parkInTriage(store, task, err, "create-children"); + } +} + +function buildChildTaskInput( + parent: Task, + sub: SubtaskItem, + classification: TriageClassification, + routeColumn: string, +): TaskCreateInput { + const title = sub.title?.trim() || "Triaged subtask"; + const description = sub.description?.trim() + ? `${title}\n\n${sub.description.trim()}` + : `${title}\n\nDerived from triage of: ${parent.title ?? parent.id}`; + return { + title, + description, + column: routeColumn as TaskCreateInput["column"], + priority: sub.priority ?? classification.priority, + source: { + sourceType: "automation", + sourceParentTaskId: parent.id, + sourceMetadata: { + [TRIAGE_PARENT_META_KEY]: parent.id, + triageArea: classification.area, + triageLabels: classification.labels, + }, + }, + }; +} + +function buildFollowUpTaskInput( + prTask: Task, + classification: TriageClassification, + prEntityId?: string, +): TaskCreateInput { + const title = `Review inbound PR: ${prTask.title ?? prTask.id}`; + return { + title, + description: `${title}\n\nClassified as ${classification.area}. Follow-up to triaged inbound pull request.`, + column: TRIAGE_DEFAULT_ROUTE_COLUMN as TaskCreateInput["column"], + priority: classification.priority, + source: { + sourceType: "automation", + sourceParentTaskId: prTask.id, + sourceMetadata: { + [TRIAGE_PARENT_META_KEY]: prTask.id, + triageArea: classification.area, + triageLabels: classification.labels, + ...(prEntityId ? { [TRIAGE_PR_ENTITY_META_KEY]: prEntityId } : {}), + }, + }, + }; +} + +/** + * Classifier/decompose failure → PARK the item in triage with a diagnostic. The + * task stays in `triage` (not dropped, not routed); a marker records the failure + * so the surface can show it and a retry can re-run. + */ +async function parkInTriage( + store: TaskStore, + task: Task, + err: unknown, + phase: string, +): Promise { + const message = err instanceof Error ? err.message : String(err); + diagnostics.errorFromException(`Triage ${phase} failed; parking in triage`, err, { + taskId: task.id, + }); + try { + await store.updateTask(task.id, { + sourceMetadataPatch: { + triageError: message, + triageErrorPhase: phase, + triageErrorAt: new Date().toISOString(), + }, + }); + } catch (writeErr) { + diagnostics.errorFromException("Failed to record triage park marker", writeErr, { + taskId: task.id, + }); + } + return { kind: "parked", reason: message }; +} + +// ── Registration (DI seam) ────────────────────────────────────────────────── + +let registered = false; + +/** + * Register the triage trait definition + onEnter hook implementation into the + * shared trait registry. Idempotent. The hook impl resolves `runTriageOnEnter` + * against the provided store/deps factory — the engine-adjacent caller supplies + * the live deps in the hook context (mirroring the default-workflow hook DI). + */ +export function registerTriageTrait(): void { + if (registered) return; + const registry = getTraitRegistry(); + if (!registry.has(TRIAGE_TRAIT_ID)) { + registry.register(TRIAGE_TRAIT_DEFINITION); + } + registerTraitHookImpl( + TRIAGE_TRAIT_ID, + "onEnter", + (...args: unknown[]) => { + const ctx = args[0] as + | { task?: Task; deps?: TriageDeps } + | undefined; + if (!ctx?.task || !ctx.deps) return undefined; + return runTriageOnEnter(ctx.task, ctx.deps); + }, + ); + registered = true; +} + +/** Test-only: reset the registration latch. */ +export function __resetTriageTraitForTests(): void { + registered = false; +} From 5bc8901f067b6205fd8795faf3b2b9e8f2766d79 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 15 Jun 2026 20:15:59 -0700 Subject: [PATCH 14/21] =?UTF-8?q?feat(command-center):=20U7=20=E2=80=94=20?= =?UTF-8?q?SDLC=20funnel=20+=20throughput?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit aggregateSdlcFunnel derives per-stage counts, conversion, completion rate, and throughput/day from activityLog task:moved transitions, mapping columns to stages by trait (unknown → Other). Rides additively on the /activity payload; SdlcFunnel renders it via the U4 Funnel primitive in the Overview Throughput section. --- .../src/__tests__/activity-analytics.test.ts | 157 ++++++++++- packages/core/src/activity-analytics.ts | 263 ++++++++++++++++++ .../command-center/CommandCenter.tsx | 26 +- .../components/command-center/SdlcFunnel.css | 8 + .../components/command-center/SdlcFunnel.tsx | 138 +++++++++ .../__tests__/SdlcFunnel.test.tsx | 132 +++++++++ 6 files changed, 717 insertions(+), 7 deletions(-) create mode 100644 packages/dashboard/app/components/command-center/SdlcFunnel.css create mode 100644 packages/dashboard/app/components/command-center/SdlcFunnel.tsx create mode 100644 packages/dashboard/app/components/command-center/__tests__/SdlcFunnel.test.tsx diff --git a/packages/core/src/__tests__/activity-analytics.test.ts b/packages/core/src/__tests__/activity-analytics.test.ts index 76f26085e..3a39ab1ae 100644 --- a/packages/core/src/__tests__/activity-analytics.test.ts +++ b/packages/core/src/__tests__/activity-analytics.test.ts @@ -6,7 +6,33 @@ import { tmpdir } from "node:os"; import { Database } from "../db.js"; import { emitUsageEvent } from "../usage-events.js"; -import { aggregateActivityAnalytics } from "../activity-analytics.js"; +import { + aggregateActivityAnalytics, + aggregateSdlcFunnel, + buildColumnStageMap, + stageForTraits, +} from "../activity-analytics.js"; + +let moveSeq = 0; +function insertMove( + db: Database, + taskId: string, + from: string, + to: string, + timestamp: string, +): void { + db.prepare( + `INSERT INTO activityLog (id, timestamp, type, taskId, taskTitle, details, metadata) + VALUES (?, ?, 'task:moved', ?, ?, ?, ?)`, + ).run( + `mv-${moveSeq++}`, + timestamp, + taskId, + `Task ${taskId}`, + `Task ${taskId} moved: ${from} → ${to}`, + JSON.stringify({ from, to }), + ); +} function insertCliSession(db: Database, id: string, createdAt: string): void { db.prepare( @@ -88,4 +114,133 @@ describe("activity-analytics", () => { const result = aggregateActivityAnalytics(db, {}); expect(result.mttr).toEqual({ value: null, unavailable: true }); }); + + describe("SDLC funnel (U7)", () => { + const RANGE = { from: "2026-03-01T00:00:00.000Z", to: "2026-03-08T00:00:00.000Z" }; + + function stage(result: ReturnType, name: string) { + return result.stages.find((s) => s.stage === name); + } + + it("maps the built-in workflow columns to stages by trait", () => { + expect(stageForTraits(["intake"])).toBe("triage"); + expect(stageForTraits(["hold", "reset-on-entry"])).toBe("todo"); + expect(stageForTraits(["wip", "timing"])).toBe("in-progress"); + expect(stageForTraits(["merge-blocker", "human-review", "merge"])).toBe("in-review"); + expect(stageForTraits(["complete"])).toBe("done"); + // No recognized trait -> other. + expect(stageForTraits(["archived"])).toBe("other"); + expect(stageForTraits([])).toBe("other"); + }); + + it("renders correct per-stage counts for tasks distributed across columns", () => { + // t1: triage -> todo -> in-progress -> in-review -> done (full funnel) + insertMove(db, "t1", "triage", "todo", "2026-03-02T00:00:00.000Z"); + insertMove(db, "t1", "todo", "in-progress", "2026-03-02T01:00:00.000Z"); + insertMove(db, "t1", "in-progress", "in-review", "2026-03-02T02:00:00.000Z"); + insertMove(db, "t1", "in-review", "done", "2026-03-02T03:00:00.000Z"); + // t2: triage -> todo -> in-progress (stalls) + insertMove(db, "t2", "triage", "todo", "2026-03-03T00:00:00.000Z"); + insertMove(db, "t2", "todo", "in-progress", "2026-03-03T01:00:00.000Z"); + // t3: triage -> todo (stalls earlier) + insertMove(db, "t3", "triage", "todo", "2026-03-04T00:00:00.000Z"); + + const result = aggregateSdlcFunnel(db, RANGE); + // Entry counts destination columns of moves. Nothing moved INTO triage + // here, so triage entered = 0; todo = 3, in-progress = 2, in-review = 1, + // done = 1. + expect(stage(result, "triage")?.entered).toBe(0); + expect(stage(result, "todo")?.entered).toBe(3); + expect(stage(result, "in-progress")?.entered).toBe(2); + expect(stage(result, "in-review")?.entered).toBe(1); + expect(stage(result, "done")?.entered).toBe(1); + }); + + it("counts a task once per stage even if it re-enters", () => { + insertMove(db, "t1", "in-review", "in-progress", "2026-03-02T00:00:00.000Z"); + insertMove(db, "t1", "in-progress", "in-review", "2026-03-02T01:00:00.000Z"); + insertMove(db, "t1", "in-review", "in-progress", "2026-03-02T02:00:00.000Z"); + + const result = aggregateSdlcFunnel(db, RANGE); + expect(stage(result, "in-progress")?.entered).toBe(1); + expect(stage(result, "in-review")?.entered).toBe(1); + }); + + it("maps custom workflow columns by trait, folding unknown into other", () => { + // Custom column ids that are NOT the builtin names, carrying standard traits. + const columns = [ + { id: "backlog", traits: [{ trait: "intake" }] }, + { id: "ready", traits: [{ trait: "reset-on-entry" }] }, + { id: "doing", traits: [{ trait: "wip" }] }, + { id: "shipped", traits: [{ trait: "complete" }] }, + { id: "icebox", traits: [{ trait: "some-unknown-trait" }] }, + ]; + insertMove(db, "c1", "backlog", "ready", "2026-03-02T00:00:00.000Z"); + insertMove(db, "c1", "ready", "doing", "2026-03-02T01:00:00.000Z"); + insertMove(db, "c1", "doing", "shipped", "2026-03-02T02:00:00.000Z"); + insertMove(db, "c2", "ready", "icebox", "2026-03-03T00:00:00.000Z"); + + const result = aggregateSdlcFunnel(db, { ...RANGE, columns }); + expect(stage(result, "todo")?.entered).toBe(1); // moved into "ready" + expect(stage(result, "in-progress")?.entered).toBe(1); // "doing" + expect(stage(result, "done")?.entered).toBe(1); // "shipped" + expect(stage(result, "other")?.entered).toBe(1); // "icebox" (unknown trait) + + // Map helper resolves by trait, not name. + const map = buildColumnStageMap(columns); + expect(map.get("backlog")).toBe("triage"); + expect(map.get("shipped")).toBe("done"); + expect(map.get("icebox")).toBe("other"); + }); + + it("completion rate = done-in-range / entered-in-range (triage entrants)", () => { + // 4 tasks enter triage; 2 reach done. + insertMove(db, "t1", "todo", "triage", "2026-03-02T00:00:00.000Z"); + insertMove(db, "t2", "todo", "triage", "2026-03-02T01:00:00.000Z"); + insertMove(db, "t3", "todo", "triage", "2026-03-02T02:00:00.000Z"); + insertMove(db, "t4", "todo", "triage", "2026-03-02T03:00:00.000Z"); + insertMove(db, "t1", "in-review", "done", "2026-03-03T00:00:00.000Z"); + insertMove(db, "t2", "in-review", "done", "2026-03-03T01:00:00.000Z"); + + const result = aggregateSdlcFunnel(db, RANGE); + expect(result.enteredInRange).toBe(4); + expect(result.doneInRange).toBe(2); + expect(result.completionRate).toBe(0.5); + }); + + it("handles the zero-denominator completion rate as null, not NaN", () => { + // No triage entrants in range; one done move. + insertMove(db, "t1", "in-review", "done", "2026-03-02T00:00:00.000Z"); + const result = aggregateSdlcFunnel(db, RANGE); + expect(result.enteredInRange).toBe(0); + expect(result.completionRate).toBeNull(); + expect(result.doneInRange).toBe(1); + }); + + it("computes throughput per day over the range", () => { + insertMove(db, "t1", "in-review", "done", "2026-03-02T00:00:00.000Z"); + insertMove(db, "t2", "in-review", "done", "2026-03-03T00:00:00.000Z"); + // 7-day range, 2 done -> ~0.2857/day + const result = aggregateSdlcFunnel(db, RANGE); + expect(result.rangeDays).toBe(7); + expect(result.throughputPerDay).toBeCloseTo(2 / 7, 5); + }); + + it("is exposed on the aggregated activity analytics payload (rides /activity)", () => { + insertMove(db, "t1", "todo", "in-progress", "2026-03-02T00:00:00.000Z"); + const result = aggregateActivityAnalytics(db, RANGE); + expect(result.funnel).toBeDefined(); + expect(result.funnel.stages.find((s) => s.stage === "in-progress")?.entered).toBe(1); + }); + + it("empty range yields zeroed funnel, not nulls in counts", () => { + const result = aggregateSdlcFunnel(db, RANGE); + expect(result.doneInRange).toBe(0); + expect(result.enteredInRange).toBe(0); + expect(result.completionRate).toBeNull(); + for (const s of result.stages) { + expect(s.entered).toBe(0); + } + }); + }); }); diff --git a/packages/core/src/activity-analytics.ts b/packages/core/src/activity-analytics.ts index dbee0b1a2..e35bf291f 100644 --- a/packages/core/src/activity-analytics.ts +++ b/packages/core/src/activity-analytics.ts @@ -1,4 +1,6 @@ import type { Database } from "./db.js"; +import { BUILTIN_CODING_WORKFLOW_IR } from "./builtin-coding-workflow-ir.js"; +import type { WorkflowIrColumn } from "./workflow-ir-types.js"; /** * Activity analytics: distinct active nodes/agents per day, sessions, messages, @@ -63,6 +65,8 @@ export interface ActivityAnalytics { stickiness: number; /** MTTR placeholder (U13 seam). */ mttr: MttrSummary; + /** SDLC funnel + throughput over the same range (U7). */ + funnel: SdlcFunnel; } interface CountRow { @@ -189,5 +193,264 @@ export function aggregateActivityAnalytics( stickiness, // U13 seam: no incident data source yet — unavailable, not 0. mttr: { value: null, unavailable: true }, + // U7 seam: SDLC funnel/throughput over the same range, mapped by workflow + // trait. Uses the built-in workflow's column→trait mapping by default; + // callers with a custom workflow IR should call aggregateSdlcFunnel directly + // with that workflow's columns so custom column ids map correctly. + funnel: aggregateSdlcFunnel(db, query), + }; +} + +/* ------------------------------------------------------------------------- */ +/* U7 — SDLC funnel + throughput */ +/* ------------------------------------------------------------------------- */ + +/** + * The canonical SDLC funnel stages, in flow order. Workflow columns map onto + * these by **trait**, never by column id/name, so custom workflows whose columns + * carry the standard traits are placed correctly; anything unrecognized folds + * into {@link OTHER_STAGE}. + */ +export const SDLC_STAGES = [ + "triage", + "todo", + "in-progress", + "in-review", + "done", +] as const; +export type SdlcStage = (typeof SDLC_STAGES)[number]; + +/** Bucket for columns whose traits don't map to a known SDLC stage. */ +export const OTHER_STAGE = "other" as const; +export type SdlcStageKey = SdlcStage | typeof OTHER_STAGE; + +/** + * Trait → stage mapping. A column is placed at the first stage any of its traits + * matches, scanning in {@link SDLC_STAGES} order so e.g. an `in-review` column + * carrying both `human-review` and `merge` resolves deterministically. Keep this + * additive: new workflow traits that imply a stage are added here, not matched by + * column name. + */ +const TRAIT_TO_STAGE: Record = { + // triage + intake: "triage", + triage: "triage", + // todo + "reset-on-entry": "todo", + // in-progress + wip: "in-progress", + timing: "in-progress", + "abort-on-exit": "in-progress", + // in-review + "human-review": "in-review", + "merge-blocker": "in-review", + merge: "in-review", + "stall-detection": "in-review", + // done + complete: "done", +}; + +/** Resolve a column's traits to an SDLC stage, or OTHER if none map. */ +export function stageForTraits(traits: readonly string[]): SdlcStageKey { + // Prefer the earliest stage in flow order among matching traits so a column is + // anchored to its most representative stage deterministically. + let best: SdlcStage | undefined; + let bestIdx = Number.POSITIVE_INFINITY; + for (const t of traits) { + const stage = TRAIT_TO_STAGE[t]; + if (stage === undefined) continue; + const idx = SDLC_STAGES.indexOf(stage); + if (idx < bestIdx) { + bestIdx = idx; + best = stage; + } + } + return best ?? OTHER_STAGE; +} + +/** Minimal column shape needed to map columns to stages by trait. */ +export interface FunnelColumnTraitSource { + id: string; + traits: { trait: string }[]; +} + +/** + * Build a `columnId → stage` map from a workflow's columns, mapping each column + * by its traits (not its id/name). The `todo` builtin column carries `hold` + * (a generic gate trait shared by other columns) so we special-case the + * presence of `reset-on-entry` for todo above; columns with no recognized trait + * fold to OTHER. + */ +export function buildColumnStageMap( + columns: readonly FunnelColumnTraitSource[], +): Map { + const map = new Map(); + for (const col of columns) { + map.set( + col.id, + stageForTraits(col.traits.map((t) => t.trait)), + ); + } + return map; +} + +export interface SdlcFunnelQuery extends ActivityAnalyticsQuery { + /** + * Workflow columns to map by trait. Defaults to the built-in coding workflow's + * columns. Pass a custom workflow's columns so its column ids resolve; any + * column id seen in the activity log but absent here folds into OTHER. + */ + columns?: readonly FunnelColumnTraitSource[]; +} + +/** Per-stage funnel datum. */ +export interface SdlcFunnelStage { + stage: SdlcStageKey; + /** Distinct tasks that entered this stage within the range. */ + entered: number; + /** + * Conversion from the previous SDLC stage (entered / prevEntered) as a 0..1 + * ratio. `null` for the first stage and when the previous stage had zero + * entrants (no divide-by-zero). `other` is excluded from conversion chaining. + */ + conversionFromPrev: number | null; +} + +export interface SdlcFunnel { + from: string | null; + to: string | null; + stages: SdlcFunnelStage[]; + /** Distinct tasks that entered the first (triage) stage's pipeline in range. */ + enteredInRange: number; + /** Distinct tasks that reached `done` in range. */ + doneInRange: number; + /** + * Completion rate = doneInRange / enteredInRange, as a 0..1 ratio. `null` when + * the denominator is zero (documented zero-denominator case), never NaN/∞. + */ + completionRate: number | null; + /** Number of whole UTC days in the range (>= 1), used for throughput. */ + rangeDays: number; + /** Tasks reaching `done` per day = doneInRange / rangeDays. */ + throughputPerDay: number; +} + +interface MoveRow { + taskId: string | null; + to: string | null; + ts: string; +} + +function defaultColumns(): FunnelColumnTraitSource[] { + const ir = BUILTIN_CODING_WORKFLOW_IR; + if (ir.version === "v2") { + return (ir.columns as WorkflowIrColumn[]).map((c) => ({ + id: c.id, + traits: c.traits.map((t) => ({ trait: t.trait })), + })); + } + return []; +} + +function countWholeDays(from?: string, to?: string): number { + if (from === undefined || to === undefined) return 1; + const f = Date.parse(from); + const t = Date.parse(to); + if (!Number.isFinite(f) || !Number.isFinite(t) || t < f) return 1; + const ms = t - f; + const days = Math.ceil(ms / 86_400_000); + return Math.max(1, days); +} + +/** + * Aggregate the SDLC funnel over a date range from `activityLog` transitions. + * + * **Entry into a stage** = a `task:moved` whose `metadata.to` column maps to that + * stage, OR a `task:created` whose initial column maps to it. Counts are distinct + * tasks per stage (a task that re-enters a stage is counted once). Columns map to + * stages **by trait** via {@link buildColumnStageMap}; unknown columns fold to + * OTHER. Completion rate divides done-in-range by entered-in-range with the + * zero-denominator case returning `null`. + */ +export function aggregateSdlcFunnel( + db: Database, + query: SdlcFunnelQuery = {}, +): SdlcFunnel { + const columns = query.columns ?? defaultColumns(); + const stageMap = buildColumnStageMap(columns); + const stageOf = (columnId: string | null): SdlcStageKey => { + if (columnId === null) return OTHER_STAGE; + return stageMap.get(columnId) ?? OTHER_STAGE; + }; + + const range = rangeClauses("timestamp", query); + const where = range.where + ? `${range.where} AND type = 'task:moved'` + : `WHERE type = 'task:moved'`; + + // task:moved carries metadata.to (the destination column id). The funnel is + // driven entirely by transitions — a task entering a stage is a move whose + // destination column maps to that stage. (task:created carries no column in + // metadata, so it is intentionally excluded; the first move records entry.) + const rows = db + .prepare( + `SELECT taskId, + json_extract(metadata, '$.to') AS "to", + timestamp AS ts + FROM activityLog ${where}`, + ) + .all(...range.params) as MoveRow[]; + + // Distinct tasks per stage. + const perStage = new Map>(); + const ensure = (s: SdlcStageKey): Set => { + let set = perStage.get(s); + if (!set) { + set = new Set(); + perStage.set(s, set); + } + return set; + }; + + for (const row of rows) { + if (row.taskId === null) continue; + const stage = stageOf(row.to); + ensure(stage).add(row.taskId); + } + + const stages: SdlcFunnelStage[] = []; + let prevEntered: number | null = null; + for (const stage of SDLC_STAGES) { + const entered = perStage.get(stage)?.size ?? 0; + const conversionFromPrev = + prevEntered === null || prevEntered === 0 ? null : entered / prevEntered; + stages.push({ stage, entered, conversionFromPrev }); + prevEntered = entered; + } + // Append OTHER as a trailing, non-chained bucket if anything landed there. + const otherCount = perStage.get(OTHER_STAGE)?.size ?? 0; + if (otherCount > 0) { + stages.push({ stage: OTHER_STAGE, entered: otherCount, conversionFromPrev: null }); + } + + // Entered-in-range = distinct tasks that entered the FIRST funnel stage + // (triage) in range. completion rate = done / entered. + const enteredInRange = perStage.get("triage")?.size ?? 0; + const doneInRange = perStage.get("done")?.size ?? 0; + const completionRate = + enteredInRange === 0 ? null : doneInRange / enteredInRange; + + const rangeDays = countWholeDays(query.from, query.to); + const throughputPerDay = doneInRange / rangeDays; + + return { + from: query.from ?? null, + to: query.to ?? null, + stages, + enteredInRange, + doneInRange, + completionRate, + rangeDays, + throughputPerDay, }; } diff --git a/packages/dashboard/app/components/command-center/CommandCenter.tsx b/packages/dashboard/app/components/command-center/CommandCenter.tsx index 03a8b7e89..7bbda132d 100644 --- a/packages/dashboard/app/components/command-center/CommandCenter.tsx +++ b/packages/dashboard/app/components/command-center/CommandCenter.tsx @@ -9,6 +9,7 @@ import { ProductivityArea } from "./areas/ProductivityArea"; import { EcosystemArea } from "./areas/EcosystemArea"; import { SignalsArea } from "./areas/SignalsArea"; import { MissionControlPanel } from "./MissionControlPanel"; +import { SdlcFunnel } from "./SdlcFunnel"; import "./CommandCenter.css"; type SubViewId = @@ -49,7 +50,7 @@ interface OverviewStatCard { * Headline stat cards (one per measurement area). Values land once Phase A's * analytics endpoints exist; until then each card shows the shared empty state. */ -function OverviewTab({ hasData }: { hasData: boolean }) { +function OverviewTab({ hasData, range }: { hasData: boolean; range: DateRange }) { const { t } = useTranslation("app"); const cards: OverviewStatCard[] = [ @@ -61,11 +62,23 @@ function OverviewTab({ hasData }: { hasData: boolean }) { { id: "signals", label: t("commandCenter.overview.openSignals", "Open signals") }, ]; + // The throughput funnel reads its own data (activityLog transitions) and shows + // its own empty state, so it renders even when the stat-card aggregates have no + // data yet. + const throughputSection = ( +
+ +
+ ); + if (!hasData) { return ( -
- -

{t("commandCenter.empty", "No usage data yet. Run some agents to populate the Command Center.")}

+
+
+ +

{t("commandCenter.empty", "No usage data yet. Run some agents to populate the Command Center.")}

+
+ {throughputSection}
); } @@ -86,6 +99,7 @@ function OverviewTab({ hasData }: { hasData: boolean }) { {t("commandCenter.overview.liveStripPending", "Live Mission Control loads with active sessions.")}
+ {throughputSection}
); } @@ -110,7 +124,7 @@ export function CommandCenter() { // No analytics endpoints yet, so there is no data to show — drives the empty state. const hasData = false; - const [range, setRange] = useState(() => rangeFromPreset(defaultPresets((k, f) => f)[1])); + const [range, setRange] = useState(() => rangeFromPreset(defaultPresets((_k, f) => f)[1])); const tabRefs = useRef>([]); @@ -159,7 +173,7 @@ export function CommandCenter() { function renderActiveTab() { switch (activeTab) { case "overview": - return ; + return ; case "tokens": return ; case "tools": diff --git a/packages/dashboard/app/components/command-center/SdlcFunnel.css b/packages/dashboard/app/components/command-center/SdlcFunnel.css new file mode 100644 index 000000000..d58a5d4cc --- /dev/null +++ b/packages/dashboard/app/components/command-center/SdlcFunnel.css @@ -0,0 +1,8 @@ +/* + * SDLC funnel (U7). Layout-only; the funnel bars and stat cards reuse the shared + * `cc-funnel-*` (charts.css), `cc-area-*`, and `cc-stat-*` styles. This file just + * adds spacing between the funnel and the throughput cards. + */ +.cc-area[data-testid="cc-area-funnel"] .cc-area-section + .cc-area-section { + margin-top: var(--space-4, 1rem); +} diff --git a/packages/dashboard/app/components/command-center/SdlcFunnel.tsx b/packages/dashboard/app/components/command-center/SdlcFunnel.tsx new file mode 100644 index 000000000..8c8013113 --- /dev/null +++ b/packages/dashboard/app/components/command-center/SdlcFunnel.tsx @@ -0,0 +1,138 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import type { ActivityAnalytics } from "@fusion/core"; +import type { DateRange } from "./DateRangePicker"; +import { Funnel, type FunnelStage } from "./charts/Funnel"; +import { AreaShell } from "./areas/AreaShell"; +import { useAnalyticsArea } from "./areas/useAnalyticsArea"; +import { formatCount } from "./areas/areaShared"; +import "./SdlcFunnel.css"; + +/** The funnel sub-shape carried on the activity analytics payload (U7). */ +type SdlcFunnelData = ActivityAnalytics["funnel"]; + +/** Human-readable label per stage key, falling back to the raw key. */ +function useStageLabels(): (stage: string) => string { + const { t } = useTranslation("app"); + return (stage: string) => { + switch (stage) { + case "triage": + return t("commandCenter.funnel.stage.triage", "Triage"); + case "todo": + return t("commandCenter.funnel.stage.todo", "Todo"); + case "in-progress": + return t("commandCenter.funnel.stage.inProgress", "In progress"); + case "in-review": + return t("commandCenter.funnel.stage.inReview", "In review"); + case "done": + return t("commandCenter.funnel.stage.done", "Done"); + case "other": + return t("commandCenter.funnel.stage.other", "Other"); + default: + return stage; + } + }; +} + +function formatRate(rate: number | null): string { + if (rate === null) return "—"; + return `${Math.round(rate * 100)}%`; +} + +function formatThroughput(value: number): string { + if (!Number.isFinite(value)) return "—"; + return value.toFixed(2); +} + +/** + * SDLC funnel + throughput (U7). Renders the HISTORICAL funnel over the selected + * date range from `activityLog` transitions (distinct from the live funnel in + * Mission Control). Reads the `funnel` field that rides on the `/command-center/ + * activity` payload — no extra endpoint — and renders it via the U4 `Funnel` + * primitive plus throughput / completion-rate stat cards. + * + * Stage labels are mapped from the stage **keys** the core aggregator produces + * (which it derives by workflow trait, not column name), so custom workflow + * columns surface correctly and unknown columns appear under "Other". + */ +export function SdlcFunnel({ range }: { range: DateRange }) { + const { t } = useTranslation("app"); + const labelFor = useStageLabels(); + const { data, isLoading, error } = useAnalyticsArea( + "/command-center/activity", + range, + ); + + const funnel: SdlcFunnelData | null = data?.funnel ?? null; + + const stages: FunnelStage[] = useMemo( + () => (funnel?.stages ?? []).map((s) => ({ label: labelFor(s.stage), value: s.entered })), + [funnel?.stages, labelFor], + ); + + const isEmpty = + !funnel || funnel.stages.every((s) => s.entered === 0); + + return ( + +
+

+ {t("commandCenter.funnel.title", "SDLC funnel")} +

+ +
+ +
+

+ {t("commandCenter.funnel.throughputTitle", "Throughput")} +

+
+
+
+ {t("commandCenter.funnel.completionRate", "Completion rate")} +
+
{formatRate(funnel?.completionRate ?? null)}
+ + {t("commandCenter.funnel.completionRateHint", "Done ÷ entered (in range)")} + +
+
+
+ {t("commandCenter.funnel.throughputPerDay", "Tasks done / day")} +
+
{formatThroughput(funnel?.throughputPerDay ?? 0)}
+ + {t("commandCenter.funnel.rangeDays", "{{count}} day range", { + count: funnel?.rangeDays ?? 0, + })} + +
+
+
+ {t("commandCenter.funnel.doneInRange", "Reached done")} +
+
{formatCount(funnel?.doneInRange ?? 0)}
+
+
+
+ {t("commandCenter.funnel.enteredInRange", "Entered triage")} +
+
{formatCount(funnel?.enteredInRange ?? 0)}
+
+
+
+
+ ); +} diff --git a/packages/dashboard/app/components/command-center/__tests__/SdlcFunnel.test.tsx b/packages/dashboard/app/components/command-center/__tests__/SdlcFunnel.test.tsx new file mode 100644 index 000000000..447261bdb --- /dev/null +++ b/packages/dashboard/app/components/command-center/__tests__/SdlcFunnel.test.tsx @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; + +// Mock the api() helper so the funnel fetches a deterministic fixture. +const apiMock = vi.fn(); +vi.mock("../../../api/legacy", () => ({ + api: (path: string, opts?: RequestInit) => apiMock(path, opts), +})); + +import { SdlcFunnel } from "../SdlcFunnel"; +import type { DateRange } from "../DateRangePicker"; + +const range7d: DateRange = { from: "2026-06-08", to: null, preset: "7d" }; + +/** Build an /activity payload whose funnel sub-shape drives the component. */ +function activityFixture(funnel: Record) { + return { + from: "2026-06-08", + to: null, + sessions: 0, + messages: 0, + activeNodes: 0, + activeAgents: 0, + daily: [], + stickiness: 0, + mttr: { value: null, unavailable: true }, + funnel, + }; +} + +function fullFunnel() { + return { + from: "2026-06-08", + to: null, + stages: [ + { stage: "triage", entered: 4, conversionFromPrev: null }, + { stage: "todo", entered: 4, conversionFromPrev: 1 }, + { stage: "in-progress", entered: 3, conversionFromPrev: 0.75 }, + { stage: "in-review", entered: 2, conversionFromPrev: 0.666 }, + { stage: "done", entered: 2, conversionFromPrev: 1 }, + { stage: "other", entered: 1, conversionFromPrev: null }, + ], + enteredInRange: 4, + doneInRange: 2, + completionRate: 0.5, + rangeDays: 7, + throughputPerDay: 2 / 7, + }; +} + +beforeEach(() => { + apiMock.mockReset(); +}); + +describe("SdlcFunnel", () => { + it("fetches the activity endpoint and renders per-stage counts", async () => { + apiMock.mockResolvedValue(activityFixture(fullFunnel())); + render(); + + await screen.findByTestId("cc-area-funnel"); + expect(apiMock).toHaveBeenCalledWith(expect.stringContaining("/command-center/activity"), undefined); + + // Funnel bars carry an accessible label per stage with its count. + expect(screen.getByLabelText("Triage: 4")).toBeTruthy(); + expect(screen.getByLabelText("In progress: 3")).toBeTruthy(); + expect(screen.getByLabelText("Done: 2")).toBeTruthy(); + // Unknown-trait columns surface under "Other". + expect(screen.getByLabelText("Other: 1")).toBeTruthy(); + }); + + it("shows completion rate and throughput stat cards", async () => { + apiMock.mockResolvedValue(activityFixture(fullFunnel())); + render(); + + await screen.findByTestId("cc-area-funnel"); + expect(screen.getByTestId("cc-funnel-completion-rate").textContent).toContain("50%"); + expect(screen.getByTestId("cc-funnel-done").textContent).toContain("2"); + expect(screen.getByTestId("cc-funnel-entered").textContent).toContain("4"); + expect(screen.getByTestId("cc-funnel-throughput").textContent).toContain("0.29"); + }); + + it("renders '—' for a null completion rate (zero-denominator)", async () => { + apiMock.mockResolvedValue( + activityFixture({ + from: "2026-06-08", + to: null, + stages: [ + { stage: "triage", entered: 0, conversionFromPrev: null }, + { stage: "todo", entered: 0, conversionFromPrev: null }, + { stage: "in-progress", entered: 0, conversionFromPrev: null }, + { stage: "in-review", entered: 1, conversionFromPrev: null }, + { stage: "done", entered: 1, conversionFromPrev: null }, + ], + enteredInRange: 0, + doneInRange: 1, + completionRate: null, + rangeDays: 7, + throughputPerDay: 1 / 7, + }), + ); + render(); + + await screen.findByTestId("cc-area-funnel"); + expect(screen.getByTestId("cc-funnel-completion-rate").textContent).toContain("—"); + }); + + it("renders the empty state when no transitions exist in the range", async () => { + apiMock.mockResolvedValue( + activityFixture({ + from: "2026-06-08", + to: null, + stages: [ + { stage: "triage", entered: 0, conversionFromPrev: null }, + { stage: "todo", entered: 0, conversionFromPrev: null }, + { stage: "in-progress", entered: 0, conversionFromPrev: null }, + { stage: "in-review", entered: 0, conversionFromPrev: null }, + { stage: "done", entered: 0, conversionFromPrev: null }, + ], + enteredInRange: 0, + doneInRange: 0, + completionRate: null, + rangeDays: 7, + throughputPerDay: 0, + }), + ); + render(); + + await waitFor(() => { + expect(screen.getByTestId("cc-area-funnel-empty")).toBeTruthy(); + }); + }); +}); From 168dc2f796b4fbb768a0c3a5d638c5f0fdf378d5 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 15 Jun 2026 20:19:16 -0700 Subject: [PATCH 15/21] =?UTF-8?q?feat(command-center):=20U10=20=E2=80=94?= =?UTF-8?q?=20OpenTelemetry=20(OTLP)=20metrics=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mapAnalyticsToOtlp maps token/cost/activity aggregates to the OTLP/HTTP JSON envelope (counters + gauges, model/provider/node/agent attributes); a periodic dashboard exporter is opt-in via FUSION_OTEL_METRICS_ENDPOINT, https-validated, header-redacted, backs off on failure, and never blocks startup/shutdown. Minimal exporter (no SDK dep) — real @opentelemetry SDK is a follow-up. --- .changeset/u10-otel-export.md | 14 + .../core/src/__tests__/otel-metrics.test.ts | 207 ++++++++++ packages/core/src/index.ts | 8 + packages/core/src/otel-metrics.ts | 284 ++++++++++++++ .../src/__tests__/otel-exporter.test.ts | 250 ++++++++++++ packages/dashboard/src/otel-exporter.ts | 365 ++++++++++++++++++ packages/dashboard/src/server.ts | 18 + 7 files changed, 1146 insertions(+) create mode 100644 .changeset/u10-otel-export.md create mode 100644 packages/core/src/__tests__/otel-metrics.test.ts create mode 100644 packages/core/src/otel-metrics.ts create mode 100644 packages/dashboard/src/__tests__/otel-exporter.test.ts create mode 100644 packages/dashboard/src/otel-exporter.ts diff --git a/.changeset/u10-otel-export.md b/.changeset/u10-otel-export.md new file mode 100644 index 000000000..a58f8d43b --- /dev/null +++ b/.changeset/u10-otel-export.md @@ -0,0 +1,14 @@ +--- +"@runfusion/fusion": minor +--- + +Export Command Center analytics over OpenTelemetry (OTLP) so teams can ship token / cost / activity metrics to Datadog / Grafana / etc. **Disabled by default** (U10, R4). + +- New pure mapping `mapAnalyticsToOtlp` in `@fusion/core` (`otel-metrics.ts`) turns the token/cost/activity aggregator outputs into the OTLP/HTTP JSON wire shape (`resourceMetrics`) — counters for token/cost, gauges for activity — with `model` / `provider` / `node.id` / `agent.id` attributes per data point. Fully testable without a live collector; no SDK dependency in core. +- Dashboard exporter (`otel-exporter.ts`) periodically maps current analytics and POSTs them to a configured collector, wired into `server.ts` startup/shutdown. + +**SDK choice:** ships a **minimal OTLP/HTTP JSON exporter rather than the official `@opentelemetry/*` SDK** — and therefore adds **no new runtime dependency**. The OTLP/HTTP JSON protocol is a single, stable `POST /v1/metrics` of a well-defined JSON envelope (built in core), so for a default-disabled feature we avoid pulling the multi-package SDK (sdk-metrics + exporter-metrics-otlp-http + resources + api). The wire shape is collector-compatible; swapping in the official SDK later is mechanical. (If maintainers prefer the real SDK, that is a follow-up changeset + dependency add.) + +**Enabled only via env** (none set ⇒ nothing starts): `FUSION_OTEL_METRICS_ENDPOINT` (full `/v1/metrics` URL, required to enable), `FUSION_OTEL_METRICS_HEADERS` (`k=v,k2=v2` auth headers), `FUSION_OTEL_METRICS_INTERVAL_MS`, `FUSION_OTEL_METRICS_TIMEOUT_MS`, `FUSION_OTEL_RESOURCE_ATTRIBUTES`. + +**Security:** endpoint validated on write — `http://` is rejected in production (exporter does not start) and warns loudly otherwise; auth header (Datadog/Grafana token) VALUES are never logged and are masked in diagnostics; a collector-unreachable failure logs (redacted) and backs off exponentially without crashing the server or blocking requests. diff --git a/packages/core/src/__tests__/otel-metrics.test.ts b/packages/core/src/__tests__/otel-metrics.test.ts new file mode 100644 index 000000000..e366f2a2d --- /dev/null +++ b/packages/core/src/__tests__/otel-metrics.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect } from "vitest"; + +import { mapAnalyticsToOtlp, OTEL_METRIC_PREFIX } from "../otel-metrics.js"; +import type { TokenAnalytics } from "../token-analytics.js"; +import type { ActivityAnalytics } from "../activity-analytics.js"; + +const TIME_NANO = "1700000000000000000"; + +function tokenFixture(): TokenAnalytics { + return { + from: null, + to: null, + groupBy: "model", + totals: { + inputTokens: 1000, + outputTokens: 500, + cachedTokens: 200, + cacheWriteTokens: 50, + totalTokens: 1750, + nTasks: 3, + }, + cost: { usd: 12.34, unavailable: false, stale: false }, + groups: [ + { + key: "claude-opus-4-8", + inputTokens: 600, + outputTokens: 300, + cachedTokens: 100, + cacheWriteTokens: 25, + totalTokens: 1025, + nTasks: 2, + cost: { usd: 9.0, unavailable: false, stale: false }, + }, + { + key: "gpt-5", + inputTokens: 400, + outputTokens: 200, + cachedTokens: 100, + cacheWriteTokens: 25, + totalTokens: 725, + nTasks: 1, + // Unpriced group → cost must be omitted, not reported as $0. + cost: { usd: null, unavailable: true, stale: false }, + }, + ], + }; +} + +function activityFixture(): ActivityAnalytics { + return { + from: null, + to: null, + sessions: 7, + messages: 42, + activeNodes: 3, + activeAgents: 5, + daily: [], + stickiness: 0.6, + mttr: { value: null, unavailable: true }, + }; +} + +function findMetric(payload: ReturnType, name: string) { + const metrics = payload.resourceMetrics[0].scopeMetrics[0].metrics; + const m = metrics.find((x) => x.name === name); + expect(m, `metric ${name} present`).toBeDefined(); + return m!; +} + +describe("mapAnalyticsToOtlp", () => { + it("maps token totals to a monotonic Sum counter with a grand-total point", () => { + const payload = mapAnalyticsToOtlp({ + tokens: tokenFixture(), + activity: activityFixture(), + timeUnixNano: TIME_NANO, + }); + const total = findMetric(payload, `${OTEL_METRIC_PREFIX}.tokens.total`); + expect(total.sum?.isMonotonic).toBe(true); + expect(total.sum?.aggregationTemporality).toBe(2); + // Grand total point (no attributes) carries the totals value. + const grand = total.sum?.dataPoints.find((p) => p.attributes.length === 0); + expect(grand?.asInt).toBe("1750"); + }); + + it("emits one attributed data point per group (model/provider/node/agent)", () => { + const payload = mapAnalyticsToOtlp({ + tokens: tokenFixture(), + activity: activityFixture(), + timeUnixNano: TIME_NANO, + }); + const input = findMetric(payload, `${OTEL_METRIC_PREFIX}.tokens.input`); + const modelPoints = input.sum!.dataPoints.filter((p) => + p.attributes.some((a) => a.key === "model"), + ); + const models = modelPoints + .map((p) => p.attributes.find((a) => a.key === "model")!.value.stringValue) + .sort(); + expect(models).toEqual(["claude-opus-4-8", "gpt-5"]); + const opus = modelPoints.find( + (p) => + p.attributes.find((a) => a.key === "model")!.value.stringValue === + "claude-opus-4-8", + ); + expect(opus?.asInt).toBe("600"); + }); + + it("uses provider/node/agent attribute keys per groupBy", () => { + const base = tokenFixture(); + for (const [groupBy, attrKey] of [ + ["provider", "provider"], + ["node", "node.id"], + ["agent", "agent.id"], + ] as const) { + const payload = mapAnalyticsToOtlp({ + tokens: { ...base, groupBy, groups: [{ ...base.groups[0], key: "k" }] }, + activity: activityFixture(), + timeUnixNano: TIME_NANO, + }); + const input = findMetric(payload, `${OTEL_METRIC_PREFIX}.tokens.input`); + const attributed = input.sum!.dataPoints.find((p) => p.attributes.length > 0); + expect(attributed?.attributes[0].key).toBe(attrKey); + } + }); + + it("omits cost data points for unpriced groups (never reports $0)", () => { + const payload = mapAnalyticsToOtlp({ + tokens: tokenFixture(), + activity: activityFixture(), + timeUnixNano: TIME_NANO, + }); + const cost = findMetric(payload, `${OTEL_METRIC_PREFIX}.cost.usd`); + // Grand total (12.34) + opus (9.0); gpt-5 (null) omitted ⇒ 2 points. + expect(cost.sum?.dataPoints.length).toBe(2); + const grand = cost.sum?.dataPoints.find((p) => p.attributes.length === 0); + expect(grand?.asDouble).toBeCloseTo(12.34, 5); + const hasGpt5 = cost.sum?.dataPoints.some((p) => + p.attributes.some((a) => a.value.stringValue === "gpt-5"), + ); + expect(hasGpt5).toBe(false); + }); + + it("maps activity to gauges (active nodes/agents/sessions/messages/stickiness)", () => { + const payload = mapAnalyticsToOtlp({ + tokens: tokenFixture(), + activity: activityFixture(), + timeUnixNano: TIME_NANO, + }); + expect( + findMetric(payload, `${OTEL_METRIC_PREFIX}.activity.active_nodes`).gauge + ?.dataPoints[0].asInt, + ).toBe("3"); + expect( + findMetric(payload, `${OTEL_METRIC_PREFIX}.activity.active_agents`).gauge + ?.dataPoints[0].asInt, + ).toBe("5"); + expect( + findMetric(payload, `${OTEL_METRIC_PREFIX}.activity.sessions`).gauge + ?.dataPoints[0].asInt, + ).toBe("7"); + expect( + findMetric(payload, `${OTEL_METRIC_PREFIX}.activity.messages`).gauge + ?.dataPoints[0].asInt, + ).toBe("42"); + expect( + findMetric(payload, `${OTEL_METRIC_PREFIX}.activity.stickiness`).gauge + ?.dataPoints[0].asDouble, + ).toBeCloseTo(0.6, 5); + }); + + it("applies resource attributes and a default service.name", () => { + const dflt = mapAnalyticsToOtlp({ + tokens: tokenFixture(), + activity: activityFixture(), + timeUnixNano: TIME_NANO, + }); + const defaultAttrs = dflt.resourceMetrics[0].resource.attributes; + expect( + defaultAttrs.find((a) => a.key === "service.name")?.value.stringValue, + ).toBe("fusion-dashboard"); + + const custom = mapAnalyticsToOtlp({ + tokens: tokenFixture(), + activity: activityFixture(), + timeUnixNano: TIME_NANO, + resourceAttributes: { "service.name": "my-svc", env: "staging" }, + }); + const attrs = custom.resourceMetrics[0].resource.attributes; + expect(attrs.find((a) => a.key === "env")?.value.stringValue).toBe("staging"); + }); + + it("coerces non-finite / negative counts to 0 (no NaN on the wire)", () => { + const bad = tokenFixture(); + bad.totals.inputTokens = Number.NaN; + bad.totals.outputTokens = -5; + const payload = mapAnalyticsToOtlp({ + tokens: bad, + activity: activityFixture(), + timeUnixNano: TIME_NANO, + }); + const input = findMetric(payload, `${OTEL_METRIC_PREFIX}.tokens.input`); + const grand = input.sum!.dataPoints.find((p) => p.attributes.length === 0); + expect(grand?.asInt).toBe("0"); + const output = findMetric(payload, `${OTEL_METRIC_PREFIX}.tokens.output`); + const grandOut = output.sum!.dataPoints.find((p) => p.attributes.length === 0); + expect(grandOut?.asInt).toBe("0"); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d336658c3..98e038aa3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -579,6 +579,14 @@ export type { LiveRun, ColumnCount, } from "./command-center-live.js"; +export { mapAnalyticsToOtlp, OTEL_METRIC_PREFIX } from "./otel-metrics.js"; +export type { + OtelMappingInput, + OtlpExportPayload, + OtlpMetric, + OtlpNumberDataPoint, + OtlpAttribute, +} from "./otel-metrics.js"; export { STALLED_REVIEW_REENQUEUE_THRESHOLD, STALLED_REVIEW_INVALID_TRANSITION_THRESHOLD, diff --git a/packages/core/src/otel-metrics.ts b/packages/core/src/otel-metrics.ts new file mode 100644 index 000000000..43990bbbd --- /dev/null +++ b/packages/core/src/otel-metrics.ts @@ -0,0 +1,284 @@ +/** + * OpenTelemetry (OTLP) metric mapping (U10). + * + * Pure mapping of the Command Center aggregator outputs (tokens / cost / activity) + * to OTLP metric instruments. This module produces the **OTLP/HTTP JSON wire + * shape** (`{ resourceMetrics: [...] }`) directly — the exact body an OTLP/HTTP + * collector accepts at `/v1/metrics` — so it is testable without a live collector + * and without pulling the full `@opentelemetry/*` SDK into `@fusion/core`. + * + * Design (KTD2): the MAPPING lives in core (reusable, side-effect-free); the + * network export (endpoint validation, auth headers, periodic scheduling, back + * off) lives in the dashboard exporter. This module never reads the clock, the + * network, or env — callers pass an explicit `timeUnixNano`. + * + * Instrument choices: + * - Token counts and USD cost are **monotonic counters** (`Sum`, cumulative, + * monotonic) — they only grow over a fixed range and aggregate cleanly. + * - Activity "current state" figures (active nodes/agents, sessions, stickiness) + * are **gauges** — point-in-time values that should not be summed across + * series. + * + * Attributes (model / provider / node / agent) are attached per data point from + * the aggregator's group keys, so a collector can break metrics down by any of + * them. We emit one data point per group plus an unattributed grand-total point. + */ + +import type { TokenAnalytics } from "./token-analytics.js"; +import type { ActivityAnalytics } from "./activity-analytics.js"; + +/** Instrument namespace prefix for every metric this module emits. */ +export const OTEL_METRIC_PREFIX = "fusion.command_center"; + +/** A single OTLP attribute key/value (string-valued; numbers are stringified). */ +export interface OtlpAttribute { + key: string; + value: { stringValue: string }; +} + +/** An OTLP number data point (used for both Sum and Gauge). */ +export interface OtlpNumberDataPoint { + /** Group attributes (model/provider/node/agent), empty for grand totals. */ + attributes: OtlpAttribute[]; + /** Nanoseconds since epoch; the start of the measurement window. */ + startTimeUnixNano: string; + /** Nanoseconds since epoch; when the value was observed. */ + timeUnixNano: string; + /** Integer counts use asInt; fractional values (cost, ratios) use asDouble. */ + asInt?: string; + asDouble?: number; +} + +/** An OTLP metric (one instrument), either a Sum (counter) or a Gauge. */ +export interface OtlpMetric { + name: string; + description: string; + unit: string; + sum?: { + dataPoints: OtlpNumberDataPoint[]; + /** 2 = CUMULATIVE in the OTLP AggregationTemporality enum. */ + aggregationTemporality: 2; + isMonotonic: boolean; + }; + gauge?: { + dataPoints: OtlpNumberDataPoint[]; + }; +} + +/** The OTLP/HTTP JSON export envelope sent to a collector's `/v1/metrics`. */ +export interface OtlpExportPayload { + resourceMetrics: Array<{ + resource: { attributes: OtlpAttribute[] }; + scopeMetrics: Array<{ + scope: { name: string; version: string }; + metrics: OtlpMetric[]; + }>; + }>; +} + +/** Inputs to {@link mapAnalyticsToOtlp}. */ +export interface OtelMappingInput { + tokens: TokenAnalytics; + activity: ActivityAnalytics; + /** Observation time in nanoseconds since the Unix epoch (caller-supplied). */ + timeUnixNano: string; + /** + * Start of the measurement window in nanoseconds since the Unix epoch. Used + * for the Sum start time so a collector treats the counters as a fresh + * cumulative series. Defaults to {@link OtelMappingInput.timeUnixNano}. + */ + startTimeUnixNano?: string; + /** + * Resource attributes describing the emitting service (e.g. + * `{ "service.name": "fusion-dashboard" }`). Defaults to a minimal + * `service.name`. + */ + resourceAttributes?: Record; + /** OTLP scope (instrumentation library) version. Defaults to `"1"`. */ + scopeVersion?: string; +} + +function attr(key: string, value: string): OtlpAttribute { + return { key, value: { stringValue: value } }; +} + +function toAttributes(record: Record): OtlpAttribute[] { + return Object.entries(record).map(([k, v]) => attr(k, v)); +} + +function intPoint( + value: number, + attributes: OtlpAttribute[], + startTimeUnixNano: string, + timeUnixNano: string, +): OtlpNumberDataPoint { + return { + attributes, + startTimeUnixNano, + timeUnixNano, + // OTLP ints are wire-encoded as strings. Coerce non-finite/negative to 0. + asInt: String(Math.max(0, Math.trunc(Number.isFinite(value) ? value : 0))), + }; +} + +function doublePoint( + value: number, + attributes: OtlpAttribute[], + startTimeUnixNano: string, + timeUnixNano: string, +): OtlpNumberDataPoint { + return { + attributes, + startTimeUnixNano, + timeUnixNano, + asDouble: Number.isFinite(value) ? value : 0, + }; +} + +function counter( + name: string, + description: string, + unit: string, + dataPoints: OtlpNumberDataPoint[], +): OtlpMetric { + return { + name, + description, + unit, + sum: { dataPoints, aggregationTemporality: 2, isMonotonic: true }, + }; +} + +function gauge( + name: string, + description: string, + unit: string, + dataPoints: OtlpNumberDataPoint[], +): OtlpMetric { + return { name, description, unit, gauge: { dataPoints } }; +} + +/** + * Attributes for a token group. The grouped dimension is reflected by the key + * the aggregator chose (`groupBy`); we tag it with the matching attribute name + * so a collector sees `model` / `provider` / `node.id` / `agent.id`. + */ +function groupAttributes( + groupBy: TokenAnalytics["groupBy"], + key: string | null, +): OtlpAttribute[] { + if (!groupBy || key === null) return []; + switch (groupBy) { + case "model": + return [attr("model", key)]; + case "provider": + return [attr("provider", key)]; + case "node": + return [attr("node.id", key)]; + case "agent": + return [attr("agent.id", key)]; + } +} + +/** + * Map token + activity analytics to an OTLP/HTTP JSON export payload. + * + * Token/cost metrics emit one data point per group (carrying the group's + * model/provider/node/agent attribute) plus an unattributed grand-total point. + * Cost is omitted from a data point when `cost.usd` is null (unpriced models) so + * an unavailable cost never reports as `$0`. Activity metrics are gauges with no + * group attributes (the activity aggregator is range-scoped, not grouped). + * + * Pure: no I/O, no clock. Returns a fresh payload every call. + */ +export function mapAnalyticsToOtlp(input: OtelMappingInput): OtlpExportPayload { + const { tokens, activity, timeUnixNano } = input; + const start = input.startTimeUnixNano ?? timeUnixNano; + const resourceAttributes = input.resourceAttributes ?? { + "service.name": "fusion-dashboard", + }; + const scopeVersion = input.scopeVersion ?? "1"; + + const p = OTEL_METRIC_PREFIX; + + // ── Token counters (one data point per group + a grand total) ────────── + const inputTokenPoints: OtlpNumberDataPoint[] = []; + const outputTokenPoints: OtlpNumberDataPoint[] = []; + const cachedTokenPoints: OtlpNumberDataPoint[] = []; + const totalTokenPoints: OtlpNumberDataPoint[] = []; + const costPoints: OtlpNumberDataPoint[] = []; + + // Grand totals (unattributed). + inputTokenPoints.push(intPoint(tokens.totals.inputTokens, [], start, timeUnixNano)); + outputTokenPoints.push(intPoint(tokens.totals.outputTokens, [], start, timeUnixNano)); + cachedTokenPoints.push(intPoint(tokens.totals.cachedTokens, [], start, timeUnixNano)); + totalTokenPoints.push(intPoint(tokens.totals.totalTokens, [], start, timeUnixNano)); + if (tokens.cost.usd !== null) { + costPoints.push(doublePoint(tokens.cost.usd, [], start, timeUnixNano)); + } + + // Per-group points. + for (const group of tokens.groups) { + const attrs = groupAttributes(tokens.groupBy, group.key); + inputTokenPoints.push(intPoint(group.inputTokens, attrs, start, timeUnixNano)); + outputTokenPoints.push(intPoint(group.outputTokens, attrs, start, timeUnixNano)); + cachedTokenPoints.push(intPoint(group.cachedTokens, attrs, start, timeUnixNano)); + totalTokenPoints.push(intPoint(group.totalTokens, attrs, start, timeUnixNano)); + if (group.cost.usd !== null) { + costPoints.push(doublePoint(group.cost.usd, attrs, start, timeUnixNano)); + } + } + + const metrics: OtlpMetric[] = [ + counter(`${p}.tokens.input`, "Input (uncached) tokens consumed", "{token}", inputTokenPoints), + counter(`${p}.tokens.output`, "Output tokens generated", "{token}", outputTokenPoints), + counter(`${p}.tokens.cached`, "Cache-read (cached input) tokens", "{token}", cachedTokenPoints), + counter(`${p}.tokens.total`, "Total tokens consumed", "{token}", totalTokenPoints), + counter(`${p}.cost.usd`, "Derived USD cost from token usage", "USD", costPoints), + // ── Activity gauges (point-in-time) ────────────────────────────────── + gauge( + `${p}.activity.active_nodes`, + "Distinct active nodes over the range", + "{node}", + [intPoint(activity.activeNodes, [], start, timeUnixNano)], + ), + gauge( + `${p}.activity.active_agents`, + "Distinct active agents over the range", + "{agent}", + [intPoint(activity.activeAgents, [], start, timeUnixNano)], + ), + gauge( + `${p}.activity.sessions`, + "CLI/chat sessions over the range", + "{session}", + [intPoint(activity.sessions, [], start, timeUnixNano)], + ), + gauge( + `${p}.activity.messages`, + "User messages over the range", + "{message}", + [intPoint(activity.messages, [], start, timeUnixNano)], + ), + gauge( + `${p}.activity.stickiness`, + "Stickiness ratio (DAU/MAU)", + "1", + [doublePoint(activity.stickiness, [], start, timeUnixNano)], + ), + ]; + + return { + resourceMetrics: [ + { + resource: { attributes: toAttributes(resourceAttributes) }, + scopeMetrics: [ + { + scope: { name: p, version: scopeVersion }, + metrics, + }, + ], + }, + ], + }; +} diff --git a/packages/dashboard/src/__tests__/otel-exporter.test.ts b/packages/dashboard/src/__tests__/otel-exporter.test.ts new file mode 100644 index 000000000..290ec2b10 --- /dev/null +++ b/packages/dashboard/src/__tests__/otel-exporter.test.ts @@ -0,0 +1,250 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { Database } from "@fusion/core"; +import type { TaskStore } from "@fusion/core"; +import { + resolveOtelExporterConfig, + redactHeadersForDiagnostics, + parseKeyValueList, + startOtelExporter, + maybeStartOtelExporter, + type FetchLike, + type OtelExporterConfig, +} from "../otel-exporter.js"; +import type { RuntimeLogger } from "../runtime-logger.js"; + +interface CapturedLog { + level: "info" | "warn" | "error"; + message: string; + context?: Record; +} + +function makeLogger(): { logger: RuntimeLogger; logs: CapturedLog[] } { + const logs: CapturedLog[] = []; + const mk = (): RuntimeLogger => ({ + info: (message, context) => logs.push({ level: "info", message, context }), + warn: (message, context) => logs.push({ level: "warn", message, context }), + error: (message, context) => logs.push({ level: "error", message, context }), + child: () => mk(), + }); + return { logger: mk(), logs }; +} + +function seedDb(db: Database): void { + db.prepare( + `INSERT INTO tasks + (id, description, "column", createdAt, updatedAt, + tokenUsageInputTokens, tokenUsageOutputTokens, tokenUsageCachedTokens, + tokenUsageCacheWriteTokens, tokenUsageTotalTokens, tokenUsageLastUsedAt, + modelProvider, modelId) + VALUES ('t1', 'd', 'todo', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', + 100, 50, 10, 5, 165, '2026-03-01T00:00:00.000Z', 'anthropic', 'claude-opus-4-8')`, + ).run(); +} + +function configFor(overrides: Partial = {}): OtelExporterConfig { + return { + endpoint: "https://collector.example/v1/metrics", + headers: { "DD-API-KEY": "super-secret-token-value" }, + intervalMs: 60_000 as OtelExporterConfig["intervalMs"], + timeoutMs: 5_000, + resourceAttributes: { "service.name": "fusion-dashboard" }, + ...overrides, + }; +} + +describe("resolveOtelExporterConfig (disabled by default)", () => { + it("returns disabled when no endpoint is configured", () => { + expect(resolveOtelExporterConfig({}).kind).toBe("disabled"); + }); + + it("enables when an https endpoint is set", () => { + const r = resolveOtelExporterConfig({ + FUSION_OTEL_METRICS_ENDPOINT: "https://collector:4318/v1/metrics", + FUSION_OTEL_METRICS_HEADERS: "DD-API-KEY=abc,X-Other=1", + }); + expect(r.kind).toBe("enabled"); + if (r.kind !== "enabled") return; + expect(r.warnHttp).toBe(false); + expect(r.config.headers["DD-API-KEY"]).toBe("abc"); + }); + + it("rejects http:// in production", () => { + const r = resolveOtelExporterConfig({ + NODE_ENV: "production", + FUSION_OTEL_METRICS_ENDPOINT: "http://collector:4318/v1/metrics", + }); + expect(r.kind).toBe("rejected"); + }); + + it("allows http:// outside production but flags warnHttp", () => { + const r = resolveOtelExporterConfig({ + FUSION_OTEL_METRICS_ENDPOINT: "http://localhost:4318/v1/metrics", + }); + expect(r.kind).toBe("enabled"); + if (r.kind !== "enabled") return; + expect(r.warnHttp).toBe(true); + }); + + it("rejects a malformed endpoint URL", () => { + const r = resolveOtelExporterConfig({ FUSION_OTEL_METRICS_ENDPOINT: "not a url" }); + expect(r.kind).toBe("rejected"); + }); +}); + +describe("parseKeyValueList / redactHeadersForDiagnostics", () => { + it("parses key=value lists and skips malformed pairs", () => { + expect(parseKeyValueList("a=1, b=2,bad,c=3")).toEqual({ a: "1", b: "2", c: "3" }); + }); + + it("masks all header values, preserving keys", () => { + const r = redactHeadersForDiagnostics({ "DD-API-KEY": "secret", Authorization: "Bearer x" }); + expect(r).toEqual({ "DD-API-KEY": "[REDACTED]", Authorization: "[REDACTED]" }); + }); +}); + +describe("startOtelExporter (with a collector stub)", () => { + let tmpDir: string; + let db: Database; + let store: TaskStore; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "kb-otel-exporter-")); + db = new Database(join(tmpDir, ".fusion")); + db.init(); + seedDb(db); + store = { getDatabase: () => db } as unknown as TaskStore; + }); + + afterEach(async () => { + db.close(); + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("exports token/cost/activity metrics with expected names + attributes", async () => { + let capturedBody: string | undefined; + const fetchImpl: FetchLike = async (_url, init) => { + capturedBody = init.body; + return { ok: true, status: 200 }; + }; + const { logger } = makeLogger(); + const handle = startOtelExporter({ store, config: configFor(), logger, fetchImpl }); + await handle.exportOnce(); + handle.stop(); + + expect(capturedBody).toBeDefined(); + const payload = JSON.parse(capturedBody!); + const metrics = payload.resourceMetrics[0].scopeMetrics[0].metrics; + const names = metrics.map((m: { name: string }) => m.name); + expect(names).toContain("fusion.command_center.tokens.total"); + expect(names).toContain("fusion.command_center.cost.usd"); + expect(names).toContain("fusion.command_center.activity.active_nodes"); + // model attribute present on a token data point. + const total = metrics.find( + (m: { name: string }) => m.name === "fusion.command_center.tokens.total", + ); + const attributed = total.sum.dataPoints.find( + (p: { attributes: Array<{ key: string }> }) => p.attributes.length > 0, + ); + expect(attributed.attributes[0].key).toBe("model"); + }); + + it("sends configured auth headers but never logs their values", async () => { + let sentHeaders: Record | undefined; + const fetchImpl: FetchLike = async (_url, init) => { + sentHeaders = init.headers; + return { ok: true, status: 200 }; + }; + const { logger, logs } = makeLogger(); + const handle = startOtelExporter({ store, config: configFor(), logger, fetchImpl }); + await handle.exportOnce(); + handle.stop(); + + // The secret IS sent on the wire. + expect(sentHeaders?.["DD-API-KEY"]).toBe("super-secret-token-value"); + // ...but never appears in any log line. + const serialized = JSON.stringify(logs); + expect(serialized).not.toContain("super-secret-token-value"); + }); + + it("backs off and logs (redacted) when the collector is unreachable; never throws", async () => { + const fetchImpl: FetchLike = async () => { + throw new Error("ECONNREFUSED collector down token=super-secret-token-value"); + }; + const { logger, logs } = makeLogger(); + const handle = startOtelExporter({ store, config: configFor(), logger, fetchImpl }); + // Must not throw out of the export. + await expect(handle.exportOnce()).resolves.toBeUndefined(); + handle.stop(); + + const warn = logs.find((l) => l.level === "warn" && l.message.includes("unreachable")); + expect(warn).toBeDefined(); + // The secret embedded in the error message is redacted. + expect(JSON.stringify(logs)).not.toContain("super-secret-token-value"); + // Header values masked in the warn context. + expect((warn?.context?.headers as Record)["DD-API-KEY"]).toBe("[REDACTED]"); + }); + + it("treats a non-2xx response as a failure and backs off, without throwing", async () => { + const fetchImpl: FetchLike = async () => ({ ok: false, status: 503 }); + const { logger, logs } = makeLogger(); + const handle = startOtelExporter({ store, config: configFor(), logger, fetchImpl }); + await expect(handle.exportOnce()).resolves.toBeUndefined(); + handle.stop(); + expect(logs.some((l) => l.level === "warn" && l.context?.status === 503)).toBe(true); + }); +}); + +describe("maybeStartOtelExporter (disabled-by-default gate)", () => { + let tmpDir: string; + let db: Database; + let store: TaskStore; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "kb-otel-maybe-")); + db = new Database(join(tmpDir, ".fusion")); + db.init(); + store = { getDatabase: () => db } as unknown as TaskStore; + }); + + afterEach(async () => { + db.close(); + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("does NOT start an exporter when no endpoint env is set", () => { + const fetchImpl = vi.fn(async () => ({ ok: true, status: 200 })); + const { logger } = makeLogger(); + const handle = maybeStartOtelExporter({ store, logger, env: {}, fetchImpl }); + expect(handle).toBeNull(); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("logs a warning and does not start when the endpoint is rejected", () => { + const { logger, logs } = makeLogger(); + const handle = maybeStartOtelExporter({ + store, + logger, + env: { NODE_ENV: "production", FUSION_OTEL_METRICS_ENDPOINT: "http://x/v1/metrics" }, + }); + expect(handle).toBeNull(); + expect(logs.some((l) => l.level === "warn" && l.message.includes("NOT started"))).toBe(true); + }); + + it("starts and warns loudly for an http:// endpoint outside production", () => { + const { logger, logs } = makeLogger(); + const handle = maybeStartOtelExporter({ + store, + logger, + env: { FUSION_OTEL_METRICS_ENDPOINT: "http://localhost:4318/v1/metrics" }, + fetchImpl: async () => ({ ok: true, status: 200 }), + }); + expect(handle).not.toBeNull(); + handle?.stop(); + expect(logs.some((l) => l.level === "warn" && l.message.includes("UNENCRYPTED"))).toBe(true); + }); +}); diff --git a/packages/dashboard/src/otel-exporter.ts b/packages/dashboard/src/otel-exporter.ts new file mode 100644 index 000000000..7c2e58695 --- /dev/null +++ b/packages/dashboard/src/otel-exporter.ts @@ -0,0 +1,365 @@ +/** + * OpenTelemetry (OTLP) metrics exporter wiring (U10) — dashboard side. + * + * Periodically maps the Command Center analytics (tokens / cost / activity) to + * OTLP/HTTP JSON via the pure `mapAnalyticsToOtlp` mapping in `@fusion/core`, + * then POSTs them to a configured collector. **Disabled by default** — nothing + * starts unless an endpoint is explicitly configured. + * + * SDK choice (changeset note): this is a **minimal OTLP/HTTP JSON exporter**, not + * the full `@opentelemetry/*` SDK. The OTLP/HTTP JSON protocol is a single, + * stable `POST /v1/metrics` of a well-defined JSON envelope (produced in core), + * so for a default-disabled feature we avoid pulling the multi-package SDK + * (sdk-metrics + exporter-metrics-otlp-http + resources + api). The wire shape is + * collector-compatible; swapping in the official SDK later is mechanical. + * + * Security: + * - Endpoint is validated on write. In production (`NODE_ENV === "production"`) + * a non-`https:` endpoint is rejected (exporter does not start). Outside + * production an `http://` endpoint is allowed but warns loudly. + * - Auth headers (Datadog/Grafana/etc. tokens) are held in memory only; their + * values are NEVER logged. Header NAMES may appear in diagnostics; header + * VALUES are redacted via `redactSecrets` + an explicit value mask. + * - Collector-unreachable failures log (redacted) and back off exponentially; + * they never throw out of the interval, never crash the server, never block + * a request (the export runs on its own timer). + */ + +import type { TaskStore } from "@fusion/core"; +import { aggregateTokenAnalytics, aggregateActivityAnalytics, mapAnalyticsToOtlp } from "@fusion/core"; +import { redactSecrets } from "@fusion/core"; +import type { RuntimeLogger } from "./runtime-logger.js"; + +/** Resolved, validated exporter configuration. */ +export interface OtelExporterConfig { + /** Full OTLP/HTTP metrics endpoint, e.g. `https://collector:4318/v1/metrics`. */ + endpoint: string; + /** Auth + other headers to send (values are secret-class — never logged). */ + headers: Record; + /** Export interval in ms. */ + intervalMs: string extends never ? never : number; + /** Per-request timeout in ms. */ + timeoutMs: number; + /** Resource attributes (e.g. service.name). */ + resourceAttributes: Record; +} + +/** Minimum/maximum bounds for the export interval (ms). */ +const MIN_INTERVAL_MS = 5_000; +const MAX_INTERVAL_MS = 60 * 60 * 1000; +const DEFAULT_INTERVAL_MS = 60_000; +const DEFAULT_TIMEOUT_MS = 10_000; + +/** Backoff bounds for an unreachable collector. */ +const BACKOFF_BASE_MS = 30_000; +const BACKOFF_MAX_MS = 15 * 60 * 1000; + +/** A single redacted header key (value masked) for diagnostics. */ +const HEADER_VALUE_MASK = "[REDACTED]"; + +function isProduction(env: NodeJS.ProcessEnv): boolean { + return env.NODE_ENV === "production"; +} + +/** + * Parse `key=value,key2=value2` header / attribute lists (the OTEL convention). + * Whitespace around keys/values is trimmed; malformed pairs are skipped. + */ +export function parseKeyValueList(raw: string | undefined): Record { + const out: Record = {}; + if (!raw) return out; + for (const pair of raw.split(",")) { + const eq = pair.indexOf("="); + if (eq <= 0) continue; + const key = pair.slice(0, eq).trim(); + const value = pair.slice(eq + 1).trim(); + if (key) out[key] = value; + } + return out; +} + +function clampInterval(value: number | undefined): number { + if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_INTERVAL_MS; + return Math.min(MAX_INTERVAL_MS, Math.max(MIN_INTERVAL_MS, value)); +} + +/** + * Resolve exporter config from environment. Returns `null` (disabled) when no + * endpoint is configured, OR when the endpoint fails production https validation + * (the caller logs the rejection). This is the **disabled-by-default** gate: + * `FUSION_OTEL_METRICS_ENDPOINT` must be explicitly set to enable. + * + * Recognized env: + * - `FUSION_OTEL_METRICS_ENDPOINT` — full `/v1/metrics` URL (required to enable) + * - `FUSION_OTEL_METRICS_HEADERS` — `key=value,key2=value2` auth headers + * - `FUSION_OTEL_METRICS_INTERVAL_MS` — export interval (default 60_000) + * - `FUSION_OTEL_METRICS_TIMEOUT_MS` — per-request timeout (default 10_000) + * - `FUSION_OTEL_RESOURCE_ATTRIBUTES` — `key=value,...` resource attributes + */ +export function resolveOtelExporterConfig( + env: NodeJS.ProcessEnv = process.env, +): + | { kind: "disabled" } + | { kind: "rejected"; reason: string; endpoint: string } + | { kind: "enabled"; config: OtelExporterConfig; warnHttp: boolean } { + const endpoint = env.FUSION_OTEL_METRICS_ENDPOINT?.trim(); + if (!endpoint) return { kind: "disabled" }; + + let url: URL; + try { + url = new URL(endpoint); + } catch { + return { kind: "rejected", reason: "endpoint is not a valid URL", endpoint }; + } + + if (url.protocol !== "https:" && url.protocol !== "http:") { + return { + kind: "rejected", + reason: `unsupported protocol "${url.protocol}" (only http/https)`, + endpoint, + }; + } + + const isHttp = url.protocol === "http:"; + if (isHttp && isProduction(env)) { + return { + kind: "rejected", + reason: "http:// endpoints are not allowed in production (use https://)", + endpoint, + }; + } + + const intervalMs = clampInterval( + env.FUSION_OTEL_METRICS_INTERVAL_MS + ? Number.parseInt(env.FUSION_OTEL_METRICS_INTERVAL_MS, 10) + : undefined, + ); + const timeoutRaw = env.FUSION_OTEL_METRICS_TIMEOUT_MS + ? Number.parseInt(env.FUSION_OTEL_METRICS_TIMEOUT_MS, 10) + : undefined; + const timeoutMs = + typeof timeoutRaw === "number" && Number.isFinite(timeoutRaw) && timeoutRaw > 0 + ? timeoutRaw + : DEFAULT_TIMEOUT_MS; + + const headers = parseKeyValueList(env.FUSION_OTEL_METRICS_HEADERS); + const resourceAttributes = { + "service.name": "fusion-dashboard", + ...parseKeyValueList(env.FUSION_OTEL_RESOURCE_ATTRIBUTES), + }; + + return { + kind: "enabled", + warnHttp: isHttp, + config: { + endpoint, + headers, + intervalMs: intervalMs as OtelExporterConfig["intervalMs"], + timeoutMs, + resourceAttributes, + }, + }; +} + +/** Diagnostic-safe view of headers: keys preserved, values masked + redacted. */ +export function redactHeadersForDiagnostics( + headers: Record, +): Record { + const out: Record = {}; + for (const key of Object.keys(headers)) { + // Never surface the value; mask it. The key alone (e.g. "DD-API-KEY") is + // safe and useful for debugging which auth scheme is configured. + out[key] = HEADER_VALUE_MASK; + } + return out; +} + +/** Minimal fetch-like signature so tests can inject a collector stub. */ +export type FetchLike = ( + url: string, + init: { + method: string; + headers: Record; + body: string; + signal?: AbortSignal; + }, +) => Promise<{ ok: boolean; status: number; text?: () => Promise }>; + +export interface OtelExporterDeps { + store: TaskStore; + config: OtelExporterConfig; + logger: RuntimeLogger; + /** Injectable fetch (defaults to global `fetch`). */ + fetchImpl?: FetchLike; + /** Injectable clock for `timeUnixNano` (defaults to `Date.now`). */ + now?: () => number; +} + +/** + * A running OTLP metrics exporter. Holds an interval that maps current analytics + * and POSTs them. `stop()` clears the timer and any in-flight backoff. + */ +export interface OtelExporterHandle { + /** Run a single export now (used by tests; the timer calls this internally). */ + exportOnce(): Promise; + /** Stop the periodic exporter and release the timer. */ + stop(): void; +} + +/** + * Start the periodic OTLP metrics exporter. The caller is responsible for only + * invoking this when {@link resolveOtelExporterConfig} returned `enabled`. + * + * The export is wrapped so a collector failure logs (redacted) and backs off + * exponentially without ever throwing out of the timer. + */ +export function startOtelExporter(deps: OtelExporterDeps): OtelExporterHandle { + const { store, config, logger } = deps; + const fetchImpl: FetchLike = + deps.fetchImpl ?? ((url, init) => fetch(url, init) as unknown as ReturnType); + const now = deps.now ?? Date.now; + + let stopped = false; + let timer: ReturnType | undefined; + let consecutiveFailures = 0; + + const log = logger.child("otel-exporter"); + + function backoffMs(): number { + if (consecutiveFailures === 0) return config.intervalMs; + const backoff = Math.min( + BACKOFF_MAX_MS, + BACKOFF_BASE_MS * 2 ** (consecutiveFailures - 1), + ); + // Back off, but never poll faster than the configured interval. + return Math.max(config.intervalMs, backoff); + } + + async function exportOnce(): Promise { + // Mapping + DB read are guarded so a malformed snapshot never throws out. + let body: string; + try { + const db = store.getDatabase(); + const tokens = aggregateTokenAnalytics(db, { groupBy: "model", now: now() }); + const activity = aggregateActivityAnalytics(db, {}); + const nowMs = now(); + const payload = mapAnalyticsToOtlp({ + tokens, + activity, + timeUnixNano: String(nowMs * 1_000_000), + resourceAttributes: config.resourceAttributes, + }); + body = JSON.stringify(payload); + } catch (err) { + log.error("Failed to compose OTLP metrics payload", { + message: redactSecrets(err instanceof Error ? err.message : String(err)), + }); + return; + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), config.timeoutMs); + timeout.unref?.(); + try { + const res = await fetchImpl(config.endpoint, { + method: "POST", + headers: { "Content-Type": "application/json", ...config.headers }, + body, + signal: controller.signal, + }); + if (!res.ok) { + consecutiveFailures += 1; + log.warn("OTLP collector returned a non-2xx status; backing off", { + status: res.status, + consecutiveFailures, + // Never log header values; keys only. + headers: redactHeadersForDiagnostics(config.headers), + }); + return; + } + if (consecutiveFailures > 0) { + log.info("OTLP collector reachable again; resuming normal interval", { + afterFailures: consecutiveFailures, + }); + } + consecutiveFailures = 0; + } catch (err) { + consecutiveFailures += 1; + log.warn("OTLP collector unreachable; backing off", { + message: redactSecrets(err instanceof Error ? err.message : String(err)), + consecutiveFailures, + headers: redactHeadersForDiagnostics(config.headers), + }); + } finally { + clearTimeout(timeout); + } + } + + function scheduleNext(): void { + if (stopped) return; + timer = setTimeout(() => { + void exportOnce().finally(scheduleNext); + }, backoffMs()); + timer.unref?.(); + } + + log.info("OTLP metrics exporter started", { + // Endpoint is config (not a secret); headers are masked. + endpoint: config.endpoint, + intervalMs: config.intervalMs, + headers: redactHeadersForDiagnostics(config.headers), + }); + + // First export after one interval (don't block startup). + scheduleNext(); + + return { + exportOnce, + stop() { + stopped = true; + if (timer) clearTimeout(timer); + timer = undefined; + }, + }; +} + +/** + * Convenience wrapper: resolve config from env and, when enabled+valid, start + * the exporter. Returns the handle, or `null` when disabled/rejected (logging + * the rejection). Safe to call unconditionally from server startup — it is a + * no-op unless `FUSION_OTEL_METRICS_ENDPOINT` is set. + */ +export function maybeStartOtelExporter(args: { + store: TaskStore; + logger: RuntimeLogger; + env?: NodeJS.ProcessEnv; + fetchImpl?: FetchLike; + now?: () => number; +}): OtelExporterHandle | null { + const log = args.logger.child("otel-exporter"); + const resolved = resolveOtelExporterConfig(args.env ?? process.env); + if (resolved.kind === "disabled") { + return null; + } + if (resolved.kind === "rejected") { + log.warn("OTLP metrics exporter NOT started (invalid endpoint)", { + endpoint: resolved.endpoint, + reason: resolved.reason, + }); + return null; + } + if (resolved.warnHttp) { + log.warn( + "OTLP metrics endpoint uses http:// — auth tokens will be sent UNENCRYPTED. " + + "Use https:// in any non-local deployment.", + { endpoint: resolved.config.endpoint }, + ); + } + return startOtelExporter({ + store: args.store, + config: resolved.config, + logger: args.logger, + fetchImpl: args.fetchImpl, + now: args.now, + }); +} diff --git a/packages/dashboard/src/server.ts b/packages/dashboard/src/server.ts index 7a95bf798..f5aca4e32 100644 --- a/packages/dashboard/src/server.ts +++ b/packages/dashboard/src/server.ts @@ -75,6 +75,7 @@ import { recoverAlreadyMergedReviewTasksRecoveriesPerDay, } from "./reliability-metrics.js"; import { loadViewChunkManifest } from "./view-chunk-manifest.js"; +import { maybeStartOtelExporter, type OtelExporterHandle } from "./otel-exporter.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -1707,6 +1708,10 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT const originalListen = dashboardApp.listen.bind(dashboardApp); const httpsCreds = options?.https; + // U10: OTLP metrics exporter. Disabled by default — only started when + // FUSION_OTEL_METRICS_ENDPOINT is explicitly configured. Held here so the + // server "close" handler can stop its timer. + let otelExporter: OtelExporterHandle | null = null; dashboardApp.listen = ((...args: Parameters) => { const normalizedArgs = normalizeListenArgsForTests(args) as Parameters; @@ -1731,9 +1736,22 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT server = originalListen(...normalizedArgs); } + // U10: start the OTLP exporter (no-op unless FUSION_OTEL_METRICS_ENDPOINT + // is set). Failures here must never break server startup. + try { + otelExporter = maybeStartOtelExporter({ store, logger: runtimeLogger }); + } catch (error) { + runtimeLogger.warn("OTLP metrics exporter failed to start", { + message: "OTLP metrics exporter failed to start", + ...normalizeErrorForLog(error), + }); + } + server.once("close", () => { clearAiSessionCleanupInterval(); aiSessionStore.stopScheduledCleanup(); + otelExporter?.stop(); + otelExporter = null; (apiRouter as Router & { dispose?: () => void }).dispose?.(); void stopAllDevServers().catch((error) => { runtimeLogger.warn("Failed to shutdown dev-server managers", { From 0a87890bd861294ff5d1efa56667a08c86d89468 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 15 Jun 2026 20:34:52 -0700 Subject: [PATCH 16/21] =?UTF-8?q?feat(knowledge):=20U14=20=E2=80=94=20pers?= =?UTF-8?q?istent=20knowledge=20index?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds knowledge_pages (db migration 118→119) + a deterministic, model-free keyword index of task/PR history in packages/dashboard/src, incrementally refreshed on task completion (task:moved→done listener) and queryable via an auth-gated, project-scoped API. Complements the LLM-extracted insights/memory surfaces rather than duplicating them. Follow-ups: no React view yet; PR-history page population attaches via U18. --- .changeset/u14-knowledge-index.md | 10 + .../core/src/__tests__/db-migrate.test.ts | 30 +- packages/core/src/__tests__/db.test.ts | 44 +- .../core/src/__tests__/goals-schema.test.ts | 2 +- .../core/src/__tests__/insight-store.test.ts | 10 +- .../__tests__/merge-request-record.test.ts | 2 +- .../core/src/__tests__/mission-store.test.ts | 2 +- packages/core/src/__tests__/run-audit.test.ts | 4 +- .../src/__tests__/store-merge-queue.test.ts | 2 +- .../core/src/__tests__/task-documents.test.ts | 2 +- .../core/src/__tests__/usage-events.test.ts | 11 +- packages/core/src/db.ts | 57 ++- .../src/__tests__/knowledge-index.test.ts | 240 +++++++++++ .../register-knowledge-routes.auth.test.ts | 81 ++++ .../register-knowledge-routes.test.ts | 185 +++++++++ packages/dashboard/src/index.ts | 17 + .../dashboard/src/knowledge-index-refresh.ts | 67 +++ packages/dashboard/src/knowledge-index.ts | 386 ++++++++++++++++++ packages/dashboard/src/routes.ts | 6 + .../src/routes/register-git-github.ts | 7 + .../src/routes/register-knowledge-routes.ts | 95 +++++ .../src/store/__tests__/roadmap-store.test.ts | 4 +- 22 files changed, 1208 insertions(+), 56 deletions(-) create mode 100644 .changeset/u14-knowledge-index.md create mode 100644 packages/dashboard/src/__tests__/knowledge-index.test.ts create mode 100644 packages/dashboard/src/__tests__/register-knowledge-routes.auth.test.ts create mode 100644 packages/dashboard/src/__tests__/register-knowledge-routes.test.ts create mode 100644 packages/dashboard/src/knowledge-index-refresh.ts create mode 100644 packages/dashboard/src/knowledge-index.ts create mode 100644 packages/dashboard/src/routes/register-knowledge-routes.ts diff --git a/.changeset/u14-knowledge-index.md b/.changeset/u14-knowledge-index.md new file mode 100644 index 000000000..0027d4e17 --- /dev/null +++ b/.changeset/u14-knowledge-index.md @@ -0,0 +1,10 @@ +--- +"@runfusion/fusion": minor +--- + +Add a persistent, incrementally-refreshed knowledge index (U14) downstream agents can query. + +- **Schema** — new `knowledge_pages` SQLite table (`packages/core/src/db.ts`) with `SCHEMA_VERSION` bumped 118 → 119 (added in the same change as the migration; the fingerprint auto-covers SCHEMA_SQL tables). Keyword search uses a denormalized lowercased `searchText` column with AND-of-terms `LIKE` matching, deliberately avoiding SQLite FTS5 (not available on every build) and any external embedding API. +- **Index module** (`packages/dashboard/src/knowledge-index.ts`) — upsert-by-source-key pages, a model-free keyword query API, and `refreshKnowledgeForTask` that re-indexes a single completed task (one upsert, never a full re-index, so unaffected pages keep their timestamps). This is the delta over the existing `insights`/`memoryView` surfaces, which are LLM-extracted learnings, not a deterministic searchable index of concrete task/PR history. +- **Refresh hook** — `KnowledgeIndexRefreshService` listens for `task:moved → done` (mirroring `GitHubSourceIssueCloseService`) and is wired alongside the other completion listeners; fail-soft so it can never disrupt task completion. +- **Query API** (`register-knowledge-routes.ts`) — `GET /api/knowledge/query` and `POST /api/knowledge/refresh`, registered as an `ApiRouteRegistrar` so they inherit the dashboard's standard session/auth middleware (401 when unauthenticated) and apply `getScopedStore(req)` (no cross-project reads), exactly like U9. diff --git a/packages/core/src/__tests__/db-migrate.test.ts b/packages/core/src/__tests__/db-migrate.test.ts index 5f91c3094..b1ecb81df 100644 --- a/packages/core/src/__tests__/db-migrate.test.ts +++ b/packages/core/src/__tests__/db-migrate.test.ts @@ -715,7 +715,7 @@ describe("schema migration", () => { const row = db.prepare("SELECT deletedAt FROM tasks WHERE id = 'FN-legacy'").get() as { deletedAt: string | null }; expect(row.deletedAt).toBeNull(); - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(119); db.close(); }); @@ -748,7 +748,7 @@ describe("schema migration", () => { { id: "WS-001", mode: "prompt", gateMode: "advisory" }, { id: "WS-002", mode: "script", gateMode: "advisory" }, ]); - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(119); db.close(); }); @@ -798,7 +798,7 @@ describe("schema migration", () => { reviewerContextRetryCount: 0, reviewerFallbackRetryCount: 0, }); - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(119); db.close(); }); @@ -827,7 +827,7 @@ describe("schema migration", () => { const columns = db.prepare("PRAGMA table_info(milestones)").all() as Array<{ name: string }>; expect(columns.map((column) => column.name)).toContain("acceptanceCriteria"); - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(119); db.close(); }); @@ -868,7 +868,7 @@ describe("schema migration", () => { const missionColumns = db.prepare("PRAGMA table_info(missions)").all() as Array<{ name: string }>; expect(missionColumns.map((column) => column.name)).toContain("autoMerge"); - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(119); db.close(); }); @@ -902,7 +902,7 @@ describe("schema migration", () => { { id: "WS-002", mode: "script", enabled: 1, gateMode: "advisory" }, { id: "WS-003", mode: "prompt", enabled: 0, gateMode: "advisory" }, ]); - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(119); db.close(); }); @@ -939,7 +939,7 @@ describe("schema migration", () => { const indexes = db.prepare("PRAGMA index_list(mission_goals)").all() as Array<{ name: string }>; expect(indexes.some((index) => index.name === "idxMissionGoalsGoalId")).toBe(true); - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(119); db.close(); }); @@ -1000,7 +1000,7 @@ describe("schema migration", () => { expect(customFieldsColumn).toBeDefined(); expect(customFieldsColumn?.dflt_value).toBe("'{}'"); - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(119); db.close(); }); @@ -1038,7 +1038,7 @@ describe("schema migration", () => { const indexes = db.prepare("PRAGMA index_list(workflow_settings)").all() as Array<{ name: string }>; expect(indexes.some((index) => index.name === "idx_workflow_settings_project")).toBe(true); - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(119); db.close(); }); @@ -1120,7 +1120,7 @@ describe("schema migration", () => { expect(indexNames).toContain("idx_cli_sessions_chatSessionId"); expect(indexNames).toContain("idx_cli_sessions_project_state"); - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(119); db.close(); }); @@ -1152,7 +1152,7 @@ describe("schema migration", () => { .all() as Array<{ name: string }>; expect(columns.map((column) => column.name)).toContain("cliExecutorAdapterId"); - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(119); db.close(); }); @@ -1162,7 +1162,7 @@ describe("schema migration", () => { const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>; expect(tables.map((row) => row.name)).toContain("cli_sessions"); - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(119); db.close(); }); @@ -1219,20 +1219,20 @@ describe("schema migration", () => { .get() as { migrated_fragment_id: string | null }; expect(stepRow.migrated_fragment_id).toBeNull(); - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(119); db.close(); }); it("migration 109 is idempotent on re-init", () => { const db = new Database(fusionDir); db.init(); - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(119); db.close(); // Re-open the same on-disk DB: already at 109, the 109 block must be a no-op. const reopened = new Database(fusionDir); reopened.init(); - expect(reopened.getSchemaVersion()).toBe(118); + expect(reopened.getSchemaVersion()).toBe(119); const workflowColumns = reopened.prepare("PRAGMA table_info(workflows)").all() as Array<{ name: string }>; expect(workflowColumns.filter((c) => c.name === "kind")).toHaveLength(1); const stepColumns = reopened.prepare("PRAGMA table_info(workflow_steps)").all() as Array<{ name: string }>; diff --git a/packages/core/src/__tests__/db.test.ts b/packages/core/src/__tests__/db.test.ts index aa49d59aa..755b4ecbc 100644 --- a/packages/core/src/__tests__/db.test.ts +++ b/packages/core/src/__tests__/db.test.ts @@ -334,7 +334,7 @@ describe("Database", () => { }); it("seeds schema version", () => { - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(119); }); it("includes tokenUsageCacheWriteTokens on freshly initialized tasks table", () => { @@ -393,7 +393,7 @@ describe("Database", () => { it("is idempotent - calling init() twice does not fail", () => { expect(() => db.init()).not.toThrow(); - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(119); }); it("does not overwrite existing config on re-init", () => { // Update the config @@ -1463,7 +1463,7 @@ describe("schema migrations", () => { db.init(); // Verify version bumped to 29 (includes v1→v2 through v26→v29) - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(119); // Verify new columns exist and existing data is intact const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; @@ -1488,15 +1488,15 @@ describe("schema migrations", () => { const db = new Database(fusionDir); db.init(); - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(119); // Re-init should not fail db.init(); - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(119); // Re-init should not fail db.init(); - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(119); db.close(); }); @@ -1531,7 +1531,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(119); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; expect(cols.map((col) => col.name)).toContain("priority"); @@ -1572,7 +1572,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(119); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const colNames = cols.map((col) => col.name); @@ -1644,7 +1644,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(119); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const colNames = cols.map((col) => col.name); @@ -1884,7 +1884,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(119); const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>; expect(cols.map((col) => col.name)).toContain("attachments"); @@ -1958,7 +1958,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(119); const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>; expect(tables).toEqual([{ name: "agentRatings" }]); @@ -1982,7 +1982,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(119); const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>; expect(tables).toEqual([{ name: "mission_events" }]); @@ -2086,7 +2086,7 @@ describe("schema migrations", () => { db.init(); // Verify version bumped to 29 - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(119); // Verify new columns exist and existing data is intact const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; @@ -2305,7 +2305,7 @@ describe("schema migrations", () => { localDb.init(); - expect(localDb.getSchemaVersion()).toBe(118); + expect(localDb.getSchemaVersion()).toBe(119); const columns = localDb.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; expect(columns.map((column) => column.name)).toContain("tokenUsageCacheWriteTokens"); @@ -2616,7 +2616,7 @@ describe("createDatabase factory", () => { const db = createDatabase(fusionDir); db.init(); - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(119); expect(db.getLastModified()).toBeGreaterThan(0); db.close(); @@ -2770,7 +2770,7 @@ describe("migration v77 task token budget columns", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(118); + expect(migrated.getSchemaVersion()).toBe(119); const rows = migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const names = new Set(rows.map((row) => row.name)); expect(names.has("tokenBudgetSoftAlertedAt")).toBe(true); @@ -2801,7 +2801,7 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => { const fresh = new Database(fusion); try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(118); + expect(fresh.getSchemaVersion()).toBe(119); const names = new Set( (fresh.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name), ); @@ -2829,7 +2829,7 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(118); + expect(migrated.getSchemaVersion()).toBe(119); const names = new Set( (migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name), ); @@ -2855,7 +2855,7 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => { const fresh = new Database(fusion); try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(118); + expect(fresh.getSchemaVersion()).toBe(119); const table = fresh .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'") .get() as { name: string } | undefined; @@ -2889,7 +2889,7 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(118); + expect(migrated.getSchemaVersion()).toBe(119); const table = migrated .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'") .get() as { name: string } | undefined; @@ -2930,7 +2930,7 @@ describe("migration v67 drops orphan project auth tables", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(118); + expect(migrated.getSchemaVersion()).toBe(119); const tables = migrated .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'") .all() as Array<{ name: string }>; @@ -2957,7 +2957,7 @@ describe("migration v67 drops orphan project auth tables", () => { try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(118); + expect(fresh.getSchemaVersion()).toBe(119); const tables = fresh .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'") .all() as Array<{ name: string }>; diff --git a/packages/core/src/__tests__/goals-schema.test.ts b/packages/core/src/__tests__/goals-schema.test.ts index 75cf8c431..faea0da0b 100644 --- a/packages/core/src/__tests__/goals-schema.test.ts +++ b/packages/core/src/__tests__/goals-schema.test.ts @@ -91,6 +91,6 @@ describe("goals schema", () => { }); it("reports schema version 101", () => { - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(119); }); }); diff --git a/packages/core/src/__tests__/insight-store.test.ts b/packages/core/src/__tests__/insight-store.test.ts index 84f57c78a..e7bac40c8 100644 --- a/packages/core/src/__tests__/insight-store.test.ts +++ b/packages/core/src/__tests__/insight-store.test.ts @@ -1000,7 +1000,7 @@ describe("Migration: pre-33 DB upgrade", () => { // Step 1: Create a fresh database at v33 (runs all migrations up to 33) const db1 = createDatabase(legacyDir); db1.init(); - expect(db1.getSchemaVersion()).toBe(118); + expect(db1.getSchemaVersion()).toBe(119); db1.close(); // Step 2: Manually downgrade to version 32 and drop insight tables @@ -1035,7 +1035,7 @@ describe("Migration: pre-33 DB upgrade", () => { expect(tableNamesBefore).not.toContain("project_insight_runs"); // Now run init — this triggers the v32→v33 migration db3.init(); - expect(db3.getSchemaVersion()).toBe(118); + expect(db3.getSchemaVersion()).toBe(119); // Step 4: Verify insight tables exist after migration const tablesAfter = db3.prepare( @@ -1066,12 +1066,12 @@ describe("Migration: pre-33 DB upgrade", () => { try { const db1 = createDatabase(testDir); db1.init(); - expect(db1.getSchemaVersion()).toBe(118); + expect(db1.getSchemaVersion()).toBe(119); db1.close(); const db2 = createDatabase(testDir); expect(() => db2.init()).not.toThrow(); - expect(db2.getSchemaVersion()).toBe(118); + expect(db2.getSchemaVersion()).toBe(119); db2.close(); } finally { rmSync(testDir, { recursive: true, force: true }); @@ -1085,7 +1085,7 @@ describe("Migration: pre-33 DB upgrade", () => { // Step 1: Create a fresh DB and run migrations const db1 = createDatabase(compatDir); db1.init(); - expect(db1.getSchemaVersion()).toBe(118); + expect(db1.getSchemaVersion()).toBe(119); // Step 2: Strip lifecycle and cancelledAt columns by recreating the // table without them. This simulates a DB that was created before the diff --git a/packages/core/src/__tests__/merge-request-record.test.ts b/packages/core/src/__tests__/merge-request-record.test.ts index f68511ee1..088c8a267 100644 --- a/packages/core/src/__tests__/merge-request-record.test.ts +++ b/packages/core/src/__tests__/merge-request-record.test.ts @@ -38,7 +38,7 @@ describe("TaskStore merge request record + completion handoff marker", () => { .all() as Array<{ name: string }>; expect(tableRows).toEqual([{ name: "completion_handoff_markers" }, { name: "merge_requests" }]); - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(119); }); it("upserts merge request records", async () => { diff --git a/packages/core/src/__tests__/mission-store.test.ts b/packages/core/src/__tests__/mission-store.test.ts index 71b9dd675..a214d7288 100644 --- a/packages/core/src/__tests__/mission-store.test.ts +++ b/packages/core/src/__tests__/mission-store.test.ts @@ -3746,7 +3746,7 @@ describe("MissionStore", () => { describe("Loop State & Validator Run Schema (v31)", () => { it("schema version is 101 after migration", () => { - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(119); }); it("mission_features table has loop state columns", () => { diff --git a/packages/core/src/__tests__/run-audit.test.ts b/packages/core/src/__tests__/run-audit.test.ts index 68faeb4cd..26fb06041 100644 --- a/packages/core/src/__tests__/run-audit.test.ts +++ b/packages/core/src/__tests__/run-audit.test.ts @@ -583,8 +583,8 @@ describe("Run Audit", () => { expect(indexNames).toContain("idxRunAuditEventsTimestamp"); }); - it("schema version is bumped to 118", () => { - expect(db.getSchemaVersion()).toBe(118); + it("schema version is bumped to 119", () => { + expect(db.getSchemaVersion()).toBe(119); }); }); }); diff --git a/packages/core/src/__tests__/store-merge-queue.test.ts b/packages/core/src/__tests__/store-merge-queue.test.ts index f4311184d..39cb2c691 100644 --- a/packages/core/src/__tests__/store-merge-queue.test.ts +++ b/packages/core/src/__tests__/store-merge-queue.test.ts @@ -60,7 +60,7 @@ describe("TaskStore merge queue", () => { expect.arrayContaining(["idx_mergeQueue_lease_ready", "idx_mergeQueue_leaseExpiresAt"]), ); - expect(store.getDatabase().getSchemaVersion()).toBe(118); + expect(store.getDatabase().getSchemaVersion()).toBe(119); }); it("migrates a legacy v88 database and preserves task rows", async () => { diff --git a/packages/core/src/__tests__/task-documents.test.ts b/packages/core/src/__tests__/task-documents.test.ts index ec9257507..f22558db4 100644 --- a/packages/core/src/__tests__/task-documents.test.ts +++ b/packages/core/src/__tests__/task-documents.test.ts @@ -51,7 +51,7 @@ describe("TaskStore task documents", () => { expect(tableNames.has("task_documents")).toBe(true); expect(tableNames.has("task_document_revisions")).toBe(true); - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(119); const index = db .prepare( diff --git a/packages/core/src/__tests__/usage-events.test.ts b/packages/core/src/__tests__/usage-events.test.ts index d17587b55..6ae70ac96 100644 --- a/packages/core/src/__tests__/usage-events.test.ts +++ b/packages/core/src/__tests__/usage-events.test.ts @@ -175,15 +175,18 @@ describe("usage_events", () => { expect(rows[0].agentId).toBe("A-chat"); }); - // Migration: seed a DB at the PREVIOUS schema version, run migrate, assert - // the table exists and SCHEMA_VERSION equals the highest migration target. - // Fresh-DB tests cannot catch the early-return bug this guards. + // Migration: seed a DB at the version JUST BEFORE usage_events was introduced + // (117 — usage_events is the v118 migration), run migrate, assert the table + // exists and SCHEMA_VERSION reaches the highest migration target. Pinned to + // 117 (not SCHEMA_VERSION-1) so it keeps exercising usage_events' own + // migration as later migrations are added. Fresh-DB tests cannot catch the + // migrate-loop early-return bug this guards. it("creates usage_events when migrating from the previous schema version", () => { db.exec("DROP INDEX IF EXISTS idxUsageEventsTs"); db.exec("DROP INDEX IF EXISTS idxUsageEventsTaskId"); db.exec("DROP INDEX IF EXISTS idxUsageEventsAgentId"); db.exec("DROP TABLE IF EXISTS usage_events"); - db.prepare("UPDATE __meta SET value = ? WHERE key = 'schemaVersion'").run(String(SCHEMA_VERSION - 1)); + db.prepare("UPDATE __meta SET value = ? WHERE key = 'schemaVersion'").run("117"); (db as unknown as { migrate: () => void }).migrate(); diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index 78332279f..9c6eb1857 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -162,7 +162,7 @@ export function isFts5CorruptionError(error: unknown): boolean { // ── Schema Definition ──────────────────────────────────────────────── -const SCHEMA_VERSION = 118; +const SCHEMA_VERSION = 119; const TASKS_FTS_AUTOMERGE = 8; const TASKS_FTS_CRISISMERGE = 16; @@ -1230,6 +1230,31 @@ CREATE TABLE IF NOT EXISTS usage_events ( CREATE INDEX IF NOT EXISTS idxUsageEventsTs ON usage_events(ts); CREATE INDEX IF NOT EXISTS idxUsageEventsTaskId ON usage_events(taskId); CREATE INDEX IF NOT EXISTS idxUsageEventsAgentId ON usage_events(agentId); + +-- Persistent, incrementally-refreshed knowledge index (U14). One row per +-- knowledge page (currently one page per completed task; PR-history pages +-- share the same shape). Downstream agents query it through the dashboard's +-- scoped knowledge-index endpoint. searchText is a denormalized lowercased +-- concatenation of the page's title/summary/content + tags used for keyword +-- LIKE matching, so the index works without requiring SQLite FTS5 (which is +-- not available on every build -- see probeFts5 above). Refresh is per-source +-- (upsert by sourceKey), never a full re-index, so unaffected pages keep their +-- timestamps. +CREATE TABLE IF NOT EXISTS knowledge_pages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sourceKind TEXT NOT NULL, + sourceId TEXT NOT NULL, + sourceKey TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + summary TEXT, + content TEXT NOT NULL, + tags TEXT, + searchText TEXT NOT NULL, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idxKnowledgePagesSourceKind ON knowledge_pages(sourceKind); +CREATE INDEX IF NOT EXISTS idxKnowledgePagesUpdatedAt ON knowledge_pages(updatedAt); `; const TABLE_LEVEL_CONSTRAINT_PREFIXES = new Set([ @@ -4773,6 +4798,36 @@ export class Database { }); } + // Migration 119: Persistent knowledge index (U14). One queryable page per + // completed task / PR-history entry, refreshed incrementally (upsert by + // sourceKey) on task completion. Mirrors the SCHEMA_SQL definition above so + // a fresh-from-SCHEMA_SQL DB and a migrated DB converge on the same table. + if (version < 119) { + this.applyMigration(119, () => { + this.db.exec(` + CREATE TABLE IF NOT EXISTS knowledge_pages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sourceKind TEXT NOT NULL, + sourceId TEXT NOT NULL, + sourceKey TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + summary TEXT, + content TEXT NOT NULL, + tags TEXT, + searchText TEXT NOT NULL, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL + ) + `); + this.db.exec(` + CREATE INDEX IF NOT EXISTS idxKnowledgePagesSourceKind ON knowledge_pages(sourceKind) + `); + this.db.exec(` + CREATE INDEX IF NOT EXISTS idxKnowledgePagesUpdatedAt ON knowledge_pages(updatedAt) + `); + }); + } + } /** diff --git a/packages/dashboard/src/__tests__/knowledge-index.test.ts b/packages/dashboard/src/__tests__/knowledge-index.test.ts new file mode 100644 index 000000000..28fb228fb --- /dev/null +++ b/packages/dashboard/src/__tests__/knowledge-index.test.ts @@ -0,0 +1,240 @@ +// @vitest-environment node + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { EventEmitter } from "node:events"; + +import { Database, SCHEMA_VERSION } from "@fusion/core"; +import type { TaskStore } from "@fusion/core"; +import { + upsertKnowledgePage, + queryKnowledgePages, + getKnowledgePage, + countKnowledgePages, + refreshKnowledgeForTask, + renderTaskPage, + tokenizeQuery, + buildSearchText, +} from "../knowledge-index.js"; + +function makeDb(): { db: Database; tmpDir: string } { + const tmpDir = mkdtempSync(join(tmpdir(), "kb-knowledge-index-")); + const db = new Database(join(tmpDir, ".fusion")); + db.init(); + return { db, tmpDir }; +} + +describe("knowledge-index store", () => { + let db: Database; + let tmpDir: string; + + beforeEach(() => { + ({ db, tmpDir } = makeDb()); + }); + + afterEach(() => { + db.close(); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("creates knowledge_pages with the expected columns on fresh init", () => { + const cols = (db.prepare("PRAGMA table_info(knowledge_pages)").all() as Array<{ name: string }>).map( + (c) => c.name, + ); + expect(cols).toEqual([ + "id", + "sourceKind", + "sourceId", + "sourceKey", + "title", + "summary", + "content", + "tags", + "searchText", + "createdAt", + "updatedAt", + ]); + }); + + it("upserts a page and returns it via a keyword query", () => { + const { created } = upsertKnowledgePage(db, { + sourceKind: "task", + sourceId: "T-1", + title: "Add caching layer", + content: "Introduced an LRU cache in fetcher.ts", + tags: ["fetcher.ts"], + }); + expect(created).toBe(true); + const hits = queryKnowledgePages(db, { query: "cache" }); + expect(hits).toHaveLength(1); + expect(hits[0].sourceId).toBe("T-1"); + expect(hits[0].tags).toEqual(["fetcher.ts"]); + }); + + it("AND-matches all query terms", () => { + upsertKnowledgePage(db, { sourceKind: "task", sourceId: "T-1", title: "alpha gadget", content: "only alpha here" }); + upsertKnowledgePage(db, { sourceKind: "task", sourceId: "T-2", title: "alpha thing", content: "beta widget" }); + expect(queryKnowledgePages(db, { query: "alpha widget" }).map((p) => p.sourceId)).toEqual(["T-2"]); + }); + + it("a blank/termless query returns nothing (never the whole index)", () => { + upsertKnowledgePage(db, { sourceKind: "task", sourceId: "T-1", title: "x", content: "y" }); + expect(queryKnowledgePages(db, { query: "" })).toHaveLength(0); + expect(queryKnowledgePages(db, { query: " " })).toHaveLength(0); + expect(countKnowledgePages(db)).toBe(1); + }); + + it("escapes LIKE wildcards so user input can't widen the match", () => { + upsertKnowledgePage(db, { sourceKind: "task", sourceId: "T-1", title: "literal", content: "100% done" }); + // A bare "%" must not match every row; it has no alphanumeric token at all. + expect(queryKnowledgePages(db, { query: "%" })).toHaveLength(0); + // The literal token does match. + expect(queryKnowledgePages(db, { query: "100" })).toHaveLength(1); + }); + + it("incremental refresh updates only the affected page; others keep their timestamps", () => { + const { page: a } = upsertKnowledgePage(db, { + sourceKind: "task", + sourceId: "T-A", + title: "A", + content: "a", + now: "2026-01-01T00:00:00.000Z", + }); + const { page: b } = upsertKnowledgePage(db, { + sourceKind: "task", + sourceId: "T-B", + title: "B", + content: "b", + now: "2026-01-01T00:00:00.000Z", + }); + expect(a.createdAt).toBe("2026-01-01T00:00:00.000Z"); + + // Re-index only T-A at a later time. + const { created, page: aUpdated } = upsertKnowledgePage(db, { + sourceKind: "task", + sourceId: "T-A", + title: "A v2", + content: "a v2", + now: "2026-02-02T00:00:00.000Z", + }); + expect(created).toBe(false); + expect(aUpdated.createdAt).toBe("2026-01-01T00:00:00.000Z"); // createdAt preserved + expect(aUpdated.updatedAt).toBe("2026-02-02T00:00:00.000Z"); // updatedAt advanced + + // T-B is untouched: same updatedAt as when it was created. + const bAfter = getKnowledgePage(db, "task", "T-B"); + expect(bAfter?.updatedAt).toBe(b.updatedAt); + expect(bAfter?.updatedAt).toBe("2026-01-01T00:00:00.000Z"); + // Still exactly two pages — no duplicate created on re-index. + expect(countKnowledgePages(db)).toBe(2); + }); + + // Seed a DB at the PREVIOUS schema version (118), run migrate, assert the + // table exists and SCHEMA_VERSION lands at the highest migration target (119). + // Fresh-DB tests cannot catch the migrate-loop early-return bug this guards. + it("creates knowledge_pages when migrating from the previous schema version", () => { + db.exec("DROP INDEX IF EXISTS idxKnowledgePagesSourceKind"); + db.exec("DROP INDEX IF EXISTS idxKnowledgePagesUpdatedAt"); + db.exec("DROP TABLE IF EXISTS knowledge_pages"); + db.prepare("UPDATE __meta SET value = ? WHERE key = 'schemaVersion'").run(String(SCHEMA_VERSION - 1)); + + (db as unknown as { migrate: () => void }).migrate(); + + const table = db + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='knowledge_pages'") + .get() as { name: string } | undefined; + expect(table?.name).toBe("knowledge_pages"); + expect(db.getSchemaVersion()).toBe(SCHEMA_VERSION); + + // The migrated table is writable and queryable. + upsertKnowledgePage(db, { sourceKind: "task", sourceId: "T-mig", title: "migrated", content: "ok" }); + expect(queryKnowledgePages(db, { query: "migrated" })).toHaveLength(1); + }); + + it("SCHEMA_VERSION matches the highest applied migration on a fresh DB", () => { + expect(db.getSchemaVersion()).toBe(SCHEMA_VERSION); + }); +}); + +describe("knowledge-index pure helpers", () => { + it("tokenizeQuery splits on non-word chars and lowercases", () => { + expect(tokenizeQuery("Add OAuth, login-flow!")).toEqual(["add", "oauth", "login", "flow"]); + expect(tokenizeQuery(" ")).toEqual([]); + }); + + it("buildSearchText concatenates and lowercases all fields", () => { + const text = buildSearchText({ title: "Title", summary: "Sum", content: "Body", tags: ["Tag"] }); + expect(text).toBe("title sum body tag"); + }); + + it("renderTaskPage builds a deterministic page from task facts", () => { + const page = renderTaskPage({ + id: "FN-7", + title: "Fix bug", + description: "Null deref in parser", + modifiedFiles: ["src/parser.ts"], + commitSubjects: ["fix: guard null"], + prUrl: "https://example.com/pr/7", + }); + expect(page.sourceKind).toBe("task"); + expect(page.sourceId).toBe("FN-7"); + expect(page.title).toBe("Fix bug"); + expect(page.content).toContain("Null deref in parser"); + expect(page.content).toContain("src/parser.ts"); + expect(page.content).toContain("fix: guard null"); + expect(page.content).toContain("https://example.com/pr/7"); + expect(page.tags).toEqual(["parser.ts"]); + }); + + it("renderTaskPage falls back to a generated title when none is set", () => { + const page = renderTaskPage({ id: "FN-8", description: "", modifiedFiles: [] }); + expect(page.title).toBe("Task FN-8"); + }); +}); + +describe("refreshKnowledgeForTask hook", () => { + let db: Database; + let tmpDir: string; + + function storeFor(database: Database, tasks: Record): TaskStore { + const store = new EventEmitter() as unknown as TaskStore & { + getDatabase(): Database; + getTask(id: string): Promise; + }; + store.getDatabase = () => database; + store.getTask = async (id: string) => tasks[id] ?? null; + return store; + } + + beforeEach(() => { + ({ db, tmpDir } = makeDb()); + }); + + afterEach(() => { + db.close(); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("indexes a completed task so it becomes queryable", async () => { + const store = storeFor(db, { + "FN-1": { + id: "FN-1", + title: "Implement retry", + description: "Exponential backoff in client.ts", + modifiedFiles: ["client.ts"], + column: "done", + }, + }); + const page = await refreshKnowledgeForTask(store, "FN-1"); + expect(page?.sourceId).toBe("FN-1"); + expect(queryKnowledgePages(db, { query: "backoff" })).toHaveLength(1); + }); + + it("is fail-soft: returns null for a missing task without throwing", async () => { + const store = storeFor(db, {}); + await expect(refreshKnowledgeForTask(store, "nope")).resolves.toBeNull(); + expect(countKnowledgePages(db)).toBe(0); + }); +}); diff --git a/packages/dashboard/src/__tests__/register-knowledge-routes.auth.test.ts b/packages/dashboard/src/__tests__/register-knowledge-routes.auth.test.ts new file mode 100644 index 000000000..1d5e8be48 --- /dev/null +++ b/packages/dashboard/src/__tests__/register-knowledge-routes.auth.test.ts @@ -0,0 +1,81 @@ +// @vitest-environment node + +/** + * Auth integration for the knowledge-index endpoints (U14): every endpoint must + * be rejected with 401 when unauthenticated and accepted with a valid bearer + * token. Mirrors `register-command-center-routes.auth.test.ts` — the registrar + * adds no auth of its own; it inherits the server-level middleware, which is + * exactly what this asserts. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { EventEmitter } from "node:events"; +import type { Task, TaskStore } from "@fusion/core"; +import { request } from "../test-request.js"; +import { createServer } from "../server.js"; + +vi.mock("@fusion/core", async (importOriginal) => { + const { createCoreMock } = await import("../test/mockCoreEngine.js"); + return createCoreMock(() => importOriginal(), {}); +}); + +class MockStore extends EventEmitter { + getRootDir(): string { + return "/tmp/fn-knowledge-auth-test"; + } + + getFusionDir(): string { + return "/tmp/fn-knowledge-auth-test/.fusion"; + } + + getDatabase() { + return { + exec: vi.fn(), + prepare: vi.fn().mockReturnValue({ + run: vi.fn().mockReturnValue({ changes: 0 }), + get: vi.fn().mockReturnValue({ count: 0 }), + all: vi.fn().mockReturnValue([]), + }), + }; + } + + getDatabaseHealth() { + return { + healthy: true, + corruptionDetected: false, + corruptionErrors: [], + isRunning: false, + lastCheckedAt: null, + }; + } + + async listTasks(): Promise { + return []; + } +} + +const TOKEN = "fn_knowledge_test1234567890abc"; + +describe("Knowledge routes — auth", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("rejects an unauthenticated query with 401", async () => { + const app = createServer(new MockStore() as unknown as TaskStore, { + daemon: { token: TOKEN }, + }); + const res = await request(app, "GET", "/api/knowledge/query?q=anything"); + expect(res.status).toBe(401); + }); + + it("accepts the query with a valid bearer token", async () => { + const app = createServer(new MockStore() as unknown as TaskStore, { + daemon: { token: TOKEN }, + }); + const res = await request(app, "GET", "/api/knowledge/query?q=anything", undefined, { + Authorization: `Bearer ${TOKEN}`, + }); + expect(res.status).toBe(200); + }); +}); diff --git a/packages/dashboard/src/__tests__/register-knowledge-routes.test.ts b/packages/dashboard/src/__tests__/register-knowledge-routes.test.ts new file mode 100644 index 000000000..ccba544a6 --- /dev/null +++ b/packages/dashboard/src/__tests__/register-knowledge-routes.test.ts @@ -0,0 +1,185 @@ +// @vitest-environment node + +import express, { type NextFunction, type Request, type Response } from "express"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { EventEmitter } from "node:events"; + +import { Database } from "@fusion/core"; +import type { TaskStore } from "@fusion/core"; +import { request } from "../test-request.js"; +import { ApiError } from "../api-error.js"; +import { registerKnowledgeRoutes } from "../routes/register-knowledge-routes.js"; +import { upsertKnowledgePage } from "../knowledge-index.js"; +import type { ApiRoutesContext } from "../routes/types.js"; + +interface QueryResponse { + query: string; + pages: Array<{ sourceId: string }>; + total: number; +} +interface RefreshResponse { + page: { sourceId: string }; +} + +/** POST JSON helper over the bare `request` (which only accepts string bodies). */ +function postJson( + app: ReturnType, + path: string, + body: unknown, +): ReturnType { + return request(app, "POST", path, JSON.stringify(body), { + "content-type": "application/json", + }); +} + +/** A minimal TaskStore exposing getDatabase()/getTask(), which is all routes use. */ +function storeFor(db: Database, tasks: Record = {}): TaskStore { + const store = new EventEmitter() as unknown as TaskStore & { + getDatabase(): Database; + getTask(id: string): Promise; + }; + store.getDatabase = () => db; + store.getTask = async (id: string) => tasks[id] ?? null; + return store; +} + +function buildApp(stores: Record, fallback: TaskStore) { + const app = express(); + app.use(express.json()); + const router = express.Router(); + const ctx = { + router, + getScopedStore: async (req: Request): Promise => { + const projectId = typeof req.query.projectId === "string" ? req.query.projectId : undefined; + return projectId && stores[projectId] ? stores[projectId] : fallback; + }, + rethrowAsApiError: (error: unknown, fallbackMessage?: string): never => { + if (error instanceof ApiError) throw error; + throw new ApiError(500, fallbackMessage ?? "Internal error"); + }, + } as unknown as ApiRoutesContext; + registerKnowledgeRoutes(ctx); + app.use("/api", router); + app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => { + if (err instanceof ApiError) { + res.status(err.statusCode).json({ error: err.message }); + return; + } + res.status(500).json({ error: "Internal error" }); + }); + return app; +} + +describe("register-knowledge-routes", () => { + let tmpDir: string; + let dbA: Database; + let dbB: Database; + let app: ReturnType; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "kb-knowledge-routes-")); + dbA = new Database(join(tmpDir, "a", ".fusion")); + dbA.init(); + dbB = new Database(join(tmpDir, "b", ".fusion")); + dbB.init(); + + upsertKnowledgePage(dbA, { + sourceKind: "task", + sourceId: "FN-A1", + title: "Add OAuth login flow", + content: "Implemented oauth login with token refresh in auth.ts", + tags: ["auth.ts"], + }); + upsertKnowledgePage(dbB, { + sourceKind: "task", + sourceId: "FN-B1", + title: "Secret project-B widget", + content: "Project B only — confidential widget rendering", + tags: ["widget.ts"], + }); + + const storeA = storeFor(dbA, { + "FN-A2": { + id: "FN-A2", + title: "Refactor payment module", + description: "Cleaned up the stripe payment handler", + modifiedFiles: ["payment.ts"], + column: "done", + }, + }); + const storeB = storeFor(dbB); + app = buildApp({ "proj-a": storeA, "proj-b": storeB }, storeA); + }); + + afterEach(() => { + dbA.close(); + dbB.close(); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("returns relevant pages for a keyword query (fixture)", async () => { + const res = await request(app, "GET", "/api/knowledge/query?q=oauth&projectId=proj-a"); + expect(res.status).toBe(200); + const body = res.body as QueryResponse; + expect(body.pages).toHaveLength(1); + expect(body.pages[0].sourceId).toBe("FN-A1"); + expect(body.total).toBe(1); + }); + + it("returns empty for a non-matching keyword", async () => { + const res = await request(app, "GET", "/api/knowledge/query?q=kubernetes&projectId=proj-a"); + expect(res.status).toBe(200); + expect((res.body as QueryResponse).pages).toHaveLength(0); + }); + + it("returns empty for a blank query rather than the whole index", async () => { + const res = await request(app, "GET", "/api/knowledge/query?q=&projectId=proj-a"); + expect(res.status).toBe(200); + const body = res.body as QueryResponse; + expect(body.pages).toHaveLength(0); + expect(body.total).toBe(1); + }); + + it("project scoping — project-A query cannot read project-B pages", async () => { + // The project-B-only term must never surface for project A. + const leak = await request(app, "GET", "/api/knowledge/query?q=widget&projectId=proj-a"); + expect(leak.status).toBe(200); + expect((leak.body as QueryResponse).pages).toHaveLength(0); + + // ...but is visible to project B. + const ok = await request(app, "GET", "/api/knowledge/query?q=widget&projectId=proj-b"); + expect(ok.status).toBe(200); + const okBody = ok.body as QueryResponse; + expect(okBody.pages).toHaveLength(1); + expect(okBody.pages[0].sourceId).toBe("FN-B1"); + }); + + it("POST /refresh incrementally indexes a completed task, then it is queryable", async () => { + const refresh = await postJson(app, "/api/knowledge/refresh?projectId=proj-a", { + taskId: "FN-A2", + }); + expect(refresh.status).toBe(200); + expect((refresh.body as RefreshResponse).page.sourceId).toBe("FN-A2"); + + const q = await request(app, "GET", "/api/knowledge/query?q=stripe&projectId=proj-a"); + expect(q.status).toBe(200); + const qBody = q.body as QueryResponse; + expect(qBody.pages).toHaveLength(1); + expect(qBody.pages[0].sourceId).toBe("FN-A2"); + }); + + it("POST /refresh returns 404 for an unknown task", async () => { + const res = await postJson(app, "/api/knowledge/refresh?projectId=proj-a", { + taskId: "does-not-exist", + }); + expect(res.status).toBe(404); + }); + + it("POST /refresh requires a taskId", async () => { + const res = await postJson(app, "/api/knowledge/refresh?projectId=proj-a", {}); + expect(res.status).toBe(400); + }); +}); diff --git a/packages/dashboard/src/index.ts b/packages/dashboard/src/index.ts index c7a64658d..3edf33a46 100644 --- a/packages/dashboard/src/index.ts +++ b/packages/dashboard/src/index.ts @@ -37,6 +37,23 @@ export { rateLimit, RATE_LIMITS, type RateLimitOptions } from "./rate-limit.js"; export { GitHubPollingService, type GitHubPollingServiceOptions, type TaskWatchInput, type WatchedBadgeType } from "./github-poll.js"; export { GitHubIssueCommentService, DEFAULT_COMMENT_TEMPLATE } from "./github-issue-comment.js"; export { GitHubSourceIssueCloseService } from "./github-source-issue-close.js"; +export { + upsertKnowledgePage, + queryKnowledgePages, + getKnowledgePage, + countKnowledgePages, + refreshKnowledgeForTask, + renderTaskPage, + buildSearchText, + tokenizeQuery, + KNOWLEDGE_QUERY_DEFAULT_LIMIT, + KNOWLEDGE_QUERY_MAX_LIMIT, + type KnowledgePage, + type KnowledgePageInput, + type KnowledgeSourceKind, + type KnowledgeQueryOptions, +} from "./knowledge-index.js"; +export { KnowledgeIndexRefreshService } from "./knowledge-index-refresh.js"; export { GitHubTrackingCommentService, formatTrackingComment } from "./github-tracking-comments.js"; export { GitHubTrackingStateService, decideIssueAction } from "./github-tracking-state.js"; export { GitHubTrackingReconciler, RECONCILE_CONCURRENCY_LIMIT, RECONCILE_SCAN_LIMIT } from "./github-tracking-reconciler.js"; diff --git a/packages/dashboard/src/knowledge-index-refresh.ts b/packages/dashboard/src/knowledge-index-refresh.ts new file mode 100644 index 000000000..5bc000e1d --- /dev/null +++ b/packages/dashboard/src/knowledge-index-refresh.ts @@ -0,0 +1,67 @@ +import type { TaskStore } from "@fusion/core"; +import { refreshKnowledgeForTask } from "./knowledge-index.js"; + +/** + * Task-completion refresh hook for the persistent knowledge index (U14). + * + * Listens for `task:moved` and, when a task reaches `done`, incrementally + * re-indexes just that task as a knowledge page (one upsert, never a full + * re-index). Mirrors the attach/detach/start/stop lifecycle of + * `GitHubSourceIssueCloseService` so it can be wired the same way alongside the + * other `task:moved` listeners. All refresh work is fail-soft (see + * {@link refreshKnowledgeForTask}) so it can never disrupt task completion. + */ +interface TaskMovedEvent { + task: { id: string }; + // store's `task:moved` carries `ColumnId`; this handler only literal-compares + // legacy ids, so the widened string field is safe. + from: string; + to: string; +} + +export class KnowledgeIndexRefreshService { + private readonly defaultStore: TaskStore; + private readonly listeners = new Map void }>(); + private started = false; + + constructor(store: TaskStore) { + this.defaultStore = store; + } + + start(): void { + if (this.started) return; + this.started = true; + this.attach(this.defaultStore); + } + + stop(): void { + if (!this.started) return; + this.started = false; + for (const store of this.listeners.keys()) { + this.detach(store); + } + } + + attach(store: TaskStore): void { + if (this.listeners.has(store)) return; + const onTaskMoved = (event: TaskMovedEvent): void => { + void this.handleTaskMoved(store, event); + }; + this.listeners.set(store, { onTaskMoved }); + if (this.started) { + store.on("task:moved", onTaskMoved); + } + } + + detach(store: TaskStore): void { + const handlers = this.listeners.get(store); + if (!handlers) return; + store.off("task:moved", handlers.onTaskMoved); + this.listeners.delete(store); + } + + private async handleTaskMoved(store: TaskStore, event: TaskMovedEvent): Promise { + if (event.to !== "done") return; + await refreshKnowledgeForTask(store, event.task.id); + } +} diff --git a/packages/dashboard/src/knowledge-index.ts b/packages/dashboard/src/knowledge-index.ts new file mode 100644 index 000000000..f9db93932 --- /dev/null +++ b/packages/dashboard/src/knowledge-index.ts @@ -0,0 +1,386 @@ +/** + * Persistent knowledge index (U14). + * + * A persistent, incrementally-refreshed knowledge layer that downstream agents + * can query. Each "page" captures the durable, queryable summary of one source + * (currently one page per completed task; PR-history pages share the same row + * shape). Pages are stored in the `knowledge_pages` SQLite table (schema + + * migration 119 in `packages/core/src/db.ts`). + * + * ## Delta over `insights` / `memoryView` + * + * This is intentionally NOT a second copy of the existing surfaces: + * + * - `InsightStore` / `insights-routes.ts` / `InsightsView` store **LLM-extracted + * durable project learnings** ("patterns/principles/pitfalls" mined from + * working memory by an agent run). `memoryView` renders the freeform working/ + * insights **markdown memory files**. Both are *interpretation* layers and both + * require a model run to populate. + * - The knowledge index is a **deterministic, model-free, keyword-searchable + * index of concrete task/PR history** (title, description, modified files, + * commits, PR links). It is refreshed **incrementally on task completion** — + * one upsert per affected page, never a full re-index — and exposes a plain + * keyword **query API** an agent can call to recall "what work touched X". + * + * So the genuinely new capability is: (1) a persistent per-task/PR page store, + * (2) an incremental refresh hook on task completion, and (3) a keyword query + * API — none of which the insights/memory surfaces provide. + * + * ## Search + * + * Matching is plain keyword `LIKE` over a denormalized lowercased `searchText` + * column (AND-of-terms), deliberately avoiding SQLite FTS5 — FTS5 is not + * available on every SQLite build the engine runs on (see `probeFts5` in + * `db.ts`), and the plan scopes this unit to "SQLite full-text/keyword search, + * NOT an external embedding API". + * + * ## Security + * + * The query API is registered as an {@link ApiRouteRegistrar} (see + * `routes/register-knowledge-routes.ts`) so it inherits the dashboard's standard + * session/auth middleware AND resolves the database through `getScopedStore(req)` + * before reading — exactly like U9. The index holds sensitive repo/commit/PR + * content, so it is an information-disclosure surface, never an open endpoint. + */ + +import type { Database, TaskStore } from "@fusion/core"; + +/** The kind of source a knowledge page was indexed from. */ +export type KnowledgeSourceKind = "task" | "pr"; + +/** A knowledge page row as stored/read from `knowledge_pages`. */ +export interface KnowledgePage { + id: number; + sourceKind: KnowledgeSourceKind; + sourceId: string; + /** Stable dedupe key (`:`); upserts target this. */ + sourceKey: string; + title: string; + summary: string | null; + content: string; + tags: string[]; + createdAt: string; + updatedAt: string; +} + +/** Input for {@link upsertKnowledgePage}. */ +export interface KnowledgePageInput { + sourceKind: KnowledgeSourceKind; + sourceId: string; + title: string; + summary?: string | null; + content: string; + tags?: string[]; + /** Injectable clock for deterministic tests. Defaults to now. */ + now?: string; +} + +interface KnowledgePageRow { + id: number; + sourceKind: string; + sourceId: string; + sourceKey: string; + title: string; + summary: string | null; + content: string; + tags: string | null; + searchText: string; + createdAt: string; + updatedAt: string; +} + +/** Maximum number of pages a single keyword query returns. */ +export const KNOWLEDGE_QUERY_DEFAULT_LIMIT = 20; +export const KNOWLEDGE_QUERY_MAX_LIMIT = 100; + +function sourceKeyFor(kind: KnowledgeSourceKind, id: string): string { + return `${kind}:${id}`; +} + +function rowToPage(row: KnowledgePageRow): KnowledgePage { + let tags: string[] = []; + if (row.tags) { + try { + const parsed = JSON.parse(row.tags) as unknown; + if (Array.isArray(parsed)) tags = parsed.filter((t): t is string => typeof t === "string"); + } catch { + tags = []; + } + } + return { + id: row.id, + sourceKind: row.sourceKind as KnowledgeSourceKind, + sourceId: row.sourceId, + sourceKey: row.sourceKey, + title: row.title, + summary: row.summary, + content: row.content, + tags, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +/** + * Build the denormalized, lowercased search blob a page is matched against. + * Pure so it can be unit-tested independently of the DB. + */ +export function buildSearchText(input: { + title: string; + summary?: string | null; + content: string; + tags?: string[]; +}): string { + return [ + input.title, + input.summary ?? "", + input.content, + (input.tags ?? []).join(" "), + ] + .join(" ") + .toLowerCase(); +} + +/** + * Tokenize a free-text query into lowercased keyword terms. Empty / whitespace + * input yields no terms (callers treat that as "match nothing", not "match all", + * to avoid returning the whole sensitive index for a blank query). + */ +export function tokenizeQuery(query: string): string[] { + return query + .toLowerCase() + .split(/[^a-z0-9_]+/i) + .map((t) => t.trim()) + .filter((t) => t.length > 0); +} + +/** + * Insert or update a knowledge page, keyed by `(sourceKind, sourceId)`. + * + * **Incremental by construction:** only the row for this source is touched, so a + * refresh of one task never rewrites (or re-timestamps) any other page. On an + * update, `createdAt` is preserved and only `updatedAt` advances. + * + * @returns the upserted page and whether it was newly created. + */ +export function upsertKnowledgePage( + db: Database, + input: KnowledgePageInput, +): { page: KnowledgePage; created: boolean } { + const now = input.now ?? new Date().toISOString(); + const sourceKey = sourceKeyFor(input.sourceKind, input.sourceId); + const tags = input.tags ?? []; + const searchText = buildSearchText({ + title: input.title, + summary: input.summary, + content: input.content, + tags, + }); + const tagsJson = JSON.stringify(tags); + + const existing = db + .prepare("SELECT * FROM knowledge_pages WHERE sourceKey = ?") + .get(sourceKey) as KnowledgePageRow | undefined; + + if (existing) { + db.prepare( + `UPDATE knowledge_pages + SET title = ?, summary = ?, content = ?, tags = ?, searchText = ?, updatedAt = ? + WHERE sourceKey = ?`, + ).run(input.title, input.summary ?? null, input.content, tagsJson, searchText, now, sourceKey); + const updated = db + .prepare("SELECT * FROM knowledge_pages WHERE sourceKey = ?") + .get(sourceKey) as KnowledgePageRow; + return { page: rowToPage(updated), created: false }; + } + + db.prepare( + `INSERT INTO knowledge_pages + (sourceKind, sourceId, sourceKey, title, summary, content, tags, searchText, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + input.sourceKind, + input.sourceId, + sourceKey, + input.title, + input.summary ?? null, + input.content, + tagsJson, + searchText, + now, + now, + ); + const inserted = db + .prepare("SELECT * FROM knowledge_pages WHERE sourceKey = ?") + .get(sourceKey) as KnowledgePageRow; + return { page: rowToPage(inserted), created: true }; +} + +/** Fetch a single page by its source identity, or `undefined`. */ +export function getKnowledgePage( + db: Database, + sourceKind: KnowledgeSourceKind, + sourceId: string, +): KnowledgePage | undefined { + const row = db + .prepare("SELECT * FROM knowledge_pages WHERE sourceKey = ?") + .get(sourceKeyFor(sourceKind, sourceId)) as KnowledgePageRow | undefined; + return row ? rowToPage(row) : undefined; +} + +/** Options for {@link queryKnowledgePages}. */ +export interface KnowledgeQueryOptions { + query: string; + sourceKind?: KnowledgeSourceKind; + limit?: number; +} + +/** + * Keyword search the index. Returns pages whose `searchText` contains **all** + * query terms (AND), most-recently-updated first. A blank/termless query returns + * an empty list rather than the whole index. + */ +export function queryKnowledgePages(db: Database, options: KnowledgeQueryOptions): KnowledgePage[] { + const terms = tokenizeQuery(options.query); + if (terms.length === 0) return []; + + const limit = Math.min( + Math.max(1, options.limit ?? KNOWLEDGE_QUERY_DEFAULT_LIMIT), + KNOWLEDGE_QUERY_MAX_LIMIT, + ); + + const clauses: string[] = []; + const params: string[] = []; + for (const term of terms) { + clauses.push("searchText LIKE ? ESCAPE '\\'"); + params.push(`%${escapeLike(term)}%`); + } + if (options.sourceKind) { + clauses.push("sourceKind = ?"); + params.push(options.sourceKind); + } + + const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""; + const rows = db + .prepare(`SELECT * FROM knowledge_pages ${where} ORDER BY updatedAt DESC, id DESC LIMIT ?`) + .all(...params, limit) as KnowledgePageRow[]; + return rows.map(rowToPage); +} + +/** Escape SQLite `LIKE` wildcards in a term so user input can't inject them. */ +function escapeLike(term: string): string { + return term.replace(/[\\%_]/g, (ch) => `\\${ch}`); +} + +/** Total number of pages in the index. */ +export function countKnowledgePages(db: Database): number { + const row = db.prepare("SELECT COUNT(*) AS count FROM knowledge_pages").get() as { count: number }; + return row.count; +} + +/** + * Render a completed task into a deterministic knowledge page body. Pure so the + * refresh hook is testable without a real store. Concatenates the durable, + * non-sensitive facts: title, description, modified files, associated commit + * subjects, and PR link if present. + */ +export function renderTaskPage(task: { + id: string; + title?: string; + description?: string; + modifiedFiles?: string[]; + commitSubjects?: string[]; + prUrl?: string | null; + column?: string; +}): KnowledgePageInput { + const title = (task.title ?? "").trim() || `Task ${task.id}`; + const lines: string[] = []; + if (task.description?.trim()) { + lines.push(task.description.trim()); + } + if (task.modifiedFiles && task.modifiedFiles.length > 0) { + lines.push(`Files: ${task.modifiedFiles.join(", ")}`); + } + if (task.commitSubjects && task.commitSubjects.length > 0) { + lines.push(`Commits:\n${task.commitSubjects.map((s) => `- ${s}`).join("\n")}`); + } + if (task.prUrl) { + lines.push(`PR: ${task.prUrl}`); + } + const tags = (task.modifiedFiles ?? []) + .map((f) => f.split("/").pop() ?? f) + .filter((t) => t.length > 0); + return { + sourceKind: "task", + sourceId: task.id, + title, + summary: task.description?.trim().slice(0, 280) || null, + content: lines.join("\n\n") || title, + tags, + }; +} + +/** + * Incremental refresh hook: index (or re-index) a single task as a knowledge + * page. Intended to be invoked from the task-completion path (or by code that + * observes a task reaching `done`). It reads only the one task and upserts only + * its page, so unaffected pages are never touched. + * + * **Fail-soft:** any read/write error is logged and swallowed so a knowledge + * refresh can never break the task-completion flow that called it. + * + * @returns the upserted page, or `null` if the task could not be loaded/indexed. + */ +export async function refreshKnowledgeForTask( + store: TaskStore, + taskId: string, + options?: { now?: string }, +): Promise { + try { + const detail = await store.getTask(taskId); + if (!detail) return null; + + let commitSubjects: string[] = []; + try { + const lineageId = (detail as { lineageId?: string }).lineageId ?? detail.id; + const rows = store + .getDatabase() + .prepare( + "SELECT commitSubject FROM task_commit_associations WHERE taskLineageId = ? ORDER BY authoredAt ASC", + ) + .all(lineageId) as Array<{ commitSubject: string }>; + commitSubjects = rows.map((r) => r.commitSubject); + } catch { + commitSubjects = []; + } + + const prUrl = extractPrUrl(detail); + const input = renderTaskPage({ + id: detail.id, + title: detail.title, + description: detail.description, + modifiedFiles: (detail as { modifiedFiles?: string[] }).modifiedFiles, + commitSubjects, + prUrl, + column: detail.column, + }); + if (options?.now) input.now = options.now; + + const { page } = upsertKnowledgePage(store.getDatabase(), input); + return page; + } catch (err) { + console.warn(`[knowledge-index] refresh skipped for task ${taskId}:`, err); + return null; + } +} + +/** Best-effort extraction of a PR URL from a task detail, tolerant of shape. */ +function extractPrUrl(detail: unknown): string | null { + if (!detail || typeof detail !== "object") return null; + const d = detail as Record; + if (typeof d.prUrl === "string" && d.prUrl) return d.prUrl; + const pr = d.pullRequest as Record | undefined; + if (pr && typeof pr.url === "string" && pr.url) return pr.url; + if (pr && typeof pr.htmlUrl === "string" && pr.htmlUrl) return pr.htmlUrl; + return null; +} diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 3b8a903a0..4a1d627ae 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -169,6 +169,7 @@ import { registerModelRoutes } from "./routes/register-model-routes.js"; import { registerCustomProviderRoutes } from "./routes/register-custom-provider-routes.js"; import { registerUsageRoutes } from "./routes/register-usage-routes.js"; import { registerCommandCenterRoutes } from "./routes/register-command-center-routes.js"; +import { registerKnowledgeRoutes } from "./routes/register-knowledge-routes.js"; import { registerSignalRoutes } from "./routes/register-signal-routes.js"; import { registerAuthRoutes } from "./routes/register-auth-routes.js"; import { registerRuntimeProviderRoutes } from "./routes/register-runtime-provider-routes.js"; @@ -1994,6 +1995,11 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout // U9 — Command Center analytics + live snapshot endpoints. Thin adapters over // the core aggregators; inherit standard auth + getScopedStore project scoping. registerCommandCenterRoutes(routeContext); + // U14 — persistent knowledge index query + incremental-refresh endpoints. + // Inherit standard auth + getScopedStore project scoping (same as U9); the + // index holds sensitive repo/PR content so no endpoint is unauthenticated or + // cross-project readable. + registerKnowledgeRoutes(routeContext); // U11 — inbound external signal webhooks (Sentry/Datadog/PagerDuty/generic). // Each route HMAC-verifies against a per-provider secret; never an // unauthenticated task-creation endpoint. diff --git a/packages/dashboard/src/routes/register-git-github.ts b/packages/dashboard/src/routes/register-git-github.ts index 123f1b1d0..df4b4c0e0 100644 --- a/packages/dashboard/src/routes/register-git-github.ts +++ b/packages/dashboard/src/routes/register-git-github.ts @@ -43,6 +43,7 @@ import { GitHubTrackingCommentService } from "../github-tracking-comments.js"; import { GitHubTrackingStateService } from "../github-tracking-state.js"; import { GitHubTrackingReconciler, RECONCILE_SCAN_LIMIT } from "../github-tracking-reconciler.js"; import { GitHubSourceIssueCloseService } from "../github-source-issue-close.js"; +import { KnowledgeIndexRefreshService } from "../knowledge-index-refresh.js"; import { githubRateLimiter } from "../github-poll.js"; import * as projectStoreResolver from "../project-store-resolver.js"; import { generatePrMetadata } from "../pr-metadata-generator.js"; @@ -2485,6 +2486,12 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void { githubSourceIssueCloseService.start(); ctx.registerDispose(() => githubSourceIssueCloseService.stop()); + // U14 — incremental knowledge-index refresh on task completion. Listens for + // task:moved → done and re-indexes just that task as a knowledge page. + const knowledgeIndexRefreshService = new KnowledgeIndexRefreshService(store); + knowledgeIndexRefreshService.start(); + ctx.registerDispose(() => knowledgeIndexRefreshService.stop()); + const githubTrackingStateService = new GitHubTrackingStateService(store); const githubTrackingReconciler = new GitHubTrackingReconciler(); const reconcileScheduledStores = new WeakSet(); diff --git a/packages/dashboard/src/routes/register-knowledge-routes.ts b/packages/dashboard/src/routes/register-knowledge-routes.ts new file mode 100644 index 000000000..07b870e77 --- /dev/null +++ b/packages/dashboard/src/routes/register-knowledge-routes.ts @@ -0,0 +1,95 @@ +import { ApiError } from "../api-error.js"; +import { + queryKnowledgePages, + countKnowledgePages, + refreshKnowledgeForTask, + KNOWLEDGE_QUERY_DEFAULT_LIMIT, + KNOWLEDGE_QUERY_MAX_LIMIT, + type KnowledgeSourceKind, +} from "../knowledge-index.js"; +import type { ApiRouteRegistrar } from "./types.js"; + +/** + * Persistent knowledge-index API (U14). + * + * Thin HTTP adapter over the keyword index in `knowledge-index.ts`. Downstream + * agents call `GET /api/knowledge/query` to recall task/PR history. + * + * Security (same contract as U9 — `register-command-center-routes.ts`): + * - Every route inherits the dashboard's standard session/auth middleware via + * the {@link ApiRouteRegistrar} contract, so an unauthenticated request is + * rejected with 401 by the server-level auth middleware before reaching these + * handlers. No knowledge endpoint is unauthenticated. + * - Every endpoint resolves the database through `getScopedStore(req)` before + * reading/writing, so a project-A caller can never read project-B pages. The + * index holds sensitive repo/commit/PR content, so it is an information- + * disclosure surface, not an open endpoint. + */ + +const VALID_SOURCE_KINDS: ReadonlySet = new Set(["task", "pr"]); + +function resolveSourceKind(query: { sourceKind?: unknown }): KnowledgeSourceKind | undefined { + const raw = typeof query.sourceKind === "string" ? query.sourceKind : undefined; + return raw !== undefined && VALID_SOURCE_KINDS.has(raw) + ? (raw as KnowledgeSourceKind) + : undefined; +} + +function resolveLimit(query: { limit?: unknown }): number { + const raw = typeof query.limit === "string" ? Number.parseInt(query.limit, 10) : NaN; + if (!Number.isFinite(raw)) return KNOWLEDGE_QUERY_DEFAULT_LIMIT; + return Math.min(Math.max(1, raw), KNOWLEDGE_QUERY_MAX_LIMIT); +} + +export const registerKnowledgeRoutes: ApiRouteRegistrar = (ctx) => { + const { router, getScopedStore, rethrowAsApiError } = ctx; + + /** + * GET /api/knowledge/query?q=&sourceKind=task|pr&limit=N + * Keyword search over the project-scoped knowledge index. Returns the matching + * pages (most-recently-updated first) and the total index size. + */ + router.get("/knowledge/query", async (req, res) => { + try { + const store = await getScopedStore(req); + const q = typeof req.query.q === "string" ? req.query.q : ""; + const pages = queryKnowledgePages(store.getDatabase(), { + query: q, + sourceKind: resolveSourceKind(req.query), + limit: resolveLimit(req.query), + }); + res.json({ + query: q, + pages, + total: countKnowledgePages(store.getDatabase()), + }); + } catch (err: unknown) { + if (err instanceof ApiError) throw err; + rethrowAsApiError(err, "Failed to query knowledge index"); + } + }); + + /** + * POST /api/knowledge/refresh { taskId } + * Incrementally re-index a single task as a knowledge page. Exposes the + * task-completion refresh hook over HTTP so the completion path (or an + * operator) can trigger an incremental refresh without a full re-index. + */ + router.post("/knowledge/refresh", async (req, res) => { + try { + const store = await getScopedStore(req); + const taskId = typeof req.body?.taskId === "string" ? req.body.taskId.trim() : ""; + if (!taskId) { + throw new ApiError(400, "taskId is required"); + } + const page = await refreshKnowledgeForTask(store, taskId); + if (!page) { + throw new ApiError(404, `Task not found or could not be indexed: ${taskId}`); + } + res.json({ page }); + } catch (err: unknown) { + if (err instanceof ApiError) throw err; + rethrowAsApiError(err, "Failed to refresh knowledge index"); + } + }); +}; diff --git a/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts b/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts index 48bd18099..7365b0d72 100644 --- a/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts +++ b/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts @@ -743,10 +743,10 @@ describe("RoadmapStore", () => { }); describe("schema version", () => { - it("schema version is 118 after init", () => { + it("schema version is 119 after init", () => { // Tracks @fusion/core's SCHEMA_VERSION (the roadmap store layers on core's // Database). Bump this in lockstep when core adds a migration. - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(119); }); }); From f5bd86214fa71768796c21a77d8e766cb7a75729 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 15 Jun 2026 20:59:37 -0700 Subject: [PATCH 17/21] =?UTF-8?q?feat(monitor):=20U13=20=E2=80=94=20monito?= =?UTF-8?q?r=20stage=20(deployments,=20incidents,=20MTTR)=20closes=20the?= =?UTF-8?q?=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds deployments + incidents tables (db migration 119→120), real MTTR/deploy/ incident aggregation replacing the U7 seam, an auth-gated SSRF-safe deploy/ incident ingestion route, and a monitor trait that auto-opens a single fix task on a regression signal. Storm guard groups by the U11 Signal groupingKey with a threshold gate, cooldown absorption, per-window circuit breaker, and self-loop guard. Also completes the otel test ActivityAnalytics fixture. --- .../src/__tests__/activity-analytics.test.ts | 106 ++++- .../core/src/__tests__/db-migrate.test.ts | 30 +- packages/core/src/__tests__/db.test.ts | 120 +++++- .../core/src/__tests__/goals-schema.test.ts | 2 +- .../core/src/__tests__/insight-store.test.ts | 10 +- .../__tests__/merge-request-record.test.ts | 2 +- .../core/src/__tests__/mission-store.test.ts | 2 +- .../core/src/__tests__/otel-metrics.test.ts | 6 +- packages/core/src/__tests__/run-audit.test.ts | 2 +- .../src/__tests__/store-merge-queue.test.ts | 2 +- .../core/src/__tests__/task-documents.test.ts | 2 +- packages/core/src/activity-analytics.ts | 174 +++++++- packages/core/src/db.ts | 104 ++++- packages/core/src/index.ts | 3 +- .../src/__tests__/monitor-routes.test.ts | 121 ++++++ .../src/__tests__/monitor-store.test.ts | 166 ++++++++ .../src/__tests__/monitor-trait.test.ts | 137 ++++++ packages/dashboard/src/index.ts | 34 ++ packages/dashboard/src/monitor-store.ts | 403 ++++++++++++++++++ packages/dashboard/src/monitor-trait.ts | 195 +++++++++ packages/dashboard/src/routes.ts | 5 + .../dashboard/src/routes/monitor-routes.ts | 170 ++++++++ .../src/store/__tests__/roadmap-store.test.ts | 2 +- 23 files changed, 1730 insertions(+), 68 deletions(-) create mode 100644 packages/dashboard/src/__tests__/monitor-routes.test.ts create mode 100644 packages/dashboard/src/__tests__/monitor-store.test.ts create mode 100644 packages/dashboard/src/__tests__/monitor-trait.test.ts create mode 100644 packages/dashboard/src/monitor-store.ts create mode 100644 packages/dashboard/src/monitor-trait.ts create mode 100644 packages/dashboard/src/routes/monitor-routes.ts diff --git a/packages/core/src/__tests__/activity-analytics.test.ts b/packages/core/src/__tests__/activity-analytics.test.ts index 3a39ab1ae..4e6ee7203 100644 --- a/packages/core/src/__tests__/activity-analytics.test.ts +++ b/packages/core/src/__tests__/activity-analytics.test.ts @@ -8,11 +8,53 @@ import { Database } from "../db.js"; import { emitUsageEvent } from "../usage-events.js"; import { aggregateActivityAnalytics, + aggregateMonitorMetrics, aggregateSdlcFunnel, buildColumnStageMap, stageForTraits, } from "../activity-analytics.js"; +let incidentSeq = 0; +function insertIncident( + db: Database, + fields: { + groupingKey: string; + status: "open" | "resolved"; + openedAt: string; + resolvedAt?: string | null; + severity?: string; + }, +): string { + const incidentId = `inc-${incidentSeq++}`; + const now = "2026-03-01T00:00:00.000Z"; + db.prepare( + `INSERT INTO incidents + (incidentId, groupingKey, title, severity, status, source, openedAt, resolvedAt, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + incidentId, + fields.groupingKey, + `Incident ${incidentId}`, + fields.severity ?? "error", + fields.status, + "webhook", + fields.openedAt, + fields.resolvedAt ?? null, + now, + now, + ); + return incidentId; +} + +let deploySeq = 0; +function insertDeployment(db: Database, deployedAt: string): void { + const id = `dep-${deploySeq++}`; + db.prepare( + `INSERT INTO deployments (deploymentId, service, environment, deployedAt, createdAt) + VALUES (?, ?, ?, ?, ?)`, + ).run(id, "svc", "prod", deployedAt, deployedAt); +} + let moveSeq = 0; function insertMove( db: Database, @@ -110,9 +152,11 @@ describe("activity-analytics", () => { expect(result.stickiness).toBe(0); }); - it("leaves a clean MTTR seam for U13 (unavailable, not 0)", () => { + it("MTTR is unavailable (not 0) when no incident has been resolved", () => { const result = aggregateActivityAnalytics(db, {}); - expect(result.mttr).toEqual({ value: null, unavailable: true }); + expect(result.mttr).toEqual({ value: null, unavailable: true, sampleCount: 0 }); + expect(result.monitor.openIncidents).toBe(0); + expect(result.monitor.deployments).toBe(0); }); describe("SDLC funnel (U7)", () => { @@ -243,4 +287,62 @@ describe("activity-analytics", () => { } }); }); + + describe("monitor metrics / MTTR (U13)", () => { + const RANGE = { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T23:59:59.999Z" }; + + it("incident opened then resolved yields correct MTTR (minutes)", () => { + // Opened 10:00, resolved 10:30 → 30 minutes. + insertIncident(db, { + groupingKey: "g1", + status: "resolved", + openedAt: "2026-03-02T10:00:00.000Z", + resolvedAt: "2026-03-02T10:30:00.000Z", + }); + const m = aggregateMonitorMetrics(db, RANGE); + expect(m.mttr).toEqual({ value: 30, unavailable: false, sampleCount: 1 }); + expect(m.incidentsResolved).toBe(1); + expect(m.openIncidents).toBe(0); + }); + + it("averages MTTR across multiple resolved incidents", () => { + insertIncident(db, { groupingKey: "g1", status: "resolved", openedAt: "2026-03-02T10:00:00.000Z", resolvedAt: "2026-03-02T10:20:00.000Z" }); // 20m + insertIncident(db, { groupingKey: "g2", status: "resolved", openedAt: "2026-03-03T10:00:00.000Z", resolvedAt: "2026-03-03T11:00:00.000Z" }); // 60m + const m = aggregateMonitorMetrics(db, RANGE); + expect(m.mttr.value).toBe(40); + expect(m.mttr.sampleCount).toBe(2); + }); + + it("unresolved incident contributes to open incidents, NOT to MTTR", () => { + insertIncident(db, { groupingKey: "g1", status: "open", openedAt: "2026-03-02T10:00:00.000Z" }); + const m = aggregateMonitorMetrics(db, RANGE); + expect(m.mttr).toEqual({ value: null, unavailable: true, sampleCount: 0 }); + expect(m.openIncidents).toBe(1); + expect(m.incidentsOpened).toBe(1); + expect(m.incidentsResolved).toBe(0); + }); + + it("a resolution outside the range does not count toward MTTR", () => { + insertIncident(db, { groupingKey: "g1", status: "resolved", openedAt: "2026-02-01T10:00:00.000Z", resolvedAt: "2026-02-01T10:30:00.000Z" }); + const m = aggregateMonitorMetrics(db, RANGE); + expect(m.mttr.unavailable).toBe(true); + expect(m.incidentsResolved).toBe(0); + }); + + it("deploy with no incident counts toward deploy frequency", () => { + insertDeployment(db, "2026-03-05T12:00:00.000Z"); + insertDeployment(db, "2026-03-06T12:00:00.000Z"); + const m = aggregateMonitorMetrics(db, RANGE); + expect(m.deployments).toBe(2); + expect(m.incidentsOpened).toBe(0); + expect(m.mttr.unavailable).toBe(true); + }); + + it("rides the aggregated activity payload (mttr + monitor surfaced)", () => { + insertIncident(db, { groupingKey: "g1", status: "resolved", openedAt: "2026-03-02T10:00:00.000Z", resolvedAt: "2026-03-02T10:30:00.000Z" }); + const result = aggregateActivityAnalytics(db, RANGE); + expect(result.mttr.value).toBe(30); + expect(result.monitor.mttr.value).toBe(30); + }); + }); }); diff --git a/packages/core/src/__tests__/db-migrate.test.ts b/packages/core/src/__tests__/db-migrate.test.ts index b1ecb81df..cd138fe25 100644 --- a/packages/core/src/__tests__/db-migrate.test.ts +++ b/packages/core/src/__tests__/db-migrate.test.ts @@ -715,7 +715,7 @@ describe("schema migration", () => { const row = db.prepare("SELECT deletedAt FROM tasks WHERE id = 'FN-legacy'").get() as { deletedAt: string | null }; expect(row.deletedAt).toBeNull(); - expect(db.getSchemaVersion()).toBe(119); + expect(db.getSchemaVersion()).toBe(120); db.close(); }); @@ -748,7 +748,7 @@ describe("schema migration", () => { { id: "WS-001", mode: "prompt", gateMode: "advisory" }, { id: "WS-002", mode: "script", gateMode: "advisory" }, ]); - expect(db.getSchemaVersion()).toBe(119); + expect(db.getSchemaVersion()).toBe(120); db.close(); }); @@ -798,7 +798,7 @@ describe("schema migration", () => { reviewerContextRetryCount: 0, reviewerFallbackRetryCount: 0, }); - expect(db.getSchemaVersion()).toBe(119); + expect(db.getSchemaVersion()).toBe(120); db.close(); }); @@ -827,7 +827,7 @@ describe("schema migration", () => { const columns = db.prepare("PRAGMA table_info(milestones)").all() as Array<{ name: string }>; expect(columns.map((column) => column.name)).toContain("acceptanceCriteria"); - expect(db.getSchemaVersion()).toBe(119); + expect(db.getSchemaVersion()).toBe(120); db.close(); }); @@ -868,7 +868,7 @@ describe("schema migration", () => { const missionColumns = db.prepare("PRAGMA table_info(missions)").all() as Array<{ name: string }>; expect(missionColumns.map((column) => column.name)).toContain("autoMerge"); - expect(db.getSchemaVersion()).toBe(119); + expect(db.getSchemaVersion()).toBe(120); db.close(); }); @@ -902,7 +902,7 @@ describe("schema migration", () => { { id: "WS-002", mode: "script", enabled: 1, gateMode: "advisory" }, { id: "WS-003", mode: "prompt", enabled: 0, gateMode: "advisory" }, ]); - expect(db.getSchemaVersion()).toBe(119); + expect(db.getSchemaVersion()).toBe(120); db.close(); }); @@ -939,7 +939,7 @@ describe("schema migration", () => { const indexes = db.prepare("PRAGMA index_list(mission_goals)").all() as Array<{ name: string }>; expect(indexes.some((index) => index.name === "idxMissionGoalsGoalId")).toBe(true); - expect(db.getSchemaVersion()).toBe(119); + expect(db.getSchemaVersion()).toBe(120); db.close(); }); @@ -1000,7 +1000,7 @@ describe("schema migration", () => { expect(customFieldsColumn).toBeDefined(); expect(customFieldsColumn?.dflt_value).toBe("'{}'"); - expect(db.getSchemaVersion()).toBe(119); + expect(db.getSchemaVersion()).toBe(120); db.close(); }); @@ -1038,7 +1038,7 @@ describe("schema migration", () => { const indexes = db.prepare("PRAGMA index_list(workflow_settings)").all() as Array<{ name: string }>; expect(indexes.some((index) => index.name === "idx_workflow_settings_project")).toBe(true); - expect(db.getSchemaVersion()).toBe(119); + expect(db.getSchemaVersion()).toBe(120); db.close(); }); @@ -1120,7 +1120,7 @@ describe("schema migration", () => { expect(indexNames).toContain("idx_cli_sessions_chatSessionId"); expect(indexNames).toContain("idx_cli_sessions_project_state"); - expect(db.getSchemaVersion()).toBe(119); + expect(db.getSchemaVersion()).toBe(120); db.close(); }); @@ -1152,7 +1152,7 @@ describe("schema migration", () => { .all() as Array<{ name: string }>; expect(columns.map((column) => column.name)).toContain("cliExecutorAdapterId"); - expect(db.getSchemaVersion()).toBe(119); + expect(db.getSchemaVersion()).toBe(120); db.close(); }); @@ -1162,7 +1162,7 @@ describe("schema migration", () => { const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>; expect(tables.map((row) => row.name)).toContain("cli_sessions"); - expect(db.getSchemaVersion()).toBe(119); + expect(db.getSchemaVersion()).toBe(120); db.close(); }); @@ -1219,20 +1219,20 @@ describe("schema migration", () => { .get() as { migrated_fragment_id: string | null }; expect(stepRow.migrated_fragment_id).toBeNull(); - expect(db.getSchemaVersion()).toBe(119); + expect(db.getSchemaVersion()).toBe(120); db.close(); }); it("migration 109 is idempotent on re-init", () => { const db = new Database(fusionDir); db.init(); - expect(db.getSchemaVersion()).toBe(119); + expect(db.getSchemaVersion()).toBe(120); db.close(); // Re-open the same on-disk DB: already at 109, the 109 block must be a no-op. const reopened = new Database(fusionDir); reopened.init(); - expect(reopened.getSchemaVersion()).toBe(119); + expect(reopened.getSchemaVersion()).toBe(120); const workflowColumns = reopened.prepare("PRAGMA table_info(workflows)").all() as Array<{ name: string }>; expect(workflowColumns.filter((c) => c.name === "kind")).toHaveLength(1); const stepColumns = reopened.prepare("PRAGMA table_info(workflow_steps)").all() as Array<{ name: string }>; diff --git a/packages/core/src/__tests__/db.test.ts b/packages/core/src/__tests__/db.test.ts index 755b4ecbc..caa1a982f 100644 --- a/packages/core/src/__tests__/db.test.ts +++ b/packages/core/src/__tests__/db.test.ts @@ -334,7 +334,7 @@ describe("Database", () => { }); it("seeds schema version", () => { - expect(db.getSchemaVersion()).toBe(119); + expect(db.getSchemaVersion()).toBe(120); }); it("includes tokenUsageCacheWriteTokens on freshly initialized tasks table", () => { @@ -393,7 +393,7 @@ describe("Database", () => { it("is idempotent - calling init() twice does not fail", () => { expect(() => db.init()).not.toThrow(); - expect(db.getSchemaVersion()).toBe(119); + expect(db.getSchemaVersion()).toBe(120); }); it("does not overwrite existing config on re-init", () => { // Update the config @@ -1463,7 +1463,7 @@ describe("schema migrations", () => { db.init(); // Verify version bumped to 29 (includes v1→v2 through v26→v29) - expect(db.getSchemaVersion()).toBe(119); + expect(db.getSchemaVersion()).toBe(120); // Verify new columns exist and existing data is intact const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; @@ -1488,15 +1488,15 @@ describe("schema migrations", () => { const db = new Database(fusionDir); db.init(); - expect(db.getSchemaVersion()).toBe(119); + expect(db.getSchemaVersion()).toBe(120); // Re-init should not fail db.init(); - expect(db.getSchemaVersion()).toBe(119); + expect(db.getSchemaVersion()).toBe(120); // Re-init should not fail db.init(); - expect(db.getSchemaVersion()).toBe(119); + expect(db.getSchemaVersion()).toBe(120); db.close(); }); @@ -1531,7 +1531,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(119); + expect(db.getSchemaVersion()).toBe(120); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; expect(cols.map((col) => col.name)).toContain("priority"); @@ -1572,7 +1572,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(119); + expect(db.getSchemaVersion()).toBe(120); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const colNames = cols.map((col) => col.name); @@ -1644,7 +1644,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(119); + expect(db.getSchemaVersion()).toBe(120); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const colNames = cols.map((col) => col.name); @@ -1884,7 +1884,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(119); + expect(db.getSchemaVersion()).toBe(120); const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>; expect(cols.map((col) => col.name)).toContain("attachments"); @@ -1958,7 +1958,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(119); + expect(db.getSchemaVersion()).toBe(120); const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>; expect(tables).toEqual([{ name: "agentRatings" }]); @@ -1982,7 +1982,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(119); + expect(db.getSchemaVersion()).toBe(120); const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>; expect(tables).toEqual([{ name: "mission_events" }]); @@ -2086,7 +2086,7 @@ describe("schema migrations", () => { db.init(); // Verify version bumped to 29 - expect(db.getSchemaVersion()).toBe(119); + expect(db.getSchemaVersion()).toBe(120); // Verify new columns exist and existing data is intact const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; @@ -2305,7 +2305,7 @@ describe("schema migrations", () => { localDb.init(); - expect(localDb.getSchemaVersion()).toBe(119); + expect(localDb.getSchemaVersion()).toBe(120); const columns = localDb.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; expect(columns.map((column) => column.name)).toContain("tokenUsageCacheWriteTokens"); @@ -2616,7 +2616,7 @@ describe("createDatabase factory", () => { const db = createDatabase(fusionDir); db.init(); - expect(db.getSchemaVersion()).toBe(119); + expect(db.getSchemaVersion()).toBe(120); expect(db.getLastModified()).toBeGreaterThan(0); db.close(); @@ -2770,7 +2770,7 @@ describe("migration v77 task token budget columns", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(119); + expect(migrated.getSchemaVersion()).toBe(120); const rows = migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const names = new Set(rows.map((row) => row.name)); expect(names.has("tokenBudgetSoftAlertedAt")).toBe(true); @@ -2801,7 +2801,7 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => { const fresh = new Database(fusion); try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(119); + expect(fresh.getSchemaVersion()).toBe(120); const names = new Set( (fresh.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name), ); @@ -2829,7 +2829,7 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(119); + expect(migrated.getSchemaVersion()).toBe(120); const names = new Set( (migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name), ); @@ -2855,7 +2855,7 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => { const fresh = new Database(fusion); try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(119); + expect(fresh.getSchemaVersion()).toBe(120); const table = fresh .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'") .get() as { name: string } | undefined; @@ -2889,7 +2889,7 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(119); + expect(migrated.getSchemaVersion()).toBe(120); const table = migrated .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'") .get() as { name: string } | undefined; @@ -2908,6 +2908,82 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => { }); }); +describe("migration v120 adds deployments + incidents tables (U13)", () => { + it("creates the deployments and incidents tables + indexes on fresh init", () => { + const temp = makeTmpDir(); + const fusion = join(temp, ".fusion"); + const fresh = new Database(fusion); + try { + fresh.init(); + expect(fresh.getSchemaVersion()).toBe(120); + const tables = new Set( + ( + fresh + .prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('deployments','incidents')", + ) + .all() as Array<{ name: string }> + ).map((t) => t.name), + ); + expect(tables.has("deployments")).toBe(true); + expect(tables.has("incidents")).toBe(true); + const indexes = new Set( + ( + fresh + .prepare( + "SELECT name FROM sqlite_master WHERE type='index' AND (tbl_name='deployments' OR tbl_name='incidents')", + ) + .all() as Array<{ name: string }> + ).map((i) => i.name), + ); + expect(indexes.has("idxDeploymentsDeployedAt")).toBe(true); + expect(indexes.has("idxIncidentsGroupingKey")).toBe(true); + } finally { + try { fresh.close(); } catch { /* already closed */ } + removeTrackedTmpDirSync(temp); + } + }); + + it("from v119 → init() adds deployments + incidents without dropping existing rows", () => { + const temp = makeTmpDir(); + const fusion = join(temp, ".fusion"); + const localDb = new Database(fusion); + let migrated: Database | undefined; + try { + localDb.init(); + localDb + .prepare('INSERT INTO tasks (id, description, "column", createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)') + .run("FN-V119", "pre-120 row", "todo", "2026-01-01T00:00:00.000Z", "2026-01-01T00:00:00.000Z"); + // Roll back to v119 and drop the tables the v120 migration creates. + localDb.exec("DROP TABLE IF EXISTS deployments"); + localDb.exec("DROP TABLE IF EXISTS incidents"); + localDb.prepare("UPDATE __meta SET value = '119' WHERE key = 'schemaVersion'").run(); + localDb.close(); + + migrated = new Database(fusion); + migrated.init(); + expect(migrated.getSchemaVersion()).toBe(120); + const tables = new Set( + ( + migrated + .prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('deployments','incidents')", + ) + .all() as Array<{ name: string }> + ).map((t) => t.name), + ); + expect(tables.has("deployments")).toBe(true); + expect(tables.has("incidents")).toBe(true); + const task = migrated.prepare("SELECT id FROM tasks WHERE id = ?").get("FN-V119") as { id: string } | undefined; + expect(task?.id).toBe("FN-V119"); + } finally { + try { migrated?.close(); } catch { /* already closed */ } + try { localDb.close(); } catch { /* already closed */ } + removeTrackedTmpDirSync(temp); + } + }); +}); + describe("migration v67 drops orphan project auth tables", () => { it("drops project_auth_* tables left over from the removed pluggable auth feature", () => { const temp = makeTmpDir(); @@ -2930,7 +3006,7 @@ describe("migration v67 drops orphan project auth tables", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(119); + expect(migrated.getSchemaVersion()).toBe(120); const tables = migrated .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'") .all() as Array<{ name: string }>; @@ -2957,7 +3033,7 @@ describe("migration v67 drops orphan project auth tables", () => { try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(119); + expect(fresh.getSchemaVersion()).toBe(120); const tables = fresh .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'") .all() as Array<{ name: string }>; diff --git a/packages/core/src/__tests__/goals-schema.test.ts b/packages/core/src/__tests__/goals-schema.test.ts index faea0da0b..71a7e7bc7 100644 --- a/packages/core/src/__tests__/goals-schema.test.ts +++ b/packages/core/src/__tests__/goals-schema.test.ts @@ -91,6 +91,6 @@ describe("goals schema", () => { }); it("reports schema version 101", () => { - expect(db.getSchemaVersion()).toBe(119); + expect(db.getSchemaVersion()).toBe(120); }); }); diff --git a/packages/core/src/__tests__/insight-store.test.ts b/packages/core/src/__tests__/insight-store.test.ts index e7bac40c8..575c75f7d 100644 --- a/packages/core/src/__tests__/insight-store.test.ts +++ b/packages/core/src/__tests__/insight-store.test.ts @@ -1000,7 +1000,7 @@ describe("Migration: pre-33 DB upgrade", () => { // Step 1: Create a fresh database at v33 (runs all migrations up to 33) const db1 = createDatabase(legacyDir); db1.init(); - expect(db1.getSchemaVersion()).toBe(119); + expect(db1.getSchemaVersion()).toBe(120); db1.close(); // Step 2: Manually downgrade to version 32 and drop insight tables @@ -1035,7 +1035,7 @@ describe("Migration: pre-33 DB upgrade", () => { expect(tableNamesBefore).not.toContain("project_insight_runs"); // Now run init — this triggers the v32→v33 migration db3.init(); - expect(db3.getSchemaVersion()).toBe(119); + expect(db3.getSchemaVersion()).toBe(120); // Step 4: Verify insight tables exist after migration const tablesAfter = db3.prepare( @@ -1066,12 +1066,12 @@ describe("Migration: pre-33 DB upgrade", () => { try { const db1 = createDatabase(testDir); db1.init(); - expect(db1.getSchemaVersion()).toBe(119); + expect(db1.getSchemaVersion()).toBe(120); db1.close(); const db2 = createDatabase(testDir); expect(() => db2.init()).not.toThrow(); - expect(db2.getSchemaVersion()).toBe(119); + expect(db2.getSchemaVersion()).toBe(120); db2.close(); } finally { rmSync(testDir, { recursive: true, force: true }); @@ -1085,7 +1085,7 @@ describe("Migration: pre-33 DB upgrade", () => { // Step 1: Create a fresh DB and run migrations const db1 = createDatabase(compatDir); db1.init(); - expect(db1.getSchemaVersion()).toBe(119); + expect(db1.getSchemaVersion()).toBe(120); // Step 2: Strip lifecycle and cancelledAt columns by recreating the // table without them. This simulates a DB that was created before the diff --git a/packages/core/src/__tests__/merge-request-record.test.ts b/packages/core/src/__tests__/merge-request-record.test.ts index 088c8a267..b2058d322 100644 --- a/packages/core/src/__tests__/merge-request-record.test.ts +++ b/packages/core/src/__tests__/merge-request-record.test.ts @@ -38,7 +38,7 @@ describe("TaskStore merge request record + completion handoff marker", () => { .all() as Array<{ name: string }>; expect(tableRows).toEqual([{ name: "completion_handoff_markers" }, { name: "merge_requests" }]); - expect(db.getSchemaVersion()).toBe(119); + expect(db.getSchemaVersion()).toBe(120); }); it("upserts merge request records", async () => { diff --git a/packages/core/src/__tests__/mission-store.test.ts b/packages/core/src/__tests__/mission-store.test.ts index a214d7288..24e23783f 100644 --- a/packages/core/src/__tests__/mission-store.test.ts +++ b/packages/core/src/__tests__/mission-store.test.ts @@ -3746,7 +3746,7 @@ describe("MissionStore", () => { describe("Loop State & Validator Run Schema (v31)", () => { it("schema version is 101 after migration", () => { - expect(db.getSchemaVersion()).toBe(119); + expect(db.getSchemaVersion()).toBe(120); }); it("mission_features table has loop state columns", () => { diff --git a/packages/core/src/__tests__/otel-metrics.test.ts b/packages/core/src/__tests__/otel-metrics.test.ts index e366f2a2d..bd1b8a9fd 100644 --- a/packages/core/src/__tests__/otel-metrics.test.ts +++ b/packages/core/src/__tests__/otel-metrics.test.ts @@ -47,6 +47,8 @@ function tokenFixture(): TokenAnalytics { } function activityFixture(): ActivityAnalytics { + // Focused fixture: the OTLP mapping only reads the activity gauge fields below, + // so funnel/monitor (U7/U13 additions) are intentionally omitted via the cast. return { from: null, to: null, @@ -56,8 +58,8 @@ function activityFixture(): ActivityAnalytics { activeAgents: 5, daily: [], stickiness: 0.6, - mttr: { value: null, unavailable: true }, - }; + mttr: { value: null, unavailable: true, sampleCount: 0 }, + } as unknown as ActivityAnalytics; } function findMetric(payload: ReturnType, name: string) { diff --git a/packages/core/src/__tests__/run-audit.test.ts b/packages/core/src/__tests__/run-audit.test.ts index 26fb06041..e3e97bb4d 100644 --- a/packages/core/src/__tests__/run-audit.test.ts +++ b/packages/core/src/__tests__/run-audit.test.ts @@ -584,7 +584,7 @@ describe("Run Audit", () => { }); it("schema version is bumped to 119", () => { - expect(db.getSchemaVersion()).toBe(119); + expect(db.getSchemaVersion()).toBe(120); }); }); }); diff --git a/packages/core/src/__tests__/store-merge-queue.test.ts b/packages/core/src/__tests__/store-merge-queue.test.ts index 39cb2c691..0c20810b6 100644 --- a/packages/core/src/__tests__/store-merge-queue.test.ts +++ b/packages/core/src/__tests__/store-merge-queue.test.ts @@ -60,7 +60,7 @@ describe("TaskStore merge queue", () => { expect.arrayContaining(["idx_mergeQueue_lease_ready", "idx_mergeQueue_leaseExpiresAt"]), ); - expect(store.getDatabase().getSchemaVersion()).toBe(119); + expect(store.getDatabase().getSchemaVersion()).toBe(120); }); it("migrates a legacy v88 database and preserves task rows", async () => { diff --git a/packages/core/src/__tests__/task-documents.test.ts b/packages/core/src/__tests__/task-documents.test.ts index f22558db4..ecc41bee2 100644 --- a/packages/core/src/__tests__/task-documents.test.ts +++ b/packages/core/src/__tests__/task-documents.test.ts @@ -51,7 +51,7 @@ describe("TaskStore task documents", () => { expect(tableNames.has("task_documents")).toBe(true); expect(tableNames.has("task_document_revisions")).toBe(true); - expect(db.getSchemaVersion()).toBe(119); + expect(db.getSchemaVersion()).toBe(120); const index = db .prepare( diff --git a/packages/core/src/activity-analytics.ts b/packages/core/src/activity-analytics.ts index e35bf291f..2046fa77e 100644 --- a/packages/core/src/activity-analytics.ts +++ b/packages/core/src/activity-analytics.ts @@ -10,11 +10,11 @@ import type { WorkflowIrColumn } from "./workflow-ir-types.js"; * activity come from `usage_events`. Inclusivity: `from`/`to` are inclusive, * matching `usage-events.ts`. * - * **MTTR seam (U13).** Mean-time-to-resolve aggregation is deliberately NOT - * implemented here yet — it depends on the deployments/incidents tables U13 - * introduces. {@link aggregateActivityAnalytics} returns an `mttr` field set to - * the documented "unavailable" sentinel so the shape is stable now and U13 can - * fill it in without changing callers. See {@link MttrSummary}. + * **MTTR (U13).** Mean-time-to-resolve is computed over the `incidents` table + * introduced by U13: MTTR = mean(resolvedAt − openedAt) across incidents whose + * `resolvedAt` falls within the range. Unresolved incidents contribute to + * "open incidents", not to MTTR. Deployment frequency comes from the + * `deployments` table. See {@link MttrSummary} and {@link MonitorMetrics}. */ export interface ActivityAnalyticsQuery { @@ -34,15 +34,36 @@ export interface DailyActivity { } /** - * MTTR summary placeholder. U13 will populate `value` (mean minutes to resolve) - * once deployments/incidents land; until then it is the documented unavailable - * sentinel — `null` value with `unavailable: true`, never `0`. + * MTTR summary. `value` is the mean minutes to resolve across incidents whose + * `resolvedAt` falls in the range. When no incident has been resolved in range + * MTTR cannot be computed: `value` is `null` and `unavailable` is `true`, never + * `0`. The `sampleCount` is the number of resolved incidents the mean is over. */ export interface MttrSummary { - /** Mean minutes to resolve; null until U13 provides incident data. */ + /** Mean minutes to resolve; null when no resolved incident exists in range. */ value: number | null; - /** True when MTTR cannot be computed (no incident data source yet). */ + /** True when MTTR cannot be computed (no resolved incidents in range). */ unavailable: boolean; + /** Number of resolved incidents the mean is computed over. */ + sampleCount: number; +} + +/** + * Monitor-stage metrics (U13): MTTR plus deployment / incident counts that feed + * the Command Center's External Signals area and the Monitor surface. All counts + * are over the same date range as the parent activity query. + */ +export interface MonitorMetrics { + /** Mean-time-to-resolve over incidents resolved in range. */ + mttr: MttrSummary; + /** Incidents opened (by `openedAt`) within the range. */ + incidentsOpened: number; + /** Incidents resolved (by `resolvedAt`) within the range. */ + incidentsResolved: number; + /** Incidents currently in the `open` state (point-in-time, not range-bound). */ + openIncidents: number; + /** Deployments recorded (by `deployedAt`) within the range — deploy frequency. */ + deployments: number; } export interface ActivityAnalytics { @@ -63,8 +84,10 @@ export interface ActivityAnalytics { * range; MAU = distinct active agents over the whole range. 0 when MAU is 0. */ stickiness: number; - /** MTTR placeholder (U13 seam). */ + /** MTTR over incidents resolved in range (U13). */ mttr: MttrSummary; + /** Full monitor-stage metrics (MTTR + deploy/incident counts) (U13). */ + monitor: MonitorMetrics; /** SDLC funnel + throughput over the same range (U7). */ funnel: SdlcFunnel; } @@ -182,6 +205,9 @@ export function aggregateActivityAnalytics( const mau = activeAgents; const stickiness = mau > 0 ? dau / mau : 0; + // U13: real monitor metrics over the incidents/deployments tables. + const monitor = aggregateMonitorMetrics(db, query); + return { from: query.from ?? null, to: query.to ?? null, @@ -191,8 +217,8 @@ export function aggregateActivityAnalytics( activeAgents, daily, stickiness, - // U13 seam: no incident data source yet — unavailable, not 0. - mttr: { value: null, unavailable: true }, + mttr: monitor.mttr, + monitor, // U7 seam: SDLC funnel/throughput over the same range, mapped by workflow // trait. Uses the built-in workflow's column→trait mapping by default; // callers with a custom workflow IR should call aggregateSdlcFunnel directly @@ -454,3 +480,125 @@ export function aggregateSdlcFunnel( throughputPerDay, }; } + +/* ------------------------------------------------------------------------- */ +/* U13 — Monitor stage: MTTR + deploy/incident metrics */ +/* ------------------------------------------------------------------------- */ + +interface ResolvedIncidentRow { + openedAt: string; + resolvedAt: string; +} + +/** + * Aggregate monitor-stage metrics over a date range from the `incidents` and + * `deployments` tables (U13). + * + * - **MTTR** = mean(resolvedAt − openedAt), in minutes, over incidents whose + * `resolvedAt` is within `[from, to]`. An incident with no `resolvedAt` + * (still open) is excluded — it contributes to {@link MonitorMetrics.openIncidents}, + * never to MTTR. When no incident is resolved in range, MTTR is the documented + * unavailable sentinel (`value: null`, `unavailable: true`), never `0`. + * - **incidentsOpened** counts incidents by `openedAt` in range. + * - **incidentsResolved** counts incidents by `resolvedAt` in range. + * - **openIncidents** is the current count of `status = 'open'` incidents + * (point-in-time, deliberately not range-bound — "how many are open now"). + * - **deployments** counts deploys by `deployedAt` in range (deploy frequency). + * + * Tables are queried defensively: if `incidents`/`deployments` are absent (a DB + * predating migration 120), every metric degrades to its empty value rather than + * throwing, so the aggregator is safe to call on any schema. + */ +export function aggregateMonitorMetrics( + db: Database, + query: ActivityAnalyticsQuery = {}, +): MonitorMetrics { + if (!tableExists(db, "incidents")) { + return { + mttr: { value: null, unavailable: true, sampleCount: 0 }, + incidentsOpened: 0, + incidentsResolved: 0, + openIncidents: 0, + deployments: tableExists(db, "deployments") + ? countDeployments(db, query) + : 0, + }; + } + + const openedRange = rangeClauses("openedAt", query); + const incidentsOpened = ( + db + .prepare(`SELECT COUNT(*) AS count FROM incidents ${openedRange.where}`) + .get(...openedRange.params) as CountRow + ).count; + + // Resolved-in-range: resolvedAt within [from,to]. Build clauses on resolvedAt + // plus a NOT NULL guard so unresolved incidents are excluded from MTTR. + const resolvedRange = rangeClauses("resolvedAt", query); + const resolvedWhere = resolvedRange.where + ? `${resolvedRange.where} AND resolvedAt IS NOT NULL` + : `WHERE resolvedAt IS NOT NULL`; + + const incidentsResolved = ( + db + .prepare(`SELECT COUNT(*) AS count FROM incidents ${resolvedWhere}`) + .get(...resolvedRange.params) as CountRow + ).count; + + const openIncidents = ( + db + .prepare(`SELECT COUNT(*) AS count FROM incidents WHERE status = 'open'`) + .get() as CountRow + ).count; + + const resolvedRows = db + .prepare( + `SELECT openedAt, resolvedAt FROM incidents ${resolvedWhere}`, + ) + .all(...resolvedRange.params) as ResolvedIncidentRow[]; + + let totalMs = 0; + let sampleCount = 0; + for (const row of resolvedRows) { + const opened = Date.parse(row.openedAt); + const resolved = Date.parse(row.resolvedAt); + if (!Number.isFinite(opened) || !Number.isFinite(resolved)) continue; + const delta = resolved - opened; + if (delta < 0) continue; // guard against clock skew / bad data + totalMs += delta; + sampleCount += 1; + } + + const mttr: MttrSummary = + sampleCount === 0 + ? { value: null, unavailable: true, sampleCount: 0 } + : { value: totalMs / sampleCount / 60_000, unavailable: false, sampleCount }; + + return { + mttr, + incidentsOpened, + incidentsResolved, + openIncidents, + deployments: tableExists(db, "deployments") + ? countDeployments(db, query) + : 0, + }; +} + +function countDeployments(db: Database, query: ActivityAnalyticsQuery): number { + const range = rangeClauses("deployedAt", query); + return ( + db + .prepare(`SELECT COUNT(*) AS count FROM deployments ${range.where}`) + .get(...range.params) as CountRow + ).count; +} + +function tableExists(db: Database, table: string): boolean { + const row = db + .prepare( + `SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`, + ) + .get(table) as { name: string } | undefined; + return row !== undefined; +} diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index 9c6eb1857..fbd15aa85 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -162,7 +162,7 @@ export function isFts5CorruptionError(error: unknown): boolean { // ── Schema Definition ──────────────────────────────────────────────── -const SCHEMA_VERSION = 119; +const SCHEMA_VERSION = 120; const TASKS_FTS_AUTOMERGE = 8; const TASKS_FTS_CRISISMERGE = 16; @@ -1255,6 +1255,47 @@ CREATE TABLE IF NOT EXISTS knowledge_pages ( ); CREATE INDEX IF NOT EXISTS idxKnowledgePagesSourceKind ON knowledge_pages(sourceKind); CREATE INDEX IF NOT EXISTS idxKnowledgePagesUpdatedAt ON knowledge_pages(updatedAt); + +-- Monitor stage: deployments + incidents (U13). Deployments are recorded from +-- CI/Ship events; incidents are opened from U11 signals and resolved when the +-- underlying signal clears. MTTR = mean(resolvedAt - openedAt) over resolved +-- incidents in range (aggregated in activity-analytics.ts). Both ingest through +-- the authenticated monitor-routes endpoint and feed the Command Center. +CREATE TABLE IF NOT EXISTS deployments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + deploymentId TEXT NOT NULL UNIQUE, + service TEXT, + environment TEXT, + version TEXT, + status TEXT, + deployedAt TEXT NOT NULL, + link TEXT, + meta TEXT, + createdAt TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idxDeploymentsDeployedAt ON deployments(deployedAt); +CREATE INDEX IF NOT EXISTS idxDeploymentsService ON deployments(service); + +CREATE TABLE IF NOT EXISTS incidents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + incidentId TEXT NOT NULL UNIQUE, + groupingKey TEXT NOT NULL, + title TEXT NOT NULL, + severity TEXT, + status TEXT NOT NULL, + source TEXT, + fixTaskId TEXT, + openedAt TEXT NOT NULL, + resolvedAt TEXT, + link TEXT, + meta TEXT, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idxIncidentsGroupingKey ON incidents(groupingKey); +CREATE INDEX IF NOT EXISTS idxIncidentsStatus ON incidents(status); +CREATE INDEX IF NOT EXISTS idxIncidentsOpenedAt ON incidents(openedAt); +CREATE INDEX IF NOT EXISTS idxIncidentsResolvedAt ON incidents(resolvedAt); `; const TABLE_LEVEL_CONSTRAINT_PREFIXES = new Set([ @@ -4828,6 +4869,67 @@ export class Database { }); } + // Migration 120: Monitor stage — deployments + incidents tables (U13). + // Deployments are recorded from CI/Ship events; incidents are opened from + // U11 signals and resolved when the signal clears. MTTR is computed over + // resolved incidents in activity-analytics.ts. Mirrors the SCHEMA_SQL + // definition above so a fresh-from-SCHEMA_SQL DB and a migrated DB converge + // on the same tables. + if (version < 120) { + this.applyMigration(120, () => { + this.db.exec(` + CREATE TABLE IF NOT EXISTS deployments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + deploymentId TEXT NOT NULL UNIQUE, + service TEXT, + environment TEXT, + version TEXT, + status TEXT, + deployedAt TEXT NOT NULL, + link TEXT, + meta TEXT, + createdAt TEXT NOT NULL + ) + `); + this.db.exec(` + CREATE INDEX IF NOT EXISTS idxDeploymentsDeployedAt ON deployments(deployedAt) + `); + this.db.exec(` + CREATE INDEX IF NOT EXISTS idxDeploymentsService ON deployments(service) + `); + this.db.exec(` + CREATE TABLE IF NOT EXISTS incidents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + incidentId TEXT NOT NULL UNIQUE, + groupingKey TEXT NOT NULL, + title TEXT NOT NULL, + severity TEXT, + status TEXT NOT NULL, + source TEXT, + fixTaskId TEXT, + openedAt TEXT NOT NULL, + resolvedAt TEXT, + link TEXT, + meta TEXT, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL + ) + `); + this.db.exec(` + CREATE INDEX IF NOT EXISTS idxIncidentsGroupingKey ON incidents(groupingKey) + `); + this.db.exec(` + CREATE INDEX IF NOT EXISTS idxIncidentsStatus ON incidents(status) + `); + this.db.exec(` + CREATE INDEX IF NOT EXISTS idxIncidentsOpenedAt ON incidents(openedAt) + `); + this.db.exec(` + CREATE INDEX IF NOT EXISTS idxIncidentsResolvedAt ON incidents(resolvedAt) + `); + }); + } + } /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 98e038aa3..e1f60d9db 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -558,12 +558,13 @@ export type { ToolCategoryCount, InterventionBreakdown, } from "./tool-analytics.js"; -export { aggregateActivityAnalytics } from "./activity-analytics.js"; +export { aggregateActivityAnalytics, aggregateMonitorMetrics } from "./activity-analytics.js"; export type { ActivityAnalytics, ActivityAnalyticsQuery, DailyActivity, MttrSummary, + MonitorMetrics, } from "./activity-analytics.js"; export { aggregateProductivityAnalytics } from "./productivity-analytics.js"; export type { diff --git a/packages/dashboard/src/__tests__/monitor-routes.test.ts b/packages/dashboard/src/__tests__/monitor-routes.test.ts new file mode 100644 index 000000000..6d3d3fee3 --- /dev/null +++ b/packages/dashboard/src/__tests__/monitor-routes.test.ts @@ -0,0 +1,121 @@ +// @vitest-environment node + +/** + * U13 — Monitor route auth + ingestion. Two security layers: + * 1. the server-level daemon bearer-token middleware (gates all /api/*), and + * 2. the route-level monitor ingestion secret (FUSION_MONITOR_INGEST_SECRET). + * An unauthenticated deploy/incident POST returns 401 and records NOTHING. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { EventEmitter } from "node:events"; +import type { Task, TaskStore } from "@fusion/core"; +import { request } from "../test-request.js"; +import { createServer } from "../server.js"; +import { + isAuthorizedMonitorIngest, + MONITOR_INGEST_SECRET_ENV, +} from "../routes/monitor-routes.js"; + +vi.mock("@fusion/core", async (importOriginal) => { + const { createCoreMock } = await import("../test/mockCoreEngine.js"); + return createCoreMock(() => importOriginal(), {}); +}); + +class MockStore extends EventEmitter { + getRootDir(): string { + return "/tmp/fn-monitor-routes-test"; + } + getFusionDir(): string { + return "/tmp/fn-monitor-routes-test/.fusion"; + } + getDatabase() { + return { + exec: vi.fn(), + bumpLastModified: vi.fn(), + prepare: vi.fn().mockReturnValue({ + run: vi.fn().mockReturnValue({ changes: 1 }), + get: vi.fn().mockReturnValue({ count: 0, name: "incidents" }), + all: vi.fn().mockReturnValue([]), + }), + }; + } + getDatabaseHealth() { + return { healthy: true, corruptionDetected: false, corruptionErrors: [], isRunning: false, lastCheckedAt: null }; + } + async listTasks(): Promise { + return []; + } +} + +const DAEMON_TOKEN = "fn_monitor_daemon_1234567890"; +const INGEST_SECRET = "monitor_ingest_secret_abcdef"; + +describe("monitor routes — auth", () => { + beforeEach(() => { + vi.clearAllMocks(); + delete process.env[MONITOR_INGEST_SECRET_ENV]; + }); + afterEach(() => { + delete process.env[MONITOR_INGEST_SECRET_ENV]; + }); + + const json = (obj: unknown): string => JSON.stringify(obj); + const CT = { "content-type": "application/json" }; + + it("rejects a deploy POST with no daemon token (401)", async () => { + const app = createServer(new MockStore() as unknown as TaskStore, { daemon: { token: DAEMON_TOKEN } }); + const res = await request(app, "POST", "/api/monitor/deployments", json({ service: "api" }), CT); + expect(res.status).toBe(401); + }); + + it("rejects a deploy POST that passes daemon auth but has no ingest secret configured (401)", async () => { + const app = createServer(new MockStore() as unknown as TaskStore, { daemon: { token: DAEMON_TOKEN } }); + const res = await request(app, "POST", "/api/monitor/deployments", json({ service: "api" }), { + ...CT, + Authorization: `Bearer ${DAEMON_TOKEN}`, + }); + // Daemon token allows it past the middleware, but the route requires its own + // ingest secret which is unset → 401. + expect(res.status).toBe(401); + }); + + it("rejects an incident POST with a daemon-only token (no ingest secret match) (401)", async () => { + process.env[MONITOR_INGEST_SECRET_ENV] = INGEST_SECRET; + const app = createServer(new MockStore() as unknown as TaskStore, { daemon: { token: DAEMON_TOKEN } }); + // Satisfies the daemon middleware but not the route's ingest secret. + const res = await request(app, "POST", "/api/monitor/incidents", json({ groupingKey: "g1", title: "x" }), { + ...CT, + Authorization: `Bearer ${DAEMON_TOKEN}`, + }); + expect(res.status).toBe(401); + }); + + it("accepts a deploy POST when daemon token == ingest secret", async () => { + // When the daemon token and ingest secret are the same value, one bearer + // satisfies both layers → the deploy is recorded (201). + process.env[MONITOR_INGEST_SECRET_ENV] = DAEMON_TOKEN; + const app = createServer(new MockStore() as unknown as TaskStore, { daemon: { token: DAEMON_TOKEN } }); + const res = await request(app, "POST", "/api/monitor/deployments", json({ service: "api" }), { + ...CT, + Authorization: `Bearer ${DAEMON_TOKEN}`, + }); + expect(res.status).toBe(201); + expect((res.body as { ok: boolean }).ok).toBe(true); + }); +}); + +describe("isAuthorizedMonitorIngest", () => { + it("is false when no secret is configured (never unauthenticated)", () => { + expect(isAuthorizedMonitorIngest({ authorization: "Bearer anything" }, {})).toBe(false); + }); + it("is false on a missing token", () => { + expect(isAuthorizedMonitorIngest({}, { [MONITOR_INGEST_SECRET_ENV]: "s" })).toBe(false); + }); + it("is false on a wrong token", () => { + expect(isAuthorizedMonitorIngest({ authorization: "Bearer wrong" }, { [MONITOR_INGEST_SECRET_ENV]: "right" })).toBe(false); + }); + it("is true on a matching token", () => { + expect(isAuthorizedMonitorIngest({ authorization: "Bearer right" }, { [MONITOR_INGEST_SECRET_ENV]: "right" })).toBe(true); + }); +}); diff --git a/packages/dashboard/src/__tests__/monitor-store.test.ts b/packages/dashboard/src/__tests__/monitor-store.test.ts new file mode 100644 index 000000000..7e2e1a9df --- /dev/null +++ b/packages/dashboard/src/__tests__/monitor-store.test.ts @@ -0,0 +1,166 @@ +// @vitest-environment node + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { Database, aggregateMonitorMetrics } from "@fusion/core"; +import { + recordDeployment, + ingestIncidentSignal, + resolveIncident, + getOpenIncidentByGroupingKey, + attachFixTask, + decideStormGuard, + countRecentAutoFixTasks, + DEFAULT_STORM_GUARD, + type Incident, +} from "../monitor-store.js"; + +function makeDb(): { db: Database; tmpDir: string } { + const tmpDir = mkdtempSync(join(tmpdir(), "kb-monitor-store-")); + const db = new Database(join(tmpDir, ".fusion")); + db.init(); + return { db, tmpDir }; +} + +describe("monitor-store (U13)", () => { + let db: Database; + let tmpDir: string; + + beforeEach(() => { + ({ db, tmpDir } = makeDb()); + }); + afterEach(() => { + db.close(); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + describe("deployments", () => { + it("records a deployment and counts it toward deploy frequency", () => { + recordDeployment(db, { service: "api", environment: "prod", deployedAt: "2026-03-05T12:00:00.000Z" }); + const m = aggregateMonitorMetrics(db, {}); + expect(m.deployments).toBe(1); + expect(m.incidentsOpened).toBe(0); + }); + + it("is idempotent by deploymentId (upsert, not duplicate)", () => { + recordDeployment(db, { deploymentId: "d1", deployedAt: "2026-03-05T12:00:00.000Z" }); + recordDeployment(db, { deploymentId: "d1", deployedAt: "2026-03-05T12:00:00.000Z", status: "rolled-back" }); + const m = aggregateMonitorMetrics(db, {}); + expect(m.deployments).toBe(1); + }); + }); + + describe("incidents + MTTR", () => { + it("opens an incident then resolves it → correct MTTR", () => { + ingestIncidentSignal(db, { + groupingKey: "g1", + title: "API 500s", + at: "2026-03-02T10:00:00.000Z", + }); + const resolved = resolveIncident(db, "g1", "2026-03-02T10:30:00.000Z"); + expect(resolved?.status).toBe("resolved"); + + const m = aggregateMonitorMetrics(db, { + from: "2026-03-01T00:00:00.000Z", + to: "2026-03-31T00:00:00.000Z", + }); + expect(m.mttr).toEqual({ value: 30, unavailable: false, sampleCount: 1 }); + expect(m.openIncidents).toBe(0); + }); + + it("a burst sharing one groupingKey absorbs into ONE open incident", () => { + for (let i = 0; i < 100; i += 1) { + ingestIncidentSignal(db, { + groupingKey: "g-burst", + title: "Flood", + at: `2026-03-02T10:0${(i % 6)}:00.000Z`, + }); + } + const open = getOpenIncidentByGroupingKey(db, "g-burst"); + expect(open).not.toBeNull(); + expect(open?.meta?.occurrences).toBe(100); + const m = aggregateMonitorMetrics(db, {}); + expect(m.openIncidents).toBe(1); + expect(m.incidentsOpened).toBe(1); + }); + + it("unresolved incident → open incidents, not MTTR", () => { + ingestIncidentSignal(db, { groupingKey: "g1", title: "Down", at: "2026-03-02T10:00:00.000Z" }); + const m = aggregateMonitorMetrics(db, {}); + expect(m.openIncidents).toBe(1); + expect(m.mttr.unavailable).toBe(true); + }); + + it("resolveIncident returns null when nothing is open", () => { + expect(resolveIncident(db, "nope")).toBeNull(); + }); + }); + + describe("storm guard decision", () => { + function incidentWith(partial: Partial): Incident { + return { + id: 1, + incidentId: "inc-1", + groupingKey: "g1", + title: "t", + severity: "error", + status: "open", + source: "webhook", + fixTaskId: null, + openedAt: "2026-03-02T10:00:00.000Z", + resolvedAt: null, + link: null, + meta: { occurrences: 1, firstFiredAt: "2026-03-02T10:00:00.000Z" }, + createdAt: "2026-03-02T10:00:00.000Z", + updatedAt: "2026-03-02T10:00:00.000Z", + ...partial, + }; + } + const NOW = Date.parse("2026-03-02T10:00:30.000Z"); // 30s after open + + it("suppresses a single flapping firing (gate not met)", () => { + const d = decideStormGuard(incidentWith({ meta: { occurrences: 1, firstFiredAt: "2026-03-02T10:00:00.000Z" } }), 0, DEFAULT_STORM_GUARD, NOW); + expect(d.action).toBe("suppress"); + }); + + it("opens once the occurrence threshold is met", () => { + const d = decideStormGuard(incidentWith({ meta: { occurrences: 3, firstFiredAt: "2026-03-02T10:00:00.000Z" } }), 0, DEFAULT_STORM_GUARD, NOW); + expect(d.action).toBe("open-fix-task"); + }); + + it("opens once the sustained-duration gate is met even below threshold", () => { + const later = Date.parse("2026-03-02T10:10:00.000Z"); // 10 min open + const d = decideStormGuard(incidentWith({ meta: { occurrences: 1, firstFiredAt: "2026-03-02T10:00:00.000Z" } }), 0, DEFAULT_STORM_GUARD, later); + expect(d.action).toBe("open-fix-task"); + }); + + it("absorbs when an incident already has a fix task (cooldown / no self-loop)", () => { + const d = decideStormGuard(incidentWith({ fixTaskId: "FN-1", meta: { occurrences: 50 } }), 0, DEFAULT_STORM_GUARD, NOW); + expect(d.action).toBe("absorb"); + if (d.action === "absorb") expect(d.existingFixTaskId).toBe("FN-1"); + }); + + it("suppresses when the circuit breaker is tripped", () => { + const d = decideStormGuard( + incidentWith({ meta: { occurrences: 5, firstFiredAt: "2026-03-02T10:00:00.000Z" } }), + DEFAULT_STORM_GUARD.maxTasksPerWindow, + DEFAULT_STORM_GUARD, + NOW, + ); + expect(d.action).toBe("suppress"); + if (d.action === "suppress") expect(d.reason).toBe("circuit-breaker"); + }); + }); + + describe("countRecentAutoFixTasks", () => { + it("counts only incidents with a fix task in the window", () => { + const { incident } = ingestIncidentSignal(db, { groupingKey: "g1", title: "t" }); + expect(countRecentAutoFixTasks(db)).toBe(0); + attachFixTask(db, incident.incidentId, "FN-1"); + expect(countRecentAutoFixTasks(db)).toBe(1); + }); + }); +}); diff --git a/packages/dashboard/src/__tests__/monitor-trait.test.ts b/packages/dashboard/src/__tests__/monitor-trait.test.ts new file mode 100644 index 000000000..a756f611c --- /dev/null +++ b/packages/dashboard/src/__tests__/monitor-trait.test.ts @@ -0,0 +1,137 @@ +// @vitest-environment node + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { Database } from "@fusion/core"; +import type { Task, TaskCreateInput, TaskStore } from "@fusion/core"; +import { runMonitorOnRegression, isMonitorFixTask } from "../monitor-trait.js"; +import { DEFAULT_STORM_GUARD } from "../monitor-store.js"; + +/** + * A minimal TaskStore stub: a real Database (for the incidents/deployments + * tables the monitor store writes) plus a `createTask` that records created + * tasks so we can assert exactly how many fix tasks were opened. + */ +function makeStore(db: Database): { store: TaskStore; created: Task[] } { + const created: Task[] = []; + let seq = 0; + const store = { + getDatabase: () => db, + async createTask(input: TaskCreateInput): Promise { + const task = { + id: `FN-${++seq}`, + title: input.title, + description: input.description, + column: input.column, + source: input.source, + } as unknown as Task; + created.push(task); + return task; + }, + } as unknown as TaskStore; + return { store, created }; +} + +function makeDb(): { db: Database; tmpDir: string } { + const tmpDir = mkdtempSync(join(tmpdir(), "kb-monitor-trait-")); + const db = new Database(join(tmpDir, ".fusion")); + db.init(); + return { db, tmpDir }; +} + +describe("monitor-trait runMonitorOnRegression (U13)", () => { + let db: Database; + let tmpDir: string; + + beforeEach(() => { + ({ db, tmpDir } = makeDb()); + }); + afterEach(() => { + db.close(); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("a post-ship error signal past the gate auto-creates ONE linked fix task in triage", async () => { + const { store, created } = makeStore(db); + let outcome; + // Fire 3 times (threshold) sharing one groupingKey. + for (let i = 0; i < 3; i += 1) { + outcome = await runMonitorOnRegression( + { groupingKey: "g1", title: "Checkout 500s", severity: "error", source: "sentry" }, + { store }, + ); + } + expect(created).toHaveLength(1); + expect(outcome?.kind).toBe("fix-task-opened"); + const fix = created[0]; + expect(fix.column).toBe("triage"); + expect(isMonitorFixTask(fix)).toBe(true); + }); + + it("a 100-event burst sharing one groupingKey yields exactly ONE fix task", async () => { + const { store, created } = makeStore(db); + for (let i = 0; i < 100; i += 1) { + await runMonitorOnRegression( + { groupingKey: "g-burst", title: "Flood", severity: "error" }, + { store }, + ); + } + expect(created).toHaveLength(1); + }); + + it("a flapping alert (single firing, gate not met) yields NO new task", async () => { + const { store, created } = makeStore(db); + const outcome = await runMonitorOnRegression( + { groupingKey: "g-flap", title: "Blip", severity: "warning" }, + { store }, + ); + expect(created).toHaveLength(0); + expect(outcome.kind).toBe("suppressed"); + }); + + it("an already-open fix task absorbs repeat signals (cooldown, no second task)", async () => { + const { store, created } = makeStore(db); + // Open a fix task via threshold. + for (let i = 0; i < 3; i += 1) { + await runMonitorOnRegression({ groupingKey: "g1", title: "Down" }, { store }); + } + expect(created).toHaveLength(1); + // Further firings absorb. + const absorbed = await runMonitorOnRegression({ groupingKey: "g1", title: "Down again" }, { store }); + expect(absorbed.kind).toBe("absorbed"); + expect(created).toHaveLength(1); + }); + + it("circuit breaker caps auto-created tasks per window", async () => { + const { store, created } = makeStore(db); + const config = { ...DEFAULT_STORM_GUARD, threshold: 1, maxTasksPerWindow: 2 }; + for (let g = 0; g < 5; g += 1) { + await runMonitorOnRegression({ groupingKey: `g-${g}`, title: "x" }, { store, config }); + } + expect(created).toHaveLength(2); + }); + + it("the sustained-duration gate opens a task for a low-frequency but long-lived incident", async () => { + const { store, created } = makeStore(db); + const past = "2026-03-02T10:00:00.000Z"; + const openMoment = Date.parse(past); + // Open with a single firing (occurrences=1) evaluated AT open time — the + // sustained gate (5 min) is not yet met. + await runMonitorOnRegression( + { groupingKey: "g-slow", title: "Slow leak", at: past }, + { store, nowMs: openMoment }, + ); + expect(created).toHaveLength(0); // first firing: gate not met at open time + // Evaluate "now" 10 minutes later so the sustained gate is satisfied. + const later = Date.parse("2026-03-02T10:10:00.000Z"); + const outcome = await runMonitorOnRegression( + { groupingKey: "g-slow", title: "Slow leak", at: past }, + { store, nowMs: later }, + ); + expect(outcome.kind).toBe("fix-task-opened"); + expect(created).toHaveLength(1); + }); +}); diff --git a/packages/dashboard/src/index.ts b/packages/dashboard/src/index.ts index 3edf33a46..f8e2350bd 100644 --- a/packages/dashboard/src/index.ts +++ b/packages/dashboard/src/index.ts @@ -54,6 +54,40 @@ export { type KnowledgeQueryOptions, } from "./knowledge-index.js"; export { KnowledgeIndexRefreshService } from "./knowledge-index-refresh.js"; +export { + recordDeployment, + ingestIncidentSignal, + resolveIncident, + getOpenIncidentByGroupingKey, + getIncident, + attachFixTask, + decideStormGuard, + countRecentAutoFixTasks, + DEFAULT_STORM_GUARD, + type Deployment, + type DeploymentInput, + type Incident, + type IncidentSignalInput, + type IncidentStatus, + type StormGuardConfig, + type StormGuardDecision, +} from "./monitor-store.js"; +export { + registerMonitorTrait, + runMonitorOnRegression, + isMonitorFixTask, + MONITOR_TRAIT_ID, + MONITOR_TRAIT_DEFINITION, + MONITOR_FIX_ROUTE_COLUMN, + type MonitorDeps, + type MonitorRegressionOutcome, +} from "./monitor-trait.js"; +export { + registerMonitorRoutes, + resolveMonitorIngestSecret, + isAuthorizedMonitorIngest, + MONITOR_INGEST_SECRET_ENV, +} from "./routes/monitor-routes.js"; export { GitHubTrackingCommentService, formatTrackingComment } from "./github-tracking-comments.js"; export { GitHubTrackingStateService, decideIssueAction } from "./github-tracking-state.js"; export { GitHubTrackingReconciler, RECONCILE_CONCURRENCY_LIMIT, RECONCILE_SCAN_LIMIT } from "./github-tracking-reconciler.js"; diff --git a/packages/dashboard/src/monitor-store.ts b/packages/dashboard/src/monitor-store.ts new file mode 100644 index 000000000..25438a725 --- /dev/null +++ b/packages/dashboard/src/monitor-store.ts @@ -0,0 +1,403 @@ +import { randomUUID } from "node:crypto"; +import type { Database } from "@fusion/core"; + +/** + * U13 — Monitor stage storage + storm guard. + * + * Persists deployments (from CI/Ship events) and incidents (from U11 signals) + * into the `deployments` / `incidents` tables (schema + migration 120 in + * `packages/core/src/db.ts`). MTTR and deploy/incident counts are aggregated in + * `packages/core/src/activity-analytics.ts` (`aggregateMonitorMetrics`) — this + * module is the write side + the storm guard that decides when a regression + * signal opens an auto-fix task. + * + * ## Storm guard (closes the loop without flooding the board) + * + * Production signals are bursty. The guard groups re-firing signals by the + * U11 {@link Signal.groupingKey} and applies four gates before (and after) a + * fix task is opened: + * + * 1. **Threshold / sustained-duration gate.** A single, instantly-self-clearing + * (flapping) alert does NOT open a task. An incident must accrue at least + * {@link StormGuardConfig.threshold} firings OR remain open for at least + * {@link StormGuardConfig.sustainedMs} before a fix task is created. + * 2. **Cooldown / absorption.** While an incident for a groupingKey is open and + * already has a fix task, re-firing signals are *attached* to that existing + * incident/fix task (occurrence count bumps) rather than opening a new one. + * The existing fix task is looked up by its dedupe key, mirroring + * `findLatestByDedupeKey` in approval-request-store.ts. + * 3. **Circuit breaker.** No more than {@link StormGuardConfig.maxTasksPerWindow} + * auto-fix tasks are created per {@link StormGuardConfig.windowMs}, capping a + * pathological storm that spans many distinct groupingKeys. + * 4. **Self-loop guard.** A fix task Fusion itself opened never re-triggers the + * guard: signals whose grouping key resolves to a Fusion-opened fix task are + * absorbed, and the monitor trait skips tasks it already produced (mirrors + * U12's no-self-loop rule). + */ + +/** A recorded deployment row. */ +export interface Deployment { + id: number; + deploymentId: string; + service: string | null; + environment: string | null; + version: string | null; + status: string | null; + deployedAt: string; + link: string | null; + meta: Record | null; + createdAt: string; +} + +/** Input to record a deployment (from a CI/Ship event). */ +export interface DeploymentInput { + /** Stable provider id; used for idempotent upsert. Generated if absent. */ + deploymentId?: string; + service?: string; + environment?: string; + version?: string; + status?: string; + /** ISO-8601; defaults to now. */ + deployedAt?: string; + link?: string; + meta?: Record; +} + +export type IncidentStatus = "open" | "resolved"; + +/** A recorded incident row. */ +export interface Incident { + id: number; + incidentId: string; + groupingKey: string; + title: string; + severity: string | null; + status: IncidentStatus; + source: string | null; + fixTaskId: string | null; + openedAt: string; + resolvedAt: string | null; + link: string | null; + meta: Record | null; + createdAt: string; + updatedAt: string; +} + +/** Input to open / re-fire an incident from a normalized signal. */ +export interface IncidentSignalInput { + groupingKey: string; + title: string; + severity?: string; + source?: string; + link?: string; + meta?: Record; + /** Event timestamp (ISO-8601); defaults to now. */ + at?: string; +} + +interface DeploymentRow { + id: number; + deploymentId: string; + service: string | null; + environment: string | null; + version: string | null; + status: string | null; + deployedAt: string; + link: string | null; + meta: string | null; + createdAt: string; +} + +interface IncidentRow { + id: number; + incidentId: string; + groupingKey: string; + title: string; + severity: string | null; + status: string; + source: string | null; + fixTaskId: string | null; + openedAt: string; + resolvedAt: string | null; + link: string | null; + meta: string | null; + createdAt: string; + updatedAt: string; +} + +function parseMeta(value: string | null): Record | null { + if (!value) return null; + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === "object" ? (parsed as Record) : null; + } catch { + return null; + } +} + +function deploymentFromRow(row: DeploymentRow): Deployment { + return { ...row, meta: parseMeta(row.meta) }; +} + +function incidentFromRow(row: IncidentRow): Incident { + return { + ...row, + status: row.status === "resolved" ? "resolved" : "open", + meta: parseMeta(row.meta), + }; +} + +/** + * Occurrence count carried in an incident's `meta.occurrences`. Re-firing + * signals bump this; the threshold gate reads it. + */ +const OCCURRENCES_META_KEY = "occurrences"; +/** First-firing timestamp carried in `meta.firstFiredAt` for the sustained gate. */ +const FIRST_FIRED_META_KEY = "firstFiredAt"; + +// ── Deployments ───────────────────────────────────────────────────────────── + +/** Record a deployment (idempotent by `deploymentId`). */ +export function recordDeployment(db: Database, input: DeploymentInput): Deployment { + const deploymentId = input.deploymentId?.trim() || `dep-${randomUUID()}`; + const now = new Date().toISOString(); + const deployedAt = input.deployedAt ?? now; + const meta = input.meta ? JSON.stringify(input.meta) : null; + + db.prepare( + `INSERT INTO deployments + (deploymentId, service, environment, version, status, deployedAt, link, meta, createdAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(deploymentId) DO UPDATE SET + service = excluded.service, + environment = excluded.environment, + version = excluded.version, + status = excluded.status, + deployedAt = excluded.deployedAt, + link = excluded.link, + meta = excluded.meta`, + ).run( + deploymentId, + input.service ?? null, + input.environment ?? null, + input.version ?? null, + input.status ?? null, + deployedAt, + input.link ?? null, + meta, + now, + ); + db.bumpLastModified(); + + const row = db + .prepare(`SELECT * FROM deployments WHERE deploymentId = ?`) + .get(deploymentId) as DeploymentRow; + return deploymentFromRow(row); +} + +// ── Incidents ─────────────────────────────────────────────────────────────── + +/** Get the currently-open incident for a grouping key, if any. */ +export function getOpenIncidentByGroupingKey( + db: Database, + groupingKey: string, +): Incident | null { + const row = db + .prepare( + `SELECT * FROM incidents WHERE groupingKey = ? AND status = 'open' + ORDER BY openedAt DESC, id DESC LIMIT 1`, + ) + .get(groupingKey) as IncidentRow | undefined; + return row ? incidentFromRow(row) : null; +} + +export function getIncident(db: Database, incidentId: string): Incident | null { + const row = db + .prepare(`SELECT * FROM incidents WHERE incidentId = ?`) + .get(incidentId) as IncidentRow | undefined; + return row ? incidentFromRow(row) : null; +} + +/** + * Ingest an incident signal. If an open incident already exists for the grouping + * key, the firing is ABSORBED into it (occurrence count + updatedAt bumped) — + * this is the cooldown/dedup path. Otherwise a fresh `open` incident is created. + * Returns the incident plus whether it was newly opened. + */ +export function ingestIncidentSignal( + db: Database, + input: IncidentSignalInput, +): { incident: Incident; created: boolean } { + const now = input.at ?? new Date().toISOString(); + const existing = getOpenIncidentByGroupingKey(db, input.groupingKey); + + if (existing) { + // Absorb the re-firing signal into the open incident. + const meta = existing.meta ?? {}; + const occurrences = Number(meta[OCCURRENCES_META_KEY] ?? 1) + 1; + const nextMeta = { + ...meta, + ...(input.meta ?? {}), + [OCCURRENCES_META_KEY]: occurrences, + [FIRST_FIRED_META_KEY]: meta[FIRST_FIRED_META_KEY] ?? existing.openedAt, + }; + db.prepare( + `UPDATE incidents SET updatedAt = ?, meta = ? WHERE incidentId = ?`, + ).run(now, JSON.stringify(nextMeta), existing.incidentId); + db.bumpLastModified(); + const updated = getIncident(db, existing.incidentId); + return { incident: updated ?? existing, created: false }; + } + + const incidentId = `inc-${randomUUID()}`; + const meta = { + ...(input.meta ?? {}), + [OCCURRENCES_META_KEY]: 1, + [FIRST_FIRED_META_KEY]: now, + }; + db.prepare( + `INSERT INTO incidents + (incidentId, groupingKey, title, severity, status, source, fixTaskId, openedAt, resolvedAt, link, meta, createdAt, updatedAt) + VALUES (?, ?, ?, ?, 'open', ?, NULL, ?, NULL, ?, ?, ?, ?)`, + ).run( + incidentId, + input.groupingKey, + input.title, + input.severity ?? null, + input.source ?? null, + now, + input.link ?? null, + JSON.stringify(meta), + now, + now, + ); + db.bumpLastModified(); + const incident = getIncident(db, incidentId); + if (!incident) throw new Error(`incident ${incidentId} not found after insert`); + return { incident, created: true }; +} + +/** + * Resolve an open incident for a grouping key (sets `status = resolved` + + * `resolvedAt`). Returns the resolved incident, or null if none was open. The + * resolution feeds MTTR via {@link aggregateMonitorMetrics}. + */ +export function resolveIncident( + db: Database, + groupingKey: string, + at?: string, +): Incident | null { + const open = getOpenIncidentByGroupingKey(db, groupingKey); + if (!open) return null; + const now = at ?? new Date().toISOString(); + db.prepare( + `UPDATE incidents SET status = 'resolved', resolvedAt = ?, updatedAt = ? WHERE incidentId = ?`, + ).run(now, now, open.incidentId); + db.bumpLastModified(); + return getIncident(db, open.incidentId); +} + +/** Attach a fix task id to an incident (records the loop-closure linkage). */ +export function attachFixTask(db: Database, incidentId: string, fixTaskId: string): void { + const now = new Date().toISOString(); + db.prepare( + `UPDATE incidents SET fixTaskId = ?, updatedAt = ? WHERE incidentId = ?`, + ).run(fixTaskId, now, incidentId); + db.bumpLastModified(); +} + +// ── Storm guard ─────────────────────────────────────────────────────────────── + +export interface StormGuardConfig { + /** Minimum firings before a fix task is opened (threshold gate). */ + threshold: number; + /** Minimum open-duration (ms) that alternatively satisfies the gate. */ + sustainedMs: number; + /** Circuit breaker: max auto-fix tasks created per {@link windowMs}. */ + maxTasksPerWindow: number; + /** Circuit-breaker window (ms). */ + windowMs: number; +} + +export const DEFAULT_STORM_GUARD: StormGuardConfig = { + threshold: 3, + sustainedMs: 5 * 60_000, + maxTasksPerWindow: 10, + windowMs: 60 * 60_000, +}; + +export type StormGuardDecision = + | { action: "open-fix-task"; incident: Incident } + | { action: "absorb"; incident: Incident; existingFixTaskId: string | null; reason: string } + | { action: "suppress"; incident: Incident; reason: string }; + +/** + * Decide what to do with an ingested incident, per the storm guard. Pure given + * the incident's current state (occurrences / first-fired / fixTaskId) plus a + * count of recently-created tasks for the circuit breaker. + * + * - If the incident already has a fix task → ABSORB (cooldown / no self-loop). + * - If the threshold/sustained gate is not yet met → SUPPRESS (flapping guard). + * - If the circuit breaker is tripped → SUPPRESS. + * - Otherwise → OPEN-FIX-TASK. + */ +export function decideStormGuard( + incident: Incident, + recentAutoTaskCount: number, + config: StormGuardConfig = DEFAULT_STORM_GUARD, + nowMs: number = Date.now(), +): StormGuardDecision { + // Already linked to a fix task → absorb repeats (cooldown + no self-loop). + if (incident.fixTaskId) { + return { + action: "absorb", + incident, + existingFixTaskId: incident.fixTaskId, + reason: "existing-fix-task", + }; + } + + const meta = incident.meta ?? {}; + const occurrences = Number(meta[OCCURRENCES_META_KEY] ?? 1); + const firstFired = String(meta[FIRST_FIRED_META_KEY] ?? incident.openedAt); + const firstFiredMs = Date.parse(firstFired); + const openMs = Number.isFinite(firstFiredMs) ? nowMs - firstFiredMs : 0; + + const gatePassed = + occurrences >= config.threshold || openMs >= config.sustainedMs; + if (!gatePassed) { + return { + action: "suppress", + incident, + reason: `gate-not-met (occurrences=${occurrences}, openMs=${openMs})`, + }; + } + + // Circuit breaker: cap auto-created tasks per window. + if (recentAutoTaskCount >= config.maxTasksPerWindow) { + return { action: "suppress", incident, reason: "circuit-breaker" }; + } + + return { action: "open-fix-task", incident }; +} + +/** + * Count auto-fix tasks created within the circuit-breaker window. An auto-fix + * task is one linked to an incident (fixTaskId set) whose incident updatedAt is + * within the window. This is a deliberately coarse proxy that does not require a + * separate audit table. + */ +export function countRecentAutoFixTasks( + db: Database, + config: StormGuardConfig = DEFAULT_STORM_GUARD, + nowMs: number = Date.now(), +): number { + const cutoff = new Date(nowMs - config.windowMs).toISOString(); + const row = db + .prepare( + `SELECT COUNT(*) AS count FROM incidents + WHERE fixTaskId IS NOT NULL AND updatedAt >= ?`, + ) + .get(cutoff) as { count: number }; + return row.count; +} diff --git a/packages/dashboard/src/monitor-trait.ts b/packages/dashboard/src/monitor-trait.ts new file mode 100644 index 000000000..0809f0f19 --- /dev/null +++ b/packages/dashboard/src/monitor-trait.ts @@ -0,0 +1,195 @@ +import type { + Task, + TaskCreateInput, + TaskStore, + TraitDefinition, +} from "@fusion/core"; +import { getTraitRegistry, registerTraitHookImpl } from "@fusion/core"; +import { createSessionDiagnostics } from "./ai-session-diagnostics.js"; +import { + attachFixTask, + countRecentAutoFixTasks, + decideStormGuard, + ingestIncidentSignal, + type IncidentSignalInput, + type StormGuardConfig, +} from "./monitor-store.js"; + +/** + * U13 — Monitor stage trait. + * + * A column carrying the `monitor` trait watches post-ship work. When a card + * enters it, the trait records that the shipped change is now being monitored. + * Separately, a regression signal (an inbound U11 error signal arriving after a + * ship) is fed through {@link runMonitorOnRegression}, which opens — through the + * storm guard — at most ONE linked fix task in `triage`, closing the loop back to + * Triage (U12). + * + * Mirrors `triage-trait.ts`: the trait DEFINITION is registered as a built-in so + * plugins cannot override it; the IMPLEMENTATION lives in dashboard because it + * reuses the monitor store + the task store, wired through the core→dashboard DI + * seam (`registerTraitHookImpl`). The trait never re-triggers on a fix task it + * itself opened (no self-loop), mirroring U12. + */ + +const diagnostics = createSessionDiagnostics("monitor-trait"); + +/** Registry id of the monitor trait. */ +export const MONITOR_TRAIT_ID = "monitor"; + +/** Column an auto-opened fix task lands in (back to the start of the loop). */ +export const MONITOR_FIX_ROUTE_COLUMN = "triage"; + +/** Metadata marking a task as a Fusion-opened monitor fix task (self-loop guard). */ +export const MONITOR_FIX_TASK_META_KEY = "monitorFixForIncidentId"; +/** Metadata carrying the grouping key the fix task addresses. */ +export const MONITOR_FIX_GROUPING_META_KEY = "monitorFixGroupingKey"; + +export const MONITOR_TRAIT_DEFINITION: TraitDefinition = { + id: MONITOR_TRAIT_ID, + name: "Monitor", + description: + "Watch post-ship work; on a regression signal, open a single linked fix task (storm-guarded) back in triage.", + builtin: true, + flags: { notify: true }, + hooks: { onEnter: true }, + configSchema: { + fields: [ + { key: "threshold", type: "number", description: "Firings before a fix task opens" }, + { key: "sustainedMs", type: "number", description: "Sustained open-duration that satisfies the gate (ms)" }, + { key: "maxTasksPerWindow", type: "number", description: "Circuit breaker: max auto-fix tasks per window" }, + ], + }, +}; + +/** + * True if a task is a Fusion-opened monitor fix task (never re-triage / never + * re-trigger the guard on these — no self-loop). + */ +export function isMonitorFixTask(task: Task): boolean { + const meta = (task.source?.sourceMetadata ?? {}) as Record; + return typeof meta[MONITOR_FIX_TASK_META_KEY] === "string"; +} + +function buildFixTaskInput( + signal: IncidentSignalInput, + incidentId: string, +): TaskCreateInput { + const title = `Fix regression: ${signal.title}`; + const lines = [title]; + if (signal.link) lines.push(`\nSource: ${signal.link}`); + lines.push(`\nGrouping key: ${signal.groupingKey}`); + lines.push(`Incident: ${incidentId}`); + return { + title, + description: lines.join("\n"), + column: MONITOR_FIX_ROUTE_COLUMN as TaskCreateInput["column"], + priority: signal.severity === "critical" ? "urgent" : "high", + source: { + sourceType: "automation", + sourceMetadata: { + [MONITOR_FIX_TASK_META_KEY]: incidentId, + [MONITOR_FIX_GROUPING_META_KEY]: signal.groupingKey, + signalSource: signal.source, + signalSeverity: signal.severity, + }, + }, + }; +} + +export interface MonitorDeps { + store: TaskStore; + config?: StormGuardConfig; + /** Injectable clock for deterministic tests. */ + nowMs?: number; +} + +export type MonitorRegressionOutcome = + | { kind: "fix-task-opened"; taskId: string; incidentId: string } + | { kind: "absorbed"; incidentId: string; existingFixTaskId: string | null; reason: string } + | { kind: "suppressed"; incidentId: string; reason: string } + | { kind: "error"; reason: string }; + +/** + * Handle a post-ship regression signal. Ingests it into the incidents table + * (opening or absorbing into an open incident by groupingKey), then runs the + * storm guard: + * + * - absorb → an open incident already has a fix task; bump occurrence, no new task. + * - suppress → flapping (gate not met) or circuit-breaker tripped; no new task. + * - open → create exactly one fix task in triage and link it to the incident. + * + * Idempotent across a burst sharing one groupingKey: the FIRST firing past the + * gate opens the task and links it; every subsequent firing finds the linked + * incident and absorbs. A Fusion-opened fix task never re-enters this path. + */ +export async function runMonitorOnRegression( + signal: IncidentSignalInput, + deps: MonitorDeps, +): Promise { + const { store, config, nowMs } = deps; + const db = store.getDatabase(); + + let incidentId: string; + try { + const { incident } = ingestIncidentSignal(db, signal); + incidentId = incident.incidentId; + + const recent = countRecentAutoFixTasks(db, config, nowMs); + const decision = decideStormGuard(incident, recent, config, nowMs); + + if (decision.action === "absorb") { + return { + kind: "absorbed", + incidentId, + existingFixTaskId: decision.existingFixTaskId, + reason: decision.reason, + }; + } + if (decision.action === "suppress") { + return { kind: "suppressed", incidentId, reason: decision.reason }; + } + + // open-fix-task: create exactly one task and link it (closes the loop). + const task = await store.createTask(buildFixTaskInput(signal, incidentId)); + attachFixTask(db, incidentId, task.id); + return { kind: "fix-task-opened", taskId: task.id, incidentId }; + } catch (err) { + diagnostics.errorFromException("Monitor regression handling failed", err, { + groupingKey: signal.groupingKey, + }); + return { kind: "error", reason: err instanceof Error ? err.message : String(err) }; + } +} + +// ── Registration (DI seam) ────────────────────────────────────────────────── + +let registered = false; + +/** + * Register the monitor trait definition + onEnter hook implementation. The + * onEnter hook records that a shipped task is now monitored; regression-driven + * fix-task creation runs through {@link runMonitorOnRegression} from the signal + * ingestion path, not from onEnter. Idempotent. + */ +export function registerMonitorTrait(): void { + if (registered) return; + const registry = getTraitRegistry(); + if (!registry.has(MONITOR_TRAIT_ID)) { + registry.register(MONITOR_TRAIT_DEFINITION); + } + registerTraitHookImpl(MONITOR_TRAIT_ID, "onEnter", (...args: unknown[]) => { + const ctx = args[0] as { task?: Task } | undefined; + if (!ctx?.task) return undefined; + // Post-ship watch is currently a no-op marker hook; the loop-closing work is + // signal-driven (runMonitorOnRegression). Returning undefined keeps the + // card in place — monitoring is observational, not a routing action. + return undefined; + }); + registered = true; +} + +/** Test-only: reset the registration latch. */ +export function __resetMonitorTraitForTests(): void { + registered = false; +} diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 4a1d627ae..737fce875 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -171,6 +171,7 @@ import { registerUsageRoutes } from "./routes/register-usage-routes.js"; import { registerCommandCenterRoutes } from "./routes/register-command-center-routes.js"; import { registerKnowledgeRoutes } from "./routes/register-knowledge-routes.js"; import { registerSignalRoutes } from "./routes/register-signal-routes.js"; +import { registerMonitorRoutes } from "./routes/monitor-routes.js"; import { registerAuthRoutes } from "./routes/register-auth-routes.js"; import { registerRuntimeProviderRoutes } from "./routes/register-runtime-provider-routes.js"; import { registerFnBinaryRoutes } from "./routes/register-fn-binary-routes.js"; @@ -2004,6 +2005,10 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout // Each route HMAC-verifies against a per-provider secret; never an // unauthenticated task-creation endpoint. registerSignalRoutes(routeContext); + // U13 — Monitor stage: deployment + incident ingestion (bearer-token authed, + // never unauthenticated) + MTTR/deploy/incident metrics read. Closes the loop + // by opening storm-guarded fix tasks back in triage. + registerMonitorRoutes(routeContext); registerUpdateCheckRoutes(routeContext); registerDiagnosticsRoutes(routeContext); // CLI Agent Executor hook ingestion (U17) — per-session token auth, exempt from diff --git a/packages/dashboard/src/routes/monitor-routes.ts b/packages/dashboard/src/routes/monitor-routes.ts new file mode 100644 index 000000000..2c49baddd --- /dev/null +++ b/packages/dashboard/src/routes/monitor-routes.ts @@ -0,0 +1,170 @@ +import { timingSafeEqual } from "node:crypto"; +import type { Request, Response } from "express"; +import { aggregateMonitorMetrics } from "@fusion/core"; +import type { TaskStore } from "@fusion/core"; +import { badRequest, unauthorized } from "../api-error.js"; +import { isSafeExternalUrl } from "../signal-source.js"; +import { recordDeployment, resolveIncident } from "../monitor-store.js"; +import { runMonitorOnRegression } from "../monitor-trait.js"; +import type { ApiRouteRegistrar } from "./types.js"; + +/** + * U13 — Monitor stage routes. + * + * Two ingestion endpoints (CI/Ship → deploys, U11 signals → incidents) plus a + * read endpoint for MTTR / deploy / incident metrics. + * + * POST /api/monitor/deployments record a deployment (deploy frequency) + * POST /api/monitor/incidents open / resolve / re-fire an incident + * GET /api/monitor/metrics MTTR + deploy/incident counts over a range + * + * ## Auth (mandatory — mirrors U11) + * + * The two POST ingestion endpoints require a shared secret / bearer token in the + * `Authorization: Bearer ` header, compared in constant time against the + * secret in `FUSION_MONITOR_INGEST_SECRET` (env / encrypted settings, never + * source-controlled). A missing secret config OR a missing/invalid token → + * **401, and nothing is recorded.** Payload URLs are SSRF-untrusted: a `link` + * that is not a safe external URL is dropped (stored as data only, never + * fetched). The GET metrics endpoint inherits the dashboard's standard + * session/auth middleware + `getScopedStore(req)` scoping, like U9. + */ + +/** Env var carrying the monitor ingestion bearer token. */ +export const MONITOR_INGEST_SECRET_ENV = "FUSION_MONITOR_INGEST_SECRET"; + +/** Resolve the monitor ingestion secret (env / encrypted settings). */ +export function resolveMonitorIngestSecret( + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + const value = env[MONITOR_INGEST_SECRET_ENV]; + return value && value.length > 0 ? value : undefined; +} + +function extractBearer(headers: Request["headers"]): string | undefined { + const raw = headers.authorization; + const header = Array.isArray(raw) ? raw[0] : raw; + if (!header) return undefined; + const match = /^Bearer\s+(.+)$/i.exec(header.trim()); + return match ? match[1].trim() : undefined; +} + +/** + * Constant-time bearer check. Returns true ONLY when a secret is configured AND + * the presented token matches it. No secret configured → always false (the + * endpoint is never unauthenticated). + */ +export function isAuthorizedMonitorIngest( + headers: Request["headers"], + env: NodeJS.ProcessEnv = process.env, +): boolean { + const secret = resolveMonitorIngestSecret(env); + if (!secret) return false; + const token = extractBearer(headers); + if (!token) return false; + const a = Buffer.from(token); + const b = Buffer.from(secret); + if (a.length !== b.length) return false; + try { + return timingSafeEqual(a, b); + } catch { + return false; + } +} + +/** Drop a payload link that is not a safe external URL (SSRF-untrusted). */ +function safeLink(link: unknown): string | undefined { + return typeof link === "string" && isSafeExternalUrl(link) ? link : undefined; +} + +export const registerMonitorRoutes: ApiRouteRegistrar = (ctx) => { + const { router, getScopedStore, rethrowAsApiError } = ctx; + + // ── Deployment ingestion (CI/Ship → deploy frequency) ───────────────────── + router.post("/monitor/deployments", async (req: Request, res: Response) => { + if (!isAuthorizedMonitorIngest(req.headers)) { + throw unauthorized("Invalid or missing monitor ingestion token"); + } + const body = (req.body ?? {}) as Record; + const store: TaskStore = await getScopedStore(req); + try { + const deployment = recordDeployment(store.getDatabase(), { + deploymentId: typeof body.deploymentId === "string" ? body.deploymentId : undefined, + service: typeof body.service === "string" ? body.service : undefined, + environment: typeof body.environment === "string" ? body.environment : undefined, + version: typeof body.version === "string" ? body.version : undefined, + status: typeof body.status === "string" ? body.status : undefined, + deployedAt: typeof body.deployedAt === "string" ? body.deployedAt : undefined, + link: safeLink(body.link), + meta: body.meta && typeof body.meta === "object" ? (body.meta as Record) : undefined, + }); + res.status(201).json({ ok: true, deploymentId: deployment.deploymentId }); + } catch (err) { + rethrowAsApiError(err, "Failed to record deployment"); + } + }); + + // ── Incident ingestion (U11 signal → incident / fix task) ───────────────── + router.post("/monitor/incidents", async (req: Request, res: Response) => { + if (!isAuthorizedMonitorIngest(req.headers)) { + throw unauthorized("Invalid or missing monitor ingestion token"); + } + const body = (req.body ?? {}) as Record; + const groupingKey = typeof body.groupingKey === "string" ? body.groupingKey.trim() : ""; + const title = typeof body.title === "string" ? body.title.trim() : ""; + const action = body.action === "resolve" ? "resolve" : "open"; + + if (!groupingKey) { + throw badRequest("Missing required field: groupingKey"); + } + + const store: TaskStore = await getScopedStore(req); + + try { + if (action === "resolve") { + const incident = resolveIncident(store.getDatabase(), groupingKey, + typeof body.at === "string" ? body.at : undefined); + res.status(200).json({ + ok: true, + resolved: incident !== null, + incidentId: incident?.incidentId, + }); + return; + } + + if (!title) { + throw badRequest("Missing required field: title"); + } + + const outcome = await runMonitorOnRegression( + { + groupingKey, + title, + severity: typeof body.severity === "string" ? body.severity : undefined, + source: typeof body.source === "string" ? body.source : undefined, + link: safeLink(body.link), + meta: body.meta && typeof body.meta === "object" ? (body.meta as Record) : undefined, + at: typeof body.at === "string" ? body.at : undefined, + }, + { store }, + ); + res.status(outcome.kind === "fix-task-opened" ? 201 : 200).json({ ok: true, outcome }); + } catch (err) { + if (err && typeof err === "object" && "status" in err) throw err; + rethrowAsApiError(err, "Failed to ingest incident"); + } + }); + + // ── Metrics read (MTTR + deploy/incident counts) ────────────────────────── + router.get("/monitor/metrics", async (req: Request, res: Response) => { + const store: TaskStore = await getScopedStore(req); + const from = typeof req.query.from === "string" ? req.query.from : undefined; + const to = typeof req.query.to === "string" ? req.query.to : undefined; + try { + const metrics = aggregateMonitorMetrics(store.getDatabase(), { from, to }); + res.json(metrics); + } catch (err) { + rethrowAsApiError(err, "Failed to read monitor metrics"); + } + }); +}; diff --git a/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts b/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts index 7365b0d72..a792db97f 100644 --- a/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts +++ b/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts @@ -746,7 +746,7 @@ describe("RoadmapStore", () => { it("schema version is 119 after init", () => { // Tracks @fusion/core's SCHEMA_VERSION (the roadmap store layers on core's // Database). Bump this in lockstep when core adds a migration. - expect(db.getSchemaVersion()).toBe(119); + expect(db.getSchemaVersion()).toBe(120); }); }); From c1b581e01871715825d26235aed9c72826af3ec9 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 15 Jun 2026 21:09:14 -0700 Subject: [PATCH 18/21] fix(review): apply autofix feedback - add missing changesets for the Command Center dashboard and Monitor stage (both ship in published @runfusion/fusion; required by AGENTS.md) - pin the knowledge_pages migration test to literal version 118 (not SCHEMA_VERSION-1) so it keeps exercising migration 119 after later bumps --- .changeset/command-center-dashboard.md | 10 ++++++++++ .changeset/monitor-stage.md | 10 ++++++++++ .../dashboard/src/__tests__/knowledge-index.test.ts | 6 +++++- 3 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 .changeset/command-center-dashboard.md create mode 100644 .changeset/monitor-stage.md diff --git a/.changeset/command-center-dashboard.md b/.changeset/command-center-dashboard.md new file mode 100644 index 000000000..571c89a2c --- /dev/null +++ b/.changeset/command-center-dashboard.md @@ -0,0 +1,10 @@ +--- +"@runfusion/fusion": minor +--- + +Add the **Command Center** dashboard — a combined analytics/observability and live Mission-Control view (`?view=command-center`). + +- **Telemetry** — a queryable `usage_events` SQLite table populated via a dedicated `emitUsageEvent` capture seam (tool calls, messages, session lifecycle), feeding date-range aggregators for tokens, tool usage + autonomy ratio, activity (sessions/messages/active-nodes/stickiness), productivity (files/commits/PRs/LOC), and ecosystem breadth — all in `packages/core` and reusable by CLI/engine. +- **Cost** — derived from token counts via a hand-maintained `model-pricing` map carrying `pricingAsOf` + a staleness flag; unknown models report unavailable rather than guessing. +- **View** — a new lazy-loaded, ARIA-tabbed Command Center with hand-rolled CSS-bar chart primitives, a date-range picker, per-area panels, a live Mission-Control panel (SSE push + idle-aware polling), and an SDLC funnel. +- **API** — `GET /api/command-center/{tokens,tools,activity,productivity,live}` (agent-usable), each under session auth and project scoping, with `?format=csv` export and an opt-in OpenTelemetry (OTLP) metrics exporter. diff --git a/.changeset/monitor-stage.md b/.changeset/monitor-stage.md new file mode 100644 index 000000000..fe7309e7a --- /dev/null +++ b/.changeset/monitor-stage.md @@ -0,0 +1,10 @@ +--- +"@runfusion/fusion": minor +--- + +Add the **Monitor stage** (U13) — deployment and incident tracking that closes the SDLC loop. + +- **Schema** — new `deployments` and `incidents` SQLite tables (`packages/core/src/db.ts`, `SCHEMA_VERSION` 119 → 120, migration added in the same change; fingerprint auto-covers SCHEMA_SQL tables). +- **Metrics** — real MTTR (incident-open → resolved) plus deploy/incident counts in `activity-analytics`, replacing the prior unavailable seam. +- **Ingestion** — `POST /api/monitor/{deployments,incidents}` self-authenticate via a shared ingest secret (constant-time bearer check, fail-closed) with SSRF-untrusted payload links; `GET /api/monitor/metrics` exposes the aggregates. +- **Loop closure** — a `monitor` workflow trait can auto-open a single fix task on a regression signal, guarded by `groupingKey` grouping, a threshold/sustained gate, cooldown absorption, a per-window circuit breaker, and a self-loop guard. diff --git a/packages/dashboard/src/__tests__/knowledge-index.test.ts b/packages/dashboard/src/__tests__/knowledge-index.test.ts index 28fb228fb..9d8c5cb35 100644 --- a/packages/dashboard/src/__tests__/knowledge-index.test.ts +++ b/packages/dashboard/src/__tests__/knowledge-index.test.ts @@ -138,7 +138,11 @@ describe("knowledge-index store", () => { db.exec("DROP INDEX IF EXISTS idxKnowledgePagesSourceKind"); db.exec("DROP INDEX IF EXISTS idxKnowledgePagesUpdatedAt"); db.exec("DROP TABLE IF EXISTS knowledge_pages"); - db.prepare("UPDATE __meta SET value = ? WHERE key = 'schemaVersion'").run(String(SCHEMA_VERSION - 1)); + // Pinned to the literal pre-migration version (118), NOT SCHEMA_VERSION-1: + // knowledge_pages was created by migration 119, so seeding at 118 keeps this + // test exercising that CREATE block even after later migrations land (mirrors + // the literal-117 pin in usage-events.test.ts). + db.prepare("UPDATE __meta SET value = ? WHERE key = 'schemaVersion'").run("118"); (db as unknown as { migrate: () => void }).migrate(); From 4119dc4cf8b66db2cccc9db6a13ecc38f9c0ec12 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 15 Jun 2026 21:15:28 -0700 Subject: [PATCH 19/21] fix(ci): remove unused isWithinReplayWindow import in pagerduty signal source Lint failure (@typescript-eslint/no-unused-vars). The pagerduty replay-window check itself is tracked as a residual review finding (P1) for follow-up. --- packages/dashboard/src/signal-sources/pagerduty.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/dashboard/src/signal-sources/pagerduty.ts b/packages/dashboard/src/signal-sources/pagerduty.ts index 3ac224cce..d8be05305 100644 --- a/packages/dashboard/src/signal-sources/pagerduty.ts +++ b/packages/dashboard/src/signal-sources/pagerduty.ts @@ -1,6 +1,5 @@ import { applySignalCaps, - isWithinReplayWindow, verifyHmacSignature, type Signal, type SignalSeverity, From 61f389ed3cdca6a6c78294a0c6c0b3924a1824d8 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 16 Jun 2026 05:56:11 -0700 Subject: [PATCH 20/21] Address PR review feedback (#1683) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - monitor-trait: atomic incident-level claim (conditional UPDATE) so concurrent regression ingests can't open duplicate fix tasks; loser absorbs (+ tests) - register-git-github: attach/detach KnowledgeIndexRefreshService per project store so task:moved→done refreshes the index for non-primary projects - agent-logger: make usage-event emission genuinely fail-soft (try/catch + absorb async rejection) so a throwing emitUsageEvent can't break tool logging (+ sync-throw and rejected-promise tests) - db: add composite (kind, ts) index on usage_events backing Command Center tool analytics; folded into migration 118 (unreleased) — no SCHEMA_VERSION bump - db.test: assert the six v120 deployments/incidents indexes survive migration - lazy-loaded-views-docs.test: fix stale "20-view" → "21-view" description - add FNXC_LOG change-log annotations across the touched package files per AGENTS.md Co-Authored-By: Claude Opus 4.8 (1M context) --- .../core/src/__tests__/db-migrate.test.ts | 4 ++ packages/core/src/__tests__/db.test.ts | 21 ++++++ .../core/src/__tests__/insight-store.test.ts | 4 ++ packages/core/src/db.ts | 15 ++++ .../__tests__/lazy-loaded-views-docs.test.ts | 7 +- .../command-center/areas/ProductivityArea.tsx | 4 ++ .../command-center/areas/SignalsArea.tsx | 5 ++ .../command-center/areas/TokensArea.tsx | 4 ++ .../command-center/areas/ToolsArea.tsx | 4 ++ .../areas/__tests__/areas.test.tsx | 4 ++ .../command-center/areas/areaShared.ts | 5 ++ .../src/__tests__/monitor-routes.test.ts | 4 ++ .../src/__tests__/monitor-store.test.ts | 6 ++ .../src/__tests__/monitor-trait.test.ts | 68 ++++++++++++++++++- .../src/__tests__/otel-exporter.test.ts | 4 ++ ...egister-command-center-routes.auth.test.ts | 4 ++ .../register-knowledge-routes.auth.test.ts | 4 ++ .../__tests__/routes-pull-requests.test.ts | 4 ++ packages/dashboard/src/monitor-store.ts | 35 ++++++++++ packages/dashboard/src/monitor-trait.ts | 24 ++++++- packages/dashboard/src/otel-exporter.ts | 5 ++ packages/dashboard/src/routes.ts | 4 ++ .../src/routes/register-git-github.ts | 7 ++ packages/dashboard/src/server.ts | 4 ++ .../engine/src/__tests__/agent-logger.test.ts | 49 +++++++++++++ packages/engine/src/agent-logger.ts | 37 ++++++---- 26 files changed, 321 insertions(+), 15 deletions(-) diff --git a/packages/core/src/__tests__/db-migrate.test.ts b/packages/core/src/__tests__/db-migrate.test.ts index cd138fe25..d75d8e49d 100644 --- a/packages/core/src/__tests__/db-migrate.test.ts +++ b/packages/core/src/__tests__/db-migrate.test.ts @@ -1,3 +1,7 @@ +/* +FNXC:Database 2026-06-16-09:40: +Command Center / SDLC work (PR #1683) added usage_events, knowledge_pages, deployments, and incidents tables behind schema migrations 118-120. These legacy-data migration tests guard the separate legacy-import path so the in-DB schema migrations and the legacy importer stay independent. +*/ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { detectLegacyData, migrateFromLegacy, getMigrationStatus } from "../db-migrate.js"; import { Database } from "../db.js"; diff --git a/packages/core/src/__tests__/db.test.ts b/packages/core/src/__tests__/db.test.ts index caa1a982f..70895c5da 100644 --- a/packages/core/src/__tests__/db.test.ts +++ b/packages/core/src/__tests__/db.test.ts @@ -2962,6 +2962,12 @@ describe("migration v120 adds deployments + incidents tables (U13)", () => { migrated = new Database(fusion); migrated.init(); + // FNXC:Database 2026-06-16-14:30: + // The v119→init migration path must restore not just the deployments + + // incidents tables but their indexes too — a migration could regress index + // creation while table + row assertions still pass. Assert the real index + // names the v120 migration creates (idxDeployments*, idxIncidents*) so that + // regression is caught. expect(migrated.getSchemaVersion()).toBe(120); const tables = new Set( ( @@ -2974,6 +2980,21 @@ describe("migration v120 adds deployments + incidents tables (U13)", () => { ); expect(tables.has("deployments")).toBe(true); expect(tables.has("incidents")).toBe(true); + const indexes = new Set( + ( + migrated + .prepare( + "SELECT name FROM sqlite_master WHERE type='index' AND (tbl_name='deployments' OR tbl_name='incidents')", + ) + .all() as Array<{ name: string }> + ).map((i) => i.name), + ); + expect(indexes.has("idxDeploymentsDeployedAt")).toBe(true); + expect(indexes.has("idxDeploymentsService")).toBe(true); + expect(indexes.has("idxIncidentsGroupingKey")).toBe(true); + expect(indexes.has("idxIncidentsStatus")).toBe(true); + expect(indexes.has("idxIncidentsOpenedAt")).toBe(true); + expect(indexes.has("idxIncidentsResolvedAt")).toBe(true); const task = migrated.prepare("SELECT id FROM tasks WHERE id = ?").get("FN-V119") as { id: string } | undefined; expect(task?.id).toBe("FN-V119"); } finally { diff --git a/packages/core/src/__tests__/insight-store.test.ts b/packages/core/src/__tests__/insight-store.test.ts index 575c75f7d..8ece2af02 100644 --- a/packages/core/src/__tests__/insight-store.test.ts +++ b/packages/core/src/__tests__/insight-store.test.ts @@ -8,6 +8,10 @@ * - Stable identity on upsert (id/createdAt preserved) * - Deterministic ordering under timestamp ties * - Migration: pre-33 DB upgrades to include insight tables + * + * FNXC:Insights 2026-06-16-09:40: + * Touched alongside the Command Center schema work (PR #1683, migrations 118-120) so the insight-store + * migration coverage stays valid as later schema versions land; assertions pin the pre-33 upgrade path. */ import { describe, it, expect, beforeEach, vi } from "vitest"; diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index fbd15aa85..6a858f0c6 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -1230,6 +1230,9 @@ CREATE TABLE IF NOT EXISTS usage_events ( CREATE INDEX IF NOT EXISTS idxUsageEventsTs ON usage_events(ts); CREATE INDEX IF NOT EXISTS idxUsageEventsTaskId ON usage_events(taskId); CREATE INDEX IF NOT EXISTS idxUsageEventsAgentId ON usage_events(agentId); +-- FNXC:Database 2026-06-16-14:30: +-- Command Center tool analytics (aggregateToolAnalytics in tool-analytics.ts) filters usage_events by 'kind' (e.g. 'tool_call', 'session_start') with optional 'ts' bounds on every tool/session count. The (kind, ts) composite index keeps that path from scanning unrelated event kinds as telemetry grows. Added in the same unreleased PR (#1683) that introduces usage_events, so it ships inside migration 118 rather than a new version bump; mirrored there so fresh-init and migrated DBs converge. +CREATE INDEX IF NOT EXISTS idxUsageEventsKindTs ON usage_events(kind, ts); -- Persistent, incrementally-refreshed knowledge index (U14). One row per -- knowledge page (currently one page per completed task; PR-history pages @@ -4810,6 +4813,15 @@ export class Database { // Migration 118: Queryable usage_events telemetry table (tool calls, // messages, session lifecycle). Mirrors the SCHEMA_SQL definition above so // a fresh-from-SCHEMA_SQL DB and a migrated DB converge on the same table. + // FNXC:Database 2026-06-16-14:30: + // The (kind, ts) composite index (idxUsageEventsKindTs) backs the Command + // Center analytics path: aggregateToolAnalytics filters usage_events by kind + // with optional ts bounds for every tool/session count, and would otherwise + // scan unrelated event kinds as telemetry grows. Folded into this migration + // (rather than a new SCHEMA_VERSION bump) because usage_events itself is + // unreleased — every DB that runs migration 118 runs it from this PR's code, + // so no migrated DB can be stuck at v118+ without the index. The IF NOT + // EXISTS body stays re-runnable. if (version < 118) { this.applyMigration(118, () => { this.db.exec(` @@ -4836,6 +4848,9 @@ export class Database { this.db.exec(` CREATE INDEX IF NOT EXISTS idxUsageEventsAgentId ON usage_events(agentId) `); + this.db.exec(` + CREATE INDEX IF NOT EXISTS idxUsageEventsKindTs ON usage_events(kind, ts) + `); }); } diff --git a/packages/dashboard/app/__tests__/lazy-loaded-views-docs.test.ts b/packages/dashboard/app/__tests__/lazy-loaded-views-docs.test.ts index 53a86f982..7a334395b 100644 --- a/packages/dashboard/app/__tests__/lazy-loaded-views-docs.test.ts +++ b/packages/dashboard/app/__tests__/lazy-loaded-views-docs.test.ts @@ -1,3 +1,8 @@ +/* +FNXC:CommandCenter 2026-06-16-09:40: +The Command Center view (PR #1683) is the 21st App-level lazy-loaded view. This test enforces that the +curated lazy-view inventory in AGENTS.md stays in sync with App.tsx; the count contract moved from 20 to 21. +*/ import { describe, expect, it } from "vitest"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; @@ -78,7 +83,7 @@ function extractAppLazyViews(appSource: string): Set { } describe("AGENTS lazy-loaded views inventory", () => { - it("documents the App-level lazy views accurately and keeps the curated 20-view list in sync", () => { + it("documents the App-level lazy views accurately and keeps the curated 21-view list in sync", () => { const agentsDoc = readFileSync(resolve(__dirname, "../../../../AGENTS.md"), "utf-8"); const appSource = readFileSync(resolve(__dirname, "../App.tsx"), "utf-8"); diff --git a/packages/dashboard/app/components/command-center/areas/ProductivityArea.tsx b/packages/dashboard/app/components/command-center/areas/ProductivityArea.tsx index 5751e5ec4..f453042f0 100644 --- a/packages/dashboard/app/components/command-center/areas/ProductivityArea.tsx +++ b/packages/dashboard/app/components/command-center/areas/ProductivityArea.tsx @@ -12,6 +12,10 @@ import { formatCount } from "./areaShared"; * presented as *volume* proxies, kept visually distinct from outcome counters * (PRs, commits). Unavailable LOC renders the "—" sentinel with a tooltip, * NEVER 0. + * + * FNXC:CommandCenter 2026-06-16-09:42: + * Productivity area of the Command Center (PR #1683). Volume proxies (files/LOC) must read as distinct + * from outcome counters (PRs/commits), and missing LOC must render "—", never 0, to avoid implying zero work. */ export function ProductivityArea({ range }: { range: DateRange }) { const { t } = useTranslation("app"); diff --git a/packages/dashboard/app/components/command-center/areas/SignalsArea.tsx b/packages/dashboard/app/components/command-center/areas/SignalsArea.tsx index cecab2e98..b4c7c1c8a 100644 --- a/packages/dashboard/app/components/command-center/areas/SignalsArea.tsx +++ b/packages/dashboard/app/components/command-center/areas/SignalsArea.tsx @@ -6,6 +6,11 @@ import { Bar } from "../charts/Bar"; import { AreaShell } from "./AreaShell"; import { rangeQuery, formatCount, isInvalidRange } from "./areaShared"; +/* +FNXC:CommandCenter 2026-06-16-09:42: +Signals area of the Command Center (PR #1683). Surfaces external-signal volume/severity (Sentry/Datadog/PagerDuty/webhook ingest from U11) so operators see incoming pressure alongside internal analytics. +*/ + /** * Shape the External Signals endpoint will return once U11/U13 land. Until then * the endpoint does not exist, so this area degrades to its empty state — it diff --git a/packages/dashboard/app/components/command-center/areas/TokensArea.tsx b/packages/dashboard/app/components/command-center/areas/TokensArea.tsx index 1ea2d82ce..7fe15aba1 100644 --- a/packages/dashboard/app/components/command-center/areas/TokensArea.tsx +++ b/packages/dashboard/app/components/command-center/areas/TokensArea.tsx @@ -1,3 +1,7 @@ +/* +FNXC:CommandCenter 2026-06-16-09:42: +Tokens area of the Command Center (PR #1683). Renders token totals + derived cost grouped by model/provider; unpriced models must report cost as unavailable (never $0) so totals are not understated. +*/ import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import type { diff --git a/packages/dashboard/app/components/command-center/areas/ToolsArea.tsx b/packages/dashboard/app/components/command-center/areas/ToolsArea.tsx index ebfd14c7f..25836a12d 100644 --- a/packages/dashboard/app/components/command-center/areas/ToolsArea.tsx +++ b/packages/dashboard/app/components/command-center/areas/ToolsArea.tsx @@ -12,6 +12,10 @@ import { formatCount } from "./areaShared"; * sorted descending by count (the endpoint already returns `byCategory` * descending, but we re-sort defensively so display order never depends on * server ordering). + * + * FNXC:CommandCenter 2026-06-16-09:42: + * Tools area of the Command Center (PR #1683). Shows the autonomy ratio plus tool-category usage; display + * order is re-sorted client-side so it never silently depends on server ordering. */ export function ToolsArea({ range }: { range: DateRange }) { const { t } = useTranslation("app"); diff --git a/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx b/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx index 117f6306c..77dfd6e1e 100644 --- a/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx +++ b/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx @@ -1,3 +1,7 @@ +/* +FNXC:CommandCenter 2026-06-16-09:42: +Command Center area component tests (PR #1683). Pin loading/error/unavailable-vs-zero rendering for each analytics area against mocked fixtures so the "—" sentinel and cost-unavailable contracts can't regress. +*/ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, waitFor, within, act } from "@testing-library/react"; diff --git a/packages/dashboard/app/components/command-center/areas/areaShared.ts b/packages/dashboard/app/components/command-center/areas/areaShared.ts index 837d965af..c294e9663 100644 --- a/packages/dashboard/app/components/command-center/areas/areaShared.ts +++ b/packages/dashboard/app/components/command-center/areas/areaShared.ts @@ -1,5 +1,10 @@ import type { DateRange } from "../DateRangePicker"; +/* +FNXC:CommandCenter 2026-06-16-09:42: +Shared Command Center area helpers (PR #1683): date-range query building and count formatting reused across the analytics areas so range-to-query and unavailable-vs-zero rendering stay consistent. +*/ + /** * Build the `?from=&to=` query string for an analytics endpoint from a * {@link DateRange}. Open bounds (null) are omitted so the server applies its diff --git a/packages/dashboard/src/__tests__/monitor-routes.test.ts b/packages/dashboard/src/__tests__/monitor-routes.test.ts index 6d3d3fee3..51ebb59ec 100644 --- a/packages/dashboard/src/__tests__/monitor-routes.test.ts +++ b/packages/dashboard/src/__tests__/monitor-routes.test.ts @@ -5,6 +5,10 @@ * 1. the server-level daemon bearer-token middleware (gates all /api/*), and * 2. the route-level monitor ingestion secret (FUSION_MONITOR_INGEST_SECRET). * An unauthenticated deploy/incident POST returns 401 and records NOTHING. + * + * FNXC:Monitor 2026-06-16-09:44: + * U13 monitor ingest auth coverage (PR #1683): both the daemon bearer middleware and the route-level + * ingest secret must hold, and a rejected request must persist nothing — fail-closed, no partial writes. */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; diff --git a/packages/dashboard/src/__tests__/monitor-store.test.ts b/packages/dashboard/src/__tests__/monitor-store.test.ts index 7e2e1a9df..33f1817f6 100644 --- a/packages/dashboard/src/__tests__/monitor-store.test.ts +++ b/packages/dashboard/src/__tests__/monitor-store.test.ts @@ -1,5 +1,11 @@ // @vitest-environment node +/* +FNXC:Monitor 2026-06-16-09:48: +U13 monitor-store coverage (PR #1683): pins deployment/incident persistence and MTTR/deploy/incident +aggregation that close the SDLC loop, including the incident-level fix-task claim that prevents duplicate +auto-fix tasks under concurrent regression ingests. +*/ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { join } from "node:path"; diff --git a/packages/dashboard/src/__tests__/monitor-trait.test.ts b/packages/dashboard/src/__tests__/monitor-trait.test.ts index a756f611c..d3012fff5 100644 --- a/packages/dashboard/src/__tests__/monitor-trait.test.ts +++ b/packages/dashboard/src/__tests__/monitor-trait.test.ts @@ -8,7 +8,12 @@ import { tmpdir } from "node:os"; import { Database } from "@fusion/core"; import type { Task, TaskCreateInput, TaskStore } from "@fusion/core"; import { runMonitorOnRegression, isMonitorFixTask } from "../monitor-trait.js"; -import { DEFAULT_STORM_GUARD } from "../monitor-store.js"; +import { + DEFAULT_STORM_GUARD, + claimIncidentForFixTask, + ingestIncidentSignal, + getIncident, +} from "../monitor-store.js"; /** * A minimal TaskStore stub: a real Database (for the incidents/deployments @@ -134,4 +139,65 @@ describe("monitor-trait runMonitorOnRegression (U13)", () => { expect(outcome.kind).toBe("fix-task-opened"); expect(created).toHaveLength(1); }); + + it("two CONCURRENT regression ingests for the same open incident open exactly ONE fix task", async () => { + // Force the interleaving the storm guard alone cannot prevent: both callers + // pass decideStormGuard (fixTaskId still null) and both reach the await on + // task creation before either links. A gated createTask holds both calls at + // that exact yield point so they overlap; only the claim-holder should win. + const created: Task[] = []; + let seq = 0; + let releaseGate: () => void = () => {}; + const gate = new Promise((resolve) => { + releaseGate = resolve; + }); + let createCalls = 0; + const store = { + getDatabase: () => db, + async createTask(input: TaskCreateInput): Promise { + createCalls += 1; + await gate; // suspend here so a concurrent caller can interleave + const task = { + id: `FN-${++seq}`, + title: input.title, + column: input.column, + source: input.source, + } as unknown as Task; + created.push(task); + return task; + }, + } as unknown as TaskStore; + + // Prime an open incident already past the gate (occurrences >= threshold) so + // both concurrent firings decide open-fix-task. + for (let i = 0; i < DEFAULT_STORM_GUARD.threshold; i += 1) { + ingestIncidentSignal(db, { groupingKey: "g-race", title: "Race 500s" }); + } + + const a = runMonitorOnRegression({ groupingKey: "g-race", title: "Race 500s" }, { store }); + const b = runMonitorOnRegression({ groupingKey: "g-race", title: "Race 500s" }, { store }); + // Let both reach (or skip) the await, then release. + await Promise.resolve(); + releaseGate(); + const [ra, rb] = await Promise.all([a, b]); + + // Exactly one task created; the other caller absorbed via the lost claim. + expect(createCalls).toBe(1); + expect(created).toHaveLength(1); + const kinds = [ra.kind, rb.kind].sort(); + expect(kinds).toEqual(["absorbed", "fix-task-opened"]); + + // The incident is linked to the single real task, not a sentinel. + const incidentId = (ra.kind === "fix-task-opened" ? ra : (rb as typeof ra)).incidentId; + const incident = getIncident(db, incidentId); + expect(incident?.fixTaskId).toBe(created[0].id); + }); + + it("the atomic claim step prevents a second create once an incident is claimed/linked", () => { + const { incident } = ingestIncidentSignal(db, { groupingKey: "g-claim", title: "Claim me" }); + // First claim wins. + expect(claimIncidentForFixTask(db, incident.incidentId)).toBe(true); + // A second concurrent caller loses the claim (fixTaskId no longer NULL). + expect(claimIncidentForFixTask(db, incident.incidentId)).toBe(false); + }); }); diff --git a/packages/dashboard/src/__tests__/otel-exporter.test.ts b/packages/dashboard/src/__tests__/otel-exporter.test.ts index 290ec2b10..2266e9a76 100644 --- a/packages/dashboard/src/__tests__/otel-exporter.test.ts +++ b/packages/dashboard/src/__tests__/otel-exporter.test.ts @@ -1,3 +1,7 @@ +/* +FNXC:Telemetry 2026-06-16-09:44: +U10 OTLP exporter coverage (PR #1683): pins the default-off behavior, https-only endpoint validation in production, header redaction, and retry/backoff so the exporter can't silently start, leak secrets, or hot-loop on a failing collector. +*/ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { mkdtempSync } from "node:fs"; import { rm } from "node:fs/promises"; diff --git a/packages/dashboard/src/__tests__/register-command-center-routes.auth.test.ts b/packages/dashboard/src/__tests__/register-command-center-routes.auth.test.ts index 9dfd73fec..b7d48b8eb 100644 --- a/packages/dashboard/src/__tests__/register-command-center-routes.auth.test.ts +++ b/packages/dashboard/src/__tests__/register-command-center-routes.auth.test.ts @@ -6,6 +6,10 @@ * valid bearer token. Mirrors `auth-middleware-integration.test.ts` but exercises * the U9 routes specifically (the registrar adds no auth of its own — it inherits * the server-level middleware, which is exactly what this asserts). + * + * FNXC:CommandCenter 2026-06-16-09:44: + * U9 Command Center auth coverage (PR #1683): every analytics endpoint, including /live, must 401 when + * unauthenticated — the registrar relies entirely on the server-level bearer middleware, so this pins it. */ import { describe, it, expect, vi, beforeEach } from "vitest"; diff --git a/packages/dashboard/src/__tests__/register-knowledge-routes.auth.test.ts b/packages/dashboard/src/__tests__/register-knowledge-routes.auth.test.ts index 1d5e8be48..3d6608445 100644 --- a/packages/dashboard/src/__tests__/register-knowledge-routes.auth.test.ts +++ b/packages/dashboard/src/__tests__/register-knowledge-routes.auth.test.ts @@ -6,6 +6,10 @@ * token. Mirrors `register-command-center-routes.auth.test.ts` — the registrar * adds no auth of its own; it inherits the server-level middleware, which is * exactly what this asserts. + * + * FNXC:Knowledge 2026-06-16-09:46: + * U14 knowledge-index auth coverage (PR #1683): the index holds sensitive repo/PR content, so every + * endpoint must 401 when unauthenticated and never be cross-project readable; this pins that contract. */ import { describe, it, expect, vi, beforeEach } from "vitest"; diff --git a/packages/dashboard/src/__tests__/routes-pull-requests.test.ts b/packages/dashboard/src/__tests__/routes-pull-requests.test.ts index 77d9f8bd6..eb4e517f4 100644 --- a/packages/dashboard/src/__tests__/routes-pull-requests.test.ts +++ b/packages/dashboard/src/__tests__/routes-pull-requests.test.ts @@ -1,5 +1,9 @@ // @vitest-environment node +/* +FNXC:PullRequests 2026-06-16-09:44: +U18 auto-resolve-review-comments coverage (PR #1683): extends the PR thread-summary assertions to the fixed/acted thread states the auto-resolution loop produces, so the backward-move-blocked-by-open-PR guard and thread summaries stay correct. +*/ import { beforeEach, describe, expect, it, vi } from "vitest"; import express from "express"; import type { PrEntity, PrThreadState, Task, TaskStore } from "@fusion/core"; diff --git a/packages/dashboard/src/monitor-store.ts b/packages/dashboard/src/monitor-store.ts index 25438a725..dd9e33953 100644 --- a/packages/dashboard/src/monitor-store.ts +++ b/packages/dashboard/src/monitor-store.ts @@ -297,6 +297,41 @@ export function resolveIncident( return getIncident(db, open.incidentId); } +/** + * Sentinel written to `fixTaskId` by {@link claimIncidentForFixTask} to reserve + * an open incident BEFORE its fix task exists. It is overwritten with the real + * task id by {@link attachFixTask} once the task is created. A claimed-but-not- + * yet-attached incident is treated as already-linked by the storm guard + * (`fixTaskId` is non-null), so a concurrent caller absorbs rather than creating + * a duplicate. Distinguishable from a real task id by its prefix. + */ +export const FIX_TASK_CLAIM_SENTINEL_PREFIX = "claiming:"; + +/** + * Atomically claim an open incident for fix-task creation. Performs a single + * conditional UPDATE that sets `fixTaskId` to a sentinel only WHERE it is still + * NULL, so exactly one concurrent caller can win the claim for a given incident. + * + * Returns true if THIS caller acquired the claim (and must therefore create + + * {@link attachFixTask} the real task), false if another caller already claimed + * or linked it (caller should absorb). This closes the create-then-link race: + * the only interleaving point in `runMonitorOnRegression` is the `await` on task + * creation, which now happens strictly AFTER an exclusive claim is held. + */ +export function claimIncidentForFixTask(db: Database, incidentId: string): boolean { + const now = new Date().toISOString(); + const sentinel = `${FIX_TASK_CLAIM_SENTINEL_PREFIX}${incidentId}`; + const result = db + .prepare( + `UPDATE incidents SET fixTaskId = ?, updatedAt = ? + WHERE incidentId = ? AND fixTaskId IS NULL`, + ) + .run(sentinel, now, incidentId) as { changes?: number | bigint }; + const claimed = Number(result.changes ?? 0) > 0; + if (claimed) db.bumpLastModified(); + return claimed; +} + /** Attach a fix task id to an incident (records the loop-closure linkage). */ export function attachFixTask(db: Database, incidentId: string, fixTaskId: string): void { const now = new Date().toISOString(); diff --git a/packages/dashboard/src/monitor-trait.ts b/packages/dashboard/src/monitor-trait.ts index 0809f0f19..b78de0eef 100644 --- a/packages/dashboard/src/monitor-trait.ts +++ b/packages/dashboard/src/monitor-trait.ts @@ -8,6 +8,7 @@ import { getTraitRegistry, registerTraitHookImpl } from "@fusion/core"; import { createSessionDiagnostics } from "./ai-session-diagnostics.js"; import { attachFixTask, + claimIncidentForFixTask, countRecentAutoFixTasks, decideStormGuard, ingestIncidentSignal, @@ -122,6 +123,12 @@ export type MonitorRegressionOutcome = * Idempotent across a burst sharing one groupingKey: the FIRST firing past the * gate opens the task and links it; every subsequent firing finds the linked * incident and absorbs. A Fusion-opened fix task never re-enters this path. + * + * FNXC:Monitor 2026-06-16-14:05: only one fix task may be opened per open + * incident window; concurrent regression ingests must not duplicate. The + * create-then-link step is guarded by an atomic incident-level claim + * (claimIncidentForFixTask) so the await on task creation cannot interleave two + * winners for the same open incident. */ export async function runMonitorOnRegression( signal: IncidentSignalInput, @@ -150,7 +157,22 @@ export async function runMonitorOnRegression( return { kind: "suppressed", incidentId, reason: decision.reason }; } - // open-fix-task: create exactly one task and link it (closes the loop). + // open-fix-task: claim the incident BEFORE the await on task creation. The + // claim is an atomic conditional UPDATE (set fixTaskId WHERE fixTaskId IS + // NULL), so under concurrent regression ingests for the same open incident + // exactly one caller wins. Losers absorb instead of opening a duplicate task + // — without this, two callers could both pass decideStormGuard (fixTaskId + // still null), both await store.createTask, and both attach, opening two + // tasks where only the last link wins. + if (!claimIncidentForFixTask(db, incidentId)) { + const linked = decision.incident.fixTaskId ?? null; + return { + kind: "absorbed", + incidentId, + existingFixTaskId: linked, + reason: "fix-task-claimed-concurrently", + }; + } const task = await store.createTask(buildFixTaskInput(signal, incidentId)); attachFixTask(db, incidentId, task.id); return { kind: "fix-task-opened", taskId: task.id, incidentId }; diff --git a/packages/dashboard/src/otel-exporter.ts b/packages/dashboard/src/otel-exporter.ts index 7c2e58695..ee5f04d78 100644 --- a/packages/dashboard/src/otel-exporter.ts +++ b/packages/dashboard/src/otel-exporter.ts @@ -1,3 +1,8 @@ +/* +FNXC:Telemetry 2026-06-16-09:44: +U10 OTLP exporter (PR #1683): export Command Center analytics as OTLP/HTTP JSON to an external collector. Must be OFF by default (no endpoint → nothing starts) and reject non-https endpoints in production; deliberately avoids the heavy @opentelemetry SDK in favor of a minimal, collector-compatible POST. +*/ + /** * OpenTelemetry (OTLP) metrics exporter wiring (U10) — dashboard side. * diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 737fce875..436751afc 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -1993,6 +1993,10 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout }); registerUsageRoutes(routeContext); + /* + FNXC:DashboardRoutes 2026-06-16-09:46: + PR #1683 wires the Command Center / SDLC registrars into the dashboard router: U9 analytics+live, U14 knowledge index, U11 external-signal webhooks, U13 monitor ingest/metrics. All inherit the server-level daemon bearer auth and getScopedStore project scoping; the signal/monitor ingest paths add their own per-provider/ingest-secret verification on top — none is an unauthenticated task-creation endpoint. + */ // U9 — Command Center analytics + live snapshot endpoints. Thin adapters over // the core aggregators; inherit standard auth + getScopedStore project scoping. registerCommandCenterRoutes(routeContext); diff --git a/packages/dashboard/src/routes/register-git-github.ts b/packages/dashboard/src/routes/register-git-github.ts index df4b4c0e0..98184e615 100644 --- a/packages/dashboard/src/routes/register-git-github.ts +++ b/packages/dashboard/src/routes/register-git-github.ts @@ -2542,6 +2542,12 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void { attachedStateStores.add(projectStore); githubTrackingStateService.attach(projectStore); githubSourceIssueCloseService.attach(projectStore); + // FNXC:Knowledge 2026-06-16-14:32: + // Knowledge index refresh on task:moved→done must run for every registered project store, not just the primary. + // Mirror the GitHubTrackingStateService/GitHubSourceIssueCloseService attach/detach lifecycle so non-primary + // projects also re-index completed tasks. attach() is idempotent (guards on its per-store listener Map), so + // re-attaching the primary store here is harmless even though start() already attached the default store. + knowledgeIndexRefreshService.attach(projectStore); if (!reconcileScheduledStores.has(projectStore)) { reconcileScheduledStores.add(projectStore); @@ -2598,6 +2604,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void { for (const projectStore of attachedStateStores) { githubTrackingStateService.detach(projectStore); githubSourceIssueCloseService.detach(projectStore); + knowledgeIndexRefreshService.detach(projectStore); } githubTrackingStateService.stop(); }); diff --git a/packages/dashboard/src/server.ts b/packages/dashboard/src/server.ts index f5aca4e32..bc25ae160 100644 --- a/packages/dashboard/src/server.ts +++ b/packages/dashboard/src/server.ts @@ -1708,6 +1708,10 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT const originalListen = dashboardApp.listen.bind(dashboardApp); const httpsCreds = options?.https; + /* + FNXC:Telemetry 2026-06-16-09:47: + U10 (PR #1683): the OTLP metrics exporter is started on listen only when FUSION_OTEL_METRICS_ENDPOINT is set (off by default) and its handle is retained here so the server "close" handler can stop the export timer — otherwise the periodic exporter would outlive the server and leak a timer in tests/restarts. + */ // U10: OTLP metrics exporter. Disabled by default — only started when // FUSION_OTEL_METRICS_ENDPOINT is explicitly configured. Held here so the // server "close" handler can stop its timer. diff --git a/packages/engine/src/__tests__/agent-logger.test.ts b/packages/engine/src/__tests__/agent-logger.test.ts index 961abda4f..b83653128 100644 --- a/packages/engine/src/__tests__/agent-logger.test.ts +++ b/packages/engine/src/__tests__/agent-logger.test.ts @@ -587,5 +587,54 @@ describe("AgentLogger", () => { // The tool result payload MUST NOT leak into meta. expect(JSON.stringify(meta)).not.toContain("super-secret-output"); }); + + /* + * FNXC:Telemetry 2026-06-16-05:47: + * Prove the fail-soft telemetry contract: a throwing store.emitUsageEvent must never break + * onToolStart/onToolEnd, and tool logging (appendAgentLog) must still proceed. Covers both a + * synchronously throwing store and one that returns a rejected Promise. + */ + it("does not throw and still logs the tool when emitUsageEvent throws synchronously", async () => { + const store = { + appendAgentLog: vi.fn().mockResolvedValue(undefined), + emitUsageEvent: vi.fn().mockImplementation(() => { + throw new Error("telemetry sink exploded"); + }), + } as unknown as TaskStore & { emitUsageEvent: ReturnType }; + const logger = new AgentLogger({ store, taskId: "FN-UE-FAILSOFT", agent: "executor" }); + logger.setUsageContext({ model: "m", provider: "p", nodeId: "n", agentId: "a" }); + + expect(() => logger.onToolStart("Bash", { command: "ls" })).not.toThrow(); + expect(() => logger.onToolEnd("Bash", false, "output")).not.toThrow(); + + // emitUsageEvent was attempted for both start and end despite throwing. + expect(store.emitUsageEvent).toHaveBeenCalled(); + + // Tool logging still proceeds: tool start + tool_result rows are persisted. + await vi.advanceTimersByTimeAsync(0); + const calls = (store.appendAgentLog as ReturnType).mock.calls; + const types = calls.map((c) => c[2]); + expect(types).toContain("tool"); + expect(types).toContain("tool_result"); + + // Failure is observed via warn, not propagated. + expect(loggerWarnSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to emit usage event")); + }); + + it("does not throw when emitUsageEvent returns a rejected promise", async () => { + const store = { + appendAgentLog: vi.fn().mockResolvedValue(undefined), + emitUsageEvent: vi.fn().mockRejectedValue(new Error("async telemetry failure")), + } as unknown as TaskStore & { emitUsageEvent: ReturnType }; + const logger = new AgentLogger({ store, taskId: "FN-UE-FAILSOFT-ASYNC" }); + logger.setUsageContext({ model: "m", provider: "p", nodeId: "n", agentId: "a" }); + + expect(() => logger.onToolStart("Read", { path: "a.ts" })).not.toThrow(); + expect(() => logger.onToolEnd("Read", true, "boom")).not.toThrow(); + + // Let the rejected emit-promise settle; the .catch must absorb it. + await vi.advanceTimersByTimeAsync(0); + expect(loggerWarnSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to emit usage event")); + }); }); }); diff --git a/packages/engine/src/agent-logger.ts b/packages/engine/src/agent-logger.ts index 5692b2d94..06697a2cf 100644 --- a/packages/engine/src/agent-logger.ts +++ b/packages/engine/src/agent-logger.ts @@ -172,7 +172,12 @@ export class AgentLogger { /** * Emit a normalized tool `usage_events` row through the task store, if a store, - * taskId, and usage context are all available. Fail-soft via store.emitUsageEvent. + * taskId, and usage context are all available. + * + * FNXC:Telemetry 2026-06-16-05:47: + * Usage-event emission is fail-soft: telemetry is a side effect of tool logging and must never break it. + * `store.emitUsageEvent` is wrapped in try/catch so a throwing (or rejecting) store leaves + * `onToolStart`/`onToolEnd` non-throwing and lets agent-log writes proceed. Failures are warned, not propagated. */ private emitToolUsageEvent( kind: "tool_call" | "tool_result" | "tool_error", @@ -181,17 +186,25 @@ export class AgentLogger { ): void { const ctx = this.usageContext; if (!ctx || !this.store || !this.taskId) return; - this.store.emitUsageEvent({ - kind, - taskId: this.taskId, - agentId: ctx.agentId ?? null, - nodeId: ctx.nodeId ?? null, - model: ctx.model ?? null, - provider: ctx.provider ?? null, - toolName, - category: categorizeToolName(toolName), - ...(meta !== undefined && { meta }), - }); + try { + const maybePromise = this.store.emitUsageEvent({ + kind, + taskId: this.taskId, + agentId: ctx.agentId ?? null, + nodeId: ctx.nodeId ?? null, + model: ctx.model ?? null, + provider: ctx.provider ?? null, + toolName, + category: categorizeToolName(toolName), + ...(meta !== undefined && { meta }), + }); + // Swallow async rejections too so a Promise-returning store stays fail-soft. + void Promise.resolve(maybePromise).catch((err) => { + this.log.warn(`Failed to emit usage event (${kind}) for "${toolName}" on ${this.taskId}: ${err instanceof Error ? err.message : String(err)}`); + }); + } catch (err) { + this.log.warn(`Failed to emit usage event (${kind}) for "${toolName}" on ${this.taskId}: ${err instanceof Error ? err.message : String(err)}`); + } } /** From 0d854263ee73aa945355bfdfbd4cb31d963a632b Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 16 Jun 2026 15:11:09 -0700 Subject: [PATCH 21/21] Address PR review feedback round 2 (#1683) - monitor-store: add releaseIncidentFixTaskClaim (guarded UPDATE that only clears an in-flight sentinel, never a real attached task id) and make countRecentAutoFixTasks ignore sentinel placeholders, so a claim stranded by a failed createTask can't permanently absorb/suppress future regressions - monitor-trait: release the claim if createTask throws after a successful claim, returning an error outcome instead of stranding the sentinel - tests: release-vs-real-id, sentinel-excluded count, createTask-failure-then-reopen - fix a type-unsound narrowing in the concurrency test (cast to the full union exposed the error variant's missing incidentId); use a discriminated guard - add FNXC annotations on the new release/count paths and the concurrency harness Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/__tests__/monitor-store.test.ts | 45 +++++++++++++++ .../src/__tests__/monitor-trait.test.ts | 57 ++++++++++++++++++- packages/dashboard/src/monitor-store.ts | 41 ++++++++++++- packages/dashboard/src/monitor-trait.ts | 23 +++++++- 4 files changed, 161 insertions(+), 5 deletions(-) diff --git a/packages/dashboard/src/__tests__/monitor-store.test.ts b/packages/dashboard/src/__tests__/monitor-store.test.ts index 33f1817f6..1aaf3ef80 100644 --- a/packages/dashboard/src/__tests__/monitor-store.test.ts +++ b/packages/dashboard/src/__tests__/monitor-store.test.ts @@ -18,9 +18,13 @@ import { resolveIncident, getOpenIncidentByGroupingKey, attachFixTask, + claimIncidentForFixTask, + releaseIncidentFixTaskClaim, + getIncident, decideStormGuard, countRecentAutoFixTasks, DEFAULT_STORM_GUARD, + FIX_TASK_CLAIM_SENTINEL_PREFIX, type Incident, } from "../monitor-store.js"; @@ -168,5 +172,46 @@ describe("monitor-store (U13)", () => { attachFixTask(db, incident.incidentId, "FN-1"); expect(countRecentAutoFixTasks(db)).toBe(1); }); + + // FNXC:Monitor 2026-06-16-15:40: the breaker count must ignore in-flight / + // stranded sentinel placeholders and only count real fix-task links. + it("ignores sentinel placeholders but counts real fix-task links", () => { + const { incident: a } = ingestIncidentSignal(db, { groupingKey: "ga", title: "a" }); + const { incident: b } = ingestIncidentSignal(db, { groupingKey: "gb", title: "b" }); + // a is only claimed (sentinel) → must NOT count. + expect(claimIncidentForFixTask(db, a.incidentId)).toBe(true); + expect(countRecentAutoFixTasks(db)).toBe(0); + // b gets a real fix task → counts. + attachFixTask(db, b.incidentId, "FN-2"); + expect(countRecentAutoFixTasks(db)).toBe(1); + }); + }); + + describe("releaseIncidentFixTaskClaim", () => { + // FNXC:Monitor 2026-06-16-15:40: a claim must be releasable back to NULL when + // task creation fails, but the release must never clobber a real attached id. + it("clears a sentinel claim back to NULL", () => { + const { incident } = ingestIncidentSignal(db, { groupingKey: "g-rel", title: "t" }); + expect(claimIncidentForFixTask(db, incident.incidentId)).toBe(true); + const claimed = getIncident(db, incident.incidentId); + expect(claimed?.fixTaskId).toBe(`${FIX_TASK_CLAIM_SENTINEL_PREFIX}${incident.incidentId}`); + + expect(releaseIncidentFixTaskClaim(db, incident.incidentId)).toBe(true); + const released = getIncident(db, incident.incidentId); + expect(released?.fixTaskId).toBeNull(); + + // Releasing again is a no-op (nothing to clear). + expect(releaseIncidentFixTaskClaim(db, incident.incidentId)).toBe(false); + }); + + it("does NOT clear a real attached fix task id", () => { + const { incident } = ingestIncidentSignal(db, { groupingKey: "g-real", title: "t" }); + claimIncidentForFixTask(db, incident.incidentId); + attachFixTask(db, incident.incidentId, "FN-99"); + + // The release guard (fixTaskId = sentinel) must reject an attached row. + expect(releaseIncidentFixTaskClaim(db, incident.incidentId)).toBe(false); + expect(getIncident(db, incident.incidentId)?.fixTaskId).toBe("FN-99"); + }); }); }); diff --git a/packages/dashboard/src/__tests__/monitor-trait.test.ts b/packages/dashboard/src/__tests__/monitor-trait.test.ts index d3012fff5..a7e4999f1 100644 --- a/packages/dashboard/src/__tests__/monitor-trait.test.ts +++ b/packages/dashboard/src/__tests__/monitor-trait.test.ts @@ -13,6 +13,7 @@ import { claimIncidentForFixTask, ingestIncidentSignal, getIncident, + getOpenIncidentByGroupingKey, } from "../monitor-store.js"; /** @@ -147,6 +148,12 @@ describe("monitor-trait runMonitorOnRegression (U13)", () => { // that exact yield point so they overlap; only the claim-holder should win. const created: Task[] = []; let seq = 0; + // FNXC:Monitor 2026-06-16-15:40: the gate (a Promise both createTask calls + // await) holds both concurrent callers suspended at the createTask yield + // point so the claim race is reproduced deterministically rather than by + // chance scheduling. With both callers parked there, releaseGate() unblocks + // them together, proving the atomic claim lets exactly ONE fix task open + // (the loser absorbs on the lost claim, not on scheduling luck). let releaseGate: () => void = () => {}; const gate = new Promise((resolve) => { releaseGate = resolve; @@ -188,11 +195,57 @@ describe("monitor-trait runMonitorOnRegression (U13)", () => { expect(kinds).toEqual(["absorbed", "fix-task-opened"]); // The incident is linked to the single real task, not a sentinel. - const incidentId = (ra.kind === "fix-task-opened" ? ra : (rb as typeof ra)).incidentId; - const incident = getIncident(db, incidentId); + const openedOutcome = ra.kind === "fix-task-opened" ? ra : rb; + if (openedOutcome.kind !== "fix-task-opened") { + throw new Error(`expected exactly one fix-task-opened outcome, got ${ra.kind} + ${rb.kind}`); + } + const incident = getIncident(db, openedOutcome.incidentId); expect(incident?.fixTaskId).toBe(created[0].id); }); + // FNXC:Monitor 2026-06-16-15:40: if createTask throws AFTER the claim, the + // claim must be released so the sentinel can't permanently absorb/suppress + // future regressions for the same incident. + it("a createTask failure after claim releases the claim so a later regression can open a fix task", async () => { + let failNext = true; + const created: Task[] = []; + let seq = 0; + const store = { + getDatabase: () => db, + async createTask(input: TaskCreateInput): Promise { + if (failNext) { + failNext = false; + throw new Error("task store unavailable"); + } + const task = { + id: `FN-${++seq}`, + title: input.title, + column: input.column, + source: input.source, + } as unknown as Task; + created.push(task); + return task; + }, + } as unknown as TaskStore; + + // Prime an open incident past the gate so the guard decides open-fix-task. + for (let i = 0; i < DEFAULT_STORM_GUARD.threshold; i += 1) { + ingestIncidentSignal(db, { groupingKey: "g-fail", title: "Boom" }); + } + + // First open-fix-task attempt: createTask throws → claim released, error out. + const failed = await runMonitorOnRegression({ groupingKey: "g-fail", title: "Boom" }, { store }); + expect(failed.kind).toBe("error"); + expect(created).toHaveLength(0); + const incident = getOpenIncidentByGroupingKey(db, "g-fail"); + expect(incident?.fixTaskId).toBeNull(); // claim released, not stranded + + // A later regression can now open a fix task again (not absorbed by a sentinel). + const reopened = await runMonitorOnRegression({ groupingKey: "g-fail", title: "Boom" }, { store }); + expect(reopened.kind).toBe("fix-task-opened"); + expect(created).toHaveLength(1); + }); + it("the atomic claim step prevents a second create once an incident is claimed/linked", () => { const { incident } = ingestIncidentSignal(db, { groupingKey: "g-claim", title: "Claim me" }); // First claim wins. diff --git a/packages/dashboard/src/monitor-store.ts b/packages/dashboard/src/monitor-store.ts index dd9e33953..aa1dba972 100644 --- a/packages/dashboard/src/monitor-store.ts +++ b/packages/dashboard/src/monitor-store.ts @@ -341,6 +341,33 @@ export function attachFixTask(db: Database, incidentId: string, fixTaskId: strin db.bumpLastModified(); } +/** + * FNXC:Monitor 2026-06-16-15:40: a fix-task claim must be released if task + * creation fails so a stranded sentinel can't permanently absorb/suppress + * future regressions. {@link claimIncidentForFixTask} writes a non-null sentinel + * to `fixTaskId`; if {@link attachFixTask} never runs (createTask threw after the + * claim), the incident would stay pseudo-linked forever — every later regression + * would absorb against the sentinel and the circuit-breaker count would include + * it. This releases the claim back to NULL, but ONLY when the value is STILL the + * exact sentinel, so it can never clobber a real attached task id (the + * `WHERE fixTaskId = ` guard rejects any already-attached row). + * + * Returns true if a sentinel was actually cleared. + */ +export function releaseIncidentFixTaskClaim(db: Database, incidentId: string): boolean { + const now = new Date().toISOString(); + const sentinel = `${FIX_TASK_CLAIM_SENTINEL_PREFIX}${incidentId}`; + const result = db + .prepare( + `UPDATE incidents SET fixTaskId = NULL, updatedAt = ? + WHERE incidentId = ? AND fixTaskId = ?`, + ) + .run(now, incidentId, sentinel) as { changes?: number | bigint }; + const released = Number(result.changes ?? 0) > 0; + if (released) db.bumpLastModified(); + return released; +} + // ── Storm guard ─────────────────────────────────────────────────────────────── export interface StormGuardConfig { @@ -421,6 +448,16 @@ export function decideStormGuard( * task is one linked to an incident (fixTaskId set) whose incident updatedAt is * within the window. This is a deliberately coarse proxy that does not require a * separate audit table. + * + * FNXC:Monitor 2026-06-16-15:40: the circuit-breaker count must ignore in-flight + * and stranded sentinel placeholders. {@link claimIncidentForFixTask} writes a + * `${FIX_TASK_CLAIM_SENTINEL_PREFIX}…` sentinel into `fixTaskId` BEFORE the real + * task exists; the real id overwrites it synchronously right after createTask, so + * excluding sentinels here only discounts the brief in-flight window and the + * stranded-claim case (creation failed) — exactly the rows that should not count + * against the breaker. Loser-absorption is unaffected: a loser absorbs because + * {@link decideStormGuard} sees the SPECIFIC incident's non-null `fixTaskId`, or + * because its claim attempt lost — never because of this window count. */ export function countRecentAutoFixTasks( db: Database, @@ -431,8 +468,8 @@ export function countRecentAutoFixTasks( const row = db .prepare( `SELECT COUNT(*) AS count FROM incidents - WHERE fixTaskId IS NOT NULL AND updatedAt >= ?`, + WHERE fixTaskId IS NOT NULL AND fixTaskId NOT LIKE ? AND updatedAt >= ?`, ) - .get(cutoff) as { count: number }; + .get(`${FIX_TASK_CLAIM_SENTINEL_PREFIX}%`, cutoff) as { count: number }; return row.count; } diff --git a/packages/dashboard/src/monitor-trait.ts b/packages/dashboard/src/monitor-trait.ts index b78de0eef..27446b674 100644 --- a/packages/dashboard/src/monitor-trait.ts +++ b/packages/dashboard/src/monitor-trait.ts @@ -12,6 +12,7 @@ import { countRecentAutoFixTasks, decideStormGuard, ingestIncidentSignal, + releaseIncidentFixTaskClaim, type IncidentSignalInput, type StormGuardConfig, } from "./monitor-store.js"; @@ -173,7 +174,27 @@ export async function runMonitorOnRegression( reason: "fix-task-claimed-concurrently", }; } - const task = await store.createTask(buildFixTaskInput(signal, incidentId)); + // FNXC:Monitor 2026-06-16-15:40: a fix-task claim must be released if task + // creation fails so a stranded sentinel can't permanently absorb/suppress + // future regressions. The claim wrote a non-null sentinel to fixTaskId; if + // createTask throws here, attachFixTask never overwrites it, leaving the + // incident pseudo-linked forever. Release the claim (back to NULL, only when + // still the sentinel) before surfacing an error outcome so a later regression + // can open a fix task again. + let task: Task; + try { + task = await store.createTask(buildFixTaskInput(signal, incidentId)); + } catch (createErr) { + releaseIncidentFixTaskClaim(db, incidentId); + diagnostics.errorFromException("Monitor fix-task creation failed; released claim", createErr, { + groupingKey: signal.groupingKey, + incidentId, + }); + return { + kind: "error", + reason: createErr instanceof Error ? createErr.message : String(createErr), + }; + } attachFixTask(db, incidentId, task.id); return { kind: "fix-task-opened", taskId: task.id, incidentId }; } catch (err) {