From 216632bd3ae3da92b51d582e4644dba1e3589bb8 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 30 Jul 2026 16:50:00 -0700 Subject: [PATCH] fix(core): task-duration stats were computed from an empty set on a renamed board (#2870) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third of the 14 lane-bound SQL sites from #2839, after #2864 and #2866. Independent of both. ## The defect `aggregateProductivityAnalytics` filtered its duration query on `"column" = 'done'`. On a renamed board that matches nothing, so the entire task-duration distribution — median, p90, average, total — is computed from an **empty row set** and reports zeros while the project ships work. Nothing errors. Same shape and fix as the previous two: resolve per **project** via `resolveProjectColumnsForRoles`, bind an `IN` list, thread the store from the single Command Center caller so the parameter has a supplier immediately rather than becoming an inert seam. ## Measured Reverted, only the renamed case flips: ``` ✓ default vocabulary: a finished task contributes to the duration stats × renamed vocabulary: a task in the RENAMED complete lane contributes ✓ renamed vocabulary: a task still in the WIP lane does NOT contribute ✓ without a lane store, the legacy id still answers Tests 1 failed | 3 passed (4) ``` ## The negative asserts the median, not just the count This fix's failure mode is **worse than the bug it fixes**. Resolving too many lanes would pull unfinished work into the distribution and produce a plausible-but-wrong median — a number nobody questions — where the bug produces an obvious zero. So the WIP-lane case asserts `medianMs` is null as well as `completedTasks` being 0. ## A fixture error worth naming My first version asserted `taskDuration.count`. `TaskDurationSummary` exposes `completedTasks`. Every case failed with `expected undefined to be 1` — **including the controls** — which reads exactly like a broken product until you notice the control is failing too. A control that fails is a fixture bug, not a finding; that asymmetry is the fastest way to tell them apart. ## Scope The sync SQLite arm keeps its literal: it throws in backend mode and has no production caller, the same dead-arm conclusion as `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) --- .../productivity-analytics-renamed-lanes.md | 7 + ...ctivity-analytics-renamed-lanes.pg.test.ts | 135 ++++++++++++++++++ packages/core/src/productivity-analytics.ts | 28 +++- .../routes/register-command-center-routes.ts | 4 +- scripts/lib/sql-column-literals-baseline.json | 2 +- 5 files changed, 172 insertions(+), 4 deletions(-) create mode 100644 .changeset/productivity-analytics-renamed-lanes.md create mode 100644 packages/core/src/__tests__/postgres/productivity-analytics-renamed-lanes.pg.test.ts diff --git a/.changeset/productivity-analytics-renamed-lanes.md b/.changeset/productivity-analytics-renamed-lanes.md new file mode 100644 index 0000000000..88715360d5 --- /dev/null +++ b/.changeset/productivity-analytics-renamed-lanes.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Task-duration stats now include finished work on renamed boards. +category: fix +dev: `aggregateProductivityAnalytics` takes an optional lane store and resolves the complete columns via `resolveProjectColumnsForRoles`; its duration query previously filtered on the literal `'done'`. diff --git a/packages/core/src/__tests__/postgres/productivity-analytics-renamed-lanes.pg.test.ts b/packages/core/src/__tests__/postgres/productivity-analytics-renamed-lanes.pg.test.ts new file mode 100644 index 0000000000..2de02a2e13 --- /dev/null +++ b/packages/core/src/__tests__/postgres/productivity-analytics-renamed-lanes.pg.test.ts @@ -0,0 +1,135 @@ +/* +FNXC:WorkflowResolvedColumns 2026-07-30-19:30 (task-duration stats computed from an empty set): + +`aggregateProductivityAnalytics` filtered its duration query on `"column" = 'done'`. On a renamed +board that matches nothing, so the whole task-duration distribution — median, p90, the spread — is +computed from an EMPTY row set and reports zeros while the project ships work. Nothing errors. + +WHY NO EXISTING CHECK SAW IT. The lifecycle census parses TypeScript comparisons; this id lives +inside a SQL string. The sweep that converted this file's TypeScript guards left the query alone and +the file scored as converted. + +The cases are DIFFERENTIAL: the same finished task, aggregated under two vocabularies whose roles are +identical and only the ids differ. `shipped` collides 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 { aggregateProductivityAnalytics } from "../../productivity-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" }; + +pgDescribe("productivity analytics under a renamed board vocabulary", () => { + const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({ + prefix: "fusion_productivity_lanes", + }); + + beforeAll(h.beforeAll); + beforeEach(h.beforeEach); + afterEach(h.afterEach); + afterAll(h.afterAll); + + 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-productivity"; + 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); + } + + /** A finished task with real active/planning time, so it lands in the duration distribution. */ + async function seedFinishedTask(lane: string): Promise { + const store = h.store(); + await store.createTaskWithReservedId( + { description: "KB-DUR", column: "todo" }, + { taskId: "KB-DUR", createdAt: IN_RANGE, updatedAt: IN_RANGE, applyDefaultWorkflowSteps: false }, + ); + /* Seeded directly: the query needs execution_completed_at inside the range plus non-zero + cumulative time, neither of which moveTask would produce. */ + await h.adminDb().execute(sql` + UPDATE project.tasks + SET "column" = ${lane}, + execution_completed_at = ${IN_RANGE}, + cumulative_active_ms = 60000, + cumulative_planning_ms = 30000, + updated_at = ${IN_RANGE} + WHERE id = 'KB-DUR'`); + store.taskCache.delete("KB-DUR"); + } + + const run = (withStore: boolean) => aggregateProductivityAnalytics( + Object.assign(h.layer(), { projectId: "p1" }), + RANGE, + withStore ? h.store() : undefined, + ); + + /* Control: the default vocabulary sees the finished task. Passes before and after the fix. */ + it("default vocabulary: a finished task contributes to the duration stats", async () => { + await seedFinishedTask("done"); + + const result = await run(true); + + expect(result.taskDuration.completedTasks).toBe(1); + expect(result.taskDuration.medianMs).toBe(90_000); + }); + + /* The defect: before the fix this row set was empty on a renamed board. */ + it("renamed vocabulary: a task in the RENAMED complete lane contributes", async () => { + await seedRenamedWorkflow(); + await seedFinishedTask("shipped"); + + const result = await run(true); + + expect(result.taskDuration.completedTasks).toBe(1); + expect(result.taskDuration.medianMs).toBe(90_000); + }); + + /* + The paired negative: resolving the real lanes must not degrade into "every column is complete". + Unfinished work must stay out of the duration distribution, or the fix trades a zero for a wrong + median — worse, because a plausible number invites no scrutiny. + */ + it("renamed vocabulary: a task still in the WIP lane does NOT contribute", async () => { + await seedRenamedWorkflow(); + await seedFinishedTask("building"); + + const result = await run(true); + + expect(result.taskDuration.completedTasks).toBe(0); + expect(result.taskDuration.medianMs).toBeNull(); + }); + + /* Omitting the store must keep the legacy answer, so an unconverted caller is byte-identical. */ + it("without a lane store, the legacy id still answers", async () => { + await seedFinishedTask("done"); + + const result = await run(false); + + expect(result.taskDuration.completedTasks).toBe(1); + expect(result.taskDuration.medianMs).toBe(90_000); + }); +}); diff --git a/packages/core/src/productivity-analytics.ts b/packages/core/src/productivity-analytics.ts index 01b72c28c9..4b2159fd12 100644 --- a/packages/core/src/productivity-analytics.ts +++ b/packages/core/src/productivity-analytics.ts @@ -1,5 +1,6 @@ import { sql } from "drizzle-orm"; import type { Database } from "./db.js"; +import { resolveProjectColumnsForRoles, type ProjectLaneVocabularyStore } from "./project-lane-vocabulary.js"; import type { AsyncDataLayer } from "./postgres/data-layer.js"; /** @@ -161,6 +162,22 @@ function nearestRankPercentile(sortedValues: readonly number[], percentile: numb export async function aggregateProductivityAnalytics( dbOrLayer: Database | AsyncDataLayer, query: ProductivityAnalyticsQuery = {}, + /* + FNXC:WorkflowResolvedColumns 2026-07-30-19:20: + The store, used ONLY to resolve which columns carry the `complete` trait. + + The duration query filtered on `"column" = 'done'`. That id lives inside a SQL string, which the + lifecycle census cannot see (it parses TypeScript comparisons), so the sweep that converted this + file's TS guards left it alone and the file scored as converted. On a renamed board the + task-duration distribution — median, p90, the whole spread — is computed from an EMPTY row set and + reports zeros while the project ships work. + + Resolved per PROJECT: this aggregates a whole project, so the union of the complete columns across + its workflows is the right set and a bound IN list is enough. + + Omitted, the legacy id answers, so an unconverted caller is byte-identical. + */ + laneStore?: ProjectLaneVocabularyStore, ): Promise { // FNXC:PostgresCommandCenterAnalytics 2026-06-27-10:00: // Backend (PostgreSQL) path. The async connection does not put `project` on @@ -172,7 +189,7 @@ export async function aggregateProductivityAnalytics( // epoch ms. Semantics (range columns, COALESCE/SUM/COUNT, statsRows gate, LOC // unavailable sentinel, duration percentiles) mirror the sync branch exactly. if ("ping" in dbOrLayer) { - return aggregateProductivityAnalyticsAsync(dbOrLayer, query); + return aggregateProductivityAnalyticsAsync(dbOrLayer, query, laneStore); } const db = dbOrLayer as Database; // Modified files: read the JSON array off tasks updated in range. @@ -347,7 +364,14 @@ export async function aggregateProductivityAnalytics( async function aggregateProductivityAnalyticsAsync( layer: AsyncDataLayer, query: ProductivityAnalyticsQuery, + laneStore?: ProjectLaneVocabularyStore, ): Promise { + const completeLanes = laneStore + ? [...await resolveProjectColumnsForRoles(laneStore, ["complete"])] + : ["done"]; + /* An IN list of bound parameters, not `= ANY(${array})`: drizzle expands a JS array in a template + into a tuple, which PostgreSQL rejects for ANY. Each id stays a parameter. */ + const completeIn = sql.join(completeLanes.map((lane) => sql`${lane}`), sql`, `); // Modified files: tasks updated in range whose modified_files is a non-empty // jsonb array. postgres-js returns jsonb already parsed. const mfFrom = query.from !== undefined ? sql`AND updated_at >= ${query.from}` : sql``; @@ -403,7 +427,7 @@ async function aggregateProductivityAnalyticsAsync( const durationRows = (await layer.db.execute( sql`SELECT cumulative_active_ms AS "cumulativeActiveMs", cumulative_planning_ms AS "cumulativePlanningMs", execution_completed_at AS "executionCompletedAt" FROM project.tasks - WHERE "column" = 'done' + WHERE "column" IN (${completeIn}) AND execution_completed_at IS NOT NULL AND (COALESCE(cumulative_active_ms, 0) + COALESCE(cumulative_planning_ms, 0)) > 0 ${dFrom} ${dTo} diff --git a/packages/dashboard/src/routes/register-command-center-routes.ts b/packages/dashboard/src/routes/register-command-center-routes.ts index 5ed8bb3e08..cf45a02e3a 100644 --- a/packages/dashboard/src/routes/register-command-center-routes.ts +++ b/packages/dashboard/src/routes/register-command-center-routes.ts @@ -348,7 +348,9 @@ export const registerCommandCenterRoutes: ApiRouteRegistrar = (ctx) => { const result = await aggregateProductivityAnalytics(requireAsyncLayer(store, "Command Center productivity analytics"), { from: range.from, to: range.to, - }); + /* FNXC:WorkflowResolvedColumns 2026-07-30-19:25: store supplied so the duration distribution + resolves the board's complete lanes instead of the literal 'done'. */ + }, store); if (wantsCsv(req.query)) { sendCsv( res, diff --git a/scripts/lib/sql-column-literals-baseline.json b/scripts/lib/sql-column-literals-baseline.json index acbaa6abdc..4f96a88639 100644 --- a/scripts/lib/sql-column-literals-baseline.json +++ b/scripts/lib/sql-column-literals-baseline.json @@ -4,7 +4,7 @@ "packages/core/src/github-issue-analytics.ts": 2, "packages/core/src/gitlab-issue-analytics.ts": 2, "packages/core/src/mission-store.ts": 1, - "packages/core/src/productivity-analytics.ts": 2, + "packages/core/src/productivity-analytics.ts": 1, "packages/core/src/task-store/async-archive-lineage.ts": 3, "packages/core/src/task-store/async-maintenance.ts": 1, "packages/core/src/task-store/async-merge-coordination.ts": 1,