@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.
- package/.github/workflows/pr-title-lint.yml +5 -3
- package/.releaserc.json +36 -2
- package/AGENTS.md +10 -6
- package/CHANGELOG.md +12 -0
- package/app/delivery.ts +2 -2
- package/app/deliveryUnitStatus.test.ts +143 -0
- package/app/deliveryUnitStatus.ts +242 -0
- package/app/mergeEscalationUserTask.test.ts +21 -55
- package/app/mergeLoopBehaviour.test.ts +446 -0
- package/app/plan.ts +3 -3
- package/package.json +2 -1
- package/resources/processes/merge-loop.bpmn +692 -395
- package/app/mergeCiReattempt.test.ts +0 -138
- package/app/mergeEscalationQuestion.test.ts +0 -190
- package/app/mergeRebaseArm.test.ts +0 -140
- package/app/mergeRetryArm.test.ts +0 -100
|
@@ -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 (
|
|
5
|
-
#
|
|
6
|
-
#
|
|
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
|
-
|
|
5
|
-
|
|
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:`, `
|
|
357
|
-
`
|
|
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.
|
|
361
|
-
|
|
362
|
-
`
|
|
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-
|
|
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
|
|
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.
|
|
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
|
-
//
|
|
2
|
-
//
|
|
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
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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
|
|
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
|
|
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("
|
|
39
|
-
//
|
|
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
|
-
|
|
55
|
-
"
|
|
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
|
-
//
|
|
68
|
-
//
|
|
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)",
|