Files
fusion/packages/dashboard/src/command-center-csv.ts
gsxdsm 765218f80f FN-7449: include chat token usage in dashboard totals
Chat token usage now contributes to Command Center token analytics totals alongside task execution usage.

- Add durable chat token usage rows and persistence hooks for assistant and room messages.
- Aggregate chat usage into token totals, time series, provider/model groups, CSV export, and dashboard counts.
- Cover chat token persistence and analytics with core and dashboard tests.
- Document the expanded dashboard token usage behavior and add a changeset for the published package.

Files changed:
 .changeset/fn-7449-chat-token-totals.md            |   7 +
 docs/dashboard-guide.md                            |   4 +-
 .../core/src/__tests__/token-analytics.test.ts     | 146 ++++++++++++++++++
 packages/core/src/chat-store.ts                    | 105 +++++++++++++
 packages/core/src/chat-types.ts                    |  37 +++++
 packages/core/src/db.ts                            |  52 ++++++-
 packages/core/src/index.ts                         |   3 +
 packages/core/src/token-analytics.ts               | 167 +++++++++++++++++----
 .../__tests__/CommandCenter.test.tsx               |   4 +-
 .../components/command-center/areas/TokensArea.tsx |   4 +
 .../src/__tests__/chat-manager-cli-send.test.ts    |  40 +++++
 .../dashboard/src/__tests__/chat-manager.test.ts   | 100 ++++++++++++
 .../src/__tests__/command-center-csv.test.ts       |   2 +
 packages/dashboard/src/chat.ts                     | 110 +++++++++++++-
 packages/dashboard/src/command-center-csv.ts       |   3 +
 15 files changed, 745 insertions(+), 39 deletions(-)

Fusion-Task-Id: FN-7449
Fusion-Task-Lineage: f2bacbb6-93b9-4874-b431-2049981b66c1
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-02 15:00:40 -07:00

315 lines
9.6 KiB
TypeScript

import type {
TokenAnalytics,
ToolAnalytics,
ActivityAnalytics,
ProductivityAnalytics,
GithubIssueAnalytics,
GitlabIssueAnalytics,
WorkflowAnalytics,
} from "@fusion/core";
/**
* Command Center CSV serialization (U8).
*
* RFC-4180 serialization of the Phase-A analytics aggregator output so any
* analytics table can be exported as `text/csv`. Pure: these helpers take an
* already-aggregated, already-project-scoped result and emit a string. The
* route handler (`register-command-center-routes.ts`) is responsible for
* resolving the project-scoped store and running the aggregator first, exactly
* like the JSON path — there is no DB access here, so there is no scoping leak
* surface in this module.
*
* Format guarantees (RFC-4180):
* - A header row is always emitted, even for an empty result (header-only CSV,
* never a 204 / empty body).
* - Fields containing a comma, double-quote, CR, or LF are wrapped in double
* quotes; embedded double-quotes are doubled.
* - Records are terminated with CRLF (`\r\n`), consistently.
*/
const CRLF = "\r\n";
/** A scalar cell value. `null`/`undefined` serialize to an empty field. */
export type CsvCell = string | number | boolean | null | undefined;
/** A logical table: a fixed header plus zero or more rows of cells. */
export interface CsvTable {
header: readonly string[];
rows: readonly (readonly CsvCell[])[];
}
/** Quote a single field per RFC-4180 when (and only when) required. */
function quoteField(value: CsvCell): string {
if (value === null || value === undefined) return "";
const s = typeof value === "string" ? value : String(value);
if (/[",\r\n]/.test(s)) {
return `"${s.replace(/"/g, '""')}"`;
}
return s;
}
/** Serialize one record (array of cells) to a CSV line (no terminator). */
function serializeRecord(record: readonly CsvCell[]): string {
return record.map(quoteField).join(",");
}
/**
* Serialize a {@link CsvTable} to an RFC-4180 string. Always emits the header
* row; an empty `rows` yields a header-only document. The document is
* CRLF-terminated, including a trailing CRLF after the final record (RFC-4180
* permits this and it keeps the empty/non-empty cases uniform).
*/
export function serializeCsv(table: CsvTable): string {
const lines: string[] = [serializeRecord(table.header)];
for (const row of table.rows) {
lines.push(serializeRecord(row));
}
return lines.join(CRLF) + CRLF;
}
// ---------------------------------------------------------------------------
// Aggregator → CsvTable converters
//
// Each analytics result is a small nested object; we flatten the
// developer-meaningful fields into a tabular shape. For token analytics with a
// groupBy, each group becomes a row; otherwise the grand total is a single row.
// ---------------------------------------------------------------------------
/** Token analytics → CSV. One row per group, or a single total row. */
export function tokenAnalyticsToTable(result: TokenAnalytics): CsvTable {
const header = [
"key",
"inputTokens",
"outputTokens",
"cachedTokens",
"cacheWriteTokens",
"totalTokens",
"nTasks",
"nChatMessages",
"costUsd",
"costUnavailable",
];
if (result.groupBy && result.groups.length > 0) {
const rows = result.groups.map((g) => [
g.key,
g.inputTokens,
g.outputTokens,
g.cachedTokens,
g.cacheWriteTokens,
g.totalTokens,
g.nTasks,
g.nChatMessages ?? 0,
g.cost.usd,
g.cost.unavailable,
]);
return { header, rows };
}
const t = result.totals;
return {
header,
rows: [
[
"(total)",
t.inputTokens,
t.outputTokens,
t.cachedTokens,
t.cacheWriteTokens,
t.totalTokens,
t.nTasks,
t.nChatMessages ?? 0,
result.cost.usd,
result.cost.unavailable,
],
],
};
}
/** Tool analytics → CSV. One row per category plus a summary row. */
export function toolAnalyticsToTable(result: ToolAnalytics): CsvTable {
const header = ["category", "count"];
const rows: CsvCell[][] = result.byCategory.map((c) => [c.category, c.count]);
// Always include the headline metrics so an empty byCategory is not empty.
rows.push(["(toolCalls)", result.toolCalls]);
rows.push(["(sessions)", result.sessions]);
rows.push(["(interventions)", result.interventions.total]);
rows.push(["(autonomyRatio)", result.autonomyRatio]);
rows.push(["(fullyAutonomous)", result.fullyAutonomous]);
return { header, rows };
}
/** Activity analytics → CSV. One row per day plus summary rows. */
export function activityAnalyticsToTable(result: ActivityAnalytics): CsvTable {
const header = ["day", "messages", "activeNodes", "activeAgents", "agentRuns"];
const rows: CsvCell[][] = result.daily.map((d) => [
d.day,
d.messages,
d.activeNodes,
d.activeAgents,
d.agentRuns,
]);
rows.push([
"(total)",
result.messages,
result.activeNodes,
result.activeAgents,
result.agentRuns.total,
]);
rows.push(["(sessions)", result.sessions, "", "", ""]);
rows.push(["(stickiness)", result.stickiness, "", "", ""]);
rows.push(["(agentRuns.total)", result.agentRuns.total, "", "", ""]);
rows.push(["(agentRuns.active)", result.agentRuns.active, "", "", ""]);
rows.push(["(agentRuns.completed)", result.agentRuns.completed, "", "", ""]);
rows.push(["(agentRuns.failed)", result.agentRuns.failed, "", "", ""]);
return { header, rows };
}
/** Productivity analytics → CSV. One row per language plus summary rows. */
export function productivityAnalyticsToTable(
result: ProductivityAnalytics,
): CsvTable {
const header = ["metric", "count"];
const rows: CsvCell[][] = [];
for (const lang of result.byLanguage) {
rows.push([`language:${lang.language}`, lang.count]);
}
rows.push(["modifiedFiles", result.modifiedFiles]);
rows.push(["commits", result.commits]);
rows.push(["pullRequests", result.pullRequests]);
rows.push(["loc", result.loc.value ?? ""]);
rows.push(["hoursSaved", result.hoursSaved.value ?? ""]);
rows.push(["completedTasks", result.taskDuration.completedTasks]);
rows.push(["avgDurationMs", result.taskDuration.averageMs ?? ""]);
rows.push(["medianDurationMs", result.taskDuration.medianMs ?? ""]);
rows.push(["p90DurationMs", result.taskDuration.p90Ms ?? ""]);
rows.push(["totalDurationMs", result.taskDuration.totalMs ?? ""]);
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,
): CsvTable {
const header = [
"section",
"key",
"filed",
"fixed",
"net",
"taskId",
"taskTitle",
"resolvedAt",
"resolvedAtExact",
"url",
];
const rows: CsvCell[][] = result.daily.map((d) => [
"daily",
d.date,
d.filed,
d.fixed,
d.filed - d.fixed,
"",
"",
"",
"",
"",
]);
for (const repo of result.byRepo) {
rows.push(["repo", repo.repo, repo.filed, repo.fixed, repo.filed - repo.fixed, "", "", "", "", ""]);
}
for (const issue of result.resolved) {
const key = issue.issueNumber === null ? issue.repo : `${issue.repo}#${issue.issueNumber}`;
rows.push([
"resolved",
key,
"",
"",
"",
issue.taskId,
issue.taskTitle,
issue.resolvedAt,
issue.resolvedAtExact,
issue.url,
]);
}
rows.push(["summary", "total", result.filed, result.fixed, result.net, "", "", "", "", ""]);
return { header, rows };
}
/** GitLab issue/MR analytics → CSV. Daily, project, resolved detail, and summary rows. */
export function gitlabIssueAnalyticsToTable(
result: GitlabIssueAnalytics,
): CsvTable {
const githubShaped: GithubIssueAnalytics = {
...result,
byRepo: result.byProject.map((entry) => ({ repo: entry.project, filed: entry.filed, fixed: entry.fixed })),
resolved: result.resolved.map((entry) => ({
taskId: entry.taskId,
taskTitle: entry.taskTitle,
repo: entry.project,
issueNumber: entry.issueNumber,
url: entry.url,
resolvedAt: entry.resolvedAt,
resolvedAtExact: entry.resolvedAtExact,
})),
};
return githubIssueAnalyticsToTable(githubShaped);
}