FN-7146: add workflow analytics to Command Center

Adds a read-only per-workflow analytics surface for Command Center operators.

- Add a core workflow analytics aggregator with token, cost, task status, and files-changed rollups.
- Expose workflow analytics through JSON and CSV Command Center routes using the scoped store default workflow.
- Add the Workflows Command Center tab, localized copy, docs, tests, and a release changeset.

Files changed:
 .changeset/fn-7146-workflow-analytics.md           |   7 +
 docs/dashboard-guide.md                            |   7 +-
 .../core/src/__tests__/workflow-analytics.test.ts  | 306 +++++++++++++++++++
 packages/core/src/index.ts                         |   7 +
 packages/core/src/workflow-analytics.ts            | 339 +++++++++++++++++++++
 .../components/command-center/CommandCenter.tsx    |   8 +
 .../__tests__/CommandCenter.test.tsx               |  51 +++-
 .../command-center/areas/WorkflowArea.tsx          | 170 +++++++++++
 .../areas/__tests__/WorkflowArea.test.tsx          | 136 +++++++++
 .../register-command-center-routes.auth.test.ts    |   5 +
 .../register-command-center-routes.test.ts         | 109 +++++++
 packages/dashboard/src/command-center-csv.ts       |  57 ++++
 .../src/routes/register-command-center-routes.ts   |  30 ++
 packages/i18n/locales/en/app.json                  |  21 ++
 packages/i18n/locales/es/app.json                  |  21 ++
 packages/i18n/locales/fr/app.json                  |  21 ++
 packages/i18n/locales/ko/app.json                  |  21 ++
 packages/i18n/locales/zh-CN/app.json               |  21 ++
 packages/i18n/locales/zh-TW/app.json               |  21 ++
 19 files changed, 1354 insertions(+), 4 deletions(-)

Fusion-Task-Id: FN-7146
Fusion-Task-Lineage: f75e40a5-9010-45d5-a729-132e547887d7
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-27 17:46:17 -07:00
parent d4137f1abc
commit e8163ab81b
19 changed files with 1354 additions and 4 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add a per-workflow analytics tab to the Command Center dashboard.
category: feature
dev: New `aggregateWorkflowAnalytics` core aggregator + `/api/command-center/workflows` route + WorkflowArea tab; reads tasks ⨝ task_workflow_selection, no new schema.

View File

@@ -859,13 +859,15 @@ Features:
- **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.
- **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. -->
- **Workflows** breaks down selected-range token totals, estimated cost, tasks done, in-progress and in-review counts, and files changed by workflow. Tasks with an explicit workflow selection appear under that built-in or custom workflow name; tasks without a selection are attributed to the project default workflow. Unknown model pricing keeps the unavailable `—` cost sentinel rather than displaying `$0`, matching Tokens and Team cost semantics.
- **Ecosystem** shows active model breadth, per-model task activity, and real plugin activations for the selected range. Plugin activation counts come from project-scoped plugin/extension load events via `/api/command-center/plugin-activations`; if no activation rows exist in range, the metric renders unavailable (`—`) rather than fabricating zero. The tab still reuses the tokens analytics endpoint grouped by model, adds a task-share-by-model pie from `TokenAnalytics.groups`, and renders a tokens/tasks trend line when `TokenAnalytics.series` buckets are present; if series buckets are absent, no synthetic trend is shown.
<!-- FNXC:CommandCenter 2026-06-21-07:07: FN-6722 requires the GitHub area to expose a resolved-issue detail list from local task-store analytics only, with exact close timestamps flagged when reconciliation populated `sourceIssueClosedAt` and approximation called out otherwise. -->
- **GitHub** shows local GitHub issue flow for the selected range: **Filed by Fusion** counts tasks with a persisted `githubTracking.issue`, **Fixed by Fusion** counts tasks imported from GitHub source issues (`sourceIssueProvider = "github"`) that are currently in `done`, using the persisted `sourceIssueClosedAt` / `TaskSourceIssue.closedAt` close time when the reconciler has observed it. Rows that predate the field or have not been observed closed fall back to task `updatedAt` as the documented completion-time approximation; Fusion never fabricates a close timestamp and this analytics path never calls GitHub, the `gh` CLI, or any external network source. To make historical fixed dates exact, use **Backfill exact close times** in the Fixed by Fusion card; the dashboard calls the project-scoped manual `POST /api/git/github/backfill-source-issue-closed-at` endpoint in `{ offset, limit }` batches until `hasMore` is false, then surfaces the accumulated `scanned`, `filled`, `skipped`, and `errors` counts. The endpoint fetches real GitHub `closed_at` values once, fills only missing `sourceIssueClosedAt` values, and never runs automatically or from analytics-time rendering. The area shows filed/fixed/net stat cards, a filed-vs-fixed pie, a filed/fixed recharts trend line, existing daily sparklines, a by-repository bar breakdown, and a **Resolved issues** detail list. Resolved rows include the Fusion task, repository, source issue number, optional issue link, resolved timestamp, and whether that timestamp is exact (`sourceIssueClosedAt`) or the documented `updatedAt` approximation; missing issue URLs render as plain text rather than empty anchors or click targets. The same resolved rows are available from the GitHub analytics payload as `resolved` and from the CSV export.
- **Signals** is backed by the project-scoped `/api/command-center/signals` endpoint, which aggregates real rows from the local `incidents` table. Verified external connectors (`POST /api/signals/webhook`, `/sentry`, `/datadog`, and `/pagerduty`) create triage tasks and also write/resolve incidents, so Signals shows total/open/resolved counts, MTTR when resolved incidents have enough timestamps, and source/severity/status breakdowns from connector traffic. It adds an open-vs-resolved status pie from the same response. Signals has no per-day series today, so it intentionally does not render a line chart or fabricate a trend. The companion `/api/command-center/signals/connectors` endpoint returns only per-provider configured booleans, allowing the empty state to distinguish "no connector configured" from "connector configured, awaiting signals" without exposing secrets.
- **System** is the canonical system-telemetry destination. It reads local telemetry from `GET /api/system-stats` and, when multiple registered nodes exist, shows a node selector that can proxy the same system-stats payload through `GET /api/nodes/:id/system-stats` for remote nodes. It renders live radial gauges for app CPU, host memory, and heap usage, keeps a small client-side rolling buffer for CPU/memory/heap trend sparklines, adds a recharts CPU/memory/heap line from that same rolling buffer, and adds a task-by-column pie alongside the existing tasks-by-column and agents-by-state bars. Host memory uses OS-available memory (Node `process.availableMemory()` when available, with a flagged `freemem` fallback) so macOS inactive/cache pages are not reported as used. The Vitest process count, manual kill confirmation, auto-kill toggle, threshold controls, and last-auto-kill timestamp moved here unchanged; the standalone System Stats modal and its desktop Header/mobile More affordances were removed.
- **Mission Control** shows live active sessions/runs/nodes, current sessions and nodes, an animated live activity snapshot, and a live SDLC funnel; when idle it reports that live updates resume when work starts. No additional pie or line chart is rendered because the live SDLC funnel already visualizes the panel's only quantitative distribution (`snapshot.columns`), while sessions/nodes are live control lists rather than categorical analytics. Motion-heavy accents respect reduced-motion preferences.
- CSV exports are available from the analytics endpoints with `?format=csv`. The Activity CSV includes daily `agentRuns` values plus summary rows for `(agentRuns.total)`, `(agentRuns.active)`, `(agentRuns.completed)`, and `(agentRuns.failed)`.
- CSV exports are available from the analytics endpoints with `?format=csv`. The Workflows CSV includes one row per workflow plus a summary row; the Activity CSV includes daily `agentRuns` values plus summary rows for `(agentRuns.total)`, `(agentRuns.active)`, `(agentRuns.completed)`, and `(agentRuns.failed)`.
Rendering invariants:
- On mobile (`max-width: 768px`), `.cc-tabpanel` remains the sole vertical scroll owner for every chart-bearing tab. Shared chart primitives (`Bar`, `StackedBar`, `Sparkline`, `LineChart`, `RadialGauge`, `Funnel`, `TokenSeriesChart`, and the Command Center recharts wrappers) must shrink within the tabpanel, keep non-zero usable height, avoid stretch/clipping artifacts, and never introduce a competing vertical overflow container.
@@ -876,9 +878,10 @@ Rendering invariants:
- The dashboard browser-layout smoke includes a `[data-smoke="command-center-charts"]` fixture that loads emitted lazy Command Center CSS and verifies representative recharts pie, line, and empty states at mobile (390×844) and desktop breakpoints. The fixture asserts non-zero chart and SVG heights, visible empty-state text, no internal/page horizontal overflow, and no chart-level vertical scroll owner before chart layout changes are considered verified.
Data states:
- Overview shows a loading state while core analytics settle, then shows `No usage data yet. Run some agents to populate the Command Center.` only after the selected range has settled with no core usage data. Overview, Tokens, Tools, Activity, Productivity, Team, Ecosystem, GitHub, Signals, System, and Reliability omit their additive recharts cards in loading/error/empty states, so non-populated data never leaves an empty chart shell.
- Overview shows a loading state while core analytics settle, then shows `No usage data yet. Run some agents to populate the Command Center.` only after the selected range has settled with no core usage data. Overview, Tokens, Tools, Activity, Productivity, Team, Workflows, Ecosystem, GitHub, Signals, System, and Reliability omit their additive recharts cards in loading/error/empty states, so non-populated data never leaves an empty chart shell.
- GitHub issue analytics is local and additive: empty filed/fixed totals keep the stat cards and historical backfill button available while omitting empty chart shells; malformed historical `githubTracking` JSON is skipped instead of breaking the Command Center.
- Team analytics renders its shared loading/error/empty states for null or zero-agent responses, omits empty chart shells for zero-value datasets, and keeps the Command Center tab panel as the mobile scroll owner.
- Workflow analytics renders the same shared loading/error/empty states for zero-workflow responses, omits empty chart shells for zero-value workflow datasets, and keeps unavailable cost as `—` instead of `$0`.
- System telemetry keeps the previous snapshot visible during refresh failures, preserves the node selector when a selected remote node fails to refresh, renders a first-sample CPU `Sampling…` state without NaN values, shows zero-value task/agent bars for empty collections while omitting the zero-value task-distribution pie, and keeps the Command Center tab panel as the mobile scroll owner.
- Signals is best-effort over local incidents data: if the project has no incidents, the Signals area shows either the setup empty state (no signal connector secret configured) or the quiet empty state (at least one connector configured but no rows in range), omits its status pie, and other Command Center metrics remain valid; endpoint errors surface as the shared analytics error state instead of silently swallowing a missing route.

View File

@@ -0,0 +1,306 @@
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 { aggregateWorkflowAnalytics } from "../workflow-analytics.js";
interface TaskSeed {
id: string;
workflowId?: string | null;
column?: string;
columnMovedAt?: string | null;
updatedAt?: string;
modifiedFiles?: unknown;
inputTokens?: number;
outputTokens?: number;
cachedTokens?: number;
cacheWriteTokens?: number;
totalTokens?: number | null;
tokenUsageLastUsedAt?: string | null;
modelProvider?: string | null;
modelId?: string | null;
}
function modifiedFilesValue(value: unknown): string | null {
if (value === undefined) return "[]";
if (value === null) return null;
if (typeof value === "string") return value;
return JSON.stringify(value);
}
function insertWorkflow(db: Database, id: string, name: string): void {
db.prepare(
`INSERT INTO workflows (id, name, description, ir, layout, kind, createdAt, updatedAt)
VALUES (?, ?, '', '{"version":"v1","name":"test","nodes":[],"edges":[]}', '{}', 'workflow', ?, ?)`,
).run(id, name, "2026-03-01T00:00:00.000Z", "2026-03-01T00:00:00.000Z");
}
function insertTask(db: Database, task: TaskSeed): void {
const updatedAt = task.updatedAt ?? "2026-03-01T00:00:00.000Z";
db.prepare(
`INSERT INTO tasks
(id, description, "column", createdAt, updatedAt, columnMovedAt,
modifiedFiles, tokenUsageInputTokens, tokenUsageOutputTokens, tokenUsageCachedTokens,
tokenUsageCacheWriteTokens, tokenUsageTotalTokens, tokenUsageLastUsedAt, modelProvider, modelId)
VALUES (?, 'desc', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
task.id,
task.column ?? "todo",
updatedAt,
updatedAt,
task.columnMovedAt ?? null,
modifiedFilesValue(task.modifiedFiles),
task.inputTokens ?? null,
task.outputTokens ?? null,
task.cachedTokens ?? null,
task.cacheWriteTokens ?? null,
task.totalTokens === undefined ? null : task.totalTokens,
task.tokenUsageLastUsedAt ?? null,
task.modelProvider ?? null,
task.modelId ?? null,
);
if (task.workflowId !== undefined && task.workflowId !== null) {
db.prepare(
`INSERT INTO task_workflow_selection (taskId, workflowId, stepIds, updatedAt)
VALUES (?, ?, '[]', ?)`,
).run(task.id, task.workflowId, updatedAt);
}
}
describe("workflow-analytics", () => {
let tmpDir: string;
let db: Database;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "kb-workflow-analytics-"));
db = new Database(join(tmpDir, ".fusion"));
db.init();
});
afterEach(async () => {
db.close();
await rm(tmpDir, { recursive: true, force: true });
});
it("groups selected and unselected tasks under resolved workflow names", () => {
insertWorkflow(db, "WF-custom", "Release workflow");
insertTask(db, {
id: "custom-tokens",
workflowId: "WF-custom",
inputTokens: 1_000_000,
outputTokens: 1_000_000,
totalTokens: 2_000_000,
tokenUsageLastUsedAt: "2026-03-02T00:00:00.000Z",
modelProvider: "openai",
modelId: "gpt-4o",
modifiedFiles: ["src/custom.ts", "src/shared.ts"],
updatedAt: "2026-03-02T00:00:00.000Z",
});
insertTask(db, {
id: "builtin-done",
workflowId: "builtin:quick-fix",
column: "done",
columnMovedAt: "2026-03-03T00:00:00.000Z",
inputTokens: 50,
outputTokens: 25,
totalTokens: 75,
tokenUsageLastUsedAt: "2026-03-03T00:00:00.000Z",
modelProvider: "openai",
modelId: "gpt-4o-mini",
modifiedFiles: ["src/builtin.ts"],
updatedAt: "2026-03-03T00:00:00.000Z",
});
insertTask(db, {
id: "default-progress",
column: "in-progress",
modifiedFiles: ["docs/default.md"],
updatedAt: "2026-03-04T00:00:00.000Z",
});
insertTask(db, {
id: "default-review",
column: "in-review",
updatedAt: "2026-03-05T00:00:00.000Z",
});
const result = aggregateWorkflowAnalytics(db, {
from: "2026-03-01T00:00:00.000Z",
to: "2026-03-31T00:00:00.000Z",
defaultWorkflowId: "builtin:coding",
now: Date.parse("2026-03-10T00:00:00.000Z"),
});
expect(result.workflows.map((workflow) => workflow.workflowId)).toEqual([
"WF-custom",
"builtin:quick-fix",
"builtin:coding",
]);
const byWorkflow = new Map(result.workflows.map((workflow) => [workflow.workflowId, workflow]));
expect(byWorkflow.get("WF-custom")).toMatchObject({
workflowName: "Release workflow",
isBuiltin: false,
filesChanged: 2,
tasksCompleted: 0,
tasksInProgress: 0,
tasksInReview: 0,
});
expect(byWorkflow.get("WF-custom")?.tokens.totalTokens).toBe(2_000_000);
expect(byWorkflow.get("WF-custom")?.cost).toEqual({ usd: 12.5, unavailable: false, stale: false });
expect(byWorkflow.get("builtin:quick-fix")).toMatchObject({
workflowName: "Quick fix (built-in)",
isBuiltin: true,
filesChanged: 1,
tasksCompleted: 1,
});
expect(byWorkflow.get("builtin:coding")).toMatchObject({
workflowName: "Coding (built-in)",
isBuiltin: true,
filesChanged: 1,
tasksInProgress: 1,
tasksInReview: 1,
});
expect(result.totals.tokens.totalTokens).toBe(2_000_075);
expect(result.totals.filesChanged).toBe(4);
expect(result.totals.tasksCompleted).toBe(1);
expect(result.totals.tasksInProgress).toBe(1);
expect(result.totals.tasksInReview).toBe(1);
});
it("marks unpriced workflow costs unavailable instead of treating them as zero", () => {
insertTask(db, {
id: "unknown-model",
workflowId: "builtin:quick-fix",
inputTokens: 100,
outputTokens: 50,
totalTokens: 150,
tokenUsageLastUsedAt: "2026-03-02T00:00:00.000Z",
modelProvider: "unknown-provider",
modelId: "unknown-model",
});
const result = aggregateWorkflowAnalytics(db, {});
expect(result.workflows[0].cost).toEqual({ usd: null, unavailable: true, stale: false });
expect(result.totals.cost).toEqual({ usd: null, unavailable: true, stale: false });
});
it("returns zeroed totals and an empty workflow array for an empty range", () => {
insertTask(db, {
id: "outside",
workflowId: "builtin:quick-fix",
column: "done",
columnMovedAt: "2026-02-28T23:59:59.999Z",
tokenUsageLastUsedAt: "2026-02-28T23:59:59.999Z",
inputTokens: 10,
totalTokens: 10,
modifiedFiles: ["outside.ts"],
updatedAt: "2026-02-28T23:59:59.999Z",
});
insertTask(db, {
id: "outside-progress",
workflowId: "builtin:quick-fix",
column: "in-progress",
columnMovedAt: "2026-02-28T23:59:59.999Z",
updatedAt: "2026-04-01T00:00:00.000Z",
});
insertTask(db, {
id: "outside-review",
workflowId: "builtin:quick-fix",
column: "in-review",
updatedAt: "2026-02-28T23:59:59.999Z",
});
const result = aggregateWorkflowAnalytics(db, {
from: "2026-03-01T00:00:00.000Z",
to: "2026-03-31T00:00:00.000Z",
});
expect(result.totals).toEqual({
tokens: {
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
cacheWriteTokens: 0,
totalTokens: 0,
nTasks: 0,
},
cost: { usd: null, unavailable: false, stale: false },
filesChanged: 0,
tasksCompleted: 0,
tasksInProgress: 0,
tasksInReview: 0,
});
expect(result.workflows).toEqual([]);
});
it("uses inclusive upper and lower bounds for tokens, completions, and files", () => {
insertTask(db, {
id: "from-boundary",
workflowId: "builtin:quick-fix",
column: "done",
columnMovedAt: "2026-03-01T00:00:00.000Z",
tokenUsageLastUsedAt: "2026-03-01T00:00:00.000Z",
inputTokens: 10,
totalTokens: 10,
modifiedFiles: ["from.ts"],
updatedAt: "2026-03-01T00:00:00.000Z",
});
insertTask(db, {
id: "progress-from-boundary",
workflowId: "builtin:quick-fix",
column: "in-progress",
columnMovedAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-04-01T00:00:00.000Z",
});
insertTask(db, {
id: "to-boundary",
workflowId: "builtin:quick-fix",
column: "done",
columnMovedAt: "2026-03-31T00:00:00.000Z",
tokenUsageLastUsedAt: "2026-03-31T00:00:00.000Z",
inputTokens: 20,
totalTokens: 20,
modifiedFiles: ["to.ts"],
updatedAt: "2026-03-31T00:00:00.000Z",
});
insertTask(db, {
id: "review-to-boundary",
workflowId: "builtin:quick-fix",
column: "in-review",
updatedAt: "2026-03-31T00:00:00.000Z",
});
insertTask(db, {
id: "after-boundary",
workflowId: "builtin:quick-fix",
column: "done",
columnMovedAt: "2026-03-31T00:00:00.001Z",
tokenUsageLastUsedAt: "2026-03-31T00:00:00.001Z",
inputTokens: 30,
totalTokens: 30,
modifiedFiles: ["after.ts"],
updatedAt: "2026-03-31T00:00:00.001Z",
});
insertTask(db, {
id: "progress-after-boundary",
workflowId: "builtin:quick-fix",
column: "in-progress",
columnMovedAt: "2026-03-31T00:00:00.001Z",
updatedAt: "2026-03-31T00:00:00.001Z",
});
const result = aggregateWorkflowAnalytics(db, {
from: "2026-03-01T00:00:00.000Z",
to: "2026-03-31T00:00:00.000Z",
});
expect(result.workflows).toHaveLength(1);
expect(result.workflows[0].tokens.totalTokens).toBe(30);
expect(result.workflows[0].tasksCompleted).toBe(2);
expect(result.workflows[0].tasksInProgress).toBe(1);
expect(result.workflows[0].tasksInReview).toBe(1);
expect(result.workflows[0].filesChanged).toBe(2);
});
});

View File

@@ -655,6 +655,13 @@ export type {
TeamAgentSummary,
TeamMetricTotals,
} from "./team-analytics.js";
export { aggregateWorkflowAnalytics } from "./workflow-analytics.js";
export type {
WorkflowAnalytics,
WorkflowAnalyticsQuery,
WorkflowSummary,
WorkflowMetricTotals,
} from "./workflow-analytics.js";
export { aggregateGithubIssueAnalytics } from "./github-issue-analytics.js";
export type {
GithubIssueAnalytics,

View File

@@ -0,0 +1,339 @@
import type { Database } from "./db.js";
import { BUILTIN_WORKFLOWS, getBuiltinWorkflow, isBuiltinWorkflowId } from "./builtin-workflows.js";
import { costFor, type CostResult, type ModelPricingOverrides } from "./model-pricing.js";
import type { TokenTotals } from "./token-analytics.js";
export interface WorkflowAnalyticsQuery {
/** ISO-8601 lower bound (inclusive). */
from?: string;
/** ISO-8601 upper bound (inclusive). */
to?: string;
/** Epoch ms "now" used only for pricing-staleness. */
now?: number;
/** User-managed pricing overrides that take precedence over the built-in baseline. */
pricingOverrides?: ModelPricingOverrides;
/** Workflow id used for tasks without an explicit task_workflow_selection row. */
defaultWorkflowId?: string;
}
export interface WorkflowMetricTotals {
tokens: TokenTotals;
cost: CostResult;
filesChanged: number;
tasksCompleted: number;
tasksInProgress: number;
tasksInReview: number;
}
export interface WorkflowSummary extends WorkflowMetricTotals {
workflowId: string;
workflowName: string;
isBuiltin: boolean;
}
export interface WorkflowAnalytics {
from: string | null;
to: string | null;
totals: WorkflowMetricTotals;
workflows: WorkflowSummary[];
}
interface WorkflowNameRow {
id: string;
name: string | null;
}
interface TaskTokenRow {
workflowId: string;
inputTokens: number | null;
outputTokens: number | null;
cachedTokens: number | null;
cacheWriteTokens: number | null;
totalTokens: number | null;
modelProvider: string | null;
modelId: string | null;
}
interface CountByWorkflowRow {
workflowId: string;
count: number;
}
interface ModifiedFilesRow {
workflowId: string;
modifiedFiles: string | null;
}
function emptyTokenTotals(): TokenTotals {
return {
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
cacheWriteTokens: 0,
totalTokens: 0,
nTasks: 0,
};
}
interface CostAccumulator {
usd: number;
anyPriced: boolean;
anyUnavailable: boolean;
anyStale: boolean;
}
function emptyCostAccumulator(): CostAccumulator {
return { usd: 0, anyPriced: false, anyUnavailable: false, anyStale: false };
}
function finalizeCost(acc: CostAccumulator): CostResult {
return {
usd: acc.anyPriced ? acc.usd : null,
unavailable: acc.anyUnavailable,
stale: acc.anyStale,
};
}
function addTokenRow(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;
totals.totalTokens +=
row.totalTokens ??
(row.inputTokens ?? 0) +
(row.outputTokens ?? 0) +
(row.cachedTokens ?? 0) +
(row.cacheWriteTokens ?? 0);
totals.nTasks += 1;
}
function addRowCost(
acc: CostAccumulator,
row: TaskTokenRow,
now?: number,
pricingOverrides?: ModelPricingOverrides,
): 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,
pricingOverrides,
);
if (result.stale) acc.anyStale = true;
if (result.unavailable || result.usd === null) {
acc.anyUnavailable = true;
} else {
acc.usd += result.usd;
acc.anyPriced = true;
}
}
function emptyMetricTotals(): WorkflowMetricTotals {
return {
tokens: emptyTokenTotals(),
cost: { usd: null, unavailable: false, stale: false },
filesChanged: 0,
tasksCompleted: 0,
tasksInProgress: 0,
tasksInReview: 0,
};
}
function countModifiedFiles(value: string | null): number {
if (!value) return 0;
let files: unknown;
try {
files = JSON.parse(value);
} catch {
return 0;
}
if (!Array.isArray(files)) return 0;
let count = 0;
for (const file of files) {
if (typeof file === "string" && file.length > 0) count += 1;
}
return count;
}
function addRangeClauses(column: string, clauses: string[], params: string[], query: WorkflowAnalyticsQuery): void {
if (query.from !== undefined) {
clauses.push(`${column} >= ?`);
params.push(query.from);
}
if (query.to !== undefined) {
clauses.push(`${column} <= ?`);
params.push(query.to);
}
}
function resolveWorkflowName(db: Database, workflowId: string): { workflowName: string; isBuiltin: boolean } {
const builtin = getBuiltinWorkflow(workflowId) ?? BUILTIN_WORKFLOWS.find((workflow) => workflow.id === workflowId);
if (builtin) return { workflowName: builtin.name, isBuiltin: true };
const row = db.prepare("SELECT id, name FROM workflows WHERE id = ?").get(workflowId) as WorkflowNameRow | undefined;
return {
workflowName: row?.name && row.name.length > 0 ? row.name : workflowId,
isBuiltin: isBuiltinWorkflowId(workflowId),
};
}
function makeSummary(db: Database, workflowId: string): WorkflowSummary {
const resolved = resolveWorkflowName(db, workflowId);
return {
workflowId,
...resolved,
...emptyMetricTotals(),
};
}
/**
* Aggregate store-derived per-workflow Command Center metrics over a date range.
*
* FNXC:CommandCenter 2026-06-27-12:00:
* Per-workflow analytics derive from tasks ⨝ task_workflow_selection, with the project default workflow backfilling unselected tasks. The HTTP layer passes an already project-scoped Database handle, so this pure read-only aggregator adds observability for custom workflows without introducing schema or cross-project reads.
*/
export function aggregateWorkflowAnalytics(
db: Database,
query: WorkflowAnalyticsQuery = {},
): WorkflowAnalytics {
const defaultWorkflowId = query.defaultWorkflowId ?? "builtin:coding";
const summaries = new Map<string, WorkflowSummary>();
const costAccumulators = new Map<string, CostAccumulator>();
const totalTokens = emptyTokenTotals();
const totalCost = emptyCostAccumulator();
const pricingOverrides = query.pricingOverrides;
const ensureSummary = (workflowId: string): WorkflowSummary => {
const existing = summaries.get(workflowId);
if (existing) return existing;
const created = makeSummary(db, workflowId);
summaries.set(workflowId, created);
costAccumulators.set(workflowId, emptyCostAccumulator());
return created;
};
const workflowExpr = "COALESCE(NULLIF(s.workflowId, ''), ?)";
const tokenClauses = ["t.tokenUsageLastUsedAt IS NOT NULL"];
const tokenParams: string[] = [defaultWorkflowId];
addRangeClauses("t.tokenUsageLastUsedAt", tokenClauses, tokenParams, query);
const tokenRows = db
.prepare(
`SELECT
${workflowExpr} AS workflowId,
t.tokenUsageInputTokens AS inputTokens,
t.tokenUsageOutputTokens AS outputTokens,
t.tokenUsageCachedTokens AS cachedTokens,
t.tokenUsageCacheWriteTokens AS cacheWriteTokens,
t.tokenUsageTotalTokens AS totalTokens,
t.modelProvider,
t.modelId
FROM tasks t
LEFT JOIN task_workflow_selection s ON s.taskId = t.id
WHERE ${tokenClauses.join(" AND ")}`,
)
.all(...tokenParams) as TaskTokenRow[];
for (const row of tokenRows) {
const summary = ensureSummary(row.workflowId);
const workflowCost = costAccumulators.get(row.workflowId) ?? emptyCostAccumulator();
costAccumulators.set(row.workflowId, workflowCost);
addTokenRow(summary.tokens, row);
addTokenRow(totalTokens, row);
addRowCost(workflowCost, row, query.now, pricingOverrides);
addRowCost(totalCost, row, query.now, pricingOverrides);
}
const completedClauses = [`t."column" = 'done'`, "t.columnMovedAt IS NOT NULL"];
const completedParams: string[] = [defaultWorkflowId];
addRangeClauses("t.columnMovedAt", completedClauses, completedParams, query);
const completedRows = db
.prepare(
`SELECT ${workflowExpr} AS workflowId, COUNT(*) AS count
FROM tasks t
LEFT JOIN task_workflow_selection s ON s.taskId = t.id
WHERE ${completedClauses.join(" AND ")}
GROUP BY workflowId`,
)
.all(...completedParams) as CountByWorkflowRow[];
for (const row of completedRows) {
ensureSummary(row.workflowId).tasksCompleted = row.count;
}
const currentClauses = [`t."column" IN ('in-progress', 'in-review')`];
const currentParams: string[] = [defaultWorkflowId];
/*
* FNXC:CommandCenter 2026-06-27-17:45:
* The Workflows tab describes all task counts as range-scoped analytics. Count active workflow tasks only when their current column transition (or updatedAt fallback for legacy rows) falls inside the selected range so stale in-progress/review tasks do not suppress the empty state for unrelated windows.
*/
addRangeClauses("COALESCE(t.columnMovedAt, t.updatedAt)", currentClauses, currentParams, query);
const currentRows = db
.prepare(
`SELECT ${workflowExpr} AS workflowId, t."column" AS columnName, COUNT(*) AS count
FROM tasks t
LEFT JOIN task_workflow_selection s ON s.taskId = t.id
WHERE ${currentClauses.join(" AND ")}
GROUP BY workflowId, t."column"`,
)
.all(...currentParams) as Array<CountByWorkflowRow & { columnName: string }>;
for (const row of currentRows) {
const summary = ensureSummary(row.workflowId);
if (row.columnName === "in-progress") summary.tasksInProgress = row.count;
if (row.columnName === "in-review") summary.tasksInReview = row.count;
}
const filesClauses = ["t.modifiedFiles IS NOT NULL", "t.modifiedFiles NOT IN ('', '[]')"];
const filesParams: string[] = [defaultWorkflowId];
addRangeClauses("t.updatedAt", filesClauses, filesParams, query);
const fileRows = db
.prepare(
`SELECT ${workflowExpr} AS workflowId, t.modifiedFiles
FROM tasks t
LEFT JOIN task_workflow_selection s ON s.taskId = t.id
WHERE ${filesClauses.join(" AND ")}`,
)
.all(...filesParams) as ModifiedFilesRow[];
for (const row of fileRows) {
ensureSummary(row.workflowId).filesChanged += countModifiedFiles(row.modifiedFiles);
}
for (const [workflowId, summary] of summaries) {
summary.cost = finalizeCost(costAccumulators.get(workflowId) ?? emptyCostAccumulator());
}
let filesChanged = 0;
let tasksCompleted = 0;
let tasksInProgress = 0;
let tasksInReview = 0;
for (const summary of summaries.values()) {
filesChanged += summary.filesChanged;
tasksCompleted += summary.tasksCompleted;
tasksInProgress += summary.tasksInProgress;
tasksInReview += summary.tasksInReview;
}
const sortedWorkflows = [...summaries.values()].sort((a, b) => {
const tokenCmp = b.tokens.totalTokens - a.tokens.totalTokens;
if (tokenCmp !== 0) return tokenCmp;
return a.workflowId.localeCompare(b.workflowId);
});
return {
from: query.from ?? null,
to: query.to ?? null,
totals: {
tokens: totalTokens,
cost: finalizeCost(totalCost),
filesChanged,
tasksCompleted,
tasksInProgress,
tasksInReview,
},
workflows: sortedWorkflows,
};
}

View File

@@ -10,6 +10,7 @@ import { ToolsArea } from "./areas/ToolsArea";
import { ActivityArea } from "./areas/ActivityArea";
import { ProductivityArea } from "./areas/ProductivityArea";
import { TeamArea } from "./areas/TeamArea";
import { WorkflowArea } from "./areas/WorkflowArea";
import { EcosystemArea } from "./areas/EcosystemArea";
import { GithubArea } from "./areas/GithubArea";
import { SignalsArea } from "./areas/SignalsArea";
@@ -36,6 +37,7 @@ type SubViewId =
| "activity"
| "productivity"
| "team"
| "workflows"
| "ecosystem"
| "github"
| "signals"
@@ -59,6 +61,9 @@ const OVERVIEW_TOKEN_MODEL_LIMIT = 8;
FNXC:CommandCenter 2026-06-18-16:57:
Team tab shows each agent's tokens/cost/files-changed/tasks-completed with live status and bar charts, reusing existing analytics primitives; GitHub-issue per-agent stats are FN-6653, not here.
FNXC:CommandCenter 2026-06-27-12:00:
Workflows is the canonical Command Center tab for read-only per-workflow observability. Keep it adjacent to Team because both surfaces break down the same tokens/cost/files/task-count metrics by an operational dimension.
FNXC:CommandCenter 2026-06-19-00:00:
FN-6702 moves Reliability from a top-level dashboard view into a Command Center tab next to System telemetry. Reuse ReliabilityView unchanged so its /api/health/reliability loading, error, insufficient-data, and populated states keep the same data flow.
@@ -74,6 +79,7 @@ function useSubViews(nodesEnabled: boolean): SubView[] {
{ id: "activity", label: t("commandCenter.tabs.activity", "Activity") },
{ id: "productivity", label: t("commandCenter.tabs.productivity", "Productivity") },
{ id: "team", label: t("commandCenter.tabs.team", "Team") },
{ id: "workflows", label: t("commandCenter.tabs.workflows", "Workflows") },
{ id: "ecosystem", label: t("commandCenter.tabs.ecosystem", "Ecosystem") },
{ id: "github", label: t("commandCenter.tabs.github", "GitHub") },
{ id: "signals", label: t("commandCenter.tabs.signals", "Signals") },
@@ -562,6 +568,8 @@ export function CommandCenter({
return <ProductivityArea range={range} />;
case "team":
return <TeamArea range={range} projectId={projectId} addToast={addToast} />;
case "workflows":
return <WorkflowArea range={range} />;
case "ecosystem":
return <EcosystemArea range={range} />;
case "github":

View File

@@ -247,6 +247,34 @@ function teamFixture(agents: unknown[] = [
};
}
function workflowFixture(workflows: unknown[] = [
{
workflowId: "builtin:coding",
workflowName: "Coding (built-in)",
isBuiltin: true,
tokens: { inputTokens: 900, outputTokens: 450, cachedTokens: 150, cacheWriteTokens: 0, totalTokens: 1500, nTasks: 2 },
cost: { usd: 4.25, unavailable: false, stale: false },
filesChanged: 7,
tasksCompleted: 3,
tasksInProgress: 1,
tasksInReview: 0,
},
]) {
return {
from: "2026-06-08",
to: null,
totals: {
tokens: { inputTokens: 900, outputTokens: 450, cachedTokens: 150, cacheWriteTokens: 0, totalTokens: 1500, nTasks: 2 },
cost: { usd: 4.25, unavailable: false, stale: false },
filesChanged: 7,
tasksCompleted: 3,
tasksInProgress: 1,
tasksInReview: 0,
},
workflows,
};
}
function signalsFixture(open = 2) {
return {
totalSignals: open,
@@ -329,6 +357,7 @@ function mockOverviewApi({
activity = activityFixture(),
github = githubFixture(),
team = teamFixture([]),
workflows = workflowFixture([]),
signals = signalsFixture(),
live = liveFixture(),
}: {
@@ -337,6 +366,7 @@ function mockOverviewApi({
activity?: unknown;
github?: unknown;
team?: unknown;
workflows?: unknown;
signals?: unknown;
live?: unknown;
} = {}) {
@@ -346,6 +376,9 @@ function mockOverviewApi({
if (path.startsWith("/command-center/activity")) return Promise.resolve(activity);
if (path.startsWith("/command-center/github")) return Promise.resolve(github);
if (path.startsWith("/command-center/team")) return team instanceof Error ? Promise.reject(team) : Promise.resolve(team);
if (path.startsWith("/command-center/workflows")) {
return workflows instanceof Error ? Promise.reject(workflows) : Promise.resolve(workflows);
}
if (path.startsWith("/command-center/signals")) {
return signals instanceof Error ? Promise.reject(signals) : Promise.resolve(signals);
}
@@ -938,8 +971,8 @@ describe("CommandCenter shell", () => {
render(<CommandCenter />);
const tablist = screen.getByRole("tablist");
const tabs = within(tablist).getAllByRole("tab");
// Overview, Tokens, Tools, Activity, Productivity, Team, Ecosystem, GitHub, Signals, System, Reliability, Mission Control.
expect(tabs.length).toBe(12);
// Overview, Tokens, Tools, Activity, Productivity, Team, Workflows, Ecosystem, GitHub, Signals, System, Reliability, Mission Control.
expect(tabs.length).toBe(13);
expect(screen.queryByTestId("command-center-tab-nodes")).toBeNull();
// roving tabindex: exactly one tab is focusable.
const focusable = tabs.filter((tab) => tab.getAttribute("tabindex") === "0");
@@ -1045,6 +1078,19 @@ describe("CommandCenter shell", () => {
expect(rows[0].getAttribute("data-testid")).toBe("cc-team-row-agent-alpha");
});
it("renders and routes the Workflows tab exactly once", async () => {
mockOverviewApi({ workflows: workflowFixture() });
render(<CommandCenter />);
expect(screen.getAllByTestId("command-center-tab-workflows")).toHaveLength(1);
fireEvent.click(screen.getByTestId("command-center-tab-workflows"));
await screen.findByTestId("cc-area-workflows");
expect(screen.getByTestId("command-center-tab-workflows").getAttribute("aria-selected")).toBe("true");
expect(screen.getByTestId("command-center-panel-workflows")).toBeTruthy();
expect(screen.getByTestId("cc-workflows-table").textContent).toContain("Coding (built-in)");
});
it("renders the Team empty state for zero agents without an empty chart shell", async () => {
mockOverviewApi({ team: teamFixture([]) });
render(<CommandCenter />);
@@ -1111,6 +1157,7 @@ describe("CommandCenter shell", () => {
"tools",
"activity",
"productivity",
"workflows",
"ecosystem",
"github",
"signals",

View File

@@ -0,0 +1,170 @@
/*
FNXC:CommandCenter 2026-06-27-12:00:
The Workflows tab is an additive read-only per-workflow analytics surface paralleling Team. It consumes /command-center/workflows so operators can compare workflow token spend, task throughput, and files changed without adding editing controls or persisted schema.
*/
import { useTranslation } from "react-i18next";
import type { CostResult, WorkflowAnalytics, WorkflowSummary } from "@fusion/core";
import type { DateRange } from "../DateRangePicker";
import { Bar, type BarDatum } from "../charts/Bar";
import { AreaShell } from "./AreaShell";
import { useAnalyticsArea } from "./useAnalyticsArea";
import { formatCost, formatCount } from "./areaShared";
type SortKey = "workflow" | "tokens" | "cost" | "filesChanged" | "tasksCompleted" | "tasksInProgress" | "tasksInReview";
function costSortValue(cost: CostResult): number {
return cost.unavailable || cost.usd === null ? -1 : cost.usd;
}
function workflowLabel(workflow: WorkflowSummary, unknownLabel: string): string {
return workflow.workflowName || workflow.workflowId || unknownLabel;
}
function sortWorkflows(workflows: WorkflowSummary[], key: SortKey, dir: 1 | -1, unknownLabel: string): WorkflowSummary[] {
const sorted = [...workflows];
sorted.sort((a, b) => {
let cmp = 0;
if (key === "workflow") {
cmp = workflowLabel(a, unknownLabel).localeCompare(workflowLabel(b, unknownLabel));
} else if (key === "tokens") {
cmp = a.tokens.totalTokens - b.tokens.totalTokens;
} else if (key === "cost") {
cmp = costSortValue(a.cost) - costSortValue(b.cost);
} else if (key === "filesChanged") {
cmp = a.filesChanged - b.filesChanged;
} else if (key === "tasksCompleted") {
cmp = a.tasksCompleted - b.tasksCompleted;
} else if (key === "tasksInProgress") {
cmp = a.tasksInProgress - b.tasksInProgress;
} else {
cmp = a.tasksInReview - b.tasksInReview;
}
if (cmp === 0) cmp = a.workflowId.localeCompare(b.workflowId);
return cmp * dir;
});
return sorted;
}
function buildBarData(
workflows: WorkflowSummary[],
valueFor: (workflow: WorkflowSummary) => number,
unknownLabel: string,
): BarDatum[] {
return [...workflows]
.sort((a, b) => valueFor(b) - valueFor(a) || a.workflowId.localeCompare(b.workflowId))
.slice(0, 12)
.map((workflow) => {
const value = valueFor(workflow);
return {
label: workflowLabel(workflow, unknownLabel),
value,
valueLabel: formatCount(value),
};
});
}
export function WorkflowArea({ range }: { range: DateRange }) {
const { t } = useTranslation("app");
const { data, isLoading, error } = useAnalyticsArea<WorkflowAnalytics>("/command-center/workflows", range);
const unknownWorkflow = t("commandCenter.workflows.unknownWorkflow", "(unknown workflow)");
const noChartData = t("commandCenter.workflows.noChartData", "No non-zero values for this chart yet.");
const workflows = data?.workflows ?? [];
const sortedWorkflows = sortWorkflows(workflows, "tokens", -1, unknownWorkflow);
const tokenBarData = buildBarData(workflows, (workflow) => workflow.tokens.totalTokens, unknownWorkflow);
const completedBarData = buildBarData(workflows, (workflow) => workflow.tasksCompleted, unknownWorkflow);
const hasTokenChart = tokenBarData.some((datum) => datum.value > 0);
const hasCompletedChart = completedBarData.some((datum) => datum.value > 0);
return (
<AreaShell
testId="workflows"
isLoading={isLoading}
error={error}
isEmpty={!data || data.workflows.length === 0}
emptyMessage={t("commandCenter.workflows.empty", "No workflow analytics have been recorded for this range yet.")}
>
<div className="cc-area-section">
<h3 className="cc-area-section-title">{t("commandCenter.workflows.totalsTitle", "Workflow totals")}</h3>
<div className="cc-stat-grid">
<div className="card cc-stat-card" data-testid="cc-workflows-total-tokens">
<div className="cc-stat-label">{t("commandCenter.workflows.totalTokens", "Total tokens")}</div>
<div className="cc-stat-value">{formatCount(data?.totals.tokens.totalTokens ?? 0)}</div>
</div>
<div className="card cc-stat-card" data-testid="cc-workflows-total-cost">
<div className="cc-stat-label">{t("commandCenter.workflows.totalCost", "Estimated cost")}</div>
<div className="cc-stat-value">
{data ? formatCost(data.totals.cost.usd, data.totals.cost.unavailable) : "—"}
</div>
</div>
<div className="card cc-stat-card" data-testid="cc-workflows-total-files">
<div className="cc-stat-label">{t("commandCenter.workflows.filesChanged", "Files changed")}</div>
<div className="cc-stat-value">{formatCount(data?.totals.filesChanged ?? 0)}</div>
</div>
<div className="card cc-stat-card" data-testid="cc-workflows-total-completed">
<div className="cc-stat-label">{t("commandCenter.workflows.tasksCompleted", "Tasks done")}</div>
<div className="cc-stat-value">{formatCount(data?.totals.tasksCompleted ?? 0)}</div>
</div>
</div>
</div>
<div className="cc-area-section cc-team-chart-grid">
<div className="cc-team-chart-panel" data-testid="cc-workflows-tokens-chart">
<h3 className="cc-area-section-title">{t("commandCenter.workflows.tokensByWorkflow", "Tokens by workflow")}</h3>
{hasTokenChart ? (
<Bar data={tokenBarData} ariaLabel={t("commandCenter.workflows.tokensByWorkflow", "Tokens by workflow")} />
) : (
<p className="cc-muted-hint">{noChartData}</p>
)}
</div>
<div className="cc-team-chart-panel" data-testid="cc-workflows-completed-chart">
<h3 className="cc-area-section-title">{t("commandCenter.workflows.completedByWorkflow", "Tasks done by workflow")}</h3>
{hasCompletedChart ? (
<Bar data={completedBarData} ariaLabel={t("commandCenter.workflows.completedByWorkflow", "Tasks done by workflow")} />
) : (
<p className="cc-muted-hint">{noChartData}</p>
)}
</div>
</div>
<div className="cc-area-section">
<h3 className="cc-area-section-title">{t("commandCenter.workflows.tableTitle", "Per-workflow breakdown")}</h3>
<div className="cc-table-wrap">
<table className="cc-table" data-testid="cc-workflows-table">
<thead>
<tr>
<th>{t("commandCenter.workflows.workflow", "Workflow")}</th>
<th>{t("commandCenter.workflows.tokens", "Tokens")}</th>
<th>{t("commandCenter.workflows.cost", "Cost")}</th>
<th>{t("commandCenter.workflows.files", "Files changed")}</th>
<th>{t("commandCenter.workflows.done", "Tasks done")}</th>
<th>{t("commandCenter.workflows.inProgress", "In progress")}</th>
<th>{t("commandCenter.workflows.inReview", "In review")}</th>
</tr>
</thead>
<tbody>
{sortedWorkflows.map((workflow) => (
<tr key={workflow.workflowId} data-testid={`cc-workflows-row-${workflow.workflowId}`}>
<td>
<span className="cc-team-agent-cell">
<span>
<span className="cc-team-agent-name">{workflowLabel(workflow, unknownWorkflow)}</span>
<span className="cc-team-agent-role">{workflow.workflowId}</span>
</span>
</span>
</td>
<td>{formatCount(workflow.tokens.totalTokens)}</td>
<td>{formatCost(workflow.cost.usd, workflow.cost.unavailable)}</td>
<td>{formatCount(workflow.filesChanged)}</td>
<td>{formatCount(workflow.tasksCompleted)}</td>
<td>{formatCount(workflow.tasksInProgress)}</td>
<td>{formatCount(workflow.tasksInReview)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</AreaShell>
);
}

View File

@@ -0,0 +1,136 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, within } from "@testing-library/react";
import type { DateRange } from "../../DateRangePicker";
const mocks = vi.hoisted(() => ({
api: vi.fn(),
}));
vi.mock("../../../../api/legacy", () => ({
api: (path: string, opts?: RequestInit) => mocks.api(path, opts),
}));
import { WorkflowArea } from "../WorkflowArea";
const range7d: DateRange = { from: "2026-06-08", to: null, preset: "7d" };
function emptyWorkflowFixture() {
return {
from: null,
to: null,
totals: {
tokens: { inputTokens: 0, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 0, nTasks: 0 },
cost: { usd: null, unavailable: false, stale: false },
filesChanged: 0,
tasksCompleted: 0,
tasksInProgress: 0,
tasksInReview: 0,
},
workflows: [],
};
}
function populatedWorkflowFixture() {
return {
...emptyWorkflowFixture(),
totals: {
tokens: { inputTokens: 1000, outputTokens: 500, cachedTokens: 150, cacheWriteTokens: 0, totalTokens: 1650, nTasks: 3 },
cost: { usd: 4.25, unavailable: true, stale: false },
filesChanged: 9,
tasksCompleted: 4,
tasksInProgress: 1,
tasksInReview: 1,
},
workflows: [
{
workflowId: "builtin:coding",
workflowName: "Coding (built-in)",
isBuiltin: true,
tokens: { inputTokens: 900, outputTokens: 450, cachedTokens: 150, cacheWriteTokens: 0, totalTokens: 1500, nTasks: 2 },
cost: { usd: 4.25, unavailable: false, stale: false },
filesChanged: 7,
tasksCompleted: 3,
tasksInProgress: 1,
tasksInReview: 0,
},
{
workflowId: "WF-unpriced",
workflowName: "Unpriced Workflow",
isBuiltin: false,
tokens: { inputTokens: 100, outputTokens: 50, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 150, nTasks: 1 },
cost: { usd: null, unavailable: true, stale: false },
filesChanged: 2,
tasksCompleted: 1,
tasksInProgress: 0,
tasksInReview: 1,
},
],
};
}
beforeEach(() => {
mocks.api.mockReset();
});
describe("WorkflowArea", () => {
it("renders loading, empty, and error states through AreaShell", async () => {
let resolveWorkflows: (value: unknown) => void = () => undefined;
mocks.api.mockReturnValueOnce(new Promise((resolve) => { resolveWorkflows = resolve; }));
const { unmount } = render(<WorkflowArea range={range7d} />);
expect(screen.getByTestId("cc-area-workflows-loading")).toBeTruthy();
resolveWorkflows(emptyWorkflowFixture());
await screen.findByTestId("cc-area-workflows-empty");
unmount();
mocks.api.mockRejectedValueOnce(new Error("workflow failed"));
render(<WorkflowArea range={range7d} />);
await screen.findByTestId("cc-area-workflows-error");
expect(screen.getByTestId("cc-area-workflows-error").textContent).toContain("workflow failed");
});
it("renders per-workflow totals, charts, and table rows without showing unavailable cost as zero", async () => {
mocks.api.mockResolvedValueOnce(populatedWorkflowFixture());
render(<WorkflowArea range={range7d} />);
await screen.findByTestId("cc-area-workflows");
expect(mocks.api).toHaveBeenCalledWith("/command-center/workflows?from=2026-06-08", undefined);
expect(screen.getByTestId("cc-workflows-total-tokens").textContent).toContain("1,650");
expect(screen.getByTestId("cc-workflows-total-cost").textContent).toContain("—");
expect(screen.getByTestId("cc-workflows-total-cost").textContent).not.toContain("$0");
expect(screen.getByTestId("cc-workflows-tokens-chart").textContent).toContain("Coding (built-in)");
const table = screen.getByTestId("cc-workflows-table");
expect(within(table).getByText("Coding (built-in)")).toBeTruthy();
expect(within(table).getByText("Unpriced Workflow")).toBeTruthy();
expect(screen.getByTestId("cc-workflows-row-builtin:coding").textContent).toContain("$4.25");
const unpricedRow = screen.getByTestId("cc-workflows-row-WF-unpriced");
expect(unpricedRow.textContent).toContain("—");
expect(unpricedRow.textContent).not.toContain("$0");
});
it("keeps chart rendering safe when workflows have zero-valued metrics", async () => {
mocks.api.mockResolvedValueOnce({
...emptyWorkflowFixture(),
workflows: [
{
workflowId: "WF-zero",
workflowName: "Zero Workflow",
isBuiltin: false,
tokens: { inputTokens: 0, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 0, nTasks: 0 },
cost: { usd: null, unavailable: false, stale: false },
filesChanged: 0,
tasksCompleted: 0,
tasksInProgress: 0,
tasksInReview: 0,
},
],
});
render(<WorkflowArea range={range7d} />);
await screen.findByTestId("cc-area-workflows");
expect(screen.getByTestId("cc-workflows-tokens-chart").textContent).toContain("No non-zero values");
expect(screen.getByTestId("cc-workflows-completed-chart").textContent).toContain("No non-zero values");
expect(screen.getByTestId("cc-area-workflows").textContent).not.toContain("NaN");
});
});

View File

@@ -53,6 +53,10 @@ class MockStore extends EventEmitter {
};
}
async getDefaultWorkflowId(): Promise<string | undefined> {
return undefined;
}
getGlobalSettingsStore() {
return {
/*
@@ -88,6 +92,7 @@ const ENDPOINTS: Array<{ method?: "GET" | "POST"; path: string }> = [
{ method: "POST", path: "/api/command-center/productivity/backfill-loc" },
{ path: "/api/command-center/plugin-activations" },
{ path: "/api/command-center/team" },
{ path: "/api/command-center/workflows" },
{ path: "/api/command-center/github" },
{ path: "/api/command-center/signals" },
{ path: "/api/command-center/live" },

View File

@@ -99,6 +99,37 @@ function seedTeamMetrics(db: Database, opts: { agentId: string; name: string; to
);
}
function seedWorkflowMetrics(db: Database, opts: { taskId: string; workflowId?: string; workflowName?: string; tokens: number }): void {
if (opts.workflowId && opts.workflowName) {
db.prepare(
`INSERT OR IGNORE INTO workflows (id, name, description, ir, layout, kind, createdAt, updatedAt)
VALUES (?, ?, '', '{"version":"v1","name":"test","nodes":[],"edges":[]}', '{}', 'workflow',
'2026-03-01T00:00:00.000Z', '2026-03-01T00:00:00.000Z')`,
).run(opts.workflowId, opts.workflowName);
}
db.prepare(
`INSERT INTO tasks
(id, description, "column", modelProvider, modelId,
tokenUsageInputTokens, tokenUsageOutputTokens, tokenUsageTotalTokens,
tokenUsageLastUsedAt, modifiedFiles, columnMovedAt, createdAt, updatedAt)
VALUES (?, 'desc', 'done', 'anthropic', 'claude-sonnet-4-5', ?, ?, ?,
'2026-03-01T00:00:00.000Z', ?, '2026-03-02T00:00:00.000Z',
'2026-03-01T00:00:00.000Z', '2026-03-02T00:00:00.000Z')`,
).run(
opts.taskId,
opts.tokens,
opts.tokens,
opts.tokens * 2,
JSON.stringify([`src/${opts.taskId}.ts`]),
);
if (opts.workflowId) {
db.prepare(
`INSERT INTO task_workflow_selection (taskId, workflowId, stepIds, updatedAt)
VALUES (?, ?, '[]', '2026-03-02T00:00:00.000Z')`,
).run(opts.taskId, opts.workflowId);
}
}
function seedSignalMetrics(db: Database, opts: { prefix: string; source: string; open: number; resolved: number }): void {
let seq = 0;
for (let i = 0; i < opts.open; i += 1) {
@@ -218,6 +249,7 @@ function storeFor(
...globalSettings,
} as GlobalSettings;
store.getDatabase = () => db;
store.getDefaultWorkflowId = vi.fn(async () => undefined) as TaskStore["getDefaultWorkflowId"];
store.getGlobalSettingsStore = () => ({
getSettings: async () => settings,
invalidateCache: vi.fn(),
@@ -463,6 +495,43 @@ describe("register-command-center-routes", () => {
);
});
it("returns the workflow aggregator shape for a fixture DB", async () => {
seedWorkflowMetrics(dbA, { taskId: "FN-A-workflow-custom", workflowId: "WF-route-a", workflowName: "Route Workflow", tokens: 321 });
seedWorkflowMetrics(dbA, { taskId: "FN-A-workflow-default", tokens: 50 });
const res = await request(
app,
"GET",
"/api/command-center/workflows?from=2026-02-01T00:00:00.000Z&to=2026-04-01T00:00:00.000Z&projectId=proj-a",
);
expect(res.status).toBe(200);
const body = res.body as {
totals: { tokens: { totalTokens: number }; filesChanged: number; tasksCompleted: number };
workflows: Array<{ workflowId: string; workflowName: string; tokens: { totalTokens: number }; filesChanged: number }>;
};
expect(body).toHaveProperty("totals");
expect(body).toHaveProperty("workflows");
expect(body.totals.tokens.totalTokens).toBe(942);
expect(body.totals.filesChanged).toBe(2);
expect(body.totals.tasksCompleted).toBe(2);
expect(body.workflows).toContainEqual(
expect.objectContaining({
workflowId: "WF-route-a",
workflowName: "Route Workflow",
tokens: expect.objectContaining({ totalTokens: 642 }),
filesChanged: 1,
}),
);
expect(body.workflows).toContainEqual(
expect.objectContaining({
workflowId: "builtin:coding",
workflowName: "Coding (built-in)",
tokens: expect.objectContaining({ totalTokens: 300 }),
}),
);
});
it("team analytics applies persisted pricing overrides", async () => {
seedTeamMetrics(dbA, { agentId: "agent-route-a", name: "Route Alpha", tokens: 100, taskId: "FN-A-team-override" });
const storeA = storeFor(dbA, {}, {
@@ -586,6 +655,7 @@ describe("register-command-center-routes", () => {
"activity",
"productivity",
"team",
"workflows",
"github",
"signals",
"plugin-activations",
@@ -715,6 +785,22 @@ describe("register-command-center-routes", () => {
expect(empty.body).toMatchObject({ activations: 0, byPlugin: [], unavailable: true });
});
it("workflow endpoint stays project scoped", async () => {
seedWorkflowMetrics(dbA, { taskId: "FN-A-workflow", workflowId: "WF-project-a", workflowName: "Project A Workflow", tokens: 111 });
seedWorkflowMetrics(dbB, { taskId: "FN-B-workflow", workflowId: "WF-project-b", workflowName: "Project B Workflow", tokens: 999 });
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/workflows?${range}&projectId=proj-a`);
const b = await request(app, "GET", `/api/command-center/workflows?${range}&projectId=proj-b`);
const aWorkflows = (a.body as { workflows: Array<{ workflowId: string; workflowName: string; tokens: { totalTokens: number } }> }).workflows;
const bWorkflows = (b.body as { workflows: Array<{ workflowId: string; workflowName: string; tokens: { totalTokens: number } }> }).workflows;
expect(aWorkflows.some((workflow) => workflow.workflowId === "WF-project-a" && workflow.tokens.totalTokens === 222)).toBe(true);
expect(aWorkflows.some((workflow) => workflow.workflowId === "WF-project-b" || workflow.workflowName === "Project B Workflow")).toBe(false);
expect(bWorkflows.some((workflow) => workflow.workflowId === "WF-project-b" && workflow.tokens.totalTokens === 1998)).toBe(true);
expect(bWorkflows.some((workflow) => workflow.workflowId === "WF-project-a" || workflow.workflowName === "Project A Workflow")).toBe(false);
});
it("team endpoint stays project scoped", async () => {
seedTeamMetrics(dbA, { agentId: "agent-a-only", name: "Project A Agent", tokens: 111, taskId: "FN-A-team" });
seedTeamMetrics(dbB, { agentId: "agent-b-only", name: "Project B Agent", tokens: 999, taskId: "FN-B-team" });
@@ -864,6 +950,27 @@ describe("register-command-center-routes", () => {
expect(lines[1]).toContain(",0,");
});
it("?format=csv includes workflow detail rows", async () => {
const range = "from=2026-02-01T00:00:00.000Z&to=2026-04-01T00:00:00.000Z";
seedWorkflowMetrics(dbA, { taskId: "FN-A-workflow-csv", workflowId: "WF-csv", workflowName: "CSV Workflow", tokens: 123 });
const res = await request(
app,
"GET",
`/api/command-center/workflows?${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="command-center-workflows.csv"',
);
const csv = res.body as string;
expect(csv).toContain("workflowId,workflowName,isBuiltin");
expect(csv).toContain("WF-csv,CSV Workflow,false");
expect(csv).toContain("(total),(total),");
});
it("?format=csv includes GitHub resolved issue detail rows", async () => {
seedGithubIssueMetrics(dbA, { prefix: "FN-A", repo: "acme/alpha", filed: 1, fixed: 1 });
seedGithubIssueMetrics(dbB, { prefix: "FN-B", repo: "acme/beta", filed: 1, fixed: 1 });
@@ -941,6 +1048,7 @@ describe("register-command-center-routes", () => {
["tools", "command-center-tools.csv"],
["activity", "command-center-activity.csv"],
["productivity", "command-center-productivity.csv"],
["workflows", "command-center-workflows.csv"],
["github", "command-center-github.csv"],
]) {
const res = await request(
@@ -1072,6 +1180,7 @@ describe("vite /api proxy negative-lookahead (proxy verification)", () => {
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/team")).toBe(true);
expect(PROXY_RE.test("/api/command-center/workflows")).toBe(true);
expect(PROXY_RE.test("/api/command-center/live")).toBe(true);
expect(PROXY_RE.test("/api/command-center/github")).toBe(true);
expect(PROXY_RE.test("/api/command-center/signals")).toBe(true);

View File

@@ -4,6 +4,7 @@ import type {
ActivityAnalytics,
ProductivityAnalytics,
GithubIssueAnalytics,
WorkflowAnalytics,
} from "@fusion/core";
/**
@@ -182,6 +183,62 @@ export function productivityAnalyticsToTable(
return { header, rows };
}
/** Workflow analytics → CSV. One row per workflow plus a summary row. */
export function workflowAnalyticsToTable(result: WorkflowAnalytics): CsvTable {
const header = [
"workflowId",
"workflowName",
"isBuiltin",
"inputTokens",
"outputTokens",
"cachedTokens",
"cacheWriteTokens",
"totalTokens",
"nTasks",
"costUsd",
"costUnavailable",
"tasksCompleted",
"tasksInProgress",
"tasksInReview",
"filesChanged",
];
const rows: CsvCell[][] = result.workflows.map((workflow) => [
workflow.workflowId,
workflow.workflowName,
workflow.isBuiltin,
workflow.tokens.inputTokens,
workflow.tokens.outputTokens,
workflow.tokens.cachedTokens,
workflow.tokens.cacheWriteTokens,
workflow.tokens.totalTokens,
workflow.tokens.nTasks,
workflow.cost.usd,
workflow.cost.unavailable,
workflow.tasksCompleted,
workflow.tasksInProgress,
workflow.tasksInReview,
workflow.filesChanged,
]);
rows.push([
"(total)",
"(total)",
"",
result.totals.tokens.inputTokens,
result.totals.tokens.outputTokens,
result.totals.tokens.cachedTokens,
result.totals.tokens.cacheWriteTokens,
result.totals.tokens.totalTokens,
result.totals.tokens.nTasks,
result.totals.cost.usd,
result.totals.cost.unavailable,
result.totals.tasksCompleted,
result.totals.tasksInProgress,
result.totals.tasksInReview,
result.totals.filesChanged,
]);
return { header, rows };
}
/** GitHub issue analytics → CSV. Daily, repo, resolved detail, and summary rows. */
export function githubIssueAnalyticsToTable(
result: GithubIssueAnalytics,

View File

@@ -5,6 +5,7 @@ import {
aggregateProductivityAnalytics,
aggregatePluginActivations,
aggregateTeamAnalytics,
aggregateWorkflowAnalytics,
aggregateGithubIssueAnalytics,
aggregateSignalsAnalytics,
composeLiveSnapshot,
@@ -21,6 +22,7 @@ import {
toolAnalyticsToTable,
activityAnalyticsToTable,
productivityAnalyticsToTable,
workflowAnalyticsToTable,
githubIssueAnalyticsToTable,
type CsvTable,
} from "../command-center-csv.js";
@@ -351,6 +353,34 @@ export const registerCommandCenterRoutes: ApiRouteRegistrar = (ctx) => {
}
});
/**
* GET /api/command-center/workflows
* Per-workflow store-derived tokens/cost, files changed, and task counts.
*/
router.get("/command-center/workflows", async (req, res) => {
try {
const store = await getScopedStore(req);
const range = resolveRange(req.query);
const settings = await store.getGlobalSettingsStore().getSettings();
const defaultWorkflowId = (await store.getDefaultWorkflowId()) ?? "builtin:coding";
const result = aggregateWorkflowAnalytics(store.getDatabase(), {
from: range.from,
to: range.to,
now: Date.now(),
pricingOverrides: settings.modelPricingOverrides,
defaultWorkflowId,
});
if (wantsCsv(req.query)) {
sendCsv(res, "command-center-workflows.csv", workflowAnalyticsToTable(result));
return;
}
res.json(result);
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to aggregate workflow analytics");
}
});
/**
* GET /api/command-center/github
* GitHub issues filed by Fusion and imported GitHub issues fixed by Fusion.

View File

@@ -1771,6 +1771,7 @@
"signals": "Signals",
"system": "System",
"team": "Team",
"workflows": "Workflows",
"tokens": "Tokens",
"tools": "Tools"
},
@@ -1798,6 +1799,26 @@
"unknownRole": "Unknown role",
"unknownState": "unknown"
},
"workflows": {
"completedByWorkflow": "Tasks done by workflow",
"cost": "Cost",
"done": "Tasks done",
"empty": "No workflow analytics have been recorded for this range yet.",
"files": "Files changed",
"filesChanged": "Files changed",
"inProgress": "In progress",
"inReview": "In review",
"noChartData": "No non-zero values for this chart yet.",
"tableTitle": "Per-workflow breakdown",
"tasksCompleted": "Tasks done",
"tokens": "Tokens",
"tokensByWorkflow": "Tokens by workflow",
"totalCost": "Estimated cost",
"totalsTitle": "Workflow totals",
"totalTokens": "Total tokens",
"unknownWorkflow": "(unknown workflow)",
"workflow": "Workflow"
},
"tokens": {
"byModelChart": "Tokens by model",
"cached": "Cached",

View File

@@ -1760,6 +1760,7 @@
"signals": "",
"system": "",
"team": "",
"workflows": "Workflows",
"tokens": "",
"tools": ""
},
@@ -1787,6 +1788,26 @@
"unknownRole": "",
"unknownState": ""
},
"workflows": {
"completedByWorkflow": "Tasks done by workflow",
"cost": "Cost",
"done": "Tasks done",
"empty": "No workflow analytics have been recorded for this range yet.",
"files": "Files changed",
"filesChanged": "Files changed",
"inProgress": "In progress",
"inReview": "In review",
"noChartData": "No non-zero values for this chart yet.",
"tableTitle": "Per-workflow breakdown",
"tasksCompleted": "Tasks done",
"tokens": "Tokens",
"tokensByWorkflow": "Tokens by workflow",
"totalCost": "Estimated cost",
"totalsTitle": "Workflow totals",
"totalTokens": "Total tokens",
"unknownWorkflow": "(unknown workflow)",
"workflow": "Workflow"
},
"tokens": {
"byModelChart": "",
"cached": "",

View File

@@ -1760,6 +1760,7 @@
"signals": "",
"system": "",
"team": "",
"workflows": "Workflows",
"tokens": "",
"tools": ""
},
@@ -1787,6 +1788,26 @@
"unknownRole": "",
"unknownState": ""
},
"workflows": {
"completedByWorkflow": "Tasks done by workflow",
"cost": "Cost",
"done": "Tasks done",
"empty": "No workflow analytics have been recorded for this range yet.",
"files": "Files changed",
"filesChanged": "Files changed",
"inProgress": "In progress",
"inReview": "In review",
"noChartData": "No non-zero values for this chart yet.",
"tableTitle": "Per-workflow breakdown",
"tasksCompleted": "Tasks done",
"tokens": "Tokens",
"tokensByWorkflow": "Tokens by workflow",
"totalCost": "Estimated cost",
"totalsTitle": "Workflow totals",
"totalTokens": "Total tokens",
"unknownWorkflow": "(unknown workflow)",
"workflow": "Workflow"
},
"tokens": {
"byModelChart": "",
"cached": "",

View File

@@ -1760,6 +1760,7 @@
"signals": "",
"system": "",
"team": "",
"workflows": "Workflows",
"tokens": "",
"tools": ""
},
@@ -1787,6 +1788,26 @@
"unknownRole": "",
"unknownState": ""
},
"workflows": {
"completedByWorkflow": "Tasks done by workflow",
"cost": "Cost",
"done": "Tasks done",
"empty": "No workflow analytics have been recorded for this range yet.",
"files": "Files changed",
"filesChanged": "Files changed",
"inProgress": "In progress",
"inReview": "In review",
"noChartData": "No non-zero values for this chart yet.",
"tableTitle": "Per-workflow breakdown",
"tasksCompleted": "Tasks done",
"tokens": "Tokens",
"tokensByWorkflow": "Tokens by workflow",
"totalCost": "Estimated cost",
"totalsTitle": "Workflow totals",
"totalTokens": "Total tokens",
"unknownWorkflow": "(unknown workflow)",
"workflow": "Workflow"
},
"tokens": {
"byModelChart": "",
"cached": "",

View File

@@ -1760,6 +1760,7 @@
"signals": "",
"system": "",
"team": "",
"workflows": "Workflows",
"tokens": "",
"tools": ""
},
@@ -1787,6 +1788,26 @@
"unknownRole": "",
"unknownState": ""
},
"workflows": {
"completedByWorkflow": "Tasks done by workflow",
"cost": "Cost",
"done": "Tasks done",
"empty": "No workflow analytics have been recorded for this range yet.",
"files": "Files changed",
"filesChanged": "Files changed",
"inProgress": "In progress",
"inReview": "In review",
"noChartData": "No non-zero values for this chart yet.",
"tableTitle": "Per-workflow breakdown",
"tasksCompleted": "Tasks done",
"tokens": "Tokens",
"tokensByWorkflow": "Tokens by workflow",
"totalCost": "Estimated cost",
"totalsTitle": "Workflow totals",
"totalTokens": "Total tokens",
"unknownWorkflow": "(unknown workflow)",
"workflow": "Workflow"
},
"tokens": {
"byModelChart": "",
"cached": "",

View File

@@ -1760,6 +1760,7 @@
"signals": "",
"system": "",
"team": "",
"workflows": "Workflows",
"tokens": "",
"tools": ""
},
@@ -1787,6 +1788,26 @@
"unknownRole": "",
"unknownState": ""
},
"workflows": {
"completedByWorkflow": "Tasks done by workflow",
"cost": "Cost",
"done": "Tasks done",
"empty": "No workflow analytics have been recorded for this range yet.",
"files": "Files changed",
"filesChanged": "Files changed",
"inProgress": "In progress",
"inReview": "In review",
"noChartData": "No non-zero values for this chart yet.",
"tableTitle": "Per-workflow breakdown",
"tasksCompleted": "Tasks done",
"tokens": "Tokens",
"tokensByWorkflow": "Tokens by workflow",
"totalCost": "Estimated cost",
"totalsTitle": "Workflow totals",
"totalTokens": "Total tokens",
"unknownWorkflow": "(unknown workflow)",
"workflow": "Workflow"
},
"tokens": {
"byModelChart": "",
"cached": "",