@nanobpm/nano-workforce 0.123.2 → 0.124.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +9 -5
  3. package/app/deliveryGraphDispatch.test.ts +143 -0
  4. package/app/deliveryGraphDispatch.ts +168 -0
  5. package/app/deliveryGraphProposals.test.ts +267 -0
  6. package/app/deliveryGraphProposals.ts +269 -0
  7. package/app/deliveryGraphRun.test.ts +6 -52
  8. package/app/deliveryGraphRun.ts +21 -76
  9. package/app/deliveryGraphText.ts +3 -3
  10. package/app/deliveryRunner.ts +4 -3
  11. package/app/service.ts +15 -0
  12. package/db/migrations/075_delivery_graph_proposals.sql +48 -0
  13. package/docs/adr/0005-agent-authored-delivery-graphs.md +18 -0
  14. package/docs/adr/0006-delivery-units-one-representation.md +221 -0
  15. package/docs/agent-guide.md +50 -58
  16. package/e2e/delivery-graph-dispatch.e2e.ts +155 -0
  17. package/openapi.yaml +118 -161
  18. package/operations/compileDeliveryGraph.test.ts +100 -37
  19. package/operations/compileDeliveryGraph.ts +64 -18
  20. package/operations/dispatchDeliveryGraph.test.ts +171 -152
  21. package/operations/dispatchDeliveryGraph.ts +79 -99
  22. package/operations/getAgentInstructions.test.ts +10 -6
  23. package/operations/previewDeliveryGraph.test.ts +90 -51
  24. package/operations/previewDeliveryGraph.ts +45 -18
  25. package/package.json +1 -1
  26. package/pages/cockpit/mount.js +19 -12
  27. package/pages/delivery-graphs/mount.js +37 -137
  28. package/pages/delivery-graphs.page.json +50 -3
  29. package/scripts/check-migrations.test.ts +9 -0
  30. package/scripts/check-migrations.ts +11 -1
  31. package/test/cockpit-embed-endpoints.test.ts +59 -36
  32. package/test/delivery-graphs-embed.test.ts +36 -34
  33. package/e2e/delivery-graph-start.e2e.ts +0 -145
  34. package/operations/startDeliveryGraph.integration.test.ts +0 -316
  35. package/operations/startDeliveryGraph.ts +0 -222
@@ -1,113 +1,93 @@
1
- // POST /app/api/actions/delivery-graph/dispatch → operationId `dispatchDeliveryGraph` (issue #386,
2
- // ADR 0005 slice S5). The human-facing UI JSON-paste DISPATCH ingress: the Delivery Graphs page's
3
- // "Dispatch" action posts the operator's pasted delivery-graph as a raw JSON STRING plus an explicit
4
- // `approve` flag; this door parses it (`parseDeliveryGraphText`) and DELEGATES to the SAME gated,
5
- // idempotent `startDeliveryGraph` handler the agent-facing / REST paths use. There is deliberately NO
6
- // parallel dispatch path — this is a thin UI text adapter onto the ONE contract (S5's stated "UI
7
- // JSON-paste" ingress that S5 named but did not build).
1
+ // POST /app/api/actions/delivery-graph/dispatch → operationId `dispatchDeliveryGraph` (ADR 0005
2
+ // Decision 7, issue #460). The OPERATOR-ONLY dispatch door: the cockpit's staged-proposals grid posts
3
+ // the `digest` of the proposal the operator picked; this door loads that `staged` proposal, runs the
4
+ // retained S4 runner for its previewed graph (`dispatchDeliveryGraphRun`), and marks the proposal
5
+ // `dispatched`.
8
6
  //
9
- // APPROVAL. When the operator ticks `approve` (having reviewed the preview), the door derives the
10
- // graph's content digest via the SAME pure compiler `startDeliveryGraph` uses and presents it as
11
- // the `approvalToken`, so a side-effecting graph dispatches. Without `approve`, a side-effecting
12
- // graph is PARKED at approval by the start door (a 400 whose `awaiting-approval` run shows in the
13
- // in-flight grid) and a non-side-effecting graph dispatches straight away.
14
- // • The start door owns idempotency, the durable run row, and the at-most-once launch fence — this
15
- // adapter adds nothing to that; it only parses the paste and surfaces a human `error` banner.
7
+ // The operator clicking Dispatch IS the approval there is no replayable token. This door is NOT part
8
+ // of the agent surface: the agent compile door returns only a navigational preview (no digest-as-
9
+ // dispatch-handle), so an agent cannot reach a run through the documented surface. Idempotent: a
10
+ // re-dispatch of an already-running run short-circuits with `alreadyRunning`. An unknown / expired /
11
+ // superseded / already-dispatched digest is a clean 400.
16
12
 
17
- import { compileDeliveryGraph } from "../app/deliveryGraphCompiler.ts";
18
- import { parseDeliveryGraphText } from "../app/deliveryGraphText.ts";
19
- import { deliveryGraphDigest } from "../app/deliveryRunner.ts";
20
- import type { DeliveryCompileError, DeliveryGraphTextResult } from "../nano-generated/api-io.d.ts";
13
+ import { dispatchDeliveryGraphRun } from "../app/deliveryGraphDispatch.ts";
14
+ import { getStagedProposal, markProposalDispatched, markProposalExpired } from "../app/deliveryGraphProposals.ts";
15
+ import type { DeliveryGraphTextResult } from "../nano-generated/api-io.d.ts";
21
16
  import { defineOperation } from "../nano-generated/operations.ts";
22
- import startDeliveryGraph from "./startDeliveryGraph.ts";
23
17
 
24
- export default defineOperation("dispatchDeliveryGraph", async (input, app) => {
25
- const body = input.body;
26
- const parsed = parseDeliveryGraphText(body);
27
- if (!parsed.ok) {
28
- app.log.warn("dispatch-delivery-graph rejected: parse", { message: parsed.error });
29
- return { status: 400, body: { ok: false, error: parsed.error } };
18
+ export default defineOperation("dispatchDeliveryGraph", 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("dispatch-delivery-graph rejected: missing digest");
22
+ return { status: 400, body: { ok: false, error: "request body must carry a `digest` naming the staged proposal to dispatch" } };
30
23
  }
31
- const approve = body?.approve === true;
32
- const idemRaw = typeof body?.idempotencyKey === "string" ? body.idempotencyKey.trim() : "";
24
+ const idemRaw = body && typeof body === "object" && "idempotencyKey" in body && typeof body.idempotencyKey === "string" ? body.idempotencyKey.trim() : "";
33
25
  const idempotencyKey = idemRaw !== "" ? idemRaw : undefined;
34
26
 
35
- // When the operator approves, derive the graph's content digest (the approval token) via the SAME
36
- // pure compiler the start door uses, so a side-effecting graph they reviewed in the preview
37
- // dispatches. A compile failure here surfaces as a clean 400 rather than reaching the start door.
38
- let approvalToken: string | undefined;
39
- if (approve) {
40
- const compiled = await compileDeliveryGraph(parsed.graph);
41
- if (!compiled.ok) {
42
- app.log.warn("dispatch-delivery-graph rejected: compile", { errors: compiled.errors.length });
43
- return {
44
- status: 400,
45
- body: {
46
- ok: false,
47
- error: `graph failed validation: ${compiled.errors.length} error(s)`,
48
- errors: compiled.errors,
49
- },
50
- };
51
- }
52
- approvalToken = deliveryGraphDigest(compiled.bpmn);
27
+ // Load the live staged proposal for this digest refuses an unknown/expired/superseded/already-
28
+ // dispatched digest cleanly (no run is launched).
29
+ const proposal = await getStagedProposal(app.data, digest);
30
+ if (!proposal) {
31
+ app.log.warn("dispatch-delivery-graph rejected: no live staged proposal", { digest });
32
+ return {
33
+ status: 400,
34
+ body: { ok: false, error: `no staged proposal for digest ${digest} it may have been dispatched, superseded, or aged out; recompile to re-stage it` },
35
+ };
53
36
  }
54
37
 
55
- const startBody: Record<string, unknown> = { graph: parsed.graph };
56
- if (approvalToken !== undefined) startBody.approvalToken = approvalToken;
57
- if (idempotencyKey !== undefined) startBody.idempotencyKey = idempotencyKey;
58
-
59
- // Delegate to the ONE dispatch contract — the exact `startDeliveryGraph` handler, no re-implementation.
60
- // The parsed graph is `unknown` (the paste is runtime-validated inside the door), so the handler's
61
- // typed input signature is bridged here; nothing re-implements dispatch.
62
- // biome-ignore lint/plugin: bridging the runtime-validated paste onto the start door's typed input; the door owns validation.
63
- const delegate = startDeliveryGraph as (
64
- i: { req: unknown; params: unknown; query: unknown; body: unknown },
65
- a: typeof app,
66
- ) => Promise<{ status?: number; body?: Record<string, unknown> } | undefined>;
67
- const res = await delegate(
68
- { req: input.req, params: input.params, query: input.query, body: startBody },
69
- app,
70
- );
71
- const resBody: Record<string, unknown> = res?.body ?? {};
72
- const status = res?.status ?? 202;
73
-
74
- // Re-shape the start door's result onto this ingress contract by narrowing each field — no assertions.
75
- const outBody: DeliveryGraphTextResult = { ok: resBody.ok === true };
76
- if (typeof resBody.status === "string") outBody.status = resBody.status;
77
- if (typeof resBody.runKey === "string") outBody.runKey = resBody.runKey;
78
- if (typeof resBody.digest === "string") outBody.digest = resBody.digest;
79
- if (typeof resBody.sideEffecting === "boolean") outBody.sideEffecting = resBody.sideEffecting;
80
- if (typeof resBody.alreadyRunning === "boolean") outBody.alreadyRunning = resBody.alreadyRunning;
81
- if (typeof resBody.processInstanceKey === "string") outBody.processInstanceKey = resBody.processInstanceKey;
82
- if (typeof resBody.processDefinitionId === "string") outBody.processDefinitionId = resBody.processDefinitionId;
83
- if (typeof resBody.approvalToken === "string") outBody.approvalToken = resBody.approvalToken;
84
- if (typeof resBody.message === "string") outBody.message = resBody.message;
38
+ // The stored graph was validated at stage time; dispatch re-compiles it to derive the run-row shape.
39
+ let graph: unknown;
40
+ try {
41
+ graph = JSON.parse(proposal.graph);
42
+ } catch (err) {
43
+ app.log.error("dispatch-delivery-graph: stored graph is corrupt", { digest });
44
+ // Fail closed: a corrupt graph can never launch, so retire the proposal (→ `expired`) instead of
45
+ // leaving an undismissable `staged` row that fails every dispatch attempt the same way.
46
+ await markProposalExpired(app.data, digest);
47
+ return { status: 400, body: { ok: false, error: `staged proposal ${digest} is corrupt: ${err instanceof Error ? err.message : String(err)}` } };
48
+ }
85
49
 
86
- // Carry through the start door's path-qualified validation/compile `errors` so callers/UI keep the
87
- // structured detail (not just the summary banner). Narrow each entry to the wire pair — no assertions.
88
- if (Array.isArray(resBody.errors)) {
89
- const errors: DeliveryCompileError[] = [];
90
- for (const e of resBody.errors) {
91
- if (
92
- typeof e === "object" &&
93
- e !== null &&
94
- "path" in e &&
95
- "message" in e &&
96
- typeof e.path === "string" &&
97
- typeof e.message === "string"
98
- ) {
99
- errors.push({ path: e.path, message: e.message });
100
- }
101
- }
102
- if (errors.length > 0) outBody.errors = errors;
50
+ const dispatched = await dispatchDeliveryGraphRun(app, graph, { runKey: idempotencyKey, title: proposal.title });
51
+ if (!dispatched.ok) {
52
+ app.log.warn("dispatch-delivery-graph refused: compile", { digest, errors: dispatched.errors.length });
53
+ const outBody: DeliveryGraphTextResult = {
54
+ ok: false,
55
+ error: `graph failed validation: ${dispatched.errors.length} error(s)`,
56
+ errors: dispatched.errors,
57
+ };
58
+ return { status: 400, body: outBody };
103
59
  }
104
60
 
105
- // Surface a human error banner for the page on a refusal (parked-at-approval / validation).
106
- if (status >= 400 && typeof outBody.error !== "string") {
107
- if (typeof resBody.error === "string") outBody.error = resBody.error;
108
- else if (typeof resBody.message === "string") outBody.error = resBody.message;
109
- else if (outBody.errors !== undefined) outBody.error = `graph failed validation: ${outBody.errors.length} error(s)`;
110
- else outBody.error = "dispatch was refused";
61
+ // Retire the proposal from the staged list — the run now shows in the in-flight grid. Guard against
62
+ // an `idempotencyKey` that short-circuits onto an ALREADY-running run of a DIFFERENT graph: in that
63
+ // case `dispatchDeliveryGraphRun` returns that other run's `digest`, so THIS proposal's graph was
64
+ // never launched. Consuming it then would mark a proposal `dispatched` that never ran. Only retire the
65
+ // proposal when the live run is genuinely this proposal's graph (`dispatched.digest === digest`).
66
+ if (dispatched.digest !== digest) {
67
+ app.log.warn("dispatch-delivery-graph refused: idempotencyKey bound to a different running graph", {
68
+ digest,
69
+ runDigest: dispatched.digest,
70
+ runKey: dispatched.runKey,
71
+ });
72
+ return {
73
+ status: 409,
74
+ body: {
75
+ ok: false,
76
+ error: `idempotencyKey is already bound to a different running delivery graph (digest ${dispatched.digest}); the staged proposal ${digest} was NOT dispatched — retry with a fresh idempotencyKey (or none)`,
77
+ },
78
+ };
111
79
  }
112
- return { status, body: outBody };
80
+ await markProposalDispatched(app.data, digest);
81
+
82
+ const outBody: DeliveryGraphTextResult = {
83
+ ok: true,
84
+ status: dispatched.status,
85
+ runKey: dispatched.runKey,
86
+ digest: dispatched.digest,
87
+ sideEffecting: dispatched.sideEffecting,
88
+ alreadyRunning: dispatched.alreadyRunning,
89
+ };
90
+ if (dispatched.processInstanceKey !== undefined) outBody.processInstanceKey = dispatched.processInstanceKey;
91
+ if (dispatched.processDefinitionId !== undefined) outBody.processDefinitionId = dispatched.processDefinitionId;
92
+ return { status: 202, body: outBody };
113
93
  });
@@ -52,9 +52,11 @@ test("the guide covers every capability the endpoint promises", async () => {
52
52
 
53
53
  test("the guide documents the delivery-graph surface (ADR 0005)", async () => {
54
54
  const md = ((await handler(input(), app)) as any).body.instructions as string;
55
- // The two doors, with their exact action paths.
56
- assert(md.includes("compile-delivery-graph"), "documents the pure compile door");
57
- assert(md.includes("start/delivery-graph"), "documents the gated dispatch door");
55
+ // The agent surface: a single compile door that validates + previews + STAGES. There is NO agent
56
+ // start/dispatch door (issue #460) dispatch is an operator action in the cockpit.
57
+ assert(md.includes("compile-delivery-graph"), "documents the compile+stage door");
58
+ assert(!md.includes("start/delivery-graph"), "does NOT expose an agent start/dispatch door (issue #460)");
59
+ assert(md.includes("propose → compile → stage"), "frames the agent surface as ending at stage");
58
60
  // The closed node vocabulary: assert the exact config snippet for each of the four kinds,
59
61
  // so the test fails if §9's node-kind table is removed or reworded — not merely if the bare
60
62
  // words "agent"/"wait"/"human"/"connector" appear anywhere else in the guide.
@@ -65,9 +67,11 @@ test("the guide documents the delivery-graph surface (ADR 0005)", async () => {
65
67
  // The fact-edge syntax: an edge is `{ from, to }` and `from` may be a qualified `<nodeId>.<fact>`.
66
68
  assert(md.includes("each edge is `{ from, to }`"), "documents the edge shape");
67
69
  assert(md.includes("qualified `<nodeId>.<fact>`"), "documents the qualified fact-edge syntax");
68
- // The approval gate + idempotency of the start door.
69
- assert(md.includes("approvalToken"), "documents the approval gate");
70
- assert(md.includes("idempotencyKey"), "documents idempotency");
70
+ // Operator-only dispatch: the compile response carries a digest + navigational reviewUrl and the
71
+ // guide directs the agent to ask an operator to dispatch in the cockpit (no self-service replay).
72
+ assert(md.includes("reviewUrl"), "documents the navigational reviewUrl");
73
+ assert(md.includes("Dispatch is an operator action") || md.includes("Dispatch** in the cockpit"), "documents operator-only dispatch");
74
+ assert(!md.includes("approvalToken"), "the replayable approvalToken flow is gone (issue #460)");
71
75
  // The worked example: a human emit node handing a version to a downstream edge.
72
76
  assert(md.includes("manual-publish.publishedVersion"), "includes the worked example's human-emit fact edge");
73
77
  });
@@ -1,17 +1,35 @@
1
- // Tests for the POST /app/api/actions/delivery-graph/preview operation `previewDeliveryGraph`
2
- // (issue #386, ADR 0005 slice S1) — the human-facing UI JSON-paste PREVIEW ingress. It parses the
3
- // operator's pasted JSON STRING and runs the SAME pure `compileDeliveryGraph` compiler the agent door
4
- // uses, mapping the result onto a compact summary (200) or a human `error` + path-qualified `errors`
5
- // (400). It is PURE — no data layer, no dispatch so it mirrors the compile door's test harness.
1
+ // Tests for the POST /app/api/actions/delivery-graph/preview operation `previewDeliveryGraph` (ADR
2
+ // 0005 Decision 7, issue #460) — the human-facing UI JSON-paste PREVIEW+STAGE ingress. It parses the
3
+ // operator's pasted JSON STRING, runs the SAME `compileDeliveryGraph` compiler the agent door uses,
4
+ // and like the agent compile door persists the compiled graph as a `staged` proposal, returning a
5
+ // compact summary (200, `staged:true` + `reviewUrl`) or a human `error` + path-qualified `errors`
6
+ // (400). It never dispatches — that is a separate operator action on the staged proposal.
7
+ import { mkdtempSync, rmSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join, resolve } from "node:path";
6
10
  import { test } from "node:test";
7
11
  import { assert, assertEquals } from "#test-assert";
8
- import type { AppApi } from "@nanobpm/urban";
12
+ import type { AppApi, DataLayer } from "@nanobpm/urban";
13
+ import { bootTestApp } from "@nanobpm/urban-testkit";
14
+ import { deliveryGraphProposals } from "../app/deliveryGraphProposals.ts";
9
15
  import { noopLog } from "../test/log.ts";
10
16
  import handler from "./previewDeliveryGraph.ts";
11
17
 
12
- const app = { log: noopLog() } as unknown as AppApi;
18
+ const APP_ROOT = resolve(import.meta.dirname, "..");
13
19
 
14
- async function call(body: unknown) {
20
+ async function withApp(fn: (app: AppApi, data: DataLayer) => Promise<void>): Promise<void> {
21
+ const dir = mkdtempSync(join(tmpdir(), "nwf-dgpreview-"));
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) {
15
33
  return (await handler({ req: {} as any, params: {}, query: {}, body } as any, app)) as any;
16
34
  }
17
35
 
@@ -24,60 +42,81 @@ const GOOD = JSON.stringify({
24
42
  edges: [{ from: "a", to: "b" }],
25
43
  });
26
44
 
27
- test("preview-delivery-graph: a pasted well-formed graph → 200 summary with digest + counts", async () => {
28
- const res = await call({ graphJson: GOOD });
29
- assertEquals(res.status, 200);
30
- assertEquals(res.body.ok, true);
31
- assert(typeof res.body.digest === "string" && res.body.digest.length > 0);
32
- assertEquals(res.body.nodeCount, 2);
33
- assertEquals(res.body.humanNodeCount, 1);
34
- assertEquals(res.body.sideEffectCount, 1);
35
- assertEquals(res.body.sideEffecting, true);
36
- assert(typeof res.body.diagram === "string" && res.body.diagram.length > 0);
37
- assertEquals(res.body.title, "runbook");
38
- // The FULL preview detail (#441) — the human stop-points and side-effecting actions the page
39
- // renders, not just the counts. `a` is the side-effecting agent node; `b` is the human stop.
40
- assert(Array.isArray(res.body.humanNodes) && res.body.humanNodes.length === 1);
41
- assertEquals(res.body.humanNodes[0].nodeId, "b");
42
- assertEquals(res.body.humanNodes[0].prompt, "do X");
43
- assert(Array.isArray(res.body.sideEffects) && res.body.sideEffects.length === 1);
44
- assertEquals(res.body.sideEffects[0].nodeId, "a");
45
- assertEquals(res.body.sideEffects[0].kind, "agent");
46
- assert(typeof res.body.sideEffects[0].description === "string" && res.body.sideEffects[0].description.length > 0);
45
+ test("preview-delivery-graph: a pasted well-formed graph → 200 summary, staged, with digest + counts", async () => {
46
+ await withApp(async (app, data) => {
47
+ const res = await call(app, { graphJson: GOOD });
48
+ assertEquals(res.status, 200);
49
+ assertEquals(res.body.ok, true);
50
+ assertEquals(res.body.staged, true);
51
+ assert(typeof res.body.digest === "string" && res.body.digest.length > 0);
52
+ assert(typeof res.body.reviewUrl === "string" && res.body.reviewUrl.length > 0);
53
+ assertEquals(res.body.nodeCount, 2);
54
+ assertEquals(res.body.humanNodeCount, 1);
55
+ assertEquals(res.body.sideEffectCount, 1);
56
+ assertEquals(res.body.sideEffecting, true);
57
+ assert(typeof res.body.diagram === "string" && res.body.diagram.length > 0);
58
+ assertEquals(res.body.title, "runbook");
59
+ // The FULL preview detail (#441) — the human stop-points and side-effecting actions the page
60
+ // renders, not just the counts. `a` is the side-effecting agent node; `b` is the human stop.
61
+ assert(Array.isArray(res.body.humanNodes) && res.body.humanNodes.length === 1);
62
+ assertEquals(res.body.humanNodes[0].nodeId, "b");
63
+ assertEquals(res.body.humanNodes[0].prompt, "do X");
64
+ assert(Array.isArray(res.body.sideEffects) && res.body.sideEffects.length === 1);
65
+ assertEquals(res.body.sideEffects[0].nodeId, "a");
66
+ assertEquals(res.body.sideEffects[0].kind, "agent");
67
+ assert(typeof res.body.sideEffects[0].description === "string" && res.body.sideEffects[0].description.length > 0);
68
+ // A staged proposal now exists for the operator to dispatch — and NO dispatch handle came back.
69
+ assertEquals((await deliveryGraphProposals(data).get(res.body.digest))?.status, "staged");
70
+ assertEquals(res.body.runKey, undefined);
71
+ assertEquals(res.body.processInstanceKey, undefined);
72
+ });
47
73
  });
48
74
 
49
- test("preview-delivery-graph: is PURE — repeated previews return the identical digest", async () => {
50
- const a = await call({ graphJson: GOOD });
51
- const b = await call({ graphJson: GOOD });
52
- assertEquals(a.body.digest, b.body.digest);
75
+ test("preview-delivery-graph: repeated previews stage the identical digest idempotently (one live row)", async () => {
76
+ await withApp(async (app, data) => {
77
+ const a = await call(app, { graphJson: GOOD });
78
+ const b = await call(app, { graphJson: GOOD });
79
+ assertEquals(a.body.digest, b.body.digest);
80
+ assertEquals((await deliveryGraphProposals(data).find({ digest: a.body.digest })).length, 1);
81
+ });
53
82
  });
54
83
 
55
- test("preview-delivery-graph: text that is not valid JSON → 400 with a human error", async () => {
56
- const res = await call({ graphJson: "{ not json" });
57
- assertEquals(res.status, 400);
58
- assertEquals(res.body.ok, false);
59
- assert(typeof res.body.error === "string" && res.body.error.includes("not valid JSON"));
84
+ test("preview-delivery-graph: text that is not valid JSON → 400 with a human error, nothing staged", async () => {
85
+ await withApp(async (app, data) => {
86
+ const res = await call(app, { graphJson: "{ not json" });
87
+ assertEquals(res.status, 400);
88
+ assertEquals(res.body.ok, false);
89
+ assert(typeof res.body.error === "string" && res.body.error.includes("not valid JSON"));
90
+ assertEquals((await deliveryGraphProposals(data).all()).length, 0);
91
+ });
60
92
  });
61
93
 
62
94
  test("preview-delivery-graph: a blank paste → 400, never a 500", async () => {
63
- const res = await call({ graphJson: " " });
64
- assertEquals(res.status, 400);
65
- assertEquals(res.body.ok, false);
66
- assert(typeof res.body.error === "string" && res.body.error.length > 0);
95
+ await withApp(async (app) => {
96
+ const res = await call(app, { graphJson: " " });
97
+ assertEquals(res.status, 400);
98
+ assertEquals(res.body.ok, false);
99
+ assert(typeof res.body.error === "string" && res.body.error.length > 0);
100
+ });
67
101
  });
68
102
 
69
- test("preview-delivery-graph: a valid-JSON but malformed graph → 400 with path-qualified errors", async () => {
70
- const res = await call({
71
- graphJson: JSON.stringify({ nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" } }], edges: [{ from: "a", to: "ghost" }] }),
103
+ test("preview-delivery-graph: a valid-JSON but malformed graph → 400 with path-qualified errors, nothing staged", async () => {
104
+ await withApp(async (app, data) => {
105
+ const res = await call(app, {
106
+ graphJson: JSON.stringify({ nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" } }], edges: [{ from: "a", to: "ghost" }] }),
107
+ });
108
+ assertEquals(res.status, 400);
109
+ assertEquals(res.body.ok, false);
110
+ assert(Array.isArray(res.body.errors) && res.body.errors.length > 0);
111
+ assert(res.body.errors.every((e: { path: string; message: string }) => typeof e.path === "string"));
112
+ assertEquals((await deliveryGraphProposals(data).all()).length, 0);
72
113
  });
73
- assertEquals(res.status, 400);
74
- assertEquals(res.body.ok, false);
75
- assert(Array.isArray(res.body.errors) && res.body.errors.length > 0);
76
- assert(res.body.errors.every((e: { path: string; message: string }) => typeof e.path === "string"));
77
114
  });
78
115
 
79
116
  test("preview-delivery-graph: a pasted JSON array (not an object) → 400", async () => {
80
- const res = await call({ graphJson: "[]" });
81
- assertEquals(res.status, 400);
82
- assertEquals(res.body.ok, false);
117
+ await withApp(async (app) => {
118
+ const res = await call(app, { graphJson: "[]" });
119
+ assertEquals(res.status, 400);
120
+ assertEquals(res.body.ok, false);
121
+ });
83
122
  });
@@ -1,17 +1,24 @@
1
- // POST /app/api/actions/delivery-graph/preview → operationId `previewDeliveryGraph` (issue #386,
2
- // ADR 0005 slice S1). The human-facing UI JSON-paste PREVIEW ingress: the Delivery Graphs page's
3
- // "Preview" action posts the operator's pasted delivery-graph as a raw JSON STRING; this door parses
4
- // it (`parseDeliveryGraphText`) and runs the SAME pure `compileDeliveryGraph` compiler the agent-facing
5
- // door uses, returning a compact summary the content `digest` (the approval token to dispatch with),
6
- // the node / human-stop / side-effect counts, and the mermaid `diagram`.
1
+ // POST /app/api/actions/delivery-graph/preview → operationId `previewDeliveryGraph` (ADR 0005
2
+ // Decision 7, issue #460). The human-facing UI JSON-paste PREVIEW+STAGE ingress: the Delivery Graphs
3
+ // page's "Preview & stage" action posts the operator's pasted delivery-graph as a raw JSON STRING;
4
+ // this door parses it (`parseDeliveryGraphText`) and runs the SAME `compileDeliveryGraph` compiler the
5
+ // agent-facing door uses, and like the agent compile door persists the compiled graph as a
6
+ // `staged` proposal (content-addressed by its `digest`).
7
7
  //
8
- // It is PURE and side-effect-free (compile and start are separate doors — Decision 5/7): nothing is
9
- // deployed or dispatched, so an operator can Preview repeatedly while fixing the JSON. A blank/invalid
10
- // paste, or a graph that fails validation, is a 400 carrying a human `error` (and, for a compile
11
- // failure, the path-qualified `errors`). This is a thin UI adapter over the ONE compile contract, not
12
- // a parallel compile path.
8
+ // It returns a compact preview summary (the `digest`, node/human/side-effect counts, the mermaid
9
+ // `diagram`, and the full human-stop / side-effect detail) plus a navigational `reviewUrl`. It never
10
+ // deploys or dispatches dispatch is a separate OPERATOR action on the staged proposal (the Dispatch
11
+ // button on the staged-proposals grid). A blank/invalid paste, or a graph that fails validation, is a
12
+ // 400 carrying a human `error` (and path-qualified `errors` for a compile failure); nothing is staged.
13
13
 
14
14
  import { compileDeliveryGraph } from "../app/deliveryGraphCompiler.ts";
15
+ import {
16
+ buildProposalPreview,
17
+ buildProposalRow,
18
+ proposalLogicalKey,
19
+ proposalReviewUrl,
20
+ stageProposal,
21
+ } from "../app/deliveryGraphProposals.ts";
15
22
  import { parseDeliveryGraphText } from "../app/deliveryGraphText.ts";
16
23
  import { deliveryGraphDigest } from "../app/deliveryRunner.ts";
17
24
  import { defineOperation } from "../nano-generated/operations.ts";
@@ -34,8 +41,29 @@ export default defineOperation("previewDeliveryGraph", async ({ body }, app) =>
34
41
  },
35
42
  };
36
43
  }
44
+
37
45
  const digest = deliveryGraphDigest(compiled.bpmn);
38
- app.log.info("preview-delivery-graph compiled", {
46
+ const name =
47
+ typeof compiled.resolved.name === "string" && compiled.resolved.name.trim() !== ""
48
+ ? compiled.resolved.name.trim()
49
+ : null;
50
+ const preview = buildProposalPreview(compiled);
51
+ await stageProposal(
52
+ app.data,
53
+ buildProposalRow({
54
+ digest,
55
+ logicalKey: proposalLogicalKey(name, digest),
56
+ title: name,
57
+ graphJson: JSON.stringify(parsed.graph),
58
+ preview,
59
+ nodeCount: compiled.resolved.nodes.length,
60
+ humanNodeCount: compiled.humanNodes.length,
61
+ sideEffectCount: compiled.sideEffects.length,
62
+ sideEffecting: compiled.sideEffects.length > 0,
63
+ }),
64
+ );
65
+
66
+ app.log.info("preview-delivery-graph staged", {
39
67
  nodes: compiled.resolved.nodes.length,
40
68
  humanNodes: compiled.humanNodes.length,
41
69
  sideEffects: compiled.sideEffects.length,
@@ -45,19 +73,18 @@ export default defineOperation("previewDeliveryGraph", async ({ body }, app) =>
45
73
  status: 200,
46
74
  body: {
47
75
  ok: true,
76
+ staged: true,
48
77
  digest,
49
- ...(typeof compiled.resolved.name === "string" && compiled.resolved.name !== ""
50
- ? { title: compiled.resolved.name }
51
- : {}),
78
+ reviewUrl: proposalReviewUrl(digest),
79
+ ...(name !== null ? { title: name } : {}),
52
80
  sideEffecting: compiled.sideEffects.length > 0,
53
81
  nodeCount: compiled.resolved.nodes.length,
54
82
  humanNodeCount: compiled.humanNodes.length,
55
83
  sideEffectCount: compiled.sideEffects.length,
56
84
  diagram: compiled.diagram,
57
85
  // The FULL extracted preview detail (not just the counts): the human stop-points and the
58
- // side-effecting actions the operator is being asked to approve (Decision 7). The Delivery
59
- // Graphs page renders these lists so the operator sees WHERE it parks on a person and WHAT it
60
- // will do before dispatching — the "preview before dispatch" principle made visible (#441).
86
+ // side-effecting actions. The Delivery Graphs page renders these so the operator sees WHERE it
87
+ // parks on a person and WHAT it will do the "preview before dispatch" principle made visible.
61
88
  humanNodes: compiled.humanNodes,
62
89
  sideEffects: compiled.sideEffects,
63
90
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.123.2",
3
+ "version": "0.124.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",
@@ -319,7 +319,8 @@ function relaySocketFactory(url) {
319
319
  * @param {Element} host — where the cockpit renders (standalone: document.body; embedded: the App-View host).
320
320
  * @param {object} [opts]
321
321
  * @param {string} [opts.reportUrl] — the supply JSON endpoint the app serves (default
322
- * `"app/api/agentic/supply"`, base-relative).
322
+ * `new URL("../app/api/agentic/supply", import.meta.url).href`, module-anchored so it
323
+ * resolves to the app root `<appMount>/app/api/agentic/supply`, not the `/cockpit/` shell base).
323
324
  * @param {string} [opts.relayUrl] — the agentic channel WebSocket URL (with auth token + capability query).
324
325
  * @param {string} [opts.hookSecret] — shared secret sent as `x-hook-secret` on the report fetch when the
325
326
  * app's supply endpoint is guarded by NANO_PR_WEBHOOK_SECRET (omit for open deployments).
@@ -331,7 +332,9 @@ function relaySocketFactory(url) {
331
332
  * @param {number} [opts.pastFetchTimeoutMs] — upper bound (ms) on a single past-sessions transcripts
332
333
  * fetch; the fetch is aborted past this so a hung endpoint can't wedge the past panel (default 15000).
333
334
  * @param {string} [opts.transcriptsUrl] — the captured-session list endpoint backing the always-on
334
- * "past sessions" history + replay (default `"app/api/agentic/transcripts"`, base-relative).
335
+ * "past sessions" history + replay (default
336
+ * `new URL("../app/api/agentic/transcripts", import.meta.url).href`, module-anchored so it
337
+ * resolves to the app root `<appMount>/app/api/agentic/transcripts`, not the `/cockpit/` shell base).
335
338
  * @returns a handle with `.dispose()`.
336
339
  */
337
340
  export function mountCockpit(host, opts = {}) {
@@ -341,16 +344,20 @@ export function mountCockpit(host, opts = {}) {
341
344
  );
342
345
  }
343
346
  const doc = document;
344
- // Base-relative defaults (no leading slash) so the browser resolves them against the document's
345
- // baseURI. Standalone (served at the app root) this is the origin root; through the Studio console
346
- // App-View the document base is the app-view path (`/console/app-view/<AppName>/`) while the iframe
347
- // ORIGIN is the console (:8080) — an absolute (leading-slash) path would resolve against the console
348
- // origin root (`:8080/app/api/agentic/supply` → 404) instead of the app-view base that proxies the
349
- // API, leaving the cockpit empty (#279). A base-relative default lands on the right endpoint both
350
- // ways, so the cockpit populates identically standalone and embedded even though the console never
351
- // injects window.__NANO_APP_VIEW__.
352
- const reportUrl = opts.reportUrl ?? "app/api/agentic/supply";
353
- const transcriptsUrl = opts.transcriptsUrl ?? "app/api/agentic/transcripts";
347
+ // Default endpoints are anchored to THIS MODULE's URL (import.meta.url), NOT the document base.
348
+ // The API is served at the app root (`<appMount>/app/api/agentic/…`), but the cockpit shell
349
+ // (embed.html / standalone.html) and therefore this mount.js is served one directory deep at
350
+ // `<appMount>/cockpit/`. A document-base-relative default (`"app/api/agentic/supply"`) resolves
351
+ // against that `…/cockpit/` base to `…/cockpit/app/api/agentic/supply` → 404 on EVERY surface
352
+ // (standalone, local urban-SPA App-View embed, and the Studio console App-View, which serves the
353
+ // shell at `<app-view-base>/cockpit/…`), leaving the cockpit empty (#467). An absolute leading-slash
354
+ // path is worse still — through Studio it resolves against the console ORIGIN (:8080), not the
355
+ // app-view base that proxies the API (#279). mount.js is ALWAYS at `<appMount>/cockpit/mount.js`
356
+ // while the API is ALWAYS at `<appMount>/app/api/…`, so `../app/api/…` off import.meta.url lands on
357
+ // the right endpoint on all three surfaces regardless of the document base — the console never
358
+ // injects window.__NANO_APP_VIEW__, so this default is what actually runs there too.
359
+ const reportUrl = opts.reportUrl ?? new URL("../app/api/agentic/supply", import.meta.url).href;
360
+ const transcriptsUrl = opts.transcriptsUrl ?? new URL("../app/api/agentic/transcripts", import.meta.url).href;
354
361
  const hookSecret = opts.hookSecret;
355
362
  const relayUrl = opts.relayUrl ?? defaultRelayUrl(opts.relayToken, opts.relayCapability);
356
363
  const refreshMs = opts.refreshMs ?? DEFAULT_REFRESH_MS;