@nanobpm/nano-workforce 0.171.5 → 0.171.7
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/baseBranch.ts +56 -0
- package/app/contracts.ts +3 -3
- package/app/deliveryGraphDispatch.ts +6 -1
- package/app/deliveryRunner.test.ts +56 -0
- package/app/deliveryRunner.ts +29 -0
- package/app/feature.test.ts +30 -0
- package/app/feature.ts +15 -1
- package/app/plan.test.ts +9 -0
- package/app/plan.ts +22 -46
- package/app/repoEnvelope.ts +102 -0
- package/app/service.test.ts +28 -0
- package/app/service.ts +11 -72
- package/e2e/feature-run.e2e.ts +16 -1
- package/e2e/plan-fanout.e2e.ts +28 -0
- package/openapi.yaml +23 -0
- package/operations/dispatchDeliveryGraph.test.ts +62 -0
- package/operations/dispatchDeliveryGraph.ts +34 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.171.7](https://github.com/nanobpm/nano-workforce/compare/v0.171.6...v0.171.7) (2026-09-01)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **agentic:** seed repository isolation envelope on delivery-graph agent cells ([#687](https://github.com/nanobpm/nano-workforce/issues/687)) ([8c96a7d](https://github.com/nanobpm/nano-workforce/commit/8c96a7db34a6e389d09d5b9968fbcbc362d85cd5)), closes [#686](https://github.com/nanobpm/nano-workforce/issues/686) [#684](https://github.com/nanobpm/nano-workforce/issues/684) [#685](https://github.com/nanobpm/nano-workforce/issues/685) [#551](https://github.com/nanobpm/nano-workforce/issues/551) [#686](https://github.com/nanobpm/nano-workforce/issues/686)
|
|
6
|
+
|
|
7
|
+
## [0.171.6](https://github.com/nanobpm/nano-workforce/compare/v0.171.5...v0.171.6) (2026-09-01)
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
* **agentic:** emit the repository envelope on implementation jobs so they run in an isolated clone ([#685](https://github.com/nanobpm/nano-workforce/issues/685)) ([be98081](https://github.com/nanobpm/nano-workforce/commit/be980818321f18e101d0fbef85b30b75d6a6fd15)), closes [#684](https://github.com/nanobpm/nano-workforce/issues/684)
|
|
12
|
+
|
|
1
13
|
## [0.171.5](https://github.com/nanobpm/nano-workforce/compare/v0.171.4...v0.171.5) (2026-09-01)
|
|
2
14
|
|
|
3
15
|
### Performance Improvements
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Base-branch name validation — the canonical, side-effect-free gate shared by every door that
|
|
2
|
+
// accepts a caller-supplied branch name (the epic launch doors via `app/plan.ts`, and the
|
|
3
|
+
// operator delivery-graph dispatch door in `operations/dispatchDeliveryGraph.ts`).
|
|
4
|
+
//
|
|
5
|
+
// This is a deliberate LEAF module: it imports nothing and runs no top-level initialization, so an
|
|
6
|
+
// API door can pull in the validator without dragging in `app/plan.ts`'s substantial transitive
|
|
7
|
+
// imports and its import-time env seeding (`ESCALATION_SLA_TIMEOUT`/`CAPS_WAIT_TIMEOUT`). `plan.ts`
|
|
8
|
+
// re-exports these symbols, so existing importers are unaffected — this is derivation over
|
|
9
|
+
// duplication (one implementation), just hoisted below the heavy module.
|
|
10
|
+
|
|
11
|
+
/** Raised when a caller supplies a `baseBranch` that isn't a plausible git branch name. The
|
|
12
|
+
* value is interpolated into the authoritative implementer prompt (which carries `git`/`gh`
|
|
13
|
+
* shell snippets and inline-code Markdown), so a non-ref value could break the rendered
|
|
14
|
+
* instructions or smuggle in a command/prompt fragment — reject it at the edge instead. */
|
|
15
|
+
export class InvalidBaseBranchError extends Error {
|
|
16
|
+
readonly value: string;
|
|
17
|
+
constructor(value: string) {
|
|
18
|
+
super(`invalid base branch name: ${JSON.stringify(value)}`);
|
|
19
|
+
this.name = "InvalidBaseBranchError";
|
|
20
|
+
this.value = value;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Raised when a caller supplies a blank/absent `baseBranch`. Every epic launch must name its base
|
|
25
|
+
* branch explicitly (ADR 0003): "land on the default branch" is a conscious, named, confirmed choice
|
|
26
|
+
* (the confirm-default gate), never a silent fallback. The operation edge maps this to a 400. */
|
|
27
|
+
export class MissingBaseBranchError extends Error {
|
|
28
|
+
constructor() {
|
|
29
|
+
super("base branch is required (blank/absent base branches are rejected)");
|
|
30
|
+
this.name = "MissingBaseBranchError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Conservative allowlist gate for a base-branch name. Stricter than `git check-ref-format` on
|
|
35
|
+
* purpose: only `[A-Za-z0-9._/-]`, no leading `/`/`.`/`-` (a leading dash reads as a CLI flag),
|
|
36
|
+
* no trailing `/`/`.`, no `..`/`//`, no empty or `.lock`-suffixed path component, bounded length.
|
|
37
|
+
* This rejects whitespace, shell metacharacters, command substitution, and newlines outright. */
|
|
38
|
+
export function isPlausibleBranchName(s: string): boolean {
|
|
39
|
+
if (s.length === 0 || s.length > 255) return false;
|
|
40
|
+
if (!/^[A-Za-z0-9._/-]+$/.test(s)) return false;
|
|
41
|
+
if (/^[/.-]/.test(s) || /[/.]$/.test(s)) return false;
|
|
42
|
+
if (s.includes("..") || s.includes("//")) return false;
|
|
43
|
+
return s.split("/").every((seg) => seg.length > 0 && !seg.startsWith(".") && !seg.endsWith(".lock"));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Normalise a caller-supplied base branch: trim, then require it. A blank/absent value is rejected
|
|
47
|
+
* (`MissingBaseBranchError`) — ADR 0003 removed the implicit default-branch fallback, so every epic
|
|
48
|
+
* launch must name its base explicitly. A non-blank value that is not a plausible git branch name is
|
|
49
|
+
* rejected (`InvalidBaseBranchError`) rather than persisted or rendered into the agent prompt. The
|
|
50
|
+
* operation edge maps both to a 400. Always returns a non-null branch on success. */
|
|
51
|
+
export function normalizeBaseBranch(input: string | null | undefined): string {
|
|
52
|
+
const s = (input ?? "").trim();
|
|
53
|
+
if (s.length === 0) throw new MissingBaseBranchError();
|
|
54
|
+
if (!isPlausibleBranchName(s)) throw new InvalidBaseBranchError(s);
|
|
55
|
+
return s;
|
|
56
|
+
}
|
package/app/contracts.ts
CHANGED
|
@@ -382,11 +382,11 @@ export const WIRE_CONTRACTS = {
|
|
|
382
382
|
"io.nanobpm.agentTask.repository": {
|
|
383
383
|
category: "wire",
|
|
384
384
|
name: "io.nanobpm.agentTask.repository",
|
|
385
|
-
owner: "app/
|
|
385
|
+
owner: "app/repoEnvelope.ts",
|
|
386
386
|
semantics:
|
|
387
|
-
"Repo-provisioning envelope the app emits as a `createInstance` process variable (`repoEnvelopeVars
|
|
387
|
+
"Repo-provisioning envelope the app emits as a `createInstance` process variable (`repoEnvelopeVars`, app/repoEnvelope.ts) and the c8ctl worker harness consumes to provision an isolated clone — instead of the agent inheriting the worker's launch dir (issue #684). `ref` is the branch checked out: the PR HEAD branch on the PR-based paths (review-round / fix-ci / rebase), or — on the PRE-PR implementation path (feature.bpmn / plan-fanout's `implement-cell`, issue #684; the delivery-graph runner's agent cells, issue #686) — the BASE branch, off which the harness cuts a new feature branch named by the optional `branch.create` (the deterministic `feat/<task.id>`, emitted only for a single-task feature run; the epic seed AND the delivery-graph run-root seed omit it so each fan-out slice's agent branches per node/MI child). Beyond `{provider,url,ref}`, it carries clone-shaping fields for large monorepos (issue #287): `singleBranch:true` + `filter:\"blob:none\"` (a branch-scoped, blobless partial clone — trees fetched up-front, blobs lazily, no `--depth 1` so the merge-base/3-dot diff stays valid) and an optional `baseRef` (the PR base branch, emitted only when resolvable, so the harness fetches its tip and keeps `origin/<base>` reachable). World-restore (issue #324, ADR 0062 Slice 4/5): an optional `commitSha` — the last durable push-checkpoint — is emitted so a REPLACEMENT activation on a fresh worktree reconstructs the tree to the EXACT pushed SHA (inverting the round's `git push` into `git fetch && git checkout <sha>`), omitted when the PR has no checkpoint yet. Gated on c8ctl provisioner support (jwulf/c8ctl-plugin-nano#91).",
|
|
388
388
|
shape:
|
|
389
|
-
'{ provider: "github", url: string, ref: string, singleBranch: true, filter: "blob:none", baseRef?: string, commitSha?: string }',
|
|
389
|
+
'{ provider: "github", url: string, ref: string, singleBranch: true, filter: "blob:none", baseRef?: string, commitSha?: string, branch?: { create: string } }',
|
|
390
390
|
},
|
|
391
391
|
"epicSet.submit": {
|
|
392
392
|
category: "wire",
|
|
@@ -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 } & DeliveryRunTimeouts = {},
|
|
52
|
+
options: { runKey?: string | null; title?: string | null; repository?: string | null; baseBranch?: string | null } & DeliveryRunTimeouts = {},
|
|
53
53
|
): Promise<DispatchDeliveryGraphResult> {
|
|
54
54
|
const validationErrors = validateDeliveryGraph(graph);
|
|
55
55
|
if (validationErrors.length > 0) {
|
|
@@ -140,6 +140,11 @@ 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 (absent → the
|
|
145
|
+
// runner emits no envelope and the harness keeps its legacy launch-dir behaviour).
|
|
146
|
+
repository: options.repository,
|
|
147
|
+
baseBranch: options.baseBranch,
|
|
143
148
|
});
|
|
144
149
|
} catch (err) {
|
|
145
150
|
await markClaimFailed();
|
|
@@ -363,6 +363,62 @@ test("runDeliveryGraph coerces a numeric engine processInstanceKey to a string h
|
|
|
363
363
|
assertEquals(typeof r.handle.processInstanceKey, "string");
|
|
364
364
|
});
|
|
365
365
|
|
|
366
|
+
// Host-git provisioning (issue #684/#686): the delivery-graph runner must seed the canonical
|
|
367
|
+
// `io.nanobpm.agentTask.repository` isolation envelope (`repoEnvelopeVars`) as a run-root process
|
|
368
|
+
// variable so every agent cell's servicing `senior:*` job provisions an ISOLATED throwaway clone
|
|
369
|
+
// instead of mutating the worker's launch dir — the delivery-graph analog of the plan.ts epic seed.
|
|
370
|
+
// These pin the createInstance variables the harness (headers ∪ variables) reads.
|
|
371
|
+
function captureCreateInstanceVars(): { engine: Parameters<typeof runDeliveryGraph>[0]; seen: () => Record<string, unknown> } {
|
|
372
|
+
let captured: Record<string, unknown> = {};
|
|
373
|
+
const engine = {
|
|
374
|
+
deployResources: async () => [],
|
|
375
|
+
createInstance: async (req: { variables?: Record<string, unknown> }) => {
|
|
376
|
+
captured = req.variables ?? {};
|
|
377
|
+
return { processInstanceKey: "1" };
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
return { engine, seen: () => captured };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
test("runDeliveryGraph seeds the repository isolation envelope when repository + baseBranch are supplied (#684/#686)", async () => {
|
|
384
|
+
const { engine, seen } = captureCreateInstanceVars();
|
|
385
|
+
const r = await runDeliveryGraph(engine, GRAPH, { repository: "owner/repo", baseBranch: "main" });
|
|
386
|
+
assert(r.ok, `expected ok:true, got ${JSON.stringify(r)}`);
|
|
387
|
+
const env = (seen() as Record<string, { repository?: Record<string, unknown> }>)["io.nanobpm.agentTask"];
|
|
388
|
+
assert(env?.repository, `expected the run-root vars to carry io.nanobpm.agentTask.repository, got ${JSON.stringify(seen())}`);
|
|
389
|
+
const repo = env.repository as Record<string, unknown>;
|
|
390
|
+
// PRE-PR shape: `ref = base` (the harness checks out the base; each agent cuts its own feat/<node.id>).
|
|
391
|
+
assertEquals(repo.ref, "main");
|
|
392
|
+
assertEquals(repo.url, "https://github.com/owner/repo.git");
|
|
393
|
+
assertEquals(repo.provider, "github");
|
|
394
|
+
// Branch-scoped blobless clone (#287) so large monorepos provision within the clone timeout.
|
|
395
|
+
assertEquals(repo.singleBranch, true);
|
|
396
|
+
assertEquals(repo.filter, "blob:none");
|
|
397
|
+
// baseRef = base too, so `origin/<base>` stays reachable for the review 3-dot diff.
|
|
398
|
+
assertEquals(repo.baseRef, "main");
|
|
399
|
+
// NO branch.create at the run root — a run fans out to many agent nodes, each needing its own
|
|
400
|
+
// feat/<node.id>, so a single run-level envelope names none (mirrors the plan.ts epic seed).
|
|
401
|
+
assertEquals("branch" in repo, false);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("runDeliveryGraph emits NO envelope when repository/baseBranch are absent — repo-less graphs unchanged (#686)", async () => {
|
|
405
|
+
for (const options of [{}, { repository: "owner/repo" }, { baseBranch: "main" }, { repository: " ", baseBranch: "main" }]) {
|
|
406
|
+
const { engine, seen } = captureCreateInstanceVars();
|
|
407
|
+
const r = await runDeliveryGraph(engine, GRAPH, options);
|
|
408
|
+
assert(r.ok, `expected ok:true for ${JSON.stringify(options)}, got ${JSON.stringify(r)}`);
|
|
409
|
+
assertEquals("io.nanobpm.agentTask" in seen(), false, `no envelope expected for ${JSON.stringify(options)}`);
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test("runDeliveryGraph drops a malformed repository rather than emitting a bogus clone URL (#686)", async () => {
|
|
414
|
+
const { engine, seen } = captureCreateInstanceVars();
|
|
415
|
+
// A value that is not exactly `owner/repo` (a trailing `.git`) must degrade to NO envelope — the
|
|
416
|
+
// helper's defence-in-depth guard — never a double-suffixed `…/owner/repo.git.git` clone URL.
|
|
417
|
+
const r = await runDeliveryGraph(engine, GRAPH, { repository: "owner/repo.git", baseBranch: "main" });
|
|
418
|
+
assert(r.ok, `expected ok:true, got ${JSON.stringify(r)}`);
|
|
419
|
+
assertEquals("io.nanobpm.agentTask" in seen(), false);
|
|
420
|
+
});
|
|
421
|
+
|
|
366
422
|
test("the canonical `agent → converge-merge → wait[pr merged]` graph DISPATCHES with a fact-bound wait target (#570)", async () => {
|
|
367
423
|
// Regression for #570: a `wait[pr]` node whose `target` is a fact reference (`open.pr`, the #548
|
|
368
424
|
// late-binding shape the guide documents as canonical) COMPILED+staged but threw at dispatch —
|
package/app/deliveryRunner.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type { DeliveryFact, DeliveryGraph, DeliveryNode } from "../nano-generate
|
|
|
19
19
|
import { TRANSCRIPT_URL_BASE_VAR, transcriptUrlBaseFor } from "./agentic/transcript-url.ts";
|
|
20
20
|
import { assertNever, compileDeliveryGraph, DELIVERY_GRAPH_PROCESS_ID } from "./deliveryGraphCompiler.ts";
|
|
21
21
|
import { DEFAULT_EVERY_MS, msToIsoDuration, parseProbe, readinessPollEvery, readinessTimeout } from "./readiness.ts";
|
|
22
|
+
import { repoEnvelopeVars } from "./repoEnvelope.ts";
|
|
22
23
|
import { isoDuration } from "./reviewWait.ts";
|
|
23
24
|
|
|
24
25
|
/** The content digest of a compiled graph — `sha256(bpmn)[:12]` — the single source of truth for the
|
|
@@ -52,6 +53,18 @@ export interface DeliveryRunOptions extends DeliveryRunTimeouts {
|
|
|
52
53
|
* cross-correlate. Pass an explicit `runKey` only when you need a reproducible/externally-owned gate
|
|
53
54
|
* scope. */
|
|
54
55
|
runKey?: string;
|
|
56
|
+
/** OPTIONAL `owner/repo` the run's `agent` nodes implement against. When supplied together with
|
|
57
|
+
* `baseBranch`, the runner seeds the canonical repository-provisioning envelope
|
|
58
|
+
* (`io.nanobpm.agentTask.repository`, via `repoEnvelopeVars`) as a run-root `createInstance` process
|
|
59
|
+
* variable so each `agent` cell's servicing `senior:*` job provisions an ISOLATED throwaway clone
|
|
60
|
+
* instead of inheriting the worker's launch dir (issue #684/#686 — the same isolation the legacy
|
|
61
|
+
* feature/plan paths got in #685). Absent/unresolved → NO envelope is emitted and the harness falls
|
|
62
|
+
* back to the legacy launch-dir behaviour, so today's repo-less graphs are unchanged. */
|
|
63
|
+
repository?: string | null;
|
|
64
|
+
/** OPTIONAL base branch the run's `agent` nodes branch off — the `ref` the harness checks out in the
|
|
65
|
+
* isolated clone (the PRE-PR shape: no PR head exists yet, so the agent cuts its own `feat/<node.id>`
|
|
66
|
+
* branch off this base inside the clone). Only consulted when `repository` is also set. */
|
|
67
|
+
baseBranch?: string | null;
|
|
55
68
|
}
|
|
56
69
|
|
|
57
70
|
const DEFAULTS: Required<Omit<DeliveryRunTimeouts, "escalationAssignee">> = {
|
|
@@ -146,6 +159,8 @@ export async function runDeliveryGraph(
|
|
|
146
159
|
const { processDefinitionId, bpmn, nodeInputs } = prep.prepared;
|
|
147
160
|
|
|
148
161
|
await engine.deployResources([{ name: `${processDefinitionId}.bpmn`, content: bpmn, contentType: "application/xml" }]);
|
|
162
|
+
const base = typeof options.baseBranch === "string" && options.baseBranch.trim() !== "" ? options.baseBranch.trim() : null;
|
|
163
|
+
const repo = typeof options.repository === "string" && options.repository.trim() !== "" ? options.repository.trim() : null;
|
|
149
164
|
const { processInstanceKey } = await engine.createInstance({
|
|
150
165
|
processDefinitionId,
|
|
151
166
|
variables: {
|
|
@@ -155,6 +170,20 @@ export async function runDeliveryGraph(
|
|
|
155
170
|
// node ioMapping in deliveryGraphCompiler). Seeded once at the run root — the same value for
|
|
156
171
|
// every node — and read down into each agent job via `=transcriptUrlBase`.
|
|
157
172
|
[TRANSCRIPT_URL_BASE_VAR]: transcriptUrlBaseFor(),
|
|
173
|
+
// Host-git provisioning (c8ctl, issue #684/#686): deliver the ONE canonical repository envelope
|
|
174
|
+
// (`repoEnvelopeVars`, app/repoEnvelope.ts) so every `agent` node's servicing `senior:*` job gets
|
|
175
|
+
// an ISOLATED throwaway clone instead of inheriting the worker's launch dir — otherwise several
|
|
176
|
+
// copilot workers on one host share (and clobber) a single checkout, the exact field failure #684
|
|
177
|
+
// described. This is the delivery-graph analog of the whole-epic seed in `app/plan.ts`: a single
|
|
178
|
+
// run-root `createInstance` process variable that propagates through each agent cell's subProcess
|
|
179
|
+
// into its job. Like plan.ts's fan-out seed it carries `ref = base` but NO `branchCreate` — a run
|
|
180
|
+
// fans out to MANY agent nodes, each needing its own deterministic `feat/<node.id>` branch, so a
|
|
181
|
+
// single run-level envelope can't name one; each agent cuts its own branch off `base` inside the
|
|
182
|
+
// isolated clone (the agent-guide's `feat/*` convention, kept idempotent by the #551 preflight).
|
|
183
|
+
// `baseRef = base` too, so the harness keeps `origin/<base>` reachable for the review 3-dot diff.
|
|
184
|
+
// Spread last so an unresolved repo/base (`{}`) leaves the other run-root vars untouched — a
|
|
185
|
+
// repo-less graph is then dispatched exactly as before (legacy launch-dir behaviour).
|
|
186
|
+
...repoEnvelopeVars(repo ?? "", base, base),
|
|
158
187
|
},
|
|
159
188
|
});
|
|
160
189
|
// The engine can yield a numeric key; `DeliveryRunHandle.processInstanceKey` is typed `string` and
|
package/app/feature.test.ts
CHANGED
|
@@ -131,6 +131,36 @@ test("startFeature: seeds the single task slice + base-branch brief onto the ins
|
|
|
131
131
|
assertEquals(v.status, null);
|
|
132
132
|
});
|
|
133
133
|
|
|
134
|
+
test("startFeature: seeds the pre-PR repository envelope so the harness provisions an isolated clone (#684)", async () => {
|
|
135
|
+
let captured: any = null;
|
|
136
|
+
const engine = {
|
|
137
|
+
createInstance: (req: any) => {
|
|
138
|
+
captured = req;
|
|
139
|
+
return Promise.resolve({ processInstanceKey: "PI-684" });
|
|
140
|
+
},
|
|
141
|
+
} as any;
|
|
142
|
+
await startFeature(
|
|
143
|
+
memData({ feature_runs: { rows: [], key: "feature_key" } }),
|
|
144
|
+
engine,
|
|
145
|
+
PARSED,
|
|
146
|
+
"epic/x",
|
|
147
|
+
true,
|
|
148
|
+
false,
|
|
149
|
+
);
|
|
150
|
+
// Without the envelope the c8ctl harness leaves cwd undefined and the agent mutates the worker's
|
|
151
|
+
// shared launch dir; with it, the harness clones a throwaway workspace. The implementation path is
|
|
152
|
+
// PRE-PR, so it checks out the BASE branch (`ref`) and the harness cuts the deterministic
|
|
153
|
+
// `feat/<task.id>` feature branch off it (`branch.create`).
|
|
154
|
+
const repo = (captured.variables as Record<string, any>)["io.nanobpm.agentTask"].repository;
|
|
155
|
+
assertEquals(repo.url, "https://github.com/owner/repo.git");
|
|
156
|
+
assertEquals(repo.ref, "epic/x");
|
|
157
|
+
assertEquals(repo.branch.create, "feat/issue-42");
|
|
158
|
+
assertEquals(repo.branch.create, `feat/${featureTaskId(PARSED.number)}`);
|
|
159
|
+
// The blobless/single-branch monorepo shaping rides along, exactly like the PR-based envelope.
|
|
160
|
+
assertEquals(repo.singleBranch, true);
|
|
161
|
+
assertEquals(repo.filter, "blob:none");
|
|
162
|
+
});
|
|
163
|
+
|
|
134
164
|
test("startFeature: custom instructions ride the instance as a variable (trimmed)", async () => {
|
|
135
165
|
let captured: any = null;
|
|
136
166
|
const engine = {
|
package/app/feature.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { TRANSCRIPT_URL_BASE_VAR, transcriptUrlBaseFor } from "./agentic/transcr
|
|
|
19
19
|
import { coalesceTitle, fetchIssueTitle } from "./github.ts";
|
|
20
20
|
import { ESCALATION_SLA_TIMEOUT, normalizeBaseBranch, type ParsedIssue, renderBaseBranchBrief } from "./plan.ts";
|
|
21
21
|
import type { ReadinessProbe } from "./readiness.ts";
|
|
22
|
+
import { repoEnvelopeVars } from "./repoEnvelope.ts";
|
|
22
23
|
|
|
23
24
|
/** Optional intake-time readiness gate for a feature run (issue #295): the `capability`/`command`/…
|
|
24
25
|
* probes the run must ALL satisfy before its implementation agent is dispatched (parked, durably, at
|
|
@@ -336,6 +337,10 @@ export async function startFeature(
|
|
|
336
337
|
updated_at: ts,
|
|
337
338
|
});
|
|
338
339
|
}
|
|
340
|
+
const taskId = featureTaskId(parsed.number);
|
|
341
|
+
// Pre-PR feature branch (issue #684): the deterministic `feat/<task.id>` the harness creates off the
|
|
342
|
+
// base for the implementation agent (the agent-guide's `feat/*` convention), matching `task.id`.
|
|
343
|
+
const prePrBranch = `feat/${taskId}`;
|
|
339
344
|
const { processInstanceKey } = await engine.createInstance({
|
|
340
345
|
processDefinitionId: FEATURE_PROCESS_ID,
|
|
341
346
|
variables: {
|
|
@@ -349,7 +354,7 @@ export async function startFeature(
|
|
|
349
354
|
// (resources/prompts/feature.md); `task.id` fixes its deterministic branch `feat/<task.id>` across a
|
|
350
355
|
// resume. Unlike an epic, there is no planner — the whole issue IS the slice.
|
|
351
356
|
task: {
|
|
352
|
-
id:
|
|
357
|
+
id: taskId,
|
|
353
358
|
title: parsed.planKey,
|
|
354
359
|
prompt:
|
|
355
360
|
`Implement the GitHub issue ${parsed.planKey} end to end. Read it in full first ` +
|
|
@@ -406,6 +411,15 @@ export async function startFeature(
|
|
|
406
411
|
// links this feature run to its agent transcript in Nano Explorer (feature.bpmn `implement-task`
|
|
407
412
|
// ioMapping). Read down into the job via `=transcriptUrlBase`.
|
|
408
413
|
[TRANSCRIPT_URL_BASE_VAR]: transcriptUrlBaseFor(),
|
|
414
|
+
// Host-git provisioning (c8ctl, issue #684): deliver the repository envelope so the
|
|
415
|
+
// `senior:feature` implementation agent gets an ISOLATED throwaway clone instead of inheriting
|
|
416
|
+
// the worker's launch dir (which, with several copilot workers on one host, means concurrent
|
|
417
|
+
// implementation jobs share — and clobber — one checkout, violating the durable-resume design).
|
|
418
|
+
// A feature run is PRE-PR: there is no head branch yet, so the harness checks out the BASE
|
|
419
|
+
// branch (`ref = base`) and creates the deterministic `feat/<task.id>` feature branch itself
|
|
420
|
+
// (`branchCreate`), matching the agent-guide's `feat/*` convention. Spread last so an unresolved
|
|
421
|
+
// repo (`{}`) leaves the other vars untouched.
|
|
422
|
+
...repoEnvelopeVars(parsed.repo, base, null, null, prePrBranch),
|
|
409
423
|
},
|
|
410
424
|
});
|
|
411
425
|
const processKey = processInstanceKey == null ? null : String(processInstanceKey);
|
package/app/plan.test.ts
CHANGED
|
@@ -241,6 +241,15 @@ test("startPlan pins the base branch: persisted on the row + seeded as baseBranc
|
|
|
241
241
|
// Process variables the implement-task consumes.
|
|
242
242
|
assertEquals(seen.baseBranch, "epic/agent-protocol");
|
|
243
243
|
assertEquals(seen.baseBranchBrief.includes("gh pr create --base epic/agent-protocol"), true);
|
|
244
|
+
// Host-git provisioning (#684): the whole-epic repository envelope so each slice's implementation
|
|
245
|
+
// agent gets an isolated clone. The epic seed checks out the BASE branch (`ref`) — each slice's
|
|
246
|
+
// `feat/<task.id>` branch differs per MI child, so NO `branch.create` here (the agent branches).
|
|
247
|
+
const repo = seen["io.nanobpm.agentTask"].repository;
|
|
248
|
+
assertEquals(repo.url, "https://github.com/owner/repo.git");
|
|
249
|
+
assertEquals(repo.ref, "epic/agent-protocol");
|
|
250
|
+
assertEquals("branch" in repo, false);
|
|
251
|
+
assertEquals(repo.singleBranch, true);
|
|
252
|
+
assertEquals(repo.filter, "blob:none");
|
|
244
253
|
});
|
|
245
254
|
|
|
246
255
|
test("startPlan renders baseBranchBrief unconditionally now that base is required", async () => {
|
package/app/plan.ts
CHANGED
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
// the process. Data access goes through the record gateway (`data.table`), never
|
|
11
11
|
// hand-written SQL — matching app/service.ts.
|
|
12
12
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
13
|
+
import {
|
|
14
|
+
InvalidBaseBranchError,
|
|
15
|
+
isPlausibleBranchName,
|
|
16
|
+
MissingBaseBranchError,
|
|
17
|
+
normalizeBaseBranch,
|
|
18
|
+
} from "./baseBranch.ts";
|
|
13
19
|
import { blackboardUrl, mintBlackboardToken, renderCoordinationBrief } from "./blackboard.ts";
|
|
14
20
|
import { capsWaitTimeout, DEFAULT_CAPS_WAIT_TIMEOUT } from "./capsWait.ts";
|
|
15
21
|
import { EPIC_PHASE } from "./epicPhase.ts";
|
|
@@ -24,6 +30,7 @@ import {
|
|
|
24
30
|
import { derivedTrackingTable } from "./instanceTracking.ts";
|
|
25
31
|
import { clearExclusions } from "./mergeExclusion.ts";
|
|
26
32
|
import type { ReadinessProbe } from "./readiness.ts";
|
|
33
|
+
import { repoEnvelopeVars } from "./repoEnvelope.ts";
|
|
27
34
|
import { clearTaskDeltas } from "./taskDelta.ts";
|
|
28
35
|
|
|
29
36
|
/** The BPMN process this module drives (resources/processes/plan-fanout.bpmn). */
|
|
@@ -485,52 +492,11 @@ export function parseIssue(input: string): ParsedIssue | null {
|
|
|
485
492
|
return null;
|
|
486
493
|
}
|
|
487
494
|
|
|
488
|
-
/**
|
|
489
|
-
*
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
export
|
|
493
|
-
readonly value: string;
|
|
494
|
-
constructor(value: string) {
|
|
495
|
-
super(`invalid base branch name: ${JSON.stringify(value)}`);
|
|
496
|
-
this.name = "InvalidBaseBranchError";
|
|
497
|
-
this.value = value;
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
/** Raised when a caller supplies a blank/absent `baseBranch`. Every epic launch must name its base
|
|
502
|
-
* branch explicitly (ADR 0003): "land on the default branch" is a conscious, named, confirmed choice
|
|
503
|
-
* (the confirm-default gate), never a silent fallback. The operation edge maps this to a 400. */
|
|
504
|
-
export class MissingBaseBranchError extends Error {
|
|
505
|
-
constructor() {
|
|
506
|
-
super("base branch is required (blank/absent base branches are rejected)");
|
|
507
|
-
this.name = "MissingBaseBranchError";
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
/** Conservative allowlist gate for a base-branch name. Stricter than `git check-ref-format` on
|
|
512
|
-
* purpose: only `[A-Za-z0-9._/-]`, no leading `/`/`.`/`-` (a leading dash reads as a CLI flag),
|
|
513
|
-
* no trailing `/`/`.`, no `..`/`//`, no empty or `.lock`-suffixed path component, bounded length.
|
|
514
|
-
* This rejects whitespace, shell metacharacters, command substitution, and newlines outright. */
|
|
515
|
-
function isPlausibleBranchName(s: string): boolean {
|
|
516
|
-
if (s.length === 0 || s.length > 255) return false;
|
|
517
|
-
if (!/^[A-Za-z0-9._/-]+$/.test(s)) return false;
|
|
518
|
-
if (/^[/.-]/.test(s) || /[/.]$/.test(s)) return false;
|
|
519
|
-
if (s.includes("..") || s.includes("//")) return false;
|
|
520
|
-
return s.split("/").every((seg) => seg.length > 0 && !seg.startsWith(".") && !seg.endsWith(".lock"));
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
/** Normalise a caller-supplied base branch: trim, then require it. A blank/absent value is rejected
|
|
524
|
-
* (`MissingBaseBranchError`) — ADR 0003 removed the implicit default-branch fallback, so every epic
|
|
525
|
-
* launch must name its base explicitly. A non-blank value that is not a plausible git branch name is
|
|
526
|
-
* rejected (`InvalidBaseBranchError`) rather than persisted or rendered into the agent prompt. The
|
|
527
|
-
* operation edge maps both to a 400. Always returns a non-null branch on success. */
|
|
528
|
-
export function normalizeBaseBranch(input: string | null | undefined): string {
|
|
529
|
-
const s = (input ?? "").trim();
|
|
530
|
-
if (s.length === 0) throw new MissingBaseBranchError();
|
|
531
|
-
if (!isPlausibleBranchName(s)) throw new InvalidBaseBranchError(s);
|
|
532
|
-
return s;
|
|
533
|
-
}
|
|
495
|
+
/** The base-branch validation gate lives in the side-effect-free leaf `./baseBranch.ts` so an API
|
|
496
|
+
* door (e.g. `operations/dispatchDeliveryGraph.ts`) can reuse it without importing this heavy module
|
|
497
|
+
* and its import-time env seeding. Re-exported here so existing importers keep resolving through
|
|
498
|
+
* `plan.ts` — one implementation (derivation over duplication), just hoisted below the heavy module. */
|
|
499
|
+
export { InvalidBaseBranchError, isPlausibleBranchName, MissingBaseBranchError, normalizeBaseBranch };
|
|
534
500
|
|
|
535
501
|
/** The per-instance brief appended to an implementer agent's prompt when the plan pins a base
|
|
536
502
|
* branch. It is authoritative over the static "branch off the default branch" wording in
|
|
@@ -1074,6 +1040,16 @@ export async function startPlan(
|
|
|
1074
1040
|
// instance. A ROOT never runs the preflight, so its `gateKey` stays `null`, unused.
|
|
1075
1041
|
gateKey: probes ? `preflight:${parsed.planKey}` : null,
|
|
1076
1042
|
resolvedArtifacts: null,
|
|
1043
|
+
// Host-git provisioning (c8ctl, issue #684): deliver the repository envelope so each epic slice's
|
|
1044
|
+
// `senior:feature` implementation agent (plan-fanout's per-wave `implement-cell`) gets an
|
|
1045
|
+
// ISOLATED throwaway clone instead of inheriting the worker's launch dir — otherwise several
|
|
1046
|
+
// copilot workers on one host share (and clobber) a single checkout, violating the durable-resume
|
|
1047
|
+
// design. This is the whole-epic seed, so it carries `ref = base` (the epic integration branch)
|
|
1048
|
+
// but NO `branchCreate`: each slice's deterministic `feat/<task.id>` branch differs per MI child,
|
|
1049
|
+
// so the agent cuts its own branch inside the isolated clone (per resources/prompts/feature.md).
|
|
1050
|
+
// The process-level variable propagates through the wave subprocess + `implement-cell` callActivity
|
|
1051
|
+
// into each agent job. Spread last so an unresolved repo (`{}`) leaves the other vars untouched.
|
|
1052
|
+
...repoEnvelopeVars(parsed.repo, base),
|
|
1077
1053
|
},
|
|
1078
1054
|
});
|
|
1079
1055
|
const processKey = processInstanceKey == null ? null : String(processInstanceKey);
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// nano-workforce — the repository-provisioning envelope (`io.nanobpm.agentTask.repository`).
|
|
2
|
+
//
|
|
3
|
+
// The ONE canonical builder of the agent-task repository envelope the c8ctl nano worker harness
|
|
4
|
+
// consumes to provision an ISOLATED clone for an agent job — instead of the agent inheriting
|
|
5
|
+
// whatever directory the worker was launched from (which, with several copilot workers on one host,
|
|
6
|
+
// means concurrent jobs share — and clobber — one checkout; see issue #684). It lives in its own
|
|
7
|
+
// module (not service.ts) so BOTH the PR-based dispatch (`service.ts`: review-round / fix-ci /
|
|
8
|
+
// rebase) and the PRE-PR implementation dispatch (`feature.ts`: `startFeature`; `plan.ts`:
|
|
9
|
+
// `startPlan` → the epic's `implement-cell`) derive from this single implementation without a
|
|
10
|
+
// `plan.ts ↔ service.ts` import cycle (AGENTS.md "derivation over duplication — no drift surfaces").
|
|
11
|
+
import { isCommitSha } from "./world/index.ts";
|
|
12
|
+
|
|
13
|
+
/** The reserved namespace key the c8ctl nano worker harness reads the agent-task envelope from
|
|
14
|
+
* (headers ∪ variables, deep-merged). See c8ctl `normalizeTaskEnvelope`. */
|
|
15
|
+
const AGENT_TASK_NS = "io.nanobpm.agentTask";
|
|
16
|
+
|
|
17
|
+
/** Build the repository slice of the agent-task envelope for an agent job. Delivered as a *process
|
|
18
|
+
* variable* under the reserved `io.nanobpm.agentTask` key so the harness provisions an isolated
|
|
19
|
+
* clone — instead of the agent inheriting whatever directory the worker was launched from (which
|
|
20
|
+
* only happened to be a usable checkout for repos already present locally). `ref` is the branch the
|
|
21
|
+
* harness checks out: the PR HEAD branch on the PR-based paths (review-round / fix-ci / rebase), or —
|
|
22
|
+
* on the PRE-PR implementation path (`branchCreate` set) — the BASE branch, off which the harness
|
|
23
|
+
* cuts the new feature branch. When `ref` is unresolved we emit nothing (no `repository.url`) so the
|
|
24
|
+
* harness falls back to the legacy launch-dir behavior rather than silently cloning the repo's
|
|
25
|
+
* default branch. The static `task.prompt` header on the service task deep-merges with this over the
|
|
26
|
+
* same namespace.
|
|
27
|
+
*
|
|
28
|
+
* The clone is requested **branch-scoped and blobless** (`singleBranch: true` + `filter:
|
|
29
|
+
* "blob:none"`) so large monorepos (e.g. `camunda/camunda`, ~1.16 GB) provision within the c8ctl
|
|
30
|
+
* clone timeout instead of full-cloning the whole history (issue #287). `blob:none` is a *blobless*
|
|
31
|
+
* partial clone (trees are still fetched up-front — a *treeless* clone would be `--filter=tree:0`); it
|
|
32
|
+
* keeps the full *commit graph* (so `git merge-base` / the review 3-dot diff stays correct) while
|
|
33
|
+
* fetching file blobs lazily — small upfront, correct diffs. `--depth 1` is deliberately NOT used:
|
|
34
|
+
* it would drop the merge-base and break `git diff origin/<base>...HEAD`. When the PR base branch
|
|
35
|
+
* is known we also emit `baseRef` so the harness fetches the base tip alongside the head, keeping
|
|
36
|
+
* that base reachable for the diff.
|
|
37
|
+
*
|
|
38
|
+
* World-restore (issue #324, ADR 0062 Slice 4/5): when a PR already has a durable push-checkpoint,
|
|
39
|
+
* `commitSha` is emitted so a REPLACEMENT activation (a fresh worktree after a lease loss)
|
|
40
|
+
* reconstructs the working tree to the EXACT pushed SHA — the inversion of the round's outbound
|
|
41
|
+
* `git push` into an inbound `git fetch && git checkout <sha>` — rather than to a branch tip that may
|
|
42
|
+
* have moved. Omitted (no key) when the PR has no checkpoint yet, so a first activation clones the
|
|
43
|
+
* head branch normally.
|
|
44
|
+
*
|
|
45
|
+
* Pre-PR provisioning (issue #684): the implementation path (feature.bpmn / plan-fanout's
|
|
46
|
+
* `implement-cell`) dispatches its agent BEFORE any PR exists, so it passes `ref = base` and a
|
|
47
|
+
* `branchCreate` naming the deterministic `feat/<task.id>` feature branch the harness cuts off that
|
|
48
|
+
* base itself (the agent-guide's `feat/*` convention) — instead of the agent branching by hand — so
|
|
49
|
+
* the isolated clone lands on the right branch deterministically across a resume. `branchCreate` is
|
|
50
|
+
* omitted on the PR-based paths, which check out an existing head. */
|
|
51
|
+
export function repoEnvelopeVars(
|
|
52
|
+
repo: string,
|
|
53
|
+
ref: string | null,
|
|
54
|
+
baseRef: string | null = null,
|
|
55
|
+
commitSha: string | null = null,
|
|
56
|
+
branchCreate: string | null = null,
|
|
57
|
+
): Record<string, unknown> {
|
|
58
|
+
if (!ref) return {};
|
|
59
|
+
// Defence in depth: every current caller derives `repo` from parsePr/parseIssue (regex-bounded to
|
|
60
|
+
// `owner/repo`), but this is an exported helper the fan-out epic gives many new callers. A repo
|
|
61
|
+
// that is not exactly `owner/repo` would build a bogus clone URL, so emit nothing (the harness
|
|
62
|
+
// then falls back to the launch-dir behaviour) rather than handing the harness a malformed URL.
|
|
63
|
+
// The owner is a GitHub login (alphanumeric + hyphen); the repo-name segment additionally allows
|
|
64
|
+
// `.` and `_`. A trailing `.git` is rejected outright so we never emit a double-suffixed
|
|
65
|
+
// `…/owner/repo.git.git`, and the anchored allowlist bars query/fragment/host-injection chars.
|
|
66
|
+
if (!/^[A-Za-z0-9-]+\/[A-Za-z0-9._-]+$/.test(repo) || /\.git$/i.test(repo)) return {};
|
|
67
|
+
return {
|
|
68
|
+
[AGENT_TASK_NS]: {
|
|
69
|
+
repository: {
|
|
70
|
+
provider: "github",
|
|
71
|
+
url: `https://github.com/${repo}.git`,
|
|
72
|
+
ref,
|
|
73
|
+
// Branch-scoped, blobless partial clone (issue #287): fetch only the head branch with lazy
|
|
74
|
+
// blobs so large monorepos provision within the clone timeout. Single-branch + blob:none
|
|
75
|
+
// (not --depth 1) preserves the commit graph so the review's `git diff origin/<base>...HEAD`
|
|
76
|
+
// has a valid merge-base. Gated on c8ctl provisioner support (jwulf/c8ctl-plugin-nano#91).
|
|
77
|
+
singleBranch: true,
|
|
78
|
+
filter: "blob:none",
|
|
79
|
+
// The base branch this PR targets — emitted so the harness fetches its tip alongside the
|
|
80
|
+
// single-branch head, keeping `origin/<base>` reachable for the diff. Omitted when unknown.
|
|
81
|
+
...(baseRef ? { baseRef } : {}),
|
|
82
|
+
// World-restore (issue #324): the last pushed SHA a replacement activation reconstructs the
|
|
83
|
+
// working tree to (inverting the round's push into a fetch+checkout). Only emitted when it is
|
|
84
|
+
// a well-formed 40-hex commit SHA: `commitSha` is forwarded to the harness as an EXACT
|
|
85
|
+
// checkout target, so a non-SHA ref or a whitespace-tainted value could reconstruct to an
|
|
86
|
+
// unintended ref (a moved branch tip) or fail provisioning. A malformed value degrades to
|
|
87
|
+
// omission — the harness then clones the head branch tip, the pre-#324 behaviour. Omitted too
|
|
88
|
+
// when the PR has no durable push-checkpoint yet.
|
|
89
|
+
...(isCommitSha(commitSha) ? { commitSha } : {}),
|
|
90
|
+
// Pre-PR provisioning (issue #684): the implementation path (feature.bpmn / plan-fanout's
|
|
91
|
+
// implement-cell) dispatches its agent BEFORE any PR exists, so `ref` is the BASE branch, not a
|
|
92
|
+
// head. `branchCreate` asks the harness to cut the deterministic `feat/<task.id>` feature branch
|
|
93
|
+
// off that base itself (the agent-guide's `feat/*` convention) instead of the agent branching by
|
|
94
|
+
// hand — making the isolated clone land on the right branch deterministically across a resume.
|
|
95
|
+
// Omitted (no key) on the PR-based paths (review/fix-ci/rebase), which check out an existing head.
|
|
96
|
+
...(typeof branchCreate === "string" && branchCreate.trim() !== ""
|
|
97
|
+
? { branch: { create: branchCreate.trim() } }
|
|
98
|
+
: {}),
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
package/app/service.test.ts
CHANGED
|
@@ -550,6 +550,34 @@ test("repoEnvelopeVars emits commitSha only for a well-formed 40-hex SHA (world-
|
|
|
550
550
|
assertEquals("commitSha" in none, false);
|
|
551
551
|
});
|
|
552
552
|
|
|
553
|
+
// Pre-PR provisioning (issue #684): the implementation path has no head branch yet, so it passes
|
|
554
|
+
// `ref = base` + a `branchCreate` so the harness clones the base and cuts the deterministic
|
|
555
|
+
// `feat/<task.id>` feature branch off it. `branch.create` is emitted only for a non-blank branch and
|
|
556
|
+
// is absent on the PR-based paths (which check out an existing head).
|
|
557
|
+
test("repoEnvelopeVars emits branch.create only for a non-blank pre-PR branch (#684)", () => {
|
|
558
|
+
const repo = (repoEnvelopeVars("owner/repo", "main", null, null, "feat/issue-7") as any)["io.nanobpm.agentTask"]
|
|
559
|
+
.repository;
|
|
560
|
+
assertEquals(repo.ref, "main", "the pre-PR envelope checks out the BASE branch as its ref");
|
|
561
|
+
assertEquals(repo.branch.create, "feat/issue-7", "the harness cuts the deterministic feature branch off the base");
|
|
562
|
+
// Still branch-scoped and blobless like the PR-based envelope.
|
|
563
|
+
assertEquals(repo.singleBranch, true);
|
|
564
|
+
assertEquals(repo.filter, "blob:none");
|
|
565
|
+
// A whitespace-tainted branch is trimmed; a blank/absent one omits the `branch` key entirely so the
|
|
566
|
+
// PR-based paths (and any caller that doesn't pre-create a branch) are unaffected.
|
|
567
|
+
assertEquals(
|
|
568
|
+
(repoEnvelopeVars("owner/repo", "main", null, null, " feat/issue-9 ") as any)["io.nanobpm.agentTask"].repository
|
|
569
|
+
.branch.create,
|
|
570
|
+
"feat/issue-9",
|
|
571
|
+
);
|
|
572
|
+
for (const blank of [null, undefined, "", " "]) {
|
|
573
|
+
const r = (repoEnvelopeVars("owner/repo", "main", null, null, blank as any) as any)["io.nanobpm.agentTask"]
|
|
574
|
+
.repository;
|
|
575
|
+
assertEquals("branch" in r, false, `expected no branch key for ${JSON.stringify(blank)}`);
|
|
576
|
+
}
|
|
577
|
+
// The default (4-arg) PR-based call never emits a branch.create.
|
|
578
|
+
assertEquals("branch" in (repoEnvelopeVars("owner/repo", "feat/x", "main") as any)["io.nanobpm.agentTask"].repository, false);
|
|
579
|
+
});
|
|
580
|
+
|
|
553
581
|
// Durable-resume enrolment gate (issue #325, ADR 0062 Slice 5/5): `worldRestoreSha` — the seam
|
|
554
582
|
// `submitPr`/`startMerge` thread into `repoEnvelopeVars` — hands the harness the last push-checkpoint
|
|
555
583
|
// ONLY when the enrolled fleet advertises `durable-resume`. With no participant it degrades to null,
|
package/app/service.ts
CHANGED
|
@@ -85,6 +85,12 @@ import {
|
|
|
85
85
|
readinessPollEvery,
|
|
86
86
|
readinessTimeout,
|
|
87
87
|
} from "./readiness.ts";
|
|
88
|
+
// The repository-provisioning envelope builder lives in its own module (app/repoEnvelope.ts) so the
|
|
89
|
+
// PRE-PR implementation dispatch (`feature.ts`/`plan.ts`) can reuse the ONE canonical implementation
|
|
90
|
+
// without a `plan.ts ↔ service.ts` import cycle (issue #684). Imported for the PR-based callers
|
|
91
|
+
// (submitPr/startMerge) and re-exported below so the long-standing `import { repoEnvelopeVars } from
|
|
92
|
+
// "./service.ts"` call sites (and its tests) keep resolving.
|
|
93
|
+
import { repoEnvelopeVars } from "./repoEnvelope.ts";
|
|
88
94
|
import { clampNudgeMinutes, reviewWaitTimeout } from "./reviewWait.ts";
|
|
89
95
|
import { trialMergeAudits } from "./trialMerge.ts";
|
|
90
96
|
import {
|
|
@@ -112,7 +118,7 @@ import {
|
|
|
112
118
|
} from "./userTasks.ts";
|
|
113
119
|
import { deriveWaitGate } from "./waitGate.ts";
|
|
114
120
|
import { waveMergeTargets } from "./waves.ts";
|
|
115
|
-
import {
|
|
121
|
+
import { WorldStore } from "./world/index.ts";
|
|
116
122
|
|
|
117
123
|
/** The BPMN process that drives review convergence (`resources/processes/convergence-loop.bpmn`). */
|
|
118
124
|
export const PROCESS_ID = "convergence-loop";
|
|
@@ -413,77 +419,10 @@ async function registerDependencies(data: DataLayer, prKey: string, depKeys: str
|
|
|
413
419
|
}
|
|
414
420
|
}
|
|
415
421
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
/** Build the repository slice of the agent-task envelope for a PR-based agent job (review-round,
|
|
421
|
-
* fix-ci, rebase). Delivered as a *process variable* under the reserved `io.nanobpm.agentTask`
|
|
422
|
-
* key so the harness provisions an isolated clone checked out on the PR's head branch — instead of
|
|
423
|
-
* the agent inheriting whatever directory the worker was launched from (which only happened to be
|
|
424
|
-
* a usable checkout for repos already present locally). `ref` MUST be the PR head branch; when it
|
|
425
|
-
* is unresolved we emit nothing (no `repository.url`) so the harness falls back to the legacy
|
|
426
|
-
* launch-dir behavior rather than silently cloning the repo's default branch. The static
|
|
427
|
-
* `task.prompt` header on the service task deep-merges with this over the same namespace.
|
|
428
|
-
*
|
|
429
|
-
* The clone is requested **branch-scoped and blobless** (`singleBranch: true` + `filter:
|
|
430
|
-
* "blob:none"`) so large monorepos (e.g. `camunda/camunda`, ~1.16 GB) provision within the c8ctl
|
|
431
|
-
* clone timeout instead of full-cloning the whole history (issue #287). `blob:none` is a *blobless*
|
|
432
|
-
* partial clone (trees are still fetched up-front — a *treeless* clone would be `--filter=tree:0`); it
|
|
433
|
-
* keeps the full *commit graph* (so `git merge-base` / the review 3-dot diff stays correct) while
|
|
434
|
-
* fetching file blobs lazily — small upfront, correct diffs. `--depth 1` is deliberately NOT used:
|
|
435
|
-
* it would drop the merge-base and break `git diff origin/<base>...HEAD`. When the PR base branch
|
|
436
|
-
* is known we also emit `baseRef` so the harness fetches the base tip alongside the head, keeping
|
|
437
|
-
* that base reachable for the diff.
|
|
438
|
-
*
|
|
439
|
-
* World-restore (issue #324, ADR 0062 Slice 4/5): when a PR already has a durable push-checkpoint,
|
|
440
|
-
* `commitSha` is emitted so a REPLACEMENT activation (a fresh worktree after a lease loss)
|
|
441
|
-
* reconstructs the working tree to the EXACT pushed SHA — the inversion of the round's outbound
|
|
442
|
-
* `git push` into an inbound `git fetch && git checkout <sha>` — rather than to a branch tip that may
|
|
443
|
-
* have moved. Omitted (no key) when the PR has no checkpoint yet, so a first activation clones the
|
|
444
|
-
* head branch normally. */
|
|
445
|
-
export function repoEnvelopeVars(
|
|
446
|
-
repo: string,
|
|
447
|
-
ref: string | null,
|
|
448
|
-
baseRef: string | null = null,
|
|
449
|
-
commitSha: string | null = null,
|
|
450
|
-
): Record<string, unknown> {
|
|
451
|
-
if (!ref) return {};
|
|
452
|
-
// Defence in depth: every current caller derives `repo` from parsePr/parseIssue (regex-bounded to
|
|
453
|
-
// `owner/repo`), but this is an exported helper the fan-out epic gives many new callers. A repo
|
|
454
|
-
// that is not exactly `owner/repo` would build a bogus clone URL, so emit nothing (the harness
|
|
455
|
-
// then falls back to the launch-dir behaviour) rather than handing the harness a malformed URL.
|
|
456
|
-
// The owner is a GitHub login (alphanumeric + hyphen); the repo-name segment additionally allows
|
|
457
|
-
// `.` and `_`. A trailing `.git` is rejected outright so we never emit a double-suffixed
|
|
458
|
-
// `…/owner/repo.git.git`, and the anchored allowlist bars query/fragment/host-injection chars.
|
|
459
|
-
if (!/^[A-Za-z0-9-]+\/[A-Za-z0-9._-]+$/.test(repo) || /\.git$/i.test(repo)) return {};
|
|
460
|
-
return {
|
|
461
|
-
[AGENT_TASK_NS]: {
|
|
462
|
-
repository: {
|
|
463
|
-
provider: "github",
|
|
464
|
-
url: `https://github.com/${repo}.git`,
|
|
465
|
-
ref,
|
|
466
|
-
// Branch-scoped, blobless partial clone (issue #287): fetch only the head branch with lazy
|
|
467
|
-
// blobs so large monorepos provision within the clone timeout. Single-branch + blob:none
|
|
468
|
-
// (not --depth 1) preserves the commit graph so the review's `git diff origin/<base>...HEAD`
|
|
469
|
-
// has a valid merge-base. Gated on c8ctl provisioner support (jwulf/c8ctl-plugin-nano#91).
|
|
470
|
-
singleBranch: true,
|
|
471
|
-
filter: "blob:none",
|
|
472
|
-
// The base branch this PR targets — emitted so the harness fetches its tip alongside the
|
|
473
|
-
// single-branch head, keeping `origin/<base>` reachable for the diff. Omitted when unknown.
|
|
474
|
-
...(baseRef ? { baseRef } : {}),
|
|
475
|
-
// World-restore (issue #324): the last pushed SHA a replacement activation reconstructs the
|
|
476
|
-
// working tree to (inverting the round's push into a fetch+checkout). Only emitted when it is
|
|
477
|
-
// a well-formed 40-hex commit SHA: `commitSha` is forwarded to the harness as an EXACT
|
|
478
|
-
// checkout target, so a non-SHA ref or a whitespace-tainted value could reconstruct to an
|
|
479
|
-
// unintended ref (a moved branch tip) or fail provisioning. A malformed value degrades to
|
|
480
|
-
// omission — the harness then clones the head branch tip, the pre-#324 behaviour. Omitted too
|
|
481
|
-
// when the PR has no durable push-checkpoint yet.
|
|
482
|
-
...(isCommitSha(commitSha) ? { commitSha } : {}),
|
|
483
|
-
},
|
|
484
|
-
},
|
|
485
|
-
};
|
|
486
|
-
}
|
|
422
|
+
// The repository-provisioning envelope builder now lives in its own module (app/repoEnvelope.ts);
|
|
423
|
+
// re-export it here so the long-standing `import { repoEnvelopeVars } from "./service.ts"` call
|
|
424
|
+
// sites (and its tests) keep resolving (issue #684).
|
|
425
|
+
export { repoEnvelopeVars };
|
|
487
426
|
|
|
488
427
|
/** The last durable push-checkpoint SHA for a PR (issue #324, ADR 0062 Slice 4/5), or `null` when it
|
|
489
428
|
* has none yet. Threaded into `repoEnvelopeVars` so a replacement activation reconstructs the exact
|
package/e2e/feature-run.e2e.ts
CHANGED
|
@@ -123,8 +123,18 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
test("raise-only: an opened PR ends the run at `opened`, taking the raise-only branch", async () => {
|
|
126
|
+
// Host-git provisioning (#684): capture the repository envelope the `senior:feature` agent job
|
|
127
|
+
// carries — proof the implement-cell agent gets an ISOLATED clone (base branch + a harness-cut
|
|
128
|
+
// `feat/<task.id>`) instead of inheriting the worker's launch dir. It rides the process variable
|
|
129
|
+
// all the way down through feature.bpmn's `implement` callActivity into the cell's agent job.
|
|
130
|
+
let agentRepo: any = null;
|
|
126
131
|
await withApp(
|
|
127
|
-
{
|
|
132
|
+
{
|
|
133
|
+
"senior:feature": (job) => {
|
|
134
|
+
agentRepo = (job.variables as Record<string, any>)["io.nanobpm.agentTask"]?.repository ?? null;
|
|
135
|
+
return { status: "opened", pr: "owner/repo#101", summary: "built it" };
|
|
136
|
+
},
|
|
137
|
+
},
|
|
128
138
|
{ baseBranch: "epic/e2e" },
|
|
129
139
|
async ({ app, featureKey }) => {
|
|
130
140
|
const flows = takenFlows(app);
|
|
@@ -134,6 +144,11 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
134
144
|
);
|
|
135
145
|
assert.ok(!flows.includes("gw-converge->converge"), "the converge hand-off branch was NOT taken");
|
|
136
146
|
|
|
147
|
+
assert.ok(agentRepo, "the senior:feature job carried the io.nanobpm.agentTask repository envelope");
|
|
148
|
+
assert.equal(agentRepo.url, "https://github.com/owner/repo.git", "the harness clones the target repo");
|
|
149
|
+
assert.equal(agentRepo.ref, "epic/e2e", "a PRE-PR job checks out the BASE branch, not a head");
|
|
150
|
+
assert.equal(agentRepo.branch.create, "feat/issue-7", "the harness cuts the deterministic feature branch");
|
|
151
|
+
|
|
137
152
|
const run = await featureRow(app, featureKey);
|
|
138
153
|
assert.equal(run.status, "opened", "the run settled at opened");
|
|
139
154
|
assert.equal(run.pr_key, "owner/repo#101", "the raised PR key was recorded");
|
package/e2e/plan-fanout.e2e.ts
CHANGED
|
@@ -121,6 +121,34 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
|
|
|
121
121
|
const singleTaskPlan: Stub = () => ({ tasks: [{ id: "t1", title: "T1", prompt: "do t1" }] });
|
|
122
122
|
const approveReview: Stub = () => ({ approved: true, findings: "" });
|
|
123
123
|
|
|
124
|
+
test("host-git provisioning: each slice's implement-cell agent job carries the repository envelope (#684)", async () => {
|
|
125
|
+
// The whole-epic seed emits `io.nanobpm.agentTask.repository` so every slice's `senior:feature`
|
|
126
|
+
// agent gets an ISOLATED clone instead of clobbering the worker's launch dir. The process variable
|
|
127
|
+
// rides down through the wave subprocess + `implement-cell-call` callActivity into each agent job.
|
|
128
|
+
// The epic seed carries the BASE branch as `ref` but NO `branch.create` — each slice's
|
|
129
|
+
// `feat/<task.id>` differs per MI child, so the agent cuts its own branch inside the clone.
|
|
130
|
+
let agentRepo: any = null;
|
|
131
|
+
await withApp(
|
|
132
|
+
{
|
|
133
|
+
"senior:plan": singleTaskPlan,
|
|
134
|
+
"senior:plan-review": approveReview,
|
|
135
|
+
"senior:feature": (job) => {
|
|
136
|
+
agentRepo = (job.variables as Record<string, any>)["io.nanobpm.agentTask"]?.repository ?? null;
|
|
137
|
+
return { status: "blocked", summary: "n/a" };
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
async ({ app }) => {
|
|
141
|
+
await app.settle();
|
|
142
|
+
assert.ok(agentRepo, "the epic slice's senior:feature job carried the io.nanobpm.agentTask envelope");
|
|
143
|
+
assert.equal(agentRepo.url, "https://github.com/owner/repo.git", "the harness clones the target repo");
|
|
144
|
+
assert.equal(agentRepo.ref, "epic/e2e", "the epic seed checks out the BASE (integration) branch");
|
|
145
|
+
assert.equal("branch" in agentRepo, false, "the epic seed omits branch.create — the agent branches per slice");
|
|
146
|
+
assert.equal(agentRepo.singleBranch, true, "the blobless/single-branch monorepo shaping rides along");
|
|
147
|
+
assert.equal(agentRepo.filter, "blob:none");
|
|
148
|
+
},
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
124
152
|
test("task escalation: a native userTask parks the child; answering routes back to implement-task", async () => {
|
|
125
153
|
let featureCalls = 0;
|
|
126
154
|
await withApp(
|
package/openapi.yaml
CHANGED
|
@@ -2090,6 +2090,29 @@ components:
|
|
|
2090
2090
|
description: >-
|
|
2091
2091
|
OPTIONAL run-level ISO-8601 SLA for `human` nodes (#505) before they record an `escalated`
|
|
2092
2092
|
outcome. Absent → the `P1D` default. An invalid duration is rejected at submit.
|
|
2093
|
+
repository:
|
|
2094
|
+
type: string
|
|
2095
|
+
maxLength: 255
|
|
2096
|
+
pattern: '^[A-Za-z0-9-]+/(?!.*\.[Gg][Ii][Tt]$)[A-Za-z0-9._-]+$'
|
|
2097
|
+
description: >-
|
|
2098
|
+
OPTIONAL `owner/repo` the run's `agent` nodes implement against (#684/#686). When supplied
|
|
2099
|
+
together with `baseBranch`, the runner seeds the canonical `io.nanobpm.agentTask.repository`
|
|
2100
|
+
provisioning envelope (`repoEnvelopeVars`) as a run-root process variable so every agent
|
|
2101
|
+
node's servicing `senior:*` job gets an ISOLATED throwaway clone instead of inheriting the
|
|
2102
|
+
worker's launch dir. Absent → no envelope (legacy launch-dir behaviour, unchanged). A value
|
|
2103
|
+
that is not exactly `owner/repo` is rejected at submit.
|
|
2104
|
+
baseBranch:
|
|
2105
|
+
type: string
|
|
2106
|
+
maxLength: 255
|
|
2107
|
+
pattern: '^(?![/.-])(?!.*[/.]$)(?!.*\.\.)(?!.*//)(?!.*/\.)(?!.*\.lock(?:/|$))[A-Za-z0-9._/-]+$'
|
|
2108
|
+
description: >-
|
|
2109
|
+
OPTIONAL base branch the run's `agent` nodes branch off (#684/#686) — the `ref` the harness
|
|
2110
|
+
checks out in the isolated clone (the PRE-PR shape: each agent cuts its own `feat/<node.id>`
|
|
2111
|
+
branch off this base). Only consulted when `repository` is also set; absent → no envelope. A
|
|
2112
|
+
value that is not a plausible git branch name (whitespace, shell metacharacters, a leading
|
|
2113
|
+
`-`, `..`/`//`, a path segment starting with `.` or ending in `.lock`, etc.) is rejected at
|
|
2114
|
+
submit. This pattern mirrors the authoritative server-side gate (`isPlausibleBranchName`,
|
|
2115
|
+
app/baseBranch.ts) so the documented contract and the door agree.
|
|
2093
2116
|
DeliveryGraphDismissRequest:
|
|
2094
2117
|
description: >-
|
|
2095
2118
|
The OPERATOR dismiss request (#520). The cockpit's staged-proposals grid posts the content
|
|
@@ -247,4 +247,66 @@ describe("dispatchDeliveryGraph — operator dispatch by staged-proposal digest"
|
|
|
247
247
|
assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
|
|
248
248
|
assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "dispatched");
|
|
249
249
|
});
|
|
250
|
+
|
|
251
|
+
test("a malformed `repository` is rejected at submit → 400, nothing launched (#684/#686)", async () => {
|
|
252
|
+
const app = await boot();
|
|
253
|
+
assert.ok(app.api);
|
|
254
|
+
const api = app.api;
|
|
255
|
+
const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
|
|
256
|
+
// Not an `owner/repo` reference — refused at submit (by the edge `pattern` or the door's own guard),
|
|
257
|
+
// rather than silently dropped into a bogus clone URL. Nothing launches; the proposal stays staged.
|
|
258
|
+
const res = await api.call<{ ok?: boolean; error?: string }>("dispatchDeliveryGraph", {
|
|
259
|
+
body: { digest: staged.body.digest, repository: "not a repo!", baseBranch: "main" },
|
|
260
|
+
});
|
|
261
|
+
assert.equal(res.status, 400);
|
|
262
|
+
assert.equal((await deliveryGraphRuns(app.db).all()).length, 0);
|
|
263
|
+
assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "staged");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("a malformed `baseBranch` is rejected at submit → 400, nothing launched (#684/#686)", async () => {
|
|
267
|
+
const app = await boot();
|
|
268
|
+
assert.ok(app.api);
|
|
269
|
+
const api = app.api;
|
|
270
|
+
const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
|
|
271
|
+
// Not a plausible git branch name (a leading dash reads as a CLI flag / shell metacharacters) — the
|
|
272
|
+
// door's conservative allowlist refuses it at submit rather than seeding an invalid-ref envelope.
|
|
273
|
+
const res = await api.call<{ ok?: boolean; error?: string }>("dispatchDeliveryGraph", {
|
|
274
|
+
body: { digest: staged.body.digest, repository: "owner/repo", baseBranch: "-rf; rm main" },
|
|
275
|
+
});
|
|
276
|
+
assert.equal(res.status, 400);
|
|
277
|
+
assert.equal((await deliveryGraphRuns(app.db).all()).length, 0);
|
|
278
|
+
assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "staged");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("a `.lock`-suffixed `baseBranch` segment is rejected at submit → 400, nothing launched (#684/#686)", async () => {
|
|
282
|
+
const app = await boot();
|
|
283
|
+
assert.ok(app.api);
|
|
284
|
+
const api = app.api;
|
|
285
|
+
const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
|
|
286
|
+
// A path segment ending in `.lock` (or one starting with `.`) is a valid-looking ref the loose
|
|
287
|
+
// charset would admit but `isPlausibleBranchName` rejects — the door must refuse it, matching the
|
|
288
|
+
// (now tightened) OpenAPI `baseBranch` pattern rather than seeding an invalid-ref envelope.
|
|
289
|
+
const res = await api.call<{ ok?: boolean; error?: string }>("dispatchDeliveryGraph", {
|
|
290
|
+
body: { digest: staged.body.digest, repository: "owner/repo", baseBranch: "feat/x.lock" },
|
|
291
|
+
});
|
|
292
|
+
assert.equal(res.status, 400);
|
|
293
|
+
assert.equal((await deliveryGraphRuns(app.db).all()).length, 0);
|
|
294
|
+
assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "staged");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("a valid repository + baseBranch dispatches the run for isolated provisioning → 202 running (#684/#686)", async () => {
|
|
298
|
+
const app = await boot();
|
|
299
|
+
assert.ok(app.api);
|
|
300
|
+
const api = app.api;
|
|
301
|
+
const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
|
|
302
|
+
const res = await api.call<{ ok: boolean; status: string }>("dispatchDeliveryGraph", {
|
|
303
|
+
body: { digest: staged.body.digest, repository: "owner/repo", baseBranch: "main" },
|
|
304
|
+
});
|
|
305
|
+
assert.equal(res.status, 202);
|
|
306
|
+
assert.equal(res.body.ok, true);
|
|
307
|
+
assert.equal(res.body.status, "running");
|
|
308
|
+
await app.settle();
|
|
309
|
+
assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
|
|
310
|
+
assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "dispatched");
|
|
311
|
+
});
|
|
250
312
|
});
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// re-dispatch of an already-running run short-circuits with `alreadyRunning`. An unknown / expired /
|
|
11
11
|
// superseded / already-dispatched digest is a clean 400.
|
|
12
12
|
|
|
13
|
+
import { isPlausibleBranchName } from "../app/baseBranch.ts";
|
|
13
14
|
import { dispatchDeliveryGraphRun } from "../app/deliveryGraphDispatch.ts";
|
|
14
15
|
import { getStagedProposal, markProposalDispatched, markProposalExpired } from "../app/deliveryGraphProposals.ts";
|
|
15
16
|
import { isValidIsoDuration } from "../app/reviewWait.ts";
|
|
@@ -75,6 +76,38 @@ export default defineOperation("dispatchDeliveryGraph", async ({ body }, app) =>
|
|
|
75
76
|
if (parsed.value !== undefined) timeouts[field] = parsed.value;
|
|
76
77
|
}
|
|
77
78
|
|
|
79
|
+
// Host-git provisioning override (#684/#686) — the OPTIONAL `owner/repo` + base branch the run's
|
|
80
|
+
// `agent` nodes implement against. When both are present the runner seeds the canonical
|
|
81
|
+
// `io.nanobpm.agentTask.repository` isolation envelope (`repoEnvelopeVars`) onto every agent cell's
|
|
82
|
+
// job so it provisions a throwaway clone instead of mutating the worker's launch dir. `repository` is
|
|
83
|
+
// shape-validated here (mirrors `repoEnvelopeVars`' own `owner/repo` allowlist) so a malformed value
|
|
84
|
+
// is a clean 400 rather than a silently-dropped envelope; both absent → no envelope (legacy behaviour).
|
|
85
|
+
const repoRaw = body && typeof body === "object" && "repository" in body && typeof body.repository === "string" ? body.repository.trim() : "";
|
|
86
|
+
let repository: string | undefined;
|
|
87
|
+
if (repoRaw !== "") {
|
|
88
|
+
if (repoRaw.length > 255 || !/^[A-Za-z0-9-]+\/[A-Za-z0-9._-]+$/.test(repoRaw) || /\.git$/i.test(repoRaw)) {
|
|
89
|
+
const shown = truncateForEcho(repoRaw);
|
|
90
|
+
app.log.warn("dispatch-delivery-graph rejected: invalid repository", { value: shown });
|
|
91
|
+
return { status: 400, body: { ok: false, error: `\`repository\` must be an \`owner/repo\` reference; got \`${shown}\`` } };
|
|
92
|
+
}
|
|
93
|
+
repository = repoRaw;
|
|
94
|
+
}
|
|
95
|
+
const baseRaw = body && typeof body === "object" && "baseBranch" in body && typeof body.baseBranch === "string" ? body.baseBranch.trim() : "";
|
|
96
|
+
let baseBranch: string | undefined;
|
|
97
|
+
if (baseRaw !== "") {
|
|
98
|
+
// `baseBranch` becomes the isolation envelope's `ref` — a real Git ref the harness checks out and
|
|
99
|
+
// branches off. Gate it with the canonical conservative branch-name allowlist (`app/plan.ts`,
|
|
100
|
+
// shared with the epic/feature launch paths) so whitespace, shell metacharacters, newlines, a
|
|
101
|
+
// leading `-`, `..`/`//`, etc. are a clean 400 rather than an invalid-ref/argument-parsing edge
|
|
102
|
+
// case in a downstream git invocation.
|
|
103
|
+
if (baseRaw.length > 255 || !isPlausibleBranchName(baseRaw)) {
|
|
104
|
+
const shown = truncateForEcho(baseRaw);
|
|
105
|
+
app.log.warn("dispatch-delivery-graph rejected: invalid baseBranch", { value: shown });
|
|
106
|
+
return { status: 400, body: { ok: false, error: `\`baseBranch\` must be a plausible git branch name; got \`${shown}\`` } };
|
|
107
|
+
}
|
|
108
|
+
baseBranch = baseRaw;
|
|
109
|
+
}
|
|
110
|
+
|
|
78
111
|
// Load the live staged proposal for this digest — refuses an unknown/expired/superseded/already-
|
|
79
112
|
// dispatched digest cleanly (no run is launched).
|
|
80
113
|
const proposal = await getStagedProposal(app.data, digest);
|
|
@@ -98,7 +131,7 @@ export default defineOperation("dispatchDeliveryGraph", async ({ body }, app) =>
|
|
|
98
131
|
return { status: 400, body: { ok: false, error: `staged proposal ${digest} is corrupt: ${err instanceof Error ? err.message : String(err)}` } };
|
|
99
132
|
}
|
|
100
133
|
|
|
101
|
-
const dispatched = await dispatchDeliveryGraphRun(app, graph, { runKey: idempotencyKey, title: proposal.title, ...timeouts });
|
|
134
|
+
const dispatched = await dispatchDeliveryGraphRun(app, graph, { runKey: idempotencyKey, title: proposal.title, repository, baseBranch, ...timeouts });
|
|
102
135
|
if (!dispatched.ok) {
|
|
103
136
|
app.log.warn("dispatch-delivery-graph refused: compile", { digest, errors: dispatched.errors.length });
|
|
104
137
|
const outBody: DeliveryGraphTextResult = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.171.
|
|
3
|
+
"version": "0.171.7",
|
|
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",
|