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:
7
.changeset/team-analytics-renamed-lanes.md
Normal file
7
.changeset/team-analytics-renamed-lanes.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 }>;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user