@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.
- package/CHANGELOG.md +12 -0
- package/app/contracts.ts +16 -0
- package/app/deliveryGraph.test.ts +84 -0
- package/app/deliveryGraph.ts +79 -0
- package/app/deliveryGraphCompiler.ts +4 -2
- package/app/deliveryGraphLibrary.test.ts +134 -0
- package/app/deliveryGraphLibrary.ts +153 -0
- package/app/deliveryGraphProposals.test.ts +136 -0
- package/app/deliveryGraphProposals.ts +54 -6
- package/app/deliveryGraphShape.test.ts +66 -0
- package/app/deliveryGraphShape.ts +67 -0
- package/app/deliveryGraphTextIngress.test.ts +137 -0
- package/app/deliveryGraphTextIngress.ts +73 -3
- package/app/planReadModel.test.ts +23 -0
- package/db/migrations/084_plan_wave_tasks_effective_status.sql +51 -0
- package/db/migrations/085_delivery_graph_library.sql +32 -0
- package/openapi.yaml +366 -0
- package/operations/deleteLibraryEntry.test.ts +88 -0
- package/operations/deleteLibraryEntry.ts +23 -0
- package/operations/dismissProposal.test.ts +105 -0
- package/operations/dismissProposal.ts +53 -0
- package/operations/getLibraryEntry.test.ts +75 -0
- package/operations/getLibraryEntry.ts +25 -0
- package/operations/importToLibrary.test.ts +195 -0
- package/operations/importToLibrary.ts +62 -0
- package/operations/listLibrary.test.ts +79 -0
- package/operations/listLibrary.ts +24 -0
- package/operations/saveToLibrary.test.ts +225 -0
- package/operations/saveToLibrary.ts +89 -0
- package/package.json +1 -1
- package/pages/delivery-graphs/delivery-graphs.css +33 -0
- package/pages/delivery-graphs/embed.html +1 -0
- package/pages/delivery-graphs/library-embed.html +31 -0
- package/pages/delivery-graphs/library-standalone.html +38 -0
- package/pages/delivery-graphs/library.mount.js +364 -0
- package/pages/delivery-graphs/mount.js +133 -4
- package/pages/delivery-graphs/staged.mount.js +109 -4
- package/pages/delivery-graphs/standalone.html +2 -1
- package/pages/delivery-graphs.page.json +24 -1
- package/scripts/pages-contract.test.ts +50 -0
- package/test/delivery-graphs-import.test.ts +92 -0
- package/test/delivery-graphs-library-embed.test.ts +148 -0
- package/test/delivery-graphs-library-export.test.ts +62 -0
- package/test/delivery-graphs-staged-embed.test.ts +9 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
// pages/delivery-graphs/library.mount.js — the reusable delivery-graph LIBRARY App-View (ADR 0005,
|
|
2
|
+
// issue #523, epic #519 S4). The OPERATOR surface over the S3 library backend (#522): it LISTS every
|
|
3
|
+
// saved library entry (the `listLibrary` door) and, per row, offers
|
|
4
|
+
// • Reuse — load the saved graph JSON back into the COMPOSE textarea (`#dg-json`) so it can be
|
|
5
|
+
// edited / re-previewed / re-staged. The compose view is a SEPARATE App-View iframe, so Reuse
|
|
6
|
+
// drives the compose mount's inbound fill seam over the host bridge: it posts the shared
|
|
7
|
+
// `deliveryGraph.compose.fill` message (its `type` imported from ./mount.js as the ONE source of
|
|
8
|
+
// truth) UP to the console, which routes it to the compose App-View, which fills `#dg-json`.
|
|
9
|
+
// • Delete — remove the entry via the `deleteLibraryEntry` door (idempotent).
|
|
10
|
+
//
|
|
11
|
+
// A self-contained, dependency-free renderer in the SAME shape as the compose view (./mount.js) and
|
|
12
|
+
// the staged view (./staged.mount.js): the SAME module mounts embedded in the console (App View) and
|
|
13
|
+
// standalone — only the host element and injected endpoint config differ. The app has no browser build
|
|
14
|
+
// step, so this consumes the library doors straight off the wire.
|
|
15
|
+
|
|
16
|
+
import { DG_COMPOSE_FILL_MESSAGE } from "./mount.js";
|
|
17
|
+
|
|
18
|
+
// The read behind the list: every saved library entry, newest first (base-relative — a leading-slash
|
|
19
|
+
// path resolves against the console iframe ORIGIN, not the app-view base, and 404s the door, #279).
|
|
20
|
+
const DEFAULT_LIBRARY_URL = "app/api/delivery-graph/library";
|
|
21
|
+
|
|
22
|
+
// How often the list re-polls so a freshly-saved (or just-deleted) entry appears (or drops off)
|
|
23
|
+
// without a manual refresh — mirrors the 5s cadence the staged list uses.
|
|
24
|
+
const DEFAULT_REFRESH_MS = 5000;
|
|
25
|
+
|
|
26
|
+
// A bounded timeout for every door request. Without it a hung door leaves the fetch promise pending
|
|
27
|
+
// forever, so the busy() lock never clears and the UI is stranded; on timeout the AbortController
|
|
28
|
+
// rejects the fetch, surfacing as an error banner and re-enabling the controls via the finally blocks.
|
|
29
|
+
const REQUEST_TIMEOUT_MS = 30000;
|
|
30
|
+
|
|
31
|
+
// The confirm shown before a delete — removing a library entry is a one-way drop (it can only be
|
|
32
|
+
// brought back by re-saving), so the operator acknowledges it.
|
|
33
|
+
const DELETE_CONFIRM = "Delete this saved library entry? It is removed from the library — to bring it back you must save it again.";
|
|
34
|
+
|
|
35
|
+
// The filename suffix for an exported delivery graph (issue #525, epic #519 S6). Export is a purely
|
|
36
|
+
// client-side Blob download of the entry's stored graph JSON — no backend door — so the graph a peer
|
|
37
|
+
// deployment (or a sibling library) later re-imports is byte-identical to what was saved.
|
|
38
|
+
export const DELIVERY_GRAPH_EXPORT_SUFFIX = ".deliverygraph.json";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build the client-side download descriptor for exporting a library entry's graph JSON (issue #525).
|
|
42
|
+
* Pure and DOM-free so it is unit-testable in isolation from the mount: it returns the exact
|
|
43
|
+
* { filename, mime, contents } the Export Blob download is assembled from.
|
|
44
|
+
* • `contents` is the entry's STORED graph JSON verbatim (never re-serialised, so a round-trip
|
|
45
|
+
* export→import can't drift the bytes); an entry with no stored graph yields "".
|
|
46
|
+
* • `filename` is the entry name sanitised to a safe basename with the `.deliverygraph.json` suffix,
|
|
47
|
+
* falling back to the entry id (then a constant) when the name is empty or all-unsafe characters.
|
|
48
|
+
* @param {{name?:string, id?:string, graph?:string}} entry
|
|
49
|
+
* @returns {{filename:string, mime:string, contents:string}}
|
|
50
|
+
*/
|
|
51
|
+
export function buildDeliveryGraphExport(entry) {
|
|
52
|
+
const contents = entry && typeof entry.graph === "string" ? entry.graph : "";
|
|
53
|
+
const name = String(entry && entry.name != null ? entry.name : "").trim();
|
|
54
|
+
const id = String(entry && entry.id != null ? entry.id : "").trim();
|
|
55
|
+
// Collapse any run of filesystem-unsafe characters to a single hyphen, then trim leading/trailing
|
|
56
|
+
// separators so we never emit a hidden dotfile or a name with dangling punctuation.
|
|
57
|
+
const safe = name.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^[-.]+|[-.]+$/g, "");
|
|
58
|
+
const stem = safe || (id ? `delivery-graph-${id}` : "delivery-graph");
|
|
59
|
+
return { filename: `${stem}${DELIVERY_GRAPH_EXPORT_SUFFIX}`, mime: "application/json", contents };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Escape untrusted strings before they touch innerHTML. */
|
|
63
|
+
function esc(value) {
|
|
64
|
+
return String(value ?? "").replace(
|
|
65
|
+
/[&<>"']/g,
|
|
66
|
+
(ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ch],
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Format an ISO timestamp for the operator, falling back to the raw value if unparseable. */
|
|
71
|
+
function fmtTime(iso) {
|
|
72
|
+
const t = Date.parse(iso);
|
|
73
|
+
if (Number.isNaN(t)) return esc(iso);
|
|
74
|
+
return esc(new Date(t).toLocaleString());
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A small source pill (composed / imported / from-staged / from-dispatched). */
|
|
78
|
+
function sourcePill(source) {
|
|
79
|
+
const s = String(source ?? "").toLowerCase();
|
|
80
|
+
return `<span class="pill pill-${s === "imported" ? "wait" : s === "composed" ? "agent" : "connector"}">${esc(s || "\u2014")}</span>`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Render one saved library entry as a card row with Reuse + Delete actions. */
|
|
84
|
+
function renderEntry(entry) {
|
|
85
|
+
const name = entry.name ? `<code>${esc(entry.name)}</code>` : '<span class="muted">(unnamed)</span>';
|
|
86
|
+
const note = entry.description ? `<p class="muted">${esc(entry.description)}</p>` : "";
|
|
87
|
+
return `<section class="card">
|
|
88
|
+
<h2>${name} ${sourcePill(entry.source)}</h2>
|
|
89
|
+
${note}
|
|
90
|
+
<div class="chips">
|
|
91
|
+
<span class="chip">Saved <b>${fmtTime(entry.createdAt)}</b></span>
|
|
92
|
+
<span class="chip">Updated <b>${fmtTime(entry.updatedAt)}</b></span>
|
|
93
|
+
<span class="chip">Id <code>${esc(entry.id)}</code></span>
|
|
94
|
+
</div>
|
|
95
|
+
<div class="actions">
|
|
96
|
+
<button class="btn btn-primary" type="button" data-reuse="${esc(entry.id)}">Reuse</button>
|
|
97
|
+
<button class="btn btn-ghost" type="button" data-export="${esc(entry.id)}">Export</button>
|
|
98
|
+
<button class="btn btn-ghost" type="button" data-delete="${esc(entry.id)}">Delete</button>
|
|
99
|
+
</div>
|
|
100
|
+
</section>`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Render the whole list (or the empty state). */
|
|
104
|
+
function renderList(entries) {
|
|
105
|
+
if (!Array.isArray(entries) || entries.length === 0) {
|
|
106
|
+
return `<section class="card">
|
|
107
|
+
<h2>Library <span class="count">0</span></h2>
|
|
108
|
+
<p class="muted">No saved delivery graphs yet. Save one from the compose or staged views, then Reuse it here.</p>
|
|
109
|
+
</section>`;
|
|
110
|
+
}
|
|
111
|
+
const header = `<section class="card card-ok">
|
|
112
|
+
<h2>Library <span class="count">${entries.length}</span></h2>
|
|
113
|
+
<p class="ok">Saved, reusable delivery graphs. <b>Reuse</b> loads one back into the composer above to edit / re-stage; <b>Export</b> downloads its graph JSON; <b>Delete</b> removes it.</p>
|
|
114
|
+
</section>`;
|
|
115
|
+
return header + entries.map(renderEntry).join("");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Only attach the guard secret when the resolved door URL is SAME-ORIGIN. `libraryUrl` can be
|
|
119
|
+
// overridden (e.g. via the standalone `?library=` query param) to a full `https://…` URL on a foreign
|
|
120
|
+
// origin; sending `x-hook-secret` there would exfiltrate the shared guard secret to an arbitrary host.
|
|
121
|
+
// A cross-origin (or unparseable, or non-browser) target therefore gets no secret.
|
|
122
|
+
function isSameOrigin(url) {
|
|
123
|
+
try {
|
|
124
|
+
if (typeof window === "undefined" || !window.location) return false;
|
|
125
|
+
return new URL(url, window.location.href).origin === window.location.origin;
|
|
126
|
+
} catch (_e) {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Mount the library list into `host`.
|
|
133
|
+
* @param {Element|null} host — the element to render into (or null → look up #delivery-graphs-library-root).
|
|
134
|
+
* @param {{libraryUrl?:string, hookSecret?:string, refreshMs?:number}} [config]
|
|
135
|
+
*/
|
|
136
|
+
export function mountDeliveryGraphLibrary(host, config = {}) {
|
|
137
|
+
const isElement = host != null && host.nodeType === 1 && typeof host.innerHTML === "string";
|
|
138
|
+
const root = isElement ? host : document.getElementById("delivery-graphs-library-root");
|
|
139
|
+
if (!root) return () => {};
|
|
140
|
+
|
|
141
|
+
const libraryUrl = config.libraryUrl ?? DEFAULT_LIBRARY_URL;
|
|
142
|
+
const refreshMs = typeof config.refreshMs === "number" && config.refreshMs > 0 ? config.refreshMs : DEFAULT_REFRESH_MS;
|
|
143
|
+
const headers = (url) => ({
|
|
144
|
+
"content-type": "application/json",
|
|
145
|
+
...(config.hookSecret && isSameOrigin(url) ? { "x-hook-secret": config.hookSecret } : {}),
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// The delete door is the per-entry path under the list door: DELETE app/api/delivery-graph/library/<id>.
|
|
149
|
+
// Append the id to the path only, preserving any query/hash on the configured door so a
|
|
150
|
+
// `?library=` override carrying a search string or fragment still resolves the per-entry URL.
|
|
151
|
+
// Kept base-relative (no `new URL`) to preserve the App-View resolution class (#279).
|
|
152
|
+
const entryUrl = (id) => {
|
|
153
|
+
const [beforeHash, hash = ""] = libraryUrl.split("#");
|
|
154
|
+
const [path, search = ""] = beforeHash.split("?");
|
|
155
|
+
return `${path.replace(/\/+$/, "")}/${encodeURIComponent(id)}${search ? `?${search}` : ""}${hash ? `#${hash}` : ""}`;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
root.innerHTML = `<div class="dg">
|
|
159
|
+
<div class="actions">
|
|
160
|
+
<span id="dg-library-status" class="status"></span>
|
|
161
|
+
</div>
|
|
162
|
+
<div id="dg-library-list"></div>
|
|
163
|
+
</div>`;
|
|
164
|
+
|
|
165
|
+
const statusEl = root.querySelector("#dg-library-status");
|
|
166
|
+
const listEl = root.querySelector("#dg-library-list");
|
|
167
|
+
|
|
168
|
+
function setStatus(text, tone) {
|
|
169
|
+
statusEl.textContent = text || "";
|
|
170
|
+
statusEl.className = "status" + (tone ? " status-" + tone : "");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// The last loaded entries, kept so Reuse/Delete can resolve an entry (its full `graph` JSON) by id
|
|
174
|
+
// without a second fetch — the list door carries `graph` inline for exactly this.
|
|
175
|
+
let entries = [];
|
|
176
|
+
|
|
177
|
+
let busyCount = 0;
|
|
178
|
+
// A re-render (renderList → new buttons) resets every button to enabled, so the disabled state is
|
|
179
|
+
// derived from busyCount and re-applied after each render and on every busy() transition — a poll
|
|
180
|
+
// can't silently re-enable the buttons while a Reuse/Delete request is in flight.
|
|
181
|
+
function applyDisabled() {
|
|
182
|
+
const disabled = busyCount > 0;
|
|
183
|
+
for (const btn of listEl.querySelectorAll("button")) btn.disabled = disabled;
|
|
184
|
+
}
|
|
185
|
+
function busy(on) {
|
|
186
|
+
busyCount += on ? 1 : -1;
|
|
187
|
+
applyDisabled();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Fetch JSON from a door and return { status, body } (never throws on an HTTP error). Rejects
|
|
191
|
+
* (AbortError) if the request outlives REQUEST_TIMEOUT_MS so a hung door can't wedge the busy lock. */
|
|
192
|
+
async function request(url, init) {
|
|
193
|
+
const controller = new AbortController();
|
|
194
|
+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
195
|
+
try {
|
|
196
|
+
const res = await fetch(url, { ...init, headers: headers(url), signal: controller.signal });
|
|
197
|
+
let body = {};
|
|
198
|
+
try {
|
|
199
|
+
body = await res.json();
|
|
200
|
+
} catch (_e) {
|
|
201
|
+
body = {};
|
|
202
|
+
}
|
|
203
|
+
return { status: res.status, body };
|
|
204
|
+
} finally {
|
|
205
|
+
clearTimeout(timer);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const get = (url) => request(url, { method: "GET" });
|
|
210
|
+
const del = (url) => request(url, { method: "DELETE" });
|
|
211
|
+
|
|
212
|
+
let disposed = false;
|
|
213
|
+
// True while the last completed load failed — so a subsequent successful load clears its own stale
|
|
214
|
+
// error banner WITHOUT clobbering a transient action toast (Reuse/Delete ok/err message).
|
|
215
|
+
let loadErrorShown = false;
|
|
216
|
+
|
|
217
|
+
async function refresh() {
|
|
218
|
+
try {
|
|
219
|
+
const { status, body } = await get(libraryUrl);
|
|
220
|
+
if (disposed) return;
|
|
221
|
+
if (status === 200 && Array.isArray(body.entries)) {
|
|
222
|
+
entries = body.entries;
|
|
223
|
+
listEl.innerHTML = renderList(entries);
|
|
224
|
+
applyDisabled();
|
|
225
|
+
if (loadErrorShown) {
|
|
226
|
+
setStatus("");
|
|
227
|
+
loadErrorShown = false;
|
|
228
|
+
}
|
|
229
|
+
} else {
|
|
230
|
+
entries = [];
|
|
231
|
+
listEl.innerHTML = renderList([]);
|
|
232
|
+
applyDisabled();
|
|
233
|
+
setStatus(body && body.error ? body.error : "Could not load the library.", "err");
|
|
234
|
+
loadErrorShown = true;
|
|
235
|
+
}
|
|
236
|
+
} catch (err) {
|
|
237
|
+
if (disposed) return;
|
|
238
|
+
setStatus(err && err.message ? err.message : "Library request failed.", "err");
|
|
239
|
+
loadErrorShown = true;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// "Reuse": load a saved graph back into the compose textarea (`#dg-json`). The compose view is a
|
|
244
|
+
// SEPARATE App-View iframe, so we can't touch its DOM directly — we drive its inbound fill seam over
|
|
245
|
+
// the host bridge, posting the shared `deliveryGraph.compose.fill` message UP to the console (the
|
|
246
|
+
// INBOUND twin of the outbound `nano-navigate` DI-preview bridge). Standalone (not embedded) there is
|
|
247
|
+
// no console to route it and no compose view to fill, so we say so instead of failing silently.
|
|
248
|
+
const isEmbedded = typeof window !== "undefined" && window.parent && window.parent !== window;
|
|
249
|
+
function doReuse(id) {
|
|
250
|
+
const entry = entries.find((e) => e && e.id === id);
|
|
251
|
+
if (!entry) {
|
|
252
|
+
setStatus("That entry is no longer in the library \u2014 refresh and try again.", "err");
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (typeof entry.graph !== "string" || entry.graph.trim() === "") {
|
|
256
|
+
setStatus("That entry has no stored graph to reuse.", "err");
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (!isEmbedded) {
|
|
260
|
+
setStatus("Open this page inside the console to reuse a saved graph in the composer.", "err");
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
window.parent.postMessage(
|
|
264
|
+
{ type: DG_COMPOSE_FILL_MESSAGE, graphJson: entry.graph },
|
|
265
|
+
window.location.origin,
|
|
266
|
+
);
|
|
267
|
+
setStatus("\u2713 Loaded into the composer above \u2014 edit, Preview or Stage it.", "ok");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// "Export": a purely client-side download of a saved entry's graph JSON as `<name>.deliverygraph.json`
|
|
271
|
+
// (issue #525). No backend door — the list door already carries each entry's `graph` inline (the same
|
|
272
|
+
// field Reuse uses), so we assemble a Blob from it and drive an anchor download. The bytes written are
|
|
273
|
+
// the STORED graph verbatim (buildDeliveryGraphExport never re-serialises), so an export→import
|
|
274
|
+
// round-trip is byte-stable. Runs in any context (embedded or standalone) — unlike Reuse it needs no
|
|
275
|
+
// console to route it.
|
|
276
|
+
function doExport(id) {
|
|
277
|
+
const entry = entries.find((e) => e && e.id === id);
|
|
278
|
+
if (!entry) {
|
|
279
|
+
setStatus("That entry is no longer in the library \u2014 refresh and try again.", "err");
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const { filename, mime, contents } = buildDeliveryGraphExport(entry);
|
|
283
|
+
if (typeof contents !== "string" || contents.trim() === "") {
|
|
284
|
+
setStatus("That entry has no stored graph to export.", "err");
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
let url = null;
|
|
288
|
+
let a = null;
|
|
289
|
+
try {
|
|
290
|
+
const blob = new Blob([contents], { type: mime });
|
|
291
|
+
url = URL.createObjectURL(blob);
|
|
292
|
+
a = document.createElement("a");
|
|
293
|
+
a.href = url;
|
|
294
|
+
a.download = filename;
|
|
295
|
+
document.body.appendChild(a);
|
|
296
|
+
a.click();
|
|
297
|
+
setStatus(`\u2713 Downloaded ${filename}`, "ok");
|
|
298
|
+
} catch (err) {
|
|
299
|
+
setStatus(err && err.message ? err.message : "Export failed.", "err");
|
|
300
|
+
} finally {
|
|
301
|
+
if (a) a.remove();
|
|
302
|
+
// Defer revoke to the next tick so the download can start reliably before the URL is freed.
|
|
303
|
+
if (url) setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// "Delete": remove a saved library entry via the deleteLibraryEntry door (idempotent). Confirm (it is
|
|
308
|
+
// a one-way drop off the library), then DELETE the per-entry path; refresh so the row leaves at once.
|
|
309
|
+
async function doDelete(id) {
|
|
310
|
+
if (typeof id !== "string" || id.trim() === "") return;
|
|
311
|
+
if (typeof window !== "undefined" && typeof window.confirm === "function" && !window.confirm(DELETE_CONFIRM)) {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
busy(true);
|
|
315
|
+
setStatus("Deleting…");
|
|
316
|
+
try {
|
|
317
|
+
const { status, body } = await del(entryUrl(id));
|
|
318
|
+
if (status === 200 && body.ok) {
|
|
319
|
+
setStatus(body.deleted ? "\u2713 Deleted \u2014 the entry is off the library." : "\u2713 Already gone \u2014 nothing to delete.", "ok");
|
|
320
|
+
await refresh();
|
|
321
|
+
} else {
|
|
322
|
+
setStatus(body && body.error ? body.error : "Delete failed.", "err");
|
|
323
|
+
}
|
|
324
|
+
} catch (err) {
|
|
325
|
+
setStatus(err && err.message ? err.message : "Delete request failed.", "err");
|
|
326
|
+
} finally {
|
|
327
|
+
busy(false);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
listEl.addEventListener("click", (ev) => {
|
|
332
|
+
const reuseBtn = ev.target && ev.target.closest ? ev.target.closest("[data-reuse]") : null;
|
|
333
|
+
if (reuseBtn) {
|
|
334
|
+
ev.preventDefault();
|
|
335
|
+
doReuse(reuseBtn.getAttribute("data-reuse"));
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const exportBtn = ev.target && ev.target.closest ? ev.target.closest("[data-export]") : null;
|
|
339
|
+
if (exportBtn) {
|
|
340
|
+
ev.preventDefault();
|
|
341
|
+
doExport(exportBtn.getAttribute("data-export"));
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const deleteBtn = ev.target && ev.target.closest ? ev.target.closest("[data-delete]") : null;
|
|
345
|
+
if (deleteBtn) {
|
|
346
|
+
ev.preventDefault();
|
|
347
|
+
doDelete(deleteBtn.getAttribute("data-delete"));
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
refresh();
|
|
352
|
+
// Skip a scheduled poll while a Reuse/Delete request is in flight: re-rendering the list mid-request
|
|
353
|
+
// would drop the in-flight button (and its disabled state) out from under the user. The delete path
|
|
354
|
+
// drives its own refresh() on completion, so nothing is missed.
|
|
355
|
+
const timer = setInterval(() => {
|
|
356
|
+
if (busyCount === 0) refresh();
|
|
357
|
+
}, refreshMs);
|
|
358
|
+
|
|
359
|
+
return () => {
|
|
360
|
+
disposed = true;
|
|
361
|
+
clearInterval(timer);
|
|
362
|
+
root.innerHTML = "";
|
|
363
|
+
};
|
|
364
|
+
}
|
|
@@ -18,6 +18,29 @@
|
|
|
18
18
|
const DEFAULT_PREVIEW_URL = "app/api/actions/delivery-graph/preview";
|
|
19
19
|
const DEFAULT_STAGE_URL = "app/api/actions/delivery-graph/stage";
|
|
20
20
|
|
|
21
|
+
// The filesystem IMPORT door (issue #524, epic #519 S5). The Import control below reads a chosen
|
|
22
|
+
// `.json` file's text client-side and POSTs it here; the door validates + compiles it and persists it
|
|
23
|
+
// to the library with `source: imported`. Base-relative like the preview/stage defaults (App-View #279
|
|
24
|
+
// resolution class — a leading-slash path 404s).
|
|
25
|
+
const DEFAULT_IMPORT_URL = "app/api/actions/delivery-graph/library/import";
|
|
26
|
+
|
|
27
|
+
// The INBOUND reuse-fill seam (issue #523, epic #519 S4). Until now the compose textarea (`#dg-json`)
|
|
28
|
+
// had NO inbound prefill path — its value was set only by "Load example" or the operator typing. The
|
|
29
|
+
// Library App-View's per-row **Reuse** (this wave) loads a saved graph into the composer by posting a
|
|
30
|
+
// host-bridge message of this shape, which reaches this compose App-View window:
|
|
31
|
+
//
|
|
32
|
+
// { type: DG_COMPOSE_FILL_MESSAGE, graphJson: "<a DeliveryGraph JSON string>" }
|
|
33
|
+
//
|
|
34
|
+
// It is the INBOUND twin of the OUTBOUND `nano-navigate` bridge already used for "Preview generated
|
|
35
|
+
// DI": a small, typed postMessage envelope across the App-View iframe boundary. The shape is declared
|
|
36
|
+
// once in app/contracts.ts as the `deliveryGraph.compose.fill` wire contract, and this string is the
|
|
37
|
+
// ONE source of truth for its `type` — the Library Reuse producer imports it from here rather than
|
|
38
|
+
// re-declaring a synonym. The filesystem **Import** control (#524, sequenced AFTER this) does NOT use
|
|
39
|
+
// this cross-frame message — it lives in THIS same mount, so it fills directly through `fillComposer()`
|
|
40
|
+
// below. Every fill (this message-driven bridge, or a same-mount caller like the #524 file input)
|
|
41
|
+
// routes through the single `fillComposer()` seam below; keep new fill sources going through it.
|
|
42
|
+
export const DG_COMPOSE_FILL_MESSAGE = "nano-delivery-graph-compose-fill";
|
|
43
|
+
|
|
21
44
|
// A bounded timeout for every door request. Without it a hung endpoint leaves the fetch promise pending
|
|
22
45
|
// forever, so the busy() lock never clears and the UI is stranded (buttons disabled, status stuck) with
|
|
23
46
|
// no way to retry. On timeout the AbortController rejects the fetch, which surfaces as an error banner
|
|
@@ -171,10 +194,24 @@ function renderPreview(result, staged) {
|
|
|
171
194
|
return summary + renderSideEffects(result.sideEffects) + renderHumanNodes(result.humanNodes) + diagram;
|
|
172
195
|
}
|
|
173
196
|
|
|
197
|
+
// Only attach the guard secret when the resolved door URL is SAME-ORIGIN. The preview/stage/import door
|
|
198
|
+
// URLs can be overridden (e.g. via the standalone `?preview=` / `?stage=` / `?import=` query params) to a
|
|
199
|
+
// full `https://…`
|
|
200
|
+
// URL on a foreign origin; sending `x-hook-secret` there would exfiltrate the shared guard secret to an
|
|
201
|
+
// arbitrary host. A cross-origin (or unparseable, or non-browser) target therefore gets no secret.
|
|
202
|
+
function isSameOrigin(url) {
|
|
203
|
+
try {
|
|
204
|
+
if (typeof window === "undefined" || !window.location) return false;
|
|
205
|
+
return new URL(url, window.location.href).origin === window.location.origin;
|
|
206
|
+
} catch (_e) {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
174
211
|
/**
|
|
175
212
|
* Mount the compose → preview / stage view into `host`.
|
|
176
213
|
* @param {Element|null} host — the element to render into (or null → look up #delivery-graphs-root).
|
|
177
|
-
* @param {{previewUrl?:string, stageUrl?:string, hookSecret?:string}} [config]
|
|
214
|
+
* @param {{previewUrl?:string, stageUrl?:string, importUrl?:string, hookSecret?:string}} [config]
|
|
178
215
|
*/
|
|
179
216
|
export function mountDeliveryGraphs(host, config = {}) {
|
|
180
217
|
const isElement = host != null && host.nodeType === 1 && typeof host.innerHTML === "string";
|
|
@@ -183,9 +220,10 @@ export function mountDeliveryGraphs(host, config = {}) {
|
|
|
183
220
|
|
|
184
221
|
const previewUrl = config.previewUrl ?? DEFAULT_PREVIEW_URL;
|
|
185
222
|
const stageUrl = config.stageUrl ?? DEFAULT_STAGE_URL;
|
|
186
|
-
const
|
|
223
|
+
const importUrl = config.importUrl ?? DEFAULT_IMPORT_URL;
|
|
224
|
+
const headers = (url) => ({
|
|
187
225
|
"content-type": "application/json",
|
|
188
|
-
...(config.hookSecret ? { "x-hook-secret": config.hookSecret } : {}),
|
|
226
|
+
...(config.hookSecret && isSameOrigin(url) ? { "x-hook-secret": config.hookSecret } : {}),
|
|
189
227
|
});
|
|
190
228
|
|
|
191
229
|
// The static compose shell. The compose card is a native <details> so an operator can COLLAPSE the
|
|
@@ -202,6 +240,10 @@ export function mountDeliveryGraphs(host, config = {}) {
|
|
|
202
240
|
<button id="dg-preview" class="btn btn-primary" type="button">Preview</button>
|
|
203
241
|
<button id="dg-stage" class="btn" type="button">Stage</button>
|
|
204
242
|
<button id="dg-example" class="btn btn-ghost" type="button">Load example</button>
|
|
243
|
+
<label class="btn btn-ghost dg-import" title="Import a delivery-graph .json file into the library">
|
|
244
|
+
Import file
|
|
245
|
+
<input id="dg-import" class="dg-import-input" type="file" accept=".json,application/json" />
|
|
246
|
+
</label>
|
|
205
247
|
<span id="dg-status" class="status"></span>
|
|
206
248
|
</div>
|
|
207
249
|
</div>
|
|
@@ -215,6 +257,8 @@ export function mountDeliveryGraphs(host, config = {}) {
|
|
|
215
257
|
const previewBtn = root.querySelector("#dg-preview");
|
|
216
258
|
const stageBtn = root.querySelector("#dg-stage");
|
|
217
259
|
const exampleBtn = root.querySelector("#dg-example");
|
|
260
|
+
const importInput = root.querySelector("#dg-import");
|
|
261
|
+
const composeDetails = root.querySelector("#dg-compose");
|
|
218
262
|
|
|
219
263
|
// The most recent successful PREVIEW's laid-out BPMN — bridged to the host explorer on demand (the
|
|
220
264
|
// preview door returns it, so DI preview needs no staging, #516). Cleared whenever the composed graph
|
|
@@ -230,6 +274,51 @@ export function mountDeliveryGraphs(host, config = {}) {
|
|
|
230
274
|
previewBtn.disabled = on;
|
|
231
275
|
stageBtn.disabled = on;
|
|
232
276
|
exampleBtn.disabled = on;
|
|
277
|
+
if (importInput) importInput.disabled = on;
|
|
278
|
+
// Lock the textarea too: an in-flight import awaits file.text() + the POST, and on success
|
|
279
|
+
// fillComposer() overwrites #dg-json unconditionally — leaving it editable would let a slow import
|
|
280
|
+
// silently clobber edits the operator made during the delay.
|
|
281
|
+
jsonEl.disabled = on;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// The SINGLE inbound fill seam (issue #523): load a graph JSON into the composer as if the operator
|
|
285
|
+
// had pasted it. It is driven by the Library Reuse host-bridge message (below) and — same-mount, no
|
|
286
|
+
// bridge — by the #524 filesystem import that lands after this. It resets the previewed BPMN (so
|
|
287
|
+
// "Preview generated DI" can never show a stale diagram against freshly-filled JSON), clears the old
|
|
288
|
+
// output, and expands the (possibly collapsed) compose panel so the loaded graph is visible. Returns
|
|
289
|
+
// true when it filled, false for a blank/non-string payload (nothing is clobbered on a bad fill).
|
|
290
|
+
function fillComposer(graphJson, opts = {}) {
|
|
291
|
+
if (typeof graphJson !== "string" || graphJson.trim() === "") return false;
|
|
292
|
+
jsonEl.value = graphJson;
|
|
293
|
+
lastBpmn = "";
|
|
294
|
+
outputEl.innerHTML = "";
|
|
295
|
+
if (composeDetails && !composeDetails.open) composeDetails.open = true;
|
|
296
|
+
setStatus(opts.status || "Loaded into the composer \u2014 Preview or Stage it.", "ok");
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// The inbound half of the App-View bridge: fill the composer from a Library Reuse (or import)
|
|
301
|
+
// message. Only a SAME-ORIGIN message of the agreed `deliveryGraph.compose.fill` shape fills — a
|
|
302
|
+
// foreign origin or a mismatched shape is ignored, so this listener can't be driven by an unrelated
|
|
303
|
+
// page. Registered on `window` (the message arrives on this App-View's own window) and torn down by
|
|
304
|
+
// the disposer below.
|
|
305
|
+
function onFillMessage(ev) {
|
|
306
|
+
if (!ev || typeof window === "undefined") return;
|
|
307
|
+
// Same-origin host-bridge seam: require an EXACT origin match. A missing/empty/foreign `ev.origin`
|
|
308
|
+
// (a malformed or forged event, or a future browser edge case) is rejected outright — never fall
|
|
309
|
+
// through to the fill just because the origin was absent.
|
|
310
|
+
if (ev.origin !== window.location.origin) return;
|
|
311
|
+
// The fill message is routed UP to the console and back down when embedded, so accept it only from
|
|
312
|
+
// the parent frame in that case — a missing/falsy `ev.source` is rejected too, never allowed to fall
|
|
313
|
+
// through; standalone, there is no parent and the import path fills directly.
|
|
314
|
+
const embedded = typeof window.parent !== "undefined" && window.parent !== window;
|
|
315
|
+
if (embedded && ev.source !== window.parent) return;
|
|
316
|
+
const data = ev.data;
|
|
317
|
+
if (!data || data.type !== DG_COMPOSE_FILL_MESSAGE || typeof data.graphJson !== "string") return;
|
|
318
|
+
fillComposer(data.graphJson, { status: "Reused a saved graph \u2014 Preview or Stage it." });
|
|
319
|
+
}
|
|
320
|
+
if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
|
|
321
|
+
window.addEventListener("message", onFillMessage);
|
|
233
322
|
}
|
|
234
323
|
|
|
235
324
|
/** POST a JSON body to a door and return { status, body } (never throws on an HTTP error). Rejects
|
|
@@ -240,7 +329,7 @@ export function mountDeliveryGraphs(host, config = {}) {
|
|
|
240
329
|
try {
|
|
241
330
|
const res = await fetch(url, {
|
|
242
331
|
method: "POST",
|
|
243
|
-
headers: headers(),
|
|
332
|
+
headers: headers(url),
|
|
244
333
|
body: JSON.stringify(payload),
|
|
245
334
|
signal: controller.signal,
|
|
246
335
|
});
|
|
@@ -299,6 +388,43 @@ export function mountDeliveryGraphs(host, config = {}) {
|
|
|
299
388
|
outputEl.innerHTML = "";
|
|
300
389
|
setStatus("Example loaded — Preview or Stage it.", "");
|
|
301
390
|
});
|
|
391
|
+
|
|
392
|
+
// The filesystem IMPORT control (issue #524, epic #519 S5). Read the chosen `.json` file's text
|
|
393
|
+
// CLIENT-SIDE, then POST it to the importToLibrary door, which validates + compiles it and (on
|
|
394
|
+
// success) persists it to the library with `source: imported`. A file that is not valid JSON, or a
|
|
395
|
+
// graph that will not compile, is a clean 400 whose path-qualified errors render inline — nothing is
|
|
396
|
+
// persisted. On a successful import we route the file text through the SAME `fillComposer()` seam
|
|
397
|
+
// #523 introduced (no reshaping — one inbound fill path), so the imported graph appears in the
|
|
398
|
+
// composer ready to Preview/Stage. The input is reset after each pick so re-choosing the same file
|
|
399
|
+
// still fires `change`.
|
|
400
|
+
async function importFile(file) {
|
|
401
|
+
if (!file) return;
|
|
402
|
+
busy(true);
|
|
403
|
+
setStatus(`Importing ${file.name}\u2026`);
|
|
404
|
+
try {
|
|
405
|
+
const text = await file.text();
|
|
406
|
+
const { status, body } = await post(importUrl, { graphJson: text });
|
|
407
|
+
if (status === 200 && body.ok) {
|
|
408
|
+
outputEl.innerHTML = "";
|
|
409
|
+
fillComposer(text, { status: `\u2713 Imported \u201c${body.entry.name}\u201d into the library — Preview or Stage it.` });
|
|
410
|
+
} else {
|
|
411
|
+
outputEl.innerHTML = renderErrors(body.error, body.errors);
|
|
412
|
+
setStatus("Import failed — see the details below.", "err");
|
|
413
|
+
}
|
|
414
|
+
} catch (err) {
|
|
415
|
+
outputEl.innerHTML = renderErrors(err && err.message ? err.message : String(err), []);
|
|
416
|
+
setStatus("Import request failed.", "err");
|
|
417
|
+
} finally {
|
|
418
|
+
busy(false);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (importInput) {
|
|
422
|
+
importInput.addEventListener("change", () => {
|
|
423
|
+
const file = importInput.files && importInput.files[0];
|
|
424
|
+
importFile(file);
|
|
425
|
+
importInput.value = "";
|
|
426
|
+
});
|
|
427
|
+
}
|
|
302
428
|
// Any edit invalidates the previewed BPMN so "Preview generated DI" can't show a stale diagram.
|
|
303
429
|
jsonEl.addEventListener("input", () => {
|
|
304
430
|
lastBpmn = "";
|
|
@@ -334,6 +460,9 @@ export function mountDeliveryGraphs(host, config = {}) {
|
|
|
334
460
|
});
|
|
335
461
|
|
|
336
462
|
return () => {
|
|
463
|
+
if (typeof window !== "undefined" && typeof window.removeEventListener === "function") {
|
|
464
|
+
window.removeEventListener("message", onFillMessage);
|
|
465
|
+
}
|
|
337
466
|
root.innerHTML = "";
|
|
338
467
|
};
|
|
339
468
|
}
|