@nanobpm/nano-workforce 0.115.0 → 0.117.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/renovate.yml +72 -0
- package/CHANGELOG.md +14 -0
- package/README.md +17 -0
- package/app/convergeGate.test.ts +94 -84
- package/app/deliveryGraphRun.test.ts +249 -0
- package/app/deliveryGraphRun.ts +323 -0
- package/app/deliveryRunner.ts +9 -1
- package/app/instance-tracking.test.ts +32 -0
- package/app/persist-escalation.test.ts +0 -33
- package/app/service.ts +39 -0
- package/db/migrations/057_drop_escalation_head_override.sql +16 -0
- package/db/migrations/058_delivery_graph_runs.sql +58 -0
- package/e2e/convergence-escalation.e2e.ts +6 -0
- package/e2e/delivery-graph-start.e2e.ts +145 -0
- package/nano.app.json +16 -1
- package/openapi.yaml +120 -0
- package/operations/startDeliveryGraph.integration.test.ts +316 -0
- package/operations/startDeliveryGraph.ts +222 -0
- package/package.json +1 -1
- package/pages/overview.page.json +34 -0
- package/resources/processes/convergence-loop.bpmn +177 -88
- package/resources/prompts/feature.md +15 -13
- package/resources/prompts/scope-classify.md +142 -0
- package/test/derivation-parity/derivation-parity.test.ts +2 -2
- package/test/derivation-parity/flows.ts +1 -1
- package/workers/converge-gate/worker.ts +14 -157
- package/workers/persist-escalation/worker.ts +1 -8
- package/app/scopeGuard.test.ts +0 -185
- package/app/scopeGuard.ts +0 -165
- package/workers/converge-gate/worker.test.ts +0 -116
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
name: Renovate
|
|
2
|
+
|
|
3
|
+
# Self-hosted Renovate (issue #406). renovate.json declares the policy — auto-merge
|
|
4
|
+
# non-major @nanobpm/* updates once CI is green, majors need a human — but nothing was
|
|
5
|
+
# ever running it, so the config sat dormant and nwf stayed pinned to old @nanobpm/urban.
|
|
6
|
+
# This workflow IS the runner: on a schedule (and on demand) it opens update PRs and, on a
|
|
7
|
+
# later pass once the PR's CI is green, merges the automerge ones itself. We self-host via
|
|
8
|
+
# GitHub Actions rather than the Mend hosted app so the whole capability is in-repo and
|
|
9
|
+
# reproducible, with no external app-install state to drift.
|
|
10
|
+
on:
|
|
11
|
+
schedule:
|
|
12
|
+
# Every 3 hours. Renovate needs to run periodically not just to DISCOVER new versions,
|
|
13
|
+
# but to COME BACK and merge automerge PRs: renovate.json sets platformAutomerge:false
|
|
14
|
+
# (main is unprotected, so GitHub-native auto-merge is unavailable), which means Renovate
|
|
15
|
+
# performs the merge itself on a subsequent run once the PR's branch status is green.
|
|
16
|
+
# A few-hourly cadence keeps that merge latency low without burning Actions minutes.
|
|
17
|
+
- cron: "0 */3 * * *"
|
|
18
|
+
# On-demand runs for immediate pickup (e.g. right after a new urban publishes) and for
|
|
19
|
+
# debugging with a raised log level.
|
|
20
|
+
workflow_dispatch:
|
|
21
|
+
inputs:
|
|
22
|
+
logLevel:
|
|
23
|
+
description: "Renovate log level"
|
|
24
|
+
default: "info"
|
|
25
|
+
type: choice
|
|
26
|
+
options:
|
|
27
|
+
- debug
|
|
28
|
+
- info
|
|
29
|
+
- warn
|
|
30
|
+
# Re-run whenever the policy or this runner itself changes, so config edits take effect
|
|
31
|
+
# without waiting for the next scheduled tick.
|
|
32
|
+
push:
|
|
33
|
+
branches: [main]
|
|
34
|
+
paths:
|
|
35
|
+
- renovate.json
|
|
36
|
+
- .github/workflows/renovate.yml
|
|
37
|
+
|
|
38
|
+
# Renovate authenticates with RENOVATE_TOKEN (a PAT) for all git/PR/merge operations, so the
|
|
39
|
+
# job's GITHUB_TOKEN needs no elevated scope.
|
|
40
|
+
permissions:
|
|
41
|
+
contents: read
|
|
42
|
+
|
|
43
|
+
# Never let two Renovate passes run concurrently — overlapping runs race on the same branches
|
|
44
|
+
# and can double-open PRs. cancel-in-progress:false lets an in-flight pass (which may be mid-merge)
|
|
45
|
+
# finish rather than being killed by a newer trigger.
|
|
46
|
+
concurrency:
|
|
47
|
+
group: renovate
|
|
48
|
+
cancel-in-progress: false
|
|
49
|
+
|
|
50
|
+
jobs:
|
|
51
|
+
renovate:
|
|
52
|
+
name: renovate
|
|
53
|
+
runs-on: ubuntu-latest
|
|
54
|
+
steps:
|
|
55
|
+
- name: Checkout
|
|
56
|
+
uses: actions/checkout@v4
|
|
57
|
+
|
|
58
|
+
- name: Renovate
|
|
59
|
+
uses: renovatebot/github-action@v46.2.2
|
|
60
|
+
with:
|
|
61
|
+
# A Personal Access Token (repo scope), NOT the default GITHUB_TOKEN. This is required,
|
|
62
|
+
# not a preference: PRs opened by GITHUB_TOKEN do not trigger `on: pull_request`, so CI
|
|
63
|
+
# would never run on them and the green-gated automerge in renovate.json could never fire.
|
|
64
|
+
# A PAT-authored PR triggers CI normally, which is what makes "merge when green" work.
|
|
65
|
+
token: ${{ secrets.RENOVATE_TOKEN }}
|
|
66
|
+
env:
|
|
67
|
+
# Only ever operate on this repo (no org-wide autodiscovery).
|
|
68
|
+
RENOVATE_REPOSITORIES: ${{ github.repository }}
|
|
69
|
+
RENOVATE_AUTODISCOVER: "false"
|
|
70
|
+
# Renovate reads the repo's own renovate.json as its config automatically once it
|
|
71
|
+
# clones the repo above — no global configurationFile needed.
|
|
72
|
+
LOG_LEVEL: ${{ inputs.logLevel || 'info' }}
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [0.117.0](https://github.com/nanobpm/nano-workforce/compare/v0.116.0...v0.117.0) (2026-08-21)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **delivery-graph:** gated startDeliveryGraph dispatch door (ADR 0005 S5) ([#405](https://github.com/nanobpm/nano-workforce/issues/405)) ([dc7c78e](https://github.com/nanobpm/nano-workforce/commit/dc7c78e03ef1a3a8cfb70e4e51b721a4419c8962)), closes [#380](https://github.com/nanobpm/nano-workforce/issues/380)
|
|
7
|
+
|
|
8
|
+
# [0.116.0](https://github.com/nanobpm/nano-workforce/compare/v0.115.0...v0.116.0) (2026-08-20)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* replace deterministic scope regex with a scope-integrity agent classifier ([#403](https://github.com/nanobpm/nano-workforce/issues/403)) ([588b869](https://github.com/nanobpm/nano-workforce/commit/588b8699ea738ff0772f23d68bc051e8ff417d49)), closes [#395](https://github.com/nanobpm/nano-workforce/issues/395) [#395](https://github.com/nanobpm/nano-workforce/issues/395) [#395](https://github.com/nanobpm/nano-workforce/issues/395) [398/#399](https://github.com/nanobpm/nano-workforce/issues/399) [#395](https://github.com/nanobpm/nano-workforce/issues/395) [#395](https://github.com/nanobpm/nano-workforce/issues/395) [#395](https://github.com/nanobpm/nano-workforce/issues/395)
|
|
14
|
+
|
|
1
15
|
# [0.115.0](https://github.com/nanobpm/nano-workforce/compare/v0.114.1...v0.115.0) (2026-08-20)
|
|
2
16
|
|
|
3
17
|
|
package/README.md
CHANGED
|
@@ -441,6 +441,23 @@ returns `401` without the matching header; unset = open. The source lives in
|
|
|
441
441
|
model, **generate** the diagram — CI enforces DI freshness), migration policy, and
|
|
442
442
|
the CI gates.
|
|
443
443
|
|
|
444
|
+
### Dependency updates (Renovate)
|
|
445
|
+
|
|
446
|
+
First-party `@nanobpm/*` packages (notably `@nanobpm/urban`) are kept current by a
|
|
447
|
+
self-hosted [Renovate](https://docs.renovatebot.com/) runner:
|
|
448
|
+
[`.github/workflows/renovate.yml`](.github/workflows/renovate.yml) runs on a schedule (and
|
|
449
|
+
`workflow_dispatch`), opens update PRs, and — per [`renovate.json`](renovate.json) — merges
|
|
450
|
+
non-major `@nanobpm/*` bumps once CI is green while leaving majors for a human.
|
|
451
|
+
|
|
452
|
+
It requires a repository secret **`RENOVATE_TOKEN`**: a Personal Access Token with `repo` +
|
|
453
|
+
`workflow` scope (or a fine-grained PAT with *Contents: read & write*, *Pull requests: read &
|
|
454
|
+
write*, *Issues: read & write* — the Dependency Dashboard is a GitHub Issue — and *Workflows:
|
|
455
|
+
read & write*, so Renovate can update files under `.github/workflows`). A PAT is
|
|
456
|
+
mandatory rather than the built-in `GITHUB_TOKEN` because PRs opened by `GITHUB_TOKEN` do not
|
|
457
|
+
trigger `on: pull_request` CI — so the "merge when green" gate would never fire. Set it via
|
|
458
|
+
`gh secret set RENOVATE_TOKEN --repo nanobpm/nano-workforce` (or repo → Settings → Secrets →
|
|
459
|
+
Actions), then trigger a first run from the Actions tab.
|
|
460
|
+
|
|
444
461
|
## License
|
|
445
462
|
|
|
446
463
|
Apache-2.0 — see [LICENSE](LICENSE).
|
package/app/convergeGate.test.ts
CHANGED
|
@@ -217,15 +217,9 @@ test("pickLatestCopilotReviewBody: FAILS CLOSED (null) when the reviews read was
|
|
|
217
217
|
async function makeUnderTest(deps: {
|
|
218
218
|
readThreads: (repo: string, n: number) => Promise<ReviewThread[] | null>;
|
|
219
219
|
readReviewBody: (repo: string, n: number) => Promise<string | null>;
|
|
220
|
-
readPrBody?: (repo: string, n: number) => Promise<string | null>;
|
|
221
|
-
readHeadSha?: (repo: string, n: number) => Promise<string | null>;
|
|
222
220
|
}) {
|
|
223
221
|
const { makeHandler } = await import("../workers/converge-gate/worker.ts");
|
|
224
|
-
|
|
225
|
-
// below exercise only the review-comment dimension; scope-guard tests pass an explicit body. The
|
|
226
|
-
// HEAD read defaults to null (unreadable) so a scope block stays blocked unless a test opts into
|
|
227
|
-
// the #395 override door with an explicit HEAD — see workers/converge-gate/worker.test.ts.
|
|
228
|
-
return makeHandler({ readPrBody: async () => "", readHeadSha: async () => null, ...deps });
|
|
222
|
+
return makeHandler(deps);
|
|
229
223
|
}
|
|
230
224
|
|
|
231
225
|
test("converge-gate: a clean PR is allowed to converge", async () => {
|
|
@@ -348,82 +342,6 @@ test("converge-gate: resolves repo/prNumber from the prKey when the vars are abs
|
|
|
348
342
|
assertEquals(seen, ["o/r", 7]);
|
|
349
343
|
});
|
|
350
344
|
|
|
351
|
-
// ── The scope-integrity guard through the worker (#313) ─────────────────────
|
|
352
|
-
|
|
353
|
-
test("converge-gate: a partial delivery that Closes a broader-scoped parent blocks convergence", async () => {
|
|
354
|
-
const handler = await makeUnderTest({
|
|
355
|
-
readThreads: async () => [],
|
|
356
|
-
readReviewBody: async () => "",
|
|
357
|
-
readPrBody: async () =>
|
|
358
|
-
"Delivers the nested ad-hoc half.\n\n## Scope\nEmbedded SUB_PROCESS tools remain the deferred refinement.\n\nCloses #631",
|
|
359
|
-
});
|
|
360
|
-
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
361
|
-
assertEquals(out.convergeBlocked, true);
|
|
362
|
-
assertStringIncludes(out.convergeBlockReason ?? "", "Scope integrity blocked");
|
|
363
|
-
assertStringIncludes(out.convergeBlockReason ?? "", "#631");
|
|
364
|
-
});
|
|
365
|
-
|
|
366
|
-
test("converge-gate: a deferral with a filed follow-up issue and a non-closing ref converges", async () => {
|
|
367
|
-
const handler = await makeUnderTest({
|
|
368
|
-
readThreads: async () => [],
|
|
369
|
-
readReviewBody: async () => "",
|
|
370
|
-
readPrBody: async () =>
|
|
371
|
-
"Delivers the nested ad-hoc half.\n\n## Scope\nEmbedded SUB_PROCESS tools are deferred.\nTracked-in: #872\n\nRefs #631",
|
|
372
|
-
});
|
|
373
|
-
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
374
|
-
assertEquals(out, { convergeBlocked: false, convergeBlockReason: "" });
|
|
375
|
-
});
|
|
376
|
-
|
|
377
|
-
test("converge-gate: a full-scope Closes PR with no deferral prose converges", async () => {
|
|
378
|
-
const handler = await makeUnderTest({
|
|
379
|
-
readThreads: async () => [],
|
|
380
|
-
readReviewBody: async () => "",
|
|
381
|
-
readPrBody: async () => "Implements the feature end to end.\n\nCloses #313",
|
|
382
|
-
});
|
|
383
|
-
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
384
|
-
assertEquals(out, { convergeBlocked: false, convergeBlockReason: "" });
|
|
385
|
-
});
|
|
386
|
-
|
|
387
|
-
test("converge-gate: a scope block and a comment block are reported together", async () => {
|
|
388
|
-
const handler = await makeUnderTest({
|
|
389
|
-
readThreads: async () => [{ isResolved: false, path: "a.ts", bodies: ["please fix"] }],
|
|
390
|
-
readReviewBody: async () => "",
|
|
391
|
-
readPrBody: async () => "Ships one half.\n\nDeferred: the rest.\n\nCloses #631",
|
|
392
|
-
});
|
|
393
|
-
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
394
|
-
assertEquals(out.convergeBlocked, true);
|
|
395
|
-
assertStringIncludes(out.convergeBlockReason ?? "", "unresolved review thread");
|
|
396
|
-
assertStringIncludes(out.convergeBlockReason ?? "", "Scope integrity blocked");
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
test("converge-gate: FAILS CLOSED when the PR-body read returns null (no transport)", async () => {
|
|
400
|
-
const handler = await makeUnderTest({
|
|
401
|
-
readThreads: async () => [{ isResolved: true, path: "a.ts", bodies: ["ok"] }],
|
|
402
|
-
readReviewBody: async () => "",
|
|
403
|
-
readPrBody: async () => null,
|
|
404
|
-
});
|
|
405
|
-
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
406
|
-
assertEquals(out.convergeBlocked, true);
|
|
407
|
-
assertStringIncludes(out.convergeBlockReason ?? "", "could not read the PR description");
|
|
408
|
-
});
|
|
409
|
-
|
|
410
|
-
test("converge-gate: FAILS CLOSED with the SCOPE reason when the PR-body read throws", async () => {
|
|
411
|
-
// A transport failure while reading/parsing the PR body is a scope-integrity read failure, not a
|
|
412
|
-
// review-comment verification failure: it must surface BLOCK_UNVERIFIABLE_BODY, not the generic
|
|
413
|
-
// review-comment BLOCK_UNVERIFIABLE — otherwise the human escalation is pointed at review threads
|
|
414
|
-
// when the real problem is the PR description could not be read.
|
|
415
|
-
const handler = await makeUnderTest({
|
|
416
|
-
readThreads: async () => [{ isResolved: true, path: "a.ts", bodies: ["ok"] }],
|
|
417
|
-
readReviewBody: async () => "",
|
|
418
|
-
readPrBody: async () => {
|
|
419
|
-
throw new Error("boom");
|
|
420
|
-
},
|
|
421
|
-
});
|
|
422
|
-
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
423
|
-
assertEquals(out.convergeBlocked, true);
|
|
424
|
-
assertStringIncludes(out.convergeBlockReason ?? "", "could not read the PR description");
|
|
425
|
-
});
|
|
426
|
-
|
|
427
345
|
// ── Structural guard over the committed BPMN (no engine) ─────────────────────
|
|
428
346
|
|
|
429
347
|
const bpmn = readFileSync("resources/processes/convergence-loop.bpmn", "utf8");
|
|
@@ -460,16 +378,108 @@ test("gw-converge-gate blocks on an explicit convergeBlocked = true condition",
|
|
|
460
378
|
assertStringIncludes(f, "convergeBlocked = true");
|
|
461
379
|
});
|
|
462
380
|
|
|
463
|
-
test("gw-converge-gate default arm
|
|
381
|
+
test("gw-converge-gate default arm routes to the scope classifier (not straight to finalize)", () => {
|
|
464
382
|
const gw = flat.match(/<bpmn:exclusiveGateway\b[^>]*\bid="gw-converge-gate"[^>]*>/);
|
|
465
383
|
assert(gw, "gw-converge-gate gateway missing");
|
|
466
384
|
assertStringIncludes(gw[0], 'default="f_convergeOk"');
|
|
467
385
|
const ok = flowElement("f_convergeOk");
|
|
468
386
|
assert(ok, "f_convergeOk flow missing");
|
|
387
|
+
assertStringIncludes(ok, 'targetRef="classify-scope"');
|
|
388
|
+
assert(!/conditionExpression/.test(ok), "the default arm must carry no conditionExpression");
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// ── The scope classifier (agent task) replaces the deterministic scope regex ──
|
|
392
|
+
|
|
393
|
+
test("classify-scope is an agent task servicing senior:scope-classify with a linked prompt", () => {
|
|
394
|
+
const task = flat.match(/<bpmn:serviceTask\b[^>]*\bid="classify-scope"[^>]*>.*?<\/bpmn:serviceTask>/);
|
|
395
|
+
assert(task, "classify-scope service task missing");
|
|
396
|
+
assertStringIncludes(task[0], 'type="senior:scope-classify"');
|
|
397
|
+
assertStringIncludes(task[0], 'resourceId="scope-classify.md"');
|
|
398
|
+
assertStringIncludes(task[0], 'linkName="prompt"');
|
|
399
|
+
// After honouring (or ignoring) the human's scope answer, it clears the one-shot
|
|
400
|
+
// scopeAnswer so a later round does not re-honour a stale decision.
|
|
401
|
+
assertStringIncludes(task[0], 'source="=null" target="scopeAnswer"');
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// ── The scope-answer plumbing (#395 loop-defect fix) ─────────────────────────
|
|
405
|
+
// review-round clears `answer` on every round, so a human's scope answer cannot reach
|
|
406
|
+
// the downstream classify-scope via `answer`. A dedicated `scopeAnswer` variable, gated
|
|
407
|
+
// by a `scopePending` marker, carries the decision across the review round without being
|
|
408
|
+
// confused with answers to other escalation kinds.
|
|
409
|
+
|
|
410
|
+
test("PrScopeClassifyIn feeds the classifier the surviving scopeAnswer, not the cleared answer", () => {
|
|
411
|
+
const shape = flat.match(/<nano:shape\b[^>]*\bid="PrScopeClassifyIn"[^>]*>.*?<\/nano:shape>/);
|
|
412
|
+
assert(shape, "PrScopeClassifyIn envelope missing");
|
|
413
|
+
assertStringIncludes(shape[0], 'name="scopeAnswer"');
|
|
414
|
+
assert(!/name="answer"/.test(shape[0]), "classifier must read scopeAnswer, not the shared answer");
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test("PrScopeClassifyOut.scopeBlockReason is required (always emitted, empty when not blocked)", () => {
|
|
418
|
+
const shape = flat.match(/<nano:shape\b[^>]*\bid="PrScopeClassifyOut"[^>]*>.*?<\/nano:shape>/);
|
|
419
|
+
assert(shape, "PrScopeClassifyOut envelope missing");
|
|
420
|
+
assert(
|
|
421
|
+
/name="scopeBlockReason"(?![^>]*optional)/.test(shape[0]),
|
|
422
|
+
"scopeBlockReason must not be optional — the wire contract requires it always present",
|
|
423
|
+
);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("persist-escalation-scope marks the open escalation as scope-kind (scopePending = true)", () => {
|
|
427
|
+
const task = flat.match(/<bpmn:serviceTask\b[^>]*\bid="persist-escalation-scope"[^>]*>.*?<\/bpmn:serviceTask>/);
|
|
428
|
+
assert(task, "persist-escalation-scope task missing");
|
|
429
|
+
assertStringIncludes(task[0], 'source="=true" target="scopePending"');
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("record-answer captures a scope answer into scopeAnswer only while scopePending", () => {
|
|
433
|
+
const task = flat.match(/<bpmn:serviceTask\b[^>]*\bid="record-answer"[^>]*>.*?<\/bpmn:serviceTask>/);
|
|
434
|
+
assert(task, "record-answer task missing");
|
|
435
|
+
// Capture is gated on scopePending so an answer to a *different* escalation is not
|
|
436
|
+
// mis-read as a scope override; otherwise scopeAnswer is preserved.
|
|
437
|
+
assertStringIncludes(
|
|
438
|
+
task[0],
|
|
439
|
+
'source="=(if scopePending = true then answer else scopeAnswer)" target="scopeAnswer"',
|
|
440
|
+
);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("review-round resets the scopePending marker after record-answer has consumed it", () => {
|
|
444
|
+
const task = flat.match(/<bpmn:serviceTask\b[^>]*\bid="review-round"[^>]*>.*?<\/bpmn:serviceTask>/);
|
|
445
|
+
assert(task, "review-round task missing");
|
|
446
|
+
assertStringIncludes(task[0], 'source="=false" target="scopePending"');
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test("classify-scope feeds gw-scope-gate, which blocks on scopeBlocked = true", () => {
|
|
450
|
+
const toGate = flowElement("f_toScopeGate");
|
|
451
|
+
assert(toGate, "f_toScopeGate flow missing");
|
|
452
|
+
assertStringIncludes(toGate, 'sourceRef="classify-scope"');
|
|
453
|
+
assertStringIncludes(toGate, 'targetRef="gw-scope-gate"');
|
|
454
|
+
const blocked = flowElement("f_scopeBlocked");
|
|
455
|
+
assert(blocked, "f_scopeBlocked flow missing");
|
|
456
|
+
assertStringIncludes(blocked, 'targetRef="persist-escalation-scope"');
|
|
457
|
+
assertStringIncludes(blocked, "scopeBlocked = true");
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("gw-scope-gate default arm finalizes (scope ok → persist-converged)", () => {
|
|
461
|
+
const gw = flat.match(/<bpmn:exclusiveGateway\b[^>]*\bid="gw-scope-gate"[^>]*>/);
|
|
462
|
+
assert(gw, "gw-scope-gate gateway missing");
|
|
463
|
+
assertStringIncludes(gw[0], 'default="f_scopeOk"');
|
|
464
|
+
const ok = flowElement("f_scopeOk");
|
|
465
|
+
assert(ok, "f_scopeOk flow missing");
|
|
469
466
|
assertStringIncludes(ok, 'targetRef="persist-converged"');
|
|
470
467
|
assert(!/conditionExpression/.test(ok), "the default arm must carry no conditionExpression");
|
|
471
468
|
});
|
|
472
469
|
|
|
470
|
+
test("the scope escalation routes through gw-escalated with the classifier's specific reason", () => {
|
|
471
|
+
const f = flowElement("f_scopeEscGate");
|
|
472
|
+
assert(f, "f_scopeEscGate flow missing");
|
|
473
|
+
assertStringIncludes(f, 'sourceRef="persist-escalation-scope"');
|
|
474
|
+
assertStringIncludes(f, 'targetRef="gw-escalated"');
|
|
475
|
+
const task = flat.match(/<bpmn:serviceTask\b[^>]*\bid="persist-escalation-scope"[^>]*>.*?<\/bpmn:serviceTask>/);
|
|
476
|
+
assert(task, "persist-escalation-scope task missing");
|
|
477
|
+
assertStringIncludes(task[0], 'type="pr.persist-escalation"');
|
|
478
|
+
assertStringIncludes(task[0], 'target="question"');
|
|
479
|
+
// The human sees the classifier's specific finding, not a generic boilerplate reason.
|
|
480
|
+
assertStringIncludes(task[0], "scopeBlockReason");
|
|
481
|
+
});
|
|
482
|
+
|
|
473
483
|
test("the blocked-comments escalation routes through gw-escalated toward an answerable wait-answer", () => {
|
|
474
484
|
// #333: previously this flowed UNCONDITIONALLY into wait-answer, so a blank convergeBlockReason
|
|
475
485
|
// (the question is mapped from that OPTIONAL variable) opened no escalation yet still parked a
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// Unit coverage for the S5 dispatch-door aggregate (ADR 0005 Decision 7) — the pure decision helpers
|
|
2
|
+
// (the idempotency key, the approval gate, the parked human-label map, the derived parked-node phase),
|
|
3
|
+
// plus the durable at-most-once launch-claim fence (`claimRunForLaunch`) exercised against the real
|
|
4
|
+
// provisioned SQLite data layer so its actual `status <> 'running'` compare-and-swap SQL is validated,
|
|
5
|
+
// not just modelled. The integration test (operations/startDeliveryGraph.integration.test.ts) proves
|
|
6
|
+
// the COMPOSED behaviour at the edge.
|
|
7
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join, resolve } from "node:path";
|
|
10
|
+
import { test } from "node:test";
|
|
11
|
+
import { assertEquals } from "#test-assert";
|
|
12
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
13
|
+
import { bootTestApp } from "@nanobpm/urban-testkit";
|
|
14
|
+
import { compileDeliveryGraph } from "./deliveryGraphCompiler.ts";
|
|
15
|
+
import {
|
|
16
|
+
buildDeliveryGraphRunRow,
|
|
17
|
+
buildHumanLabels,
|
|
18
|
+
claimRunForLaunch,
|
|
19
|
+
computeRunKey,
|
|
20
|
+
deriveDeliveryPhase,
|
|
21
|
+
DELIVERY_PHASE,
|
|
22
|
+
deliveryGraphRuns,
|
|
23
|
+
humanTaskElementId,
|
|
24
|
+
isDeliveryGraphApproved,
|
|
25
|
+
parkRunFencedAgainstLaunch,
|
|
26
|
+
parseHumanLabels,
|
|
27
|
+
} from "./deliveryGraphRun.ts";
|
|
28
|
+
import { pollDeliveryGraphPhase } from "./service.ts";
|
|
29
|
+
|
|
30
|
+
const APP_ROOT = resolve(import.meta.dirname, "..");
|
|
31
|
+
|
|
32
|
+
/** Boot an app purely for its provisioned data layer (migration 058 applied), run `fn`, tear down. */
|
|
33
|
+
async function withData(fn: (data: DataLayer) => Promise<void>): Promise<void> {
|
|
34
|
+
const dir = mkdtempSync(join(tmpdir(), "nwf-dgrun-"));
|
|
35
|
+
const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dir, "app.db")}` } });
|
|
36
|
+
try {
|
|
37
|
+
await fn(app.db);
|
|
38
|
+
} finally {
|
|
39
|
+
await app.stop?.();
|
|
40
|
+
rmSync(dir, { recursive: true, force: true });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const claimRow = (status: "awaiting-approval" | "running") =>
|
|
45
|
+
buildDeliveryGraphRunRow({
|
|
46
|
+
runKey: "rk",
|
|
47
|
+
digest: "d",
|
|
48
|
+
status,
|
|
49
|
+
sideEffecting: true,
|
|
50
|
+
nodeCount: 1,
|
|
51
|
+
humanNodeCount: 0,
|
|
52
|
+
sideEffectCount: 1,
|
|
53
|
+
title: "t",
|
|
54
|
+
phase: status === "running" ? DELIVERY_PHASE.RUNNING : DELIVERY_PHASE.AWAITING_APPROVAL,
|
|
55
|
+
processKey: null,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("claimRunForLaunch: an empty slot is won by INSERT; a second racer that also read empty loses the run_key PK fence", async () => {
|
|
59
|
+
await withData(async (data) => {
|
|
60
|
+
const claim = claimRow("running");
|
|
61
|
+
assertEquals(await claimRunForLaunch(data, false, claim), true); // inserted the claim → this caller launches
|
|
62
|
+
assertEquals(await claimRunForLaunch(data, false, claim), false); // the row now exists → PK fence, no second launch
|
|
63
|
+
assertEquals((await deliveryGraphRuns(data).get("rk"))?.status, "running");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("claimRunForLaunch: a parked awaiting-approval row is claimed by ONE compare-and-swap — a second approved racer loses the `status <> 'running'` guard, so a graph launches at most once", async () => {
|
|
68
|
+
await withData(async (data) => {
|
|
69
|
+
const runs = deliveryGraphRuns(data);
|
|
70
|
+
await runs.insert(claimRow("awaiting-approval")); // a prior unapproved POST parked this run
|
|
71
|
+
const claim = claimRow("running");
|
|
72
|
+
assertEquals(await claimRunForLaunch(data, true, claim), true); // CAS flips awaiting-approval → running
|
|
73
|
+
assertEquals(await claimRunForLaunch(data, true, claim), false); // already running → guard blocks the double-launch
|
|
74
|
+
assertEquals((await runs.get("rk"))?.status, "running");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("claimRunForLaunch: a TERMINAL row re-runs — the CAS flips it to running, and a concurrent re-run racer loses the guard", async () => {
|
|
79
|
+
await withData(async (data) => {
|
|
80
|
+
const runs = deliveryGraphRuns(data);
|
|
81
|
+
await runs.insert({ ...claimRow("running"), status: "failed" }); // a completed/terminal prior run
|
|
82
|
+
const claim = claimRow("running");
|
|
83
|
+
assertEquals(await claimRunForLaunch(data, true, claim), true); // re-run: failed <> running → flips
|
|
84
|
+
assertEquals(await claimRunForLaunch(data, true, claim), false); // now running → no second launch
|
|
85
|
+
assertEquals((await runs.get("rk"))?.status, "running");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("claimRunForLaunch: re-running a terminal row clears the PRIOR instance key in the SAME atomic flip — a claimed `running` row is never visible pointing at a stale process_key", async () => {
|
|
90
|
+
await withData(async (data) => {
|
|
91
|
+
const runs = deliveryGraphRuns(data);
|
|
92
|
+
// A terminal prior run still carrying its old instance key + parked-node projection.
|
|
93
|
+
await runs.insert({
|
|
94
|
+
...claimRow("running"),
|
|
95
|
+
status: "failed",
|
|
96
|
+
process_key: "OLD-PI",
|
|
97
|
+
process_definition_id: "OLD-DEF",
|
|
98
|
+
phase: "Parked on human node: publish",
|
|
99
|
+
phase_node_id: "delivery-human-task__n1",
|
|
100
|
+
});
|
|
101
|
+
// The fresh launch claim carries no instance key yet (processKey: null).
|
|
102
|
+
assertEquals(await claimRunForLaunch(data, true, claimRow("running")), true);
|
|
103
|
+
const row = await runs.get("rk");
|
|
104
|
+
assertEquals(row?.status, "running");
|
|
105
|
+
assertEquals(row?.process_key, null); // stale key cleared atomically with the flip — not left as "OLD-PI"
|
|
106
|
+
assertEquals(row?.process_definition_id, null);
|
|
107
|
+
assertEquals(row?.phase_node_id, null);
|
|
108
|
+
assertEquals(row?.phase, DELIVERY_PHASE.RUNNING);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ── pollDeliveryGraphPhase: engine-key coercion ───────────────────────────────
|
|
113
|
+
test("pollDeliveryGraphPhase: a numeric engine processInstanceKey still matches the string process_key, so a COMPLETED instance reconciles to done", async () => {
|
|
114
|
+
await withData(async (data) => {
|
|
115
|
+
const runs = deliveryGraphRuns(data);
|
|
116
|
+
await runs.insert({ ...claimRow("running"), process_key: "12345" });
|
|
117
|
+
// The engine can yield a NUMERIC key; the poller compares against the string process_key.
|
|
118
|
+
const engine = {
|
|
119
|
+
searchProcessInstances: async () => [{ processInstanceKey: 12345, state: "COMPLETED" }],
|
|
120
|
+
searchUserTasks: async () => [],
|
|
121
|
+
};
|
|
122
|
+
await pollDeliveryGraphPhase(data, engine as never);
|
|
123
|
+
assertEquals((await runs.get("rk"))?.status, "done");
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ── parkRunFencedAgainstLaunch: the approval-park write never clobbers a launched claim ────────────
|
|
128
|
+
test("parkRunFencedAgainstLaunch: an approval-park write onto a launched `running` claim is a no-op — the at-most-once dispatch fence survives (no clobber back to awaiting-approval, no nulled process_key)", async () => {
|
|
129
|
+
await withData(async (data) => {
|
|
130
|
+
const runs = deliveryGraphRuns(data);
|
|
131
|
+
// A concurrent APPROVED submit already launched: the row is `running` with a live instance key.
|
|
132
|
+
await runs.insert({ ...claimRow("running"), process_key: "PI-1" });
|
|
133
|
+
// A racing UNAPPROVED submit that read the pre-launch row now tries to (re-)park it. The guarded
|
|
134
|
+
// write must refuse to overwrite the launched claim — otherwise a later re-submit double-launches.
|
|
135
|
+
await parkRunFencedAgainstLaunch(data, true, claimRow("awaiting-approval"));
|
|
136
|
+
const row = await runs.get("rk");
|
|
137
|
+
assertEquals(row?.status, "running"); // NOT clobbered back to awaiting-approval
|
|
138
|
+
assertEquals(row?.process_key, "PI-1"); // instance key preserved
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("parkRunFencedAgainstLaunch: a first park INSERTs the row; a park onto a still-parked row idempotently re-parks it (metadata refreshed, status stays awaiting-approval)", async () => {
|
|
143
|
+
await withData(async (data) => {
|
|
144
|
+
const runs = deliveryGraphRuns(data);
|
|
145
|
+
// First unapproved submit: no row yet → INSERT.
|
|
146
|
+
await parkRunFencedAgainstLaunch(data, false, claimRow("awaiting-approval"));
|
|
147
|
+
assertEquals((await runs.get("rk"))?.status, "awaiting-approval");
|
|
148
|
+
// A second unapproved submit onto the existing parked row: guarded UPDATE re-parks it (status is
|
|
149
|
+
// not `running`, so it applies) without duplicating the row.
|
|
150
|
+
await parkRunFencedAgainstLaunch(data, true, { ...claimRow("awaiting-approval"), digest: "d2" });
|
|
151
|
+
const row = await runs.get("rk");
|
|
152
|
+
assertEquals(row?.status, "awaiting-approval");
|
|
153
|
+
assertEquals(row?.digest, "d2");
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ── computeRunKey ─────────────────────────────────────────────────────────────
|
|
158
|
+
test("computeRunKey: a non-blank caller key wins; a blank/absent key falls back to the digest", () => {
|
|
159
|
+
assertEquals(computeRunKey("run-1", "digestX"), "run-1");
|
|
160
|
+
assertEquals(computeRunKey(" run-2 ", "digestX"), "run-2"); // trimmed
|
|
161
|
+
assertEquals(computeRunKey("", "digestX"), "digestX");
|
|
162
|
+
assertEquals(computeRunKey(" ", "digestX"), "digestX");
|
|
163
|
+
assertEquals(computeRunKey(null, "digestX"), "digestX");
|
|
164
|
+
assertEquals(computeRunKey(undefined, "digestX"), "digestX");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// ── isDeliveryGraphApproved ───────────────────────────────────────────────────
|
|
168
|
+
test("isDeliveryGraphApproved: a non-side-effecting graph needs no approval", () => {
|
|
169
|
+
assertEquals(isDeliveryGraphApproved(false, null, "d"), true);
|
|
170
|
+
assertEquals(isDeliveryGraphApproved(false, "wrong", "d"), true);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("isDeliveryGraphApproved: a side-effecting graph dispatches ONLY with the matching content token", () => {
|
|
174
|
+
assertEquals(isDeliveryGraphApproved(true, "d", "d"), true);
|
|
175
|
+
assertEquals(isDeliveryGraphApproved(true, " d ", "d"), true); // trimmed
|
|
176
|
+
assertEquals(isDeliveryGraphApproved(true, "wrong", "d"), false);
|
|
177
|
+
assertEquals(isDeliveryGraphApproved(true, null, "d"), false);
|
|
178
|
+
assertEquals(isDeliveryGraphApproved(true, "", "d"), false);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// ── buildHumanLabels / parseHumanLabels ───────────────────────────────────────
|
|
182
|
+
test("buildHumanLabels: maps each human node's compiled user-task element id → its instruction label", () => {
|
|
183
|
+
const graph = {
|
|
184
|
+
nodes: [
|
|
185
|
+
{ id: "open-b", kind: "agent", agent: { jobType: "j" } },
|
|
186
|
+
{ id: "publish", kind: "human", human: { prompt: "run the manual OTP publish\nsecond line" } },
|
|
187
|
+
{ id: "ack", kind: "human" }, // no prompt → falls back to the node id
|
|
188
|
+
],
|
|
189
|
+
edges: [{ from: "open-b", to: "publish" }, { from: "publish", to: "ack" }],
|
|
190
|
+
};
|
|
191
|
+
const compiled = compileDeliveryGraph(graph);
|
|
192
|
+
assertEquals(compiled.ok, true);
|
|
193
|
+
if (!compiled.ok) return;
|
|
194
|
+
const labels = buildHumanLabels(compiled);
|
|
195
|
+
const publishEl = compiled.resolved.nodes.find((n) => n.id === "publish")?.element ?? "";
|
|
196
|
+
const ackEl = compiled.resolved.nodes.find((n) => n.id === "ack")?.element ?? "";
|
|
197
|
+
assertEquals(labels[humanTaskElementId(publishEl)], "run the manual OTP publish"); // first line only
|
|
198
|
+
assertEquals(labels[humanTaskElementId(ackEl)], "ack"); // fallback to node id
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("parseHumanLabels: round-trips a stored map and tolerates null/blank/corrupt", () => {
|
|
202
|
+
assertEquals(parseHumanLabels(JSON.stringify({ a: "x" })), { a: "x" });
|
|
203
|
+
assertEquals(parseHumanLabels(null), {});
|
|
204
|
+
assertEquals(parseHumanLabels(""), {});
|
|
205
|
+
assertEquals(parseHumanLabels(" "), {});
|
|
206
|
+
assertEquals(parseHumanLabels("{not json"), {});
|
|
207
|
+
assertEquals(parseHumanLabels(JSON.stringify(["a"])), {}); // non-object
|
|
208
|
+
assertEquals(parseHumanLabels(JSON.stringify({ a: 1, b: "y" })), { b: "y" }); // drops non-string values
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ── deriveDeliveryPhase ───────────────────────────────────────────────────────
|
|
212
|
+
test("deriveDeliveryPhase: COMPLETED → done, TERMINATED → failed", () => {
|
|
213
|
+
assertEquals(deriveDeliveryPhase("COMPLETED", [], {}), { status: "done", phase: DELIVERY_PHASE.COMPLETED, phase_node_id: null });
|
|
214
|
+
assertEquals(deriveDeliveryPhase("TERMINATED", [], {}), { status: "failed", phase: DELIVERY_PHASE.FAILED, phase_node_id: null });
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("deriveDeliveryPhase: ACTIVE with an open human task → parked on that node with its label", () => {
|
|
218
|
+
const el = humanTaskElementId("n2");
|
|
219
|
+
const p = deriveDeliveryPhase("ACTIVE", [{ elementId: el }], { [el]: "manual OTP publish" });
|
|
220
|
+
assertEquals(p.status, "running");
|
|
221
|
+
assertEquals(p.phase, "Parked on human node: manual OTP publish");
|
|
222
|
+
assertEquals(p.phase_node_id, el);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("deriveDeliveryPhase: a parked node with no stored label falls back to the element id", () => {
|
|
226
|
+
const el = humanTaskElementId("n5");
|
|
227
|
+
const p = deriveDeliveryPhase("ACTIVE", [{ elementId: el }], {});
|
|
228
|
+
assertEquals(p.phase, `Parked on human node: ${el}`);
|
|
229
|
+
assertEquals(p.phase_node_id, el);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("deriveDeliveryPhase: ACTIVE with only a non-human open task (or none) → a bare Running", () => {
|
|
233
|
+
assertEquals(deriveDeliveryPhase("ACTIVE", [], {}), { status: "running", phase: DELIVERY_PHASE.RUNNING, phase_node_id: null });
|
|
234
|
+
assertEquals(deriveDeliveryPhase("ACTIVE", [{ elementId: "some-service-task" }], {}), {
|
|
235
|
+
status: "running",
|
|
236
|
+
phase: DELIVERY_PHASE.RUNNING,
|
|
237
|
+
phase_node_id: null,
|
|
238
|
+
});
|
|
239
|
+
// A null state (instance not found this pass) is treated as still-running, never a false terminal.
|
|
240
|
+
assertEquals(deriveDeliveryPhase(null, [], {}), { status: "running", phase: DELIVERY_PHASE.RUNNING, phase_node_id: null });
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("deriveDeliveryPhase: multiple open human tasks pick the lowest element id deterministically", () => {
|
|
244
|
+
const a = humanTaskElementId("n1");
|
|
245
|
+
const b = humanTaskElementId("n3");
|
|
246
|
+
const p = deriveDeliveryPhase("ACTIVE", [{ elementId: b }, { elementId: a }], { [a]: "first", [b]: "second" });
|
|
247
|
+
assertEquals(p.phase, "Parked on human node: first");
|
|
248
|
+
assertEquals(p.phase_node_id, a);
|
|
249
|
+
});
|