@nanobpm/nano-workforce 0.87.0 → 0.88.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 +7 -0
- package/SPEC.md +16 -5
- package/app/contracts.ts +9 -0
- package/app/github.ts +15 -11
- package/app/service.test.ts +16 -1
- package/app/service.ts +34 -6
- package/package.json +1 -1
- package/pages/overview.page.json +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# [0.88.0](https://github.com/nanobpm/nano-workforce/compare/v0.87.0...v0.88.0) (2026-08-18)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **service:** branch-scoped treeless clone for review-job repo envelope ([#287](https://github.com/nanobpm/nano-workforce/issues/287)) ([#288](https://github.com/nanobpm/nano-workforce/issues/288)) ([c90dc23](https://github.com/nanobpm/nano-workforce/commit/c90dc23274fbb5e19835e25eef755f88337ab86f)), closes [jwulf/c8ctl-plugin-nano#91](https://github.com/jwulf/c8ctl-plugin-nano/issues/91)
|
|
7
|
+
|
|
1
8
|
# [0.87.0](https://github.com/nanobpm/nano-workforce/compare/v0.86.0...v0.87.0) (2026-08-18)
|
|
2
9
|
|
|
3
10
|
|
package/SPEC.md
CHANGED
|
@@ -193,9 +193,17 @@ Consequences the prompt (`resources/prompts/review-round.md`) encodes:
|
|
|
193
193
|
`io.nanobpm.agentTask.repository.url`; the **app** supplies it — plus the head
|
|
194
194
|
branch as `…repository.ref` — as a process variable at `createInstance`
|
|
195
195
|
(`repoEnvelopeVars` in `app/service.ts`, resolving the head via `fetchPrMeta`/
|
|
196
|
-
`fetchPrHead`). The
|
|
197
|
-
|
|
198
|
-
|
|
196
|
+
`fetchPrHead`). The envelope also carries clone-shaping fields so large monorepos
|
|
197
|
+
provision within the c8ctl clone timeout (issue #287): `singleBranch: true` and
|
|
198
|
+
`filter: "blob:none"` request a **branch-scoped, blobless partial clone** (trees are
|
|
199
|
+
still fetched up-front — a *treeless* clone would be `--filter=tree:0`; the full
|
|
200
|
+
commit graph is kept — no `--depth 1` — so `git merge-base` / the review 3-dot diff
|
|
201
|
+
stays correct while blobs fetch lazily), and, when the PR base branch is resolvable,
|
|
202
|
+
an optional `…repository.baseRef` so the harness fetches the base tip alongside the
|
|
203
|
+
head and keeps `origin/<base>` reachable for the diff. The harness is PR-agnostic: it
|
|
204
|
+
does **not** derive the head branch from `prNumber`/`prUrl`. When the head can't be
|
|
205
|
+
resolved the envelope is omitted and the agent falls back to the worker's launch
|
|
206
|
+
directory (the legacy behavior).
|
|
199
207
|
|
|
200
208
|
## 6. Signals
|
|
201
209
|
|
|
@@ -625,8 +633,11 @@ but encode incompatible decisions about a shared contract** — a genuine design
|
|
|
625
633
|
integration provisions the repo and checks out the PR's head branch (it must
|
|
626
634
|
already give the worker repo access to work at all). The **app** resolves the head
|
|
627
635
|
branch and passes it in the `io.nanobpm.agentTask.repository.{url,ref}` envelope
|
|
628
|
-
(a `createInstance` process variable — see `repoEnvelopeVars`)
|
|
629
|
-
|
|
636
|
+
(a `createInstance` process variable — see `repoEnvelopeVars`), along with the
|
|
637
|
+
branch-scoped, blobless clone-shaping fields (`singleBranch`, `filter`, optional
|
|
638
|
+
`baseRef`) that let large monorepos provision within the clone timeout (#287); the
|
|
639
|
+
harness is PR-agnostic and provisions from that envelope. The worker stays a pure
|
|
640
|
+
provisioner.
|
|
630
641
|
- **review-ready via GitHub webhook** — same message, swappable faster trigger,
|
|
631
642
|
when the app is publicly reachable. Deferred (poller-only for v1).
|
|
632
643
|
- **Supervised vs external worker** — the agent runs as an external
|
package/app/contracts.ts
CHANGED
|
@@ -334,6 +334,15 @@ export const WIRE_CONTRACTS = {
|
|
|
334
334
|
"Op-tagged relay control frame a worker terminal chunk producer emits and the hub consumes. The op-tagged shape superseded the legacy positional `{stream, offset, chunk}` frame (nano-ide #234/#236); a producer must emit the op-tagged shape or the hub rejects it as `malformed relay message payload`.",
|
|
335
335
|
shape: '{ op: "produce", incarnation: number, stream: string, offset: number, chunk: string }',
|
|
336
336
|
},
|
|
337
|
+
"io.nanobpm.agentTask.repository": {
|
|
338
|
+
category: "wire",
|
|
339
|
+
name: "io.nanobpm.agentTask.repository",
|
|
340
|
+
owner: "app/service.ts",
|
|
341
|
+
semantics:
|
|
342
|
+
"Repo-provisioning envelope the app emits as a `createInstance` process variable (`repoEnvelopeVars`) and the c8ctl worker harness consumes to provision an isolated clone on the PR head branch. 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). Gated on c8ctl provisioner support (jwulf/c8ctl-plugin-nano#91).",
|
|
343
|
+
shape:
|
|
344
|
+
'{ provider: "github", url: string, ref: string, singleBranch: true, filter: "blob:none", baseRef?: string }',
|
|
345
|
+
},
|
|
337
346
|
} as const satisfies Record<string, WireContract>;
|
|
338
347
|
|
|
339
348
|
export const TYPE_CONTRACTS = {
|
package/app/github.ts
CHANGED
|
@@ -406,6 +406,10 @@ export interface PrMeta {
|
|
|
406
406
|
* workspace checkout (`io.nanobpm.agentTask.repository.ref`) so the review agent lands on the
|
|
407
407
|
* PR branch instead of the worker's launch directory. `null` when GitHub doesn't return it. */
|
|
408
408
|
headRef: string | null;
|
|
409
|
+
/** The PR's base branch name (e.g. `main`). Emitted in the repository envelope so the c8ctl
|
|
410
|
+
* harness fetches the base tip alongside the single-branch head clone, keeping `git diff
|
|
411
|
+
* origin/<base>...HEAD` (the review 3-dot diff) computable. `null` when GitHub doesn't return it. */
|
|
412
|
+
baseRef: string | null;
|
|
409
413
|
}
|
|
410
414
|
|
|
411
415
|
export async function fetchPrMeta(
|
|
@@ -414,10 +418,10 @@ export async function fetchPrMeta(
|
|
|
414
418
|
token: string,
|
|
415
419
|
): Promise<PrMeta | null> {
|
|
416
420
|
if (await useGh()) {
|
|
417
|
-
const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "title,body,headRefName"]);
|
|
421
|
+
const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "title,body,headRefName,baseRefName"]);
|
|
418
422
|
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
419
|
-
const j = JSON.parse(out) as { title?: string; body?: string; headRefName?: string | null };
|
|
420
|
-
return { title: j.title ?? null, body: j.body ?? "", headRef: j.headRefName ?? null };
|
|
423
|
+
const j = JSON.parse(out) as { title?: string; body?: string; headRefName?: string | null; baseRefName?: string | null };
|
|
424
|
+
return { title: j.title ?? null, body: j.body ?? "", headRef: j.headRefName ?? null, baseRef: j.baseRefName ?? null };
|
|
421
425
|
}
|
|
422
426
|
if (!token) return null;
|
|
423
427
|
const r = await fetch(`https://api.github.com/repos/${repo}/pulls/${number}`, {
|
|
@@ -425,8 +429,8 @@ export async function fetchPrMeta(
|
|
|
425
429
|
});
|
|
426
430
|
if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
|
|
427
431
|
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
428
|
-
const j = (await r.json()) as { title?: string; body?: string; head?: { ref?: string | null } };
|
|
429
|
-
return { title: j.title ?? null, body: j.body ?? "", headRef: j.head?.ref ?? null };
|
|
432
|
+
const j = (await r.json()) as { title?: string; body?: string; head?: { ref?: string | null }; base?: { ref?: string | null } };
|
|
433
|
+
return { title: j.title ?? null, body: j.body ?? "", headRef: j.head?.ref ?? null, baseRef: j.base?.ref ?? null };
|
|
430
434
|
}
|
|
431
435
|
|
|
432
436
|
/** Fetch an issue's title via the configured transport, mirroring `fetchPrMeta` (both `gh` and
|
|
@@ -666,12 +670,12 @@ export async function fetchPrHead(
|
|
|
666
670
|
repo: string,
|
|
667
671
|
number: number | string,
|
|
668
672
|
token: string,
|
|
669
|
-
): Promise<{ headRef: string | null; headSha: string | null } | null> {
|
|
673
|
+
): Promise<{ headRef: string | null; headSha: string | null; baseRef: string | null } | null> {
|
|
670
674
|
if (await useGh()) {
|
|
671
|
-
const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "headRefName,headRefOid"]);
|
|
675
|
+
const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "headRefName,headRefOid,baseRefName"]);
|
|
672
676
|
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
673
|
-
const j = JSON.parse(out) as { headRefName?: string | null; headRefOid?: string | null };
|
|
674
|
-
return { headRef: j.headRefName ?? null, headSha: j.headRefOid ?? null };
|
|
677
|
+
const j = JSON.parse(out) as { headRefName?: string | null; headRefOid?: string | null; baseRefName?: string | null };
|
|
678
|
+
return { headRef: j.headRefName ?? null, headSha: j.headRefOid ?? null, baseRef: j.baseRefName ?? null };
|
|
675
679
|
}
|
|
676
680
|
if (!token) return null;
|
|
677
681
|
const r = await fetch(`https://api.github.com/repos/${repo}/pulls/${number}`, {
|
|
@@ -679,8 +683,8 @@ export async function fetchPrHead(
|
|
|
679
683
|
});
|
|
680
684
|
if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
|
|
681
685
|
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
682
|
-
const j = (await r.json()) as { head?: { ref?: string | null; sha?: string | null } };
|
|
683
|
-
return { headRef: j.head?.ref ?? null, headSha: j.head?.sha ?? null };
|
|
686
|
+
const j = (await r.json()) as { head?: { ref?: string | null; sha?: string | null }; base?: { ref?: string | null } };
|
|
687
|
+
return { headRef: j.head?.ref ?? null, headSha: j.head?.sha ?? null, baseRef: j.base?.ref ?? null };
|
|
684
688
|
}
|
|
685
689
|
|
|
686
690
|
/** The PR's current base branch ref — the branch this PR would land *into*. `null` when no
|
package/app/service.test.ts
CHANGED
|
@@ -439,11 +439,26 @@ test("startMerge reads root_request_key off the PR row onto the merge instance",
|
|
|
439
439
|
// checkout ref, and omitted entirely when the head branch couldn't be resolved (so the harness
|
|
440
440
|
// falls back to the legacy launch-dir behavior instead of cloning the wrong default branch).
|
|
441
441
|
test("repoEnvelopeVars emits the repository envelope keyed on the PR head branch", () => {
|
|
442
|
-
const vars = repoEnvelopeVars("owner/repo", "feat/issue-12");
|
|
442
|
+
const vars = repoEnvelopeVars("owner/repo", "feat/issue-12", "main");
|
|
443
443
|
const env = (vars as any)["io.nanobpm.agentTask"];
|
|
444
444
|
assertEquals(env.repository.url, "https://github.com/owner/repo.git");
|
|
445
445
|
assertEquals(env.repository.ref, "feat/issue-12");
|
|
446
446
|
assertEquals(env.repository.provider, "github");
|
|
447
|
+
// Branch-scoped, blobless partial clone (issue #287): large monorepos provision within the clone
|
|
448
|
+
// timeout while the full commit graph is kept so `git diff origin/<base>...HEAD` has a merge-base.
|
|
449
|
+
assertEquals(env.repository.singleBranch, true);
|
|
450
|
+
assertEquals(env.repository.filter, "blob:none");
|
|
451
|
+
// The base branch is emitted so the harness fetches its tip, keeping `origin/<base>` reachable.
|
|
452
|
+
assertEquals(env.repository.baseRef, "main");
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
test("repoEnvelopeVars omits baseRef when the base branch is unresolved", () => {
|
|
456
|
+
const env = (repoEnvelopeVars("owner/repo", "feat/issue-12") as any)["io.nanobpm.agentTask"];
|
|
457
|
+
// The single-branch/blobless partial-clone request still stands without a base ref…
|
|
458
|
+
assertEquals(env.repository.singleBranch, true);
|
|
459
|
+
assertEquals(env.repository.filter, "blob:none");
|
|
460
|
+
// …but `baseRef` is omitted entirely rather than emitted as null (no key at all).
|
|
461
|
+
assertEquals("baseRef" in env.repository, false);
|
|
447
462
|
});
|
|
448
463
|
|
|
449
464
|
test("repoEnvelopeVars emits nothing when the head branch is unresolved", () => {
|
package/app/service.ts
CHANGED
|
@@ -323,8 +323,18 @@ const AGENT_TASK_NS = "io.nanobpm.agentTask";
|
|
|
323
323
|
* a usable checkout for repos already present locally). `ref` MUST be the PR head branch; when it
|
|
324
324
|
* is unresolved we emit nothing (no `repository.url`) so the harness falls back to the legacy
|
|
325
325
|
* launch-dir behavior rather than silently cloning the repo's default branch. The static
|
|
326
|
-
* `task.prompt` header on the service task deep-merges with this over the same namespace.
|
|
327
|
-
|
|
326
|
+
* `task.prompt` header on the service task deep-merges with this over the same namespace.
|
|
327
|
+
*
|
|
328
|
+
* The clone is requested **branch-scoped and blobless** (`singleBranch: true` + `filter:
|
|
329
|
+
* "blob:none"`) so large monorepos (e.g. `camunda/camunda`, ~1.16 GB) provision within the c8ctl
|
|
330
|
+
* clone timeout instead of full-cloning the whole history (issue #287). `blob:none` is a *blobless*
|
|
331
|
+
* partial clone (trees are still fetched up-front — a *treeless* clone would be `--filter=tree:0`); it
|
|
332
|
+
* keeps the full *commit graph* (so `git merge-base` / the review 3-dot diff stays correct) while
|
|
333
|
+
* fetching file blobs lazily — small upfront, correct diffs. `--depth 1` is deliberately NOT used:
|
|
334
|
+
* it would drop the merge-base and break `git diff origin/<base>...HEAD`. When the PR base branch
|
|
335
|
+
* is known we also emit `baseRef` so the harness fetches the base tip alongside the head, keeping
|
|
336
|
+
* that base reachable for the diff. */
|
|
337
|
+
export function repoEnvelopeVars(repo: string, ref: string | null, baseRef: string | null = null): Record<string, unknown> {
|
|
328
338
|
if (!ref) return {};
|
|
329
339
|
// Defence in depth: every current caller derives `repo` from parsePr/parseIssue (regex-bounded to
|
|
330
340
|
// `owner/repo`), but this is an exported helper the fan-out epic gives many new callers. A repo
|
|
@@ -336,7 +346,20 @@ export function repoEnvelopeVars(repo: string, ref: string | null): Record<strin
|
|
|
336
346
|
if (!/^[A-Za-z0-9-]+\/[A-Za-z0-9._-]+$/.test(repo) || /\.git$/i.test(repo)) return {};
|
|
337
347
|
return {
|
|
338
348
|
[AGENT_TASK_NS]: {
|
|
339
|
-
repository: {
|
|
349
|
+
repository: {
|
|
350
|
+
provider: "github",
|
|
351
|
+
url: `https://github.com/${repo}.git`,
|
|
352
|
+
ref,
|
|
353
|
+
// Branch-scoped, blobless partial clone (issue #287): fetch only the head branch with lazy
|
|
354
|
+
// blobs so large monorepos provision within the clone timeout. Single-branch + blob:none
|
|
355
|
+
// (not --depth 1) preserves the commit graph so the review's `git diff origin/<base>...HEAD`
|
|
356
|
+
// has a valid merge-base. Gated on c8ctl provisioner support (jwulf/c8ctl-plugin-nano#91).
|
|
357
|
+
singleBranch: true,
|
|
358
|
+
filter: "blob:none",
|
|
359
|
+
// The base branch this PR targets — emitted so the harness fetches its tip alongside the
|
|
360
|
+
// single-branch head, keeping `origin/<base>` reachable for the diff. Omitted when unknown.
|
|
361
|
+
...(baseRef ? { baseRef } : {}),
|
|
362
|
+
},
|
|
340
363
|
},
|
|
341
364
|
};
|
|
342
365
|
}
|
|
@@ -364,12 +387,14 @@ export async function submitPr(
|
|
|
364
387
|
const token = process.env.GITHUB_TOKEN ?? "";
|
|
365
388
|
let title: string | null = null;
|
|
366
389
|
let headRef: string | null = null;
|
|
390
|
+
let baseRef: string | null = null;
|
|
367
391
|
const depKeys = new Set(dependsOn.map((d) => parsePr(d)?.prKey).filter((k): k is string => !!k));
|
|
368
392
|
try {
|
|
369
393
|
const meta = await fetchPrMeta(parsed.repo, parsed.number, token);
|
|
370
394
|
if (meta) {
|
|
371
395
|
title = meta.title;
|
|
372
396
|
headRef = meta.headRef;
|
|
397
|
+
baseRef = meta.baseRef;
|
|
373
398
|
for (const k of parseDependsOn(meta.body)) depKeys.add(k);
|
|
374
399
|
}
|
|
375
400
|
} catch (err) {
|
|
@@ -468,7 +493,7 @@ export async function submitPr(
|
|
|
468
493
|
// Host-git provisioning (c8ctl): deliver the repository envelope so the `senior:pr-review`
|
|
469
494
|
// harness clones an isolated workspace checked out on the PR head branch. Spread last so an
|
|
470
495
|
// unresolved head (`{}`) leaves the other vars untouched.
|
|
471
|
-
...repoEnvelopeVars(parsed.repo, headRef),
|
|
496
|
+
...repoEnvelopeVars(parsed.repo, headRef, baseRef),
|
|
472
497
|
},
|
|
473
498
|
});
|
|
474
499
|
const processKey = processInstanceKey == null ? null : String(processInstanceKey);
|
|
@@ -504,8 +529,11 @@ export async function startMerge(
|
|
|
504
529
|
// means the envelope is omitted and the agent falls back to the worker's launch dir.
|
|
505
530
|
const token = process.env.GITHUB_TOKEN ?? "";
|
|
506
531
|
let headRef: string | null = null;
|
|
532
|
+
let baseRef: string | null = null;
|
|
507
533
|
try {
|
|
508
|
-
|
|
534
|
+
const head = await fetchPrHead(pr.repo, pr.number, token);
|
|
535
|
+
headRef = head?.headRef ?? null;
|
|
536
|
+
baseRef = head?.baseRef ?? null;
|
|
509
537
|
} catch (err) {
|
|
510
538
|
console.warn(`[startMerge] ${pr.prKey} head branch fetch: ${err}`);
|
|
511
539
|
}
|
|
@@ -531,7 +559,7 @@ export async function startMerge(
|
|
|
531
559
|
abandonBrief: renderAbandonBrief(abUrl),
|
|
532
560
|
// Host-git provisioning (c8ctl): same repository envelope as the convergence loop, so the
|
|
533
561
|
// fix-ci/rebase agents operate on an isolated checkout of the PR head branch.
|
|
534
|
-
...repoEnvelopeVars(pr.repo, headRef),
|
|
562
|
+
...repoEnvelopeVars(pr.repo, headRef, baseRef),
|
|
535
563
|
},
|
|
536
564
|
});
|
|
537
565
|
if (processInstanceKey != null) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.88.0",
|
|
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",
|
package/pages/overview.page.json
CHANGED
|
@@ -67,6 +67,7 @@
|
|
|
67
67
|
"columns": [
|
|
68
68
|
{ "field": "title", "template": "{{title}}", "header": "Item", "subtitleField": "pr_key", "truncate": true, "width": "36%", "link": { "kind": "page", "page": "home", "keyField": "pr_key" } },
|
|
69
69
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
70
|
+
{ "field": "incident_message", "header": "Incident", "truncate": true, "badge": { "tone": "danger", "label": "1" } },
|
|
70
71
|
{ "field": "current_round", "template": "{{current_round}} · {{active_worker}}", "header": "Round · Agent" },
|
|
71
72
|
{ "field": "updated_at", "header": "Updated", "width": "9rem" }
|
|
72
73
|
]
|