@nanobpm/nano-workforce 0.101.1 → 0.102.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [0.102.0](https://github.com/nanobpm/nano-workforce/compare/v0.101.1...v0.102.0) (2026-08-19)
2
+
3
+
4
+ ### Features
5
+
6
+ * **console:** merged-per-day burn-down/throughput chart ([#345](https://github.com/nanobpm/nano-workforce/issues/345)) ([d4b27a9](https://github.com/nanobpm/nano-workforce/commit/d4b27a9cad36217db7c8ffc49ef26e0f31fb688e)), closes [#344](https://github.com/nanobpm/nano-workforce/issues/344) [#290](https://github.com/nanobpm/nano-workforce/issues/290) [#337](https://github.com/nanobpm/nano-workforce/issues/337) [#339](https://github.com/nanobpm/nano-workforce/issues/339) [#340](https://github.com/nanobpm/nano-workforce/issues/340) [#338](https://github.com/nanobpm/nano-workforce/issues/338)
7
+
1
8
  ## [0.101.1](https://github.com/nanobpm/nano-workforce/compare/v0.101.0...v0.101.1) (2026-08-19)
2
9
 
3
10
 
@@ -0,0 +1,173 @@
1
+ // Read-model derivation + projection test for the merged-per-day throughput chart (issue #344).
2
+ //
3
+ // `deriveMergesPerDay` is the single source of truth behind the denormalised `merges_per_day` table
4
+ // the Velocity page reads. It must: count DISTINCT PRs per calendar day (a PR with several `merged`
5
+ // audit rows on one day — an `already-merged` short-circuit or a retry — counts once); ignore
6
+ // `queued`/`blocked` attempts entirely; order days ascending; carry a running burn-up `cumulative`;
7
+ // and scale each day's `bar` against the busiest day. `pollMergesPerDay` must project that onto the
8
+ // read table idempotently — a steady-state re-run writes nothing, and a day dropped from the audit is
9
+ // pruned.
10
+ import { test } from "node:test";
11
+ import { assert, assertEquals } from "#test-assert";
12
+ import type { DataLayer } from "@nanobpm/urban";
13
+ import { deriveMergesPerDay, type MergeAuditRow, pollMergesPerDay } from "./mergesPerDay.ts";
14
+
15
+ // A tiny in-memory record gateway (all/find/insert/update/delete), mirroring the fake-app style used
16
+ // across the app tests (see app/delivery.test.ts), enough to exercise the `pollMergesPerDay`
17
+ // projection.
18
+ function memData(): { data: DataLayer; stores: Record<string, any[]>; writes: () => number } {
19
+ const stores: Record<string, any[]> = {};
20
+ let writes = 0;
21
+ function tbl(name: string, pk = "id") {
22
+ const rows = (stores[name] ??= [] as any[]);
23
+ return {
24
+ async all() {
25
+ return rows.slice();
26
+ },
27
+ async get(id: any) {
28
+ return rows.find((r) => r[pk] === id);
29
+ },
30
+ async find(where: any = {}) {
31
+ return rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
32
+ },
33
+ async insert(row: any) {
34
+ writes++;
35
+ rows.push({ ...row });
36
+ return row[pk];
37
+ },
38
+ async update(id: any, patch: any) {
39
+ writes++;
40
+ const r = rows.find((row) => row[pk] === id);
41
+ if (r) Object.assign(r, patch);
42
+ return 1;
43
+ },
44
+ async delete(id: any) {
45
+ const i = rows.findIndex((row) => row[pk] === id);
46
+ if (i >= 0) {
47
+ writes++;
48
+ rows.splice(i, 1);
49
+ }
50
+ return 1;
51
+ },
52
+ };
53
+ }
54
+ const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
55
+ return { data, stores, writes: () => writes };
56
+ }
57
+
58
+ const merged = (pr_key: string, at: string): MergeAuditRow => ({ pr_key, outcome: "merged", at });
59
+
60
+ test("counts DISTINCT merged PRs per calendar day", () => {
61
+ const days = deriveMergesPerDay([
62
+ merged("o/r#1", "2026-01-01T09:00:00Z"),
63
+ merged("o/r#2", "2026-01-01T18:30:00Z"),
64
+ merged("o/r#3", "2026-01-02T10:00:00Z"),
65
+ ]);
66
+ assertEquals(days.map((d) => [d.day, d.merged]), [
67
+ ["2026-01-01", 2],
68
+ ["2026-01-02", 1],
69
+ ]);
70
+ });
71
+
72
+ test("dedupes duplicate merged rows for the same PR on the same day (COUNT DISTINCT pr_key)", () => {
73
+ const days = deriveMergesPerDay([
74
+ merged("o/r#1", "2026-01-01T09:00:00Z"),
75
+ merged("o/r#1", "2026-01-01T09:00:05Z"), // retry / already-merged short-circuit
76
+ merged("o/r#1", "2026-01-01T23:59:00Z"),
77
+ ]);
78
+ assertEquals(days.length, 1);
79
+ assertEquals(days[0].merged, 1);
80
+ });
81
+
82
+ test("the same PR merged on two different days counts once per day", () => {
83
+ // A defensive case: distinctness is per-day, not global.
84
+ const days = deriveMergesPerDay([
85
+ merged("o/r#1", "2026-01-01T09:00:00Z"),
86
+ merged("o/r#1", "2026-01-02T09:00:00Z"),
87
+ ]);
88
+ assertEquals(days.map((d) => [d.day, d.merged]), [
89
+ ["2026-01-01", 1],
90
+ ["2026-01-02", 1],
91
+ ]);
92
+ });
93
+
94
+ test("ignores queued and blocked attempts", () => {
95
+ const days = deriveMergesPerDay([
96
+ merged("o/r#1", "2026-01-01T09:00:00Z"),
97
+ { pr_key: "o/r#2", outcome: "queued", at: "2026-01-01T09:10:00Z" },
98
+ { pr_key: "o/r#3", outcome: "blocked", at: "2026-01-01T09:20:00Z" },
99
+ ]);
100
+ assertEquals(days.length, 1);
101
+ assertEquals(days[0].merged, 1);
102
+ });
103
+
104
+ test("orders days ascending and carries a running burn-up cumulative", () => {
105
+ const days = deriveMergesPerDay([
106
+ merged("o/r#5", "2026-01-03T10:00:00Z"),
107
+ merged("o/r#1", "2026-01-01T10:00:00Z"),
108
+ merged("o/r#2", "2026-01-01T11:00:00Z"),
109
+ merged("o/r#4", "2026-01-02T10:00:00Z"),
110
+ ]);
111
+ assertEquals(days.map((d) => d.day), ["2026-01-01", "2026-01-02", "2026-01-03"]);
112
+ assertEquals(days.map((d) => d.merged), [2, 1, 1]);
113
+ assertEquals(days.map((d) => d.cumulative), [2, 3, 4]);
114
+ });
115
+
116
+ test("bar scales against the busiest day: full for the max, non-empty for a lone merge, empty for zero", () => {
117
+ const days = deriveMergesPerDay([
118
+ // day A: 4 merges (the max) → widest bar
119
+ merged("o/r#1", "2026-01-01T01:00:00Z"),
120
+ merged("o/r#2", "2026-01-01T02:00:00Z"),
121
+ merged("o/r#3", "2026-01-01T03:00:00Z"),
122
+ merged("o/r#4", "2026-01-01T04:00:00Z"),
123
+ // day B: 1 merge → short but visible bar
124
+ merged("o/r#5", "2026-01-02T01:00:00Z"),
125
+ ]);
126
+ const [a, b] = days;
127
+ assert(a.bar.length > b.bar.length, "the busier day must draw a longer bar");
128
+ assert(b.bar.length >= 1, "a day with any merge must draw at least one glyph");
129
+ assert(a.bar.length <= 30, "the busiest bar must not exceed the configured width");
130
+ });
131
+
132
+ test("empty audit yields no days", () => {
133
+ assertEquals(deriveMergesPerDay([]), []);
134
+ });
135
+
136
+ test("pollMergesPerDay projects the aggregate onto merges_per_day", async () => {
137
+ const { data, stores } = memData();
138
+ stores.merges = [
139
+ { id: 1, pr_key: "o/r#1", outcome: "merged", at: "2026-01-01T09:00:00Z" },
140
+ { id: 2, pr_key: "o/r#1", outcome: "merged", at: "2026-01-01T09:05:00Z" }, // dup same day
141
+ { id: 3, pr_key: "o/r#2", outcome: "merged", at: "2026-01-02T09:00:00Z" },
142
+ { id: 4, pr_key: "o/r#3", outcome: "queued", at: "2026-01-02T09:10:00Z" }, // ignored
143
+ ];
144
+ await pollMergesPerDay(data);
145
+ const rows = (stores.merges_per_day ?? []).slice().sort((x, y) => x.day.localeCompare(y.day));
146
+ assertEquals(rows.map((r) => [r.day, r.merged, r.cumulative]), [
147
+ ["2026-01-01", 1, 1],
148
+ ["2026-01-02", 1, 2],
149
+ ]);
150
+ for (const r of rows) assert(typeof r.updated_at === "string" && r.updated_at.length > 0);
151
+ });
152
+
153
+ test("pollMergesPerDay is idempotent — a steady-state re-run writes nothing", async () => {
154
+ const { data, stores, writes } = memData();
155
+ stores.merges = [{ id: 1, pr_key: "o/r#1", outcome: "merged", at: "2026-01-01T09:00:00Z" }];
156
+ await pollMergesPerDay(data);
157
+ const afterFirst = writes();
158
+ assert(afterFirst > 0, "the first pass must project at least one row");
159
+ await pollMergesPerDay(data);
160
+ assertEquals(writes(), afterFirst, "a steady-state re-run must not write");
161
+ });
162
+
163
+ test("pollMergesPerDay prunes a day that no longer derives from the audit", async () => {
164
+ const { data, stores } = memData();
165
+ stores.merges_per_day = [
166
+ { day: "2025-12-31", merged: 3, cumulative: 3, bar: "███", updated_at: "old" },
167
+ ];
168
+ stores.merges = [{ id: 1, pr_key: "o/r#1", outcome: "merged", at: "2026-01-01T09:00:00Z" }];
169
+ await pollMergesPerDay(data);
170
+ const days = (stores.merges_per_day ?? []).map((r: any) => r.day);
171
+ assert(!days.includes("2025-12-31"), "a stale day must be pruned");
172
+ assert(days.includes("2026-01-01"), "the derived day must be present");
173
+ });
@@ -0,0 +1,150 @@
1
+ // Merged-per-day throughput / burn-up read model (issue #344).
2
+ //
3
+ // A simple time-series behind the console's "Velocity" page: how many PRs the fleet landed each
4
+ // calendar day (throughput), plus a running `cumulative` burn-up total. Every land is already
5
+ // audited in the `merges` table (004_merge.sql) — one row per merge attempt, whose `outcome`
6
+ // includes `merged`, `queued`, `blocked` and `retry` (see the merge classifier in github.ts; the
7
+ // set is not exhaustive), `at` = ISO timestamp. Only `outcome = 'merged'` rows feed this aggregate,
8
+ // so merged-per-day is
9
+ // fully DERIVABLE from that audit trail with NO new write-path bookkeeping (AGENTS.md: "Derivation
10
+ // over duplication"). The canonical aggregate is the one in the issue:
11
+ //
12
+ // SELECT date(at) AS day, COUNT(DISTINCT pr_key) AS merged
13
+ // FROM merges WHERE outcome = 'merged' GROUP BY date(at);
14
+ //
15
+ // Two halves, mirroring the `deriveDelivery`/`pollDelivery` and `deriveLineage`/`pollLineage`
16
+ // convention:
17
+ // • `deriveMergesPerDay` — a PURE function: merge audit rows → one ordered `MergeDay` per calendar
18
+ // day (merged count, burn-up cumulative, a proportional bar string). No I/O, fully tested. Counts
19
+ // `COUNT(DISTINCT pr_key)` — a PR with several `merged` rows on one day (an `already-merged`
20
+ // short-circuit or retry) counts once — and ignores `queued`/`blocked` rows entirely.
21
+ // • `pollMergesPerDay` — the gateway glue: read the `merges` rows and project them onto the
22
+ // `merges_per_day` read table (051_merges_per_day.sql) the schema-driven Velocity page binds. A
23
+ // denormalised flat table because Urban's datasource cannot read a SQL VIEW (gateway.ts
24
+ // `schema()` whitelists `type='table'` only — same reason `lineage_threads`/`plans.delivery` are
25
+ // flat tables). Writes only when a day's projection actually changes, so a steady-state pass is a
26
+ // no-op.
27
+ import type { DataLayer } from "@nanobpm/urban";
28
+
29
+ const now = () => new Date().toISOString();
30
+
31
+ /** The subset of a `merges` audit row (004_merge.sql) the projection reads. */
32
+ export interface MergeAuditRow {
33
+ pr_key: string;
34
+ outcome: string;
35
+ at: string;
36
+ }
37
+
38
+ /** One projected calendar day of merge throughput. */
39
+ export interface MergeDay {
40
+ /** Calendar day, ISO `YYYY-MM-DD` (SQLite `date(at)`). */
41
+ day: string;
42
+ /** Distinct PRs merged that day (`COUNT(DISTINCT pr_key)`). */
43
+ merged: number;
44
+ /** Running total of merged PRs up to and including this day — the burn-up line. */
45
+ cumulative: number;
46
+ /** Proportional block-character bar (length scaled to the busiest day), for the prose chart. */
47
+ bar: string;
48
+ }
49
+
50
+ /** Widest bar (in block glyphs) the busiest day draws; every other day scales against it. */
51
+ const BAR_WIDTH = 30;
52
+ const BAR_FULL = "█";
53
+
54
+ /** The calendar day of an ISO timestamp — the JS twin of SQLite `date(at)`. A well-formed `merges.at`
55
+ * is an ISO string, so the first 10 chars are `YYYY-MM-DD`; fall back to the whole trimmed value for
56
+ * any non-ISO shape so a malformed row still groups deterministically rather than throwing. */
57
+ function dayOf(at: string): string {
58
+ const s = String(at).trim();
59
+ return /^\d{4}-\d{2}-\d{2}/.test(s) ? s.slice(0, 10) : s;
60
+ }
61
+
62
+ /** Render a proportional bar: `merged` glyphs scaled against the busiest day's `max`, min one glyph
63
+ * for any non-zero day so a lone merge is still visible. Zero renders as an empty bar. */
64
+ function barFor(merged: number, max: number): string {
65
+ if (merged <= 0 || max <= 0) return "";
66
+ const n = Math.max(1, Math.round((merged / max) * BAR_WIDTH));
67
+ return BAR_FULL.repeat(n);
68
+ }
69
+
70
+ /** PURE aggregate: merge audit rows → one ordered `MergeDay` per calendar day (ascending).
71
+ *
72
+ * Only `outcome === "merged"` rows count; `queued`/`blocked` attempts are ignored. Within a day a
73
+ * `pr_key` is counted once (`COUNT(DISTINCT pr_key)`), so duplicate `merged` audit rows — an
74
+ * `already-merged` short-circuit or a retry — do not double-count. `cumulative` is the running total
75
+ * across days (burn-up); `bar` is scaled against the busiest day so the chart is comparable. */
76
+ export function deriveMergesPerDay(rows: readonly MergeAuditRow[]): MergeDay[] {
77
+ // day -> set of distinct merged pr_keys that day.
78
+ const prKeysByDay = new Map<string, Set<string>>();
79
+ for (const r of rows) {
80
+ if (r.outcome !== "merged") continue;
81
+ if (r.pr_key == null || r.at == null) continue;
82
+ const day = dayOf(r.at);
83
+ let set = prKeysByDay.get(day);
84
+ if (!set) {
85
+ set = new Set<string>();
86
+ prKeysByDay.set(day, set);
87
+ }
88
+ set.add(r.pr_key);
89
+ }
90
+
91
+ const counts = [...prKeysByDay.entries()]
92
+ .map(([day, set]) => ({ day, merged: set.size }))
93
+ .sort((a, b) => a.day.localeCompare(b.day));
94
+ const max = counts.reduce((m, c) => Math.max(m, c.merged), 0);
95
+
96
+ let cumulative = 0;
97
+ const out: MergeDay[] = [];
98
+ for (const { day, merged } of counts) {
99
+ cumulative += merged;
100
+ out.push({ day, merged, cumulative, bar: barFor(merged, max) });
101
+ }
102
+ return out;
103
+ }
104
+
105
+ /** The denormalised read-table row `pollMergesPerDay` projects, one per calendar day. */
106
+ interface MergesPerDayRow extends MergeDay {
107
+ updated_at: string;
108
+ }
109
+
110
+ const mergesPerDay = (data: DataLayer) => data.table<MergesPerDayRow>("merges_per_day", "day");
111
+ const mergesAudit = (data: DataLayer) => data.table<MergeAuditRow>("merges", "id");
112
+
113
+ /** Idempotent read-model pass: recompute merged-per-day from the `merges` audit table and denormalise
114
+ * it onto the `merges_per_day` read table the Velocity page reads. Additive/derived only — never
115
+ * touches `merges`. Upserts a day only when its projection actually changes (so a steady-state pass is
116
+ * a no-op) and prunes any stale day row that no longer derives (defensive — days are append-only in
117
+ * practice, but a purge/rewrite of the audit must not leave a phantom). */
118
+ export async function pollMergesPerDay(data: DataLayer): Promise<void> {
119
+ try {
120
+ // Only `outcome === "merged"` rows contribute to the aggregate, so filter at the read rather than
121
+ // scanning queued/blocked rows as the audit grows (deriveMergesPerDay ignores non-merged rows too).
122
+ const audit = await mergesAudit(data).find({ outcome: "merged" });
123
+ const want = deriveMergesPerDay(audit);
124
+ const wantByDay = new Map(want.map((d) => [d.day, d]));
125
+
126
+ const existing = await mergesPerDay(data).all();
127
+ const existingByDay = new Map(existing.map((r) => [r.day, r]));
128
+
129
+ for (const d of want) {
130
+ const cur = existingByDay.get(d.day);
131
+ if (!cur) {
132
+ await mergesPerDay(data).insert({ ...d, updated_at: now() });
133
+ } else if (cur.merged !== d.merged || cur.cumulative !== d.cumulative || cur.bar !== d.bar) {
134
+ await mergesPerDay(data).update(d.day, {
135
+ merged: d.merged,
136
+ cumulative: d.cumulative,
137
+ bar: d.bar,
138
+ updated_at: now(),
139
+ });
140
+ }
141
+ }
142
+
143
+ // Prune any projected day that no longer derives from the audit trail.
144
+ for (const r of existing) {
145
+ if (!wantByDay.has(r.day)) await mergesPerDay(data).delete(r.day);
146
+ }
147
+ } catch (err) {
148
+ console.error(`[poller] merges-per-day: ${err}`);
149
+ }
150
+ }
@@ -76,7 +76,10 @@ test("the migrated wait stays bounded: an event-based gateway races the signal a
76
76
  });
77
77
 
78
78
  test("the timeout arm escalates to a human (the wait cannot hang forever)", () => {
79
- // timer catch → persist-review-stalled (records the escalation) → wait-answer (native userTask).
79
+ // timer catch → persist-review-stalled (records the escalation) → gw-escalated → wait-answer.
80
+ // Since #340 every escalation arm converges on the shared `gw-escalated` gateway (no per-arm twin
81
+ // edge to the userTask — derivation over duplication), which routes to the native `wait-answer`
82
+ // userTask when an escalation actually opened (`escalated = true`). Assert that canonical path.
80
83
  assert(
81
84
  /sourceRef="wait-review-timeout"[^>]*targetRef="persist-review-stalled"|targetRef="persist-review-stalled"[^>]*sourceRef="wait-review-timeout"/.test(
82
85
  flat,
@@ -84,9 +87,18 @@ test("the timeout arm escalates to a human (the wait cannot hang forever)", () =
84
87
  "a timed-out review wait routes to the stalled-review escalation",
85
88
  );
86
89
  assert(
87
- /sourceRef="persist-review-stalled"[^>]*targetRef="wait-answer"|targetRef="wait-answer"[^>]*sourceRef="persist-review-stalled"/.test(
90
+ /sourceRef="persist-review-stalled"[^>]*targetRef="gw-escalated"|targetRef="gw-escalated"[^>]*sourceRef="persist-review-stalled"/.test(
88
91
  flat,
89
92
  ),
90
- "the stalled-review escalation parks on the human answer userTask",
93
+ "the stalled-review escalation converges on the shared gw-escalated gateway",
94
+ );
95
+ const escWait = flat.match(
96
+ /<bpmn:sequenceFlow\b[^>]*\bsourceRef="gw-escalated"[^>]*\btargetRef="wait-answer"[^>]*>[\s\S]*?<\/bpmn:sequenceFlow>/,
97
+ );
98
+ assert(escWait, "gw-escalated must route to the human answer userTask (wait-answer)");
99
+ assertStringIncludes(
100
+ escWait![0],
101
+ "escalated = true",
102
+ "gw-escalated parks on wait-answer only when an escalation actually opened",
91
103
  );
92
104
  });
package/app/service.ts CHANGED
@@ -43,6 +43,7 @@ import {
43
43
  import { pollLineage } from "./lineage.ts";
44
44
  import { mergeLanes, readExclusions } from "./mergeExclusion.ts";
45
45
  import { freshHeadRunAction, headRunPresenceCount, loadMergeProtocol } from "./mergeProtocol.ts";
46
+ import { pollMergesPerDay } from "./mergesPerDay.ts";
46
47
  import { type PrLaneDecision, planPrLane, taskDependencyDepths } from "./mergeTrain.ts";
47
48
  import {
48
49
  backfillPlanBuckets,
@@ -2116,6 +2117,7 @@ export async function pollOnce(
2116
2117
  await pollPromotion(data, engine, token);
2117
2118
  await pollFeatureDelivery(data);
2118
2119
  await pollLineage(data);
2120
+ await pollMergesPerDay(data);
2119
2121
  await pollUserTasks(data, engine);
2120
2122
  if (engineRest) {
2121
2123
  const base = engineRest.restAddress.replace(/\/+$/, "");
@@ -0,0 +1,33 @@
1
+ -- Merged-per-day throughput / burn-up read model (issue #344).
2
+ --
3
+ -- Every land is already audited in `merges` (004_merge.sql): one row per merge attempt, whose
4
+ -- `outcome` includes `merged`, `queued`, `blocked` and `retry` (the set is not exhaustive) and
5
+ -- `at` = ISO timestamp. Only `outcome = 'merged'` rows count. The per-calendar-day merged count is
6
+ -- therefore fully DERIVABLE from that audit trail — no new write-path bookkeeping (AGENTS.md:
7
+ -- "Derivation over duplication"). The canonical aggregate is:
8
+ --
9
+ -- SELECT date(at) AS day, COUNT(DISTINCT pr_key) AS merged
10
+ -- FROM merges WHERE outcome = 'merged' GROUP BY date(at);
11
+ --
12
+ -- `COUNT(DISTINCT pr_key)` (not `COUNT(*)`) so a PR that produced several audit rows on one day —
13
+ -- e.g. an `already-merged` short-circuit or a retry — counts once per day.
14
+ --
15
+ -- The natural home for that aggregate would be a SQL VIEW, but Urban's page datasource cannot read
16
+ -- one: `gateway.ts schema()` whitelists `sqlite_master.type = 'table'` only, so a page binding to a
17
+ -- VIEW 400s at request time (see the `lineage_threads` / `plans.delivery` precedent, 037_lineage.sql).
18
+ -- So — following the codebase convention for read-model projections — this is a DENORMALISED flat
19
+ -- table the schema-driven Velocity page reads directly, recomputed idempotently each poll pass by
20
+ -- `pollMergesPerDay` (app/mergesPerDay.ts) from the `merges` audit rows. `cumulative` is the burn-up
21
+ -- running total; `bar` is a precomputed proportional block-character bar so the declarative `prose`
22
+ -- renderer can draw a horizontal bar per day with no chart node type.
23
+ --
24
+ -- Forward-only, additive (expand): a new derived read table. The runner wraps each file in its own
25
+ -- transaction, so this file must NOT contain BEGIN/COMMIT. Numbered after the current highest prefix
26
+ -- (050). `day` is the PRIMARY KEY, which SQLite already indexes, so no extra index is needed.
27
+ CREATE TABLE IF NOT EXISTS merges_per_day (
28
+ day TEXT PRIMARY KEY, -- calendar day (date(at), ISO YYYY-MM-DD)
29
+ merged INTEGER NOT NULL DEFAULT 0, -- COUNT(DISTINCT pr_key) merged that day
30
+ cumulative INTEGER NOT NULL DEFAULT 0, -- running total of merged PRs up to and including `day` (burn-up)
31
+ bar TEXT NOT NULL DEFAULT '', -- proportional block-character bar for the prose chart
32
+ updated_at TEXT NOT NULL
33
+ );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.101.1",
3
+ "version": "0.102.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
package/pages/_nav.json CHANGED
@@ -23,7 +23,8 @@
23
23
  }
24
24
  },
25
25
  { "label": "Cockpit", "page": "cockpit" },
26
- { "label": "Board", "page": "board" }
26
+ { "label": "Board", "page": "board" },
27
+ { "label": "Velocity", "page": "velocity" }
27
28
  ],
28
29
  "sticky": true
29
30
  }
@@ -48,6 +48,10 @@
48
48
  {
49
49
  "label": "Board",
50
50
  "page": "board"
51
+ },
52
+ {
53
+ "label": "Velocity",
54
+ "page": "velocity"
51
55
  }
52
56
  ],
53
57
  "sticky": true
@@ -48,6 +48,10 @@
48
48
  {
49
49
  "label": "Board",
50
50
  "page": "board"
51
+ },
52
+ {
53
+ "label": "Velocity",
54
+ "page": "velocity"
51
55
  }
52
56
  ],
53
57
  "sticky": true
@@ -48,6 +48,10 @@
48
48
  {
49
49
  "label": "Board",
50
50
  "page": "board"
51
+ },
52
+ {
53
+ "label": "Velocity",
54
+ "page": "velocity"
51
55
  }
52
56
  ],
53
57
  "sticky": true
@@ -48,6 +48,10 @@
48
48
  {
49
49
  "label": "Board",
50
50
  "page": "board"
51
+ },
52
+ {
53
+ "label": "Velocity",
54
+ "page": "velocity"
51
55
  }
52
56
  ],
53
57
  "sticky": true
@@ -48,6 +48,10 @@
48
48
  {
49
49
  "label": "Board",
50
50
  "page": "board"
51
+ },
52
+ {
53
+ "label": "Velocity",
54
+ "page": "velocity"
51
55
  }
52
56
  ],
53
57
  "sticky": true
@@ -48,6 +48,10 @@
48
48
  {
49
49
  "label": "Board",
50
50
  "page": "board"
51
+ },
52
+ {
53
+ "label": "Velocity",
54
+ "page": "velocity"
51
55
  }
52
56
  ],
53
57
  "sticky": true
@@ -48,6 +48,10 @@
48
48
  {
49
49
  "label": "Board",
50
50
  "page": "board"
51
+ },
52
+ {
53
+ "label": "Velocity",
54
+ "page": "velocity"
51
55
  }
52
56
  ],
53
57
  "sticky": true
@@ -48,6 +48,10 @@
48
48
  {
49
49
  "label": "Board",
50
50
  "page": "board"
51
+ },
52
+ {
53
+ "label": "Velocity",
54
+ "page": "velocity"
51
55
  }
52
56
  ],
53
57
  "sticky": true
@@ -48,6 +48,10 @@
48
48
  {
49
49
  "label": "Board",
50
50
  "page": "board"
51
+ },
52
+ {
53
+ "label": "Velocity",
54
+ "page": "velocity"
51
55
  }
52
56
  ],
53
57
  "sticky": true
@@ -0,0 +1,92 @@
1
+ {
2
+ "schemaVersion": "1.0",
3
+ "title": "Velocity",
4
+ "nodes": [
5
+ {
6
+ "type": "nav",
7
+ "id": "nav",
8
+ "props": {
9
+ "variant": "bar",
10
+ "title": "Nano Workforce",
11
+ "items": [
12
+ {
13
+ "label": "Overview",
14
+ "page": "overview"
15
+ },
16
+ {
17
+ "label": "Lineage",
18
+ "page": "lineage"
19
+ },
20
+ {
21
+ "label": "Convergence",
22
+ "page": "home"
23
+ },
24
+ {
25
+ "label": "Epics",
26
+ "page": "epic"
27
+ },
28
+ {
29
+ "label": "Feature",
30
+ "page": "feature"
31
+ },
32
+ {
33
+ "label": "Tasks",
34
+ "page": "tasks",
35
+ "badge": {
36
+ "source": "app",
37
+ "table": "user_tasks",
38
+ "filter": [],
39
+ "tone": "danger",
40
+ "refreshMs": 5000,
41
+ "hideWhenZero": true
42
+ }
43
+ },
44
+ {
45
+ "label": "Cockpit",
46
+ "page": "cockpit"
47
+ },
48
+ {
49
+ "label": "Board",
50
+ "page": "board"
51
+ },
52
+ {
53
+ "label": "Velocity",
54
+ "page": "velocity"
55
+ }
56
+ ],
57
+ "sticky": true
58
+ }
59
+ },
60
+ {
61
+ "type": "text",
62
+ "id": "title",
63
+ "props": { "text": "Velocity", "variant": "heading" }
64
+ },
65
+ {
66
+ "type": "text",
67
+ "id": "subtitle",
68
+ "props": {
69
+ "text": "Merged-per-day throughput \u2014 how many PRs the fleet landed each calendar day, derived directly from the merge audit trail. Each row is one day: the bar is scaled against the busiest day, the header shows that day's merged count and the running burn-up total.",
70
+ "variant": "sub"
71
+ }
72
+ },
73
+ {
74
+ "type": "prose",
75
+ "id": "merges-per-day",
76
+ "props": {
77
+ "title": "PRs merged per day",
78
+ "refreshMs": 5000,
79
+ "measure": 60,
80
+ "empty": "No merges recorded yet.",
81
+ "data": {
82
+ "kind": "datasource",
83
+ "source": "app",
84
+ "table": "merges_per_day",
85
+ "orderBy": { "field": "day", "dir": "asc" }
86
+ },
87
+ "header": "{{day}} \u00b7 {{merged}} merged \u00b7 \u03a3 {{cumulative}}",
88
+ "body": "bar"
89
+ }
90
+ }
91
+ ]
92
+ }
@@ -21,7 +21,18 @@ const MIGRATIONS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "db",
21
21
 
22
22
  // Historical collisions that predate this gate. Forward-only + already applied ⇒ cannot be
23
23
  // renumbered. New duplicates are NOT allowed here — fix them before merge.
24
- const GRANDFATHERED_DUPES: ReadonlySet<string> = new Set(["004", "005", "006", "007"]);
24
+ //
25
+ // 049 is a grandfathered post-hoc collision: three independently-merged PRs each took the then-next
26
+ // free prefix on their own branch and landed a `049_*.sql` (#290 `049_plan_task_needs`, #337
27
+ // `049_world_checkpoint`, #339 `049_drop_feature_escalation_surface`). Because a per-branch prefix is
28
+ // computed without visibility of sibling branches, the collision was SILENT at each PR's green CI and
29
+ // only surfaced once all three were on main — exactly the merge-time failure mode this gate warns
30
+ // about, realised across three PRs that never saw each other. By the time it was caught the three
31
+ // migrations were already applied forward-only on main and production, so — like 004–007 — they
32
+ // cannot be renumbered (a rename re-runs `CREATE TABLE`/`ALTER TABLE DROP COLUMN` on migrated DBs and
33
+ // fails). They create three disjoint schema objects, so their relative apply order is irrelevant.
34
+ // Grandfather 049; any NEW duplicate prefix still fails the build.
35
+ const GRANDFATHERED_DUPES: ReadonlySet<string> = new Set(["004", "005", "006", "007", "049"]);
25
36
 
26
37
  const PREFIX = /^(\d{3})_[^/]*\.sql$/;
27
38