@nanobpm/nano-workforce 0.101.0 → 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 +14 -0
- package/app/github.test.ts +36 -1
- package/app/github.ts +35 -2
- package/app/mergesPerDay.test.ts +173 -0
- package/app/mergesPerDay.ts +150 -0
- package/app/queuedVerdict.test.ts +1 -0
- package/app/reviewReadinessGate.test.ts +15 -3
- package/app/service.ts +20 -1
- package/db/migrations/051_merges_per_day.sql +33 -0
- package/package.json +1 -1
- package/pages/_nav.json +2 -1
- package/pages/board.page.json +4 -0
- package/pages/cockpit.page.json +4 -0
- package/pages/epic-detail.page.json +4 -0
- package/pages/epic.page.json +4 -0
- package/pages/feature.page.json +4 -0
- package/pages/home.page.json +4 -0
- package/pages/lineage.page.json +4 -0
- package/pages/overview.page.json +4 -0
- package/pages/tasks.page.json +4 -0
- package/pages/velocity.page.json +92 -0
- package/resources/processes/merge-loop.bpmn +174 -148
- package/scripts/check-migrations.ts +12 -1
- package/workers/merge/worker.test.ts +45 -0
- package/workers/merge/worker.ts +32 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
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
|
+
|
|
8
|
+
## [0.101.1](https://github.com/nanobpm/nano-workforce/compare/v0.101.0...v0.101.1) (2026-08-19)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **merge-loop:** abandon a closed-not-merged PR instead of escalating ([#342](https://github.com/nanobpm/nano-workforce/issues/342)) ([#343](https://github.com/nanobpm/nano-workforce/issues/343)) ([10ea6be](https://github.com/nanobpm/nano-workforce/commit/10ea6be0e23c9315dfb71a6c94958826a1795a8c)), closes [#350](https://github.com/nanobpm/nano-workforce/issues/350)
|
|
14
|
+
|
|
1
15
|
# [0.101.0](https://github.com/nanobpm/nano-workforce/compare/v0.100.0...v0.101.0) (2026-08-19)
|
|
2
16
|
|
|
3
17
|
|
package/app/github.test.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// the merge-exclusion graph. Force the token transport and stub `globalThis.fetch`.
|
|
4
4
|
import { test } from "node:test";
|
|
5
5
|
import { assertEquals, assertRejects } from "#test-assert";
|
|
6
|
-
import { BaseBranchMustExistError, coalesceTitle, createPullRequest, ensureBaseBranch, ensurePromotionPr, fetchIssueTitle, fetchPrFiles, isNotAPullRequestError, listPrsForHead } from "./github.ts";
|
|
6
|
+
import { BaseBranchMustExistError, classifyPrLiveness, coalesceTitle, createPullRequest, ensureBaseBranch, ensurePromotionPr, fetchIssueTitle, fetchPrFiles, isNotAPullRequestError, listPrsForHead, type PrState } from "./github.ts";
|
|
7
7
|
|
|
8
8
|
// A fake `fetch` that serves `pages` of file batches; each page N (1-based) returns `pages[N-1]`
|
|
9
9
|
// files (named `f{index}`), setting a `Link: rel="next"` header whenever a later page exists.
|
|
@@ -428,3 +428,38 @@ test("ensurePromotionPr: creates when none exists, then reuses on a re-run (idem
|
|
|
428
428
|
assertEquals(second?.number, 500);
|
|
429
429
|
assertEquals(state.creates.length, 1);
|
|
430
430
|
});
|
|
431
|
+
// The shared PR-liveness gate (#342) maps live GitHub state onto one of {open, merged, closed,
|
|
432
|
+
// unknown} so neither durable loop (merge/convergence) can escalate against a non-open PR. A closed
|
|
433
|
+
// PR is terminal (abandon), a merged PR completes, and a null read stays conservative (unknown →
|
|
434
|
+
// proceed as before). `merged` wins over `state` because a merged PR also reports state="closed".
|
|
435
|
+
function prState(over: Partial<PrState>): PrState {
|
|
436
|
+
return {
|
|
437
|
+
merged: false,
|
|
438
|
+
state: "open",
|
|
439
|
+
mergeStateStatus: "CLEAN",
|
|
440
|
+
failingChecks: 0,
|
|
441
|
+
failingCheckNames: [],
|
|
442
|
+
presentCheckNames: [],
|
|
443
|
+
totalChecks: 0,
|
|
444
|
+
isDraft: false,
|
|
445
|
+
headRefOid: null,
|
|
446
|
+
...over,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
test("classifyPrLiveness: an open PR proceeds", () => {
|
|
451
|
+
assertEquals(classifyPrLiveness(prState({ state: "open" })), "open");
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("classifyPrLiveness: a merged PR completes (merged wins over a closed state)", () => {
|
|
455
|
+
assertEquals(classifyPrLiveness(prState({ merged: true, state: "closed" })), "merged");
|
|
456
|
+
assertEquals(classifyPrLiveness(prState({ merged: true, state: "merged" })), "merged");
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
test("classifyPrLiveness: a closed-not-merged PR is terminal (abandon)", () => {
|
|
460
|
+
assertEquals(classifyPrLiveness(prState({ merged: false, state: "closed" })), "closed");
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
test("classifyPrLiveness: a null read (transport hiccup) is unknown — never abandons blind", () => {
|
|
464
|
+
assertEquals(classifyPrLiveness(null), "unknown");
|
|
465
|
+
});
|
package/app/github.ts
CHANGED
|
@@ -484,6 +484,11 @@ export function coalesceTitle(...candidates: (string | null | undefined)[]): str
|
|
|
484
484
|
* failing gates (empty in token mode) so the CI-fix agent knows what to make green. */
|
|
485
485
|
export interface PrState {
|
|
486
486
|
merged: boolean;
|
|
487
|
+
/** GitHub's high-level PR lifecycle state, normalised to `"open" | "closed" | "merged"`. A PR
|
|
488
|
+
* closed *without* merging reports `"closed"` (GitHub also reports a merged PR as `"closed"` on
|
|
489
|
+
* the REST list, but `merged` disambiguates it). Lets a caller gate on PR liveness — see
|
|
490
|
+
* `classifyPrLiveness` — so neither loop escalates against a non-open PR (#342). */
|
|
491
|
+
state: "open" | "closed" | "merged";
|
|
487
492
|
mergeStateStatus: string;
|
|
488
493
|
failingChecks: number;
|
|
489
494
|
failingCheckNames: string[];
|
|
@@ -584,8 +589,10 @@ export async function fetchPrState(
|
|
|
584
589
|
};
|
|
585
590
|
const rollup = j.statusCheckRollup ?? [];
|
|
586
591
|
const names = failingCheckNames(rollup);
|
|
592
|
+
const merged = j.state === "MERGED" || !!j.mergedAt;
|
|
587
593
|
return {
|
|
588
|
-
merged
|
|
594
|
+
merged,
|
|
595
|
+
state: merged ? "merged" : (j.state ?? "").toUpperCase() === "CLOSED" ? "closed" : "open",
|
|
589
596
|
mergeStateStatus: (j.mergeStateStatus || "UNKNOWN").toUpperCase(),
|
|
590
597
|
failingChecks: names.length,
|
|
591
598
|
failingCheckNames: names,
|
|
@@ -604,14 +611,19 @@ export async function fetchPrState(
|
|
|
604
611
|
const j = (await r.json()) as {
|
|
605
612
|
merged?: boolean;
|
|
606
613
|
merged_at?: string | null;
|
|
614
|
+
state?: string;
|
|
607
615
|
mergeable_state?: string;
|
|
608
616
|
draft?: boolean;
|
|
609
617
|
head?: { sha?: string | null };
|
|
610
618
|
};
|
|
619
|
+
const restMerged = !!j.merged || !!j.merged_at;
|
|
611
620
|
return {
|
|
612
621
|
// The single-PR GET returns a `merged` boolean (unlike the list endpoint); we also honour
|
|
613
622
|
// `merged_at` so this mirrors the gh branch's `state === "MERGED" || mergedAt` rule.
|
|
614
|
-
merged:
|
|
623
|
+
merged: restMerged,
|
|
624
|
+
// REST reports a merged PR as `state:"closed"` too, so `merged` disambiguates: a `closed` PR
|
|
625
|
+
// here is genuinely closed WITHOUT merging (e.g. superseded) — the #342 abandon case.
|
|
626
|
+
state: restMerged ? "merged" : (j.state ?? "").toLowerCase() === "closed" ? "closed" : "open",
|
|
615
627
|
mergeStateStatus: normalizeMergeState(j.mergeable_state ?? "unknown"),
|
|
616
628
|
failingChecks: -1, // REST here doesn't enumerate checks → classifier treats BLOCKED as "wait"
|
|
617
629
|
failingCheckNames: [], // …and the CI-fix agent gets no per-check list in token mode
|
|
@@ -622,6 +634,27 @@ export async function fetchPrState(
|
|
|
622
634
|
};
|
|
623
635
|
}
|
|
624
636
|
|
|
637
|
+
/** Map a PR's live GitHub state to one **liveness** verdict shared by both durable loops (merge +
|
|
638
|
+
* convergence), so neither can ever escalate against a non-open PR (#342):
|
|
639
|
+
*
|
|
640
|
+
* • `open` — proceed with the normal protocol.
|
|
641
|
+
* • `merged` — already landed (out-of-band); complete the loop as merged.
|
|
642
|
+
* • `closed` — closed on GitHub WITHOUT merging (e.g. superseded); the PR can never merge, so
|
|
643
|
+
* the loop must **abandon** (terminate) it — NOT escalate a merge no human can
|
|
644
|
+
* complete. This is terminal state, not a human decision.
|
|
645
|
+
* • `unknown` — a transport hiccup left us without live state (`fetchPrState` returned null);
|
|
646
|
+
* stay conservative and fall through to the normal path rather than abandoning a
|
|
647
|
+
* PR we could not read.
|
|
648
|
+
*
|
|
649
|
+
* Deriving all three from one source keeps a single canonical liveness gate instead of each loop
|
|
650
|
+
* re-implementing `pre?.merged`/closed checks against drifting field names. */
|
|
651
|
+
export function classifyPrLiveness(pre: PrState | null): "open" | "merged" | "closed" | "unknown" {
|
|
652
|
+
if (!pre) return "unknown";
|
|
653
|
+
if (pre.merged) return "merged";
|
|
654
|
+
if (pre.state === "closed") return "closed";
|
|
655
|
+
return "open";
|
|
656
|
+
}
|
|
657
|
+
|
|
625
658
|
/** The changed file paths of a PR (for the D2 conflict-scan, #58). `gh` returns them directly;
|
|
626
659
|
* the token transport pages `/pulls/{n}/files` (100/page, capped). Returns `null` when no
|
|
627
660
|
* transport is usable (idle), an empty array for a PR with no files. */
|
|
@@ -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
|
|
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="
|
|
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
|
|
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
|
@@ -25,6 +25,7 @@ import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
|
|
|
25
25
|
import { backfillFeatureStages, deriveFeatureDelivery, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRunStatus, featureEscalations, featureRuns } from "./feature.ts";
|
|
26
26
|
import {
|
|
27
27
|
classifyMergeability,
|
|
28
|
+
classifyPrLiveness,
|
|
28
29
|
coalesceTitle,
|
|
29
30
|
ensureFreshHeadRun,
|
|
30
31
|
ensurePromotionPr,
|
|
@@ -42,6 +43,7 @@ import {
|
|
|
42
43
|
import { pollLineage } from "./lineage.ts";
|
|
43
44
|
import { mergeLanes, readExclusions } from "./mergeExclusion.ts";
|
|
44
45
|
import { freshHeadRunAction, headRunPresenceCount, loadMergeProtocol } from "./mergeProtocol.ts";
|
|
46
|
+
import { pollMergesPerDay } from "./mergesPerDay.ts";
|
|
45
47
|
import { type PrLaneDecision, planPrLane, taskDependencyDepths } from "./mergeTrain.ts";
|
|
46
48
|
import {
|
|
47
49
|
backfillPlanBuckets,
|
|
@@ -925,7 +927,8 @@ async function pollMerges(data: DataLayer, engine: EngineClient, token: string)
|
|
|
925
927
|
try {
|
|
926
928
|
const st = await fetchPrState(repo, number, token);
|
|
927
929
|
if (st === null) continue; // no transport → skip this PR (others may still advance)
|
|
928
|
-
|
|
930
|
+
const liveness = classifyPrLiveness(st);
|
|
931
|
+
if (liveness === "merged") {
|
|
929
932
|
// Landed out-of-band (a maintainer clicked Merge, a mergify queue merged it, etc.). The
|
|
930
933
|
// instance is parked at `wait-mergeable`, which subscribes to `merge-ready` — NOT
|
|
931
934
|
// `merge-landed` (that catch, `wait-landed`, only exists later, after we enqueue). Publishing
|
|
@@ -941,6 +944,21 @@ async function pollMerges(data: DataLayer, engine: EngineClient, token: string)
|
|
|
941
944
|
console.log(`[poller] already merged -> ${prKey}`);
|
|
942
945
|
continue;
|
|
943
946
|
}
|
|
947
|
+
if (liveness === "closed") {
|
|
948
|
+
// Closed on GitHub WITHOUT merging (e.g. superseded by a newer PR — #350). The PR can never
|
|
949
|
+
// land, so it must NOT be classified as blocked/conflict and escalated (that orphans the
|
|
950
|
+
// process on a dead PR, #342). Route it through the same canonical `merge-ready` → `ready` →
|
|
951
|
+
// `attempt-merge` path as the merged case; the merge worker's closed short-circuit records a
|
|
952
|
+
// terminal `abandoned` audit row and drives the loop down its terminate/abandon end event.
|
|
953
|
+
// One canonical abandon implementation lives in the worker — the poller only routes to it.
|
|
954
|
+
await flipToMergingThenPublish(data, engine, prKey, "waiting_merge", {
|
|
955
|
+
name: "merge-ready",
|
|
956
|
+
correlationKey: prKey,
|
|
957
|
+
variables: { mergeState: "ready", failingChecks: 0, failingChecksList: "" },
|
|
958
|
+
});
|
|
959
|
+
console.log(`[poller] closed without merging -> ${prKey}`);
|
|
960
|
+
continue;
|
|
961
|
+
}
|
|
944
962
|
const verdict = classifyMergeability(st);
|
|
945
963
|
if (verdict === "waiting") {
|
|
946
964
|
// Frugal-CI remedy (#43): when the repo publishes a merge protocol that wants a fresh
|
|
@@ -2099,6 +2117,7 @@ export async function pollOnce(
|
|
|
2099
2117
|
await pollPromotion(data, engine, token);
|
|
2100
2118
|
await pollFeatureDelivery(data);
|
|
2101
2119
|
await pollLineage(data);
|
|
2120
|
+
await pollMergesPerDay(data);
|
|
2102
2121
|
await pollUserTasks(data, engine);
|
|
2103
2122
|
if (engineRest) {
|
|
2104
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.
|
|
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
package/pages/board.page.json
CHANGED
package/pages/cockpit.page.json
CHANGED