@nanobpm/nano-workforce 0.129.0 → 0.130.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.
@@ -1,9 +1,11 @@
1
1
  name: PR title lint
2
2
 
3
3
  # Squash-merge uses the PR title as the commit subject on `main`, and
4
- # semantic-release (Angular preset) only cuts a release for `feat:`/`fix:`/`perf:`.
5
- # A non-conventional title therefore lands on `main` and is silently skipped by
6
- # the release job so gate the title against the Conventional Commits grammar.
4
+ # semantic-release (conventionalcommits preset) cuts a release for
5
+ # `feat:`/`fix:`/`perf:`/`refactor:`/`build:`/`revert:`/`docs:` (see
6
+ # `.releaserc.json` releaseRules). A non-conventional title therefore lands on
7
+ # `main` and is silently skipped by the release job — so gate the title against
8
+ # the Conventional Commits grammar.
7
9
  on:
8
10
  pull_request:
9
11
  types: [opened, edited, synchronize, reopened]
package/.releaserc.json CHANGED
@@ -1,8 +1,42 @@
1
1
  {
2
2
  "branches": ["main"],
3
3
  "plugins": [
4
- "@semantic-release/commit-analyzer",
5
- "@semantic-release/release-notes-generator",
4
+ [
5
+ "@semantic-release/commit-analyzer",
6
+ {
7
+ "preset": "conventionalcommits",
8
+ "releaseRules": [
9
+ { "type": "feat", "release": "minor" },
10
+ { "type": "fix", "release": "patch" },
11
+ { "type": "perf", "release": "patch" },
12
+ { "type": "refactor", "release": "patch" },
13
+ { "type": "build", "release": "patch" },
14
+ { "type": "revert", "release": "patch" },
15
+ { "type": "docs", "release": "patch" }
16
+ ]
17
+ }
18
+ ],
19
+ [
20
+ "@semantic-release/release-notes-generator",
21
+ {
22
+ "preset": "conventionalcommits",
23
+ "presetConfig": {
24
+ "types": [
25
+ { "type": "feat", "section": "Features" },
26
+ { "type": "fix", "section": "Bug Fixes" },
27
+ { "type": "perf", "section": "Performance Improvements" },
28
+ { "type": "refactor", "section": "Code Refactoring" },
29
+ { "type": "build", "section": "Build System" },
30
+ { "type": "revert", "section": "Reverts" },
31
+ { "type": "docs", "section": "Documentation" },
32
+ { "type": "test", "hidden": true },
33
+ { "type": "ci", "hidden": true },
34
+ { "type": "chore", "hidden": true },
35
+ { "type": "style", "hidden": true }
36
+ ]
37
+ }
38
+ }
39
+ ],
6
40
  "@semantic-release/changelog",
7
41
  "@semantic-release/npm",
8
42
  "@semantic-release/github",
package/AGENTS.md CHANGED
@@ -353,16 +353,20 @@ agents:
353
353
  - **DCO sign-off is enforced.** Every commit needs a `Signed-off-by` trailer —
354
354
  use `git commit -s` (or `git rebase --signoff`). A missing sign-off fails the
355
355
  DCO check.
356
- - **Conventional Commits.** `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`,
357
- `test:`, imperative mood. Review-comment fix-ups are `chore:`, not `fix:`.
356
+ - **Conventional Commits.** `feat:`, `fix:`, `perf:`, `refactor:`, `build:`,
357
+ `revert:`, `docs:`, `chore:`, `ci:`, `test:`, `style:`, imperative mood — see the
358
+ next bullet for which of these trigger a release. Review-comment fix-ups are
359
+ `chore:`, not `fix:`.
358
360
  - **PR titles must be Conventional too — they become the release trigger.** PRs
359
361
  land on `main` via **squash merge**, so the **PR title is the commit subject**
360
- semantic-release analyses. Only `feat:` (minor) and `fix:`/`perf:` (patch) cut a
361
- release; any other type or a non-conventional title like `Redesign …` or
362
- `Foundation: …` — lands on `main` and is **silently skipped** by the release job
362
+ semantic-release analyses. `feat:` cuts a **minor**; `fix:`, `perf:`,
363
+ `refactor:`, `build:`, `revert:`, and `docs:` cut a **patch** (see
364
+ `.releaserc.json` `releaseRules`); a `BREAKING CHANGE:` footer cuts a **major**.
365
+ Only `chore:`, `ci:`, `test:`, and `style:` are **no-release** — a PR titled
366
+ with one of those lands on `main` and is **silently skipped** by the release job
363
367
  (no version, no changelog, no deploy). A user-facing feature **must** be titled
364
368
  `feat:`. The `PR title lint` workflow (`.github/workflows/pr-title-lint.yml`)
365
- enforces this; if a non-conventional title ever slips through, push one empty
369
+ enforces this; if a non-releasing title ever slips through, push one empty
366
370
  releasable commit (`git commit --allow-empty -s -m "feat(scope): …"`) to release
367
371
  the accumulated changes.
368
372
  - **Feature work in a worktree** off `origin/main`, one branch per change; open a
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.130.0](https://github.com/nanobpm/nano-workforce/compare/v0.129.1...v0.130.0) (2026-08-23)
2
+
3
+ ### Features
4
+
5
+ * **delivery-unit:** one derived delivery-unit status union (ADR 0006, S1) ([#494](https://github.com/nanobpm/nano-workforce/issues/494)) ([47245d3](https://github.com/nanobpm/nano-workforce/commit/47245d37599a2f220c9835672c37656b6b0e6a6d)), closes [#464](https://github.com/nanobpm/nano-workforce/issues/464)
6
+
7
+ ## [0.129.1](https://github.com/nanobpm/nano-workforce/compare/v0.129.0...v0.129.1) (2026-08-23)
8
+
9
+ ### Code Refactoring
10
+
11
+ * **merge-loop:** sub-process topology + behavioural guards ([#466](https://github.com/nanobpm/nano-workforce/issues/466)) ([#490](https://github.com/nanobpm/nano-workforce/issues/490)) ([ac6f130](https://github.com/nanobpm/nano-workforce/commit/ac6f130d19e545c4d3da23d738c30c1cb6f2b981)), closes [Magikcraft/nano-bpm#971](https://github.com/Magikcraft/nano-bpm/issues/971)
12
+
1
13
  # [0.129.0](https://github.com/nanobpm/nano-workforce/compare/v0.128.0...v0.129.0) (2026-08-23)
2
14
 
3
15
 
package/app/delivery.ts CHANGED
@@ -78,7 +78,7 @@ export function deriveDelivery(
78
78
  /** The `plan.status` values that mean the epic's fan-out lifecycle is still LIVE — the planner is
79
79
  * decomposing (`planning`) or the fleet is implementing (`dispatched`). Both are unambiguously
80
80
  * in-flight, so an epic in either status is always in the Active bucket regardless of `delivery`. */
81
- export const EPIC_LIVE_STATUSES: readonly string[] = ["planning", "dispatched"];
81
+ export const EPIC_LIVE_STATUSES = ["planning", "dispatched"] as const;
82
82
 
83
83
  /** The Active/History partition for an EPIC (issue #298), the twin of feature runs'
84
84
  * `deriveListBucket` (app/stage.ts). It exists because an epic must NOT vanish from the Active list
@@ -108,7 +108,7 @@ export function deriveEpicBucket(
108
108
  delivery: string | null | undefined,
109
109
  acknowledgedAt: string | null | undefined,
110
110
  ): "active" | "history" {
111
- if (EPIC_LIVE_STATUSES.includes(status)) return "active";
111
+ if (EPIC_LIVE_STATUSES.some((s) => s === status)) return "active";
112
112
  if (status === "done") {
113
113
  // A still-`converging` epic is Active regardless of any (stray) acknowledged_at — it is genuinely
114
114
  // working and is not acknowledgeable, so it can never be ticked off mid-flight (fail-closed).
@@ -0,0 +1,143 @@
1
+ // Coverage for ADR 0006 slice S1 — the ONE canonical delivery-unit status union and the per-shape
2
+ // derivations that map each bespoke source union into it (app/deliveryUnitStatus.ts).
3
+ //
4
+ // Guards THREE things:
5
+ // 1. TOTALITY — every member of each source union (feature / plan aggregate / plan-task node /
6
+ // delivery-graph run) maps to a valid canonical member, and the compiled `fnFor` agrees with the
7
+ // declared map object (so the DSL derivation and the TS map cannot drift).
8
+ // 2. TERMINALITY PRECEDENCE — a source SETTLED/terminal status always maps to a canonical
9
+ // settled/terminal status, so a reconciler reading the union never mistakes a finished unit for a
10
+ // live one (or vice-versa).
11
+ // 3. FRAMEWORK PARITY — for each of the four read models, `assertReadModelParity` proves the SQL VIEW
12
+ // lowering and the TS `fnFor` lowering agree over the full source-status matrix.
13
+ import { DatabaseSync } from "node:sqlite";
14
+ import { test } from "node:test";
15
+ import { assertReadModelParity, type ParityDb, type ParitySample } from "@nanobpm/urban";
16
+ import { assert, assertEquals } from "#test-assert";
17
+ import { DELIVERY_GRAPH_RUN_STATUSES, DELIVERY_GRAPH_TERMINAL_STATUSES } from "./deliveryGraphRun.ts";
18
+ import {
19
+ DELIVERY_GRAPH_STATUS_TO_UNIT,
20
+ DELIVERY_STATUS_COLUMN,
21
+ DELIVERY_UNIT_SETTLED_STATUSES,
22
+ DELIVERY_UNIT_STATUSES,
23
+ DELIVERY_UNIT_TERMINAL_STATUSES,
24
+ type DeliveryUnitStatus,
25
+ deliveryGraphDeliveryStatus,
26
+ FEATURE_STATUS_TO_UNIT,
27
+ featureDeliveryStatus,
28
+ isDeliveryUnitSettled,
29
+ isDeliveryUnitTerminal,
30
+ PLAN_STATUS_TO_UNIT,
31
+ PLAN_STATUSES,
32
+ PLAN_TASK_STATUS_TO_UNIT,
33
+ planDeliveryStatus,
34
+ planTaskDeliveryStatus,
35
+ toDeliveryUnitStatus,
36
+ } from "./deliveryUnitStatus.ts";
37
+ import { FEATURE_RUN_STATUSES, FEATURE_TERMINAL_STATUSES } from "./feature.ts";
38
+ import { PLAN_TASK_STATUSES, PLAN_TERMINAL_STATUSES } from "./plan.ts";
39
+
40
+ const CANONICAL = new Set<string>(DELIVERY_UNIT_STATUSES);
41
+
42
+ // ── Type-level No-Drift guard (issue #464 review) ────────────────────────────────────────────────
43
+ // `PLAN_STATUSES` is DERIVED from `EPIC_LIVE_STATUSES` (app/delivery.ts) + `PLAN_TERMINAL_STATUSES`
44
+ // (app/plan.ts). If either source is declared as a widened `readonly string[]` instead of an `as const`
45
+ // literal tuple, `(typeof PLAN_STATUSES)[number]` collapses to `string`, `PLAN_STATUS_TO_UNIT` degrades
46
+ // to `Record<string, …>`, and the exhaustiveness guard silently evaporates — `tsc` would no longer fail
47
+ // when the plan vocabulary gains a member without a canonical mapping. This assertion fails to COMPILE
48
+ // if that widening ever returns (the `false` branch makes `true` unassignable).
49
+ type _IsLiteralUnion<T extends string> = string extends T ? false : true;
50
+ const _planStatusesAreLiteral: _IsLiteralUnion<(typeof PLAN_STATUSES)[number]> = true;
51
+ void _planStatusesAreLiteral;
52
+
53
+ // A `ParityDb` over node:sqlite's `DatabaseSync` for `assertReadModelParity` (which needs positional
54
+ // exec/all/run, whereas `DatabaseSync` exposes query methods on prepared statements).
55
+ function parityDb(db: DatabaseSync): ParityDb {
56
+ return {
57
+ exec: (sql) => db.exec(sql),
58
+ all: <T = Record<string, unknown>>(sql: string, params: unknown[] = []) =>
59
+ db.prepare(sql).all(...(params as never[])) as T[],
60
+ run: (sql, params: unknown[] = []) => {
61
+ const r = db.prepare(sql).run(...(params as never[]));
62
+ return { changes: Number(r.changes), lastInsertRowid: r.lastInsertRowid };
63
+ },
64
+ };
65
+ }
66
+
67
+ const shapes = [
68
+ { name: "feature", model: featureDeliveryStatus, sources: FEATURE_RUN_STATUSES, map: FEATURE_STATUS_TO_UNIT },
69
+ { name: "plan", model: planDeliveryStatus, sources: PLAN_STATUSES, map: PLAN_STATUS_TO_UNIT },
70
+ { name: "plan-task", model: planTaskDeliveryStatus, sources: PLAN_TASK_STATUSES, map: PLAN_TASK_STATUS_TO_UNIT },
71
+ { name: "delivery-graph", model: deliveryGraphDeliveryStatus, sources: DELIVERY_GRAPH_RUN_STATUSES, map: DELIVERY_GRAPH_STATUS_TO_UNIT },
72
+ ] as const;
73
+
74
+ test("TOTALITY: every source status maps to a valid canonical member, and fnFor agrees with the declared map", () => {
75
+ for (const { name, model, sources, map } of shapes) {
76
+ for (const source of sources) {
77
+ const declared = (map as Record<string, DeliveryUnitStatus>)[source];
78
+ assert(declared !== undefined, `${name}: source status "${source}" has no canonical mapping`);
79
+ assert(CANONICAL.has(declared), `${name}: "${source}" maps to non-canonical "${declared}"`);
80
+ // The DSL-compiled TS lowering must produce the SAME canonical value as the declared map object.
81
+ assertEquals(toDeliveryUnitStatus(model, source), declared, `${name}: fnFor drift for "${source}"`);
82
+ }
83
+ // An out-of-band status is NULL (never an invented member), matching the VIEW's ELSE NULL.
84
+ assertEquals(toDeliveryUnitStatus(model, "not-a-real-status"), null, `${name}: unmapped status must be null`);
85
+ assertEquals(toDeliveryUnitStatus(model, null), null, `${name}: null status must be null`);
86
+ }
87
+ });
88
+
89
+ test("TERMINALITY PRECEDENCE: a source SETTLED/terminal status maps to a canonical settled/terminal status", () => {
90
+ // Feature counts opened/converging as SETTLED-for-redispatch (live PR stages), so check against the
91
+ // settled set; the plan aggregate and delivery-graph terminals are truly DONE, so check the done tier.
92
+ for (const s of FEATURE_TERMINAL_STATUSES) {
93
+ assert(isDeliveryUnitSettled(FEATURE_STATUS_TO_UNIT[s]), `feature terminal "${s}" must map to a settled canonical status`);
94
+ }
95
+ for (const s of PLAN_TERMINAL_STATUSES as readonly (keyof typeof PLAN_STATUS_TO_UNIT)[]) {
96
+ assert(isDeliveryUnitTerminal(PLAN_STATUS_TO_UNIT[s]), `plan terminal "${s}" must map to a done-tier canonical status`);
97
+ }
98
+ for (const s of DELIVERY_GRAPH_TERMINAL_STATUSES) {
99
+ assert(isDeliveryUnitTerminal(DELIVERY_GRAPH_STATUS_TO_UNIT[s]), `graph terminal "${s}" must map to a done-tier canonical status`);
100
+ }
101
+ // The non-terminal parked waits must NOT be classified terminal (a reconciler must keep polling them).
102
+ for (const nonTerminal of ["escalated", "awaiting_operator", "waiting", "running", "requested"] as DeliveryUnitStatus[]) {
103
+ assert(!isDeliveryUnitTerminal(nonTerminal), `"${nonTerminal}" must be non-terminal`);
104
+ }
105
+ });
106
+
107
+ test("NODE-VS-AGGREGATE decision (ADR 0006 §4): a plan-task node maps into the SAME union; its lane wait becomes the canonical `waiting`", () => {
108
+ assertEquals(PLAN_TASK_STATUS_TO_UNIT["waiting-for-lane"], "waiting", "the node lane/dependency wait is the canonical `waiting`");
109
+ assertEquals(PLAN_TASK_STATUS_TO_UNIT.pending, "requested", "a queued (not-yet-run) node is pre-dispatch `requested`");
110
+ // Every node status resolves to a member of the one canonical union — no separate node vocabulary.
111
+ for (const s of PLAN_TASK_STATUSES) {
112
+ assert(CANONICAL.has(PLAN_TASK_STATUS_TO_UNIT[s]), `node status "${s}" must be a canonical member`);
113
+ }
114
+ });
115
+
116
+ test("the settled set is exactly the terminal (done-tier) set plus the two live PR resting stages", () => {
117
+ assertEquals(
118
+ [...DELIVERY_UNIT_SETTLED_STATUSES].sort(),
119
+ [...DELIVERY_UNIT_TERMINAL_STATUSES, "opened", "converging"].sort(),
120
+ "settled = terminal ∪ {opened, converging}",
121
+ );
122
+ for (const t of DELIVERY_UNIT_TERMINAL_STATUSES) assert(isDeliveryUnitSettled(t), `terminal "${t}" is settled`);
123
+ });
124
+
125
+ test("no dead canonical members: every DELIVERY_UNIT_STATUSES value is reachable from at least one source mapping", () => {
126
+ const reached = new Set<string>();
127
+ for (const { map } of shapes) for (const v of Object.values(map)) reached.add(v as string);
128
+ for (const canonical of DELIVERY_UNIT_STATUSES) {
129
+ assert(reached.has(canonical), `canonical "${canonical}" is unreachable — a dead member or a missing mapping`);
130
+ }
131
+ });
132
+
133
+ test("FRAMEWORK PARITY: each per-shape delivery_status model's SQL and TS lowerings agree over the full source-status matrix", () => {
134
+ for (const { name, model, sources } of shapes) {
135
+ const samples: ParitySample[] = sources.map((status) => ({ baseRow: { status } }));
136
+ // Also exercise the ELSE NULL arm with an out-of-band value.
137
+ samples.push({ baseRow: { status: "out-of-band" } });
138
+ const db = new DatabaseSync(":memory:");
139
+ assertReadModelParity(model, parityDb(db), samples, { columns: [DELIVERY_STATUS_COLUMN] });
140
+ db.close();
141
+ assert(true, `${name} parity holds`);
142
+ }
143
+ });
@@ -0,0 +1,242 @@
1
+ // The ONE delivery-unit status union — ADR 0006 slice **S1** (status lifecycle).
2
+ //
3
+ // Background (ADR 0006, issue #464). nano-workforce models the same aggregate — a *scheduled unit of
4
+ // work driven to a delivery outcome* — in three separate representations, each with its OWN bespoke
5
+ // status union:
6
+ //
7
+ // * feature — `FEATURE_RUN_STATUSES` (11: running/escalated/opened/converging/awaiting_operator/
8
+ // merged/converged/blocked/skipped/failed/abandoned) — app/feature.ts
9
+ // * epic — the `plans` aggregate (`planning`/`dispatched`/`done`/`failed`/`abandoned`, the union
10
+ // of `EPIC_LIVE_STATUSES` + `PLAN_TERMINAL_STATUSES`, app/delivery.ts + app/plan.ts)
11
+ // AND its `plan_tasks` NODE status `PLAN_TASK_STATUSES` (7) — app/plan.ts
12
+ // * graph — `DELIVERY_GRAPH_RUN_STATUSES` (5: awaiting-approval/running/done/failed/abandoned)
13
+ // — app/deliveryGraphRun.ts
14
+ //
15
+ // A change to "what states a unit of work can be in" therefore has to be made, by hand, in three (four,
16
+ // counting the epic's two levels) places that can silently drift — exactly the "No drift surfaces /
17
+ // derivation over duplication" hazard this repo treats as a defect class (AGENTS.md).
18
+ //
19
+ // This module is S1's deliverable: it defines the SINGLE canonical aggregate union
20
+ // ({@link DELIVERY_UNIT_STATUSES}) and, via ADR-0065's `defineReadModel`, the per-shape derivations that
21
+ // map each bespoke union INTO it — declared ONCE and compiled to BOTH the SQLite VIEW select-list
22
+ // (`sqlSelectFor`, for S2's `delivery_units`-backed VIEWs) AND the runtime TS oracle (`fnFor`, for the
23
+ // reconcilers). There is nothing to keep in lockstep: the two lowerings fall out of the same closed-DSL
24
+ // AST, and `assertReadModelParity` (app/deliveryUnitStatus.test.ts) proves they agree.
25
+ //
26
+ // SCOPE (S1). This slice OWNS the canonical vocabulary, the per-shape mapping, the terminal/settled
27
+ // precedence, and the node-vs-aggregate decision (below). It does NOT repoint any existing VIEW or move
28
+ // any writer/`instanceTracking` binding onto the new union — the legacy tables stay the physical write
29
+ // target through S2, and the `instanceTracking` bindings + `senior:*` doors collapse in S3 (ADR 0006
30
+ // rollout). The derivations here are the single source those later slices reference, not a second
31
+ // projection alongside them.
32
+
33
+ import { caseWhen, col, defineReadModel, type Expr, eq, lit, type ReadModel, when } from "@nanobpm/urban";
34
+ import { EPIC_LIVE_STATUSES } from "./delivery.ts";
35
+ import type { DeliveryGraphRunStatus } from "./deliveryGraphRun.ts";
36
+ import type { FeatureRunStatus } from "./feature.ts";
37
+ import { PLAN_TERMINAL_STATUSES, type PlanTaskStatus } from "./plan.ts";
38
+
39
+ /**
40
+ * The ONE canonical delivery-unit status union — the single source of truth for "what state a unit of
41
+ * work is in", replacing the three bespoke unions. A superset that preserves every source union's
42
+ * distinctions without loss (feature is the reference shape, so its members pass through by name):
43
+ *
44
+ * - `requested` — created, not yet dispatched to an executor (no live engine instance yet).
45
+ * - `running` — an executor (agent/probe/connector) is actively working the unit.
46
+ * - `escalated` — NON-terminal: parked awaiting a HUMAN answer (an open escalation user task).
47
+ * - `awaiting_operator` — NON-terminal: parked awaiting an OPERATOR acknowledgement (blocked wait).
48
+ * - `waiting` — NON-terminal: parked on a lane / dependency gate (a wave barrier).
49
+ * - `opened` — a PR was raised and the unit rests here (convergence was not requested).
50
+ * - `converging` — the opened PR is in its review-convergence loop.
51
+ * - `converged` — TERMINAL: review converged but the PR did not merge (auto-merge off).
52
+ * - `merged` — TERMINAL: the PR landed (the win).
53
+ * - `done` — TERMINAL: an aggregate settled successfully WITHOUT a single-PR terminal
54
+ * (an epic/graph whose members all landed) — the PR-less success outcome.
55
+ * - `skipped` — TERMINAL: nothing to do.
56
+ * - `blocked` — TERMINAL: could not proceed / gave up (distinct from the non-terminal
57
+ * `awaiting_operator` wait — a `blocked` unit is settled, not parked).
58
+ * - `failed` — TERMINAL: an unexpected failure.
59
+ * - `abandoned` — TERMINAL: the PR was abandoned, or the process instance was cancelled.
60
+ */
61
+ export const DELIVERY_UNIT_STATUSES = [
62
+ "requested",
63
+ "running",
64
+ "escalated",
65
+ "awaiting_operator",
66
+ "waiting",
67
+ "opened",
68
+ "converging",
69
+ "converged",
70
+ "merged",
71
+ "done",
72
+ "skipped",
73
+ "blocked",
74
+ "failed",
75
+ "abandoned",
76
+ ] as const;
77
+ export type DeliveryUnitStatus = (typeof DELIVERY_UNIT_STATUSES)[number];
78
+
79
+ /**
80
+ * The TRULY-terminal ("done tier") statuses — a unit in one of these has settled to a final outcome and
81
+ * will not advance again. Mirrors the union of the source terminal sets (`FEATURE_TERMINAL_STATUSES`
82
+ * minus its live PR stages, `PLAN_TERMINAL_STATUSES`, `DELIVERY_GRAPH_TERMINAL_STATUSES`). Distinct from
83
+ * {@link DELIVERY_UNIT_SETTLED_STATUSES}: `opened`/`converging` are settled FOR RE-DISPATCH but are LIVE
84
+ * pipeline stages, not `done`.
85
+ */
86
+ export const DELIVERY_UNIT_TERMINAL_STATUSES: readonly DeliveryUnitStatus[] = [
87
+ "converged",
88
+ "merged",
89
+ "done",
90
+ "skipped",
91
+ "blocked",
92
+ "failed",
93
+ "abandoned",
94
+ ];
95
+
96
+ /**
97
+ * The SETTLED-FOR-RE-DISPATCH statuses — {@link DELIVERY_UNIT_TERMINAL_STATUSES} plus the two live PR
98
+ * resting stages (`opened`/`converging`) a unit stops at without a further wave restart. Mirrors
99
+ * `FEATURE_TERMINAL_STATUSES` (app/feature.ts), which likewise counts `opened`/`converging` as terminal
100
+ * for re-dispatch gating even though they are LIVE (not `done`). A re-dispatch of the same unit
101
+ * short-circuits IFF its prior run is in one of these; the NON-terminal parked waits
102
+ * (`escalated`/`awaiting_operator`/`waiting`) and `running`/`requested` are excluded, so a live or
103
+ * parked unit is never orphaned by a parallel restart.
104
+ */
105
+ export const DELIVERY_UNIT_SETTLED_STATUSES: readonly DeliveryUnitStatus[] = [
106
+ ...DELIVERY_UNIT_TERMINAL_STATUSES,
107
+ "opened",
108
+ "converging",
109
+ ];
110
+
111
+ /** True iff `status` is a truly-terminal ("done tier") delivery-unit status. */
112
+ export const isDeliveryUnitTerminal = (status: DeliveryUnitStatus): boolean =>
113
+ DELIVERY_UNIT_TERMINAL_STATUSES.includes(status);
114
+
115
+ /** True iff `status` is settled for RE-DISPATCH (terminal, or a live PR resting stage). */
116
+ export const isDeliveryUnitSettled = (status: DeliveryUnitStatus): boolean =>
117
+ DELIVERY_UNIT_SETTLED_STATUSES.includes(status);
118
+
119
+ /**
120
+ * The plan AGGREGATE lifecycle values — the union of the two existing sources (`EPIC_LIVE_STATUSES` +
121
+ * `PLAN_TERMINAL_STATUSES`), NOT re-listed here, so this stays a derived view of them and cannot drift.
122
+ */
123
+ export const PLAN_STATUSES = [...EPIC_LIVE_STATUSES, ...PLAN_TERMINAL_STATUSES] as const;
124
+
125
+ // ── Per-shape mappings — declared ONCE, keyed by the SOURCE union so `tsc` fails if a source union
126
+ // gains a member without a canonical mapping (the type-level No-Drift guard). ──────────────────────
127
+
128
+ /**
129
+ * Feature is the REFERENCE shape: every `FEATURE_RUN_STATUSES` member has a same-named canonical member,
130
+ * so the map is the identity. The distinct feature waits survive intact — `escalated` (human),
131
+ * `awaiting_operator` (operator), and the terminal `blocked` (gave up) stay three different states.
132
+ */
133
+ export const FEATURE_STATUS_TO_UNIT: Record<FeatureRunStatus, DeliveryUnitStatus> = {
134
+ running: "running",
135
+ escalated: "escalated",
136
+ opened: "opened",
137
+ converging: "converging",
138
+ awaiting_operator: "awaiting_operator",
139
+ merged: "merged",
140
+ converged: "converged",
141
+ blocked: "blocked",
142
+ skipped: "skipped",
143
+ failed: "failed",
144
+ abandoned: "abandoned",
145
+ };
146
+
147
+ /**
148
+ * Plan AGGREGATE → canonical. `planning` (decomposing, no fan-out yet) is pre-dispatch ⇒ `requested`;
149
+ * `dispatched` (fan-out running) ⇒ `running`; the three terminals pass through. Keyed by the plan
150
+ * aggregate value (a bare string in `Plan.status`), covering every {@link PLAN_STATUSES} member.
151
+ */
152
+ export const PLAN_STATUS_TO_UNIT: Record<(typeof PLAN_STATUSES)[number], DeliveryUnitStatus> = {
153
+ planning: "requested",
154
+ dispatched: "running",
155
+ done: "done",
156
+ failed: "failed",
157
+ abandoned: "abandoned",
158
+ };
159
+
160
+ /**
161
+ * Plan-task NODE → canonical. **Node-vs-aggregate decision (ADR 0006 §4):** a node is a DEGENERATE
162
+ * delivery unit, so its status maps into the SAME canonical union rather than carrying a separate node
163
+ * contract — there is ONE vocabulary. The node-specific lane/dependency wait (`waiting-for-lane`) is
164
+ * expressed by the canonical `waiting` member (a state the aggregate level never enters); `pending`
165
+ * (queued, not yet run in its wave) is pre-dispatch ⇒ `requested`.
166
+ */
167
+ export const PLAN_TASK_STATUS_TO_UNIT: Record<PlanTaskStatus, DeliveryUnitStatus> = {
168
+ pending: "requested",
169
+ opened: "opened",
170
+ blocked: "blocked",
171
+ skipped: "skipped",
172
+ escalated: "escalated",
173
+ "waiting-for-lane": "waiting",
174
+ abandoned: "abandoned",
175
+ };
176
+
177
+ /**
178
+ * Delivery-graph RUN → canonical. `awaiting-approval` (reserved, pre-dispatch: no live instance, parked
179
+ * before launch — issue #460) ⇒ `requested`; `running` passes through; the three terminals pass through.
180
+ */
181
+ export const DELIVERY_GRAPH_STATUS_TO_UNIT: Record<DeliveryGraphRunStatus, DeliveryUnitStatus> = {
182
+ "awaiting-approval": "requested",
183
+ running: "running",
184
+ done: "done",
185
+ failed: "failed",
186
+ abandoned: "abandoned",
187
+ };
188
+
189
+ /**
190
+ * Build the canonical `delivery_status` derivation for one shape as a closed-DSL {@link Expr}: a
191
+ * `CASE` over the base row's `status` column, one `WHEN status = '<source>' THEN '<canonical>'` per map
192
+ * entry. The map is TOTAL over its source union, so the `ELSE` is unreachable in practice; it falls back
193
+ * to `null` (never an invented status) so an out-of-band source value surfaces as NULL rather than a
194
+ * silent mis-map. Both backends (`sqlSelectFor` VIEW body, `fnFor` runtime) fall out of this one AST.
195
+ */
196
+ export const deliveryStatusExpr = (map: Readonly<Record<string, DeliveryUnitStatus>>): Expr =>
197
+ caseWhen(
198
+ Object.entries(map).map(([source, unit]) => when(eq(col("status"), lit(source)), lit(unit))),
199
+ lit(null),
200
+ );
201
+
202
+ /** The single derived column every per-shape delivery-unit read model exposes. */
203
+ export const DELIVERY_STATUS_COLUMN = "delivery_status";
204
+
205
+ /** The base alias the managed VIEWs give each source table — pinned so emitted SQL is stable/testable. */
206
+ export const DELIVERY_UNIT_STATUS_BASE_ALIAS = "du";
207
+
208
+ const statusReadModel = (name: string, baseTable: string, map: Readonly<Record<string, DeliveryUnitStatus>>): ReadModel =>
209
+ defineReadModel({
210
+ name,
211
+ baseTable,
212
+ selectBaseColumns: false,
213
+ derive: { [DELIVERY_STATUS_COLUMN]: deliveryStatusExpr(map) },
214
+ });
215
+
216
+ /**
217
+ * The four per-shape derivations of the ONE canonical union, each a `defineReadModel` exposing a single
218
+ * `delivery_status` column over its source table. S2 provisions these as VIEWs over `delivery_units`;
219
+ * the reconcilers consume `fnFor(DELIVERY_STATUS_COLUMN)`. They all target {@link DELIVERY_UNIT_STATUSES}
220
+ * — the union is single-sourced; only the per-shape MAPPING differs.
221
+ */
222
+ export const featureDeliveryStatus: ReadModel = statusReadModel("feature_delivery_status", "feature_runs", FEATURE_STATUS_TO_UNIT);
223
+ export const planDeliveryStatus: ReadModel = statusReadModel("plan_delivery_status", "plans", PLAN_STATUS_TO_UNIT);
224
+ export const planTaskDeliveryStatus: ReadModel = statusReadModel("plan_task_delivery_status", "plan_tasks", PLAN_TASK_STATUS_TO_UNIT);
225
+ export const deliveryGraphDeliveryStatus: ReadModel = statusReadModel("delivery_graph_delivery_status", "delivery_graph_runs", DELIVERY_GRAPH_STATUS_TO_UNIT);
226
+
227
+ /** All four per-shape delivery-status read models, for bulk registration/validation by later slices. */
228
+ export const DELIVERY_STATUS_READ_MODELS: readonly ReadModel[] = [
229
+ featureDeliveryStatus,
230
+ planDeliveryStatus,
231
+ planTaskDeliveryStatus,
232
+ deliveryGraphDeliveryStatus,
233
+ ];
234
+
235
+ /**
236
+ * Map a source status to the canonical union in-process (the TS backend of {@link deliveryStatusExpr},
237
+ * via the compiled `fnFor`) — the reconciler-facing helper. Returns `null` for an unmapped value,
238
+ * matching the VIEW's `ELSE NULL`.
239
+ */
240
+ export const toDeliveryUnitStatus = (model: ReadModel, status: string | null): DeliveryUnitStatus | null =>
241
+ // biome-ignore lint/plugin: runtime/framework contract boundary — `fnFor` returns `unknown`; the derived column yields one of its declared `lit(...)` canonical statuses (or null on the ELSE arm).
242
+ model.fnFor(DELIVERY_STATUS_COLUMN)({ status }) as DeliveryUnitStatus | null;
@@ -1,71 +1,37 @@
1
- // Structural + cross-layer regression guard for converging the merge-loop escalation onto the ONE
2
- // native user-task answer pathway (#256).
1
+ // Cross-layer drift guard: the merge-loop escalation user task the model parks on must be one the
2
+ // canonical completer (`agentCompletion.ts`) actually accepts and validates (#256, #466).
3
3
  //
4
- // Before #256 the merge loop parked on a durable `escalation-answered` message catch answered by a
5
- // bespoke `answerEscalation()` publish a SECOND answer pathway invisible to the Tasks inbox, so a
6
- // merge escalation could not be answered from the nwf UI at all. It now parks on a native
7
- // `wait-merge-answer` userTask (backed by `pr-escalation.form`) followed by the SAME
8
- // `pr.answer-escalation` reconcile step the review loop's `wait-answer` runs, so both loops answer
9
- // through the one canonical `completeUserTask` door and surface in the one Tasks inbox.
10
- //
11
- // These are pure text assertions over the committed BPMN (no engine), matching the repo's
12
- // lightweight model-guard style (see mergeRebaseArm.test.ts), plus a drift guard tying the model's
13
- // user-task element id to the completer's accepted escalation set so the two can't silently diverge.
4
+ // The merge escalation converges on ONE native `wait-merge-answer` userTask (backed by the shared
5
+ // `pr-escalation` form) so it is answerable from the one Tasks inbox. The *behavioural* invariants —
6
+ // that the loop parks on that task, that answering it reconciles the escalations row and re-arms the
7
+ // poller — are exercised end-to-end by the WASM engine in `mergeLoopBehaviour.test.ts`. But that
8
+ // engine harness completes the task through the engine, NOT through nwf's application-level
9
+ // completer, so it cannot catch the specific silent-drift failure this guard closes: the model
10
+ // deploys and parks on `wait-merge-answer`, yet `agentCompletion.ts` refuses to drive it because the
11
+ // id fell out of `ESCALATION_TASK_ELEMENTS` (or its form contract drifted) a task no worker will
12
+ // ever answer. This guard ties the model's user-task id to the completer's accepted set + form
13
+ // contract so the two layers cannot diverge unnoticed.
14
14
 
15
15
  import { test } from "node:test";
16
- import { assert, assertStringIncludes } from "#test-assert";
16
+ import { assert } from "#test-assert";
17
17
  import { readFileSync } from "node:fs";
18
18
  import { ESCALATION_TASK_ELEMENTS, validateEscalationVariables } from "./agentCompletion.ts";
19
19
 
20
- const bpmn = readFileSync("resources/processes/merge-loop.bpmn", "utf8");
21
- const flat = bpmn.replace(/\s+/g, " ");
22
-
23
- function hasFlow(source: string, target: string): boolean {
24
- const re = new RegExp(
25
- `<bpmn:sequenceFlow\\b[^>]*\\bsourceRef="${source}"[^>]*\\btargetRef="${target}"|` +
26
- `<bpmn:sequenceFlow\\b[^>]*\\btargetRef="${target}"[^>]*\\bsourceRef="${source}"`,
27
- );
28
- return re.test(flat);
29
- }
30
-
31
- test("the merge escalation parks on a native wait-merge-answer userTask backed by pr-escalation.form", () => {
32
- const task = flat.match(/<bpmn:userTask\b[^>]*\bid="wait-merge-answer"[\s\S]*?<\/bpmn:userTask>/);
33
- assert(task, "wait-merge-answer must be a <bpmn:userTask>");
34
- assertStringIncludes(task![0], 'formId="pr-escalation"', "it must render the shared pr-escalation form");
35
- assertStringIncludes(task![0], "<zeebe:userTask", "it must be a native (Zeebe) user task");
36
- });
20
+ const flat = readFileSync("resources/processes/merge-loop.bpmn", "utf8").replace(/\s+/g, " ");
37
21
 
38
- test("the answered task reconciles the escalations row, then re-arms the merge poller", () => {
39
- // wait-merge-answer record-merge-answer (pr.answer-escalation) arm-merge, mirroring the review
40
- // loop's wait-answer → record-answer. Without the reconcile step the escalations row would stay
41
- // `open` forever after the task completes (a phantom on /status).
42
- const record = flat.match(/<bpmn:serviceTask\b[^>]*\bid="record-merge-answer"[\s\S]*?<\/bpmn:serviceTask>/);
43
- assert(record, "record-merge-answer service task must exist");
44
- assertStringIncludes(record![0], 'type="pr.answer-escalation"', "it must run the shared reconcile worker");
45
- assert(hasFlow("wait-merge-answer", "record-merge-answer"), "wait-merge-answer → record-merge-answer missing");
46
- assert(hasFlow("record-merge-answer", "arm-merge"), "record-merge-answer → arm-merge (re-arm) missing");
47
- });
48
-
49
- test("the legacy escalation-answered message pathway is gone", () => {
50
- assert(!flat.includes("escalation-answered"), "the escalation-answered message must be removed");
51
- assert(!flat.includes("Message_mergeEscAnswered"), "the merge escalation message declaration must be removed");
52
- // The answer wait must no longer be a message catch — it is now a user task.
22
+ test("drift guard: the model's merge user-task element is one the canonical completer accepts", () => {
23
+ // (1) The model actually parks on `wait-merge-answer` as a native user task...
53
24
  assert(
54
- !/<bpmn:intermediateCatchEvent\b[^>]*\bid="wait-merge-answer"/.test(flat),
55
- "wait-merge-answer must no longer be an intermediateCatchEvent",
25
+ /<bpmn:userTask\b[^>]*\bid="wait-merge-answer"/.test(flat),
26
+ "the model must park the merge escalation on a userTask id='wait-merge-answer'",
56
27
  );
57
- });
58
-
59
- test("drift guard: the model's merge user-task element is one the canonical completer accepts", () => {
60
- // The completer refuses any user task outside ESCALATION_TASK_ELEMENTS, so a model that parks on
61
- // `wait-merge-answer` while the code doesn't accept it would deploy but never be answerable — the
62
- // exact silent-drift failure mode this guard closes.
28
+ // (2) ...and the completer accepts that exact id (else it deploys but is never answerable)...
63
29
  assert(
64
30
  ESCALATION_TASK_ELEMENTS.has("wait-merge-answer"),
65
31
  "ESCALATION_TASK_ELEMENTS must accept wait-merge-answer",
66
32
  );
67
- // And it must map to the pr-escalation form contract (answer required) — a missing answer is
68
- // rejected, proving the element resolves to the same form the model renders.
33
+ // (3) ...resolving to the pr-escalation form contract (a missing answer is rejected; a present
34
+ // one accepted), proving the element maps to the same form the model renders.
69
35
  assert(
70
36
  validateEscalationVariables("wait-merge-answer", {}) !== null,
71
37
  "wait-merge-answer must enforce the pr-escalation form contract (answer required)",