@nanobpm/nano-workforce 0.101.1 → 0.102.1

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.
@@ -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.1",
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
+ }