@nanobpm/nano-workforce 0.134.0 → 0.136.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 +16 -0
- package/app/deliveryGraphDeploy.test.ts +104 -0
- package/app/deliveryGraphProposals.ts +16 -0
- package/app/deliveryRunner.test.ts +33 -0
- package/app/deliveryRunner.ts +48 -2
- package/docs/adr/0005-agent-authored-delivery-graphs.md +26 -0
- package/docs/agent-guide.md +17 -0
- package/openapi.yaml +93 -1
- package/operations/listStagedProposals.test.ts +159 -0
- package/operations/listStagedProposals.ts +37 -0
- package/package.json +2 -2
- package/pages/delivery-graphs/staged-embed.html +33 -0
- package/pages/delivery-graphs/staged-standalone.html +41 -0
- package/pages/delivery-graphs/staged.mount.js +284 -0
- package/pages/delivery-graphs.page.json +4 -41
- package/test/delivery-graphs-embed.test.ts +16 -9
- package/test/delivery-graphs-staged-embed.test.ts +77 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
## [0.136.0](https://github.com/nanobpm/nano-workforce/compare/v0.135.0...v0.136.0) (2026-08-24)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* **delivery:** agent-node classifier-emit contract so S7 guarded branches fire ([#506](https://github.com/nanobpm/nano-workforce/issues/506)) ([#509](https://github.com/nanobpm/nano-workforce/issues/509)) ([f3acd5b](https://github.com/nanobpm/nano-workforce/commit/f3acd5bd5977fe3b12797f7d1e3acb7d5e67eb8c))
|
|
6
|
+
|
|
7
|
+
### Bug Fixes
|
|
8
|
+
|
|
9
|
+
* **deps:** update dependency @nanobpm/urban to ^0.82.0 ([#482](https://github.com/nanobpm/nano-workforce/issues/482)) ([81decc1](https://github.com/nanobpm/nano-workforce/commit/81decc1b8ebd53b2835aaecbb369c5347371ceb3))
|
|
10
|
+
|
|
11
|
+
## [0.135.0](https://github.com/nanobpm/nano-workforce/compare/v0.134.0...v0.135.0) (2026-08-24)
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* **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))
|
|
16
|
+
|
|
1
17
|
## [0.134.0](https://github.com/nanobpm/nano-workforce/compare/v0.133.1...v0.134.0) (2026-08-24)
|
|
2
18
|
|
|
3
19
|
### Features
|
|
@@ -277,6 +277,110 @@ test("S7 deploy+route: the green default branch SKIPS `migrate` and rides the el
|
|
|
277
277
|
assert(r.releaseRan, "the green outcome still reaches `release` via the else-flow (proof the exclusive merge fires on one token)");
|
|
278
278
|
});
|
|
279
279
|
|
|
280
|
+
// ── #506: the REAL agentic-worker classifier-emit contract drives a guarded split ──────────────────
|
|
281
|
+
// The S7 stubs above (`() => ({ result: outcome })`) prove the ENGINE routes on a published fact, but a
|
|
282
|
+
// bare `{ result }` is NOT what a real `senior:*` fleet agent returns — it completes with the whole
|
|
283
|
+
// Output-contract envelope (`{ status, summary, pr, … }`) and never a bare fact. So the gap #506 closes
|
|
284
|
+
// is: (a) the node's declared `emits` must be threaded into the agent's `appendPrompt` so a real agent
|
|
285
|
+
// is TOLD to surface the fact, and (b) the fact rides that SAME envelope as an extra top-level field.
|
|
286
|
+
// This graph proves both against the real engine: the `adopt` node declares `emits: [result]` and is
|
|
287
|
+
// serviced by a worker that (1) ASSERTS the emit contract reached it via `appendPrompt` — proving the
|
|
288
|
+
// runner actually delivers the instruction, not a test stub — and (2) returns the full envelope with the
|
|
289
|
+
// fact folded in, exactly as a contract-following agent would. Both branches are driven end to end.
|
|
290
|
+
const GUARDED_ADOPT_REAL: DeliveryGraph = {
|
|
291
|
+
name: "adopt runbook (real agent)",
|
|
292
|
+
nodes: [
|
|
293
|
+
{
|
|
294
|
+
id: "adopt",
|
|
295
|
+
kind: "agent",
|
|
296
|
+
agent: { jobType: "senior:feature", prompt: "Adopt the published package into this consumer and open a PR." },
|
|
297
|
+
emits: [{ name: "result", type: "string", description: "breaking | compatible" }],
|
|
298
|
+
},
|
|
299
|
+
{ id: "migrate", kind: "agent", agent: { jobType: "senior:migrate" } },
|
|
300
|
+
{ id: "release", kind: "connector", connector: { target: "npm:publish", dedupeKey: "rel-real-1" } },
|
|
301
|
+
],
|
|
302
|
+
edges: [
|
|
303
|
+
{ from: "adopt", to: "migrate", when: "adopt.result", equals: "breaking" },
|
|
304
|
+
{ from: "adopt", to: "release", default: true },
|
|
305
|
+
{ from: "migrate", to: "release" },
|
|
306
|
+
],
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
/** Drive `GUARDED_ADOPT_REAL` with a worker that behaves like a REAL contract-following `senior:feature`
|
|
310
|
+
* agent: it reads the emit contract the runner threaded into its `appendPrompt`, then completes with the
|
|
311
|
+
* full Output-contract envelope carrying the classifier fact as a top-level field. Returns whether the
|
|
312
|
+
* contract actually reached the agent, plus which branches ran. */
|
|
313
|
+
async function driveGuardedRealAgent(outcome: "breaking" | "compatible"): Promise<{
|
|
314
|
+
state: string;
|
|
315
|
+
contractDelivered: boolean;
|
|
316
|
+
factSurfaced: boolean;
|
|
317
|
+
migrateRan: boolean;
|
|
318
|
+
releaseRan: boolean;
|
|
319
|
+
}> {
|
|
320
|
+
const engine = await createWasmEngineClient();
|
|
321
|
+
try {
|
|
322
|
+
let contractDelivered = false;
|
|
323
|
+
let factSurfaced = false;
|
|
324
|
+
let migrateRan = false;
|
|
325
|
+
let releaseRan = false;
|
|
326
|
+
|
|
327
|
+
await engine.registerWorker("senior:feature", async (job) => {
|
|
328
|
+
const appendPrompt = String((job.variables as Record<string, unknown> | undefined)?.appendPrompt ?? "");
|
|
329
|
+
// (a) The classifier emit contract MUST have reached the agent via its steering channel — this is
|
|
330
|
+
// the #506 fix (a plain `senior:feature` seed would carry no such instruction).
|
|
331
|
+
contractDelivered =
|
|
332
|
+
appendPrompt.includes("Classifier emit contract") &&
|
|
333
|
+
appendPrompt.includes("`result`") &&
|
|
334
|
+
appendPrompt.includes("AGENT_RESULT_FILE");
|
|
335
|
+
factSurfaced = appendPrompt.includes("`result`");
|
|
336
|
+
// (b) A real agent completes with the WHOLE Output-contract envelope, folding the declared fact in
|
|
337
|
+
// as an extra top-level field — NOT a bare `{ result }` stub.
|
|
338
|
+
return { status: "opened", summary: `adopt done (${outcome})`, pr: "owner/repo#900", result: outcome };
|
|
339
|
+
});
|
|
340
|
+
await engine.registerWorker("senior:migrate", async () => {
|
|
341
|
+
migrateRan = true;
|
|
342
|
+
return { status: "opened", summary: "migrated", pr: "owner/repo#901" };
|
|
343
|
+
});
|
|
344
|
+
await engine.registerWorker(DELIVERY_CONNECTOR_TASK_TYPE, async () => {
|
|
345
|
+
releaseRan = true;
|
|
346
|
+
return {};
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
const run = await runDeliveryGraph(engine, GUARDED_ADOPT_REAL);
|
|
350
|
+
assert(run.ok, `runDeliveryGraph failed: ${JSON.stringify(run)}`);
|
|
351
|
+
const key = run.handle.processInstanceKey;
|
|
352
|
+
|
|
353
|
+
let state = "?";
|
|
354
|
+
for (let round = 0; round < MAX_ROUNDS; round++) {
|
|
355
|
+
await engine.drain();
|
|
356
|
+
const [pi] = await engine.searchProcessInstances({ processInstanceKeys: [key] });
|
|
357
|
+
assert(pi, `no process instance snapshot for ${key}`);
|
|
358
|
+
state = pi.state ?? "?";
|
|
359
|
+
if (state === "COMPLETED" || state === "TERMINATED") break;
|
|
360
|
+
}
|
|
361
|
+
return { state, contractDelivered, factSurfaced, migrateRan, releaseRan };
|
|
362
|
+
} finally {
|
|
363
|
+
await engine.close();
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
test("#506 deploy+route: a REAL contract-following agent's envelope carries the classifier fact and routes the BREAKING branch through `migrate`", async () => {
|
|
368
|
+
const r = await driveGuardedRealAgent("breaking");
|
|
369
|
+
assert(r.contractDelivered, "the emit contract must reach the agent via its threaded appendPrompt (the #506 fix)");
|
|
370
|
+
assert(r.factSurfaced, "the declared fact must be named to the agent");
|
|
371
|
+
assertEquals(r.state, "COMPLETED", "the breaking branch must run to a COMPLETED instance");
|
|
372
|
+
assert(r.migrateRan, "the breaking outcome (returned inside the real Output-contract envelope) must route through `migrate`");
|
|
373
|
+
assert(r.releaseRan, "both branches must re-converge on `release`");
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test("#506 deploy+route: the SAME real agent returning `compatible` in its envelope rides the default flow, SKIPPING `migrate`", async () => {
|
|
377
|
+
const r = await driveGuardedRealAgent("compatible");
|
|
378
|
+
assert(r.contractDelivered, "the emit contract must reach the agent via its threaded appendPrompt (the #506 fix)");
|
|
379
|
+
assertEquals(r.state, "COMPLETED", "the compatible branch must run to a COMPLETED instance");
|
|
380
|
+
assert(!r.migrateRan, "the compatible outcome must NOT route through `migrate` — the envelope's `result` rides the default flow");
|
|
381
|
+
assert(r.releaseRan, "the compatible outcome still reaches `release` via the else-flow");
|
|
382
|
+
});
|
|
383
|
+
|
|
280
384
|
test("S7 deploy+route: mutually-exclusive leaves join End on an exclusive merge — the untaken leaf never blocks completion", async () => {
|
|
281
385
|
// Mode D: `adopt` routes a missing surface to an escalate (human) leaf, else to a `done` connector
|
|
282
386
|
// leaf. On the default path the escalate leaf never fires; an exclusive End merge must still let the
|
|
@@ -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> {
|
|
@@ -119,6 +119,39 @@ test("the human node seeds prompt/nodeId/emits; a click-done (no-emit, no-prompt
|
|
|
119
119
|
assertEquals(ack?.nodeId, "ack");
|
|
120
120
|
});
|
|
121
121
|
|
|
122
|
+
test("agent node classifier-emit contract (#506): a declared `emits` threads the emit instruction into appendPrompt; a no-emit node leaves it untouched", async () => {
|
|
123
|
+
// #506: a guarded split (S7) routes on a producer's emitted scalar, published from the engine
|
|
124
|
+
// variable named exactly after the fact. A real `senior:*` agent completes with the Output-contract
|
|
125
|
+
// envelope and would never return that fact unless TOLD — so an agent node that declares `emits`
|
|
126
|
+
// must carry the emit contract in its `appendPrompt` (its only steering channel), while a plain
|
|
127
|
+
// implementation node (no emits) must be byte-for-byte unchanged.
|
|
128
|
+
const graph: DeliveryGraph = {
|
|
129
|
+
name: "classifier",
|
|
130
|
+
nodes: [
|
|
131
|
+
{ id: "adopt", kind: "agent", agent: { jobType: "senior:feature", prompt: "adopt the package" }, emits: [{ name: "result", type: "string", description: "breaking | compatible" }] },
|
|
132
|
+
{ id: "plain", kind: "agent", agent: { jobType: "senior:feature", prompt: "just implement it" } },
|
|
133
|
+
],
|
|
134
|
+
edges: [{ from: "adopt.result", to: "plain", when: "adopt.result", equals: "breaking" }, { from: "adopt", to: "plain", default: true }],
|
|
135
|
+
};
|
|
136
|
+
const p = await prepareOk(graph);
|
|
137
|
+
const agents = Object.values(p.nodeInputs).filter((v) => "jobType" in v) as Array<Record<string, unknown>>;
|
|
138
|
+
const adopt = agents.find((v) => String(v.appendPrompt).startsWith("adopt the package"));
|
|
139
|
+
const plain = agents.find((v) => String(v.appendPrompt).startsWith("just implement it"));
|
|
140
|
+
|
|
141
|
+
// The emit-declaring node keeps its authored prompt AND gains the emit contract naming its fact.
|
|
142
|
+
assert(adopt, "the emit-declaring agent node must be seeded");
|
|
143
|
+
const adoptPrompt = String(adopt?.appendPrompt);
|
|
144
|
+
assert(adoptPrompt.startsWith("adopt the package"), "the authored prompt is preserved as the prefix");
|
|
145
|
+
assert(adoptPrompt.includes("Classifier emit contract"), `the emit contract must be threaded in, got: ${adoptPrompt}`);
|
|
146
|
+
assert(adoptPrompt.includes("`result`") && adoptPrompt.includes("(string)"), "the declared fact name + type must be surfaced to the agent");
|
|
147
|
+
assert(adoptPrompt.includes("breaking | compatible"), "the fact's optional description rides the contract");
|
|
148
|
+
assert(adoptPrompt.includes("AGENT_RESULT_FILE"), "the contract names the completion channel the fact rides");
|
|
149
|
+
|
|
150
|
+
// A node that declares NO facts is untouched — appendPrompt is exactly the authored prompt.
|
|
151
|
+
assertEquals(plain?.appendPrompt, "just implement it");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
|
|
122
155
|
test("wait gateKeys default to a fresh per-run token so concurrent runs of one graph never cross-correlate", async () => {
|
|
123
156
|
const gateKeyOf = (p: Awaited<ReturnType<typeof prepareOk>>) =>
|
|
124
157
|
(Object.values(p.nodeInputs).find((v) => "gateKey" in v) as { gateKey?: string } | undefined)?.gateKey;
|
package/app/deliveryRunner.ts
CHANGED
|
@@ -170,6 +170,39 @@ function rewriteProcessId(bpmn: string, processDefinitionId: string): string {
|
|
|
170
170
|
.replace(`bpmnElement="${DELIVERY_GRAPH_PROCESS_ID}"`, `bpmnElement="${processDefinitionId}"`);
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
/** Render the classifier-emit contract appended to an `agent` node's `appendPrompt` (issue #506) — the
|
|
174
|
+
* instruction that turns a declared `emits[]` into completion variables a downstream guarded split (S7)
|
|
175
|
+
* can route on. A `senior:*` fleet agent completes with the Output-contract envelope (`status`,
|
|
176
|
+
* `summary`, `pr`, …); the delivery output ioMapping instead publishes the engine variable named exactly
|
|
177
|
+
* after each fact (`factSourceVar` → `fact.name`), so the agent must ALSO return each declared fact as a
|
|
178
|
+
* TOP-LEVEL field of that same result JSON. This block tells it so, deriving entirely from the node's
|
|
179
|
+
* declared `emits` (no second source of truth). Empty for a no-emit node → the prompt is unchanged, so a
|
|
180
|
+
* plain implementation node behaves exactly as before. Deterministic: fixed wording, facts in declared
|
|
181
|
+
* order, so identical graphs still compile+seed byte-identically. */
|
|
182
|
+
export function renderEmitContract(emits: readonly DeliveryFact[]): string {
|
|
183
|
+
if (emits.length === 0) return "";
|
|
184
|
+
const facts = emits.map((f) => `- \`${f.name}\` (${f.type})${f.description ? ` — ${f.description}` : ""}`);
|
|
185
|
+
return [
|
|
186
|
+
"",
|
|
187
|
+
"",
|
|
188
|
+
"---",
|
|
189
|
+
"",
|
|
190
|
+
"## Classifier emit contract (delivery graph)",
|
|
191
|
+
"",
|
|
192
|
+
"This node is a PRODUCER in a delivery graph: a downstream **guarded split** routes on the typed",
|
|
193
|
+
"fact(s) below. In ADDITION to your normal result fields (`status`, `summary`, `pr`, …), the",
|
|
194
|
+
"structured result you write to `AGENT_RESULT_FILE` MUST include these TOP-LEVEL fields, each a",
|
|
195
|
+
"bare scalar of the declared type:",
|
|
196
|
+
"",
|
|
197
|
+
...facts,
|
|
198
|
+
"",
|
|
199
|
+
"The value you return for each fact IS the routing decision — a downstream edge fires only when the",
|
|
200
|
+
"fact equals a specific literal, otherwise the graph takes the `default` (else) branch. If you",
|
|
201
|
+
"genuinely cannot determine a fact, OMIT it (the default branch is taken) rather than guessing.",
|
|
202
|
+
].join("\n");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
|
|
173
206
|
/** Build the `nodeInputs.<element>` seed for one node, per its kind — the exact fields the compiled
|
|
174
207
|
* subProcess ioMapping pulls. Total over the closed kind set. */
|
|
175
208
|
function buildNodeInput(
|
|
@@ -177,8 +210,21 @@ function buildNodeInput(
|
|
|
177
210
|
ctx: { runKey: string; element: string; nodeTimeout: string; probeTimeout: string; probePollEvery: string; escalationSlaTimeout: string; escalationAssignee: string | null },
|
|
178
211
|
): NodeInput {
|
|
179
212
|
switch (node.kind) {
|
|
180
|
-
case "agent":
|
|
181
|
-
|
|
213
|
+
case "agent": {
|
|
214
|
+
// Classifier-emit contract (issue #506). A `senior:*` fleet agent's real completion is the
|
|
215
|
+
// Output-contract envelope (`{ status, summary, pr, … }`) — it does NOT return a bare fact, so a
|
|
216
|
+
// node's declared `emits` would never appear and a downstream GUARDED split (S7) could only ever
|
|
217
|
+
// take its `default` branch. Close the gap the same way `factSourceVar` already reads it: the
|
|
218
|
+
// output ioMapping publishes the engine variable named exactly after each fact, so the agent must
|
|
219
|
+
// return `{ <fact>: <value> }` AS A TOP-LEVEL field of its result JSON (the same channel that
|
|
220
|
+
// carries `status`/`summary`/`pr`). The agent only knows to do this if it is TOLD — so the
|
|
221
|
+
// declared emits are rendered into the node's `appendPrompt` (its sole steering channel; the
|
|
222
|
+
// delivery agent node carries no base-prompt resource), keeping `emits` the single source of
|
|
223
|
+
// truth. A no-emit node appends nothing, so a plain implementation node is unchanged.
|
|
224
|
+
const basePrompt = node.agent.prompt ?? "";
|
|
225
|
+
const emits = Array.isArray(node.emits) ? node.emits.map((f) => ({ ...f })) : [];
|
|
226
|
+
return { jobType: node.agent.jobType, appendPrompt: basePrompt + renderEmitContract(emits), timeout: isoDuration(node.agent.timeout, ctx.nodeTimeout) };
|
|
227
|
+
}
|
|
182
228
|
case "wait": {
|
|
183
229
|
const probe = parseProbe(node.wait);
|
|
184
230
|
return {
|
|
@@ -268,6 +268,32 @@ resume cannot double-fire.
|
|
|
268
268
|
> Determinism is preserved: gateway ids are positional over id-sorted nodes, so a graph with no guards
|
|
269
269
|
> compiles byte-for-byte as before.
|
|
270
270
|
|
|
271
|
+
> **Amendment (issue #506): the agent-node classifier-emit contract.** S7 (above) routes a guarded
|
|
272
|
+
> split on a producer's emitted scalar, published to `<producerElement>_<fact>` by the node's output
|
|
273
|
+
> ioMapping — which, for an `agent`/`connector` node, reads the engine variable named exactly after the
|
|
274
|
+
> fact (`factSourceVar` → `fact.name`). A REAL `senior:*` fleet agent, though, completes with its
|
|
275
|
+
> **Output-contract envelope** (`{ status, summary, pr, question, delta }`) and never a bare
|
|
276
|
+
> `{ <fact>: <value> }`, so a guarded split authored on a `senior:feature` node compiled + was
|
|
277
|
+
> deadlock-safe (the `default` else-flow fires when the fact is unset) but its non-default branch was
|
|
278
|
+
> **inert** — the migrate/escalate arm never fired. The contract that closes this:
|
|
279
|
+
> - An `agent` node's declared `emits[]` is threaded into the node's `appendPrompt` (its sole steering
|
|
280
|
+
> channel — the delivery agent node carries no base-prompt resource) as a **classifier emit contract**
|
|
281
|
+
> block (`deliveryRunner.renderEmitContract`), instructing the servicing agent to ALSO return each
|
|
282
|
+
> declared fact as a **top-level field** of its result JSON — the very same `AGENT_RESULT_FILE`
|
|
283
|
+
> channel that already carries `status`/`summary`/`pr`. The fleet harness merges that JSON into the
|
|
284
|
+
> job completion variables, so `<fact.name>` lands in scope exactly where the output ioMapping reads
|
|
285
|
+
> it. `emits` stays the single source of truth: the contract text is derived from it, never a parallel
|
|
286
|
+
> declaration.
|
|
287
|
+
> - The convention a graph author relies on: declare `emits: [{ name, type }]` on the agent node and
|
|
288
|
+
> guard the downstream edge with `when: "<node>.<name>"` + `equals: <literal>`; a contract-following
|
|
289
|
+
> agent returns `{ …, <name>: <value> }` and the split routes on it. Omitting the fact (the agent
|
|
290
|
+
> could not decide) takes the `default` branch — the deadlock-safe fallback S7 already guarantees.
|
|
291
|
+
> - A no-emit agent node appends nothing, so a plain implementation node is byte-for-byte unchanged.
|
|
292
|
+
> The `deploy+route` coverage now drives BOTH branches with a **real contract-following worker** (it
|
|
293
|
+
> asserts the emit contract reached it via `appendPrompt`, then completes with the full envelope
|
|
294
|
+
> carrying the fact), not a bare-`{ result }` stub — proving the instruction is actually delivered and
|
|
295
|
+
> the real completion shape routes.
|
|
296
|
+
|
|
271
297
|
## Open questions
|
|
272
298
|
|
|
273
299
|
- **Compiler target for the first cut** — confirm compile-to-native (diagram + native scheduling) vs a
|
package/docs/agent-guide.md
CHANGED
|
@@ -448,6 +448,23 @@ A **typed fact** (`emits[]` entry) is `{ name, type, description? }` where
|
|
|
448
448
|
downstream as `<nodeId>.<name>`. A "click done" human node or a pass-through node declares
|
|
449
449
|
no facts.
|
|
450
450
|
|
|
451
|
+
**Guarded routing + the agent classifier-emit contract.** An edge may carry a **guard** —
|
|
452
|
+
`when: "<nodeId>.<fact>"` + `equals: <scalar>` — or be the split's single `default: true`
|
|
453
|
+
else-branch (S7). A node whose out-edges are guarded is a **data-based exclusive split**: at
|
|
454
|
+
runtime exactly one branch fires, chosen by the producer's emitted fact. For an **`agent`**
|
|
455
|
+
node the fact is late-bound from the servicing job's completion: the delivery output-mapping
|
|
456
|
+
publishes the engine variable named **exactly after the fact** (e.g. a fact `result` reads the
|
|
457
|
+
completion variable `result`). A real `senior:*` fleet agent completes with its **Output
|
|
458
|
+
contract** envelope (`{ status, summary, pr, question, delta }`), so — to make a guarded split
|
|
459
|
+
fire — an agent node that declares `emits` has a **classifier emit contract** automatically
|
|
460
|
+
appended to its prompt at dispatch: the agent MUST return each declared fact as an **extra
|
|
461
|
+
top-level field of the same result JSON** (the `AGENT_RESULT_FILE` it already writes
|
|
462
|
+
`status`/`summary`/`pr` to). Author side, this means: declare `emits: [{ name, type }]` on the
|
|
463
|
+
agent node and guard the downstream edge on `<node>.<name>`; a contract-following agent returns
|
|
464
|
+
`{ …, <name>: <value> }` and the split routes on it. If the agent cannot decide the fact it
|
|
465
|
+
**omits** it, and the split takes its `default` (else) branch — the deadlock-safe fallback. A
|
|
466
|
+
node that declares no `emits` gets no contract text and behaves exactly as before.
|
|
467
|
+
|
|
451
468
|
### 9.2 The agent loop: draft → compile → stage → ask an operator to dispatch
|
|
452
469
|
|
|
453
470
|
```
|
package/openapi.yaml
CHANGED
|
@@ -1392,7 +1392,17 @@ components:
|
|
|
1392
1392
|
properties:
|
|
1393
1393
|
id: { type: string }
|
|
1394
1394
|
kind: { type: string, enum: [agent] }
|
|
1395
|
-
emits:
|
|
1395
|
+
emits:
|
|
1396
|
+
type: array
|
|
1397
|
+
items: { $ref: "#/components/schemas/DeliveryFact" }
|
|
1398
|
+
description: >-
|
|
1399
|
+
The typed facts this agent node hands forward (issue #506 — the classifier-emit
|
|
1400
|
+
contract). Each declared fact is appended to the node's dispatch prompt as an
|
|
1401
|
+
instruction the servicing `senior:*` agent MUST honour: return the fact as an extra
|
|
1402
|
+
TOP-LEVEL field of its result JSON (the same `AGENT_RESULT_FILE` envelope that carries
|
|
1403
|
+
`status`/`summary`/`pr`). The delivery output-mapping publishes that completion variable
|
|
1404
|
+
(named exactly after the fact) so a downstream guarded edge (`when: "<node>.<fact>"` +
|
|
1405
|
+
`equals`) routes on it; an omitted fact takes the split's `default` branch.
|
|
1396
1406
|
agent:
|
|
1397
1407
|
type: object
|
|
1398
1408
|
additionalProperties: false
|
|
@@ -1673,6 +1683,62 @@ components:
|
|
|
1673
1683
|
The compiled BPMN 2.0 XML INCLUDING diagram interchange (`bpmndi:BPMNDiagram`), recompiled
|
|
1674
1684
|
deterministically from the staged graph — byte-identical to what a dispatch would deploy.
|
|
1675
1685
|
Rendered read-only in the host explorer's definition preview. Nothing is deployed.
|
|
1686
|
+
StagedProposalSummary:
|
|
1687
|
+
description: >-
|
|
1688
|
+
One LIVE staged delivery-graph proposal (issue #511) — the metadata the staged App-View renders
|
|
1689
|
+
as a Preview-DI + Dispatch row. A projection of the durable `delivery_graph_proposals` row; the
|
|
1690
|
+
`graph`/`preview` payloads are omitted (the App-View recompiles by `digest` for the DI preview).
|
|
1691
|
+
type: object
|
|
1692
|
+
additionalProperties: false
|
|
1693
|
+
required:
|
|
1694
|
+
- digest
|
|
1695
|
+
- title
|
|
1696
|
+
- nodeCount
|
|
1697
|
+
- humanNodeCount
|
|
1698
|
+
- sideEffectCount
|
|
1699
|
+
- sideEffecting
|
|
1700
|
+
- createdAt
|
|
1701
|
+
- expiresAt
|
|
1702
|
+
properties:
|
|
1703
|
+
digest:
|
|
1704
|
+
type: string
|
|
1705
|
+
description: The proposal's content digest — the handle the Preview-DI and Dispatch doors take.
|
|
1706
|
+
title:
|
|
1707
|
+
type: string
|
|
1708
|
+
nullable: true
|
|
1709
|
+
description: The graph's name, when it carried one.
|
|
1710
|
+
nodeCount:
|
|
1711
|
+
type: integer
|
|
1712
|
+
description: Total nodes in the compiled graph.
|
|
1713
|
+
humanNodeCount:
|
|
1714
|
+
type: integer
|
|
1715
|
+
description: How many nodes park on a person.
|
|
1716
|
+
sideEffectCount:
|
|
1717
|
+
type: integer
|
|
1718
|
+
description: How many nodes perform a side effect (merge/publish) once dispatched.
|
|
1719
|
+
sideEffecting:
|
|
1720
|
+
type: boolean
|
|
1721
|
+
description: True when the graph has any side-effecting node — dispatching it authorises those actions.
|
|
1722
|
+
createdAt:
|
|
1723
|
+
type: string
|
|
1724
|
+
description: When the proposal was staged (ISO-8601).
|
|
1725
|
+
expiresAt:
|
|
1726
|
+
type: string
|
|
1727
|
+
description: When the proposal ages out of its TTL if never dispatched (ISO-8601).
|
|
1728
|
+
StagedProposalList:
|
|
1729
|
+
description: The live staged delivery-graph proposals awaiting dispatch (issue #511), newest first.
|
|
1730
|
+
type: object
|
|
1731
|
+
additionalProperties: false
|
|
1732
|
+
required:
|
|
1733
|
+
- count
|
|
1734
|
+
- proposals
|
|
1735
|
+
properties:
|
|
1736
|
+
count:
|
|
1737
|
+
type: integer
|
|
1738
|
+
proposals:
|
|
1739
|
+
type: array
|
|
1740
|
+
items:
|
|
1741
|
+
$ref: "#/components/schemas/StagedProposalSummary"
|
|
1676
1742
|
DeliveryGraphTextResult:
|
|
1677
1743
|
description: >-
|
|
1678
1744
|
The delivery-graph text-ingress outcome (issue #460) — a single shape covering the JSON-paste
|
|
@@ -2927,6 +2993,32 @@ paths:
|
|
|
2927
2993
|
application/json:
|
|
2928
2994
|
schema:
|
|
2929
2995
|
$ref: "#/components/schemas/DeliveryGraphProposalBpmnResult"
|
|
2996
|
+
/delivery-graph/staged:
|
|
2997
|
+
get:
|
|
2998
|
+
operationId: listStagedProposals
|
|
2999
|
+
summary: List the LIVE staged delivery-graph proposals awaiting dispatch (issue #511), newest first.
|
|
3000
|
+
description: >-
|
|
3001
|
+
The read behind the staged-proposals App-View: every `staged` delivery-graph proposal that has
|
|
3002
|
+
not aged out of its TTL, newest first, projected to the Preview-DI + Dispatch metadata (the
|
|
3003
|
+
`graph`/`preview` payloads are omitted — the App-View recompiles by `digest` for the DI preview).
|
|
3004
|
+
Mirrors the `previewProposalBpmn`/`dispatchDeliveryGraph` freshness guard so an expired-but-not-
|
|
3005
|
+
yet-swept row is never listed. Read-only.
|
|
3006
|
+
security:
|
|
3007
|
+
- hookSecret: []
|
|
3008
|
+
- {}
|
|
3009
|
+
responses:
|
|
3010
|
+
"200":
|
|
3011
|
+
description: The live staged proposals.
|
|
3012
|
+
content:
|
|
3013
|
+
application/json:
|
|
3014
|
+
schema:
|
|
3015
|
+
$ref: "#/components/schemas/StagedProposalList"
|
|
3016
|
+
"401":
|
|
3017
|
+
description: Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).
|
|
3018
|
+
content:
|
|
3019
|
+
application/json:
|
|
3020
|
+
schema:
|
|
3021
|
+
$ref: "#/components/schemas/ErrorBody"
|
|
2930
3022
|
/actions/start/feature:
|
|
2931
3023
|
post:
|
|
2932
3024
|
operationId: startFeature
|
|
@@ -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.
|
|
3
|
+
"version": "0.136.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",
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
},
|
|
60
60
|
"dependencies": {
|
|
61
61
|
"@nanobpm/agentic": "^0.4.0",
|
|
62
|
-
"@nanobpm/urban": "^0.
|
|
62
|
+
"@nanobpm/urban": "^0.82.0",
|
|
63
63
|
"bpmn-auto-layout": "^2.0.0-alpha.2"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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 & 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": "
|
|
88
|
+
"type": "appView",
|
|
89
89
|
"id": "delivery-graphs-staged",
|
|
90
90
|
"props": {
|
|
91
91
|
"title": "Staged proposals",
|
|
92
|
-
"
|
|
93
|
-
"
|
|
94
|
-
"
|
|
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
|
|
88
|
-
//
|
|
89
|
-
//
|
|
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
|
|
93
|
-
assert(staged?.
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
+
});
|