@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,35 +1,81 @@
|
|
|
1
|
-
// POST /app/api/actions/compile-delivery-graph → operationId `compileDeliveryGraph` (ADR 0005
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// `compile` and `start` are SEPARATE doors, and there is deliberately no `dryRun` flag on the start
|
|
8
|
-
// door). Because it has zero side effects, an agent may call it repeatedly: JSON → compile → fix.
|
|
1
|
+
// POST /app/api/actions/compile-delivery-graph → operationId `compileDeliveryGraph` (ADR 0005
|
|
2
|
+
// Decision 7, issue #460). The agent-facing delivery-graph door — and the END of the agent's surface.
|
|
3
|
+
// It VALIDATES (the pure `validateDeliveryGraph` semantic check, run inside the compiler) and COMPILES
|
|
4
|
+
// the graph, and when valid PERSISTS the compiled graph as a `staged` proposal (content-addressed by
|
|
5
|
+
// its `digest`). It returns a PREVIEW plus a navigational `reviewUrl` and NOTHING that can trigger a
|
|
6
|
+
// run: no run key, no token, no process-instance key.
|
|
9
7
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
8
|
+
// This is capability-by-absence (issue #460): the old two-step submit → token → re-submit flow made
|
|
9
|
+
// the "approval" a REPLAYABLE content digest handed back to the same caller, so any holder of the API
|
|
10
|
+
// credential self-approved. By removing the dispatch affordance from the agent surface entirely — there
|
|
11
|
+
// is no `start` endpoint — there is nothing to replay. Dispatch is an OPERATOR action performed in the
|
|
12
|
+
// cockpit; the response tells the agent its role ends here, turning the boundary into a self-documenting
|
|
13
|
+
// protocol. A malformed graph is a 400 carrying path-qualified errors; nothing is staged.
|
|
14
14
|
|
|
15
15
|
import { compileDeliveryGraph } from "../app/deliveryGraphCompiler.ts";
|
|
16
|
+
import {
|
|
17
|
+
buildProposalPreview,
|
|
18
|
+
buildProposalRow,
|
|
19
|
+
proposalLogicalKey,
|
|
20
|
+
proposalReviewUrl,
|
|
21
|
+
stageProposal,
|
|
22
|
+
} from "../app/deliveryGraphProposals.ts";
|
|
23
|
+
import { deliveryGraphDigest } from "../app/deliveryRunner.ts";
|
|
16
24
|
import { defineOperation } from "../nano-generated/operations.ts";
|
|
17
25
|
|
|
26
|
+
const STAGED_MESSAGE =
|
|
27
|
+
"The graph compiled and is staged for operator review. Ask the operator to preview and approve — or request modifications — in the cockpit. Dispatch is an operator action; there is no start endpoint.";
|
|
28
|
+
|
|
18
29
|
export default defineOperation("compileDeliveryGraph", async ({ body }, app) => {
|
|
19
30
|
// The runtime validates the body's SHAPE against `DeliveryGraph` before we run; the compiler adds
|
|
20
|
-
// the SEMANTIC checks (acyclicity, edge integrity, fact resolution)
|
|
21
|
-
//
|
|
22
|
-
// `
|
|
31
|
+
// the SEMANTIC checks (acyclicity, edge integrity, fact resolution). A directly-invoked delegate
|
|
32
|
+
// could still pass `undefined` — the compiler reads its input as `unknown` and maps that to a clean
|
|
33
|
+
// `ok:false`, never a 500.
|
|
23
34
|
const result = await compileDeliveryGraph(body);
|
|
24
35
|
if (!result.ok) {
|
|
25
36
|
app.log.warn("compile-delivery-graph rejected", { errors: result.errors.length });
|
|
26
37
|
return { status: 400, body: result };
|
|
27
38
|
}
|
|
28
|
-
|
|
39
|
+
|
|
40
|
+
// Persist the compiled graph as a `staged` proposal — the agent's surface ends here. Superseded by
|
|
41
|
+
// logical key + TTL inside `stageProposal`.
|
|
42
|
+
const digest = deliveryGraphDigest(result.bpmn);
|
|
43
|
+
const name =
|
|
44
|
+
typeof result.resolved.name === "string" && result.resolved.name.trim() !== ""
|
|
45
|
+
? result.resolved.name.trim()
|
|
46
|
+
: null;
|
|
47
|
+
const preview = buildProposalPreview(result);
|
|
48
|
+
await stageProposal(
|
|
49
|
+
app.data,
|
|
50
|
+
buildProposalRow({
|
|
51
|
+
digest,
|
|
52
|
+
logicalKey: proposalLogicalKey(name, digest),
|
|
53
|
+
title: name,
|
|
54
|
+
graphJson: JSON.stringify(body),
|
|
55
|
+
preview,
|
|
56
|
+
nodeCount: result.resolved.nodes.length,
|
|
57
|
+
humanNodeCount: result.humanNodes.length,
|
|
58
|
+
sideEffectCount: result.sideEffects.length,
|
|
59
|
+
sideEffecting: result.sideEffects.length > 0,
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
app.log.info("compile-delivery-graph staged", {
|
|
64
|
+
digest,
|
|
29
65
|
nodes: result.resolved.nodes.length,
|
|
30
|
-
edges: result.resolved.edges.length,
|
|
31
66
|
humanNodes: result.humanNodes.length,
|
|
32
67
|
sideEffects: result.sideEffects.length,
|
|
33
68
|
});
|
|
34
|
-
|
|
69
|
+
|
|
70
|
+
// The response carries a preview + a navigational pointer and NO dispatch handle (issue #460).
|
|
71
|
+
return {
|
|
72
|
+
status: 200,
|
|
73
|
+
body: {
|
|
74
|
+
status: "ready",
|
|
75
|
+
message: STAGED_MESSAGE,
|
|
76
|
+
digest,
|
|
77
|
+
preview,
|
|
78
|
+
reviewUrl: proposalReviewUrl(digest),
|
|
79
|
+
},
|
|
80
|
+
};
|
|
35
81
|
});
|
|
@@ -1,166 +1,185 @@
|
|
|
1
1
|
// Integration coverage for the POST /app/api/actions/delivery-graph/dispatch operation
|
|
2
|
-
// `dispatchDeliveryGraph` (
|
|
3
|
-
//
|
|
4
|
-
// `
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
import
|
|
11
|
-
import {
|
|
12
|
-
import
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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({
|
|
2
|
+
// `dispatchDeliveryGraph` (ADR 0005 Decision 7, issue #460) — the OPERATOR-ONLY dispatch door. The
|
|
3
|
+
// cockpit's staged-proposals grid posts the `digest` of the proposal the operator picked; this door
|
|
4
|
+
// loads that live `staged` proposal, launches the retained S4 runner for its graph, and marks the
|
|
5
|
+
// proposal `dispatched`. There is no replayable token — the operator clicking Dispatch IS the
|
|
6
|
+
// approval, and the door is reachable only from the cockpit (the agent compile door returns no
|
|
7
|
+
// dispatch handle). These tests drive the REAL door through `bootTestApp`'s api driver against the
|
|
8
|
+
// WASM engine: compile-to-stage, then dispatch by digest, asserting the digest is resolved, an
|
|
9
|
+
// unknown/consumed digest is refused, and the run launches engine-natively.
|
|
10
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join, resolve } from "node:path";
|
|
13
|
+
import { after, describe, test } from "node:test";
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
16
|
+
import { deliveryGraphProposals } from "../app/deliveryGraphProposals.ts";
|
|
17
|
+
import { deliveryGraphRuns } from "../app/deliveryGraphRun.ts";
|
|
18
|
+
|
|
19
|
+
const APP_ROOT = resolve(import.meta.dirname, "..");
|
|
20
|
+
const GITHUB_ENV: Record<string, string> = { NANO_PR_GITHUB_TRANSPORT: "token", GITHUB_TOKEN: "" };
|
|
21
|
+
|
|
22
|
+
const HUMAN_ONLY = {
|
|
23
|
+
name: "manual gate",
|
|
24
|
+
nodes: [{ id: "ack", kind: "human", human: { prompt: "click done when the release is out" } }],
|
|
25
|
+
};
|
|
26
|
+
const SIDE_EFFECTING = {
|
|
83
27
|
name: "release runbook",
|
|
84
28
|
nodes: [
|
|
85
|
-
{ id: "open-b", kind: "agent", agent: { jobType: "senior:
|
|
29
|
+
{ id: "open-b", kind: "agent", agent: { jobType: "senior:demo", prompt: "un-draft + merge #B" } },
|
|
86
30
|
{ id: "publish", kind: "human", human: { prompt: "run the manual OTP publish" } },
|
|
87
31
|
],
|
|
88
32
|
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
|
-
});
|
|
33
|
+
};
|
|
94
34
|
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
35
|
+
describe("dispatchDeliveryGraph — operator dispatch by staged-proposal digest", () => {
|
|
36
|
+
const dirs: string[] = [];
|
|
37
|
+
const apps: TestApp[] = [];
|
|
38
|
+
after(async () => {
|
|
39
|
+
for (const app of apps) await app.stop?.();
|
|
40
|
+
for (const d of dirs) rmSync(d, { recursive: true, force: true });
|
|
41
|
+
});
|
|
42
|
+
const boot = async (): Promise<TestApp> => {
|
|
43
|
+
const d = mkdtempSync(join(tmpdir(), "nwf-dispatch-"));
|
|
44
|
+
dirs.push(d);
|
|
45
|
+
const app = await bootTestApp(APP_ROOT, { env: { ...GITHUB_ENV, NANO_APP_DB_URL: `file:${join(d, "app.db")}` } });
|
|
46
|
+
apps.push(app);
|
|
47
|
+
return app;
|
|
48
|
+
};
|
|
103
49
|
|
|
104
|
-
test("
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
50
|
+
test("a missing/blank digest → 400 with a human error, nothing launched", async () => {
|
|
51
|
+
const app = await boot();
|
|
52
|
+
assert.ok(app.api);
|
|
53
|
+
const res = await app.api.call<{ ok: boolean; error?: string }>("dispatchDeliveryGraph", { body: { digest: " " } });
|
|
54
|
+
assert.equal(res.status, 400);
|
|
55
|
+
assert.equal(res.body.ok, false);
|
|
56
|
+
assert.ok(typeof res.body.error === "string" && res.body.error.length > 0);
|
|
57
|
+
assert.equal((await deliveryGraphRuns(app.db).all()).length, 0);
|
|
58
|
+
});
|
|
110
59
|
|
|
111
|
-
test("
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
});
|
|
60
|
+
test("an unknown / never-staged digest → 400, nothing launched", async () => {
|
|
61
|
+
const app = await boot();
|
|
62
|
+
assert.ok(app.api);
|
|
63
|
+
const res = await app.api.call<{ ok: boolean; error?: string }>("dispatchDeliveryGraph", { body: { digest: "deadbeef0000" } });
|
|
64
|
+
assert.equal(res.status, 400);
|
|
65
|
+
assert.equal(res.body.ok, false);
|
|
66
|
+
assert.ok(/no staged proposal/.test(res.body.error ?? ""));
|
|
67
|
+
assert.equal((await deliveryGraphRuns(app.db).all()).length, 0);
|
|
68
|
+
});
|
|
120
69
|
|
|
121
|
-
test("
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
});
|
|
70
|
+
test("a human-only graph: stage via compile, then dispatch by digest → 202 running; proposal marked dispatched", async () => {
|
|
71
|
+
const app = await boot();
|
|
72
|
+
assert.ok(app.api);
|
|
73
|
+
const api = app.api;
|
|
132
74
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
assertEquals(res.body.sideEffecting, true);
|
|
140
|
-
assertEquals(started.length, 1);
|
|
141
|
-
assertEquals(runs()[0].status, "running");
|
|
142
|
-
});
|
|
75
|
+
// Stage through the agent compile door — it returns only a preview + digest (no dispatch handle).
|
|
76
|
+
const staged = await api.call<{ status: string; digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
|
|
77
|
+
assert.equal(staged.status, 200);
|
|
78
|
+
assert.equal(staged.body.status, "ready");
|
|
79
|
+
const digest = staged.body.digest;
|
|
80
|
+
assert.equal((await deliveryGraphProposals(app.db).get(digest))?.status, "staged");
|
|
143
81
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
82
|
+
// The operator dispatches that digest.
|
|
83
|
+
const res = await api.call<{ ok: boolean; status: string; runKey: string; alreadyRunning?: boolean }>(
|
|
84
|
+
"dispatchDeliveryGraph",
|
|
85
|
+
{ body: { digest } },
|
|
86
|
+
);
|
|
87
|
+
assert.equal(res.status, 202);
|
|
88
|
+
assert.equal(res.body.ok, true);
|
|
89
|
+
assert.equal(res.body.status, "running");
|
|
90
|
+
await app.settle();
|
|
91
|
+
const runs = await deliveryGraphRuns(app.db).all();
|
|
92
|
+
assert.equal(runs.length, 1);
|
|
93
|
+
assert.equal(runs[0].status, "running");
|
|
94
|
+
// The proposal drops out of the staged list — it is now dispatched.
|
|
95
|
+
assert.equal((await deliveryGraphProposals(app.db).get(digest))?.status, "dispatched");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("a side-effecting graph dispatches the agent side effect once the operator picks its digest", async () => {
|
|
99
|
+
const app = await boot();
|
|
100
|
+
assert.ok(app.api);
|
|
101
|
+
const api = app.api;
|
|
102
|
+
let agentFired = 0;
|
|
103
|
+
await app.engine.registerWorker("senior:demo", async () => {
|
|
104
|
+
agentFired++;
|
|
105
|
+
return {};
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: SIDE_EFFECTING });
|
|
109
|
+
const res = await api.call<{ ok: boolean; status: string; sideEffecting: boolean }>("dispatchDeliveryGraph", {
|
|
110
|
+
body: { digest: staged.body.digest },
|
|
111
|
+
});
|
|
112
|
+
assert.equal(res.status, 202);
|
|
113
|
+
assert.equal(res.body.ok, true);
|
|
114
|
+
assert.equal(res.body.sideEffecting, true);
|
|
115
|
+
await app.settle();
|
|
116
|
+
assert.equal(agentFired, 1, "the side effect fired exactly once");
|
|
117
|
+
assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "dispatched");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("re-dispatching an ALREADY-dispatched digest → 400 (the proposal is consumed; the run shows in the in-flight grid)", async () => {
|
|
121
|
+
const app = await boot();
|
|
122
|
+
assert.ok(app.api);
|
|
123
|
+
const api = app.api;
|
|
124
|
+
const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
|
|
125
|
+
await api.call("dispatchDeliveryGraph", { body: { digest: staged.body.digest } });
|
|
126
|
+
await app.settle();
|
|
127
|
+
const again = await api.call<{ ok: boolean }>("dispatchDeliveryGraph", { body: { digest: staged.body.digest } });
|
|
128
|
+
assert.equal(again.status, 400);
|
|
129
|
+
assert.equal(again.body.ok, false);
|
|
130
|
+
// Still exactly one run — the consumed proposal cannot re-launch.
|
|
131
|
+
assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("an idempotencyKey already bound to a DIFFERENT running graph → 409; the proposal is NOT consumed and nothing new launches", async () => {
|
|
135
|
+
const app = await boot();
|
|
136
|
+
assert.ok(app.api);
|
|
137
|
+
const api = app.api;
|
|
138
|
+
|
|
139
|
+
// Stage two distinct graphs (different digests, different logical keys → neither supersedes).
|
|
140
|
+
const a = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
|
|
141
|
+
const b = await api.call<{ digest: string }>("compileDeliveryGraph", { body: SIDE_EFFECTING });
|
|
142
|
+
assert.notEqual(a.body.digest, b.body.digest);
|
|
143
|
+
|
|
144
|
+
// Dispatch graph A under a shared idempotencyKey — it launches and stays running (parks on a human).
|
|
145
|
+
const first = await api.call<{ ok: boolean; status: string }>("dispatchDeliveryGraph", {
|
|
146
|
+
body: { digest: a.body.digest, idempotencyKey: "shared-key" },
|
|
147
|
+
});
|
|
148
|
+
assert.equal(first.status, 202);
|
|
149
|
+
await app.settle();
|
|
150
|
+
|
|
151
|
+
// Dispatch graph B under the SAME idempotencyKey — it short-circuits onto A's run. B's graph was
|
|
152
|
+
// never launched, so B must NOT be consumed: refuse with 409 and leave B staged.
|
|
153
|
+
const second = await api.call<{ ok: boolean; error?: string }>("dispatchDeliveryGraph", {
|
|
154
|
+
body: { digest: b.body.digest, idempotencyKey: "shared-key" },
|
|
155
|
+
});
|
|
156
|
+
assert.equal(second.status, 409);
|
|
157
|
+
assert.equal(second.body.ok, false);
|
|
158
|
+
await app.settle();
|
|
159
|
+
// Proposal B is still staged (dispatchable) — it was never launched.
|
|
160
|
+
assert.equal((await deliveryGraphProposals(app.db).get(b.body.digest))?.status, "staged");
|
|
161
|
+
// Only A's single run exists — B did not launch anything.
|
|
162
|
+
assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("a proposal whose stored graph is corrupt JSON → 400 AND the proposal is retired (expired), never lingering staged", async () => {
|
|
166
|
+
const app = await boot();
|
|
167
|
+
assert.ok(app.api);
|
|
168
|
+
const api = app.api;
|
|
169
|
+
|
|
170
|
+
// Stage a valid graph, then corrupt its stored `graph` payload directly (simulating on-disk
|
|
171
|
+
// corruption/tampering that passed stage-time validation).
|
|
172
|
+
const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
|
|
173
|
+
const digest = staged.body.digest;
|
|
174
|
+
assert.equal((await deliveryGraphProposals(app.db).get(digest))?.status, "staged");
|
|
175
|
+
await deliveryGraphProposals(app.db).update(digest, { graph: "{not-json" });
|
|
152
176
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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);
|
|
177
|
+
const res = await api.call<{ ok: boolean; error?: string }>("dispatchDeliveryGraph", { body: { digest } });
|
|
178
|
+
assert.equal(res.status, 400);
|
|
179
|
+
assert.equal(res.body.ok, false);
|
|
180
|
+
assert.ok(/corrupt/.test(res.body.error ?? ""));
|
|
181
|
+
// Fail closed: the corrupt proposal is retired, not left as an undismissable `staged` row.
|
|
182
|
+
assert.equal((await deliveryGraphProposals(app.db).get(digest))?.status, "expired");
|
|
183
|
+
assert.equal((await deliveryGraphRuns(app.db).all()).length, 0);
|
|
184
|
+
});
|
|
166
185
|
});
|