@nanobpm/nano-workforce 0.139.3 → 0.139.4
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/deliveryGraph.test.ts +13 -0
- package/app/deliveryGraph.ts +17 -1
- package/app/deliveryGraphCompiler.test.ts +42 -0
- package/app/deliveryGraphCompiler.ts +23 -12
- package/app/deliveryRunner.test.ts +45 -0
- package/app/deliveryRunner.ts +17 -3
- package/openapi.yaml +8 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [0.139.4](https://github.com/nanobpm/nano-workforce/compare/v0.139.3...v0.139.4) (2026-08-25)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **delivery-graph:** honor a wait node's per-node poll.timeoutMs + onTimeout ([#462](https://github.com/nanobpm/nano-workforce/issues/462)) ([#545](https://github.com/nanobpm/nano-workforce/issues/545)) ([f91d456](https://github.com/nanobpm/nano-workforce/commit/f91d4561736e0d7b4bbcaf55a7a4ff61a602b82d)), closes [Magikcraft/nano-bpm#978](https://github.com/Magikcraft/nano-bpm/issues/978)
|
|
6
|
+
|
|
1
7
|
## [0.139.3](https://github.com/nanobpm/nano-workforce/compare/v0.139.2...v0.139.3) (2026-08-25)
|
|
2
8
|
|
|
3
9
|
### Documentation
|
|
@@ -718,3 +718,16 @@ test("S7 single-target guarded fan-out is NOT an exclusive split — a node whos
|
|
|
718
718
|
});
|
|
719
719
|
assertEquals(errors, []);
|
|
720
720
|
});
|
|
721
|
+
|
|
722
|
+
test("a wait node's onTimeout: fail is rejected (unsupported-on-timeout) while continue/escalate validate (#462)", () => {
|
|
723
|
+
const waitWith = (onTimeout: string) => ({
|
|
724
|
+
name: "onTimeout",
|
|
725
|
+
nodes: [{ id: "g", kind: "wait", wait: { kind: "pr", target: "acme/repo#1", match: { prState: "merged" }, onTimeout } }],
|
|
726
|
+
edges: [],
|
|
727
|
+
});
|
|
728
|
+
const err = hasCode(validateDeliveryGraph(waitWith("fail")), "unsupported-on-timeout");
|
|
729
|
+
assertEquals(err.path, "nodes[0].wait.onTimeout");
|
|
730
|
+
// continue + escalate are honored — they must NOT raise the unsupported-on-timeout error.
|
|
731
|
+
assertEquals(validateDeliveryGraph(waitWith("continue")), []);
|
|
732
|
+
assertEquals(validateDeliveryGraph(waitWith("escalate")), []);
|
|
733
|
+
});
|
package/app/deliveryGraph.ts
CHANGED
|
@@ -83,7 +83,8 @@ export type DeliveryGraphErrorCode =
|
|
|
83
83
|
| "mixed-fan-out"
|
|
84
84
|
| "multiple-defaults"
|
|
85
85
|
| "non-exhaustive-split"
|
|
86
|
-
| "exclusive-merge-parity"
|
|
86
|
+
| "exclusive-merge-parity"
|
|
87
|
+
| "unsupported-on-timeout";
|
|
87
88
|
|
|
88
89
|
/** A single semantic validation failure. `path` is a JSON-path-qualified pointer at the offending
|
|
89
90
|
* input (`nodes[2].kind`, `edges[1].from`, `nodes[0].emits[1].name`), `message` is human-actionable,
|
|
@@ -342,6 +343,21 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
|
|
|
342
343
|
});
|
|
343
344
|
}
|
|
344
345
|
}
|
|
346
|
+
// A `wait` node's `onTimeout: fail` cannot be honored yet: the compiler would emit a terminate
|
|
347
|
+
// end on the not-ready-at-boundary path, but the engine treats terminate-end events as
|
|
348
|
+
// parsed-not-executed (Magikcraft/nano-bpm bpmn.rs), so `fail` would silently degrade to a plain
|
|
349
|
+
// end — the "declared knob silently ignored" defect class. Reject it loudly (path-qualified)
|
|
350
|
+
// until engine parity lands (Magikcraft/nano-bpm#978), rather than mis-compile it. `escalate`
|
|
351
|
+
// (default) and `continue` ARE honored.
|
|
352
|
+
if (kind === "wait" && config.onTimeout === "fail") {
|
|
353
|
+
errors.push({
|
|
354
|
+
path: `${path}.${configKey}.onTimeout`,
|
|
355
|
+
message:
|
|
356
|
+
"`onTimeout: fail` on a `wait` node is not yet supported (blocked on engine terminate-end " +
|
|
357
|
+
"execution, Magikcraft/nano-bpm#978); use `escalate` (default) or `continue`",
|
|
358
|
+
code: "unsupported-on-timeout",
|
|
359
|
+
});
|
|
360
|
+
}
|
|
345
361
|
}
|
|
346
362
|
} else if (rawNode.human !== undefined && !isRecord(rawNode.human)) {
|
|
347
363
|
// `human` config is OPTIONAL (formKey/prompt both resolve to a generic fallback in S3), but
|
|
@@ -605,3 +605,45 @@ test("S7 compiler: a post-merge node with an extra always-firing producer joins
|
|
|
605
605
|
assert(/<bpmn:parallelGateway id="gwj\d+"[^>]*name="join into finalize"/.test(r.bpmn), "finalize joins its always-firing producers on a parallel gateway");
|
|
606
606
|
assert(!/<bpmn:exclusiveGateway id="gwm\d+"[^>]*name="join into finalize"/.test(r.bpmn), "finalize is NOT compiled as an exclusive merge");
|
|
607
607
|
});
|
|
608
|
+
|
|
609
|
+
test("a wait node's onTimeout: continue proceeds past the gate with NO escalation task; escalate (default) keeps the human stop (#462)", async () => {
|
|
610
|
+
// AC (#462): `onTimeout: continue` routes the not-ready-at-boundary branch straight to the node end
|
|
611
|
+
// — no `__esc` escalation user task, no human stop — while the default (`escalate`) parks it on the
|
|
612
|
+
// escalation task. Two sibling wait nodes, one of each, isolate the difference.
|
|
613
|
+
const graph = {
|
|
614
|
+
name: "continue vs escalate",
|
|
615
|
+
nodes: [
|
|
616
|
+
{ id: "soft", kind: "wait", wait: { kind: "pr", target: "acme/repo#1", match: { prState: "merged" }, onTimeout: "continue" } },
|
|
617
|
+
{ id: "hard", kind: "wait", wait: { kind: "pr", target: "acme/repo#2", match: { prState: "merged" }, onTimeout: "escalate" } },
|
|
618
|
+
],
|
|
619
|
+
edges: [{ from: "soft", to: "hard" }],
|
|
620
|
+
};
|
|
621
|
+
const r = await compileOk(graph);
|
|
622
|
+
const softEl = elementForNode(r.bpmn, "soft");
|
|
623
|
+
const hardEl = elementForNode(r.bpmn, "hard");
|
|
624
|
+
// continue: no escalation twin for `soft`, and its not-ready boundary flow lands on the node end.
|
|
625
|
+
assert(!r.bpmn.includes(`delivery-human-task__${softEl}__esc`), "continue emits no escalation user task");
|
|
626
|
+
assert(
|
|
627
|
+
r.bpmn.includes(`<bpmn:sequenceFlow id="${softEl}_i4" name="not ready" sourceRef="${softEl}_lastGw" targetRef="${softEl}_end" />`),
|
|
628
|
+
"continue routes the not-ready boundary branch to the node end",
|
|
629
|
+
);
|
|
630
|
+
// escalate: `hard` keeps its escalation twin and routes not-ready to it.
|
|
631
|
+
assert(r.bpmn.includes(`delivery-human-task__${hardEl}__esc`), "escalate keeps the escalation user task");
|
|
632
|
+
assert(
|
|
633
|
+
r.bpmn.includes(`<bpmn:sequenceFlow id="${hardEl}_i4" name="not ready" sourceRef="${hardEl}_lastGw" targetRef="delivery-human-task__${hardEl}__esc" />`),
|
|
634
|
+
"escalate routes the not-ready boundary branch to the escalation task",
|
|
635
|
+
);
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
test("a wait node's onTimeout: fail is rejected at compile with a path-qualified error (blocked on engine terminate-end, #462/#978)", async () => {
|
|
639
|
+
const errors = await compileFail({
|
|
640
|
+
name: "fail not yet supported",
|
|
641
|
+
nodes: [
|
|
642
|
+
{ id: "g", kind: "wait", wait: { kind: "pr", target: "acme/repo#1", match: { prState: "merged" }, onTimeout: "fail" } },
|
|
643
|
+
],
|
|
644
|
+
edges: [],
|
|
645
|
+
});
|
|
646
|
+
const hit = errors.find((e) => e.path === "nodes[0].wait.onTimeout");
|
|
647
|
+
assert(hit, `expected a path-qualified onTimeout error, got ${JSON.stringify(errors)}`);
|
|
648
|
+
assert(hit?.message.includes("#978"), `the error names the blocking engine issue, got ${hit?.message}`);
|
|
649
|
+
});
|
|
@@ -953,10 +953,16 @@ function serviceBodyLines(
|
|
|
953
953
|
/** `wait` body: `start → pr.readiness-probe (poll) → ready? → end`, escalating on not-ready or on the
|
|
954
954
|
* `=probeTimeout` engine bound. The probe polls its OWN target, so an unrelated upstream event can
|
|
955
955
|
* never flip it to ready (#274/S2 concurrency-correctness); the `pr` kind (S2) binds `mergedSha`. */
|
|
956
|
-
function waitBodyLines(el: string, node: DeliveryNode): string[] {
|
|
956
|
+
function waitBodyLines(el: string, node: Extract<DeliveryNode, { kind: "wait" }>): string[] {
|
|
957
957
|
const nodeId = node.id;
|
|
958
958
|
const esc = escalationTaskElement(el);
|
|
959
959
|
const emits = normaliseEmits(node);
|
|
960
|
+
// `onTimeout` routing (#462): `escalate` (default) parks the not-ready-at-boundary token on a
|
|
961
|
+
// human-completable escalation task; `continue` proceeds past the gate as not-ready WITHOUT a human
|
|
962
|
+
// stop (a documented sharp edge — the downstream side-effecting node then runs without the awaited
|
|
963
|
+
// fact). `fail` is rejected earlier at validation (blocked on engine terminate-end, #978), so it
|
|
964
|
+
// never reaches here.
|
|
965
|
+
const continueOnTimeout = node.wait?.onTimeout === "continue";
|
|
960
966
|
// Defect A: read-only probe diagnostics seeded onto the escalation task so the operator/agent can
|
|
961
967
|
// tell a genuine "not published yet" from a transient false-negative — the probe's last detail, the
|
|
962
968
|
// resolved target/match, and a compact summary of the candidate releases the probe observed.
|
|
@@ -1033,22 +1039,27 @@ function waitBodyLines(el: string, node: DeliveryNode): string[] {
|
|
|
1033
1039
|
` <bpmn:outgoing>${el}_i7</bpmn:outgoing>`,
|
|
1034
1040
|
` <bpmn:outgoing>${el}_i4</bpmn:outgoing>`,
|
|
1035
1041
|
" </bpmn:exclusiveGateway>",
|
|
1036
|
-
...
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1042
|
+
...(continueOnTimeout
|
|
1043
|
+
? []
|
|
1044
|
+
: escalationTaskLines(
|
|
1045
|
+
esc,
|
|
1046
|
+
nodeId,
|
|
1047
|
+
[`${el}_i4`],
|
|
1048
|
+
`${el}_i5`,
|
|
1049
|
+
waitEscalationContextFeel(nodeId),
|
|
1050
|
+
{ resume: { kind: node.kind, emits }, diagnosticInputs },
|
|
1051
|
+
)),
|
|
1052
|
+
// On `continue`, the not-ready-at-boundary branch (`_i4`) proceeds straight to the node end (no
|
|
1053
|
+
// human stop, no `_i5` escalation-return flow); on `escalate` it parks on the escalation task,
|
|
1054
|
+
// which returns via `_i5`.
|
|
1055
|
+
` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_i1</bpmn:incoming>${continueOnTimeout ? `<bpmn:incoming>${el}_i4</bpmn:incoming>` : `<bpmn:incoming>${el}_i5</bpmn:incoming>`}<bpmn:incoming>${el}_i7</bpmn:incoming></bpmn:endEvent>`,
|
|
1045
1056
|
flow(`${el}_i0`, `${el}_start`, `${el}_probeLoop`),
|
|
1046
1057
|
flow(`${el}_i1`, `${el}_probeLoop`, `${el}_end`),
|
|
1047
1058
|
flow(`${el}_i2`, `${el}_be`, `${el}_lastAttempt`),
|
|
1048
1059
|
flow(`${el}_i6`, `${el}_lastAttempt`, `${el}_lastGw`),
|
|
1049
1060
|
` <bpmn:sequenceFlow id="${el}_i7" name="ready" sourceRef="${el}_lastGw" targetRef="${el}_end"><bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=ready = true</bpmn:conditionExpression></bpmn:sequenceFlow>`,
|
|
1050
|
-
` <bpmn:sequenceFlow id="${el}_i4" name="not ready" sourceRef="${el}_lastGw" targetRef="${esc}" />`,
|
|
1051
|
-
flow(`${el}_i5`, esc, `${el}_end`),
|
|
1061
|
+
` <bpmn:sequenceFlow id="${el}_i4" name="not ready" sourceRef="${el}_lastGw" targetRef="${continueOnTimeout ? `${el}_end` : esc}" />`,
|
|
1062
|
+
...(continueOnTimeout ? [] : [flow(`${el}_i5`, esc, `${el}_end`)]),
|
|
1052
1063
|
];
|
|
1053
1064
|
}
|
|
1054
1065
|
|
|
@@ -258,6 +258,51 @@ test("a RUN-LEVEL timeout is normalized (lower-case → canonical) and a malform
|
|
|
258
258
|
}
|
|
259
259
|
});
|
|
260
260
|
|
|
261
|
+
test("a wait node's per-node poll.timeoutMs drives its escalation boundary while a sibling keeps the run/default (#462)", async () => {
|
|
262
|
+
// AC (#462): a `wait` node declaring `poll.timeoutMs` seeds nodeInputs.<el>.probeTimeout derived
|
|
263
|
+
// from that budget (the compiled `=probeTimeout` boundary), while a sibling wait WITHOUT a declared
|
|
264
|
+
// timeout keeps the run-level value. Mirrors the per-node `everyMs → probePollEvery` override that
|
|
265
|
+
// already exists — the escalation boundary is the one budget that was silently ignored, so a 7-day
|
|
266
|
+
// gate escalated at the 30-minute run default.
|
|
267
|
+
const graph: DeliveryGraph = {
|
|
268
|
+
name: "per-node wait timeout",
|
|
269
|
+
nodes: [
|
|
270
|
+
{ id: "long-gate", kind: "wait", wait: { kind: "pr", target: "owner/repo#1", match: { prState: "merged" }, poll: { timeoutMs: 604_800_000 } } },
|
|
271
|
+
{ id: "default-gate", kind: "wait", wait: { kind: "pr", target: "owner/repo#2", match: { prState: "merged" } } },
|
|
272
|
+
],
|
|
273
|
+
edges: [{ from: "long-gate", to: "default-gate" }],
|
|
274
|
+
};
|
|
275
|
+
const p = await prepareOk(graph, { probeTimeout: "PT20M", runKey: "run-462" });
|
|
276
|
+
const waits = Object.values(p.nodeInputs).filter((v) => "gateKey" in v) as Array<{ gateKey: string; probeTimeout: string }>;
|
|
277
|
+
const byGate = (suffix: string) => waits.find((w) => w.gateKey.endsWith(suffix));
|
|
278
|
+
// Element ids are positional by sorted node id: default-gate → n0, long-gate → n1.
|
|
279
|
+
assertEquals(byGate(":n1")?.probeTimeout, "PT604800S"); // 7 days in seconds — the per-node budget wins
|
|
280
|
+
assertEquals(byGate(":n0")?.probeTimeout, "PT20M"); // sibling keeps the run-level value
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("an invalid per-node poll.timeoutMs/everyMs falls back to the run-level ctx.* override, not the built-in default (#462)", async () => {
|
|
284
|
+
// Guard against the JS-truthiness gap: a negative `poll.timeoutMs` (`-1`) is truthy, so a bare
|
|
285
|
+
// `probe.poll?.timeoutMs ? readinessTimeout(probe, {}) : ctx.probeTimeout` would route to
|
|
286
|
+
// `readinessTimeout(probe, {})`, which rejects `< 1` and — with `env: {}` — returns the built-in
|
|
287
|
+
// PT30M default, silently discarding the run/dispatch override. An invalid value must fall through
|
|
288
|
+
// to `ctx.*` (the run-level value) instead. Same for `everyMs`.
|
|
289
|
+
const graph: DeliveryGraph = {
|
|
290
|
+
name: "invalid per-node budget",
|
|
291
|
+
nodes: [
|
|
292
|
+
{
|
|
293
|
+
id: "bad-gate",
|
|
294
|
+
kind: "wait",
|
|
295
|
+
wait: { kind: "pr", target: "owner/repo#1", match: { prState: "merged" }, poll: { timeoutMs: -1, everyMs: -5 } },
|
|
296
|
+
},
|
|
297
|
+
],
|
|
298
|
+
edges: [],
|
|
299
|
+
};
|
|
300
|
+
const p = await prepareOk(graph, { probeTimeout: "PT20M", probePollEvery: "PT42S", runKey: "run-462b" });
|
|
301
|
+
const wait = Object.values(p.nodeInputs).find((v) => "gateKey" in v) as { probeTimeout: string; probePollEvery: string } | undefined;
|
|
302
|
+
assertEquals(wait?.probeTimeout, "PT20M"); // run-level override, NOT the built-in PT30M default
|
|
303
|
+
assertEquals(wait?.probePollEvery, "PT42S"); // run-level cadence, NOT DEFAULT_EVERY_MS
|
|
304
|
+
});
|
|
305
|
+
|
|
261
306
|
test("a malformed graph returns the S1 compile errors and prepares nothing", async () => {
|
|
262
307
|
const r = await prepareDeliveryGraph({ nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" } }], edges: [{ from: "a", to: "ghost" }] } as unknown as DeliveryGraph);
|
|
263
308
|
assert(!r.ok, "a dangling edge fails to prepare");
|
package/app/deliveryRunner.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
17
17
|
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
|
-
import { DEFAULT_EVERY_MS, msToIsoDuration, parseProbe, readinessPollEvery } from "./readiness.ts";
|
|
20
|
+
import { DEFAULT_EVERY_MS, msToIsoDuration, parseProbe, readinessPollEvery, readinessTimeout } from "./readiness.ts";
|
|
21
21
|
import { isoDuration } from "./reviewWait.ts";
|
|
22
22
|
|
|
23
23
|
/** The content digest of a compiled graph — `sha256(bpmn)[:12]` — the single source of truth for the
|
|
@@ -227,11 +227,25 @@ function buildNodeInput(
|
|
|
227
227
|
}
|
|
228
228
|
case "wait": {
|
|
229
229
|
const probe = parseProbe(node.wait);
|
|
230
|
+
// Only a VALID, positive per-node budget overrides the run level. Match the `>= 1` predicate
|
|
231
|
+
// `readinessTimeout`/`readinessPollEvery` apply internally, rather than a bare JS-truthiness
|
|
232
|
+
// check on `poll.timeoutMs`/`everyMs`: a negative (`-1`) value is truthy, so a truthiness gate
|
|
233
|
+
// would route to `readinessTimeout(probe, {})`, which then rejects it (`< 1`) and — because
|
|
234
|
+
// `env` is `{}` — falls back to the *built-in* default (PT30M / DEFAULT_EVERY_MS), silently
|
|
235
|
+
// discarding the run/dispatch override in `ctx.*`. Gating on the same validity predicate here
|
|
236
|
+
// makes an invalid per-node value fall through to `ctx.probeTimeout`/`ctx.probePollEvery`.
|
|
237
|
+
const declaredTimeout = typeof probe.poll?.timeoutMs === "number" && probe.poll.timeoutMs >= 1;
|
|
238
|
+
const declaredEvery = typeof probe.poll?.everyMs === "number" && probe.poll.everyMs >= 1;
|
|
230
239
|
return {
|
|
231
240
|
gateKey: `${ctx.runKey}:${ctx.element}`,
|
|
232
241
|
probe: node.wait,
|
|
233
|
-
|
|
234
|
-
|
|
242
|
+
// Per-node escalation boundary (#462): a `wait` node's declared `poll.timeoutMs` drives its
|
|
243
|
+
// compiled `=probeTimeout` bound, mirroring the `everyMs → probePollEvery` override below —
|
|
244
|
+
// otherwise a node's poll budget is honored for the interval but silently ignored for the
|
|
245
|
+
// boundary (a 7-day gate escalated at the 30-minute run default). Falls back to the run-level
|
|
246
|
+
// `ctx.probeTimeout` (which itself honors the dispatch override / default) when undeclared.
|
|
247
|
+
probeTimeout: declaredTimeout ? readinessTimeout(probe, {}) : ctx.probeTimeout,
|
|
248
|
+
probePollEvery: declaredEvery ? readinessPollEvery(probe, {}) : ctx.probePollEvery,
|
|
235
249
|
};
|
|
236
250
|
}
|
|
237
251
|
case "human":
|
package/openapi.yaml
CHANGED
|
@@ -1430,7 +1430,14 @@ components:
|
|
|
1430
1430
|
description: >-
|
|
1431
1431
|
A `wait` node — a durable `ReadinessProbe` (ADR 0001 §2) watching an external fact. Reuses the
|
|
1432
1432
|
existing `ReadinessProbe` shape verbatim (Decision 3 — never a second wait loop); the `pr`
|
|
1433
|
-
merge-state kind is added to that shape by slice S2 and flows in here automatically.
|
|
1433
|
+
merge-state kind is added to that shape by slice S2 and flows in here automatically. The
|
|
1434
|
+
probe's `poll.timeoutMs` sets THIS node's escalation boundary (how long the gate waits before
|
|
1435
|
+
it acts on `onTimeout`), falling back to the run/default when absent (#462). `onTimeout`
|
|
1436
|
+
`escalate` (default) parks the elapsed gate on a human-completable task; `continue` proceeds
|
|
1437
|
+
past the gate as not-ready with NO human stop (a sharp edge — the downstream side-effecting
|
|
1438
|
+
node then runs without the awaited fact); `fail` is NOT yet supported on a delivery `wait`
|
|
1439
|
+
node (blocked on engine terminate-end execution, Magikcraft/nano-bpm#978) and is rejected at
|
|
1440
|
+
compile with a path-qualified error rather than silently degrading.
|
|
1434
1441
|
allOf:
|
|
1435
1442
|
- $ref: "#/components/schemas/DeliveryNodeCommon"
|
|
1436
1443
|
- type: object
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.139.
|
|
3
|
+
"version": "0.139.4",
|
|
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",
|