Files
fusion/packages/core/src/activity-analytics.ts
gsxdsm 64092ca0aa FN-6654: add Command Center agent-run sheets
Surface agent heartbeat-run analytics in the Command Center activity views and exports.

- Add agent-run summary and daily aggregation to activity analytics with zero-value fallback for older databases.
- Render overview and Activity stat cards plus a daily agent-runs sparkline.
- Include agent-run values in Activity CSV exports, docs, release notes, and regression coverage.

Files changed:
 .changeset/fn-6654-agent-runs-sheets.md            |   5 +
 docs/dashboard-guide.md                            |   5 +-
 .../core/src/__tests__/activity-analytics.test.ts  |  66 +++++++++++++
 packages/core/src/activity-analytics.ts            | 102 +++++++++++++++++++--
 .../components/command-center/CommandCenter.tsx    |   7 +-
 .../__tests__/CommandCenter.test.tsx               |  27 +++++-
 .../command-center/areas/ActivityArea.tsx          |  37 +++++++-
 .../command-center/areas/__tests__/areas.test.tsx  |  50 +++++++++-
 .../src/__tests__/command-center-csv.test.ts       |  46 +++++++++-
 .../register-command-center-routes.test.ts         |  14 +++
 packages/dashboard/src/command-center-csv.ts       |  12 ++-
 11 files changed, 348 insertions(+), 23 deletions(-)

Fusion-Task-Id: FN-6654

Fusion-Task-Lineage: d5c78ec1-8aef-4fdf-8a11-ee337da4cb00
2026-06-18 16:51:56 -07:00

704 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
* 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 (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 {
/** ISO-8601 lower bound (inclusive). */
from?: string;
/** ISO-8601 upper bound (inclusive). */
to?: string;
}
/** Distinct active nodes/agents, messages, and agent-run count for a single UTC day. */
export interface DailyActivity {
/** UTC date, `YYYY-MM-DD`. */
day: string;
activeNodes: number;
activeAgents: number;
messages: number;
/** Agent heartbeat runs started on this UTC day. */
agentRuns: number;
}
/** Agent heartbeat-run counts over an activity range, grouped by canonical status. */
export interface AgentRunSummary {
total: number;
active: number;
completed: number;
failed: number;
}
/**
* 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 when no resolved incident exists in range. */
value: number | null;
/** 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 {
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;
/** Agent heartbeat runs started in range, grouped by status. */
agentRuns: AgentRunSummary;
/** 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 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;
}
interface CountRow {
count: number;
}
interface DistinctRow {
count: number;
}
interface DayAggRow {
day: string;
activeNodes: number;
activeAgents: number;
messages: number;
}
interface AgentRunStatusRow {
status: string;
count: number;
}
interface AgentRunDayRow {
day: string;
count: 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[];
/**
* FNXC:CommandCenter 2026-06-18-00:00:
* Command Center activity analytics must surface agent heartbeat-run volume as stat cards by status and as a per-day trend without requiring a schema migration or new endpoint. Count by agentRuns.startedAt in the selected range, and degrade to zeros when older databases do not have the table.
*/
const agentRunMetrics = aggregateAgentRunMetrics(db, query);
const dailyByDay = new Map<string, DailyActivity>();
for (const r of dailyRows) {
dailyByDay.set(r.day, {
day: r.day,
activeNodes: r.activeNodes,
activeAgents: r.activeAgents,
messages: r.messages ?? 0,
agentRuns: 0,
});
}
for (const r of agentRunMetrics.daily) {
const existing = dailyByDay.get(r.day);
if (existing) {
existing.agentRuns = r.count;
} else {
dailyByDay.set(r.day, {
day: r.day,
activeNodes: 0,
activeAgents: 0,
messages: 0,
agentRuns: r.count,
});
}
}
const daily: DailyActivity[] = [...dailyByDay.values()].sort((a, b) => a.day.localeCompare(b.day));
// 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;
// U13: real monitor metrics over the incidents/deployments tables.
const monitor = aggregateMonitorMetrics(db, query);
return {
from: query.from ?? null,
to: query.to ?? null,
sessions,
messages,
activeNodes,
activeAgents,
agentRuns: agentRunMetrics.summary,
daily,
stickiness,
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
// with that workflow's columns so custom column ids map correctly.
funnel: aggregateSdlcFunnel(db, query),
};
}
function zeroAgentRunSummary(): AgentRunSummary {
return { total: 0, active: 0, completed: 0, failed: 0 };
}
function aggregateAgentRunMetrics(
db: Database,
query: ActivityAnalyticsQuery,
): { summary: AgentRunSummary; daily: AgentRunDayRow[] } {
if (!tableExists(db, "agentRuns")) {
return { summary: zeroAgentRunSummary(), daily: [] };
}
const range = rangeClauses("startedAt", query);
const statusRows = db
.prepare(
`SELECT status, COUNT(*) AS count
FROM agentRuns ${range.where}
GROUP BY status`,
)
.all(...range.params) as AgentRunStatusRow[];
const summary = zeroAgentRunSummary();
for (const row of statusRows) {
summary.total += row.count;
if (row.status === "active") summary.active = row.count;
if (row.status === "completed") summary.completed = row.count;
if (row.status === "failed") summary.failed = row.count;
}
const daily = db
.prepare(
`SELECT substr(startedAt, 1, 10) AS day, COUNT(*) AS count
FROM agentRuns ${range.where}
GROUP BY day
ORDER BY day ASC`,
)
.all(...range.params) as AgentRunDayRow[];
return { summary, daily };
}
/* ------------------------------------------------------------------------- */
/* 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<string, SdlcStage> = {
// 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<string, SdlcStageKey> {
const map = new Map<string, SdlcStageKey>();
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;
/**
* Cohort completion rate for tasks that entered triage in range: count of
* those entrants that also reached `done`, divided by `enteredInRange`.
* Bounded to the 0..1 conversion ratio by set intersection; `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<SdlcStageKey, Set<string>>();
const ensure = (s: SdlcStageKey): Set<string> => {
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. doneInRange remains every task that reached done in range.
const triageEntrants = perStage.get("triage") ?? new Set<string>();
const doneEntrants = perStage.get("done") ?? new Set<string>();
const enteredInRange = triageEntrants.size;
const doneInRange = doneEntrants.size;
/*
FNXC:CommandCenter 2026-06-18-00:00:
Completion rate must be a cohort conversion, not done-in-range divided by triage-in-range. Tasks can finish inside a date range after entering triage before the range (or never entering triage), so intersecting the in-range triage cohort with done tasks keeps the dashboard and OTEL metric trustable at 0..1 or null.
*/
const completedTriageEntrants = Array.from(triageEntrants).filter((taskId) =>
doneEntrants.has(taskId),
).length;
const completionRate =
enteredInRange === 0 ? null : completedTriageEntrants / 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,
};
}
/* ------------------------------------------------------------------------- */
/* 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;
}