@nanobpm/nano-workforce 0.138.2 → 0.139.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/app/contracts.ts +16 -0
  3. package/app/deliveryGraph.test.ts +84 -0
  4. package/app/deliveryGraph.ts +79 -0
  5. package/app/deliveryGraphCompiler.ts +4 -2
  6. package/app/deliveryGraphLibrary.test.ts +134 -0
  7. package/app/deliveryGraphLibrary.ts +153 -0
  8. package/app/deliveryGraphProposals.test.ts +136 -0
  9. package/app/deliveryGraphProposals.ts +54 -6
  10. package/app/deliveryGraphShape.test.ts +66 -0
  11. package/app/deliveryGraphShape.ts +67 -0
  12. package/app/deliveryGraphTextIngress.test.ts +137 -0
  13. package/app/deliveryGraphTextIngress.ts +73 -3
  14. package/app/planReadModel.test.ts +23 -0
  15. package/db/migrations/084_plan_wave_tasks_effective_status.sql +51 -0
  16. package/db/migrations/085_delivery_graph_library.sql +32 -0
  17. package/openapi.yaml +366 -0
  18. package/operations/deleteLibraryEntry.test.ts +88 -0
  19. package/operations/deleteLibraryEntry.ts +23 -0
  20. package/operations/dismissProposal.test.ts +105 -0
  21. package/operations/dismissProposal.ts +53 -0
  22. package/operations/getLibraryEntry.test.ts +75 -0
  23. package/operations/getLibraryEntry.ts +25 -0
  24. package/operations/importToLibrary.test.ts +195 -0
  25. package/operations/importToLibrary.ts +62 -0
  26. package/operations/listLibrary.test.ts +79 -0
  27. package/operations/listLibrary.ts +24 -0
  28. package/operations/saveToLibrary.test.ts +225 -0
  29. package/operations/saveToLibrary.ts +89 -0
  30. package/package.json +1 -1
  31. package/pages/delivery-graphs/delivery-graphs.css +33 -0
  32. package/pages/delivery-graphs/embed.html +1 -0
  33. package/pages/delivery-graphs/library-embed.html +31 -0
  34. package/pages/delivery-graphs/library-standalone.html +38 -0
  35. package/pages/delivery-graphs/library.mount.js +364 -0
  36. package/pages/delivery-graphs/mount.js +133 -4
  37. package/pages/delivery-graphs/staged.mount.js +109 -4
  38. package/pages/delivery-graphs/standalone.html +2 -1
  39. package/pages/delivery-graphs.page.json +24 -1
  40. package/scripts/pages-contract.test.ts +50 -0
  41. package/test/delivery-graphs-import.test.ts +92 -0
  42. package/test/delivery-graphs-library-embed.test.ts +148 -0
  43. package/test/delivery-graphs-library-export.test.ts +62 -0
  44. package/test/delivery-graphs-staged-embed.test.ts +9 -0
@@ -0,0 +1,51 @@
1
+ -- Epic-detail wave-state: a merged slice must not read "opened" (drift with the summary bar).
2
+ --
3
+ -- `plan_wave_tasks` (059) is the per-task display VIEW the Epic-detail "Wave state" grid binds
4
+ -- (pages/epic-detail.page.json → `wave-state`). Its `status` column exposed the RAW
5
+ -- `plan_tasks.status` verbatim — but nothing writes `plan_tasks.status = 'merged'` when a slice's PR
6
+ -- lands: the merge write-path flips `pull_requests.status` (and, for a close-without-merge, the
7
+ -- `abandonClosedPr` self-heal flips the task terminal), yet a *merged* PR leaves its `plan_tasks` row
8
+ -- frozen at `opened`. So a converged-and-merged slice kept showing Status "opened" in the grid, even
9
+ -- as its sibling summary VIEWs (`plan_wave_counts`/`plan_wave_summary`) already counted it `merged`
10
+ -- via the PR join. That is exactly the drift AGENTS.md forbids: two sibling VIEWs over the same join
11
+ -- disagreeing on whether a slice is merged.
12
+ --
13
+ -- Fix by DERIVING the displayed status the SAME way the count VIEWs bucket `merged` (082): a task is
14
+ -- `merged` iff its PR reached `pull_requests__tracking.derived_status = 'merged'` (the terminal-folded
15
+ -- status the canonical runtime reads, ADR-0065), otherwise it falls through to its own
16
+ -- `plan_tasks.status`. This keeps the per-task grid and the per-wave summary bar in exact agreement —
17
+ -- a single notion of "effective task status", still fully DERIVED with no write-path. `pr_url` /
18
+ -- `process_key` link targets are re-exported by the `pull_requests__tracking` VIEW (`p.*`), so the
19
+ -- single join now sources both the effective status and the link targets. The "Active" tab filter
20
+ -- (`status IN (pending,opened,escalated,waiting-for-lane,blocked)`) therefore drops a merged slice as
21
+ -- intended instead of stranding it under "opened".
22
+ --
23
+ -- Forward-only VIEW redefinition (DROP then CREATE); a merged VIEW is not editable in place. The
24
+ -- runner wraps each file in its own transaction, so this file must NOT contain BEGIN/COMMIT. Kept a
25
+ -- plain `CREATE VIEW … AS SELECT … FROM …` (no CTE / no select-list subquery) so the static
26
+ -- pages↔schema contract guard (scripts/pages-contract.test.ts) still parses it. Numbered after 083.
27
+
28
+ DROP VIEW IF EXISTS plan_wave_tasks;
29
+
30
+ CREATE VIEW plan_wave_tasks AS
31
+ SELECT
32
+ t.id AS id,
33
+ t.plan_key AS plan_key,
34
+ t.task_index AS task_index,
35
+ t.task_id AS task_id,
36
+ t.title AS title,
37
+ t.prompt AS prompt,
38
+ CASE WHEN p.derived_status = 'merged' THEN 'merged' ELSE t.status END AS status,
39
+ t.pr_key AS pr_key,
40
+ t.summary AS summary,
41
+ t.created_at AS created_at,
42
+ t.updated_at AS updated_at,
43
+ t.wave AS wave,
44
+ t.open_question AS open_question,
45
+ t.answer AS answer,
46
+ t.draft_pr_key AS draft_pr_key,
47
+ t.corr_key AS corr_key,
48
+ p.url AS pr_url,
49
+ p.process_key AS process_key
50
+ FROM plan_tasks t
51
+ LEFT JOIN pull_requests__tracking p ON p.pr_key = t.pr_key;
@@ -0,0 +1,32 @@
1
+ -- The reusable delivery-graph LIBRARY store (issue #522, epic #519 S3) — the durable base the
2
+ -- Library App-View (S4/#523), filesystem import (S5/#524), and export (S6/#525) build on. Unlike the
3
+ -- `staged` proposals store (`delivery_graph_proposals`, migration 075), which is content-digest-keyed
4
+ -- and TTL-swept, a library entry is meant to be EDITED and KEPT: its graph can change while its
5
+ -- identity stays stable, and it never ages out.
6
+ --
7
+ -- • id (PK) — a slug + short-hash of the entry's NAME (`<slug>-<sha256(name)[:8]>`), NOT the content
8
+ -- digest. Library entries are editable (the graph is mutable), so keying on the content would move
9
+ -- the row on every edit; keying on the (human, mutable) name gives a stable, human-readable id that
10
+ -- is idempotent on re-save of the same name (an upsert refreshes the graph, preserves created_at).
11
+ -- • name — the human name of the saved graph. Its slug/hash derives the id.
12
+ -- • description — an optional human note.
13
+ -- • graph — the `DeliveryGraph` JSON (serialised). Validated (compiled) at save time so an
14
+ -- uncompilable graph can never be persisted.
15
+ -- • source — how the entry entered the library: `composed` (saved from a raw graph JSON),
16
+ -- `imported` (loaded from the filesystem, S5/#524), `from-staged` (saved from a staged proposal's
17
+ -- digest), or `from-dispatched` (saved from a dispatched proposal's digest).
18
+ -- • created_at / updated_at — ISO-8601 timestamps; created_at is preserved across an idempotent
19
+ -- re-save of the same name. No `expires_at`: the library has NO TTL (contrast `delivery_graph_proposals`).
20
+ CREATE TABLE IF NOT EXISTS delivery_graph_library (
21
+ id TEXT PRIMARY KEY,
22
+ name TEXT NOT NULL,
23
+ description TEXT,
24
+ graph TEXT NOT NULL,
25
+ source TEXT NOT NULL DEFAULT 'composed',
26
+ created_at TEXT NOT NULL,
27
+ updated_at TEXT NOT NULL
28
+ );
29
+
30
+ -- The list App-View orders entries newest-first; index the sort key.
31
+ CREATE INDEX IF NOT EXISTS ix_delivery_graph_library_created
32
+ ON delivery_graph_library (created_at);
package/openapi.yaml CHANGED
@@ -1663,6 +1663,21 @@ components:
1663
1663
  description: >-
1664
1664
  OPTIONAL run-level ISO-8601 SLA for `human` nodes (#505) before they record an `escalated`
1665
1665
  outcome. Absent → the `P1D` default. An invalid duration is rejected at submit.
1666
+ DeliveryGraphDismissRequest:
1667
+ description: >-
1668
+ The OPERATOR dismiss request (#520). The cockpit's staged-proposals grid posts the content
1669
+ `digest` of the proposal the operator wants to discard as noise; the door loads that staged
1670
+ proposal and flips it to the terminal `dismissed` status. Dismiss is an operator action — this
1671
+ request carries NO graph and NO token (the graph is already staged; the operator's click is the
1672
+ approval). It launches nothing.
1673
+ type: object
1674
+ additionalProperties: false
1675
+ required:
1676
+ - digest
1677
+ properties:
1678
+ digest:
1679
+ type: string
1680
+ description: The staged proposal's content digest (its primary key) — the proposal to dismiss.
1666
1681
  DeliveryGraphProposalBpmnRequest:
1667
1682
  description: >-
1668
1683
  Request the compiled BPMN of a staged delivery-graph proposal for read-only DI PREVIEW. Carries
@@ -1757,6 +1772,153 @@ components:
1757
1772
  type: array
1758
1773
  items:
1759
1774
  $ref: "#/components/schemas/StagedProposalSummary"
1775
+ DeliveryGraphLibraryEntry:
1776
+ description: >-
1777
+ One saved reusable delivery-graph LIBRARY entry (issue #522, epic #519 S3) — the durable base
1778
+ the Library App-View (S4/#523), filesystem import (S5/#524), and export (S6/#525) build on.
1779
+ Unlike a `staged` proposal (content-digest-keyed, TTL-swept), a library entry is keyed by a
1780
+ slug + short-hash of its NAME — the name *is* the identity — so its graph can be edited in
1781
+ place without moving the row, and it never ages out. (A rename is not an in-place update:
1782
+ because the id is derived from the name, renaming derives a new id, i.e. a new entry; the old
1783
+ row remains until explicitly deleted.) The full `graph` JSON is carried so the export
1784
+ affordance (S6/#525) can build a client-side download from the list payload without a second
1785
+ fetch.
1786
+ type: object
1787
+ additionalProperties: false
1788
+ required:
1789
+ - id
1790
+ - name
1791
+ - description
1792
+ - graph
1793
+ - source
1794
+ - createdAt
1795
+ - updatedAt
1796
+ properties:
1797
+ id:
1798
+ type: string
1799
+ description: The entry's stable id — `<slug>-<sha256(name)[:8]>`, derived from the name (not the content).
1800
+ name:
1801
+ type: string
1802
+ description: The saved graph's human name.
1803
+ description:
1804
+ type: string
1805
+ nullable: true
1806
+ description: An optional human note, or null when none was given.
1807
+ graph:
1808
+ type: string
1809
+ description: The `DeliveryGraph` JSON (serialised) — validated/compiled before it was ever persisted.
1810
+ source:
1811
+ type: string
1812
+ enum: [composed, imported, from-staged, from-dispatched]
1813
+ description: How the entry entered the library (raw compose, filesystem import, or a staged/dispatched proposal's digest).
1814
+ createdAt:
1815
+ type: string
1816
+ description: When the entry was first saved (ISO-8601); preserved across an idempotent re-save of the same name.
1817
+ updatedAt:
1818
+ type: string
1819
+ description: When the entry was last saved/edited (ISO-8601).
1820
+ DeliveryGraphLibraryList:
1821
+ description: Every saved library entry (issue #522), newest first.
1822
+ type: object
1823
+ additionalProperties: false
1824
+ required:
1825
+ - count
1826
+ - entries
1827
+ properties:
1828
+ count:
1829
+ type: integer
1830
+ entries:
1831
+ type: array
1832
+ items:
1833
+ $ref: "#/components/schemas/DeliveryGraphLibraryEntry"
1834
+ SaveToLibrarySubmit:
1835
+ description: >-
1836
+ Save a delivery graph to the reusable library (issue #522). Carries the entry `name` (its
1837
+ slug/hash derive the id) and an optional `description`, PLUS exactly one graph source: either a
1838
+ raw `graphJson` STRING (validated + compiled before persisting, `source: composed`), or the
1839
+ `digest` of an existing staged/dispatched proposal whose already-stored graph is reused
1840
+ (`source: from-staged` / `from-dispatched`). A graph that fails to compile is a clean 400 and
1841
+ nothing is persisted.
1842
+ type: object
1843
+ additionalProperties: false
1844
+ required:
1845
+ - name
1846
+ properties:
1847
+ name:
1848
+ type: string
1849
+ description: The entry's human name — its slug + short-hash derive the stable library id (re-saving the same name upserts).
1850
+ description:
1851
+ type: string
1852
+ description: An optional human note stored alongside the entry.
1853
+ graphJson:
1854
+ type: string
1855
+ description: >-
1856
+ A raw `DeliveryGraph` JSON string to validate, compile and save (`source: composed`).
1857
+ Mutually exclusive with `digest`.
1858
+ digest:
1859
+ type: string
1860
+ description: The content digest of an existing staged/dispatched proposal whose stored graph is reused. Mutually exclusive with `graphJson`.
1861
+ ImportToLibrarySubmit:
1862
+ description: >-
1863
+ Import a delivery graph into the reusable library FROM A FILE (issue #524, epic #519 S5). The
1864
+ compose App-View's `<input type=file accept=.json>` reads the selected file's text client-side
1865
+ and POSTs it here as the raw `graphJson` string. The door validates + compiles it via the SAME
1866
+ `parseAndCompileText` pipeline the preview/stage/save doors use, then persists it with
1867
+ `source: imported`. A file that is not valid JSON, or a graph that fails to compile, is a clean
1868
+ 400 with path-qualified errors and NOTHING is persisted. The entry `name` defaults to the
1869
+ imported graph's own `name`; an explicit `name` overrides it (an unnamed graph with no override
1870
+ is a clean 400).
1871
+ type: object
1872
+ additionalProperties: false
1873
+ required:
1874
+ - graphJson
1875
+ properties:
1876
+ graphJson:
1877
+ type: string
1878
+ description: The raw `DeliveryGraph` JSON text read from the imported file (validated + compiled before persisting).
1879
+ name:
1880
+ type: string
1881
+ description: Optional override for the entry name — its slug + short-hash derive the library id. Defaults to the imported graph's own `name`.
1882
+ description:
1883
+ type: string
1884
+ description: An optional human note stored alongside the imported entry.
1885
+ SaveToLibraryResult:
1886
+ description: >-
1887
+ The save-to-library outcome (issue #522). `ok` discriminates success; success carries the
1888
+ persisted `entry`, a failure carries a human `error` (and, for a compile failure, path-qualified
1889
+ `errors`). Nothing is persisted on a failure.
1890
+ type: object
1891
+ additionalProperties: false
1892
+ required:
1893
+ - ok
1894
+ properties:
1895
+ ok:
1896
+ type: boolean
1897
+ description: True when the graph validated and the entry was saved; false otherwise.
1898
+ error:
1899
+ type: string
1900
+ description: A human-readable failure message.
1901
+ errors:
1902
+ type: array
1903
+ items:
1904
+ $ref: "#/components/schemas/DeliveryCompileError"
1905
+ description: Path-qualified validation/compile failures, when the submitted or referenced graph was malformed.
1906
+ entry:
1907
+ $ref: "#/components/schemas/DeliveryGraphLibraryEntry"
1908
+ DeleteLibraryEntryResult:
1909
+ description: The delete-library-entry outcome (issue #522). `deleted` is true when a row was removed, false when the id named nothing (idempotent).
1910
+ type: object
1911
+ additionalProperties: false
1912
+ required:
1913
+ - ok
1914
+ - deleted
1915
+ properties:
1916
+ ok:
1917
+ type: boolean
1918
+ description: True — the request was well-formed and processed.
1919
+ deleted:
1920
+ type: boolean
1921
+ description: True when an entry was removed; false when the id named no entry (a re-delete is a clean no-op).
1760
1922
  DeliveryGraphTextResult:
1761
1923
  description: >-
1762
1924
  The delivery-graph text-ingress outcome (issue #460) — a single shape covering the JSON-paste
@@ -3020,6 +3182,39 @@ paths:
3020
3182
  application/json:
3021
3183
  schema:
3022
3184
  $ref: "#/components/schemas/DeliveryGraphTextResult"
3185
+ /actions/delivery-graph/dismiss:
3186
+ post:
3187
+ operationId: dismissProposal
3188
+ summary: OPERATOR DISMISS — discard a staged delivery-graph proposal by its digest as noise (idempotent). (#520)
3189
+ description: >-
3190
+ The OPERATOR-ONLY dismiss door (#520). The cockpit's staged-proposals grid posts the `digest` of
3191
+ the proposal the operator wants to discard as noise; this door loads that live `staged` proposal
3192
+ and flips it to the terminal `dismissed` status, so it drops out of the staged list — exactly like
3193
+ `superseded`/`expired`, but recording a deliberate operator discard rather than a TTL sweep or a
3194
+ newer digest landing. It launches nothing. Idempotent: a re-dismiss of an already-terminal
3195
+ (dismissed / dispatched / superseded / expired) or unknown digest is a clean 400, leaving state
3196
+ untouched.
3197
+ requestBody:
3198
+ required: true
3199
+ content:
3200
+ application/json:
3201
+ schema:
3202
+ $ref: "#/components/schemas/DeliveryGraphDismissRequest"
3203
+ responses:
3204
+ "200":
3205
+ description: The staged proposal was dismissed; it drops out of the staged list.
3206
+ content:
3207
+ application/json:
3208
+ schema:
3209
+ $ref: "#/components/schemas/DeliveryGraphTextResult"
3210
+ "400":
3211
+ description: >-
3212
+ The digest was missing, or named no live staged proposal (unknown / already dismissed /
3213
+ dispatched / superseded / expired). The body carries a human `error`. Nothing changed.
3214
+ content:
3215
+ application/json:
3216
+ schema:
3217
+ $ref: "#/components/schemas/DeliveryGraphTextResult"
3023
3218
  /actions/delivery-graph/proposal-bpmn:
3024
3219
  post:
3025
3220
  operationId: previewProposalBpmn
@@ -3076,6 +3271,177 @@ paths:
3076
3271
  application/json:
3077
3272
  schema:
3078
3273
  $ref: "#/components/schemas/ErrorBody"
3274
+ /actions/delivery-graph/library/save:
3275
+ post:
3276
+ operationId: saveToLibrary
3277
+ summary: Save a delivery graph to the reusable library — from a raw graph JSON or an existing proposal digest (issue #522, #519 S3).
3278
+ description: >-
3279
+ Persist a delivery graph to the reusable LIBRARY (issue #522, epic #519 S3) — the durable base
3280
+ S4/S5/S6 build on. The request carries the entry `name` (its slug + short-hash derive the stable
3281
+ library id, so re-saving the same name upserts) plus EITHER a raw `graphJson` STRING (validated
3282
+ and compiled via the SAME `parseAndCompileText` pipeline the preview/stage doors use, then saved
3283
+ with `source: composed`) OR the `digest` of an existing staged/dispatched proposal whose
3284
+ already-stored graph is reused (`source: from-staged` / `from-dispatched`). A graph that is not
3285
+ valid JSON or fails to compile is a clean 400 and NOTHING is persisted (an uncompilable graph can
3286
+ never enter the library). Unlike a staged proposal, a library entry has no TTL. This door is
3287
+ INTENTIONALLY UNGUARDED (no shared-secret requirement), unlike the get/delete/import library
3288
+ doors: it is also invoked by a DECLARATIVE page row action ("Save to library" on the
3289
+ In-flight/History grid) that structurally cannot attach the `x-hook-secret` header, so a guard
3290
+ here would make the door unreachable by its own UI. See PR #533 review.
3291
+ requestBody:
3292
+ required: true
3293
+ content:
3294
+ application/json:
3295
+ schema:
3296
+ $ref: "#/components/schemas/SaveToLibrarySubmit"
3297
+ responses:
3298
+ "200":
3299
+ description: The graph validated and the entry was saved (upserted on its name-derived id).
3300
+ content:
3301
+ application/json:
3302
+ schema:
3303
+ $ref: "#/components/schemas/SaveToLibraryResult"
3304
+ "400":
3305
+ description: The request was malformed, the referenced digest named no stored graph, or the graph failed to compile — nothing persisted.
3306
+ content:
3307
+ application/json:
3308
+ schema:
3309
+ $ref: "#/components/schemas/SaveToLibraryResult"
3310
+ /actions/delivery-graph/library/import:
3311
+ post:
3312
+ operationId: importToLibrary
3313
+ summary: Import a delivery graph FROM A FILE into the reusable library (issue #524, #519 S5).
3314
+ description: >-
3315
+ Import a delivery graph into the LIBRARY from a filesystem file (issue #524, epic #519 S5). The
3316
+ compose App-View's `<input type=file accept=.json>` reads the chosen file's text client-side and
3317
+ POSTs it here as the raw `graphJson`. The door validates + compiles it via the SAME
3318
+ `parseAndCompileText` pipeline the preview/stage/save doors use, then persists it with
3319
+ `source: imported`. A file that is not valid JSON, or a graph that fails to compile, is a clean
3320
+ 400 with path-qualified errors and NOTHING is persisted. The entry name defaults to the imported
3321
+ graph's own `name` (an explicit `name` overrides it). The optional shared-secret guard mirrors the
3322
+ get/delete library doors: when NANO_PR_WEBHOOK_SECRET is set, callers must present it via the
3323
+ x-hook-secret header (unset → open). (The `save` door is unguarded — it is also reached by a
3324
+ declarative page row action that cannot carry the header.)
3325
+ security:
3326
+ - hookSecret: []
3327
+ - {}
3328
+ requestBody:
3329
+ required: true
3330
+ content:
3331
+ application/json:
3332
+ schema:
3333
+ $ref: "#/components/schemas/ImportToLibrarySubmit"
3334
+ responses:
3335
+ "200":
3336
+ description: The imported graph validated and the entry was saved (source=imported).
3337
+ content:
3338
+ application/json:
3339
+ schema:
3340
+ $ref: "#/components/schemas/SaveToLibraryResult"
3341
+ "400":
3342
+ description: The file was not valid JSON, carried no usable name, or the graph failed to compile — nothing persisted.
3343
+ content:
3344
+ application/json:
3345
+ schema:
3346
+ $ref: "#/components/schemas/SaveToLibraryResult"
3347
+ "401":
3348
+ description: Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).
3349
+ content:
3350
+ application/json:
3351
+ schema:
3352
+ $ref: "#/components/schemas/SaveToLibraryResult"
3353
+ /delivery-graph/library:
3354
+ get:
3355
+ operationId: listLibrary
3356
+ summary: List the saved reusable delivery-graph library entries (issue #522, #519 S3), newest first.
3357
+ description: >-
3358
+ The read behind the Library App-View (S4/#523): every saved library entry, newest first, with
3359
+ its full `graph` JSON so the export affordance (S6/#525) can build a client-side download from
3360
+ the list payload. Read-only. The optional shared-secret guard mirrors the other read doors
3361
+ (getLineage / listActivePrs / listStagedProposals): when NANO_PR_WEBHOOK_SECRET is set, callers
3362
+ must present it via the x-hook-secret header.
3363
+ security:
3364
+ - hookSecret: []
3365
+ - {}
3366
+ responses:
3367
+ "200":
3368
+ description: The saved library entries, newest first.
3369
+ content:
3370
+ application/json:
3371
+ schema:
3372
+ $ref: "#/components/schemas/DeliveryGraphLibraryList"
3373
+ "401":
3374
+ description: Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).
3375
+ content:
3376
+ application/json:
3377
+ schema:
3378
+ $ref: "#/components/schemas/ErrorBody"
3379
+ /delivery-graph/library/{id}:
3380
+ get:
3381
+ operationId: getLibraryEntry
3382
+ summary: Fetch one saved library entry by id (issue #522, #519 S3).
3383
+ description: >-
3384
+ Return one saved library entry (issue #522) by its `id`, including its full `graph` JSON. An
3385
+ unknown id is a clean 404. The optional shared-secret guard mirrors the other read doors.
3386
+ security:
3387
+ - hookSecret: []
3388
+ - {}
3389
+ parameters:
3390
+ - name: id
3391
+ in: path
3392
+ required: true
3393
+ schema:
3394
+ type: string
3395
+ description: The library entry's id (`<slug>-<sha256(name)[:8]>`).
3396
+ responses:
3397
+ "200":
3398
+ description: The library entry.
3399
+ content:
3400
+ application/json:
3401
+ schema:
3402
+ $ref: "#/components/schemas/DeliveryGraphLibraryEntry"
3403
+ "401":
3404
+ description: Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).
3405
+ content:
3406
+ application/json:
3407
+ schema:
3408
+ $ref: "#/components/schemas/ErrorBody"
3409
+ "404":
3410
+ description: No library entry exists for the given id.
3411
+ content:
3412
+ application/json:
3413
+ schema:
3414
+ $ref: "#/components/schemas/ErrorBody"
3415
+ delete:
3416
+ operationId: deleteLibraryEntry
3417
+ summary: Delete one saved library entry by id (issue #522, #519 S3).
3418
+ description: >-
3419
+ Delete one saved library entry (issue #522) by its `id`. Idempotent — deleting an id that names
3420
+ no entry returns `deleted: false` (a clean no-op), not an error. The optional shared-secret guard
3421
+ mirrors the other write doors.
3422
+ security:
3423
+ - hookSecret: []
3424
+ - {}
3425
+ parameters:
3426
+ - name: id
3427
+ in: path
3428
+ required: true
3429
+ schema:
3430
+ type: string
3431
+ description: The library entry's id to delete.
3432
+ responses:
3433
+ "200":
3434
+ description: The delete was processed (see `deleted` for whether a row was actually removed).
3435
+ content:
3436
+ application/json:
3437
+ schema:
3438
+ $ref: "#/components/schemas/DeleteLibraryEntryResult"
3439
+ "401":
3440
+ description: Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).
3441
+ content:
3442
+ application/json:
3443
+ schema:
3444
+ $ref: "#/components/schemas/ErrorBody"
3079
3445
  /actions/start/feature:
3080
3446
  post:
3081
3447
  operationId: startFeature
@@ -0,0 +1,88 @@
1
+ // Tests for DELETE /app/api/delivery-graph/library/{id} → `deleteLibraryEntry` (issue #522, epic #519
2
+ // S3). Idempotent delete: a known id is removed (`deleted:true`); an unknown / already-gone id is a
3
+ // clean no-op (`deleted:false`), never an error.
4
+ import { mkdtempSync, rmSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join, resolve } from "node:path";
7
+ import { test } from "node:test";
8
+ import { assertEquals } from "#test-assert";
9
+ import type { AppApi, DataLayer } from "@nanobpm/urban";
10
+ import { bootTestApp } from "@nanobpm/urban-testkit";
11
+ import { buildLibraryEntryRow, deliveryGraphLibrary, saveLibraryEntry } from "../app/deliveryGraphLibrary.ts";
12
+ import { noopLog } from "../test/log.ts";
13
+ import handler from "./deleteLibraryEntry.ts";
14
+
15
+ const APP_ROOT = resolve(import.meta.dirname, "..");
16
+ const GRAPH = JSON.stringify({ name: "runbook", nodes: [] });
17
+
18
+ async function withApp(fn: (app: AppApi, data: DataLayer) => Promise<void>): Promise<void> {
19
+ const dir = mkdtempSync(join(tmpdir(), "nwf-dglibdel-"));
20
+ const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dir, "app.db")}` } });
21
+ try {
22
+ const edge = { data: app.db, log: noopLog() } as unknown as AppApi;
23
+ await fn(edge, app.db);
24
+ } finally {
25
+ await app.stop?.();
26
+ rmSync(dir, { recursive: true, force: true });
27
+ }
28
+ }
29
+
30
+ const req = { headers: new Headers() } as any;
31
+ async function call(app: AppApi, id: string) {
32
+ return (await handler({ req, params: { id }, query: {} } as any, app)) as any;
33
+ }
34
+
35
+ test("delete-library-entry: a known id → 200 deleted:true and the row is gone", async () => {
36
+ await withApp(async (app, data) => {
37
+ const saved = await saveLibraryEntry(data, buildLibraryEntryRow({ name: "runbook", graphJson: GRAPH, source: "composed" }));
38
+ const res = await call(app, saved.id);
39
+ assertEquals(res.status, 200);
40
+ assertEquals(res.body.ok, true);
41
+ assertEquals(res.body.deleted, true);
42
+ assertEquals((await deliveryGraphLibrary(data).all()).length, 0);
43
+ });
44
+ });
45
+
46
+ test("delete-library-entry: an unknown id → 200 deleted:false (idempotent no-op)", async () => {
47
+ await withApp(async (app) => {
48
+ const res = await call(app, "no-such-id");
49
+ assertEquals(res.status, 200);
50
+ assertEquals(res.body.ok, true);
51
+ assertEquals(res.body.deleted, false);
52
+ });
53
+ });
54
+
55
+ test("delete-library-entry: a re-delete of an already-gone id → deleted:false", async () => {
56
+ await withApp(async (app, data) => {
57
+ const saved = await saveLibraryEntry(data, buildLibraryEntryRow({ name: "runbook", graphJson: GRAPH, source: "composed" }));
58
+ assertEquals((await call(app, saved.id)).body.deleted, true);
59
+ assertEquals((await call(app, saved.id)).body.deleted, false);
60
+ });
61
+ });
62
+
63
+ // The optional shared-secret guard is enforced in the handler (not by OpenAPI `security`), mirroring
64
+ // the other write/read doors. `SECRET` is captured at module import, so we cache-bust re-import the
65
+ // handler with NANO_PR_WEBHOOK_SECRET set to exercise both the rejected (401) path (which must NOT
66
+ // delete) and the authorized (200) path against a real booted data layer holding a known entry.
67
+ test("delete-library-entry: shared-secret guard — 401 without x-hook-secret (no delete), 200 with it", async () => {
68
+ await withApp(async (app, data) => {
69
+ const saved = await saveLibraryEntry(data, buildLibraryEntryRow({ name: "runbook", graphJson: GRAPH, source: "composed" }));
70
+ const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
71
+ process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
72
+ try {
73
+ const mod = await import(`./deleteLibraryEntry.ts?guard=${Date.now()}`);
74
+ const guarded = mod.default as (c: any, a: any) => Promise<any>;
75
+ const bad = await guarded({ req: { headers: new Headers() }, params: { id: saved.id }, query: {} } as any, app);
76
+ assertEquals(bad.status, 401);
77
+ // The rejected request must not have touched the row.
78
+ assertEquals((await deliveryGraphLibrary(data).all()).length, 1);
79
+ const ok = await guarded({ req: { headers: new Headers({ "x-hook-secret": "s3cr3t" }) }, params: { id: saved.id }, query: {} } as any, app);
80
+ assertEquals(ok.status, 200);
81
+ assertEquals(ok.body.deleted, true);
82
+ assertEquals((await deliveryGraphLibrary(data).all()).length, 0);
83
+ } finally {
84
+ if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
85
+ else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
86
+ }
87
+ });
88
+ });
@@ -0,0 +1,23 @@
1
+ // DELETE /app/api/delivery-graph/library/{id} → operationId `deleteLibraryEntry` (issue #522, epic
2
+ // #519 S3). Delete one saved library entry by its `id`. Idempotent: deleting an id that names no entry
3
+ // returns `deleted: false` (a clean no-op), not an error — so a double-click / retry from the S4
4
+ // Library App-View never errors.
5
+ //
6
+ // The optional shared-secret guard mirrors the other write/read doors: when NANO_PR_WEBHOOK_SECRET is
7
+ // set, callers must present it via the x-hook-secret header; unset → open.
8
+
9
+ import { deleteLibraryEntry } from "../app/deliveryGraphLibrary.ts";
10
+ import { envVar } from "../app/version.ts";
11
+ import { defineOperation } from "../nano-generated/operations.ts";
12
+
13
+ const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
14
+
15
+ export default defineOperation("deleteLibraryEntry", async ({ params, req }, app) => {
16
+ if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
17
+ app.log.warn("deleteLibraryEntry rejected: missing/invalid shared secret");
18
+ return { status: 401, body: { error: "unauthorized" } };
19
+ }
20
+ const deleted = await deleteLibraryEntry(app.data, params.id);
21
+ app.log.info("delete-library-entry", { id: params.id, deleted });
22
+ return { status: 200, body: { ok: true, deleted } };
23
+ });