@nanobpm/nano-workforce 0.35.2 → 0.37.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/.github/workflows/ci.yml +10 -17
- package/AGENTS.md +8 -7
- package/CHANGELOG.md +14 -0
- package/README.md +1 -21
- package/actions/abandon.test.ts +8 -16
- package/actions/blackboard.test.ts +13 -21
- package/actions/blackboard.ts +1 -0
- package/actions/feature-answer-hook.ts +1 -0
- package/actions/plan-hook.ts +2 -1
- package/actions/webhook-submit.ts +2 -1
- package/app/abandon.test.ts +10 -15
- package/app/baseGuard.test.ts +7 -6
- package/app/blackboard.test.ts +22 -32
- package/app/blackboard.ts +7 -6
- package/app/ensure-pr.test.ts +10 -12
- package/app/github.test.ts +9 -8
- package/app/github.ts +24 -22
- package/app/instance-tracking.test.ts +8 -6
- package/app/mergeExclusion.test.ts +11 -20
- package/app/mergeExclusion.ts +8 -3
- package/app/mergeProtocol.test.ts +14 -13
- package/app/mergeProtocol.ts +1 -0
- package/app/mergeRebaseArm.test.ts +8 -6
- package/app/mergeTrain.test.ts +10 -9
- package/app/persist-escalation.test.ts +9 -30
- package/app/persist-round.test.ts +6 -19
- package/app/plan.test.ts +19 -42
- package/app/plan.ts +1 -1
- package/app/record-plan-review.test.ts +8 -7
- package/app/retro.test.ts +24 -48
- package/app/retro.ts +4 -2
- package/app/reviewWait.test.ts +12 -11
- package/app/rounds.test.ts +13 -12
- package/app/service.test.ts +14 -27
- package/app/service.ts +8 -4
- package/app/taskDelta.test.ts +8 -17
- package/app/taskDelta.ts +1 -0
- package/app/trialMerge.test.ts +4 -3
- package/app/version.ts +9 -21
- package/app/waves.test.ts +17 -16
- package/biome.json +143 -0
- package/operations/getVersion.test.ts +8 -12
- package/operations/getVersion.ts +2 -3
- package/operations/listActivePrs.test.ts +8 -14
- package/operations/listActivePrs.ts +2 -3
- package/operations/postMessage.ts +3 -3
- package/operations/startAndMessage.test.ts +6 -12
- package/package.json +9 -4
- package/plugins/no-unsafe-type-assertion.grit +12 -0
- package/scripts/check-agent-prompts.test.ts +15 -11
- package/scripts/check-agent-prompts.ts +4 -2
- package/scripts/layout-bpmn.ts +6 -26
- package/scripts/pages-contract.test.ts +14 -13
- package/scripts/purge-db.ts +3 -2
- package/scripts/upgrade-from-pack.ts +9 -5
- package/test/assert.ts +74 -0
- package/tsconfig.json +4 -1
- package/workers/finalize/worker.ts +2 -1
- package/workers/merge/worker.ts +3 -3
- package/workers/persist-escalation/worker.ts +2 -1
- package/workers/persist-round/worker.ts +2 -1
- package/workers/record-plan-review/worker.test.ts +6 -9
- package/workers/record-plan-review/worker.ts +2 -1
- package/workers/record-results/worker.test.ts +5 -8
- package/workers/record-results/worker.ts +2 -1
- package/workers/record-trial-merge/worker.test.ts +7 -18
- package/workers/record-trial-merge/worker.ts +6 -6
- package/workers/record-wave/worker.test.ts +13 -20
- package/workers/record-wave/worker.ts +7 -8
- package/workers/retro-gather/worker.test.ts +4 -17
- package/workers/retro-record/worker.test.ts +8 -27
- package/workers/retro-record/worker.ts +3 -3
- package/workers/select-wave/worker.test.ts +5 -8
- package/deno.json +0 -24
- package/deno.lock +0 -1776
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
// crashed) run stuck "active" in the UI — the exact drift Copilot flagged on #96. This ties the
|
|
5
5
|
// manifest to the code's single source of truth for "done" (TERMINAL_STATUSES / PLAN_TERMINAL_
|
|
6
6
|
// STATUSES) so the two can't diverge silently.
|
|
7
|
-
import {
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import { assert, assertEquals } from "#test-assert";
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
8
10
|
import { TERMINAL_STATUSES } from "./service.ts";
|
|
9
11
|
import { PLAN_TERMINAL_STATUSES } from "./plan.ts";
|
|
10
12
|
|
|
@@ -16,7 +18,7 @@ interface Binding {
|
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
async function bindings(): Promise<Binding[]> {
|
|
19
|
-
const manifest = JSON.parse(
|
|
21
|
+
const manifest = JSON.parse(readFileSync(new URL("../nano.app.json", import.meta.url), "utf8"));
|
|
20
22
|
return manifest.instanceTracking as Binding[];
|
|
21
23
|
}
|
|
22
24
|
|
|
@@ -26,7 +28,7 @@ function bindingFor(all: Binding[], table: string): Binding {
|
|
|
26
28
|
return b;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
|
-
|
|
31
|
+
test("instanceTracking: pull_requests activeStatuses excludes every terminal status", async () => {
|
|
30
32
|
const b = bindingFor(await bindings(), "pull_requests");
|
|
31
33
|
for (const terminal of TERMINAL_STATUSES) {
|
|
32
34
|
assert(
|
|
@@ -40,7 +42,7 @@ Deno.test("instanceTracking: pull_requests activeStatuses excludes every termina
|
|
|
40
42
|
// pull_requests row can hold while a live engine instance still backs it (see app/service.ts merge
|
|
41
43
|
// poller: converging/waiting_review/escalated + the merge-stage waiting_deps/waiting_merge/
|
|
42
44
|
// waiting_lane/queued/merging). If a new one is added to the flow, add it here AND to the manifest.
|
|
43
|
-
|
|
45
|
+
test("instanceTracking: pull_requests activeStatuses covers every in-flight status", async () => {
|
|
44
46
|
const inFlight = [
|
|
45
47
|
"converging",
|
|
46
48
|
"waiting_review",
|
|
@@ -59,14 +61,14 @@ Deno.test("instanceTracking: pull_requests activeStatuses covers every in-flight
|
|
|
59
61
|
for (const s of inFlight) assert(!TERMINAL_STATUSES.includes(s));
|
|
60
62
|
});
|
|
61
63
|
|
|
62
|
-
|
|
64
|
+
test("instanceTracking: plans activeStatuses excludes every terminal status", async () => {
|
|
63
65
|
const b = bindingFor(await bindings(), "plans");
|
|
64
66
|
for (const terminal of PLAN_TERMINAL_STATUSES) {
|
|
65
67
|
assert(!b.activeStatuses?.includes(terminal), `terminal status "${terminal}" must not be active`);
|
|
66
68
|
}
|
|
67
69
|
});
|
|
68
70
|
|
|
69
|
-
|
|
71
|
+
test("instanceTracking: plans activeStatuses covers every in-flight status", async () => {
|
|
70
72
|
const inFlight = ["planning", "dispatched"];
|
|
71
73
|
const b = bindingFor(await bindings(), "plans");
|
|
72
74
|
assertEquals([...(b.activeStatuses ?? [])].sort(), [...inFlight].sort());
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Unit tests for the merge-exclusion graph + conflict-scan (D1/D2, issues #57 #58 / #49).
|
|
2
|
-
import {
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { assert, assertEquals } from "#test-assert";
|
|
3
4
|
import type { DataLayer } from "@nanobpm/urban";
|
|
4
5
|
import {
|
|
5
6
|
clearExclusions,
|
|
@@ -13,58 +14,48 @@ import {
|
|
|
13
14
|
import { computeWaves } from "./waves.ts";
|
|
14
15
|
|
|
15
16
|
// In-memory record-gateway fake (insert/find/findOne/update/delete), mirroring the app tests.
|
|
16
|
-
// deno-lint-ignore no-explicit-any
|
|
17
17
|
function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
18
|
-
// deno-lint-ignore no-explicit-any
|
|
19
18
|
const stores: Record<string, any[]> = {};
|
|
20
19
|
const seq: Record<string, number> = {};
|
|
21
20
|
function tbl(name: string, pk = "id") {
|
|
22
|
-
// deno-lint-ignore no-explicit-any
|
|
23
21
|
const rows = (stores[name] ??= [] as any[]);
|
|
24
|
-
// deno-lint-ignore no-explicit-any
|
|
25
22
|
const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
|
|
26
23
|
return {
|
|
27
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
28
24
|
async insert(row: any) {
|
|
29
25
|
const id = (seq[name] = (seq[name] ?? 0) + 1);
|
|
30
26
|
rows.push({ id, ...row });
|
|
31
27
|
return id;
|
|
32
28
|
},
|
|
33
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
34
29
|
async find(where: any = {}) {
|
|
35
30
|
return rows.filter((r) => match(r, where));
|
|
36
31
|
},
|
|
37
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
38
32
|
async findOne(where: any = {}) {
|
|
39
33
|
return rows.find((r) => match(r, where));
|
|
40
34
|
},
|
|
41
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
42
35
|
async update(id: any, patch: any) {
|
|
43
36
|
const r = rows.find((row) => row.id === id);
|
|
44
37
|
if (r) Object.assign(r, patch);
|
|
45
38
|
},
|
|
46
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
47
39
|
async delete(id: any) {
|
|
48
40
|
const i = rows.findIndex((row) => row.id === id);
|
|
49
41
|
if (i >= 0) rows.splice(i, 1);
|
|
50
42
|
},
|
|
51
43
|
};
|
|
52
44
|
}
|
|
53
|
-
// deno-lint-ignore no-explicit-any
|
|
54
45
|
const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
|
|
55
46
|
return { data, stores };
|
|
56
47
|
}
|
|
57
48
|
|
|
58
49
|
const files = (e: ExclusionEdge) => e.files;
|
|
59
50
|
|
|
60
|
-
|
|
51
|
+
test("normalizePair: orders deterministically and rejects self/blank pairs", () => {
|
|
61
52
|
assertEquals(normalizePair("b", "a"), ["a", "b"]);
|
|
62
53
|
assertEquals(normalizePair("a", "b"), ["a", "b"]);
|
|
63
54
|
assertEquals(normalizePair("a", "a"), null, "a task never excludes itself");
|
|
64
55
|
assertEquals(normalizePair("", "a"), null);
|
|
65
56
|
});
|
|
66
57
|
|
|
67
|
-
|
|
58
|
+
test("deriveExclusions: an edge per file-overlapping pair, carrying the sorted overlap", () => {
|
|
68
59
|
const edges = deriveExclusions(
|
|
69
60
|
new Map([
|
|
70
61
|
["gap-2", ["engine/tests.rs", "engine/state.rs"]],
|
|
@@ -83,14 +74,14 @@ Deno.test("deriveExclusions: an edge per file-overlapping pair, carrying the sor
|
|
|
83
74
|
assert(!edges.some((e) => e.taskA === "gap-5" || e.taskB === "gap-5"), "gap-5 excluded (no overlap)");
|
|
84
75
|
});
|
|
85
76
|
|
|
86
|
-
|
|
77
|
+
test("deriveExclusions: no overlap → no edges; blank paths ignored", () => {
|
|
87
78
|
assertEquals(
|
|
88
79
|
deriveExclusions(new Map([["a", ["x.rs"]], ["b", ["y.rs"]], ["c", ["", " "]]])),
|
|
89
80
|
[],
|
|
90
81
|
);
|
|
91
82
|
});
|
|
92
83
|
|
|
93
|
-
|
|
84
|
+
test("recordExclusions: upserts per unordered pair — a re-scan refreshes files, never duplicates", async () => {
|
|
94
85
|
const { data, stores } = memData();
|
|
95
86
|
const first = await recordExclusions(data, "p", deriveExclusions(
|
|
96
87
|
new Map([["gap-2", ["a.rs"]], ["gap-8", ["a.rs"]]]),
|
|
@@ -109,7 +100,7 @@ Deno.test("recordExclusions: upserts per unordered pair — a re-scan refreshes
|
|
|
109
100
|
assertEquals(edge.files, ["a.rs", "b.rs"], "files refreshed in place");
|
|
110
101
|
});
|
|
111
102
|
|
|
112
|
-
|
|
103
|
+
test("recordExclusions: a duplicate pair within one batch folds into an update, never a second row", async () => {
|
|
113
104
|
const { data, stores } = memData();
|
|
114
105
|
// The same unordered pair appears twice in one call (second given in the other order + more files).
|
|
115
106
|
// The in-memory map must fold the newly inserted id back so the second occurrence updates in place.
|
|
@@ -123,7 +114,7 @@ Deno.test("recordExclusions: a duplicate pair within one batch folds into an upd
|
|
|
123
114
|
assertEquals(edge.files, ["x.rs", "y.rs"], "later occurrence refreshed files in place");
|
|
124
115
|
});
|
|
125
116
|
|
|
126
|
-
|
|
117
|
+
test("clearExclusions: drops one plan's graph, leaving others intact", async () => {
|
|
127
118
|
const { data } = memData();
|
|
128
119
|
await recordExclusions(data, "p", deriveExclusions(new Map([["a", ["x"]], ["b", ["x"]]])));
|
|
129
120
|
await recordExclusions(data, "q", deriveExclusions(new Map([["c", ["y"]], ["d", ["y"]]])));
|
|
@@ -132,7 +123,7 @@ Deno.test("clearExclusions: drops one plan's graph, leaving others intact", asyn
|
|
|
132
123
|
assertEquals((await readExclusions(data, "q")).length, 1, "the other plan survives");
|
|
133
124
|
});
|
|
134
125
|
|
|
135
|
-
|
|
126
|
+
test("mergeLanes: connected components are serial landing lanes; singletons land in parallel", () => {
|
|
136
127
|
// gap-2—gap-8—gap-9 form one chain (transitive shared surface); gap-5 stands alone.
|
|
137
128
|
const edges = deriveExclusions(
|
|
138
129
|
new Map([
|
|
@@ -146,11 +137,11 @@ Deno.test("mergeLanes: connected components are serial landing lanes; singletons
|
|
|
146
137
|
assertEquals(lanes, [["gap-2", "gap-8", "gap-9"], ["gap-5"]]);
|
|
147
138
|
});
|
|
148
139
|
|
|
149
|
-
|
|
140
|
+
test("mergeLanes: with no edges, every task is its own lane (fully parallel landing)", () => {
|
|
150
141
|
assertEquals(mergeLanes([], ["b", "a", "c"]), [["a"], ["b"], ["c"]]);
|
|
151
142
|
});
|
|
152
143
|
|
|
153
|
-
|
|
144
|
+
test("D1 invariant: a merge-exclusion is NOT a dispatch dependency", () => {
|
|
154
145
|
// Two tasks that collide on a shared file but declare no build-on dependency.
|
|
155
146
|
const overlap = new Map([["gap-2", ["engine/tests.rs"]], ["gap-8", ["engine/tests.rs"]]]);
|
|
156
147
|
const edges = deriveExclusions(overlap);
|
package/app/mergeExclusion.ts
CHANGED
|
@@ -169,12 +169,17 @@ export async function clearExclusions(data: DataLayer, planKey: string): Promise
|
|
|
169
169
|
export function mergeLanes(edges: ExclusionEdge[], allTasks: Iterable<string> = []): string[][] {
|
|
170
170
|
const parent = new Map<string, string>();
|
|
171
171
|
const find = (x: string): string => {
|
|
172
|
+
const parentOf = (node: string): string => {
|
|
173
|
+
const p = parent.get(node);
|
|
174
|
+
if (p === undefined) throw new Error(`missing union-find parent for ${node}`);
|
|
175
|
+
return p;
|
|
176
|
+
};
|
|
172
177
|
let root = x;
|
|
173
|
-
while (
|
|
178
|
+
while (parentOf(root) !== root) root = parentOf(root);
|
|
174
179
|
// Path-compress so repeated finds stay near-flat.
|
|
175
180
|
let cur = x;
|
|
176
|
-
while (
|
|
177
|
-
const next =
|
|
181
|
+
while (parentOf(cur) !== root) {
|
|
182
|
+
const next = parentOf(cur);
|
|
178
183
|
parent.set(cur, root);
|
|
179
184
|
cur = next;
|
|
180
185
|
}
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
// extractor must find the fenced ```merge-protocol JSON, and the fresh-head-run decision must fire
|
|
4
4
|
// exactly once per landing attempt — only in the frugal-CI stuck state (no head run + waiting) —
|
|
5
5
|
// so a converged PR is nudged into a fresh CI run without disturbing a run already in flight.
|
|
6
|
-
// Run with `
|
|
7
|
-
import {
|
|
6
|
+
// Run with `node --test`.
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import { assertEquals } from "#test-assert";
|
|
8
9
|
import {
|
|
9
10
|
DEFAULT_MERGE_PROTOCOL,
|
|
10
11
|
extractProtocolBlock,
|
|
@@ -13,14 +14,14 @@ import {
|
|
|
13
14
|
parseMergeProtocol,
|
|
14
15
|
} from "./mergeProtocol.ts";
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
test("parseMergeProtocol: non-object / junk → defaults (total, never throws)", () => {
|
|
17
18
|
assertEquals(parseMergeProtocol(undefined), { ...DEFAULT_MERGE_PROTOCOL });
|
|
18
19
|
assertEquals(parseMergeProtocol(null), { ...DEFAULT_MERGE_PROTOCOL });
|
|
19
20
|
assertEquals(parseMergeProtocol("nope"), { ...DEFAULT_MERGE_PROTOCOL });
|
|
20
21
|
assertEquals(parseMergeProtocol([1, 2]), { ...DEFAULT_MERGE_PROTOCOL });
|
|
21
22
|
});
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
test("parseMergeProtocol: full nano-bpm-style descriptor", () => {
|
|
24
25
|
const got = parseMergeProtocol({
|
|
25
26
|
autoMerge: false,
|
|
26
27
|
freshHeadRun: "ready-or-reopen",
|
|
@@ -37,7 +38,7 @@ Deno.test("parseMergeProtocol: full nano-bpm-style descriptor", () => {
|
|
|
37
38
|
assertEquals(got.doc, "AGENTS.md#merging-prs");
|
|
38
39
|
});
|
|
39
40
|
|
|
40
|
-
|
|
41
|
+
test("parseMergeProtocol: invalid enums / wrong types fall back per-field", () => {
|
|
41
42
|
const got = parseMergeProtocol({
|
|
42
43
|
autoMerge: "yes", // not a boolean → default
|
|
43
44
|
freshHeadRun: "sometimes", // not in the enum → default (none)
|
|
@@ -50,12 +51,12 @@ Deno.test("parseMergeProtocol: invalid enums / wrong types fall back per-field",
|
|
|
50
51
|
assertEquals(got.requiredChecks, ["ok"]);
|
|
51
52
|
});
|
|
52
53
|
|
|
53
|
-
|
|
54
|
+
test("parseMergeProtocol: comment dropped when absent", () => {
|
|
54
55
|
const got = parseMergeProtocol({ land: { method: "admin" } });
|
|
55
56
|
assertEquals(got.land, { method: "admin" });
|
|
56
57
|
});
|
|
57
58
|
|
|
58
|
-
|
|
59
|
+
test("extractProtocolBlock: finds the fenced merge-protocol JSON (with info word)", () => {
|
|
59
60
|
const md = [
|
|
60
61
|
"## Merging PRs",
|
|
61
62
|
"Some prose about how to merge.",
|
|
@@ -73,7 +74,7 @@ Deno.test("extractProtocolBlock: finds the fenced merge-protocol JSON (with info
|
|
|
73
74
|
assertEquals(p.land.method, "mergify-queue");
|
|
74
75
|
});
|
|
75
76
|
|
|
76
|
-
|
|
77
|
+
test("extractProtocolBlock: none present → null", () => {
|
|
77
78
|
assertEquals(extractProtocolBlock("# Doc\n```json\n{}\n```\n"), null);
|
|
78
79
|
});
|
|
79
80
|
|
|
@@ -82,14 +83,14 @@ const NANO: MergeProtocol = parseMergeProtocol({
|
|
|
82
83
|
land: { method: "mergify-queue" },
|
|
83
84
|
});
|
|
84
85
|
|
|
85
|
-
|
|
86
|
+
test("freshHeadRunAction: fires in the frugal-CI stuck state (no run + waiting)", () => {
|
|
86
87
|
// ready PR, no head run at all → reopen (ready-or-reopen, not a draft)
|
|
87
88
|
assertEquals(freshHeadRunAction(NANO, "waiting", 0, false), "reopen");
|
|
88
89
|
// draft PR, no head run → mark ready
|
|
89
90
|
assertEquals(freshHeadRunAction(NANO, "waiting", 0, true), "ready");
|
|
90
91
|
});
|
|
91
92
|
|
|
92
|
-
|
|
93
|
+
test("freshHeadRunAction: fires once per landing-attempt head, then re-fires after rebase", () => {
|
|
93
94
|
assertEquals(
|
|
94
95
|
freshHeadRunAction(NANO, "waiting", 0, false, { headRefOid: "h1", lastActionHeadRefOid: null }),
|
|
95
96
|
"reopen",
|
|
@@ -104,7 +105,7 @@ Deno.test("freshHeadRunAction: fires once per landing-attempt head, then re-fire
|
|
|
104
105
|
);
|
|
105
106
|
});
|
|
106
107
|
|
|
107
|
-
|
|
108
|
+
test("freshHeadRunAction: never fires once a run exists, or when not waiting", () => {
|
|
108
109
|
assertEquals(freshHeadRunAction(NANO, "waiting", 1, false), null); // run already in flight
|
|
109
110
|
assertEquals(freshHeadRunAction(NANO, "waiting", -1, false), null); // token mode (unknown) → conservative
|
|
110
111
|
assertEquals(freshHeadRunAction(NANO, "ready", 0, false), null); // already landable
|
|
@@ -113,11 +114,11 @@ Deno.test("freshHeadRunAction: never fires once a run exists, or when not waitin
|
|
|
113
114
|
assertEquals(freshHeadRunAction(NANO, "conflict", 0, false), null); // conflict → rebase arm (#42)
|
|
114
115
|
});
|
|
115
116
|
|
|
116
|
-
|
|
117
|
+
test("freshHeadRunAction: protocol.freshHeadRun=none is a no-op (default repos unchanged)", () => {
|
|
117
118
|
assertEquals(freshHeadRunAction(DEFAULT_MERGE_PROTOCOL, "waiting", 0, false), null);
|
|
118
119
|
});
|
|
119
120
|
|
|
120
|
-
|
|
121
|
+
test("freshHeadRunAction: mode=ready only acts on drafts", () => {
|
|
121
122
|
const readyOnly = parseMergeProtocol({ freshHeadRun: "ready", land: { method: "gh-merge" } });
|
|
122
123
|
assertEquals(freshHeadRunAction(readyOnly, "waiting", 0, true), "ready");
|
|
123
124
|
assertEquals(freshHeadRunAction(readyOnly, "waiting", 0, false), null); // not a draft → nothing to ready
|
package/app/mergeProtocol.ts
CHANGED
|
@@ -72,6 +72,7 @@ function strArray(v: unknown): string[] | undefined {
|
|
|
72
72
|
}
|
|
73
73
|
function oneOf<T extends string>(v: unknown, allowed: ReadonlySet<string>): T | undefined {
|
|
74
74
|
const s = str(v);
|
|
75
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
75
76
|
return s !== undefined && allowed.has(s) ? (s as T) : undefined;
|
|
76
77
|
}
|
|
77
78
|
|
|
@@ -10,9 +10,11 @@
|
|
|
10
10
|
// way it originally shipped. It is a pure text assertion over the BPMN (no engine), matching the
|
|
11
11
|
// repo's lightweight model-guard style.
|
|
12
12
|
|
|
13
|
-
import {
|
|
13
|
+
import { test } from "node:test";
|
|
14
|
+
import { assert, assertStringIncludes } from "#test-assert";
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
14
16
|
|
|
15
|
-
const bpmn =
|
|
17
|
+
const bpmn = readFileSync("resources/processes/merge-loop.bpmn", "utf8");
|
|
16
18
|
|
|
17
19
|
// Collapse whitespace so attribute-order / line-wrapping churn doesn't make the assertions brittle.
|
|
18
20
|
const flat = bpmn.replace(/\s+/g, " ");
|
|
@@ -27,14 +29,14 @@ function hasFlow(source: string, target: string): boolean {
|
|
|
27
29
|
return re.test(flat);
|
|
28
30
|
}
|
|
29
31
|
|
|
30
|
-
|
|
32
|
+
test("conflict routes to the rebase budget gate, not straight to a human", () => {
|
|
31
33
|
// The conflict verdict must reach the auto-rebase gate…
|
|
32
34
|
assert(hasFlow("gw-mergeable", "gw-rebase"), "gw-mergeable → gw-rebase (conflict) missing");
|
|
33
35
|
// …guarded by the exact conflict condition (DIRTY → mergeState = "conflict").
|
|
34
36
|
assertStringIncludes(flat, 'mergeState = "conflict"');
|
|
35
37
|
});
|
|
36
38
|
|
|
37
|
-
|
|
39
|
+
test("rebase arm mirrors the fix-ci arm: budget gate → agent → result gate", () => {
|
|
38
40
|
// Budget gate: within budget → the agent; exhausted → the (existing) conflict escalation.
|
|
39
41
|
assert(hasFlow("gw-rebase", "rebase"), "gw-rebase → rebase (within budget) missing");
|
|
40
42
|
assert(hasFlow("gw-rebase", "merge-esc-conflict"), "gw-rebase → merge-esc-conflict (budget exhausted) missing");
|
|
@@ -49,7 +51,7 @@ Deno.test("rebase arm mirrors the fix-ci arm: budget gate → agent → result g
|
|
|
49
51
|
assertStringIncludes(flat, "=rebaseRound + 1");
|
|
50
52
|
});
|
|
51
53
|
|
|
52
|
-
|
|
54
|
+
test("rebase result: success re-arms the poller; unresolved escalates to a human", () => {
|
|
53
55
|
// Success loops back to re-attempt the merge — forward progress, no human needed.
|
|
54
56
|
assert(hasFlow("gw-rebase-result", "arm-merge"), "gw-rebase-result → arm-merge (rebased) missing");
|
|
55
57
|
assertStringIncludes(flat, 'status = "rebased"');
|
|
@@ -60,7 +62,7 @@ Deno.test("rebase result: success re-arms the poller; unresolved escalates to a
|
|
|
60
62
|
);
|
|
61
63
|
});
|
|
62
64
|
|
|
63
|
-
|
|
65
|
+
test("regression: the conflict verdict passes through the rebase actor, not straight to escalation", () => {
|
|
64
66
|
// The original #42 livelock had the conflict verdict escalate to a human with no remediation
|
|
65
67
|
// actor. The primary target of the conflict verdict must now be the rebase gate.
|
|
66
68
|
assert(
|
package/app/mergeTrain.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Red/green regression for D6's pure merge-train lane planner.
|
|
2
|
-
import {
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { assertEquals } from "#test-assert";
|
|
3
4
|
import { planLane, planPrLane, taskDependencyDepths } from "./mergeTrain.ts";
|
|
4
5
|
|
|
5
6
|
const taskToPr = new Map([
|
|
@@ -9,7 +10,7 @@ const taskToPr = new Map([
|
|
|
9
10
|
["gap-10", "o/r#10"],
|
|
10
11
|
]);
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
test("planLane: dependency depth then task id picks the single lane head", () => {
|
|
13
14
|
const depth = new Map([["gap-8", 2], ["gap-2", 0], ["gap-9", 0]]);
|
|
14
15
|
assertEquals(planLane(["gap-8", "gap-9", "gap-2"], taskToPr, new Set(), depth), {
|
|
15
16
|
headTaskId: "gap-2",
|
|
@@ -19,7 +20,7 @@ Deno.test("planLane: dependency depth then task id picks the single lane head",
|
|
|
19
20
|
});
|
|
20
21
|
});
|
|
21
22
|
|
|
22
|
-
|
|
23
|
+
test("taskDependencyDepths: longest dependency path determines landing order depth", () => {
|
|
23
24
|
const depths = taskDependencyDepths([
|
|
24
25
|
{ task_id: "b", depends_on_task_id: "a" },
|
|
25
26
|
{ task_id: "c", depends_on_task_id: "b" },
|
|
@@ -36,7 +37,7 @@ Deno.test("taskDependencyDepths: longest dependency path determines landing orde
|
|
|
36
37
|
]);
|
|
37
38
|
});
|
|
38
39
|
|
|
39
|
-
|
|
40
|
+
test("planLane: task id is the deterministic tiebreaker", () => {
|
|
40
41
|
assertEquals(planLane(["gap-9", "gap-2", "gap-8"], taskToPr, new Set()), {
|
|
41
42
|
headTaskId: "gap-2",
|
|
42
43
|
headPrKey: "o/r#2",
|
|
@@ -45,7 +46,7 @@ Deno.test("planLane: task id is the deterministic tiebreaker", () => {
|
|
|
45
46
|
});
|
|
46
47
|
});
|
|
47
48
|
|
|
48
|
-
|
|
49
|
+
test("planLane: already-merged head advances to the next unmerged member", () => {
|
|
49
50
|
assertEquals(planLane(["gap-2", "gap-8", "gap-9"], taskToPr, new Set(["o/r#2"])), {
|
|
50
51
|
headTaskId: "gap-8",
|
|
51
52
|
headPrKey: "o/r#8",
|
|
@@ -54,7 +55,7 @@ Deno.test("planLane: already-merged head advances to the next unmerged member",
|
|
|
54
55
|
});
|
|
55
56
|
});
|
|
56
57
|
|
|
57
|
-
|
|
58
|
+
test("planLane: single-member lanes never hold their PR", () => {
|
|
58
59
|
assertEquals(planLane(["gap-10"], taskToPr, new Set()), {
|
|
59
60
|
headTaskId: null,
|
|
60
61
|
headPrKey: null,
|
|
@@ -63,7 +64,7 @@ Deno.test("planLane: single-member lanes never hold their PR", () => {
|
|
|
63
64
|
});
|
|
64
65
|
});
|
|
65
66
|
|
|
66
|
-
|
|
67
|
+
test("planLane: tasks without PR keys do not block PR-backed members", () => {
|
|
67
68
|
assertEquals(planLane(["scaffold", "gap-2", "gap-8"], taskToPr, new Set()), {
|
|
68
69
|
headTaskId: "gap-2",
|
|
69
70
|
headPrKey: "o/r#2",
|
|
@@ -72,7 +73,7 @@ Deno.test("planLane: tasks without PR keys do not block PR-backed members", () =
|
|
|
72
73
|
});
|
|
73
74
|
});
|
|
74
75
|
|
|
75
|
-
|
|
76
|
+
test("planPrLane: held PRs point at the current lane head", () => {
|
|
76
77
|
assertEquals(
|
|
77
78
|
planPrLane([["gap-2", "gap-8"], ["gap-10"]], taskToPr, new Set(), "o/r#8"),
|
|
78
79
|
{
|
|
@@ -84,7 +85,7 @@ Deno.test("planPrLane: held PRs point at the current lane head", () => {
|
|
|
84
85
|
);
|
|
85
86
|
});
|
|
86
87
|
|
|
87
|
-
|
|
88
|
+
test("planPrLane: lane head and PRs outside exclusion lanes are not held", () => {
|
|
88
89
|
assertEquals(planPrLane([["gap-2", "gap-8"], ["gap-10"]], taskToPr, new Set(), "o/r#2").isHeld, false);
|
|
89
90
|
assertEquals(planPrLane([["gap-2", "gap-8"], ["gap-10"]], taskToPr, new Set(), "o/r#10").isHeld, false);
|
|
90
91
|
assertEquals(planPrLane([["gap-2", "gap-8"]], taskToPr, new Set(), "o/r#404").isHeld, false);
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
// passes `recordRound=false`, which must suppress the round insert while still opening the
|
|
7
7
|
// escalation. The agent-raised / max-rounds arms omit the flag (no prior round row) and must
|
|
8
8
|
// still record the round.
|
|
9
|
-
import {
|
|
9
|
+
import { test } from "node:test";
|
|
10
|
+
import { assert, assertEquals } from "#test-assert";
|
|
10
11
|
import handler from "../workers/persist-escalation/worker.ts";
|
|
11
12
|
|
|
12
13
|
function fakeApp() {
|
|
@@ -18,19 +19,15 @@ function fakeApp() {
|
|
|
18
19
|
table(name: string, _key: string) {
|
|
19
20
|
const store = (rows[name] ??= new Map());
|
|
20
21
|
return {
|
|
21
|
-
// deno-lint-ignore require-await
|
|
22
22
|
async get(key: string) {
|
|
23
23
|
return store.get(key);
|
|
24
24
|
},
|
|
25
|
-
// deno-lint-ignore require-await
|
|
26
25
|
async insert(row: unknown) {
|
|
27
26
|
(inserts[name] ??= []).push(row);
|
|
28
27
|
const pk = name === "escalations" ? "id" : name === "rounds" ? "id" : "pr_key";
|
|
29
|
-
// deno-lint-ignore no-explicit-any
|
|
30
28
|
store.set((row as any)[pk], row);
|
|
31
29
|
return name === "escalations" ? 42 : 1;
|
|
32
30
|
},
|
|
33
|
-
// deno-lint-ignore require-await
|
|
34
31
|
async update(key: string, patch: unknown) {
|
|
35
32
|
(updates[name] ??= []).push({ key, patch });
|
|
36
33
|
},
|
|
@@ -41,33 +38,29 @@ function fakeApp() {
|
|
|
41
38
|
return { app, inserts, updates, rows };
|
|
42
39
|
}
|
|
43
40
|
|
|
44
|
-
|
|
41
|
+
test("stalled arm (recordRound=false) does not insert a duplicate rounds row", async () => {
|
|
45
42
|
const { app, inserts } = fakeApp();
|
|
46
43
|
const job = {
|
|
47
44
|
variables: { prKey: "o/r#1", round: 3, status: "blocked", question: "stalled", recordRound: false },
|
|
48
45
|
};
|
|
49
|
-
// deno-lint-ignore no-explicit-any
|
|
50
46
|
const out = await handler(job as any, app as any);
|
|
51
47
|
assertEquals(inserts.rounds.length, 0, "no round row when the round was already recorded");
|
|
52
48
|
assertEquals(inserts.escalations.length, 1, "escalation is still opened");
|
|
53
|
-
// deno-lint-ignore no-explicit-any
|
|
54
49
|
assertEquals((out as any).escalationId, 42);
|
|
55
50
|
});
|
|
56
51
|
|
|
57
|
-
|
|
52
|
+
test("escalation arm without the flag still records the round", async () => {
|
|
58
53
|
const { app, inserts } = fakeApp();
|
|
59
54
|
const job = { variables: { prKey: "o/r#1", round: 3, status: "blocked", question: "max rounds" } };
|
|
60
|
-
// deno-lint-ignore no-explicit-any
|
|
61
55
|
await handler(job as any, app as any);
|
|
62
56
|
assertEquals(inserts.rounds.length, 1);
|
|
63
|
-
// deno-lint-ignore no-explicit-any
|
|
64
57
|
assertEquals((inserts.rounds[0] as any).round_no, 3);
|
|
65
58
|
});
|
|
66
59
|
|
|
67
60
|
// When the convergence-loop passes repo/prNumber and the FK parent is missing (engine/app.db
|
|
68
61
|
// desync), persist-escalation reconstructs the `pull_requests` row before the rounds/escalations
|
|
69
62
|
// inserts so opening an escalation never dies with an opaque FOREIGN KEY constraint failure.
|
|
70
|
-
|
|
63
|
+
test("persist-escalation heals a missing pull_requests parent before recording", async () => {
|
|
71
64
|
const { app, inserts, updates } = fakeApp();
|
|
72
65
|
const job = {
|
|
73
66
|
variables: {
|
|
@@ -79,32 +72,26 @@ Deno.test("persist-escalation heals a missing pull_requests parent before record
|
|
|
79
72
|
prNumber: 8,
|
|
80
73
|
},
|
|
81
74
|
};
|
|
82
|
-
// deno-lint-ignore no-explicit-any
|
|
83
75
|
await handler(job as any, app as any);
|
|
84
76
|
assertEquals(inserts.pull_requests?.length, 1, "the missing parent is reconstructed");
|
|
85
77
|
// Assert against the reconstruction insert payload (ensurePr) rather than the stored row: the
|
|
86
78
|
// fake update() doesn't apply patches, so the row would otherwise still read the insert's
|
|
87
79
|
// "converging" status and mask the worker's real final state.
|
|
88
|
-
// deno-lint-ignore no-explicit-any
|
|
89
80
|
const healed = inserts.pull_requests![0] as any;
|
|
90
81
|
assertEquals(healed.status, "converging", "the healed parent starts in the converging aggregate");
|
|
91
82
|
assertEquals(inserts.rounds.length, 1, "the round is still recorded");
|
|
92
83
|
assertEquals(inserts.escalations.length, 1, "the escalation is still opened");
|
|
93
84
|
// And the worker still moves the (now-present) PR to escalated as its final state.
|
|
94
85
|
assertEquals(updates.pull_requests!.length, 1, "the PR is updated once after the heal");
|
|
95
|
-
// deno-lint-ignore no-explicit-any
|
|
96
86
|
assertEquals((updates.pull_requests![0] as any).patch.status, "escalated");
|
|
97
87
|
});
|
|
98
88
|
|
|
99
89
|
|
|
100
|
-
|
|
90
|
+
test("a padded question is persisted trimmed (no whitespace drift)", async () => {
|
|
101
91
|
const { app, inserts, updates } = fakeApp();
|
|
102
92
|
const job = { variables: { prKey: "o/r#1", round: 4, status: "needs_input", question: " needs a decision " } };
|
|
103
|
-
// deno-lint-ignore no-explicit-any
|
|
104
93
|
await handler(job as any, app as any);
|
|
105
|
-
// deno-lint-ignore no-explicit-any
|
|
106
94
|
assertEquals((inserts.escalations[0] as any).question, "needs a decision", "escalation stores the trimmed question");
|
|
107
|
-
// deno-lint-ignore no-explicit-any
|
|
108
95
|
assertEquals((updates.pull_requests![0] as any).patch.open_escalation_question, "needs a decision", "denormalised question is trimmed too");
|
|
109
96
|
});
|
|
110
97
|
|
|
@@ -113,7 +100,7 @@ Deno.test("a padded question is persisted trimmed (no whitespace drift)", async
|
|
|
113
100
|
// on Magikcraft/nano-bpm #597/#599) must NOT throw (which parked an un-remediable JobNoRetries
|
|
114
101
|
// incident). It now opens an *answerable* escalation with a fabricated, concrete question and the
|
|
115
102
|
// agent's transcript attached, so a human can unblock the loop entirely from the UI.
|
|
116
|
-
|
|
103
|
+
test("blank question fabricates an answerable escalation (no throw, no incident)", async () => {
|
|
117
104
|
for (const question of [undefined, "", " "]) {
|
|
118
105
|
const { app, inserts, updates } = fakeApp();
|
|
119
106
|
const job = {
|
|
@@ -124,12 +111,9 @@ Deno.test("blank question fabricates an answerable escalation (no throw, no inci
|
|
|
124
111
|
"io.nanobpm.agentResult": { output: "the agent's prose review, no result file" },
|
|
125
112
|
},
|
|
126
113
|
};
|
|
127
|
-
// deno-lint-ignore no-explicit-any
|
|
128
114
|
const out = await handler(job as any, app as any);
|
|
129
|
-
// deno-lint-ignore no-explicit-any
|
|
130
115
|
assertEquals((out as any).escalationId, 42, "an escalation is opened, not refused");
|
|
131
116
|
assertEquals(inserts.escalations.length, 1, "escalation row written");
|
|
132
|
-
// deno-lint-ignore no-explicit-any
|
|
133
117
|
const esc = inserts.escalations[0] as any;
|
|
134
118
|
assert(esc.question.trim().length > 0, "fabricated question is concrete/non-blank");
|
|
135
119
|
assert(
|
|
@@ -139,7 +123,6 @@ Deno.test("blank question fabricates an answerable escalation (no throw, no inci
|
|
|
139
123
|
assertEquals(esc.transcript, "the agent's prose review, no result file", "transcript attached");
|
|
140
124
|
// Default status for an unclassified escalation is a question needing input.
|
|
141
125
|
assertEquals(esc.kind, "question");
|
|
142
|
-
// deno-lint-ignore no-explicit-any
|
|
143
126
|
const pr = updates.pull_requests![0] as any;
|
|
144
127
|
assertEquals(pr.patch.open_escalation_question, esc.question, "denormalised question set");
|
|
145
128
|
}
|
|
@@ -147,12 +130,10 @@ Deno.test("blank question fabricates an answerable escalation (no throw, no inci
|
|
|
147
130
|
|
|
148
131
|
// When a non-empty-but-unclassified status arrives with no question, the fabricated question
|
|
149
132
|
// names the status so the human sees what the agent reported.
|
|
150
|
-
|
|
133
|
+
test("unclassified status without a question names the status in the fabricated question", async () => {
|
|
151
134
|
const { app, inserts } = fakeApp();
|
|
152
135
|
const job = { variables: { prKey: "o/r#1", round: 3, status: "in_progress" } };
|
|
153
|
-
// deno-lint-ignore no-explicit-any
|
|
154
136
|
await handler(job as any, app as any);
|
|
155
|
-
// deno-lint-ignore no-explicit-any
|
|
156
137
|
const esc = inserts.escalations[0] as any;
|
|
157
138
|
assert(esc.question.includes("in_progress"), "fabricated question references the raw status");
|
|
158
139
|
assertEquals(esc.kind, "blocker", "a non needs_input status is a blocker escalation");
|
|
@@ -160,7 +141,7 @@ Deno.test("unclassified status without a question names the status in the fabric
|
|
|
160
141
|
|
|
161
142
|
// When repo/prNumber process variables are absent the heal still runs by parsing the canonical
|
|
162
143
|
// `owner/repo#N` prKey, so the escalation's FK parent is never left unguarded.
|
|
163
|
-
|
|
144
|
+
test("persist-escalation heals from the prKey when repo/prNumber are absent", async () => {
|
|
164
145
|
const { app, inserts } = fakeApp();
|
|
165
146
|
const job = {
|
|
166
147
|
variables: {
|
|
@@ -171,10 +152,8 @@ Deno.test("persist-escalation heals from the prKey when repo/prNumber are absent
|
|
|
171
152
|
abandonUrl: "https://host/hooks/abandon?token=TOK-en_123",
|
|
172
153
|
},
|
|
173
154
|
};
|
|
174
|
-
// deno-lint-ignore no-explicit-any
|
|
175
155
|
await handler(job as any, app as any);
|
|
176
156
|
assertEquals(inserts.pull_requests?.length, 1, "the parent is reconstructed from the prKey");
|
|
177
|
-
// deno-lint-ignore no-explicit-any
|
|
178
157
|
const healed = inserts.pull_requests![0] as any;
|
|
179
158
|
assertEquals(healed.repo, "o/r");
|
|
180
159
|
assertEquals(healed.number, 12);
|