@nanobpm/nano-workforce 0.138.2 → 0.139.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/app/contracts.ts +16 -0
- package/app/deliveryGraph.test.ts +84 -0
- package/app/deliveryGraph.ts +79 -0
- package/app/deliveryGraphCompiler.ts +4 -2
- package/app/deliveryGraphLibrary.test.ts +134 -0
- package/app/deliveryGraphLibrary.ts +153 -0
- package/app/deliveryGraphProposals.test.ts +136 -0
- package/app/deliveryGraphProposals.ts +54 -6
- package/app/deliveryGraphShape.test.ts +66 -0
- package/app/deliveryGraphShape.ts +67 -0
- package/app/deliveryGraphTextIngress.test.ts +137 -0
- package/app/deliveryGraphTextIngress.ts +73 -3
- package/app/planReadModel.test.ts +23 -0
- package/db/migrations/084_plan_wave_tasks_effective_status.sql +51 -0
- package/db/migrations/085_delivery_graph_library.sql +32 -0
- package/openapi.yaml +366 -0
- package/operations/deleteLibraryEntry.test.ts +88 -0
- package/operations/deleteLibraryEntry.ts +23 -0
- package/operations/dismissProposal.test.ts +105 -0
- package/operations/dismissProposal.ts +53 -0
- package/operations/getLibraryEntry.test.ts +75 -0
- package/operations/getLibraryEntry.ts +25 -0
- package/operations/importToLibrary.test.ts +195 -0
- package/operations/importToLibrary.ts +62 -0
- package/operations/listLibrary.test.ts +79 -0
- package/operations/listLibrary.ts +24 -0
- package/operations/saveToLibrary.test.ts +225 -0
- package/operations/saveToLibrary.ts +89 -0
- package/package.json +1 -1
- package/pages/delivery-graphs/delivery-graphs.css +33 -0
- package/pages/delivery-graphs/embed.html +1 -0
- package/pages/delivery-graphs/library-embed.html +31 -0
- package/pages/delivery-graphs/library-standalone.html +38 -0
- package/pages/delivery-graphs/library.mount.js +364 -0
- package/pages/delivery-graphs/mount.js +133 -4
- package/pages/delivery-graphs/staged.mount.js +109 -4
- package/pages/delivery-graphs/standalone.html +2 -1
- package/pages/delivery-graphs.page.json +24 -1
- package/scripts/pages-contract.test.ts +50 -0
- package/test/delivery-graphs-import.test.ts +92 -0
- package/test/delivery-graphs-library-embed.test.ts +148 -0
- package/test/delivery-graphs-library-export.test.ts +62 -0
- package/test/delivery-graphs-staged-embed.test.ts +9 -0
|
@@ -0,0 +1,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
|
+
});
|