@nanobpm/nano-workforce 0.178.5 → 0.179.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/app/deliveryGraphDeploy.test.ts +7 -7
- package/app/deliveryGraphDispatch.test.ts +6 -6
- package/app/deliveryGraphDispatch.ts +7 -4
- package/app/deliveryRunner.test.ts +49 -15
- package/app/deliveryRunner.ts +52 -23
- package/app/feature.ts +7 -4
- package/app/plan.ts +6 -3
- package/app/repoEnvelope.ts +75 -4
- package/e2e/delivery-graph-dispatch.e2e.ts +3 -3
- package/e2e/delivery-graph.e2e.ts +3 -3
- package/openapi.yaml +87 -13
- package/operations/dispatchDeliveryGraph.test.ts +136 -10
- package/operations/dispatchDeliveryGraph.ts +49 -1
- package/operations/listStagedProposals.test.ts +1 -1
- package/package.json +1 -1
- package/pages/delivery-graphs/delivery-graphs.css +27 -0
- package/pages/delivery-graphs/staged.mount.js +93 -14
- package/test/delivery-graphs-staged-confirm.test.ts +63 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [0.179.0](https://github.com/nanobpm/nano-workforce/compare/v0.178.5...v0.179.0) (2026-09-04)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* **fan-out:** require the repository-isolation envelope on fan-out dispatch ([#730](https://github.com/nanobpm/nano-workforce/issues/730)) ([f94425d](https://github.com/nanobpm/nano-workforce/commit/f94425db5d447260a0e4c9f31b120695f8d18a7c)), closes [#729](https://github.com/nanobpm/nano-workforce/issues/729)
|
|
6
|
+
|
|
1
7
|
## [0.178.5](https://github.com/nanobpm/nano-workforce/compare/v0.178.4...v0.178.5) (2026-09-03)
|
|
2
8
|
|
|
3
9
|
### Bug Fixes
|
|
@@ -68,7 +68,7 @@ test("deploy+advance: a well-formed graph deploys through the real engine and ev
|
|
|
68
68
|
await engine.registerWorker("pr.readiness-probe", async () => ({ ready: true, mergedSha: "deadbeefcafe" }));
|
|
69
69
|
await engine.registerWorker(DELIVERY_CONNECTOR_TASK_TYPE, async () => ({}));
|
|
70
70
|
|
|
71
|
-
const run = await runDeliveryGraph(engine, MATRIX_GRAPH);
|
|
71
|
+
const run = await runDeliveryGraph(engine, MATRIX_GRAPH, { repoless: true });
|
|
72
72
|
assert(run.ok, `runDeliveryGraph failed: ${JSON.stringify(run)}`);
|
|
73
73
|
const key = run.handle.processInstanceKey;
|
|
74
74
|
|
|
@@ -120,7 +120,7 @@ test("deploy+advance: a stalled service node escalates on its node-timeout bound
|
|
|
120
120
|
};
|
|
121
121
|
// Short node timeout so the boundary fires within one virtual-clock advance; a long SLA so the
|
|
122
122
|
// human node's own escalation boundary never fires during the drive.
|
|
123
|
-
const run = await runDeliveryGraph(engine, graph, { nodeTimeout: "PT1M", escalationSlaTimeout: "PT1H" });
|
|
123
|
+
const run = await runDeliveryGraph(engine, graph, { nodeTimeout: "PT1M", escalationSlaTimeout: "PT1H", repoless: true });
|
|
124
124
|
assert(run.ok, `runDeliveryGraph failed: ${JSON.stringify(run)}`);
|
|
125
125
|
const key = run.handle.processInstanceKey;
|
|
126
126
|
|
|
@@ -256,7 +256,7 @@ test("#543 transcript correlation: a completed agent job exposes a resolvable in
|
|
|
256
256
|
],
|
|
257
257
|
edges: [{ from: "impl", to: "review" }],
|
|
258
258
|
};
|
|
259
|
-
const run = await runDeliveryGraph(engine, graph);
|
|
259
|
+
const run = await runDeliveryGraph(engine, graph, { repoless: true });
|
|
260
260
|
assert(run.ok, `runDeliveryGraph failed: ${JSON.stringify(run)}`);
|
|
261
261
|
const key = run.handle.processInstanceKey;
|
|
262
262
|
|
|
@@ -324,7 +324,7 @@ async function driveGuarded(outcome: "breaking" | "green"): Promise<{ state: str
|
|
|
324
324
|
return {};
|
|
325
325
|
});
|
|
326
326
|
|
|
327
|
-
const run = await runDeliveryGraph(engine, GUARDED_ADOPT);
|
|
327
|
+
const run = await runDeliveryGraph(engine, GUARDED_ADOPT, { repoless: true });
|
|
328
328
|
assert(run.ok, `runDeliveryGraph failed: ${JSON.stringify(run)}`);
|
|
329
329
|
const key = run.handle.processInstanceKey;
|
|
330
330
|
|
|
@@ -425,7 +425,7 @@ async function driveGuardedRealAgent(outcome: "breaking" | "compatible"): Promis
|
|
|
425
425
|
return {};
|
|
426
426
|
});
|
|
427
427
|
|
|
428
|
-
const run = await runDeliveryGraph(engine, GUARDED_ADOPT_REAL);
|
|
428
|
+
const run = await runDeliveryGraph(engine, GUARDED_ADOPT_REAL, { repoless: true });
|
|
429
429
|
assert(run.ok, `runDeliveryGraph failed: ${JSON.stringify(run)}`);
|
|
430
430
|
const key = run.handle.processInstanceKey;
|
|
431
431
|
|
|
@@ -487,7 +487,7 @@ test("S7 deploy+route: mutually-exclusive leaves join End on an exclusive merge
|
|
|
487
487
|
doneRan = true;
|
|
488
488
|
return {};
|
|
489
489
|
});
|
|
490
|
-
const run = await runDeliveryGraph(engine, graph, { escalationSlaTimeout: "PT1H" });
|
|
490
|
+
const run = await runDeliveryGraph(engine, graph, { escalationSlaTimeout: "PT1H", repoless: true });
|
|
491
491
|
assert(run.ok, `runDeliveryGraph failed: ${JSON.stringify(run)}`);
|
|
492
492
|
const key = run.handle.processInstanceKey;
|
|
493
493
|
let state = "?";
|
|
@@ -516,7 +516,7 @@ test("S7 deploy+route: mutually-exclusive leaves join End on an exclusive merge
|
|
|
516
516
|
doneRan = true;
|
|
517
517
|
return {};
|
|
518
518
|
});
|
|
519
|
-
const run = await runDeliveryGraph(engine, graph, { escalationSlaTimeout: "PT1H" });
|
|
519
|
+
const run = await runDeliveryGraph(engine, graph, { escalationSlaTimeout: "PT1H", repoless: true });
|
|
520
520
|
assert(run.ok, `runDeliveryGraph failed: ${JSON.stringify(run)}`);
|
|
521
521
|
const key = run.handle.processInstanceKey;
|
|
522
522
|
let parked = "";
|
|
@@ -91,7 +91,7 @@ const SIDE_EFFECTING = {
|
|
|
91
91
|
|
|
92
92
|
test("dispatchDeliveryGraphRun: a human-only graph launches straight away (running), one engine instance", async () => {
|
|
93
93
|
const { app, started, runs } = makeApp();
|
|
94
|
-
const res = await dispatchDeliveryGraphRun(app, HUMAN_ONLY);
|
|
94
|
+
const res = await dispatchDeliveryGraphRun(app, HUMAN_ONLY, { repoless: true });
|
|
95
95
|
assertEquals(res.ok, true);
|
|
96
96
|
if (!res.ok) return;
|
|
97
97
|
assertEquals(res.status, "running");
|
|
@@ -103,7 +103,7 @@ test("dispatchDeliveryGraphRun: a human-only graph launches straight away (runni
|
|
|
103
103
|
|
|
104
104
|
test("dispatchDeliveryGraphRun: a side-effecting graph dispatches with NO approval token — the operator seam IS the approval", async () => {
|
|
105
105
|
const { app, started, runs } = makeApp();
|
|
106
|
-
const res = await dispatchDeliveryGraphRun(app, SIDE_EFFECTING);
|
|
106
|
+
const res = await dispatchDeliveryGraphRun(app, SIDE_EFFECTING, { repoless: true });
|
|
107
107
|
assertEquals(res.ok, true);
|
|
108
108
|
if (!res.ok) return;
|
|
109
109
|
assertEquals(res.status, "running");
|
|
@@ -114,9 +114,9 @@ test("dispatchDeliveryGraphRun: a side-effecting graph dispatches with NO approv
|
|
|
114
114
|
|
|
115
115
|
test("dispatchDeliveryGraphRun: a re-dispatch of a still-running run short-circuits (alreadyRunning) — the side effect launches at most once", async () => {
|
|
116
116
|
const { app, started } = makeApp();
|
|
117
|
-
const first = await dispatchDeliveryGraphRun(app, SIDE_EFFECTING);
|
|
117
|
+
const first = await dispatchDeliveryGraphRun(app, SIDE_EFFECTING, { repoless: true });
|
|
118
118
|
assert(first.ok);
|
|
119
|
-
const second = await dispatchDeliveryGraphRun(app, SIDE_EFFECTING);
|
|
119
|
+
const second = await dispatchDeliveryGraphRun(app, SIDE_EFFECTING, { repoless: true });
|
|
120
120
|
assertEquals(second.ok, true);
|
|
121
121
|
if (!second.ok) return;
|
|
122
122
|
assertEquals(second.alreadyRunning, true);
|
|
@@ -125,8 +125,8 @@ test("dispatchDeliveryGraphRun: a re-dispatch of a still-running run short-circu
|
|
|
125
125
|
|
|
126
126
|
test("dispatchDeliveryGraphRun: an explicit idempotency key forces a distinct run row", async () => {
|
|
127
127
|
const { app, started } = makeApp();
|
|
128
|
-
await dispatchDeliveryGraphRun(app, HUMAN_ONLY);
|
|
129
|
-
await dispatchDeliveryGraphRun(app, HUMAN_ONLY, { runKey: "second-run" });
|
|
128
|
+
await dispatchDeliveryGraphRun(app, HUMAN_ONLY, { repoless: true });
|
|
129
|
+
await dispatchDeliveryGraphRun(app, HUMAN_ONLY, { runKey: "second-run", repoless: true });
|
|
130
130
|
assertEquals(started.length, 2);
|
|
131
131
|
});
|
|
132
132
|
|
|
@@ -49,7 +49,7 @@ export type DispatchDeliveryGraphResult =
|
|
|
49
49
|
export async function dispatchDeliveryGraphRun(
|
|
50
50
|
app: Pick<AppApi, "data" | "engine" | "log">,
|
|
51
51
|
graph: unknown,
|
|
52
|
-
options: { runKey?: string | null; title?: string | null; repository?: string | null; baseBranch?: string | null } & DeliveryRunTimeouts = {},
|
|
52
|
+
options: { runKey?: string | null; title?: string | null; repository?: string | null; baseBranch?: string | null; repoless?: boolean } & DeliveryRunTimeouts = {},
|
|
53
53
|
): Promise<DispatchDeliveryGraphResult> {
|
|
54
54
|
const validationErrors = validateDeliveryGraph(graph);
|
|
55
55
|
if (validationErrors.length > 0) {
|
|
@@ -140,11 +140,14 @@ export async function dispatchDeliveryGraphRun(
|
|
|
140
140
|
escalationSlaTimeout: options.escalationSlaTimeout,
|
|
141
141
|
probePollEvery: options.probePollEvery,
|
|
142
142
|
escalationAssignee: options.escalationAssignee,
|
|
143
|
-
// Host-git provisioning (#684/#686): forward the run-level repo/base so the runner seeds the
|
|
144
|
-
// `io.nanobpm.agentTask.repository` isolation envelope onto every agent cell's job
|
|
145
|
-
//
|
|
143
|
+
// Host-git provisioning (#684/#686/#729): forward the run-level repo/base so the runner seeds the
|
|
144
|
+
// `io.nanobpm.agentTask.repository` isolation envelope onto every agent cell's job. The envelope is
|
|
145
|
+
// REQUIRED unless the run is EXPLICITLY `repoless` — an unresolved repo/base on a non-`repoless`
|
|
146
|
+
// run is a hard launch failure (marked `failed` below via the catch), never a silent no-envelope
|
|
147
|
+
// fallback to the shared launch dir. The dispatch door enforces the same contract at submit (400).
|
|
146
148
|
repository: options.repository,
|
|
147
149
|
baseBranch: options.baseBranch,
|
|
150
|
+
repoless: options.repoless,
|
|
148
151
|
});
|
|
149
152
|
} catch (err) {
|
|
150
153
|
await markClaimFailed();
|
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
// The engine-native EXECUTION of a prepared graph (deploy + run + gate + fan-in + late-bind + dedupe) is
|
|
11
11
|
// proven end-to-end in `e2e/delivery-graph.e2e.ts`.
|
|
12
12
|
import { test } from "node:test";
|
|
13
|
-
import { assert, assertEquals } from "#test-assert";
|
|
13
|
+
import { assert, assertEquals, assertRejects } from "#test-assert";
|
|
14
14
|
import { prepareDeliveryGraph, renderIdempotencyPreamble, runDeliveryGraph } from "./deliveryRunner.ts";
|
|
15
|
+
import { RepoEnvelopeConflictError, RepoEnvelopeUnresolvedError } from "./repoEnvelope.ts";
|
|
15
16
|
import type { DeliveryGraph } from "../nano-generated/api-io.d.ts";
|
|
16
17
|
|
|
17
18
|
const GRAPH: DeliveryGraph = {
|
|
@@ -357,7 +358,7 @@ test("runDeliveryGraph coerces a numeric engine processInstanceKey to a string h
|
|
|
357
358
|
deployResources: async () => [],
|
|
358
359
|
createInstance: async () => ({ processInstanceKey: 987654321 as unknown as string }),
|
|
359
360
|
};
|
|
360
|
-
const r = await runDeliveryGraph(engine, GRAPH);
|
|
361
|
+
const r = await runDeliveryGraph(engine, GRAPH, { repoless: true });
|
|
361
362
|
assert(r.ok, `expected ok:true, got ${JSON.stringify(r)}`);
|
|
362
363
|
assertEquals(r.handle.processInstanceKey, "987654321");
|
|
363
364
|
assertEquals(typeof r.handle.processInstanceKey, "string");
|
|
@@ -401,22 +402,55 @@ test("runDeliveryGraph seeds the repository isolation envelope when repository +
|
|
|
401
402
|
assertEquals("branch" in repo, false);
|
|
402
403
|
});
|
|
403
404
|
|
|
404
|
-
test("runDeliveryGraph
|
|
405
|
+
test("runDeliveryGraph seeds NO envelope ONLY on an EXPLICIT repoless run — the conscious opt-out (#729)", async () => {
|
|
406
|
+
const { engine, seen } = captureCreateInstanceVars();
|
|
407
|
+
const r = await runDeliveryGraph(engine, GRAPH, { repoless: true });
|
|
408
|
+
assert(r.ok, `expected ok:true for an explicit repoless run, got ${JSON.stringify(r)}`);
|
|
409
|
+
assertEquals("io.nanobpm.agentTask" in seen(), false, "an explicit repoless run must emit no envelope");
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test("runDeliveryGraph THROWS on an unresolved repo/base when NOT repoless — never a silent shared launch dir (#729)", async () => {
|
|
413
|
+
// Issue #729: the fan-out seed is REQUIRED. Dispatching without a resolvable repository + base branch
|
|
414
|
+
// (and without the explicit `repoless` opt-out) must fail LOUDLY at seed time rather than silently
|
|
415
|
+
// emit `{}` and degrade every agent job to the worker's shared launch dir (issue #684's field failure
|
|
416
|
+
// re-opened as a silent fallback). Missing both, or only one of the pair, is unresolved.
|
|
405
417
|
for (const options of [{}, { repository: "owner/repo" }, { baseBranch: "main" }, { repository: " ", baseBranch: "main" }]) {
|
|
406
|
-
const { engine
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
418
|
+
const { engine } = captureCreateInstanceVars();
|
|
419
|
+
await assertRejects(
|
|
420
|
+
() => runDeliveryGraph(engine, GRAPH, options),
|
|
421
|
+
RepoEnvelopeUnresolvedError,
|
|
422
|
+
);
|
|
410
423
|
}
|
|
411
424
|
});
|
|
412
425
|
|
|
413
|
-
test("runDeliveryGraph
|
|
414
|
-
const { engine
|
|
415
|
-
// A value that is not exactly `owner/repo` (a trailing `.git`)
|
|
416
|
-
//
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
426
|
+
test("runDeliveryGraph THROWS on a malformed repository rather than emitting a bogus clone URL (#729)", async () => {
|
|
427
|
+
const { engine } = captureCreateInstanceVars();
|
|
428
|
+
// A value that is not exactly `owner/repo` (a trailing `.git`) is an UNRESOLVED input on the required
|
|
429
|
+
// path — it must throw, never degrade to a double-suffixed `…/owner/repo.git.git` clone URL nor to a
|
|
430
|
+
// silent no-envelope launch-dir fallback.
|
|
431
|
+
await assertRejects(
|
|
432
|
+
() => runDeliveryGraph(engine, GRAPH, { repository: "owner/repo.git", baseBranch: "main" }),
|
|
433
|
+
RepoEnvelopeUnresolvedError,
|
|
434
|
+
);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("runDeliveryGraph THROWS when repoless is combined with repository/baseBranch — never silently disables isolation (#729)", async () => {
|
|
438
|
+
// `repoless: true` is mutually exclusive with `repository`/`baseBranch`. The dispatch door rejects the
|
|
439
|
+
// conflicting shape with a 400, but a PROGRAMMATIC caller that bypasses the door could pass both — and
|
|
440
|
+
// 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), so isolation can never be
|
|
442
|
+
// silently disabled by a conflicting call. Either half of the pair alongside `repoless` is a conflict.
|
|
443
|
+
for (const options of [
|
|
444
|
+
{ repoless: true, repository: "owner/repo", baseBranch: "main" },
|
|
445
|
+
{ repoless: true, repository: "owner/repo" },
|
|
446
|
+
{ repoless: true, baseBranch: "main" },
|
|
447
|
+
]) {
|
|
448
|
+
const { engine } = captureCreateInstanceVars();
|
|
449
|
+
await assertRejects(
|
|
450
|
+
() => runDeliveryGraph(engine, GRAPH, options),
|
|
451
|
+
RepoEnvelopeConflictError,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
420
454
|
});
|
|
421
455
|
|
|
422
456
|
test("the canonical `agent → converge-merge → wait[pr merged]` graph DISPATCHES with a fact-bound wait target (#570)", async () => {
|
|
@@ -444,7 +478,7 @@ test("the canonical `agent → converge-merge → wait[pr merged]` graph DISPATC
|
|
|
444
478
|
deployResources: async () => [],
|
|
445
479
|
createInstance: async () => ({ processInstanceKey: "555" }),
|
|
446
480
|
};
|
|
447
|
-
const r = await runDeliveryGraph(engine, graph);
|
|
481
|
+
const r = await runDeliveryGraph(engine, graph, { repoless: true });
|
|
448
482
|
assert(r.ok, `expected the canonical fact-bound wait[pr] graph to launch, got ${JSON.stringify(r)}`);
|
|
449
483
|
assertEquals(r.handle.processInstanceKey, "555");
|
|
450
484
|
});
|
package/app/deliveryRunner.ts
CHANGED
|
@@ -20,7 +20,7 @@ import type { DeliveryFact, DeliveryGraph, DeliveryNode } from "../nano-generate
|
|
|
20
20
|
import { TRANSCRIPT_URL_BASE_VAR, transcriptUrlBaseFor } from "./agentic/transcript-url.ts";
|
|
21
21
|
import { assertNever, compileDeliveryGraph, DELIVERY_GRAPH_PROCESS_ID } from "./deliveryGraphCompiler.ts";
|
|
22
22
|
import { DEFAULT_EVERY_MS, msToIsoDuration, parseProbe, readinessPollEvery, readinessTimeout } from "./readiness.ts";
|
|
23
|
-
import {
|
|
23
|
+
import { RepoEnvelopeConflictError, requireRepoEnvelopeVars } 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,18 +62,27 @@ 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` the run's `agent` nodes implement against.
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
65
|
+
/** OPTIONAL `owner/repo` the run's `agent` nodes implement against. Together with `baseBranch` the
|
|
66
|
+
* runner seeds the canonical repository-provisioning envelope (`io.nanobpm.agentTask.repository`, via
|
|
67
|
+
* `requireRepoEnvelopeVars`) as a run-root `createInstance` process variable so each `agent` cell's
|
|
68
|
+
* servicing `senior:*` job provisions an ISOLATED throwaway clone instead of inheriting the worker's
|
|
69
|
+
* launch dir (issue #684/#686 — the same isolation the legacy feature/plan paths got in #685).
|
|
70
|
+
*
|
|
71
|
+
* Issue #729: the envelope is now REQUIRED unless the run is EXPLICITLY `repoless`. A run that is not
|
|
72
|
+
* `repoless` but supplies an unresolved/missing `repository`/`baseBranch` throws
|
|
73
|
+
* `RepoEnvelopeUnresolvedError` at seed time (a loud launch failure) rather than silently degrading to
|
|
74
|
+
* the shared launch-dir behaviour that let concurrent fan-out workers clobber one checkout. */
|
|
72
75
|
repository?: string | null;
|
|
73
76
|
/** OPTIONAL base branch the run's `agent` nodes branch off — the `ref` the harness checks out in the
|
|
74
77
|
* isolated clone (the PRE-PR shape: no PR head exists yet, so the agent cuts its own `feat/<node.id>`
|
|
75
|
-
* branch off this base inside the clone).
|
|
78
|
+
* branch off this base inside the clone). Required (with `repository`) unless the run is `repoless`. */
|
|
76
79
|
baseBranch?: string | null;
|
|
80
|
+
/** EXPLICIT opt-out of repository provisioning (issue #729). `true` → the run is dispatched with NO
|
|
81
|
+
* isolation envelope (the legacy launch-dir behaviour), for a genuinely repo-less graph (e.g. one with
|
|
82
|
+
* no `agent` nodes that touch a checkout). This must be a CONSCIOUS choice at the dispatch door so the
|
|
83
|
+
* default can never silently share a checkout: when it is not set, `repository` + `baseBranch` are
|
|
84
|
+
* mandatory and an unresolved pair is a hard launch failure, not a silent no-envelope fallback. */
|
|
85
|
+
repoless?: boolean;
|
|
77
86
|
}
|
|
78
87
|
|
|
79
88
|
const DEFAULTS: Required<Omit<DeliveryRunTimeouts, "escalationAssignee">> = {
|
|
@@ -170,6 +179,37 @@ export async function runDeliveryGraph(
|
|
|
170
179
|
await engine.deployResources([{ name: `${processDefinitionId}.bpmn`, content: bpmn, contentType: "application/xml" }]);
|
|
171
180
|
const base = typeof options.baseBranch === "string" && options.baseBranch.trim() !== "" ? options.baseBranch.trim() : null;
|
|
172
181
|
const repo = typeof options.repository === "string" && options.repository.trim() !== "" ? options.repository.trim() : null;
|
|
182
|
+
// Host-git provisioning (c8ctl, issue #684/#686/#729): resolve the ONE canonical repository envelope
|
|
183
|
+
// (`app/repoEnvelope.ts`) BEFORE seeding so every `agent` node's servicing `senior:*` job gets an
|
|
184
|
+
// ISOLATED throwaway clone instead of inheriting the worker's launch dir — otherwise several copilot
|
|
185
|
+
// workers on one host share (and clobber) a single checkout, the exact field failure #684 described.
|
|
186
|
+
// This is the delivery-graph analog of the whole-epic seed in `app/plan.ts`: a single run-root
|
|
187
|
+
// `createInstance` process variable that propagates through each agent cell's subProcess into its job.
|
|
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);
|
|
173
213
|
const { processInstanceKey } = await engine.createInstance({
|
|
174
214
|
processDefinitionId,
|
|
175
215
|
variables: {
|
|
@@ -179,20 +219,9 @@ export async function runDeliveryGraph(
|
|
|
179
219
|
// node ioMapping in deliveryGraphCompiler). Seeded once at the run root — the same value for
|
|
180
220
|
// every node — and read down into each agent job via `=transcriptUrlBase`.
|
|
181
221
|
[TRANSCRIPT_URL_BASE_VAR]: transcriptUrlBaseFor(),
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
// copilot workers on one host share (and clobber) a single checkout, the exact field failure #684
|
|
186
|
-
// described. This is the delivery-graph analog of the whole-epic seed in `app/plan.ts`: a single
|
|
187
|
-
// run-root `createInstance` process variable that propagates through each agent cell's subProcess
|
|
188
|
-
// into its job. Like plan.ts's fan-out seed it carries `ref = base` but NO `branchCreate` — a run
|
|
189
|
-
// fans out to MANY agent nodes, each needing its own deterministic `feat/<node.id>` branch, so a
|
|
190
|
-
// single run-level envelope can't name one; each agent cuts its own branch off `base` inside the
|
|
191
|
-
// isolated clone (the agent-guide's `feat/*` convention, kept idempotent by the #551 preflight).
|
|
192
|
-
// `baseRef = base` too, so the harness keeps `origin/<base>` reachable for the review 3-dot diff.
|
|
193
|
-
// Spread last so an unresolved repo/base (`{}`) leaves the other run-root vars untouched — a
|
|
194
|
-
// repo-less graph is then dispatched exactly as before (legacy launch-dir behaviour).
|
|
195
|
-
...repoEnvelopeVars(repo ?? "", base, base),
|
|
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,
|
|
196
225
|
},
|
|
197
226
|
});
|
|
198
227
|
// The engine can yield a numeric key; `DeliveryRunHandle.processInstanceKey` is typed `string` and
|
package/app/feature.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { coalesceTitle, fetchIssueTitle } from "./github.ts";
|
|
|
20
20
|
import { derivedTrackingTable } from "./instanceTracking.ts";
|
|
21
21
|
import { ESCALATION_SLA_TIMEOUT, normalizeBaseBranch, type ParsedIssue, renderBaseBranchBrief } from "./plan.ts";
|
|
22
22
|
import type { ReadinessProbe } from "./readiness.ts";
|
|
23
|
-
import {
|
|
23
|
+
import { requireRepoEnvelopeVars } from "./repoEnvelope.ts";
|
|
24
24
|
|
|
25
25
|
/** Optional intake-time readiness gate for a feature run (issue #295): the `capability`/`command`/…
|
|
26
26
|
* probes the run must ALL satisfy before its implementation agent is dispatched (parked, durably, at
|
|
@@ -491,9 +491,12 @@ export async function startFeature(
|
|
|
491
491
|
// implementation jobs share — and clobber — one checkout, violating the durable-resume design).
|
|
492
492
|
// A feature run is PRE-PR: there is no head branch yet, so the harness checks out the BASE
|
|
493
493
|
// branch (`ref = base`) and creates the deterministic `feat/<task.id>` feature branch itself
|
|
494
|
-
// (`branchCreate`), matching the agent-guide's `feat/*` convention.
|
|
495
|
-
// repo (`
|
|
496
|
-
|
|
494
|
+
// (`branchCreate`), matching the agent-guide's `feat/*` convention. This is a fan-out seed, so it
|
|
495
|
+
// is REQUIRED (issue #729): `parsed.repo` is regex-bound (`parseIssue`) and `base` is
|
|
496
|
+
// `normalizeBaseBranch`-validated non-blank, so the `requireRepoEnvelopeVars` guard never trips
|
|
497
|
+
// here — but it turns an unresolved repo/base into a HARD launch failure rather than a silent `{}`
|
|
498
|
+
// that would degrade the agent to the shared launch dir.
|
|
499
|
+
...requireRepoEnvelopeVars(parsed.repo, base, null, null, prePrBranch),
|
|
497
500
|
},
|
|
498
501
|
});
|
|
499
502
|
const processKey = processInstanceKey == null ? null : String(processInstanceKey);
|
package/app/plan.ts
CHANGED
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
import { derivedTrackingTable } from "./instanceTracking.ts";
|
|
31
31
|
import { clearExclusions } from "./mergeExclusion.ts";
|
|
32
32
|
import type { ReadinessProbe } from "./readiness.ts";
|
|
33
|
-
import {
|
|
33
|
+
import { requireRepoEnvelopeVars } from "./repoEnvelope.ts";
|
|
34
34
|
import { clearTaskDeltas } from "./taskDelta.ts";
|
|
35
35
|
|
|
36
36
|
/** The BPMN process this module drives (resources/processes/plan-fanout.bpmn). */
|
|
@@ -1048,8 +1048,11 @@ export async function startPlan(
|
|
|
1048
1048
|
// but NO `branchCreate`: each slice's deterministic `feat/<task.id>` branch differs per MI child,
|
|
1049
1049
|
// so the agent cuts its own branch inside the isolated clone (per resources/prompts/feature.md).
|
|
1050
1050
|
// The process-level variable propagates through the wave subprocess + `implement-cell` callActivity
|
|
1051
|
-
// into each agent job.
|
|
1052
|
-
|
|
1051
|
+
// into each agent job. This is a fan-out seed, so it is REQUIRED (issue #729): `parsed.repo` is
|
|
1052
|
+
// regex-bound (`parseIssue`) and `base` is `normalizeBaseBranch`-validated non-blank, so the
|
|
1053
|
+
// `requireRepoEnvelopeVars` guard never trips here — but it makes an unresolved repo/base a HARD
|
|
1054
|
+
// launch failure rather than a silent `{}` that would degrade every slice to the shared launch dir.
|
|
1055
|
+
...requireRepoEnvelopeVars(parsed.repo, base),
|
|
1053
1056
|
},
|
|
1054
1057
|
});
|
|
1055
1058
|
const processKey = processInstanceKey == null ? null : String(processInstanceKey);
|
package/app/repoEnvelope.ts
CHANGED
|
@@ -15,6 +15,49 @@ import { isCommitSha } from "./world/index.ts";
|
|
|
15
15
|
* (headers ∪ variables, deep-merged). See c8ctl `normalizeTaskEnvelope`. */
|
|
16
16
|
const AGENT_TASK_NS = "io.nanobpm.agentTask";
|
|
17
17
|
|
|
18
|
+
/** The ONE canonical `owner/repo` allowlist, shared by `repoEnvelopeVars` (which degrades to `{}` on a
|
|
19
|
+
* miss) and `requireRepoEnvelopeVars` (which throws on a miss) so the two can never drift apart. The
|
|
20
|
+
* owner is a GitHub login (alphanumeric + hyphen); the repo-name segment additionally allows `.` and
|
|
21
|
+
* `_`. A trailing `.git` is rejected outright so we never emit a double-suffixed `…/owner/repo.git.git`,
|
|
22
|
+
* and the anchored allowlist bars query/fragment/host-injection chars. */
|
|
23
|
+
const OWNER_REPO_RE = /^[A-Za-z0-9-]+\/[A-Za-z0-9._-]+$/;
|
|
24
|
+
function isPlainOwnerRepo(repo: string): boolean {
|
|
25
|
+
return OWNER_REPO_RE.test(repo) && !/\.git$/i.test(repo);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Raised by `requireRepoEnvelopeVars` when the repository-isolation envelope is REQUIRED on a fan-out
|
|
29
|
+
* path but its inputs are unresolved (a blank base/head `ref`, or a `repo` that is not a plain
|
|
30
|
+
* `owner/repo`). Issue #729: the fan-out dispatch paths must fail loudly here rather than let
|
|
31
|
+
* `repoEnvelopeVars` silently emit `{}` and degrade to the shared launch-dir behaviour (issue #684's
|
|
32
|
+
* field failure re-opened as a silent fallback). The API edge maps this to a clean 400 / launch error;
|
|
33
|
+
* a caller that legitimately wants no clone must opt in EXPLICITLY (never reach this helper) so the
|
|
34
|
+
* default can never silently share a checkout. */
|
|
35
|
+
export class RepoEnvelopeUnresolvedError extends Error {
|
|
36
|
+
readonly reason: string;
|
|
37
|
+
constructor(reason: string) {
|
|
38
|
+
super(`repository-isolation envelope is required but its input is unresolved: ${reason}`);
|
|
39
|
+
this.name = "RepoEnvelopeUnresolvedError";
|
|
40
|
+
this.reason = reason;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Raised when a fan-out dispatch supplies a repository-isolation envelope (`repository`/`baseBranch`)
|
|
45
|
+
* AND an explicit `repoless: true` opt-out at the same time (issue #729). The two are mutually
|
|
46
|
+
* exclusive: `repoless: true` means "dispatch a genuinely checkout-less graph with NO envelope", so a
|
|
47
|
+
* caller that also names a repo/base is contradictory. The dispatch door already rejects this shape
|
|
48
|
+
* with a 400, but a PROGRAMMATIC caller that bypasses the door could still pass both — and silently
|
|
49
|
+
* disable the very isolation the envelope guards (the runner would drop the repo/base and emit `{}`).
|
|
50
|
+
* Failing loudly here too keeps the mutual exclusivity enforced at the seed point, not only at the HTTP
|
|
51
|
+
* edge, so isolation can never be silently disabled by a conflicting-but-well-meant call. */
|
|
52
|
+
export class RepoEnvelopeConflictError extends Error {
|
|
53
|
+
readonly reason: string;
|
|
54
|
+
constructor(reason: string) {
|
|
55
|
+
super(`repository-isolation envelope conflicts with an explicit repoless opt-out: ${reason}`);
|
|
56
|
+
this.name = "RepoEnvelopeConflictError";
|
|
57
|
+
this.reason = reason;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
18
61
|
/** Build the repository slice of the agent-task envelope for an agent job. Delivered as a *process
|
|
19
62
|
* variable* under the reserved `io.nanobpm.agentTask` key so the harness provisions an isolated
|
|
20
63
|
* clone — instead of the agent inheriting whatever directory the worker was launched from (which
|
|
@@ -63,10 +106,7 @@ export function repoEnvelopeVars(
|
|
|
63
106
|
// `owner/repo`), but this is an exported helper the fan-out epic gives many new callers. A repo
|
|
64
107
|
// that is not exactly `owner/repo` would build a bogus clone URL, so emit nothing (the harness
|
|
65
108
|
// then falls back to the launch-dir behaviour) rather than handing the harness a malformed URL.
|
|
66
|
-
|
|
67
|
-
// `.` and `_`. A trailing `.git` is rejected outright so we never emit a double-suffixed
|
|
68
|
-
// `…/owner/repo.git.git`, and the anchored allowlist bars query/fragment/host-injection chars.
|
|
69
|
-
if (!/^[A-Za-z0-9-]+\/[A-Za-z0-9._-]+$/.test(repo) || /\.git$/i.test(repo)) return {};
|
|
109
|
+
if (!isPlainOwnerRepo(repo)) return {};
|
|
70
110
|
return {
|
|
71
111
|
[AGENT_TASK_NS]: {
|
|
72
112
|
repository: {
|
|
@@ -114,6 +154,37 @@ export function repoEnvelopeVars(
|
|
|
114
154
|
};
|
|
115
155
|
}
|
|
116
156
|
|
|
157
|
+
/** The REQUIRED-envelope guard for the fan-out dispatch paths (issue #729). Same signature and output
|
|
158
|
+
* as `repoEnvelopeVars`, but THROWS `RepoEnvelopeUnresolvedError` on an unresolved input (a blank
|
|
159
|
+
* base/head `ref`, or a `repo` that is not a plain `owner/repo`) instead of degrading to the silent
|
|
160
|
+
* `{}` that leaves the agent job inheriting the worker's launch dir. Every fan-out seed
|
|
161
|
+
* (`app/deliveryRunner.ts` `runDeliveryGraph`, `app/plan.ts` `startPlan`, `app/feature.ts`
|
|
162
|
+
* `startFeature`) MUST route through this helper so a run that can't be isolated fails LOUDLY at seed
|
|
163
|
+
* time — never silently shares (and clobbers) one checkout across concurrent workers on a host (issue
|
|
164
|
+
* #684, re-opened as a silent fallback). A dispatch that legitimately wants no clone must opt in
|
|
165
|
+
* EXPLICITLY at its door (e.g. delivery-graph `repoless: true`) and simply never call this. */
|
|
166
|
+
export function requireRepoEnvelopeVars(
|
|
167
|
+
repo: string,
|
|
168
|
+
ref: string | null,
|
|
169
|
+
baseRef: string | null = null,
|
|
170
|
+
commitSha: string | null = null,
|
|
171
|
+
branchCreate: string | null = null,
|
|
172
|
+
): Record<string, unknown> {
|
|
173
|
+
if (typeof ref !== "string" || ref.trim() === "") {
|
|
174
|
+
throw new RepoEnvelopeUnresolvedError("base/head ref is blank — the harness has no branch to check out");
|
|
175
|
+
}
|
|
176
|
+
if (!isPlainOwnerRepo(repo)) {
|
|
177
|
+
throw new RepoEnvelopeUnresolvedError(`repo is not an \`owner/repo\` reference: ${JSON.stringify(repo)}`);
|
|
178
|
+
}
|
|
179
|
+
const vars = repoEnvelopeVars(repo, ref, baseRef, commitSha, branchCreate);
|
|
180
|
+
// repoEnvelopeVars degrades to `{}` on exactly the two conditions rejected above, so a non-empty
|
|
181
|
+
// envelope is guaranteed here. Assert it so the degrade-vs-throw pair can never silently drift.
|
|
182
|
+
if (Object.keys(vars).length === 0) {
|
|
183
|
+
throw new RepoEnvelopeUnresolvedError(`envelope resolved empty for repo=${JSON.stringify(repo)} ref=${JSON.stringify(ref)}`);
|
|
184
|
+
}
|
|
185
|
+
return vars;
|
|
186
|
+
}
|
|
187
|
+
|
|
117
188
|
/** Resolve the clone timeout (ms) the harness applies to provisioning, from the one typed knob
|
|
118
189
|
* `NANO_PR_CLONE_TIMEOUT_MS` (default 600000 = 10 min; issue #694). A branch-scoped blobless clone
|
|
119
190
|
* of a large monorepo still approaches/exceeds the harness's 120s default, so we raise it here. A
|
|
@@ -100,7 +100,7 @@ describe("delivery-graph dispatch — agent compiles→stages, operator dispatch
|
|
|
100
100
|
assert.equal(proposal?.status, "staged");
|
|
101
101
|
|
|
102
102
|
// ── Operator dispatch: dispatch the digest → deploys + runs engine-natively ───────────────────
|
|
103
|
-
const dispatched = await api.call<DispatchResult>("dispatchDeliveryGraph", { body: { digest } });
|
|
103
|
+
const dispatched = await api.call<DispatchResult>("dispatchDeliveryGraph", { body: { digest, repoless: true } });
|
|
104
104
|
assert.equal(dispatched.status, 202, "dispatching a staged digest launches the run");
|
|
105
105
|
assert.equal(dispatched.body.status, "running");
|
|
106
106
|
assert.equal(dispatched.body.alreadyRunning, false);
|
|
@@ -122,7 +122,7 @@ describe("delivery-graph dispatch — agent compiles→stages, operator dispatch
|
|
|
122
122
|
assert.match(String(phased?.phase), /^Parked on human node:/, `phase shows the parked human node, got ${phased?.phase}`);
|
|
123
123
|
|
|
124
124
|
// ── No replay: the consumed proposal cannot re-launch ─────────────────────────────────────────
|
|
125
|
-
const replay = await api.call<DispatchResult>("dispatchDeliveryGraph", { body: { digest } });
|
|
125
|
+
const replay = await api.call<DispatchResult>("dispatchDeliveryGraph", { body: { digest, repoless: true } });
|
|
126
126
|
assert.equal(replay.status, 400, "an already-dispatched digest cannot be re-dispatched");
|
|
127
127
|
await app.settle();
|
|
128
128
|
assert.equal(agentFired, 1, "the agent side effect STILL fired only once (no double-launch)");
|
|
@@ -146,7 +146,7 @@ describe("delivery-graph dispatch — agent compiles→stages, operator dispatch
|
|
|
146
146
|
const graph: DeliveryGraph = { name: "manual gate", nodes: [{ id: "ack", kind: "human", human: { prompt: "click done" } }] };
|
|
147
147
|
const staged = await api.call<StagedResult>("compileDeliveryGraph", { body: graph });
|
|
148
148
|
assert.equal(staged.status, 200);
|
|
149
|
-
const res = await api.call<DispatchResult>("dispatchDeliveryGraph", { body: { digest: staged.body.digest } });
|
|
149
|
+
const res = await api.call<DispatchResult>("dispatchDeliveryGraph", { body: { digest: staged.body.digest, repoless: true } });
|
|
150
150
|
assert.equal(res.status, 202, "dispatching a human-only graph runs it");
|
|
151
151
|
assert.equal(res.body.status, "running");
|
|
152
152
|
assert.equal(res.body.sideEffecting, false);
|
|
@@ -125,7 +125,7 @@ describe("delivery-graph runner — engine-native execution (S4)", () => {
|
|
|
125
125
|
],
|
|
126
126
|
};
|
|
127
127
|
|
|
128
|
-
const run = await runDeliveryGraph(app.engine, graph, { probeTimeout: "PT2S" });
|
|
128
|
+
const run = await runDeliveryGraph(app.engine, graph, { probeTimeout: "PT2S", repoless: true });
|
|
129
129
|
assert.ok(run.ok, `graph should deploy + run, got ${JSON.stringify(run)}`);
|
|
130
130
|
await app.settle();
|
|
131
131
|
|
|
@@ -174,7 +174,7 @@ describe("delivery-graph runner — engine-native execution (S4)", () => {
|
|
|
174
174
|
edges: [{ from: "a.cmd", to: "w" }],
|
|
175
175
|
};
|
|
176
176
|
|
|
177
|
-
const run = await runDeliveryGraph(app.engine, graph, { probeTimeout: "PT2S" });
|
|
177
|
+
const run = await runDeliveryGraph(app.engine, graph, { probeTimeout: "PT2S", repoless: true });
|
|
178
178
|
assert.ok(run.ok, `graph should deploy + run, got ${JSON.stringify(run)}`);
|
|
179
179
|
await app.settle();
|
|
180
180
|
|
|
@@ -209,7 +209,7 @@ describe("delivery-graph runner — engine-native execution (S4)", () => {
|
|
|
209
209
|
],
|
|
210
210
|
edges: [],
|
|
211
211
|
};
|
|
212
|
-
const run = await runDeliveryGraph(app.engine, graph, { probeTimeout: "PT2S", probePollEvery: "PT1S", escalationSlaTimeout: "PT1H" });
|
|
212
|
+
const run = await runDeliveryGraph(app.engine, graph, { probeTimeout: "PT2S", probePollEvery: "PT1S", escalationSlaTimeout: "PT1H", repoless: true });
|
|
213
213
|
assert.ok(run.ok, `graph should deploy + run, got ${JSON.stringify(run)}`);
|
|
214
214
|
await app.settle();
|
|
215
215
|
|