@nanobpm/nano-workforce 0.133.1 → 0.135.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.135.0](https://github.com/nanobpm/nano-workforce/compare/v0.134.0...v0.135.0) (2026-08-24)
2
+
3
+ ### Features
4
+
5
+ * **delivery-graph:** preview DI for agent-staged proposals ([#511](https://github.com/nanobpm/nano-workforce/issues/511)) ([#513](https://github.com/nanobpm/nano-workforce/issues/513)) ([34f0950](https://github.com/nanobpm/nano-workforce/commit/34f0950fdbb7a9eec9e09d6f84cff46de09ea830))
6
+
7
+ ## [0.134.0](https://github.com/nanobpm/nano-workforce/compare/v0.133.1...v0.134.0) (2026-08-24)
8
+
9
+ ### Features
10
+
11
+ * **delivery-graph:** node timeout PT1H default, submission + per-node override ([#505](https://github.com/nanobpm/nano-workforce/issues/505)) ([#507](https://github.com/nanobpm/nano-workforce/issues/507)) ([96fe961](https://github.com/nanobpm/nano-workforce/commit/96fe96165bb898919f7000002e68cb4ba5a925e9))
12
+
1
13
  ## [0.133.1](https://github.com/nanobpm/nano-workforce/compare/v0.133.0...v0.133.1) (2026-08-24)
2
14
 
3
15
  ### Bug Fixes
@@ -23,6 +23,7 @@ import {
23
23
  DELIVERY_PHASE,
24
24
  deliveryGraphRuns,
25
25
  } from "./deliveryGraphRun.ts";
26
+ import type { DeliveryRunTimeouts } from "./deliveryRunner.ts";
26
27
  import { deliveryGraphDigest, runDeliveryGraph } from "./deliveryRunner.ts";
27
28
 
28
29
  /** The outcome of a dispatch attempt — mirrors the retained run lifecycle. `ok:false` carries the
@@ -48,7 +49,7 @@ export type DispatchDeliveryGraphResult =
48
49
  export async function dispatchDeliveryGraphRun(
49
50
  app: Pick<AppApi, "data" | "engine" | "log">,
50
51
  graph: unknown,
51
- options: { runKey?: string | null; title?: string | null } = {},
52
+ options: { runKey?: string | null; title?: string | null } & DeliveryRunTimeouts = {},
52
53
  ): Promise<DispatchDeliveryGraphResult> {
53
54
  const validationErrors = validateDeliveryGraph(graph);
54
55
  if (validationErrors.length > 0) {
@@ -130,7 +131,16 @@ export async function dispatchDeliveryGraphRun(
130
131
  };
131
132
  let launched: Awaited<ReturnType<typeof runDeliveryGraph>>;
132
133
  try {
133
- launched = await runDeliveryGraph(app.engine, typedGraph, { runKey });
134
+ // Thread the operator-supplied run-level timeouts (#505) so a submission override reaches every
135
+ // node's seeded `nodeInputs` (absent → the runner's PT1H/PT30M/P1D defaults).
136
+ launched = await runDeliveryGraph(app.engine, typedGraph, {
137
+ runKey,
138
+ nodeTimeout: options.nodeTimeout,
139
+ probeTimeout: options.probeTimeout,
140
+ escalationSlaTimeout: options.escalationSlaTimeout,
141
+ probePollEvery: options.probePollEvery,
142
+ escalationAssignee: options.escalationAssignee,
143
+ });
134
144
  } catch (err) {
135
145
  await markClaimFailed();
136
146
  app.log.error("dispatch-delivery-graph launch threw", { runKey });
@@ -221,6 +221,22 @@ export async function getStagedProposal(
221
221
  return row;
222
222
  }
223
223
 
224
+ /** Every LIVE staged proposal — `status = 'staged'` AND not aged out of its TTL — newest first. The
225
+ * staged App-View (`pages/delivery-graphs/staged.mount.js`) polls this to render the Preview-DI +
226
+ * Dispatch list. Mirrors `getStagedProposal`'s freshness guard (`isProposalExpired`) so an
227
+ * expired-but-not-yet-swept row is never offered for preview/dispatch, unlike a raw
228
+ * `status = 'staged'` datasource filter which cannot express a `expires_at > now` cutoff and so lingers
229
+ * an aged-out row until the sweep realises the TTL. Read-only; no write. */
230
+ export async function listStagedProposals(
231
+ data: DataLayer,
232
+ at: Date = new Date(),
233
+ ): Promise<DeliveryGraphProposal[]> {
234
+ const rows = await deliveryGraphProposals(data).find({ status: "staged" });
235
+ return rows
236
+ .filter((row) => !isProposalExpired(row.expires_at, at))
237
+ .sort((a, b) => b.created_at.localeCompare(a.created_at));
238
+ }
239
+
224
240
  /** Mark a staged proposal `dispatched` once the operator launches it — it drops out of the cockpit's
225
241
  * staged list (the run then shows in the in-flight grid). */
226
242
  export async function markProposalDispatched(data: DataLayer, digest: string): Promise<void> {
@@ -138,6 +138,93 @@ test("wait gateKeys default to a fresh per-run token so concurrent runs of one g
138
138
  assertEquals(gateKeyOf(seeded), "run-7:n3");
139
139
  });
140
140
 
141
+ test("the node timeout defaults to PT1H (raised from PT30M) when no option is supplied (#505)", async () => {
142
+ // #505: the hard PT30M default tripped the boundary timer on legitimately-long implementation nodes.
143
+ // With no timeout option, every agent/connector node inherits the NEW PT1H run default.
144
+ const p = await prepareOk(GRAPH);
145
+ const timeouts = Object.values(p.nodeInputs)
146
+ .filter((v) => "timeout" in v)
147
+ .map((v) => (v as { timeout: string }).timeout);
148
+ assert(timeouts.length === 2, `expected the agent + connector nodes to seed a timeout, got ${timeouts.length}`);
149
+ for (const t of timeouts) assertEquals(t, "PT1H");
150
+ });
151
+
152
+ test("a submission nodeTimeout override seeds every agent/connector node with that duration (#505)", async () => {
153
+ // AC: an operator dispatch that sets nodeTimeout: "PT2H" seeds PT2H for ALL agent/connector nodes.
154
+ const p = await prepareOk(GRAPH, { nodeTimeout: "PT2H" });
155
+ const timeouts = Object.values(p.nodeInputs)
156
+ .filter((v) => "timeout" in v)
157
+ .map((v) => (v as { timeout: string }).timeout);
158
+ assert(timeouts.length === 2, `expected two seeded node timeouts, got ${timeouts.length}`);
159
+ for (const t of timeouts) assertEquals(t, "PT2H");
160
+ });
161
+
162
+ test("a per-node timeout override wins for its node while siblings keep the run/default value (#505)", async () => {
163
+ // AC: a node declaring timeout: "PT4H" seeds nodeInputs.<el>.timeout == "PT4H" while its siblings keep
164
+ // the run-level (here PT2H) value. Asserted positionally on the compiled nodeInputs map.
165
+ const graph: DeliveryGraph = {
166
+ name: "per-node override",
167
+ nodes: [
168
+ { id: "heavy", kind: "agent", agent: { jobType: "senior:feature", prompt: "long build", timeout: "PT4H" } },
169
+ { id: "quick", kind: "agent", agent: { jobType: "senior:demo" } },
170
+ { id: "notify", kind: "connector", connector: { target: "slack:post", dedupeKey: "n-1", timeout: "PT10M" } },
171
+ ],
172
+ edges: [
173
+ { from: "heavy", to: "quick" },
174
+ { from: "quick", to: "notify" },
175
+ ],
176
+ };
177
+ const p = await prepareOk(graph, { nodeTimeout: "PT2H" });
178
+ const byJobType = (jt: string) =>
179
+ Object.values(p.nodeInputs).find((v) => (v as { jobType?: string }).jobType === jt) as { timeout: string } | undefined;
180
+ const connector = Object.values(p.nodeInputs).find((v) => (v as { target?: string }).target === "slack:post") as
181
+ | { timeout: string }
182
+ | undefined;
183
+
184
+ assertEquals(byJobType("senior:feature")?.timeout, "PT4H"); // per-node override wins
185
+ assertEquals(byJobType("senior:demo")?.timeout, "PT2H"); // sibling keeps the run-level value
186
+ assertEquals(connector?.timeout, "PT10M"); // connector per-node override wins too
187
+ });
188
+
189
+ test("a per-node timeout is normalized (lower-case → canonical) and a malformed one falls back to the run value (#505)", async () => {
190
+ // A graph built programmatically (bypassing the OpenAPI pattern) can carry a lower-case or malformed
191
+ // per-node duration. The runner normalizes it through `isoDuration` so a bad value never bakes an
192
+ // uninterpretable boundary timer: `pt4h` → `PT4H`, and `nonsense` falls back to the run-level default.
193
+ const graph: DeliveryGraph = {
194
+ name: "per-node normalization",
195
+ nodes: [
196
+ { id: "lower", kind: "agent", agent: { jobType: "senior:feature", timeout: "pt4h" } },
197
+ { id: "bad", kind: "connector", connector: { target: "slack:post", dedupeKey: "n-1", timeout: "nonsense" } },
198
+ ],
199
+ edges: [{ from: "lower", to: "bad" }],
200
+ } as unknown as DeliveryGraph;
201
+ const p = await prepareOk(graph, { nodeTimeout: "PT2H" });
202
+ const agent = Object.values(p.nodeInputs).find((v) => (v as { jobType?: string }).jobType === "senior:feature") as
203
+ | { timeout: string }
204
+ | undefined;
205
+ const connector = Object.values(p.nodeInputs).find((v) => (v as { target?: string }).target === "slack:post") as
206
+ | { timeout: string }
207
+ | undefined;
208
+
209
+ assertEquals(agent?.timeout, "PT4H"); // lower-case normalized to canonical form
210
+ assertEquals(connector?.timeout, "PT2H"); // malformed value rejected → run-level default
211
+ });
212
+
213
+ test("a RUN-LEVEL timeout is normalized (lower-case → canonical) and a malformed one falls back to the default (#505)", async () => {
214
+ // A programmatic caller of prepareDeliveryGraph/runDeliveryGraph bypasses the OpenAPI/door validators,
215
+ // so a lower-case or malformed run-level `nodeTimeout` must not become the fallback baked into a node's
216
+ // boundary timer FEEL. isoDuration canonicalizes it (`pt3h` → `PT3H`) at the run level too, and a
217
+ // malformed value falls back to the DEFAULTS run value rather than an uninterpretable duration.
218
+ const lower = await prepareOk(GRAPH, { nodeTimeout: "pt3h" });
219
+ for (const v of Object.values(lower.nodeInputs).filter((v) => "timeout" in v)) {
220
+ assertEquals((v as { timeout: string }).timeout, "PT3H"); // lower-case run value normalized
221
+ }
222
+ const bad = await prepareOk(GRAPH, { nodeTimeout: "nonsense" });
223
+ for (const v of Object.values(bad.nodeInputs).filter((v) => "timeout" in v)) {
224
+ assertEquals((v as { timeout: string }).timeout, "PT1H"); // malformed run value → PT1H default, never baked raw
225
+ }
226
+ });
227
+
141
228
  test("a malformed graph returns the S1 compile errors and prepares nothing", async () => {
142
229
  const r = await prepareDeliveryGraph({ nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" } }], edges: [{ from: "a", to: "ghost" }] } as unknown as DeliveryGraph);
143
230
  assert(!r.ok, "a dangling edge fails to prepare");
@@ -18,6 +18,7 @@ import type { EngineClient } from "@nanobpm/urban";
18
18
  import type { DeliveryFact, DeliveryGraph, DeliveryNode } from "../nano-generated/api-io.d.ts";
19
19
  import { assertNever, compileDeliveryGraph, DELIVERY_GRAPH_PROCESS_ID } from "./deliveryGraphCompiler.ts";
20
20
  import { DEFAULT_EVERY_MS, msToIsoDuration, parseProbe, readinessPollEvery } from "./readiness.ts";
21
+ import { isoDuration } from "./reviewWait.ts";
21
22
 
22
23
  /** The content digest of a compiled graph — `sha256(bpmn)[:12]` — the single source of truth for the
23
24
  * content-addressed deploy id (`delivery-graph-<digest>`) AND the dispatch fence's default idempotency
@@ -53,7 +54,7 @@ export interface DeliveryRunOptions extends DeliveryRunTimeouts {
53
54
  }
54
55
 
55
56
  const DEFAULTS: Required<Omit<DeliveryRunTimeouts, "escalationAssignee">> = {
56
- nodeTimeout: "PT30M",
57
+ nodeTimeout: "PT1H",
57
58
  probeTimeout: "PT30M",
58
59
  probePollEvery: msToIsoDuration(DEFAULT_EVERY_MS),
59
60
  escalationSlaTimeout: "P1D",
@@ -110,11 +111,14 @@ export async function prepareDeliveryGraph(
110
111
  const bpmn = rewriteProcessId(compiled.bpmn, processDefinitionId);
111
112
 
112
113
  const runKey = options.runKey?.trim() || randomUUID();
114
+ // Normalize the run-level timeouts through isoDuration so a programmatic caller that bypasses the
115
+ // OpenAPI/door validators cannot bake a malformed or lower-case duration into a BPMN timer FEEL —
116
+ // isoDuration canonicalizes case and falls back to the default on a malformed/blank value.
113
117
  const timeouts = {
114
- nodeTimeout: options.nodeTimeout ?? DEFAULTS.nodeTimeout,
115
- probeTimeout: options.probeTimeout ?? DEFAULTS.probeTimeout,
116
- probePollEvery: options.probePollEvery ?? DEFAULTS.probePollEvery,
117
- escalationSlaTimeout: options.escalationSlaTimeout ?? DEFAULTS.escalationSlaTimeout,
118
+ nodeTimeout: isoDuration(options.nodeTimeout, DEFAULTS.nodeTimeout),
119
+ probeTimeout: isoDuration(options.probeTimeout, DEFAULTS.probeTimeout),
120
+ probePollEvery: isoDuration(options.probePollEvery, DEFAULTS.probePollEvery),
121
+ escalationSlaTimeout: isoDuration(options.escalationSlaTimeout, DEFAULTS.escalationSlaTimeout),
118
122
  escalationAssignee: options.escalationAssignee ?? null,
119
123
  };
120
124
  const elementByNodeId = new Map(compiled.resolved.nodes.map((n) => [n.id, n.element]));
@@ -174,7 +178,7 @@ function buildNodeInput(
174
178
  ): NodeInput {
175
179
  switch (node.kind) {
176
180
  case "agent":
177
- return { jobType: node.agent.jobType, appendPrompt: node.agent.prompt ?? "", timeout: ctx.nodeTimeout };
181
+ return { jobType: node.agent.jobType, appendPrompt: node.agent.prompt ?? "", timeout: isoDuration(node.agent.timeout, ctx.nodeTimeout) };
178
182
  case "wait": {
179
183
  const probe = parseProbe(node.wait);
180
184
  return {
@@ -201,7 +205,7 @@ function buildNodeInput(
201
205
  target: node.connector.target,
202
206
  dedupeKey: node.connector.dedupeKey ?? null,
203
207
  payload: node.connector.payload ?? null,
204
- timeout: ctx.nodeTimeout,
208
+ timeout: isoDuration(node.connector.timeout, ctx.nodeTimeout),
205
209
  };
206
210
  default:
207
211
  return assertNever(node, "buildNodeInput");
package/app/reviewWait.ts CHANGED
@@ -20,6 +20,14 @@ export const DEFAULT_REVIEW_WAIT_TIMEOUT = "PT20M";
20
20
  // would fail to interpret; not a full grammar (we don't need fractional seconds here).
21
21
  const ISO_DURATION = /^P(?!$)(\d+Y)?(\d+M)?(\d+W)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+S)?)?$/;
22
22
 
23
+ /** True when `raw` is a well-formed ISO-8601 duration under {@link isoDuration}'s grammar — the strict
24
+ * predicate an operator submission door uses to REJECT a malformed duration (400) rather than silently
25
+ * fall back to a default. Derives from the same {@link ISO_DURATION} grammar so the accept/reject
26
+ * decision can never drift from the normalise-or-default one. Case-insensitive (`pt2h` is valid). */
27
+ export function isValidIsoDuration(raw: string): boolean {
28
+ return ISO_DURATION.test(raw.trim().toUpperCase());
29
+ }
30
+
23
31
  /** Validate an ISO-8601 duration string for a BPMN timer's `<bpmn:timeDuration>`, falling back to
24
32
  * `def` when the value is absent, blank, or malformed — a bad env value must never deploy an
25
33
  * uninterpretable timer expression into a process. Normalises to upper case (`pt20m` → `PT20M`).
package/openapi.yaml CHANGED
@@ -1407,6 +1407,15 @@ components:
1407
1407
  type: string
1408
1408
  maxLength: 20000
1409
1409
  description: OPTIONAL steering prompt appended to the node's job brief.
1410
+ timeout:
1411
+ type: string
1412
+ pattern: '^[Pp](?!$)(\d+[Yy])?(\d+[Mm])?(\d+[Ww])?(\d+[Dd])?([Tt](?=\d)(\d+[Hh])?(\d+[Mm])?(\d+[Ss])?)?$'
1413
+ maxLength: 64
1414
+ description: >-
1415
+ OPTIONAL per-node ISO-8601 SLA timeout (#505). Overrides the run-level `nodeTimeout`
1416
+ (and the `PT1H` default) for THIS node's bounded-timeout → escalate boundary timer, so
1417
+ a legitimately-long node (e.g. a full `senior:feature` implementation) can outlast a
1418
+ quick gate without a spurious escalation. Absent → the run/default value.
1410
1419
  DeliveryNodeWait:
1411
1420
  description: >-
1412
1421
  A `wait` node — a durable `ReadinessProbe` (ADR 0001 §2) watching an external fact. Reuses the
@@ -1496,6 +1505,14 @@ components:
1496
1505
  type: object
1497
1506
  additionalProperties: true
1498
1507
  description: Minimal forward-declared payload stub — the concrete connector payload schema is deferred (ADR non-goal).
1508
+ timeout:
1509
+ type: string
1510
+ pattern: '^[Pp](?!$)(\d+[Yy])?(\d+[Mm])?(\d+[Ww])?(\d+[Dd])?([Tt](?=\d)(\d+[Hh])?(\d+[Mm])?(\d+[Ss])?)?$'
1511
+ maxLength: 64
1512
+ description: >-
1513
+ OPTIONAL per-node ISO-8601 SLA timeout (#505). Overrides the run-level `nodeTimeout`
1514
+ (and the `PT1H` default) for THIS connector node's bounded-timeout → escalate boundary
1515
+ timer. Absent → the run/default value.
1499
1516
  DeliveryEdge:
1500
1517
  description: >-
1501
1518
  A dependency edge — "`to` proceeds once fact `from` is observable" (ADR 0005 Decision 3).
@@ -1596,6 +1613,28 @@ components:
1596
1613
  type: string
1597
1614
  maxLength: 255
1598
1615
  description: OPTIONAL idempotency key. A re-dispatch with the same key (or, when omitted, the same digest) does not double-launch. Blank/whitespace is treated as absent.
1616
+ nodeTimeout:
1617
+ type: string
1618
+ pattern: '^[Pp](?!$)(\d+[Yy])?(\d+[Mm])?(\d+[Ww])?(\d+[Dd])?([Tt](?=\d)(\d+[Hh])?(\d+[Mm])?(\d+[Ss])?)?$'
1619
+ maxLength: 64
1620
+ description: >-
1621
+ OPTIONAL run-level ISO-8601 SLA timeout for `agent`/`connector` nodes (#505) — the
1622
+ bounded-timeout → escalate boundary bound every such node inherits unless it declares its own
1623
+ per-node `timeout`. Absent → the `PT1H` default. An invalid duration is rejected at submit.
1624
+ probeTimeout:
1625
+ type: string
1626
+ pattern: '^[Pp](?!$)(\d+[Yy])?(\d+[Mm])?(\d+[Ww])?(\d+[Dd])?([Tt](?=\d)(\d+[Hh])?(\d+[Mm])?(\d+[Ss])?)?$'
1627
+ maxLength: 64
1628
+ description: >-
1629
+ OPTIONAL run-level ISO-8601 poll budget for `wait` gates (#505) before they escalate. Absent →
1630
+ the `PT30M` default. An invalid duration is rejected at submit.
1631
+ escalationSlaTimeout:
1632
+ type: string
1633
+ pattern: '^[Pp](?!$)(\d+[Yy])?(\d+[Mm])?(\d+[Ww])?(\d+[Dd])?([Tt](?=\d)(\d+[Hh])?(\d+[Mm])?(\d+[Ss])?)?$'
1634
+ maxLength: 64
1635
+ description: >-
1636
+ OPTIONAL run-level ISO-8601 SLA for `human` nodes (#505) before they record an `escalated`
1637
+ outcome. Absent → the `P1D` default. An invalid duration is rejected at submit.
1599
1638
  DeliveryGraphProposalBpmnRequest:
1600
1639
  description: >-
1601
1640
  Request the compiled BPMN of a staged delivery-graph proposal for read-only DI PREVIEW. Carries
@@ -1634,6 +1673,62 @@ components:
1634
1673
  The compiled BPMN 2.0 XML INCLUDING diagram interchange (`bpmndi:BPMNDiagram`), recompiled
1635
1674
  deterministically from the staged graph — byte-identical to what a dispatch would deploy.
1636
1675
  Rendered read-only in the host explorer's definition preview. Nothing is deployed.
1676
+ StagedProposalSummary:
1677
+ description: >-
1678
+ One LIVE staged delivery-graph proposal (issue #511) — the metadata the staged App-View renders
1679
+ as a Preview-DI + Dispatch row. A projection of the durable `delivery_graph_proposals` row; the
1680
+ `graph`/`preview` payloads are omitted (the App-View recompiles by `digest` for the DI preview).
1681
+ type: object
1682
+ additionalProperties: false
1683
+ required:
1684
+ - digest
1685
+ - title
1686
+ - nodeCount
1687
+ - humanNodeCount
1688
+ - sideEffectCount
1689
+ - sideEffecting
1690
+ - createdAt
1691
+ - expiresAt
1692
+ properties:
1693
+ digest:
1694
+ type: string
1695
+ description: The proposal's content digest — the handle the Preview-DI and Dispatch doors take.
1696
+ title:
1697
+ type: string
1698
+ nullable: true
1699
+ description: The graph's name, when it carried one.
1700
+ nodeCount:
1701
+ type: integer
1702
+ description: Total nodes in the compiled graph.
1703
+ humanNodeCount:
1704
+ type: integer
1705
+ description: How many nodes park on a person.
1706
+ sideEffectCount:
1707
+ type: integer
1708
+ description: How many nodes perform a side effect (merge/publish) once dispatched.
1709
+ sideEffecting:
1710
+ type: boolean
1711
+ description: True when the graph has any side-effecting node — dispatching it authorises those actions.
1712
+ createdAt:
1713
+ type: string
1714
+ description: When the proposal was staged (ISO-8601).
1715
+ expiresAt:
1716
+ type: string
1717
+ description: When the proposal ages out of its TTL if never dispatched (ISO-8601).
1718
+ StagedProposalList:
1719
+ description: The live staged delivery-graph proposals awaiting dispatch (issue #511), newest first.
1720
+ type: object
1721
+ additionalProperties: false
1722
+ required:
1723
+ - count
1724
+ - proposals
1725
+ properties:
1726
+ count:
1727
+ type: integer
1728
+ proposals:
1729
+ type: array
1730
+ items:
1731
+ $ref: "#/components/schemas/StagedProposalSummary"
1637
1732
  DeliveryGraphTextResult:
1638
1733
  description: >-
1639
1734
  The delivery-graph text-ingress outcome (issue #460) — a single shape covering the JSON-paste
@@ -2888,6 +2983,32 @@ paths:
2888
2983
  application/json:
2889
2984
  schema:
2890
2985
  $ref: "#/components/schemas/DeliveryGraphProposalBpmnResult"
2986
+ /delivery-graph/staged:
2987
+ get:
2988
+ operationId: listStagedProposals
2989
+ summary: List the LIVE staged delivery-graph proposals awaiting dispatch (issue #511), newest first.
2990
+ description: >-
2991
+ The read behind the staged-proposals App-View: every `staged` delivery-graph proposal that has
2992
+ not aged out of its TTL, newest first, projected to the Preview-DI + Dispatch metadata (the
2993
+ `graph`/`preview` payloads are omitted — the App-View recompiles by `digest` for the DI preview).
2994
+ Mirrors the `previewProposalBpmn`/`dispatchDeliveryGraph` freshness guard so an expired-but-not-
2995
+ yet-swept row is never listed. Read-only.
2996
+ security:
2997
+ - hookSecret: []
2998
+ - {}
2999
+ responses:
3000
+ "200":
3001
+ description: The live staged proposals.
3002
+ content:
3003
+ application/json:
3004
+ schema:
3005
+ $ref: "#/components/schemas/StagedProposalList"
3006
+ "401":
3007
+ description: Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).
3008
+ content:
3009
+ application/json:
3010
+ schema:
3011
+ $ref: "#/components/schemas/ErrorBody"
2891
3012
  /actions/start/feature:
2892
3013
  post:
2893
3014
  operationId: startFeature
@@ -182,4 +182,69 @@ describe("dispatchDeliveryGraph — operator dispatch by staged-proposal digest"
182
182
  assert.equal((await deliveryGraphProposals(app.db).get(digest))?.status, "expired");
183
183
  assert.equal((await deliveryGraphRuns(app.db).all()).length, 0);
184
184
  });
185
+
186
+ test("an invalid run-level nodeTimeout duration is rejected at submit → 400, nothing launched (#505)", async () => {
187
+ const app = await boot();
188
+ assert.ok(app.api);
189
+ const api = app.api;
190
+ const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
191
+ const res = await api.call<{ ok?: boolean; error?: string; issues?: Array<{ path: string }> }>("dispatchDeliveryGraph", {
192
+ body: { digest: staged.body.digest, nodeTimeout: "2 hours" },
193
+ });
194
+ // Rejected at submit — either by the edge shape-validator (openapi `pattern`) or the door's own
195
+ // ISO-8601 guard; both surface a 400. Nothing launches and the proposal stays staged (dispatchable).
196
+ assert.equal(res.status, 400);
197
+ assert.equal((await deliveryGraphRuns(app.db).all()).length, 0);
198
+ assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "staged");
199
+ });
200
+
201
+ test("an oversized invalid duration never bloats the 400 response — rejected with a bounded error, nothing launched (#505)", async () => {
202
+ const app = await boot();
203
+ assert.ok(app.api);
204
+ const api = app.api;
205
+ const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
206
+ const huge = `PT${"9".repeat(5000)}X`;
207
+ const res = await api.call<{ ok?: boolean; error?: string }>("dispatchDeliveryGraph", {
208
+ body: { digest: staged.body.digest, nodeTimeout: huge },
209
+ });
210
+ assert.equal(res.status, 400);
211
+ // The 5000-char blob is never echoed back verbatim — the edge pattern rejects it, and the door's
212
+ // own guard (`truncateForEcho`) caps the echo when the edge is bypassed. Either way the response
213
+ // stays bounded, so a malformed input can't bloat logs/response bodies.
214
+ assert.ok((res.body.error ?? "").length < 300, `error body should be bounded, got ${(res.body.error ?? "").length} chars`);
215
+ assert.equal((await deliveryGraphRuns(app.db).all()).length, 0);
216
+ });
217
+
218
+ test("a syntactically-valid but oversized duration is rejected at the door → 400, nothing launched (#505)", async () => {
219
+ const app = await boot();
220
+ assert.ok(app.api);
221
+ const api = app.api;
222
+ const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
223
+ // Matches the ISO-8601 grammar but exceeds the door's MAX_DURATION_LEN (64) — the door re-enforces the
224
+ // openapi `maxLength: 64` so an oversized value is refused even if the edge validator is bypassed.
225
+ const longValid = `PT${"9".repeat(70)}H`;
226
+ const res = await api.call<{ ok?: boolean; error?: string }>("dispatchDeliveryGraph", {
227
+ body: { digest: staged.body.digest, nodeTimeout: longValid },
228
+ });
229
+ assert.equal(res.status, 400);
230
+ assert.ok((res.body.error ?? "").length < 300, `error body should be bounded, got ${(res.body.error ?? "").length} chars`);
231
+ assert.equal((await deliveryGraphRuns(app.db).all()).length, 0);
232
+ assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "staged");
233
+ });
234
+
235
+ test("a valid run-level nodeTimeout override dispatches the run → 202 running (#505)", async () => {
236
+ const app = await boot();
237
+ assert.ok(app.api);
238
+ const api = app.api;
239
+ const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
240
+ const res = await api.call<{ ok: boolean; status: string }>("dispatchDeliveryGraph", {
241
+ body: { digest: staged.body.digest, nodeTimeout: "PT2H" },
242
+ });
243
+ assert.equal(res.status, 202);
244
+ assert.equal(res.body.ok, true);
245
+ assert.equal(res.body.status, "running");
246
+ await app.settle();
247
+ assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
248
+ assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "dispatched");
249
+ });
185
250
  });
@@ -12,9 +12,39 @@
12
12
 
13
13
  import { dispatchDeliveryGraphRun } from "../app/deliveryGraphDispatch.ts";
14
14
  import { getStagedProposal, markProposalDispatched, markProposalExpired } from "../app/deliveryGraphProposals.ts";
15
+ import { isValidIsoDuration } from "../app/reviewWait.ts";
15
16
  import type { DeliveryGraphTextResult } from "../nano-generated/api-io.d.ts";
16
17
  import { defineOperation } from "../nano-generated/operations.ts";
17
18
 
19
+ /** Cap an untrusted, rejected duration string before it is echoed into logs/response bodies. `openapi.yaml`
20
+ * caps these dispatch duration fields at `maxLength: 64` at the edge, and the door re-enforces that bound
21
+ * (see `MAX_DURATION_LEN`); this truncation is defense-in-depth for when the edge validator is bypassed,
22
+ * so a very large malformed value can never bloat either the logs or the response. */
23
+ const MAX_ECHO_LEN = 80;
24
+ function truncateForEcho(value: string): string {
25
+ return value.length > MAX_ECHO_LEN ? `${value.slice(0, MAX_ECHO_LEN)}… (${value.length} chars)` : value;
26
+ }
27
+
28
+ /** Door-level cap on a duration override, mirroring the `maxLength: 64` on these fields in `openapi.yaml`.
29
+ * Re-enforced here so a syntactically-valid-but-oversized duration is still rejected when the edge
30
+ * validator is bypassed (internal calls/tests), keeping seeded process variables and error/log output bounded. */
31
+ const MAX_DURATION_LEN = 64;
32
+
33
+ /** Validate an OPTIONAL run-level ISO-8601 duration override off the dispatch body (#505). Blank/
34
+ * whitespace is treated as absent (→ the runner default). A present-but-malformed value returns
35
+ * `{ ok: false, invalid }` so the door can reject it at submit rather than silently deploy an
36
+ * uninterpretable timer. Reuses the canonical `reviewWait` grammar so accept/reject never drifts from
37
+ * the runner's normalise-or-default one, and enforces `MAX_DURATION_LEN` so an oversized value is
38
+ * rejected even if the OpenAPI `maxLength` edge check is bypassed. */
39
+ function validateDurationOverride(raw: unknown): { ok: true; value: string | undefined } | { ok: false; invalid: string } {
40
+ if (raw === undefined || raw === null) return { ok: true, value: undefined };
41
+ if (typeof raw !== "string") return { ok: false, invalid: String(raw) };
42
+ const trimmed = raw.trim();
43
+ if (trimmed === "") return { ok: true, value: undefined };
44
+ if (trimmed.length > MAX_DURATION_LEN || !isValidIsoDuration(trimmed)) return { ok: false, invalid: trimmed };
45
+ return { ok: true, value: trimmed.toUpperCase() };
46
+ }
47
+
18
48
  export default defineOperation("dispatchDeliveryGraph", async ({ body }, app) => {
19
49
  const digest = body && typeof body === "object" && "digest" in body && typeof body.digest === "string" ? body.digest.trim() : "";
20
50
  if (digest === "") {
@@ -24,6 +54,27 @@ export default defineOperation("dispatchDeliveryGraph", async ({ body }, app) =>
24
54
  const idemRaw = body && typeof body === "object" && "idempotencyKey" in body && typeof body.idempotencyKey === "string" ? body.idempotencyKey.trim() : "";
25
55
  const idempotencyKey = idemRaw !== "" ? idemRaw : undefined;
26
56
 
57
+ // Run-level timeout overrides (#505) — exposed at submission, validated as ISO-8601 durations here so
58
+ // an invalid value is a clean 400 (never a deployed, uninterpretable timer). Absent → runner defaults.
59
+ const rawTimeouts: Record<"nodeTimeout" | "probeTimeout" | "escalationSlaTimeout", unknown> =
60
+ body && typeof body === "object"
61
+ ? {
62
+ nodeTimeout: "nodeTimeout" in body ? body.nodeTimeout : undefined,
63
+ probeTimeout: "probeTimeout" in body ? body.probeTimeout : undefined,
64
+ escalationSlaTimeout: "escalationSlaTimeout" in body ? body.escalationSlaTimeout : undefined,
65
+ }
66
+ : { nodeTimeout: undefined, probeTimeout: undefined, escalationSlaTimeout: undefined };
67
+ const timeouts: { nodeTimeout?: string; probeTimeout?: string; escalationSlaTimeout?: string } = {};
68
+ for (const field of ["nodeTimeout", "probeTimeout", "escalationSlaTimeout"] as const) {
69
+ const parsed = validateDurationOverride(rawTimeouts[field]);
70
+ if (!parsed.ok) {
71
+ const shown = truncateForEcho(parsed.invalid);
72
+ app.log.warn("dispatch-delivery-graph rejected: invalid duration", { field, value: shown, invalidLength: parsed.invalid.length });
73
+ return { status: 400, body: { ok: false, error: `\`${field}\` must be an ISO-8601 duration (e.g. \`PT2H\`); got \`${shown}\`` } };
74
+ }
75
+ if (parsed.value !== undefined) timeouts[field] = parsed.value;
76
+ }
77
+
27
78
  // Load the live staged proposal for this digest — refuses an unknown/expired/superseded/already-
28
79
  // dispatched digest cleanly (no run is launched).
29
80
  const proposal = await getStagedProposal(app.data, digest);
@@ -47,7 +98,7 @@ export default defineOperation("dispatchDeliveryGraph", async ({ body }, app) =>
47
98
  return { status: 400, body: { ok: false, error: `staged proposal ${digest} is corrupt: ${err instanceof Error ? err.message : String(err)}` } };
48
99
  }
49
100
 
50
- const dispatched = await dispatchDeliveryGraphRun(app, graph, { runKey: idempotencyKey, title: proposal.title });
101
+ const dispatched = await dispatchDeliveryGraphRun(app, graph, { runKey: idempotencyKey, title: proposal.title, ...timeouts });
51
102
  if (!dispatched.ok) {
52
103
  app.log.warn("dispatch-delivery-graph refused: compile", { digest, errors: dispatched.errors.length });
53
104
  const outBody: DeliveryGraphTextResult = {
@@ -0,0 +1,159 @@
1
+ // Integration coverage for GET /app/api/delivery-graph/staged operation `listStagedProposals` (issue
2
+ // #511) — the read behind the Staged proposals App-View. It lists every LIVE staged delivery-graph
3
+ // proposal (not aged out of its TTL), newest first, projected to the Preview-DI + Dispatch metadata.
4
+ // These tests drive the REAL door through `bootTestApp`'s api driver: stage via the compile door, then
5
+ // list; assert the staged proposal appears with its counts, that dispatching drops it off the list, and
6
+ // that the `graph`/`preview` payloads are NOT leaked into the lean list projection.
7
+ import { mkdtempSync, rmSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join, resolve } from "node:path";
10
+ import { after, describe, test } from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
13
+ import { noopLog } from "../test/log.ts";
14
+
15
+ const APP_ROOT = resolve(import.meta.dirname, "..");
16
+ const GITHUB_ENV: Record<string, string> = { NANO_PR_GITHUB_TRANSPORT: "token", GITHUB_TOKEN: "" };
17
+
18
+ const SIDE_EFFECTING = {
19
+ name: "release runbook",
20
+ nodes: [
21
+ { id: "open-b", kind: "agent", agent: { jobType: "senior:demo", prompt: "un-draft + merge #B" } },
22
+ { id: "cut", kind: "agent", agent: { jobType: "senior:demo", prompt: "cut the release" } },
23
+ ],
24
+ edges: [{ from: "open-b", to: "cut" }],
25
+ };
26
+
27
+ const WAIT_ONLY = {
28
+ name: "soak only",
29
+ nodes: [
30
+ { id: "soak", kind: "wait", wait: { kind: "github-check", target: "owner/repo@main" } },
31
+ { id: "done", kind: "human", human: { prompt: "Confirm the soak looked clean." } },
32
+ ],
33
+ edges: [{ from: "soak", to: "done" }],
34
+ };
35
+
36
+ interface StagedProposalSummary {
37
+ digest: string;
38
+ title: string | null;
39
+ nodeCount: number;
40
+ humanNodeCount: number;
41
+ sideEffectCount: number;
42
+ sideEffecting: boolean;
43
+ createdAt: string;
44
+ expiresAt: string;
45
+ }
46
+ type ListResponse = { count: number; proposals: StagedProposalSummary[] };
47
+
48
+ describe("listStagedProposals — the live staged-proposals list", () => {
49
+ const dirs: string[] = [];
50
+ const apps: TestApp[] = [];
51
+ after(async () => {
52
+ for (const app of apps) await app.stop?.();
53
+ for (const d of dirs) rmSync(d, { recursive: true, force: true });
54
+ });
55
+ const boot = async (): Promise<TestApp> => {
56
+ const d = mkdtempSync(join(tmpdir(), "nwf-list-staged-"));
57
+ dirs.push(d);
58
+ const app = await bootTestApp(APP_ROOT, { env: { ...GITHUB_ENV, NANO_APP_DB_URL: `file:${join(d, "app.db")}` } });
59
+ apps.push(app);
60
+ return app;
61
+ };
62
+
63
+ test("no staged proposals → 200 with an empty list", async () => {
64
+ const app = await boot();
65
+ assert.ok(app.api);
66
+ const res = await app.api.call<ListResponse>("listStagedProposals", {});
67
+ assert.equal(res.status, 200);
68
+ assert.equal(res.body.count, 0);
69
+ assert.deepEqual(res.body.proposals, []);
70
+ });
71
+
72
+ test("a staged graph appears with its projected counts; no graph/preview payload is leaked", async () => {
73
+ const app = await boot();
74
+ assert.ok(app.api);
75
+ const api = app.api;
76
+ const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: SIDE_EFFECTING });
77
+ const digest = staged.body.digest;
78
+
79
+ const res = await api.call<ListResponse>("listStagedProposals", {});
80
+ assert.equal(res.status, 200);
81
+ assert.equal(res.body.count, 1);
82
+ const row = res.body.proposals[0];
83
+ assert.equal(row.digest, digest);
84
+ assert.equal(row.title, "release runbook");
85
+ assert.equal(row.nodeCount, 2);
86
+ assert.equal(row.humanNodeCount, 0);
87
+ assert.equal(row.sideEffectCount, 2);
88
+ assert.equal(row.sideEffecting, true);
89
+ assert.ok(typeof row.createdAt === "string" && row.createdAt.length > 0);
90
+ assert.ok(typeof row.expiresAt === "string" && row.expiresAt.length > 0);
91
+ // The list is a lean projection — the heavy graph/preview JSON is NOT included (the App-View
92
+ // recompiles by digest through previewProposalBpmn for the DI preview).
93
+ assert.ok(!("graph" in row), "the list must not leak the stored graph JSON");
94
+ assert.ok(!("preview" in row), "the list must not leak the stored preview JSON");
95
+ });
96
+
97
+ test("a wait/human-only graph is reported as not side-effecting", async () => {
98
+ const app = await boot();
99
+ assert.ok(app.api);
100
+ const api = app.api;
101
+ await api.call<{ digest: string }>("compileDeliveryGraph", { body: WAIT_ONLY });
102
+ const res = await api.call<ListResponse>("listStagedProposals", {});
103
+ assert.equal(res.body.count, 1);
104
+ const row = res.body.proposals[0];
105
+ assert.equal(row.sideEffecting, false);
106
+ assert.equal(row.sideEffectCount, 0);
107
+ assert.equal(row.humanNodeCount, 1);
108
+ });
109
+
110
+ test("dispatching a staged proposal drops it off the live list", async () => {
111
+ const app = await boot();
112
+ assert.ok(app.api);
113
+ const api = app.api;
114
+ const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: SIDE_EFFECTING });
115
+ const digest = staged.body.digest;
116
+ assert.equal((await api.call<ListResponse>("listStagedProposals", {})).body.count, 1);
117
+
118
+ const dispatched = await api.call<{ ok: boolean }>("dispatchDeliveryGraph", { body: { digest } });
119
+ assert.equal(dispatched.body.ok, true);
120
+
121
+ const after = await api.call<ListResponse>("listStagedProposals", {});
122
+ assert.equal(after.body.count, 0, "a dispatched proposal is no longer staged");
123
+ });
124
+
125
+ // The optional shared-secret guard is enforced in the handler (not by OpenAPI `security`), mirroring
126
+ // the other read doors (getLineage / listActivePrs). `SECRET` is captured at module import, so we
127
+ // cache-bust re-import the handler with NANO_PR_WEBHOOK_SECRET set to exercise both the rejected and
128
+ // authorized paths, driving it directly against a real booted data layer.
129
+ test("shared-secret guard: 401 without x-hook-secret, 200 with it", async () => {
130
+ const app = await boot();
131
+ const stubApp = { log: noopLog(), data: app.db } as any;
132
+ const ctx = (headers: Record<string, string> = {}) => ({
133
+ req: {
134
+ method: "GET",
135
+ path: "/app/api/delivery-graph/staged",
136
+ query: new URLSearchParams(),
137
+ headers: new Headers(headers),
138
+ text: async () => "",
139
+ } as any,
140
+ params: {},
141
+ query: {},
142
+ body: undefined,
143
+ });
144
+ const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
145
+ process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
146
+ try {
147
+ const mod = await import(`./listStagedProposals.ts?guard=${Date.now()}`);
148
+ const guarded = mod.default as (c: unknown, a: unknown) => Promise<{ status: number; body: any }>;
149
+ const bad = await guarded(ctx(), stubApp);
150
+ assert.equal(bad.status, 401);
151
+ const ok = await guarded(ctx({ "x-hook-secret": "s3cr3t" }), stubApp);
152
+ assert.equal(ok.status, 200);
153
+ assert.ok(Array.isArray(ok.body.proposals));
154
+ } finally {
155
+ if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
156
+ else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
157
+ }
158
+ });
159
+ });
@@ -0,0 +1,37 @@
1
+ // GET /app/api/delivery-graph/staged → operationId `listStagedProposals` (issue #511). The read behind
2
+ // the staged-proposals App-View: every LIVE `staged` delivery-graph proposal (not aged out of its TTL),
3
+ // newest first, projected to the metadata the Preview-DI + Dispatch list renders.
4
+ //
5
+ // It replaces the declarative `dataGrid` datasource the staged grid used, so the list can live in an
6
+ // App-View (JS) that CAN drive the `nano-navigate` DI-preview bridge — a declarative grid row-action
7
+ // can POST but cannot hand the recompiled BPMN up to the host explorer. The `graph`/`preview` payloads
8
+ // are deliberately omitted: the App-View recompiles by `digest` through `previewProposalBpmn` for the DI
9
+ // preview, so the list stays lean.
10
+ //
11
+ // The optional shared-secret guard stays HERE (the runtime does not enforce OpenAPI `security`): when
12
+ // NANO_PR_WEBHOOK_SECRET is set, callers must present it via the x-hook-secret header — mirroring the
13
+ // other read doors (getLineage / listActivePrs).
14
+ import { listStagedProposals } from "../app/deliveryGraphProposals.ts";
15
+ import { envVar } from "../app/version.ts";
16
+ import { defineOperation } from "../nano-generated/operations.ts";
17
+
18
+ const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
19
+
20
+ export default defineOperation("listStagedProposals", async ({ req }, app) => {
21
+ if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
22
+ app.log.warn("listStagedProposals rejected: missing/invalid shared secret");
23
+ return { status: 401, body: { error: "unauthorized" } };
24
+ }
25
+ const rows = await listStagedProposals(app.data);
26
+ const proposals = rows.map((row) => ({
27
+ digest: row.digest,
28
+ title: row.title,
29
+ nodeCount: row.node_count,
30
+ humanNodeCount: row.human_node_count,
31
+ sideEffectCount: row.side_effect_count,
32
+ sideEffecting: row.side_effecting === 1,
33
+ createdAt: row.created_at,
34
+ expiresAt: row.expires_at,
35
+ }));
36
+ return { status: 200, body: { count: proposals.length, proposals } };
37
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.133.1",
3
+ "version": "0.135.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",
@@ -0,0 +1,33 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Delivery graphs — staged proposals (preview · dispatch) (App View embed)</title>
7
+ <link rel="stylesheet" href="./delivery-graphs.css" />
8
+ <style>
9
+ html, body { margin: 0; height: 100%; background: #0b0f14; }
10
+ </style>
11
+ </head>
12
+ <body>
13
+ <!--
14
+ Console App-View embed (ADR 0057, issue #511). The console loads this document into its App-View
15
+ surface and hands it a host element; we mount the SAME staged-proposals list (Preview DI + Dispatch)
16
+ via the SAME ./staged.mount.js as the standalone shell — only the host and the injected endpoint
17
+ config differ, so the view renders identically. When the console injects endpoint config via
18
+ `window.__NANO_APP_VIEW__`, it wins.
19
+ -->
20
+ <main id="delivery-graphs-staged-root"></main>
21
+ <script type="module">
22
+ import { mountStagedProposals } from "./staged.mount.js";
23
+
24
+ const cfg = window.__NANO_APP_VIEW__ ?? {};
25
+ mountStagedProposals(cfg.host ?? document.getElementById("delivery-graphs-staged-root"), {
26
+ stagedUrl: cfg.stagedUrl,
27
+ dispatchUrl: cfg.dispatchUrl,
28
+ proposalBpmnUrl: cfg.proposalBpmnUrl,
29
+ hookSecret: cfg.hookSecret,
30
+ });
31
+ </script>
32
+ </body>
33
+ </html>
@@ -0,0 +1,41 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6
+ <title>Delivery graphs — staged proposals (preview · dispatch)</title>
7
+ <link rel="stylesheet" href="./delivery-graphs.css" />
8
+ <style>
9
+ html, body { margin: 0; height: 100%; background: #0b0f14; }
10
+ </style>
11
+ </head>
12
+ <body>
13
+ <!--
14
+ Standalone shell (phone / direct link). Loads the SAME ./staged.mount.js the console App-View embed
15
+ uses, so the standalone and embedded views render identically. Endpoints default to the current
16
+ origin; override the list/dispatch/preview endpoints via ?staged= / ?dispatch= / ?proposal-bpmn=.
17
+ For a secured deployment, pass the guard secret via the URL fragment #secret= (sent as
18
+ x-hook-secret) — NOT the query string, so it never leaks via server access logs, browser history,
19
+ or the Referer header. The fragment is stripped from the address bar immediately after it is read.
20
+ Note: "Preview generated DI" needs the host console explorer to render into, so it only works when
21
+ embedded — standalone it reports that instead of failing silently.
22
+ -->
23
+ <main id="delivery-graphs-staged-root"></main>
24
+ <script type="module">
25
+ import { mountStagedProposals } from "./staged.mount.js";
26
+
27
+ const params = new URLSearchParams(location.search);
28
+ const secrets = new URLSearchParams(location.hash.slice(1));
29
+ const hookSecret = secrets.get("secret") ?? undefined;
30
+ if (location.hash) {
31
+ history.replaceState(null, "", location.pathname + location.search);
32
+ }
33
+ mountStagedProposals(document.getElementById("delivery-graphs-staged-root"), {
34
+ stagedUrl: params.get("staged") ?? undefined,
35
+ dispatchUrl: params.get("dispatch") ?? undefined,
36
+ proposalBpmnUrl: params.get("proposal-bpmn") ?? undefined,
37
+ hookSecret,
38
+ });
39
+ </script>
40
+ </body>
41
+ </html>
@@ -0,0 +1,284 @@
1
+ // pages/delivery-graphs/staged.mount.js — the Staged proposals App-View (ADR 0005 Decision 7, issues
2
+ // #460 + #511). The OPERATOR surface for the delivery-graph proposals an agent (or the compose view)
3
+ // has staged: it lists every LIVE staged proposal and, per row, offers
4
+ // • Preview DI — recompile the proposal's BPMN (with diagram interchange) and hand it UP to the host
5
+ // console's process explorer over the `nano-navigate` bridge, rendered read-only BEFORE dispatch;
6
+ // • Dispatch — the operator's launch action (#460): POST the proposal's `digest` to the dispatch
7
+ // door. Clicking Dispatch IS the approval, content-addressed to exactly the graph previewed.
8
+ //
9
+ // It REPLACES the old declarative `dataGrid` (a grid row-action can POST but cannot take the recompiled
10
+ // BPMN and `postMessage` it to the explorer — so a staged proposal had a Dispatch button but no way to
11
+ // SEE the graph, #511). This is a THIN UI over EXISTING doors — the list read (`listStagedProposals`),
12
+ // the DI recompile (`previewProposalBpmn`), and the dispatch (`dispatchDeliveryGraph`) — with no
13
+ // parallel logic. Dispatch stays OPERATOR-ONLY: this surface only ever posts a `digest` that is already
14
+ // staged; it never compiles or stages (that is the compose view), so the #460 boundary holds.
15
+ //
16
+ // A self-contained, dependency-free renderer in the SAME shape as the compose view (./mount.js) and the
17
+ // demand×supply board (pages/board/mount.js): the SAME module mounts embedded in the console (App View)
18
+ // and standalone — only the host element and injected endpoint config differ.
19
+
20
+ // The read behind the list: every live staged proposal, newest first (base-relative — a leading-slash
21
+ // path resolves against the console iframe ORIGIN, not the app-view base, and 404s the door, #279).
22
+ const DEFAULT_STAGED_URL = "app/api/delivery-graph/staged";
23
+ // The operator dispatch door: POST { digest } → launches the staged graph engine-natively (#460).
24
+ const DEFAULT_DISPATCH_URL = "app/api/actions/delivery-graph/dispatch";
25
+ // The read-only DI preview door: recompiles a staged proposal's BPMN (with diagram interchange) so its
26
+ // generated diagram can be rendered in the host explorer BEFORE dispatch. No deploy, no dispatch.
27
+ const DEFAULT_PROPOSAL_BPMN_URL = "app/api/actions/delivery-graph/proposal-bpmn";
28
+
29
+ // How often the list re-polls the read door so a freshly-staged (or just-dispatched) proposal appears
30
+ // (or drops off) without a manual refresh — mirrors the 5s cadence the old declarative grid used.
31
+ const DEFAULT_REFRESH_MS = 5000;
32
+
33
+ // A bounded timeout for every door request. Without it a hung door leaves the fetch promise pending
34
+ // forever, so the busy() lock never clears and the UI is stranded; on timeout the AbortController
35
+ // rejects the fetch, surfacing as an error banner and re-enabling the controls via the finally blocks.
36
+ const REQUEST_TIMEOUT_MS = 30000;
37
+
38
+ // The confirm shown before a dispatch — dispatching authorises every side-effecting node, so the
39
+ // operator acknowledges that the launch (and its side effects) is content-addressed to this graph.
40
+ const DISPATCH_CONFIRM =
41
+ "Dispatch this staged delivery graph? This launches the graph engine-natively — any side-effecting " +
42
+ "node (it merges PRs / publishes packages) will run. Clicking Dispatch IS the approval, " +
43
+ "content-addressed to exactly the graph you previewed.";
44
+
45
+ /** Escape untrusted strings before they touch innerHTML. */
46
+ function esc(value) {
47
+ return String(value ?? "").replace(
48
+ /[&<>"']/g,
49
+ (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[ch],
50
+ );
51
+ }
52
+
53
+ /** Format an ISO timestamp for the operator, falling back to the raw value if unparseable. */
54
+ function fmtTime(iso) {
55
+ const t = Date.parse(iso);
56
+ if (Number.isNaN(t)) return esc(iso);
57
+ return esc(new Date(t).toLocaleString());
58
+ }
59
+
60
+ /** Render one staged proposal as a card row with Preview-DI + Dispatch actions. */
61
+ function renderProposal(p) {
62
+ const title = p.title ? `<code>${esc(p.title)}</code>` : '<span class="muted">(unnamed)</span>';
63
+ const gate = p.sideEffecting
64
+ ? '<span class="pill pill-connector">side-effecting</span>'
65
+ : '<span class="pill pill-wait">no side effects</span>';
66
+ return `<section class="card">
67
+ <h2>${title} ${gate}</h2>
68
+ <div class="chips">
69
+ <span class="chip">Nodes <b>${esc(p.nodeCount)}</b></span>
70
+ <span class="chip">Human <b>${esc(p.humanNodeCount)}</b></span>
71
+ <span class="chip">Side effects <b>${esc(p.sideEffectCount)}</b></span>
72
+ <span class="chip">Staged <b>${fmtTime(p.createdAt)}</b></span>
73
+ <span class="chip">Expires <b>${fmtTime(p.expiresAt)}</b></span>
74
+ <span class="chip">Digest <code>${esc(p.digest)}</code></span>
75
+ </div>
76
+ <div class="actions">
77
+ <button class="btn btn-ghost" type="button" data-preview-di="${esc(p.digest)}">Preview generated DI</button>
78
+ <button class="btn btn-primary" type="button" data-dispatch="${esc(p.digest)}">Dispatch</button>
79
+ </div>
80
+ </section>`;
81
+ }
82
+
83
+ /** Render the whole list (or the empty state). */
84
+ function renderList(proposals) {
85
+ if (!Array.isArray(proposals) || proposals.length === 0) {
86
+ return `<section class="card">
87
+ <h2>Staged proposals <span class="count">0</span></h2>
88
+ <p class="muted">No staged proposals awaiting dispatch. Compile a graph (as an agent) or preview + stage one in the compose view above, then Preview &amp; Dispatch it here.</p>
89
+ </section>`;
90
+ }
91
+ const header = `<section class="card card-ok">
92
+ <h2>Staged proposals <span class="count">${proposals.length}</span></h2>
93
+ <p class="ok">Awaiting an operator. <b>Preview generated DI</b> renders the laid-out BPMN in the process explorer; <b>Dispatch</b> launches it (dispatch is the approval, #460).</p>
94
+ </section>`;
95
+ return header + proposals.map(renderProposal).join("");
96
+ }
97
+
98
+ /**
99
+ * Mount the staged-proposals list into `host`.
100
+ * @param {Element|null} host — the element to render into (or null → look up #delivery-graphs-staged-root).
101
+ * @param {{stagedUrl?:string, dispatchUrl?:string, proposalBpmnUrl?:string, hookSecret?:string, refreshMs?:number}} [config]
102
+ */
103
+ export function mountStagedProposals(host, config = {}) {
104
+ const isElement = host != null && host.nodeType === 1 && typeof host.innerHTML === "string";
105
+ const root = isElement ? host : document.getElementById("delivery-graphs-staged-root");
106
+ if (!root) return () => {};
107
+
108
+ const stagedUrl = config.stagedUrl ?? DEFAULT_STAGED_URL;
109
+ const dispatchUrl = config.dispatchUrl ?? DEFAULT_DISPATCH_URL;
110
+ const proposalBpmnUrl = config.proposalBpmnUrl ?? DEFAULT_PROPOSAL_BPMN_URL;
111
+ const refreshMs = typeof config.refreshMs === "number" && config.refreshMs > 0 ? config.refreshMs : DEFAULT_REFRESH_MS;
112
+ const headers = () => ({
113
+ "content-type": "application/json",
114
+ ...(config.hookSecret ? { "x-hook-secret": config.hookSecret } : {}),
115
+ });
116
+
117
+ root.innerHTML = `<div class="dg">
118
+ <div class="actions">
119
+ <span id="dg-staged-status" class="status"></span>
120
+ </div>
121
+ <div id="dg-staged-list"></div>
122
+ </div>`;
123
+
124
+ const statusEl = root.querySelector("#dg-staged-status");
125
+ const listEl = root.querySelector("#dg-staged-list");
126
+
127
+ function setStatus(text, tone) {
128
+ statusEl.textContent = text || "";
129
+ statusEl.className = "status" + (tone ? " status-" + tone : "");
130
+ }
131
+
132
+ let busyCount = 0;
133
+ // A re-render (renderList → new buttons) resets every button to enabled, so the disabled state is
134
+ // NOT stored on the elements — it is derived from busyCount and re-applied after each render (below)
135
+ // and on every busy()/idle() transition. That keeps a poll or dispatch-driven refresh from silently
136
+ // re-enabling the buttons while a Preview/Dispatch request is still in flight.
137
+ function applyDisabled() {
138
+ const disabled = busyCount > 0;
139
+ for (const btn of listEl.querySelectorAll("button")) btn.disabled = disabled;
140
+ }
141
+ function busy(on) {
142
+ busyCount += on ? 1 : -1;
143
+ applyDisabled();
144
+ }
145
+
146
+ /** Fetch JSON from a door and return { status, body } (never throws on an HTTP error). Rejects
147
+ * (AbortError) if the request outlives REQUEST_TIMEOUT_MS so a hung door can't wedge the busy lock. */
148
+ async function request(url, init) {
149
+ const controller = new AbortController();
150
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
151
+ try {
152
+ const res = await fetch(url, { ...init, headers: headers(), signal: controller.signal });
153
+ let body = {};
154
+ try {
155
+ body = await res.json();
156
+ } catch (_e) {
157
+ body = {};
158
+ }
159
+ return { status: res.status, body };
160
+ } finally {
161
+ clearTimeout(timer);
162
+ }
163
+ }
164
+
165
+ const get = (url) => request(url, { method: "GET" });
166
+ const post = (url, payload) => request(url, { method: "POST", body: JSON.stringify(payload) });
167
+
168
+ let disposed = false;
169
+ // True while the last completed load failed — so a subsequent successful load knows to clear its own
170
+ // stale error banner, WITHOUT clobbering a transient action toast (Preview/Dispatch ok/err message).
171
+ let loadErrorShown = false;
172
+
173
+ async function refresh() {
174
+ try {
175
+ const { status, body } = await get(stagedUrl);
176
+ if (disposed) return;
177
+ if (status === 200 && Array.isArray(body.proposals)) {
178
+ listEl.innerHTML = renderList(body.proposals);
179
+ applyDisabled();
180
+ if (loadErrorShown) {
181
+ setStatus("");
182
+ loadErrorShown = false;
183
+ }
184
+ } else {
185
+ listEl.innerHTML = renderList([]);
186
+ applyDisabled();
187
+ setStatus(body && body.error ? body.error : "Could not load staged proposals.", "err");
188
+ loadErrorShown = true;
189
+ }
190
+ } catch (err) {
191
+ if (disposed) return;
192
+ setStatus(err && err.message ? err.message : "Staged-proposals request failed.", "err");
193
+ loadErrorShown = true;
194
+ }
195
+ }
196
+
197
+ // "Preview generated DI": recompile the proposal's BPMN (with diagram interchange) and hand it to the
198
+ // host console's process explorer, which renders it read-only in a definition-preview view. We run
199
+ // inside the console App-View iframe, so we fetch from our OWN nwf door (same origin as this app) and
200
+ // pass the XML UP to the console over the nano-navigate bridge — the XML is far larger than a URL
201
+ // budget, so it travels in the message, not the path. Standalone (not embedded) there is no host
202
+ // explorer to drive, so we say so instead of failing silently.
203
+ const isEmbedded = typeof window !== "undefined" && window.parent && window.parent !== window;
204
+ async function doPreviewDi(digest) {
205
+ const staged = typeof digest === "string" ? digest.trim() : "";
206
+ if (staged === "") return;
207
+ if (!isEmbedded) {
208
+ setStatus("Open this page inside the console cockpit to preview the generated DI.", "err");
209
+ return;
210
+ }
211
+ busy(true);
212
+ setStatus("Compiling DI…");
213
+ try {
214
+ const { status, body } = await post(proposalBpmnUrl, { digest: staged });
215
+ if (status === 200 && body.ok && typeof body.bpmn === "string" && body.bpmn.trim() !== "") {
216
+ window.parent.postMessage(
217
+ { type: "nano-navigate", target: "definitionPreview", params: { xml: body.bpmn } },
218
+ window.location.origin,
219
+ );
220
+ setStatus("\u2713 Opening the generated DI in the process explorer…", "ok");
221
+ } else {
222
+ setStatus(body && body.error ? body.error : "Could not compile the DI for this proposal.", "err");
223
+ }
224
+ } catch (err) {
225
+ setStatus(err && err.message ? err.message : "DI preview request failed.", "err");
226
+ } finally {
227
+ busy(false);
228
+ }
229
+ }
230
+
231
+ // "Dispatch": the operator's launch (#460). Confirm (dispatch authorises every side-effecting node),
232
+ // then POST the digest to the dispatch door; on success the proposal flips to `dispatched` and drops
233
+ // off the list on the next poll — refresh immediately so the operator sees it leave.
234
+ async function doDispatch(digest) {
235
+ const staged = typeof digest === "string" ? digest.trim() : "";
236
+ if (staged === "") return;
237
+ if (typeof window !== "undefined" && typeof window.confirm === "function" && !window.confirm(DISPATCH_CONFIRM)) {
238
+ return;
239
+ }
240
+ busy(true);
241
+ setStatus("Dispatching…");
242
+ try {
243
+ const { status, body } = await post(dispatchUrl, { digest: staged });
244
+ if ((status === 202 || status === 200) && body.ok) {
245
+ setStatus("\u2713 Dispatched — the run is now in flight.", "ok");
246
+ await refresh();
247
+ } else {
248
+ setStatus(body && body.error ? body.error : "Dispatch failed.", "err");
249
+ }
250
+ } catch (err) {
251
+ setStatus(err && err.message ? err.message : "Dispatch request failed.", "err");
252
+ } finally {
253
+ busy(false);
254
+ }
255
+ }
256
+
257
+ listEl.addEventListener("click", (ev) => {
258
+ const previewBtn = ev.target && ev.target.closest ? ev.target.closest("[data-preview-di]") : null;
259
+ if (previewBtn) {
260
+ ev.preventDefault();
261
+ doPreviewDi(previewBtn.getAttribute("data-preview-di"));
262
+ return;
263
+ }
264
+ const dispatchBtn = ev.target && ev.target.closest ? ev.target.closest("[data-dispatch]") : null;
265
+ if (dispatchBtn) {
266
+ ev.preventDefault();
267
+ doDispatch(dispatchBtn.getAttribute("data-dispatch"));
268
+ }
269
+ });
270
+
271
+ refresh();
272
+ // Skip a scheduled poll while a Preview/Dispatch request is in flight: re-rendering the list mid-
273
+ // request would drop the in-flight button (and its disabled state) out from under the user. The
274
+ // dispatch path drives its own refresh() on completion, so nothing is missed.
275
+ const timer = setInterval(() => {
276
+ if (busyCount === 0) refresh();
277
+ }, refreshMs);
278
+
279
+ return () => {
280
+ disposed = true;
281
+ clearInterval(timer);
282
+ root.innerHTML = "";
283
+ };
284
+ }
@@ -85,50 +85,13 @@
85
85
  }
86
86
  },
87
87
  {
88
- "type": "dataGrid",
88
+ "type": "appView",
89
89
  "id": "delivery-graphs-staged",
90
90
  "props": {
91
91
  "title": "Staged proposals",
92
- "collapsible": true,
93
- "defaultCollapsed": false,
94
- "showCount": true,
95
- "rowKey": "digest",
96
- "refreshMs": 5000,
97
- "empty": "No staged proposals awaiting dispatch. Compile a graph (as an agent) or preview + stage one above, then Dispatch it here.",
98
- "data": {
99
- "kind": "datasource",
100
- "source": "app",
101
- "table": "delivery_graph_proposals",
102
- "orderBy": { "field": "created_at", "dir": "desc" },
103
- "filter": [{ "field": "status", "in": ["staged"] }]
104
- },
105
- "columns": [
106
- { "field": "title", "template": "{{title}}", "header": "Graph", "subtitleField": "digest", "truncate": true, "width": "34%" },
107
- { "field": "node_count", "header": "Nodes" },
108
- { "field": "human_node_count", "header": "Human" },
109
- { "field": "side_effect_count", "header": "Side effects" },
110
- { "field": "created_at", "header": "Staged", "width": "9rem", "format": "datetime" },
111
- { "field": "expires_at", "header": "Expires", "width": "9rem", "format": "datetime" }
112
- ],
113
- "rowActions": [
114
- {
115
- "label": "Dispatch",
116
- "confirm": "Dispatch this staged delivery graph? This launches the graph engine-natively \u2014 any side-effecting node (it merges PRs / publishes packages) will run. Clicking Dispatch IS the approval, content-addressed to exactly the graph shown here.",
117
- "action": {
118
- "path": "/app/api/actions/delivery-graph/dispatch",
119
- "body": { "digest": "{{row.digest}}" }
120
- }
121
- }
122
- ],
123
- "detail": {
124
- "fields": [
125
- { "field": "digest", "label": "Digest" },
126
- { "field": "logical_key", "label": "Logical key" },
127
- { "field": "side_effecting", "label": "Side-effecting" },
128
- { "field": "preview", "label": "Preview (diagram, human stop-points, side effects)" },
129
- { "field": "graph", "label": "Graph JSON (normalized serialization to be dispatched)" }
130
- ]
131
- }
92
+ "embed": "./delivery-graphs/staged-embed.html",
93
+ "standalone": "./delivery-graphs/staged-standalone.html",
94
+ "fill": true
132
95
  }
133
96
  },
134
97
  {
@@ -84,15 +84,22 @@ test("#460: the compose view exposes NO dispatch or approval affordance — it o
84
84
  assert(!/approvalToken/.test(MOUNT_JS), "mount.js must NOT carry the removed replayable approvalToken");
85
85
  });
86
86
 
87
- test("#460: dispatch is the operator's Staged-proposals row-action on the page", () => {
88
- // The page (not mount.js) offers dispatch: a Staged proposals grid over delivery_graph_proposals
89
- // with a Dispatch row-action that posts the proposal's digest to the operator dispatch door.
87
+ test("#460/#511: dispatch is the operator's action on the Staged-proposals App-View", () => {
88
+ // Dispatch is NOT in the compose view (asserted above). It lives on the Staged-proposals surface,
89
+ // which is now an App-View (issue #511) rather than a declarative grid: a grid row-action can POST but
90
+ // cannot hand the recompiled BPMN up to the host explorer, so a staged proposal had a Dispatch button
91
+ // but no way to SEE the graph. The App-View carries BOTH Preview-DI and Dispatch. The wiring itself
92
+ // (which doors staged.mount.js posts to) is pinned by delivery-graphs-staged-embed.test.ts.
90
93
  const page = JSON.parse(PAGE_JSON) as { nodes: Array<Record<string, any>> };
91
94
  const staged = page.nodes.find((n) => n.id === "delivery-graphs-staged");
92
- assert(staged, "the page must carry a Staged proposals grid");
93
- assert(staged?.props?.data?.table === "delivery_graph_proposals", "the staged grid binds to delivery_graph_proposals");
94
- const dispatch = (staged?.props?.rowActions ?? []).find((a: any) => a.label === "Dispatch");
95
- assert(dispatch, "the staged grid must expose a Dispatch row-action");
96
- assert(dispatch.action.path.endsWith("actions/delivery-graph/dispatch"), "Dispatch posts to the operator dispatch door");
97
- assert(dispatch.action.body.digest === "{{row.digest}}", "Dispatch posts the proposal's digest");
95
+ assert(staged, "the page must carry a Staged proposals surface");
96
+ assert(staged?.type === "appView", "the Staged proposals surface is an App-View (#511), not a declarative grid");
97
+ assert(
98
+ staged?.props?.embed === "./delivery-graphs/staged-embed.html",
99
+ "the Staged proposals App-View embeds ./delivery-graphs/staged-embed.html",
100
+ );
101
+ assert(
102
+ staged?.props?.standalone === "./delivery-graphs/staged-standalone.html",
103
+ "the Staged proposals App-View has a standalone shell",
104
+ );
98
105
  });
@@ -0,0 +1,77 @@
1
+ // Contract guard for the Staged proposals App-View (issues #460 + #511).
2
+ //
3
+ // A staged delivery-graph proposal (agent-authored, or staged from the compose view) must be
4
+ // PREVIEWABLE and DISPATCHABLE from the cockpit. The old declarative `dataGrid` could POST a Dispatch
5
+ // row-action but could not hand the recompiled BPMN up to the host explorer, so a staged proposal had a
6
+ // Dispatch button and NO way to see the graph. The staged App-View (pages/delivery-graphs/staged.*)
7
+ // closes that: per row it offers Preview-DI (over the nano-navigate bridge) AND Dispatch. This test
8
+ // pins the wiring so it cannot silently regress: the sidecars exist and mount the same module, the door
9
+ // defaults are base-relative (the #279 App-View resolution class — a leading-slash path 404s through the
10
+ // console iframe), it drives the DI-preview bridge, and it posts the dispatch by digest.
11
+ import { test } from "node:test";
12
+ import { assert } from "#test-assert";
13
+ import { readFileSync } from "node:fs";
14
+
15
+ const ROOT = decodeURIComponent(new URL("../", import.meta.url).pathname);
16
+ const DIR = `${ROOT}pages/delivery-graphs`;
17
+ const MOUNT_JS = readFileSync(`${DIR}/staged.mount.js`, "utf8");
18
+ const EMBED_HTML = readFileSync(`${DIR}/staged-embed.html`, "utf8");
19
+ const STANDALONE_HTML = readFileSync(`${DIR}/staged-standalone.html`, "utf8");
20
+ const PAGE_JSON = readFileSync(`${ROOT}pages/delivery-graphs.page.json`, "utf8");
21
+
22
+ // Pull the string default out of `const <name> = config.<field> ?? <CONST>;` (or a module const).
23
+ function defaultUrl(name: string): string {
24
+ const m = MOUNT_JS.match(new RegExp(`${name}\\s*=\\s*config\\.\\w+\\s*\\?\\?\\s*(\\w+);`));
25
+ assert(m, `staged.mount.js must default ${name} from config with a fallback constant`);
26
+ const constM = MOUNT_JS.match(new RegExp(`const ${m![1]}\\s*=\\s*"([^"]*)"`));
27
+ assert(constM, `staged.mount.js must declare the ${m![1]} fallback as a string literal`);
28
+ return constM![1];
29
+ }
30
+
31
+ test("#511: the staged App-View mounts the same module standalone and embedded", () => {
32
+ assert(/mountStagedProposals/.test(MOUNT_JS), "staged.mount.js must export mountStagedProposals");
33
+ for (const [file, html] of [["staged-embed.html", EMBED_HTML], ["staged-standalone.html", STANDALONE_HTML]] as const) {
34
+ assert(
35
+ /import \{ mountStagedProposals \} from "\.\/staged\.mount\.js"/.test(html),
36
+ `${file} must import mountStagedProposals from ./staged.mount.js`,
37
+ );
38
+ assert(/mountStagedProposals\(/.test(html), `${file} must call mountStagedProposals`);
39
+ }
40
+ });
41
+
42
+ test("#511: the page binds the Staged proposals node to the staged App-View sidecars", () => {
43
+ const page = JSON.parse(PAGE_JSON) as { nodes: Array<Record<string, any>> };
44
+ const staged = page.nodes.find((n) => n.id === "delivery-graphs-staged");
45
+ assert(staged, "the page must carry the delivery-graphs-staged node");
46
+ assert(staged?.type === "appView", "delivery-graphs-staged must be an appView (#511)");
47
+ assert(staged?.props?.embed === "./delivery-graphs/staged-embed.html", "it embeds the staged embed sidecar");
48
+ assert(staged?.props?.standalone === "./delivery-graphs/staged-standalone.html", "it has the staged standalone sidecar");
49
+ });
50
+
51
+ test("#511/#279: the staged list door default is base-relative", () => {
52
+ const url = defaultUrl("stagedUrl");
53
+ assert(url.endsWith("delivery-graph/staged"), `stagedUrl default "${url}" must hit the listStagedProposals door`);
54
+ assert(!url.startsWith("/"), `default stagedUrl "${url}" must be base-relative (App-View #279 resolution class)`);
55
+ });
56
+
57
+ test("#511: DI preview — the staged view wires the proposal-bpmn door and bridges the XML to the explorer", () => {
58
+ const url = defaultUrl("proposalBpmnUrl");
59
+ assert(url.endsWith("actions/delivery-graph/proposal-bpmn"), `proposalBpmnUrl default "${url}" must hit the previewProposalBpmn door`);
60
+ assert(!url.startsWith("/"), `default proposalBpmnUrl "${url}" must be base-relative`);
61
+ assert(/data-preview-di=/.test(MOUNT_JS), "staged.mount.js must render a per-row Preview-DI affordance carrying the digest");
62
+ assert(/target:\s*"definitionPreview"/.test(MOUNT_JS), "staged.mount.js must post nano-navigate to the definitionPreview target");
63
+ assert(/params:\s*\{\s*xml:/.test(MOUNT_JS), "staged.mount.js must carry the compiled BPMN xml in the bridge message");
64
+ });
65
+
66
+ test("#460/#511: Dispatch is the operator's launch — posts the digest to the dispatch door, and never compiles/stages", () => {
67
+ const url = defaultUrl("dispatchUrl");
68
+ assert(url.endsWith("actions/delivery-graph/dispatch"), `dispatchUrl default "${url}" must hit the dispatchDeliveryGraph door`);
69
+ assert(!url.startsWith("/"), `default dispatchUrl "${url}" must be base-relative`);
70
+ assert(/data-dispatch=/.test(MOUNT_JS), "staged.mount.js must render a per-row Dispatch affordance carrying the digest");
71
+ assert(/window\.confirm\(/.test(MOUNT_JS), "Dispatch must confirm before launching (dispatch authorises side effects)");
72
+ // Operator-only: this surface dispatches a digest that is ALREADY staged — it must not compile or
73
+ // stage (that is the compose view), so the #460 boundary holds and the self-approval hole stays shut.
74
+ assert(!/delivery-graph\/preview\b/.test(MOUNT_JS), "staged.mount.js must NOT wire the compile/stage door");
75
+ assert(!/graphJson/.test(MOUNT_JS), "staged.mount.js must NOT submit pasted graph JSON (it only lists+dispatches staged proposals)");
76
+ assert(!/approvalToken/.test(MOUNT_JS), "staged.mount.js must NOT carry the removed replayable approvalToken");
77
+ });