fix(core): team analytics reported an idle project on a renamed board (#2864)

First of the 14 genuinely lane-bound SQL sites from #2839. I had been
deferring these as "the owner is active in those files" — then checked,
and **no open PR touches them**. The batch-core commit had already
landed, so there was nothing in flight to collide with. The deferral was
an assumption I could have tested two turns earlier.

## The defect

`aggregateTeamAnalytics` filtered in SQL on `"column" = 'done'` and `IN
('in-progress','in-review')`. On a board whose lanes are renamed those
match nothing, so per-agent completed counts, the project total, and the
in-flight breakdown all came back **zero**.

Nothing errors. A dashboard reading *"0 tasks completed"* for a team
that shipped all week looks like an idle project, not a bug — which is
why this survives review and never gets filed.

**Why the sweep missed it:** the lifecycle census parses TypeScript
comparisons; these ids live inside SQL strings, which are string data.
The batch-core conversion fixed this file's TS guards **today** and left
the queries untouched. The file scored as converted.

## Resolved per project, not per task

`resolveProjectColumnsForRoles` gives the union of a role's columns
across the project's workflows, which is the right set here because
analytics aggregates a whole project — so a bound `IN` list is
sufficient. 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. Same program,
two correct answers; worth not copying the wrong one.

## Measured

Reverted, exactly one case flips:

```
✓ default vocabulary: a completed task is counted
× renamed vocabulary: a task in the RENAMED complete lane is counted
✓ renamed vocabulary: a task in the WIP lane is NOT counted as completed
✓ without a lane store, the legacy ids still answer
  Tests  1 failed | 3 passed (4)
```

The three controls are deliberate: the default vocabulary (a generally
broken aggregator cannot hide behind the renamed case), a WIP task that
must **not** count as completed on the renamed board (resolving real
lanes must not degrade into "every column counts" — an undercount turned
overcount is harder to notice), and an omitted lane store that must keep
the legacy answer byte-identical.

## Two mistakes the first attempt made, both caught by running it

- **`= ANY(${array})` does not work.** Drizzle expands a JS array in a
template into a comma tuple, so PostgreSQL rejected `(($1,$2,$3))` with
*"op ANY/ALL (array) requires array on right side"*. An `IN` list of
individual bound parameters is the working shape. Each id stays a
parameter — these come from operator-authored workflow definitions and
are never interpolated as SQL text.
- The fixture's `ON CONFLICT (id)` had no matching constraint on
`project.agents`; the sibling suite seeds with explicit
`created_at`/`updated_at`.

## Scope

One production caller (`register-command-center-routes.ts`), threaded in
this same change so the parameter has a supplier from the start rather
than becoming another inert seam of exactly the class this program keeps
finding.

The sync SQLite arm in the same file keeps its literals: that path
throws in backend mode and has no production caller, the same dead-arm
conclusion reached for `cleanupStaleMergeQueueRowsImpl` on #2839.

## Verification

`pnpm test:gate` green · both Command Center analytics suites 8/8 ·
`tsc` core 0, dashboard 0 · lint 0 · changeset included.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-30 16:09:14 -07:00
committed by GitHub
parent ef50244234
commit ea477f3ada
4 changed files with 218 additions and 4 deletions

View File

@@ -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.

View File

@@ -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<void> {
const RENAME: Record<string, string> = {
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<void> {
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);
});
});

View File

@@ -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<TeamAnalytics> {
// 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<TeamAnalytics> {
/*
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<CountByAgentRow & { columnName: string }>;

View File

@@ -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;