@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 +12 -0
- package/app/deliveryGraphDispatch.ts +12 -2
- package/app/deliveryGraphProposals.ts +16 -0
- package/app/deliveryRunner.test.ts +87 -0
- package/app/deliveryRunner.ts +11 -7
- package/app/reviewWait.ts +8 -0
- package/openapi.yaml +121 -0
- package/operations/dispatchDeliveryGraph.test.ts +65 -0
- package/operations/dispatchDeliveryGraph.ts +52 -1
- package/operations/listStagedProposals.test.ts +159 -0
- package/operations/listStagedProposals.ts +37 -0
- package/package.json +1 -1
- 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,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
|
-
|
|
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");
|
package/app/deliveryRunner.ts
CHANGED
|
@@ -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: "
|
|
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
|
|
115
|
-
probeTimeout: options.probeTimeout
|
|
116
|
-
probePollEvery: options.probePollEvery
|
|
117
|
-
escalationSlaTimeout: options.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.
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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
|
+
});
|