@nanobpm/nano-workforce 0.138.2 → 0.139.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/app/contracts.ts +16 -0
  3. package/app/deliveryGraph.test.ts +84 -0
  4. package/app/deliveryGraph.ts +79 -0
  5. package/app/deliveryGraphCompiler.ts +4 -2
  6. package/app/deliveryGraphLibrary.test.ts +134 -0
  7. package/app/deliveryGraphLibrary.ts +153 -0
  8. package/app/deliveryGraphProposals.test.ts +136 -0
  9. package/app/deliveryGraphProposals.ts +54 -6
  10. package/app/deliveryGraphShape.test.ts +66 -0
  11. package/app/deliveryGraphShape.ts +67 -0
  12. package/app/deliveryGraphTextIngress.test.ts +137 -0
  13. package/app/deliveryGraphTextIngress.ts +73 -3
  14. package/app/planReadModel.test.ts +23 -0
  15. package/db/migrations/084_plan_wave_tasks_effective_status.sql +51 -0
  16. package/db/migrations/085_delivery_graph_library.sql +32 -0
  17. package/openapi.yaml +366 -0
  18. package/operations/deleteLibraryEntry.test.ts +88 -0
  19. package/operations/deleteLibraryEntry.ts +23 -0
  20. package/operations/dismissProposal.test.ts +105 -0
  21. package/operations/dismissProposal.ts +53 -0
  22. package/operations/getLibraryEntry.test.ts +75 -0
  23. package/operations/getLibraryEntry.ts +25 -0
  24. package/operations/importToLibrary.test.ts +195 -0
  25. package/operations/importToLibrary.ts +62 -0
  26. package/operations/listLibrary.test.ts +79 -0
  27. package/operations/listLibrary.ts +24 -0
  28. package/operations/saveToLibrary.test.ts +225 -0
  29. package/operations/saveToLibrary.ts +89 -0
  30. package/package.json +1 -1
  31. package/pages/delivery-graphs/delivery-graphs.css +33 -0
  32. package/pages/delivery-graphs/embed.html +1 -0
  33. package/pages/delivery-graphs/library-embed.html +31 -0
  34. package/pages/delivery-graphs/library-standalone.html +38 -0
  35. package/pages/delivery-graphs/library.mount.js +364 -0
  36. package/pages/delivery-graphs/mount.js +133 -4
  37. package/pages/delivery-graphs/staged.mount.js +109 -4
  38. package/pages/delivery-graphs/standalone.html +2 -1
  39. package/pages/delivery-graphs.page.json +24 -1
  40. package/scripts/pages-contract.test.ts +50 -0
  41. package/test/delivery-graphs-import.test.ts +92 -0
  42. package/test/delivery-graphs-library-embed.test.ts +148 -0
  43. package/test/delivery-graphs-library-export.test.ts +62 -0
  44. package/test/delivery-graphs-staged-embed.test.ts +9 -0
@@ -22,9 +22,16 @@
22
22
  const DEFAULT_STAGED_URL = "app/api/delivery-graph/staged";
23
23
  // The operator dispatch door: POST { digest } → launches the staged graph engine-natively (#460).
24
24
  const DEFAULT_DISPATCH_URL = "app/api/actions/delivery-graph/dispatch";
25
+ // The operator dismiss door: POST { digest } → discards a staged proposal as noise, flipping it to the
26
+ // terminal `dismissed` status so it drops off the staged list (#520). Launches nothing.
27
+ const DEFAULT_DISMISS_URL = "app/api/actions/delivery-graph/dismiss";
25
28
  // The read-only DI preview door: recompiles a staged proposal's BPMN (with diagram interchange) so its
26
29
  // generated diagram can be rendered in the host explorer BEFORE dispatch. No deploy, no dispatch.
27
30
  const DEFAULT_PROPOSAL_BPMN_URL = "app/api/actions/delivery-graph/proposal-bpmn";
31
+ // The save-to-library door: POST { name, digest } → copies this staged proposal's already-stored graph
32
+ // into the reusable library (issue #523, save-from-digest → source `from-staged`). Persists a library
33
+ // entry; it never dispatches or re-stages, so the #460 operator boundary holds.
34
+ const DEFAULT_SAVE_LIBRARY_URL = "app/api/actions/delivery-graph/library/save";
28
35
 
29
36
  // How often the list re-polls the read door so a freshly-staged (or just-dispatched) proposal appears
30
37
  // (or drops off) without a manual refresh — mirrors the 5s cadence the old declarative grid used.
@@ -42,6 +49,13 @@ const DISPATCH_CONFIRM =
42
49
  "node (it merges PRs / publishes packages) will run. Clicking Dispatch IS the approval, " +
43
50
  "content-addressed to exactly the graph you previewed.";
44
51
 
52
+ // The confirm shown before a dismiss — dismissing is a terminal discard: the proposal drops off the
53
+ // staged list for good (it can be re-staged only by recompiling). It launches nothing, so this is a
54
+ // lighter acknowledgement than Dispatch, but still a one-way action the operator confirms.
55
+ const DISMISS_CONFIRM =
56
+ "Dismiss this staged delivery graph? It is discarded as noise and drops off the staged list — this " +
57
+ "launches nothing, but to bring it back you must recompile/re-stage it.";
58
+
45
59
  /** Escape untrusted strings before they touch innerHTML. */
46
60
  function esc(value) {
47
61
  return String(value ?? "").replace(
@@ -76,6 +90,8 @@ function renderProposal(p) {
76
90
  <div class="actions">
77
91
  <button class="btn btn-ghost" type="button" data-preview-di="${esc(p.digest)}">Preview generated DI</button>
78
92
  <button class="btn btn-primary" type="button" data-dispatch="${esc(p.digest)}">Dispatch</button>
93
+ <button class="btn btn-ghost" type="button" data-save-library="${esc(p.digest)}" data-title="${esc(p.title ?? "")}">Save to library</button>
94
+ <button class="btn btn-ghost" type="button" data-dismiss="${esc(p.digest)}">Dismiss</button>
79
95
  </div>
80
96
  </section>`;
81
97
  }
@@ -95,10 +111,24 @@ function renderList(proposals) {
95
111
  return header + proposals.map(renderProposal).join("");
96
112
  }
97
113
 
114
+ // Only attach the guard secret when the resolved door URL is SAME-ORIGIN. The staged/dispatch/dismiss/
115
+ // proposal-bpmn/save-library door URLs can be overridden (e.g. via the standalone `?staged=` /
116
+ // `?dispatch=` / `?proposal-bpmn=` query params) to a full `https://…` URL on a foreign origin; sending
117
+ // `x-hook-secret` there would exfiltrate the shared guard secret to an arbitrary host. A cross-origin
118
+ // (or unparseable, or non-browser) target therefore gets no secret.
119
+ function isSameOrigin(url) {
120
+ try {
121
+ if (typeof window === "undefined" || !window.location) return false;
122
+ return new URL(url, window.location.href).origin === window.location.origin;
123
+ } catch (_e) {
124
+ return false;
125
+ }
126
+ }
127
+
98
128
  /**
99
129
  * Mount the staged-proposals list into `host`.
100
130
  * @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]
131
+ * @param {{stagedUrl?:string, dispatchUrl?:string, dismissUrl?:string, proposalBpmnUrl?:string, saveLibraryUrl?:string, hookSecret?:string, refreshMs?:number}} [config]
102
132
  */
103
133
  export function mountStagedProposals(host, config = {}) {
104
134
  const isElement = host != null && host.nodeType === 1 && typeof host.innerHTML === "string";
@@ -107,11 +137,13 @@ export function mountStagedProposals(host, config = {}) {
107
137
 
108
138
  const stagedUrl = config.stagedUrl ?? DEFAULT_STAGED_URL;
109
139
  const dispatchUrl = config.dispatchUrl ?? DEFAULT_DISPATCH_URL;
140
+ const dismissUrl = config.dismissUrl ?? DEFAULT_DISMISS_URL;
110
141
  const proposalBpmnUrl = config.proposalBpmnUrl ?? DEFAULT_PROPOSAL_BPMN_URL;
142
+ const saveLibraryUrl = config.saveLibraryUrl ?? DEFAULT_SAVE_LIBRARY_URL;
111
143
  const refreshMs = typeof config.refreshMs === "number" && config.refreshMs > 0 ? config.refreshMs : DEFAULT_REFRESH_MS;
112
- const headers = () => ({
144
+ const headers = (url) => ({
113
145
  "content-type": "application/json",
114
- ...(config.hookSecret ? { "x-hook-secret": config.hookSecret } : {}),
146
+ ...(config.hookSecret && isSameOrigin(url) ? { "x-hook-secret": config.hookSecret } : {}),
115
147
  });
116
148
 
117
149
  root.innerHTML = `<div class="dg">
@@ -149,7 +181,7 @@ export function mountStagedProposals(host, config = {}) {
149
181
  const controller = new AbortController();
150
182
  const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
151
183
  try {
152
- const res = await fetch(url, { ...init, headers: headers(), signal: controller.signal });
184
+ const res = await fetch(url, { ...init, headers: headers(url), signal: controller.signal });
153
185
  let body = {};
154
186
  try {
155
187
  body = await res.json();
@@ -254,6 +286,67 @@ export function mountStagedProposals(host, config = {}) {
254
286
  }
255
287
  }
256
288
 
289
+ // "Dismiss": the operator's discard (#520). Confirm (dismiss is a one-way drop off the staged list),
290
+ // then POST the digest to the dismiss door; on success the proposal flips to `dismissed` and drops off
291
+ // the list on the next poll — refresh immediately so the operator sees it leave. Launches nothing.
292
+ async function doDismiss(digest) {
293
+ const staged = typeof digest === "string" ? digest.trim() : "";
294
+ if (staged === "") return;
295
+ if (typeof window !== "undefined" && typeof window.confirm === "function" && !window.confirm(DISMISS_CONFIRM)) {
296
+ return;
297
+ }
298
+ busy(true);
299
+ setStatus("Dismissing…");
300
+ try {
301
+ const { status, body } = await post(dismissUrl, { digest: staged });
302
+ if ((status === 200 || status === 202) && body.ok) {
303
+ setStatus("\u2713 Dismissed — the proposal is off the staged list.", "ok");
304
+ await refresh();
305
+ } else {
306
+ setStatus(body && body.error ? body.error : "Dismiss failed.", "err");
307
+ }
308
+ } catch (err) {
309
+ setStatus(err && err.message ? err.message : "Dismiss request failed.", "err");
310
+ } finally {
311
+ busy(false);
312
+ }
313
+ }
314
+
315
+ // "Save to library": copy this staged proposal's already-stored graph into the reusable library
316
+ // (issue #523, save-from-digest → source `from-staged`). Prompt the operator for the entry name
317
+ // (defaulting to the proposal title — its slug/short-hash derive the library id, so re-saving the
318
+ // same name upserts), then POST { name, digest } to the save door. This persists a library entry
319
+ // only — it never dispatches or re-stages, so the operator boundary the staged view enforces (#460)
320
+ // is untouched.
321
+ async function doSaveToLibrary(digest, defaultName) {
322
+ const staged = typeof digest === "string" ? digest.trim() : "";
323
+ if (staged === "") return;
324
+ let name = defaultName ? String(defaultName) : "";
325
+ if (typeof window !== "undefined" && typeof window.prompt === "function") {
326
+ const entered = window.prompt("Save to library as (name):", name);
327
+ if (entered === null) return; // operator cancelled
328
+ name = entered;
329
+ }
330
+ if (name.trim() === "") {
331
+ setStatus("A library entry needs a non-blank name.", "err");
332
+ return;
333
+ }
334
+ busy(true);
335
+ setStatus("Saving to library…");
336
+ try {
337
+ const { status, body } = await post(saveLibraryUrl, { name: name.trim(), digest: staged });
338
+ if (status === 200 && body.ok) {
339
+ setStatus("\u2713 Saved to the library \u2014 reuse it from the Library view.", "ok");
340
+ } else {
341
+ setStatus(body && body.error ? body.error : "Save to library failed.", "err");
342
+ }
343
+ } catch (err) {
344
+ setStatus(err && err.message ? err.message : "Save-to-library request failed.", "err");
345
+ } finally {
346
+ busy(false);
347
+ }
348
+ }
349
+
257
350
  listEl.addEventListener("click", (ev) => {
258
351
  const previewBtn = ev.target && ev.target.closest ? ev.target.closest("[data-preview-di]") : null;
259
352
  if (previewBtn) {
@@ -265,6 +358,18 @@ export function mountStagedProposals(host, config = {}) {
265
358
  if (dispatchBtn) {
266
359
  ev.preventDefault();
267
360
  doDispatch(dispatchBtn.getAttribute("data-dispatch"));
361
+ return;
362
+ }
363
+ const saveLibraryBtn = ev.target && ev.target.closest ? ev.target.closest("[data-save-library]") : null;
364
+ if (saveLibraryBtn) {
365
+ ev.preventDefault();
366
+ doSaveToLibrary(saveLibraryBtn.getAttribute("data-save-library"), saveLibraryBtn.getAttribute("data-title"));
367
+ return;
368
+ }
369
+ const dismissBtn = ev.target && ev.target.closest ? ev.target.closest("[data-dismiss]") : null;
370
+ if (dismissBtn) {
371
+ ev.preventDefault();
372
+ doDismiss(dismissBtn.getAttribute("data-dismiss"));
268
373
  }
269
374
  });
270
375
 
@@ -13,7 +13,7 @@
13
13
  <!--
14
14
  Standalone shell (phone / direct link). Loads the SAME ./mount.js the console App-View embed uses,
15
15
  so the standalone and embedded views render identically. Endpoints default to the current origin;
16
- override the preview/dispatch endpoints via ?preview= / ?dispatch=. For a secured deployment, pass
16
+ override the preview/stage/import endpoints via ?preview= / ?stage= / ?import=. For a secured deployment, pass
17
17
  the guard secret via the URL fragment #secret= (sent as x-hook-secret) — NOT the query string, so
18
18
  it never leaks via server access logs, browser history, or the Referer header. The fragment is
19
19
  stripped from the address bar immediately after it is read.
@@ -31,6 +31,7 @@
31
31
  mountDeliveryGraphs(document.getElementById("delivery-graphs-root"), {
32
32
  previewUrl: params.get("preview") ?? undefined,
33
33
  stageUrl: params.get("stage") ?? undefined,
34
+ importUrl: params.get("import") ?? undefined,
34
35
  hookSecret,
35
36
  });
36
37
  </script>
@@ -94,6 +94,16 @@
94
94
  "fill": true
95
95
  }
96
96
  },
97
+ {
98
+ "type": "appView",
99
+ "id": "delivery-graphs-library",
100
+ "props": {
101
+ "title": "Library",
102
+ "embed": "./delivery-graphs/library-embed.html",
103
+ "standalone": "./delivery-graphs/library-standalone.html",
104
+ "fill": true
105
+ }
106
+ },
97
107
  {
98
108
  "type": "dataGrid",
99
109
  "id": "delivery-graphs-inflight",
@@ -120,10 +130,12 @@
120
130
  "columns": [
121
131
  { "field": "title", "template": "{{title}}", "header": "Graph", "subtitleField": "run_key", "truncate": true, "width": "28%", "link": { "kind": "page", "page": "delivery-graph-detail", "keyField": "run_key" } },
122
132
  { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
133
+ { "field": "process_key", "header": "Instance", "width": "9rem", "truncate": true, "link": { "kind": "processExplorer", "keyField": "process_key" } },
123
134
  { "field": "phase", "header": "Phase", "truncate": true, "width": "26%" },
124
135
  { "field": "node_count", "header": "Nodes" },
125
136
  { "field": "human_node_count", "header": "Human" },
126
137
  { "field": "side_effect_count", "header": "Side effects" },
138
+ { "field": "created_at", "header": "Dispatched", "width": "9rem", "format": "datetime" },
127
139
  { "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
128
140
  ],
129
141
  "detail": {
@@ -132,7 +144,18 @@
132
144
  { "field": "digest", "label": "Digest" },
133
145
  { "field": "phase_node_id", "label": "Parked node" }
134
146
  ]
135
- }
147
+ },
148
+ "rowActions": [
149
+ {
150
+ "label": "Save to library",
151
+ "confirm": "Save this dispatched delivery graph to the reusable library? Its stored graph is copied into the Library (source: from-dispatched) so it can be reused later.",
152
+ "showWhenField": "title",
153
+ "action": {
154
+ "path": "/app/api/actions/delivery-graph/library/save",
155
+ "body": { "name": "{{row.title}}", "digest": "{{row.digest}}" }
156
+ }
157
+ }
158
+ ]
136
159
  }
137
160
  }
138
161
  ]
@@ -407,3 +407,53 @@ test("issue #386: the human-facing Delivery Graphs surface is wired (nav tab, pa
407
407
  "overview delivery-graphs section must link its item to delivery-graph-detail by run_key",
408
408
  );
409
409
  });
410
+
411
+ test("issue #521: the Delivery Graphs History tab surfaces dispatch time + the instance key", async () => {
412
+ // The in-flight grid's History tab (`delivery-graphs-inflight`) is where a completed/failed run is
413
+ // reviewed after the fact. It must surface WHEN the run was dispatched (`created_at`, stamped at
414
+ // dispatch) and its engine instance key (`process_key`) as a first-class, Explorer-linked cell —
415
+ // not just the `updated_at` last-touch. Grid columns are shared across the In-flight/History/All
416
+ // tabs (the renderer has no per-tab column override — tabs carry only `label`+`filter`), so pinning
417
+ // the columns on the grid that owns the History tab is what surfaces them on History.
418
+ const page = JSON.parse(readFileSync(`${ROOT}pages/delivery-graphs.page.json`, "utf8"));
419
+ const grid = (page.nodes ?? []).find(
420
+ (n: Json) => n.type === "dataGrid" && n.id === "delivery-graphs-inflight",
421
+ );
422
+ assert(grid, "delivery-graphs page must have the `delivery-graphs-inflight` grid");
423
+
424
+ // The History tab must exist (this is the tab whose columns we are pinning).
425
+ const history = (grid.props?.tabs ?? []).find((t: Json) => t.label === "History");
426
+ assert(history, "the delivery-graphs-inflight grid must have a History tab");
427
+
428
+ const columns: Json[] = grid.props?.columns ?? [];
429
+
430
+ // Dispatched: the dispatch time, formatted as a datetime, distinct from the `updated_at` "Updated".
431
+ const dispatched = columns.find((c: Json) => c.field === "created_at");
432
+ assert(dispatched, "History tab must expose a `created_at` column (dispatch time)");
433
+ assert(
434
+ dispatched.header === "Dispatched",
435
+ "the `created_at` column must be headed \"Dispatched\"",
436
+ );
437
+ assert(
438
+ dispatched.format === "datetime",
439
+ "the `created_at` (Dispatched) column must be formatted as a datetime",
440
+ );
441
+
442
+ // Instance: an explicit cell carrying `process_key`, deep-linked to the Explorer via the same
443
+ // `processExplorer` link kind used by the Status column, keyed on `process_key`.
444
+ const instance = columns.find(
445
+ (c: Json) => c.field === "process_key" && c.link?.kind === "processExplorer",
446
+ );
447
+ assert(
448
+ instance,
449
+ "History tab must expose an explicit Instance cell on `process_key` with a processExplorer link",
450
+ );
451
+ assert(
452
+ instance.header === "Instance",
453
+ "the `process_key` cell must be headed \"Instance\"",
454
+ );
455
+ assert(
456
+ instance.link?.keyField === "process_key",
457
+ "the Instance cell's processExplorer link must key on `process_key`",
458
+ );
459
+ });
@@ -0,0 +1,92 @@
1
+ // Contract guard for the filesystem IMPORT wiring on the Delivery Graphs compose App-View (issue #524,
2
+ // epic #519 S5). The Import control is ADDED into the PRE-EXISTING compose mount (pages/delivery-graphs/
3
+ // mount.js — the one #523 reshaped), alongside #523's inbound reuse-fill seam. It must: render an
4
+ // `<input type=file accept=.json>`, read the picked file's text client-side, POST it to the
5
+ // importToLibrary door (base-relative, App-View #279 resolution class), route a successful import back
6
+ // through #523's SINGLE `fillComposer()` seam (no second inbound fill path), and render path-qualified
7
+ // compile errors inline on a 400. This test pins that wiring so it can't silently regress.
8
+ import { test } from "node:test";
9
+ import { assert } from "#test-assert";
10
+ import { readFileSync } from "node:fs";
11
+
12
+ const ROOT = decodeURIComponent(new URL("../", import.meta.url).pathname);
13
+ const DIR = `${ROOT}pages/delivery-graphs`;
14
+ const MOUNT_JS = readFileSync(`${DIR}/mount.js`, "utf8");
15
+ const CSS = readFileSync(`${DIR}/delivery-graphs.css`, "utf8");
16
+ const EMBED_HTML = readFileSync(`${DIR}/embed.html`, "utf8");
17
+ const STANDALONE_HTML = readFileSync(`${DIR}/standalone.html`, "utf8");
18
+
19
+ // Pull the string default out of `const <name> = config.<name> ?? <CONST>;` (a module const).
20
+ function defaultUrl(name: string): string {
21
+ const m = MOUNT_JS.match(new RegExp(`${name}\\s*=\\s*config\\.\\w+\\s*\\?\\?\\s*(\\w+);`));
22
+ assert(m, `mount.js must default ${name} from config with a fallback constant`);
23
+ const constM = MOUNT_JS.match(new RegExp(`const ${m![1]}\\s*=\\s*"([^"]*)"`));
24
+ assert(constM, `mount.js must declare the ${m![1]} fallback as a string literal`);
25
+ return constM![1];
26
+ }
27
+
28
+ test("#524: the compose mount renders a file-input Import control accepting .json", () => {
29
+ assert(/id="dg-import"/.test(MOUNT_JS), "mount.js must render an Import file input with id=dg-import");
30
+ assert(/type="file"/.test(MOUNT_JS), "the Import control must be an <input type=file>");
31
+ assert(/accept="[^"]*\.json[^"]*"/.test(MOUNT_JS), "the Import file input must accept .json files");
32
+ });
33
+
34
+ test("#524: Import wires the importToLibrary door (base-relative), reading the file text client-side", () => {
35
+ const importUrl = defaultUrl("importUrl");
36
+ assert(
37
+ importUrl.endsWith("actions/delivery-graph/library/import"),
38
+ `importUrl default "${importUrl}" must hit the importToLibrary door`,
39
+ );
40
+ assert(!importUrl.startsWith("/"), `default importUrl "${importUrl}" must be base-relative (App-View #279 resolution class)`);
41
+ // The file's text is read CLIENT-SIDE and POSTed as graphJson to the import door.
42
+ assert(/\.text\(\)/.test(MOUNT_JS), "mount.js must read the selected file's text client-side via File.text()");
43
+ assert(/post\(importUrl,\s*\{\s*graphJson:/.test(MOUNT_JS), "the Import handler must POST the file text as graphJson to the import door");
44
+ // The <input> must actually be WIRED to the handler: without a change listener that invokes
45
+ // importFile(), the door + handler could stay intact while Import is inert (every other assertion
46
+ // here still green). Pin the change→importFile wiring so removing the listener fails the suite.
47
+ assert(
48
+ /addEventListener\(\s*["']change["'][\s\S]{0,200}?importFile\(/.test(MOUNT_JS),
49
+ "the Import file input's change listener must invoke importFile() so picking a file triggers an import",
50
+ );
51
+ });
52
+
53
+ test("#524: a successful import routes through #523's single fillComposer() seam", () => {
54
+ // #523 owns the inbound fill seam; #524 builds on it rather than adding a second inbound fill path.
55
+ assert(/function fillComposer\(/.test(MOUNT_JS), "the #523 fillComposer() seam must still be present");
56
+ const importHandler = MOUNT_JS.slice(MOUNT_JS.indexOf("async function importFile"));
57
+ assert(importHandler.length > 0, "mount.js must define the importFile handler");
58
+ assert(/fillComposer\(text\b/.test(importHandler), "a successful import must route the imported text through the fillComposer() seam");
59
+ });
60
+
61
+ test("#524: an import failure renders the door's path-qualified compile errors inline", () => {
62
+ const importHandler = MOUNT_JS.slice(MOUNT_JS.indexOf("async function importFile"));
63
+ assert(
64
+ /renderErrors\(body\.error,\s*body\.errors\)/.test(importHandler),
65
+ "an import 400 must render the door's path-qualified errors inline",
66
+ );
67
+ });
68
+
69
+ test("#524: both shells forward importUrl into the mount (embed via cfg, standalone via ?import=)", () => {
70
+ // The mount resolves `config.importUrl ?? DEFAULT_IMPORT_URL`, so the default-URL guard above passes
71
+ // even if a shell drops the `importUrl` forwarding entirely — silently breaking embedded custom
72
+ // deployments and the standalone `?import=` override. Pin BOTH forwardings, exactly as the shells
73
+ // already forward previewUrl/stageUrl.
74
+ assert(
75
+ /importUrl:\s*cfg\.importUrl/.test(EMBED_HTML),
76
+ "embed.html must forward the console-injected importUrl (importUrl: cfg.importUrl) into the mount",
77
+ );
78
+ assert(
79
+ /importUrl:\s*params\.get\("import"\)/.test(STANDALONE_HTML),
80
+ "standalone.html must forward the ?import= override (importUrl: params.get(\"import\")) into the mount",
81
+ );
82
+ // mount.js must actually read config.importUrl (not hard-code the default), so the forwarding matters.
83
+ assert(
84
+ /const importUrl\s*=\s*config\.importUrl\s*\?\?/.test(MOUNT_JS),
85
+ "mount.js must resolve importUrl from config.importUrl with a fallback default",
86
+ );
87
+ });
88
+
89
+ test("#524: the native file input is visually hidden and the label reads as a button", () => {
90
+ assert(/\.dg-import-input/.test(CSS), "the CSS must style the Import file input");
91
+ assert(/clip:\s*rect\(0,\s*0,\s*0,\s*0\)/.test(CSS), "the native file input must be visually hidden (the label is the button)");
92
+ });
@@ -0,0 +1,148 @@
1
+ // Contract guard for the reusable delivery-graph LIBRARY App-View (issue #523, epic #519 S4).
2
+ //
3
+ // The Library surface (pages/delivery-graphs/library.*) LISTS saved library entries (the `listLibrary`
4
+ // door) and, per row, offers Reuse (load the saved graph back into the compose textarea `#dg-json`) and
5
+ // Delete (the `deleteLibraryEntry` door). Reuse crosses the App-View iframe boundary: it drives the
6
+ // compose mount's NEW inbound fill seam over the shared `deliveryGraph.compose.fill` host-bridge
7
+ // message. Plus a Save-to-library affordance on the staged App-View (save-from-digest) and a
8
+ // Save-to-library row action on the dispatched/history grid. This test pins that wiring so it cannot
9
+ // silently regress: the sidecars mount the same module, the door defaults are base-relative (the #279
10
+ // App-View resolution class), Reuse posts the ONE shared fill message, Delete hits the per-entry door,
11
+ // the compose mount adds the inbound fill listener, and the page carries the new Library App-View node.
12
+ import { test } from "node:test";
13
+ import { assert, assertEquals, assertStringIncludes } from "#test-assert";
14
+ import { readFileSync } from "node:fs";
15
+ import { DG_COMPOSE_FILL_MESSAGE } from "../pages/delivery-graphs/mount.js";
16
+
17
+ const ROOT = decodeURIComponent(new URL("../", import.meta.url).pathname);
18
+ const DIR = `${ROOT}pages/delivery-graphs`;
19
+ const LIBRARY_JS = readFileSync(`${DIR}/library.mount.js`, "utf8");
20
+ const COMPOSE_JS = readFileSync(`${DIR}/mount.js`, "utf8");
21
+ const STAGED_JS = readFileSync(`${DIR}/staged.mount.js`, "utf8");
22
+ const EMBED_HTML = readFileSync(`${DIR}/library-embed.html`, "utf8");
23
+ const STANDALONE_HTML = readFileSync(`${DIR}/library-standalone.html`, "utf8");
24
+ const PAGE_JSON = readFileSync(`${ROOT}pages/delivery-graphs.page.json`, "utf8");
25
+
26
+ // Pull the string default out of `const <name> = config.<field> ?? <CONST>;` (or a module const).
27
+ function defaultUrl(src: string, name: string): string {
28
+ const m = src.match(new RegExp(`${name}\\s*=\\s*config\\.\\w+\\s*\\?\\?\\s*(\\w+);`));
29
+ assert(m, `mount must default ${name} from config with a fallback constant`);
30
+ const constM = src.match(new RegExp(`const ${m![1]}\\s*=\\s*"([^"]*)"`));
31
+ assert(constM, `mount must declare the ${m![1]} fallback as a string literal`);
32
+ return constM![1];
33
+ }
34
+
35
+ test("#523: the Library App-View mounts the same module standalone and embedded", () => {
36
+ assert(/mountDeliveryGraphLibrary/.test(LIBRARY_JS), "library.mount.js must export mountDeliveryGraphLibrary");
37
+ for (const [file, html] of [["library-embed.html", EMBED_HTML], ["library-standalone.html", STANDALONE_HTML]] as const) {
38
+ assert(
39
+ /import \{ mountDeliveryGraphLibrary \} from "\.\/library\.mount\.js"/.test(html),
40
+ `${file} must import mountDeliveryGraphLibrary from ./library.mount.js`,
41
+ );
42
+ assert(/mountDeliveryGraphLibrary\(/.test(html), `${file} must call mountDeliveryGraphLibrary`);
43
+ }
44
+ });
45
+
46
+ test("#523: the page binds the Library node to the library App-View sidecars", () => {
47
+ const page = JSON.parse(PAGE_JSON) as { nodes: Array<Record<string, any>> };
48
+ const library = page.nodes.find((n) => n.id === "delivery-graphs-library");
49
+ assert(library, "the page must carry the delivery-graphs-library node");
50
+ assert(library?.type === "appView", "delivery-graphs-library must be an appView (#523)");
51
+ assert(library?.props?.embed === "./delivery-graphs/library-embed.html", "it embeds the library embed sidecar");
52
+ assert(library?.props?.standalone === "./delivery-graphs/library-standalone.html", "it has the library standalone sidecar");
53
+ });
54
+
55
+ test("#523/#279: the Library list door default is base-relative and hits the listLibrary door", () => {
56
+ const url = defaultUrl(LIBRARY_JS, "libraryUrl");
57
+ assert(url.endsWith("delivery-graph/library"), `libraryUrl default "${url}" must hit the listLibrary door`);
58
+ assert(!url.startsWith("/"), `default libraryUrl "${url}" must be base-relative (App-View #279 resolution class)`);
59
+ // The list read consumes the door's `entries` array (one row per saved entry).
60
+ assert(/body\.entries/.test(LIBRARY_JS), "library.mount.js must render one row per `entries[]` item the listLibrary door returns");
61
+ });
62
+
63
+ test("#523: Reuse drives the compose fill seam over the shared host-bridge message", () => {
64
+ // Reuse loads the saved graph back into the SEPARATE compose App-View, so it posts the ONE shared
65
+ // fill message (its type imported from ./mount.js, never re-declared) UP over the App-View boundary.
66
+ assert(
67
+ /import \{ DG_COMPOSE_FILL_MESSAGE \} from "\.\/mount\.js"/.test(LIBRARY_JS),
68
+ "library.mount.js must import DG_COMPOSE_FILL_MESSAGE from ./mount.js (the ONE source of truth for the fill message type)",
69
+ );
70
+ assert(/data-reuse=/.test(LIBRARY_JS), "library.mount.js must render a per-row Reuse affordance carrying the entry id");
71
+ assert(/postMessage\(/.test(LIBRARY_JS), "Reuse must post the fill message across the App-View boundary");
72
+ assert(/type:\s*DG_COMPOSE_FILL_MESSAGE/.test(LIBRARY_JS), "the Reuse message must carry the shared DG_COMPOSE_FILL_MESSAGE type");
73
+ assert(/graphJson:\s*entry\.graph/.test(LIBRARY_JS), "the Reuse message must carry the saved entry's graph JSON");
74
+ });
75
+
76
+ test("#523: Delete hits the per-entry deleteLibraryEntry door", () => {
77
+ assert(/data-delete=/.test(LIBRARY_JS), "library.mount.js must render a per-row Delete affordance carrying the entry id");
78
+ // The delete door is the per-entry path under the list door: DELETE .../delivery-graph/library/<id>.
79
+ assert(/method:\s*"DELETE"/.test(LIBRARY_JS), "Delete must issue an HTTP DELETE to the deleteLibraryEntry door");
80
+ assert(/encodeURIComponent\(/.test(LIBRARY_JS), "the delete path must URL-encode the entry id it appends to the library door");
81
+ });
82
+
83
+ test("#523: the compose mount exposes an INBOUND reuse-fill seam (message → #dg-json)", () => {
84
+ // The compose mount previously had NO inbound prefill — its #dg-json was set only by Load-example /
85
+ // typing. S4 adds a same-origin message listener that fills #dg-json through a single fillComposer seam.
86
+ assert(/export const DG_COMPOSE_FILL_MESSAGE\s*=/.test(COMPOSE_JS), "mount.js must export the DG_COMPOSE_FILL_MESSAGE fill-message type");
87
+ // Pin the actual wire value the imported const carries: this is the cross-App-View host-bridge
88
+ // message type the Library/compose sidecars agree on, so a silent change to the string is a
89
+ // breaking wire-contract change and the compose mount must declare exactly that literal.
90
+ assertEquals(DG_COMPOSE_FILL_MESSAGE, "nano-delivery-graph-compose-fill", "the shared fill-message type must be the pinned wire-contract string");
91
+ assertStringIncludes(COMPOSE_JS, `"${DG_COMPOSE_FILL_MESSAGE}"`, "mount.js must declare the fill-message type as the pinned string literal");
92
+ assert(/function fillComposer\(/.test(COMPOSE_JS), "mount.js must define the single fillComposer seam every fill routes through");
93
+ assert(/addEventListener\("message"/.test(COMPOSE_JS), "mount.js must register an inbound `message` listener for the fill seam");
94
+ assert(/data\.type !== DG_COMPOSE_FILL_MESSAGE/.test(COMPOSE_JS), "the listener must gate on the shared fill-message type");
95
+ assert(/jsonEl\.value = graphJson/.test(COMPOSE_JS), "fillComposer must load the graph JSON into the #dg-json textarea");
96
+ // Same-origin guard: a foreign origin must not be able to drive the fill.
97
+ assert(/ev\.origin !== window\.location\.origin/.test(COMPOSE_JS), "the fill listener must reject cross-origin messages");
98
+ });
99
+
100
+ test("#523: Save-to-library on the staged App-View posts save-from-digest", () => {
101
+ const url = defaultUrl(STAGED_JS, "saveLibraryUrl");
102
+ assert(url.endsWith("actions/delivery-graph/library/save"), `saveLibraryUrl default "${url}" must hit the saveToLibrary door`);
103
+ assert(!url.startsWith("/"), `default saveLibraryUrl "${url}" must be base-relative (App-View #279 resolution class)`);
104
+ assert(/data-save-library=/.test(STAGED_JS), "staged.mount.js must render a per-row Save-to-library affordance carrying the digest");
105
+ // Save-from-digest: it posts { name, digest } — it must NOT compile or stage a raw graph (the #460
106
+ // operator boundary the staged view enforces stays intact).
107
+ assert(/post\(saveLibraryUrl,\s*\{\s*name:[^}]*digest:/.test(STAGED_JS), "Save-to-library must POST { name, digest } (save-from-digest) to the save door");
108
+ });
109
+
110
+ test("#523: Save-to-library is offered on a dispatched/history grid row (save-from-dispatched)", () => {
111
+ const page = JSON.parse(PAGE_JSON) as { nodes: Array<Record<string, any>> };
112
+ const grid = page.nodes.find((n) => n.id === "delivery-graphs-inflight");
113
+ assert(grid, "the page must carry the delivery-graphs-inflight grid");
114
+ const action = (grid?.props?.rowActions ?? []).find((a: Record<string, any>) => a.label === "Save to library");
115
+ assert(action, "the in-flight grid must offer a `Save to library` row action");
116
+ assert(
117
+ action?.action?.path === "/app/api/actions/delivery-graph/library/save",
118
+ "the Save-to-library row action must post to the saveToLibrary door",
119
+ );
120
+ assert(
121
+ action?.action?.body?.digest === "{{row.digest}}" && action?.action?.body?.name === "{{row.title}}",
122
+ "the Save-to-library row action must save-from-digest, naming the entry from the run title",
123
+ );
124
+ });
125
+ test("#523: the x-hook-secret guard is gated on a same-origin door (no cross-origin secret exfil)", () => {
126
+ // A `?library=` / `?staged=` / `?preview=` override can point a door at a full `https://…` URL on a
127
+ // FOREIGN origin. The shared guard secret must NEVER ride along to an arbitrary host, so each mount
128
+ // attaches `x-hook-secret` only when the resolved door URL is same-origin.
129
+ for (const [name, src] of [
130
+ ["library.mount.js", LIBRARY_JS],
131
+ ["mount.js", COMPOSE_JS],
132
+ ["staged.mount.js", STAGED_JS],
133
+ ] as const) {
134
+ assert(/function isSameOrigin\(url\)/.test(src), `${name} must define an isSameOrigin(url) guard`);
135
+ assert(
136
+ /new URL\(url,\s*window\.location\.href\)\.origin === window\.location\.origin/.test(src),
137
+ `${name} isSameOrigin must compare the resolved URL origin against window.location.origin`,
138
+ );
139
+ assert(
140
+ /config\.hookSecret && isSameOrigin\(url\)\s*\?\s*\{\s*"x-hook-secret"/.test(src),
141
+ `${name} must attach x-hook-secret ONLY when hookSecret is set AND the door URL is same-origin`,
142
+ );
143
+ assert(
144
+ /headers:\s*headers\(url\)/.test(src),
145
+ `${name} must thread the request URL into headers(url) so the secret gate sees the target origin`,
146
+ );
147
+ }
148
+ });
@@ -0,0 +1,62 @@
1
+ // Contract guard for the delivery-graph LIBRARY Export affordance (issue #525, epic #519 S6).
2
+ //
3
+ // Export is a purely client-side Blob download of a saved library entry's stored graph JSON as
4
+ // `<name>.deliverygraph.json` — no backend door. It builds directly on the S4 Library App-View (#523,
5
+ // `pages/delivery-graphs/library.mount.js`): each rendered library entry gains an Export button
6
+ // alongside Reuse/Delete. This test pins BOTH halves so the affordance cannot silently regress:
7
+ // • a behavioural guard on the pure, DOM-free `buildDeliveryGraphExport()` helper — it "builds a
8
+ // download from the entry's graph JSON": the download contents are the entry's stored graph
9
+ // verbatim and the filename is the sanitised name + `.deliverygraph.json`; and
10
+ // • a source guard that the mount renders the per-row Export control and assembles the Blob download
11
+ // (createObjectURL → anchor.download → revokeObjectURL) from that helper.
12
+ import { test } from "node:test";
13
+ import { assert, assertEquals } from "#test-assert";
14
+ import { readFileSync } from "node:fs";
15
+ import { buildDeliveryGraphExport, DELIVERY_GRAPH_EXPORT_SUFFIX } from "../pages/delivery-graphs/library.mount.js";
16
+
17
+ const ROOT = decodeURIComponent(new URL("../", import.meta.url).pathname);
18
+ const LIBRARY_JS = readFileSync(`${ROOT}pages/delivery-graphs/library.mount.js`, "utf8");
19
+
20
+ test("#525: the export filename suffix is the pinned .deliverygraph.json contract", () => {
21
+ assertEquals(DELIVERY_GRAPH_EXPORT_SUFFIX, ".deliverygraph.json");
22
+ });
23
+
24
+ test("#525: buildDeliveryGraphExport downloads the entry's stored graph JSON verbatim", () => {
25
+ const graph = '{"nodes":[{"id":"a"}],"edges":[]}';
26
+ const out = buildDeliveryGraphExport({ id: "lib-1", name: "Onboarding", graph });
27
+ // "builds a download from the entry's graph JSON": bytes are the stored graph, unmodified.
28
+ assertEquals(out.contents, graph, "export contents must be the entry's stored graph JSON, byte-for-byte");
29
+ assertEquals(out.mime, "application/json", "an exported delivery graph is application/json");
30
+ assertEquals(out.filename, `Onboarding${DELIVERY_GRAPH_EXPORT_SUFFIX}`, "filename is <name>.deliverygraph.json");
31
+ });
32
+
33
+ test("#525: the export filename sanitises unsafe characters in the entry name", () => {
34
+ const out = buildDeliveryGraphExport({ id: "lib-2", name: "My Graph / v2: final!", graph: "{}" });
35
+ assertEquals(out.filename, `My-Graph-v2-final${DELIVERY_GRAPH_EXPORT_SUFFIX}`);
36
+ // No path separators, and never a leading-dot hidden file.
37
+ assert(!out.filename.includes("/") && !out.filename.includes("\\"), "filename must carry no path separators");
38
+ assert(!out.filename.startsWith("."), "filename must not be a hidden dotfile");
39
+ });
40
+
41
+ test("#525: an unnamed entry falls back to a stable id-derived filename", () => {
42
+ const named = buildDeliveryGraphExport({ id: "lib-3", name: " ", graph: "{}" });
43
+ assertEquals(named.filename, `delivery-graph-lib-3${DELIVERY_GRAPH_EXPORT_SUFFIX}`, "blank name falls back to the entry id");
44
+ const anon = buildDeliveryGraphExport({ graph: "{}" });
45
+ assertEquals(anon.filename, `delivery-graph${DELIVERY_GRAPH_EXPORT_SUFFIX}`, "no name and no id falls back to a constant");
46
+ });
47
+
48
+ test("#525: an entry with no stored graph yields empty contents (nothing to download)", () => {
49
+ assertEquals(buildDeliveryGraphExport({ id: "x", name: "n" }).contents, "");
50
+ assertEquals(buildDeliveryGraphExport({ id: "x", name: "n", graph: 42 as unknown as string }).contents, "");
51
+ });
52
+
53
+ test("#525: the Library mount renders a per-row Export control wired to a Blob download", () => {
54
+ assert(/data-export=/.test(LIBRARY_JS), "library.mount.js must render a per-row Export affordance carrying the entry id");
55
+ assert(/closest\("\[data-export\]"\)/.test(LIBRARY_JS), "the list click handler must route the Export button");
56
+ assert(/buildDeliveryGraphExport\(entry\)/.test(LIBRARY_JS), "Export must assemble the download from buildDeliveryGraphExport(entry)");
57
+ // The actual client-side download mechanics: Blob → object URL → anchor download → revoke.
58
+ assert(/new Blob\(\[contents\]/.test(LIBRARY_JS), "Export must build a Blob from the entry's graph contents");
59
+ assert(/URL\.createObjectURL\(/.test(LIBRARY_JS), "Export must create an object URL for the download");
60
+ assert(/\.download = filename/.test(LIBRARY_JS), "Export must set the anchor download filename");
61
+ assert(/URL\.revokeObjectURL\(/.test(LIBRARY_JS), "Export must revoke the object URL after triggering the download");
62
+ });
@@ -75,3 +75,12 @@ test("#460/#511: Dispatch is the operator's launch — posts the digest to the d
75
75
  assert(!/graphJson/.test(MOUNT_JS), "staged.mount.js must NOT submit pasted graph JSON (it only lists+dispatches staged proposals)");
76
76
  assert(!/approvalToken/.test(MOUNT_JS), "staged.mount.js must NOT carry the removed replayable approvalToken");
77
77
  });
78
+
79
+ test("#520: Dismiss is the operator's discard — posts the digest to the dismiss door, behind a confirm, and launches nothing", () => {
80
+ const url = defaultUrl("dismissUrl");
81
+ assert(url.endsWith("actions/delivery-graph/dismiss"), `dismissUrl default "${url}" must hit the dismissProposal door`);
82
+ assert(!url.startsWith("/"), `default dismissUrl "${url}" must be base-relative (App-View #279 resolution class)`);
83
+ assert(/data-dismiss=/.test(MOUNT_JS), "staged.mount.js must render a per-row Dismiss affordance carrying the digest");
84
+ // Dismiss is a one-way discard off the staged list — confirm before it drops the proposal.
85
+ assert(/window\.confirm\(DISMISS_CONFIRM\)/.test(MOUNT_JS), "staged.mount.js must confirm before dismissing (a one-way discard off the staged list)");
86
+ });