@nanobpm/nano-workforce 0.133.1 → 0.134.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 CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.134.0](https://github.com/nanobpm/nano-workforce/compare/v0.133.1...v0.134.0) (2026-08-24)
2
+
3
+ ### Features
4
+
5
+ * **delivery-graph:** node timeout PT1H default, submission + per-node override ([#505](https://github.com/nanobpm/nano-workforce/issues/505)) ([#507](https://github.com/nanobpm/nano-workforce/issues/507)) ([96fe961](https://github.com/nanobpm/nano-workforce/commit/96fe96165bb898919f7000002e68cb4ba5a925e9))
6
+
1
7
  ## [0.133.1](https://github.com/nanobpm/nano-workforce/compare/v0.133.0...v0.133.1) (2026-08-24)
2
8
 
3
9
  ### Bug Fixes
@@ -23,6 +23,7 @@ import {
23
23
  DELIVERY_PHASE,
24
24
  deliveryGraphRuns,
25
25
  } from "./deliveryGraphRun.ts";
26
+ import type { DeliveryRunTimeouts } from "./deliveryRunner.ts";
26
27
  import { deliveryGraphDigest, runDeliveryGraph } from "./deliveryRunner.ts";
27
28
 
28
29
  /** The outcome of a dispatch attempt — mirrors the retained run lifecycle. `ok:false` carries the
@@ -48,7 +49,7 @@ export type DispatchDeliveryGraphResult =
48
49
  export async function dispatchDeliveryGraphRun(
49
50
  app: Pick<AppApi, "data" | "engine" | "log">,
50
51
  graph: unknown,
51
- options: { runKey?: string | null; title?: string | null } = {},
52
+ options: { runKey?: string | null; title?: string | null } & DeliveryRunTimeouts = {},
52
53
  ): Promise<DispatchDeliveryGraphResult> {
53
54
  const validationErrors = validateDeliveryGraph(graph);
54
55
  if (validationErrors.length > 0) {
@@ -130,7 +131,16 @@ export async function dispatchDeliveryGraphRun(
130
131
  };
131
132
  let launched: Awaited<ReturnType<typeof runDeliveryGraph>>;
132
133
  try {
133
- launched = await runDeliveryGraph(app.engine, typedGraph, { runKey });
134
+ // Thread the operator-supplied run-level timeouts (#505) so a submission override reaches every
135
+ // node's seeded `nodeInputs` (absent → the runner's PT1H/PT30M/P1D defaults).
136
+ launched = await runDeliveryGraph(app.engine, typedGraph, {
137
+ runKey,
138
+ nodeTimeout: options.nodeTimeout,
139
+ probeTimeout: options.probeTimeout,
140
+ escalationSlaTimeout: options.escalationSlaTimeout,
141
+ probePollEvery: options.probePollEvery,
142
+ escalationAssignee: options.escalationAssignee,
143
+ });
134
144
  } catch (err) {
135
145
  await markClaimFailed();
136
146
  app.log.error("dispatch-delivery-graph launch threw", { runKey });
@@ -138,6 +138,93 @@ test("wait gateKeys default to a fresh per-run token so concurrent runs of one g
138
138
  assertEquals(gateKeyOf(seeded), "run-7:n3");
139
139
  });
140
140
 
141
+ test("the node timeout defaults to PT1H (raised from PT30M) when no option is supplied (#505)", async () => {
142
+ // #505: the hard PT30M default tripped the boundary timer on legitimately-long implementation nodes.
143
+ // With no timeout option, every agent/connector node inherits the NEW PT1H run default.
144
+ const p = await prepareOk(GRAPH);
145
+ const timeouts = Object.values(p.nodeInputs)
146
+ .filter((v) => "timeout" in v)
147
+ .map((v) => (v as { timeout: string }).timeout);
148
+ assert(timeouts.length === 2, `expected the agent + connector nodes to seed a timeout, got ${timeouts.length}`);
149
+ for (const t of timeouts) assertEquals(t, "PT1H");
150
+ });
151
+
152
+ test("a submission nodeTimeout override seeds every agent/connector node with that duration (#505)", async () => {
153
+ // AC: an operator dispatch that sets nodeTimeout: "PT2H" seeds PT2H for ALL agent/connector nodes.
154
+ const p = await prepareOk(GRAPH, { nodeTimeout: "PT2H" });
155
+ const timeouts = Object.values(p.nodeInputs)
156
+ .filter((v) => "timeout" in v)
157
+ .map((v) => (v as { timeout: string }).timeout);
158
+ assert(timeouts.length === 2, `expected two seeded node timeouts, got ${timeouts.length}`);
159
+ for (const t of timeouts) assertEquals(t, "PT2H");
160
+ });
161
+
162
+ test("a per-node timeout override wins for its node while siblings keep the run/default value (#505)", async () => {
163
+ // AC: a node declaring timeout: "PT4H" seeds nodeInputs.<el>.timeout == "PT4H" while its siblings keep
164
+ // the run-level (here PT2H) value. Asserted positionally on the compiled nodeInputs map.
165
+ const graph: DeliveryGraph = {
166
+ name: "per-node override",
167
+ nodes: [
168
+ { id: "heavy", kind: "agent", agent: { jobType: "senior:feature", prompt: "long build", timeout: "PT4H" } },
169
+ { id: "quick", kind: "agent", agent: { jobType: "senior:demo" } },
170
+ { id: "notify", kind: "connector", connector: { target: "slack:post", dedupeKey: "n-1", timeout: "PT10M" } },
171
+ ],
172
+ edges: [
173
+ { from: "heavy", to: "quick" },
174
+ { from: "quick", to: "notify" },
175
+ ],
176
+ };
177
+ const p = await prepareOk(graph, { nodeTimeout: "PT2H" });
178
+ const byJobType = (jt: string) =>
179
+ Object.values(p.nodeInputs).find((v) => (v as { jobType?: string }).jobType === jt) as { timeout: string } | undefined;
180
+ const connector = Object.values(p.nodeInputs).find((v) => (v as { target?: string }).target === "slack:post") as
181
+ | { timeout: string }
182
+ | undefined;
183
+
184
+ assertEquals(byJobType("senior:feature")?.timeout, "PT4H"); // per-node override wins
185
+ assertEquals(byJobType("senior:demo")?.timeout, "PT2H"); // sibling keeps the run-level value
186
+ assertEquals(connector?.timeout, "PT10M"); // connector per-node override wins too
187
+ });
188
+
189
+ test("a per-node timeout is normalized (lower-case → canonical) and a malformed one falls back to the run value (#505)", async () => {
190
+ // A graph built programmatically (bypassing the OpenAPI pattern) can carry a lower-case or malformed
191
+ // per-node duration. The runner normalizes it through `isoDuration` so a bad value never bakes an
192
+ // uninterpretable boundary timer: `pt4h` → `PT4H`, and `nonsense` falls back to the run-level default.
193
+ const graph: DeliveryGraph = {
194
+ name: "per-node normalization",
195
+ nodes: [
196
+ { id: "lower", kind: "agent", agent: { jobType: "senior:feature", timeout: "pt4h" } },
197
+ { id: "bad", kind: "connector", connector: { target: "slack:post", dedupeKey: "n-1", timeout: "nonsense" } },
198
+ ],
199
+ edges: [{ from: "lower", to: "bad" }],
200
+ } as unknown as DeliveryGraph;
201
+ const p = await prepareOk(graph, { nodeTimeout: "PT2H" });
202
+ const agent = Object.values(p.nodeInputs).find((v) => (v as { jobType?: string }).jobType === "senior:feature") as
203
+ | { timeout: string }
204
+ | undefined;
205
+ const connector = Object.values(p.nodeInputs).find((v) => (v as { target?: string }).target === "slack:post") as
206
+ | { timeout: string }
207
+ | undefined;
208
+
209
+ assertEquals(agent?.timeout, "PT4H"); // lower-case normalized to canonical form
210
+ assertEquals(connector?.timeout, "PT2H"); // malformed value rejected → run-level default
211
+ });
212
+
213
+ test("a RUN-LEVEL timeout is normalized (lower-case → canonical) and a malformed one falls back to the default (#505)", async () => {
214
+ // A programmatic caller of prepareDeliveryGraph/runDeliveryGraph bypasses the OpenAPI/door validators,
215
+ // so a lower-case or malformed run-level `nodeTimeout` must not become the fallback baked into a node's
216
+ // boundary timer FEEL. isoDuration canonicalizes it (`pt3h` → `PT3H`) at the run level too, and a
217
+ // malformed value falls back to the DEFAULTS run value rather than an uninterpretable duration.
218
+ const lower = await prepareOk(GRAPH, { nodeTimeout: "pt3h" });
219
+ for (const v of Object.values(lower.nodeInputs).filter((v) => "timeout" in v)) {
220
+ assertEquals((v as { timeout: string }).timeout, "PT3H"); // lower-case run value normalized
221
+ }
222
+ const bad = await prepareOk(GRAPH, { nodeTimeout: "nonsense" });
223
+ for (const v of Object.values(bad.nodeInputs).filter((v) => "timeout" in v)) {
224
+ assertEquals((v as { timeout: string }).timeout, "PT1H"); // malformed run value → PT1H default, never baked raw
225
+ }
226
+ });
227
+
141
228
  test("a malformed graph returns the S1 compile errors and prepares nothing", async () => {
142
229
  const r = await prepareDeliveryGraph({ nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" } }], edges: [{ from: "a", to: "ghost" }] } as unknown as DeliveryGraph);
143
230
  assert(!r.ok, "a dangling edge fails to prepare");
@@ -18,6 +18,7 @@ import type { EngineClient } from "@nanobpm/urban";
18
18
  import type { DeliveryFact, DeliveryGraph, DeliveryNode } from "../nano-generated/api-io.d.ts";
19
19
  import { assertNever, compileDeliveryGraph, DELIVERY_GRAPH_PROCESS_ID } from "./deliveryGraphCompiler.ts";
20
20
  import { DEFAULT_EVERY_MS, msToIsoDuration, parseProbe, readinessPollEvery } from "./readiness.ts";
21
+ import { isoDuration } from "./reviewWait.ts";
21
22
 
22
23
  /** The content digest of a compiled graph — `sha256(bpmn)[:12]` — the single source of truth for the
23
24
  * content-addressed deploy id (`delivery-graph-<digest>`) AND the dispatch fence's default idempotency
@@ -53,7 +54,7 @@ export interface DeliveryRunOptions extends DeliveryRunTimeouts {
53
54
  }
54
55
 
55
56
  const DEFAULTS: Required<Omit<DeliveryRunTimeouts, "escalationAssignee">> = {
56
- nodeTimeout: "PT30M",
57
+ nodeTimeout: "PT1H",
57
58
  probeTimeout: "PT30M",
58
59
  probePollEvery: msToIsoDuration(DEFAULT_EVERY_MS),
59
60
  escalationSlaTimeout: "P1D",
@@ -110,11 +111,14 @@ export async function prepareDeliveryGraph(
110
111
  const bpmn = rewriteProcessId(compiled.bpmn, processDefinitionId);
111
112
 
112
113
  const runKey = options.runKey?.trim() || randomUUID();
114
+ // Normalize the run-level timeouts through isoDuration so a programmatic caller that bypasses the
115
+ // OpenAPI/door validators cannot bake a malformed or lower-case duration into a BPMN timer FEEL —
116
+ // isoDuration canonicalizes case and falls back to the default on a malformed/blank value.
113
117
  const timeouts = {
114
- nodeTimeout: options.nodeTimeout ?? DEFAULTS.nodeTimeout,
115
- probeTimeout: options.probeTimeout ?? DEFAULTS.probeTimeout,
116
- probePollEvery: options.probePollEvery ?? DEFAULTS.probePollEvery,
117
- escalationSlaTimeout: options.escalationSlaTimeout ?? DEFAULTS.escalationSlaTimeout,
118
+ nodeTimeout: isoDuration(options.nodeTimeout, DEFAULTS.nodeTimeout),
119
+ probeTimeout: isoDuration(options.probeTimeout, DEFAULTS.probeTimeout),
120
+ probePollEvery: isoDuration(options.probePollEvery, DEFAULTS.probePollEvery),
121
+ escalationSlaTimeout: isoDuration(options.escalationSlaTimeout, DEFAULTS.escalationSlaTimeout),
118
122
  escalationAssignee: options.escalationAssignee ?? null,
119
123
  };
120
124
  const elementByNodeId = new Map(compiled.resolved.nodes.map((n) => [n.id, n.element]));
@@ -174,7 +178,7 @@ function buildNodeInput(
174
178
  ): NodeInput {
175
179
  switch (node.kind) {
176
180
  case "agent":
177
- return { jobType: node.agent.jobType, appendPrompt: node.agent.prompt ?? "", timeout: ctx.nodeTimeout };
181
+ return { jobType: node.agent.jobType, appendPrompt: node.agent.prompt ?? "", timeout: isoDuration(node.agent.timeout, ctx.nodeTimeout) };
178
182
  case "wait": {
179
183
  const probe = parseProbe(node.wait);
180
184
  return {
@@ -201,7 +205,7 @@ function buildNodeInput(
201
205
  target: node.connector.target,
202
206
  dedupeKey: node.connector.dedupeKey ?? null,
203
207
  payload: node.connector.payload ?? null,
204
- timeout: ctx.nodeTimeout,
208
+ timeout: isoDuration(node.connector.timeout, ctx.nodeTimeout),
205
209
  };
206
210
  default:
207
211
  return assertNever(node, "buildNodeInput");
package/app/reviewWait.ts CHANGED
@@ -20,6 +20,14 @@ export const DEFAULT_REVIEW_WAIT_TIMEOUT = "PT20M";
20
20
  // would fail to interpret; not a full grammar (we don't need fractional seconds here).
21
21
  const ISO_DURATION = /^P(?!$)(\d+Y)?(\d+M)?(\d+W)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+S)?)?$/;
22
22
 
23
+ /** True when `raw` is a well-formed ISO-8601 duration under {@link isoDuration}'s grammar — the strict
24
+ * predicate an operator submission door uses to REJECT a malformed duration (400) rather than silently
25
+ * fall back to a default. Derives from the same {@link ISO_DURATION} grammar so the accept/reject
26
+ * decision can never drift from the normalise-or-default one. Case-insensitive (`pt2h` is valid). */
27
+ export function isValidIsoDuration(raw: string): boolean {
28
+ return ISO_DURATION.test(raw.trim().toUpperCase());
29
+ }
30
+
23
31
  /** Validate an ISO-8601 duration string for a BPMN timer's `<bpmn:timeDuration>`, falling back to
24
32
  * `def` when the value is absent, blank, or malformed — a bad env value must never deploy an
25
33
  * uninterpretable timer expression into a process. Normalises to upper case (`pt20m` → `PT20M`).
package/openapi.yaml CHANGED
@@ -1407,6 +1407,15 @@ components:
1407
1407
  type: string
1408
1408
  maxLength: 20000
1409
1409
  description: OPTIONAL steering prompt appended to the node's job brief.
1410
+ timeout:
1411
+ type: string
1412
+ pattern: '^[Pp](?!$)(\d+[Yy])?(\d+[Mm])?(\d+[Ww])?(\d+[Dd])?([Tt](?=\d)(\d+[Hh])?(\d+[Mm])?(\d+[Ss])?)?$'
1413
+ maxLength: 64
1414
+ description: >-
1415
+ OPTIONAL per-node ISO-8601 SLA timeout (#505). Overrides the run-level `nodeTimeout`
1416
+ (and the `PT1H` default) for THIS node's bounded-timeout → escalate boundary timer, so
1417
+ a legitimately-long node (e.g. a full `senior:feature` implementation) can outlast a
1418
+ quick gate without a spurious escalation. Absent → the run/default value.
1410
1419
  DeliveryNodeWait:
1411
1420
  description: >-
1412
1421
  A `wait` node — a durable `ReadinessProbe` (ADR 0001 §2) watching an external fact. Reuses the
@@ -1496,6 +1505,14 @@ components:
1496
1505
  type: object
1497
1506
  additionalProperties: true
1498
1507
  description: Minimal forward-declared payload stub — the concrete connector payload schema is deferred (ADR non-goal).
1508
+ timeout:
1509
+ type: string
1510
+ pattern: '^[Pp](?!$)(\d+[Yy])?(\d+[Mm])?(\d+[Ww])?(\d+[Dd])?([Tt](?=\d)(\d+[Hh])?(\d+[Mm])?(\d+[Ss])?)?$'
1511
+ maxLength: 64
1512
+ description: >-
1513
+ OPTIONAL per-node ISO-8601 SLA timeout (#505). Overrides the run-level `nodeTimeout`
1514
+ (and the `PT1H` default) for THIS connector node's bounded-timeout → escalate boundary
1515
+ timer. Absent → the run/default value.
1499
1516
  DeliveryEdge:
1500
1517
  description: >-
1501
1518
  A dependency edge — "`to` proceeds once fact `from` is observable" (ADR 0005 Decision 3).
@@ -1596,6 +1613,28 @@ components:
1596
1613
  type: string
1597
1614
  maxLength: 255
1598
1615
  description: OPTIONAL idempotency key. A re-dispatch with the same key (or, when omitted, the same digest) does not double-launch. Blank/whitespace is treated as absent.
1616
+ nodeTimeout:
1617
+ type: string
1618
+ pattern: '^[Pp](?!$)(\d+[Yy])?(\d+[Mm])?(\d+[Ww])?(\d+[Dd])?([Tt](?=\d)(\d+[Hh])?(\d+[Mm])?(\d+[Ss])?)?$'
1619
+ maxLength: 64
1620
+ description: >-
1621
+ OPTIONAL run-level ISO-8601 SLA timeout for `agent`/`connector` nodes (#505) — the
1622
+ bounded-timeout → escalate boundary bound every such node inherits unless it declares its own
1623
+ per-node `timeout`. Absent → the `PT1H` default. An invalid duration is rejected at submit.
1624
+ probeTimeout:
1625
+ type: string
1626
+ pattern: '^[Pp](?!$)(\d+[Yy])?(\d+[Mm])?(\d+[Ww])?(\d+[Dd])?([Tt](?=\d)(\d+[Hh])?(\d+[Mm])?(\d+[Ss])?)?$'
1627
+ maxLength: 64
1628
+ description: >-
1629
+ OPTIONAL run-level ISO-8601 poll budget for `wait` gates (#505) before they escalate. Absent →
1630
+ the `PT30M` default. An invalid duration is rejected at submit.
1631
+ escalationSlaTimeout:
1632
+ type: string
1633
+ pattern: '^[Pp](?!$)(\d+[Yy])?(\d+[Mm])?(\d+[Ww])?(\d+[Dd])?([Tt](?=\d)(\d+[Hh])?(\d+[Mm])?(\d+[Ss])?)?$'
1634
+ maxLength: 64
1635
+ description: >-
1636
+ OPTIONAL run-level ISO-8601 SLA for `human` nodes (#505) before they record an `escalated`
1637
+ outcome. Absent → the `P1D` default. An invalid duration is rejected at submit.
1599
1638
  DeliveryGraphProposalBpmnRequest:
1600
1639
  description: >-
1601
1640
  Request the compiled BPMN of a staged delivery-graph proposal for read-only DI PREVIEW. Carries
@@ -182,4 +182,69 @@ describe("dispatchDeliveryGraph — operator dispatch by staged-proposal digest"
182
182
  assert.equal((await deliveryGraphProposals(app.db).get(digest))?.status, "expired");
183
183
  assert.equal((await deliveryGraphRuns(app.db).all()).length, 0);
184
184
  });
185
+
186
+ test("an invalid run-level nodeTimeout duration is rejected at submit → 400, nothing launched (#505)", async () => {
187
+ const app = await boot();
188
+ assert.ok(app.api);
189
+ const api = app.api;
190
+ const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
191
+ const res = await api.call<{ ok?: boolean; error?: string; issues?: Array<{ path: string }> }>("dispatchDeliveryGraph", {
192
+ body: { digest: staged.body.digest, nodeTimeout: "2 hours" },
193
+ });
194
+ // Rejected at submit — either by the edge shape-validator (openapi `pattern`) or the door's own
195
+ // ISO-8601 guard; both surface a 400. Nothing launches and the proposal stays staged (dispatchable).
196
+ assert.equal(res.status, 400);
197
+ assert.equal((await deliveryGraphRuns(app.db).all()).length, 0);
198
+ assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "staged");
199
+ });
200
+
201
+ test("an oversized invalid duration never bloats the 400 response — rejected with a bounded error, nothing launched (#505)", async () => {
202
+ const app = await boot();
203
+ assert.ok(app.api);
204
+ const api = app.api;
205
+ const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
206
+ const huge = `PT${"9".repeat(5000)}X`;
207
+ const res = await api.call<{ ok?: boolean; error?: string }>("dispatchDeliveryGraph", {
208
+ body: { digest: staged.body.digest, nodeTimeout: huge },
209
+ });
210
+ assert.equal(res.status, 400);
211
+ // The 5000-char blob is never echoed back verbatim — the edge pattern rejects it, and the door's
212
+ // own guard (`truncateForEcho`) caps the echo when the edge is bypassed. Either way the response
213
+ // stays bounded, so a malformed input can't bloat logs/response bodies.
214
+ assert.ok((res.body.error ?? "").length < 300, `error body should be bounded, got ${(res.body.error ?? "").length} chars`);
215
+ assert.equal((await deliveryGraphRuns(app.db).all()).length, 0);
216
+ });
217
+
218
+ test("a syntactically-valid but oversized duration is rejected at the door → 400, nothing launched (#505)", async () => {
219
+ const app = await boot();
220
+ assert.ok(app.api);
221
+ const api = app.api;
222
+ const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
223
+ // Matches the ISO-8601 grammar but exceeds the door's MAX_DURATION_LEN (64) — the door re-enforces the
224
+ // openapi `maxLength: 64` so an oversized value is refused even if the edge validator is bypassed.
225
+ const longValid = `PT${"9".repeat(70)}H`;
226
+ const res = await api.call<{ ok?: boolean; error?: string }>("dispatchDeliveryGraph", {
227
+ body: { digest: staged.body.digest, nodeTimeout: longValid },
228
+ });
229
+ assert.equal(res.status, 400);
230
+ assert.ok((res.body.error ?? "").length < 300, `error body should be bounded, got ${(res.body.error ?? "").length} chars`);
231
+ assert.equal((await deliveryGraphRuns(app.db).all()).length, 0);
232
+ assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "staged");
233
+ });
234
+
235
+ test("a valid run-level nodeTimeout override dispatches the run → 202 running (#505)", async () => {
236
+ const app = await boot();
237
+ assert.ok(app.api);
238
+ const api = app.api;
239
+ const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
240
+ const res = await api.call<{ ok: boolean; status: string }>("dispatchDeliveryGraph", {
241
+ body: { digest: staged.body.digest, nodeTimeout: "PT2H" },
242
+ });
243
+ assert.equal(res.status, 202);
244
+ assert.equal(res.body.ok, true);
245
+ assert.equal(res.body.status, "running");
246
+ await app.settle();
247
+ assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
248
+ assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "dispatched");
249
+ });
185
250
  });
@@ -12,9 +12,39 @@
12
12
 
13
13
  import { dispatchDeliveryGraphRun } from "../app/deliveryGraphDispatch.ts";
14
14
  import { getStagedProposal, markProposalDispatched, markProposalExpired } from "../app/deliveryGraphProposals.ts";
15
+ import { isValidIsoDuration } from "../app/reviewWait.ts";
15
16
  import type { DeliveryGraphTextResult } from "../nano-generated/api-io.d.ts";
16
17
  import { defineOperation } from "../nano-generated/operations.ts";
17
18
 
19
+ /** Cap an untrusted, rejected duration string before it is echoed into logs/response bodies. `openapi.yaml`
20
+ * caps these dispatch duration fields at `maxLength: 64` at the edge, and the door re-enforces that bound
21
+ * (see `MAX_DURATION_LEN`); this truncation is defense-in-depth for when the edge validator is bypassed,
22
+ * so a very large malformed value can never bloat either the logs or the response. */
23
+ const MAX_ECHO_LEN = 80;
24
+ function truncateForEcho(value: string): string {
25
+ return value.length > MAX_ECHO_LEN ? `${value.slice(0, MAX_ECHO_LEN)}… (${value.length} chars)` : value;
26
+ }
27
+
28
+ /** Door-level cap on a duration override, mirroring the `maxLength: 64` on these fields in `openapi.yaml`.
29
+ * Re-enforced here so a syntactically-valid-but-oversized duration is still rejected when the edge
30
+ * validator is bypassed (internal calls/tests), keeping seeded process variables and error/log output bounded. */
31
+ const MAX_DURATION_LEN = 64;
32
+
33
+ /** Validate an OPTIONAL run-level ISO-8601 duration override off the dispatch body (#505). Blank/
34
+ * whitespace is treated as absent (→ the runner default). A present-but-malformed value returns
35
+ * `{ ok: false, invalid }` so the door can reject it at submit rather than silently deploy an
36
+ * uninterpretable timer. Reuses the canonical `reviewWait` grammar so accept/reject never drifts from
37
+ * the runner's normalise-or-default one, and enforces `MAX_DURATION_LEN` so an oversized value is
38
+ * rejected even if the OpenAPI `maxLength` edge check is bypassed. */
39
+ function validateDurationOverride(raw: unknown): { ok: true; value: string | undefined } | { ok: false; invalid: string } {
40
+ if (raw === undefined || raw === null) return { ok: true, value: undefined };
41
+ if (typeof raw !== "string") return { ok: false, invalid: String(raw) };
42
+ const trimmed = raw.trim();
43
+ if (trimmed === "") return { ok: true, value: undefined };
44
+ if (trimmed.length > MAX_DURATION_LEN || !isValidIsoDuration(trimmed)) return { ok: false, invalid: trimmed };
45
+ return { ok: true, value: trimmed.toUpperCase() };
46
+ }
47
+
18
48
  export default defineOperation("dispatchDeliveryGraph", async ({ body }, app) => {
19
49
  const digest = body && typeof body === "object" && "digest" in body && typeof body.digest === "string" ? body.digest.trim() : "";
20
50
  if (digest === "") {
@@ -24,6 +54,27 @@ export default defineOperation("dispatchDeliveryGraph", async ({ body }, app) =>
24
54
  const idemRaw = body && typeof body === "object" && "idempotencyKey" in body && typeof body.idempotencyKey === "string" ? body.idempotencyKey.trim() : "";
25
55
  const idempotencyKey = idemRaw !== "" ? idemRaw : undefined;
26
56
 
57
+ // Run-level timeout overrides (#505) — exposed at submission, validated as ISO-8601 durations here so
58
+ // an invalid value is a clean 400 (never a deployed, uninterpretable timer). Absent → runner defaults.
59
+ const rawTimeouts: Record<"nodeTimeout" | "probeTimeout" | "escalationSlaTimeout", unknown> =
60
+ body && typeof body === "object"
61
+ ? {
62
+ nodeTimeout: "nodeTimeout" in body ? body.nodeTimeout : undefined,
63
+ probeTimeout: "probeTimeout" in body ? body.probeTimeout : undefined,
64
+ escalationSlaTimeout: "escalationSlaTimeout" in body ? body.escalationSlaTimeout : undefined,
65
+ }
66
+ : { nodeTimeout: undefined, probeTimeout: undefined, escalationSlaTimeout: undefined };
67
+ const timeouts: { nodeTimeout?: string; probeTimeout?: string; escalationSlaTimeout?: string } = {};
68
+ for (const field of ["nodeTimeout", "probeTimeout", "escalationSlaTimeout"] as const) {
69
+ const parsed = validateDurationOverride(rawTimeouts[field]);
70
+ if (!parsed.ok) {
71
+ const shown = truncateForEcho(parsed.invalid);
72
+ app.log.warn("dispatch-delivery-graph rejected: invalid duration", { field, value: shown, invalidLength: parsed.invalid.length });
73
+ return { status: 400, body: { ok: false, error: `\`${field}\` must be an ISO-8601 duration (e.g. \`PT2H\`); got \`${shown}\`` } };
74
+ }
75
+ if (parsed.value !== undefined) timeouts[field] = parsed.value;
76
+ }
77
+
27
78
  // Load the live staged proposal for this digest — refuses an unknown/expired/superseded/already-
28
79
  // dispatched digest cleanly (no run is launched).
29
80
  const proposal = await getStagedProposal(app.data, digest);
@@ -47,7 +98,7 @@ export default defineOperation("dispatchDeliveryGraph", async ({ body }, app) =>
47
98
  return { status: 400, body: { ok: false, error: `staged proposal ${digest} is corrupt: ${err instanceof Error ? err.message : String(err)}` } };
48
99
  }
49
100
 
50
- const dispatched = await dispatchDeliveryGraphRun(app, graph, { runKey: idempotencyKey, title: proposal.title });
101
+ const dispatched = await dispatchDeliveryGraphRun(app, graph, { runKey: idempotencyKey, title: proposal.title, ...timeouts });
51
102
  if (!dispatched.ok) {
52
103
  app.log.warn("dispatch-delivery-graph refused: compile", { digest, errors: dispatched.errors.length });
53
104
  const outBody: DeliveryGraphTextResult = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.133.1",
3
+ "version": "0.134.0",
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",