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 { isReviewColumnRole, isWipColumnRole, type ColumnRoleTraitFlags } from "./column-roles.js";
|
||||||
|
import { resolveProjectColumnsForRoles, type ProjectLaneVocabularyStore } from "./project-lane-vocabulary.js";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import type { Database } from "./db.js";
|
import type { Database } from "./db.js";
|
||||||
import type { AsyncDataLayer } from "./postgres/data-layer.js";
|
import type { AsyncDataLayer } from "./postgres/data-layer.js";
|
||||||
@@ -215,6 +216,25 @@ function makeSummary(agentId: string, agent?: AgentRow): TeamAgentSummary {
|
|||||||
export async function aggregateTeamAnalytics(
|
export async function aggregateTeamAnalytics(
|
||||||
dbOrLayer: Database | AsyncDataLayer,
|
dbOrLayer: Database | AsyncDataLayer,
|
||||||
query: TeamAnalyticsQuery = {},
|
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> {
|
): Promise<TeamAnalytics> {
|
||||||
// FNXC:PostgresCommandCenterAnalytics 2026-06-27-10:00:
|
// FNXC:PostgresCommandCenterAnalytics 2026-06-27-10:00:
|
||||||
// Backend (PostgreSQL) path. Fetch agents + the four task-derived row sets
|
// 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
|
// connection has no `project` on search_path), then run the identical pure
|
||||||
// per-agent aggregation as the sync branch via buildTeamAnalytics.
|
// per-agent aggregation as the sync branch via buildTeamAnalytics.
|
||||||
if ("ping" in dbOrLayer) {
|
if ("ping" in dbOrLayer) {
|
||||||
return aggregateTeamAnalyticsAsync(dbOrLayer, query);
|
return aggregateTeamAnalyticsAsync(dbOrLayer, query, laneStore);
|
||||||
}
|
}
|
||||||
const db = dbOrLayer as Database;
|
const db = dbOrLayer as Database;
|
||||||
|
|
||||||
@@ -295,7 +315,51 @@ export async function aggregateTeamAnalytics(
|
|||||||
async function aggregateTeamAnalyticsAsync(
|
async function aggregateTeamAnalyticsAsync(
|
||||||
layer: AsyncDataLayer,
|
layer: AsyncDataLayer,
|
||||||
query: TeamAnalyticsQuery,
|
query: TeamAnalyticsQuery,
|
||||||
|
laneStore?: ProjectLaneVocabularyStore,
|
||||||
): Promise<TeamAnalytics> {
|
): 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(
|
const agents = (await layer.db.execute(
|
||||||
sql`SELECT id, name, role, state FROM project.agents ORDER BY id`,
|
sql`SELECT id, name, role, state FROM project.agents ORDER BY id`,
|
||||||
)) as unknown as AgentRow[];
|
)) as unknown as AgentRow[];
|
||||||
@@ -335,14 +399,14 @@ async function aggregateTeamAnalyticsAsync(
|
|||||||
const completedRows = (await layer.db.execute(
|
const completedRows = (await layer.db.execute(
|
||||||
sql`SELECT assigned_agent_id AS "agentId", count(*)::int AS count
|
sql`SELECT assigned_agent_id AS "agentId", count(*)::int AS count
|
||||||
FROM project.tasks
|
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`,
|
GROUP BY assigned_agent_id`,
|
||||||
)) as unknown as CountByAgentRow[];
|
)) as unknown as CountByAgentRow[];
|
||||||
|
|
||||||
const currentRows = (await layer.db.execute(
|
const currentRows = (await layer.db.execute(
|
||||||
sql`SELECT assigned_agent_id AS "agentId", "column" AS "columnName", count(*)::int AS count
|
sql`SELECT assigned_agent_id AS "agentId", "column" AS "columnName", count(*)::int AS count
|
||||||
FROM project.tasks
|
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"`,
|
GROUP BY assigned_agent_id, "column"`,
|
||||||
)) as unknown as Array<CountByAgentRow & { columnName: string }>;
|
)) as unknown as Array<CountByAgentRow & { columnName: string }>;
|
||||||
|
|
||||||
|
|||||||
@@ -483,7 +483,13 @@ async function resolveColumnFlagsByName(
|
|||||||
now: Date.now(),
|
now: Date.now(),
|
||||||
pricingOverrides: settings.modelPricingOverrides,
|
pricingOverrides: settings.modelPricingOverrides,
|
||||||
columnFlagsByName: await resolveColumnFlagsByName(store),
|
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);
|
res.json(result);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof ApiError) throw err;
|
if (err instanceof ApiError) throw err;
|
||||||
|
|||||||
Reference in New Issue
Block a user