@nanobpm/nano-workforce 0.126.0 → 0.127.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/invariants.yml +8 -0
- package/.github/workflows/pr-title-lint.yml +9 -1
- package/.github/workflows/release.yml +8 -2
- package/AGENTS.md +18 -0
- package/CHANGELOG.md +7 -0
- package/app/agentCompletion.ts +11 -0
- package/app/agentic/vocab/demand-report.test.ts +23 -10
- package/app/pollUserTasks.test.ts +66 -2
- package/app/service.ts +21 -8
- package/app/tasksPage.test.ts +44 -0
- package/app/userTasks.test.ts +19 -0
- package/app/userTasks.ts +13 -1
- package/db/migrations/077_user_tasks_form_key.sql +25 -0
- package/docs/adr/0006-delivery-units-one-representation.md +59 -20
- package/e2e/delivery-graph.e2e.ts +7 -0
- package/e2e/feature-preflight.e2e.ts +7 -0
- package/e2e/inter-epic-dependency.e2e.ts +8 -0
- package/e2e/plan-fanout-preflight.e2e.ts +7 -0
- package/e2e/plan-fanout-sla.e2e.ts +4 -3
- package/e2e/plan-fanout.e2e.ts +2 -2
- package/e2e/readiness-gate.e2e.ts +8 -37
- package/e2e/support/probe-exec.test.ts +78 -0
- package/e2e/support/probe-exec.ts +69 -0
- package/e2e/support/time.test.ts +42 -0
- package/e2e/support/time.ts +33 -0
- package/package.json +3 -3
- package/pages/tasks.page.json +24 -554
|
@@ -20,7 +20,7 @@ to collapse three bespoke status unions into one),
|
|
|
20
20
|
nano-ide **#424** (datasource can read a SQL VIEW — the *data-level* unlock),
|
|
21
21
|
nano-workforce **#416** (the PR bumping the testkit to engine-wasm 0.7.2, which executes `callActivity`
|
|
22
22
|
— the *process-level* unlock),
|
|
23
|
-
nano-workforce **#464** (the tracking issue with slices S1–
|
|
23
|
+
nano-workforce **#464** (the tracking issue with slices S1–S6),
|
|
24
24
|
nano-workforce **#305** (consolidate escalations on native `user_tasks` — a natural sub-step of S1/S3).
|
|
25
25
|
|
|
26
26
|
## Context
|
|
@@ -89,7 +89,7 @@ because it did nothing. **Unlocked by #416** (engine-wasm 0.4.0 → **0.7.2**).
|
|
|
89
89
|
**Verified live:** a `callActivity` parent+child model deployed through engine-wasm 0.7.2 runs to
|
|
90
90
|
`COMPLETED`. Caveat: #416 bumps only the **dev-only** `@nanobpm/urban-testkit`; the production
|
|
91
91
|
`@nanobpm/urban` broker does not itself pin `engine-wasm`, so this verification proves the in-process
|
|
92
|
-
testkit, not the broker/runtime that will execute future `callActivity` models. S4
|
|
92
|
+
testkit, not the broker/runtime that will execute future `callActivity` models. S4–S6 therefore also
|
|
93
93
|
carry a **deployment-runtime prerequisite** — the deployed broker's `engine-core` must carry the same
|
|
94
94
|
`callActivity` support — which green testkit CI does not by itself guarantee.
|
|
95
95
|
|
|
@@ -129,18 +129,52 @@ a copy — expressed here as the **target** state:
|
|
|
129
129
|
content digest (`app/deliveryGraphRun.ts` `computeRunKey`) — so S2's compatibility VIEWs map each
|
|
130
130
|
legacy key onto the new identity, ensuring unrelated runs are never merged onto one row.
|
|
131
131
|
|
|
132
|
-
### 2. Process encoding —
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
132
|
+
### 2. Process encoding — **fine-grained** cells composed by `callActivity`
|
|
133
|
+
|
|
134
|
+
The primitive is a set of **small, single-purpose cells** — `implement-cell`, `converge-cell`,
|
|
135
|
+
`merge-cell`, plus the sibling wait-gate and human-escalation cells — each a standalone process
|
|
136
|
+
(`resources/processes/implement-cell.bpmn`, …), composed by reference and gated by edges. This is a
|
|
137
|
+
deliberate choice of granularity **over** a single coarse whole-feature subprocess with opaque
|
|
138
|
+
completion flags.
|
|
139
|
+
|
|
140
|
+
- Compose by reference, replacing the inlined segments, not the surrounding orchestration: in
|
|
141
|
+
**feature** (`feature.bpmn`) the readiness preflight, base-branch setup, and `record-feature` remain;
|
|
142
|
+
the implement/escalation segment and the convergence/merge tail each become `callActivity`s. **Epic**
|
|
143
|
+
= the multi-instance `implement` body is a `callActivity`; **delivery graph** = the compiler *emits*
|
|
144
|
+
`callActivity` references, not inlined subprocess copies.
|
|
145
|
+
- `feature` / `epic` are **derived macros** over the cells, not hand-written processes: a `feature`
|
|
146
|
+
expands to `implement → converge? → merge?`, so the cells stay the single source of truth and the
|
|
147
|
+
common case is still one node. (Same derivation discipline as the data encoding: compose by
|
|
148
|
+
reference, never inline a copy.)
|
|
149
|
+
- **Why fine-grained, not coarse.** A coarse whole-feature `callActivity` with `converge?`/`merge?`
|
|
150
|
+
flags buries those steps *inside* the black box, so a graph can never insert a gate *between*
|
|
151
|
+
"converged" and "merged". Fine-grained cells let a graph converge features A **and** B, then hold and
|
|
152
|
+
land both behind a single `human`/`wait` fan-in — the integration-branch / gated-landing pattern.
|
|
153
|
+
`feature.bpmn` already separates `converge` (its `gw-converge` gateway) from `autoMerge`, so the seam
|
|
154
|
+
exists today; this promotes it to a **node boundary** the graph can wire (see §3).
|
|
141
155
|
- This is gated on the engine-wasm 0.7.2 unlock, which is now live on `main`.
|
|
142
156
|
|
|
143
|
-
### 3.
|
|
157
|
+
### 3. Node completion policy — `converge` / `merge` are first-class cells, not smuggled state
|
|
158
|
+
|
|
159
|
+
Today the "get to green, then land" tail lives in two places the delivery graph cannot reach: a gateway
|
|
160
|
+
*inside* `feature.bpmn` (`gw-converge` + the `autoMerge` boolean on `ConvergeFeatureIn`), and — for a
|
|
161
|
+
delivery-graph `agent` node — **free text in a prompt** (`{ jobType: "senior:feature", prompt: "un-draft
|
|
162
|
+
+ merge #B" }`), with the graph only *observing* the result via a downstream `wait` node that emits
|
|
163
|
+
`mergedSha`. A graph therefore drives merge by asking an agent nicely, not structurally. Promote them to
|
|
164
|
+
first-class, edge-gated cells:
|
|
165
|
+
|
|
166
|
+
- **`converge`** = drive the PR through its review-convergence loop to green. **`merge`** = land it.
|
|
167
|
+
These are deliberately **separable phases** (`feature.bpmn` already splits `gw-converge` from
|
|
168
|
+
`autoMerge`), so a graph can stop at "green" and gate the landing behind any upstream node.
|
|
169
|
+
- A `feature`/`epic` node's `converge?` / `merge?` selectors choose whether the derived macro (§2)
|
|
170
|
+
includes those cells; omitting `merge` and wiring an explicit `merge` cell downstream of a gate is the
|
|
171
|
+
advanced case.
|
|
172
|
+
- **`merge` is two-level (ADR 0003 base-branch admission).** A unit's `merge` cell lands onto the
|
|
173
|
+
epic/graph **base branch**, never `main` directly; the graph's final merge-to-`main` is a *separate*
|
|
174
|
+
top-level step. `feature.bpmn`'s `autoMerge` is exactly this per-unit knob — do not collapse the two
|
|
175
|
+
levels.
|
|
176
|
+
|
|
177
|
+
### 4. Status lifecycle — one derived union
|
|
144
178
|
|
|
145
179
|
The three bespoke status unions collapse into **one derived union** via ADR 0065's `defineReadModel`,
|
|
146
180
|
so a change to lifecycle semantics is made once and derived everywhere, not re-declared per
|
|
@@ -153,7 +187,7 @@ representation. These unions are **not** identical today — features use
|
|
|
153
187
|
separate node contract — plus the per-shape mapping and precedence and the write/`instanceTracking`
|
|
154
188
|
behavior — not merely projecting an existing value.
|
|
155
189
|
|
|
156
|
-
###
|
|
190
|
+
### 5. Preserve — the static-vs-adaptive execution axis (do NOT bundle it)
|
|
157
191
|
|
|
158
192
|
This ADR consolidates the *representation*, not the *execution strategy*. ADR 0005's deliberate
|
|
159
193
|
distinction stays intact: **plan-fanout remains adaptive** (agent-discovered slices, waves that adapt),
|
|
@@ -183,8 +217,8 @@ their topology is produced. Unifying that axis is explicitly out of scope here.
|
|
|
183
217
|
|
|
184
218
|
## Rollout (see #464 for the live checklist)
|
|
185
219
|
|
|
186
|
-
Each slice is independently shippable; the process slices (S4
|
|
187
|
-
0.7.2 unlock. The **dev-testkit** side of that unlock has landed (#416, verified in-process above); S4
|
|
220
|
+
Each slice is independently shippable; the process slices (S4–S6) are sequenced behind the engine-wasm
|
|
221
|
+
0.7.2 unlock. The **dev-testkit** side of that unlock has landed (#416, verified in-process above); S4–S6
|
|
188
222
|
additionally gate on the **deployed broker/runtime** carrying verified `callActivity` support (the
|
|
189
223
|
deployment-runtime prerequisite noted above), not on #416 alone.
|
|
190
224
|
|
|
@@ -207,15 +241,20 @@ deployment-runtime prerequisite noted above), not on #416 alone.
|
|
|
207
241
|
bindings and every other writer off the legacy tables does the table-to-VIEW contract phase retire the
|
|
208
242
|
legacy write paths.
|
|
209
243
|
- **S3 · collapse doors** — unify the three `instanceTracking` bindings + `senior:*` dispatch doors.
|
|
210
|
-
- **S4 ·
|
|
211
|
-
human-escalation cells** (Decision §2) into standalone processes;
|
|
212
|
-
MI body compose them via `callActivity
|
|
213
|
-
|
|
244
|
+
- **S4 · fine-grained cells** — extract `implement-cell.bpmn`, `converge-cell.bpmn`, `merge-cell.bpmn`
|
|
245
|
+
**and the sibling wait-gate and human-escalation cells** (Decision §2) into standalone processes;
|
|
246
|
+
`feature.bpmn` + the `plan-fanout` MI body compose them via `callActivity`, with `feature`/`epic` as
|
|
247
|
+
derived macros over the cells.
|
|
248
|
+
- **S5 · `converge?` / `merge?` as first-class node policy** — promote convergence + landing from the
|
|
249
|
+
`feature.bpmn` `gw-converge`/`autoMerge` gateway and the delivery-graph *prompt prose* into edge-gated
|
|
250
|
+
`converge`/`merge` cell nodes on the delivery vocabulary (Decision §3); honour ADR 0003 two-level
|
|
251
|
+
merge (unit → base branch; graph → `main`).
|
|
252
|
+
- **S6 · compiler emits calls** — `deliveryGraphCompiler` references shared cells instead of inlining
|
|
214
253
|
per-node copies.
|
|
215
254
|
|
|
216
255
|
## Non-goals / deferred
|
|
217
256
|
|
|
218
|
-
- **Unifying the static-vs-adaptive execution axis** (see Decision §
|
|
257
|
+
- **Unifying the static-vs-adaptive execution axis** (see Decision §5) — preserved deliberately.
|
|
219
258
|
- **Changing the downstream PR/convergence loop** — already single-sourced (`pull_requests`); untouched.
|
|
220
259
|
- **Cross-repo/platform representation** — this ADR is nano-workforce-local; any platform-wide delivery
|
|
221
260
|
aggregate would be a separate nano-bpm ADR.
|
|
@@ -24,6 +24,7 @@ import { connectorDedupeKey, deliveryConnectorDispatches, dispatchConnector } fr
|
|
|
24
24
|
import { readConnectorInput } from "../workers/delivery-connector/worker.ts";
|
|
25
25
|
import { runDeliveryGraph } from "../app/deliveryRunner.ts";
|
|
26
26
|
import type { DeliveryGraph } from "../nano-generated/api-io.d.ts";
|
|
27
|
+
import { deterministicProbeSeam } from "./support/probe-exec.ts";
|
|
27
28
|
|
|
28
29
|
const APP_ROOT = resolve(import.meta.dirname, "..");
|
|
29
30
|
const GITHUB_ENV: Record<string, string> = { NANO_PR_GITHUB_TRANSPORT: "token", GITHUB_TOKEN: "" };
|
|
@@ -57,9 +58,15 @@ describe("delivery-graph runner — engine-native execution (S4)", () => {
|
|
|
57
58
|
apps.push(app);
|
|
58
59
|
return app;
|
|
59
60
|
};
|
|
61
|
+
// The `wait` nodes drive `command: true`/`false` probes through the shared readiness-probe worker.
|
|
62
|
+
// Inject the deterministic exec so they resolve within the virtual clock's drain fixpoint instead
|
|
63
|
+
// of racing a real subprocess `settle()` cannot await (issue #450).
|
|
64
|
+
const probeSeam = deterministicProbeSeam("delivery-graph e2e");
|
|
65
|
+
before(() => probeSeam.install());
|
|
60
66
|
after(async () => {
|
|
61
67
|
for (const app of apps) await app.stop?.();
|
|
62
68
|
for (const d of dirs) rmSync(d, { recursive: true, force: true });
|
|
69
|
+
probeSeam.restoreAndAssertHermetic();
|
|
63
70
|
});
|
|
64
71
|
|
|
65
72
|
test("runs end-to-end: agent, wait, human execute; edges gate; fan-in works; human fact late-binds into the connector", async () => {
|
|
@@ -24,6 +24,7 @@ import { after, before, describe, test } from "node:test";
|
|
|
24
24
|
import { fileURLToPath } from "node:url";
|
|
25
25
|
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
26
26
|
import { admitGithubState, installAdmitGithub } from "./support/github-admit.ts";
|
|
27
|
+
import { deterministicProbeSeam } from "./support/probe-exec.ts";
|
|
27
28
|
|
|
28
29
|
const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
29
30
|
|
|
@@ -92,12 +93,17 @@ async function boot(): Promise<{ app: TestApp; dbDir: string }> {
|
|
|
92
93
|
|
|
93
94
|
describe("single-issue feature intake readiness gate (feature.bpmn, issue #295)", () => {
|
|
94
95
|
let restoreGithub: (() => void) | undefined;
|
|
96
|
+
const probeSeam = deterministicProbeSeam("feature-preflight e2e");
|
|
95
97
|
|
|
96
98
|
before(() => {
|
|
97
99
|
for (const [k, v] of Object.entries(GITHUB_ENV_OVERRIDES)) {
|
|
98
100
|
savedEnv.set(k, process.env[k]);
|
|
99
101
|
process.env[k] = v;
|
|
100
102
|
}
|
|
103
|
+
// Inject the deterministic probe exec so the `command: true` probe resolves WITHIN the virtual
|
|
104
|
+
// clock's drain fixpoint instead of spawning a real subprocess whose wall-clock completion
|
|
105
|
+
// `settle()` cannot await — the flake behind this suite (issue #450).
|
|
106
|
+
probeSeam.install();
|
|
101
107
|
// `pr.ensure-base-branch` reads the base ref via the token transport, which would throw
|
|
102
108
|
// `no GitHub transport available` under an empty token. Pin the shared hermetic admit-github
|
|
103
109
|
// stub (dummy token + fetch intercept) like the sibling preflight e2e so base-branch admission
|
|
@@ -106,6 +112,7 @@ describe("single-issue feature intake readiness gate (feature.bpmn, issue #295)"
|
|
|
106
112
|
});
|
|
107
113
|
after(() => {
|
|
108
114
|
restoreGithub?.();
|
|
115
|
+
probeSeam.restoreAndAssertHermetic();
|
|
109
116
|
for (const [k, v] of savedEnv) {
|
|
110
117
|
if (v === undefined) delete process.env[k];
|
|
111
118
|
else process.env[k] = v;
|
|
@@ -23,6 +23,7 @@ import { after, before, describe, test } from "node:test";
|
|
|
23
23
|
import { fileURLToPath } from "node:url";
|
|
24
24
|
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
25
25
|
import { admitGithubState, installAdmitGithub } from "./support/github-admit.ts";
|
|
26
|
+
import { deterministicProbeSeam } from "./support/probe-exec.ts";
|
|
26
27
|
|
|
27
28
|
const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
28
29
|
|
|
@@ -89,8 +90,14 @@ async function boot(): Promise<{ app: TestApp; dbDir: string }> {
|
|
|
89
90
|
|
|
90
91
|
describe("inter-epic capability gate — adversarial (plan-fanout.bpmn, issue #292 S5)", () => {
|
|
91
92
|
let restoreGithub: (() => void) | undefined;
|
|
93
|
+
// The adversarial gates poll `command: false` (never-green) probes through the readiness-probe
|
|
94
|
+
// worker; inject the deterministic exec so each poll resolves inside the virtual clock's drain
|
|
95
|
+
// fixpoint (the escalation/timeout path is driven by engine-clock advancement, not a real
|
|
96
|
+
// subprocess) — issue #450.
|
|
97
|
+
const probeSeam = deterministicProbeSeam("inter-epic adversarial e2e");
|
|
92
98
|
|
|
93
99
|
before(() => {
|
|
100
|
+
probeSeam.install();
|
|
94
101
|
for (const [k, v] of Object.entries(GITHUB_ENV_OVERRIDES)) {
|
|
95
102
|
savedEnv.set(k, process.env[k]);
|
|
96
103
|
process.env[k] = v;
|
|
@@ -105,6 +112,7 @@ describe("inter-epic capability gate — adversarial (plan-fanout.bpmn, issue #2
|
|
|
105
112
|
if (v === undefined) delete process.env[k];
|
|
106
113
|
else process.env[k] = v;
|
|
107
114
|
}
|
|
115
|
+
probeSeam.restoreAndAssertHermetic();
|
|
108
116
|
});
|
|
109
117
|
|
|
110
118
|
// ── S5 P1 — the gate HOLDS wave 0 ───────────────────────────────────────────────────────────────
|
|
@@ -22,6 +22,7 @@ import { after, before, describe, test } from "node:test";
|
|
|
22
22
|
import { fileURLToPath } from "node:url";
|
|
23
23
|
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
24
24
|
import { admitGithubState, installAdmitGithub } from "./support/github-admit.ts";
|
|
25
|
+
import { deterministicProbeSeam } from "./support/probe-exec.ts";
|
|
25
26
|
|
|
26
27
|
const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
27
28
|
|
|
@@ -80,12 +81,17 @@ async function boot(): Promise<{ app: TestApp; dbDir: string }> {
|
|
|
80
81
|
|
|
81
82
|
describe("plan-fanout inter-epic capability preflight (plan-fanout.bpmn, issue #292 S3)", () => {
|
|
82
83
|
let restoreGithub: (() => void) | undefined;
|
|
84
|
+
const probeSeam = deterministicProbeSeam("plan-fanout-preflight e2e");
|
|
83
85
|
|
|
84
86
|
before(() => {
|
|
85
87
|
for (const [k, v] of Object.entries(GITHUB_ENV_OVERRIDES)) {
|
|
86
88
|
savedEnv.set(k, process.env[k]);
|
|
87
89
|
process.env[k] = v;
|
|
88
90
|
}
|
|
91
|
+
// Inject the deterministic probe exec so the `command: true` probe resolves WITHIN the virtual
|
|
92
|
+
// clock's drain fixpoint instead of spawning a real subprocess whose wall-clock completion
|
|
93
|
+
// `settle()` cannot await — the flake behind this suite (issue #450).
|
|
94
|
+
probeSeam.install();
|
|
89
95
|
// The fan-out head (`pr.ensure-base-branch`, ADR 0003) reads/creates the base ref via the token
|
|
90
96
|
// transport, which would throw `no GitHub transport available` under an empty token. Pin the
|
|
91
97
|
// shared hermetic admit-github stub (dummy token + fetch intercept) like the sibling plan-fanout
|
|
@@ -94,6 +100,7 @@ describe("plan-fanout inter-epic capability preflight (plan-fanout.bpmn, issue #
|
|
|
94
100
|
});
|
|
95
101
|
after(() => {
|
|
96
102
|
restoreGithub?.();
|
|
103
|
+
probeSeam.restoreAndAssertHermetic();
|
|
97
104
|
for (const [k, v] of savedEnv) {
|
|
98
105
|
if (v === undefined) delete process.env[k];
|
|
99
106
|
else process.env[k] = v;
|
|
@@ -25,6 +25,7 @@ import { fileURLToPath } from "node:url";
|
|
|
25
25
|
import type { EngineJob } from "@nanobpm/urban/runtime";
|
|
26
26
|
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
27
27
|
import { admitGithubState, installAdmitGithub } from "./support/github-admit.ts";
|
|
28
|
+
import { advancePastTimer } from "./support/time.ts";
|
|
28
29
|
|
|
29
30
|
const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
30
31
|
|
|
@@ -154,7 +155,7 @@ describe("plan-fanout escalation SLA + assignment (U5)", () => {
|
|
|
154
155
|
|
|
155
156
|
// Never answer — let the SLA elapse. The interrupting boundary cancels the parked task and
|
|
156
157
|
// routes to the task-done end (the safe auto-abandon default).
|
|
157
|
-
await app
|
|
158
|
+
await advancePastTimer(app, PAST_SLA_MS);
|
|
158
159
|
|
|
159
160
|
const flows = takenFlows(app);
|
|
160
161
|
assert.ok(
|
|
@@ -180,7 +181,7 @@ describe("plan-fanout escalation SLA + assignment (U5)", () => {
|
|
|
180
181
|
await openTask(app, processKey, "plan-review-decision");
|
|
181
182
|
await assertAssignmentFilterable(app, processKey, "plan-review-decision");
|
|
182
183
|
|
|
183
|
-
await app
|
|
184
|
+
await advancePastTimer(app, PAST_SLA_MS);
|
|
184
185
|
|
|
185
186
|
const flows = takenFlows(app);
|
|
186
187
|
assert.ok(
|
|
@@ -221,7 +222,7 @@ describe("plan-fanout escalation SLA + assignment (U5)", () => {
|
|
|
221
222
|
await openTask(app, processKey, "trial-merge-decision");
|
|
222
223
|
await assertAssignmentFilterable(app, processKey, "trial-merge-decision");
|
|
223
224
|
|
|
224
|
-
await app
|
|
225
|
+
await advancePastTimer(app, PAST_SLA_MS);
|
|
225
226
|
|
|
226
227
|
const flows = takenFlows(app);
|
|
227
228
|
assert.ok(
|
package/e2e/plan-fanout.e2e.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { fileURLToPath } from "node:url";
|
|
|
22
22
|
import type { EngineJob } from "@nanobpm/urban/runtime";
|
|
23
23
|
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
24
24
|
import { admitGithubState, installAdmitGithub } from "./support/github-admit.ts";
|
|
25
|
+
import { advancePastTimer } from "./support/time.ts";
|
|
25
26
|
|
|
26
27
|
const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
27
28
|
|
|
@@ -413,8 +414,7 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
|
|
|
413
414
|
|
|
414
415
|
// Never publish caps-resolved — let the bound (default P1D) elapse. Advancing past it is the
|
|
415
416
|
// ONLY way the token can move, so this proves the wait is genuinely bounded.
|
|
416
|
-
await app
|
|
417
|
-
await app.settle();
|
|
417
|
+
await advancePastTimer(app, 25 * 60 * 60 * 1000);
|
|
418
418
|
|
|
419
419
|
const flows = takenFlows(app);
|
|
420
420
|
assert.ok(
|
|
@@ -23,34 +23,7 @@ import { dirname, join, resolve } from "node:path";
|
|
|
23
23
|
import { after, before, describe, test } from "node:test";
|
|
24
24
|
import { fileURLToPath } from "node:url";
|
|
25
25
|
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
26
|
-
import
|
|
27
|
-
import { __setProbeExecForTest } from "../workers/readiness-probe/worker.ts";
|
|
28
|
-
|
|
29
|
-
// A synchronous, in-memory ProbeExec so the probe resolves WITHIN the testkit's virtual-clock drain
|
|
30
|
-
// fixpoint instead of spawning a REAL subprocess (real-time work `settle()` cannot deterministically
|
|
31
|
-
// await — issue #450). It maps the hermetic shell builtins these scenarios use to a deterministic
|
|
32
|
-
// `CommandResult` — `true` → exit 0 (green), `false` → exit 1 (never green) — mirroring the real
|
|
33
|
-
// commands' semantics exactly, but with zero real time. Any OTHER command, or any HTTP call, is an
|
|
34
|
-
// unintended probe escape: because `probeSingleShot` catches a thrown/rejected probe error and folds
|
|
35
|
-
// it into a silent "not ready", an escape would otherwise be INVISIBLE and could let a bounded
|
|
36
|
-
// not-ready scenario still pass, masking a regression (reviewer note). So we record every escape and
|
|
37
|
-
// assert none occurred in teardown, failing the suite loudly instead of swallowing it.
|
|
38
|
-
const unexpectedProbeIO: string[] = [];
|
|
39
|
-
const deterministicExec: ProbeExec = {
|
|
40
|
-
run(command: string): Promise<CommandResult> {
|
|
41
|
-
const cmd = command.trim();
|
|
42
|
-
if (cmd !== "true" && cmd !== "false") {
|
|
43
|
-
unexpectedProbeIO.push(`command: ${cmd}`);
|
|
44
|
-
return Promise.resolve({ code: 127, stdout: "", stderr: "" });
|
|
45
|
-
}
|
|
46
|
-
const code = cmd === "true" ? 0 : 1;
|
|
47
|
-
return Promise.resolve({ code, stdout: "", stderr: "" });
|
|
48
|
-
},
|
|
49
|
-
httpGet(url: string): Promise<never> {
|
|
50
|
-
unexpectedProbeIO.push(`http: ${url}`);
|
|
51
|
-
return Promise.reject(new Error(`readiness-gate e2e: unexpected real HTTP probe (command probes only)`));
|
|
52
|
-
},
|
|
53
|
-
};
|
|
26
|
+
import { deterministicProbeSeam } from "./support/probe-exec.ts";
|
|
54
27
|
|
|
55
28
|
const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
56
29
|
let dbSeq = 0;
|
|
@@ -60,7 +33,7 @@ const GITHUB_ENV_OVERRIDES: Record<string, string> = {
|
|
|
60
33
|
GITHUB_TOKEN: "",
|
|
61
34
|
};
|
|
62
35
|
const savedEnv = new Map<string, string | undefined>();
|
|
63
|
-
|
|
36
|
+
const probeSeam = deterministicProbeSeam("readiness-gate e2e");
|
|
64
37
|
|
|
65
38
|
interface TakenFlow {
|
|
66
39
|
from: string;
|
|
@@ -94,7 +67,7 @@ describe("nano-workforce artifact-readiness wait-gate (readiness-gate.bpmn)", ()
|
|
|
94
67
|
// clock (issue #450). Scenario-agnostic: it maps each scenario's command by string. Capture the
|
|
95
68
|
// prior override and restore exactly that in teardown, so the seam is restored to its real prior
|
|
96
69
|
// state rather than assuming production.
|
|
97
|
-
|
|
70
|
+
probeSeam.install();
|
|
98
71
|
});
|
|
99
72
|
|
|
100
73
|
after(() => {
|
|
@@ -102,13 +75,11 @@ describe("nano-workforce artifact-readiness wait-gate (readiness-gate.bpmn)", ()
|
|
|
102
75
|
if (v === undefined) delete process.env[k];
|
|
103
76
|
else process.env[k] = v;
|
|
104
77
|
}
|
|
105
|
-
// Restore the prior exec
|
|
106
|
-
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
// still pass, masking a regression.
|
|
111
|
-
assert.deepEqual(unexpectedProbeIO, [], `readiness-gate e2e saw unexpected probe I/O: ${unexpectedProbeIO.join(", ")}`);
|
|
78
|
+
// Restore the prior exec (the seam must never outlive this suite) and fail loudly if the probe
|
|
79
|
+
// ever escaped the hermetic `true`/`false` builtins — `probeSingleShot` folds a probe error into
|
|
80
|
+
// a silent "not ready", so without this an escape would be invisible and could let a bounded
|
|
81
|
+
// not-ready scenario still pass, masking a regression.
|
|
82
|
+
probeSeam.restoreAndAssertHermetic();
|
|
112
83
|
});
|
|
113
84
|
|
|
114
85
|
test("READY: a green probe publishes readiness-ready and the gate releases through wait-ready → gate-ready", async () => {
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Negative-path coverage for the deterministic probe seam (e2e/support/probe-exec.ts).
|
|
2
|
+
//
|
|
3
|
+
// The wired readiness-gate suites only ever route the hermetic `true`/`false` builtins through the
|
|
4
|
+
// seam, so a regression in RECORDING or REJECTING an unexpected command/HTTP call — the seam's whole
|
|
5
|
+
// reason to exist — would leave every one of them green while silently swallowing an escape. This
|
|
6
|
+
// exercises the escape contract directly: a non-hermetic command or a real HTTP probe must be
|
|
7
|
+
// recorded and make `restoreAndAssertHermetic()` fail, and the recorded escape must never leak the
|
|
8
|
+
// raw command or a URL's credential material into the teardown assertion (ADR 0004 pinned
|
|
9
|
+
// decision 2 — mirrors production `redactTarget`).
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { test } from "node:test";
|
|
12
|
+
import { __setProbeExecForTest } from "../../workers/readiness-probe/worker.ts";
|
|
13
|
+
import { deterministicProbeSeam } from "./probe-exec.ts";
|
|
14
|
+
|
|
15
|
+
/** Install the seam, then hand back the exact `ProbeExec` it wired into the worker so a test can
|
|
16
|
+
* route probes through the real installed seam (not a private copy). Uses the documented
|
|
17
|
+
* set-returns-previous contract of `__setProbeExecForTest` to read the current override, then puts
|
|
18
|
+
* it straight back so the seam's own restore still returns the DB to the prior exec. */
|
|
19
|
+
function installAndCaptureExec(seam: ReturnType<typeof deterministicProbeSeam>) {
|
|
20
|
+
seam.install();
|
|
21
|
+
const installed = __setProbeExecForTest(undefined);
|
|
22
|
+
assert.ok(installed, "install() must have wired a ProbeExec into the worker");
|
|
23
|
+
__setProbeExecForTest(installed);
|
|
24
|
+
return installed;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test("hermetic true/false probes leave the seam clean", async () => {
|
|
28
|
+
const seam = deterministicProbeSeam("hermetic");
|
|
29
|
+
const exec = installAndCaptureExec(seam);
|
|
30
|
+
|
|
31
|
+
assert.deepEqual(await exec.run("true", {}), { code: 0, stdout: "", stderr: "" });
|
|
32
|
+
assert.deepEqual(await exec.run("false", {}), { code: 1, stdout: "", stderr: "" });
|
|
33
|
+
|
|
34
|
+
// No escape recorded — teardown passes.
|
|
35
|
+
seam.restoreAndAssertHermetic();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("a non-hermetic command escapes, fails teardown, and never leaks the raw command", async () => {
|
|
39
|
+
const seam = deterministicProbeSeam("cmd-escape");
|
|
40
|
+
const exec = installAndCaptureExec(seam);
|
|
41
|
+
|
|
42
|
+
const secret = "curl https://user:supersecret@host/health?token=abc123";
|
|
43
|
+
const result = await exec.run(secret, {});
|
|
44
|
+
// The escaping command still resolves to a non-green result so a bounded not-ready scenario holds.
|
|
45
|
+
assert.equal(result.code, 127);
|
|
46
|
+
|
|
47
|
+
assert.throws(
|
|
48
|
+
() => seam.restoreAndAssertHermetic(),
|
|
49
|
+
(err: unknown) => {
|
|
50
|
+
const msg = String(err);
|
|
51
|
+
assert.match(msg, /cmd-escape saw unexpected probe I\/O/);
|
|
52
|
+
assert.doesNotMatch(msg, /supersecret/, "the credential must never reach teardown output");
|
|
53
|
+
assert.doesNotMatch(msg, /abc123/, "the token must never reach teardown output");
|
|
54
|
+
return true;
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("a real HTTP probe escapes, fails teardown, and redacts the URL's credential material", async () => {
|
|
60
|
+
const seam = deterministicProbeSeam("http-escape");
|
|
61
|
+
const exec = installAndCaptureExec(seam);
|
|
62
|
+
|
|
63
|
+
await assert.rejects(
|
|
64
|
+
exec.httpGet("https://user:tok@host/ready?apikey=zzz999", {}),
|
|
65
|
+
/http-escape: unexpected real HTTP probe/,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
assert.throws(
|
|
69
|
+
() => seam.restoreAndAssertHermetic(),
|
|
70
|
+
(err: unknown) => {
|
|
71
|
+
const msg = String(err);
|
|
72
|
+
assert.match(msg, /http-escape saw unexpected probe I\/O/);
|
|
73
|
+
assert.doesNotMatch(msg, /zzz999/, "the query token must never reach teardown output");
|
|
74
|
+
assert.doesNotMatch(msg, /user:tok/, "the userinfo credential must never reach teardown output");
|
|
75
|
+
return true;
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Deterministic readiness-probe exec for e2es driven by the testkit's VIRTUAL clock (issue #450).
|
|
2
|
+
//
|
|
3
|
+
// Production `defaultProbeExec` (app/readiness.ts) runs a `command` probe as a REAL
|
|
4
|
+
// `node:child_process` subprocess. That subprocess resolves on the wall clock, spanning macrotasks
|
|
5
|
+
// the urban-testkit's virtual-clock `settle()`/`drain()` fixpoint cannot deterministically await —
|
|
6
|
+
// so `settle()` can return BEFORE the probe publishes `readiness-ready`, and a gate-flow assertion
|
|
7
|
+
// (e.g. `pf_gw->pf_end`) races the subprocess. That race is the flake behind feature-preflight /
|
|
8
|
+
// plan-fanout-preflight failing intermittently (green probe logged, gate flow not yet taken).
|
|
9
|
+
//
|
|
10
|
+
// This seam injects a synchronous, in-memory `ProbeExec` (via the worker's `__setProbeExecForTest`)
|
|
11
|
+
// so the probe resolves WITHIN the drain fixpoint — no real spawn, no wall-clock race. It maps the
|
|
12
|
+
// hermetic shell builtins the gate e2es use to a deterministic `CommandResult` — `true` → exit 0
|
|
13
|
+
// (green), `false` → exit 1 (never green) — mirroring the real commands exactly, with zero real time.
|
|
14
|
+
//
|
|
15
|
+
// Any OTHER command, or any HTTP call, is an unintended probe escape: `probeSingleShot` folds a
|
|
16
|
+
// thrown/rejected probe error into a silent "not ready", so an escape would be INVISIBLE and could
|
|
17
|
+
// let a bounded not-ready scenario still pass, masking a regression. Every escape is recorded and
|
|
18
|
+
// asserted-none in teardown, failing the suite loudly instead of swallowing it.
|
|
19
|
+
//
|
|
20
|
+
// Single source of truth shared by every readiness-gate e2e (readiness-gate, feature-preflight,
|
|
21
|
+
// plan-fanout-preflight, delivery-graph, inter-epic-dependency) so the deterministic-exec contract
|
|
22
|
+
// can never drift between them.
|
|
23
|
+
import assert from "node:assert/strict";
|
|
24
|
+
import { type CommandResult, type ProbeExec, redactString } from "../../app/readiness.ts";
|
|
25
|
+
import { __setProbeExecForTest } from "../../workers/readiness-probe/worker.ts";
|
|
26
|
+
|
|
27
|
+
export interface DeterministicProbeSeam {
|
|
28
|
+
/** Install the deterministic exec — call once in the suite's `before`. */
|
|
29
|
+
install(): void;
|
|
30
|
+
/** Restore the prior exec and assert no probe escaped the hermetic `true`/`false` builtins — call
|
|
31
|
+
* once in the suite's `after`. */
|
|
32
|
+
restoreAndAssertHermetic(): void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Build a deterministic probe seam scoped to one suite. `label` is used in the escape assertion
|
|
36
|
+
* message and the unexpected-HTTP error, so a failure names the offending suite. */
|
|
37
|
+
export function deterministicProbeSeam(label: string): DeterministicProbeSeam {
|
|
38
|
+
const escapes: string[] = [];
|
|
39
|
+
const exec: ProbeExec = {
|
|
40
|
+
run(command: string): Promise<CommandResult> {
|
|
41
|
+
const cmd = command.trim();
|
|
42
|
+
if (cmd !== "true" && cmd !== "false") {
|
|
43
|
+
// A `command` target is an arbitrary shell snippet that can embed a secret, so — exactly as
|
|
44
|
+
// production `redactTarget` does (app/readiness.ts, ADR 0004 pinned decision 2) — record only
|
|
45
|
+
// a fixed placeholder, never the raw command, so an escape can't leak credentials into the
|
|
46
|
+
// teardown assertion at `restoreAndAssertHermetic()`.
|
|
47
|
+
escapes.push("command: <redacted>");
|
|
48
|
+
return Promise.resolve({ code: 127, stdout: "", stderr: "" });
|
|
49
|
+
}
|
|
50
|
+
return Promise.resolve({ code: cmd === "true" ? 0 : 1, stdout: "", stderr: "" });
|
|
51
|
+
},
|
|
52
|
+
httpGet(url: string): Promise<never> {
|
|
53
|
+
// A probe URL can carry a token in its userinfo or query string; strip those before recording.
|
|
54
|
+
escapes.push(`http: ${redactString(url)}`);
|
|
55
|
+
return Promise.reject(new Error(`${label}: unexpected real HTTP probe (command probes only)`));
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
let saved: ProbeExec | undefined;
|
|
59
|
+
return {
|
|
60
|
+
install() {
|
|
61
|
+
saved = __setProbeExecForTest(exec);
|
|
62
|
+
},
|
|
63
|
+
restoreAndAssertHermetic() {
|
|
64
|
+
// Restore the prior exec first — the seam must never outlive this suite.
|
|
65
|
+
__setProbeExecForTest(saved);
|
|
66
|
+
assert.deepEqual(escapes, [], `${label} saw unexpected probe I/O: ${escapes.join(", ")}`);
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { advancePastTimer } from "./time.ts";
|
|
4
|
+
|
|
5
|
+
// Guards the defect class behind issue #474: a long BPMN business-wait must be crossed by advancing
|
|
6
|
+
// the ENGINE clock (fire the boundary timer) + one reconcile settle — NOT by `app.advanceTime`,
|
|
7
|
+
// which steps the scheduler in lockstep and replays every 5s runtime-cadence poll across the window
|
|
8
|
+
// (~18,000× for a 25h jump; ~123s of no-op churn). This pins the mechanism so a regression back to
|
|
9
|
+
// the lockstep path — or an added `scheduler.advance` — fails here instead of silently re-slowing CI.
|
|
10
|
+
test("advancePastTimer advances the engine clock then settles once, never replaying scheduler cadence", async () => {
|
|
11
|
+
const PAST_SLA_MS = 25 * 60 * 60 * 1000;
|
|
12
|
+
const calls: string[] = [];
|
|
13
|
+
let engineMs: number | undefined;
|
|
14
|
+
|
|
15
|
+
const app = {
|
|
16
|
+
engine: {
|
|
17
|
+
advanceTime: async (ms: number) => {
|
|
18
|
+
engineMs = ms;
|
|
19
|
+
calls.push("engine.advanceTime");
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
settle: async () => {
|
|
23
|
+
calls.push("settle");
|
|
24
|
+
},
|
|
25
|
+
// Present only to catch a regression: touching the scheduler's own `advance` is the slow
|
|
26
|
+
// lockstep replay path the helper exists to avoid, so it must never be called.
|
|
27
|
+
scheduler: {
|
|
28
|
+
advance: async () => {
|
|
29
|
+
calls.push("scheduler.advance");
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
} as unknown as Parameters<typeof advancePastTimer>[0];
|
|
33
|
+
|
|
34
|
+
await advancePastTimer(app, PAST_SLA_MS);
|
|
35
|
+
|
|
36
|
+
assert.deepEqual(
|
|
37
|
+
calls,
|
|
38
|
+
["engine.advanceTime", "settle"],
|
|
39
|
+
"the engine boundary fires, then exactly one reconcile settle — the scheduler cadence is never replayed",
|
|
40
|
+
);
|
|
41
|
+
assert.equal(engineMs, PAST_SLA_MS, "the full business-wait window is applied to the engine clock");
|
|
42
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { TestApp } from "@nanobpm/urban-testkit";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Advance a long BPMN **business-wait** boundary timer and reconcile once.
|
|
5
|
+
*
|
|
6
|
+
* A business wait — "park at this task until the 24h SLA elapses, then auto-escalate" — is
|
|
7
|
+
* modelled as a BPMN interrupting boundary timer on the ENGINE clock: durable, engine-owned, and
|
|
8
|
+
* already virtual under the engine clock. This is the line camunda/orchestration-cluster-api-js#450
|
|
9
|
+
* draws: *"Long/business waits are BPMN timer events … not [runtime-cadence] `sleep`/poll."* To
|
|
10
|
+
* cross such a wait we advance the ENGINE clock so the boundary fires, then {@link TestApp.settle}
|
|
11
|
+
* once to drain the follow-on token flow.
|
|
12
|
+
*
|
|
13
|
+
* This deliberately does **not** call {@link TestApp.advanceTime}, which steps the virtual-clock
|
|
14
|
+
* scheduler in lockstep with the engine and therefore REPLAYS every short runtime-cadence poll —
|
|
15
|
+
* the 5s `instanceTracking` reconcilers — once per interval across the whole window. A 25h jump
|
|
16
|
+
* replays each poller ~18,000× (measured: ~123s of pure no-op reconcile churn *per call*, which is
|
|
17
|
+
* essentially the entire e2e wall-clock). Those replays are runtime cadence, not the business wait,
|
|
18
|
+
* and #450 treats such busy-replays as a bug to *surface*, not to coalesce away in the scheduler.
|
|
19
|
+
* Advancing engine time + one settle fires the same boundary in ~2ms.
|
|
20
|
+
*
|
|
21
|
+
* Use ONLY when the assertions target **engine state** — taken sequence-flows (`app.snapshot()`) or
|
|
22
|
+
* instance state — which is populated by `engine.advanceTime` + `engine.drain` (run inside
|
|
23
|
+
* `settle`). Do NOT use it when an assertion depends on a **read model the reconcile pollers
|
|
24
|
+
* project**: for that the pollers must actually run, so use {@link TestApp.advanceTime}. The
|
|
25
|
+
* scheduler's virtual clock intentionally does not track this jump.
|
|
26
|
+
*/
|
|
27
|
+
export async function advancePastTimer(
|
|
28
|
+
app: Pick<TestApp, "engine" | "settle">,
|
|
29
|
+
ms: number,
|
|
30
|
+
): Promise<void> {
|
|
31
|
+
await app.engine.advanceTime(ms);
|
|
32
|
+
await app.settle();
|
|
33
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.127.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",
|
|
@@ -58,13 +58,13 @@
|
|
|
58
58
|
"lint:fix": "biome check --write app operations workers pages components scripts e2e main.ts"
|
|
59
59
|
},
|
|
60
60
|
"dependencies": {
|
|
61
|
-
"@nanobpm/agentic": "^0.
|
|
61
|
+
"@nanobpm/agentic": "^0.4.0",
|
|
62
62
|
"@nanobpm/urban": "^0.80.0",
|
|
63
63
|
"bpmn-auto-layout": "^2.0.0-alpha.2"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
66
|
"@biomejs/biome": "^2.4.11",
|
|
67
|
-
"@nanobpm/urban-testkit": "^0.
|
|
67
|
+
"@nanobpm/urban-testkit": "^0.12.16",
|
|
68
68
|
"@nanobpm/workflow": "^0.14.0",
|
|
69
69
|
"@semantic-release/changelog": "^6.0.3",
|
|
70
70
|
"@semantic-release/git": "^10.0.1",
|