From b2cf2db106ae9653e9d7813ca78e4741b0f0a511 Mon Sep 17 00:00:00 2001 From: flexi767 Date: Sat, 4 Jul 2026 19:29:47 +0000 Subject: [PATCH] fix(core): add task dimension to command-center token grouping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/core/src/__tests__/token-analytics.test.ts | 7 ++++++- packages/core/src/otel-metrics.ts | 4 +++- packages/core/src/token-analytics.ts | 4 +++- .../dashboard/src/routes/register-command-center-routes.ts | 3 ++- 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/core/src/__tests__/token-analytics.test.ts b/packages/core/src/__tests__/token-analytics.test.ts index caa469d319..91557e0542 100644 --- a/packages/core/src/__tests__/token-analytics.test.ts +++ b/packages/core/src/__tests__/token-analytics.test.ts @@ -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", () => { diff --git a/packages/core/src/otel-metrics.ts b/packages/core/src/otel-metrics.ts index 43990bbbd8..615d21b70e 100644 --- a/packages/core/src/otel-metrics.ts +++ b/packages/core/src/otel-metrics.ts @@ -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)]; } } diff --git a/packages/core/src/token-analytics.ts b/packages/core/src/token-analytics.ts index d958179cf0..caa2ea7516 100644 --- a/packages/core/src/token-analytics.ts +++ b/packages/core/src/token-analytics.ts @@ -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; } } diff --git a/packages/dashboard/src/routes/register-command-center-routes.ts b/packages/dashboard/src/routes/register-command-center-routes.ts index 5076d5ab9c..70999e1eb1 100644 --- a/packages/dashboard/src/routes/register-command-center-routes.ts +++ b/packages/dashboard/src/routes/register-command-center-routes.ts @@ -67,6 +67,7 @@ const VALID_GROUP_BY: ReadonlySet = new Set([ "provider", "node", "agent", + "task", ]); const VALID_TOKEN_GRANULARITY: ReadonlySet = new Set([ @@ -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 {