@nanobpm/nano-workforce 0.107.1 → 0.108.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +33 -0
- package/.github/workflows/invariants.yml +72 -0
- package/AGENTS.md +7 -1
- package/CHANGELOG.md +14 -0
- package/package.json +4 -2
- package/scripts/check-derivation-parity.ts +214 -0
- package/scripts/check-migrations.test.ts +39 -1
- package/scripts/check-migrations.ts +18 -6
- package/test/derivation-parity/README.md +42 -28
- package/test/derivation-parity/derivation-parity.test.ts +2 -33
- package/test/derivation-parity/flows.ts +83 -46
package/.github/workflows/ci.yml
CHANGED
|
@@ -5,6 +5,12 @@ on:
|
|
|
5
5
|
branches: [main]
|
|
6
6
|
push:
|
|
7
7
|
branches: [main]
|
|
8
|
+
# Merge-skew guard (issue #366): also run the required gates against the merge queue's
|
|
9
|
+
# PROSPECTIVE merged commit (GitHub Actions `merge_group` event). Several gates assert a whole-repo invariant (migration prefixes,
|
|
10
|
+
# BPMN DI freshness, committed generated artifacts) that two PRs can each satisfy in isolation yet
|
|
11
|
+
# violate once BOTH land on `main`. Validating the speculative merge commit the queue builds — not
|
|
12
|
+
# the stale PR head — blocks such a merge categorically instead of letting it poison the next PR.
|
|
13
|
+
merge_group:
|
|
8
14
|
|
|
9
15
|
permissions:
|
|
10
16
|
contents: read
|
|
@@ -77,3 +83,30 @@ jobs:
|
|
|
77
83
|
- name: E2E (urban-testkit)
|
|
78
84
|
run: npm run e2e
|
|
79
85
|
|
|
86
|
+
# FINAL regression guard for epic nano-ide#314 (S6, #321): the compounding oracle that keeps the
|
|
87
|
+
# code-first (`defineFlow`) and model-first (`.bpmn`) representations of the nano-workforce corpus
|
|
88
|
+
# in lockstep. For every model it DERIVES the BPMN from its defineFlow port, structurally DIFFS it
|
|
89
|
+
# against the checked-in golden via the S0 harness (@nanobpm/workflow/test-support), and DEPLOYS
|
|
90
|
+
# the derived model to the in-process @nanobpm/engine-wasm engine — failing on any structural drift
|
|
91
|
+
# OR deploy rejection. Parked models (awaiting an upstream construct) must each carry a documented
|
|
92
|
+
# blocker, and a self-proving canary asserts the oracle's red path genuinely fires so the gate can
|
|
93
|
+
# never rot into a vacuous green while the corpus is parked. Hermetic (in-process wasm engine, no
|
|
94
|
+
# sockets), so it runs on every PR/push as its own job.
|
|
95
|
+
derivation-parity:
|
|
96
|
+
name: derivation parity (nwf corpus regression guard)
|
|
97
|
+
runs-on: ubuntu-latest
|
|
98
|
+
steps:
|
|
99
|
+
- name: Checkout
|
|
100
|
+
uses: actions/checkout@v4
|
|
101
|
+
|
|
102
|
+
- name: Setup Node.js
|
|
103
|
+
uses: actions/setup-node@v4
|
|
104
|
+
with:
|
|
105
|
+
node-version: "24"
|
|
106
|
+
|
|
107
|
+
- name: Install dependencies
|
|
108
|
+
run: npm ci
|
|
109
|
+
|
|
110
|
+
- name: Derive + diff + deploy the full nwf corpus
|
|
111
|
+
run: npm run check:derivation-parity
|
|
112
|
+
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
name: Whole-repo invariants (merge-skew guard)
|
|
2
|
+
|
|
3
|
+
# Closes the merge-skew failure class (issue #366).
|
|
4
|
+
#
|
|
5
|
+
# Several gates assert a GLOBAL invariant / checked-in DERIVED artifact — `derived == f(sources)` —
|
|
6
|
+
# but the main CI only ever verifies them against a PR's OWN head. Two PRs can each pass in isolation
|
|
7
|
+
# and still break the invariant once BOTH squash-merge, because `main`'s `derived` is then
|
|
8
|
+
# `f(sources_A ∪ sources_B)`, which neither branch's green CI ever saw (the 052 migration collision,
|
|
9
|
+
# #359; the stale `retro.bpmn` DI, #365). Nothing re-checked `main`, so the breakage landed silently
|
|
10
|
+
# and poisoned the next unrelated PR to touch the same job.
|
|
11
|
+
#
|
|
12
|
+
# This lean workflow re-asserts those whole-repo invariants where the merge actually happens:
|
|
13
|
+
# - `merge_group` — the queue's PROSPECTIVE merged commit, so a skew is blocked BEFORE it lands.
|
|
14
|
+
# - `push: [main]` — a fast backstop that fails a `main`-scoped build within minutes if something
|
|
15
|
+
# slipped through, instead of first surfacing on an unrelated open PR.
|
|
16
|
+
# - `schedule` — a daily catch-all for any skew introduced by a merge that bypassed the queue.
|
|
17
|
+
on:
|
|
18
|
+
merge_group:
|
|
19
|
+
push:
|
|
20
|
+
branches: [main]
|
|
21
|
+
schedule:
|
|
22
|
+
# 06:00 UTC daily — cheap catch-all backstop.
|
|
23
|
+
- cron: "0 6 * * *"
|
|
24
|
+
workflow_dispatch:
|
|
25
|
+
|
|
26
|
+
permissions:
|
|
27
|
+
contents: read
|
|
28
|
+
|
|
29
|
+
jobs:
|
|
30
|
+
invariants:
|
|
31
|
+
name: whole-repo invariants
|
|
32
|
+
runs-on: ubuntu-latest
|
|
33
|
+
steps:
|
|
34
|
+
- name: Checkout
|
|
35
|
+
uses: actions/checkout@v4
|
|
36
|
+
with:
|
|
37
|
+
# Full history: the migration immutability gate diffs against the merge-base with
|
|
38
|
+
# origin/main, and the layout gate needs the whole tree — neither works on a shallow clone.
|
|
39
|
+
fetch-depth: 0
|
|
40
|
+
|
|
41
|
+
- name: Setup Node.js
|
|
42
|
+
uses: actions/setup-node@v4
|
|
43
|
+
with:
|
|
44
|
+
node-version: "24"
|
|
45
|
+
|
|
46
|
+
- name: Install dependencies
|
|
47
|
+
run: npm ci
|
|
48
|
+
|
|
49
|
+
# Prefix-collision (+ immutability): two branches that each took "the next" free migration
|
|
50
|
+
# prefix collide once merged. Re-run on the merged/`main` tree so the collision can't hide.
|
|
51
|
+
- name: Check migration prefixes (no collisions)
|
|
52
|
+
run: npm run check:migrations
|
|
53
|
+
|
|
54
|
+
# BPMN DI freshness: a merged semantic model can carry flows whose DI was regenerated on neither
|
|
55
|
+
# branch. Regenerate the DI over the merged model and fail if any committed diagram is stale.
|
|
56
|
+
- name: Check BPMN diagram freshness (layout)
|
|
57
|
+
run: npm run layout:check
|
|
58
|
+
|
|
59
|
+
# Generated-artifact freshness (`urban gen --check`): landscape.gen.html, JSON Schemas / OpenAPI,
|
|
60
|
+
# the processos grammar (ir.gbnf) and friends are `derived == f(sources)` — re-derive over the
|
|
61
|
+
# merged sources and fail if a committed artifact drifted.
|
|
62
|
+
- name: Check generated artifacts (urban gen --check)
|
|
63
|
+
run: npm run gen:check
|
|
64
|
+
|
|
65
|
+
# Navigation index is another checked-in derived artifact; re-assert it on the merged tree too.
|
|
66
|
+
- name: Check navigation index freshness
|
|
67
|
+
run: npm run sync:nav:check
|
|
68
|
+
|
|
69
|
+
# Backstop: catch ANY other committed generated file that the merged sources render stale, even
|
|
70
|
+
# one without its own `--check` script above. A clean tree is the whole-repo invariant.
|
|
71
|
+
- name: No stale committed artifacts on the merged tree
|
|
72
|
+
run: git diff --exit-code
|
package/AGENTS.md
CHANGED
|
@@ -272,7 +272,13 @@ Migrations live in `db/migrations/*.sql` and are **auto-applied on boot** from
|
|
|
272
272
|
Check `origin/main`, not your branch point — a fan-out epic branch forks at one
|
|
273
273
|
prefix while `main` keeps advancing, so the branch-local "next" number collides
|
|
274
274
|
on merge. Two files must never share a prefix; `npm run check:migrations`
|
|
275
|
-
(a CI gate) enforces this and fails the build on any new duplicate.
|
|
275
|
+
(a CI gate) enforces this and fails the build on any new duplicate. Because a
|
|
276
|
+
prefix collision only exists in the *union* of two branches, this gate — like
|
|
277
|
+
`layout:check` and the generated-artifact `--check`s — is also re-run on the
|
|
278
|
+
merge queue's **prospective merged commit** and on **push to `main`** by
|
|
279
|
+
`.github/workflows/invariants.yml` (issue #366), so a merge-skew collision is
|
|
280
|
+
blocked at merge time or fails a `main`-scoped build within minutes rather than
|
|
281
|
+
first surfacing on an unrelated open PR.
|
|
276
282
|
- **A merged migration is IMMUTABLE — never rename, delete, or edit it.** The
|
|
277
283
|
runtime keys the `_urban_migrations` ledger by *filename*, so a renamed file is
|
|
278
284
|
a *new* migration to the runner: it re-runs its DDL against an already-migrated
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [0.108.0](https://github.com/nanobpm/nano-workforce/compare/v0.107.2...v0.108.0) (2026-08-20)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **derivation-parity:** un-park retro to full whole-model parity ([#372](https://github.com/nanobpm/nano-workforce/issues/372)) ([1320838](https://github.com/nanobpm/nano-workforce/commit/1320838251646258ec2151a547ed2b5573ce1168)), closes [nano-ide#405](https://github.com/nano-ide/issues/405) [355/#356](https://github.com/nanobpm/nano-workforce/issues/356) [#371](https://github.com/nanobpm/nano-workforce/issues/371)
|
|
7
|
+
|
|
8
|
+
## [0.107.2](https://github.com/nanobpm/nano-workforce/compare/v0.107.1...v0.107.2) (2026-08-20)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **ci:** close the merge-skew failure class — re-check whole-repo invariants on the post-merge state ([#367](https://github.com/nanobpm/nano-workforce/issues/367)) ([62f5214](https://github.com/nanobpm/nano-workforce/commit/62f5214961cdb82a708d7cc08acecbf5eecf25ed)), closes [#359](https://github.com/nanobpm/nano-workforce/issues/359) [#365](https://github.com/nanobpm/nano-workforce/issues/365) [#366](https://github.com/nanobpm/nano-workforce/issues/366)
|
|
14
|
+
|
|
1
15
|
## [0.107.1](https://github.com/nanobpm/nano-workforce/compare/v0.107.0...v0.107.1) (2026-08-20)
|
|
2
16
|
|
|
3
17
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.108.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",
|
|
@@ -41,6 +41,8 @@
|
|
|
41
41
|
"heal:migrations": "node --experimental-strip-types scripts/heal-migration-ledger.ts",
|
|
42
42
|
"check:contracts": "node --experimental-strip-types scripts/check-contracts.ts",
|
|
43
43
|
"reconcile:contracts": "node --experimental-strip-types scripts/reconcile-contracts.ts",
|
|
44
|
+
"precheck:derivation-parity": "urban gen",
|
|
45
|
+
"check:derivation-parity": "node --experimental-strip-types scripts/check-derivation-parity.ts",
|
|
44
46
|
"gen": "urban gen",
|
|
45
47
|
"gen:check": "urban gen --check",
|
|
46
48
|
"layout": "node --experimental-strip-types scripts/layout-bpmn.ts",
|
|
@@ -62,7 +64,7 @@
|
|
|
62
64
|
"devDependencies": {
|
|
63
65
|
"@biomejs/biome": "^2.4.11",
|
|
64
66
|
"@nanobpm/urban-testkit": "^0.5.0",
|
|
65
|
-
"@nanobpm/workflow": "^0.
|
|
67
|
+
"@nanobpm/workflow": "^0.13.0",
|
|
66
68
|
"@semantic-release/changelog": "^6.0.3",
|
|
67
69
|
"@semantic-release/git": "^10.0.1",
|
|
68
70
|
"@semantic-release/npm": "^13.1.5",
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// check-derivation-parity — the FINAL regression guard for epic nanobpm/nano-ide#314 (S6, #321).
|
|
2
|
+
//
|
|
3
|
+
// This is the compounding oracle that keeps the code-first (`defineFlow`) and model-first (`.bpmn`)
|
|
4
|
+
// representations of the nano-workforce corpus in lockstep. For EVERY golden under
|
|
5
|
+
// `resources/processes/*.bpmn` it runs the full derive → diff → deploy loop:
|
|
6
|
+
//
|
|
7
|
+
// (a) DERIVE — take the golden's `defineFlow` port (test/derivation-parity/flows.ts) and derive
|
|
8
|
+
// its BPMN with `@nanobpm/workflow`.
|
|
9
|
+
// (b) DIFF — structurally compare the derived model against the checked-in golden using the S0
|
|
10
|
+
// parity harness (`@nanobpm/workflow/test-support`'s `normalize` / `assertDerivation-
|
|
11
|
+
// Parity`). The normalization/diff is NEVER reimplemented here — this gate only calls
|
|
12
|
+
// the shared harness, so the code-first check can't drift from the unit suite's.
|
|
13
|
+
// (c) DEPLOY — deploy the derived model to the in-process `@nanobpm/engine-wasm` engine (via
|
|
14
|
+
// `@nanobpm/urban-testkit`) and assert the engine ACCEPTS it.
|
|
15
|
+
//
|
|
16
|
+
// Any structural drift (b) OR deploy rejection (c) fails the build.
|
|
17
|
+
//
|
|
18
|
+
// PARKED MODELS ARE ACCOUNTED FOR, NOT IGNORED. `retro` is a green whole-model parity port; the
|
|
19
|
+
// remaining corpus is parked behind upstream `@nanobpm/workflow` constructs (see
|
|
20
|
+
// test/derivation-parity/flows.ts for the two blocker classes). Each parked model must carry a
|
|
21
|
+
// documented `blockedReason`; a model that is neither ported nor documented fails this gate, so the
|
|
22
|
+
// corpus can never silently lose coverage. As each parked model flips to a real `flow` upstream, it
|
|
23
|
+
// is automatically pulled into the full derive → diff → deploy loop here with NO change to this
|
|
24
|
+
// script.
|
|
25
|
+
//
|
|
26
|
+
// SELF-PROVING CANARY. To keep the gate honest even while most of the corpus is parked, a gate that
|
|
27
|
+
// merely iterated it could be a near-vacuous green — it could rot without anyone noticing. So before
|
|
28
|
+
// touching the corpus we run a CANARY that proves the oracle's RED path genuinely fires: a faithful
|
|
29
|
+
// derived flow deploys green and diffs green, a drifted derivation is CAUGHT by the diff, and a
|
|
30
|
+
// corrupted model is REJECTED by the engine. If any red path fails to fire (the diff misses drift,
|
|
31
|
+
// or the engine accepts garbage), the gate fails — the oracle must be able to say no.
|
|
32
|
+
|
|
33
|
+
import { mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
34
|
+
import { tmpdir } from "node:os";
|
|
35
|
+
import { dirname, join } from "node:path";
|
|
36
|
+
import { fileURLToPath } from "node:url";
|
|
37
|
+
import { createWasmEngineClient } from "@nanobpm/urban-testkit";
|
|
38
|
+
import type { DeclarativeFlow } from "@nanobpm/workflow";
|
|
39
|
+
import { declarativeToBpmn, defineFlow, toDeployableBpmn } from "@nanobpm/workflow";
|
|
40
|
+
import { assertDerivationParity } from "@nanobpm/workflow/test-support";
|
|
41
|
+
import { PORTS } from "../test/derivation-parity/flows.ts";
|
|
42
|
+
|
|
43
|
+
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
44
|
+
const PROCESSES_DIR = join(REPO_ROOT, "resources", "processes");
|
|
45
|
+
const goldenPath = (model: string): string => join(PROCESSES_DIR, `${model}.bpmn`);
|
|
46
|
+
|
|
47
|
+
type WasmEngine = Awaited<ReturnType<typeof createWasmEngineClient>>;
|
|
48
|
+
|
|
49
|
+
const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e));
|
|
50
|
+
|
|
51
|
+
/** Deploy a derived flow's *deployable* BPMN (semantic model + auto-layout DI) to the wasm engine
|
|
52
|
+
* and assert acceptance. Throws when the engine rejects the model or reports nothing deployed. */
|
|
53
|
+
async function deployDerived(engine: WasmEngine, id: string, flow: DeclarativeFlow): Promise<void> {
|
|
54
|
+
const xml = await toDeployableBpmn(flow);
|
|
55
|
+
const result = await engine.deployResources([{ name: `${id}.bpmn`, content: xml, contentType: "application/xml" }]);
|
|
56
|
+
if (!result || result.deployed < 1) {
|
|
57
|
+
throw new Error(`engine did not accept derived "${id}" (deployed=${result?.deployed ?? 0})`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Prove the derive → diff → deploy oracle can actually say NO, so an all-parked corpus can't let
|
|
62
|
+
* this gate rot into a vacuous green. Pushes a line onto `errors` for any red path that fails to
|
|
63
|
+
* fire. */
|
|
64
|
+
async function proveOracleFiresRed(engine: WasmEngine, errors: string[]): Promise<void> {
|
|
65
|
+
const dir = mkdtempSync(join(tmpdir(), "nwf-parity-canary-"));
|
|
66
|
+
try {
|
|
67
|
+
const canary = defineFlow("parity-canary", (w) => {
|
|
68
|
+
w.task("step-a", { jobType: "senior:noop" });
|
|
69
|
+
});
|
|
70
|
+
const golden = join(dir, "parity-canary.bpmn");
|
|
71
|
+
writeFileSync(golden, declarativeToBpmn(canary), "utf8");
|
|
72
|
+
|
|
73
|
+
// DIFF, green: a faithful derivation matches its own golden.
|
|
74
|
+
try {
|
|
75
|
+
assertDerivationParity(canary, golden);
|
|
76
|
+
} catch (e) {
|
|
77
|
+
errors.push(` canary: a faithful derivation was reported as drift — the diff is broken (${errMsg(e)})`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// DIFF, red: a structurally different derivation MUST be caught.
|
|
81
|
+
const drifted = defineFlow("parity-canary", (w) => {
|
|
82
|
+
w.task("step-a", { jobType: "senior:noop" });
|
|
83
|
+
w.task("step-b", { jobType: "senior:noop" });
|
|
84
|
+
});
|
|
85
|
+
let structuralFired = false;
|
|
86
|
+
try {
|
|
87
|
+
assertDerivationParity(drifted, golden);
|
|
88
|
+
} catch {
|
|
89
|
+
structuralFired = true;
|
|
90
|
+
}
|
|
91
|
+
if (!structuralFired) {
|
|
92
|
+
errors.push(" canary: the structural-drift oracle did NOT fire (an added node slipped past the diff)");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// DEPLOY, green: a valid derived model is accepted by the engine.
|
|
96
|
+
try {
|
|
97
|
+
await deployDerived(engine, "parity-canary", canary);
|
|
98
|
+
} catch (e) {
|
|
99
|
+
errors.push(` canary: the engine rejected a VALID derived model — the deploy oracle is broken (${errMsg(e)})`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// DEPLOY, red: a corrupted model MUST be rejected.
|
|
103
|
+
let deployFired = false;
|
|
104
|
+
try {
|
|
105
|
+
await engine.deployResources([{ name: "corrupt.bpmn", content: "<not-bpmn/>", contentType: "application/xml" }]);
|
|
106
|
+
} catch {
|
|
107
|
+
deployFired = true;
|
|
108
|
+
}
|
|
109
|
+
if (!deployFired) {
|
|
110
|
+
errors.push(" canary: the deploy-rejection oracle did NOT fire (the engine accepted invalid BPMN)");
|
|
111
|
+
}
|
|
112
|
+
} finally {
|
|
113
|
+
rmSync(dir, { recursive: true, force: true });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Assert PORTS covers EXACTLY the checked-in goldens under
|
|
118
|
+
* `resources/processes/*.bpmn`. Without this the guard's "full corpus" claim is
|
|
119
|
+
* unproven: a newly-added golden with no PORTS entry would be silently skipped
|
|
120
|
+
* (eroding coverage while still reporting OK), and a stale PORTS entry for a
|
|
121
|
+
* deleted golden would iterate a model that no longer exists. Both directions
|
|
122
|
+
* fail the gate. */
|
|
123
|
+
function assertPortsCoverGoldens(errors: string[]): void {
|
|
124
|
+
const goldens = readdirSync(PROCESSES_DIR)
|
|
125
|
+
.filter((f) => f.endsWith(".bpmn"))
|
|
126
|
+
.map((f) => f.slice(0, -".bpmn".length));
|
|
127
|
+
const ported = PORTS.map((p) => p.model);
|
|
128
|
+
|
|
129
|
+
const missing = goldens.filter((g) => !ported.includes(g)).sort();
|
|
130
|
+
if (missing.length > 0) {
|
|
131
|
+
errors.push(
|
|
132
|
+
` PORTS is missing ${missing.length} checked-in golden(s) under resources/processes ` +
|
|
133
|
+
`(${missing.join(", ")}) — add a derived flow or a documented blockedReason so the ` +
|
|
134
|
+
`"full corpus" guard can't silently skip them.`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const orphaned = ported.filter((m) => !goldens.includes(m)).sort();
|
|
139
|
+
if (orphaned.length > 0) {
|
|
140
|
+
errors.push(
|
|
141
|
+
` PORTS references ${orphaned.length} model(s) with no golden under resources/processes ` +
|
|
142
|
+
`(${orphaned.join(", ")}) — remove the stale entry or restore its golden.`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const seen = new Set<string>();
|
|
147
|
+
const duplicated = new Set<string>();
|
|
148
|
+
for (const m of ported) {
|
|
149
|
+
if (seen.has(m)) duplicated.add(m);
|
|
150
|
+
seen.add(m);
|
|
151
|
+
}
|
|
152
|
+
if (duplicated.size > 0) {
|
|
153
|
+
errors.push(
|
|
154
|
+
` PORTS has duplicate entries for ${duplicated.size} model(s) (${[...duplicated].sort().join(", ")}) — ` +
|
|
155
|
+
`each golden must appear exactly once.`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function main(): Promise<void> {
|
|
161
|
+
const errors: string[] = [];
|
|
162
|
+
const engine = await createWasmEngineClient();
|
|
163
|
+
|
|
164
|
+
let ported = 0;
|
|
165
|
+
let deployed = 0;
|
|
166
|
+
let parked = 0;
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
assertPortsCoverGoldens(errors);
|
|
170
|
+
await proveOracleFiresRed(engine, errors);
|
|
171
|
+
|
|
172
|
+
for (const port of PORTS) {
|
|
173
|
+
if (port.flow) {
|
|
174
|
+
ported++;
|
|
175
|
+
const golden = goldenPath(port.model);
|
|
176
|
+
try {
|
|
177
|
+
assertDerivationParity(port.flow, golden);
|
|
178
|
+
} catch (e) {
|
|
179
|
+
errors.push(` ${port.model}: STRUCTURAL DRIFT vs golden — ${errMsg(e)}`);
|
|
180
|
+
continue; // a model that doesn't derive its golden can't be trusted to deploy meaningfully
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
await deployDerived(engine, port.model, port.flow);
|
|
184
|
+
deployed++;
|
|
185
|
+
} catch (e) {
|
|
186
|
+
errors.push(` ${port.model}: DEPLOY REJECTED by wasm engine — ${errMsg(e)}`);
|
|
187
|
+
}
|
|
188
|
+
} else {
|
|
189
|
+
parked++;
|
|
190
|
+
if (!port.blockedReason || port.blockedReason.trim().length === 0) {
|
|
191
|
+
errors.push(
|
|
192
|
+
` ${port.model}: neither ported (no flow) nor documented (no blockedReason) — every ` +
|
|
193
|
+
`corpus model must derive its golden or carry a precise blocker.`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
} finally {
|
|
199
|
+
// Release the underlying WASM engine resources so repeated runs (local / CI matrix) don't leak.
|
|
200
|
+
await engine.close();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (errors.length > 0) {
|
|
204
|
+
console.error(`check-derivation-parity: the nano-workforce corpus failed its derivation-parity guard:\n${errors.join("\n")}`);
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
console.log(
|
|
209
|
+
`check-derivation-parity: OK (${PORTS.length} corpus models — ${ported} ported ` +
|
|
210
|
+
`[${deployed} deploy-accepted by the wasm engine], ${parked} documented-parked; oracle red paths verified).`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (import.meta.main) main();
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// with origin/main; here we drive its diff classifier directly with representative
|
|
7
7
|
// `git diff --find-renames --name-status` output so each violation shape is pinned.
|
|
8
8
|
import test from "node:test";
|
|
9
|
-
import { immutabilityErrorsFromDiff } from "./check-migrations.ts";
|
|
9
|
+
import { collisionErrorsFromFiles, immutabilityErrorsFromDiff } from "./check-migrations.ts";
|
|
10
10
|
import { assert, assertEquals } from "#test-assert";
|
|
11
11
|
|
|
12
12
|
test("a rename of a merged migration is a violation", () => {
|
|
@@ -56,3 +56,41 @@ test("mixed changes report every violation but ignore the addition", () => {
|
|
|
56
56
|
assert(errors.some((e) => /DELETED/.test(e)));
|
|
57
57
|
assert(errors.some((e) => /RENAMED/.test(e)));
|
|
58
58
|
});
|
|
59
|
+
|
|
60
|
+
// Merge-skew regression coverage (issue #366).
|
|
61
|
+
//
|
|
62
|
+
// The failure class this pins: two PRs each pass `check:migrations` on their OWN head, but their
|
|
63
|
+
// COMBINATION on `main` after both squash-merge violates the prefix-collision invariant — because
|
|
64
|
+
// neither branch's green CI ever saw the other's file. The 052 collision (#351 + #355, hotfixed in
|
|
65
|
+
// #359) was exactly this. Driving the pure collision detector with each branch's tree AND their union
|
|
66
|
+
// demonstrates that only the post-merge state trips the gate, which is why the gate must re-run on
|
|
67
|
+
// the prospective merged commit (merge_group) / on push to `main`, not just PR heads.
|
|
68
|
+
test("merge skew: two individually-clean branches whose union collides IS caught", () => {
|
|
69
|
+
const mainTree = ["050_capability_gates.sql", "051_merges_per_day.sql"];
|
|
70
|
+
// Each branch independently picks the same "next free" prefix (060) without seeing its sibling.
|
|
71
|
+
const branchA = [...mainTree, "060_plan_conformance.sql"];
|
|
72
|
+
const branchB = [...mainTree, "060_worker_durable_resume.sql"];
|
|
73
|
+
|
|
74
|
+
// On its own head, each branch is clean — this is why both PRs go green in isolation.
|
|
75
|
+
assertEquals(collisionErrorsFromFiles(branchA), [], "branch A alone has no colliding prefix");
|
|
76
|
+
assertEquals(collisionErrorsFromFiles(branchB), [], "branch B alone has no colliding prefix");
|
|
77
|
+
|
|
78
|
+
// The post-merge tree on `main` (git merges both files cleanly — the names don't textually
|
|
79
|
+
// conflict) now shares slot 060. The gate, re-run on that merged state, catches it.
|
|
80
|
+
const mergedOnMain = [...new Set([...branchA, ...branchB])].sort();
|
|
81
|
+
const errors = collisionErrorsFromFiles(mergedOnMain);
|
|
82
|
+
assertEquals(errors.length, 1, "the merged tree has exactly one colliding prefix");
|
|
83
|
+
assert(/prefix 060/.test(errors[0]));
|
|
84
|
+
assert(/060_plan_conformance.sql/.test(errors[0]));
|
|
85
|
+
assert(/060_worker_durable_resume.sql/.test(errors[0]));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("collision detector flags a non-NNN shape and grandfathers historical dupes", () => {
|
|
89
|
+
assert(collisionErrorsFromFiles(["nope.sql"]).some((e) => /required NNN_name.sql shape/.test(e)));
|
|
90
|
+
// Grandfathered historical collisions (already applied forward-only) must stay exempt.
|
|
91
|
+
assertEquals(
|
|
92
|
+
collisionErrorsFromFiles(["052_plan_conformance.sql", "052_worker_durable_resume.sql"]),
|
|
93
|
+
[],
|
|
94
|
+
"grandfathered prefix 052 is not a new violation",
|
|
95
|
+
);
|
|
96
|
+
});
|
|
@@ -150,11 +150,13 @@ function checkImmutability(errors: string[]): boolean {
|
|
|
150
150
|
return true;
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
153
|
+
/** Classify a migration file listing into shape + prefix-collision errors. Pure over the file list
|
|
154
|
+
* so the MERGE-SKEW scenario can be pinned in a test: two individually-clean branches each pass this
|
|
155
|
+
* over their OWN tree, but the gate must fail over the UNION that lands on `main` after both merge
|
|
156
|
+
* (issue #366). This is exactly why the gate has to re-run on the post-merge state — neither branch's
|
|
157
|
+
* green CI ever saw the other's prefix. Grandfathered historical collisions stay exempt. Exported
|
|
158
|
+
* for unit coverage. */
|
|
159
|
+
export function collisionErrorsFromFiles(files: readonly string[]): string[] {
|
|
158
160
|
const errors: string[] = [];
|
|
159
161
|
const byPrefix = new Map<string, string[]>();
|
|
160
162
|
|
|
@@ -175,13 +177,23 @@ function main(): void {
|
|
|
175
177
|
for (const [prefix, group] of byPrefix) {
|
|
176
178
|
if (group.length > 1 && !GRANDFATHERED_DUPES.has(prefix)) {
|
|
177
179
|
errors.push(
|
|
178
|
-
` prefix ${prefix} is used by ${group.length} files: ${group.join(", ")} — ` +
|
|
180
|
+
` prefix ${prefix} is used by ${group.length} files: ${[...group].sort().join(", ")} — ` +
|
|
179
181
|
`two migrations cannot share an apply-order slot. Renumber the newer one to the next ` +
|
|
180
182
|
`free prefix (check origin/main, not your branch point).`,
|
|
181
183
|
);
|
|
182
184
|
}
|
|
183
185
|
}
|
|
184
186
|
|
|
187
|
+
return errors;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function main(): void {
|
|
191
|
+
const files = readdirSync(MIGRATIONS_DIR)
|
|
192
|
+
.filter((f) => f.endsWith(".sql"))
|
|
193
|
+
.sort();
|
|
194
|
+
|
|
195
|
+
const errors: string[] = collisionErrorsFromFiles(files);
|
|
196
|
+
|
|
185
197
|
const immutabilityChecked = checkImmutability(errors);
|
|
186
198
|
|
|
187
199
|
if (errors.length > 0) {
|
|
@@ -26,7 +26,7 @@ behind an upstream construct, and do **not** relax to node-surface parity_:
|
|
|
26
26
|
|
|
27
27
|
| Model | Top-level start/end | Status |
|
|
28
28
|
| ------------------ | ------------------- | ------ |
|
|
29
|
-
| `retro` | 1 / 1 |
|
|
29
|
+
| `retro` | 1 / 1 | ✅ green — whole-model parity |
|
|
30
30
|
| `convergence-loop` | 1 / 1 | ⛔ parked — class 2 (arbitrary graph) |
|
|
31
31
|
| `spine-demo` | 1 / 2 | ⛔ parked — class 1 (multi start/end) |
|
|
32
32
|
| `readiness-gate` | 1 / 5 | ⛔ parked — class 1 (multi start/end) |
|
|
@@ -34,13 +34,17 @@ behind an upstream construct, and do **not** relax to node-surface parity_:
|
|
|
34
34
|
| `merge-loop` | 1 / 2 | ⛔ parked — class 1 (multi start/end) |
|
|
35
35
|
| `plan-fanout` | 3 / 3 | ⛔ parked — class 1 (multi start/end) |
|
|
36
36
|
|
|
37
|
-
`retro`
|
|
38
|
-
pipeline
|
|
39
|
-
subgraph
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
37
|
+
`retro` is a green whole-model parity port — a single-start/single-end agent
|
|
38
|
+
pipeline (gather → conformance → record-conformance) with a `deviations?`
|
|
39
|
+
conformance-escalation subgraph (#355/#356) and a shared synthesize → record
|
|
40
|
+
tail. Its `record-conformance-ack` service task carries a general
|
|
41
|
+
`<zeebe:ioMapping>` that the stock `task` builder could not emit until
|
|
42
|
+
`@nanobpm/workflow@0.13.0` landed the general service-task `io` construct
|
|
43
|
+
(nano-ide#405); every other element was always expressible (`w.task`+prompt,
|
|
44
|
+
`w.branch`, `w.human`, envelopes). The remaining six goldens are parked across
|
|
45
|
+
**two** distinct blocker classes, each awaiting an upstream `@nanobpm/workflow`
|
|
46
|
+
(nano-ide) construct + re-release (never a golden edit, never relaxed
|
|
47
|
+
acceptance).
|
|
44
48
|
|
|
45
49
|
## The blockers
|
|
46
50
|
|
|
@@ -83,24 +87,6 @@ pinned by a diagnostic in `derivation-parity.test.ts`:
|
|
|
83
87
|
The fix is an **arbitrary-graph / explicit-join (named-target)** builder upstream
|
|
84
88
|
in `@nanobpm/workflow` — a **superset** of the class-1 gap.
|
|
85
89
|
|
|
86
|
-
### Class 3 — general service-task ioMapping (`retro`)
|
|
87
|
-
|
|
88
|
-
`retro` clears classes 1 and 2 (single start/end, structured topology), but its
|
|
89
|
-
golden's `record-conformance-ack` service task carries a **general**
|
|
90
|
-
`<zeebe:ioMapping>` — inputs `=planKey`→`planKey` and
|
|
91
|
-
`=if (is defined(note)) then note else null`→`note`. `@nanobpm/workflow@0.12.0`'s
|
|
92
|
-
`task` builder only emits an ioMapping as a side effect of a `prompt.append` (a
|
|
93
|
-
single `appendPrompt` input); there is no way to declare arbitrary input/output
|
|
94
|
-
mappings on a service task. Every other element of the golden IS expressible
|
|
95
|
-
(`w.task`+prompt, `w.branch` for the `deviations?` gateway, `w.human` for the
|
|
96
|
-
`conformance-escalation` userTask, envelopes) — this one service-task ioMapping
|
|
97
|
-
is the sole gap. The `retro golden needs a general service-task ioMapping the
|
|
98
|
-
stock builder cannot emit` diagnostic pins both halves (the golden needs it; the
|
|
99
|
-
stock builder cannot produce it).
|
|
100
|
-
|
|
101
|
-
The fix is a general `io: { input?, output? }` on the external `task`/`run`
|
|
102
|
-
builder upstream in `@nanobpm/workflow` (**nano-ide#405**).
|
|
103
|
-
|
|
104
90
|
## Resuming this slice
|
|
105
91
|
|
|
106
92
|
The follow-up upstream slices (opened in **nanobpm/nano-ide** per decision path
|
|
@@ -108,8 +94,11 @@ The follow-up upstream slices (opened in **nanobpm/nano-ide** per decision path
|
|
|
108
94
|
|
|
109
95
|
- a terminal / explicit-end (+ multi-start) construct (unblocks class 1), **and**
|
|
110
96
|
- an arbitrary-graph / explicit-join (named-target) builder (unblocks class 2 —
|
|
111
|
-
`convergence-loop`)
|
|
112
|
-
|
|
97
|
+
`convergence-loop`).
|
|
98
|
+
|
|
99
|
+
(The class-3 general service-task `io` mapping has already landed —
|
|
100
|
+
`@nanobpm/workflow@0.13.0`, nano-ide#405 — unparking `retro` to whole-model
|
|
101
|
+
parity.)
|
|
113
102
|
|
|
114
103
|
Then, on a resumed run here:
|
|
115
104
|
|
|
@@ -121,3 +110,28 @@ Then, on a resumed run here:
|
|
|
121
110
|
|
|
122
111
|
No golden `.bpmn` file may be edited to force a match — the derivation must
|
|
123
112
|
reproduce the checked-in golden.
|
|
113
|
+
|
|
114
|
+
## CI regression guard (S6, nano-ide#321)
|
|
115
|
+
|
|
116
|
+
The unit suite above (run under `npm test`) does the DERIVE + structural DIFF for
|
|
117
|
+
every ported model. The **final regression guard** adds the third leg — DEPLOY —
|
|
118
|
+
and wires the whole corpus into CI as its own job:
|
|
119
|
+
|
|
120
|
+
npm run check:derivation-parity # scripts/check-derivation-parity.ts
|
|
121
|
+
|
|
122
|
+
For every model it derives the BPMN from its `defineFlow` port, structurally
|
|
123
|
+
diffs it against the checked-in golden via the **same** S0 harness
|
|
124
|
+
(`normalize` / `assertDerivationParity` — never reimplemented), and **deploys the
|
|
125
|
+
derived model to the in-process `@nanobpm/engine-wasm` engine**
|
|
126
|
+
(`@nanobpm/urban-testkit`), asserting the engine accepts it. Any structural drift
|
|
127
|
+
**or** deploy rejection fails the build. Parked models must each carry a
|
|
128
|
+
documented `blockedReason`, so the corpus can never silently lose coverage; a
|
|
129
|
+
parked model flips into the full derive → diff → deploy loop automatically the
|
|
130
|
+
moment its `flow` lands — no change to the gate.
|
|
131
|
+
|
|
132
|
+
Because most of the corpus is (currently) parked, the gate runs a **self-proving
|
|
133
|
+
canary** first: it proves a faithful derivation deploys and diffs green, a
|
|
134
|
+
drifted derivation is caught by the diff, and a corrupted model is rejected by
|
|
135
|
+
the engine — so the oracle's red path can never rot into a vacuous green. The
|
|
136
|
+
guard runs on every PR/push as the `derivation-parity` job in
|
|
137
|
+
`.github/workflows/ci.yml`.
|
|
@@ -85,8 +85,8 @@ test("class-1 blocked goldens genuinely have multiple top-level start/end events
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
// The two single-start/single-end goldens (retro, convergence-loop) clear
|
|
88
|
-
// class 1; retro is
|
|
89
|
-
// convergence-loop is class-2 blocked
|
|
88
|
+
// class 1; retro is a GREEN whole-model parity port (it runs through
|
|
89
|
+
// `assertDerivationParity` above), convergence-loop is class-2 blocked (below).
|
|
90
90
|
for (const model of ["retro", "convergence-loop"]) {
|
|
91
91
|
const xml = readFileSync(goldenPath(model), "utf8");
|
|
92
92
|
assertEquals(countTag(xml, "startEvent"), 1, `${model} should have one start event`);
|
|
@@ -94,37 +94,6 @@ test("class-1 blocked goldens genuinely have multiple top-level start/end events
|
|
|
94
94
|
}
|
|
95
95
|
});
|
|
96
96
|
|
|
97
|
-
// CLASS 3 — retro clears classes 1 & 2 (single start/end, structured topology)
|
|
98
|
-
// but its golden carries a service task with a GENERAL <zeebe:ioMapping> — inputs
|
|
99
|
-
// whose target is NOT `appendPrompt` — which @nanobpm/workflow@0.12.0's `task`
|
|
100
|
-
// builder cannot emit (it only produces an ioMapping via a `prompt.append`, i.e.
|
|
101
|
-
// a lone `appendPrompt` input). Prove both halves: the golden needs it, and the
|
|
102
|
-
// stock builder cannot produce it (awaits nano-ide#405).
|
|
103
|
-
test("retro golden needs a general service-task ioMapping the stock builder cannot emit", () => {
|
|
104
|
-
const xml = readFileSync(goldenPath("retro"), "utf8");
|
|
105
|
-
// (a) The golden has a service task carrying an ioMapping input to a non-prompt
|
|
106
|
-
// target (`record-conformance-ack`: =planKey→planKey, note→note).
|
|
107
|
-
assert(
|
|
108
|
-
/target="planKey"/.test(xml) && /target="note"/.test(xml),
|
|
109
|
-
"retro golden should carry general ioMapping inputs (planKey, note) on record-conformance-ack",
|
|
110
|
-
);
|
|
111
|
-
// (b) The stock `task` builder only ever emits `appendPrompt` as an ioMapping
|
|
112
|
-
// target — never a general input like `note` — so the golden is not
|
|
113
|
-
// derivable until the upstream `io` construct lands.
|
|
114
|
-
const probe = defineFlow("io-probe", (w) => {
|
|
115
|
-
w.task("agent", {
|
|
116
|
-
jobType: "senior:retro",
|
|
117
|
-
prompt: { resourceId: "retro.md", bindingType: "latest", append: "=retroDigest" },
|
|
118
|
-
});
|
|
119
|
-
});
|
|
120
|
-
const derived = declarativeToBpmn(probe);
|
|
121
|
-
assert(/target="appendPrompt"/.test(derived), "prompt.append should emit an appendPrompt ioMapping input");
|
|
122
|
-
assert(
|
|
123
|
-
!/target="note"/.test(derived) && !/target="planKey"/.test(derived),
|
|
124
|
-
"the stock task builder cannot emit a general (non-appendPrompt) ioMapping input",
|
|
125
|
-
);
|
|
126
|
-
});
|
|
127
|
-
|
|
128
97
|
// CLASS 2 — convergence-loop has a single start/end (clears class 1) but an
|
|
129
98
|
// ARBITRARY control-flow graph the structured-only builder cannot emit. Prove
|
|
130
99
|
// the three specific features against the golden itself.
|
|
@@ -9,12 +9,13 @@
|
|
|
9
9
|
// the structurally-derivable goldens at full whole-model parity, park the rest
|
|
10
10
|
// pending an upstream construct, and do NOT relax to node-surface parity):
|
|
11
11
|
//
|
|
12
|
-
// •
|
|
13
|
-
//
|
|
14
|
-
// + re-release — never a
|
|
12
|
+
// • `retro` is a GREEN whole-model parity port (see below). The remaining six
|
|
13
|
+
// goldens are `blockedReason`-parked, in TWO distinct classes, each awaiting
|
|
14
|
+
// an upstream `@nanobpm/workflow` (nano-ide) construct + re-release — never a
|
|
15
|
+
// golden edit and never relaxed acceptance:
|
|
15
16
|
//
|
|
16
17
|
// (1) MULTI top-level start/end (spine-demo, readiness-gate, feature,
|
|
17
|
-
// merge-loop, plan-fanout). `@nanobpm/workflow
|
|
18
|
+
// merge-loop, plan-fanout). `@nanobpm/workflow` derives EXACTLY
|
|
18
19
|
// ONE `<bpmn:startEvent id="Start">` + ONE `<bpmn:endEvent id="End">`,
|
|
19
20
|
// converging every dangler into that single end (see `Compiler.compile`
|
|
20
21
|
// in the package's `declarative.ts`). Needs a terminal/explicit-end
|
|
@@ -34,23 +35,10 @@
|
|
|
34
35
|
// arbitrary-graph / explicit-join (named-target) builder — a SUPERSET of
|
|
35
36
|
// the class-(1) gap.
|
|
36
37
|
//
|
|
37
|
-
// (3) GENERAL service-task ioMapping (retro). retro WAS a green full-parity
|
|
38
|
-
// port (a linear gather → synthesize → record agent pipeline) until the
|
|
39
|
-
// conformance work (nano-workforce #355/#356) added a conformance-
|
|
40
|
-
// escalation subgraph to its golden. Every new element ports with the
|
|
41
|
-
// stock builder (`w.branch` for the `deviations?` gateway, `w.human` for
|
|
42
|
-
// the `conformance-escalation` userTask, `w.task`+prompt/envelopes for
|
|
43
|
-
// the service tasks) EXCEPT `record-conformance-ack`: a service task with
|
|
44
|
-
// a general <zeebe:ioMapping> (inputs `=planKey`→planKey and
|
|
45
|
-
// `=if (is defined(note)) then note else null`→note). @nanobpm/workflow@
|
|
46
|
-
// 0.12.0's `task` builder only emits an ioMapping via a `prompt.append`
|
|
47
|
-
// (a single `appendPrompt` input), so this task is not derivable. Needs a
|
|
48
|
-
// general `io` on the task/run builder upstream (nano-ide#405).
|
|
49
|
-
//
|
|
50
38
|
// A resumed run flips any parked model to a real `flow` once the corresponding
|
|
51
|
-
// upstream construct lands and `@nanobpm/workflow` is bumped
|
|
39
|
+
// upstream construct lands and `@nanobpm/workflow` is bumped to carry it.
|
|
52
40
|
|
|
53
|
-
import type
|
|
41
|
+
import { type DeclarativeFlow, defineFlow, envelope } from "@nanobpm/workflow";
|
|
54
42
|
|
|
55
43
|
/** One model's port entry: the golden basename plus EITHER the derived flow
|
|
56
44
|
* (when it can be reproduced) OR the reason it is blocked — never both and never
|
|
@@ -72,38 +60,87 @@ export type PortEntry =
|
|
|
72
60
|
readonly blockedReason: string;
|
|
73
61
|
};
|
|
74
62
|
|
|
75
|
-
// ── retro (
|
|
76
|
-
// retro
|
|
77
|
-
// agent pipeline
|
|
78
|
-
// conformance-escalation subgraph
|
|
79
|
-
//
|
|
80
|
-
// `
|
|
81
|
-
//
|
|
82
|
-
//
|
|
63
|
+
// ── retro (GREEN — whole-model parity) ───────────────────────────────────────
|
|
64
|
+
// retro is a single-start/single-end model: a linear gather → conformance →
|
|
65
|
+
// record-conformance agent pipeline, a `deviations?` exclusive gateway guarding a
|
|
66
|
+
// conformance-escalation subgraph (nano-workforce #355/#356), then a shared
|
|
67
|
+
// synthesize → record tail. Every element ports with the stock builder:
|
|
68
|
+
// `w.task`+envelopes for the data-envelope service tasks, `w.task`+prompt for the
|
|
69
|
+
// two agent tasks (`senior:conformance`, `senior:retro`), `w.branch` for the
|
|
70
|
+
// `deviations?` gateway, `w.human` for the `conformance-escalation` userTask, and
|
|
71
|
+
// — since @nanobpm/workflow@0.13.0 landed the general service-task `io` construct
|
|
72
|
+
// (nano-ide#405) — `w.task`+`io` for `record-conformance-ack`'s general
|
|
83
73
|
// <zeebe:ioMapping> (inputs `=planKey`→planKey and
|
|
84
|
-
// `=if (is defined(note)) then note else null`→note)
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
74
|
+
// `=if (is defined(note)) then note else null`→note).
|
|
75
|
+
|
|
76
|
+
/** The typed data envelopes retro's non-agent service tasks lift into the model
|
|
77
|
+
* (`nano:shape` + `io.nanobpm.dataEnvelope.in`), matching the golden's shapes. */
|
|
78
|
+
const RetroGatherIn = envelope("RetroGatherIn", { planKey: "string" });
|
|
79
|
+
const RetroRecordIn = envelope("RetroRecordIn", {
|
|
80
|
+
planKey: "string",
|
|
81
|
+
retroLearnings: { type: "integer", optional: true },
|
|
82
|
+
status: { type: "string", optional: true },
|
|
83
|
+
pr: { type: "string", optional: true },
|
|
84
|
+
summary: { type: "string", optional: true },
|
|
85
|
+
});
|
|
86
|
+
const ConformanceRecordIn = envelope("ConformanceRecordIn", {
|
|
87
|
+
planKey: "string",
|
|
88
|
+
status: { type: "string", optional: true },
|
|
89
|
+
commentUrl: { type: "string", optional: true },
|
|
90
|
+
slicesMet: { type: "integer", optional: true },
|
|
91
|
+
slicesReduced: { type: "integer", optional: true },
|
|
92
|
+
slicesNotVerified: { type: "integer", optional: true },
|
|
93
|
+
deviationsRaised: { type: "integer", optional: true },
|
|
94
|
+
deviationsUnraised: { type: "integer", optional: true },
|
|
95
|
+
hasDeviations: { type: "boolean", optional: true },
|
|
96
|
+
summary: { type: "string", optional: true },
|
|
97
|
+
});
|
|
90
98
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
"
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
/** The code-first port of `resources/processes/retro.bpmn`. */
|
|
100
|
+
const retroFlow: DeclarativeFlow = defineFlow(
|
|
101
|
+
"retro",
|
|
102
|
+
{
|
|
103
|
+
gather: { in: RetroGatherIn },
|
|
104
|
+
"record-conformance": { in: ConformanceRecordIn },
|
|
105
|
+
record: { in: RetroRecordIn },
|
|
106
|
+
},
|
|
107
|
+
(w) => {
|
|
108
|
+
w.task("gather", { jobType: "pr.retro-gather" });
|
|
109
|
+
w.task("conformance", {
|
|
110
|
+
jobType: "senior:conformance",
|
|
111
|
+
prompt: { resourceId: "conformance.md", bindingType: "latest", append: "=conformanceDigest" },
|
|
112
|
+
});
|
|
113
|
+
w.task("record-conformance", { jobType: "pr.conformance-record" });
|
|
114
|
+
w.branch("hasDeviations = true", {
|
|
115
|
+
then: (b) => {
|
|
116
|
+
b.human("conformance-escalation", {
|
|
117
|
+
form: "conformance-escalation",
|
|
118
|
+
candidateGroups: "operators",
|
|
119
|
+
});
|
|
120
|
+
b.task("record-conformance-ack", {
|
|
121
|
+
jobType: "pr.conformance-ack",
|
|
122
|
+
io: {
|
|
123
|
+
input: [
|
|
124
|
+
{ source: "=planKey", target: "planKey" },
|
|
125
|
+
{ source: "=if (is defined(note)) then note else null", target: "note" },
|
|
126
|
+
],
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
w.task("synthesize", {
|
|
132
|
+
jobType: "senior:retro",
|
|
133
|
+
prompt: { resourceId: "retro.md", bindingType: "latest", append: "=retroDigest" },
|
|
134
|
+
});
|
|
135
|
+
w.task("record", { jobType: "pr.retro-record" });
|
|
136
|
+
},
|
|
137
|
+
);
|
|
101
138
|
|
|
102
139
|
/** The single-top-level-end/start compiler limitation, reused as the
|
|
103
140
|
* `blockedReason` for every golden that has more than one top-level start
|
|
104
141
|
* and/or end event. */
|
|
105
142
|
const MULTI_START_END_BLOCK =
|
|
106
|
-
"blocked: @nanobpm/workflow
|
|
143
|
+
"blocked: the published @nanobpm/workflow compiler derives a single top-level start/end and " +
|
|
107
144
|
"converges all danglers into one <endEvent id=\"End\">; this golden has " +
|
|
108
145
|
"multiple top-level start and/or end events, which the published compiler " +
|
|
109
146
|
"cannot reproduce. Awaits an upstream terminal/explicit-end (+ multi-start) " +
|
|
@@ -111,7 +148,7 @@ const MULTI_START_END_BLOCK =
|
|
|
111
148
|
|
|
112
149
|
/** All seven ports, keyed by model, in the epic's stated authoring order. */
|
|
113
150
|
export const PORTS: readonly PortEntry[] = [
|
|
114
|
-
{ model: "retro",
|
|
151
|
+
{ model: "retro", flow: retroFlow },
|
|
115
152
|
{
|
|
116
153
|
model: "spine-demo",
|
|
117
154
|
blockedReason: `${MULTI_START_END_BLOCK} (spine-demo: 1 start, 2 ends)`,
|
|
@@ -128,7 +165,7 @@ export const PORTS: readonly PortEntry[] = [
|
|
|
128
165
|
model: "convergence-loop",
|
|
129
166
|
blockedReason:
|
|
130
167
|
"blocked (arbitrary control-flow graph): single top-level start/end, but " +
|
|
131
|
-
"its topology is not expressible with @nanobpm/workflow
|
|
168
|
+
"its topology is not expressible with the published @nanobpm/workflow's " +
|
|
132
169
|
"structured-only builder (loop/switch/branch). Proven in the test suite: " +
|
|
133
170
|
"the loop head `review-round` is a serviceTask that merges 3 back-edges " +
|
|
134
171
|
"directly (in=3), but loop() always inserts an exclusive-gateway head " +
|