@nanobpm/nano-workforce 0.138.2 → 0.139.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/app/contracts.ts +16 -0
  3. package/app/deliveryGraph.test.ts +84 -0
  4. package/app/deliveryGraph.ts +79 -0
  5. package/app/deliveryGraphCompiler.ts +4 -2
  6. package/app/deliveryGraphLibrary.test.ts +134 -0
  7. package/app/deliveryGraphLibrary.ts +153 -0
  8. package/app/deliveryGraphProposals.test.ts +136 -0
  9. package/app/deliveryGraphProposals.ts +54 -6
  10. package/app/deliveryGraphShape.test.ts +66 -0
  11. package/app/deliveryGraphShape.ts +67 -0
  12. package/app/deliveryGraphTextIngress.test.ts +137 -0
  13. package/app/deliveryGraphTextIngress.ts +73 -3
  14. package/app/planReadModel.test.ts +23 -0
  15. package/db/migrations/084_plan_wave_tasks_effective_status.sql +51 -0
  16. package/db/migrations/085_delivery_graph_library.sql +32 -0
  17. package/openapi.yaml +366 -0
  18. package/operations/deleteLibraryEntry.test.ts +88 -0
  19. package/operations/deleteLibraryEntry.ts +23 -0
  20. package/operations/dismissProposal.test.ts +105 -0
  21. package/operations/dismissProposal.ts +53 -0
  22. package/operations/getLibraryEntry.test.ts +75 -0
  23. package/operations/getLibraryEntry.ts +25 -0
  24. package/operations/importToLibrary.test.ts +195 -0
  25. package/operations/importToLibrary.ts +62 -0
  26. package/operations/listLibrary.test.ts +79 -0
  27. package/operations/listLibrary.ts +24 -0
  28. package/operations/saveToLibrary.test.ts +225 -0
  29. package/operations/saveToLibrary.ts +89 -0
  30. package/package.json +1 -1
  31. package/pages/delivery-graphs/delivery-graphs.css +33 -0
  32. package/pages/delivery-graphs/embed.html +1 -0
  33. package/pages/delivery-graphs/library-embed.html +31 -0
  34. package/pages/delivery-graphs/library-standalone.html +38 -0
  35. package/pages/delivery-graphs/library.mount.js +364 -0
  36. package/pages/delivery-graphs/mount.js +133 -4
  37. package/pages/delivery-graphs/staged.mount.js +109 -4
  38. package/pages/delivery-graphs/standalone.html +2 -1
  39. package/pages/delivery-graphs.page.json +24 -1
  40. package/scripts/pages-contract.test.ts +50 -0
  41. package/test/delivery-graphs-import.test.ts +92 -0
  42. package/test/delivery-graphs-library-embed.test.ts +148 -0
  43. package/test/delivery-graphs-library-export.test.ts +62 -0
  44. package/test/delivery-graphs-staged-embed.test.ts +9 -0
@@ -16,7 +16,9 @@ import {
16
16
  deliveryGraphProposals,
17
17
  getStagedProposal,
18
18
  isProposalExpired,
19
+ markProposalDismissed,
19
20
  markProposalDispatched,
21
+ markProposalExpired,
20
22
  proposalExpiry,
21
23
  proposalLogicalKey,
22
24
  proposalReviewUrl,
@@ -265,3 +267,137 @@ test("sweepExpiredProposals: a dispatch racing between the read and the write is
265
267
  assertEquals((await deliveryGraphProposals(data).get("d1"))?.status, "dispatched");
266
268
  });
267
269
  });
270
+
271
+ test("markProposalDismissed: flipping a live `staged` row returns true and lands it `dismissed`", async () => {
272
+ await withData(async (data) => {
273
+ await stageProposal(data, row());
274
+ const flipped = await markProposalDismissed(data, "d1");
275
+ // A real flip reports success (res.changed > 0) so the door can return 200/ok:true.
276
+ assertEquals(flipped, true);
277
+ assertEquals((await deliveryGraphProposals(data).get("d1"))?.status, "dismissed");
278
+ });
279
+ });
280
+
281
+ test("markProposalDismissed: only a `staged` row is flipped; an already-terminal (dispatched) row is left untouched", async () => {
282
+ await withData(async (data) => {
283
+ await stageProposal(data, row());
284
+ await markProposalDispatched(data, "d1");
285
+ // A dismiss landing after the row already moved on must NOT clobber the terminal status, and must
286
+ // report the lost race by returning `false` so the door can 400 instead of misreporting success.
287
+ const flipped = await markProposalDismissed(data, "d1");
288
+ assertEquals(flipped, false);
289
+ assertEquals((await deliveryGraphProposals(data).get("d1"))?.status, "dispatched");
290
+ });
291
+ });
292
+
293
+ test("markProposalDismissed: a dispatch racing between the door's liveness read and the write is NOT clobbered to `dismissed`", async () => {
294
+ await withData(async (data) => {
295
+ // A live staged proposal — the door's `getStagedProposal` sees it as `staged` before the write.
296
+ await stageProposal(data, row());
297
+
298
+ // Wrap the data layer so that, in the window between the door's liveness read and `markProposalDismissed`'s
299
+ // guarded `exec`, the operator dispatches the proposal (status: staged -> dispatched). A blind
300
+ // update-by-key would clobber that dispatch back to `dismissed`; the guarded UPDATE (`WHERE status='staged'`)
301
+ // must instead no-op and leave the row `dispatched`.
302
+ let raced = false;
303
+ const racyData = new Proxy(data, {
304
+ get(target, prop, receiver) {
305
+ if (prop === "open") {
306
+ return () => {
307
+ const src = target.open();
308
+ return new Proxy(src, {
309
+ get(s, p) {
310
+ if (p === "exec") {
311
+ return async (sql: string, params?: unknown[]) => {
312
+ if (!raced) {
313
+ raced = true;
314
+ await markProposalDispatched(data, "d1");
315
+ }
316
+ return s.exec(sql, params);
317
+ };
318
+ }
319
+ const v = Reflect.get(s, p, s);
320
+ return typeof v === "function" ? v.bind(s) : v;
321
+ },
322
+ });
323
+ };
324
+ }
325
+ const v = Reflect.get(target, prop, target);
326
+ return typeof v === "function" ? v.bind(target) : v;
327
+ },
328
+ });
329
+
330
+ const flipped = await markProposalDismissed(racyData as DataLayer, "d1");
331
+ assert(raced, "the racing dispatch should have fired");
332
+ // The guarded UPDATE changed 0 rows (row was `dispatched` at write time) → returns false so the door 400s.
333
+ assertEquals(flipped, false);
334
+ assertEquals((await deliveryGraphProposals(data).get("d1"))?.status, "dispatched");
335
+ });
336
+ });
337
+
338
+ test("markProposalExpired: flipping a live `staged` row returns true and lands it `expired`", async () => {
339
+ await withData(async (data) => {
340
+ await stageProposal(data, row());
341
+ const flipped = await markProposalExpired(data, "d1");
342
+ // A real flip reports success (res.changed > 0).
343
+ assertEquals(flipped, true);
344
+ assertEquals((await deliveryGraphProposals(data).get("d1"))?.status, "expired");
345
+ });
346
+ });
347
+
348
+ test("markProposalExpired: only a `staged` row is flipped; an already-terminal (dispatched) row is left untouched", async () => {
349
+ await withData(async (data) => {
350
+ await stageProposal(data, row());
351
+ await markProposalDispatched(data, "d1");
352
+ // A retirement landing after the row already moved on must NOT clobber the terminal status back to
353
+ // `expired`, and must report the lost race by returning `false`.
354
+ const flipped = await markProposalExpired(data, "d1");
355
+ assertEquals(flipped, false);
356
+ assertEquals((await deliveryGraphProposals(data).get("d1"))?.status, "dispatched");
357
+ });
358
+ });
359
+
360
+ test("markProposalExpired: a dismiss racing between the dispatch door's liveness read and the write is NOT clobbered to `expired`", async () => {
361
+ await withData(async (data) => {
362
+ // A live staged proposal — the dispatch door's `getStagedProposal` sees it as `staged` before the write.
363
+ await stageProposal(data, row());
364
+
365
+ // Wrap the data layer so that, in the window between the door's liveness read and `markProposalExpired`'s
366
+ // guarded `exec`, the operator dismisses the proposal (status: staged -> dismissed). A blind
367
+ // update-by-key would clobber that dismiss back to `expired`; the guarded UPDATE (`WHERE status='staged'`)
368
+ // must instead no-op and leave the row `dismissed`.
369
+ let raced = false;
370
+ const racyData = new Proxy(data, {
371
+ get(target, prop, receiver) {
372
+ if (prop === "open") {
373
+ return () => {
374
+ const src = target.open();
375
+ return new Proxy(src, {
376
+ get(s, p) {
377
+ if (p === "exec") {
378
+ return async (sql: string, params?: unknown[]) => {
379
+ if (!raced) {
380
+ raced = true;
381
+ await markProposalDismissed(data, "d1");
382
+ }
383
+ return s.exec(sql, params);
384
+ };
385
+ }
386
+ const v = Reflect.get(s, p, s);
387
+ return typeof v === "function" ? v.bind(s) : v;
388
+ },
389
+ });
390
+ };
391
+ }
392
+ const v = Reflect.get(target, prop, target);
393
+ return typeof v === "function" ? v.bind(target) : v;
394
+ },
395
+ });
396
+
397
+ const flipped = await markProposalExpired(racyData as DataLayer, "d1");
398
+ assert(raced, "the racing dismiss should have fired");
399
+ // The guarded UPDATE changed 0 rows (row was `dismissed` at write time) → returns false.
400
+ assertEquals(flipped, false);
401
+ assertEquals((await deliveryGraphProposals(data).get("d1"))?.status, "dismissed");
402
+ });
403
+ });
@@ -36,9 +36,10 @@ export function proposalReviewUrl(digest: string, base: string = publicBaseUrl()
36
36
 
37
37
  /** The proposal lifecycle. `staged` = awaiting operator review/dispatch; `superseded` = replaced by a
38
38
  * newer digest for the same logical graph; `dispatched` = the operator launched it; `expired` = it aged
39
- * out of its TTL before an operator dispatched it. `superseded`/`dispatched`/`expired` all drop out of
40
- * the cockpit's staged list (which filters to `status = 'staged'`). */
41
- export const DELIVERY_PROPOSAL_STATUSES = ["staged", "superseded", "dispatched", "expired"] as const;
39
+ * out of its TTL before an operator dispatched it; `dismissed` = an operator explicitly discarded it as
40
+ * noise. `superseded`/`dispatched`/`expired`/`dismissed` all drop out of the cockpit's staged list
41
+ * (which filters to `status = 'staged'`). */
42
+ export const DELIVERY_PROPOSAL_STATUSES = ["staged", "superseded", "dispatched", "expired", "dismissed"] as const;
42
43
  export type DeliveryProposalStatus = typeof DELIVERY_PROPOSAL_STATUSES[number];
43
44
 
44
45
  /** One staged delivery-graph proposal — the durable row keyed by content `digest`. `side_effecting`
@@ -246,9 +247,56 @@ export async function markProposalDispatched(data: DataLayer, digest: string): P
246
247
  /** Retire a proposal by flipping it to `expired` — its `graph` payload is unusable (e.g. corrupt JSON
247
248
  * detected at dispatch), so it can never launch. Reuses the terminal `expired` status the sweep already
248
249
  * uses, so a fail-closed retirement drops the row out of the cockpit's staged grid instead of leaving an
249
- * undismissable `staged` row that fails every dispatch attempt the same way. */
250
- export async function markProposalExpired(data: DataLayer, digest: string): Promise<void> {
251
- await deliveryGraphProposals(data).update(digest, { status: "expired", updated_at: now() });
250
+ * undismissable `staged` row that fails every dispatch attempt the same way.
251
+ *
252
+ * Like `sweepExpiredProposals`/`markProposalDismissed`, the flip is a GUARDED UPDATE
253
+ * (`... WHERE digest=? AND status='staged'`), not a blind update-by-key. The dispatch door's
254
+ * `getStagedProposal` liveness read and this write are separate statements, so a proposal can leave
255
+ * `staged` in the window between them (an operator dismisses it, or it's superseded/dispatched by another
256
+ * concurrent action); a blind `table.update(digest, …)` would clobber that newer terminal status back to
257
+ * `expired`, breaking monotonic lifecycle transitions. The `status='staged'` guard makes the write a no-op
258
+ * when the row has already moved on.
259
+ *
260
+ * Returns whether the guarded UPDATE actually flipped a row (`res.changed > 0`), mirroring
261
+ * `markProposalDismissed`/`sweepExpiredProposals`. A `false` return means the row was no longer `staged`
262
+ * at write time (a dismiss/supersede/dispatch race won), so the caller must not treat the retirement as
263
+ * having happened. */
264
+ export async function markProposalExpired(data: DataLayer, digest: string): Promise<boolean> {
265
+ const db = data.open();
266
+ const res = await db.exec(
267
+ `UPDATE "delivery_graph_proposals" SET "status" = 'expired', "updated_at" = ? WHERE "digest" = ? AND "status" = 'staged'`,
268
+ [now(), digest],
269
+ );
270
+ return res.changed > 0;
271
+ }
272
+
273
+ /** Dismiss a staged proposal at an operator's explicit request — flip it to the terminal `dismissed`
274
+ * status so it drops out of the cockpit's staged grid (which filters to `status = 'staged'`), exactly
275
+ * like `superseded`/`expired`. Unlike `expired` (a TTL sweep) or `superseded` (a newer digest landed),
276
+ * `dismissed` records a deliberate operator "this is noise, hide it" — the proposal was neither aged out
277
+ * nor replaced. Callers (the `dismissProposal` door) gate this behind a `getStagedProposal` liveness
278
+ * check so an unknown or already-terminal digest is refused before this runs, keeping the dismiss
279
+ * idempotent.
280
+ *
281
+ * Like `sweepExpiredProposals`, the flip is a GUARDED UPDATE (`... WHERE digest=? AND status='staged'`),
282
+ * not a blind update-by-key. The door's `getStagedProposal` check and this write are separate statements,
283
+ * so a dispatch (or a supersede/expiry sweep) can move the row off `staged` in the window between them; a
284
+ * blind `table.update(digest, …)` would clobber that newer terminal status back to `dismissed`, silently
285
+ * re-hiding a run the operator just launched. The `status='staged'` guard makes the write a no-op when the
286
+ * row has already moved on, preserving monotonic lifecycle transitions under concurrent operator actions.
287
+ *
288
+ * Returns whether the guarded UPDATE actually flipped a row (`res.changed > 0`), mirroring how
289
+ * `sweepExpiredProposals` counts `res.changed`. A `false` return means the row was no longer `staged` at
290
+ * write time (the dispatch/supersede/expiry race above won), so the caller (the `dismissProposal` door)
291
+ * must NOT report success — the dismiss lost the race and its idempotency contract routes that to a clean
292
+ * 400, exactly as an already-terminal digest does. */
293
+ export async function markProposalDismissed(data: DataLayer, digest: string): Promise<boolean> {
294
+ const db = data.open();
295
+ const res = await db.exec(
296
+ `UPDATE "delivery_graph_proposals" SET "status" = 'dismissed', "updated_at" = ? WHERE "digest" = ? AND "status" = 'staged'`,
297
+ [now(), digest],
298
+ );
299
+ return res.changed > 0;
252
300
  }
253
301
 
254
302
  /** Age out every `staged` proposal whose TTL has elapsed by flipping it to `expired`, so it drops out
@@ -0,0 +1,66 @@
1
+ // Unit coverage for `validateDeliveryGraphShape` — the reused OpenAPI `DeliveryGraph` shape gate
2
+ // (PR #533 review). It must (a) reject the nested-type/unknown-property violations the semantic
3
+ // validator deliberately does not re-enumerate, with path-qualified errors, and (b) accept a
4
+ // structurally-valid graph — INCLUDING a not-yet-resolvable capability/pr probe, whose late-binding
5
+ // is deferred to the runner and must NOT be a shape-time failure.
6
+ import { test } from "node:test";
7
+ import { assert, assertEquals } from "#test-assert";
8
+ import { validateDeliveryGraphShape } from "./deliveryGraphShape.ts";
9
+
10
+ test("accepts a structurally-valid graph (agent + human)", () => {
11
+ assertEquals(
12
+ validateDeliveryGraphShape({
13
+ name: "ok",
14
+ nodes: [
15
+ { id: "a", kind: "agent", agent: { jobType: "senior:feature" } },
16
+ { id: "h", kind: "human", human: { prompt: "do X" } },
17
+ ],
18
+ edges: [{ from: "a", to: "h" }],
19
+ }).length,
20
+ 0,
21
+ );
22
+ });
23
+
24
+ test("accepts a not-yet-resolvable capability/pr wait probe (shape only, resolution deferred)", () => {
25
+ assertEquals(
26
+ validateDeliveryGraphShape({
27
+ nodes: [
28
+ { id: "c", kind: "wait", wait: { kind: "capability", target: "pkg@1.0.0", match: { package: "pkg", capabilityRef: "o/r#1" } } },
29
+ { id: "p", kind: "wait", wait: { kind: "pr", target: "o/r#1", match: { prState: "merged" } } },
30
+ ],
31
+ }).length,
32
+ 0,
33
+ );
34
+ });
35
+
36
+ test("rejects a nested wrong-typed optional field with a path-qualified error", () => {
37
+ const errors = validateDeliveryGraphShape({
38
+ // biome-ignore lint/suspicious/noExplicitAny: deliberately malformed.
39
+ nodes: [{ id: "h", kind: "human", human: { prompt: 42 } } as any],
40
+ });
41
+ assert(errors.length > 0);
42
+ assert(errors.some((e) => e.path.includes("nodes[0]/human/prompt")));
43
+ });
44
+
45
+ test("rejects a wrong-typed nested wait.poll.backoff", () => {
46
+ const errors = validateDeliveryGraphShape({
47
+ // biome-ignore lint/suspicious/noExplicitAny: deliberately malformed.
48
+ nodes: [{ id: "w", kind: "wait", wait: { kind: "http", target: "http://x", poll: { backoff: 42 } } } as any],
49
+ });
50
+ assert(errors.length > 0);
51
+ assert(errors.some((e) => e.path.includes("nodes[0]/wait/poll/backoff")));
52
+ });
53
+
54
+ test("rejects an unknown top-level property", () => {
55
+ const errors = validateDeliveryGraphShape({
56
+ nodes: [{ id: "a", kind: "agent", agent: { jobType: "senior:feature" } }],
57
+ // biome-ignore lint/suspicious/noExplicitAny: deliberately malformed.
58
+ bogus: 1,
59
+ } as any);
60
+ assert(errors.length > 0);
61
+ assert(errors.some((e) => e.path.includes("bogus")));
62
+ });
63
+
64
+ test("rejects an empty-nodes graph (nodes.minItems: 1)", () => {
65
+ assert(validateDeliveryGraphShape({ name: "x", nodes: [] }).length > 0);
66
+ });
@@ -0,0 +1,67 @@
1
+ // app/deliveryGraphShape.ts — reuse the RUNTIME's OWN OpenAPI request-body validator to shape-check a
2
+ // delivery graph that reached the compiler through a TEXT-ingress door (preview/stage/save/import).
3
+ //
4
+ // The typed agent door (`compileDeliveryGraph`) has its body validated against the openapi
5
+ // `DeliveryGraph` schema by the runtime BEFORE the delegate runs (`@nanobpm/urban`
6
+ // runtime/core/modules/api.js → `validateValue`). The four human-facing doors instead receive a raw
7
+ // `graphJson` STRING, which the runtime cannot shape-check — so the parsed object reaches
8
+ // `compileDeliveryGraph` having BYPASSED that gate, and only the SEMANTIC validator
9
+ // (`validateDeliveryGraph`) sees it. That validator, by design, does not re-enumerate every nested
10
+ // optional-field type (the drift surface it explicitly forbids — see `REQUIRED_CONFIG_FIELDS` in
11
+ // `app/deliveryGraph.ts`), so malformed nested values (`nodes[0].human.prompt: 42`,
12
+ // `wait.poll.backoff: 42`, unknown properties) slipped through, were persisted, and later made
13
+ // `parseProbe` throw downstream in `prepareDeliveryGraph`.
14
+ //
15
+ // Rather than hand-write those nested checks (drift) or add a new JSON-Schema dependency, we reuse the
16
+ // EXACT validator the runtime applies at the typed edge — `validateValue` from `@nanobpm/urban/toolkit`
17
+ // — against the SAME canonical `DeliveryGraph` schema in `openapi.yaml`. ONE schema, ONE validator, no
18
+ // second source of truth: the text doors now get byte-identical shape enforcement to the typed door.
19
+ // It is PURELY structural, so a shape-valid but not-yet-resolvable `capability`/`pr` wait probe passes
20
+ // (its late-binding is the runner's job, not the edge's) — exactly the deferred-resolution contract the
21
+ // canonical `parseProbe` intentionally keeps out of validate time.
22
+
23
+ import { readFileSync } from "node:fs";
24
+ import {
25
+ type OpenApiDoc,
26
+ type OpenApiSchema,
27
+ parseSpec,
28
+ resolveSchema,
29
+ validateValue,
30
+ } from "@nanobpm/urban/toolkit";
31
+ import type { CompileDeliveryGraphErrors } from "../nano-generated/api-io.d.ts";
32
+
33
+ /** The wire error pair the compiler/doors forward (`{ path, message }`) — the stable schema `code`
34
+ * stays server-side, mirroring how `validateDeliveryGraph` errors are stripped for the wire. */
35
+ type WireError = CompileDeliveryGraphErrors["errors"][number];
36
+
37
+ // The spec ships at the repo root; this module lives in `app/`, so `..` is the repo root. Mirrors the
38
+ // app's existing runtime-repo-file reads (`agentGuide.ts`, `agentSkill.ts`, `agentCompletion.ts`).
39
+ const SPEC_URL = new URL("../openapi.yaml", import.meta.url);
40
+
41
+ let cached: { doc: OpenApiDoc; schema: OpenApiSchema } | undefined;
42
+
43
+ /** Load + parse `openapi.yaml` ONCE and resolve the `DeliveryGraph` component schema (cached — the
44
+ * spec is immutable for a process lifetime, so re-reading per compile would be pure waste). */
45
+ function deliveryGraphSchema(): { doc: OpenApiDoc; schema: OpenApiSchema } {
46
+ if (!cached) {
47
+ const doc = parseSpec(readFileSync(SPEC_URL, "utf8"));
48
+ const schema = resolveSchema(doc, { $ref: "#/components/schemas/DeliveryGraph" });
49
+ if (!schema) {
50
+ throw new Error("openapi.yaml is missing the #/components/schemas/DeliveryGraph schema");
51
+ }
52
+ cached = { doc, schema };
53
+ }
54
+ return cached;
55
+ }
56
+
57
+ /** Shape-check `graph` against the canonical openapi `DeliveryGraph` schema using the runtime's OWN
58
+ * validator — the SAME gate the typed `compileDeliveryGraph` edge applies. Returns path-qualified
59
+ * `{ path, message }` errors (empty = the shape is valid). Structural only: a shape-valid but
60
+ * not-yet-resolvable `capability`/`pr` probe passes (late-binding is deferred to the runner). */
61
+ export function validateDeliveryGraphShape(graph: unknown): WireError[] {
62
+ const { doc, schema } = deliveryGraphSchema();
63
+ return validateValue(doc, schema, graph).map((issue) => ({
64
+ path: issue.path === "" ? "/" : issue.path,
65
+ message: issue.message,
66
+ }));
67
+ }
@@ -0,0 +1,137 @@
1
+ // Coverage for the shared text-ingress helper's "never throws / never a 500" promise
2
+ // (`parseAndCompileText`). The four doors that call it (preview/stage/save/import) DON'T wrap the
3
+ // call, so a throw from the compiler's layout pass (`layoutDeliveryDiagram` fails loud when
4
+ // `layoutBpmn` produces no DI — e.g. the `bpmn-auto-layout` peer is missing) would otherwise escape
5
+ // as a raw, unhandled 500. That fault is a server-side infra condition, not reproducible from input
6
+ // alone, so we inject a rejecting compiler through the helper's test seam to pin the guard.
7
+ import { test } from "node:test";
8
+ import { assert, assertEquals } from "#test-assert";
9
+ import { parseAndCompileText } from "./deliveryGraphTextIngress.ts";
10
+
11
+ // A SHAPE-valid graph (the openapi `DeliveryGraph` schema requires `nodes.minItems: 1`) so it reaches
12
+ // the injected compiler seam — the shape gate now runs BEFORE compile, so an empty-nodes body would be
13
+ // rejected as malformed and never exercise the never-throws guard.
14
+ const VALID_BODY = {
15
+ graphJson: JSON.stringify({
16
+ name: "x",
17
+ nodes: [{ id: "a", kind: "agent", agent: { jobType: "senior:feature" } }],
18
+ }),
19
+ };
20
+
21
+ test("a compiler that throws is mapped to a clean 400 — never an unhandled 500", async () => {
22
+ const result = await parseAndCompileText(VALID_BODY, {
23
+ compile: () => Promise.reject(new Error("layoutBpmn produced no bpmndi:BPMNDiagram")),
24
+ });
25
+ assert(!result.ok, "a thrown compile must surface as ok:false, not a resolved success");
26
+ assertEquals(result.status, 400, "a caught compile throw must be the door's clean 400, not a 500");
27
+ assert(
28
+ result.body.error.includes("layoutBpmn produced no bpmndi:BPMNDiagram"),
29
+ "the 400 body must carry the underlying compile failure message",
30
+ );
31
+ });
32
+
33
+ test("a non-Error thrown value is still caught and reported, never rethrown", async () => {
34
+ const result = await parseAndCompileText(VALID_BODY, {
35
+ // biome-ignore lint/suspicious/useError: exercising the non-Error catch branch on purpose.
36
+ compile: () => Promise.reject("kaboom"),
37
+ });
38
+ assert(!result.ok, "a non-Error rejection must also be caught as ok:false");
39
+ assertEquals(result.status, 400, "a non-Error compile throw is still a clean 400");
40
+ assert(result.body.error.includes("kaboom"), "the stringified non-Error cause must be surfaced");
41
+ });
42
+
43
+ test("a shape-validator that throws (unreadable/corrupt openapi.yaml) is mapped to a clean 400 — never a 500", async () => {
44
+ let compileCalled = false;
45
+ const result = await parseAndCompileText(VALID_BODY, {
46
+ validateShape: () => {
47
+ throw new Error("ENOENT: openapi.yaml not found");
48
+ },
49
+ compile: () => {
50
+ compileCalled = true;
51
+ return Promise.reject(new Error("compile must not run when the shape gate itself faults"));
52
+ },
53
+ });
54
+ assert(!result.ok, "a thrown shape-validator fault must surface as ok:false, not a resolved success");
55
+ assertEquals(result.status, 400, "a caught spec-load fault must be the door's clean 400, not a 500");
56
+ assert(!compileCalled, "the compiler must not run when the shape gate itself throws");
57
+ assert(
58
+ result.body.error.includes("graph shape check unavailable"),
59
+ "the 400 body must report the shape gate as unavailable",
60
+ );
61
+ assert(
62
+ result.body.error.includes("ENOENT: openapi.yaml not found"),
63
+ "the 400 body must carry the underlying spec-load failure cause",
64
+ );
65
+ });
66
+
67
+
68
+ // semantic validator does NOT re-enumerate is now rejected at the door with a path-qualified error,
69
+ // BEFORE compile runs (so a throwing compiler is never reached for these malformed bodies).
70
+ for (const [label, graph, needle] of [
71
+ [
72
+ "wait.poll.backoff wrong type",
73
+ { nodes: [{ id: "w", kind: "wait", wait: { kind: "http", target: "http://x", poll: { backoff: 42 } } }] },
74
+ "nodes[0]/wait/poll/backoff",
75
+ ],
76
+ [
77
+ "human.prompt wrong type",
78
+ { nodes: [{ id: "h", kind: "human", human: { prompt: 42 } }] },
79
+ "nodes[0]/human/prompt",
80
+ ],
81
+ [
82
+ "unknown top-level property",
83
+ { nodes: [{ id: "a", kind: "agent", agent: { jobType: "senior:feature" } }], bogus: 1 },
84
+ "bogus",
85
+ ],
86
+ ] as const) {
87
+ test(`shape gate rejects a nested-shape violation before compile: ${label}`, async () => {
88
+ let compileCalled = false;
89
+ const result = await parseAndCompileText(
90
+ { graphJson: JSON.stringify(graph) },
91
+ {
92
+ compile: () => {
93
+ compileCalled = true;
94
+ return Promise.reject(new Error("compile must not run on a shape-invalid graph"));
95
+ },
96
+ },
97
+ );
98
+ assert(!result.ok, "a shape-invalid graph must be ok:false");
99
+ assertEquals(result.status, 400, "a shape-invalid graph is a clean 400");
100
+ assert(!compileCalled, "the shape gate must short-circuit BEFORE the compiler runs");
101
+ assert(
102
+ Array.isArray(result.body.errors) && result.body.errors.length > 0,
103
+ "the 400 body must carry path-qualified shape errors",
104
+ );
105
+ assert(
106
+ result.body.errors.some((e: { path: string; message: string }) => e.path.includes(needle)),
107
+ `a shape error must point at ${needle}`,
108
+ );
109
+ });
110
+ }
111
+
112
+ test("shape gate accepts a valid graph — a not-yet-resolvable capability probe passes structurally", async () => {
113
+ let compileCalled = false;
114
+ const result = await parseAndCompileText(
115
+ {
116
+ graphJson: JSON.stringify({
117
+ nodes: [
118
+ {
119
+ id: "c",
120
+ kind: "wait",
121
+ wait: { kind: "capability", target: "pkg@1.0.0", match: { capabilityRef: "o/r#1", package: "pkg" } },
122
+ },
123
+ ],
124
+ }),
125
+ },
126
+ {
127
+ compile: () => {
128
+ compileCalled = true;
129
+ return Promise.reject(new Error("reached compile"));
130
+ },
131
+ },
132
+ );
133
+ // The shape gate passed (compile was reached — it only rejects because the seam is a stub), proving a
134
+ // structurally-valid but deferred-resolution probe is NOT falsely rejected at shape time.
135
+ assert(compileCalled, "a shape-valid capability probe must pass the shape gate and reach compile");
136
+ assert(!result.ok, "the injected stub compiler still rejects");
137
+ });
@@ -12,6 +12,7 @@
12
12
  import type { DeliveryGraphTextResult } from "../nano-generated/api-io.d.ts";
13
13
  import { compileDeliveryGraph } from "./deliveryGraphCompiler.ts";
14
14
  import { proposalReviewUrl } from "./deliveryGraphProposals.ts";
15
+ import { validateDeliveryGraphShape } from "./deliveryGraphShape.ts";
15
16
  import { parseDeliveryGraphText } from "./deliveryGraphText.ts";
16
17
  import { deliveryGraphDigest } from "./deliveryRunner.ts";
17
18
 
@@ -37,15 +38,84 @@ export interface TextIngressOk {
37
38
 
38
39
  export type TextIngressResult = TextIngressOk | TextIngressFailure;
39
40
 
41
+ /** Injectable seam for {@link parseAndCompileText}. `compile` defaults to the real
42
+ * {@link compileDeliveryGraph}; a test overrides it to drive the never-throws guard with a compiler
43
+ * that REJECTS — the real layout pass only throws on a server-side fault (a missing `bpmn-auto-layout`
44
+ * peer, so no DI is produced), which is not reproducible from input alone. */
45
+ export interface ParseAndCompileDeps {
46
+ compile?: (graph: unknown) => Promise<CompileResult>;
47
+ /** Injectable seam for the reused OpenAPI shape gate — defaults to the real
48
+ * {@link validateDeliveryGraphShape}. A test overrides it to drive the spec-load guard with a
49
+ * validator that THROWS, standing in for a stripped/corrupt deployment whose `openapi.yaml` cannot
50
+ * be read/parsed — not reproducible from input alone. */
51
+ validateShape?: (graph: unknown) => ReturnType<typeof validateDeliveryGraphShape>;
52
+ }
53
+
40
54
  /** Parse a UI JSON-paste body (`{ graphJson }`), then run the SAME pure compiler the agent door uses.
41
55
  * A blank/invalid paste or a graph that fails validation is returned as a ready-to-send 400; success
42
- * carries the compiled graph plus its content `digest` and human `name`. Never throws / never a 500. */
43
- export async function parseAndCompileText(body: unknown): Promise<TextIngressResult> {
56
+ * carries the compiled graph plus its content `digest` and human `name`. Never throws / never a 500
57
+ * even a server-side layout fault is caught and mapped to the door's clean 400/no-persist shape. */
58
+ export async function parseAndCompileText(
59
+ body: unknown,
60
+ deps: ParseAndCompileDeps = {},
61
+ ): Promise<TextIngressResult> {
44
62
  const parsed = parseDeliveryGraphText(body);
45
63
  if (!parsed.ok) {
46
64
  return { ok: false, status: 400, body: { ok: false, error: parsed.error } };
47
65
  }
48
- const compiled = await compileDeliveryGraph(parsed.graph);
66
+ // Re-apply the OpenAPI `DeliveryGraph` SHAPE gate the runtime runs at the typed `compileDeliveryGraph`
67
+ // edge (its `validateValue`). These text doors receive a raw `graphJson` STRING, so the runtime never
68
+ // shape-checked the parsed object — without this, malformed NESTED values (`nodes[0].human.prompt: 42`,
69
+ // `wait.poll.backoff: 42`, unknown properties) reach the semantic validator, which deliberately does
70
+ // not re-enumerate every optional-field type, and are persisted / later throw in `parseProbe`. Reusing
71
+ // the SAME validator against the SAME canonical schema (no ajv, no drift-surface hand checks) gives the
72
+ // text doors byte-identical shape enforcement to the agent door. Structural only, so a not-yet-
73
+ // resolvable capability/pr probe still passes — its late-binding stays the runner's job.
74
+ let shapeErrors: ReturnType<typeof validateDeliveryGraphShape>;
75
+ try {
76
+ shapeErrors = (deps.validateShape ?? validateDeliveryGraphShape)(parsed.graph);
77
+ } catch (err) {
78
+ // `validateDeliveryGraphShape` reads/parses `openapi.yaml` (once, cached) to reuse the runtime's
79
+ // OWN validator. A stripped or corrupted deployment where the spec is missing/unparseable — or the
80
+ // `DeliveryGraph` schema cannot be resolved — makes that read THROW. Left uncaught it would escape
81
+ // whichever door called us (none wrap this call) as a raw, unhandled 500, breaking the "never throws
82
+ // / never a 500" promise every caller depends on. This is a server-side fault (like the layout fault
83
+ // caught below), not bad input, so map it to the SAME clean 400/no-persist shape — ONE contract for
84
+ // every server fault this pipeline can hit, no partial/unhandled leak.
85
+ const message = err instanceof Error ? err.message : String(err);
86
+ return {
87
+ ok: false,
88
+ status: 400,
89
+ body: { ok: false, error: `graph shape check unavailable: ${message}` },
90
+ };
91
+ }
92
+ if (shapeErrors.length > 0) {
93
+ return {
94
+ ok: false,
95
+ status: 400,
96
+ body: {
97
+ ok: false,
98
+ error: `graph failed shape validation: ${shapeErrors.length} error(s)`,
99
+ errors: shapeErrors,
100
+ },
101
+ };
102
+ }
103
+ const compile = deps.compile ?? compileDeliveryGraph;
104
+ let compiled: CompileResult;
105
+ try {
106
+ compiled = await compile(parsed.graph);
107
+ } catch (err) {
108
+ // `compileDeliveryGraph` returns ok:false for every INPUT failure, but its layout pass
109
+ // (`layoutDeliveryDiagram` → `layoutBpmn`) can still THROW on a server-side fault — e.g. the
110
+ // `bpmn-auto-layout` peer missing, so no DI block is produced. Uncaught, that rejection would
111
+ // escape whichever door called us (preview/stage/save/import — none wrap this call) as a raw,
112
+ // unhandled 500, breaking the "never throws / never a 500" promise every caller depends on. Map it
113
+ // to the SAME clean 400 shape a compile error produces, so ONE guard keeps the door's documented
114
+ // "compile failure → clean 400, nothing persisted" contract honest for this edge case across all
115
+ // four doors, rather than leaking a partial/unhandled failure.
116
+ const message = err instanceof Error ? err.message : String(err);
117
+ return { ok: false, status: 400, body: { ok: false, error: `graph failed to compile: ${message}` } };
118
+ }
49
119
  if (!compiled.ok) {
50
120
  return {
51
121
  ok: false,
@@ -49,6 +49,7 @@ const MIGRATION_CHAIN = [
49
49
  "080_plan_read_model_derive_terminal.sql",
50
50
  ROLLUPS_MIGRATION,
51
51
  READ_MODEL_MIGRATION,
52
+ "084_plan_wave_tasks_effective_status.sql",
52
53
  ];
53
54
 
54
55
  // The base `plans` / `plan_tasks` / `pull_requests` shapes the VIEWs read, plus a stand-in for the
@@ -430,6 +431,28 @@ test("plan_wave_tasks carries each task's PR url + process_key link targets (unc
430
431
  assertEquals({ pr_url: rows[1].pr_url, process_key: rows[1].process_key }, { pr_url: null, process_key: null });
431
432
  });
432
433
 
434
+ test("plan_wave_tasks derives status=merged from the PR (never strands a landed slice at 'opened') — matching the summary bar", () => {
435
+ // The reported defect (#530): a slice whose PR converged + merged kept reading Status "opened" in
436
+ // the wave-state grid, because nothing writes `plan_tasks.status='merged'` on merge and the VIEW
437
+ // exposed the raw task status. The fix DERIVES the displayed status the SAME way the count VIEWs
438
+ // bucket `merged` — `pull_requests__tracking.derived_status = 'merged'` overrides the raw status —
439
+ // so the per-task grid and the per-wave summary bar agree.
440
+ const db = viewDb();
441
+ addPlan(db, "o/r#eff", { status: "done" });
442
+ addTask(db, "o/r#eff", { status: "opened", wave: 0, prStatus: "merged" }); // landed slice, task row frozen at "opened"
443
+ addTask(db, "o/r#eff", { status: "opened", wave: 0, prStatus: "converging" }); // still converging → stays "opened"
444
+ addTask(db, "o/r#eff", { status: "blocked", wave: 0 }); // no PR → raw status untouched
445
+ // A DERIVE-ONLY merged edge (base PR status frozen, tracking recomputes to `merged`) also reads merged.
446
+ addTask(db, "o/r#eff", { status: "opened", wave: 1, prStatus: "converging", prDerivedOverride: "merged" });
447
+ const byWaveIdx = db
448
+ .prepare("SELECT wave, task_index, status FROM plan_wave_tasks WHERE plan_key = ? ORDER BY task_index")
449
+ .all("o/r#eff") as Array<Record<string, unknown>>;
450
+ assertEquals(byWaveIdx.map((r) => r.status), ["merged", "opened", "blocked", "merged"]);
451
+ // The count VIEW and the per-task grid now agree on the merged tally for wave 0 (3 slices, 1 merged).
452
+ const c = db.prepare("SELECT merged, total FROM plan_wave_counts WHERE plan_key = ? AND wave = 0").get("o/r#eff") as Record<string, unknown>;
453
+ assertEquals({ merged: Number(c.merged), total: Number(c.total) }, { merged: 1, total: 3 });
454
+ });
455
+
433
456
  test("the operator pages bind the derived plan-family VIEWs (never the raw plans table)", () => {
434
457
  // Overview + Epic index + Epic detail all read the composite `plan_read_model`; the epic-detail
435
458
  // per-wave summary reads `plan_wave_summary` (the bar), and the wave-state grid `plan_wave_tasks`.