@nanobpm/nano-workforce 0.143.0 → 0.145.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,217 @@
1
+ // app/stepAxis.ts — the ONE canonical progress step axis for the derived stepper (ADR 0006 §4b, S7).
2
+ //
3
+ // §4b collapses the unit's three progress projections (feature `deriveStage`, epic write-time
4
+ // `epic_phase`, delivery-graph `pollDeliveryGraphPhase`) onto ONE derivation over a single step axis,
5
+ // rendered by the ONE `pipeline` renderer kind. This module owns the canonical definition of that
6
+ // axis: the ordered steps, the explicit cell→step mapping, the terminal-tier normalization, and the
7
+ // deterministic parallel-frontier reduction. Feature (`app/stage.ts` / `app/featureReadModel.ts`) and
8
+ // delivery-graph (`app/deliveryGraphReadModel.ts`) both project onto it; there is no per-surface
9
+ // re-declaration of the step vocabulary.
10
+ //
11
+ // SEEDED FROM `STAGE_KEYS`, OWNS THE MAPPING (§4b §217-232). `STAGE_KEYS` (app/stage.ts) is the closest
12
+ // EXISTING projection of the axis but it is NOT literally a clean cell sequence — it mixes lifecycle
13
+ // states (`Requested` / `PR open` / `Done`) with process cells (`implement` / `converge` / `merge`),
14
+ // and hosts interstitial `wait` / `human` / `escalation` cells. So this module SEEDS `STEP_KEYS` from
15
+ // `STAGE_KEYS` (the single source of truth for the six brackets) but adds the thing `STAGE_KEYS` lacks:
16
+ // an explicit map of which cell entry/exit each step corresponds to, and how the interstitial cells
17
+ // collapse into an existing bracket. v1 leaves the two existing axis consumers physically in place (the
18
+ // exported `STAGE_KEYS` and the static `stages` array in `pages/feature.page.json`) and only SEEDS this
19
+ // mapping from them — deriving/retiring those duplicates is a flagged follow-up, not S7.
20
+ //
21
+ // LIFECYCLE-STAGE FIDELITY ONLY (S7). Per §4b, S7 renders the coarse LIFECYCLE stage, not a per-cell
22
+ // position: even feature is not per-cell today (`deriveStage` collapses a readiness-probe/timer park or
23
+ // an active `implement-task` all to `Implementing`). True mid-cell / per-node resolution is the S8
24
+ // element-instance source (#542, #473). The interstitial-cell mapping below therefore documents the
25
+ // bracket each cell COLLAPSES into; it does not add per-cell steps.
26
+
27
+ import { STAGE_DONE_STATUSES, STAGE_KEYS, type StageKey, type StageState } from "./stage.ts";
28
+
29
+ /** The canonical ordered step axis — SEEDED from `STAGE_KEYS` (app/stage.ts), the single source of
30
+ * truth for the six pipeline brackets: Requested → Implementing → PR open → Converging → Merging →
31
+ * Done. This module owns the cell→step MAPPING onto these keys; the keys themselves stay sourced from
32
+ * `STAGE_KEYS` so the axis cannot fork across surfaces. */
33
+ export const STEP_KEYS: readonly StageKey[] = STAGE_KEYS;
34
+ export type StepKey = StageKey;
35
+
36
+ /** The deterministic INITIAL step for a pre-run / dispatch-pending / first-observation unit — the head
37
+ * of the axis (`Requested`, `STAGE_KEYS[0]`). Pins the scalar `activeField` so it is never undefined on
38
+ * a first observation with no prior lifecycle key (§4b §431-449). */
39
+ export const INITIAL_STEP: StepKey = STEP_KEYS[0];
40
+
41
+ /** The TERMINAL step — the tail of the axis (`Done`). A terminal unit pins its `activeField` here
42
+ * outright (with an `ok`/`failed` render state) so it can never render an undefined/invalid active
43
+ * stage (§4b §436-438). */
44
+ export const TERMINAL_STEP: StepKey = STEP_KEYS[STEP_KEYS.length - 1];
45
+
46
+ /** The process-cell vocabulary of the composed unit (§2/S4): the three executable pipeline cells
47
+ * (`implement` / `converge` / `merge`) plus the interstitial `wait` / `human` / `escalation` cells that
48
+ * can be inserted around them. */
49
+ export type ProcessCell = "implement" | "converge" | "merge" | "wait" | "human" | "escalation";
50
+
51
+ /**
52
+ * The explicit **cell → step** mapping (§4b's first deliverable). Each executable cell ENTERS its
53
+ * bracket when the token arrives and EXITS it when the token advances to the next cell's bracket; the
54
+ * three lifecycle markers bracket the cell run — a token before `implement` reads `Requested`, raising
55
+ * the PR on `implement` exit enters `PR open`, and a merged/terminal token reads `Done`.
56
+ *
57
+ * The interstitial `wait` / `human` / `escalation` cells do **not** own a distinct step — they HOLD the
58
+ * frontier at the bracket of the cell they interrupt (a human gate mid-convergence reads `Converging`,
59
+ * a wait-gate before implementation reads `Implementing`), collapsing into an existing `STAGE_KEYS`
60
+ * bracket rather than extending the axis. This is why the `pipeline` renderer needs no new stages for
61
+ * v1: every cell maps onto one of the six existing keys.
62
+ */
63
+ export const CELL_STEP: Readonly<Record<ProcessCell, StepKey>> = {
64
+ implement: "Implementing",
65
+ converge: "Converging",
66
+ merge: "Merging",
67
+ // Interstitial cells collapse into the host bracket (hold, do not advance).
68
+ wait: "Implementing",
69
+ human: "Converging",
70
+ escalation: "Converging",
71
+ };
72
+
73
+ /** The zero-based ordinal of a step on the axis — the total order the frontier reduction compares
74
+ * "advancement" by. `-1` for an unknown key (defensive; every derived step is a `STEP_KEYS` member). */
75
+ export function stepOrdinal(step: StepKey): number {
76
+ return STEP_KEYS.indexOf(step);
77
+ }
78
+
79
+ /** The renderer's terminal render-state tiers, reusing the SHIPPED `featureReadModel` `stage_state`
80
+ * basis (`STAGE_DONE_STATUSES` + the `stage_state` CASE, app/featureReadModel.ts) rather than inventing
81
+ * a second mapping (derivation-over-duplication, §4b §296-308):
82
+ * - `ok` — a SUCCESS terminal: `merged` / `converged` / the new canonical `done`.
83
+ * - `blocked` — the renderer's DISTINCT operator-actionable `blocked` terminal (never folded away).
84
+ * - `failed` — a FAILED terminal: `failed` / `skipped` / `abandoned`.
85
+ * - `null` — not terminal (in progress).
86
+ * Note this is the PER-CELL / per-PR tier: `converged` is a success terminal here. The EPIC rollup's
87
+ * shape-aware predicate (`converged` is resolved-not-landed) is applied by the caller, not here. */
88
+ export type TerminalTier = "ok" | "failed" | "blocked";
89
+
90
+ /** The delivery-graph canonical SUCCESS terminal (app/deliveryGraphReadModel.ts). `STAGE_DONE_STATUSES`
91
+ * predates the S7 axis and does NOT include it, so it is the one status tiered ON TOP of the shared
92
+ * feature basis below (special-cased per the ADR §4b `done` value). */
93
+ export const DONE_TERMINAL = "done";
94
+
95
+ /** The per-cell terminal PARTITION — the SAME basis as featureReadModel's `stage_state` CASE
96
+ * (app/featureReadModel.ts: `merged`/`converged`→ok, `blocked`→blocked, `failed`/`skipped`/`abandoned`→
97
+ * failed). It is NOT a second hand-kept copy: every member is drawn from the shipped
98
+ * `STAGE_DONE_STATUSES`, and the exhaustiveness guard below fails at module load if this partition ever
99
+ * stops covering that canonical set EXACTLY — so a terminal status added to `STAGE_DONE_STATUSES` cannot
100
+ * silently fall through untiered (drift becomes a hard error, not a wrong render). */
101
+ const SUCCESS_TERMINALS: readonly string[] = ["merged", "converged"];
102
+ const BLOCKED_TERMINAL = "blocked";
103
+ const FAILED_TERMINALS: readonly string[] = ["failed", "skipped", "abandoned"];
104
+
105
+ // Structural coupling to the single source of truth: the partition must tier EXACTLY the members of
106
+ // STAGE_DONE_STATUSES (the delivery-graph `done` value is tiered separately, so it is excluded here).
107
+ const _tiered = new Set<string>([...SUCCESS_TERMINALS, BLOCKED_TERMINAL, ...FAILED_TERMINALS]);
108
+ const _canonical = new Set<string>(STAGE_DONE_STATUSES);
109
+ if (_tiered.size !== _canonical.size || [..._canonical].some((s) => !_tiered.has(s))) {
110
+ throw new Error(
111
+ "stepAxis terminalTier partition drifted from STAGE_DONE_STATUSES — every terminal status must be " +
112
+ `tiered exactly once. tiered=[${[..._tiered].sort().join(",")}] canonical=[${[..._canonical].sort().join(",")}]`,
113
+ );
114
+ }
115
+
116
+ export function terminalTier(status: string): TerminalTier | null {
117
+ if (status === DONE_TERMINAL || SUCCESS_TERMINALS.includes(status)) return "ok";
118
+ if (status === BLOCKED_TERMINAL) return "blocked";
119
+ if (FAILED_TERMINALS.includes(status)) return "failed";
120
+ return null;
121
+ }
122
+
123
+ /** A `TerminalTier` is a NON-SUCCESS terminal (`failed` / `blocked`) iff it is operator-actionable —
124
+ * the thing an in-flight sibling must not mask. `ok` (success) is the only success terminal. */
125
+ export function isNonSuccessTerminal(tier: TerminalTier): boolean {
126
+ return tier === "failed" || tier === "blocked";
127
+ }
128
+
129
+ /** Map a terminal tier onto the `pipeline` column's `stateField` vocabulary (`ok`/`failed`/`blocked`).
130
+ * The raw canonical `done` never reaches the renderer verbatim (any other string silently degrades to
131
+ * in-progress), so a success terminal renders as `ok`. Total over the three tiers. */
132
+ export function tierRenderState(tier: TerminalTier): StageState {
133
+ return tier;
134
+ }
135
+
136
+ /** One branch of an aggregate's active frontier, projected onto the canonical axis. `terminal` is the
137
+ * branch's canonical terminal status when it has settled (`merged`/`converged`/`done`/`blocked`/
138
+ * `failed`/`skipped`/`abandoned`), or `null` while the branch is still active at `step`. `nodeId` is
139
+ * the stable identity used as the deterministic tie-break. */
140
+ export interface FrontierBranch {
141
+ nodeId: string;
142
+ step: StepKey;
143
+ terminal: string | null;
144
+ }
145
+
146
+ /** The reduced scalar the `pipeline` column binds: one `STAGE_KEYS` step plus its render state. */
147
+ export interface ReducedFrontier {
148
+ step: StepKey;
149
+ state: StageState;
150
+ }
151
+
152
+ /** Deterministic tie-break: earliest step ordinal, then stable `nodeId`. */
153
+ function earliest(a: FrontierBranch, b: FrontierBranch): FrontierBranch {
154
+ const da = stepOrdinal(a.step);
155
+ const db = stepOrdinal(b.step);
156
+ if (da !== db) return da < db ? a : b;
157
+ return a.nodeId <= b.nodeId ? a : b;
158
+ }
159
+
160
+ /**
161
+ * Reduce a parallel active frontier to ONE deterministic step for the scalar `pipeline` `activeField`
162
+ * (§4b §280-332). The `pipeline` column binds a single scalar, but an N-node/parallel DAG (epic waves,
163
+ * delivery graphs) can occupy incomparable cells at once, so the frontier is reduced deterministically:
164
+ *
165
+ * - A **non-success terminal** (`failed` / `blocked`) takes PRECEDENCE in every case — it is an
166
+ * operator-actionable signal an in-flight sibling must not mask. Among multiple non-success
167
+ * terminals the tie-break is earliest terminal step, then stable node id; the aggregate renders at
168
+ * that branch's step with that terminal's render state (`failed` / `blocked`).
169
+ * - Otherwise, if any branch is still ACTIVE, reduce to the **least-advanced active branch** (the
170
+ * "still blocked on" read) with an in-progress state — the aggregate never renders further along
171
+ * than its slowest in-flight branch. Terminal (success) branches are past, not "still blocked on".
172
+ * - Otherwise every branch is a SUCCESS terminal → `done` (the axis tail, `ok`).
173
+ *
174
+ * The shape-aware epic success predicate (`converged` is resolved-not-landed for an epic rollup, not a
175
+ * success terminal) is applied by the CALLER when it classifies each branch's `terminal`; this reducer
176
+ * treats whatever terminal it is handed per the per-cell tier. A single-branch unit (feature, and a
177
+ * delivery-graph at S7's coarse run-level fidelity) reduces trivially to that branch.
178
+ *
179
+ * Throws on an empty frontier — a unit always has at least its own (initial) branch; an empty input is
180
+ * a caller bug, not a renderable state.
181
+ */
182
+ export function reduceFrontier(branches: readonly FrontierBranch[]): ReducedFrontier {
183
+ if (branches.length === 0) {
184
+ throw new Error("reduceFrontier: empty frontier — a unit always has at least one branch");
185
+ }
186
+
187
+ const nonSuccess: FrontierBranch[] = [];
188
+ const active: FrontierBranch[] = [];
189
+ for (const b of branches) {
190
+ if (b.terminal === null) {
191
+ active.push(b);
192
+ continue;
193
+ }
194
+ const tier = terminalTier(b.terminal);
195
+ // An unrecognised terminal degrades to a failed-tier signal (defensive; the caller passes canonical
196
+ // union terminals) so it is never silently masked by an in-flight sibling.
197
+ if (tier === null || isNonSuccessTerminal(tier)) nonSuccess.push(b);
198
+ }
199
+
200
+ if (nonSuccess.length > 0) {
201
+ const pick = nonSuccess.reduce(earliest);
202
+ const tier = terminalTier(pick.terminal ?? "") ?? "failed";
203
+ return { step: pick.step, state: tierRenderState(tier) };
204
+ }
205
+
206
+ if (active.length > 0) {
207
+ const pick = active.reduce(earliest);
208
+ return { step: pick.step, state: null };
209
+ }
210
+
211
+ // All branches are success terminals → the shared success bucket (`done`).
212
+ return { step: TERMINAL_STEP, state: "ok" };
213
+ }
214
+
215
+ /** Re-export the shipped terminal-status set the tier basis draws on, so a consumer can reference the
216
+ * single source without a second import of app/featureReadModel.ts. */
217
+ export { STAGE_DONE_STATUSES };
@@ -0,0 +1,75 @@
1
+ -- Delivery-graph read model: DECLARE ONCE, compile to BOTH backends (ADR-0065, nano-ide#452), for the
2
+ -- ONE derived stepper (ADR 0006 §4b, issue #541 / S7).
3
+ --
4
+ -- Before §4b the delivery-graph surfaces rendered `delivery_graph_runs.phase` — a bare text projection
5
+ -- the user-task park poll (`pollDeliveryGraphPhase`) recomputes — on a DIFFERENT renderer from
6
+ -- feature's `pipeline` stepper. S7 collapses feature + delivery-graph onto the ONE canonical step axis
7
+ -- (app/stepAxis.ts, seeded from `STAGE_KEYS`) rendered by the ONE `pipeline` kind. This migration adds
8
+ -- the delivery-graph half: a `delivery_graph_read_model` VIEW that maps the run's lifecycle onto a
9
+ -- CONFIGURED `STAGE_KEYS` bracket (`stage`) with a render state (`stage_state`), plus a companion
10
+ -- `park_label` display column carrying the actionable "Parked on human node: <label>" text so promoting
11
+ -- the stepper does not drop the detail the plain Phase cell showed.
12
+ --
13
+ -- Every DERIVED column below is emitted VERBATIM from the ONE declaration in
14
+ -- app/deliveryGraphReadModel.ts — the member-PR rollup DDL from `deliveryGraphPrCounts.viewDdl()`, and
15
+ -- `stage`/`stage_state` from `deliveryGraphReadModel.sqlSelectFor(col, { baseAlias: "dg" })` — which
16
+ -- ALSO drive the runtime TS via `reduce`/`fnFor`. The two lowerings fall out of the same closed-DSL AST
17
+ -- and cannot diverge; a drift guard (app/deliveryGraphReadModel.test.ts) fails if this file stops
18
+ -- matching the declaration, and `assertRollupParity`/`assertReadModelParity` prove the SQL and TS
19
+ -- lowerings agree.
20
+ --
21
+ -- PER-SHAPE CORRELATION. A delivery-graph run has no aggregate `pr_key`; its downstream PRs attach via
22
+ -- `pull_requests.root_request_key = delivery_graph_runs.run_key`. The `delivery_graph_pr_counts` rollup
23
+ -- folds the member PRs (through `pull_requests__tracking.derived_status`, so an out-of-band-terminated
24
+ -- PR is not held in flight) grouped by `root_request_key`, and the read model LEFT JOINs it on
25
+ -- `dg.run_key = pc.root_request_key`. `prs_in_flight > 0` tempers a `running` run to `Converging`,
26
+ -- matching the shipped `deliveryOriginStage` (app/lineage.ts) — NOT a `process_key` join (that key is
27
+ -- reassigned to the downstream convergence/merge instances).
28
+ --
29
+ -- SEMANTICS. The status-classifying `stage`/`stage_state` read the terminal-folded `derived_status` off
30
+ -- the auto-provisioned `delivery_graph_runs__tracking` derived VIEW (ADR-0065), so a cancelled run
31
+ -- renders `Done`/`failed` instead of freezing at `Implementing`/`Converging`. Base columns stay aliased
32
+ -- identity pass-throughs (so the static pages↔schema contract guard sees the VIEW's columns), sourced
33
+ -- off `delivery_graph_runs__tracking`'s re-export of the base `delivery_graph_runs.*`; `status` is the
34
+ -- effective `COALESCE(derived_status, status)` so the pages' Active/History status filter tracks a
35
+ -- terminated run. `park_label` is a hand-authored DISPLAY column (D3 — display formatting is out of the
36
+ -- framework AST, so it carries no TS twin): the run's `phase` when it is parked on a human node
37
+ -- (`phase_node_id` set), else NULL.
38
+ --
39
+ -- Forward-only VIEW definition (DROP then CREATE). `delivery_graph_runs__tracking` is the managed VIEW
40
+ -- urban provisions at mount; SQLite does not validate a view body at CREATE time, so this migration
41
+ -- (which runs before that mount) is created fine and resolves once the managed VIEW exists. The runner
42
+ -- wraps each file in its own transaction, so this file must NOT contain BEGIN/COMMIT. Numbered after 085.
43
+
44
+ DROP VIEW IF EXISTS delivery_graph_read_model;
45
+ DROP VIEW IF EXISTS delivery_graph_pr_counts;
46
+
47
+ CREATE VIEW IF NOT EXISTS "delivery_graph_pr_counts" AS
48
+ SELECT
49
+ "__urban_rollup_src"."root_request_key" AS "root_request_key",
50
+ SUM(CASE WHEN COALESCE(((NOT COALESCE(("__urban_rollup_src"."root_request_key" IS NULL), 0)) AND (NOT COALESCE(COALESCE((COALESCE(("__urban_rollup_src"."derived_status" = 'converged'), 0) OR COALESCE(("__urban_rollup_src"."derived_status" = 'merged'), 0) OR COALESCE(("__urban_rollup_src"."derived_status" = 'abandoned'), 0)), 0), 0))), 0) THEN 1 ELSE 0 END) AS "prs_in_flight"
51
+ FROM "pull_requests__tracking" "__urban_rollup_src"
52
+ GROUP BY "__urban_rollup_src"."root_request_key";
53
+
54
+ CREATE VIEW delivery_graph_read_model AS
55
+ SELECT
56
+ dg.run_key AS run_key,
57
+ COALESCE(dg.derived_status, dg.status) AS status,
58
+ dg.process_key AS process_key,
59
+ dg.process_definition_id AS process_definition_id,
60
+ dg.digest AS digest,
61
+ dg.side_effecting AS side_effecting,
62
+ dg.node_count AS node_count,
63
+ dg.human_node_count AS human_node_count,
64
+ dg.side_effect_count AS side_effect_count,
65
+ dg.title AS title,
66
+ dg.phase AS phase,
67
+ dg.phase_node_id AS phase_node_id,
68
+ dg.human_labels AS human_labels,
69
+ dg.created_at AS created_at,
70
+ dg.updated_at AS updated_at,
71
+ CASE WHEN COALESCE(("dg"."derived_status" = 'done'), 0) THEN 'Done' WHEN COALESCE((COALESCE(("dg"."derived_status" = 'failed'), 0) OR COALESCE(("dg"."derived_status" = 'abandoned'), 0)), 0) THEN 'Done' WHEN COALESCE(("dg"."derived_status" = 'awaiting-approval'), 0) THEN 'Requested' WHEN COALESCE((COALESCE("pc"."prs_in_flight", 0) > 0), 0) THEN 'Converging' ELSE 'Implementing' END AS stage,
72
+ CASE WHEN COALESCE(("dg"."derived_status" = 'done'), 0) THEN 'ok' WHEN COALESCE((COALESCE(("dg"."derived_status" = 'failed'), 0) OR COALESCE(("dg"."derived_status" = 'abandoned'), 0)), 0) THEN 'failed' ELSE NULL END AS stage_state,
73
+ CASE WHEN dg.phase_node_id IS NOT NULL THEN dg.phase ELSE NULL END AS park_label
74
+ FROM delivery_graph_runs__tracking dg
75
+ LEFT JOIN delivery_graph_pr_counts pc ON dg.run_key = pc.root_request_key;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.143.0",
3
+ "version": "0.145.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",
@@ -64,7 +64,7 @@
64
64
  },
65
65
  "devDependencies": {
66
66
  "@biomejs/biome": "^2.4.11",
67
- "@nanobpm/urban-testkit": "^0.13.1",
67
+ "@nanobpm/urban-testkit": "^0.14.0",
68
68
  "@nanobpm/workflow": "^0.14.0",
69
69
  "@semantic-release/changelog": "^7.0.0",
70
70
  "@semantic-release/git": "^11.0.0",
@@ -85,14 +85,30 @@
85
85
  "data": {
86
86
  "kind": "datasource",
87
87
  "source": "app",
88
- "table": "delivery_graph_runs",
88
+ "table": "delivery_graph_read_model",
89
89
  "orderBy": { "field": "updated_at", "dir": "desc" },
90
90
  "filter": [{ "field": "run_key", "eqParam": true }]
91
91
  },
92
92
  "columns": [
93
93
  { "field": "title", "template": "{{title}}", "header": "Graph", "subtitleField": "run_key", "truncate": true, "width": "30%" },
94
94
  { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
95
- { "field": "phase", "header": "Phase", "truncate": true, "width": "30%" },
95
+ {
96
+ "field": "stage",
97
+ "header": "Pipeline",
98
+ "kind": "pipeline",
99
+ "stages": [
100
+ { "key": "Requested", "label": "Requested" },
101
+ { "key": "Implementing", "label": "Implementing" },
102
+ { "key": "PR open", "label": "PR open" },
103
+ { "key": "Converging", "label": "Converging" },
104
+ { "key": "Merging", "label": "Merging" },
105
+ { "key": "Done", "label": "Done" }
106
+ ],
107
+ "activeField": "stage",
108
+ "stateField": "stage_state",
109
+ "locus": { "field": "process_key", "link": { "kind": "processExplorer", "keyField": "process_key" } }
110
+ },
111
+ { "field": "park_label", "header": "Parked", "truncate": true, "width": "30%" },
96
112
  { "field": "node_count", "header": "Nodes" },
97
113
  { "field": "human_node_count", "header": "Human" },
98
114
  { "field": "side_effect_count", "header": "Side effects" },
@@ -118,7 +118,7 @@
118
118
  "data": {
119
119
  "kind": "datasource",
120
120
  "source": "app",
121
- "table": "delivery_graph_runs",
121
+ "table": "delivery_graph_read_model",
122
122
  "orderBy": { "field": "updated_at", "dir": "desc" },
123
123
  "filter": [{ "field": "status", "in": ["awaiting-approval", "running"] }]
124
124
  },
@@ -128,15 +128,32 @@
128
128
  { "label": "All", "filter": [] }
129
129
  ],
130
130
  "columns": [
131
- { "field": "title", "template": "{{title}}", "header": "Graph", "subtitleField": "run_key", "truncate": true, "width": "20%", "link": { "kind": "page", "page": "delivery-graph-detail", "keyField": "run_key" } },
132
- { "field": "status", "header": "Status", "truncate": true, "width": "12%", "link": { "kind": "processExplorer", "keyField": "process_key" } },
133
- { "field": "process_key", "header": "Instance", "width": "9%", "truncate": true, "link": { "kind": "processExplorer", "keyField": "process_key" } },
134
- { "field": "phase", "header": "Phase", "truncate": true, "width": "15%" },
131
+ { "field": "title", "template": "{{title}}", "header": "Graph", "subtitleField": "run_key", "truncate": true, "width": "18%", "link": { "kind": "page", "page": "delivery-graph-detail", "keyField": "run_key" } },
132
+ { "field": "status", "header": "Status", "truncate": true, "width": "10%", "link": { "kind": "processExplorer", "keyField": "process_key" } },
133
+ { "field": "process_key", "header": "Instance", "width": "8%", "truncate": true, "link": { "kind": "processExplorer", "keyField": "process_key" } },
134
+ {
135
+ "field": "stage",
136
+ "header": "Pipeline",
137
+ "kind": "pipeline",
138
+ "width": "18%",
139
+ "stages": [
140
+ { "key": "Requested", "label": "Requested" },
141
+ { "key": "Implementing", "label": "Implementing" },
142
+ { "key": "PR open", "label": "PR open" },
143
+ { "key": "Converging", "label": "Converging" },
144
+ { "key": "Merging", "label": "Merging" },
145
+ { "key": "Done", "label": "Done" }
146
+ ],
147
+ "activeField": "stage",
148
+ "stateField": "stage_state",
149
+ "locus": { "field": "process_key", "link": { "kind": "processExplorer", "keyField": "process_key" } }
150
+ },
151
+ { "field": "park_label", "header": "Parked", "truncate": true, "width": "12%" },
135
152
  { "field": "node_count", "header": "Nodes", "width": "5%" },
136
153
  { "field": "human_node_count", "header": "Human", "width": "5%" },
137
- { "field": "side_effect_count", "header": "Effects", "width": "7%" },
138
- { "field": "created_at", "header": "Dispatched", "width": "11%", "truncate": true, "format": "datetime" },
139
- { "field": "updated_at", "header": "Updated", "width": "11%", "truncate": true, "format": "datetime" }
154
+ { "field": "side_effect_count", "header": "Effects", "width": "6%" },
155
+ { "field": "created_at", "header": "Dispatched", "width": "7%", "truncate": true, "format": "datetime" },
156
+ { "field": "updated_at", "header": "Updated", "width": "6%", "truncate": true, "format": "datetime" }
140
157
  ],
141
158
  "detail": {
142
159
  "fields": [
@@ -207,14 +207,30 @@
207
207
  "data": {
208
208
  "kind": "datasource",
209
209
  "source": "app",
210
- "table": "delivery_graph_runs",
210
+ "table": "delivery_graph_read_model",
211
211
  "orderBy": { "field": "updated_at", "dir": "desc" },
212
212
  "filter": [{ "field": "status", "in": ["awaiting-approval", "running"] }]
213
213
  },
214
214
  "columns": [
215
215
  { "field": "title", "template": "{{title}}", "header": "Item", "subtitleField": "run_key", "truncate": true, "width": "30%", "link": { "kind": "page", "page": "delivery-graph-detail", "keyField": "run_key" } },
216
216
  { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
217
- { "field": "phase", "header": "Phase", "truncate": true, "width": "28%" },
217
+ {
218
+ "field": "stage",
219
+ "header": "Pipeline",
220
+ "kind": "pipeline",
221
+ "stages": [
222
+ { "key": "Requested", "label": "Requested" },
223
+ { "key": "Implementing", "label": "Implementing" },
224
+ { "key": "PR open", "label": "PR open" },
225
+ { "key": "Converging", "label": "Converging" },
226
+ { "key": "Merging", "label": "Merging" },
227
+ { "key": "Done", "label": "Done" }
228
+ ],
229
+ "activeField": "stage",
230
+ "stateField": "stage_state",
231
+ "locus": { "field": "process_key", "link": { "kind": "processExplorer", "keyField": "process_key" } }
232
+ },
233
+ { "field": "park_label", "header": "Parked", "truncate": true, "width": "28%" },
218
234
  { "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
219
235
  ],
220
236
  "detail": {
@@ -333,8 +333,10 @@ test("issue #205: overview is the landing page and first nav item", async () =>
333
333
  plan_read_model: { field: "list_bucket", in: ["active"] },
334
334
  feature_runs: { field: "status", in: ["running", "escalated", "awaiting_operator"] },
335
335
  // The 4th dispatch surface (issue #386) — active delivery graphs. Both in-flight statuses
336
- // (`awaiting-approval` parked at the gate, `running` dispatched) show here.
337
- delivery_graph_runs: { field: "status", in: ["awaiting-approval", "running"] },
336
+ // (`awaiting-approval` parked at the gate, `running` dispatched) show here. Binds the derived
337
+ // `delivery_graph_read_model` VIEW (S7 / #541 the single source of truth for the pipeline
338
+ // projection it also renders), which re-exports every run column plus the effective `status`.
339
+ delivery_graph_read_model: { field: "status", in: ["awaiting-approval", "running"] },
338
340
  };
339
341
  const grids = (overview.nodes ?? []).filter((n: Json) => n.type === "dataGrid");
340
342
  for (const [table, { field, in: values }] of Object.entries(expected)) {
@@ -361,19 +363,21 @@ test("issue #386: the human-facing Delivery Graphs surface is wired (nav tab, pa
361
363
  assert(tab, "pages/_nav.json must carry a `Delivery Graphs` nav tab → the delivery-graphs page");
362
364
 
363
365
  // 2) The page carries the compose → preview → dispatch App View (issue #441 — the rendered preview
364
- // that consumes the compile output), plus an in-flight grid over the delivery_graph_runs aggregate
365
- // that links to the per-graph detail page. The rich preview (mermaid diagram + humanNodes[] +
366
- // sideEffects[] + inline errors) can't render in a bare `actionForm` (its response is discarded),
367
- // so the surface is an `appView` embed over the SAME compile/dispatch doors.
366
+ // that consumes the compile output), plus an in-flight grid over the delivery-graph run data (the
367
+ // derived `delivery_graph_read_model` VIEW, S7 / #541 which re-exports every `delivery_graph_runs`
368
+ // column plus the pipeline projection) that links to the per-graph detail page. The rich preview
369
+ // (mermaid diagram + humanNodes[] + sideEffects[] + inline errors) can't render in a bare
370
+ // `actionForm` (its response is discarded), so the surface is an `appView` embed over the SAME
371
+ // compile/dispatch doors.
368
372
  const page = JSON.parse(readFileSync(`${ROOT}pages/delivery-graphs.page.json`, "utf8"));
369
373
  const compose = (page.nodes ?? []).find(
370
374
  (n: Json) => n.type === "appView" && typeof n.props?.embed === "string" && n.props.embed.includes("delivery-graphs/embed.html"),
371
375
  );
372
376
  assert(compose, "delivery-graphs page must have an appView embedding ./delivery-graphs/embed.html (the compose → preview → dispatch view, #441)");
373
377
  const grid = (page.nodes ?? []).find(
374
- (n: Json) => n.type === "dataGrid" && n.props?.data?.table === "delivery_graph_runs",
378
+ (n: Json) => n.type === "dataGrid" && n.props?.data?.table === "delivery_graph_read_model",
375
379
  );
376
- assert(grid, "delivery-graphs page must have an in-flight grid over delivery_graph_runs");
380
+ assert(grid, "delivery-graphs page must have an in-flight grid over the derived delivery_graph_read_model VIEW");
377
381
  const linkCol = (grid.props?.columns ?? []).find((c: Json) => c.link?.page === "delivery-graph-detail");
378
382
  assert(
379
383
  linkCol && linkCol.link?.keyField === "run_key",
@@ -383,9 +387,9 @@ test("issue #386: the human-facing Delivery Graphs surface is wired (nav tab, pa
383
387
  // 3) The per-graph detail page reads the run aggregate scoped to the route param (run_key).
384
388
  const detail = JSON.parse(readFileSync(`${ROOT}pages/delivery-graph-detail.page.json`, "utf8"));
385
389
  const runGrid = (detail.nodes ?? []).find(
386
- (n: Json) => n.type === "dataGrid" && n.props?.data?.table === "delivery_graph_runs",
390
+ (n: Json) => n.type === "dataGrid" && n.props?.data?.table === "delivery_graph_read_model",
387
391
  );
388
- assert(runGrid, "delivery-graph-detail must bind a grid to delivery_graph_runs");
392
+ assert(runGrid, "delivery-graph-detail must bind a grid to the derived delivery_graph_read_model VIEW");
389
393
  assert(
390
394
  (runGrid.props?.data?.filter ?? []).some((fl: Json) => fl.field === "run_key" && fl.eqParam === true),
391
395
  "delivery-graph-detail must scope its run grid to the route param (run_key eqParam)",
@@ -399,7 +403,7 @@ test("issue #386: the human-facing Delivery Graphs surface is wired (nav tab, pa
399
403
  "overview subtitle must no longer say 'three dispatch surfaces' (a delivery graph is a 4th)",
400
404
  );
401
405
  const ovGrid = (overview.nodes ?? []).find(
402
- (n: Json) => n.type === "dataGrid" && n.props?.data?.table === "delivery_graph_runs",
406
+ (n: Json) => n.type === "dataGrid" && n.props?.data?.table === "delivery_graph_read_model",
403
407
  );
404
408
  const ovLink = (ovGrid?.props?.columns ?? []).find((c: Json) => c.link?.page === "delivery-graph-detail");
405
409
  assert(
@@ -17,7 +17,6 @@
17
17
  // in that case (the edges were invalid).
18
18
  import type { AppJobHandler } from "@nanobpm/urban";
19
19
  import { type CapabilityNeed, parseCapabilityNeeds } from "../../app/capabilityNeed.ts";
20
- import { deriveEpicPhase } from "../../app/epicPhase.ts";
21
20
  import { plans, planTaskDeps, planTaskNeeds, planTasks } from "../../app/plan.ts";
22
21
  import { computeWaves, WaveError, type WaveTask } from "../../app/waves.ts";
23
22
  import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
@@ -147,11 +146,6 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
147
146
  // denormalises it onto the `plans` row.
148
147
  updated_at: ts,
149
148
  };
150
- // Domain-phase projection (#261): recording the plan hands the epic to the `review-plan` agent,
151
- // so it enters the Reviewing phase (derived structurally from this worker's BPMN element id).
152
- // Guard against a null derivation (element id absent) clobbering the genesis phase.
153
- const epicPhase = deriveEpicPhase(job.elementId);
154
- if (epicPhase) patch.epic_phase = epicPhase;
155
149
  if (tasks.length === 0) patch.outcome = note ? str(note) : "planner emitted no tasks";
156
150
  await plans(app.data).update(planKey, patch);
157
151
 
@@ -13,7 +13,6 @@
13
13
 
14
14
  import type { AppJobHandler } from "@nanobpm/urban";
15
15
  import { BpmnError } from "@nanobpm/urban";
16
- import { deriveEpicPhase } from "../../app/epicPhase.ts";
17
16
  import { plans, planTasks } from "../../app/plan.ts";
18
17
  import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
19
18
 
@@ -50,16 +49,16 @@ const handler: AppJobHandler<In> = async (job, app) => {
50
49
  throw new BpmnError("NO_WORK_DISPATCHED", `${planKey}: ${outcome}`);
51
50
  }
52
51
 
53
- // Domain-phase projection (#261): the finalizer landed with opened PRs the epic reaches its
54
- // terminal "Fleet dispatched" phase (derived structurally from this worker's BPMN element id).
55
- // The failed/no-work path above leaves epic_phase untouched: its terminal signal is status +
56
- // outcome, and stamping "Dispatched" against a failed epic would misread. A null derivation
57
- // (element id absent) must not clobber the last implementing phase.
58
- const epicPhase = deriveEpicPhase(job.elementId);
52
+ // The epic's terminal "Fleet dispatched" phase is no longer stamped here (S8, #542). While this
53
+ // finalizer's token is ACTIVE the epic reads `Finalizing` from the live element-instance model
54
+ // (`pollEpicPhase` `deriveEpicPhaseLive`); the terminal `Dispatched` is then derived from this
55
+ // `done` status by `pollEpicPhase` (`deriveTerminalEpicPhase`) a completion marker with no live
56
+ // token to read, so it is taken from the durable terminal status, not a fleeting ACTIVE token. The
57
+ // failed/no-work path above likewise stamps no phase — its terminal signal is status + outcome, and
58
+ // a `failed` epic is deliberately never labelled Dispatched.
59
59
  await plans(app.data).update(planKey, {
60
60
  status: "done",
61
61
  outcome: `${opened} PR(s) dispatched to convergence`,
62
- ...(epicPhase ? { epic_phase: epicPhase } : {}),
63
62
  updated_at: ts,
64
63
  });
65
64
 
@@ -145,8 +145,9 @@ test("record-wave retries the same wave when a task is still pending", async ()
145
145
  // Wave progress (current_wave/wave_label) was retired as a stored projection (epic #412) — derived
146
146
  // from `plan_tasks` by the plan_wave_label VIEW — so record-wave no longer writes it.
147
147
  assertEquals("current_wave" in (planUpdates[0].patch as Record<string, unknown>), false);
148
- // Domain-phase projection (#261): more waves remain, so the epic stays Implementing (wave n/t).
149
- assertEquals((planUpdates[0].patch as Record<string, unknown>).epic_phase, "Implementing (wave 2/2)");
148
+ // Domain-phase projection is no longer stamped by this worker (S8, #542) the epic phase is a pure
149
+ // read-model derivation off the live element-instance model (`pollEpicPhase`, app/service.ts).
150
+ assertEquals("epic_phase" in (planUpdates[0].patch as Record<string, unknown>), false);
150
151
  });
151
152
 
152
153
  test("record-wave pins current_wave to the last index and clears gate_wave on the final wave", async () => {
@@ -178,9 +179,9 @@ test("record-wave pins current_wave to the last index and clears gate_wave on th
178
179
  assertEquals((planUpdates[0].patch as Record<string, unknown>).gate_wave, null);
179
180
  assertEquals("current_wave" in (planUpdates[0].patch as Record<string, unknown>), false);
180
181
  assertEquals("wave_label" in (planUpdates[0].patch as Record<string, unknown>), false);
181
- // Domain-phase projection (#261): the final wave landed with no successor and no trial merge, so
182
- // the epic enters Finalizing (record-results then advances to the Dispatched terminal).
183
- assertEquals((planUpdates[0].patch as Record<string, unknown>).epic_phase, "Finalizing");
182
+ // Domain-phase projection is no longer stamped by this worker (S8, #542) the epic phase is a pure
183
+ // read-model derivation off the live element-instance model (`pollEpicPhase`, app/service.ts).
184
+ assertEquals("epic_phase" in (planUpdates[0].patch as Record<string, unknown>), false);
184
185
  });
185
186
 
186
187
  test("record-wave writes no wave-progress columns for a taskless plan (waveCount 0)", async () => {