@nanobpm/nano-workforce 0.138.3 → 0.139.1

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 (43) 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/db/migrations/085_delivery_graph_library.sql +32 -0
  15. package/openapi.yaml +366 -0
  16. package/operations/deleteLibraryEntry.test.ts +88 -0
  17. package/operations/deleteLibraryEntry.ts +23 -0
  18. package/operations/dismissProposal.test.ts +105 -0
  19. package/operations/dismissProposal.ts +53 -0
  20. package/operations/getLibraryEntry.test.ts +75 -0
  21. package/operations/getLibraryEntry.ts +25 -0
  22. package/operations/importToLibrary.test.ts +195 -0
  23. package/operations/importToLibrary.ts +62 -0
  24. package/operations/listLibrary.test.ts +79 -0
  25. package/operations/listLibrary.ts +24 -0
  26. package/operations/saveToLibrary.test.ts +225 -0
  27. package/operations/saveToLibrary.ts +89 -0
  28. package/package.json +1 -1
  29. package/pages/delivery-graphs/delivery-graphs.css +33 -0
  30. package/pages/delivery-graphs/embed.html +1 -0
  31. package/pages/delivery-graphs/library-embed.html +31 -0
  32. package/pages/delivery-graphs/library-standalone.html +38 -0
  33. package/pages/delivery-graphs/library.mount.js +374 -0
  34. package/pages/delivery-graphs/mount.js +144 -6
  35. package/pages/delivery-graphs/staged.mount.js +124 -9
  36. package/pages/delivery-graphs/standalone.html +2 -1
  37. package/pages/delivery-graphs.page.json +24 -1
  38. package/scripts/pages-contract.test.ts +50 -0
  39. package/test/delivery-graphs-embed.test.ts +39 -16
  40. package/test/delivery-graphs-import.test.ts +110 -0
  41. package/test/delivery-graphs-library-embed.test.ts +156 -0
  42. package/test/delivery-graphs-library-export.test.ts +62 -0
  43. package/test/delivery-graphs-staged-embed.test.ts +69 -17
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.139.1](https://github.com/nanobpm/nano-workforce/compare/v0.139.0...v0.139.1) (2026-08-24)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **delivery-graphs:** anchor App-View door defaults to import.meta.url ([#536](https://github.com/nanobpm/nano-workforce/issues/536)) ([#537](https://github.com/nanobpm/nano-workforce/issues/537)) ([58cd57e](https://github.com/nanobpm/nano-workforce/commit/58cd57e7f35937a9d1720e2fd4d938f70ce8f304)), closes [#279](https://github.com/nanobpm/nano-workforce/issues/279) [#467](https://github.com/nanobpm/nano-workforce/issues/467) [#279-only](https://github.com/nanobpm/nano-workforce/issues/279-only) [#279](https://github.com/nanobpm/nano-workforce/issues/279) [#279](https://github.com/nanobpm/nano-workforce/issues/279)
6
+
7
+ ## [0.139.0](https://github.com/nanobpm/nano-workforce/compare/v0.138.3...v0.139.0) (2026-08-24)
8
+
9
+ ### Features
10
+
11
+ * 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)
12
+
1
13
  ## [0.138.3](https://github.com/nanobpm/nano-workforce/compare/v0.138.2...v0.138.3) (2026-08-24)
2
14
 
3
15
  ### 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 /" } }],
@@ -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 both the OpenAPI shape gate (at the edge) and the semantic validator, so it is
280
- // safe to narrow to the typed contract. Every field below is well-formed by construction.
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
+ }