@nanobpm/nano-workforce 0.180.0 → 0.182.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/app/agentGuide.test.ts +87 -0
- package/app/agentGuide.ts +61 -0
- package/app/deliveryGraph.ts +30 -1
- package/app/deliveryGraphCompiler.ts +39 -20
- package/app/deliveryGraphProposals.test.ts +60 -0
- package/app/deliveryGraphProposals.ts +61 -3
- package/app/deliveryGraphStage.ts +11 -1
- package/app/deliveryRunner.test.ts +120 -58
- package/app/deliveryRunner.ts +154 -55
- package/app/repoEnvelope.ts +73 -1
- package/app/sequenceIssues.test.ts +143 -1
- package/app/sequenceIssues.ts +246 -31
- package/e2e/addressable-guide.e2e.ts +43 -0
- package/openapi.yaml +310 -29
- package/operations/compileDeliveryGraph.test.ts +4 -0
- package/operations/dispatchDeliveryGraph.test.ts +50 -4
- package/operations/dispatchDeliveryGraph.ts +44 -27
- package/operations/getAgentGuide.test.ts +13 -0
- package/operations/getAgentGuide.ts +40 -5
- package/package.json +1 -1
|
@@ -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
|