fix(core): add task dimension to command-center token grouping

The command-center token endpoint accepts groupBy=task, but the
dimension was never wired through: TokenGroupBy did not include "task",
groupKeyFor had no task case, VALID_GROUP_BY rejected it, and
groupAttributes emitted no attribute. As a result groupBy=task silently
fell back to ungrouped totals — the per-task rollup returned zero groups
even though tasks.tokenUsage* is populated per task.

Thread "task" through all four enumeration sites (the union type makes
the two switches compiler-exhaustive). Task rows group by task id; chat
rows have no task and return null, mirroring the existing node case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
flexi767
2026-07-04 19:29:47 +00:00
parent 2797803c0b
commit b2cf2db106
4 changed files with 14 additions and 4 deletions

View File

@@ -423,7 +423,7 @@ describe("token-analytics", () => {
);
});
it("groups by provider, node, and agent", () => {
it("groups by provider, node, agent, and task", () => {
insertTask(db, { id: "t1", inputTokens: 100, totalTokens: 100, lastUsedAt: "2026-03-01T00:00:00.000Z", modelProvider: "anthropic", nodeId: "node-1", agentId: "agent-x" });
insertTask(db, { id: "t2", inputTokens: 200, totalTokens: 200, lastUsedAt: "2026-03-02T00:00:00.000Z", modelProvider: "openai", nodeId: "node-1", agentId: "agent-y" });
@@ -441,6 +441,11 @@ describe("token-analytics", () => {
expect(new Map(byAgent.groups.map((g) => [g.key, g.totalTokens]))).toEqual(
new Map([["agent-x", 100], ["agent-y", 200]]),
);
const byTask = aggregateTokenAnalytics(db, { groupBy: "task" });
expect(new Map(byTask.groups.map((g) => [g.key, g.totalTokens]))).toEqual(
new Map([["t1", 100], ["t2", 200]]),
);
});
it("includes chat token usage in mixed task and chat totals exactly once", () => {

View File

@@ -161,7 +161,7 @@ function gauge(
/**
* Attributes for a token group. The grouped dimension is reflected by the key
* the aggregator chose (`groupBy`); we tag it with the matching attribute name
* so a collector sees `model` / `provider` / `node.id` / `agent.id`.
* so a collector sees `model` / `provider` / `node.id` / `agent.id` / `task.id`.
*/
function groupAttributes(
groupBy: TokenAnalytics["groupBy"],
@@ -177,6 +177,8 @@ function groupAttributes(
return [attr("node.id", key)];
case "agent":
return [attr("agent.id", key)];
case "task":
return [attr("task.id", key)];
}
}

View File

@@ -18,7 +18,7 @@ import type { TaskTokenUsagePerModel } from "./types.js";
*/
/** Dimension to group token totals by. */
export type TokenGroupBy = "model" | "provider" | "node" | "agent";
export type TokenGroupBy = "model" | "provider" | "node" | "agent" | "task";
/** Bucket size for optional token-usage time-series analytics. */
export type TokenTimeGranularity = "hour" | "day" | "week";
@@ -156,6 +156,8 @@ function groupKeyFor(row: TokenContributionRow, groupBy: TokenGroupBy): string |
return row.contributionKind === "task" ? row.checkoutNodeId : null;
case "agent":
return row.contributionKind === "task" ? row.assignedAgentId : row.agentId;
case "task":
return row.contributionKind === "task" ? row.id : null;
}
}

View File

@@ -67,6 +67,7 @@ const VALID_GROUP_BY: ReadonlySet<string> = new Set<TokenGroupBy>([
"provider",
"node",
"agent",
"task",
]);
const VALID_TOKEN_GRANULARITY: ReadonlySet<string> = new Set<TokenTimeGranularity>([
@@ -174,7 +175,7 @@ export const registerCommandCenterRoutes: ApiRouteRegistrar = (ctx) => {
/**
* GET /api/command-center/tokens
* Token consumption + derived USD cost (U2 + U3) over a date range.
* Query: from, to (ISO-8601), groupBy (model|provider|node|agent).
* Query: from, to (ISO-8601), groupBy (model|provider|node|agent|task).
*/
router.get("/command-center/tokens", async (req, res) => {
try {