fix(core): task-duration stats were computed from an empty set on a renamed board (#2870)
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) <noreply@anthropic.com>
This commit is contained in:
7
.changeset/productivity-analytics-renamed-lanes.md
Normal file
7
.changeset/productivity-analytics-renamed-lanes.md
Normal file
@@ -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'`.
|
||||
@@ -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<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-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<void> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<ProductivityAnalytics> {
|
||||
// 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<ProductivityAnalytics> {
|
||||
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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user