@nanobpm/nano-workforce 0.181.0 → 0.182.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/app/deliveryGraph.ts +29 -0
- package/app/deliveryGraphCompiler.ts +39 -20
- package/app/deliveryRunner.test.ts +120 -58
- package/app/deliveryRunner.ts +154 -55
- package/app/repoEnvelope.ts +73 -1
- package/app/sequenceIssues.test.ts +12 -1
- package/app/sequenceIssues.ts +14 -8
- package/openapi.yaml +91 -8
- package/operations/dispatchDeliveryGraph.test.ts +50 -4
- package/operations/dispatchDeliveryGraph.ts +44 -27
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.182.1](https://github.com/nanobpm/nano-workforce/compare/v0.182.0...v0.182.1) (2026-09-04)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **deps:** update dependency @nanobpm/urban to ^0.92.0 ([#573](https://github.com/nanobpm/nano-workforce/issues/573)) ([a7acb21](https://github.com/nanobpm/nano-workforce/commit/a7acb214bc36752149ce8be51a907ff74acb073d))
|
|
6
|
+
|
|
7
|
+
## [0.182.0](https://github.com/nanobpm/nano-workforce/compare/v0.181.0...v0.182.0) (2026-09-04)
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* stamp each agent node's repository for per-node delivery-graph isolation ([#742](https://github.com/nanobpm/nano-workforce/issues/742)) ([e8dcdfc](https://github.com/nanobpm/nano-workforce/commit/e8dcdfce2122bdb2a7e881a0900ca1f607d6d402)), closes [#739](https://github.com/nanobpm/nano-workforce/issues/739)
|
|
12
|
+
|
|
1
13
|
## [0.181.0](https://github.com/nanobpm/nano-workforce/compare/v0.180.0...v0.181.0) (2026-09-04)
|
|
2
14
|
|
|
3
15
|
### Features
|
package/app/deliveryGraph.ts
CHANGED
|
@@ -19,8 +19,10 @@
|
|
|
19
19
|
// Every error carries a JSON-path-qualified `path` (`nodes[2].kind`, `edges[1].from`, …) so the
|
|
20
20
|
// caller can point the author straight at the offending input.
|
|
21
21
|
|
|
22
|
+
import { isPlausibleBranchName } from "./baseBranch.ts";
|
|
22
23
|
import { isConvergeTarget } from "./convergeTargets.ts";
|
|
23
24
|
import { isRawConvergeMergeJobType, NODE_COMPLETION_POLICIES } from "./nodePolicy.ts";
|
|
25
|
+
import { isResolvableRepo } from "./repoEnvelope.ts";
|
|
24
26
|
|
|
25
27
|
/** The CLOSED node-kind allowlist (ADR 0005 Decision 2) — the trust boundary. Extensible only by a
|
|
26
28
|
* deliberate ADR/PR (add the openapi variant + a case here), never by a graph author. Kept as the
|
|
@@ -94,6 +96,8 @@ export type DeliveryGraphErrorCode =
|
|
|
94
96
|
| "raw-converge-node"
|
|
95
97
|
| "merge-requires-converge"
|
|
96
98
|
| "converge-merge-type"
|
|
99
|
+
| "invalid-node-repository"
|
|
100
|
+
| "invalid-node-base-branch"
|
|
97
101
|
| "unbound-pr";
|
|
98
102
|
|
|
99
103
|
/** A single semantic validation failure. `path` is a JSON-path-qualified pointer at the offending
|
|
@@ -428,6 +432,31 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
|
|
|
428
432
|
code: "merge-requires-converge",
|
|
429
433
|
});
|
|
430
434
|
}
|
|
435
|
+
// #739: an `agent` node may declare its OWN `repository` (`owner/repo`) + `baseBranch`, so a
|
|
436
|
+
// cross-repo graph provisions each cell's isolation envelope from that node's own repo (no
|
|
437
|
+
// uniform run-level repo, no `repoless`). Both are OPTIONAL (absent → the run-level fallback),
|
|
438
|
+
// but a PRESENT value must pass the SAME allowlists the dispatch door / `repoEnvelopeVars` apply
|
|
439
|
+
// — a plain `owner/repo` (no `.git`, no host/query chars) and a plausible git branch name — so a
|
|
440
|
+
// graph that bypassed OpenAPI shape validation cannot smuggle a malformed clone URL / ref past
|
|
441
|
+
// this gate into the per-node envelope. Rejected path-qualified rather than silently dropped.
|
|
442
|
+
if (kind === "agent" && config.repository !== undefined && !isResolvableRepo(config.repository)) {
|
|
443
|
+
errors.push({
|
|
444
|
+
path: `${path}.${configKey}.repository`,
|
|
445
|
+
message:
|
|
446
|
+
"`agent.repository`, when present, must be an `owner/repo` reference (no trailing `.git`, no " +
|
|
447
|
+
`host/query characters) — got ${JSON.stringify(config.repository)} (#739)`,
|
|
448
|
+
code: "invalid-node-repository",
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
if (kind === "agent" && config.baseBranch !== undefined && (typeof config.baseBranch !== "string" || !isPlausibleBranchName(config.baseBranch))) {
|
|
452
|
+
errors.push({
|
|
453
|
+
path: `${path}.${configKey}.baseBranch`,
|
|
454
|
+
message:
|
|
455
|
+
"`agent.baseBranch`, when present, must be a plausible git branch name (no whitespace, shell " +
|
|
456
|
+
`metacharacters, leading \`-\`, \`..\`/\`//\`, etc.) — got ${JSON.stringify(config.baseBranch)} (#739)`,
|
|
457
|
+
code: "invalid-node-base-branch",
|
|
458
|
+
});
|
|
459
|
+
}
|
|
431
460
|
// #548: register a converge-connector / pr-wait as a PR-binding consumer (pass 4 validates the
|
|
432
461
|
// binding once edges are resolved). Only when the id is usable so pass 4 can key by node id.
|
|
433
462
|
if (typeof id === "string" && id.length > 0) {
|
|
@@ -45,6 +45,18 @@ import {
|
|
|
45
45
|
validateDeliveryGraph,
|
|
46
46
|
} from "./deliveryGraph.ts";
|
|
47
47
|
import { DELIVERY_HUMAN_ELEMENT, GENERIC_HUMAN_FORM } from "./deliveryHuman.ts";
|
|
48
|
+
import { AGENT_TASK_NS } from "./repoEnvelope.ts";
|
|
49
|
+
|
|
50
|
+
/** The task-header key that carries an `agent` node's DECLARED per-node repository spec (#739) into the
|
|
51
|
+
* compiled BPMN. It is a DIGEST-STABLE, env-free marker — pure graph content — so two graphs differing
|
|
52
|
+
* only in a node's declared `repository`/`baseBranch` content-address differently (they are different
|
|
53
|
+
* graphs), while the env-dependent parts of the real envelope (`cloneTimeoutMs`) and the run-level
|
|
54
|
+
* FALLBACK repo are NOT baked here (they are injected by the runner POST-digest, so the same graph in
|
|
55
|
+
* two environments / two runs still shares one id). The runner replaces this single marker header on
|
|
56
|
+
* every agent service task with the flattened EFFECTIVE `io.nanobpm.agentTask.*` envelope headers
|
|
57
|
+
* (declared ?? run-level), or strips it for an unresolved/`repoless` cell. The `__` prefix marks it as
|
|
58
|
+
* an internal marker the harness never reads. */
|
|
59
|
+
export const AGENT_REPO_SPEC_HEADER = `${AGENT_TASK_NS}.__repoSpec`;
|
|
48
60
|
|
|
49
61
|
/** The engine-native BODY every node kind delegates to (Decision 2 — the graph SCHEDULES, it does not
|
|
50
62
|
* re-implement execution). Each node compiles to an EMBEDDED `bpmn:subProcess` (call activities are a
|
|
@@ -1027,7 +1039,7 @@ function innerBodyLines(w: NodeWiring, requiredEmits: ReadonlySet<string>): stri
|
|
|
1027
1039
|
// as a required data dependency. A broken producer (returns `in_progress`, or omits a required
|
|
1028
1040
|
// emit) escalates AT this node instead of threading an incomplete result onward.
|
|
1029
1041
|
const contractGate = { requiredEmits: normaliseEmits(node).filter((f) => requiredEmits.has(f.name)) };
|
|
1030
|
-
return serviceBodyLines(el, node.id, attr("type", node.agent.jobType), [], node.agent.jobType, contractGate);
|
|
1042
|
+
return serviceBodyLines(el, node.id, attr("type", node.agent.jobType), [], node.agent.jobType, contractGate, agentRepoSpecHeaderLines(node));
|
|
1031
1043
|
}
|
|
1032
1044
|
case "connector":
|
|
1033
1045
|
return serviceBodyLines(el, node.id, `type="${DELEGATE_TASK_TYPE.connector}"`, [], `connector → ${node.connector.target}`);
|
|
@@ -1040,11 +1052,25 @@ function innerBodyLines(w: NodeWiring, requiredEmits: ReadonlySet<string>): stri
|
|
|
1040
1052
|
}
|
|
1041
1053
|
}
|
|
1042
1054
|
|
|
1055
|
+
/** Render the DECLARED per-node repository-spec marker task header (#739) for an `agent` node — a single
|
|
1056
|
+
* `<zeebe:taskHeaders>` block carrying {@link AGENT_REPO_SPEC_HEADER} with a compact JSON of the node's
|
|
1057
|
+
* DECLARED `{ repository, baseBranch }` (each `null` when absent). It is emitted on EVERY agent service
|
|
1058
|
+
* task (even one with no declared repo → `{"repository":null,"baseBranch":null}`) so the runner has a
|
|
1059
|
+
* single, uniform anchor to replace with the effective envelope on every cell. Digest-stable and
|
|
1060
|
+
* env-free — only the declared values (pure graph content) appear here; the run-level fallback and the
|
|
1061
|
+
* env-dependent `cloneTimeoutMs` are injected by the runner POST-digest. Declared values pass the
|
|
1062
|
+
* `owner/repo` + branch-name allowlists (validator/OpenAPI), so the JSON carries no XML-hostile chars. */
|
|
1063
|
+
function agentRepoSpecHeaderLines(node: Extract<DeliveryNode, { kind: "agent" }>): string[] {
|
|
1064
|
+
const trimOrNull = (v: unknown): string | null => (typeof v === "string" && v.trim() !== "" ? v.trim() : null);
|
|
1065
|
+
const spec = JSON.stringify({ repository: trimOrNull(node.agent.repository), baseBranch: trimOrNull(node.agent.baseBranch) });
|
|
1066
|
+
return [
|
|
1067
|
+
" <zeebe:taskHeaders>",
|
|
1068
|
+
` <zeebe:header key="${AGENT_REPO_SPEC_HEADER}" ${attr("value", spec)} />`,
|
|
1069
|
+
" </zeebe:taskHeaders>",
|
|
1070
|
+
];
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1043
1073
|
/** `agent`/`connector` body: `start → serviceTask → end`, with a bounded `=nodeTimeout` boundary that
|
|
1044
|
-
* escalates the stalled node onto a human-completable user task. `taskDefAttr` is the pre-rendered
|
|
1045
|
-
* `type="…"` attribute; `taskProps` are optional `<zeebe:property>` envelope lines; `descriptor`
|
|
1046
|
-
* names the stalled work (job type / connector target) for the escalation task's context line (#499). */
|
|
1047
|
-
/** The FEEL boolean an `agent` node's producer-contract gate (issue #731) evaluates on its `_gate`
|
|
1048
1074
|
* exclusive split's SUCCESS flow: the completion proceeds onward only when the self-reported `status`
|
|
1049
1075
|
* is a terminal success (or absent/null) AND every required-data-dependency emit is populated non-null.
|
|
1050
1076
|
* Reads the job's returned variables from the subProcess scope (the emit source var for an agent fact
|
|
@@ -1095,23 +1121,16 @@ function serviceBodyLines(
|
|
|
1095
1121
|
taskProps: readonly string[],
|
|
1096
1122
|
descriptor: string,
|
|
1097
1123
|
contractGate?: { requiredEmits: readonly DeliveryFact[] },
|
|
1124
|
+
taskHeaders: readonly string[] = [],
|
|
1098
1125
|
): string[] {
|
|
1099
1126
|
const esc = escalationTaskElement(el);
|
|
1100
|
-
const taskExt =
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
" </zeebe:properties>",
|
|
1108
|
-
" </bpmn:extensionElements>",
|
|
1109
|
-
]
|
|
1110
|
-
: [
|
|
1111
|
-
" <bpmn:extensionElements>",
|
|
1112
|
-
` <zeebe:taskDefinition ${taskDefAttr} />`,
|
|
1113
|
-
" </bpmn:extensionElements>",
|
|
1114
|
-
];
|
|
1127
|
+
const taskExt = [
|
|
1128
|
+
" <bpmn:extensionElements>",
|
|
1129
|
+
` <zeebe:taskDefinition ${taskDefAttr} />`,
|
|
1130
|
+
...(taskProps.length > 0 ? [" <zeebe:properties>", ...taskProps, " </zeebe:properties>"] : []),
|
|
1131
|
+
...taskHeaders,
|
|
1132
|
+
" </bpmn:extensionElements>",
|
|
1133
|
+
];
|
|
1115
1134
|
const timeoutEscalation = escalationTaskLines(
|
|
1116
1135
|
esc,
|
|
1117
1136
|
nodeId,
|
|
@@ -31,7 +31,11 @@ const GRAPH: DeliveryGraph = {
|
|
|
31
31
|
};
|
|
32
32
|
|
|
33
33
|
async function prepareOk(graph: DeliveryGraph, options = {}) {
|
|
34
|
-
|
|
34
|
+
// Default to a resolvable run-level repository/baseBranch fallback (#739) so the many timeout/id/DI
|
|
35
|
+
// tests below — which don't care about repo provisioning — need not restate it; the per-node
|
|
36
|
+
// repository tests pass their own `options` (declared node repos, `repoless`, unresolved, …).
|
|
37
|
+
const opts = "repoless" in options || "repository" in options ? options : { repository: "owner/repo", baseBranch: "main", ...options };
|
|
38
|
+
const r = await prepareDeliveryGraph(graph, opts);
|
|
35
39
|
assert(r.ok, `expected ok:true, got ${JSON.stringify(r)}`);
|
|
36
40
|
return r.prepared;
|
|
37
41
|
}
|
|
@@ -364,90 +368,148 @@ test("runDeliveryGraph coerces a numeric engine processInstanceKey to a string h
|
|
|
364
368
|
assertEquals(typeof r.handle.processInstanceKey, "string");
|
|
365
369
|
});
|
|
366
370
|
|
|
367
|
-
//
|
|
368
|
-
// `io.nanobpm.agentTask.repository` isolation envelope
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
createInstance: async (req: { variables?: Record<string, unknown> }) => {
|
|
377
|
-
captured = req.variables ?? {};
|
|
378
|
-
return { processInstanceKey: "1" };
|
|
379
|
-
},
|
|
380
|
-
};
|
|
381
|
-
return { engine, seen: () => captured };
|
|
371
|
+
// Per-node repository isolation (issue #739): the delivery-graph runner seeds the canonical
|
|
372
|
+
// `io.nanobpm.agentTask.repository` isolation envelope PER agent cell as flattened `<zeebe:taskHeaders>`
|
|
373
|
+
// (`io.nanobpm.agentTask.repository.url`, …) injected into the deployable BPMN — NOT as a single
|
|
374
|
+
// run-root process variable. Each agent node's effective repository is its own declared `agent.repository`
|
|
375
|
+
// (`agent.baseBranch`), else the run-level `repository`/`baseBranch` fallback. This lets one graph fan out
|
|
376
|
+
// across DIFFERENT repos (each cell its own isolated clone) without forcing `repoless`. These pin the
|
|
377
|
+
// per-node headers the harness reads and the loud-failure invariants (#729 preserved/strengthened).
|
|
378
|
+
function agentHeaders(bpmn: string): string[] {
|
|
379
|
+
return (bpmn.match(/io\.nanobpm\.agentTask\.[^\n]*/g) ?? []).map((s) => s.trim());
|
|
382
380
|
}
|
|
383
381
|
|
|
384
|
-
test("
|
|
385
|
-
const {
|
|
386
|
-
const
|
|
382
|
+
test("prepareDeliveryGraph injects the repository envelope PER agent cell from the run-level fallback (#684/#686/#739)", async () => {
|
|
383
|
+
const p = await prepareOk(GRAPH, { repository: "owner/repo", baseBranch: "main" });
|
|
384
|
+
const headers = agentHeaders(p.bpmn);
|
|
385
|
+
// The run's single agent cell (`open-b`) carries the flattened envelope headers.
|
|
386
|
+
assert(headers.some((h) => h.includes('repository.url" value="https://github.com/owner/repo.git"')), `expected a repository.url header, got ${JSON.stringify(headers)}`);
|
|
387
|
+
assert(headers.some((h) => h.includes('repository.ref" value="main"')), "ref = base branch");
|
|
388
|
+
assert(headers.some((h) => h.includes('repository.baseRef" value="main"')), "baseRef = base branch");
|
|
389
|
+
assert(headers.some((h) => h.includes('repository.provider" value="github"')), "provider header");
|
|
390
|
+
assert(headers.some((h) => h.includes('repository.singleBranch" value="true"')), "branch-scoped blobless clone (#287)");
|
|
391
|
+
assert(headers.some((h) => h.includes('repository.filter" value="blob:none"')), "blobless filter");
|
|
392
|
+
// No `__repoSpec` marker survives injection — it is the compiler's digest-stable anchor only.
|
|
393
|
+
assert(!p.bpmn.includes("__repoSpec"), "the __repoSpec marker is fully replaced");
|
|
394
|
+
// No run-root `io.nanobpm.agentTask` variable — the envelope rides headers now, not a run variable.
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test("a node's DECLARED repository/baseBranch WINS over the run-level fallback; a bare node falls back (#739)", async () => {
|
|
398
|
+
const graph: DeliveryGraph = {
|
|
399
|
+
name: "cross-repo fan-out",
|
|
400
|
+
nodes: [
|
|
401
|
+
{ id: "own", kind: "agent", agent: { jobType: "senior:feature", prompt: "own repo", repository: "acme/widget", baseBranch: "develop" } },
|
|
402
|
+
{ id: "fallback", kind: "agent", agent: { jobType: "senior:feature", prompt: "run repo" } },
|
|
403
|
+
],
|
|
404
|
+
edges: [{ from: "own", to: "fallback" }],
|
|
405
|
+
};
|
|
406
|
+
const p = await prepareOk(graph, { repository: "owner/repo", baseBranch: "main" });
|
|
407
|
+
const headers = agentHeaders(p.bpmn);
|
|
408
|
+
// The declared node points at its OWN repo + base…
|
|
409
|
+
assert(headers.some((h) => h.includes('repository.url" value="https://github.com/acme/widget.git"')), "declared repo wins");
|
|
410
|
+
assert(headers.some((h) => h.includes('repository.ref" value="develop"')), "declared base wins");
|
|
411
|
+
// …while the bare node inherits the run-level fallback.
|
|
412
|
+
assert(headers.some((h) => h.includes('repository.url" value="https://github.com/owner/repo.git"')), "bare node falls back to run repo");
|
|
413
|
+
assert(headers.some((h) => h.includes('repository.ref" value="main"')), "bare node falls back to run base");
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test("a fully NODE-PROVISIONED cross-repo graph dispatches with NO run-level repository and NO repoless (#739)", async () => {
|
|
417
|
+
const graph: DeliveryGraph = {
|
|
418
|
+
name: "self-provisioned",
|
|
419
|
+
nodes: [
|
|
420
|
+
{ id: "a", kind: "agent", agent: { jobType: "senior:feature", prompt: "a", repository: "acme/one" } },
|
|
421
|
+
{ id: "b", kind: "agent", agent: { jobType: "senior:feature", prompt: "b", repository: "acme/two" } },
|
|
422
|
+
],
|
|
423
|
+
edges: [{ from: "a", to: "b" }],
|
|
424
|
+
};
|
|
425
|
+
// Neither a run-level repository/baseBranch NOR repoless — every node self-provisions.
|
|
426
|
+
const p = await prepareOk(graph, {});
|
|
427
|
+
const headers = agentHeaders(p.bpmn);
|
|
428
|
+
assert(headers.some((h) => h.includes("acme/one.git")), "node a → acme/one");
|
|
429
|
+
assert(headers.some((h) => h.includes("acme/two.git")), "node b → acme/two");
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("a declared repository WITHOUT a base branch omits ref/baseRef — the harness clones the default branch (#739)", async () => {
|
|
433
|
+
const graph: DeliveryGraph = {
|
|
434
|
+
name: "no base",
|
|
435
|
+
nodes: [{ id: "a", kind: "agent", agent: { jobType: "senior:feature", prompt: "a", repository: "acme/one" } }],
|
|
436
|
+
edges: [],
|
|
437
|
+
};
|
|
438
|
+
// Call the runner DIRECTLY with no run-level fallback (the shared `prepareOk` helper would inject one),
|
|
439
|
+
// so the node's declared repo is the sole source and its base is genuinely unknown.
|
|
440
|
+
const r = await prepareDeliveryGraph(graph, {});
|
|
387
441
|
assert(r.ok, `expected ok:true, got ${JSON.stringify(r)}`);
|
|
388
|
-
const
|
|
389
|
-
assert(
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
assertEquals(repo.ref, "main");
|
|
393
|
-
assertEquals(repo.url, "https://github.com/owner/repo.git");
|
|
394
|
-
assertEquals(repo.provider, "github");
|
|
395
|
-
// Branch-scoped blobless clone (#287) so large monorepos provision within the clone timeout.
|
|
396
|
-
assertEquals(repo.singleBranch, true);
|
|
397
|
-
assertEquals(repo.filter, "blob:none");
|
|
398
|
-
// baseRef = base too, so `origin/<base>` stays reachable for the review 3-dot diff.
|
|
399
|
-
assertEquals(repo.baseRef, "main");
|
|
400
|
-
// NO branch.create at the run root — a run fans out to many agent nodes, each needing its own
|
|
401
|
-
// feat/<node.id>, so a single run-level envelope names none (mirrors the plan.ts epic seed).
|
|
402
|
-
assertEquals("branch" in repo, false);
|
|
442
|
+
const headers = agentHeaders(r.prepared.bpmn);
|
|
443
|
+
assert(headers.some((h) => h.includes('repository.url" value="https://github.com/acme/one.git"')), "url present");
|
|
444
|
+
assert(!headers.some((h) => h.includes("repository.ref")), "no ref → clone the repo default branch");
|
|
445
|
+
assert(!headers.some((h) => h.includes("repository.baseRef")), "no baseRef either");
|
|
403
446
|
});
|
|
404
447
|
|
|
405
|
-
test("
|
|
406
|
-
const
|
|
407
|
-
|
|
408
|
-
assert(
|
|
409
|
-
assertEquals("io.nanobpm.agentTask" in seen(), false, "an explicit repoless run must emit no envelope");
|
|
448
|
+
test("prepareDeliveryGraph seeds NO envelope ONLY on an EXPLICIT repoless run — the conscious opt-out (#729)", async () => {
|
|
449
|
+
const p = await prepareOk(GRAPH, { repoless: true });
|
|
450
|
+
assert(!p.bpmn.includes("io.nanobpm.agentTask.repository"), "an explicit repoless run must emit no repository envelope");
|
|
451
|
+
assert(!p.bpmn.includes("__repoSpec"), "the marker is stripped on a repoless run too");
|
|
410
452
|
});
|
|
411
453
|
|
|
412
|
-
test("
|
|
413
|
-
// Issue #729: the fan-out seed is REQUIRED.
|
|
414
|
-
// (and
|
|
415
|
-
//
|
|
416
|
-
//
|
|
417
|
-
|
|
418
|
-
|
|
454
|
+
test("prepareDeliveryGraph THROWS on an unresolved repo/base when NOT repoless — never a silent shared launch dir (#729/#739)", async () => {
|
|
455
|
+
// Issue #729: the fan-out seed is REQUIRED. An agent cell that declares no repository AND has no
|
|
456
|
+
// run-level repository fallback (and no explicit `repoless` opt-out) must fail LOUDLY at prepare time
|
|
457
|
+
// rather than silently degrade to the worker's shared launch dir (issue #684's field failure). GRAPH's
|
|
458
|
+
// `open-b` node declares no repository. A run-level repository WITHOUT a base is NOT unresolved (#739:
|
|
459
|
+
// the cell clones the repo's default branch) — only a missing/blank repository is unresolved.
|
|
460
|
+
for (const options of [{}, { baseBranch: "main" }, { repository: " ", baseBranch: "main" }]) {
|
|
419
461
|
await assertRejects(
|
|
420
|
-
() =>
|
|
462
|
+
() => prepareDeliveryGraph(GRAPH, options),
|
|
421
463
|
RepoEnvelopeUnresolvedError,
|
|
422
464
|
);
|
|
423
465
|
}
|
|
424
466
|
});
|
|
425
467
|
|
|
426
|
-
test("
|
|
427
|
-
|
|
428
|
-
//
|
|
429
|
-
|
|
430
|
-
|
|
468
|
+
test("a run-level repository WITHOUT a base branch RESOLVES every bare cell — the default-branch clone (#739)", async () => {
|
|
469
|
+
// The #739 relaxation: an issue ref carries only `owner/repo`, no branch. A run-level repository with
|
|
470
|
+
// no base is a legitimate fallback — each bare cell clones that repo's DEFAULT branch, no ref header.
|
|
471
|
+
const r = await prepareDeliveryGraph(GRAPH, { repository: "owner/repo" });
|
|
472
|
+
assert(r.ok, `expected ok:true, got ${JSON.stringify(r)}`);
|
|
473
|
+
const headers = agentHeaders(r.prepared.bpmn);
|
|
474
|
+
assert(headers.some((h) => h.includes('repository.url" value="https://github.com/owner/repo.git"')), "bare cell inherits run repo");
|
|
475
|
+
assert(!headers.some((h) => h.includes("repository.ref")), "no run base → clone the default branch");
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test("prepareDeliveryGraph THROWS on a malformed run-level repository rather than emitting a bogus clone URL (#729)", async () => {
|
|
479
|
+
// A value that is not exactly `owner/repo` (a trailing `.git`) is an UNRESOLVED fallback — it must
|
|
480
|
+
// throw, never degrade to a double-suffixed `…/owner/repo.git.git` clone URL.
|
|
431
481
|
await assertRejects(
|
|
432
|
-
() =>
|
|
482
|
+
() => prepareDeliveryGraph(GRAPH, { repository: "owner/repo.git", baseBranch: "main" }),
|
|
433
483
|
RepoEnvelopeUnresolvedError,
|
|
434
484
|
);
|
|
435
485
|
});
|
|
436
486
|
|
|
437
|
-
test("
|
|
487
|
+
test("a malformed NODE-declared repository is REJECTED at validation (#739)", async () => {
|
|
488
|
+
const graph: DeliveryGraph = {
|
|
489
|
+
name: "bad node repo",
|
|
490
|
+
nodes: [{ id: "a", kind: "agent", agent: { jobType: "senior:feature", prompt: "a", repository: "acme/one.git" } }],
|
|
491
|
+
edges: [],
|
|
492
|
+
};
|
|
493
|
+
// A node whose declared repository is malformed (a trailing `.git`) is caught by the semantic validator
|
|
494
|
+
// as `invalid-node-repository` — the graph never compiles, so it can never inject a bogus clone URL
|
|
495
|
+
// nor silently fall back to the run level (which would mask the operator's typo).
|
|
496
|
+
const r = await prepareDeliveryGraph(graph, { repository: "owner/repo", baseBranch: "main" });
|
|
497
|
+
assert(!r.ok, "a malformed node repository fails to prepare");
|
|
498
|
+
assert(r.errors.some((e) => e.path === "nodes[0].agent.repository"), `expected an invalid-node-repository error, got ${JSON.stringify(r.errors)}`);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test("prepareDeliveryGraph THROWS when repoless is combined with repository/baseBranch — never silently disables isolation (#729)", async () => {
|
|
438
502
|
// `repoless: true` is mutually exclusive with `repository`/`baseBranch`. The dispatch door rejects the
|
|
439
503
|
// conflicting shape with a 400, but a PROGRAMMATIC caller that bypasses the door could pass both — and
|
|
440
504
|
// the runner would silently drop the repo/base and emit no envelope, re-disabling the exact isolation
|
|
441
|
-
// the repo/base named. The runner must fail LOUDLY too (defense-in-depth)
|
|
442
|
-
// silently disabled by a conflicting call. Either half of the pair alongside `repoless` is a conflict.
|
|
505
|
+
// the repo/base named. The runner must fail LOUDLY too (defense-in-depth).
|
|
443
506
|
for (const options of [
|
|
444
507
|
{ repoless: true, repository: "owner/repo", baseBranch: "main" },
|
|
445
508
|
{ repoless: true, repository: "owner/repo" },
|
|
446
509
|
{ repoless: true, baseBranch: "main" },
|
|
447
510
|
]) {
|
|
448
|
-
const { engine } = captureCreateInstanceVars();
|
|
449
511
|
await assertRejects(
|
|
450
|
-
() =>
|
|
512
|
+
() => prepareDeliveryGraph(GRAPH, options),
|
|
451
513
|
RepoEnvelopeConflictError,
|
|
452
514
|
);
|
|
453
515
|
}
|
package/app/deliveryRunner.ts
CHANGED
|
@@ -18,9 +18,9 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
18
18
|
import type { EngineClient } from "@nanobpm/urban";
|
|
19
19
|
import type { DeliveryFact, DeliveryGraph, DeliveryNode } from "../nano-generated/api-io.d.ts";
|
|
20
20
|
import { TRANSCRIPT_URL_BASE_VAR, transcriptUrlBaseFor } from "./agentic/transcript-url.ts";
|
|
21
|
-
import { assertNever, compileDeliveryGraph, DELIVERY_GRAPH_PROCESS_ID } from "./deliveryGraphCompiler.ts";
|
|
21
|
+
import { AGENT_REPO_SPEC_HEADER, assertNever, compileDeliveryGraph, DELIVERY_GRAPH_PROCESS_ID } from "./deliveryGraphCompiler.ts";
|
|
22
22
|
import { DEFAULT_EVERY_MS, msToIsoDuration, parseProbe, readinessPollEvery, readinessTimeout } from "./readiness.ts";
|
|
23
|
-
import { RepoEnvelopeConflictError,
|
|
23
|
+
import { agentNodeRepoEnvelope, flattenAgentTaskEnvelope, isResolvableRepo, RepoEnvelopeConflictError, RepoEnvelopeUnresolvedError } from "./repoEnvelope.ts";
|
|
24
24
|
import { isoDuration } from "./reviewWait.ts";
|
|
25
25
|
|
|
26
26
|
/** The content digest of a compiled graph — `sha256(semanticBpmn)[:12]` — the single source of truth
|
|
@@ -62,26 +62,34 @@ export interface DeliveryRunOptions extends DeliveryRunTimeouts {
|
|
|
62
62
|
* cross-correlate. Pass an explicit `runKey` only when you need a reproducible/externally-owned gate
|
|
63
63
|
* scope. */
|
|
64
64
|
runKey?: string;
|
|
65
|
-
/** OPTIONAL `owner/repo`
|
|
66
|
-
*
|
|
67
|
-
* `
|
|
68
|
-
*
|
|
69
|
-
*
|
|
65
|
+
/** OPTIONAL run-level `owner/repo` DEFAULT for `agent` nodes that do not declare their own
|
|
66
|
+
* `repository`. Per issue #739 the repository-provisioning envelope
|
|
67
|
+
* (`io.nanobpm.agentTask.repository`) is seeded PER agent cell from that node's own declared
|
|
68
|
+
* `repository`/`baseBranch` (`injectAgentRepoEnvelopes`, POST-digest, onto each service task's
|
|
69
|
+
* `<zeebe:taskHeaders>`), with this run-level value as the FALLBACK when a node omits it — so a
|
|
70
|
+
* heterogeneous cross-repo graph (each node a different repo) needs no run-level repo at all. Each
|
|
71
|
+
* cell's servicing `senior:*` job provisions an ISOLATED throwaway clone instead of inheriting the
|
|
72
|
+
* worker's launch dir (issue #684/#686 — the same isolation the legacy feature/plan paths got in
|
|
73
|
+
* #685).
|
|
70
74
|
*
|
|
71
|
-
* Issue #729: the envelope is
|
|
72
|
-
* `repoless` but
|
|
73
|
-
* `RepoEnvelopeUnresolvedError` at seed time (a loud launch failure)
|
|
74
|
-
* the shared launch-dir behaviour that let concurrent fan-out
|
|
75
|
+
* Issue #729/#739: the envelope is REQUIRED (per node) unless the run is EXPLICITLY `repoless`. A run
|
|
76
|
+
* that is not `repoless` but has an `agent` node whose repository resolves to neither a declared value
|
|
77
|
+
* nor this run-level fallback throws `RepoEnvelopeUnresolvedError` at seed time (a loud launch failure)
|
|
78
|
+
* rather than silently degrading to the shared launch-dir behaviour that let concurrent fan-out
|
|
79
|
+
* workers clobber one checkout. */
|
|
75
80
|
repository?: string | null;
|
|
76
|
-
/** OPTIONAL base
|
|
77
|
-
* isolated clone (the PRE-PR shape: no PR head
|
|
78
|
-
* branch off this base inside the clone).
|
|
81
|
+
/** OPTIONAL run-level base-branch DEFAULT for `agent` nodes that declare a `repository` but no
|
|
82
|
+
* `baseBranch` — the `ref` the harness checks out in the isolated clone (the PRE-PR shape: no PR head
|
|
83
|
+
* exists yet, so the agent cuts its own `feat/<node.id>` branch off this base inside the clone). A
|
|
84
|
+
* node with a resolvable repository but no base clones the repo's default branch, so this is a
|
|
85
|
+
* convenience default, not a hard requirement. */
|
|
79
86
|
baseBranch?: string | null;
|
|
80
87
|
/** EXPLICIT opt-out of repository provisioning (issue #729). `true` → the run is dispatched with NO
|
|
81
|
-
* isolation envelope (the legacy launch-dir behaviour),
|
|
82
|
-
* no `agent` nodes that touch a checkout). This must be
|
|
83
|
-
* default can never silently share a checkout: when it
|
|
84
|
-
*
|
|
88
|
+
* isolation envelope on ANY node (the per-node headers are stripped, the legacy launch-dir behaviour),
|
|
89
|
+
* for a genuinely repo-less graph (e.g. one with no `agent` nodes that touch a checkout). This must be
|
|
90
|
+
* a CONSCIOUS choice at the dispatch door so the default can never silently share a checkout: when it
|
|
91
|
+
* is not set, every `agent` node must resolve a repository (its own or the run-level fallback) and an
|
|
92
|
+
* unresolved node is a hard launch failure, not a silent no-envelope fallback. */
|
|
85
93
|
repoless?: boolean;
|
|
86
94
|
}
|
|
87
95
|
|
|
@@ -140,7 +148,15 @@ export async function prepareDeliveryGraph(
|
|
|
140
148
|
|
|
141
149
|
const digest = deliveryGraphDigest(compiled.semanticBpmn);
|
|
142
150
|
const processDefinitionId = `${DELIVERY_GRAPH_PROCESS_ID}-${digest}`;
|
|
143
|
-
|
|
151
|
+
// Per-node repository isolation (#739): resolve each agent cell's EFFECTIVE repository/base (its own
|
|
152
|
+
// declared `repository`/`baseBranch`, else the run-level fallback) and INJECT the flattened
|
|
153
|
+
// `io.nanobpm.agentTask.*` envelope task headers onto each agent service task POST-digest, replacing
|
|
154
|
+
// the compiler's digest-stable `__repoSpec` marker. This runs after `rewriteProcessId` and is a pure
|
|
155
|
+
// string transform of the laid-out BPMN (the digest is taken over the pre-injection semantic model,
|
|
156
|
+
// so the env-dependent `cloneTimeoutMs` and the run-level fallback never enter the content address).
|
|
157
|
+
// Throws `RepoEnvelopeUnresolvedError` when some agent cell resolves to NO repository and the run is
|
|
158
|
+
// not `repoless` — a loud launch failure, never a silent launch-dir share (#684/#729).
|
|
159
|
+
const bpmn = injectAgentRepoEnvelopes(rewriteProcessId(compiled.bpmn, processDefinitionId), graph, options);
|
|
144
160
|
|
|
145
161
|
const runKey = options.runKey?.trim() || randomUUID();
|
|
146
162
|
// Normalize the run-level timeouts through isoDuration so a programmatic caller that bypasses the
|
|
@@ -177,39 +193,14 @@ export async function runDeliveryGraph(
|
|
|
177
193
|
const { processDefinitionId, bpmn, nodeInputs } = prep.prepared;
|
|
178
194
|
|
|
179
195
|
await engine.deployResources([{ name: `${processDefinitionId}.bpmn`, content: bpmn, contentType: "application/xml" }]);
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
// `
|
|
188
|
-
// Like plan.ts's fan-out seed it carries `ref = base` but NO `branchCreate` — a run fans out to MANY
|
|
189
|
-
// agent nodes, each needing its own deterministic `feat/<node.id>` branch, so a single run-level
|
|
190
|
-
// envelope can't name one; each agent cuts its own branch off `base` inside the isolated clone (the
|
|
191
|
-
// agent-guide's `feat/*` convention, kept idempotent by the #551 preflight). `baseRef = base` too, so
|
|
192
|
-
// the harness keeps `origin/<base>` reachable for the review 3-dot diff.
|
|
193
|
-
//
|
|
194
|
-
// Issue #729: the envelope is REQUIRED here unless the run is EXPLICITLY `repoless`. A run that is not
|
|
195
|
-
// `repoless` but whose `repository`/`baseBranch` are unresolved throws `RepoEnvelopeUnresolvedError`
|
|
196
|
-
// (a loud launch failure the dispatch door surfaces as a 400 and `dispatchDeliveryGraphRun` marks the
|
|
197
|
-
// run `failed`) rather than silently emitting `{}` and degrading every agent to the shared launch dir.
|
|
198
|
-
// Only an explicit `repoless: true` (a conscious operator opt-in for a genuinely repo-less graph)
|
|
199
|
-
// dispatches with no envelope.
|
|
200
|
-
//
|
|
201
|
-
// `repoless: true` is MUTUALLY EXCLUSIVE with `repository`/`baseBranch`: the dispatch door already
|
|
202
|
-
// rejects the conflicting shape with a 400, but a PROGRAMMATIC caller (test/internal) that bypasses
|
|
203
|
-
// the door could pass both — and silently disable isolation (the `repoless` arm just drops the
|
|
204
|
-
// repo/base and emits `{}`). Re-enforce the exclusivity HERE too (defense-in-depth, mirroring the
|
|
205
|
-
// door) so a conflicting-but-well-meant call fails LOUDLY at seed time rather than quietly degrading
|
|
206
|
-
// to the shared launch dir it named a repo to avoid.
|
|
207
|
-
if (options.repoless === true && (repo !== null || base !== null)) {
|
|
208
|
-
throw new RepoEnvelopeConflictError(
|
|
209
|
-
`repoless run also named repository=${JSON.stringify(repo)} baseBranch=${JSON.stringify(base)}`,
|
|
210
|
-
);
|
|
211
|
-
}
|
|
212
|
-
const repoVars = options.repoless === true ? {} : requireRepoEnvelopeVars(repo ?? "", base, base);
|
|
196
|
+
// Per-node repository isolation (#739): the `io.nanobpm.agentTask.repository` envelope is now seeded
|
|
197
|
+
// PER agent cell as a task header (injected into `bpmn` by `prepareDeliveryGraph` →
|
|
198
|
+
// `injectAgentRepoEnvelopes`), NOT as a single run-root `createInstance` variable. A uniform run-root
|
|
199
|
+
// variable would resolve to ONE repository for every cell (wrong for a cross-repo graph) AND — because
|
|
200
|
+
// the harness lets a variable WIN over a header — would clobber each cell's per-node header. So NO
|
|
201
|
+
// repository variable is seeded here; the resolution + loud-failure invariant (unresolved agent cell
|
|
202
|
+
// on a non-`repoless` run) and the `repoless`/repo-base conflict guard all live in
|
|
203
|
+
// `injectAgentRepoEnvelopes`, which the prepare step above already ran (throwing before deploy).
|
|
213
204
|
const { processInstanceKey } = await engine.createInstance({
|
|
214
205
|
processDefinitionId,
|
|
215
206
|
variables: {
|
|
@@ -219,9 +210,6 @@ export async function runDeliveryGraph(
|
|
|
219
210
|
// node ioMapping in deliveryGraphCompiler). Seeded once at the run root — the same value for
|
|
220
211
|
// every node — and read down into each agent job via `=transcriptUrlBase`.
|
|
221
212
|
[TRANSCRIPT_URL_BASE_VAR]: transcriptUrlBaseFor(),
|
|
222
|
-
// Spread the resolved repository-isolation envelope (empty `{}` only on an explicit `repoless`
|
|
223
|
-
// run — see above) LAST so it never clobbers the other run-root vars.
|
|
224
|
-
...repoVars,
|
|
225
213
|
},
|
|
226
214
|
});
|
|
227
215
|
// The engine can yield a numeric key; `DeliveryRunHandle.processInstanceKey` is typed `string` and
|
|
@@ -245,6 +233,117 @@ function rewriteProcessId(bpmn: string, processDefinitionId: string): string {
|
|
|
245
233
|
.replace(`bpmnElement="${DELIVERY_GRAPH_PROCESS_ID}"`, `bpmnElement="${processDefinitionId}"`);
|
|
246
234
|
}
|
|
247
235
|
|
|
236
|
+
/** The EFFECTIVE repository/base resolved for one delivery-graph `agent` cell (#739): its OWN declared
|
|
237
|
+
* `repository`/`baseBranch`, falling back to the run-level dispatch value for either that it omits. */
|
|
238
|
+
export interface ResolvedAgentRepo {
|
|
239
|
+
/** The agent node's id (for diagnostics / the unresolved-node error). */
|
|
240
|
+
nodeId: string;
|
|
241
|
+
/** The effective `owner/repo` — the node's declared `repository`, else the run-level fallback, else null. */
|
|
242
|
+
repository: string | null;
|
|
243
|
+
/** The effective base branch — the node's declared `baseBranch`, else the run-level fallback, else null. */
|
|
244
|
+
baseBranch: string | null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Trim a caller/authored string to a non-empty value or null (a blank/whitespace/absent field is "not
|
|
248
|
+
* declared", so it falls back to the run level). */
|
|
249
|
+
function trimOrNull(value: unknown): string | null {
|
|
250
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Resolve every `agent` node's EFFECTIVE repository + base (#739): the node's own declared
|
|
254
|
+
* `repository`/`baseBranch` wins, else the run-level dispatch `repository`/`baseBranch` is the fallback
|
|
255
|
+
* default. Non-agent nodes carry no repository, so they are excluded. This is the ONE resolution rule
|
|
256
|
+
* the injection AND the unresolved-node invariant both derive from, so the two can never disagree on
|
|
257
|
+
* what a cell resolves to. */
|
|
258
|
+
export function resolveAgentNodeRepos(graph: DeliveryGraph, options: Pick<DeliveryRunOptions, "repository" | "baseBranch">): ResolvedAgentRepo[] {
|
|
259
|
+
const runRepo = trimOrNull(options.repository);
|
|
260
|
+
const runBase = trimOrNull(options.baseBranch);
|
|
261
|
+
const out: ResolvedAgentRepo[] = [];
|
|
262
|
+
for (const node of graph.nodes) {
|
|
263
|
+
if (node.kind !== "agent") continue;
|
|
264
|
+
out.push({
|
|
265
|
+
nodeId: node.id,
|
|
266
|
+
repository: trimOrNull(node.agent.repository) ?? runRepo,
|
|
267
|
+
baseBranch: trimOrNull(node.agent.baseBranch) ?? runBase,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** The ids of every `agent` node that resolves to NO usable repository (#739) — it declared none AND no
|
|
274
|
+
* run-level fallback applies (or the resolved value is not a plain `owner/repo`). On a non-`repoless`
|
|
275
|
+
* run these are the cells that would silently share the worker's launch dir, so the runner throws when
|
|
276
|
+
* this is non-empty (issue #684/#729). A graph in which EVERY agent node resolves a repository (declared
|
|
277
|
+
* or defaulted) is fully node-provisioned and needs neither a run-level repository nor `repoless`. */
|
|
278
|
+
export function unresolvedAgentRepoNodes(graph: DeliveryGraph, options: Pick<DeliveryRunOptions, "repository" | "baseBranch">): string[] {
|
|
279
|
+
return resolveAgentNodeRepos(graph, options)
|
|
280
|
+
.filter((r) => !isResolvableRepo(r.repository))
|
|
281
|
+
.map((r) => r.nodeId);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Escape a scalar value for an XML double-quote attribute (the injected `<zeebe:header>` value). The
|
|
285
|
+
* envelope values are URLs / `owner/repo` / `blob:none` / stringified booleans+numbers — none carry
|
|
286
|
+
* XML-hostile characters — but escape defensively so the injected BPMN is always well-formed. */
|
|
287
|
+
function xmlAttr(value: string): string {
|
|
288
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Replace each `agent` service task's digest-stable `__repoSpec` marker task header (emitted by the
|
|
292
|
+
* compiler, carrying the node's DECLARED `{ repository, baseBranch }`) with the flattened, EFFECTIVE
|
|
293
|
+
* `io.nanobpm.agentTask.*` repository-isolation headers (#739) — the per-CELL channel the c8ctl harness
|
|
294
|
+
* reads (headers ∪ variables). The effective repo/base is the marker's declared value, else the
|
|
295
|
+
* run-level fallback in `options`. This is a PURE post-digest string transform of the laid-out BPMN
|
|
296
|
+
* (the marker is env-free graph content in the digest; the injected `cloneTimeoutMs` + run-level
|
|
297
|
+
* fallback are NOT), so the content address is unaffected.
|
|
298
|
+
*
|
|
299
|
+
* Invariants (issue #684/#729), enforced here so a run that cannot isolate fails LOUDLY before deploy:
|
|
300
|
+
* • `repoless: true` is mutually exclusive with a run-level `repository`/`baseBranch` — a caller that
|
|
301
|
+
* bypasses the dispatch door and passes both throws `RepoEnvelopeConflictError` (never silently
|
|
302
|
+
* disables isolation).
|
|
303
|
+
* • On a non-`repoless` run, ANY agent cell that resolves to no repository throws
|
|
304
|
+
* `RepoEnvelopeUnresolvedError` (never a silent launch-dir share).
|
|
305
|
+
* On a `repoless` run every marker is stripped (no envelope — the conscious checkout-less opt-out). */
|
|
306
|
+
function injectAgentRepoEnvelopes(bpmn: string, graph: DeliveryGraph, options: DeliveryRunOptions): string {
|
|
307
|
+
const repoless = options.repoless === true;
|
|
308
|
+
const runRepo = trimOrNull(options.repository);
|
|
309
|
+
const runBase = trimOrNull(options.baseBranch);
|
|
310
|
+
if (repoless && (runRepo !== null || runBase !== null)) {
|
|
311
|
+
throw new RepoEnvelopeConflictError(
|
|
312
|
+
`repoless run also named repository=${JSON.stringify(runRepo)} baseBranch=${JSON.stringify(runBase)}`,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
if (!repoless) {
|
|
316
|
+
const unresolved = unresolvedAgentRepoNodes(graph, options);
|
|
317
|
+
if (unresolved.length > 0) {
|
|
318
|
+
throw new RepoEnvelopeUnresolvedError(
|
|
319
|
+
`agent node(s) resolve to no repository and the run is not repoless: ${unresolved.join(", ")} — ` +
|
|
320
|
+
"declare each node's `repository`, supply a run-level `repository`/`baseBranch` fallback, or dispatch `repoless: true`",
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const markerKey = AGENT_REPO_SPEC_HEADER.replace(/[.]/g, "\\.");
|
|
325
|
+
// The compiler emits the marker as a single `<zeebe:taskHeaders>` block per agent task; match the whole
|
|
326
|
+
// block (with its indentation) so a stripped cell leaves no empty `<zeebe:taskHeaders/>` behind.
|
|
327
|
+
const blockRe = new RegExp(
|
|
328
|
+
`([ \\t]*)<zeebe:taskHeaders>\\r?\\n[ \\t]*<zeebe:header key="${markerKey}" value=(?:'([^']*)'|"([^"]*)")\\s*/>\\r?\\n[ \\t]*</zeebe:taskHeaders>(\\r?\\n)`,
|
|
329
|
+
"g",
|
|
330
|
+
);
|
|
331
|
+
return bpmn.replace(blockRe, (_full, indent: string, sq: string | undefined, dq: string | undefined, tail: string) => {
|
|
332
|
+
const raw = sq ?? (dq ?? "").replace(/'/g, "'").replace(/"/g, '"').replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
|
333
|
+
const declared: { repository: string | null; baseBranch: string | null } = JSON.parse(raw);
|
|
334
|
+
// `repoless` → strip the block entirely (no isolation envelope, the launch-dir fallback).
|
|
335
|
+
if (repoless) return "";
|
|
336
|
+
const effRepo = trimOrNull(declared.repository) ?? runRepo;
|
|
337
|
+
const effBase = trimOrNull(declared.baseBranch) ?? runBase;
|
|
338
|
+
// The unresolved invariant above guarantees a resolvable repo here on a non-repoless run.
|
|
339
|
+
const envelope = agentNodeRepoEnvelope(effRepo ?? "", effBase);
|
|
340
|
+
const flat = flattenAgentTaskEnvelope(envelope);
|
|
341
|
+
const headerLines = Object.entries(flat).map(([k, v]) => `${indent} <zeebe:header key="${xmlAttr(k)}" value="${xmlAttr(v)}" />`);
|
|
342
|
+
if (headerLines.length === 0) return "";
|
|
343
|
+
return `${indent}<zeebe:taskHeaders>\n${headerLines.join("\n")}\n${indent}</zeebe:taskHeaders>${tail}`;
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
248
347
|
/** The idempotency preflight prepended to EVERY `agent` node's `appendPrompt` (issue #551). A delivery
|
|
249
348
|
* agent node dispatches a raw `senior:feature` job with no `feature_runs` idempotency row and no
|
|
250
349
|
* PR-existence guard, and the job carries retries — so an idle/timeout re-dispatch hands the SAME
|
package/app/repoEnvelope.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { isCommitSha } from "./world/index.ts";
|
|
|
13
13
|
|
|
14
14
|
/** The reserved namespace key the c8ctl nano worker harness reads the agent-task envelope from
|
|
15
15
|
* (headers ∪ variables, deep-merged). See c8ctl `normalizeTaskEnvelope`. */
|
|
16
|
-
const AGENT_TASK_NS = "io.nanobpm.agentTask";
|
|
16
|
+
export const AGENT_TASK_NS = "io.nanobpm.agentTask";
|
|
17
17
|
|
|
18
18
|
/** The ONE canonical `owner/repo` allowlist, shared by `repoEnvelopeVars` (which degrades to `{}` on a
|
|
19
19
|
* miss) and `requireRepoEnvelopeVars` (which throws on a miss) so the two can never drift apart. The
|
|
@@ -25,6 +25,47 @@ function isPlainOwnerRepo(repo: string): boolean {
|
|
|
25
25
|
return OWNER_REPO_RE.test(repo) && !/\.git$/i.test(repo);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/** Exported predicate mirroring the internal `owner/repo` allowlist (the ONE shared by
|
|
29
|
+
* `repoEnvelopeVars`/`requireRepoEnvelopeVars`), so a caller that must resolve a per-node effective
|
|
30
|
+
* repository BEFORE seeding an envelope (delivery-graph per-node isolation, #739) uses exactly the same
|
|
31
|
+
* gate — a node whose declared/defaulted repository is not a plain `owner/repo` is "unresolved" by the
|
|
32
|
+
* identical rule the seed helper throws on, never a second divergent check. */
|
|
33
|
+
export function isResolvableRepo(repo: unknown): repo is string {
|
|
34
|
+
return typeof repo === "string" && isPlainOwnerRepo(repo.trim());
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Flatten a repository-isolation envelope object (as built by {@link repoEnvelopeVars} —
|
|
38
|
+
* `{ "io.nanobpm.agentTask": { repository: { … } } }`) into the FLAT, dotted `io.nanobpm.agentTask.*`
|
|
39
|
+
* key → string-value pairs the c8ctl worker harness reads from a service task's `<zeebe:taskHeaders>`
|
|
40
|
+
* (the same channel the retired `io.nanobpm.agentTask.task.prompt` header used; the harness deep-merges
|
|
41
|
+
* headers ∪ variables and reconstructs the nested envelope from dotted keys — see `normalizeTaskEnvelope`).
|
|
42
|
+
*
|
|
43
|
+
* Header VALUES are strings (a Camunda job header is `string → string`), so scalar leaves are stringified
|
|
44
|
+
* (`true`/`600000`/`https://…`); the harness coerces them back at the point of use. This is the ONLY
|
|
45
|
+
* per-CELL delivery channel for the envelope: a `createInstance` variable can only seed the reserved
|
|
46
|
+
* `io.nanobpm.agentTask` key ONCE at the run root (uniform for every cell), and a `<zeebe:ioMapping>`
|
|
47
|
+
* target with dots creates a NESTED `{io:{nanobpm:{agentTask:…}}}` object the flat-key harness never
|
|
48
|
+
* reads — so per-node isolation (#739) rides task headers, injected by the runner post-digest. Returns an
|
|
49
|
+
* empty map for an empty/`{}` envelope (an unresolved/`repoless` cell → no headers, the launch-dir
|
|
50
|
+
* fallback). */
|
|
51
|
+
export function flattenAgentTaskEnvelope(envelope: Record<string, unknown>): Record<string, string> {
|
|
52
|
+
const out: Record<string, string> = {};
|
|
53
|
+
const walk = (prefix: string, value: unknown): void => {
|
|
54
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
55
|
+
for (const [k, v] of Object.entries(value)) {
|
|
56
|
+
walk(prefix === "" ? k : `${prefix}.${k}`, v);
|
|
57
|
+
}
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
// Scalar leaf — a taskHeader value is a string, so stringify booleans/numbers verbatim; the harness
|
|
61
|
+
// coerces (`singleBranch` truthy, `cloneTimeoutMs` numeric) at the point of use.
|
|
62
|
+
if (typeof value === "string") out[prefix] = value;
|
|
63
|
+
else if (typeof value === "number" || typeof value === "boolean") out[prefix] = String(value);
|
|
64
|
+
};
|
|
65
|
+
walk("", envelope);
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
28
69
|
/** Raised by `requireRepoEnvelopeVars` when the repository-isolation envelope is REQUIRED on a fan-out
|
|
29
70
|
* path but its inputs are unresolved (a blank base/head `ref`, or a `repo` that is not a plain
|
|
30
71
|
* `owner/repo`). Issue #729: the fan-out dispatch paths must fail loudly here rather than let
|
|
@@ -154,6 +195,37 @@ export function repoEnvelopeVars(
|
|
|
154
195
|
};
|
|
155
196
|
}
|
|
156
197
|
|
|
198
|
+
/** Build the repository-isolation envelope for ONE delivery-graph `agent` cell (#739), keyed under the
|
|
199
|
+
* reserved {@link AGENT_TASK_NS} namespace exactly like {@link repoEnvelopeVars}, but with the delivery
|
|
200
|
+
* per-node semantics: the `repository.url` is emitted whenever `repo` is a plain `owner/repo` (so the
|
|
201
|
+
* harness clones it) EVEN WHEN no base branch is known, and `ref`/`baseRef` are emitted only when `base`
|
|
202
|
+
* is present. This is the key difference from {@link repoEnvelopeVars} (which emits NOTHING without a
|
|
203
|
+
* `ref`): a cross-repo graph node carries only its `owner/repo` (an issue ref names no branch), so when
|
|
204
|
+
* neither the node nor the run declares a base the cell must still provision an isolated clone — of the
|
|
205
|
+
* repository's DEFAULT branch. The harness `provisionRepo` omits `--branch` when `ref` is blank, so a
|
|
206
|
+
* `ref`-less envelope clones the default branch (each agent then cuts its own `feat/<node.id>` branch
|
|
207
|
+
* inside the isolated clone, unchanged). Returns `{}` for a `repo` that is not a plain `owner/repo` (an
|
|
208
|
+
* unresolved cell — the runner's invariant rejects the run before this degrades to a launch-dir share).
|
|
209
|
+
* Blobless single-branch shaping + `cloneTimeoutMs` (issues #287/#694) are emitted on every cell. */
|
|
210
|
+
export function agentNodeRepoEnvelope(repo: string, base: string | null): Record<string, unknown> {
|
|
211
|
+
if (!isPlainOwnerRepo(repo)) return {};
|
|
212
|
+
const ref = typeof base === "string" && base.trim() !== "" ? base.trim() : null;
|
|
213
|
+
return {
|
|
214
|
+
[AGENT_TASK_NS]: {
|
|
215
|
+
repository: {
|
|
216
|
+
provider: "github",
|
|
217
|
+
url: `https://github.com/${repo}.git`,
|
|
218
|
+
singleBranch: true,
|
|
219
|
+
filter: "blob:none",
|
|
220
|
+
cloneTimeoutMs: cloneTimeoutMs(),
|
|
221
|
+
// A per-node base (declared, or defaulted from the run level) — the branch the agent cuts its
|
|
222
|
+
// `feat/<node.id>` off. Omitted when unknown so the harness clones the repo's default branch.
|
|
223
|
+
...(ref ? { ref, baseRef: ref } : {}),
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
157
229
|
/** The REQUIRED-envelope guard for the fan-out dispatch paths (issue #729). Same signature and output
|
|
158
230
|
* as `repoEnvelopeVars`, but THROWS `RepoEnvelopeUnresolvedError` on an unresolved input (a blank
|
|
159
231
|
* base/head `ref`, or a `repo` that is not a plain `owner/repo`) instead of degrading to the silent
|
|
@@ -35,7 +35,9 @@ function handAuthored(behind: string | null, issues: string[]): AnyGraph {
|
|
|
35
35
|
nodes.push({
|
|
36
36
|
id: `open-${n}`,
|
|
37
37
|
kind: "agent",
|
|
38
|
-
|
|
38
|
+
// #739: the generator stamps each agent cell with the issue's OWN repository, so a multi-repo
|
|
39
|
+
// sequence provisions each cell's isolation envelope from that issue's repo.
|
|
40
|
+
agent: { jobType: "senior:feature", prompt: `Implement ${issue} and open a PR.`, repository: issue.split("#")[0] },
|
|
39
41
|
emits: [{ name: "pr", type: "pr" }],
|
|
40
42
|
});
|
|
41
43
|
nodes.push({ id: `land-${n}`, kind: "connector", connector: { target: "converge-merge", payload: { pr: `open-${n}.pr` } } });
|
|
@@ -106,6 +108,15 @@ test("sequenceIssues: each issue emits agent(senior:feature,emits pr) → connec
|
|
|
106
108
|
assert(graph.edges.some((e) => e.from === "open-1.pr" && e.to === "merged-1"));
|
|
107
109
|
});
|
|
108
110
|
|
|
111
|
+
test("sequenceIssues: each agent cell is stamped with its OWN issue's repository — a cross-repo sequence (#739)", () => {
|
|
112
|
+
// Two issues in DIFFERENT repos → each `open-*` agent node declares that issue's repository, so the
|
|
113
|
+
// delivery runner provisions each cell's isolation envelope from its own repo (no uniform run-level
|
|
114
|
+
// repository, no `repoless`). This is the generator half of #739.
|
|
115
|
+
const graph = ok({ issues: ["acme/one#1", "beta/two#2"] });
|
|
116
|
+
assertEquals(graph.nodes.find((n) => n.id === "open-1").agent.repository, "acme/one");
|
|
117
|
+
assertEquals(graph.nodes.find((n) => n.id === "open-2").agent.repository, "beta/two");
|
|
118
|
+
});
|
|
119
|
+
|
|
109
120
|
test("sequenceIssues: merge/epic gates carry a realistic poll budget (not the 30-min default trap)", () => {
|
|
110
121
|
const graph = ok({ behind: "acme/repo#9", issues: ["acme/repo#1"] });
|
|
111
122
|
const gate = graph.nodes.find((n) => n.id === "gate-epic");
|
package/app/sequenceIssues.ts
CHANGED
|
@@ -107,9 +107,10 @@ interface BuiltGate {
|
|
|
107
107
|
credentialEnv?: string;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
/** A parsed `issues[]` entry: the
|
|
110
|
+
/** A parsed `issues[]` entry: the parsed issue (carrying its `owner/repo#N` target, used to stamp the
|
|
111
|
+
* generated agent node's own `repository` — issue #739) plus its optional interleaved gate (#740). */
|
|
111
112
|
interface ParsedEntry {
|
|
112
|
-
|
|
113
|
+
issue: ParsedIssue;
|
|
113
114
|
gate: BuiltGate | null;
|
|
114
115
|
}
|
|
115
116
|
|
|
@@ -279,7 +280,7 @@ export function buildSequenceGraph(intent: unknown): SequenceIssuesResult {
|
|
|
279
280
|
issues.push(...parsedGate.errors);
|
|
280
281
|
gate = parsedGate.gate ?? null;
|
|
281
282
|
}
|
|
282
|
-
parsedEntries.push({
|
|
283
|
+
parsedEntries.push({ issue: parsed, gate });
|
|
283
284
|
});
|
|
284
285
|
}
|
|
285
286
|
|
|
@@ -338,7 +339,8 @@ export function buildSequenceGraph(intent: unknown): SequenceIssuesResult {
|
|
|
338
339
|
/** Assemble the canonical node/edge chain for the (already-validated) parsed entries + optional gate.
|
|
339
340
|
* Each entry emits `agent → connector[converge-merge] → wait[pr, merged]`; an entry with an
|
|
340
341
|
* interleaved `gate` gets a leading `wait[<gate.kind>]` node spliced between the prior sequence step
|
|
341
|
-
* and its agent, so the agent starts only once the prior issue merged AND the gate went green.
|
|
342
|
+
* and its agent, so the agent starts only once the prior issue merged AND the gate went green. Each
|
|
343
|
+
* agent node is stamped with its own `repository` (issue #739) from the issue it implements. */
|
|
342
344
|
function assembleGraph(entries: ParsedEntry[], behindKey: string | null): DeliveryGraph {
|
|
343
345
|
const nodes: DeliveryNode[] = [];
|
|
344
346
|
const edges: DeliveryEdge[] = [];
|
|
@@ -361,12 +363,13 @@ function assembleGraph(entries: ParsedEntry[], behindKey: string | null): Delive
|
|
|
361
363
|
}
|
|
362
364
|
|
|
363
365
|
entries.forEach((entry, i) => {
|
|
366
|
+
const issue = entry.issue;
|
|
364
367
|
const n = i + 1;
|
|
365
368
|
const openId = `open-${n}`;
|
|
366
369
|
const landId = `land-${n}`;
|
|
367
370
|
const mergedId = `merged-${n}`;
|
|
368
371
|
const prRef = `${openId}.pr`;
|
|
369
|
-
const issueKey =
|
|
372
|
+
const issueKey = issue.planKey;
|
|
370
373
|
|
|
371
374
|
// The sequence predecessor whose completion releases THIS issue: the prior issue's merge, else the
|
|
372
375
|
// leading epic gate for the first issue (null when the first issue is ungated by `behind`).
|
|
@@ -395,11 +398,14 @@ function assembleGraph(entries: ParsedEntry[], behindKey: string | null): Delive
|
|
|
395
398
|
agentPredecessor = gateId;
|
|
396
399
|
}
|
|
397
400
|
|
|
398
|
-
// agent → opens the PR, emits it as a typed `pr` fact the downstream nodes late-bind.
|
|
401
|
+
// agent → opens the PR, emits it as a typed `pr` fact the downstream nodes late-bind. Stamp the
|
|
402
|
+
// node's OWN `repository` (#739) from the `owner/repo#N` it implements, so a cross-repo sequence
|
|
403
|
+
// provisions each cell's isolation envelope node-locally — no uniform run-level repo, no `repoless`.
|
|
404
|
+
// The base branch is left to the run-level fallback (an issue ref names no branch — issue #739 Notes).
|
|
399
405
|
nodes.push({
|
|
400
406
|
id: openId,
|
|
401
407
|
kind: "agent",
|
|
402
|
-
agent: { jobType: AGENT_JOB_TYPE, prompt: `Implement ${issueKey} and open a PR
|
|
408
|
+
agent: { jobType: AGENT_JOB_TYPE, prompt: `Implement ${issueKey} and open a PR.`, repository: issue.repo },
|
|
403
409
|
emits: [{ ...PR_EMIT }],
|
|
404
410
|
});
|
|
405
411
|
// connector[converge-merge] → drive the opened PR through review convergence + the merge loop.
|
|
@@ -433,7 +439,7 @@ function assembleGraph(entries: ParsedEntry[], behindKey: string | null): Delive
|
|
|
433
439
|
const gateCount = entries.filter((e) => e.gate).length;
|
|
434
440
|
const name =
|
|
435
441
|
entries.length === 1
|
|
436
|
-
? `sequence ${entries[0].
|
|
442
|
+
? `sequence ${entries[0].issue.planKey}`
|
|
437
443
|
: `sequence ${entries.length} issues${gateCount > 0 ? ` with ${gateCount} gate${gateCount === 1 ? "" : "s"}` : ""}${behindKey ? ` behind ${behindKey}` : ""}`;
|
|
438
444
|
|
|
439
445
|
return { name, nodes, edges };
|
package/openapi.yaml
CHANGED
|
@@ -1849,6 +1849,30 @@ components:
|
|
|
1849
1849
|
type: string
|
|
1850
1850
|
maxLength: 20000
|
|
1851
1851
|
description: OPTIONAL steering prompt appended to the node's job brief.
|
|
1852
|
+
repository:
|
|
1853
|
+
type: string
|
|
1854
|
+
maxLength: 255
|
|
1855
|
+
pattern: '^[A-Za-z0-9-]+/(?!.*\.[Gg][Ii][Tt]$)[A-Za-z0-9._-]+$'
|
|
1856
|
+
description: >-
|
|
1857
|
+
OPTIONAL per-node `owner/repo` this agent node implements against (#739). A delivery
|
|
1858
|
+
graph provisions the `io.nanobpm.agentTask.repository` isolation envelope PER agent
|
|
1859
|
+
cell from THIS field, so a genuinely cross-repo graph (each node a different repo)
|
|
1860
|
+
isolates correctly without the operator ticking `repoless`. Absent → the node falls
|
|
1861
|
+
back to the run-level dispatch `repository`. When set it must be exactly `owner/repo`
|
|
1862
|
+
(a trailing `.git` and any non-`owner/repo` shape are rejected at submit), the same
|
|
1863
|
+
allowlist `repoEnvelopeVars`/the dispatch door apply. The `sequenceIssues` generator
|
|
1864
|
+
populates this automatically from the `owner/repo#N` each node implements.
|
|
1865
|
+
baseBranch:
|
|
1866
|
+
type: string
|
|
1867
|
+
maxLength: 255
|
|
1868
|
+
pattern: '^(?![/.-])(?!.*[/.]$)(?!.*\.\.)(?!.*//)(?!.*/\.)(?!.*\.lock(?:/|$))[A-Za-z0-9._/-]+$'
|
|
1869
|
+
description: >-
|
|
1870
|
+
OPTIONAL per-node base branch this agent node branches off (#739) — the `ref` the
|
|
1871
|
+
harness checks out in the isolated clone (the pre-PR shape: the agent cuts its own
|
|
1872
|
+
`feat/<node.id>` branch off this base). Absent → the run-level dispatch `baseBranch`
|
|
1873
|
+
(else the node's repository default branch). A value that is not a plausible git
|
|
1874
|
+
branch name is rejected at submit; the pattern mirrors the authoritative server-side
|
|
1875
|
+
gate (`isPlausibleBranchName`, app/baseBranch.ts).
|
|
1852
1876
|
converge:
|
|
1853
1877
|
type: boolean
|
|
1854
1878
|
description: >-
|
|
@@ -2098,17 +2122,20 @@ components:
|
|
|
2098
2122
|
action — this request carries NO graph and NO token (the graph is already staged; the operator's
|
|
2099
2123
|
click is the approval).
|
|
2100
2124
|
|
|
2101
|
-
Repository provisioning is REQUIRED by default (#729)
|
|
2102
|
-
`repository` + `baseBranch` its `agent` nodes
|
|
2103
|
-
for a genuinely checkout-less graph
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2125
|
+
Repository provisioning is REQUIRED by default (#729) unless the graph is FULLY node-provisioned
|
|
2126
|
+
(#739). The run EITHER names the run-level `repository` + `baseBranch` its `agent` nodes fall back
|
|
2127
|
+
to, OR opts out with `repoless: true` for a genuinely checkout-less graph, OR — when EVERY `agent`
|
|
2128
|
+
node declares its OWN `repository` (a cross-repo graph, #739) — supplies NEITHER (the node-
|
|
2129
|
+
provisioned shape). Modeled as `oneOf` named variants (Camunda REST v2 pattern, as the
|
|
2130
|
+
convergence/plan/feature start bodies are) so the runtime rejects the "both" shape AT THE EDGE
|
|
2131
|
+
with a 400 that names the allowed shapes. The remaining invariant — that no `agent` node resolves
|
|
2132
|
+
to NO repository on a non-`repoless` run — is enforced after the staged graph is loaded (the door
|
|
2133
|
+
has the graph; the request alone does not), so a graph with an unprovisioned node still fails
|
|
2134
|
+
loudly rather than silently sharing the worker's launch dir (the issue #684 field failure).
|
|
2109
2135
|
oneOf:
|
|
2110
2136
|
- $ref: "#/components/schemas/DeliveryGraphDispatchWithRepository"
|
|
2111
2137
|
- $ref: "#/components/schemas/DeliveryGraphDispatchRepoless"
|
|
2138
|
+
- $ref: "#/components/schemas/DeliveryGraphDispatchNodeProvisioned"
|
|
2112
2139
|
DeliveryGraphDispatchWithRepository:
|
|
2113
2140
|
description: >-
|
|
2114
2141
|
The repository-provisioned dispatch shape (#729): names the `repository` + `baseBranch` the run's
|
|
@@ -2228,6 +2255,52 @@ components:
|
|
|
2228
2255
|
fields are not members of this variant, so supplying either alongside `repoless: true` fails
|
|
2229
2256
|
`oneOf` matching and is a 400. To provision a repository, use the repository variant (supply
|
|
2230
2257
|
`repository` + `baseBranch`) and omit `repoless` entirely.
|
|
2258
|
+
DeliveryGraphDispatchNodeProvisioned:
|
|
2259
|
+
description: >-
|
|
2260
|
+
The FULLY node-provisioned dispatch shape (#739): supplies NEITHER a run-level `repository`/
|
|
2261
|
+
`baseBranch` NOR `repoless`, for a cross-repo graph in which EVERY `agent` node declares its own
|
|
2262
|
+
`repository` (so the run needs no uniform fallback and no checkout-less opt-out). The runner
|
|
2263
|
+
seeds each agent cell's `io.nanobpm.agentTask.repository` isolation envelope from that node's own
|
|
2264
|
+
declared repository. The post-load invariant still holds: if ANY `agent` node resolves to no
|
|
2265
|
+
repository under this shape (it declared none and there is no run-level fallback), the dispatch
|
|
2266
|
+
fails loudly with a 400 rather than silently sharing the launch dir — so this shape is only valid
|
|
2267
|
+
for a graph whose every agent node is self-provisioned. `repository`/`baseBranch`/`repoless` are
|
|
2268
|
+
not members of this variant (`additionalProperties: false`), so supplying any of them selects a
|
|
2269
|
+
different variant instead.
|
|
2270
|
+
type: object
|
|
2271
|
+
additionalProperties: false
|
|
2272
|
+
required:
|
|
2273
|
+
- digest
|
|
2274
|
+
properties:
|
|
2275
|
+
digest:
|
|
2276
|
+
type: string
|
|
2277
|
+
description: The staged proposal's content digest (its primary key) — the proposal to dispatch.
|
|
2278
|
+
idempotencyKey:
|
|
2279
|
+
type: string
|
|
2280
|
+
maxLength: 255
|
|
2281
|
+
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.
|
|
2282
|
+
nodeTimeout:
|
|
2283
|
+
type: string
|
|
2284
|
+
pattern: '^[Pp](?!$)(\d+[Yy])?(\d+[Mm])?(\d+[Ww])?(\d+[Dd])?([Tt](?=\d)(\d+[Hh])?(\d+[Mm])?(\d+[Ss])?)?$'
|
|
2285
|
+
maxLength: 64
|
|
2286
|
+
description: >-
|
|
2287
|
+
OPTIONAL run-level ISO-8601 SLA timeout for `agent`/`connector` nodes (#505) — the
|
|
2288
|
+
bounded-timeout → escalate boundary bound every such node inherits unless it declares its own
|
|
2289
|
+
per-node `timeout`. Absent → the `PT1H` default. An invalid duration is rejected at submit.
|
|
2290
|
+
probeTimeout:
|
|
2291
|
+
type: string
|
|
2292
|
+
pattern: '^[Pp](?!$)(\d+[Yy])?(\d+[Mm])?(\d+[Ww])?(\d+[Dd])?([Tt](?=\d)(\d+[Hh])?(\d+[Mm])?(\d+[Ss])?)?$'
|
|
2293
|
+
maxLength: 64
|
|
2294
|
+
description: >-
|
|
2295
|
+
OPTIONAL run-level ISO-8601 poll budget for `wait` gates (#505) before they escalate. Absent →
|
|
2296
|
+
the `PT30M` default. An invalid duration is rejected at submit.
|
|
2297
|
+
escalationSlaTimeout:
|
|
2298
|
+
type: string
|
|
2299
|
+
pattern: '^[Pp](?!$)(\d+[Yy])?(\d+[Mm])?(\d+[Ww])?(\d+[Dd])?([Tt](?=\d)(\d+[Hh])?(\d+[Mm])?(\d+[Ss])?)?$'
|
|
2300
|
+
maxLength: 64
|
|
2301
|
+
description: >-
|
|
2302
|
+
OPTIONAL run-level ISO-8601 SLA for `human` nodes (#505) before they record an `escalated`
|
|
2303
|
+
outcome. Absent → the `P1D` default. An invalid duration is rejected at submit.
|
|
2231
2304
|
DeliveryGraphDismissRequest:
|
|
2232
2305
|
description: >-
|
|
2233
2306
|
The OPERATOR dismiss request (#520). The cockpit's staged-proposals grid posts the content
|
|
@@ -4528,6 +4601,16 @@ paths:
|
|
|
4528
4601
|
type: string
|
|
4529
4602
|
maxLength: 20000
|
|
4530
4603
|
description: OPTIONAL steering prompt appended to the node's job brief.
|
|
4604
|
+
repository:
|
|
4605
|
+
type: string
|
|
4606
|
+
maxLength: 255
|
|
4607
|
+
pattern: ^[A-Za-z0-9-]+/(?!.*\.[Gg][Ii][Tt]$)[A-Za-z0-9._-]+$
|
|
4608
|
+
description: OPTIONAL per-node `owner/repo` this agent node implements against (#739). A delivery graph provisions the `io.nanobpm.agentTask.repository` isolation envelope PER agent cell from THIS field, so a genuinely cross-repo graph (each node a different repo) isolates correctly without the operator ticking `repoless`. Absent → the node falls back to the run-level dispatch `repository`. When set it must be exactly `owner/repo` (a trailing `.git` and any non-`owner/repo` shape are rejected at submit), the same allowlist `repoEnvelopeVars`/the dispatch door apply. The `sequenceIssues` generator populates this automatically from the `owner/repo#N` each node implements.
|
|
4609
|
+
baseBranch:
|
|
4610
|
+
type: string
|
|
4611
|
+
maxLength: 255
|
|
4612
|
+
pattern: ^(?![/.-])(?!.*[/.]$)(?!.*\.\.)(?!.*//)(?!.*/\.)(?!.*\.lock(?:/|$))[A-Za-z0-9._/-]+$
|
|
4613
|
+
description: "OPTIONAL per-node base branch this agent node branches off (#739) — the `ref` the harness checks out in the isolated clone (the pre-PR shape: the agent cuts its own `feat/<node.id>` branch off this base). Absent → the run-level dispatch `baseBranch` (else the node's repository default branch). A value that is not a plausible git branch name is rejected at submit; the pattern mirrors the authoritative server-side gate (`isPlausibleBranchName`, app/baseBranch.ts)."
|
|
4531
4614
|
converge:
|
|
4532
4615
|
type: boolean
|
|
4533
4616
|
description: 'OPTIONAL first-class CONVERGE policy (ADR 0006 §3 / S5) — a DECLARED, compiler- validated completion-policy flag on this cell node. It declares that the node''s opened PR is to be driven through the review-convergence loop to green as an edge-gated completion policy; this slice adds and validates the flag, with the delivery-graph execution wiring that consumes it landing in a follow-up slice. It supersedes (in intent) the emergent `feature.bpmn` `gw-converge` gateway and the "un-draft + merge #B" prompt prose a delivery-graph `agent` node used to smuggle. Converge and merge are SEPARABLE phases; a node may converge without merging (stop at green and gate the landing behind a downstream node).'
|
|
@@ -318,23 +318,69 @@ describe("dispatchDeliveryGraph — operator dispatch by staged-proposal digest"
|
|
|
318
318
|
// dispatch envelope-less and let concurrent fan-out agents share (and clobber) the worker's launch
|
|
319
319
|
// dir (issue #684's field failure re-opened as a silent fallback). This "neither/nor" shape is now
|
|
320
320
|
// rejected at the EDGE by the request `oneOf` (the two allowed variants), not only inside the door.
|
|
321
|
-
test("a dispatch with
|
|
321
|
+
test("a bare dispatch of a graph with an UNPROVISIONED agent node is rejected → 400, nothing launched (#729/#739)", async () => {
|
|
322
322
|
const app = await boot();
|
|
323
323
|
assert.ok(app.api);
|
|
324
324
|
const api = app.api;
|
|
325
|
-
|
|
325
|
+
// SIDE_EFFECTING's `open-b` agent node declares NO repository, and this bare dispatch supplies no
|
|
326
|
+
// run-level fallback and no `repoless` — so the cell would silently share the worker's launch dir.
|
|
327
|
+
// The door rejects it LOUDLY (#729) rather than launching or degrading (#684).
|
|
328
|
+
const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: SIDE_EFFECTING });
|
|
326
329
|
const res = await api.call<{ ok?: boolean; error?: string }>("dispatchDeliveryGraph", {
|
|
327
330
|
body: { digest: staged.body.digest },
|
|
328
331
|
});
|
|
329
332
|
assert.equal(res.status, 400);
|
|
330
|
-
|
|
331
|
-
assert.ok(typeof res.body.error === "string" && res.body.error.length > 0);
|
|
333
|
+
assert.ok(typeof res.body.error === "string" && res.body.error.includes("open-b"), `error should name the unprovisioned node, got ${res.body.error}`);
|
|
332
334
|
// Loud, not silent: nothing launched and the proposal stays staged (re-dispatchable once a repo /
|
|
333
335
|
// repoless choice is supplied).
|
|
334
336
|
assert.equal((await deliveryGraphRuns(app.db).all()).length, 0);
|
|
335
337
|
assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "staged");
|
|
336
338
|
});
|
|
337
339
|
|
|
340
|
+
test("a bare dispatch of a HUMAN-ONLY graph (nothing to provision) SUCCEEDS → 202 running (#739)", async () => {
|
|
341
|
+
const app = await boot();
|
|
342
|
+
assert.ok(app.api);
|
|
343
|
+
const api = app.api;
|
|
344
|
+
// A graph with no `agent` nodes needs neither a run-level repository nor `repoless` — there is nothing
|
|
345
|
+
// to isolate. Under #739 a bare `{ digest }` dispatch (the node-provisioned variant) launches cleanly.
|
|
346
|
+
const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
|
|
347
|
+
const res = await api.call<{ ok: boolean; status: string }>("dispatchDeliveryGraph", {
|
|
348
|
+
body: { digest: staged.body.digest },
|
|
349
|
+
});
|
|
350
|
+
assert.equal(res.status, 202);
|
|
351
|
+
assert.equal(res.body.ok, true);
|
|
352
|
+
assert.equal(res.body.status, "running");
|
|
353
|
+
await app.settle();
|
|
354
|
+
assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
|
|
355
|
+
assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "dispatched");
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("a bare dispatch of a fully NODE-PROVISIONED agent graph SUCCEEDS → 202 running (#739)", async () => {
|
|
359
|
+
const app = await boot();
|
|
360
|
+
assert.ok(app.api);
|
|
361
|
+
const api = app.api;
|
|
362
|
+
// Every agent node declares its OWN repository, so the graph self-provisions each cell's isolation
|
|
363
|
+
// envelope — no run-level repository, no `repoless`. This is the cross-repo delivery graph #739 enables.
|
|
364
|
+
const NODE_PROVISIONED = {
|
|
365
|
+
name: "cross-repo",
|
|
366
|
+
nodes: [
|
|
367
|
+
{ id: "open-b", kind: "agent", agent: { jobType: "senior:demo", prompt: "implement", repository: "acme/one" } },
|
|
368
|
+
{ id: "publish", kind: "human", human: { prompt: "publish" } },
|
|
369
|
+
],
|
|
370
|
+
edges: [{ from: "open-b", to: "publish" }],
|
|
371
|
+
};
|
|
372
|
+
const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: NODE_PROVISIONED });
|
|
373
|
+
const res = await api.call<{ ok: boolean; status: string }>("dispatchDeliveryGraph", {
|
|
374
|
+
body: { digest: staged.body.digest },
|
|
375
|
+
});
|
|
376
|
+
assert.equal(res.status, 202);
|
|
377
|
+
assert.equal(res.body.ok, true);
|
|
378
|
+
assert.equal(res.body.status, "running");
|
|
379
|
+
await app.settle();
|
|
380
|
+
assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
|
|
381
|
+
assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "dispatched");
|
|
382
|
+
});
|
|
383
|
+
|
|
338
384
|
test("a dispatch with ONLY repository (no baseBranch) is rejected → 400, nothing launched (#729)", async () => {
|
|
339
385
|
const app = await boot();
|
|
340
386
|
assert.ok(app.api);
|
|
@@ -11,10 +11,12 @@
|
|
|
11
11
|
// superseded / already-dispatched digest is a clean 400.
|
|
12
12
|
|
|
13
13
|
import { isPlausibleBranchName } from "../app/baseBranch.ts";
|
|
14
|
+
import { validateDeliveryGraph } from "../app/deliveryGraph.ts";
|
|
14
15
|
import { dispatchDeliveryGraphRun } from "../app/deliveryGraphDispatch.ts";
|
|
15
16
|
import { getStagedProposal, markProposalDispatched, markProposalExpired } from "../app/deliveryGraphProposals.ts";
|
|
17
|
+
import { unresolvedAgentRepoNodes } from "../app/deliveryRunner.ts";
|
|
16
18
|
import { isValidIsoDuration } from "../app/reviewWait.ts";
|
|
17
|
-
import type { DeliveryGraphTextResult } from "../nano-generated/api-io.d.ts";
|
|
19
|
+
import type { DeliveryGraph, DeliveryGraphTextResult } from "../nano-generated/api-io.d.ts";
|
|
18
20
|
import { defineOperation } from "../nano-generated/operations.ts";
|
|
19
21
|
|
|
20
22
|
/** Cap an untrusted, rejected duration string before it is echoed into logs/response bodies. `openapi.yaml`
|
|
@@ -108,28 +110,27 @@ export default defineOperation("dispatchDeliveryGraph", async ({ body }, app) =>
|
|
|
108
110
|
baseBranch = baseRaw;
|
|
109
111
|
}
|
|
110
112
|
|
|
111
|
-
// Repository provisioning
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
113
|
+
// Repository provisioning (issue #729 / #739): a delivery-graph run whose `agent` nodes implement
|
|
114
|
+
// against a repo MUST provision an ISOLATED clone per cell — silently dispatching envelope-less (issue
|
|
115
|
+
// #684's field failure) let concurrent fan-out workers on one host share and clobber a single launch
|
|
116
|
+
// dir. The operator supplies EITHER a run-level `repository` + `baseBranch` (the fallback default for
|
|
117
|
+
// nodes that don't declare their own), OR `repoless: true` for a genuinely checkout-less graph, OR —
|
|
118
|
+
// when every agent node declares its OWN `repository` (#739) — NEITHER. The remaining invariant (no
|
|
119
|
+
// agent node resolves to NO repository on a non-`repoless` run) is checked AFTER the staged graph is
|
|
120
|
+
// loaded below, since the request alone cannot see whether every node is self-provisioned.
|
|
118
121
|
const hasRepoless = body !== null && typeof body === "object" && "repoless" in body;
|
|
119
122
|
const repolessRaw = hasRepoless ? body.repoless : undefined;
|
|
120
123
|
// `repoless` is a `true`-ONLY opt-out, exactly as the OpenAPI `oneOf` models it: it is `enum: [true]`
|
|
121
|
-
// on the repoless variant and NOT a member of the
|
|
124
|
+
// on the repoless variant and NOT a member of the other variants (`additionalProperties: false`).
|
|
122
125
|
// So reject any present-but-not-`true` value — `false`, `"yes"`, `0`, … — rather than silently folding
|
|
123
|
-
// `repoless: false` into the "not repoless" path.
|
|
124
|
-
// could send `{ digest, repository, baseBranch, repoless: false }` (or `{ digest, repoless: false }`)
|
|
125
|
-
// and slip through with a confusing tri-state the `oneOf` contract never permits.
|
|
126
|
+
// `repoless: false` into the "not repoless" path.
|
|
126
127
|
if (hasRepoless && repolessRaw !== true) {
|
|
127
128
|
app.log.warn("dispatch-delivery-graph rejected: repoless is a true-only opt-out", { type: typeof repolessRaw });
|
|
128
129
|
return {
|
|
129
130
|
status: 400,
|
|
130
131
|
body: {
|
|
131
132
|
ok: false,
|
|
132
|
-
error: "`repoless` is a `true`-only opt-out — omit it to provision a repository (with `repository` + `baseBranch
|
|
133
|
+
error: "`repoless` is a `true`-only opt-out — omit it to provision a repository (with `repository` + `baseBranch`, or per-node declared repositories), or set `repoless: true` to dispatch a checkout-less graph",
|
|
133
134
|
},
|
|
134
135
|
};
|
|
135
136
|
}
|
|
@@ -141,20 +142,6 @@ export default defineOperation("dispatchDeliveryGraph", async ({ body }, app) =>
|
|
|
141
142
|
body: { ok: false, error: "`repoless: true` is mutually exclusive with `repository`/`baseBranch` — pass one or the other, not both" },
|
|
142
143
|
};
|
|
143
144
|
}
|
|
144
|
-
if (!repoless && (repository === undefined || baseBranch === undefined)) {
|
|
145
|
-
app.log.warn("dispatch-delivery-graph rejected: missing repository/baseBranch (no repoless opt-in)", {
|
|
146
|
-
hasRepository: repository !== undefined,
|
|
147
|
-
hasBaseBranch: baseBranch !== undefined,
|
|
148
|
-
});
|
|
149
|
-
return {
|
|
150
|
-
status: 400,
|
|
151
|
-
body: {
|
|
152
|
-
ok: false,
|
|
153
|
-
error:
|
|
154
|
-
"a delivery-graph dispatch must provision an isolated checkout: supply BOTH `repository` (`owner/repo`) and `baseBranch`, or set `repoless: true` to dispatch a checkout-less graph — dispatching without either would silently share the worker's launch dir across agents (issue #729)",
|
|
155
|
-
},
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
145
|
|
|
159
146
|
// Load the live staged proposal for this digest — refuses an unknown/expired/superseded/already-
|
|
160
147
|
// dispatched digest cleanly (no run is launched).
|
|
@@ -179,6 +166,36 @@ export default defineOperation("dispatchDeliveryGraph", async ({ body }, app) =>
|
|
|
179
166
|
return { status: 400, body: { ok: false, error: `staged proposal ${digest} is corrupt: ${err instanceof Error ? err.message : String(err)}` } };
|
|
180
167
|
}
|
|
181
168
|
|
|
169
|
+
// Per-node provisioning invariant (#739): now that the graph is loaded, reject a non-`repoless` run
|
|
170
|
+
// in which some `agent` node resolves to NO repository — it declared none AND no run-level fallback
|
|
171
|
+
// was supplied. Such a cell would silently share the worker's launch dir (issue #684), so fail loudly
|
|
172
|
+
// with a clean 400 here rather than surfacing later as a launch-time throw (a 500). A graph in which
|
|
173
|
+
// EVERY agent node resolves a repository (declared, or defaulted from the run level) passes — a fully
|
|
174
|
+
// node-provisioned cross-repo graph needs neither a run-level repository nor `repoless`. The runner
|
|
175
|
+
// re-enforces this at seed time (defense in depth). Guarded on a well-formed graph; a malformed one
|
|
176
|
+
// falls through to `dispatchDeliveryGraphRun`'s own validation below.
|
|
177
|
+
if (!repoless) {
|
|
178
|
+
const graphErrors = validateDeliveryGraph(graph);
|
|
179
|
+
if (graphErrors.length === 0) {
|
|
180
|
+
// biome-ignore lint/plugin: validated staged graph narrowed to its contract after validateDeliveryGraph
|
|
181
|
+
const typedGraph = graph as DeliveryGraph;
|
|
182
|
+
const unresolved = unresolvedAgentRepoNodes(typedGraph, { repository, baseBranch });
|
|
183
|
+
if (unresolved.length > 0) {
|
|
184
|
+
app.log.warn("dispatch-delivery-graph rejected: unprovisioned agent node(s)", { digest, unresolved });
|
|
185
|
+
return {
|
|
186
|
+
status: 400,
|
|
187
|
+
body: {
|
|
188
|
+
ok: false,
|
|
189
|
+
error:
|
|
190
|
+
`${unresolved.length} agent node(s) resolve to no repository (${unresolved.join(", ")}): each must declare its own ` +
|
|
191
|
+
"`repository`, or the dispatch must supply a run-level `repository` + `baseBranch` fallback, or set `repoless: true` " +
|
|
192
|
+
"for a genuinely checkout-less graph — dispatching an unprovisioned node would silently share the worker's launch dir (issue #684/#739)",
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
182
199
|
const dispatched = await dispatchDeliveryGraphRun(app, graph, { runKey: idempotencyKey, title: proposal.title, repository, baseBranch, repoless, ...timeouts });
|
|
183
200
|
if (!dispatched.ok) {
|
|
184
201
|
app.log.warn("dispatch-delivery-graph refused: compile", { digest, errors: dispatched.errors.length });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.182.1",
|
|
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",
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
},
|
|
66
66
|
"dependencies": {
|
|
67
67
|
"@nanobpm/agentic": "^0.12.0",
|
|
68
|
-
"@nanobpm/urban": "^0.
|
|
68
|
+
"@nanobpm/urban": "^0.92.0",
|
|
69
69
|
"bpmn-auto-layout": "^2.0.0-alpha.2"
|
|
70
70
|
},
|
|
71
71
|
"devDependencies": {
|