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>
This commit is contained in:
gsxdsm
2026-06-30 13:30:28 -07:00
parent c5dd8c50b7
commit 7f3bd80dbe
6 changed files with 234 additions and 28 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Count ephemeral task-worker runs in Command Center activity stats.
category: fix
dev: Activity analytics now unions usage_events and agentRuns by agent id for active-agent range/day counts.

View File

@@ -909,13 +909,14 @@ Features:
<!-- FNXC:GlobalConcurrencyControls 2026-06-27-00:00: Those running-agent counts include every top-level slot holder: in-progress executors, active triage planners (`triage` + `planning`, not paused), and active in-review reviewer/merger/fix agents. Current-use markers map absolute utilization on a 0..cap scale, not slider-value coordinates, so one active agent renders above 0%. -->
<!-- FNXC:CommandCenter 2026-06-26-00:00: The four Overview Concurrency sliders change live scheduler capacity, so each settled edit opens a confirmation popup before persisting; cancel, backdrop, or Escape leaves the previous persisted value in place. -->
<!-- FNXC:CommandCenter 2026-06-27-10:03: Tokens detail charts must show every model bucket returned by analytics for accurate spend attribution; Overview remains a compact top-model summary because its copy explicitly frames those cards as top consumers/share. -->
<!-- FNXC:CommandCenterActivity 2026-06-30-00:00: Activity active-agent counts include both durable-agent usage events and ephemeral task-worker execution runs from agentRuns, because task execution can be visible without a matching usage_events row. -->
- **Overview controls dashboard** sits at the top of the Overview landing surface on desktop and mobile. It includes AI engine stop/start backed by `globalPause`, live scheduler status from executor stats, the shared Global Max Concurrent slider backed by `/api/global-concurrency`, range sliders for `maxConcurrent`, `maxTriageConcurrent`, and `maxWorktrees` that persist through `/api/settings`, and a compact theme dropdown with the same color-chip swatches and Shadcn variant list as Settings → Appearance. The four concurrency sliders ask for confirmation after a changed value settles; confirming persists the new cap, while cancel, backdrop, or Escape dismissal reverts to the last persisted value without saving. The global and current-project max-concurrent sliders show running-agent counts plus a current-use dot on the track once utilization data loads; triage and worktree sliders remain cap-only. These controls reuse existing APIs and App-level theme setters; they do not add a new backend route or second theme owner.
- **Overview** summarizes token usage/cost, autonomy, active nodes, sessions, agent runs, tasks done, model breadth, and real open signals, and includes the SDLC throughput funnel for the selected range at the bottom of the Overview content in loading, error, empty, and populated states. Its token total and Live activity snapshot token metric refresh on a bounded live cadence and animate number changes while preserving reduced-motion preferences. The sessions card uses the selected-range `ActivityAnalytics.sessions` value already loaded for the overview. The Live activity snapshot also shows the current board-state count for tasks in progress, independent of the selected analytics date range. Overview includes a graph-rich software-factory snapshot with the existing top-model-consumers bar, tool-category bar, top-model token-share pie, and the daily activity multi-series line chart placed before the daily activity sparkline/trend so the richer line graph sits higher in the chart grid. These reuse the already-loaded tokens, tools, activity, and signals analytics; the signals count comes from `/api/command-center/signals` and renders unavailable (`—`) while the incidents-backed response is loading or unavailable. The chart reveal/glow accents are decorative and disabled when reduced-motion preferences are active. The SDLC completion rate is shown as a radial gauge and is calculated as cohort conversion from in-range triage entrants, so the rate is capped at 100% even when older tasks finish during the range.
- **Overview** summarizes token usage/cost, autonomy, active nodes, sessions, agent runs, tasks done, model breadth, and real open signals, and includes the SDLC throughput funnel for the selected range at the bottom of the Overview content in loading, error, empty, and populated states. Its token total and Live activity snapshot token metric refresh on a bounded live cadence and animate number changes while preserving reduced-motion preferences. The sessions card uses the selected-range `ActivityAnalytics.sessions` value already loaded for the overview. The Live activity snapshot also shows the current board-state count for tasks in progress, independent of the selected analytics date range. Overview's active-agent and daily activity values count durable-agent usage events plus ephemeral task-worker execution runs, with the same agent counted once per day/range if both sources record activity. Overview includes a graph-rich software-factory snapshot with the existing top-model-consumers bar, tool-category bar, top-model token-share pie, and the daily activity multi-series line chart placed before the daily activity sparkline/trend so the richer line graph sits higher in the chart grid. These reuse the already-loaded tokens, tools, activity, and signals analytics; the signals count comes from `/api/command-center/signals` and renders unavailable (`—`) while the incidents-backed response is loading or unavailable. The chart reveal/glow accents are decorative and disabled when reduced-motion preferences are active. The SDLC completion rate is shown as a radial gauge and is calculated as cohort conversion from in-range triage entrants, so the rate is capped at 100% even when older tasks finish during the range.
<!-- FNXC:CommandCenter 2026-06-21-00:00: Command Center cost must read as an estimated, derived value from recorded token counts and the hand-maintained model pricing map; it is never persisted, and the UI must surface prices-as-of, stale low-confidence, and unavailable unknown-model states instead of implying billing truth. -->
<!-- FNXC:CommandCenter 2026-06-22-00:00: FN-6876 requires user-maintained/LiteLLM-fetched pricing overrides to feed Tokens and Team estimates immediately without implying provider billing reconciliation. -->
- **Tokens** breaks down token totals, estimated cost, tasks, and per-model usage. Per-model and per-provider breakdowns use the task's analytics-only actually-used model snapshot when available, so usage from settings-resolved runs appears under the real runtime model instead of `(unknown)` without changing future model resolution; estimated cost uses the same snapshot-first, legacy-fallback model identity so those resolved runs price normally when the model is in the pricing table. Estimated cost is derived at read time from recorded token counts multiplied by the effective per-model pricing table: Settings → Global Models pricing overrides win first, then the built-in fallback table is used. It is not persisted, so historical rows stay tied to current maintained prices instead of stale stored billing truth. The Tokens area shows a **prices as of** date/source for the effective table, marks pricing older than the staleness threshold as low-confidence, and shows cost unavailable for models with no pricing entry rather than guessing a price. It includes the existing token-usage-over-time chart, an additive recharts multi-series line graph, a full token-by-model bar, and a token-share pie backed by every grouped model returned by token analytics; use the granularity control to switch the time-series request between hourly, daily, and weekly buckets. The token total and charts poll on a bounded cadence, keep the previous data visible during refresh, animate decorative count/bar transitions, and disable those animations for reduced-motion users.
- **Tools** shows autonomy ratio, tool-call volume, intervention counts, sessions, and tool categories. The area keeps the existing category bar and adds a recharts category-share pie from `ToolAnalytics.byCategory`. There is intentionally no tools line chart yet because `ToolAnalytics` does not expose a per-day tool trend; the dashboard does not fabricate one or call a new endpoint.
- **Activity** tracks sessions, messages, active nodes, active agents, agent heartbeat runs, and stickiness. Agent-run sheets show total, active, completed, and failed runs for the selected range, and the Agent runs/day sparkline trends runs by `agentRuns.startedAt`. The area keeps the existing live animated line charts for messages/day, active agents/day, active nodes/day, and combined throughput/day (`messages + active agents + active nodes`), and adds a recharts multi-series line graph for messages, active agents, and agent runs plus an agent-run outcome pie from the existing `agentRuns` split. These charts reuse the existing activity analytics endpoint, refresh on a bounded 15-second cadence while mounted, keep the previous data visible during refreshes, and disable decorative draw-on motion for reduced-motion users.
- **Activity** tracks sessions, messages, active nodes, active agents, agent heartbeat runs, and stickiness. Active agents include durable-agent `usage_events` and ephemeral task-worker `agentRuns` rows in the selected range; duplicate same-day agent ids across both sources count once, while run-only task workers still make the active-agents lines and stat cards non-zero. Agent-run sheets show total, active, completed, and failed runs for the selected range, and the Agent runs/day sparkline trends runs by `agentRuns.startedAt`. The area keeps the existing live animated line charts for messages/day, active agents/day, active nodes/day, and combined throughput/day (`messages + active agents + active nodes`), and adds a recharts multi-series line graph for messages, active agents, and agent runs plus an agent-run outcome pie from the existing `agentRuns` split. These charts reuse the existing activity analytics endpoint, refresh on a bounded 15-second cadence while mounted, keep the previous data visible during refreshes, and disable decorative draw-on motion for reduced-motion users.
- **Productivity** separates outcome counters (commits and pull requests), task-duration stats, and volume proxies such as modified files, lines changed, and files by language. The task-duration block counts done tasks completed in the selected range and shows average, median, p90, and total active execution time from `cumulativeActiveMs`; when no qualifying duration data exists, duration values render the unavailable `—` sentinel rather than `0`. The Lines changed card includes **Preview LOC backfill**, an explicit operator control for historical commit-association diff stats. Preview runs the project-scoped backfill in dry-run mode by default and reports scanned rows, distinct commits, updated rows, skipped unavailable commits, and skipped invalid SHAs without writing; **Apply backfill** appears after a preview and requires danger confirmation before persisting additions/deletions to `task_commit_associations`, then renders the same counts as an applied report. It keeps the files-by-language bar and adds a language-share pie from `ProductivityAnalytics.byLanguage`. There is intentionally no productivity line chart because the current productivity response has no per-day throughput or completion time series; no new endpoint is called.
- **Team** shows the read-only agent org chart, heartbeat pause/resume backed by the existing `enginePaused` setting, a per-agent analytics table, tokens-by-agent and tasks-done-by-agent charts, and a real token-share pie from the same per-agent token totals. The org chart is styled by Command Center's Team CSS, not lazy Agents view CSS, auto-switches to a horizontal top-down tree when the container is wide enough using the same breakpoint resolver as the full Agents view, and otherwise keeps the vertical nested list inside the taller scrollable org-chart container. The org-chart scroll container supports mouse click-and-drag panning while touch devices keep native scrolling. Parent agents draw connector lines to child agents in both horizontal and vertical Team layouts across desktop and mobile breakpoints. Org nodes show only agent names so role/title description/meta text does not clutter Team operations. Metrics come only from the project-scoped `tasks` and `agents` tables: token totals and estimated cost are summed from the `tokenUsage*` columns by `assignedAgentId`, files changed counts parsed `tasks.modifiedFiles` paths, tasks done counts `column = 'done'` moves in the selected range, and in-progress / in-review values reflect current task columns. Agent name, role, and live state come from the `agents` table; deleted-agent task history falls back to the raw agent id instead of crashing. The tab uses `/api/command-center/team`, adds no schema, never calls GitHub, and intentionally leaves per-agent issues filed/fixed to FN-6653. Team has no per-day analytics series today, so it intentionally does not render a line chart or fabricate a trend. Decorative chart reveal motion uses duration tokens and is disabled for reduced-motion users.
<!-- FNXC:CommandCenter 2026-06-27-12:00: Workflows is a read-only Command Center detail tab for custom-workflow observability; it mirrors Team metrics by workflow without adding workflow-editing controls or schema. -->

View File

@@ -92,14 +92,17 @@ function insertAgentRun(
startedAt: string;
endedAt?: string | null;
status: string;
createAgent?: boolean;
},
): string {
const id = `run-${agentRunSeq++}`;
const agentId = fields.agentId ?? "agent-1";
db.prepare(
`INSERT OR IGNORE INTO agents (id, name, role, state, createdAt, updatedAt)
VALUES (?, ?, 'executor', 'idle', ?, ?)`,
).run(agentId, agentId, fields.startedAt, fields.startedAt);
if (fields.createAgent !== false) {
db.prepare(
`INSERT OR IGNORE INTO agents (id, name, role, state, createdAt, updatedAt)
VALUES (?, ?, 'executor', 'idle', ?, ?)`,
).run(agentId, agentId, fields.startedAt, fields.startedAt);
}
db.prepare(
`INSERT INTO agentRuns (id, agentId, data, startedAt, endedAt, status)
VALUES (?, ?, ?, ?, ?, ?)`,
@@ -178,18 +181,71 @@ describe("activity-analytics", () => {
expect(result.daily).toEqual([
{ day: "2026-03-01", activeNodes: 1, activeAgents: 1, messages: 1, agentRuns: 0 },
{ day: "2026-03-02", activeNodes: 0, activeAgents: 0, messages: 0, agentRuns: 1 },
{ day: "2026-03-03", activeNodes: 1, activeAgents: 1, messages: 1, agentRuns: 2 },
{ day: "2026-03-02", activeNodes: 0, activeAgents: 1, messages: 0, agentRuns: 1 },
{ day: "2026-03-03", activeNodes: 1, activeAgents: 2, messages: 1, agentRuns: 2 },
]);
});
it("counts run-only ephemeral task workers as active agents", () => {
insertAgentRun(db, {
agentId: "executor-FN-7297",
startedAt: "2026-03-02T12:00:00.000Z",
endedAt: "2026-03-02T12:05:00.000Z",
status: "completed",
});
const result = aggregateActivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T23:59:59.999Z" });
expect(result.activeAgents).toBe(1);
expect(result.daily).toEqual([
{ day: "2026-03-02", activeNodes: 0, activeAgents: 1, messages: 0, agentRuns: 1 },
]);
expect(result.stickiness).toBe(1);
});
it("counts the same agent once when usage and runs occur on the same day", () => {
emitUsageEvent(db, { kind: "tool_call", agentId: "agent-dup", nodeId: "node-1", ts: "2026-03-02T09:00:00.000Z" });
insertAgentRun(db, { agentId: "agent-dup", startedAt: "2026-03-02T10:00:00.000Z", status: "completed" });
const result = aggregateActivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T23:59:59.999Z" });
expect(result.activeAgents).toBe(1);
expect(result.daily).toEqual([
{ day: "2026-03-02", activeNodes: 1, activeAgents: 1, messages: 0, agentRuns: 1 },
]);
});
it("merges mixed durable and ephemeral run activity", () => {
emitUsageEvent(db, { kind: "tool_call", agentId: "durable-usage", nodeId: "node-1", ts: "2026-03-01T09:00:00.000Z" });
insertAgentRun(db, { agentId: "durable-run", startedAt: "2026-03-01T10:00:00.000Z", status: "completed" });
insertAgentRun(db, {
agentId: "executor-FN-7297",
startedAt: "2026-03-02T10:00:00.000Z",
status: "active",
});
insertAgentRun(db, { agentId: "executor-FN-7298", startedAt: "2026-03-02T11:00:00.000Z", status: "failed" });
const result = aggregateActivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T23:59:59.999Z" });
expect(result.activeAgents).toBe(4);
expect(result.agentRuns).toEqual({ total: 3, active: 1, completed: 1, failed: 1 });
expect(result.daily).toEqual([
{ day: "2026-03-01", activeNodes: 1, activeAgents: 2, messages: 0, agentRuns: 1 },
{ day: "2026-03-02", activeNodes: 0, activeAgents: 2, messages: 0, agentRuns: 2 },
]);
});
it("returns zero agent-run metrics when the agentRuns table is absent", () => {
emitUsageEvent(db, { kind: "tool_call", agentId: "legacy-agent", nodeId: "legacy-node", ts: "2026-03-02T08:00:00.000Z" });
db.prepare("DROP TABLE agentRuns").run();
const result = aggregateActivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" });
expect(result.activeAgents).toBe(1);
expect(result.agentRuns).toEqual({ total: 0, active: 0, completed: 0, failed: 0 });
expect(result.daily).toEqual([]);
expect(result.daily).toEqual([
{ day: "2026-03-02", activeNodes: 1, activeAgents: 1, messages: 0, agentRuns: 0 },
]);
});
it("computes stickiness = DAU/MAU", () => {

View File

@@ -128,7 +128,7 @@ export interface ActivityAnalytics {
messages: number;
/** Distinct nodes with any usage_event in range. */
activeNodes: number;
/** Distinct agents with any usage_event in range. */
/** Distinct agents with any usage_event or agentRun in range. */
activeAgents: number;
/** Agent heartbeat runs started in range, grouped by status. */
agentRuns: AgentRunSummary;
@@ -158,10 +158,18 @@ interface DistinctRow {
interface DayAggRow {
day: string;
activeNodes: number;
activeAgents: number;
messages: number;
}
interface AgentActivityRow {
agentId: string;
}
interface AgentActivityDayRow {
day: string;
agentId: string;
}
interface AgentRunStatusRow {
status: string;
count: number;
@@ -232,22 +240,17 @@ export function aggregateActivityAnalytics(
)
.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;
const agentActivity = collectActiveAgentActivity(db, query, eventRange);
const activeAgents = agentActivity.rangeAgentIds.size;
// Per-day distinct nodes/agents + message count. substr(ts,1,10) is the UTC
// day key (ISO-8601 timestamps).
// 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,
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
@@ -264,7 +267,7 @@ export function aggregateActivityAnalytics(
dailyByDay.set(r.day, {
day: r.day,
activeNodes: r.activeNodes,
activeAgents: r.activeAgents,
activeAgents: agentActivity.dailyAgentIds.get(r.day)?.size ?? 0,
messages: r.messages ?? 0,
agentRuns: 0,
});
@@ -277,7 +280,7 @@ export function aggregateActivityAnalytics(
dailyByDay.set(r.day, {
day: r.day,
activeNodes: 0,
activeAgents: 0,
activeAgents: agentActivity.dailyAgentIds.get(r.day)?.size ?? 0,
messages: 0,
agentRuns: r.count,
});
@@ -321,6 +324,76 @@ 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,

View File

@@ -526,11 +526,11 @@ describe("CommandCenter shell", () => {
expect(onChangeView).toHaveBeenCalledWith("agents");
});
it("renders the Overview agent-runs card when run data is the only activity", async () => {
it("renders run-only task-worker activity in Overview agent stats and charts", async () => {
mockOverviewApi({
tokens: tokenFixture(0),
tools: toolsFixture(0),
activity: activityFixture({ sessions: 0, messages: 0, activeNodes: 0, activeAgents: 0, agentRuns: 5, doneInRange: 0 }),
activity: activityFixture({ sessions: 0, messages: 0, activeNodes: 0, activeAgents: 1, agentRuns: 5, doneInRange: 0 }),
signals: signalsFixture(0),
live: liveFixture([{ column: "in-progress", count: 0 }]),
});
@@ -538,6 +538,11 @@ describe("CommandCenter shell", () => {
await waitFor(() => expect(screen.queryByTestId("command-center-empty")).toBeNull());
expect(statValue("command-center-stat-agentRuns")).toBe("5");
expect(screen.getByTestId("command-center-live-agents-working").textContent).toContain("1");
expect(screen.getByTestId("cc-overview-line")).toBeTruthy();
expect(screen.getByTestId("command-center-overview-chart-activity")).toBeTruthy();
expect(screen.getByRole("img", { name: "Daily activity line" })).toBeTruthy();
expect(screen.getByRole("img", { name: "Daily activity trend" })).toBeTruthy();
});
/*
@@ -1027,6 +1032,29 @@ describe("CommandCenter shell", () => {
expect(screen.getByTestId("command-center-panel-tokens")).toBeTruthy();
});
it("renders run-only task-worker activity in the Activity tab graphs", async () => {
mockOverviewApi({
tokens: tokenFixture(0),
tools: toolsFixture(0),
activity: activityFixture({ sessions: 0, messages: 0, activeNodes: 0, activeAgents: 1, agentRuns: 1, doneInRange: 0 }),
signals: signalsFixture(0),
live: liveFixture([{ column: "in-progress", count: 0 }]),
});
render(<CommandCenter />);
fireEvent.click(screen.getByTestId("command-center-tab-activity"));
await screen.findByTestId("cc-area-activity");
expect(screen.getByTestId("cc-activity-agents").textContent).toContain("1");
expect(screen.getByTestId("cc-activity-agent-runs").textContent).toContain("1");
expect(screen.getByTestId("cc-activity-line")).toBeTruthy();
expect(screen.getByTestId("cc-activity-line-agents")).toBeTruthy();
expect(screen.getByTestId("cc-activity-line-throughput")).toBeTruthy();
expect(screen.getByTestId("cc-activity-agent-runs-sparkline")).toBeTruthy();
expect(screen.getByRole("img", { name: "Active agents / day" })).toBeTruthy();
expect(screen.getByRole("img", { name: "Throughput / day" })).toBeTruthy();
});
it("renders and routes the System tab exactly once", async () => {
mockOverviewApi();
render(<CommandCenter />);

View File

@@ -65,15 +65,15 @@ function seedCompletedTaskDuration(db: Database, opts: { id: string; cumulativeA
).run(opts.id, opts.cumulativeActiveMs, opts.completedAt, opts.completedAt, opts.completedAt);
}
function seedAgentRun(db: Database, opts: { id: string; agentId: string; startedAt: string; status: string }): void {
function seedAgentRun(db: Database, opts: { id: string; agentId: string; startedAt: string; status: string; taskId?: string }): void {
db.prepare(
`INSERT OR IGNORE INTO agents (id, name, role, state, createdAt, updatedAt)
VALUES (?, ?, 'executor', 'idle', ?, ?)`,
).run(opts.agentId, opts.agentId, opts.startedAt, opts.startedAt);
db.prepare(
`INSERT INTO agentRuns (id, agentId, data, startedAt, endedAt, status)
VALUES (?, ?, '{}', ?, NULL, ?)`,
).run(opts.id, opts.agentId, opts.startedAt, opts.status);
VALUES (?, ?, ?, ?, NULL, ?)`,
).run(opts.id, opts.agentId, JSON.stringify({ taskId: opts.taskId ?? `task-${opts.id}` }), opts.startedAt, opts.status);
}
function seedTeamMetrics(db: Database, opts: { agentId: string; name: string; tokens: number; taskId: string }): void {
@@ -572,6 +572,12 @@ describe("register-command-center-routes", () => {
expect(activity.body).toHaveProperty("mttr");
expect(activity.body).toHaveProperty("agentRuns");
expect((activity.body as { agentRuns: { total: number; active: number } }).agentRuns).toMatchObject({ total: 1, active: 1 });
expect((activity.body as { activeAgents: number; daily: Array<{ day: string; activeAgents: number; agentRuns: number }> }).activeAgents).toBe(2);
expect((activity.body as { daily: Array<{ day: string; activeAgents: number; agentRuns: number }> }).daily).toEqual(
expect.arrayContaining([
expect.objectContaining({ day: "2026-03-02", activeAgents: 1, agentRuns: 1 }),
]),
);
const prod = await request(app, "GET", `/api/command-center/productivity?${range}&projectId=proj-a`);
expect(prod.status).toBe(200);
@@ -763,6 +769,41 @@ describe("register-command-center-routes", () => {
expect((b.body as { totals: { totalTokens: number } }).totals.totalTokens).toBe(1998);
});
it("activity route counts project-scoped run-only task workers as active agents", async () => {
seedAgentRun(dbA, {
id: "run-ephemeral-a",
agentId: "executor-FN-7297",
taskId: "FN-7297",
startedAt: "2026-03-02T00:00:00.000Z",
status: "completed",
});
seedAgentRun(dbB, {
id: "run-ephemeral-b",
agentId: "executor-FN-other",
taskId: "FN-other",
startedAt: "2026-03-03T00:00:00.000Z",
status: "completed",
});
const range = "from=2026-02-01T00:00:00.000Z&to=2026-04-01T00:00:00.000Z";
const a = await request(app, "GET", `/api/command-center/activity?${range}&projectId=proj-a`);
const b = await request(app, "GET", `/api/command-center/activity?${range}&projectId=proj-b`);
expect(a.status).toBe(200);
expect((a.body as { activeAgents: number; daily: Array<{ day: string; activeAgents: number; agentRuns: number }> }).activeAgents).toBe(2);
expect((a.body as { daily: Array<{ day: string; activeAgents: number; agentRuns: number }> }).daily).toEqual(
expect.arrayContaining([
expect.objectContaining({ day: "2026-03-02", activeAgents: 1, agentRuns: 1 }),
]),
);
expect((b.body as { activeAgents: number; daily: Array<{ day: string; activeAgents: number; agentRuns: number }> }).activeAgents).toBe(2);
expect((b.body as { daily: Array<{ day: string; activeAgents: number; agentRuns: number }> }).daily).toEqual(
expect.arrayContaining([
expect.objectContaining({ day: "2026-03-03", activeAgents: 1, agentRuns: 1 }),
]),
);
});
it("plugin activation endpoint returns scoped JSON and preserves unavailable for empty ranges", async () => {
seedPluginActivation(dbA, { pluginId: "plugin.a", activatedAt: "2026-03-10T00:00:00.000Z" });
seedPluginActivation(dbA, { pluginId: "plugin.a", activatedAt: "2026-03-11T00:00:00.000Z" });