@nanobpm/nano-workforce 0.106.3 → 0.107.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.
- package/CHANGELOG.md +14 -0
- package/app/mergeOutOfBandTerminal.test.ts +178 -0
- package/app/service.ts +111 -43
- package/package.json +2 -1
- package/test/derivation-parity/README.md +123 -0
- package/test/derivation-parity/derivation-parity.test.ts +177 -0
- package/test/derivation-parity/flows.ts +149 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
## [0.107.1](https://github.com/nanobpm/nano-workforce/compare/v0.107.0...v0.107.1) (2026-08-20)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **merge-loop:** converge PRs merged/closed out-of-band at every merge-stage wait ([#370](https://github.com/nanobpm/nano-workforce/issues/370)) ([c673c82](https://github.com/nanobpm/nano-workforce/commit/c673c823b6ed5dde5710c4cb677f7c6f01ae59de)), closes [nanobpm/nano-workforce#368](https://github.com/nanobpm/nano-workforce/issues/368)
|
|
7
|
+
|
|
8
|
+
# [0.107.0](https://github.com/nanobpm/nano-workforce/compare/v0.106.3...v0.107.0) (2026-08-20)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* port nwf models to defineFlow (S5, [#320](https://github.com/nanobpm/nano-workforce/issues/320)) ([#353](https://github.com/nanobpm/nano-workforce/issues/353)) ([c0b2d9d](https://github.com/nanobpm/nano-workforce/commit/c0b2d9ddea4f5c56159f011ca70203aa3f3c91fe)), closes [nanobpm/nano-ide#314](https://github.com/nanobpm/nano-ide/issues/314) [355/#356](https://github.com/nanobpm/nano-workforce/issues/356) [nano-ide#405](https://github.com/nano-ide/issues/405)
|
|
14
|
+
|
|
1
15
|
## [0.106.3](https://github.com/nanobpm/nano-workforce/compare/v0.106.2...v0.106.3) (2026-08-20)
|
|
2
16
|
|
|
3
17
|
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Class regression guard for the out-of-band terminal escape shared by EVERY merge-stage durable
|
|
2
|
+
// wait (issue #368).
|
|
3
|
+
//
|
|
4
|
+
// #368: a PR merged (or closed) OUT-OF-BAND — a maintainer clicks Merge, or a mergify queue lands
|
|
5
|
+
// it — while its merge-loop instance is parked at a durable GitHub wait can silently wedge forever.
|
|
6
|
+
// The `waiting_deps` branch of `pollMerges` only advanced a PR when its *declared dependencies*
|
|
7
|
+
// merged and had NO check on the PR itself already being merged: if those deps never cleared, the
|
|
8
|
+
// instance sat at `wait-deps` forever (ACTIVE, no incident, no timer boundary). `waiting_merge`
|
|
9
|
+
// already guarded this; the fix lifts that guard into ONE shared pre-check
|
|
10
|
+
// (`advanceIfTerminalOutOfBand`) run at the top of all four merge-stage waits — `waiting_deps`,
|
|
11
|
+
// `waiting_merge`, `waiting_lane`, `queued` — so no stage can strand on an out-of-band terminal
|
|
12
|
+
// transition. Each wait subscribes to a DIFFERENT catch, so the pre-check must publish the escape
|
|
13
|
+
// message THAT wait correlates to; this test asserts the class over every (status × merged/closed).
|
|
14
|
+
import { test } from "node:test";
|
|
15
|
+
import { assertEquals } from "#test-assert";
|
|
16
|
+
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
17
|
+
import { pollMerges } from "./service.ts";
|
|
18
|
+
|
|
19
|
+
// In-memory record gateway (get/find/insert/update/delete), matching app/promotionPoll.test.ts.
|
|
20
|
+
function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
21
|
+
const stores: Record<string, any[]> = {};
|
|
22
|
+
function tbl(name: string, pk = "id") {
|
|
23
|
+
const rows = (stores[name] ??= [] as any[]);
|
|
24
|
+
const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
|
|
25
|
+
return {
|
|
26
|
+
async all() {
|
|
27
|
+
return rows.slice();
|
|
28
|
+
},
|
|
29
|
+
async get(id: any) {
|
|
30
|
+
return rows.find((r) => r[pk] === id);
|
|
31
|
+
},
|
|
32
|
+
async find(where: any = {}) {
|
|
33
|
+
return rows.filter((r) => match(r, where));
|
|
34
|
+
},
|
|
35
|
+
async insert(row: any) {
|
|
36
|
+
rows.push({ ...row });
|
|
37
|
+
return row[pk];
|
|
38
|
+
},
|
|
39
|
+
async update(id: any, patch: any) {
|
|
40
|
+
const r = rows.find((row) => row[pk] === id);
|
|
41
|
+
if (r) Object.assign(r, patch);
|
|
42
|
+
},
|
|
43
|
+
async delete(id: any) {
|
|
44
|
+
for (let i = rows.length - 1; i >= 0; i--) if (rows[i][pk] === id) rows.splice(i, 1);
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
|
|
49
|
+
return { data, stores };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Records every message the poller publishes so a test can assert the exact escape.
|
|
53
|
+
function recordingEngine(): { engine: EngineClient; messages: any[] } {
|
|
54
|
+
const messages: any[] = [];
|
|
55
|
+
const engine = {
|
|
56
|
+
async publishMessage(msg: any) {
|
|
57
|
+
messages.push(msg);
|
|
58
|
+
},
|
|
59
|
+
} as any as EngineClient;
|
|
60
|
+
return { engine, messages };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// A token-transport GitHub stub. Serves `GET /repos/{repo}/pulls/{n}` from a per-number liveness
|
|
64
|
+
// map so `fetchPrState` (→ `classifyPrLiveness`) reads "merged" / "closed" / "open".
|
|
65
|
+
type Live = "merged" | "closed" | "open";
|
|
66
|
+
function githubFetch(states: Map<number, Live>) {
|
|
67
|
+
return (url: string | URL | Request, _init?: RequestInit): Promise<Response> => {
|
|
68
|
+
const u = new URL(String(url));
|
|
69
|
+
const json = (obj: unknown, status = 200) =>
|
|
70
|
+
Promise.resolve(new Response(JSON.stringify(obj), { status, headers: { "content-type": "application/json" } }));
|
|
71
|
+
const m = u.pathname.match(/\/repos\/[^/]+\/[^/]+\/pulls\/(\d+)$/);
|
|
72
|
+
if (m) {
|
|
73
|
+
const n = Number(m[1]);
|
|
74
|
+
const live = states.get(n) ?? "open";
|
|
75
|
+
return json({
|
|
76
|
+
merged: live === "merged",
|
|
77
|
+
merged_at: live === "merged" ? "2026-08-20T02:35:42Z" : null,
|
|
78
|
+
state: live === "open" ? "open" : "closed",
|
|
79
|
+
mergeable_state: live === "open" ? "clean" : "unknown",
|
|
80
|
+
draft: false,
|
|
81
|
+
head: { sha: "deadbeef" },
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return Promise.resolve(new Response(`unexpected ${u.pathname}`, { status: 500 }));
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function withGithub<T>(states: Map<number, Live>, fn: () => Promise<T>): Promise<T> {
|
|
89
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
90
|
+
const prevFetch = globalThis.fetch;
|
|
91
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
92
|
+
globalThis.fetch = githubFetch(states) as typeof fetch;
|
|
93
|
+
try {
|
|
94
|
+
return await fn();
|
|
95
|
+
} finally {
|
|
96
|
+
globalThis.fetch = prevFetch;
|
|
97
|
+
if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
98
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function prRow(prKey: string, number: number, status: string) {
|
|
103
|
+
const ts = "2026-08-20T00:00:00Z";
|
|
104
|
+
return {
|
|
105
|
+
pr_key: prKey,
|
|
106
|
+
repo: "o/r",
|
|
107
|
+
number,
|
|
108
|
+
url: `https://github.com/o/r/pull/${number}`,
|
|
109
|
+
title: "t",
|
|
110
|
+
status,
|
|
111
|
+
current_round: 0,
|
|
112
|
+
process_key: null,
|
|
113
|
+
waiting_since: null,
|
|
114
|
+
last_review_id: null,
|
|
115
|
+
outcome: null,
|
|
116
|
+
created_at: ts,
|
|
117
|
+
updated_at: ts,
|
|
118
|
+
converged_at: null,
|
|
119
|
+
merged_at: null,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Every merge-stage durable wait, with the escape message its parked catch subscribes to. The
|
|
124
|
+
// escape differs per wait because each subscribes to a different message — the whole point of the
|
|
125
|
+
// shared pre-check is to publish the RIGHT one so it correlates instead of being dropped.
|
|
126
|
+
const CASES: { status: string; merged: string; closed: string }[] = [
|
|
127
|
+
// wait-deps subscribes only `deps-cleared` (→ arm-merge → wait-mergeable, where block 2 converges).
|
|
128
|
+
{ status: "waiting_deps", merged: "deps-cleared", closed: "deps-cleared" },
|
|
129
|
+
// wait-mergeable subscribes `merge-ready` (→ gw-mergeable → attempt-merge terminal short-circuits).
|
|
130
|
+
{ status: "waiting_merge", merged: "merge-ready", closed: "merge-ready" },
|
|
131
|
+
// waiting_lane is an app hold that leaves the process on wait-mergeable → also `merge-ready`.
|
|
132
|
+
{ status: "waiting_lane", merged: "merge-ready", closed: "merge-ready" },
|
|
133
|
+
// wait-landed subscribes `merge-landed` (→ mark-merged) and `merge-evicted` (→ arm-merge). A
|
|
134
|
+
// merged queue PR lands; a closed one can never land, so re-arm and let block 2 abandon it.
|
|
135
|
+
{ status: "queued", merged: "merge-landed", closed: "merge-evicted" },
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
for (const c of CASES) {
|
|
139
|
+
for (const live of ["merged", "closed"] as const) {
|
|
140
|
+
test(`out-of-band ${live} PR at ${c.status} converges via ${live === "merged" ? c.merged : c.closed}`, async () => {
|
|
141
|
+
const { data, stores } = memData();
|
|
142
|
+
const { engine, messages } = recordingEngine();
|
|
143
|
+
stores["pull_requests"] = [prRow("o/r#100", 100, c.status)];
|
|
144
|
+
// A declared dependency that has NOT merged — the exact condition that wedged `waiting_deps`:
|
|
145
|
+
// the deps loop would never clear, so ONLY the PR's own terminal state can converge it.
|
|
146
|
+
stores["pr_dependencies"] = [{ pr_key: "o/r#100", depends_on_key: "o/r#200", created_at: "t" }];
|
|
147
|
+
|
|
148
|
+
await withGithub(new Map<number, Live>([[100, live], [200, "open"]]), () =>
|
|
149
|
+
pollMerges(data, engine, "tok"),
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
assertEquals(messages.length, 1, `expected exactly one escape message for ${c.status}/${live}`);
|
|
153
|
+
const expected = live === "merged" ? c.merged : c.closed;
|
|
154
|
+
assertEquals(messages[0].name, expected);
|
|
155
|
+
assertEquals(messages[0].correlationKey, "o/r#100");
|
|
156
|
+
if (expected === "merge-ready") assertEquals(messages[0].variables.mergeState, "ready");
|
|
157
|
+
// Flipped onto the transient `merging` status so a slow pass can't double-signal.
|
|
158
|
+
assertEquals(stores["pull_requests"][0].status, "merging");
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Negative guard: a still-OPEN `waiting_deps` PR whose declared dep is unmerged must NOT be forced
|
|
164
|
+
// terminal by the pre-check — it stays parked (no escape published), proving the pre-check fires
|
|
165
|
+
// only on a real out-of-band terminal transition and never drops a live PR.
|
|
166
|
+
test("still-open waiting_deps PR with an unmerged dep publishes nothing and stays parked", async () => {
|
|
167
|
+
const { data, stores } = memData();
|
|
168
|
+
const { engine, messages } = recordingEngine();
|
|
169
|
+
stores["pull_requests"] = [prRow("o/r#100", 100, "waiting_deps")];
|
|
170
|
+
stores["pr_dependencies"] = [{ pr_key: "o/r#100", depends_on_key: "o/r#200", created_at: "t" }];
|
|
171
|
+
|
|
172
|
+
await withGithub(new Map<number, Live>([[100, "open"], [200, "open"]]), () =>
|
|
173
|
+
pollMerges(data, engine, "tok"),
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
assertEquals(messages.length, 0);
|
|
177
|
+
assertEquals(stores["pull_requests"][0].status, "waiting_deps");
|
|
178
|
+
});
|
package/app/service.ts
CHANGED
|
@@ -1014,6 +1014,87 @@ async function mirrorTaskStatusForPr(data: DataLayer, prKey: string, status: "op
|
|
|
1014
1014
|
}
|
|
1015
1015
|
}
|
|
1016
1016
|
|
|
1017
|
+
/** The escape message a merge-stage durable wait must publish when its PR has gone terminal
|
|
1018
|
+
* (merged/closed) OUT-OF-BAND — i.e. someone landed or closed it on GitHub while the process was
|
|
1019
|
+
* parked, so the wait's own declared trigger (deps clearing, a mergeable verdict, a lane release, a
|
|
1020
|
+
* queue landing) may never fire. Each wait subscribes to a DIFFERENT message, so the escape MUST be
|
|
1021
|
+
* the one its parked catch actually correlates to (publishing any other name is dropped by the
|
|
1022
|
+
* engine and re-wedges the PR in the transient `merging` status). All roads lead to the same proven
|
|
1023
|
+
* terminal path — `attempt-merge`'s idempotent already-merged / closed short-circuits (#368):
|
|
1024
|
+
* • waiting_deps → parked at `wait-deps`, subscribes ONLY `deps-cleared`. Deps are moot once the
|
|
1025
|
+
* PR itself landed, so clear them regardless of liveness; `deps-cleared` → `arm-merge` →
|
|
1026
|
+
* `wait-mergeable`, where block 2 (below) reads the terminal state and drives merged→mark-merged
|
|
1027
|
+
* / closed→abandon. This is the gap the incident hit — `wait-deps` had NO self-merged escape.
|
|
1028
|
+
* • waiting_merge / waiting_lane → both parked at `wait-mergeable` (waiting_lane is an app-internal
|
|
1029
|
+
* hold that leaves the process on `wait-mergeable`), which subscribes `merge-ready`. Route a
|
|
1030
|
+
* `ready` verdict through `gw-mergeable → attempt-merge`, whose short-circuits complete/abandon.
|
|
1031
|
+
* • queued → parked at `wait-landed`, subscribes `merge-landed` (→ mark-merged) and `merge-evicted`
|
|
1032
|
+
* (→ arm-merge). A merged queue PR lands (`merge-landed`); a closed-unmerged one can NEVER land,
|
|
1033
|
+
* so publishing `merge-landed` would falsely mark it merged — re-arm via `merge-evicted` instead
|
|
1034
|
+
* and let block 2 abandon it on the next pass. */
|
|
1035
|
+
function outOfBandEscapeMessage(
|
|
1036
|
+
status: string,
|
|
1037
|
+
liveness: "merged" | "closed",
|
|
1038
|
+
prKey: string,
|
|
1039
|
+
): Parameters<EngineClient["publishMessage"]>[0] {
|
|
1040
|
+
switch (status) {
|
|
1041
|
+
case "waiting_deps":
|
|
1042
|
+
return { name: "deps-cleared", correlationKey: prKey, variables: {} };
|
|
1043
|
+
case "waiting_merge":
|
|
1044
|
+
case "waiting_lane":
|
|
1045
|
+
return {
|
|
1046
|
+
name: "merge-ready",
|
|
1047
|
+
correlationKey: prKey,
|
|
1048
|
+
variables: { mergeState: "ready", failingChecks: 0, failingChecksList: "" },
|
|
1049
|
+
};
|
|
1050
|
+
case "queued":
|
|
1051
|
+
return liveness === "merged"
|
|
1052
|
+
? { name: "merge-landed", correlationKey: prKey, variables: {} }
|
|
1053
|
+
: { name: "merge-evicted", correlationKey: prKey, variables: {} };
|
|
1054
|
+
default:
|
|
1055
|
+
// Unreachable: only the four merge-stage durable waits call this. Fail loud rather than
|
|
1056
|
+
// mis-route a message the parked catch can't correlate (which would silently re-wedge the PR).
|
|
1057
|
+
throw new Error(`outOfBandEscapeMessage: unexpected merge-stage status ${JSON.stringify(status)}`);
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/** ONE shared out-of-band terminal pre-check for EVERY merge-stage durable wait (#368). A PR parked
|
|
1062
|
+
* at any durable GitHub wait can be merged or closed out-of-band; without a per-branch check on the
|
|
1063
|
+
* PR's OWN state, a wait that keys only off its declared trigger (e.g. `waiting_deps`' declared
|
|
1064
|
+
* deps) strands its instance forever — ACTIVE, no incident, no timer boundary. `waiting_merge`
|
|
1065
|
+
* already guarded this; centralising the check here closes the whole class so no merge stage can
|
|
1066
|
+
* silently wedge on an out-of-band terminal transition.
|
|
1067
|
+
*
|
|
1068
|
+
* Reads the PR's live state (reusing an already-fetched `st` when the caller has one, e.g. block 2)
|
|
1069
|
+
* and, if terminal, publishes the escape message its parked catch subscribes to via
|
|
1070
|
+
* {@link flipToMergingThenPublish} — flipping to the transient `merging` so a slow pass can't
|
|
1071
|
+
* double-signal, reverting on a failed publish. Returns `true` when it advanced the PR (the caller
|
|
1072
|
+
* must `continue`), `false` when the PR is still live / unreadable and the caller should run its
|
|
1073
|
+
* normal per-status logic. Conservative: an unreadable (`null`) or ambiguous (`unknown`/open) state
|
|
1074
|
+
* never resolves terminal, so a false negative only costs a retry while dropping a live PR is
|
|
1075
|
+
* impossible. */
|
|
1076
|
+
async function advanceIfTerminalOutOfBand(
|
|
1077
|
+
data: DataLayer,
|
|
1078
|
+
engine: EngineClient,
|
|
1079
|
+
pr: { repo: string; number: number | string; pr_key: string; status: string },
|
|
1080
|
+
token: string,
|
|
1081
|
+
st?: PrState | null,
|
|
1082
|
+
): Promise<boolean> {
|
|
1083
|
+
const state = st !== undefined ? st : await fetchPrState(pr.repo, pr.number, token);
|
|
1084
|
+
const liveness = classifyPrLiveness(state);
|
|
1085
|
+
if (liveness !== "merged" && liveness !== "closed") return false;
|
|
1086
|
+
const fromStatus = pr.status; // capture before flip: flipToMergingThenPublish mutates it to `merging`
|
|
1087
|
+
await flipToMergingThenPublish(
|
|
1088
|
+
data,
|
|
1089
|
+
engine,
|
|
1090
|
+
pr.pr_key,
|
|
1091
|
+
fromStatus,
|
|
1092
|
+
outOfBandEscapeMessage(fromStatus, liveness, pr.pr_key),
|
|
1093
|
+
);
|
|
1094
|
+
console.log(`[poller] out-of-band ${liveness} (${fromStatus}) -> ${pr.pr_key}`);
|
|
1095
|
+
return true;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1017
1098
|
/** Merge-stage poll pass (SPEC §11). Four durable waits, each keyed off the PR's `status`, are
|
|
1018
1099
|
* advanced by correlating a message — mirroring the review-ready pattern so the process owns
|
|
1019
1100
|
* the wait and this glue only signals when a GitHub condition is met:
|
|
@@ -1023,12 +1104,20 @@ async function mirrorTaskStatusForPr(data: DataLayer, prKey: string, status: "op
|
|
|
1023
1104
|
* • queued → the queued PR landed → `merge-landed`; or it conflicts (DIRTY) → `merge-evicted`
|
|
1024
1105
|
* On publish we flip status to the transient `merging` (which no branch scans) so a slow pass
|
|
1025
1106
|
* can't double-signal, exactly as `pollReviews` flips to `converging`; `flipToMergingThenPublish`
|
|
1026
|
-
* reverts the flip if the publish fails so a failed handoff can't wedge the PR.
|
|
1027
|
-
|
|
1107
|
+
* reverts the flip if the publish fails so a failed handoff can't wedge the PR.
|
|
1108
|
+
*
|
|
1109
|
+
* EVERY branch first runs {@link advanceIfTerminalOutOfBand} — one shared "is this PR already
|
|
1110
|
+
* terminal (merged/closed) out-of-band?" pre-check — so no merge stage can silently strand when a PR
|
|
1111
|
+
* is landed/closed outside the loop (the `waiting_deps` self-merged wedge, #368). */
|
|
1112
|
+
export async function pollMerges(data: DataLayer, engine: EngineClient, token: string) {
|
|
1028
1113
|
// 1) Dependencies merged?
|
|
1029
1114
|
for (const pr of await prs(data).find({ status: "waiting_deps" })) {
|
|
1030
1115
|
const prKey = pr.pr_key;
|
|
1031
1116
|
try {
|
|
1117
|
+
// Out-of-band terminal FIRST: a PR merged/closed outside the loop while parked at `wait-deps`
|
|
1118
|
+
// must converge even if its declared deps never clear (the #368 wedge). `wait-deps` subscribes
|
|
1119
|
+
// only `deps-cleared`, so the shared pre-check publishes exactly that.
|
|
1120
|
+
if (await advanceIfTerminalOutOfBand(data, engine, pr, token)) continue;
|
|
1032
1121
|
const depRows = await deps(data).find({ pr_key: prKey });
|
|
1033
1122
|
let allMerged = true;
|
|
1034
1123
|
for (const d of depRows) {
|
|
@@ -1055,38 +1144,12 @@ async function pollMerges(data: DataLayer, engine: EngineClient, token: string)
|
|
|
1055
1144
|
try {
|
|
1056
1145
|
const st = await fetchPrState(repo, number, token);
|
|
1057
1146
|
if (st === null) continue; // no transport → skip this PR (others may still advance)
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
// wedges forever in the transient `merging` status (which no poller branch re-scans).
|
|
1065
|
-
// Publish `merge-ready` with a `ready` verdict instead: it routes through `gw-mergeable` to
|
|
1066
|
-
// `attempt-merge`, whose idempotent already-merged check completes the loop (`mark-merged`).
|
|
1067
|
-
await flipToMergingThenPublish(data, engine, prKey, "waiting_merge", {
|
|
1068
|
-
name: "merge-ready",
|
|
1069
|
-
correlationKey: prKey,
|
|
1070
|
-
variables: { mergeState: "ready", failingChecks: 0, failingChecksList: "" },
|
|
1071
|
-
});
|
|
1072
|
-
console.log(`[poller] already merged -> ${prKey}`);
|
|
1073
|
-
continue;
|
|
1074
|
-
}
|
|
1075
|
-
if (liveness === "closed") {
|
|
1076
|
-
// Closed on GitHub WITHOUT merging (e.g. superseded by a newer PR — #350). The PR can never
|
|
1077
|
-
// land, so it must NOT be classified as blocked/conflict and escalated (that orphans the
|
|
1078
|
-
// process on a dead PR, #342). Route it through the same canonical `merge-ready` → `ready` →
|
|
1079
|
-
// `attempt-merge` path as the merged case; the merge worker's closed short-circuit records a
|
|
1080
|
-
// terminal `abandoned` audit row and drives the loop down its terminate/abandon end event.
|
|
1081
|
-
// One canonical abandon implementation lives in the worker — the poller only routes to it.
|
|
1082
|
-
await flipToMergingThenPublish(data, engine, prKey, "waiting_merge", {
|
|
1083
|
-
name: "merge-ready",
|
|
1084
|
-
correlationKey: prKey,
|
|
1085
|
-
variables: { mergeState: "ready", failingChecks: 0, failingChecksList: "" },
|
|
1086
|
-
});
|
|
1087
|
-
console.log(`[poller] closed without merging -> ${prKey}`);
|
|
1088
|
-
continue;
|
|
1089
|
-
}
|
|
1147
|
+
// Out-of-band terminal (merged/closed) → the shared pre-check publishes `merge-ready {ready}`,
|
|
1148
|
+
// routing through `gw-mergeable → attempt-merge` whose idempotent already-merged check completes
|
|
1149
|
+
// the loop (`mark-merged`) and whose closed short-circuit abandons a PR closed without merging
|
|
1150
|
+
// (#342/#350). Reuse the `st` we just read so we don't double-fetch. This is the proven terminal
|
|
1151
|
+
// path the whole class (#368) now shares.
|
|
1152
|
+
if (await advanceIfTerminalOutOfBand(data, engine, pr, token, st)) continue;
|
|
1090
1153
|
const verdict = classifyMergeability(st);
|
|
1091
1154
|
if (verdict === "waiting") {
|
|
1092
1155
|
// Frugal-CI remedy (#43): when the repo publishes a merge protocol that wants a fresh
|
|
@@ -1145,6 +1208,10 @@ async function pollMerges(data: DataLayer, engine: EngineClient, token: string)
|
|
|
1145
1208
|
for (const pr of await prs(data).find({ status: "waiting_lane" })) {
|
|
1146
1209
|
const prKey = pr.pr_key;
|
|
1147
1210
|
try {
|
|
1211
|
+
// Out-of-band terminal FIRST: a lane-held PR merged/closed outside the loop must converge even
|
|
1212
|
+
// if its lane predecessor never releases the hold. waiting_lane leaves the process parked at
|
|
1213
|
+
// `wait-mergeable`, so the shared pre-check publishes `merge-ready {ready}` (→ attempt-merge).
|
|
1214
|
+
if (await advanceIfTerminalOutOfBand(data, engine, pr, token)) continue;
|
|
1148
1215
|
const lane = await mergeLaneDecisionForPr(data, prKey);
|
|
1149
1216
|
if (lane?.isHeld) continue;
|
|
1150
1217
|
await prs(data).update(prKey, { status: "waiting_merge", updated_at: now() });
|
|
@@ -1172,15 +1239,16 @@ async function pollMerges(data: DataLayer, engine: EngineClient, token: string)
|
|
|
1172
1239
|
try {
|
|
1173
1240
|
const st = await fetchPrState(repo, number, token);
|
|
1174
1241
|
if (st === null) continue; // no transport → skip this PR (others may still advance)
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1242
|
+
// Out-of-band terminal FIRST (reusing `st`): a queued PR merged out-of-band lands
|
|
1243
|
+
// (`merge-landed` → mark-merged); one CLOSED out-of-band without merging can never land, so the
|
|
1244
|
+
// pre-check re-arms it (`merge-evicted` → arm-merge) and block 2 abandons it — a closed queued
|
|
1245
|
+
// PR would otherwise wedge, since `queuedVerdict` calls a non-DIRTY closed PR merely "waiting"
|
|
1246
|
+
// (#368). The DIRTY-while-open eviction below still handles a live-but-conflicted queue drop.
|
|
1247
|
+
if (await advanceIfTerminalOutOfBand(data, engine, pr, token, st)) continue;
|
|
1248
|
+
// Terminal states (merged/closed) are handled by the shared pre-check above; here the PR is
|
|
1249
|
+
// still open, so the only remaining reason to leave `wait-landed` is a live queue DROP — a real
|
|
1250
|
+
// merge CONFLICT (`DIRTY`). `queuedVerdict` stays the canonical classifier for that.
|
|
1251
|
+
if (queuedVerdict(st) === "evicted") {
|
|
1184
1252
|
await flipToMergingThenPublish(data, engine, prKey, "queued", {
|
|
1185
1253
|
name: "merge-evicted",
|
|
1186
1254
|
correlationKey: prKey,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.107.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",
|
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
"devDependencies": {
|
|
63
63
|
"@biomejs/biome": "^2.4.11",
|
|
64
64
|
"@nanobpm/urban-testkit": "^0.5.0",
|
|
65
|
+
"@nanobpm/workflow": "^0.12.0",
|
|
65
66
|
"@semantic-release/changelog": "^6.0.3",
|
|
66
67
|
"@semantic-release/git": "^10.0.1",
|
|
67
68
|
"@semantic-release/npm": "^13.1.5",
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# Derivation-parity suite (`defineFlow` ports of the nwf goldens)
|
|
2
|
+
|
|
3
|
+
Epic **nanobpm/nano-ide#314**, slice **S5 / #320**: port the seven hand-authored
|
|
4
|
+
nano-workforce BPMN goldens in `resources/processes/*.bpmn` to the code-first
|
|
5
|
+
`@nanobpm/workflow` `defineFlow` surface, and prove each derived model is
|
|
6
|
+
**structurally equal** to its golden with the S0 parity harness
|
|
7
|
+
(`@nanobpm/workflow/test-support` — `normalize` / `assertDerivationParity`).
|
|
8
|
+
|
|
9
|
+
- `flows.ts` — the code-first ports (one `defineFlow` per model) plus a `PORTS`
|
|
10
|
+
registry that pairs each model with its port or its documented blocker.
|
|
11
|
+
- `derivation-parity.test.ts` — runs `assertDerivationParity` for every ported
|
|
12
|
+
model, reports blocked models as skipped with their reason, and proves the
|
|
13
|
+
blocker against the goldens themselves.
|
|
14
|
+
|
|
15
|
+
Run it with the repo suite (`npm test`) or directly:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
node --experimental-strip-types --test test/derivation-parity/derivation-parity.test.ts
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Status
|
|
22
|
+
|
|
23
|
+
Per the human decision on the **#320 escalation** — _(c)+(a): land the
|
|
24
|
+
structurally-derivable goldens now at full whole-model parity, park the rest
|
|
25
|
+
behind an upstream construct, and do **not** relax to node-surface parity_:
|
|
26
|
+
|
|
27
|
+
| Model | Top-level start/end | Status |
|
|
28
|
+
| ------------------ | ------------------- | ------ |
|
|
29
|
+
| `retro` | 1 / 1 | ⛔ parked — class 3 (general service-task ioMapping) |
|
|
30
|
+
| `convergence-loop` | 1 / 1 | ⛔ parked — class 2 (arbitrary graph) |
|
|
31
|
+
| `spine-demo` | 1 / 2 | ⛔ parked — class 1 (multi start/end) |
|
|
32
|
+
| `readiness-gate` | 1 / 5 | ⛔ parked — class 1 (multi start/end) |
|
|
33
|
+
| `feature` | 2 / 2 | ⛔ parked — class 1 (multi start/end) |
|
|
34
|
+
| `merge-loop` | 1 / 2 | ⛔ parked — class 1 (multi start/end) |
|
|
35
|
+
| `plan-fanout` | 3 / 3 | ⛔ parked — class 1 (multi start/end) |
|
|
36
|
+
|
|
37
|
+
`retro` was a green full-parity port — a linear single-start/single-end agent
|
|
38
|
+
pipeline — until the conformance work (#355/#356) added a conformance-escalation
|
|
39
|
+
subgraph to its golden, which introduced a service task carrying a general
|
|
40
|
+
`<zeebe:ioMapping>` the stock `task` builder cannot emit (class 3). All seven
|
|
41
|
+
goldens are now parked, across **three** distinct blocker classes, each awaiting
|
|
42
|
+
an upstream `@nanobpm/workflow` (nano-ide) construct + re-release (never a golden
|
|
43
|
+
edit, never relaxed acceptance).
|
|
44
|
+
|
|
45
|
+
## The blockers
|
|
46
|
+
|
|
47
|
+
### Class 1 — multiple top-level start/end events (5 models)
|
|
48
|
+
|
|
49
|
+
The published builder surface **`@nanobpm/workflow@0.12.0`** derives **exactly
|
|
50
|
+
one** top-level `<bpmn:startEvent id="Start">` and **exactly one**
|
|
51
|
+
`<bpmn:endEvent id="End">`, converging every top-level dangling branch into that
|
|
52
|
+
single end (`Compiler.compile` in the package's `declarative.ts`). There is no
|
|
53
|
+
terminal / explicit-end construct and no way to author multiple top-level start
|
|
54
|
+
events.
|
|
55
|
+
|
|
56
|
+
Five goldens have **multiple** top-level start and/or end events, so their
|
|
57
|
+
derived model can never be structurally equal under `normalize` (which
|
|
58
|
+
distinguishes `N` end events each with `in=1` from one end event with `in=N`).
|
|
59
|
+
The suite's `class-1 blocked goldens genuinely have multiple top-level start/end
|
|
60
|
+
events` diagnostic pins this against the goldens themselves.
|
|
61
|
+
|
|
62
|
+
### Class 2 — arbitrary control-flow graph (`convergence-loop`)
|
|
63
|
+
|
|
64
|
+
`convergence-loop` has a single start/end (it clears class 1) but its topology is
|
|
65
|
+
**not expressible** with `@nanobpm/workflow@0.12.0`'s structured-only builder
|
|
66
|
+
(`loop` / `switch` / `branch`). Single start/end is _necessary but not
|
|
67
|
+
sufficient_. Three golden features have no structured-builder derivation, each
|
|
68
|
+
pinned by a diagnostic in `derivation-parity.test.ts`:
|
|
69
|
+
|
|
70
|
+
1. **Task-level back-edge merge.** The loop head `review-round` is a
|
|
71
|
+
`serviceTask` that merges **three** back-edges directly (`in=3`). But `loop()`
|
|
72
|
+
always inserts an exclusive-gateway loop head that absorbs the back-edge, so
|
|
73
|
+
the body task stays `in=1` — empirically demonstrated by the `loop() inserts a
|
|
74
|
+
gateway head` test.
|
|
75
|
+
2. **Heterogeneous multi-way gateway.** `gw-status` is a single exclusive gateway
|
|
76
|
+
with **four** heterogeneous-condition out-edges (two `=x = "v"` equalities, one
|
|
77
|
+
complex boolean, one default). No `switch` (equalities + default) or `branch`
|
|
78
|
+
(one condition + default) emits that.
|
|
79
|
+
3. **Shared merge+split gateway.** `gw-escalated` is a single exclusive gateway
|
|
80
|
+
that is at once a **five-way merge and a two-way split**, reached by back-edges
|
|
81
|
+
from five distinct points.
|
|
82
|
+
|
|
83
|
+
The fix is an **arbitrary-graph / explicit-join (named-target)** builder upstream
|
|
84
|
+
in `@nanobpm/workflow` — a **superset** of the class-1 gap.
|
|
85
|
+
|
|
86
|
+
### Class 3 — general service-task ioMapping (`retro`)
|
|
87
|
+
|
|
88
|
+
`retro` clears classes 1 and 2 (single start/end, structured topology), but its
|
|
89
|
+
golden's `record-conformance-ack` service task carries a **general**
|
|
90
|
+
`<zeebe:ioMapping>` — inputs `=planKey`→`planKey` and
|
|
91
|
+
`=if (is defined(note)) then note else null`→`note`. `@nanobpm/workflow@0.12.0`'s
|
|
92
|
+
`task` builder only emits an ioMapping as a side effect of a `prompt.append` (a
|
|
93
|
+
single `appendPrompt` input); there is no way to declare arbitrary input/output
|
|
94
|
+
mappings on a service task. Every other element of the golden IS expressible
|
|
95
|
+
(`w.task`+prompt, `w.branch` for the `deviations?` gateway, `w.human` for the
|
|
96
|
+
`conformance-escalation` userTask, envelopes) — this one service-task ioMapping
|
|
97
|
+
is the sole gap. The `retro golden needs a general service-task ioMapping the
|
|
98
|
+
stock builder cannot emit` diagnostic pins both halves (the golden needs it; the
|
|
99
|
+
stock builder cannot produce it).
|
|
100
|
+
|
|
101
|
+
The fix is a general `io: { input?, output? }` on the external `task`/`run`
|
|
102
|
+
builder upstream in `@nanobpm/workflow` (**nano-ide#405**).
|
|
103
|
+
|
|
104
|
+
## Resuming this slice
|
|
105
|
+
|
|
106
|
+
The follow-up upstream slices (opened in **nanobpm/nano-ide** per decision path
|
|
107
|
+
(a)) must add:
|
|
108
|
+
|
|
109
|
+
- a terminal / explicit-end (+ multi-start) construct (unblocks class 1), **and**
|
|
110
|
+
- an arbitrary-graph / explicit-join (named-target) builder (unblocks class 2 —
|
|
111
|
+
`convergence-loop`), **and**
|
|
112
|
+
- a general service-task `io` mapping (unblocks class 3 — `retro`; nano-ide#405).
|
|
113
|
+
|
|
114
|
+
Then, on a resumed run here:
|
|
115
|
+
|
|
116
|
+
1. Bump the `@nanobpm/workflow` dependency to the release that carries the
|
|
117
|
+
construct(s).
|
|
118
|
+
2. In `flows.ts`, replace each parked entry's `blockedReason` with a real
|
|
119
|
+
`flow: defineFlow(...)` port (author each model against exactly the features
|
|
120
|
+
it uses — see the per-model feature map in the #320 brief).
|
|
121
|
+
|
|
122
|
+
No golden `.bpmn` file may be edited to force a match — the derivation must
|
|
123
|
+
reproduce the checked-in golden.
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Derivation-parity suite for epic nanobpm/nano-ide#314 (S5, sub-issue #320).
|
|
2
|
+
//
|
|
3
|
+
// For every nano-workforce golden in `resources/processes/*.bpmn`, assert that
|
|
4
|
+
// its code-first `defineFlow` port (see `./flows.ts`) derives a BPMN model that
|
|
5
|
+
// is STRUCTURALLY EQUAL to the checked-in golden, using the S0 parity harness
|
|
6
|
+
// (`@nanobpm/workflow/test-support`). The harness normalizes both models (strips
|
|
7
|
+
// DI, canonicalizes ids/ordering) and diffs their semantic structure — nodes,
|
|
8
|
+
// sequence flows, message subscriptions, timer/boundary definitions, user tasks,
|
|
9
|
+
// and linked resources — with a legible red/green diff on mismatch.
|
|
10
|
+
//
|
|
11
|
+
// Ported models run a real `assertDerivationParity`; parked models (see
|
|
12
|
+
// `./flows.ts`) are reported as skipped WITH their precise reason, in two
|
|
13
|
+
// blocker classes — class 1: multiple top-level start/end events; class 2:
|
|
14
|
+
// arbitrary control-flow graph (`convergence-loop`). Companion diagnostics prove
|
|
15
|
+
// each blocker is real against the goldens themselves. No golden is modified to
|
|
16
|
+
// force a match — the derivation must reproduce the checked-in file.
|
|
17
|
+
|
|
18
|
+
import { test } from "node:test";
|
|
19
|
+
import { readFileSync } from "node:fs";
|
|
20
|
+
import { assert, assertEquals } from "#test-assert";
|
|
21
|
+
import { assertDerivationParity, normalize } from "@nanobpm/workflow/test-support";
|
|
22
|
+
import { declarativeToBpmn, defineFlow } from "@nanobpm/workflow";
|
|
23
|
+
import { PORTS } from "./flows.ts";
|
|
24
|
+
|
|
25
|
+
const ROOT = decodeURIComponent(new URL("../../", import.meta.url).pathname);
|
|
26
|
+
const goldenPath = (model: string): string => `${ROOT}resources/processes/${model}.bpmn`;
|
|
27
|
+
|
|
28
|
+
test("derivation parity — nano-workforce corpus", async (t) => {
|
|
29
|
+
for (const port of PORTS) {
|
|
30
|
+
const golden = goldenPath(port.model);
|
|
31
|
+
const flow = port.flow;
|
|
32
|
+
if (flow) {
|
|
33
|
+
await t.test(`${port.model} derives its golden`, () => {
|
|
34
|
+
assertDerivationParity(flow, golden);
|
|
35
|
+
});
|
|
36
|
+
} else {
|
|
37
|
+
await t.test(`${port.model} (pending port)`, { skip: port.blockedReason ?? "pending" }, () => {});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Every entry either derives its golden or documents why it cannot — so the
|
|
43
|
+
// corpus is fully accounted for and no model is silently dropped.
|
|
44
|
+
test("every corpus model is either ported or has a documented blocker", () => {
|
|
45
|
+
const expected = [
|
|
46
|
+
"retro",
|
|
47
|
+
"spine-demo",
|
|
48
|
+
"readiness-gate",
|
|
49
|
+
"feature",
|
|
50
|
+
"convergence-loop",
|
|
51
|
+
"merge-loop",
|
|
52
|
+
"plan-fanout",
|
|
53
|
+
];
|
|
54
|
+
assertEquals(
|
|
55
|
+
PORTS.map((p) => p.model),
|
|
56
|
+
expected,
|
|
57
|
+
"PORTS must cover all seven goldens in the epic's authoring order",
|
|
58
|
+
);
|
|
59
|
+
for (const port of PORTS) {
|
|
60
|
+
assert(
|
|
61
|
+
port.flow !== undefined || (port.blockedReason && port.blockedReason.length > 0),
|
|
62
|
+
`${port.model} must either be ported (flow) or carry a blockedReason`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// The blockers are not guesses — prove each against the goldens themselves.
|
|
68
|
+
//
|
|
69
|
+
// CLASS 1 — five goldens have MORE THAN ONE top-level start and/or end event,
|
|
70
|
+
// which the published `@nanobpm/workflow@0.12.0` compiler (a single
|
|
71
|
+
// `<startEvent id="Start">` + single `<endEvent id="End">`) cannot derive.
|
|
72
|
+
test("class-1 blocked goldens genuinely have multiple top-level start/end events", () => {
|
|
73
|
+
const countTag = (xml: string, tag: string): number =>
|
|
74
|
+
(xml.match(new RegExp(`<bpmn:${tag}\\b`, "g")) ?? []).length;
|
|
75
|
+
|
|
76
|
+
const multiStartEndBlocked = new Set(["spine-demo", "readiness-gate", "feature", "merge-loop", "plan-fanout"]);
|
|
77
|
+
for (const model of multiStartEndBlocked) {
|
|
78
|
+
const xml = readFileSync(goldenPath(model), "utf8");
|
|
79
|
+
const starts = countTag(xml, "startEvent");
|
|
80
|
+
const ends = countTag(xml, "endEvent");
|
|
81
|
+
assert(
|
|
82
|
+
starts > 1 || ends > 1,
|
|
83
|
+
`${model} is marked compiler-blocked but has ${starts} start(s)/${ends} end(s) — reclassify it`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The two single-start/single-end goldens (retro, convergence-loop) clear
|
|
88
|
+
// class 1; retro is class-3 blocked (a general service-task ioMapping),
|
|
89
|
+
// convergence-loop is class-2 blocked — both below.
|
|
90
|
+
for (const model of ["retro", "convergence-loop"]) {
|
|
91
|
+
const xml = readFileSync(goldenPath(model), "utf8");
|
|
92
|
+
assertEquals(countTag(xml, "startEvent"), 1, `${model} should have one start event`);
|
|
93
|
+
assertEquals(countTag(xml, "endEvent"), 1, `${model} should have one end event`);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// CLASS 3 — retro clears classes 1 & 2 (single start/end, structured topology)
|
|
98
|
+
// but its golden carries a service task with a GENERAL <zeebe:ioMapping> — inputs
|
|
99
|
+
// whose target is NOT `appendPrompt` — which @nanobpm/workflow@0.12.0's `task`
|
|
100
|
+
// builder cannot emit (it only produces an ioMapping via a `prompt.append`, i.e.
|
|
101
|
+
// a lone `appendPrompt` input). Prove both halves: the golden needs it, and the
|
|
102
|
+
// stock builder cannot produce it (awaits nano-ide#405).
|
|
103
|
+
test("retro golden needs a general service-task ioMapping the stock builder cannot emit", () => {
|
|
104
|
+
const xml = readFileSync(goldenPath("retro"), "utf8");
|
|
105
|
+
// (a) The golden has a service task carrying an ioMapping input to a non-prompt
|
|
106
|
+
// target (`record-conformance-ack`: =planKey→planKey, note→note).
|
|
107
|
+
assert(
|
|
108
|
+
/target="planKey"/.test(xml) && /target="note"/.test(xml),
|
|
109
|
+
"retro golden should carry general ioMapping inputs (planKey, note) on record-conformance-ack",
|
|
110
|
+
);
|
|
111
|
+
// (b) The stock `task` builder only ever emits `appendPrompt` as an ioMapping
|
|
112
|
+
// target — never a general input like `note` — so the golden is not
|
|
113
|
+
// derivable until the upstream `io` construct lands.
|
|
114
|
+
const probe = defineFlow("io-probe", (w) => {
|
|
115
|
+
w.task("agent", {
|
|
116
|
+
jobType: "senior:retro",
|
|
117
|
+
prompt: { resourceId: "retro.md", bindingType: "latest", append: "=retroDigest" },
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
const derived = declarativeToBpmn(probe);
|
|
121
|
+
assert(/target="appendPrompt"/.test(derived), "prompt.append should emit an appendPrompt ioMapping input");
|
|
122
|
+
assert(
|
|
123
|
+
!/target="note"/.test(derived) && !/target="planKey"/.test(derived),
|
|
124
|
+
"the stock task builder cannot emit a general (non-appendPrompt) ioMapping input",
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// CLASS 2 — convergence-loop has a single start/end (clears class 1) but an
|
|
129
|
+
// ARBITRARY control-flow graph the structured-only builder cannot emit. Prove
|
|
130
|
+
// the three specific features against the golden itself.
|
|
131
|
+
test("convergence-loop golden has arbitrary-graph features the structured builder cannot emit", () => {
|
|
132
|
+
const xml = readFileSync(goldenPath("convergence-loop"), "utf8");
|
|
133
|
+
const between = (id: string, closeTag: string, tag: string): number => {
|
|
134
|
+
// Count <bpmn:<tag>> occurrences inside the element `id`, whose end is its
|
|
135
|
+
// own </bpmn:<closeTag>> (not the first nested close tag).
|
|
136
|
+
const open = xml.indexOf(`id="${id}"`);
|
|
137
|
+
assert(open >= 0, `convergence-loop golden is missing element id="${id}"`);
|
|
138
|
+
const rest = xml.slice(open);
|
|
139
|
+
const close = rest.indexOf(`</bpmn:${closeTag}>`);
|
|
140
|
+
assert(close >= 0, `convergence-loop golden element id="${id}" is missing its closing </bpmn:${closeTag}>`);
|
|
141
|
+
const body = rest.slice(0, close);
|
|
142
|
+
return (body.match(new RegExp(`<bpmn:${tag}\\b`, "g")) ?? []).length;
|
|
143
|
+
};
|
|
144
|
+
// (a) the loop head is a serviceTask that MERGES three back-edges directly.
|
|
145
|
+
assertEquals(between("review-round", "serviceTask", "incoming"), 3, "review-round should merge 3 flows on the task itself");
|
|
146
|
+
// (b) a single exclusive gateway forks FOUR heterogeneous-condition out-edges.
|
|
147
|
+
assertEquals(between("gw-status", "exclusiveGateway", "outgoing"), 4, "gw-status should be a 4-way exclusive gateway");
|
|
148
|
+
// (c) a single exclusive gateway is at once a 5-way merge and a 2-way split.
|
|
149
|
+
assertEquals(between("gw-escalated", "exclusiveGateway", "incoming"), 5, "gw-escalated should merge 5 flows");
|
|
150
|
+
assertEquals(between("gw-escalated", "exclusiveGateway", "outgoing"), 2, "gw-escalated should also split 2 ways");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// CLASS 2, empirical — demonstrate WHY the structured builder cannot reproduce
|
|
154
|
+
// (a): a `loop()` whose body starts with a task derives an exclusive-gateway
|
|
155
|
+
// loop head that absorbs the back-edge (in>=2), leaving the task itself at
|
|
156
|
+
// in=1. The golden instead merges its back-edges directly into `review-round`
|
|
157
|
+
// (in=3) with no loop-head gateway — a shape the builder cannot express.
|
|
158
|
+
test("loop() inserts a gateway head, so back-edges cannot merge into a task", () => {
|
|
159
|
+
const probe = defineFlow("loop-head-probe", (w) => {
|
|
160
|
+
w.loop((b) => {
|
|
161
|
+
b.task("review-round", { jobType: "senior:pr-review" });
|
|
162
|
+
b.branch("done", { then: (g) => g.break() });
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
const model = normalize(declarativeToBpmn(probe));
|
|
166
|
+
const inDegree = (n: string): number => Number(/<in=(\d+)/.exec(n)?.[1] ?? "0");
|
|
167
|
+
const gateways = model.nodes.filter((n) => n.startsWith("exclusiveGateway"));
|
|
168
|
+
const tasks = model.nodes.filter((n) => n.startsWith("serviceTask"));
|
|
169
|
+
assert(
|
|
170
|
+
gateways.some((n) => inDegree(n) >= 2),
|
|
171
|
+
"loop() should derive an exclusive-gateway head that absorbs the back-edge (in>=2)",
|
|
172
|
+
);
|
|
173
|
+
assert(
|
|
174
|
+
tasks.every((n) => inDegree(n) <= 1),
|
|
175
|
+
"the loop-body task cannot itself be the back-edge merge (it stays in<=1)",
|
|
176
|
+
);
|
|
177
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Code-first (`defineFlow`) ports of the hand-authored nano-workforce BPMN
|
|
2
|
+
// goldens in `resources/processes/*.bpmn`, for epic nanobpm/nano-ide#314 (S5,
|
|
3
|
+
// sub-issue #320). Each port must derive a BPMN model that is STRUCTURALLY equal
|
|
4
|
+
// to its golden under the S0 derivation-parity harness
|
|
5
|
+
// (`@nanobpm/workflow/test-support` — `normalize` / `assertDerivationParity`),
|
|
6
|
+
// proving the code-first and model-first representations agree.
|
|
7
|
+
//
|
|
8
|
+
// STATUS (per the human decision on the nano-ide#320 escalation — (c)+(a): land
|
|
9
|
+
// the structurally-derivable goldens at full whole-model parity, park the rest
|
|
10
|
+
// pending an upstream construct, and do NOT relax to node-surface parity):
|
|
11
|
+
//
|
|
12
|
+
// • All seven goldens are currently `blockedReason`-parked, in THREE distinct
|
|
13
|
+
// classes, each awaiting an upstream `@nanobpm/workflow` (nano-ide) construct
|
|
14
|
+
// + re-release — never a golden edit and never relaxed acceptance:
|
|
15
|
+
//
|
|
16
|
+
// (1) MULTI top-level start/end (spine-demo, readiness-gate, feature,
|
|
17
|
+
// merge-loop, plan-fanout). `@nanobpm/workflow@0.12.0` derives EXACTLY
|
|
18
|
+
// ONE `<bpmn:startEvent id="Start">` + ONE `<bpmn:endEvent id="End">`,
|
|
19
|
+
// converging every dangler into that single end (see `Compiler.compile`
|
|
20
|
+
// in the package's `declarative.ts`). Needs a terminal/explicit-end
|
|
21
|
+
// (+ multi-start) construct.
|
|
22
|
+
//
|
|
23
|
+
// (2) ARBITRARY control-flow graph (convergence-loop). Single start/end —
|
|
24
|
+
// so it clears class (1) — but its topology is NOT expressible with the
|
|
25
|
+
// structured-only builder (`loop`/`switch`/`branch`), empirically proven
|
|
26
|
+
// (see `derivation-parity.test.ts`): its loop head `review-round` is a
|
|
27
|
+
// serviceTask that MERGES three back-edges directly (in=3), whereas
|
|
28
|
+
// `loop()` always inserts an exclusive-gateway loop head (the task stays
|
|
29
|
+
// in=1); `gw-status` is a single exclusive gateway with FOUR
|
|
30
|
+
// heterogeneous-condition out-edges (two `=x = "v"`, one complex boolean,
|
|
31
|
+
// one default) which no `switch`/`branch` emits; and `gw-escalated` is a
|
|
32
|
+
// single gateway that is simultaneously a five-way merge and a two-way
|
|
33
|
+
// split. Single start/end is necessary but NOT sufficient. Needs an
|
|
34
|
+
// arbitrary-graph / explicit-join (named-target) builder — a SUPERSET of
|
|
35
|
+
// the class-(1) gap.
|
|
36
|
+
//
|
|
37
|
+
// (3) GENERAL service-task ioMapping (retro). retro WAS a green full-parity
|
|
38
|
+
// port (a linear gather → synthesize → record agent pipeline) until the
|
|
39
|
+
// conformance work (nano-workforce #355/#356) added a conformance-
|
|
40
|
+
// escalation subgraph to its golden. Every new element ports with the
|
|
41
|
+
// stock builder (`w.branch` for the `deviations?` gateway, `w.human` for
|
|
42
|
+
// the `conformance-escalation` userTask, `w.task`+prompt/envelopes for
|
|
43
|
+
// the service tasks) EXCEPT `record-conformance-ack`: a service task with
|
|
44
|
+
// a general <zeebe:ioMapping> (inputs `=planKey`→planKey and
|
|
45
|
+
// `=if (is defined(note)) then note else null`→note). @nanobpm/workflow@
|
|
46
|
+
// 0.12.0's `task` builder only emits an ioMapping via a `prompt.append`
|
|
47
|
+
// (a single `appendPrompt` input), so this task is not derivable. Needs a
|
|
48
|
+
// general `io` on the task/run builder upstream (nano-ide#405).
|
|
49
|
+
//
|
|
50
|
+
// A resumed run flips any parked model to a real `flow` once the corresponding
|
|
51
|
+
// upstream construct lands and `@nanobpm/workflow` is bumped past 0.12.0.
|
|
52
|
+
|
|
53
|
+
import type { DeclarativeFlow } from "@nanobpm/workflow";
|
|
54
|
+
|
|
55
|
+
/** One model's port entry: the golden basename plus EITHER the derived flow
|
|
56
|
+
* (when it can be reproduced) OR the reason it is blocked — never both and never
|
|
57
|
+
* neither. Modelled as a discriminated union so a partial/contradictory entry
|
|
58
|
+
* (both `flow` and `blockedReason`, or neither) fails to compile. */
|
|
59
|
+
export type PortEntry =
|
|
60
|
+
| {
|
|
61
|
+
/** The golden model basename under `resources/processes/<model>.bpmn`. */
|
|
62
|
+
readonly model: string;
|
|
63
|
+
/** The derived `defineFlow` — a structurally-faithful port exists. */
|
|
64
|
+
readonly flow: DeclarativeFlow;
|
|
65
|
+
readonly blockedReason?: never;
|
|
66
|
+
}
|
|
67
|
+
| {
|
|
68
|
+
/** The golden model basename under `resources/processes/<model>.bpmn`. */
|
|
69
|
+
readonly model: string;
|
|
70
|
+
readonly flow?: never;
|
|
71
|
+
/** Why whole-model parity is not yet achievable — precisely. */
|
|
72
|
+
readonly blockedReason: string;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// ── retro (PARKED — class 3) ─────────────────────────────────────────────────
|
|
76
|
+
// retro WAS a green full-parity port — a linear gather → synthesize → record
|
|
77
|
+
// agent pipeline. The conformance work (nano-workforce #355/#356) then added a
|
|
78
|
+
// conformance-escalation subgraph to the golden: a `deviations?` exclusive
|
|
79
|
+
// gateway, a `conformance-escalation` userTask, and `senior:conformance` /
|
|
80
|
+
// `pr.conformance-record` / `pr.conformance-ack` service tasks. All of those ARE
|
|
81
|
+
// expressible with the stock builder (`w.branch`, `w.human`, `w.task`+prompt,
|
|
82
|
+
// envelopes) EXCEPT `record-conformance-ack`: it carries a general
|
|
83
|
+
// <zeebe:ioMapping> (inputs `=planKey`→planKey and
|
|
84
|
+
// `=if (is defined(note)) then note else null`→note), which
|
|
85
|
+
// @nanobpm/workflow@0.12.0's `task` builder cannot emit — it only produces an
|
|
86
|
+
// ioMapping via a `prompt.append` (a single `appendPrompt` input). So retro
|
|
87
|
+
// regresses to a parked model pending the upstream construct: a general `io` on
|
|
88
|
+
// the external `task`/`run` builder (nano-ide#405). It flips back to a green
|
|
89
|
+
// port once that lands and @nanobpm/workflow is bumped past 0.12.0.
|
|
90
|
+
|
|
91
|
+
const RETRO_IO_BLOCK =
|
|
92
|
+
"blocked (general service-task ioMapping): retro's golden gained a " +
|
|
93
|
+
"conformance-escalation subgraph whose `record-conformance-ack` service task " +
|
|
94
|
+
"carries a general <zeebe:ioMapping> (inputs =planKey→planKey and " +
|
|
95
|
+
"=if (is defined(note)) then note else null→note). @nanobpm/workflow@0.12.0's " +
|
|
96
|
+
"`task` builder only emits an ioMapping via a `prompt.append` (a single " +
|
|
97
|
+
"appendPrompt input), so this task is not derivable. Every other element of " +
|
|
98
|
+
"the golden IS expressible (w.task+prompt, w.branch, w.human, envelopes). " +
|
|
99
|
+
"Awaits a general `io` on the task/run builder upstream in @nanobpm/workflow " +
|
|
100
|
+
"(nano-ide#405).";
|
|
101
|
+
|
|
102
|
+
/** The single-top-level-end/start compiler limitation, reused as the
|
|
103
|
+
* `blockedReason` for every golden that has more than one top-level start
|
|
104
|
+
* and/or end event. */
|
|
105
|
+
const MULTI_START_END_BLOCK =
|
|
106
|
+
"blocked: @nanobpm/workflow@0.12.0 derives a single top-level start/end and " +
|
|
107
|
+
"converges all danglers into one <endEvent id=\"End\">; this golden has " +
|
|
108
|
+
"multiple top-level start and/or end events, which the published compiler " +
|
|
109
|
+
"cannot reproduce. Awaits an upstream terminal/explicit-end (+ multi-start) " +
|
|
110
|
+
"construct in @nanobpm/workflow (nano-ide).";
|
|
111
|
+
|
|
112
|
+
/** All seven ports, keyed by model, in the epic's stated authoring order. */
|
|
113
|
+
export const PORTS: readonly PortEntry[] = [
|
|
114
|
+
{ model: "retro", blockedReason: RETRO_IO_BLOCK },
|
|
115
|
+
{
|
|
116
|
+
model: "spine-demo",
|
|
117
|
+
blockedReason: `${MULTI_START_END_BLOCK} (spine-demo: 1 start, 2 ends)`,
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
model: "readiness-gate",
|
|
121
|
+
blockedReason: `${MULTI_START_END_BLOCK} (readiness-gate: 1 start, 5 ends)`,
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
model: "feature",
|
|
125
|
+
blockedReason: `${MULTI_START_END_BLOCK} (feature: 2 starts, 2 ends)`,
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
model: "convergence-loop",
|
|
129
|
+
blockedReason:
|
|
130
|
+
"blocked (arbitrary control-flow graph): single top-level start/end, but " +
|
|
131
|
+
"its topology is not expressible with @nanobpm/workflow@0.12.0's " +
|
|
132
|
+
"structured-only builder (loop/switch/branch). Proven in the test suite: " +
|
|
133
|
+
"the loop head `review-round` is a serviceTask that merges 3 back-edges " +
|
|
134
|
+
"directly (in=3), but loop() always inserts an exclusive-gateway head " +
|
|
135
|
+
"(task stays in=1); `gw-status` is one gateway with 4 heterogeneous-" +
|
|
136
|
+
"condition out-edges (no switch/branch emits that); `gw-escalated` is one " +
|
|
137
|
+
"gateway that is at once a 5-way merge and a 2-way split. Awaits an " +
|
|
138
|
+
"arbitrary-graph / explicit-join (named-target) builder upstream in " +
|
|
139
|
+
"@nanobpm/workflow (nano-ide) — a superset of the multi-start/end gap.",
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
model: "merge-loop",
|
|
143
|
+
blockedReason: `${MULTI_START_END_BLOCK} (merge-loop: 1 start, 2 ends)`,
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
model: "plan-fanout",
|
|
147
|
+
blockedReason: `${MULTI_START_END_BLOCK} (plan-fanout: 3 starts, 3 ends)`,
|
|
148
|
+
},
|
|
149
|
+
];
|