diff --git a/.changeset/team-analytics-renamed-lanes.md b/.changeset/team-analytics-renamed-lanes.md new file mode 100644 index 0000000000..d10ad7ea04 --- /dev/null +++ b/.changeset/team-analytics-renamed-lanes.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Command Center team analytics now count completed and in-flight work on renamed boards. +category: fix +dev: `aggregateTeamAnalytics` takes an optional lane store and resolves complete / wip / human-review columns via `resolveProjectColumnsForRoles`; its SQL previously filtered on the literal `'done'` and `('in-progress','in-review')`, which match nothing on a custom workflow. diff --git a/packages/core/src/__tests__/postgres/team-analytics-renamed-lanes.pg.test.ts b/packages/core/src/__tests__/postgres/team-analytics-renamed-lanes.pg.test.ts new file mode 100644 index 0000000000..fb799c8113 --- /dev/null +++ b/packages/core/src/__tests__/postgres/team-analytics-renamed-lanes.pg.test.ts @@ -0,0 +1,137 @@ +/* +FNXC:WorkflowResolvedColumns 2026-07-30-17:40 (analytics reported an idle project on a renamed board): + +`aggregateTeamAnalytics` filtered on `"column" = 'done'` and `"column" IN ('in-progress','in-review')` +directly in SQL. On a board whose lanes are renamed, every one of those matched nothing: per-agent +completed counts, the project total, and the in-flight breakdown all came back ZERO. + +Nothing errors, which is what makes it expensive. A dashboard reading "0 tasks completed" for a team +that shipped all week looks like an idle project, not a bug, and wrong-but-plausible numbers are the +least likely defect for anyone to file. + +WHY NO EXISTING CHECK SAW IT. The lifecycle census parses TypeScript comparisons; these ids live +inside SQL strings, which are string data. The conversion sweep that fixed this file's TypeScript +guards left the queries untouched, and the file scored as converted. + +The cases are DIFFERENTIAL: the same seeded work, aggregated twice, under two vocabularies whose +roles are identical and only the ids differ. `shipped` and `building` collide with no legacy id, so a +surviving `'done'` cannot pass by luck. +*/ + +import { it, expect, beforeAll, beforeEach, afterEach, afterAll } from "vitest"; +import { sql } from "drizzle-orm"; +import { + pgDescribe, + createSharedPgTaskStoreTestHarness, + type SharedPgTaskStoreHarness, +} from "../../__test-utils__/pg-test-harness.js"; +import { aggregateTeamAnalytics } from "../../team-analytics.js"; +import { BUILTIN_CODING_WORKFLOW_IR } from "../../index.js"; + +const IN_RANGE = "2026-06-15T12:00:00.000Z"; +const RANGE = { from: "2026-06-01T00:00:00.000Z", to: "2026-06-30T23:59:59.999Z", now: Date.parse(IN_RANGE) }; + +pgDescribe("team analytics under a renamed board vocabulary", () => { + const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({ + prefix: "fusion_team_analytics_lanes", + }); + + beforeAll(h.beforeAll); + beforeEach(h.beforeEach); + afterEach(h.afterEach); + afterAll(h.afterAll); + + /** The builtin coding workflow with only its column ids renamed. */ + async function seedRenamedWorkflow(): Promise { + const RENAME: Record = { + todo: "drafting", + "in-progress": "building", + "in-review": "checking", + done: "shipped", + }; + const rename = (id: string | undefined) => (id && RENAME[id]) ?? id; + const ir = JSON.parse(JSON.stringify(BUILTIN_CODING_WORKFLOW_IR)) as { + id: string; + nodes?: { column?: string }[]; + columns?: { id: string }[]; + }; + ir.id = "custom:renamed-team-analytics"; + for (const node of ir.nodes ?? []) node.column = rename(node.column); + for (const column of ir.columns ?? []) column.id = rename(column.id) as string; + + const ids = (ir.columns ?? []).map((column) => column.id); + expect(ids).toContain("shipped"); + expect(ids).not.toContain("done"); + + await h.store().createWorkflowDefinition({ name: "Renamed", kind: "workflow", ir } as never); + } + + /** One agent with a completed task and one still in flight, in the given lanes. */ + async function seedAgentWork(completeLane: string, wipLane: string): Promise { + const store = h.store(); + const adminDb = h.adminDb(); + await adminDb.execute(sql` + INSERT INTO project.agents (id, name, role, state, created_at, updated_at) + VALUES ('agent-1', 'Agent One', 'executor', 'idle', ${IN_RANGE}, ${IN_RANGE})`); + + for (const [id, lane] of [["KB-DONE", completeLane], ["KB-WIP", wipLane]] as const) { + await store.createTaskWithReservedId( + { description: id, column: "todo" }, + { taskId: id, createdAt: IN_RANGE, updatedAt: IN_RANGE, applyDefaultWorkflowSteps: false }, + ); + /* Seeded directly: the aggregator reads assigned_agent_id and column_moved_at, and moveTask + would stamp columnMovedAt with `now` rather than a date inside the query range. */ + await adminDb.execute(sql` + UPDATE project.tasks + SET "column" = ${lane}, assigned_agent_id = 'agent-1', column_moved_at = ${IN_RANGE} + WHERE id = ${id}`); + store.taskCache.delete(id); + } + } + + /* Control: the default vocabulary counts the completed task. Passes before and after the fix, so a + generally broken aggregator cannot hide behind the renamed case below. */ + it("default vocabulary: a completed task is counted", async () => { + await seedAgentWork("done", "in-progress"); + + const team = await aggregateTeamAnalytics(Object.assign(h.layer(), { projectId: "p1" }), RANGE, h.store()); + + expect(team.totals.tasksCompleted).toBe(1); + }); + + /* + The defect. Before the fix `"column" = 'done'` matched nothing on this board and the whole project + reported zero completed work. + */ + it("renamed vocabulary: a task in the RENAMED complete lane is counted", async () => { + await seedRenamedWorkflow(); + await seedAgentWork("shipped", "building"); + + const team = await aggregateTeamAnalytics(Object.assign(h.layer(), { projectId: "p1" }), RANGE, h.store()); + + expect(team.totals.tasksCompleted).toBe(1); + }); + + /* + The paired negative: resolving the real lanes must not degrade into "every column counts". A task + still in the WIP lane is not completed work, under either vocabulary — otherwise the fix would turn + an undercount into an overcount, which is harder to notice. + */ + it("renamed vocabulary: a task in the WIP lane is NOT counted as completed", async () => { + await seedRenamedWorkflow(); + await seedAgentWork("building", "building"); + + const team = await aggregateTeamAnalytics(Object.assign(h.layer(), { projectId: "p1" }), RANGE, h.store()); + + expect(team.totals.tasksCompleted).toBe(0); + }); + + /* Omitting the store must keep the legacy answer, so an unconverted caller is byte-identical. */ + it("without a lane store, the legacy ids still answer", async () => { + await seedAgentWork("done", "in-progress"); + + const team = await aggregateTeamAnalytics(Object.assign(h.layer(), { projectId: "p1" }), RANGE); + + expect(team.totals.tasksCompleted).toBe(1); + }); +}); diff --git a/packages/core/src/team-analytics.ts b/packages/core/src/team-analytics.ts index 278db2212a..97f14ecb4b 100644 --- a/packages/core/src/team-analytics.ts +++ b/packages/core/src/team-analytics.ts @@ -1,4 +1,5 @@ import { isReviewColumnRole, isWipColumnRole, type ColumnRoleTraitFlags } from "./column-roles.js"; +import { resolveProjectColumnsForRoles, type ProjectLaneVocabularyStore } from "./project-lane-vocabulary.js"; import { sql } from "drizzle-orm"; import type { Database } from "./db.js"; import type { AsyncDataLayer } from "./postgres/data-layer.js"; @@ -215,6 +216,25 @@ function makeSummary(agentId: string, agent?: AgentRow): TeamAgentSummary { export async function aggregateTeamAnalytics( dbOrLayer: Database | AsyncDataLayer, query: TeamAnalyticsQuery = {}, + /* + FNXC:WorkflowResolvedColumns 2026-07-30-17:10: + The store, used ONLY to resolve which columns carry the complete / wip / human-review traits. + + These queries filtered on `"column" = 'done'` and `IN ('in-progress','in-review')`. Those ids are + invisible to the lifecycle census, which parses TypeScript comparisons and not SQL strings, so the + conversion sweep that fixed this file's TS guards left the queries alone. On a board whose lanes are + renamed, every one of them counts ZERO: completed-task totals, per-agent throughput and the + in-flight breakdown all read as an idle project, with no error anywhere. + + Resolved PER PROJECT rather than per task, which is what makes a bound array enough here: analytics + aggregates a whole project, so the union of a role's columns across its workflows is the right set. + (The merge-queue cleanup needed the superset-then-decide-in-JS shape instead, because its lanes are + genuinely per task and SQL cannot know a task's workflow.) + + Omitted, the legacy ids answer — the same degraded contract the role helpers use, so an unconverted + caller is byte-identical. + */ + laneStore?: ProjectLaneVocabularyStore, ): Promise { // FNXC:PostgresCommandCenterAnalytics 2026-06-27-10:00: // Backend (PostgreSQL) path. Fetch agents + the four task-derived row sets @@ -222,7 +242,7 @@ export async function aggregateTeamAnalytics( // connection has no `project` on search_path), then run the identical pure // per-agent aggregation as the sync branch via buildTeamAnalytics. if ("ping" in dbOrLayer) { - return aggregateTeamAnalyticsAsync(dbOrLayer, query); + return aggregateTeamAnalyticsAsync(dbOrLayer, query, laneStore); } const db = dbOrLayer as Database; @@ -295,7 +315,51 @@ export async function aggregateTeamAnalytics( async function aggregateTeamAnalyticsAsync( layer: AsyncDataLayer, query: TeamAnalyticsQuery, + laneStore?: ProjectLaneVocabularyStore, ): Promise { + /* + FNXC:PostgresCommandCenterAnalytics 2026-07-30-20:10 (#2864 review — greptile P1, "project-wide lane + union misclassifies tasks"): ACCEPTED IMPRECISION, RECORDED RATHER THAN LEFT SILENT. + + These are PROJECT-scoped unions — every column any workflow declares for the role. On a project + where two workflows reuse one column id with DIFFERENT traits (`done` as complete in one, an + ordinary lane in another), a task from the second is counted by the first's semantics and the + totals inflate. + + WHY THE UNION STAYS. The rule this program applies is that a union is safe where over-inclusion is + invisible — a query, a candidate set something else narrows — and unsafe where it drives an ACTION. + Nothing is routed or notified here, so no card is mishandled; the cost is a number an operator + reads. That is real, and it is still the better trade: per-task resolution means one workflow read + per task and moving the aggregation out of PostgreSQL into JS, turning an indexed + `COUNT ... WHERE column IN (...)` into an N-read loop on an analytics endpoint. The board-load path + reverted exactly that change once for exactly that reason. + + SCOPE OF THE DEFECT, because "analytics are wrong on renamed boards" would be the wrong reading: the + union is EXACT for any project running a single workflow, and degrades only where two workflows + disagree about the same column id. + + THE REAL FIX is making a task's resolved lane roles QUERYABLE — a column or joinable projection + maintained beside the task row — so the aggregate filters on the role instead of an id set assembled + in application code. That closes this class everywhere at once and is a schema change with an owner, + not a batch conversion. + + Bound as arrays, never interpolated: these ids come from workflow definitions, which are + operator-authored data. `= ANY($n)` keeps them parameters rather than SQL text. + */ + const completeLanes = laneStore + ? [...await resolveProjectColumnsForRoles(laneStore, ["complete"])] + : ["done"]; + const activeLanes = laneStore + ? [...await resolveProjectColumnsForRoles(laneStore, ["countsTowardWip", "humanReview"])] + : ["in-progress", "in-review"]; + /* + Built as an IN list of individual parameters rather than `= ANY(${array})`: drizzle expands a JS + array in a template into a comma-separated tuple, so ANY received `(($1,$2,$3))` and PostgreSQL + rejected it with "op ANY/ALL (array) requires array on right side". Each id stays a bound + parameter either way — these come from operator-authored workflow definitions and are never + interpolated as SQL text. + */ + const inList = (lanes: readonly string[]) => sql.join(lanes.map((lane) => sql`${lane}`), sql`, `); const agents = (await layer.db.execute( sql`SELECT id, name, role, state FROM project.agents ORDER BY id`, )) as unknown as AgentRow[]; @@ -335,14 +399,14 @@ async function aggregateTeamAnalyticsAsync( const completedRows = (await layer.db.execute( sql`SELECT assigned_agent_id AS "agentId", count(*)::int AS count FROM project.tasks - WHERE assigned_agent_id IS NOT NULL AND "column" = 'done' AND column_moved_at IS NOT NULL ${compFrom} ${compTo} + WHERE assigned_agent_id IS NOT NULL AND "column" IN (${inList(completeLanes)}) AND column_moved_at IS NOT NULL ${compFrom} ${compTo} GROUP BY assigned_agent_id`, )) as unknown as CountByAgentRow[]; const currentRows = (await layer.db.execute( sql`SELECT assigned_agent_id AS "agentId", "column" AS "columnName", count(*)::int AS count FROM project.tasks - WHERE assigned_agent_id IS NOT NULL AND "column" IN ('in-progress', 'in-review') + WHERE assigned_agent_id IS NOT NULL AND "column" IN (${inList(activeLanes)}) GROUP BY assigned_agent_id, "column"`, )) as unknown as Array; diff --git a/packages/dashboard/src/routes/register-command-center-routes.ts b/packages/dashboard/src/routes/register-command-center-routes.ts index ca60f76039..5ed8bb3e08 100644 --- a/packages/dashboard/src/routes/register-command-center-routes.ts +++ b/packages/dashboard/src/routes/register-command-center-routes.ts @@ -483,7 +483,13 @@ async function resolveColumnFlagsByName( now: Date.now(), pricingOverrides: settings.modelPricingOverrides, columnFlagsByName: await resolveColumnFlagsByName(store), - }); + /* + FNXC:WorkflowResolvedColumns 2026-07-30-17:20: + The store is the third argument so the analytics queries resolve which lanes carry the + complete / wip / human-review traits. Without it they filter on 'done' and + ('in-progress','in-review') and report zero completed work on any renamed board. + */ + }, store); res.json(result); } catch (err: unknown) { if (err instanceof ApiError) throw err;