@nanobpm/nano-workforce 0.138.3 → 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 +6 -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/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
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [0.139.0](https://github.com/nanobpm/nano-workforce/compare/v0.138.3...v0.139.0) (2026-08-24)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* deliver the delivery graph surface epic (dismiss, run history, reusable graph library import/export) ([#534](https://github.com/nanobpm/nano-workforce/issues/534)) ([dcbf4b1](https://github.com/nanobpm/nano-workforce/commit/dcbf4b15e2e78dd4bdecf7b06c8fdf8d3849a9d7)), closes [#519](https://github.com/nanobpm/nano-workforce/issues/519) [#526](https://github.com/nanobpm/nano-workforce/issues/526) [#521](https://github.com/nanobpm/nano-workforce/issues/521)
|
|
6
|
+
|
|
1
7
|
## [0.138.3](https://github.com/nanobpm/nano-workforce/compare/v0.138.2...v0.138.3) (2026-08-24)
|
|
2
8
|
|
|
3
9
|
### Bug Fixes
|
package/app/contracts.ts
CHANGED
|
@@ -384,6 +384,22 @@ export const WIRE_CONTRACTS = {
|
|
|
384
384
|
shape:
|
|
385
385
|
'{ name?: string, nodes: Array<{ id: string, kind: "agent"|"wait"|"human"|"connector", emits?: Array<{ name: string, type: "string"|"number"|"boolean"|"artifact"|"version"|"url", description?: string }>, agent?: { jobType: string, prompt?: string }, wait?: ReadinessProbe, human?: { formKey?: string, prompt?: string }, connector?: { target: string, dedupeKey?: string, payload?: object } }>, edges?: Array<{ from: string, to: string }> }',
|
|
386
386
|
},
|
|
387
|
+
"deliveryGraph.compose.fill": {
|
|
388
|
+
category: "wire",
|
|
389
|
+
name: "deliveryGraph.compose.fill",
|
|
390
|
+
owner: "pages/delivery-graphs/mount.js",
|
|
391
|
+
semantics:
|
|
392
|
+
"The INBOUND reuse-fill host-bridge message that loads a saved `DeliveryGraph` JSON into the Delivery Graphs COMPOSE App-View textarea (`#dg-json`) — issue #523, epic #519 S4. The compose mount (consumer) registers a same-origin `window` `message` listener for this shape and routes it through its single `fillComposer()` seam; the producer is the Library App-View **Reuse** action (#523), which posts it across the App-View iframe boundary (the INBOUND twin of the existing OUTBOUND `nano-navigate` DI-preview bridge). The filesystem **Import** control (#524) is NOT a producer of this message — it lives in the same compose mount and fills directly through `fillComposer()`, no cross-frame hop. The `type` string is exported ONCE as `DG_COMPOSE_FILL_MESSAGE` from pages/delivery-graphs/mount.js — the Reuse producer imports it, never re-declares a synonym.",
|
|
393
|
+
shape: '{ type: "nano-delivery-graph-compose-fill", graphJson: string }',
|
|
394
|
+
},
|
|
395
|
+
"deliveryGraph.library.import.submit": {
|
|
396
|
+
category: "wire",
|
|
397
|
+
name: "deliveryGraph.library.import.submit",
|
|
398
|
+
owner: "operations/importToLibrary.ts",
|
|
399
|
+
semantics:
|
|
400
|
+
"Filesystem-import request body POSTed to /actions/delivery-graph/library/import (issue #524, epic #519 S5). Declared in openapi.yaml as `ImportToLibrarySubmit`; the compose App-View's `<input type=file accept=.json>` reads the picked file's text client-side and POSTs it here as the raw `graphJson` string. The door validates + compiles it through the SAME `parseAndCompileText` pipeline preview/stage/save use, then persists `source: imported` — an uncompilable graph is a clean 400 and NOTHING is written. Its `name` defaults to the imported graph's own `name`; an explicit `name` overrides it (an unnamed graph with no override is a clean 400 — the library id is name-derived). Related to but DISTINCT from `SaveToLibrarySubmit` (which is graphJson-OR-digest and needs no required file text); consume this ONE shape across the openapi edge, the door, and the compose mount — do not re-declare a synonym.",
|
|
401
|
+
shape: "{ graphJson: string, name?: string, description?: string }",
|
|
402
|
+
},
|
|
387
403
|
} as const satisfies Record<string, WireContract>;
|
|
388
404
|
|
|
389
405
|
export const TYPE_CONTRACTS = {
|
|
@@ -76,6 +76,90 @@ test("a non-object graph is rejected without throwing", () => {
|
|
|
76
76
|
assertEquals(validateDeliveryGraph({ nodes: "nope" })[0].code, "empty-graph");
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
+
test("invalid-graph-name: a non-string top-level `name` is rejected, path-qualified", () => {
|
|
80
|
+
// Regression (#524 review): a JSON-string import body bypasses the OpenAPI shape gate, so a
|
|
81
|
+
// non-string `name` would otherwise reach the compiler and THROW out of `escapeXml` (surfacing as a
|
|
82
|
+
// 400 with no path-qualified `errors`). The semantic validator now catches it cleanly.
|
|
83
|
+
const err = hasCode(validateDeliveryGraph({ ...WELL_FORMED, name: 42 }), "invalid-graph-name");
|
|
84
|
+
assertEquals(err.path, "name");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("invalid-graph-name: a top-level `name` longer than 255 chars is rejected, path-qualified", () => {
|
|
88
|
+
// Regression (#524 review): an over-long name would otherwise be persisted despite violating the
|
|
89
|
+
// openapi `DeliveryGraph.name` `maxLength: 255` contract.
|
|
90
|
+
const err = hasCode(validateDeliveryGraph({ ...WELL_FORMED, name: "x".repeat(256) }), "invalid-graph-name");
|
|
91
|
+
assertEquals(err.path, "name");
|
|
92
|
+
// The boundary (exactly 255) is accepted.
|
|
93
|
+
assertEquals(validateDeliveryGraph({ ...WELL_FORMED, name: "x".repeat(255) }), []);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("invalid-graph-name: `name` length is counted by code point, not UTF-16 code unit", () => {
|
|
97
|
+
// Regression (#524 review): JS `String.length` counts UTF-16 code units, but openapi `maxLength`
|
|
98
|
+
// counts Unicode code points. A name of 255 astral characters (each 2 code units) is WITHIN the
|
|
99
|
+
// contract and must be accepted; the previous `String.length` check wrongly rejected it as 510.
|
|
100
|
+
const astral255 = "\u{1F600}".repeat(255); // 255 emoji code points = 510 UTF-16 code units
|
|
101
|
+
assertEquals([...astral255].length, 255);
|
|
102
|
+
assertEquals(validateDeliveryGraph({ ...WELL_FORMED, name: astral255 }), []);
|
|
103
|
+
// 256 code points is over the limit and rejected, path-qualified.
|
|
104
|
+
const astral256 = "\u{1F600}".repeat(256);
|
|
105
|
+
const err = hasCode(validateDeliveryGraph({ ...WELL_FORMED, name: astral256 }), "invalid-graph-name");
|
|
106
|
+
assertEquals(err.path, "name");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("too-many-nodes: a node set larger than the openapi `maxItems: 256` cap is rejected", () => {
|
|
110
|
+
// Regression (#524 review): a JSON-string import/save body bypasses the OpenAPI shape gate, so the
|
|
111
|
+
// declared `nodes.maxItems: 256` bound is re-enforced here — otherwise an oversized-but-compilable
|
|
112
|
+
// graph reaches the layout/compiler and is persisted.
|
|
113
|
+
const node = (i: number) => ({ id: `n${i}`, kind: "human", human: { prompt: "x" } });
|
|
114
|
+
const tooMany = Array.from({ length: 257 }, (_, i) => node(i));
|
|
115
|
+
const err = hasCode(validateDeliveryGraph({ nodes: tooMany }), "too-many-nodes");
|
|
116
|
+
assertEquals(err.path, "nodes");
|
|
117
|
+
// The cap SHORT-CIRCUITS the per-node walk: an oversized array is rejected on the cap ALONE, so
|
|
118
|
+
// even when every node is independently invalid (here: `human` config of the wrong type) the
|
|
119
|
+
// validator returns ONLY the single `too-many-nodes` error rather than doing 257 nodes' worth of
|
|
120
|
+
// per-node validation/map-building work first (#533 review — make the resource limit effective).
|
|
121
|
+
const oversizedAndInvalid = Array.from({ length: 257 }, (_, i) => ({ id: `n${i}`, kind: "human", human: 42 }));
|
|
122
|
+
assertEquals(validateDeliveryGraph({ nodes: oversizedAndInvalid }), [
|
|
123
|
+
{
|
|
124
|
+
path: "nodes",
|
|
125
|
+
message: "delivery graph has too many nodes (257) — the limit is 256",
|
|
126
|
+
code: "too-many-nodes",
|
|
127
|
+
},
|
|
128
|
+
]);
|
|
129
|
+
// The boundary (exactly 256) is accepted (no too-many-nodes error).
|
|
130
|
+
const exactly = Array.from({ length: 256 }, (_, i) => node(i));
|
|
131
|
+
assertEquals(
|
|
132
|
+
validateDeliveryGraph({ nodes: exactly }).filter((e) => e.code === "too-many-nodes"),
|
|
133
|
+
[],
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("too-many-edges: an edge set larger than the openapi `maxItems: 1024` cap is rejected", () => {
|
|
138
|
+
// Regression (#524 review): re-enforce `edges.maxItems: 1024` for the bypassed shape gate.
|
|
139
|
+
const nodes = [
|
|
140
|
+
{ id: "a", kind: "human", human: { prompt: "x" } },
|
|
141
|
+
{ id: "b", kind: "human", human: { prompt: "y" } },
|
|
142
|
+
];
|
|
143
|
+
const tooMany = Array.from({ length: 1025 }, () => ({ from: "a", to: "b" }));
|
|
144
|
+
const err = hasCode(validateDeliveryGraph({ nodes, edges: tooMany }), "too-many-edges");
|
|
145
|
+
assertEquals(err.path, "edges");
|
|
146
|
+
// The cap SHORT-CIRCUITS the edge walk: an oversized edge array is rejected before any endpoint
|
|
147
|
+
// resolution / adjacency work, so even 1025 dangling edges surface ONLY the cap error, not 1025
|
|
148
|
+
// `dangling-edge` errors (#533 review — make the resource limit effective).
|
|
149
|
+
const oversizedDangling = Array.from({ length: 1025 }, () => ({ from: "ghost", to: "phantom" }));
|
|
150
|
+
const capOnly = validateDeliveryGraph({ nodes, edges: oversizedDangling });
|
|
151
|
+
assertEquals(capOnly.filter((e) => e.code === "dangling-edge"), []);
|
|
152
|
+
assertEquals(hasCode(capOnly, "too-many-edges").path, "edges");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("too-many-emits: a node declaring more than the openapi `maxItems: 32` facts is rejected", () => {
|
|
156
|
+
// Regression (#524 review): re-enforce `DeliveryNodeCommon.emits.maxItems: 32` for the bypassed gate.
|
|
157
|
+
const emits = Array.from({ length: 33 }, (_, i) => ({ name: `f${i}`, type: "string" }));
|
|
158
|
+
const graph = { nodes: [{ id: "a", kind: "wait", wait: { kind: "capability", target: "x:y" }, emits }] };
|
|
159
|
+
const err = hasCode(validateDeliveryGraph(graph), "too-many-emits");
|
|
160
|
+
assertEquals(err.path, "nodes[0].emits");
|
|
161
|
+
});
|
|
162
|
+
|
|
79
163
|
test("unknown-kind: a node kind outside the closed allowlist is rejected, path-qualified", () => {
|
|
80
164
|
const errors = validateDeliveryGraph({
|
|
81
165
|
nodes: [{ id: "x", kind: "script", script: { run: "rm -rf /" } }],
|
package/app/deliveryGraph.ts
CHANGED
|
@@ -57,6 +57,10 @@ export function isDeliveryGuardScalarType(type: unknown): type is DeliveryGuardS
|
|
|
57
57
|
* class (unknown-kind / dangling / cycle / bad-`from`) without string-matching the message. */
|
|
58
58
|
export type DeliveryGraphErrorCode =
|
|
59
59
|
| "empty-graph"
|
|
60
|
+
| "invalid-graph-name"
|
|
61
|
+
| "too-many-nodes"
|
|
62
|
+
| "too-many-edges"
|
|
63
|
+
| "too-many-emits"
|
|
60
64
|
| "missing-id"
|
|
61
65
|
| "invalid-id"
|
|
62
66
|
| "duplicate-id"
|
|
@@ -130,6 +134,29 @@ export const FACT_NAME_MAX_LENGTH = 128;
|
|
|
130
134
|
const NODE_ID_PATTERN = /^[A-Za-z_][A-Za-z0-9_.-]*$/;
|
|
131
135
|
const NODE_ID_MAX_LENGTH = 128;
|
|
132
136
|
|
|
137
|
+
/** The graph's optional top-level `name` must match openapi's `DeliveryGraph.name` `maxLength: 255`.
|
|
138
|
+
* Re-enforced here INDEPENDENTLY of the OpenAPI shape gate because later steps trust it: the compiler
|
|
139
|
+
* feeds `graph.name` straight into `escapeXml(processName)` / `escapeMermaid`, so a NON-STRING name
|
|
140
|
+
* (`.replace` is not a function) THROWS out of the compiler — a bypassed shape gate (a direct delegate
|
|
141
|
+
* call, or a JSON-string body like the library import door's `graphJson`, where the OpenAPI schema
|
|
142
|
+
* never touches the parsed value) would otherwise surface as an unhandled fault mapped to a 400 with
|
|
143
|
+
* NO path-qualified `errors`, and an over-long name would be persisted despite violating the contract.
|
|
144
|
+
* Validating it here turns both into a clean, path-qualified `invalid-graph-name` failure. Length is
|
|
145
|
+
* counted by Unicode CODE POINT (`[...name].length`), matching openapi/JSON-Schema `maxLength`
|
|
146
|
+
* semantics — NOT JS `String.length`, which counts UTF-16 code units and would reject an in-contract
|
|
147
|
+
* name of ≤255 astral characters (e.g. emoji) as over-long. */
|
|
148
|
+
const GRAPH_NAME_MAX_LENGTH = 255;
|
|
149
|
+
|
|
150
|
+
/** Whole-graph fan-out caps mirroring openapi's `DeliveryGraph` array bounds (`nodes.maxItems: 256`,
|
|
151
|
+
* `edges.maxItems: 1024`) and `DeliveryNodeCommon.emits.maxItems: 32`. Re-enforced here INDEPENDENTLY
|
|
152
|
+
* of the OpenAPI shape gate because a bypassed gate (a direct delegate call, or a JSON-string body
|
|
153
|
+
* like the library import/save doors' `graphJson`, where the schema never touches the parsed value)
|
|
154
|
+
* would otherwise let an oversized-but-compilable graph reach the layout/compiler and be persisted —
|
|
155
|
+
* both violating the declared contract and exposing the import path to avoidable CPU/memory growth. */
|
|
156
|
+
const GRAPH_MAX_NODES = 256;
|
|
157
|
+
const GRAPH_MAX_EDGES = 1024;
|
|
158
|
+
const NODE_MAX_EMITS = 32;
|
|
159
|
+
|
|
133
160
|
/** The per-kind config key a node of the given kind must carry (`agent` → `agent`, etc.). */
|
|
134
161
|
const CONFIG_KEY: Record<DeliveryNodeKind, string> = {
|
|
135
162
|
agent: "agent",
|
|
@@ -212,6 +239,39 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
|
|
|
212
239
|
message: "delivery graph is empty — declare at least one node",
|
|
213
240
|
code: "empty-graph",
|
|
214
241
|
});
|
|
242
|
+
} else if (nodes.length > GRAPH_MAX_NODES) {
|
|
243
|
+
// Short-circuit on the cap BEFORE the per-node walk. Raw `graphJson` (the import/save doors)
|
|
244
|
+
// bypasses openapi's `nodes.maxItems: 256`, so an arbitrarily large array would otherwise still
|
|
245
|
+
// drive the full `nodes.forEach` — building the id→facts/types maps for every supplied node —
|
|
246
|
+
// before returning the same 400. Rejecting on the cap ALONE keeps the advertised resource limit
|
|
247
|
+
// effective (bounded validation work on an oversized untrusted import), rather than merely
|
|
248
|
+
// reporting it after doing the unbounded walk.
|
|
249
|
+
return [
|
|
250
|
+
{
|
|
251
|
+
path: "nodes",
|
|
252
|
+
message: `delivery graph has too many nodes (${nodes.length}) — the limit is ${GRAPH_MAX_NODES}`,
|
|
253
|
+
code: "too-many-nodes",
|
|
254
|
+
},
|
|
255
|
+
];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Top-level `name` (optional): mirror openapi's `DeliveryGraph.name` `maxLength: 255`, INDEPENDENTLY
|
|
259
|
+
// of the shape gate — a non-string name would otherwise throw out of the compiler's `escapeXml`, and
|
|
260
|
+
// an over-long one would be persisted despite violating the contract (see GRAPH_NAME_MAX_LENGTH).
|
|
261
|
+
if (graph.name !== undefined) {
|
|
262
|
+
if (typeof graph.name !== "string") {
|
|
263
|
+
errors.push({
|
|
264
|
+
path: "name",
|
|
265
|
+
message: "delivery graph `name` must be a string",
|
|
266
|
+
code: "invalid-graph-name",
|
|
267
|
+
});
|
|
268
|
+
} else if ([...graph.name].length > GRAPH_NAME_MAX_LENGTH) {
|
|
269
|
+
errors.push({
|
|
270
|
+
path: "name",
|
|
271
|
+
message: `delivery graph \`name\` must be \u2264 ${GRAPH_NAME_MAX_LENGTH} characters`,
|
|
272
|
+
code: "invalid-graph-name",
|
|
273
|
+
});
|
|
274
|
+
}
|
|
215
275
|
}
|
|
216
276
|
|
|
217
277
|
// Pass 1: node ids + kinds + per-kind config + declared facts. Build the id → declared-facts map
|
|
@@ -305,6 +365,12 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
|
|
|
305
365
|
message: "`emits` must be an array of typed fact declarations",
|
|
306
366
|
code: "missing-config",
|
|
307
367
|
});
|
|
368
|
+
} else if (rawNode.emits.length > NODE_MAX_EMITS) {
|
|
369
|
+
errors.push({
|
|
370
|
+
path: `${path}.emits`,
|
|
371
|
+
message: `node declares too many emitted facts (${rawNode.emits.length}) — the limit is ${NODE_MAX_EMITS}`,
|
|
372
|
+
code: "too-many-emits",
|
|
373
|
+
});
|
|
308
374
|
} else {
|
|
309
375
|
rawNode.emits.forEach((rawFact, j) => {
|
|
310
376
|
if (!isRecord(rawFact) || typeof rawFact.name !== "string" || rawFact.name.length === 0) {
|
|
@@ -374,6 +440,19 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
|
|
|
374
440
|
message: "`edges`, when present, must be an array of `{ from, to }` dependency edges",
|
|
375
441
|
code: "invalid-edges",
|
|
376
442
|
});
|
|
443
|
+
} else if (edges.length > GRAPH_MAX_EDGES) {
|
|
444
|
+
// Re-enforce openapi's `edges.maxItems: 1024` INDEPENDENTLY of the bypassed shape gate, so an
|
|
445
|
+
// oversized-but-compilable graph cannot reach the layout/compiler and be persisted. Short-circuit
|
|
446
|
+
// on the cap BEFORE the `edges.forEach` walk: like the node cap, a raw import that bypasses
|
|
447
|
+
// `maxItems` must not force endpoint resolution, `guardEdges` allocation, and adjacency work for
|
|
448
|
+
// every edge before returning the same 400 — so the advertised resource limit stays effective.
|
|
449
|
+
// Any node-level errors already accumulated in pass 1 are returned alongside the cap.
|
|
450
|
+
errors.push({
|
|
451
|
+
path: "edges",
|
|
452
|
+
message: `delivery graph has too many edges (${edges.length}) — the limit is ${GRAPH_MAX_EDGES}`,
|
|
453
|
+
code: "too-many-edges",
|
|
454
|
+
});
|
|
455
|
+
return errors;
|
|
377
456
|
}
|
|
378
457
|
// consumer (`to`) → set of upstream node ids (`from`'s node) — the dependency direction.
|
|
379
458
|
const adjacency = new Map<string, Set<string>>();
|
|
@@ -276,8 +276,10 @@ export async function compileDeliveryGraph(
|
|
|
276
276
|
return { ok: false, errors: validationErrors.map(({ path, message }) => ({ path, message })) };
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
-
// The graph passed
|
|
280
|
-
//
|
|
279
|
+
// The graph passed the OpenAPI `DeliveryGraph` SHAPE gate (the runtime edge for the typed agent
|
|
280
|
+
// door; `validateDeliveryGraphShape` in the shared text-ingress for the graphJson-string doors) and
|
|
281
|
+
// the semantic validator, so it is safe to narrow to the typed contract. Every field below is
|
|
282
|
+
// well-formed by construction.
|
|
281
283
|
// biome-ignore lint/plugin: validated external body narrowed to its contract after validateDeliveryGraph
|
|
282
284
|
const typed = graph as DeliveryGraph;
|
|
283
285
|
const nodes = [...typed.nodes].sort((a, b) => byCodeUnit(a.id, b.id));
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Unit coverage for the reusable delivery-graph LIBRARY aggregate (app/deliveryGraphLibrary.ts, issue
|
|
2
|
+
// #522, epic #519 S3). Two layers: the PURE helpers (slugify, name-derived id, row builder) tested in
|
|
3
|
+
// isolation, and the I/O (`saveLibraryEntry` upsert-on-name identity, `listLibraryEntries` newest-first,
|
|
4
|
+
// `getLibraryEntry`, `deleteLibraryEntry` idempotence) exercised against the REAL provisioned SQLite
|
|
5
|
+
// data layer so the migration + raw table ops are validated, not modelled.
|
|
6
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join, resolve } from "node:path";
|
|
9
|
+
import { test } from "node:test";
|
|
10
|
+
import { assert, assertEquals } from "#test-assert";
|
|
11
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
12
|
+
import { bootTestApp } from "@nanobpm/urban-testkit";
|
|
13
|
+
import {
|
|
14
|
+
buildLibraryEntryRow,
|
|
15
|
+
deleteLibraryEntry,
|
|
16
|
+
deliveryGraphLibrary,
|
|
17
|
+
getLibraryEntry,
|
|
18
|
+
libraryEntryId,
|
|
19
|
+
listLibraryEntries,
|
|
20
|
+
saveLibraryEntry,
|
|
21
|
+
slugifyName,
|
|
22
|
+
} from "./deliveryGraphLibrary.ts";
|
|
23
|
+
|
|
24
|
+
const APP_ROOT = resolve(import.meta.dirname, "..");
|
|
25
|
+
|
|
26
|
+
async function withData(fn: (data: DataLayer) => Promise<void>): Promise<void> {
|
|
27
|
+
const dir = mkdtempSync(join(tmpdir(), "nwf-dglib-"));
|
|
28
|
+
const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dir, "app.db")}` } });
|
|
29
|
+
try {
|
|
30
|
+
await fn(app.db);
|
|
31
|
+
} finally {
|
|
32
|
+
await app.stop?.();
|
|
33
|
+
rmSync(dir, { recursive: true, force: true });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const GRAPH = JSON.stringify({ name: "runbook", nodes: [] });
|
|
38
|
+
|
|
39
|
+
// ── pure helpers ──────────────────────────────────────────────────────────────
|
|
40
|
+
test("slugifyName: lowercases, collapses non-alphanumerics to single dashes, trims", () => {
|
|
41
|
+
assertEquals(slugifyName("Runbook A"), "runbook-a");
|
|
42
|
+
assertEquals(slugifyName(" My Graph!! "), "my-graph");
|
|
43
|
+
assertEquals(slugifyName("weird__name.v2"), "weird-name-v2");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("slugifyName: a name with no alphanumerics falls back to `graph`", () => {
|
|
47
|
+
assertEquals(slugifyName(" "), "graph");
|
|
48
|
+
assertEquals(slugifyName("***"), "graph");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("libraryEntryId: `<slug>-<8-hex>`, stable per name and trimmed identically", () => {
|
|
52
|
+
const id = libraryEntryId("Runbook A");
|
|
53
|
+
assert(/^runbook-a-[0-9a-f]{8}$/.test(id), `unexpected id: ${id}`);
|
|
54
|
+
// Deterministic + whitespace-insensitive (trimmed to the same identity).
|
|
55
|
+
assertEquals(libraryEntryId("Runbook A"), id);
|
|
56
|
+
assertEquals(libraryEntryId(" Runbook A "), id);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("libraryEntryId: names that slugify the same are disambiguated by the name hash", () => {
|
|
60
|
+
// Both slugify to `runbook-a`, but the short hash keys off the ORIGINAL trimmed name.
|
|
61
|
+
const a = libraryEntryId("Runbook A");
|
|
62
|
+
const b = libraryEntryId("runbook.a");
|
|
63
|
+
assert(a.startsWith("runbook-a-"));
|
|
64
|
+
assert(b.startsWith("runbook-a-"));
|
|
65
|
+
assert(a !== b, "distinct names must yield distinct ids even when their slugs match");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("buildLibraryEntryRow: derives id from name, normalises a blank description to null", () => {
|
|
69
|
+
const row = buildLibraryEntryRow({ name: " runbook ", description: " ", graphJson: GRAPH, source: "composed" });
|
|
70
|
+
assertEquals(row.id, libraryEntryId("runbook"));
|
|
71
|
+
assertEquals(row.name, "runbook");
|
|
72
|
+
assertEquals(row.description, null);
|
|
73
|
+
assertEquals(row.source, "composed");
|
|
74
|
+
assert(typeof row.created_at === "string" && row.created_at.length > 0);
|
|
75
|
+
assertEquals(row.updated_at, row.created_at);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ── I/O against the real data layer ─────────────────────────────────────────────
|
|
79
|
+
test("saveLibraryEntry: first save inserts; the row round-trips through get", async () => {
|
|
80
|
+
await withData(async (data) => {
|
|
81
|
+
const written = await saveLibraryEntry(
|
|
82
|
+
data,
|
|
83
|
+
buildLibraryEntryRow({ name: "runbook", description: "a note", graphJson: GRAPH, source: "composed" }),
|
|
84
|
+
);
|
|
85
|
+
const fetched = await getLibraryEntry(data, written.id);
|
|
86
|
+
assert(fetched !== null);
|
|
87
|
+
assertEquals(fetched?.name, "runbook");
|
|
88
|
+
assertEquals(fetched?.description, "a note");
|
|
89
|
+
assertEquals(fetched?.graph, GRAPH);
|
|
90
|
+
assertEquals(fetched?.source, "composed");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("saveLibraryEntry: re-saving the same name UPSERTS (one row), refreshes graph, preserves created_at", async () => {
|
|
95
|
+
await withData(async (data) => {
|
|
96
|
+
const first = await saveLibraryEntry(
|
|
97
|
+
data,
|
|
98
|
+
buildLibraryEntryRow({ name: "runbook", graphJson: GRAPH, source: "composed", createdAt: "2024-01-01T00:00:00.000Z" }),
|
|
99
|
+
);
|
|
100
|
+
const editedGraph = JSON.stringify({ name: "runbook", nodes: [{ id: "a", kind: "human" }] });
|
|
101
|
+
const second = await saveLibraryEntry(
|
|
102
|
+
data,
|
|
103
|
+
buildLibraryEntryRow({ name: "runbook", graphJson: editedGraph, source: "from-staged" }),
|
|
104
|
+
);
|
|
105
|
+
assertEquals(first.id, second.id);
|
|
106
|
+
assertEquals((await deliveryGraphLibrary(data).all()).length, 1);
|
|
107
|
+
const fetched = await getLibraryEntry(data, second.id);
|
|
108
|
+
assertEquals(fetched?.graph, editedGraph);
|
|
109
|
+
assertEquals(fetched?.source, "from-staged");
|
|
110
|
+
// created_at is anchored to the first save; the graph edit did not move the row.
|
|
111
|
+
assertEquals(fetched?.created_at, "2024-01-01T00:00:00.000Z");
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("listLibraryEntries: every saved entry, newest first", async () => {
|
|
116
|
+
await withData(async (data) => {
|
|
117
|
+
await saveLibraryEntry(data, buildLibraryEntryRow({ name: "older", graphJson: GRAPH, source: "composed", createdAt: "2024-01-01T00:00:00.000Z" }));
|
|
118
|
+
await saveLibraryEntry(data, buildLibraryEntryRow({ name: "newer", graphJson: GRAPH, source: "composed", createdAt: "2024-06-01T00:00:00.000Z" }));
|
|
119
|
+
const list = await listLibraryEntries(data);
|
|
120
|
+
assertEquals(list.length, 2);
|
|
121
|
+
assertEquals(list[0].name, "newer");
|
|
122
|
+
assertEquals(list[1].name, "older");
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("deleteLibraryEntry: removes a known entry (true); a re-delete is an idempotent no-op (false)", async () => {
|
|
127
|
+
await withData(async (data) => {
|
|
128
|
+
const row = await saveLibraryEntry(data, buildLibraryEntryRow({ name: "runbook", graphJson: GRAPH, source: "composed" }));
|
|
129
|
+
assertEquals(await deleteLibraryEntry(data, row.id), true);
|
|
130
|
+
assertEquals(await getLibraryEntry(data, row.id), null);
|
|
131
|
+
assertEquals(await deleteLibraryEntry(data, row.id), false);
|
|
132
|
+
assertEquals(await deleteLibraryEntry(data, "no-such-id"), false);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// app/deliveryGraphLibrary.ts — the reusable delivery-graph LIBRARY aggregate (issue #522, epic #519
|
|
2
|
+
// S3). The durable base the Library App-View (S4/#523), filesystem import (S5/#524), and export
|
|
3
|
+
// (S6/#525) build on. It mirrors the `delivery_graph_proposals` store/door pattern
|
|
4
|
+
// (`app/deliveryGraphProposals.ts`) so downstream slices meet a familiar API surface — but with two
|
|
5
|
+
// deliberate differences that reflect what a library IS:
|
|
6
|
+
//
|
|
7
|
+
// • Keyed by a slug + short-hash of the entry NAME (`libraryEntryId`), NOT the content digest.
|
|
8
|
+
// Proposals are content-addressed (a re-compile is a new digest, a new row); a library entry is
|
|
9
|
+
// meant to have its GRAPH edited in place — the NAME *is* the identity, so keying on the human,
|
|
10
|
+
// readable name gives a stable, human-readable id and re-saving the same name is idempotent
|
|
11
|
+
// (upsert: the graph refreshes, `created_at` is preserved). Because the id is derived from the
|
|
12
|
+
// name, a rename is NOT an in-place update: it derives a *new* id (a new entry), leaving the old
|
|
13
|
+
// row until it is explicitly deleted.
|
|
14
|
+
// • NO TTL. A proposal ages out of the cockpit; a saved library entry is kept until explicitly
|
|
15
|
+
// deleted — there is no `expires_at` and no sweep.
|
|
16
|
+
//
|
|
17
|
+
// The pure helpers (`slugifyName`, `libraryEntryId`, `buildLibraryEntryRow`) are DB-free so they
|
|
18
|
+
// unit-test in isolation; `saveLibraryEntry` / `listLibraryEntries` / `getLibraryEntry` /
|
|
19
|
+
// `deleteLibraryEntry` supply the I/O.
|
|
20
|
+
|
|
21
|
+
import { createHash } from "node:crypto";
|
|
22
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
23
|
+
import type { DeliveryGraphLibraryEntry as DeliveryGraphLibraryEntryDto } from "../nano-generated/api-io.d.ts";
|
|
24
|
+
|
|
25
|
+
const now = () => new Date().toISOString();
|
|
26
|
+
|
|
27
|
+
/** How a library entry entered the library. `composed` = saved from a raw graph JSON; `imported` =
|
|
28
|
+
* loaded from the filesystem (S5/#524); `from-staged` / `from-dispatched` = saved from a staged or
|
|
29
|
+
* dispatched proposal's digest (its already-stored graph reused). */
|
|
30
|
+
export const DELIVERY_LIBRARY_SOURCES = ["composed", "imported", "from-staged", "from-dispatched"] as const;
|
|
31
|
+
export type DeliveryLibrarySource = typeof DELIVERY_LIBRARY_SOURCES[number];
|
|
32
|
+
|
|
33
|
+
/** One saved library entry — the durable row keyed by name-derived `id`. `graph` is the serialised
|
|
34
|
+
* `DeliveryGraph` JSON (validated/compiled before it is ever written). */
|
|
35
|
+
export interface DeliveryGraphLibraryEntry {
|
|
36
|
+
id: string;
|
|
37
|
+
name: string;
|
|
38
|
+
description: string | null;
|
|
39
|
+
graph: string;
|
|
40
|
+
source: DeliveryLibrarySource;
|
|
41
|
+
created_at: string;
|
|
42
|
+
updated_at: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The `delivery_graph_library` aggregate accessor — the durable library store keyed by name-derived `id`. */
|
|
46
|
+
export const deliveryGraphLibrary = (data: DataLayer) =>
|
|
47
|
+
data.table<DeliveryGraphLibraryEntry>("delivery_graph_library", "id");
|
|
48
|
+
|
|
49
|
+
/** Slugify a human name into an id-safe token: lowercased, non-alphanumerics collapsed to single `-`,
|
|
50
|
+
* trimmed of leading/trailing `-`. A name with no alphanumerics (e.g. `" "`) yields `"graph"` so the
|
|
51
|
+
* id is always well-formed. */
|
|
52
|
+
export function slugifyName(name: string): string {
|
|
53
|
+
const slug = name
|
|
54
|
+
.trim()
|
|
55
|
+
.toLowerCase()
|
|
56
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
57
|
+
.replace(/^-+|-+$/g, "");
|
|
58
|
+
return slug || "graph";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The stable, human-readable library id for a NAME — `<slug>-<sha256(name)[:8]>`. Derived from the
|
|
62
|
+
* (mutable) name rather than the content, so an entry's graph can be edited without moving its row, and
|
|
63
|
+
* re-saving the SAME name is idempotent (same id → upsert). The short hash disambiguates two names that
|
|
64
|
+
* slugify to the same token (e.g. `"Runbook A"` vs `"runbook.a"`), keyed off the ORIGINAL trimmed name
|
|
65
|
+
* so the id survives punctuation the slug drops. */
|
|
66
|
+
export function libraryEntryId(name: string): string {
|
|
67
|
+
const trimmed = name.trim();
|
|
68
|
+
const hash = createHash("sha256").update(trimmed).digest("hex").slice(0, 8);
|
|
69
|
+
return `${slugifyName(trimmed)}-${hash}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Build a durable library row for a NAMED graph. `createdAt` is preserved across an idempotent re-save
|
|
73
|
+
* of the same name (omit it — defaulting to now — for a first save). `graphJson` must already be a
|
|
74
|
+
* validated/compiled `DeliveryGraph` serialisation (the door validates via `parseAndCompileText`). */
|
|
75
|
+
export function buildLibraryEntryRow(input: {
|
|
76
|
+
name: string;
|
|
77
|
+
description?: string | null;
|
|
78
|
+
graphJson: string;
|
|
79
|
+
source: DeliveryLibrarySource;
|
|
80
|
+
createdAt?: string;
|
|
81
|
+
}): DeliveryGraphLibraryEntry {
|
|
82
|
+
const at = now();
|
|
83
|
+
const name = input.name.trim();
|
|
84
|
+
const description = typeof input.description === "string" && input.description.trim() !== "" ? input.description.trim() : null;
|
|
85
|
+
return {
|
|
86
|
+
id: libraryEntryId(name),
|
|
87
|
+
name,
|
|
88
|
+
description,
|
|
89
|
+
graph: input.graphJson,
|
|
90
|
+
source: input.source,
|
|
91
|
+
created_at: input.createdAt ?? at,
|
|
92
|
+
updated_at: at,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Persist a library entry, UPSERTing on its name-derived `id`. A first save inserts; a re-save of the
|
|
97
|
+
* same name refreshes the graph/description/source and `updated_at` while PRESERVING the original
|
|
98
|
+
* `created_at` — an edit updates in place, it does not create a duplicate. Because `created_at` is
|
|
99
|
+
* preserved, a re-save never changes the entry's position in the newest-first list, regardless of
|
|
100
|
+
* which fields (graph included) changed. Returns the written row. */
|
|
101
|
+
export async function saveLibraryEntry(data: DataLayer, row: DeliveryGraphLibraryEntry): Promise<DeliveryGraphLibraryEntry> {
|
|
102
|
+
const table = deliveryGraphLibrary(data);
|
|
103
|
+
const existing = await table.get(row.id);
|
|
104
|
+
const toWrite: DeliveryGraphLibraryEntry = existing ? { ...row, created_at: existing.created_at } : row;
|
|
105
|
+
if (existing) {
|
|
106
|
+
const { id, ...patch } = toWrite;
|
|
107
|
+
await table.update(row.id, patch);
|
|
108
|
+
} else {
|
|
109
|
+
await table.insert(toWrite);
|
|
110
|
+
}
|
|
111
|
+
return toWrite;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Every saved library entry, newest first. The Library App-View (S4/#523) polls this to render the
|
|
115
|
+
* list. Read-only; no write. `DataLayer.all()` has no `ORDER BY` (see `app/lineage.ts`), so — like
|
|
116
|
+
* every other read-projection in this app — we sort in memory. The library is a curated, small-
|
|
117
|
+
* cardinality set (human-saved graphs), so the O(n log n) sort is not a hot path; the
|
|
118
|
+
* `ix_delivery_graph_library_created` index is kept for a future DB-ordered read path. */
|
|
119
|
+
export async function listLibraryEntries(data: DataLayer): Promise<DeliveryGraphLibraryEntry[]> {
|
|
120
|
+
const rows = await deliveryGraphLibrary(data).all();
|
|
121
|
+
return rows.sort((a, b) => b.created_at.localeCompare(a.created_at));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Load one library entry by its `id`, or null when unknown. */
|
|
125
|
+
export async function getLibraryEntry(data: DataLayer, id: string): Promise<DeliveryGraphLibraryEntry | null> {
|
|
126
|
+
return (await deliveryGraphLibrary(data).get(id)) ?? null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Delete one library entry by its `id`. Returns true when a row was removed, false when the id named
|
|
130
|
+
* nothing (idempotent — a re-delete of an already-gone entry is a clean no-op). */
|
|
131
|
+
export async function deleteLibraryEntry(data: DataLayer, id: string): Promise<boolean> {
|
|
132
|
+
const table = deliveryGraphLibrary(data);
|
|
133
|
+
const existing = await table.get(id);
|
|
134
|
+
if (!existing) return false;
|
|
135
|
+
await table.delete(id);
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Project a durable library row into the operator-facing `DeliveryGraphLibraryEntry` DTO the doors
|
|
140
|
+
* return — the single snake_case → camelCase mapping, so no door re-invents it (derivation over
|
|
141
|
+
* duplication). The full `graph` JSON is carried so the export affordance (S6/#525) can build a
|
|
142
|
+
* client-side download from a list payload without a second fetch. */
|
|
143
|
+
export function libraryEntryDto(row: DeliveryGraphLibraryEntry): DeliveryGraphLibraryEntryDto {
|
|
144
|
+
return {
|
|
145
|
+
id: row.id,
|
|
146
|
+
name: row.name,
|
|
147
|
+
description: row.description,
|
|
148
|
+
graph: row.graph,
|
|
149
|
+
source: row.source,
|
|
150
|
+
createdAt: row.created_at,
|
|
151
|
+
updatedAt: row.updated_at,
|
|
152
|
+
};
|
|
153
|
+
}
|