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 { sql } from "drizzle-orm";
|
||||||
import type { Database } from "./db.js";
|
import type { Database } from "./db.js";
|
||||||
|
import { resolveProjectColumnsForRoles, type ProjectLaneVocabularyStore } from "./project-lane-vocabulary.js";
|
||||||
import type { AsyncDataLayer } from "./postgres/data-layer.js";
|
import type { AsyncDataLayer } from "./postgres/data-layer.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -161,6 +162,22 @@ function nearestRankPercentile(sortedValues: readonly number[], percentile: numb
|
|||||||
export async function aggregateProductivityAnalytics(
|
export async function aggregateProductivityAnalytics(
|
||||||
dbOrLayer: Database | AsyncDataLayer,
|
dbOrLayer: Database | AsyncDataLayer,
|
||||||
query: ProductivityAnalyticsQuery = {},
|
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> {
|
): Promise<ProductivityAnalytics> {
|
||||||
// FNXC:PostgresCommandCenterAnalytics 2026-06-27-10:00:
|
// FNXC:PostgresCommandCenterAnalytics 2026-06-27-10:00:
|
||||||
// Backend (PostgreSQL) path. The async connection does not put `project` on
|
// 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
|
// epoch ms. Semantics (range columns, COALESCE/SUM/COUNT, statsRows gate, LOC
|
||||||
// unavailable sentinel, duration percentiles) mirror the sync branch exactly.
|
// unavailable sentinel, duration percentiles) mirror the sync branch exactly.
|
||||||
if ("ping" in dbOrLayer) {
|
if ("ping" in dbOrLayer) {
|
||||||
return aggregateProductivityAnalyticsAsync(dbOrLayer, query);
|
return aggregateProductivityAnalyticsAsync(dbOrLayer, query, laneStore);
|
||||||
}
|
}
|
||||||
const db = dbOrLayer as Database;
|
const db = dbOrLayer as Database;
|
||||||
// Modified files: read the JSON array off tasks updated in range.
|
// Modified files: read the JSON array off tasks updated in range.
|
||||||
@@ -347,7 +364,14 @@ export async function aggregateProductivityAnalytics(
|
|||||||
async function aggregateProductivityAnalyticsAsync(
|
async function aggregateProductivityAnalyticsAsync(
|
||||||
layer: AsyncDataLayer,
|
layer: AsyncDataLayer,
|
||||||
query: ProductivityAnalyticsQuery,
|
query: ProductivityAnalyticsQuery,
|
||||||
|
laneStore?: ProjectLaneVocabularyStore,
|
||||||
): Promise<ProductivityAnalytics> {
|
): 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
|
// Modified files: tasks updated in range whose modified_files is a non-empty
|
||||||
// jsonb array. postgres-js returns jsonb already parsed.
|
// jsonb array. postgres-js returns jsonb already parsed.
|
||||||
const mfFrom = query.from !== undefined ? sql`AND updated_at >= ${query.from}` : sql``;
|
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(
|
const durationRows = (await layer.db.execute(
|
||||||
sql`SELECT cumulative_active_ms AS "cumulativeActiveMs", cumulative_planning_ms AS "cumulativePlanningMs", execution_completed_at AS "executionCompletedAt"
|
sql`SELECT cumulative_active_ms AS "cumulativeActiveMs", cumulative_planning_ms AS "cumulativePlanningMs", execution_completed_at AS "executionCompletedAt"
|
||||||
FROM project.tasks
|
FROM project.tasks
|
||||||
WHERE "column" = 'done'
|
WHERE "column" IN (${completeIn})
|
||||||
AND execution_completed_at IS NOT NULL
|
AND execution_completed_at IS NOT NULL
|
||||||
AND (COALESCE(cumulative_active_ms, 0) + COALESCE(cumulative_planning_ms, 0)) > 0
|
AND (COALESCE(cumulative_active_ms, 0) + COALESCE(cumulative_planning_ms, 0)) > 0
|
||||||
${dFrom} ${dTo}
|
${dFrom} ${dTo}
|
||||||
|
|||||||
@@ -348,7 +348,9 @@ export const registerCommandCenterRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
const result = await aggregateProductivityAnalytics(requireAsyncLayer(store, "Command Center productivity analytics"), {
|
const result = await aggregateProductivityAnalytics(requireAsyncLayer(store, "Command Center productivity analytics"), {
|
||||||
from: range.from,
|
from: range.from,
|
||||||
to: range.to,
|
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)) {
|
if (wantsCsv(req.query)) {
|
||||||
sendCsv(
|
sendCsv(
|
||||||
res,
|
res,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"packages/core/src/github-issue-analytics.ts": 2,
|
"packages/core/src/github-issue-analytics.ts": 2,
|
||||||
"packages/core/src/gitlab-issue-analytics.ts": 2,
|
"packages/core/src/gitlab-issue-analytics.ts": 2,
|
||||||
"packages/core/src/mission-store.ts": 1,
|
"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-archive-lineage.ts": 3,
|
||||||
"packages/core/src/task-store/async-maintenance.ts": 1,
|
"packages/core/src/task-store/async-maintenance.ts": 1,
|
||||||
"packages/core/src/task-store/async-merge-coordination.ts": 1,
|
"packages/core/src/task-store/async-merge-coordination.ts": 1,
|
||||||
|
|||||||
Reference in New Issue
Block a user