@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.
- package/CHANGELOG.md +13 -0
- package/README.md +9 -5
- package/app/deliveryGraphDispatch.test.ts +143 -0
- package/app/deliveryGraphDispatch.ts +168 -0
- package/app/deliveryGraphProposals.test.ts +267 -0
- package/app/deliveryGraphProposals.ts +269 -0
- package/app/deliveryGraphRun.test.ts +6 -52
- package/app/deliveryGraphRun.ts +21 -76
- package/app/deliveryGraphText.ts +3 -3
- package/app/deliveryRunner.ts +4 -3
- package/app/service.ts +15 -0
- package/db/migrations/075_delivery_graph_proposals.sql +48 -0
- package/docs/adr/0005-agent-authored-delivery-graphs.md +18 -0
- package/docs/adr/0006-delivery-units-one-representation.md +221 -0
- package/docs/agent-guide.md +50 -58
- package/e2e/delivery-graph-dispatch.e2e.ts +155 -0
- package/openapi.yaml +118 -161
- package/operations/compileDeliveryGraph.test.ts +100 -37
- package/operations/compileDeliveryGraph.ts +64 -18
- package/operations/dispatchDeliveryGraph.test.ts +171 -152
- package/operations/dispatchDeliveryGraph.ts +79 -99
- package/operations/getAgentInstructions.test.ts +10 -6
- package/operations/previewDeliveryGraph.test.ts +90 -51
- package/operations/previewDeliveryGraph.ts +45 -18
- package/package.json +1 -1
- package/pages/cockpit/mount.js +19 -12
- package/pages/delivery-graphs/mount.js +37 -137
- package/pages/delivery-graphs.page.json +50 -3
- package/scripts/check-migrations.test.ts +9 -0
- package/scripts/check-migrations.ts +11 -1
- package/test/cockpit-embed-endpoints.test.ts +59 -36
- package/test/delivery-graphs-embed.test.ts +36 -34
- package/e2e/delivery-graph-start.e2e.ts +0 -145
- package/operations/startDeliveryGraph.integration.test.ts +0 -316
- package/operations/startDeliveryGraph.ts +0 -222
|
@@ -1,113 +1,93 @@
|
|
|
1
|
-
// POST /app/api/actions/delivery-graph/dispatch → operationId `dispatchDeliveryGraph` (
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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 {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
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 (
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
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
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
|
56
|
-
|
|
57
|
-
assert(md.includes("
|
|
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
|
-
//
|
|
69
|
-
|
|
70
|
-
assert(md.includes("
|
|
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
|
-
//
|
|
3
|
-
// operator's pasted JSON STRING
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
|
18
|
+
const APP_ROOT = resolve(import.meta.dirname, "..");
|
|
13
19
|
|
|
14
|
-
async function
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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:
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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` (
|
|
2
|
-
//
|
|
3
|
-
// "Preview" action posts the operator's pasted delivery-graph as a raw JSON STRING;
|
|
4
|
-
// it (`parseDeliveryGraphText`) and runs the SAME
|
|
5
|
-
// door uses,
|
|
6
|
-
//
|
|
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
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// a
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
|
59
|
-
//
|
|
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.
|
|
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",
|
package/pages/cockpit/mount.js
CHANGED
|
@@ -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"`,
|
|
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
|
|
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
|
-
//
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
|
|
353
|
-
|
|
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;
|