@nanobpm/nano-workforce 0.117.0 → 0.118.1
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/.github/workflows/invariants.yml +8 -5
- package/.github/workflows/release.yml +55 -6
- package/CHANGELOG.md +15 -0
- package/README.md +21 -0
- package/app/agentic/families/presence.family.test.ts +12 -0
- package/app/agentic/families/presence.family.ts +15 -0
- package/app/agentic/families/relay.family.test.ts +227 -0
- package/app/agentic/families/relay.family.ts +137 -20
- package/app/deliveryGraphText.ts +41 -0
- package/docs/agent-guide.md +173 -0
- package/openapi.yaml +166 -0
- package/operations/dispatchDeliveryGraph.test.ts +166 -0
- package/operations/dispatchDeliveryGraph.ts +113 -0
- package/operations/getAgentInstructions.test.ts +22 -0
- package/operations/previewDeliveryGraph.test.ts +74 -0
- package/operations/previewDeliveryGraph.ts +59 -0
- package/package.json +1 -1
- package/pages/_nav.json +1 -0
- package/pages/board.page.json +4 -0
- package/pages/cockpit.page.json +4 -0
- package/pages/delivery-graph-detail.page.json +114 -0
- package/pages/delivery-graphs.page.json +153 -0
- package/pages/epic-detail.page.json +4 -0
- package/pages/epic.page.json +4 -0
- package/pages/feature.page.json +4 -0
- package/pages/home.page.json +4 -0
- package/pages/lineage.page.json +4 -0
- package/pages/overview.page.json +6 -2
- package/pages/tasks.page.json +4 -0
- package/pages/velocity.page.json +4 -0
- package/scripts/pages-contract.test.ts +67 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Integration coverage for the POST /app/api/actions/delivery-graph/dispatch operation
|
|
2
|
+
// `dispatchDeliveryGraph` (issue #386, ADR 0005 slice S5) — the human-facing UI JSON-paste DISPATCH
|
|
3
|
+
// ingress. It parses the operator's pasted JSON STRING and DELEGATES to the SAME gated, idempotent
|
|
4
|
+
// `startDeliveryGraph` handler (no parallel dispatch path), deriving the approval token from the graph
|
|
5
|
+
// when the operator ticks `approve`. These tests drive the real delegate against an in-memory
|
|
6
|
+
// app/data/engine (mirroring startDeliveryGraph.integration.test.ts) so the composed behaviour — parse
|
|
7
|
+
// → approval-gate → launch — is proven, and assert the parse guards map to a 400 with a human error.
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import { assert, assertEquals } from "#test-assert";
|
|
10
|
+
import type { AppApi } from "@nanobpm/urban";
|
|
11
|
+
import { noopLog } from "../test/log.ts";
|
|
12
|
+
import handler from "./dispatchDeliveryGraph.ts";
|
|
13
|
+
|
|
14
|
+
// A compact in-memory app: a generic table over an array (get/find/insert/update/delete) faithful to
|
|
15
|
+
// the run aggregate's PRIMARY KEY fence, plus the guarded raw UPDATE the door issues and a fake engine.
|
|
16
|
+
function makeApp() {
|
|
17
|
+
const tables = new Map<string, Record<string, unknown>[]>();
|
|
18
|
+
const started: { processDefinitionId: string }[] = [];
|
|
19
|
+
const table = (name: string, key: string) => {
|
|
20
|
+
const rows = tables.get(name) ?? (() => {
|
|
21
|
+
const fresh: Record<string, unknown>[] = [];
|
|
22
|
+
tables.set(name, fresh);
|
|
23
|
+
return fresh;
|
|
24
|
+
})();
|
|
25
|
+
return {
|
|
26
|
+
get: (k: unknown) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
|
|
27
|
+
find: (q: Record<string, unknown>) =>
|
|
28
|
+
Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
|
|
29
|
+
insert: (r: Record<string, unknown>) => {
|
|
30
|
+
if (rows.some((existing) => existing[key] === r[key])) {
|
|
31
|
+
return Promise.reject(new Error(`UNIQUE constraint failed: ${name}.${key}`));
|
|
32
|
+
}
|
|
33
|
+
rows.push(r);
|
|
34
|
+
return Promise.resolve(r);
|
|
35
|
+
},
|
|
36
|
+
update: (k: unknown, patch: Record<string, unknown>) => {
|
|
37
|
+
const row = rows.find((r) => r[key] === k);
|
|
38
|
+
if (row) Object.assign(row, patch);
|
|
39
|
+
return Promise.resolve(row);
|
|
40
|
+
},
|
|
41
|
+
delete: (k: unknown) => {
|
|
42
|
+
const i = rows.findIndex((r) => r[key] === k);
|
|
43
|
+
if (i >= 0) rows.splice(i, 1);
|
|
44
|
+
return Promise.resolve();
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
const app = {
|
|
49
|
+
data: {
|
|
50
|
+
table,
|
|
51
|
+
open: () => ({
|
|
52
|
+
exec: (sql: string, params: unknown[]) =>
|
|
53
|
+
Promise.resolve().then(() => {
|
|
54
|
+
const cols = [...sql.matchAll(/"(\w+)"\s*=\s*\?/g)].map((m) => m[1]);
|
|
55
|
+
const runKey = params[params.length - 1];
|
|
56
|
+
const rows = tables.get("delivery_graph_runs") ?? [];
|
|
57
|
+
const row = rows.find((r) => r["run_key"] === runKey);
|
|
58
|
+
if (row && row["status"] !== "running") {
|
|
59
|
+
for (let i = 0; i < cols.length - 1; i++) row[cols[i]] = params[i];
|
|
60
|
+
return { changed: 1 };
|
|
61
|
+
}
|
|
62
|
+
return { changed: 0 };
|
|
63
|
+
}),
|
|
64
|
+
}),
|
|
65
|
+
},
|
|
66
|
+
engine: {
|
|
67
|
+
deployResources: () => Promise.resolve([]),
|
|
68
|
+
createInstance: (req: { processDefinitionId: string }) => {
|
|
69
|
+
started.push(req);
|
|
70
|
+
return Promise.resolve({ processInstanceKey: "PI-1", processDefinitionId: req.processDefinitionId });
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
log: noopLog(),
|
|
74
|
+
} as unknown as AppApi;
|
|
75
|
+
return { app, started, runs: () => tables.get("delivery_graph_runs") ?? [] };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function call(app: AppApi, body: unknown) {
|
|
79
|
+
return (await handler({ req: {} as any, params: {}, query: {}, body } as any, app)) as any;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const SIDE_EFFECTING = JSON.stringify({
|
|
83
|
+
name: "release runbook",
|
|
84
|
+
nodes: [
|
|
85
|
+
{ id: "open-b", kind: "agent", agent: { jobType: "senior:feature", prompt: "un-draft + merge #B" } },
|
|
86
|
+
{ id: "publish", kind: "human", human: { prompt: "run the manual OTP publish" } },
|
|
87
|
+
],
|
|
88
|
+
edges: [{ from: "open-b", to: "publish" }],
|
|
89
|
+
});
|
|
90
|
+
const HUMAN_ONLY = JSON.stringify({
|
|
91
|
+
name: "manual gate",
|
|
92
|
+
nodes: [{ id: "ack", kind: "human", human: { prompt: "click done when the release is out" } }],
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("dispatch-delivery-graph: a non-JSON paste → 400 with a human error, nothing launched", async () => {
|
|
96
|
+
const { app, started } = makeApp();
|
|
97
|
+
const res = await call(app, { graphJson: "{ not json" });
|
|
98
|
+
assertEquals(res.status, 400);
|
|
99
|
+
assertEquals(res.body.ok, false);
|
|
100
|
+
assert(typeof res.body.error === "string" && res.body.error.includes("not valid JSON"));
|
|
101
|
+
assertEquals(started.length, 0);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("dispatch-delivery-graph: a blank paste → 400, never a 500", async () => {
|
|
105
|
+
const { app } = makeApp();
|
|
106
|
+
const res = await call(app, { graphJson: "" });
|
|
107
|
+
assertEquals(res.status, 400);
|
|
108
|
+
assertEquals(res.body.ok, false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("dispatch-delivery-graph: a non-side-effecting graph dispatches straight away (202 running)", async () => {
|
|
112
|
+
const { app, started, runs } = makeApp();
|
|
113
|
+
const res = await call(app, { graphJson: HUMAN_ONLY });
|
|
114
|
+
assertEquals(res.status, 202);
|
|
115
|
+
assertEquals(res.body.ok, true);
|
|
116
|
+
assertEquals(res.body.status, "running");
|
|
117
|
+
assertEquals(started.length, 1);
|
|
118
|
+
assertEquals(runs()[0].status, "running");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("dispatch-delivery-graph: a side-effecting graph WITHOUT approve is parked at approval (400), nothing launched", async () => {
|
|
122
|
+
const { app, started, runs } = makeApp();
|
|
123
|
+
const res = await call(app, { graphJson: SIDE_EFFECTING });
|
|
124
|
+
assertEquals(res.status, 400);
|
|
125
|
+
assertEquals(res.body.ok, false);
|
|
126
|
+
assertEquals(res.body.status, "awaiting-approval");
|
|
127
|
+
// The human banner is populated from the door's park message.
|
|
128
|
+
assert(typeof res.body.error === "string" && res.body.error.length > 0);
|
|
129
|
+
assertEquals(started.length, 0);
|
|
130
|
+
assertEquals(runs()[0].status, "awaiting-approval");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("dispatch-delivery-graph: a side-effecting graph WITH approve dispatches (202 running), token derived server-side", async () => {
|
|
134
|
+
const { app, started, runs } = makeApp();
|
|
135
|
+
const res = await call(app, { graphJson: SIDE_EFFECTING, approve: true });
|
|
136
|
+
assertEquals(res.status, 202);
|
|
137
|
+
assertEquals(res.body.ok, true);
|
|
138
|
+
assertEquals(res.body.status, "running");
|
|
139
|
+
assertEquals(res.body.sideEffecting, true);
|
|
140
|
+
assertEquals(started.length, 1);
|
|
141
|
+
assertEquals(runs()[0].status, "running");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("dispatch-delivery-graph: re-dispatch of a running graph short-circuits (alreadyRunning), no second launch", async () => {
|
|
145
|
+
const { app, started } = makeApp();
|
|
146
|
+
await call(app, { graphJson: HUMAN_ONLY });
|
|
147
|
+
const res = await call(app, { graphJson: HUMAN_ONLY });
|
|
148
|
+
assertEquals(res.status, 202);
|
|
149
|
+
assertEquals(res.body.alreadyRunning, true);
|
|
150
|
+
assertEquals(started.length, 1);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("dispatch-delivery-graph: a graph that fails validation → 400 carries the start door's structured `errors` array, not just a summary banner", async () => {
|
|
154
|
+
const { app, started } = makeApp();
|
|
155
|
+
const res = await call(app, { graphJson: JSON.stringify({ name: "empty", nodes: [] }) });
|
|
156
|
+
assertEquals(res.status, 400);
|
|
157
|
+
assertEquals(res.body.ok, false);
|
|
158
|
+
// The structured, path-qualified errors from startDeliveryGraph must survive the adapter's re-shape.
|
|
159
|
+
assert(Array.isArray(res.body.errors) && res.body.errors.length > 0);
|
|
160
|
+
for (const e of res.body.errors) {
|
|
161
|
+
assert(typeof e.path === "string" && typeof e.message === "string");
|
|
162
|
+
}
|
|
163
|
+
// And the human banner is still derived from those errors.
|
|
164
|
+
assert(typeof res.body.error === "string" && res.body.error.length > 0);
|
|
165
|
+
assertEquals(started.length, 0);
|
|
166
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
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).
|
|
8
|
+
//
|
|
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.
|
|
16
|
+
|
|
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";
|
|
21
|
+
import { defineOperation } from "../nano-generated/operations.ts";
|
|
22
|
+
import startDeliveryGraph from "./startDeliveryGraph.ts";
|
|
23
|
+
|
|
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 } };
|
|
30
|
+
}
|
|
31
|
+
const approve = body?.approve === true;
|
|
32
|
+
const idemRaw = typeof body?.idempotencyKey === "string" ? body.idempotencyKey.trim() : "";
|
|
33
|
+
const idempotencyKey = idemRaw !== "" ? idemRaw : undefined;
|
|
34
|
+
|
|
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 = 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);
|
|
53
|
+
}
|
|
54
|
+
|
|
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;
|
|
85
|
+
|
|
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;
|
|
103
|
+
}
|
|
104
|
+
|
|
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";
|
|
111
|
+
}
|
|
112
|
+
return { status, body: outBody };
|
|
113
|
+
});
|
|
@@ -50,6 +50,28 @@ test("the guide covers every capability the endpoint promises", async () => {
|
|
|
50
50
|
assert(md.includes("nanobpm/nano-workforce"), "covers raising issues/PRs against the repo");
|
|
51
51
|
});
|
|
52
52
|
|
|
53
|
+
test("the guide documents the delivery-graph surface (ADR 0005)", async () => {
|
|
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");
|
|
58
|
+
// The closed node vocabulary: assert the exact config snippet for each of the four kinds,
|
|
59
|
+
// so the test fails if §9's node-kind table is removed or reworded — not merely if the bare
|
|
60
|
+
// words "agent"/"wait"/"human"/"connector" appear anywhere else in the guide.
|
|
61
|
+
assert(md.includes("agent: { jobType, prompt? }"), "documents the agent node config");
|
|
62
|
+
assert(md.includes("wait: <ReadinessProbe>"), "documents the wait node config");
|
|
63
|
+
assert(md.includes("human?: { formKey?, prompt? }"), "documents the human node config");
|
|
64
|
+
assert(md.includes("connector: { target, dedupeKey?, payload? }"), "documents the connector node config");
|
|
65
|
+
// The fact-edge syntax: an edge is `{ from, to }` and `from` may be a qualified `<nodeId>.<fact>`.
|
|
66
|
+
assert(md.includes("each edge is `{ from, to }`"), "documents the edge shape");
|
|
67
|
+
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");
|
|
71
|
+
// The worked example: a human emit node handing a version to a downstream edge.
|
|
72
|
+
assert(md.includes("manual-publish.publishedVersion"), "includes the worked example's human-emit fact edge");
|
|
73
|
+
});
|
|
74
|
+
|
|
53
75
|
test("examples are keyed to the request's control-API base and leave no placeholders", async () => {
|
|
54
76
|
const forwarded = input({ host: "wf.example.com", "x-forwarded-proto": "https" });
|
|
55
77
|
const md = ((await handler(forwarded, app)) as any).body.instructions as string;
|
|
@@ -0,0 +1,74 @@
|
|
|
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.
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { assert, assertEquals } from "#test-assert";
|
|
8
|
+
import type { AppApi } from "@nanobpm/urban";
|
|
9
|
+
import { noopLog } from "../test/log.ts";
|
|
10
|
+
import handler from "./previewDeliveryGraph.ts";
|
|
11
|
+
|
|
12
|
+
const app = { log: noopLog() } as unknown as AppApi;
|
|
13
|
+
|
|
14
|
+
async function call(body: unknown) {
|
|
15
|
+
return (await handler({ req: {} as any, params: {}, query: {}, body } as any, app)) as any;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const GOOD = JSON.stringify({
|
|
19
|
+
name: "runbook",
|
|
20
|
+
nodes: [
|
|
21
|
+
{ id: "a", kind: "agent", agent: { jobType: "senior:feature" } },
|
|
22
|
+
{ id: "b", kind: "human", human: { prompt: "do X" } },
|
|
23
|
+
],
|
|
24
|
+
edges: [{ from: "a", to: "b" }],
|
|
25
|
+
});
|
|
26
|
+
|
|
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
|
+
});
|
|
39
|
+
|
|
40
|
+
test("preview-delivery-graph: is PURE — repeated previews return the identical digest", async () => {
|
|
41
|
+
const a = await call({ graphJson: GOOD });
|
|
42
|
+
const b = await call({ graphJson: GOOD });
|
|
43
|
+
assertEquals(a.body.digest, b.body.digest);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("preview-delivery-graph: text that is not valid JSON → 400 with a human error", async () => {
|
|
47
|
+
const res = await call({ graphJson: "{ not json" });
|
|
48
|
+
assertEquals(res.status, 400);
|
|
49
|
+
assertEquals(res.body.ok, false);
|
|
50
|
+
assert(typeof res.body.error === "string" && res.body.error.includes("not valid JSON"));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("preview-delivery-graph: a blank paste → 400, never a 500", async () => {
|
|
54
|
+
const res = await call({ graphJson: " " });
|
|
55
|
+
assertEquals(res.status, 400);
|
|
56
|
+
assertEquals(res.body.ok, false);
|
|
57
|
+
assert(typeof res.body.error === "string" && res.body.error.length > 0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("preview-delivery-graph: a valid-JSON but malformed graph → 400 with path-qualified errors", async () => {
|
|
61
|
+
const res = await call({
|
|
62
|
+
graphJson: JSON.stringify({ nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" } }], edges: [{ from: "a", to: "ghost" }] }),
|
|
63
|
+
});
|
|
64
|
+
assertEquals(res.status, 400);
|
|
65
|
+
assertEquals(res.body.ok, false);
|
|
66
|
+
assert(Array.isArray(res.body.errors) && res.body.errors.length > 0);
|
|
67
|
+
assert(res.body.errors.every((e: { path: string; message: string }) => typeof e.path === "string"));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("preview-delivery-graph: a pasted JSON array (not an object) → 400", async () => {
|
|
71
|
+
const res = await call({ graphJson: "[]" });
|
|
72
|
+
assertEquals(res.status, 400);
|
|
73
|
+
assertEquals(res.body.ok, false);
|
|
74
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
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`.
|
|
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.
|
|
13
|
+
|
|
14
|
+
import { compileDeliveryGraph } from "../app/deliveryGraphCompiler.ts";
|
|
15
|
+
import { parseDeliveryGraphText } from "../app/deliveryGraphText.ts";
|
|
16
|
+
import { deliveryGraphDigest } from "../app/deliveryRunner.ts";
|
|
17
|
+
import { defineOperation } from "../nano-generated/operations.ts";
|
|
18
|
+
|
|
19
|
+
export default defineOperation("previewDeliveryGraph", async ({ body }, app) => {
|
|
20
|
+
const parsed = parseDeliveryGraphText(body);
|
|
21
|
+
if (!parsed.ok) {
|
|
22
|
+
app.log.warn("preview-delivery-graph rejected: parse", { message: parsed.error });
|
|
23
|
+
return { status: 400, body: { ok: false, error: parsed.error } };
|
|
24
|
+
}
|
|
25
|
+
const compiled = compileDeliveryGraph(parsed.graph);
|
|
26
|
+
if (!compiled.ok) {
|
|
27
|
+
app.log.warn("preview-delivery-graph rejected: compile", { errors: compiled.errors.length });
|
|
28
|
+
return {
|
|
29
|
+
status: 400,
|
|
30
|
+
body: {
|
|
31
|
+
ok: false,
|
|
32
|
+
error: `graph failed validation: ${compiled.errors.length} error(s)`,
|
|
33
|
+
errors: compiled.errors,
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const digest = deliveryGraphDigest(compiled.bpmn);
|
|
38
|
+
app.log.info("preview-delivery-graph compiled", {
|
|
39
|
+
nodes: compiled.resolved.nodes.length,
|
|
40
|
+
humanNodes: compiled.humanNodes.length,
|
|
41
|
+
sideEffects: compiled.sideEffects.length,
|
|
42
|
+
digest,
|
|
43
|
+
});
|
|
44
|
+
return {
|
|
45
|
+
status: 200,
|
|
46
|
+
body: {
|
|
47
|
+
ok: true,
|
|
48
|
+
digest,
|
|
49
|
+
...(typeof compiled.resolved.name === "string" && compiled.resolved.name !== ""
|
|
50
|
+
? { title: compiled.resolved.name }
|
|
51
|
+
: {}),
|
|
52
|
+
sideEffecting: compiled.sideEffects.length > 0,
|
|
53
|
+
nodeCount: compiled.resolved.nodes.length,
|
|
54
|
+
humanNodeCount: compiled.humanNodes.length,
|
|
55
|
+
sideEffectCount: compiled.sideEffects.length,
|
|
56
|
+
diagram: compiled.diagram,
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.118.1",
|
|
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/_nav.json
CHANGED
package/pages/board.page.json
CHANGED
package/pages/cockpit.page.json
CHANGED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": "1.0",
|
|
3
|
+
"title": "Delivery graph",
|
|
4
|
+
"nodes": [
|
|
5
|
+
{
|
|
6
|
+
"type": "nav",
|
|
7
|
+
"id": "nav",
|
|
8
|
+
"props": {
|
|
9
|
+
"variant": "bar",
|
|
10
|
+
"title": "Nano Workforce",
|
|
11
|
+
"items": [
|
|
12
|
+
{
|
|
13
|
+
"label": "Overview",
|
|
14
|
+
"page": "overview"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"label": "Lineage",
|
|
18
|
+
"page": "lineage"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"label": "Convergence",
|
|
22
|
+
"page": "home"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"label": "Epics",
|
|
26
|
+
"page": "epic"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"label": "Feature",
|
|
30
|
+
"page": "feature"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"label": "Delivery Graphs",
|
|
34
|
+
"page": "delivery-graphs"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"label": "Tasks",
|
|
38
|
+
"page": "tasks",
|
|
39
|
+
"badge": {
|
|
40
|
+
"source": "app",
|
|
41
|
+
"table": "user_tasks",
|
|
42
|
+
"filter": [],
|
|
43
|
+
"tone": "danger",
|
|
44
|
+
"refreshMs": 5000,
|
|
45
|
+
"hideWhenZero": true
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"label": "Cockpit",
|
|
50
|
+
"page": "cockpit"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"label": "Board",
|
|
54
|
+
"page": "board"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"label": "Velocity",
|
|
58
|
+
"page": "velocity"
|
|
59
|
+
}
|
|
60
|
+
],
|
|
61
|
+
"sticky": true
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"type": "text",
|
|
66
|
+
"id": "title",
|
|
67
|
+
"props": { "text": "Delivery graph {{param}}", "variant": "heading" }
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"type": "text",
|
|
71
|
+
"id": "subtitle",
|
|
72
|
+
"props": {
|
|
73
|
+
"text": "This delivery graph's run aggregate \u2014 its lifecycle status, the derived phase (where it is parked, e.g. on a human node), its compiled shape (node / human-stop / side-effect counts), and the content digest that is its approval token. Follow the Status cell into the process explorer to watch the compiled process advance node-by-node and edge-by-edge (the engine owns per-node token state).",
|
|
74
|
+
"variant": "sub"
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"type": "dataGrid",
|
|
79
|
+
"id": "delivery-graph-run",
|
|
80
|
+
"props": {
|
|
81
|
+
"title": "Run",
|
|
82
|
+
"rowKey": "run_key",
|
|
83
|
+
"refreshMs": 5000,
|
|
84
|
+
"empty": "No delivery graph found for this key.",
|
|
85
|
+
"data": {
|
|
86
|
+
"kind": "datasource",
|
|
87
|
+
"source": "app",
|
|
88
|
+
"table": "delivery_graph_runs",
|
|
89
|
+
"orderBy": { "field": "updated_at", "dir": "desc" },
|
|
90
|
+
"filter": [{ "field": "run_key", "eqParam": true }]
|
|
91
|
+
},
|
|
92
|
+
"columns": [
|
|
93
|
+
{ "field": "title", "template": "{{title}}", "header": "Graph", "subtitleField": "run_key", "truncate": true, "width": "30%" },
|
|
94
|
+
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
95
|
+
{ "field": "phase", "header": "Phase", "truncate": true, "width": "30%" },
|
|
96
|
+
{ "field": "node_count", "header": "Nodes" },
|
|
97
|
+
{ "field": "human_node_count", "header": "Human" },
|
|
98
|
+
{ "field": "side_effect_count", "header": "Side effects" },
|
|
99
|
+
{ "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
|
|
100
|
+
],
|
|
101
|
+
"detail": {
|
|
102
|
+
"fields": [
|
|
103
|
+
{ "field": "process_definition_id", "label": "Process definition" },
|
|
104
|
+
{ "field": "process_key", "label": "Process instance key" },
|
|
105
|
+
{ "field": "digest", "label": "Content digest (approval token)" },
|
|
106
|
+
{ "field": "side_effecting", "label": "Side-effecting (1 = requires approval)" },
|
|
107
|
+
{ "field": "phase_node_id", "label": "Parked human node element" },
|
|
108
|
+
{ "field": "created_at", "label": "Created" }
|
|
109
|
+
]
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
]
|
|
114
|
+
}
|