@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 +6 -0
- package/app/deliveryGraphDispatch.ts +12 -2
- package/app/deliveryRunner.test.ts +87 -0
- package/app/deliveryRunner.ts +11 -7
- package/app/reviewWait.ts +8 -0
- package/openapi.yaml +39 -0
- package/operations/dispatchDeliveryGraph.test.ts +65 -0
- package/operations/dispatchDeliveryGraph.ts +52 -1
- package/package.json +1 -1
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
|
-
|
|
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");
|
package/app/deliveryRunner.ts
CHANGED
|
@@ -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: "
|
|
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
|
|
115
|
-
probeTimeout: options.probeTimeout
|
|
116
|
-
probePollEvery: options.probePollEvery
|
|
117
|
-
escalationSlaTimeout: options.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.
|
|
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",
|