Files
fusion/packages/core/src/activity-analytics.ts
gsxdsm 7f3bd80dbe FN-7297: include ephemeral agents in activity stats
Count ephemeral task-worker runs in dashboard activity analytics so active-agent stats reflect task execution.

- Include agentRuns agent ids alongside usage_events when computing active-agent range totals and daily series.
- Preserve distinct per-agent counting across both activity sources and handle missing agentRuns tables.
- Cover activity analytics, API route fixtures, and Command Center UI expectations for run-only task workers.
- Document the Command Center activity semantics and add a patch changeset for the published CLI bundle.

Files changed:
 .changeset/fn-7297-ephemeral-activity.md           |   7 ++
 docs/dashboard-guide.md                            |   5 +-
 .../core/src/__tests__/activity-analytics.test.ts  |  70 ++++++++++++--
 packages/core/src/activity-analytics.ts            | 101 ++++++++++++++++++---
 .../__tests__/CommandCenter.test.tsx               |  32 ++++++-
 .../register-command-center-routes.test.ts         |  47 +++++++++-
 6 files changed, 234 insertions(+), 28 deletions(-)

Fusion-Task-Id: FN-7297

Fusion-Task-Lineage: 51e46b3c-64f3-4546-954f-97d82b8426fb

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-06-30 13:30:28 -07:00

926 lines
31 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;
}
/** Command Center Signals source breakdown from incidents opened in range. */
export interface SignalSourceCount {
source: string;
count: number;
}
/** Command Center Signals severity breakdown from incidents opened in range. */
export interface SignalSeverityCount {
severity: string;
count: number;
}
/** Command Center Signals status breakdown from incidents opened in range. */
export interface SignalStatusCount {
status: string;
count: number;
}
/**
* External signal analytics for the Command Center Signals area. Counts are
* sourced from the `incidents` table so connector ingestion, monitor metrics,
* and UI pressure indicators share one durable signal record.
*/
export interface SignalsAnalytics {
from: string | null;
to: string | null;
/** Incidents opened (by `openedAt`) within the range. */
totalSignals: number;
/** Open incidents opened within the range. */
open: number;
/** Incidents resolved (by `resolvedAt`) within the range. */
resolved: number;
/** Mean-time-to-resolve over incidents resolved in range. */
mttr: MttrSummary;
/** Incidents opened in range grouped by source; null/blank values are `unknown`. */
bySource: SignalSourceCount[];
/** Incidents opened in range grouped by severity; null/blank values are `unknown`. */
bySeverity: SignalSeverityCount[];
/** Incidents opened in range grouped by status so connector recoveries are visible. */
byStatus: SignalStatusCount[];
}
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 or agentRun 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;
messages: number;
}
interface AgentActivityRow {
agentId: string;
}
interface AgentActivityDayRow {
day: string;
agentId: string;
}
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 agentActivity = collectActiveAgentActivity(db, query, eventRange);
const activeAgents = agentActivity.rangeAgentIds.size;
// Per-day distinct nodes + message count. substr(ts,1,10) is the UTC day key
// (ISO-8601 timestamps); distinct agents are merged from usage_events and
// agentRuns below so ephemeral task workers without usage rows participate.
const dailyRows = db
.prepare(
`SELECT
substr(ts, 1, 10) AS day,
COUNT(DISTINCT nodeId) AS activeNodes,
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: agentActivity.dailyAgentIds.get(r.day)?.size ?? 0,
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: agentActivity.dailyAgentIds.get(r.day)?.size ?? 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 collectActiveAgentActivity(
db: Database,
query: ActivityAnalyticsQuery,
eventRange: { where: string; params: string[] },
): { rangeAgentIds: Set<string>; dailyAgentIds: Map<string, Set<string>> } {
const rangeAgentIds = new Set<string>();
const dailyAgentIds = new Map<string, Set<string>>();
const addDailyAgent = (day: string, agentId: string): void => {
rangeAgentIds.add(agentId);
const set = dailyAgentIds.get(day) ?? new Set<string>();
set.add(agentId);
dailyAgentIds.set(day, set);
};
const eventWhereWith = (extra: string): string =>
eventRange.where
? `${eventRange.where} AND ${extra}`
: `WHERE ${extra}`;
const usageAgents = db
.prepare(`SELECT DISTINCT agentId FROM usage_events ${eventWhereWith("agentId IS NOT NULL")}`)
.all(...eventRange.params) as AgentActivityRow[];
for (const row of usageAgents) {
rangeAgentIds.add(row.agentId);
}
const usageDailyAgents = db
.prepare(
`SELECT substr(ts, 1, 10) AS day, agentId
FROM usage_events ${eventWhereWith("agentId IS NOT NULL")}
GROUP BY day, agentId`,
)
.all(...eventRange.params) as AgentActivityDayRow[];
for (const row of usageDailyAgents) {
addDailyAgent(row.day, row.agentId);
}
if (!tableExists(db, "agentRuns")) {
return { rangeAgentIds, dailyAgentIds };
}
/*
* FNXC:CommandCenterActivity 2026-06-30-00:00:
* Dashboard active-agent metrics must include ephemeral task-worker heartbeat runs because task execution can be recorded in agentRuns without a durable-agent usage_events row. Merge by agentId so durable usage and run-only task workers count once per range/day.
*/
const runRange = rangeClauses("startedAt", query);
const runWhereWith = (extra: string): string =>
runRange.where
? `${runRange.where} AND ${extra}`
: `WHERE ${extra}`;
const runAgents = db
.prepare(`SELECT DISTINCT agentId FROM agentRuns ${runWhereWith("agentId IS NOT NULL")}`)
.all(...runRange.params) as AgentActivityRow[];
for (const row of runAgents) {
rangeAgentIds.add(row.agentId);
}
const runDailyAgents = db
.prepare(
`SELECT substr(startedAt, 1, 10) AS day, agentId
FROM agentRuns ${runWhereWith("agentId IS NOT NULL")}
GROUP BY day, agentId`,
)
.all(...runRange.params) as AgentActivityDayRow[];
for (const row of runDailyAgents) {
addDailyAgent(row.day, row.agentId);
}
return { rangeAgentIds, dailyAgentIds };
}
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[];
return {
mttr: mttrFromResolvedRows(resolvedRows),
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;
}
/* ------------------------------------------------------------------------- */
/* FN-6706 — Command Center Signals analytics from incidents */
/* ------------------------------------------------------------------------- */
interface SignalsGroupRow {
key: string | null;
count: number;
}
function emptySignalsAnalytics(query: ActivityAnalyticsQuery): SignalsAnalytics {
return {
from: query.from ?? null,
to: query.to ?? null,
totalSignals: 0,
open: 0,
resolved: 0,
mttr: { value: null, unavailable: true, sampleCount: 0 },
bySource: [],
bySeverity: [],
byStatus: [],
};
}
function mttrFromResolvedRows(rows: ResolvedIncidentRow[]): MttrSummary {
let totalMs = 0;
let sampleCount = 0;
for (const row of rows) {
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;
totalMs += delta;
sampleCount += 1;
}
return sampleCount === 0
? { value: null, unavailable: true, sampleCount: 0 }
: { value: totalMs / sampleCount / 60_000, unavailable: false, sampleCount };
}
function signalsBreakdown(
db: Database,
column: "source" | "severity" | "status",
openedWhere: string,
params: string[],
): Array<{ key: string; count: number }> {
const rows = db
.prepare(
`SELECT COALESCE(NULLIF(TRIM(${column}), ''), 'unknown') AS key, COUNT(*) AS count
FROM incidents ${openedWhere}
GROUP BY key
ORDER BY count DESC, key ASC`,
)
.all(...params) as SignalsGroupRow[];
return rows.map((row) => ({ key: row.key ?? "unknown", count: row.count }));
}
/**
* Aggregate Command Center Signals data from verified connector incidents.
*
* FNXC:CommandCenterSignals 2026-06-19-00:00:
* FN-6706 requires the Signals area to read real connector pressure from the project-scoped incidents table. Use openedAt for total/open/source/severity, resolvedAt for resolved/MTTR, bucket missing source/severity as `unknown`, and degrade to an empty unavailable-MTTR shape on older schemas without incidents.
*
* FNXC:CommandCenterSignals 2026-06-25-23:35:
* Connector resolution events must surface as a status breakdown, not only top-line open/resolved counts, so the UI and API can prove provider recovery signals changed incident state.
*/
export function aggregateSignalsAnalytics(
db: Database,
query: ActivityAnalyticsQuery = {},
): SignalsAnalytics {
if (!tableExists(db, "incidents")) return emptySignalsAnalytics(query);
const openedRange = rangeClauses("openedAt", query);
const resolvedRange = rangeClauses("resolvedAt", query);
const openWhere = openedRange.where
? `${openedRange.where} AND status = 'open'`
: "WHERE status = 'open'";
const resolvedWhere = resolvedRange.where
? `${resolvedRange.where} AND resolvedAt IS NOT NULL`
: "WHERE resolvedAt IS NOT NULL";
const totalSignals = (
db
.prepare(`SELECT COUNT(*) AS count FROM incidents ${openedRange.where}`)
.get(...openedRange.params) as CountRow
).count;
const open = (
db
.prepare(`SELECT COUNT(*) AS count FROM incidents ${openWhere}`)
.get(...openedRange.params) as CountRow
).count;
const resolved = (
db
.prepare(`SELECT COUNT(*) AS count FROM incidents ${resolvedWhere}`)
.get(...resolvedRange.params) as CountRow
).count;
const resolvedRows = db
.prepare(`SELECT openedAt, resolvedAt FROM incidents ${resolvedWhere}`)
.all(...resolvedRange.params) as ResolvedIncidentRow[];
return {
from: query.from ?? null,
to: query.to ?? null,
totalSignals,
open,
resolved,
mttr: mttrFromResolvedRows(resolvedRows),
bySource: signalsBreakdown(db, "source", openedRange.where, openedRange.params).map((row) => ({
source: row.key,
count: row.count,
})),
bySeverity: signalsBreakdown(db, "severity", openedRange.where, openedRange.params).map((row) => ({
severity: row.key,
count: row.count,
})),
byStatus: signalsBreakdown(db, "status", openedRange.where, openedRange.params).map((row) => ({
status: row.key,
count: row.count,
})),
};
}