@nanobpm/nano-workforce 0.134.0 → 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 +6 -0
- package/app/deliveryGraphProposals.ts +16 -0
- package/openapi.yaml +82 -0
- 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,9 @@
|
|
|
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
|
+
|
|
1
7
|
## [0.134.0](https://github.com/nanobpm/nano-workforce/compare/v0.133.1...v0.134.0) (2026-08-24)
|
|
2
8
|
|
|
3
9
|
### Features
|
|
@@ -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> {
|
package/openapi.yaml
CHANGED
|
@@ -1673,6 +1673,62 @@ components:
|
|
|
1673
1673
|
The compiled BPMN 2.0 XML INCLUDING diagram interchange (`bpmndi:BPMNDiagram`), recompiled
|
|
1674
1674
|
deterministically from the staged graph — byte-identical to what a dispatch would deploy.
|
|
1675
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"
|
|
1676
1732
|
DeliveryGraphTextResult:
|
|
1677
1733
|
description: >-
|
|
1678
1734
|
The delivery-graph text-ingress outcome (issue #460) — a single shape covering the JSON-paste
|
|
@@ -2927,6 +2983,32 @@ paths:
|
|
|
2927
2983
|
application/json:
|
|
2928
2984
|
schema:
|
|
2929
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"
|
|
2930
3012
|
/actions/start/feature:
|
|
2931
3013
|
post:
|
|
2932
3014
|
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.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
|
+
});
|