@nanobpm/nano-workforce 0.129.1 → 0.131.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.
@@ -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;
package/app/plan.ts CHANGED
@@ -445,7 +445,7 @@ export const MAX_PLAN_REVIEW_ROUNDS = positiveIntEnv("NANO_PLAN_REVIEW_ROUNDS",
445
445
 
446
446
  /** A plan is "done" in exactly these states; everything else (planning, dispatched)
447
447
  * is in flight. The cancel guard and the active view key off this. */
448
- export const PLAN_TERMINAL_STATUSES: readonly string[] = ["done", "failed", "abandoned"];
448
+ export const PLAN_TERMINAL_STATUSES = ["done", "failed", "abandoned"] as const;
449
449
 
450
450
  export interface ParsedIssue {
451
451
  repo: string;
@@ -591,7 +591,7 @@ export async function findActivePlansByBase(
591
591
  base: string,
592
592
  ): Promise<Plan[]> {
593
593
  const rows = await plans(data).find({ repo, base_branch: base });
594
- return rows.filter((p) => !PLAN_TERMINAL_STATUSES.includes(p.status));
594
+ return rows.filter((p) => !PLAN_TERMINAL_STATUSES.some((s) => s === p.status));
595
595
  }
596
596
 
597
597
  /** Options gating the confirm-default (rule 3) and shared-base (rule 4) admission rules. Both
@@ -926,7 +926,7 @@ export async function startPlan(
926
926
  }
927
927
  const table = plans(data);
928
928
  const existing = await table.get(parsed.planKey);
929
- if (existing && !PLAN_TERMINAL_STATUSES.includes(existing.status)) {
929
+ if (existing && !PLAN_TERMINAL_STATUSES.some((s) => s === existing.status)) {
930
930
  return { planKey: parsed.planKey, alreadyRunning: true };
931
931
  }
932
932
  const base = normalizeBaseBranch(baseBranch);
@@ -225,6 +225,31 @@ resume cannot double-fire.
225
225
  - **Non-npm emit facts** (OCI/github-release) and behavioural edges beyond the `command` escape hatch —
226
226
  added when a real case lands.
227
227
 
228
+ > **Amendment (issue #492): conditional (guarded) edges landed (S7).** The "behavioural edges" deferral
229
+ > above is **partially lifted**: an edge may now carry an optional guard — `when: "<node>.<fact>"` +
230
+ > `equals: <scalar>` — or be the split's single `default: true` else-branch. A node whose out-edges
231
+ > carry guards is an **exclusive** (data-based) split instead of the default parallel fan-out; its guarded branches compile to a BPMN `exclusiveGateway` (`gwx<i>`) with one FEEL
232
+ > `conditionExpression` per guarded flow (`=<producerElement>_<fact> = <literal>`) and a named default
233
+ > flow, and where those branches re-converge they merge on an **exclusive** gateway (`gwm<i>`,
234
+ > first-token-proceeds) rather than the parallel AND-join that would deadlock the untaken branch. The
235
+ > validator enforces the guard's shape and closes the deadlock/ambiguity classes: a guard must
236
+ > reference a **scalar** fact **declared by the edge's own producer** (`bad-when`), carry an `equals`
237
+ > whose type matches the fact (`guard-missing-equals` / `guard-type-mismatch`), never combine `when`
238
+ > with `default` (`guard-default-conflict`); a split must not **mix** guarded and plain out-edges
239
+ > (`mixed-fan-out`) nor declare **two** defaults (`multiple-defaults`), must be **exhaustive** — a
240
+ > `default`, unless a single boolean fact is guarded on both `true` and `false` (`non-exhaustive-split`)
241
+ > — and a plain (parallel) AND-join may not be fed by an exclusive-split branch (`exclusive-merge-parity`),
242
+ > including the implicit **End sink**: the terminal nodes may not mix a conditional (exclusive-split)
243
+ > tail with an always-firing one, which would deadlock the End join or double-fire its exclusive merge.
244
+ > The exclusive-split topology (both the validator's parity analysis and the compiler's gateway
245
+ > selection) is derived from the **guarded (`when`) edges fanning out to \>=2 distinct downstream
246
+ > targets only** — a lone `default: true` edge with no guarded sibling always fires, and a node whose
247
+ > guarded + `default` edges all converge on **one** downstream target has no real fan-out, so in
248
+ > either case its producer is **not** a split and must not mark downstream nodes/leaves conditional;
249
+ > the per-node mixing/exhaustiveness checks still apply to any `default`.
250
+ > Determinism is preserved: gateway ids are positional over id-sorted nodes, so a graph with no guards
251
+ > compiles byte-for-byte as before.
252
+
228
253
  ## Open questions
229
254
 
230
255
  - **Compiler target for the first cut** — confirm compile-to-native (diagram + native scheduling) vs a
package/openapi.yaml CHANGED
@@ -1502,7 +1502,9 @@ components:
1502
1502
  `from` is either a bare `<nodeId>` (wait for the upstream node's completion fact) or a
1503
1503
  qualified `<nodeId>.<fact>` referencing a declared `emits` fact of that node. Both endpoints
1504
1504
  must resolve to a node in the graph, the referenced fact must be declared, and the whole edge
1505
- set must be a DAG — all enforced by `validateDeliveryGraph`.
1505
+ set must be a DAG — all enforced by `validateDeliveryGraph`. An OPTIONAL `when`/`equals` guard
1506
+ (or a `default` else-branch) makes the edge CONDITIONAL, turning its producer into an
1507
+ exclusive split (ADR 0005 S7) — a node's out-edges are then ALL guarded or ALL unconditional.
1506
1508
  type: object
1507
1509
  additionalProperties: false
1508
1510
  required:
@@ -1517,6 +1519,33 @@ components:
1517
1519
  type: string
1518
1520
  minLength: 1
1519
1521
  description: The dependent node's id — proceeds once `from` is observed.
1522
+ when:
1523
+ type: string
1524
+ minLength: 1
1525
+ description: >-
1526
+ OPTIONAL guard reference `<nodeId>.<fact>` naming a SCALAR emitted fact (`string`,
1527
+ `number`, or `boolean`) of the `from`-adjacent producer (ADR 0005 S7). Its presence makes
1528
+ this a GUARDED edge and turns the producer into an exclusive-split point: the edge is taken
1529
+ only when that runtime fact `equals` the literal below. Equality-only — no arbitrary
1530
+ expressions (the trust boundary). Mutually exclusive with `default`.
1531
+ equals:
1532
+ description: >-
1533
+ The literal value `when`'s fact must equal for this guarded edge to be taken (ADR 0005 S7).
1534
+ REQUIRED iff `when` is present, and its JSON type must match the referenced fact's declared
1535
+ type (`string`/`number`/`boolean`).
1536
+ oneOf:
1537
+ - type: string
1538
+ - type: number
1539
+ - type: boolean
1540
+ default:
1541
+ type: boolean
1542
+ enum: [true]
1543
+ description: >-
1544
+ OPTIONAL — marks this edge as the ELSE branch of the exclusive split (taken when no guarded
1545
+ edge matches at runtime). A FLAG: only `true` is meaningful, so it is constrained to `true`
1546
+ (omit the field entirely for a non-default edge — `default: false` is not a valid wire
1547
+ value). At most one `default` edge per split node. Mutually exclusive with `when`/`equals`
1548
+ (ADR 0005 S7).
1520
1549
  DeliveryCompileError:
1521
1550
  description: >-
1522
1551
  One semantic-validation or compile failure, path-qualified at the offending input
@@ -1749,6 +1778,19 @@ components:
1749
1778
  fromFact:
1750
1779
  type: string
1751
1780
  description: The referenced emitted fact, when the edge `from` was qualified.
1781
+ when:
1782
+ type: string
1783
+ description: The guard reference (`<nodeId>.<fact>`) verbatim, present only on a guarded edge (ADR 0005 S7).
1784
+ equals:
1785
+ description: The literal the guard fact must equal, present only on a guarded edge (ADR 0005 S7).
1786
+ oneOf:
1787
+ - type: string
1788
+ - type: number
1789
+ - type: boolean
1790
+ default:
1791
+ type: boolean
1792
+ enum: [true]
1793
+ description: True when this is the exclusive split's default (else) branch; a FLAG, only ever `true` and omitted otherwise (ADR 0005 S7).
1752
1794
  ResolvedDeliveryGraph:
1753
1795
  description: >-
1754
1796
  The normalised graph the compiler resolved from the input (ADR 0005 slice S1) — nodes and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.129.1",
3
+ "version": "0.131.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",
@@ -173,6 +173,18 @@
173
173
  border-radius: 6px;
174
174
  }
175
175
 
176
+ /* The "connected — waiting for output" status note under the terminal (blank-terminal defect fix):
177
+ hidden until a live drill is connected-but-quiet, so a blank panel reads as "waiting" not "broken". */
178
+ .cockpit-terminal-note {
179
+ margin: 8px 0 0;
180
+ font-size: 0.85em;
181
+ color: #7c8794;
182
+ font-style: italic;
183
+ }
184
+ .cockpit-terminal-note[data-terminal-note="none"] {
185
+ display: none;
186
+ }
187
+
176
188
  /* ── Past sessions (H3 read path / #222): the captured-session history + replay. ──────────────── */
177
189
 
178
190
  .cockpit-past-header {
@@ -72,6 +72,7 @@ function workerView(worker, staleAfterMs, byJobKey) {
72
72
  host: worker.host ?? "\u2014",
73
73
  jobKeys,
74
74
  jobs: jobKeys.length,
75
+ drillable: jobKeys.length > 0,
75
76
  correlations,
76
77
  liveness: liveness(worker, staleAfterMs),
77
78
  staleMs: worker.staleMs,
@@ -141,12 +142,18 @@ function workerRow(doc, worker, onDrill, onOpenWorker) {
141
142
  button.setAttribute("data-stream", worker.stream);
142
143
  if (onOpenWorker) button.addEventListener("click", () => onOpenWorker(worker.instance));
143
144
  nameCell.appendChild(button);
144
- const drill = el(doc, "button", "cockpit-worker-drill", "terminal");
145
- drill.setAttribute("type", "button");
146
- drill.setAttribute("data-instance", worker.instance);
147
- drill.setAttribute("data-stream", worker.stream);
148
- if (onDrill) drill.addEventListener("click", () => onDrill(worker.stream));
149
- nameCell.appendChild(drill);
145
+ // The inline live-terminal drill ONLY for a worker that currently holds a job. An idle worker's
146
+ // `stream` is its bare instance id, which no producer writes to, so drilling it opens a permanently
147
+ // blank "live" terminal. Suppress the affordance when there is nothing live to stream (mirrors
148
+ // app/agentic/cockpit/supply-render.ts).
149
+ if (worker.drillable) {
150
+ const drill = el(doc, "button", "cockpit-worker-drill", "terminal");
151
+ drill.setAttribute("type", "button");
152
+ drill.setAttribute("data-instance", worker.instance);
153
+ drill.setAttribute("data-stream", worker.stream);
154
+ if (onDrill) drill.addEventListener("click", () => onDrill(worker.stream));
155
+ nameCell.appendChild(drill);
156
+ }
150
157
  row.appendChild(nameCell);
151
158
 
152
159
  row.appendChild(el(doc, "td", "cockpit-td cockpit-supply-family", worker.family));
@@ -494,6 +501,12 @@ export function mountCockpit(host, opts = {}) {
494
501
  const terminalHost = el(doc, "div", "cockpit-terminal-host");
495
502
  terminalHost.setAttribute("data-terminal", "host");
496
503
  terminalPanel.appendChild(terminalHost);
504
+ // Status note under the terminal, shown while a LIVE drill has connected but no output has arrived
505
+ // yet (a quiet job between frames), so a blank panel reads as "waiting" not "broken". Cleared on
506
+ // the first frame and on every mode change (mirrors app/agentic/cockpit/supply-boot.ts).
507
+ const terminalNote = el(doc, "p", "cockpit-terminal-note");
508
+ terminalNote.setAttribute("data-terminal-note", "none");
509
+ terminalPanel.appendChild(terminalNote);
497
510
  shell.appendChild(listRegion);
498
511
  shell.appendChild(pastRegion);
499
512
  shell.appendChild(terminalPanel);
@@ -524,6 +537,19 @@ export function mountCockpit(host, opts = {}) {
524
537
  if (next === "live") terminalTitle.textContent = "Worker terminal — live";
525
538
  else if (next === "replay") terminalTitle.textContent = "Worker terminal — replay (past session)";
526
539
  else terminalTitle.textContent = "Worker terminal";
540
+ // Any mode change replaces what's behind the panel, so the prior "waiting" note is stale — clear
541
+ // it. A live drill re-arms it once its fresh terminal is mounted.
542
+ setNote(undefined);
543
+ }
544
+
545
+ function setNote(text) {
546
+ if (text == null) {
547
+ terminalNote.textContent = "";
548
+ terminalNote.setAttribute("data-terminal-note", "none");
549
+ return;
550
+ }
551
+ terminalNote.textContent = text;
552
+ terminalNote.setAttribute("data-terminal-note", "waiting");
527
553
  }
528
554
 
529
555
  function teardownTerminal() {
@@ -586,8 +612,19 @@ export function mountCockpit(host, opts = {}) {
586
612
  teardownTerminal();
587
613
  try {
588
614
  terminalHost.replaceChildren();
589
- const sink = xtermSink(terminalHost);
590
- terminal = sink;
615
+ const rawSink = xtermSink(terminalHost);
616
+ terminal = rawSink;
617
+ // Wrap the sink so the first byte of live output clears the "waiting" note (mirrors supply-boot).
618
+ let cleared = false;
619
+ const sink = {
620
+ write: (chunk) => {
621
+ if (!cleared) {
622
+ cleared = true;
623
+ setNote(undefined);
624
+ }
625
+ rawSink.write(chunk);
626
+ },
627
+ };
591
628
  let session;
592
629
  const client = new RelayChannelClient({
593
630
  connect: connectRelay,
@@ -599,6 +636,8 @@ export function mountCockpit(host, opts = {}) {
599
636
  client.open();
600
637
  drill = { stream, client };
601
638
  setMode("live", stream);
639
+ // Arm the "waiting for output" note (after setMode, which clears it) until the first frame.
640
+ setNote("Connected — waiting for live output…");
602
641
  } catch (err) {
603
642
  // The new terminal failed to build after the prior one was torn down: reset the region to idle
604
643
  // (and drop any partially-built terminal) so the UI never shows a stale "live"/"replay"