@nanobpm/nano-workforce 0.120.0 → 0.120.2

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 (38) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/app/capabilityNeed.test.ts +4 -2
  3. package/app/capabilityNeed.ts +3 -1
  4. package/app/deliveryGraphCompiler.test.ts +72 -44
  5. package/app/deliveryGraphCompiler.ts +121 -19
  6. package/app/deliveryGraphRun.test.ts +2 -2
  7. package/app/deliveryRunner.test.ts +29 -17
  8. package/app/deliveryRunner.ts +31 -10
  9. package/app/feature.test.ts +3 -1
  10. package/app/feature.ts +9 -0
  11. package/app/featureReadiness.test.ts +7 -4
  12. package/app/featureReadiness.ts +8 -3
  13. package/app/plan.test.ts +1 -1
  14. package/app/plan.ts +9 -0
  15. package/app/planFanoutPreflight.test.ts +12 -12
  16. package/app/planLowering.test.ts +2 -0
  17. package/app/planLowering.ts +7 -2
  18. package/app/pollUserTasks.test.ts +49 -1
  19. package/app/readiness.test.ts +9 -0
  20. package/app/readiness.ts +25 -0
  21. package/app/service.ts +24 -6
  22. package/biome.json +24 -1
  23. package/e2e/delivery-graph.e2e.ts +2 -1
  24. package/e2e/feature-preflight.e2e.ts +2 -0
  25. package/e2e/inter-epic-dependency.e2e.ts +7 -1
  26. package/e2e/plan-fanout-preflight.e2e.ts +2 -0
  27. package/e2e/readiness-gate.e2e.ts +27 -11
  28. package/operations/compileDeliveryGraph.test.ts +3 -0
  29. package/operations/compileDeliveryGraph.ts +1 -1
  30. package/operations/dispatchDeliveryGraph.ts +1 -1
  31. package/operations/previewDeliveryGraph.ts +1 -1
  32. package/operations/startDeliveryGraph.ts +1 -1
  33. package/package.json +3 -2
  34. package/resources/processes/feature.bpmn +168 -52
  35. package/resources/processes/plan-fanout.bpmn +168 -52
  36. package/resources/processes/readiness-gate.bpmn +194 -84
  37. package/workers/readiness-probe/worker.test.ts +82 -238
  38. package/workers/readiness-probe/worker.ts +46 -128
package/app/service.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  import { isUniqueConstraintFence } from "./dbFence.ts";
30
30
  import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
31
31
  import { deliveryGraphRuns, deriveDeliveryPhase, parseHumanLabels } from "./deliveryGraphRun.ts";
32
+ import { isDeliveryHumanElement } from "./deliveryHuman.ts";
32
33
  import { fleetSupportsDurableResume } from "./durableResume.ts";
33
34
  import { backfillFeatureStages, deriveFeatureDelivery, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRunStatus, featureEscalations, featureRuns } from "./feature.ts";
34
35
  import {
@@ -70,6 +71,7 @@ import {
70
71
  probeOnce,
71
72
  READINESS_READY_MESSAGE,
72
73
  type ReadinessProbe,
74
+ readinessPollEvery,
73
75
  readinessTimeout,
74
76
  } from "./readiness.ts";
75
77
  import { clampNudgeMinutes, reviewWaitTimeout } from "./reviewWait.ts";
@@ -86,9 +88,9 @@ import {
86
88
  prEscalations,
87
89
  reconcileUserTasks,
88
90
  TRIAL_MERGE_ELEMENT,
89
- USER_TASK_KIND_LABELS,
90
91
  type UserTaskContext,
91
92
  type UserTaskRow,
93
+ userTaskKindLabel,
92
94
  userTasks,
93
95
  } from "./userTasks.ts";
94
96
  import { deriveWaitGate } from "./waitGate.ts";
@@ -1688,6 +1690,10 @@ function capabilityGateTimeout(env: Record<string, string | undefined>): string
1688
1690
  return readinessTimeout({ kind: "capability", target: "" } satisfies ReadinessProbe, env);
1689
1691
  }
1690
1692
 
1693
+ function capabilityGatePollEvery(env: Record<string, string | undefined>): string {
1694
+ return readinessPollEvery({ kind: "capability", target: "" } satisfies ReadinessProbe, env);
1695
+ }
1696
+
1691
1697
  /** Capability-edge reconcile pass (issue #289). The host half of the "consumer readiness edge":
1692
1698
  * plan-fanout's per-task fan-out parks at the `wait-caps-resolved` message barrier for any task that
1693
1699
  * declared cross-repo capability `needs` (049_plan_task_needs.sql). Here we reconcile, on EVERY pass
@@ -1721,6 +1727,7 @@ export async function pollCapabilityGatesImpl(
1721
1727
  ) {
1722
1728
  const gateTable = capabilityGates(data);
1723
1729
  const probeTimeout = capabilityGateTimeout(env);
1730
+ const probePollEvery = capabilityGatePollEvery(env);
1724
1731
  for (const plan of await plans(data).all()) {
1725
1732
  const planKey = plan.plan_key;
1726
1733
  const processKey = plan.process_key;
@@ -1757,7 +1764,7 @@ export async function pollCapabilityGatesImpl(
1757
1764
  // need as unresolved (it can only clear once the handle is corrected on a re-plan).
1758
1765
  let probeInput: ReturnType<typeof capabilityNeedToProbeInput>;
1759
1766
  try {
1760
- probeInput = capabilityNeedToProbeInput(need, { planKey, taskId, probeTimeout });
1767
+ probeInput = capabilityNeedToProbeInput(need, { planKey, taskId, probeTimeout, probePollEvery });
1761
1768
  } catch (err) {
1762
1769
  if (err instanceof UnresolvableCapabilityRefError) {
1763
1770
  if (!row) {
@@ -1808,6 +1815,7 @@ export async function pollCapabilityGatesImpl(
1808
1815
  variables: {
1809
1816
  gateKey: probeInput.gateKey,
1810
1817
  probeTimeout: probeInput.probeTimeout,
1818
+ probePollEvery: probeInput.probePollEvery,
1811
1819
  onTimeout: probeInput.onTimeout,
1812
1820
  probe: probeInput.probe,
1813
1821
  },
@@ -2159,7 +2167,7 @@ async function sweepOpenEscalationTasks(base: string, headers: Record<string, st
2159
2167
  // answerable, so a lagging COMPLETED/CANCELED read must never surface a dead affordance (#294).
2160
2168
  if (typeof it.state === "string" && it.state.toUpperCase() !== "CREATED") continue;
2161
2169
  const elementId = typeof it.elementId === "string" ? it.elementId : undefined;
2162
- if (!elementId || !Object.hasOwn(USER_TASK_KIND_LABELS, elementId)) continue;
2170
+ if (!elementId || userTaskKindLabel(elementId) === undefined) continue;
2163
2171
  const userTaskKey = it.userTaskKey == null ? "" : String(it.userTaskKey);
2164
2172
  if (!userTaskKey || seen.has(userTaskKey)) continue;
2165
2173
  seen.add(userTaskKey);
@@ -2247,7 +2255,7 @@ export async function pollUserTasks(
2247
2255
  // summary for the `conformance-escalation` ack (its instance is tracked on `plan_conformance`, not a
2248
2256
  // delivery aggregate).
2249
2257
  interface Subject {
2250
- type: "feature" | "plan" | "pr";
2258
+ type: "feature" | "plan" | "pr" | "delivery";
2251
2259
  key: string;
2252
2260
  title?: string | null;
2253
2261
  url?: string | null;
@@ -2271,6 +2279,13 @@ export async function pollUserTasks(
2271
2279
  const plan = await plans(data).get(review.plan_key);
2272
2280
  subjectByInstance.set(review.process_key, { type: "plan", key: review.plan_key, title: plan?.title ?? null, url: plan?.issue_url ?? null, conformanceSummary: review.summary });
2273
2281
  }
2282
+ // A delivery-graph `human` node parks on its run's engine instance; enrich from the run row so the
2283
+ // Tasks inbox shows the graph's title (its `run_key` as the stable subject key), mirroring the
2284
+ // feature/plan/pr enrichment. The row's inlined `delivery-human-task__<node>` id is recognised by
2285
+ // the shared `userTaskKindLabel` predicate, and buckets as `delivery` (below).
2286
+ for (const run of await deliveryGraphRuns(data).all()) {
2287
+ if (run.process_key) subjectByInstance.set(run.process_key, { type: "delivery", key: run.run_key, title: run.title, url: null });
2288
+ }
2274
2289
 
2275
2290
  // Per-element subject type for an ORPHANED task (no subject row) — the kind implies its aggregate even
2276
2291
  // when tracking is lost, so the fallback row still buckets correctly on the page.
@@ -2289,9 +2304,12 @@ export async function pollUserTasks(
2289
2304
  // when the instance is tracked or a per-kind fallback when it is orphaned. Returns `null` for a
2290
2305
  // non-escalation element (the leak guard) so an arbitrary internal user task can never reach the inbox.
2291
2306
  const contextFor = async (elementId: string, userTaskKey: string, processInstanceKey: string): Promise<UserTaskContext | null> => {
2292
- if (!Object.hasOwn(USER_TASK_KIND_LABELS, elementId)) return null;
2307
+ if (userTaskKindLabel(elementId) === undefined) return null;
2293
2308
  const subj = subjectByInstance.get(processInstanceKey);
2294
- const subjectType = subj?.type ?? DEFAULT_SUBJECT_TYPE[elementId] ?? "plan";
2309
+ // Orphaned-task fallback: the kind implies its aggregate even when no subject row references the
2310
+ // instance. A delivery-human node's id is inlined (`delivery-human-task__<node>`), so its bucket is
2311
+ // derived from the predicate rather than the static per-element table.
2312
+ const subjectType = subj?.type ?? DEFAULT_SUBJECT_TYPE[elementId] ?? (isDeliveryHumanElement(elementId) ? "delivery" : "plan");
2295
2313
  const subjectKey = subj?.key ?? processInstanceKey;
2296
2314
  let question: string | null = null;
2297
2315
  switch (elementId) {
package/biome.json CHANGED
@@ -137,5 +137,28 @@
137
137
  ],
138
138
  "formatter": {
139
139
  "enabled": false
140
- }
140
+ },
141
+ "overrides": [
142
+ {
143
+ "includes": [
144
+ "workers/readiness-probe/**"
145
+ ],
146
+ "linter": {
147
+ "rules": {
148
+ "style": {
149
+ "noRestrictedGlobals": {
150
+ "level": "error",
151
+ "options": {
152
+ "deniedGlobals": {
153
+ "setTimeout": "Use engine BPMN timers; readiness-probe must be single-shot.",
154
+ "setInterval": "Use engine BPMN timers; readiness-probe must be single-shot.",
155
+ "Date": "Use engine BPMN timers; readiness-probe must not read wall-clock time."
156
+ }
157
+ }
158
+ }
159
+ }
160
+ }
161
+ }
162
+ }
163
+ ]
141
164
  }
@@ -172,7 +172,7 @@ describe("delivery-graph runner — engine-native execution (S4)", () => {
172
172
  ],
173
173
  edges: [],
174
174
  };
175
- const run = await runDeliveryGraph(app.engine, graph, { probeTimeout: "PT2S", escalationSlaTimeout: "PT1H" });
175
+ const run = await runDeliveryGraph(app.engine, graph, { probeTimeout: "PT2S", probePollEvery: "PT1S", escalationSlaTimeout: "PT1H" });
176
176
  assert.ok(run.ok, `graph should deploy + run, got ${JSON.stringify(run)}`);
177
177
  await app.settle();
178
178
 
@@ -188,6 +188,7 @@ describe("delivery-graph runner — engine-native execution (S4)", () => {
188
188
  // wait is BOUNDED — its poll budget elapses and it escalates onto a human-completable task, parking
189
189
  // for a human rather than silently wedging or falsely resolving.
190
190
  assert.ok(!takenFlows(app).some((f) => f.endsWith("->End")), "the wait branch never falsely resolves to End");
191
+ await app.advanceTime(2_100);
191
192
  const esc = (await app.engine.searchUserTasks({ state: "CREATED" })).filter((t) => t.elementId?.endsWith("__esc"));
192
193
  assert.ok(
193
194
  esc.length >= 1,
@@ -77,6 +77,7 @@ function featureVars(overrides: Record<string, unknown>): Record<string, unknown
77
77
  customInstructions: null,
78
78
  readinessProbes: null,
79
79
  probeTimeout: null,
80
+ probePollEvery: null,
80
81
  gateKey: null,
81
82
  resolvedArtifacts: null,
82
83
  ...overrides,
@@ -129,6 +130,7 @@ describe("single-issue feature intake readiness gate (feature.bpmn, issue #295)"
129
130
  },
130
131
  ],
131
132
  probeTimeout: "PT30M",
133
+ probePollEvery: "PT15S",
132
134
  gateKey: "feature-readiness:owner/repo#7",
133
135
  }),
134
136
  });
@@ -66,6 +66,7 @@ function planVars(overrides: Record<string, unknown>): Record<string, unknown> {
66
66
  waveCount: 1,
67
67
  readinessProbes: null,
68
68
  probeTimeout: null,
69
+ probePollEvery: null,
69
70
  gateKey: null,
70
71
  resolvedArtifacts: null,
71
72
  ...overrides,
@@ -115,6 +116,7 @@ describe("inter-epic capability gate — adversarial (plan-fanout.bpmn, issue #2
115
116
  variables: planVars({
116
117
  readinessProbes: [redProbe()],
117
118
  probeTimeout: "PT2S",
119
+ probePollEvery: "PT1S",
118
120
  gateKey: "preflight:owner/repo#2",
119
121
  }),
120
122
  });
@@ -133,7 +135,8 @@ describe("inter-epic capability gate — adversarial (plan-fanout.bpmn, issue #2
133
135
  !flows.includes("readiness-preflight->ensure-base-branch"),
134
136
  "the gate HOLDS wave 0 — a parked dependent never reaches the fan-out head",
135
137
  );
136
- // The token is parked on the escalation user task, not lost.
138
+ await app.advanceTime(2_100);
139
+ // The token is parked on the escalation user task after the engine-owned timeout, not lost.
137
140
  const tasks = (await app.engine.searchUserTasks({ processInstanceKey })).filter(
138
141
  (t) => t.elementId === "readiness-escalation-pf",
139
142
  );
@@ -153,10 +156,12 @@ describe("inter-epic capability gate — adversarial (plan-fanout.bpmn, issue #2
153
156
  variables: planVars({
154
157
  readinessProbes: [redProbe()],
155
158
  probeTimeout: "PT2S",
159
+ probePollEvery: "PT1S",
156
160
  gateKey: "preflight:owner/repo#2",
157
161
  }),
158
162
  });
159
163
  await app.settle();
164
+ await app.advanceTime(2_100);
160
165
 
161
166
  const escalations = (await app.engine.searchUserTasks({ processInstanceKey })).filter(
162
167
  (t) => t.elementId === "readiness-escalation-pf",
@@ -196,6 +201,7 @@ describe("inter-epic capability gate — adversarial (plan-fanout.bpmn, issue #2
196
201
  }),
197
202
  });
198
203
  await app.settle();
204
+ await app.advanceTime(2_100);
199
205
 
200
206
  // Parked on the escalation, no human acts. Before the SLA it has NOT proceeded.
201
207
  const before = takenFlows(app);
@@ -65,6 +65,7 @@ function planVars(overrides: Record<string, unknown>): Record<string, unknown> {
65
65
  waveCount: 1,
66
66
  readinessProbes: null,
67
67
  probeTimeout: null,
68
+ probePollEvery: null,
68
69
  gateKey: null,
69
70
  resolvedArtifacts: null,
70
71
  ...overrides,
@@ -116,6 +117,7 @@ describe("plan-fanout inter-epic capability preflight (plan-fanout.bpmn, issue #
116
117
  },
117
118
  ],
118
119
  probeTimeout: "PT30M",
120
+ probePollEvery: "PT15S",
119
121
  gateKey: "preflight:owner/repo#2",
120
122
  }),
121
123
  });
@@ -18,14 +18,14 @@
18
18
  // The probes are deterministic shell builtins (`true`/`false`) so the flow is hermetic — no
19
19
  // network, no GitHub. GitHub transport is still forced offline to match the sibling e2es.
20
20
  import assert from "node:assert/strict";
21
- import { mkdtempSync, rmSync } from "node:fs";
22
- import { tmpdir } from "node:os";
21
+ import { mkdirSync, rmSync } from "node:fs";
23
22
  import { dirname, join, resolve } from "node:path";
24
23
  import { after, before, describe, test } from "node:test";
25
24
  import { fileURLToPath } from "node:url";
26
25
  import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
27
26
 
28
27
  const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
28
+ let dbSeq = 0;
29
29
 
30
30
  const GITHUB_ENV_OVERRIDES: Record<string, string> = {
31
31
  NANO_PR_GITHUB_TRANSPORT: "token",
@@ -49,7 +49,8 @@ function takenFlows(app: TestApp): string[] {
49
49
  // Each scenario boots its own app so `takenSequenceFlows` (engine-global + cumulative) reflects
50
50
  // exactly one instance's history.
51
51
  async function boot(): Promise<{ app: TestApp; dbDir: string }> {
52
- const dbDir = mkdtempSync(join(tmpdir(), "nwf-readiness-"));
52
+ const dbDir = join(APP_ROOT, ".test-artifacts", `nwf-readiness-${process.pid}-${dbSeq++}`);
53
+ mkdirSync(dbDir, { recursive: true });
53
54
  const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dbDir, "app.db")}` } });
54
55
  return { app, dbDir };
55
56
  }
@@ -79,6 +80,7 @@ describe("nano-workforce artifact-readiness wait-gate (readiness-gate.bpmn)", ()
79
80
  probe: { kind: "command", target: "true", poll: { everyMs: 5, timeoutMs: 5000, backoff: "fixed" } },
80
81
  // A long engine timer that must NOT fire — readiness wins the race first.
81
82
  probeTimeout: "PT30M",
83
+ probePollEvery: "PT15S",
82
84
  onTimeout: "escalate",
83
85
  },
84
86
  });
@@ -90,8 +92,8 @@ describe("nano-workforce artifact-readiness wait-gate (readiness-gate.bpmn)", ()
90
92
  `the gate released on the readiness signal (flows: ${flows.join(", ")})`,
91
93
  );
92
94
  assert.ok(
93
- flows.includes("probe->probe-done"),
94
- "the probe branch settled after publishing the readiness signal",
95
+ flows.includes("probe-loop->probe-done"),
96
+ "the probe loop branch settled after publishing the readiness signal",
95
97
  );
96
98
  // The gate never timed out — no escalation userTask exists.
97
99
  const tasks = await app.engine.searchUserTasks({});
@@ -115,15 +117,16 @@ describe("nano-workforce artifact-readiness wait-gate (readiness-gate.bpmn)", ()
115
117
  gateKey: "gate-timeout-1",
116
118
  // `false` is never ready; a tiny local budget makes the worker exhaust fast (real time),
117
119
  // leaving the ENGINE timer as the authoritative bound.
118
- probe: { kind: "command", target: "false", poll: { everyMs: 5, timeoutMs: 40, backoff: "fixed" } },
120
+ probe: { kind: "command", target: "false", poll: { everyMs: 15_000, timeoutMs: 60_000, backoff: "fixed" } },
119
121
  probeTimeout: "PT1M",
122
+ probePollEvery: "PT15S",
120
123
  onTimeout: "escalate",
121
124
  },
122
125
  });
123
126
  await app.settle();
124
127
 
125
- // The wait has NOT hung and has NOT yet escalated: the probe branch settled not-ready, and the
126
- // gate is parked on the timer catch — no escalation userTask before the timer's duration.
128
+ // The wait has NOT hung and has NOT yet escalated: the first single-shot probe returned not-ready,
129
+ // and the retry cadence is parked on the engine-owned poll timer.
127
130
  const beforeTimer = await app.engine.searchUserTasks({ processInstanceKey });
128
131
  assert.equal(
129
132
  beforeTimer.filter((t) => t.elementId === "readiness-escalation").length,
@@ -133,9 +136,18 @@ describe("nano-workforce artifact-readiness wait-gate (readiness-gate.bpmn)", ()
133
136
  const beforeFlows = takenFlows(app);
134
137
  assert.ok(!beforeFlows.includes("wait-ready->gate-ready"), "a never-green probe never releases as ready");
135
138
 
136
- // Advancing past the engine timer is the ONLY thing that ends the wait — proving the bound is
137
- // engine-owned. The token races off the timer catch onto the escalation userTask.
138
- await app.advanceTime(61_000);
139
+ await app.advanceTime(15_000);
140
+ const afterPoll = takenFlows(app);
141
+ assert.ok(afterPoll.includes("wait-poll->probe"), "the engine timer, not a worker sleep loop, schedules the next probe");
142
+ assert.equal(
143
+ (await app.engine.searchUserTasks({ processInstanceKey })).filter((t) => t.elementId === "readiness-escalation").length,
144
+ 0,
145
+ "one poll interval only re-probes; it does not consume the timeout",
146
+ );
147
+
148
+ // Advancing past the engine timer is the ONLY thing that ends the wait. The timeout arm routes
149
+ // through one last empirical probe before the event-based gateway timer opens escalation.
150
+ await app.advanceTime(46_000);
139
151
 
140
152
  const afterFlows = takenFlows(app);
141
153
  assert.ok(
@@ -171,6 +183,7 @@ describe("nano-workforce artifact-readiness wait-gate (readiness-gate.bpmn)", ()
171
183
  gateKey: "gate-abandon-1",
172
184
  probe: { kind: "command", target: "false", poll: { everyMs: 5, timeoutMs: 40, backoff: "fixed" } },
173
185
  probeTimeout: "PT1M",
186
+ probePollEvery: "PT15S",
174
187
  onTimeout: "escalate",
175
188
  },
176
189
  });
@@ -209,6 +222,7 @@ describe("nano-workforce artifact-readiness wait-gate (readiness-gate.bpmn)", ()
209
222
  gateKey: "gate-continue-1",
210
223
  probe: { kind: "command", target: "false", poll: { everyMs: 5, timeoutMs: 40, backoff: "fixed" } },
211
224
  probeTimeout: "PT1M",
225
+ probePollEvery: "PT15S",
212
226
  onTimeout: "continue",
213
227
  },
214
228
  });
@@ -236,6 +250,7 @@ describe("nano-workforce artifact-readiness wait-gate (readiness-gate.bpmn)", ()
236
250
  // Neither probe.onTimeout nor a top-level onTimeout is declared.
237
251
  probe: { kind: "command", target: "false", poll: { everyMs: 5, timeoutMs: 40, backoff: "fixed" } },
238
252
  probeTimeout: "PT1M",
253
+ probePollEvery: "PT15S",
239
254
  },
240
255
  });
241
256
  await app.settle();
@@ -266,6 +281,7 @@ describe("nano-workforce artifact-readiness wait-gate (readiness-gate.bpmn)", ()
266
281
  // The descriptor asks to continue; a stale top-level onTimeout says escalate. probe wins.
267
282
  probe: { kind: "command", target: "false", onTimeout: "continue", poll: { everyMs: 5, timeoutMs: 40, backoff: "fixed" } },
268
283
  probeTimeout: "PT1M",
284
+ probePollEvery: "PT15S",
269
285
  onTimeout: "escalate",
270
286
  },
271
287
  });
@@ -29,6 +29,9 @@ test("compile-delivery-graph: a well-formed graph → 200 with the pure preview"
29
29
  assertEquals(res.status, 200);
30
30
  assertEquals(res.body.ok, true);
31
31
  assert(typeof res.body.bpmn === "string" && res.body.bpmn.length > 0);
32
+ // The compile preview must show what actually deploys — including the auto-laid-out diagram
33
+ // interchange (#440), so the process explorer can render the previewed graph.
34
+ assert(res.body.bpmn.includes("<bpmndi:BPMNDiagram"), "the previewed bpmn carries diagram interchange");
32
35
  assert(typeof res.body.diagram === "string" && res.body.diagram.length > 0);
33
36
  assertEquals(res.body.resolved.nodes.length, 2);
34
37
  assertEquals(res.body.humanNodes.length, 1);
@@ -20,7 +20,7 @@ export default defineOperation("compileDeliveryGraph", async ({ body }, app) =>
20
20
  // the SEMANTIC checks (acyclicity, edge integrity, fact resolution) the schema cannot express. A
21
21
  // directly-invoked delegate could still pass `undefined` — the compiler reads its input as
22
22
  // `unknown` and maps that to a clean `ok:false`, never a 500.
23
- const result = compileDeliveryGraph(body);
23
+ const result = await compileDeliveryGraph(body);
24
24
  if (!result.ok) {
25
25
  app.log.warn("compile-delivery-graph rejected", { errors: result.errors.length });
26
26
  return { status: 400, body: result };
@@ -37,7 +37,7 @@ export default defineOperation("dispatchDeliveryGraph", async (input, app) => {
37
37
  // dispatches. A compile failure here surfaces as a clean 400 rather than reaching the start door.
38
38
  let approvalToken: string | undefined;
39
39
  if (approve) {
40
- const compiled = compileDeliveryGraph(parsed.graph);
40
+ const compiled = await compileDeliveryGraph(parsed.graph);
41
41
  if (!compiled.ok) {
42
42
  app.log.warn("dispatch-delivery-graph rejected: compile", { errors: compiled.errors.length });
43
43
  return {
@@ -22,7 +22,7 @@ export default defineOperation("previewDeliveryGraph", async ({ body }, app) =>
22
22
  app.log.warn("preview-delivery-graph rejected: parse", { message: parsed.error });
23
23
  return { status: 400, body: { ok: false, error: parsed.error } };
24
24
  }
25
- const compiled = compileDeliveryGraph(parsed.graph);
25
+ const compiled = await compileDeliveryGraph(parsed.graph);
26
26
  if (!compiled.ok) {
27
27
  app.log.warn("preview-delivery-graph rejected: compile", { errors: compiled.errors.length });
28
28
  return {
@@ -56,7 +56,7 @@ export default defineOperation("startDeliveryGraph", async ({ body }, app) => {
56
56
 
57
57
  // 2) Compile via S1. This yields the deterministic BPMN (→ the content digest / approval token) plus
58
58
  // the graph's shape: its side effects (whether approval is required), human stops, and node count.
59
- const compiled = compileDeliveryGraph(graph);
59
+ const compiled = await compileDeliveryGraph(graph);
60
60
  if (!compiled.ok) {
61
61
  app.log.warn("start-delivery-graph rejected: compile", { count: compiled.errors.length });
62
62
  return { status: 400, body: { ok: false, errors: compiled.errors } };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.120.0",
3
+ "version": "0.120.2",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -59,7 +59,8 @@
59
59
  },
60
60
  "dependencies": {
61
61
  "@nanobpm/agentic": "^0.1.0",
62
- "@nanobpm/urban": "^0.75.0"
62
+ "@nanobpm/urban": "^0.75.0",
63
+ "bpmn-auto-layout": "^2.0.0-alpha.2"
63
64
  },
64
65
  "devDependencies": {
65
66
  "@biomejs/biome": "^2.4.11",