@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,225 @@
|
|
|
1
|
+
// Tests for POST /app/api/actions/delivery-graph/library/save → `saveToLibrary` (issue #522, epic #519
|
|
2
|
+
// S3). Covers the three required paths: save-from-raw-JSON (`source: composed`), save-from-digest
|
|
3
|
+
// (reuse a staged proposal's stored graph, `source: from-staged`), and validation-reject (an
|
|
4
|
+
// uncompilable graph → clean 400, nothing persisted). Exercised against the REAL SQLite data layer so
|
|
5
|
+
// the migration + store round-trip is validated, not modelled.
|
|
6
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join, resolve } from "node:path";
|
|
9
|
+
import { test } from "node:test";
|
|
10
|
+
import { assert, assertEquals } from "#test-assert";
|
|
11
|
+
import type { 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 stageHandler from "./stageDeliveryGraph.ts";
|
|
16
|
+
import handler from "./saveToLibrary.ts";
|
|
17
|
+
|
|
18
|
+
const APP_ROOT = resolve(import.meta.dirname, "..");
|
|
19
|
+
|
|
20
|
+
async function withApp(fn: (app: AppApi, data: DataLayer) => Promise<void>): Promise<void> {
|
|
21
|
+
const dir = mkdtempSync(join(tmpdir(), "nwf-dglibsave-"));
|
|
22
|
+
const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dir, "app.db")}` } });
|
|
23
|
+
try {
|
|
24
|
+
const edge = { data: app.db, log: noopLog() } as unknown as AppApi;
|
|
25
|
+
await fn(edge, app.db);
|
|
26
|
+
} finally {
|
|
27
|
+
await app.stop?.();
|
|
28
|
+
rmSync(dir, { recursive: true, force: true });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function call(app: AppApi, body: unknown) {
|
|
33
|
+
return (await handler({ req: {} as any, params: {}, query: {}, body } as any, app)) as any;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function stage(app: AppApi, graphJson: string) {
|
|
37
|
+
return (await stageHandler({ req: {} as any, params: {}, query: {}, body: { graphJson } } as any, app)) as any;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const GOOD = JSON.stringify({
|
|
41
|
+
name: "runbook",
|
|
42
|
+
nodes: [
|
|
43
|
+
{ id: "a", kind: "agent", agent: { jobType: "senior:feature" } },
|
|
44
|
+
{ id: "b", kind: "human", human: { prompt: "do X" } },
|
|
45
|
+
],
|
|
46
|
+
edges: [{ from: "a", to: "b" }],
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("save-to-library: a raw graph JSON → 200, saved with source=composed, id derived from name", async () => {
|
|
50
|
+
await withApp(async (app, data) => {
|
|
51
|
+
const res = await call(app, { name: "My Runbook", description: "a note", graphJson: GOOD });
|
|
52
|
+
assertEquals(res.status, 200);
|
|
53
|
+
assertEquals(res.body.ok, true);
|
|
54
|
+
assert(res.body.entry.id.startsWith("my-runbook-"));
|
|
55
|
+
assertEquals(res.body.entry.name, "My Runbook");
|
|
56
|
+
assertEquals(res.body.entry.description, "a note");
|
|
57
|
+
assertEquals(res.body.entry.source, "composed");
|
|
58
|
+
assert(typeof res.body.entry.graph === "string" && res.body.entry.graph.length > 0);
|
|
59
|
+
// Persisted exactly one row.
|
|
60
|
+
assertEquals((await deliveryGraphLibrary(data).all()).length, 1);
|
|
61
|
+
assertEquals((await deliveryGraphLibrary(data).get(res.body.entry.id))?.name, "My Runbook");
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("save-to-library: from a staged proposal digest → 200, reuses the stored graph, source=from-staged", async () => {
|
|
66
|
+
await withApp(async (app, data) => {
|
|
67
|
+
const staged = await stage(app, GOOD);
|
|
68
|
+
assertEquals(staged.status, 200);
|
|
69
|
+
const digest = staged.body.digest;
|
|
70
|
+
const res = await call(app, { name: "Saved From Staged", digest });
|
|
71
|
+
assertEquals(res.status, 200);
|
|
72
|
+
assertEquals(res.body.ok, true);
|
|
73
|
+
assertEquals(res.body.entry.source, "from-staged");
|
|
74
|
+
// The reused graph compiles to the SAME digest the proposal carried.
|
|
75
|
+
const proposal = await (await import("../app/deliveryGraphProposals.ts")).deliveryGraphProposals(data).get(digest);
|
|
76
|
+
assert(proposal !== undefined);
|
|
77
|
+
// The saved entry reuses the proposal's stored graph verbatim (the digest path), not a recompile:
|
|
78
|
+
// both the response payload and the persisted row carry exactly the proposal's `graph`.
|
|
79
|
+
assertEquals(res.body.entry.graph, proposal.graph);
|
|
80
|
+
assertEquals((await deliveryGraphLibrary(data).get(res.body.entry.id))?.graph, proposal.graph);
|
|
81
|
+
assertEquals((await deliveryGraphLibrary(data).all()).length, 1);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("save-to-library: from a dispatched proposal digest → 200, source=from-dispatched", async () => {
|
|
86
|
+
await withApp(async (app, data) => {
|
|
87
|
+
const staged = await stage(app, GOOD);
|
|
88
|
+
assertEquals(staged.status, 200);
|
|
89
|
+
const digest = staged.body.digest;
|
|
90
|
+
const { markProposalDispatched } = await import("../app/deliveryGraphProposals.ts");
|
|
91
|
+
await markProposalDispatched(data, digest);
|
|
92
|
+
const res = await call(app, { name: "Saved From Dispatched", digest });
|
|
93
|
+
assertEquals(res.status, 200);
|
|
94
|
+
assertEquals(res.body.ok, true);
|
|
95
|
+
assertEquals(res.body.entry.source, "from-dispatched");
|
|
96
|
+
assertEquals((await deliveryGraphLibrary(data).all()).length, 1);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("save-to-library: a superseded proposal digest → 400, nothing persisted", async () => {
|
|
101
|
+
await withApp(async (app, data) => {
|
|
102
|
+
const staged = await stage(app, GOOD);
|
|
103
|
+
assertEquals(staged.status, 200);
|
|
104
|
+
const digest = staged.body.digest;
|
|
105
|
+
const proposals = (await import("../app/deliveryGraphProposals.ts")).deliveryGraphProposals(data);
|
|
106
|
+
await proposals.update(digest, { status: "superseded" });
|
|
107
|
+
assertEquals((await proposals.get(digest))?.status, "superseded");
|
|
108
|
+
const res = await call(app, { name: "Saved From Superseded", digest });
|
|
109
|
+
assertEquals(res.status, 400);
|
|
110
|
+
assertEquals(res.body.ok, false);
|
|
111
|
+
assert(typeof res.body.error === "string" && res.body.error.includes("not a live staged/dispatched proposal"));
|
|
112
|
+
assertEquals((await deliveryGraphLibrary(data).all()).length, 0);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("save-to-library: an expired proposal digest → 400, nothing persisted", async () => {
|
|
117
|
+
await withApp(async (app, data) => {
|
|
118
|
+
const staged = await stage(app, GOOD);
|
|
119
|
+
assertEquals(staged.status, 200);
|
|
120
|
+
const digest = staged.body.digest;
|
|
121
|
+
const { markProposalExpired } = await import("../app/deliveryGraphProposals.ts");
|
|
122
|
+
await markProposalExpired(data, digest);
|
|
123
|
+
const res = await call(app, { name: "Saved From Expired", digest });
|
|
124
|
+
assertEquals(res.status, 400);
|
|
125
|
+
assertEquals(res.body.ok, false);
|
|
126
|
+
assert(typeof res.body.error === "string" && res.body.error.includes("not a live staged/dispatched proposal"));
|
|
127
|
+
assertEquals((await deliveryGraphLibrary(data).all()).length, 0);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("save-to-library: a staged-but-TTL-expired proposal digest → 400, nothing persisted", async () => {
|
|
132
|
+
await withApp(async (app, data) => {
|
|
133
|
+
const staged = await stage(app, GOOD);
|
|
134
|
+
assertEquals(staged.status, 200);
|
|
135
|
+
const digest = staged.body.digest;
|
|
136
|
+
// Force the row's TTL horizon into the past while leaving status=`staged`.
|
|
137
|
+
await (await import("../app/deliveryGraphProposals.ts"))
|
|
138
|
+
.deliveryGraphProposals(data)
|
|
139
|
+
.update(digest, { expires_at: new Date(Date.now() - 60_000).toISOString() });
|
|
140
|
+
const res = await call(app, { name: "Saved From Stale Staged", digest });
|
|
141
|
+
assertEquals(res.status, 400);
|
|
142
|
+
assertEquals(res.body.ok, false);
|
|
143
|
+
assert(typeof res.body.error === "string" && res.body.error.includes("not a live staged/dispatched proposal"));
|
|
144
|
+
assertEquals((await deliveryGraphLibrary(data).all()).length, 0);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("save-to-library: an unknown digest → 400, nothing persisted", async () => {
|
|
149
|
+
await withApp(async (app, data) => {
|
|
150
|
+
const res = await call(app, { name: "ghost", digest: "deadbeef0000" });
|
|
151
|
+
assertEquals(res.status, 400);
|
|
152
|
+
assertEquals(res.body.ok, false);
|
|
153
|
+
assert(typeof res.body.error === "string" && res.body.error.includes("no stored graph"));
|
|
154
|
+
assertEquals((await deliveryGraphLibrary(data).all()).length, 0);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("save-to-library: not-valid-JSON graph → 400, nothing persisted", async () => {
|
|
159
|
+
await withApp(async (app, data) => {
|
|
160
|
+
const res = await call(app, { name: "bad", graphJson: "{ not json" });
|
|
161
|
+
assertEquals(res.status, 400);
|
|
162
|
+
assertEquals(res.body.ok, false);
|
|
163
|
+
assert(typeof res.body.error === "string" && res.body.error.includes("not valid JSON"));
|
|
164
|
+
assertEquals((await deliveryGraphLibrary(data).all()).length, 0);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("save-to-library: a valid-JSON but UNCOMPILABLE graph → 400 with path-qualified errors, nothing persisted", async () => {
|
|
169
|
+
await withApp(async (app, data) => {
|
|
170
|
+
// A structurally-invalid graph (edge references a node that does not exist) fails compilation.
|
|
171
|
+
const uncompilable = JSON.stringify({
|
|
172
|
+
name: "broken",
|
|
173
|
+
nodes: [{ id: "a", kind: "agent", agent: { jobType: "senior:feature" } }],
|
|
174
|
+
edges: [{ from: "a", to: "missing" }],
|
|
175
|
+
});
|
|
176
|
+
const res = await call(app, { name: "broken", graphJson: uncompilable });
|
|
177
|
+
assertEquals(res.status, 400);
|
|
178
|
+
assertEquals(res.body.ok, false);
|
|
179
|
+
assert(Array.isArray(res.body.errors) && res.body.errors.length > 0);
|
|
180
|
+
assertEquals((await deliveryGraphLibrary(data).all()).length, 0);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("save-to-library: a blank name → 400, nothing persisted", async () => {
|
|
185
|
+
await withApp(async (app, data) => {
|
|
186
|
+
const res = await call(app, { name: " ", graphJson: GOOD });
|
|
187
|
+
assertEquals(res.status, 400);
|
|
188
|
+
assertEquals(res.body.ok, false);
|
|
189
|
+
assertEquals((await deliveryGraphLibrary(data).all()).length, 0);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("save-to-library: providing BOTH graphJson and digest → 400, nothing persisted", async () => {
|
|
194
|
+
await withApp(async (app, data) => {
|
|
195
|
+
const res = await call(app, { name: "conflict", graphJson: GOOD, digest: "deadbeef0000" });
|
|
196
|
+
assertEquals(res.status, 400);
|
|
197
|
+
assertEquals(res.body.ok, false);
|
|
198
|
+
assertEquals((await deliveryGraphLibrary(data).all()).length, 0);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// Regression (PR #533 review): this door is INTENTIONALLY UNGUARDED, unlike the get/delete/import
|
|
203
|
+
// library doors — it is also invoked by a DECLARATIVE page row action ("Save to library" on the
|
|
204
|
+
// In-flight/History grid) that structurally cannot attach an `x-hook-secret` header. So even when
|
|
205
|
+
// NANO_PR_WEBHOOK_SECRET is configured, a save WITHOUT the header must still succeed (a header guard
|
|
206
|
+
// would 401 the door's own UI). We cache-bust re-import the handler with the secret set to prove it
|
|
207
|
+
// ignores the header entirely.
|
|
208
|
+
test("save-to-library: stays open even when NANO_PR_WEBHOOK_SECRET is set (no header guard)", async () => {
|
|
209
|
+
await withApp(async (app, data) => {
|
|
210
|
+
const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
|
|
211
|
+
process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
|
|
212
|
+
try {
|
|
213
|
+
const mod = await import(`./saveToLibrary.ts?guard=${Date.now()}`);
|
|
214
|
+
const reloaded = mod.default as (c: any, a: any) => Promise<any>;
|
|
215
|
+
// No x-hook-secret header, yet the save is accepted and persisted.
|
|
216
|
+
const res = await reloaded({ req: { headers: new Headers() }, params: {}, query: {}, body: { name: "unguarded", graphJson: GOOD } } as any, app);
|
|
217
|
+
assertEquals(res.status, 200);
|
|
218
|
+
assertEquals(res.body.entry.source, "composed");
|
|
219
|
+
assertEquals((await deliveryGraphLibrary(data).all()).length, 1);
|
|
220
|
+
} finally {
|
|
221
|
+
if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
|
|
222
|
+
else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// POST /app/api/actions/delivery-graph/library/save → operationId `saveToLibrary` (issue #522, epic
|
|
2
|
+
// #519 S3). Persist a delivery graph to the reusable LIBRARY — the durable base S4/S5/S6 build on. The
|
|
3
|
+
// request carries the entry `name` (its slug + short-hash derive the stable library id, so re-saving
|
|
4
|
+
// the same name upserts) plus EITHER a raw `graphJson` STRING (compiled from scratch, `source:
|
|
5
|
+
// composed`) OR the `digest` of an existing staged/dispatched proposal whose ALREADY-STORED graph is
|
|
6
|
+
// reused (`source: from-staged` / `from-dispatched`).
|
|
7
|
+
//
|
|
8
|
+
// Every save validates the graph through the SAME `parseAndCompileText` pipeline the preview/stage
|
|
9
|
+
// doors use, so an uncompilable graph can NEVER be persisted — a bad JSON string or a graph that fails
|
|
10
|
+
// validation is a clean 400 and nothing is written. Mirrors the proposals store/door pattern so the S3
|
|
11
|
+
// API surface is familiar to the S4/S5/S6 slices.
|
|
12
|
+
//
|
|
13
|
+
// This library door is INTENTIONALLY UNGUARDED, unlike the get/delete/import library doors that carry
|
|
14
|
+
// the optional NANO_PR_WEBHOOK_SECRET / x-hook-secret guard. Save is the one library door also reached
|
|
15
|
+
// by a DECLARATIVE page-runtime row action — the "Save to library" action on the In-flight/History grid
|
|
16
|
+
// (`pages/delivery-graphs.page.json`), which posts only `{path, body}` and structurally CANNOT attach a
|
|
17
|
+
// custom `x-hook-secret` header (that affordance is the external `@nanobpm/urban` page runtime's, not
|
|
18
|
+
// ours). A header guard here would therefore make the door unreachable by its own UI (a hard 401 on
|
|
19
|
+
// every dispatched-row Save whenever a secret is configured). The imperative composer/library mounts
|
|
20
|
+
// reach the other doors and can send the header; this door cannot require one until the page runtime
|
|
21
|
+
// grows a supported way for declarative actions to authenticate. See PR #533 review.
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
buildLibraryEntryRow,
|
|
25
|
+
type DeliveryLibrarySource,
|
|
26
|
+
libraryEntryDto,
|
|
27
|
+
saveLibraryEntry,
|
|
28
|
+
} from "../app/deliveryGraphLibrary.ts";
|
|
29
|
+
import { deliveryGraphProposals, isProposalExpired } from "../app/deliveryGraphProposals.ts";
|
|
30
|
+
import { parseAndCompileText } from "../app/deliveryGraphTextIngress.ts";
|
|
31
|
+
import { defineOperation } from "../nano-generated/operations.ts";
|
|
32
|
+
|
|
33
|
+
export default defineOperation("saveToLibrary", async ({ body }, app) => {
|
|
34
|
+
const name = body && typeof body.name === "string" ? body.name.trim() : "";
|
|
35
|
+
if (name === "") {
|
|
36
|
+
app.log.warn("save-to-library rejected: missing name");
|
|
37
|
+
return { status: 400, body: { ok: false, error: "request body must carry a non-blank `name` for the library entry" } };
|
|
38
|
+
}
|
|
39
|
+
const description = body && typeof body.description === "string" ? body.description : undefined;
|
|
40
|
+
|
|
41
|
+
const digest = body && typeof body.digest === "string" && body.digest.trim() !== "" ? body.digest.trim() : undefined;
|
|
42
|
+
const graphJson = body && typeof body.graphJson === "string" && body.graphJson.trim() !== "" ? body.graphJson : undefined;
|
|
43
|
+
if (digest && graphJson) {
|
|
44
|
+
app.log.warn("save-to-library rejected: both digest and graphJson", { name });
|
|
45
|
+
return { status: 400, body: { ok: false, error: "provide EITHER `graphJson` (a raw graph) OR `digest` (an existing proposal), not both" } };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Resolve the graph text + its provenance. From a digest: reuse the proposal's already-stored graph
|
|
49
|
+
// (still re-validated below, so a corrupt stored graph is refused). From raw JSON: compile from scratch.
|
|
50
|
+
let sourceGraphJson: string;
|
|
51
|
+
let source: DeliveryLibrarySource;
|
|
52
|
+
if (digest) {
|
|
53
|
+
const proposal = await deliveryGraphProposals(app.data).get(digest);
|
|
54
|
+
if (!proposal) {
|
|
55
|
+
app.log.warn("save-to-library rejected: unknown digest", { digest });
|
|
56
|
+
return { status: 400, body: { ok: false, error: `no stored graph for digest ${digest} — stage or dispatch it first, or save a raw graph` } };
|
|
57
|
+
}
|
|
58
|
+
// Only a LIVE staged (not aged out of its TTL) or dispatched proposal may seed the library — a
|
|
59
|
+
// superseded/expired/stale-staged digest is not a canonical graph, and reusing it would both persist a
|
|
60
|
+
// retired graph and mis-label it `source: from-staged`.
|
|
61
|
+
const isLiveStaged = proposal.status === "staged" && !isProposalExpired(proposal.expires_at);
|
|
62
|
+
if (!isLiveStaged && proposal.status !== "dispatched") {
|
|
63
|
+
app.log.warn("save-to-library rejected: digest not live staged/dispatched", { digest, status: proposal.status });
|
|
64
|
+
return { status: 400, body: { ok: false, error: `digest ${digest} is not a live staged/dispatched proposal (status ${proposal.status}) — stage or dispatch it first, or save a raw graph` } };
|
|
65
|
+
}
|
|
66
|
+
sourceGraphJson = proposal.graph;
|
|
67
|
+
source = proposal.status === "dispatched" ? "from-dispatched" : "from-staged";
|
|
68
|
+
} else if (graphJson) {
|
|
69
|
+
sourceGraphJson = graphJson;
|
|
70
|
+
source = "composed";
|
|
71
|
+
} else {
|
|
72
|
+
app.log.warn("save-to-library rejected: no graph source", { name });
|
|
73
|
+
return { status: 400, body: { ok: false, error: "request body must carry either `graphJson` (a raw graph) or `digest` (an existing proposal)" } };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Validate/compile — an uncompilable graph can never enter the library (nothing is persisted here).
|
|
77
|
+
const ingress = await parseAndCompileText({ graphJson: sourceGraphJson });
|
|
78
|
+
if (!ingress.ok) {
|
|
79
|
+
app.log.warn("save-to-library rejected: graph failed validation", { name, source, message: ingress.body.error });
|
|
80
|
+
return { status: 400, body: ingress.body };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const saved = await saveLibraryEntry(
|
|
84
|
+
app.data,
|
|
85
|
+
buildLibraryEntryRow({ name, description, graphJson: JSON.stringify(ingress.graph), source }),
|
|
86
|
+
);
|
|
87
|
+
app.log.info("save-to-library saved", { id: saved.id, name: saved.name, source });
|
|
88
|
+
return { status: 200, body: { ok: true, entry: libraryEntryDto(saved) } };
|
|
89
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.139.0",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
|
@@ -170,6 +170,39 @@
|
|
|
170
170
|
color: #8aa0b8;
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
/* The filesystem Import control (issue #524): a <label class="btn"> wrapping a visually-hidden native
|
|
174
|
+
<input type=file>, so the file picker reads as a button consistent with the other actions. The label
|
|
175
|
+
dims while its input is disabled (busy) via :has(), mirroring .btn:disabled. */
|
|
176
|
+
.dg .dg-import {
|
|
177
|
+
position: relative;
|
|
178
|
+
display: inline-flex;
|
|
179
|
+
align-items: center;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.dg .dg-import-input {
|
|
183
|
+
position: absolute;
|
|
184
|
+
width: 1px;
|
|
185
|
+
height: 1px;
|
|
186
|
+
padding: 0;
|
|
187
|
+
margin: -1px;
|
|
188
|
+
overflow: hidden;
|
|
189
|
+
clip: rect(0, 0, 0, 0);
|
|
190
|
+
border: 0;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.dg .dg-import:has(.dg-import-input:disabled) {
|
|
194
|
+
opacity: 0.5;
|
|
195
|
+
cursor: default;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/* The native <input type=file> is visually hidden (clipped to 1×1), which also clips its browser
|
|
199
|
+
focus ring, so surface a visible keyboard focus indicator on the wrapping label instead — keyboard
|
|
200
|
+
users can tab to Import and see where focus landed. */
|
|
201
|
+
.dg .dg-import:has(.dg-import-input:focus-visible) {
|
|
202
|
+
outline: 2px solid #58a6ff;
|
|
203
|
+
outline-offset: 2px;
|
|
204
|
+
}
|
|
205
|
+
|
|
173
206
|
.dg .status {
|
|
174
207
|
font-size: 12.5px;
|
|
175
208
|
color: #8aa0b8;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Delivery graphs — library (reuse · delete) (App View embed)</title>
|
|
7
|
+
<link rel="stylesheet" href="./delivery-graphs.css" />
|
|
8
|
+
<style>
|
|
9
|
+
html, body { margin: 0; height: 100%; background: #0b0f14; }
|
|
10
|
+
</style>
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<!--
|
|
14
|
+
Console App-View embed (ADR 0057, issue #523). The console loads this document into its App-View
|
|
15
|
+
surface and hands it a host element; we mount the SAME reusable-library list (Reuse + Delete) via
|
|
16
|
+
the SAME ./library.mount.js as the standalone shell — only the host and the injected endpoint
|
|
17
|
+
config differ, so the view renders identically. When the console injects endpoint config via
|
|
18
|
+
`window.__NANO_APP_VIEW__`, it wins.
|
|
19
|
+
-->
|
|
20
|
+
<main id="delivery-graphs-library-root"></main>
|
|
21
|
+
<script type="module">
|
|
22
|
+
import { mountDeliveryGraphLibrary } from "./library.mount.js";
|
|
23
|
+
|
|
24
|
+
const cfg = window.__NANO_APP_VIEW__ ?? {};
|
|
25
|
+
mountDeliveryGraphLibrary(cfg.host ?? document.getElementById("delivery-graphs-library-root"), {
|
|
26
|
+
libraryUrl: cfg.libraryUrl,
|
|
27
|
+
hookSecret: cfg.hookSecret,
|
|
28
|
+
});
|
|
29
|
+
</script>
|
|
30
|
+
</body>
|
|
31
|
+
</html>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
6
|
+
<title>Delivery graphs — library (reuse · delete)</title>
|
|
7
|
+
<link rel="stylesheet" href="./delivery-graphs.css" />
|
|
8
|
+
<style>
|
|
9
|
+
html, body { margin: 0; height: 100%; background: #0b0f14; }
|
|
10
|
+
</style>
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<!--
|
|
14
|
+
Standalone shell (phone / direct link). Loads the SAME ./library.mount.js the console App-View
|
|
15
|
+
embed uses, so the standalone and embedded views render identically. Endpoints default to the
|
|
16
|
+
current origin; override the list endpoint via ?library=. For a secured deployment, pass the guard
|
|
17
|
+
secret via the URL fragment #secret= (sent as x-hook-secret) — NOT the query string, so it never
|
|
18
|
+
leaks via server access logs, browser history, or the Referer header. The fragment is stripped
|
|
19
|
+
from the address bar immediately after it is read. Note: "Reuse" fills the compose App-View, which
|
|
20
|
+
only exists in the console — standalone it reports that instead of failing silently.
|
|
21
|
+
-->
|
|
22
|
+
<main id="delivery-graphs-library-root"></main>
|
|
23
|
+
<script type="module">
|
|
24
|
+
import { mountDeliveryGraphLibrary } from "./library.mount.js";
|
|
25
|
+
|
|
26
|
+
const params = new URLSearchParams(location.search);
|
|
27
|
+
const secrets = new URLSearchParams(location.hash.slice(1));
|
|
28
|
+
const hookSecret = secrets.get("secret") ?? undefined;
|
|
29
|
+
if (location.hash) {
|
|
30
|
+
history.replaceState(null, "", location.pathname + location.search);
|
|
31
|
+
}
|
|
32
|
+
mountDeliveryGraphLibrary(document.getElementById("delivery-graphs-library-root"), {
|
|
33
|
+
libraryUrl: params.get("library") ?? undefined,
|
|
34
|
+
hookSecret,
|
|
35
|
+
});
|
|
36
|
+
</script>
|
|
37
|
+
</body>
|
|
38
|
+
</html>
|