@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.
- package/CHANGELOG.md +12 -0
- package/app/contracts.ts +16 -0
- package/app/deliveryGraph.test.ts +84 -0
- package/app/deliveryGraph.ts +79 -0
- package/app/deliveryGraphCompiler.ts +4 -2
- package/app/deliveryGraphLibrary.test.ts +134 -0
- package/app/deliveryGraphLibrary.ts +153 -0
- package/app/deliveryGraphProposals.test.ts +136 -0
- package/app/deliveryGraphProposals.ts +54 -6
- package/app/deliveryGraphShape.test.ts +66 -0
- package/app/deliveryGraphShape.ts +67 -0
- package/app/deliveryGraphTextIngress.test.ts +137 -0
- package/app/deliveryGraphTextIngress.ts +73 -3
- package/app/planReadModel.test.ts +23 -0
- package/db/migrations/084_plan_wave_tasks_effective_status.sql +51 -0
- package/db/migrations/085_delivery_graph_library.sql +32 -0
- package/openapi.yaml +366 -0
- package/operations/deleteLibraryEntry.test.ts +88 -0
- package/operations/deleteLibraryEntry.ts +23 -0
- package/operations/dismissProposal.test.ts +105 -0
- package/operations/dismissProposal.ts +53 -0
- package/operations/getLibraryEntry.test.ts +75 -0
- package/operations/getLibraryEntry.ts +25 -0
- package/operations/importToLibrary.test.ts +195 -0
- package/operations/importToLibrary.ts +62 -0
- package/operations/listLibrary.test.ts +79 -0
- package/operations/listLibrary.ts +24 -0
- package/operations/saveToLibrary.test.ts +225 -0
- package/operations/saveToLibrary.ts +89 -0
- package/package.json +1 -1
- package/pages/delivery-graphs/delivery-graphs.css +33 -0
- package/pages/delivery-graphs/embed.html +1 -0
- package/pages/delivery-graphs/library-embed.html +31 -0
- package/pages/delivery-graphs/library-standalone.html +38 -0
- package/pages/delivery-graphs/library.mount.js +364 -0
- package/pages/delivery-graphs/mount.js +133 -4
- package/pages/delivery-graphs/staged.mount.js +109 -4
- package/pages/delivery-graphs/standalone.html +2 -1
- package/pages/delivery-graphs.page.json +24 -1
- package/scripts/pages-contract.test.ts +50 -0
- package/test/delivery-graphs-import.test.ts +92 -0
- package/test/delivery-graphs-library-embed.test.ts +148 -0
- package/test/delivery-graphs-library-export.test.ts +62 -0
- 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
|
|
40
|
-
*
|
|
41
|
-
|
|
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
|
-
|
|
251
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`.
|