@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,105 @@
|
|
|
1
|
+
// Integration coverage for the POST /app/api/actions/delivery-graph/dismiss operation `dismissProposal`
|
|
2
|
+
// (#520) — the OPERATOR-ONLY dismiss door. The cockpit's staged-proposals grid posts the `digest` of the
|
|
3
|
+
// proposal the operator wants to discard as noise; this door loads that live `staged` proposal and flips
|
|
4
|
+
// it to the terminal `dismissed` status, so it drops out of the staged list — exactly like
|
|
5
|
+
// `superseded`/`expired`, but recording a deliberate operator discard. It launches nothing. These tests
|
|
6
|
+
// drive the REAL door through `bootTestApp`'s api driver against the WASM engine: compile-to-stage, then
|
|
7
|
+
// dismiss by digest, asserting the row leaves the staged list, an unknown digest is refused, and a
|
|
8
|
+
// re-dismiss of an already-dismissed digest is idempotently refused (nothing changes, nothing launches).
|
|
9
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join, resolve } from "node:path";
|
|
12
|
+
import { after, describe, test } from "node:test";
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
15
|
+
import { deliveryGraphProposals, listStagedProposals } from "../app/deliveryGraphProposals.ts";
|
|
16
|
+
import { deliveryGraphRuns } from "../app/deliveryGraphRun.ts";
|
|
17
|
+
|
|
18
|
+
const APP_ROOT = resolve(import.meta.dirname, "..");
|
|
19
|
+
const GITHUB_ENV: Record<string, string> = { NANO_PR_GITHUB_TRANSPORT: "token", GITHUB_TOKEN: "" };
|
|
20
|
+
|
|
21
|
+
const HUMAN_ONLY = {
|
|
22
|
+
name: "manual gate",
|
|
23
|
+
nodes: [{ id: "ack", kind: "human", human: { prompt: "click done when the release is out" } }],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
describe("dismissProposal — operator dismiss of a staged-proposal by digest", () => {
|
|
27
|
+
const dirs: string[] = [];
|
|
28
|
+
const apps: TestApp[] = [];
|
|
29
|
+
after(async () => {
|
|
30
|
+
for (const app of apps) await app.stop?.();
|
|
31
|
+
for (const d of dirs) rmSync(d, { recursive: true, force: true });
|
|
32
|
+
});
|
|
33
|
+
const boot = async (): Promise<TestApp> => {
|
|
34
|
+
const d = mkdtempSync(join(tmpdir(), "nwf-dismiss-"));
|
|
35
|
+
dirs.push(d);
|
|
36
|
+
const app = await bootTestApp(APP_ROOT, { env: { ...GITHUB_ENV, NANO_APP_DB_URL: `file:${join(d, "app.db")}` } });
|
|
37
|
+
apps.push(app);
|
|
38
|
+
return app;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
test("a missing/blank digest → 400 with a human error, nothing changed", async () => {
|
|
42
|
+
const app = await boot();
|
|
43
|
+
assert.ok(app.api);
|
|
44
|
+
const res = await app.api.call<{ ok: boolean; error?: string }>("dismissProposal", { body: { digest: " " } });
|
|
45
|
+
assert.equal(res.status, 400);
|
|
46
|
+
assert.equal(res.body.ok, false);
|
|
47
|
+
assert.ok(typeof res.body.error === "string" && res.body.error.length > 0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("an unknown / never-staged digest → 400, nothing changed", async () => {
|
|
51
|
+
const app = await boot();
|
|
52
|
+
assert.ok(app.api);
|
|
53
|
+
const res = await app.api.call<{ ok: boolean; error?: string }>("dismissProposal", { body: { digest: "deadbeef0000" } });
|
|
54
|
+
assert.equal(res.status, 400);
|
|
55
|
+
assert.equal(res.body.ok, false);
|
|
56
|
+
assert.ok(/no staged proposal/.test(res.body.error ?? ""));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("dismiss a staged proposal by digest → 200; the row leaves the staged list; nothing launches", async () => {
|
|
60
|
+
const app = await boot();
|
|
61
|
+
assert.ok(app.api);
|
|
62
|
+
const api = app.api;
|
|
63
|
+
|
|
64
|
+
// Stage through the agent compile door — it returns a preview + digest and stages the proposal.
|
|
65
|
+
const staged = await api.call<{ status: string; digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
|
|
66
|
+
assert.equal(staged.status, 200);
|
|
67
|
+
const digest = staged.body.digest;
|
|
68
|
+
assert.equal((await deliveryGraphProposals(app.db).get(digest))?.status, "staged");
|
|
69
|
+
assert.equal((await listStagedProposals(app.db)).length, 1);
|
|
70
|
+
|
|
71
|
+
// The operator dismisses that digest.
|
|
72
|
+
const res = await api.call<{ ok: boolean; digest?: string }>("dismissProposal", { body: { digest } });
|
|
73
|
+
assert.equal(res.status, 200);
|
|
74
|
+
assert.equal(res.body.ok, true);
|
|
75
|
+
assert.equal(res.body.digest, digest);
|
|
76
|
+
|
|
77
|
+
// The proposal drops out of the staged list — it is now terminal (`dismissed`).
|
|
78
|
+
assert.equal((await deliveryGraphProposals(app.db).get(digest))?.status, "dismissed");
|
|
79
|
+
assert.equal((await listStagedProposals(app.db)).length, 0);
|
|
80
|
+
// Dismiss launches nothing — no runs exist.
|
|
81
|
+
await app.settle();
|
|
82
|
+
assert.equal((await deliveryGraphRuns(app.db).all()).length, 0);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("re-dismissing an ALREADY-dismissed digest is idempotent → 400, state unchanged", async () => {
|
|
86
|
+
const app = await boot();
|
|
87
|
+
assert.ok(app.api);
|
|
88
|
+
const api = app.api;
|
|
89
|
+
const staged = await api.call<{ status: string; digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
|
|
90
|
+
assert.equal(staged.status, 200);
|
|
91
|
+
const digest = staged.body.digest;
|
|
92
|
+
|
|
93
|
+
const first = await api.call<{ ok: boolean }>("dismissProposal", { body: { digest } });
|
|
94
|
+
assert.equal(first.status, 200);
|
|
95
|
+
assert.equal(first.body.ok, true);
|
|
96
|
+
assert.equal((await deliveryGraphProposals(app.db).get(digest))?.status, "dismissed");
|
|
97
|
+
|
|
98
|
+
// A second dismiss finds no LIVE staged proposal → clean 400; the row stays `dismissed` (unchanged).
|
|
99
|
+
const again = await api.call<{ ok: boolean; error?: string }>("dismissProposal", { body: { digest } });
|
|
100
|
+
assert.equal(again.status, 400);
|
|
101
|
+
assert.equal(again.body.ok, false);
|
|
102
|
+
assert.ok(/no staged proposal/.test(again.body.error ?? ""));
|
|
103
|
+
assert.equal((await deliveryGraphProposals(app.db).get(digest))?.status, "dismissed");
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// POST /app/api/actions/delivery-graph/dismiss → operationId `dismissProposal` (#520). The OPERATOR-ONLY
|
|
2
|
+
// dismiss door: the cockpit's staged-proposals grid posts the `digest` of the proposal the operator
|
|
3
|
+
// wants to discard as noise; this door loads that live `staged` proposal and flips it to the terminal
|
|
4
|
+
// `dismissed` status (`markProposalDismissed`), so it drops out of the staged list — exactly like
|
|
5
|
+
// `superseded`/`expired`, but recording a deliberate operator discard.
|
|
6
|
+
//
|
|
7
|
+
// It launches nothing (unlike dispatch) and is reachable only from the cockpit. Idempotent: a re-dismiss
|
|
8
|
+
// of an already-terminal (dismissed / dispatched / superseded / expired) or unknown digest is a clean
|
|
9
|
+
// 400 — the `getStagedProposal` liveness guard only resolves a live `staged` row, so a second dismiss
|
|
10
|
+
// finds no live proposal and refuses without touching state. The guarded flip itself can also lose a race
|
|
11
|
+
// (a dispatch/supersede/expiry lands between the liveness read and the write), in which case it changes 0
|
|
12
|
+
// rows and this door routes the lost race to the same clean 400 rather than misreporting success.
|
|
13
|
+
|
|
14
|
+
import { getStagedProposal, markProposalDismissed } from "../app/deliveryGraphProposals.ts";
|
|
15
|
+
import type { DeliveryGraphTextResult } from "../nano-generated/api-io.d.ts";
|
|
16
|
+
import { defineOperation } from "../nano-generated/operations.ts";
|
|
17
|
+
|
|
18
|
+
export default defineOperation("dismissProposal", async ({ body }, app) => {
|
|
19
|
+
const digest = body && typeof body === "object" && "digest" in body && typeof body.digest === "string" ? body.digest.trim() : "";
|
|
20
|
+
if (digest === "") {
|
|
21
|
+
app.log.warn("dismiss-delivery-graph rejected: missing digest");
|
|
22
|
+
return { status: 400, body: { ok: false, error: "request body must carry a `digest` naming the staged proposal to dismiss" } };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Load the live staged proposal for this digest — refuses an unknown or already-terminal (dismissed /
|
|
26
|
+
// dispatched / superseded / expired) digest cleanly. This is what makes the door idempotent: a second
|
|
27
|
+
// dismiss finds no live `staged` row and 400s without re-writing anything.
|
|
28
|
+
const proposal = await getStagedProposal(app.data, digest);
|
|
29
|
+
if (!proposal) {
|
|
30
|
+
app.log.warn("dismiss-delivery-graph rejected: no live staged proposal", { digest });
|
|
31
|
+
return {
|
|
32
|
+
status: 400,
|
|
33
|
+
body: { ok: false, error: `no staged proposal for digest ${digest} — it may already be dismissed, dispatched, superseded, or aged out` },
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Flip the live `staged` row to `dismissed`. This is a GUARDED UPDATE (`... WHERE status='staged'`), so a
|
|
38
|
+
// dispatch/supersede/expiry racing between the `getStagedProposal` read above and this write moves the row
|
|
39
|
+
// off `staged` and the flip legitimately changes 0 rows. In that lost-race case the dismiss did NOT happen,
|
|
40
|
+
// so we must not report success — fall through to the same clean 400 an already-terminal digest gets.
|
|
41
|
+
const dismissed = await markProposalDismissed(app.data, digest);
|
|
42
|
+
if (!dismissed) {
|
|
43
|
+
app.log.warn("dismiss-delivery-graph rejected: proposal left staged before dismiss landed", { digest });
|
|
44
|
+
return {
|
|
45
|
+
status: 400,
|
|
46
|
+
body: { ok: false, error: `no staged proposal for digest ${digest} — it may already be dismissed, dispatched, superseded, or aged out` },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
app.log.info("dismiss-delivery-graph: proposal dismissed", { digest });
|
|
50
|
+
|
|
51
|
+
const outBody: DeliveryGraphTextResult = { ok: true, digest, message: `staged proposal ${digest} dismissed` };
|
|
52
|
+
return { status: 200, body: outBody };
|
|
53
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Tests for GET /app/api/delivery-graph/library/{id} → `getLibraryEntry` (issue #522, epic #519 S3).
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import { assert, assertEquals } from "#test-assert";
|
|
7
|
+
import type { AppApi, DataLayer } from "@nanobpm/urban";
|
|
8
|
+
import { bootTestApp } from "@nanobpm/urban-testkit";
|
|
9
|
+
import { buildLibraryEntryRow, saveLibraryEntry } from "../app/deliveryGraphLibrary.ts";
|
|
10
|
+
import { noopLog } from "../test/log.ts";
|
|
11
|
+
import handler from "./getLibraryEntry.ts";
|
|
12
|
+
|
|
13
|
+
const APP_ROOT = resolve(import.meta.dirname, "..");
|
|
14
|
+
const GRAPH = JSON.stringify({ name: "runbook", nodes: [] });
|
|
15
|
+
|
|
16
|
+
async function withApp(fn: (app: AppApi, data: DataLayer) => Promise<void>): Promise<void> {
|
|
17
|
+
const dir = mkdtempSync(join(tmpdir(), "nwf-dglibget-"));
|
|
18
|
+
const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dir, "app.db")}` } });
|
|
19
|
+
try {
|
|
20
|
+
const edge = { data: app.db, log: noopLog() } as unknown as AppApi;
|
|
21
|
+
await fn(edge, app.db);
|
|
22
|
+
} finally {
|
|
23
|
+
await app.stop?.();
|
|
24
|
+
rmSync(dir, { recursive: true, force: true });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const req = { headers: new Headers() } as any;
|
|
29
|
+
async function call(app: AppApi, id: string) {
|
|
30
|
+
return (await handler({ req, params: { id }, query: {} } as any, app)) as any;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test("get-library-entry: a known id → 200 with the entry (including graph JSON)", async () => {
|
|
34
|
+
await withApp(async (app, data) => {
|
|
35
|
+
const saved = await saveLibraryEntry(data, buildLibraryEntryRow({ name: "runbook", graphJson: GRAPH, source: "composed" }));
|
|
36
|
+
const res = await call(app, saved.id);
|
|
37
|
+
assertEquals(res.status, 200);
|
|
38
|
+
assertEquals(res.body.id, saved.id);
|
|
39
|
+
assertEquals(res.body.name, "runbook");
|
|
40
|
+
assertEquals(res.body.graph, GRAPH);
|
|
41
|
+
assertEquals(res.body.source, "composed");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("get-library-entry: an unknown id → 404", async () => {
|
|
46
|
+
await withApp(async (app) => {
|
|
47
|
+
const res = await call(app, "no-such-id");
|
|
48
|
+
assertEquals(res.status, 404);
|
|
49
|
+
assert(typeof res.body.error === "string");
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// The optional shared-secret guard is enforced in the handler (not by OpenAPI `security`), mirroring
|
|
54
|
+
// the other read doors. `SECRET` is captured at module import, so we cache-bust re-import the handler
|
|
55
|
+
// with NANO_PR_WEBHOOK_SECRET set to exercise both the rejected (401) and authorized (200) paths
|
|
56
|
+
// against a real booted data layer holding a known entry.
|
|
57
|
+
test("get-library-entry: shared-secret guard — 401 without x-hook-secret, 200 with it", async () => {
|
|
58
|
+
await withApp(async (app, data) => {
|
|
59
|
+
const saved = await saveLibraryEntry(data, buildLibraryEntryRow({ name: "runbook", graphJson: GRAPH, source: "composed" }));
|
|
60
|
+
const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
|
|
61
|
+
process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
|
|
62
|
+
try {
|
|
63
|
+
const mod = await import(`./getLibraryEntry.ts?guard=${Date.now()}`);
|
|
64
|
+
const guarded = mod.default as (c: any, a: any) => Promise<any>;
|
|
65
|
+
const bad = await guarded({ req: { headers: new Headers() }, params: { id: saved.id }, query: {} } as any, app);
|
|
66
|
+
assertEquals(bad.status, 401);
|
|
67
|
+
const ok = await guarded({ req: { headers: new Headers({ "x-hook-secret": "s3cr3t" }) }, params: { id: saved.id }, query: {} } as any, app);
|
|
68
|
+
assertEquals(ok.status, 200);
|
|
69
|
+
assertEquals(ok.body.id, saved.id);
|
|
70
|
+
} finally {
|
|
71
|
+
if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
|
|
72
|
+
else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// GET /app/api/delivery-graph/library/{id} → operationId `getLibraryEntry` (issue #522, epic #519 S3).
|
|
2
|
+
// Fetch one saved library entry by its `id`, including its full `graph` JSON (the S4 Reuse action loads
|
|
3
|
+
// it into the compose textarea; the S6 export action downloads it). An unknown id is a clean 404.
|
|
4
|
+
//
|
|
5
|
+
// The optional shared-secret guard mirrors the other read doors (getLineage / listActivePrs /
|
|
6
|
+
// listStagedProposals): when NANO_PR_WEBHOOK_SECRET is set, callers must present it via the
|
|
7
|
+
// x-hook-secret header; unset → open.
|
|
8
|
+
|
|
9
|
+
import { getLibraryEntry, libraryEntryDto } 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("getLibraryEntry", async ({ params, req }, app) => {
|
|
16
|
+
if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
|
|
17
|
+
app.log.warn("getLibraryEntry rejected: missing/invalid shared secret");
|
|
18
|
+
return { status: 401, body: { error: "unauthorized" } };
|
|
19
|
+
}
|
|
20
|
+
const entry = await getLibraryEntry(app.data, params.id);
|
|
21
|
+
if (!entry) {
|
|
22
|
+
return { status: 404, body: { error: `no library entry for id ${params.id}` } };
|
|
23
|
+
}
|
|
24
|
+
return { status: 200, body: libraryEntryDto(entry) };
|
|
25
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// Tests for POST /app/api/actions/delivery-graph/library/import → `importToLibrary` (issue #524, epic
|
|
2
|
+
// #519 S5). Covers the three required paths: a valid file → 200, saved with source=imported; a file that
|
|
3
|
+
// is not valid JSON → 400, nothing saved; a valid-JSON but UNCOMPILABLE graph → 400 with path-qualified
|
|
4
|
+
// errors, nothing saved. Exercised against the REAL SQLite data layer so the migration + store
|
|
5
|
+
// round-trip is validated, not modelled — mirroring saveToLibrary.test.ts.
|
|
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 { AppApi, DataLayer } from "@nanobpm/urban";
|
|
12
|
+
import { bootTestApp } from "@nanobpm/urban-testkit";
|
|
13
|
+
import { deliveryGraphLibrary } from "../app/deliveryGraphLibrary.ts";
|
|
14
|
+
import { noopLog } from "../test/log.ts";
|
|
15
|
+
import handler from "./importToLibrary.ts";
|
|
16
|
+
|
|
17
|
+
const APP_ROOT = resolve(import.meta.dirname, "..");
|
|
18
|
+
|
|
19
|
+
async function withApp(fn: (app: AppApi, data: DataLayer) => Promise<void>): Promise<void> {
|
|
20
|
+
const dir = mkdtempSync(join(tmpdir(), "nwf-dglibimport-"));
|
|
21
|
+
const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dir, "app.db")}` } });
|
|
22
|
+
try {
|
|
23
|
+
const edge = { data: app.db, log: noopLog() } as unknown as AppApi;
|
|
24
|
+
await fn(edge, app.db);
|
|
25
|
+
} finally {
|
|
26
|
+
await app.stop?.();
|
|
27
|
+
rmSync(dir, { recursive: true, force: true });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function call(app: AppApi, body: unknown) {
|
|
32
|
+
// A real empty `Headers` (not `{}`) so the door's shared-secret guard — which reads
|
|
33
|
+
// `req.headers.get("x-hook-secret")` whenever NANO_PR_WEBHOOK_SECRET is configured — is safe to
|
|
34
|
+
// dereference here too, not only in the guard-specific test below.
|
|
35
|
+
return (await handler({ req: { headers: new Headers() } as any, params: {}, query: {}, body } as any, app)) as any;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const GOOD = JSON.stringify({
|
|
39
|
+
name: "imported-runbook",
|
|
40
|
+
nodes: [
|
|
41
|
+
{ id: "a", kind: "agent", agent: { jobType: "senior:feature" } },
|
|
42
|
+
{ id: "b", kind: "human", human: { prompt: "do X" } },
|
|
43
|
+
],
|
|
44
|
+
edges: [{ from: "a", to: "b" }],
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("import-to-library: a valid file → 200, saved with source=imported, name from the graph", async () => {
|
|
48
|
+
await withApp(async (app, data) => {
|
|
49
|
+
const res = await call(app, { graphJson: GOOD });
|
|
50
|
+
assertEquals(res.status, 200);
|
|
51
|
+
assertEquals(res.body.ok, true);
|
|
52
|
+
assertEquals(res.body.entry.source, "imported");
|
|
53
|
+
assertEquals(res.body.entry.name, "imported-runbook");
|
|
54
|
+
assert(res.body.entry.id.startsWith("imported-runbook-"));
|
|
55
|
+
assert(typeof res.body.entry.graph === "string" && res.body.entry.graph.length > 0);
|
|
56
|
+
// Persisted exactly one row.
|
|
57
|
+
assertEquals((await deliveryGraphLibrary(data).all()).length, 1);
|
|
58
|
+
assertEquals((await deliveryGraphLibrary(data).get(res.body.entry.id))?.source, "imported");
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("import-to-library: an explicit name overrides the graph's own name", async () => {
|
|
63
|
+
await withApp(async (app, data) => {
|
|
64
|
+
const res = await call(app, { graphJson: GOOD, name: "My Import", description: "from disk" });
|
|
65
|
+
assertEquals(res.status, 200);
|
|
66
|
+
assertEquals(res.body.entry.name, "My Import");
|
|
67
|
+
assertEquals(res.body.entry.description, "from disk");
|
|
68
|
+
assert(res.body.entry.id.startsWith("my-import-"));
|
|
69
|
+
assertEquals((await deliveryGraphLibrary(data).all()).length, 1);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// The `nameOverride ?? ingress.name` fallback: a graph with NO own name must still import when an
|
|
74
|
+
// explicit override supplies one (the complement of the unnamed-with-no-override → 400 case below).
|
|
75
|
+
test("import-to-library: an unnamed graph imports when an explicit override supplies the name", async () => {
|
|
76
|
+
await withApp(async (app, data) => {
|
|
77
|
+
const unnamed = JSON.stringify({
|
|
78
|
+
nodes: [{ id: "a", kind: "human", human: { prompt: "do X" } }],
|
|
79
|
+
edges: [],
|
|
80
|
+
});
|
|
81
|
+
const res = await call(app, { graphJson: unnamed, name: "Named By Override", description: "from disk" });
|
|
82
|
+
assertEquals(res.status, 200);
|
|
83
|
+
assertEquals(res.body.ok, true);
|
|
84
|
+
assertEquals(res.body.entry.name, "Named By Override");
|
|
85
|
+
assertEquals(res.body.entry.source, "imported");
|
|
86
|
+
assert(res.body.entry.id.startsWith("named-by-override-"));
|
|
87
|
+
// Persisted exactly one row, readable back with source=imported.
|
|
88
|
+
assertEquals((await deliveryGraphLibrary(data).all()).length, 1);
|
|
89
|
+
assertEquals((await deliveryGraphLibrary(data).get(res.body.entry.id))?.source, "imported");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("import-to-library: a file that isn't valid JSON → 400, nothing persisted", async () => {
|
|
94
|
+
await withApp(async (app, data) => {
|
|
95
|
+
const res = await call(app, { graphJson: "{ not json" });
|
|
96
|
+
assertEquals(res.status, 400);
|
|
97
|
+
assertEquals(res.body.ok, false);
|
|
98
|
+
assert(typeof res.body.error === "string" && res.body.error.includes("not valid JSON"));
|
|
99
|
+
assertEquals((await deliveryGraphLibrary(data).all()).length, 0);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("import-to-library: an empty file → 400, nothing persisted", async () => {
|
|
104
|
+
await withApp(async (app, data) => {
|
|
105
|
+
const res = await call(app, { graphJson: " " });
|
|
106
|
+
assertEquals(res.status, 400);
|
|
107
|
+
assertEquals(res.body.ok, false);
|
|
108
|
+
assertEquals((await deliveryGraphLibrary(data).all()).length, 0);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// This is a MUTATING library door, so it carries the same optional shared-secret guard as the
|
|
113
|
+
// get/delete/save library doors. `SECRET` is captured at module import, so we cache-bust re-import the
|
|
114
|
+
// handler with NANO_PR_WEBHOOK_SECRET set to exercise both the rejected (401 — nothing persisted) path
|
|
115
|
+
// and the authorized (200) path against a real booted data layer.
|
|
116
|
+
test("import-to-library: shared-secret guard — 401 without x-hook-secret (no import), 200 with it", async () => {
|
|
117
|
+
await withApp(async (app, data) => {
|
|
118
|
+
const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
|
|
119
|
+
process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
|
|
120
|
+
try {
|
|
121
|
+
const mod = await import(`./importToLibrary.ts?guard=${Date.now()}`);
|
|
122
|
+
const guarded = mod.default as (c: any, a: any) => Promise<any>;
|
|
123
|
+
const bad = await guarded({ req: { headers: new Headers() }, params: {}, query: {}, body: { graphJson: GOOD } } as any, app);
|
|
124
|
+
assertEquals(bad.status, 401);
|
|
125
|
+
// The rejected request must not have persisted anything.
|
|
126
|
+
assertEquals((await deliveryGraphLibrary(data).all()).length, 0);
|
|
127
|
+
const ok = await guarded({ req: { headers: new Headers({ "x-hook-secret": "s3cr3t" }) }, params: {}, query: {}, body: { graphJson: GOOD } } as any, app);
|
|
128
|
+
assertEquals(ok.status, 200);
|
|
129
|
+
assertEquals(ok.body.entry.source, "imported");
|
|
130
|
+
assertEquals((await deliveryGraphLibrary(data).all()).length, 1);
|
|
131
|
+
} finally {
|
|
132
|
+
if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
|
|
133
|
+
else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("import-to-library: a valid-JSON but UNCOMPILABLE graph → 400 with path-qualified errors, nothing persisted", async () => {
|
|
139
|
+
await withApp(async (app, data) => {
|
|
140
|
+
// A structurally-invalid graph (edge references a node that does not exist) fails compilation.
|
|
141
|
+
const uncompilable = JSON.stringify({
|
|
142
|
+
name: "broken",
|
|
143
|
+
nodes: [{ id: "a", kind: "agent", agent: { jobType: "senior:feature" } }],
|
|
144
|
+
edges: [{ from: "a", to: "missing" }],
|
|
145
|
+
});
|
|
146
|
+
const res = await call(app, { graphJson: uncompilable });
|
|
147
|
+
assertEquals(res.status, 400);
|
|
148
|
+
assertEquals(res.body.ok, false);
|
|
149
|
+
assert(Array.isArray(res.body.errors) && res.body.errors.length > 0);
|
|
150
|
+
assert(
|
|
151
|
+
res.body.errors.every(
|
|
152
|
+
(e: { path: string; message: string }) => typeof e.path === "string" && e.path.trim().length > 0,
|
|
153
|
+
),
|
|
154
|
+
);
|
|
155
|
+
assertEquals((await deliveryGraphLibrary(data).all()).length, 0);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Regression for PR #533 review (operations/importToLibrary.ts): because `graphJson` is a STRING at
|
|
160
|
+
// the request boundary, the runtime's OpenAPI `DeliveryGraph` shape gate never ran on the parsed
|
|
161
|
+
// object, so a malformed NESTED value the semantic validator does not re-enumerate (here
|
|
162
|
+
// `nodes[0].human.prompt: 42`) could return 200 and persist a contract-violating typed field. The
|
|
163
|
+
// reused shape gate now rejects it at the door with a path-qualified error and persists nothing.
|
|
164
|
+
test("import-to-library: a nested-shape violation (human.prompt not a string) → 400, nothing persisted", async () => {
|
|
165
|
+
await withApp(async (app, data) => {
|
|
166
|
+
const malformed = JSON.stringify({
|
|
167
|
+
name: "malformed",
|
|
168
|
+
// biome-ignore lint/suspicious/noExplicitAny: deliberately violating the DeliveryGraph shape.
|
|
169
|
+
nodes: [{ id: "h", kind: "human", human: { prompt: 42 } } as any],
|
|
170
|
+
});
|
|
171
|
+
const res = await call(app, { graphJson: malformed });
|
|
172
|
+
assertEquals(res.status, 400);
|
|
173
|
+
assertEquals(res.body.ok, false);
|
|
174
|
+
assert(Array.isArray(res.body.errors) && res.body.errors.length > 0);
|
|
175
|
+
assert(
|
|
176
|
+
res.body.errors.some((e: { path: string; message: string }) => e.path.includes("nodes[0]/human/prompt")),
|
|
177
|
+
"the 400 must carry a path-qualified error at the offending nested field",
|
|
178
|
+
);
|
|
179
|
+
assertEquals((await deliveryGraphLibrary(data).all()).length, 0);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("import-to-library: an unnamed graph with no override → 400, nothing persisted", async () => {
|
|
184
|
+
await withApp(async (app, data) => {
|
|
185
|
+
// A graph with no `name` cannot derive a library id and no override was supplied.
|
|
186
|
+
const unnamed = JSON.stringify({
|
|
187
|
+
nodes: [{ id: "a", kind: "human", human: { prompt: "do X" } }],
|
|
188
|
+
edges: [],
|
|
189
|
+
});
|
|
190
|
+
const res = await call(app, { graphJson: unnamed });
|
|
191
|
+
assertEquals(res.status, 400);
|
|
192
|
+
assertEquals(res.body.ok, false);
|
|
193
|
+
assertEquals((await deliveryGraphLibrary(data).all()).length, 0);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// POST /app/api/actions/delivery-graph/library/import → operationId `importToLibrary` (issue #524, epic
|
|
2
|
+
// #519 S5). Import a delivery graph into the reusable LIBRARY from a filesystem FILE. The compose
|
|
3
|
+
// App-View's `<input type=file accept=.json>` reads the chosen file's text client-side and POSTs it here
|
|
4
|
+
// as the raw `graphJson` string.
|
|
5
|
+
//
|
|
6
|
+
// Like `saveToLibrary` (#522), every import validates the graph through the SAME `parseAndCompileText`
|
|
7
|
+
// pipeline the preview/stage doors use, so an uncompilable graph can NEVER be persisted — a file that is
|
|
8
|
+
// not valid JSON, or a graph that fails to compile, is a clean 400 (carrying the path-qualified compile
|
|
9
|
+
// `errors`) and NOTHING is written. The persisted entry is tagged `source: imported`. Its name defaults
|
|
10
|
+
// to the imported graph's own `name`; an explicit `name` in the body overrides it (an unnamed graph with
|
|
11
|
+
// no override is a clean 400 — the library id is name-derived, so a name is required).
|
|
12
|
+
//
|
|
13
|
+
// This is a MUTATING library door, so — like the get/delete library doors — it carries the optional
|
|
14
|
+
// shared-secret guard: when NANO_PR_WEBHOOK_SECRET is set, callers must present it via the x-hook-secret
|
|
15
|
+
// header (the compose client sends it for same-origin requests); unset → open. Without this, a configured
|
|
16
|
+
// deployment would let an unauthenticated caller upsert persistent library entries. (The `save` door is
|
|
17
|
+
// the deliberate exception — it is also reached by a declarative page row action that cannot carry the
|
|
18
|
+
// header, so it is unguarded; see `saveToLibrary.ts`.)
|
|
19
|
+
|
|
20
|
+
import { buildLibraryEntryRow, libraryEntryDto, saveLibraryEntry } from "../app/deliveryGraphLibrary.ts";
|
|
21
|
+
import { parseAndCompileText } from "../app/deliveryGraphTextIngress.ts";
|
|
22
|
+
import { envVar } from "../app/version.ts";
|
|
23
|
+
import { defineOperation } from "../nano-generated/operations.ts";
|
|
24
|
+
|
|
25
|
+
const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
|
|
26
|
+
|
|
27
|
+
export default defineOperation("importToLibrary", async ({ body, req }, app) => {
|
|
28
|
+
if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
|
|
29
|
+
app.log.warn("import-to-library rejected: missing/invalid shared secret");
|
|
30
|
+
return { status: 401, body: { ok: false, error: "unauthorized" } };
|
|
31
|
+
}
|
|
32
|
+
const graphJson = body && typeof body.graphJson === "string" ? body.graphJson : "";
|
|
33
|
+
if (graphJson.trim() === "") {
|
|
34
|
+
app.log.warn("import-to-library rejected: empty file");
|
|
35
|
+
return { status: 400, body: { ok: false, error: "the imported file was empty — select a delivery-graph `.json` file" } };
|
|
36
|
+
}
|
|
37
|
+
const description = body && typeof body.description === "string" ? body.description : undefined;
|
|
38
|
+
const nameOverride = body && typeof body.name === "string" && body.name.trim() !== "" ? body.name.trim() : undefined;
|
|
39
|
+
|
|
40
|
+
// Validate/compile — a file that is not valid JSON, or a graph that fails validation, is a clean 400
|
|
41
|
+
// with path-qualified errors and nothing is persisted (an uncompilable graph can never enter the library).
|
|
42
|
+
const ingress = await parseAndCompileText({ graphJson });
|
|
43
|
+
if (!ingress.ok) {
|
|
44
|
+
app.log.warn("import-to-library rejected: graph failed validation", { message: ingress.body.error });
|
|
45
|
+
return { status: 400, body: ingress.body };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The library id is name-derived, so a name is required. It defaults to the imported graph's own
|
|
49
|
+
// compiled `name`; an explicit override wins. An unnamed graph with no override is a clean 400.
|
|
50
|
+
const name = nameOverride ?? ingress.name ?? "";
|
|
51
|
+
if (name.trim() === "") {
|
|
52
|
+
app.log.warn("import-to-library rejected: imported graph has no name and none was provided");
|
|
53
|
+
return { status: 400, body: { ok: false, error: "the imported graph has no `name` — add one to the file, or supply a name" } };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const saved = await saveLibraryEntry(
|
|
57
|
+
app.data,
|
|
58
|
+
buildLibraryEntryRow({ name, description, graphJson: JSON.stringify(ingress.graph), source: "imported" }),
|
|
59
|
+
);
|
|
60
|
+
app.log.info("import-to-library saved", { id: saved.id, name: saved.name, source: "imported" });
|
|
61
|
+
return { status: 200, body: { ok: true, entry: libraryEntryDto(saved) } };
|
|
62
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Tests for GET /app/api/delivery-graph/library → `listLibrary` (issue #522, epic #519 S3). The read
|
|
2
|
+
// behind the Library App-View: every saved entry, newest first, carrying its full graph JSON.
|
|
3
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { assert, assertEquals } from "#test-assert";
|
|
8
|
+
import type { AppApi, DataLayer } from "@nanobpm/urban";
|
|
9
|
+
import { bootTestApp } from "@nanobpm/urban-testkit";
|
|
10
|
+
import { buildLibraryEntryRow, saveLibraryEntry } from "../app/deliveryGraphLibrary.ts";
|
|
11
|
+
import { noopLog } from "../test/log.ts";
|
|
12
|
+
import handler from "./listLibrary.ts";
|
|
13
|
+
|
|
14
|
+
const APP_ROOT = resolve(import.meta.dirname, "..");
|
|
15
|
+
const GRAPH = JSON.stringify({ name: "runbook", nodes: [] });
|
|
16
|
+
|
|
17
|
+
async function withApp(fn: (app: AppApi, data: DataLayer) => Promise<void>): Promise<void> {
|
|
18
|
+
const dir = mkdtempSync(join(tmpdir(), "nwf-dgliblist-"));
|
|
19
|
+
const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dir, "app.db")}` } });
|
|
20
|
+
try {
|
|
21
|
+
const edge = { data: app.db, log: noopLog() } as unknown as AppApi;
|
|
22
|
+
await fn(edge, app.db);
|
|
23
|
+
} finally {
|
|
24
|
+
await app.stop?.();
|
|
25
|
+
rmSync(dir, { recursive: true, force: true });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const req = { headers: new Headers() } as any;
|
|
30
|
+
async function call(app: AppApi) {
|
|
31
|
+
return (await handler({ req, params: {}, query: {} } as any, app)) as any;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test("list-library: empty → count 0", async () => {
|
|
35
|
+
await withApp(async (app) => {
|
|
36
|
+
const res = await call(app);
|
|
37
|
+
assertEquals(res.status, 200);
|
|
38
|
+
assertEquals(res.body.count, 0);
|
|
39
|
+
assertEquals(res.body.entries.length, 0);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("list-library: saved entries returned newest-first with their graph JSON", async () => {
|
|
44
|
+
await withApp(async (app, data) => {
|
|
45
|
+
await saveLibraryEntry(data, buildLibraryEntryRow({ name: "older", graphJson: GRAPH, source: "composed", createdAt: "2024-01-01T00:00:00.000Z" }));
|
|
46
|
+
await saveLibraryEntry(data, buildLibraryEntryRow({ name: "newer", graphJson: GRAPH, source: "imported", createdAt: "2024-06-01T00:00:00.000Z" }));
|
|
47
|
+
const res = await call(app);
|
|
48
|
+
assertEquals(res.status, 200);
|
|
49
|
+
assertEquals(res.body.count, 2);
|
|
50
|
+
assertEquals(res.body.entries[0].name, "newer");
|
|
51
|
+
assertEquals(res.body.entries[1].name, "older");
|
|
52
|
+
assertEquals(res.body.entries[0].graph, GRAPH);
|
|
53
|
+
assertEquals(res.body.entries[0].createdAt, "2024-06-01T00:00:00.000Z");
|
|
54
|
+
assert(typeof res.body.entries[0].updatedAt === "string");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// The optional shared-secret guard is enforced in the handler (not by OpenAPI `security`), mirroring
|
|
59
|
+
// the other read doors (getLineage / listActivePrs / listStagedProposals). `SECRET` is captured at
|
|
60
|
+
// module import, so we cache-bust re-import the handler with NANO_PR_WEBHOOK_SECRET set to exercise
|
|
61
|
+
// both the rejected (401) and authorized (200) paths against a real booted data layer.
|
|
62
|
+
test("list-library: shared-secret guard — 401 without x-hook-secret, 200 with it", async () => {
|
|
63
|
+
await withApp(async (app) => {
|
|
64
|
+
const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
|
|
65
|
+
process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
|
|
66
|
+
try {
|
|
67
|
+
const mod = await import(`./listLibrary.ts?guard=${Date.now()}`);
|
|
68
|
+
const guarded = mod.default as (c: any, a: any) => Promise<any>;
|
|
69
|
+
const bad = await guarded({ req: { headers: new Headers() }, params: {}, query: {} } as any, app);
|
|
70
|
+
assertEquals(bad.status, 401);
|
|
71
|
+
const ok = await guarded({ req: { headers: new Headers({ "x-hook-secret": "s3cr3t" }) }, params: {}, query: {} } as any, app);
|
|
72
|
+
assertEquals(ok.status, 200);
|
|
73
|
+
assert(Array.isArray(ok.body.entries));
|
|
74
|
+
} finally {
|
|
75
|
+
if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
|
|
76
|
+
else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// GET /app/api/delivery-graph/library → operationId `listLibrary` (issue #522, epic #519 S3). The read
|
|
2
|
+
// behind the Library App-View (S4/#523): every saved library entry, newest first, with its full
|
|
3
|
+
// `graph` JSON so the export affordance (S6/#525) can build a client-side download straight from the
|
|
4
|
+
// list payload.
|
|
5
|
+
//
|
|
6
|
+
// The optional shared-secret guard mirrors the other read doors (getLineage / listActivePrs /
|
|
7
|
+
// listStagedProposals): when NANO_PR_WEBHOOK_SECRET is set, callers must present it via the
|
|
8
|
+
// x-hook-secret header; unset → open.
|
|
9
|
+
|
|
10
|
+
import { libraryEntryDto, listLibraryEntries } from "../app/deliveryGraphLibrary.ts";
|
|
11
|
+
import { envVar } from "../app/version.ts";
|
|
12
|
+
import { defineOperation } from "../nano-generated/operations.ts";
|
|
13
|
+
|
|
14
|
+
const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
|
|
15
|
+
|
|
16
|
+
export default defineOperation("listLibrary", async ({ req }, app) => {
|
|
17
|
+
if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
|
|
18
|
+
app.log.warn("listLibrary rejected: missing/invalid shared secret");
|
|
19
|
+
return { status: 401, body: { error: "unauthorized" } };
|
|
20
|
+
}
|
|
21
|
+
const rows = await listLibraryEntries(app.data);
|
|
22
|
+
const entries = rows.map(libraryEntryDto);
|
|
23
|
+
return { status: 200, body: { count: entries.length, entries } };
|
|
24
|
+
});
|