@nanobpm/nano-workforce 0.80.0 → 0.82.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/SPEC.md +11 -0
- package/app/contracts.ts +23 -0
- package/app/epicPhase.test.ts +62 -0
- package/app/epicPhase.ts +125 -0
- package/app/plan.ts +12 -0
- package/app/readiness.test.ts +300 -0
- package/app/readiness.ts +493 -0
- package/app/reviewWait.test.ts +19 -0
- package/app/reviewWait.ts +20 -0
- package/db/migrations/038_plan_epic_phase.sql +12 -0
- package/e2e/readiness-gate.e2e.ts +285 -0
- package/nano.app.json +4 -0
- package/package.json +1 -1
- package/pages/epic-detail.page.json +1 -0
- package/pages/epic.page.json +1 -0
- package/resources/forms/readiness-escalation.form +29 -0
- package/resources/processes/readiness-gate.bpmn +343 -0
- package/workers/readiness-probe/worker.test.ts +236 -0
- package/workers/readiness-probe/worker.ts +146 -0
- package/workers/record-plan/worker.ts +6 -0
- package/workers/record-results/worker.ts +8 -0
- package/workers/record-wave/worker.test.ts +5 -0
- package/workers/record-wave/worker.ts +12 -0
- package/workers/select-wave/worker.test.ts +4 -1
- package/workers/select-wave/worker.ts +9 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// End-to-end proof for the durable artifact-readiness wait-gate (ADR 0001 §2, issue #258).
|
|
2
|
+
//
|
|
3
|
+
// Boots this whole Urban app in-process against the WASM engine + virtual clock via `bootTestApp`
|
|
4
|
+
// and drives the real `readiness-gate` process — the reusable primitive: a parallel fork arms the
|
|
5
|
+
// `pr.readiness-probe` service task (an app-hosted worker) alongside an event-based gateway that
|
|
6
|
+
// races the `readiness-ready` message the probe publishes against a bounded timer catch.
|
|
7
|
+
//
|
|
8
|
+
// Two load-bearing behaviours are proven end to end:
|
|
9
|
+
// • READY — a `command` probe that is green immediately (`true`) drives the probe worker to
|
|
10
|
+
// publish `readiness-ready`, the gateway correlates it, and the gate releases through
|
|
11
|
+
// `wait-ready → gate-ready`.
|
|
12
|
+
// • BOUNDED (the red-first "the wait cannot hang" gate) — a `command` probe that is never green
|
|
13
|
+
// (`false`) exhausts the worker's local budget WITHOUT publishing; the wait does not hang, and
|
|
14
|
+
// when the engine timer (the authoritative bound) fires it escalates onto the native
|
|
15
|
+
// `readiness-escalation` userTask. A gate modelled without the timer arm could never satisfy
|
|
16
|
+
// this — the token would sit on `wait-ready` forever.
|
|
17
|
+
//
|
|
18
|
+
// The probes are deterministic shell builtins (`true`/`false`) so the flow is hermetic — no
|
|
19
|
+
// network, no GitHub. GitHub transport is still forced offline to match the sibling e2es.
|
|
20
|
+
import assert from "node:assert/strict";
|
|
21
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
22
|
+
import { tmpdir } from "node:os";
|
|
23
|
+
import { dirname, join, resolve } from "node:path";
|
|
24
|
+
import { after, before, describe, test } from "node:test";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
27
|
+
|
|
28
|
+
const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
29
|
+
|
|
30
|
+
const GITHUB_ENV_OVERRIDES: Record<string, string> = {
|
|
31
|
+
NANO_PR_GITHUB_TRANSPORT: "token",
|
|
32
|
+
GITHUB_TOKEN: "",
|
|
33
|
+
};
|
|
34
|
+
const savedEnv = new Map<string, string | undefined>();
|
|
35
|
+
|
|
36
|
+
interface TakenFlow {
|
|
37
|
+
from: string;
|
|
38
|
+
to: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function takenFlows(app: TestApp): string[] {
|
|
42
|
+
const snapshot = app.snapshot();
|
|
43
|
+
const flows = Array.isArray(snapshot.takenSequenceFlows) ? snapshot.takenSequenceFlows : [];
|
|
44
|
+
return flows
|
|
45
|
+
.filter((f): f is TakenFlow => typeof f === "object" && f !== null && "from" in f && "to" in f)
|
|
46
|
+
.map((f) => `${f.from}->${f.to}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Each scenario boots its own app so `takenSequenceFlows` (engine-global + cumulative) reflects
|
|
50
|
+
// exactly one instance's history.
|
|
51
|
+
async function boot(): Promise<{ app: TestApp; dbDir: string }> {
|
|
52
|
+
const dbDir = mkdtempSync(join(tmpdir(), "nwf-readiness-"));
|
|
53
|
+
const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dbDir, "app.db")}` } });
|
|
54
|
+
return { app, dbDir };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("nano-workforce artifact-readiness wait-gate (readiness-gate.bpmn)", () => {
|
|
58
|
+
before(() => {
|
|
59
|
+
for (const [k, v] of Object.entries(GITHUB_ENV_OVERRIDES)) {
|
|
60
|
+
savedEnv.set(k, process.env[k]);
|
|
61
|
+
process.env[k] = v;
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
after(() => {
|
|
66
|
+
for (const [k, v] of savedEnv) {
|
|
67
|
+
if (v === undefined) delete process.env[k];
|
|
68
|
+
else process.env[k] = v;
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("READY: a green probe publishes readiness-ready and the gate releases through wait-ready → gate-ready", async () => {
|
|
73
|
+
const { app, dbDir } = await boot();
|
|
74
|
+
try {
|
|
75
|
+
await app.engine.createInstance({
|
|
76
|
+
processDefinitionId: "readiness-gate",
|
|
77
|
+
variables: {
|
|
78
|
+
gateKey: "gate-ready-1",
|
|
79
|
+
probe: { kind: "command", target: "true", poll: { everyMs: 5, timeoutMs: 5000, backoff: "fixed" } },
|
|
80
|
+
// A long engine timer that must NOT fire — readiness wins the race first.
|
|
81
|
+
probeTimeout: "PT30M",
|
|
82
|
+
onTimeout: "escalate",
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
await app.settle();
|
|
86
|
+
|
|
87
|
+
const flows = takenFlows(app);
|
|
88
|
+
assert.ok(
|
|
89
|
+
flows.includes("wait-ready->gate-ready"),
|
|
90
|
+
`the gate released on the readiness signal (flows: ${flows.join(", ")})`,
|
|
91
|
+
);
|
|
92
|
+
assert.ok(
|
|
93
|
+
flows.includes("probe->probe-done"),
|
|
94
|
+
"the probe branch settled after publishing the readiness signal",
|
|
95
|
+
);
|
|
96
|
+
// The gate never timed out — no escalation userTask exists.
|
|
97
|
+
const tasks = await app.engine.searchUserTasks({});
|
|
98
|
+
assert.equal(
|
|
99
|
+
tasks.filter((t) => t.elementId === "readiness-escalation").length,
|
|
100
|
+
0,
|
|
101
|
+
"a probe that went green never escalates",
|
|
102
|
+
);
|
|
103
|
+
} finally {
|
|
104
|
+
await app.stop();
|
|
105
|
+
rmSync(dbDir, { recursive: true, force: true });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("BOUNDED: a never-green probe cannot hang — the engine timer fires and escalates onto the userTask", async () => {
|
|
110
|
+
const { app, dbDir } = await boot();
|
|
111
|
+
try {
|
|
112
|
+
const { processInstanceKey } = await app.engine.createInstance({
|
|
113
|
+
processDefinitionId: "readiness-gate",
|
|
114
|
+
variables: {
|
|
115
|
+
gateKey: "gate-timeout-1",
|
|
116
|
+
// `false` is never ready; a tiny local budget makes the worker exhaust fast (real time),
|
|
117
|
+
// leaving the ENGINE timer as the authoritative bound.
|
|
118
|
+
probe: { kind: "command", target: "false", poll: { everyMs: 5, timeoutMs: 40, backoff: "fixed" } },
|
|
119
|
+
probeTimeout: "PT1M",
|
|
120
|
+
onTimeout: "escalate",
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
await app.settle();
|
|
124
|
+
|
|
125
|
+
// The wait has NOT hung and has NOT yet escalated: the probe branch settled not-ready, and the
|
|
126
|
+
// gate is parked on the timer catch — no escalation userTask before the timer's duration.
|
|
127
|
+
const beforeTimer = await app.engine.searchUserTasks({ processInstanceKey });
|
|
128
|
+
assert.equal(
|
|
129
|
+
beforeTimer.filter((t) => t.elementId === "readiness-escalation").length,
|
|
130
|
+
0,
|
|
131
|
+
"the gate is still bounded-waiting on the timer, not prematurely escalated",
|
|
132
|
+
);
|
|
133
|
+
const beforeFlows = takenFlows(app);
|
|
134
|
+
assert.ok(!beforeFlows.includes("wait-ready->gate-ready"), "a never-green probe never releases as ready");
|
|
135
|
+
|
|
136
|
+
// Advancing past the engine timer is the ONLY thing that ends the wait — proving the bound is
|
|
137
|
+
// engine-owned. The token races off the timer catch onto the escalation userTask.
|
|
138
|
+
await app.advanceTime(61_000);
|
|
139
|
+
|
|
140
|
+
const afterFlows = takenFlows(app);
|
|
141
|
+
assert.ok(
|
|
142
|
+
afterFlows.includes("wait-timeout->gw-onTimeout") && afterFlows.includes("gw-onTimeout->readiness-escalation"),
|
|
143
|
+
`the timer bounded the wait and routed to escalation (flows: ${afterFlows.join(", ")})`,
|
|
144
|
+
);
|
|
145
|
+
const escalations = (await app.engine.searchUserTasks({ processInstanceKey })).filter(
|
|
146
|
+
(t) => t.elementId === "readiness-escalation",
|
|
147
|
+
);
|
|
148
|
+
assert.equal(escalations.length, 1, "the bounded timeout opened exactly one escalation userTask");
|
|
149
|
+
|
|
150
|
+
// Completing the escalation with `acknowledge` releases the gate to its `escalated` terminal
|
|
151
|
+
// via the resolution gateway (default arm) — the primitive is fully durable.
|
|
152
|
+
await app.engine.completeUserTask(escalations[0].userTaskKey, { resolution: "acknowledge" });
|
|
153
|
+
await app.settle();
|
|
154
|
+
const ackFlows = takenFlows(app);
|
|
155
|
+
assert.ok(
|
|
156
|
+
ackFlows.includes("readiness-escalation->gw-resolution") && ackFlows.includes("gw-resolution->gate-escalated"),
|
|
157
|
+
"acknowledging the escalation routes through the resolution gateway to gate-escalated",
|
|
158
|
+
);
|
|
159
|
+
} finally {
|
|
160
|
+
await app.stop();
|
|
161
|
+
rmSync(dbDir, { recursive: true, force: true });
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("ABANDON: an operator who abandons the escalation drives the gate to its `failed` terminal, not `escalated`", async () => {
|
|
166
|
+
const { app, dbDir } = await boot();
|
|
167
|
+
try {
|
|
168
|
+
const { processInstanceKey } = await app.engine.createInstance({
|
|
169
|
+
processDefinitionId: "readiness-gate",
|
|
170
|
+
variables: {
|
|
171
|
+
gateKey: "gate-abandon-1",
|
|
172
|
+
probe: { kind: "command", target: "false", poll: { everyMs: 5, timeoutMs: 40, backoff: "fixed" } },
|
|
173
|
+
probeTimeout: "PT1M",
|
|
174
|
+
onTimeout: "escalate",
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
await app.settle();
|
|
178
|
+
await app.advanceTime(61_000);
|
|
179
|
+
|
|
180
|
+
const escalations = (await app.engine.searchUserTasks({ processInstanceKey })).filter(
|
|
181
|
+
(t) => t.elementId === "readiness-escalation",
|
|
182
|
+
);
|
|
183
|
+
assert.equal(escalations.length, 1, "the bounded timeout opened exactly one escalation userTask");
|
|
184
|
+
|
|
185
|
+
// `abandon` ("give up on this gate") must NOT be a no-op: it routes to gate-failed, not gate-escalated.
|
|
186
|
+
await app.engine.completeUserTask(escalations[0].userTaskKey, { resolution: "abandon" });
|
|
187
|
+
await app.settle();
|
|
188
|
+
const flows = takenFlows(app);
|
|
189
|
+
assert.ok(
|
|
190
|
+
flows.includes("gw-resolution->gate-failed"),
|
|
191
|
+
`abandoning the escalation routes to gate-failed (flows: ${flows.join(", ")})`,
|
|
192
|
+
);
|
|
193
|
+
assert.ok(
|
|
194
|
+
!flows.includes("gw-resolution->gate-escalated"),
|
|
195
|
+
"abandon must not reach the escalated terminal",
|
|
196
|
+
);
|
|
197
|
+
} finally {
|
|
198
|
+
await app.stop();
|
|
199
|
+
rmSync(dbDir, { recursive: true, force: true });
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("onTimeout=continue: a bounded timeout proceeds (no escalation) when the caller declares continue", async () => {
|
|
204
|
+
const { app, dbDir } = await boot();
|
|
205
|
+
try {
|
|
206
|
+
await app.engine.createInstance({
|
|
207
|
+
processDefinitionId: "readiness-gate",
|
|
208
|
+
variables: {
|
|
209
|
+
gateKey: "gate-continue-1",
|
|
210
|
+
probe: { kind: "command", target: "false", poll: { everyMs: 5, timeoutMs: 40, backoff: "fixed" } },
|
|
211
|
+
probeTimeout: "PT1M",
|
|
212
|
+
onTimeout: "continue",
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
await app.settle();
|
|
216
|
+
await app.advanceTime(61_000);
|
|
217
|
+
|
|
218
|
+
const flows = takenFlows(app);
|
|
219
|
+
assert.ok(
|
|
220
|
+
flows.includes("gw-onTimeout->gate-continued"),
|
|
221
|
+
`a continue-on-timeout gate proceeds past the bounded wait (flows: ${flows.join(", ")})`,
|
|
222
|
+
);
|
|
223
|
+
} finally {
|
|
224
|
+
await app.stop();
|
|
225
|
+
rmSync(dbDir, { recursive: true, force: true });
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("DEFAULT SAFETY: an omitted onTimeout escalates (the gateway default is the safety path, not continue)", async () => {
|
|
230
|
+
const { app, dbDir } = await boot();
|
|
231
|
+
try {
|
|
232
|
+
await app.engine.createInstance({
|
|
233
|
+
processDefinitionId: "readiness-gate",
|
|
234
|
+
variables: {
|
|
235
|
+
gateKey: "gate-default-1",
|
|
236
|
+
// Neither probe.onTimeout nor a top-level onTimeout is declared.
|
|
237
|
+
probe: { kind: "command", target: "false", poll: { everyMs: 5, timeoutMs: 40, backoff: "fixed" } },
|
|
238
|
+
probeTimeout: "PT1M",
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
await app.settle();
|
|
242
|
+
await app.advanceTime(61_000);
|
|
243
|
+
|
|
244
|
+
const flows = takenFlows(app);
|
|
245
|
+
assert.ok(
|
|
246
|
+
flows.includes("gw-onTimeout->readiness-escalation"),
|
|
247
|
+
`an omitted onTimeout must default to escalation, never silently continue (flows: ${flows.join(", ")})`,
|
|
248
|
+
);
|
|
249
|
+
assert.ok(
|
|
250
|
+
!flows.includes("gw-onTimeout->gate-continued"),
|
|
251
|
+
"the safety default must not route to continue",
|
|
252
|
+
);
|
|
253
|
+
} finally {
|
|
254
|
+
await app.stop();
|
|
255
|
+
rmSync(dbDir, { recursive: true, force: true });
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("probe.onTimeout wins: the descriptor field is preferred over a top-level onTimeout (one source of truth)", async () => {
|
|
260
|
+
const { app, dbDir } = await boot();
|
|
261
|
+
try {
|
|
262
|
+
await app.engine.createInstance({
|
|
263
|
+
processDefinitionId: "readiness-gate",
|
|
264
|
+
variables: {
|
|
265
|
+
gateKey: "gate-probe-pref-1",
|
|
266
|
+
// The descriptor asks to continue; a stale top-level onTimeout says escalate. probe wins.
|
|
267
|
+
probe: { kind: "command", target: "false", onTimeout: "continue", poll: { everyMs: 5, timeoutMs: 40, backoff: "fixed" } },
|
|
268
|
+
probeTimeout: "PT1M",
|
|
269
|
+
onTimeout: "escalate",
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
await app.settle();
|
|
273
|
+
await app.advanceTime(61_000);
|
|
274
|
+
|
|
275
|
+
const flows = takenFlows(app);
|
|
276
|
+
assert.ok(
|
|
277
|
+
flows.includes("gw-onTimeout->gate-continued"),
|
|
278
|
+
`probe.onTimeout ("continue") must win over the top-level onTimeout ("escalate") (flows: ${flows.join(", ")})`,
|
|
279
|
+
);
|
|
280
|
+
} finally {
|
|
281
|
+
await app.stop();
|
|
282
|
+
rmSync(dbDir, { recursive: true, force: true });
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
});
|
package/nano.app.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.82.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",
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
},
|
|
58
58
|
"columns": [
|
|
59
59
|
{ "field": "title", "template": "{{title}}", "header": "Item", "subtitleField": "plan_key", "truncate": true, "width": "34%", "linkField": "issue_url" },
|
|
60
|
+
{ "field": "epic_phase", "header": "Phase" },
|
|
60
61
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
61
62
|
{ "field": "delivery", "header": "Delivery" },
|
|
62
63
|
{ "field": "wave_label", "header": "Wave" },
|
package/pages/epic.page.json
CHANGED
|
@@ -75,6 +75,7 @@
|
|
|
75
75
|
],
|
|
76
76
|
"columns": [
|
|
77
77
|
{ "field": "title", "template": "{{title}}", "header": "Item", "subtitleField": "plan_key", "truncate": true, "width": "34%", "link": { "kind": "page", "page": "epic-detail", "keyField": "plan_key" } },
|
|
78
|
+
{ "field": "epic_phase", "header": "Phase" },
|
|
78
79
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
79
80
|
{ "field": "delivery", "header": "Delivery" },
|
|
80
81
|
{ "field": "base_branch", "header": "Base branch" },
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "readiness-escalation",
|
|
3
|
+
"schemaVersion": 18,
|
|
4
|
+
"type": "default",
|
|
5
|
+
"components": [
|
|
6
|
+
{
|
|
7
|
+
"type": "text",
|
|
8
|
+
"text": "The artifact-readiness wait-gate timed out before its ReadinessProbe went green. A human decision is needed on how to proceed.",
|
|
9
|
+
"label": "Readiness timeout"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"type": "select",
|
|
13
|
+
"key": "resolution",
|
|
14
|
+
"label": "Resolution",
|
|
15
|
+
"values": [
|
|
16
|
+
{ "label": "Acknowledge — the artifact is ready / proceed", "value": "acknowledge" },
|
|
17
|
+
{ "label": "Abandon — give up on this gate", "value": "abandon" }
|
|
18
|
+
],
|
|
19
|
+
"validate": {
|
|
20
|
+
"required": true
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"type": "textarea",
|
|
25
|
+
"key": "answer",
|
|
26
|
+
"label": "Notes for the operator"
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
}
|