@nanobpm/nano-workforce 0.139.1 → 0.139.3

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/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.139.3](https://github.com/nanobpm/nano-workforce/compare/v0.139.2...v0.139.3) (2026-08-25)
2
+
3
+ ### Documentation
4
+
5
+ * **adr:** add §4b derived stepper decision to ADR 0006 ([#540](https://github.com/nanobpm/nano-workforce/issues/540)) ([e85d25b](https://github.com/nanobpm/nano-workforce/commit/e85d25be90fb77cd5ea53b5836df68103c2e2f33)), closes [#464](https://github.com/nanobpm/nano-workforce/issues/464) [nano-ide#473](https://github.com/nanobpm/nano-ide/issues/473) [#473](https://github.com/nanobpm/nano-workforce/issues/473) [#473](https://github.com/nanobpm/nano-workforce/issues/473) [#464](https://github.com/nanobpm/nano-workforce/issues/464)
6
+
7
+ ## [0.139.2](https://github.com/nanobpm/nano-workforce/compare/v0.139.1...v0.139.2) (2026-08-24)
8
+
9
+ ### Bug Fixes
10
+
11
+ * **delivery-graphs:** stop the in-flight grid headers wrapping to 0-width columns ([#538](https://github.com/nanobpm/nano-workforce/issues/538)) ([#539](https://github.com/nanobpm/nano-workforce/issues/539)) ([c52e85b](https://github.com/nanobpm/nano-workforce/commit/c52e85bf38edef680914df0336ff95c98a364059))
12
+
1
13
  ## [0.139.1](https://github.com/nanobpm/nano-workforce/compare/v0.139.0...v0.139.1) (2026-08-24)
2
14
 
3
15
  ### Bug Fixes
@@ -20,8 +20,13 @@ to collapse three bespoke status unions into one),
20
20
  nano-ide **#424** (datasource can read a SQL VIEW — the *data-level* unlock),
21
21
  nano-workforce **#416** (the PR bumping the testkit to engine-wasm 0.7.2, which executes `callActivity`
22
22
  — the *process-level* unlock),
23
- nano-workforce **#464** (the tracking issue with slices S1–S6),
24
- nano-workforce **#305** (consolidate escalations on native `user_tasks` a natural sub-step of S1/S3).
23
+ nano-workforce **#464** (the tracking issue its live checklist currently ends at **S5**; **S6 is
24
+ present in this ADR's rollout but not yet listed there**, and the S7/S8 slices below are **added by this
25
+ ADR** — so S6–S8 must all be appended to #464's checklist, which does not yet list them),
26
+ nano-workforce **#305** (consolidate escalations on native `user_tasks` — a natural sub-step of S1/S3),
27
+ nano-ide **#473** (surface the element-instance query on the `EngineClient` binding — the sole upstream
28
+ **binding/platform** dependency of the S8 stepper slice; S8 *additionally* depends on S4's
29
+ inline-vs-child / correlation decision, tracked with #464).
25
30
 
26
31
  ## Context
27
32
 
@@ -85,7 +90,10 @@ the multi-instance `implement` subprocess in `plan-fanout.bpmn` — plus a **thi
85
90
  compiler that re-emits it once per graph node. Sibling cells
86
91
  (readiness-poll, human-escalation) duplicate the same way. **No `callActivity` exists in any diagram**
87
92
  because it did nothing. **Unlocked by #416** (engine-wasm 0.4.0 → **0.7.2**). engine-core executes
88
- `callActivity` by inline-expanding the called process at deploy (`engine-core/src/model.rs:1217/1255`).
93
+ `callActivity` natively as a **linked child process instance** (`engine-core/src/model.rs`); inline-expanding
94
+ the called process into one flat instance is the **opt-in** `inline_call_activities` transform, *not* the
95
+ default — the same native-child-vs-opt-in-inline distinction §4b's cross-instance-correlation note below
96
+ depends on.
89
97
  **Verified live:** a `callActivity` parent+child model deployed through engine-wasm 0.7.2 runs to
90
98
  `COMPLETED`. Caveat: #416 bumps only the **dev-only** `@nanobpm/urban-testkit`; the production
91
99
  `@nanobpm/urban` broker does not itself pin `engine-wasm`, so this verification proves the in-process
@@ -187,6 +195,277 @@ representation. These unions are **not** identical today — features use
187
195
  separate node contract — plus the per-shape mapping and precedence and the write/`instanceTracking`
188
196
  behavior — not merely projecting an existing value.
189
197
 
198
+ ### 4b. One derived **stepper** — the cells are the source positions mapped onto the shared step axis, per-shape correlated
199
+
200
+ §4 collapses the three status *unions* into one derived union; this decision collapses the three
201
+ *progress projections* — the thing an operator actually reads as "where is this unit in its
202
+ lifecycle" — into **one derived stepper**, rendered by **one** primitive across all three surfaces.
203
+
204
+ Today that projection is triplicated the same way the aggregate is, and at three different maturity
205
+ levels for one idea:
206
+
207
+ | Surface | Derivation | Step vocabulary | Render |
208
+ |---|---|---|---|
209
+ | **Feature** | `deriveStage` over the declare-once read-model (`app/stage.ts`; mig. 076 declared it, mig. 081 superseded the VIEW for terminal-folded status — 073 is historical). Its inputs are the feature run's effective `status` / `pr_key` / `converge`+`auto_merge` flags / open user-task projections; `pollFeatureDelivery` separately reconciles PR status **into** that row upstream | `STAGE_KEYS` = Requested → Implementing → PR open → Converging → Merging → Done | the **`pipeline` stepper** kind (`pages/feature.page.json`) |
210
+ | **Epic** | **write-time stamp** by each spine worker (`app/epicPhase.ts`, `ELEMENT_PHASE`) | `EPIC_PHASE` (wave-labelled) | a plain `{{epic_phase}}` **text cell** |
211
+ | **Delivery graph** | engine-truth poll of **process-instance state + open user-task parks** (`pollDeliveryGraphPhase` → `deriveDeliveryPhase`: `COMPLETED`/`TERMINATED` → `done`/`failed`, else the open human-node park) | `DELIVERY_PHASE` + `Parked on human node: <label>` | a plain `phase` **text cell** |
212
+
213
+ Three derivations × three vocabularies × two renderers — and only the feature surface renders an
214
+ actual stepper; epic and delivery-graph project a bare string. A change to "the lifecycle steps of a
215
+ unit of work" is the same by-hand-in-three-places drift this ADR exists to remove.
216
+
217
+ **The step axis is the cells — but the cell→step mapping must be defined, not assumed.** Once §2 makes
218
+ the process a composition of named cells (`implement` → `converge?` → `merge?`, plus `wait` / `human` /
219
+ `escalation`), those ordered cells are the natural step axis. `STAGE_KEYS` is the closest *existing*
220
+ projection of it, but it is **not** literally that sequence: `Requested` / `PR open` / `Done` are
221
+ lifecycle states, while `implement` / `converge` / `merge` are process cells (and today's feature
222
+ derivation additionally treats `Merging` as an upcoming/visual stage and can mark stages *skipped*). So
223
+ §4b's first deliverable is an explicit **cell → step mapping** — which cell entry/exit advances which
224
+ step, how `Requested`/`PR open`/`Done` bracket the cell run, and how an inserted `wait` / `human` /
225
+ `escalation` cell **collapses into an existing `STAGE_KEYS` bracket** — the `pipeline` renderer binds a
226
+ static `stages` array (`pages/feature.page.json:120-126`), so v1 adds no axis entries and needs no
227
+ renderer change — seeded from `STAGE_KEYS` but owning the canonical definition. It
228
+ is not enough to declare one axis *is* the other. **v1 leaves the two existing axis consumers physically
229
+ in place** — the exported `STAGE_KEYS` (`app/stage.ts`) and the static `stages` array in
230
+ `pages/feature.page.json` — and only *seeds* the new mapping from `STAGE_KEYS`; it does **not** yet
231
+ generate or retire either, so both remain until a follow-up derives the static `stages` array from the
232
+ canonical mapping (a parity test, then retirement of the duplicate) — flagged here as the residual
233
+ drift-surface the one-stepper decision does not close in v1. (The canonical stage *derivation* itself
234
+ already has a single author — `app/featureReadModel.ts`, from which both the SQLite VIEW and the
235
+ `deriveStage` TS oracle are generated; its `caseWhen` emits the `Requested`…`Done` literals directly and
236
+ does **not** import `STAGE_KEYS`. So `STAGE_KEYS` is not the derivation's source but *itself* one of the
237
+ two vocabulary duplicates whose literals must be reconciled against — and ultimately derived from — the
238
+ canonical `featureReadModel` declaration; `featureReadModel` is that canonical author, not a third
239
+ divergent one.)
240
+
241
+ **The current step is derived by per-shape correlation (not a single `process_key` join).**
242
+ The projection must answer "which cell has this unit reached," fusing two truth sources:
243
+
244
+ - **Engine truth — the furthest element reached — IS available on the runtime.** nwf runs against
245
+ **Nano's Rust engine over its REST API** (not the wasm testkit). That API exposes a full
246
+ element-instance read model: `POST /v2/element-instances/search` (`searchElementInstances` →
247
+ `elementId` + `elementType` + element **state**, keyed by process-instance) and the element-instance
248
+ **wait-states** search (`searchElementInstanceWaitStates` → **job and message** parks, not only user
249
+ tasks). With namespaced cell element ids (a convention §2/S4 must still define — the current compiler
250
+ uses node-local ids), a reached element maps to a reached cell — the
251
+ sharpest signal, and the only one that sees a token mid-cell (an active `implement` job) that no
252
+ work-table row has caught up to yet.
253
+ - **Work-table + aggregate truth — the correlated downstream state, per shape.** For a **feature**, the
254
+ `feature_runs` row (`status` / `pr_key` / flags) gives the coarse `Requested` / `Implementing` / `Done`
255
+ bracket; for an **epic** there is **no aggregate `pr_key`** — its status is `plans.status` / the plan
256
+ rollups and its slice-PR identity is `plan_tasks.pr_key` (`app/retro.ts`, `app/delivery.ts`). On top of
257
+ that bracket, `pull_requests` (and siblings) carry the `converging` / `waiting_review` /
258
+ `merging` sub-state — but this is a **coarse status handoff, not a preserved sub-state**:
259
+ `pollFeatureDelivery` maps the feature's **own `pr_key`** PR status into `feature_runs` (keeping the
260
+ detailed value only in `delivery_label`), and `deriveStage` reads neither `delivery_label` nor those
261
+ sub-states — an opened `pr_key` renders `PR open` and a `converging` run renders `Converging`, i.e. it
262
+ exposes only the coarse lifecycle key (never `Merging` as the active stage). (`pollFeatureDelivery` does
263
+ **not** consume the lineage/plan projections — those are separate aggregate rollups for the epic path,
264
+ not the feature derivation; `instanceTracking` supplies only the tracking-status edges, not the PR
265
+ rollup.) So S7 must describe this as a coarse handoff and, for an epic, treat the lineage/plan rollups
266
+ as their own shape-specific path. The projection must
267
+ name an explicit **precedence** between engine truth and this state (engine element position when
268
+ available, else aggregate/work-table), not silently pick one.
269
+
270
+ **Correlation is per-shape, because `process_key` is reassigned.** A feature/epic aggregate's
271
+ `process_key` identifies its *own* (feature / plan-fanout) process instance, but
272
+ `pull_requests.process_key` is **overwritten** — first to the downstream convergence instance, then
273
+ again to the merge instance (`app/service.ts` — `submitPr` writes the convergence-instance key,
274
+ `startMerge` the merge-instance key). The stable
275
+ aggregate↔PR link is **`pr_key`**, and epics additionally need node/root mapping (lineage
276
+ `rootRequestKey`, nwf#245). So §4b must define the per-shape joins — or persist a **canonical unit
277
+ key** — rather than assume a single `process_key` join; a naïve `process_key` join would misattribute
278
+ or miss work-table state. This canonical-key requirement is itself an argument for the §1 aggregate.
279
+
280
+ **The aggregate step is a frontier — reduced to one deterministic step for the renderer.** For the
281
+ 1-node feature there is one active step. For an N-node/parallel DAG (epic waves, delivery graphs), two
282
+ branches can occupy incomparable cells at once, and no total order picks a unique maximum. But the
283
+ `pipeline` renderer binds a **single scalar** `activeField` (`pages/feature.page.json`), so S7 **must**
284
+ reduce that frontier deterministically to one step — the canonical choice is the **least-advanced active
285
+ branch** (the "still blocked on" read), so the aggregate never renders further along than its slowest
286
+ in-flight branch. This reduction is an explicit S7 decision, not left to the implementer; a set-valued /
287
+ multi-track render would be a separate renderer change, out of scope here. **v1 does not populate the
288
+ pipeline's `notInPathField` (the skipped-path axis) for the aggregate** — the reduction exposes only the
289
+ scalar `activeField` + terminal `state`, so no deterministic skipped-set rollup is defined and equivalent
290
+ graphs cannot diverge on a skipped path they never render; a per-branch skipped-set rollup onto
291
+ `notInPathField` is deferred with the set-valued renderer change (out of scope). The per-node steps are
292
+ individually well-defined **only in the conceptual / S8 element-instance model** underneath the
293
+ reduction; at S7's coarse fidelity an active agent/wait/connector node exposes only `Running` with **no
294
+ node id** (`deriveDeliveryPhase`), so S7 assigns it **no individual cell step** and renders a single
295
+ coarse run-level step — the per-cell reduction becomes observable only once S8 binds the element-instance
296
+ topology. Because the canonical unit union carries more
297
+ terminal states than `done`/`failed` (a branch can end `merged` / `converged` / `skipped` / `blocked` /
298
+ `abandoned`), the rule first **normalizes** each terminal branch — reusing the **shipped
299
+ `featureReadModel` `stage_state` tiers** (`app/featureReadModel.ts`: `STAGE_DONE_STATUSES` + the
300
+ `stage_state` CASE) as the tier basis rather than inventing a second mapping, per derivation-over-duplication.
301
+ Note `done` is **not** itself emitted by the feature model — its `STAGE_DONE_STATUSES`
302
+ (`app/featureReadModel.ts:41`) omits `done` and `stage_state` yields only `ok` / `blocked` / `failed` /
303
+ `null`; `done` is the **new** canonical success value the delivery-unit / plan / graph unions introduce.
304
+ The union normalizes explicitly: its success terminal — `done`, and likewise `merged` / `converged` — collapses to that same
305
+ **successful** terminal (`done`); `failed` / `skipped` /
306
+ `abandoned` collapse to a **failed** terminal; and `blocked` stays the renderer's **distinct `blocked`**
307
+ state — *not* folded into `done` or `failed`, preserving the existing per-node semantics (`skipped` is a
308
+ failed-tier terminal there, not a success; `blocked` is its own terminal). These canonical tiers are
309
+ **not** fed to the renderer as-is: the `pipeline` column's `state` field accepts only `ok` / `failed` /
310
+ `blocked` / `null` (`app/stage.ts` `StageState`), so when rendered the **successful** terminal (`done`)
311
+ must map to `activeField = Done` with `state = ok`, the **failed** tier to `state = failed`, and
312
+ `blocked` to the distinct `blocked` state — the raw canonical `done` never reaches the renderer's
313
+ `stateField` (any other string silently degrades to in-progress, so a converged terminal must not be fed
314
+ `done` verbatim). For the frontier precedence
315
+ below, `done` is the only **successful** terminal, while both `failed` and `blocked` are **non-success,
316
+ operator-actionable** terminals — so the combinations below are defined over `done` (success) vs. a
317
+ non-success terminal (`failed`/`blocked`). This `done` is the **derived per-cell/delivery success
318
+ terminal**, **not** the raw `plans.status = 'done'` fan-out handoff — per `app/delivery.ts:39-42` that
319
+ plan status only means "the fan-out finished and ≥1 slice opened a PR, dispatched to convergence" (other
320
+ slices may still be blocked/skipped), so feeding it verbatim would render an epic `Done` prematurely; the
321
+ epic's actual success signal is `delivery = landed` (all slice PRs merged), which is what maps to this
322
+ success bucket. The rule must then define the **terminal combinations** the least-advanced-*active*
323
+ read leaves unspecified, so parallel epics/graphs render deterministically: (a) **mixed** — one or more
324
+ branches terminal alongside ≥1 active branch — reduces to the least-advanced *active* branch (terminal
325
+ branches are past, not "still blocked on"), **except** that a **non-success** terminal (`failed` or
326
+ `blocked`) takes **precedence** and renders the aggregate at that branch's step with that terminal's
327
+ render state (`state = failed` for a failed branch, the distinct `blocked` state for a blocked one — an
328
+ operator-actionable signal, not something an in-flight sibling should mask); when **multiple** branches
329
+ are in a non-success terminal the tie-break is **earliest terminal step, then stable node id**, so the
330
+ exposed step (and its `failed`/`blocked` state) is deterministic; (b) **all-terminal** — reduces to the
331
+ **earliest non-success terminal** step (`failed`/`blocked`, same tie-break) if any branch is non-success,
332
+ else `done`.
333
+
334
+ **The success predicate is shape-aware — `converged` is a success terminal per-PR/cell, but NOT for the
335
+ epic rollup.** The normalization above collapses `converged` (and `merged`) into the success terminal
336
+ `done` at the **per-PR / per-cell frontier** — correct for a single PR's own stepper (a review-only PR
337
+ *is* done once converged). But an **epic** aggregates *slice PRs*, and there a slice that is `converged`
338
+ but not merged is **resolved-not-landed**: `deriveDelivery` reports epic success as `delivery = landed`
339
+ **only when every slice PR has merged** (`prsInFlight == 0 && prsMerged == prsOpened && prsOpened > 0`),
340
+ and treats `converged` / `abandoned` slices as terminal-but-not-landed that never make an epic `landed`
341
+ (`app/delivery.ts:39-64`). **A `blocked`/`skipped` task produces no PR and so is invisible to that
342
+ PR-count predicate** (`record-results` can set `plan.status = done` once *one* slice opens a PR while
343
+ siblings are blocked/skipped — `app/delivery.ts:40-42`), so `delivery = landed` alone would wrongly
344
+ bucket an epic with one merged slice plus a blocked task as `Done`. The epic success predicate is
345
+ therefore **`delivery = landed` AND no task in a non-success terminal** (`blocked`/`skipped`): such a
346
+ task independently vetoes the success bucket, and the epic renders that blocked/skipped branch's step
347
+ (`state = blocked`/`failed`), never `Done` — keeping `blocked`/`skipped` non-success terminals
348
+ consistent with the per-cell rule. So the all-terminal rule's `else done` is evaluated against a **shape-scoped
349
+ success predicate**: for a feature/graph cell, a `converged` branch counts as `done`; for the **epic**
350
+ aggregate, only `delivery = landed` satisfies the success bucket — an all-`converged` (review-only) epic
351
+ renders as its least-advanced *converged* step, **not** `Done`, so a review-only epic is never reported
352
+ as landed. **Concretely for the renderer:** the epic projection does **not** reuse the per-feature
353
+ `deriveStage` success-collapse (which folds `converged` → `Done`/`ok`, `app/featureReadModel.ts:73-95`);
354
+ its `activeField` is the **least-advanced slice cell's** `STAGE_KEYS` bracket and its `state` is that
355
+ cell's `pipeline` render state (`ok`/`blocked`/`failed`/in-progress), and the `Done` bucket is entered
356
+ **only** when `deriveDelivery` reports `delivery = landed` (all slice PRs merged) — never on `converged`
357
+ alone. So an all-`converged` epic pins `activeField` to the converged slices' shared bracket with
358
+ `state = ok` (resolved-not-landed), and renders `Done` only once every slice has merged. This `Done`
359
+ bucket is scoped to **integration-branch landing**: for a custom-base (`epic/*`) epic, `delivery = landed`
360
+ means every slice merged onto the integration branch, after which `pollPromotion` (#299,
361
+ `app/promotion.ts`, `app/service.ts`) opens the final `epic/* → <default>` promotion PR and tracks it via
362
+ `promotion_state` (`ready`/`open`/`promoted`). The §4b stepper **intentionally ends at integration-branch
363
+ landing** and does *not* fold that downstream promotion into cell success — a landed epic whose promotion
364
+ PR is still `open` reads `Done` on the delivery stepper, with promotion surfaced by the epic card's
365
+ existing `promotion_state`, not the stepper.
366
+
367
+ **Cross-instance correlation, because composed cells are child instances by default.** engine-core's
368
+ `CallActivity` spawns a **distinct child process instance** and links it to the parent
369
+ (`engine-core/src/model.rs`); `inline_call_activities` (embedded-subprocess, one flat instance) is an
370
+ **opt-in** transform, not the default. So a §2 cell composition is a **parent + child instances**
371
+ unless the composition explicitly opts into inlining — and the broker's `/v2/process-instances/search`
372
+ has been observed returning null parent/root keys (#464), meaning a filter on the parent key alone
373
+ cannot be assumed to see a child cell's elements. §4b therefore requires an explicit choice at S4:
374
+ either compose cells with `inline_call_activities` (keeping one flat instance, so the element query
375
+ under one key suffices) **or** correlate across child instances by the canonical unit key. This is a
376
+ named design decision, not a settled fact.
377
+
378
+ **The binding, not the engine, is the current limiter — and that is the claim to retire.** The
379
+ `@nanobpm/urban` `EngineClient` binding nwf consumes surfaces only `searchProcessInstances` +
380
+ `searchUserTasks` today, so nwf cannot *yet* read the element-instance model the engine already serves.
381
+ This is exactly why the two existing derivations are workarounds: `pollDeliveryGraphPhase`
382
+ (`app/deliveryGraphRun.ts`) sees only **user-task** parks (missing job/message parks and active
383
+ elements), and epic's `epic_phase` (`app/epicPhase.ts`, nano-ide#266) projects from **write-provenance**
384
+ (each worker stamps its own `job.elementId`) *because* a live "furthest element" query wasn't surfaced.
385
+ The enabling upstream step is to **surface `searchElementInstances` / wait-states on the `EngineClient`
386
+ binding** (nano-ide / urban), after which both workarounds collapse into one live element-instance
387
+ projection over the cell axis.
388
+
389
+ **v1 ships on what's correlated today, at lifecycle-stage fidelity; the element-instance query sharpens
390
+ it to per-cell.** Until the binding surfaces the element model, §4b's projection is derivable *now* only
391
+ from aggregate/work-table state, **process-instance lifecycle state** (`searchProcessInstances` —
392
+ `COMPLETED`/`TERMINATED`, which `deriveDeliveryPhase` folds to `done`/`failed`), and user-task parks —
393
+ and that is **lifecycle-stage, not per-cell, even
394
+ for feature**: `deriveStage` collapses a token parked in a readiness-probe service/timer loop, or an
395
+ active `implement-task`, all to `Implementing` while `feature_runs.status` stays `running` and no user
396
+ task is open. It is coarser still for the other two, for reasons that must be stated *separately*:
397
+
398
+ - **Delivery graph:** `deriveDeliveryPhase` (`app/deliveryGraphRun.ts`) returns a generic `Running`
399
+ with **no node id** for an active `agent` / `wait` / `connector` node when no human task is open, and
400
+ `delivery_graph_runs` stores no current node.
401
+ - **Epic:** `record-plan` is classified **Reviewing** (`app/epicPhase.ts`), not Planning, so the
402
+ *initial* Planning phase carries only the `plans` row — `plan_tasks` rows first become available once
403
+ **Reviewing** starts, where `record-plan` *does* write them, so from Reviewing on it is not literally
404
+ row-less — but **no field exposes the current
405
+ pre-PR process position** (which plan/review activity is live): there is no PR row, and while the
406
+ `plan-review-decision` user-task park *is* a real open user-task signal for the Reviewing decision
407
+ point, no field surfaces the **complete activity position** (which plan/review step is live) — the
408
+ initial Planning phase and the running plan/review work between decision parks remain unobservable.
409
+ Only the write-time `epic_phase` stamp (`app/epicPhase.ts`) and the plan/PR rollups
410
+ observe *where* it is. `deriveDeliveryPhase` is the delivery-graph projection only and provides no
411
+ evidence for epic resolution.
412
+
413
+ So v1's scope is deliberately bounded: the one derivation + `pipeline` renderer covers **feature and
414
+ delivery-graph at lifecycle-stage fidelity**, rendering any unobservable node as an **explicitly coarse**
415
+ in-flight step. Because `activeField` must resolve to a *configured* pipeline stage (`STAGE_KEYS` runs
416
+ `Requested`…`Done`, with no `In flight` key), S7's canonical behaviour for that coarse case is **fixed
417
+ here, not left to the implementer**: **derive a configured key statelessly from the current coarse
418
+ inputs on every pass** — map the run's `status`/`phase` to the corresponding `STAGE_KEYS` key (e.g. a
419
+ `running` feature *with no PR key* to `Implementing`, marked in-progress — preserving `deriveStage`'s
420
+ input precedence, where the `pr_key`/`opened` arm runs **before** the `running` arm, so a `running`
421
+ feature whose `pr_key` is set already derives `PR open`, not `Implementing`; `app/stage.ts`,
422
+ `app/featureReadModel.ts:77-85`) rather than remembering a prior key the read
423
+ model does not persist, so the projection survives a restart, no new stage is invented, and no
424
+ renderer/axis change is needed. (This stateless per-pass derivation is the **resolved** S7 policy and
425
+ **supersedes** any earlier "hold the aggregate at its last observed configured stage" phrasing — the read
426
+ model persists no such key, so there is nothing to hold; a coarse run is recomputed from current inputs
427
+ every pass.) Adding a dedicated `In flight` key to the canonical axis is
428
+ **explicitly rejected** for v1 — it would fork the stage vocabulary across surfaces; the S7 rollout
429
+ below binds this same **stateless coarse-key** rule, so independent S7 implementations cannot diverge on
430
+ the stage axis or `activeField`. For
431
+ a **first observation with no prior key** (a freshly `running` `delivery_graph_runs` row carries only
432
+ `phase = "Running"`, no stored lifecycle key to hold), S7 must pin a deterministic **initial**
433
+ `STAGE_KEYS` value from a **status-specific** map — a `running` graph, whose dispatch has begun, maps to
434
+ `Implementing` (not the `Requested` head), while the pre-dispatch case below maps to the pre-run initial
435
+ key (literally **`Requested`**, `STAGE_KEYS[0]`) — so the scalar `activeField` is never undefined. A
436
+ **terminal** row is pinned the same way so it can never render an undefined/invalid active stage: a
437
+ `done` graph (phase `Completed`) maps to the `STAGE_KEYS` **tail** (`Done`) with `state = ok`, while a
438
+ `failed`/`abandoned` graph (phase `Failed`) maps to that same tail bracket with `state = failed` —
439
+ terminal status settles the `activeField` outright rather than leaving it unset. The same rule must cover the **`awaiting-approval`** rows
440
+ `delivery-graphs.page.json` filters into the active grid: their schema leaves `process_key` **NULL**
441
+ (no engine instance yet) and the **door seeds `phase = "Awaiting approval"` at write time** (migration
442
+ 058 only *creates* the `delivery_graph_runs` table — it seeds no rows and assigns no `phase`/`process_key`;
443
+ those come from the run builder/dispatch path), so they have no observable
444
+ element and no prior lifecycle key. **`awaiting-approval` is a legacy/reserved status — no longer
445
+ produced** (issue #460 moved dispatch to an operator action; the current producer claims a run directly
446
+ as `running`, `app/deliveryGraphDispatch.ts:104`), so this is only a **fallback for old rows**, not a
447
+ current pre-dispatch path — S7 must **not** reintroduce a removed approval flow. Those retained rows map
448
+ to that same deterministic **initial** pre-run
449
+ `STAGE_KEYS` value, literally **`Requested`** (they are dispatch-pending, before `Implementing`), not leave their `activeField`
450
+ undefined. It
451
+ must never fabricate a specific cell position it cannot observe. The **epic pipeline is deferred to S8**:
452
+ because its pre-PR position is unobservable from S7 inputs, epic keeps its `epic_phase` **text cell** as
453
+ an explicit, retained **second source** (write-provenance) until the element query lands — rather than
454
+ fabricating a derived step or promoting `epic_phase` to a permanent stepper source. When the binding
455
+ lands the element query (S8), the projection swaps its park/position source for the live engine element
456
+ instance — sharpening feature + delivery-graph to per-cell and bringing epic onto the same `pipeline` —
457
+ **preserving the step axis and renderer, and using whatever parent/child correlation strategy S4
458
+ selects** — retiring *both* the `epic_phase` write-time stamp and the user-task-only delivery-graph poll
459
+ into one derivation. **"Per-cell" here means per-cell *source fidelity*, rendered onto the existing
460
+ collapsed lifecycle axis — not a new per-cell step axis.** S8 sharpens the *source* the projection reads
461
+ (a live engine element-instance identifies exactly which cell a token occupies) while the rendered step
462
+ still **collapses that cell into the existing six `STAGE_KEYS` brackets** per §4b's cell→step mapping
463
+ (inserted `wait` / `human` / `escalation` cells fold into an existing bracket, §217-232); the static
464
+ six-stage `pipeline` `stages` array and its renderer are **preserved unchanged**. S8 does **not**
465
+ introduce a per-cell stages array or a distinct step per cell — a distinct cell can be *observed* as the
466
+ active source but is *displayed* at its collapsed lifecycle key. (Promoting cells to first-class steps
467
+ would be a separate axis-contract change, out of scope for S8.)
468
+
190
469
  ### 5. Preserve — the static-vs-adaptive execution axis (do NOT bundle it)
191
470
 
192
471
  This ADR consolidates the *representation*, not the *execution strategy*. ADR 0005's deliberate
@@ -211,9 +490,25 @@ their topology is produced. Unifying that axis is explicitly out of scope here.
211
490
  table-to-VIEW slice requires a **separately designed recovery/compatibility migration**, not a plain
212
491
  revert.
213
492
  - **Cost.** A backfill/migration for `delivery_units`; a one-time extraction of the shared cells; and
214
- the process slices are sequenced behind the (now-live) engine-wasm unlock. No behaviour change is
215
- intended — this is a representation consolidation, guarded by parity tests against the existing VIEWs
216
- and by the deploy+run engine tests.
493
+ the process slices are sequenced behind the (now-live) engine-wasm unlock. No **process/data-semantic**
494
+ behaviour change is intended — this is a representation consolidation, guarded by parity tests against
495
+ the existing VIEWs and by the deploy+run engine tests. (The rendered cell *does* change for operators —
496
+ S7 turns the delivery-graph `phase` text cell into a `pipeline`, and S8 the epic phase cell; that
497
+ operator-visible presentation change is intended, per the S7/S8 rollout below.)
498
+ - **One stepper kills the second-order drift.** The unit's *progress projection* — the thing an
499
+ operator reads as "where is this in its lifecycle" — is triplicated the same way the aggregate is
500
+ (feature's `deriveStage`, epic's write-time `epic_phase`, delivery-graph's `pollDeliveryGraphPhase`),
501
+ and only feature renders an actual stepper; epic and delivery-graph render a bare string. §4b collapses
502
+ the three onto one derivation over the cell axis (with an explicit cell→step mapping and per-shape
503
+ correlation — `pr_key`/canonical unit key, since `pull_requests.process_key` is reassigned downstream),
504
+ rendered by one `pipeline` kind. **S7** unifies **feature + delivery-graph** at **lifecycle-stage
505
+ fidelity** with **no engine change** (even feature is not per-cell today — `deriveStage` collapses
506
+ readiness/timer/implement to `Implementing`); the **epic pipeline is deferred to S8**, keeping its
507
+ `epic_phase` text cell meanwhile. The *only* upstream **binding** dependency — surfacing the element-instance query
508
+ on the `@nanobpm/urban` `EngineClient` binding (S8, nano-ide#473), which the engine already serves —
509
+ then sharpens all three to per-cell and retires epic's write-provenance stamp. (This is the only
510
+ *binding/platform* dependency, **not** the sole upstream dependency: S8 additionally depends on S4's
511
+ inline-vs-child/correlation decision (#464) — #473 is necessary, not sufficient; see the S8 rollout.)
217
512
 
218
513
  ## Rollout (see #464 for the live checklist)
219
514
 
@@ -251,6 +546,145 @@ deployment-runtime prerequisite noted above), not on #416 alone.
251
546
  merge (unit → base branch; graph → `main`).
252
547
  - **S6 · compiler emits calls** — `deliveryGraphCompiler` references shared cells instead of inlining
253
548
  per-node copies.
549
+ - **S7 · one derived stepper — v1 on today's surface** (Decision §4b) — define the **cell → step
550
+ mapping** (seeded from `STAGE_KEYS`, but owning the canonical definition — `STAGE_KEYS` mixes
551
+ lifecycle states with cells), derive current-step by correlating the aggregate/work-table state
552
+ **per shape**: for a **feature**, the terminal-folded `feature_runs__tracking` `derived_status`
553
+ (the canonical effective-status VIEW the projection reads — `app/featureReadModel.ts`, *not* the raw
554
+ `feature_runs.status`, which a terminated run can leave frozen at `running`/`escalated`)/`pr_key`/flags + the
555
+ member PR's terminal-folded **`pull_requests__tracking.derived_status`** (the ADR-0065 tracking VIEW that
556
+ is the *direct* source — `app/lineage.ts:382-394`; *not* `pollFeatureDelivery`, which is the **consumer**
557
+ that projects that state into `feature_runs`), joined by **`pr_key`** (*not* a naïve
558
+ `process_key` join, which is reassigned downstream); for a **delivery graph** — which has **no
559
+ `pr_key`** — its `delivery_graph_runs` row, its downstream PRs (read through the **same
560
+ `pull_requests__tracking.derived_status`** tracking VIEW, so a terminated convergence that leaves base
561
+ `status = 'converging'` while `derived_status = 'abandoned'` is *not* held in the live frontier —
562
+ `app/lineage.ts:382-394`) correlated through the run's **lineage
563
+ root mapping** (`app/lineage.ts` `collectRootPrs` over `pull_requests.root_request_key`), and engine
564
+ parks by the run's own `process_key`. Note the root mapping is **not** a single clean
565
+ `root_request_key = run_key` join for every PR, and the per-node correlation **differs by node kind**,
566
+ so S7 must **persist a run-level root rollup** — a stored run→node/PR mapping, or a canonical run root
567
+ threaded through every PR-producing node — **not** merely *document* the existing per-node keys (which
568
+ alone leaves the **agent** case below unattributed). **This persisted rollup must land on the canonical
569
+ aggregate, sequenced around S2's ownership — not on the legacy `delivery_graph_runs` row S2/S3 retire.**
570
+ Because S2 repoints `delivery_graph_runs` reads to `delivery_units`-derived VIEWs/rows and S3 moves the
571
+ writers off the legacy tables, the mapping's home is either (i) a column/relation on **`delivery_units`**
572
+ (added, dual-written, and backfilled under S2's expand/contract order — §508-519 — so the derived VIEW
573
+ exposes it), or (ii) **threaded into the PRs themselves** as each PR-producing node's `root_request_key`
574
+ (`app/service.ts`'s `submitPr`, line 503), which survives the legacy-table retirement. Storing it *only* on
575
+ `delivery_graph_runs` would be orphaned when that table becomes a derived VIEW, so S7 is **sequenced
576
+ after S2 establishes `delivery_units` ownership** (or takes the PR-threaded option). Rather than assume
577
+ one join: a **connector** PR roots by the connector's
578
+ *effective* `dedupeKey` — the author-supplied `connector.dedupeKey`, else the graph-derived
579
+ `<processInstanceKey>:<elementId>` (`app/deliveryConnector.ts` `connectorDedupeKey`) — threaded into
580
+ `submitPr` as its `rootRequestKey`; whereas an **agent** PR has **no `dedupeKey` at all** (the runner
581
+ seeds only `jobType`/`appendPrompt`/`timeout` for an `agent` node, `app/deliveryGraphCompiler.ts`
582
+ `ioMappingLines`, never a dedupe/root key), so it **self-roots on its own `pr_key`** (`submitPr`'s
583
+ `effectiveRoot = rootRequestKey ?? existing.root_request_key ?? pr_key`) unless the run explicitly
584
+ threads a root. The run-level rollup S7 defines must therefore attribute the **agent** case explicitly —
585
+ it cannot lean on the connector's `dedupeKey` fallback, which agent nodes never carry. The rollup also
586
+ **defines its precedence over `run.status`**: correlated-PR state **overrides** the raw run status for
587
+ the live frontier — a `running` run with any member PR still in flight reads as **`Converging`**, not
588
+ `Implementing` (matching the shipped `deliveryOriginStage`, `app/lineage.ts:332-336`) — whereas a
589
+ **terminal** run status settles the step **without** waiting on member PRs (`done` → `Done`). So `done`
590
+ does **not** block on an open PR, but a live `running` frontier must **not** mask PR-open/converging work
591
+ behind `Implementing`. **Park-versus-PR precedence:** when both are live at a `running` frontier — an open
592
+ engine park **and** a member PR in flight — the **human park wins** (the branch reads as its park step,
593
+ e.g. `Awaiting approval`, over the PR-derived `Converging`), because an open user-task gate is the branch's
594
+ current actionable frontier and the PR work sits behind it; absent a park, PR state drives the step as
595
+ above. Because `Awaiting approval` is a park **label**, not a `STAGE_KEYS` value, it can **not** be the
596
+ pipeline's scalar `activeField`: a parked branch pins its `activeField` to the frontier's current
597
+ `STAGE_KEYS` bracket per the coarse-key rule (a pre-PR `running` frontier → `Implementing`, an
598
+ `awaiting-approval`/pre-dispatch row → `Requested`), and the human-readable `Parked on human node:
599
+ <label>` text rides the **separate companion park-label field** — so the pipeline never receives a
600
+ non-`STAGE_KEYS` `activeField` while the actionable park label is still surfaced alongside it.
601
+ Engine parks correlate via `searchUserTasks({ processInstanceKey: run.process_key })`, which is
602
+ correct **for today's inlined graphs** (the compiler inlines subProcesses into one flat instance); once
603
+ S4's `callActivity` composition puts a human cell in a **child** instance, this parent-key query would
604
+ miss it, so that step is bound to the S4 inline-vs-child decision (correlate child instances, or keep
605
+ the graph inlined) — it is not a silent promise. Reduce any parallel frontier to the
606
+ **least-advanced active branch** and **promote the `pipeline` stepper
607
+ kind** (feature-only today) onto **every** delivery-graph surface — the list page, the detail
608
+ page (`pages/delivery-graph-detail.page.json:95`), **and** the *Active Delivery Graphs* grid on
609
+ `pages/overview.page.json:217` — each of which today renders `delivery_graph_runs.phase` as a
610
+ plain-text Phase column, so S7 does not leave any of those views on a second renderer while §4b claims the
611
+ surface uses the shared stepper. Promoting the `pipeline` kind must **not drop** the actionable
612
+ `Parked on human node: <label>` detail the plain-text Phase column carries today: S7 retains it as a
613
+ companion derived **park-label** field alongside the stepper on **all three** surfaces — the list and
614
+ *Active Delivery Graphs* grids (which have no other inline park detail) **and the detail page**, whose
615
+ `phase_node_id` field carries only the node **ID**, not the human-readable `Parked on human node:
616
+ <label>` text its plain-text `phase` cell shows today, so promoting the `pipeline` kind on the detail
617
+ page would otherwise lose that actionable label too. Because `delivery_graph_runs` stores **no
618
+ stage column** (only `phase`/park metadata, whose values such as `Running` are *not* `STAGE_KEYS`), S7
619
+ supplies the pipeline's `activeField`/`state` for the graph from a **read model/VIEW over
620
+ `delivery_graph_runs`** that maps `phase`/park metadata onto the `STAGE_KEYS` axis (the stateless
621
+ coarse-key rule) — it assumes no stored `activeField` column and defines the mapping columns explicitly,
622
+ so the page renders a valid configured step rather than a raw `phase` string.
623
+ This needs **no** engine-binding change, but ships at **lifecycle-stage fidelity only — for feature
624
+ *and* delivery-graph alike**: even feature is not truly per-cell today (`deriveStage` collapses a
625
+ readiness-probe/timer park or an active `implement-task` all to `Implementing`), and for a
626
+ delivery-graph node running with no open user task (`deriveDeliveryPhase` returns generic `Running`, no
627
+ node id) the stepper renders the configured coarse key derived statelessly from run `status`/`phase` (a *configured* stage — never a
628
+ fabricated cell position or an unconfigured `activeField` label; see the coarse-case rule in §4b). **The
629
+ least-advanced-*active* frontier reduction (§4b) is therefore *defined* at S7 but not yet *computable*
630
+ from this source:** `delivery_graph_runs` stores a **single** `phase`/`phase_node_id` per run, not a
631
+ per-branch topology, so a VIEW over it cannot compare branch advancement — at S7 the graph collapses to
632
+ that one coarse run-level step, and the genuine per-branch frontier reduction is deferred to S8's
633
+ element-instance/topology read model (a single-track feature is unaffected). The **epic pipeline is out of S7 scope**: epic's pre-PR position is unobservable from these
634
+ inputs, so epic keeps its write-provenance `epic_phase` **text cell** as an explicit retained second
635
+ source until S8 (below). The mapping can begin as soon as S4 names the cells; the `pipeline` render
636
+ binding for feature + delivery-graph can start immediately.
637
+ - **S8 · surface the element-instance query → retire the epic write-stamp** (Decision §4b) — the
638
+ slice that depends on an upstream binding change, tracked as **`nanobpm/nano-ide#473`** — but
639
+ #473 is **necessary, not sufficient**: it surfaces the element read model keyed by a *process-instance
640
+ key*, while the current process-instance read model has been observed returning **null parent/root
641
+ keys** (#464), so under S4's default `callActivity` child instances #473 alone cannot traverse from a
642
+ run to its child cells' elements. S8 therefore also depends on S4's inline-vs-child decision (inline
643
+ the graph so one flat instance suffices, **or** resolve the child/root correlation, tracked with #464)
644
+ — #473 is not the sole upstream dependency. Nano's
645
+ Rust engine already serves the element-instance read model (`POST /v2/element-instances/search`
646
+ `searchElementInstances` + element-instance **wait-states** — active elements and **job/message**
647
+ parks, not only user tasks), but the `@nanobpm/urban` `EngineClient` binding nwf consumes surfaces only
648
+ `searchProcessInstances` + `searchUserTasks`. Surface `searchElementInstances` / wait-states on the
649
+ binding (upstream in nano-ide / urban, **nano-ide#473**), then swap the S7 projection's park/position
650
+ source for the live engine element instance — **preserving the step axis and the renderer, and using
651
+ whatever parent/child correlation strategy S4 selects**: if S4 chooses cross-instance correlation
652
+ (default `callActivity` child instances), S8 must also extend the element lookup to resolve the
653
+ child/root instance keys, so the "no change to the correlation key" is scoped to the axis and renderer,
654
+ not to a fixed single-instance join. This is
655
+ what lets epic's **Planning** phase and the **non-parked** part of **Reviewing** — which run inside
656
+ `plan-fanout.bpmn` before any PR exists — Planning has no `plan_tasks` work-table row (its `plans`
657
+ aggregate row *does* exist, carrying `status`/`process_key`, so the gap is a missing per-activity field,
658
+ not a missing row), and while pre-PR Reviewing
659
+ *does* carry `plan_tasks` rows (`record-plan` writes them), **no field exposes the currently executing
660
+ plan/review activity** (Reviewing *is* partly visible via the real `plan-review-decision` user task,
661
+ but Planning and the running review work are not), today
662
+ knowable only from write-provenance — become a pure read-model derivation, bringing epic onto the same
663
+ `pipeline`, retiring the `epic_phase` write-time stamp (`app/epicPhase.ts`, nano-ide#266 — **including
664
+ its genesis write in `startPlan`, `app/plan.ts`**, which stamps `epic_phase` for fresh/replanned
665
+ epics, so the retirement must move or drop that write too or those epics still depend on the retired
666
+ source) and folding
667
+ the user-task-only `pollDeliveryGraphPhase` into one live projection. **Ownerships this retirement
668
+ must carry forward, not silently drop:** (a) `app/lineage.ts` consumes `plans.epic_phase` to project
669
+ `epicPhaseLabel` and writes `pull_requests.epic_phase_label`, shown on the home/convergence surfaces —
670
+ so S8's replacement contract must supply that label from the derived pipeline (a derived
671
+ compatibility field) or update the lineage projection, or it either breaks the display or leaves a
672
+ second phase source; and (b) `pollDeliveryGraphPhase` is not only a park projection — it also owns the
673
+ `COMPLETED → done` / `TERMINATED → failed` terminal reconciliation for graphs (`app/service.ts`,
674
+ since a delivery graph has no spine worker to write its own terminal row), so S8 must **retain or move**
675
+ that reconciliation into the live projection, or completed graphs stay wedged in the active `running`
676
+ set; and (c) `app/lineage.ts` also reads `delivery_graph_runs.phase` to form `LineageThread.stageLabel`
677
+ and persists `lineage_threads.stage_label` (shown on delivery-lineage / home rows), so S8 must supply
678
+ that phase narrative from the derived projection or update the lineage read too, else those rows lose
679
+ their phase label; and (d) **four field bindings across three pages** read the `epic_phase` field, but **not all read
680
+ raw `plans`**: the epic index grid (`pages/epic.page.json:119`) and the epic-detail page
681
+ (`pages/epic-detail.page.json:99,146`) bind it through the **`plan_read_model` VIEW**
682
+ (`pages/epic.page.json:102`, `pages/epic-detail.page.json:95,140`; the VIEW projects `pl.epic_phase`,
683
+ `db/migrations/083_plan_read_model_declare_once.sql`), while only the nested Epic grid on the Home page
684
+ (`pages/home.page.json:284`) binds **raw `plans`** directly — so
685
+ retiring the column without repointing these bindings to the pipeline/read-model field
686
+ renders a missing field on the epic index, epic-detail, and Home pages; S8's checklist must update
687
+ **both** the `plan_read_model` VIEW (or its derived field) **and** the raw-`plans` Home binding, not just raw `plans` references.
254
688
 
255
689
  ## Non-goals / deferred
256
690
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.139.1",
3
+ "version": "0.139.3",
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",
@@ -128,15 +128,15 @@
128
128
  { "label": "All", "filter": [] }
129
129
  ],
130
130
  "columns": [
131
- { "field": "title", "template": "{{title}}", "header": "Graph", "subtitleField": "run_key", "truncate": true, "width": "28%", "link": { "kind": "page", "page": "delivery-graph-detail", "keyField": "run_key" } },
132
- { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
133
- { "field": "process_key", "header": "Instance", "width": "9rem", "truncate": true, "link": { "kind": "processExplorer", "keyField": "process_key" } },
134
- { "field": "phase", "header": "Phase", "truncate": true, "width": "26%" },
135
- { "field": "node_count", "header": "Nodes" },
136
- { "field": "human_node_count", "header": "Human" },
137
- { "field": "side_effect_count", "header": "Side effects" },
138
- { "field": "created_at", "header": "Dispatched", "width": "9rem", "format": "datetime" },
139
- { "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
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%" },
135
+ { "field": "node_count", "header": "Nodes", "width": "5%" },
136
+ { "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" }
140
140
  ],
141
141
  "detail": {
142
142
  "fields": [
@@ -115,6 +115,28 @@ test("#523: Save-to-library on the staged App-View posts save-from-digest", () =
115
115
  assert(/post\(saveLibraryUrl,\s*\{\s*name:[^}]*digest:/.test(STAGED_JS), "Save-to-library must POST { name, digest } (save-from-digest) to the save door");
116
116
  });
117
117
 
118
+ test("#538: the in-flight grid's column widths never over-allocate (no 0-width column, no header char-wrap)", () => {
119
+ // Regression guard: the grid is `table-layout:fixed` with `overflow-wrap:anywhere` headers, so a
120
+ // column squeezed to 0 width wraps its title one character per line (a ~250px-tall header row). That
121
+ // happens when the declared widths over-allocate. Pin the contract: every data column carries a
122
+ // percentage width, and their sum leaves headroom (>=5%) for the implicit row-action column — so no
123
+ // column can collapse to 0.
124
+ const page = JSON.parse(PAGE_JSON) as { nodes: Array<Record<string, any>> };
125
+ const grid = page.nodes.find((n) => n.id === "delivery-graphs-inflight");
126
+ assert(grid, "the page must carry the delivery-graphs-inflight grid");
127
+ const columns = (grid?.props?.columns ?? []) as Array<Record<string, any>>;
128
+ assert(columns.length > 0, "the grid must declare columns");
129
+ let sum = 0;
130
+ for (const col of columns) {
131
+ const w = col.width;
132
+ assert(typeof w === "string" && /^\d+(\.\d+)?%$/.test(w), `column "${col.header}" must declare a percentage width so it can't collapse to 0 (got ${JSON.stringify(w)})`);
133
+ const parsed = Number.parseFloat(w);
134
+ assert(parsed > 0, `column "${col.header}" must declare a positive width so it can't collapse to 0 (got ${JSON.stringify(w)})`);
135
+ sum += parsed;
136
+ }
137
+ assert(sum <= 95, `declared column widths sum to ${sum}% — they must leave >=5% for the row-action column so nothing squeezes to 0 (#538)`);
138
+ });
139
+
118
140
  test("#523: Save-to-library is offered on a dispatched/history grid row (save-from-dispatched)", () => {
119
141
  const page = JSON.parse(PAGE_JSON) as { nodes: Array<Record<string, any>> };
120
142
  const grid = page.nodes.find((n) => n.id === "delivery-graphs-inflight");