@nanobpm/nano-workforce 0.187.6 → 0.188.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +1 -0
- package/SPEC.md +69 -21
- package/app/contracts.ts +8 -0
- package/app/convergeAutoAck.test.ts +252 -0
- package/app/convergeGate.test.ts +236 -5
- package/app/convergeGate.ts +41 -5
- package/app/fineGrainedCells.test.ts +23 -0
- package/app/github.ts +47 -13
- package/app/implementReconcile.test.ts +166 -0
- package/app/implementReconcile.ts +112 -0
- package/app/service.test.ts +40 -1
- package/app/service.ts +19 -0
- package/e2e/feature-run.e2e.ts +45 -1
- package/e2e/support/github-admit.ts +23 -0
- package/nano.app.json +4 -0
- package/package.json +1 -1
- package/resources/processes/convergence-loop.bpmn +56 -22
- package/resources/processes/feature.bpmn +1 -0
- package/resources/processes/implement-cell.bpmn +77 -25
- package/resources/processes/plan-fanout.bpmn +1 -0
- package/test/derivation-parity/README.md +5 -2
- package/test/derivation-parity/derivation-parity.test.ts +10 -8
- package/test/derivation-parity/flows.ts +6 -4
- package/workers/converge-gate/worker.ts +42 -10
- package/workers/reconcile-implement/worker.test.ts +87 -0
- package/workers/reconcile-implement/worker.ts +60 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Unit coverage for pr.reconcile-implement — the implement-cell's reconcile-before-escalate step
|
|
2
|
+
// (issue #801). It wraps the canonical `reconcileImplement` decision with the injected GitHub read;
|
|
3
|
+
// here we assert the handler wires job variables through and re-emits the decision (the gate variable
|
|
4
|
+
// `reconciled` plus the adopted `status`/`pr`). The decision logic itself is exhaustively covered in
|
|
5
|
+
// app/implementReconcile.test.ts.
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { assertEquals } from "#test-assert";
|
|
8
|
+
import { noopLog } from "../../test/log.ts";
|
|
9
|
+
import handler from "./worker.ts";
|
|
10
|
+
|
|
11
|
+
// biome-ignore lint/suspicious/noExplicitAny: tiny app double — the handler only touches app.log.
|
|
12
|
+
const fakeApp: any = { log: noopLog() };
|
|
13
|
+
|
|
14
|
+
test("adopts an open PR on the branch: emits reconciled + status=opened + pr", async () => {
|
|
15
|
+
const prevToken = process.env.GITHUB_TOKEN;
|
|
16
|
+
const prevTransport = process.env.NANO_PR_GITHUB_TRANSPORT;
|
|
17
|
+
const prevFetch = globalThis.fetch;
|
|
18
|
+
process.env.GITHUB_TOKEN = "t";
|
|
19
|
+
process.env.NANO_PR_GITHUB_TRANSPORT = "token";
|
|
20
|
+
// Token transport (never shells out to `gh`): stub the pulls listing so `listPrsForHead` returns one
|
|
21
|
+
// open PR opened from `feat/issue-801`.
|
|
22
|
+
globalThis.fetch = (async () =>
|
|
23
|
+
new Response(JSON.stringify([{ number: 801, html_url: "https://github.com/owner/repo/pull/801", state: "open", base: { ref: "main" } }]), {
|
|
24
|
+
status: 200,
|
|
25
|
+
})) as typeof fetch;
|
|
26
|
+
try {
|
|
27
|
+
const out = await handler(
|
|
28
|
+
{ jobKey: "j1", variables: { subjectKey: "owner/repo#801", task: { id: "issue-801" }, status: null } } as never,
|
|
29
|
+
fakeApp,
|
|
30
|
+
);
|
|
31
|
+
assertEquals(out, { reconciled: true, status: "opened", pr: "owner/repo#801" });
|
|
32
|
+
} finally {
|
|
33
|
+
globalThis.fetch = prevFetch;
|
|
34
|
+
if (prevToken === undefined) delete process.env.GITHUB_TOKEN;
|
|
35
|
+
else process.env.GITHUB_TOKEN = prevToken;
|
|
36
|
+
if (prevTransport === undefined) delete process.env.NANO_PR_GITHUB_TRANSPORT;
|
|
37
|
+
else process.env.NANO_PR_GITHUB_TRANSPORT = prevTransport;
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("wires baseBranch through: a wrong-base open PR is NOT adopted → escalate", async () => {
|
|
42
|
+
const prevToken = process.env.GITHUB_TOKEN;
|
|
43
|
+
const prevTransport = process.env.NANO_PR_GITHUB_TRANSPORT;
|
|
44
|
+
const prevFetch = globalThis.fetch;
|
|
45
|
+
process.env.GITHUB_TOKEN = "t";
|
|
46
|
+
process.env.NANO_PR_GITHUB_TRANSPORT = "token";
|
|
47
|
+
// The only open PR on the head branch targets a different base than the run's pinned `baseBranch`.
|
|
48
|
+
globalThis.fetch = (async () =>
|
|
49
|
+
new Response(JSON.stringify([{ number: 55, html_url: "https://github.com/owner/repo/pull/55", state: "open", base: { ref: "stale-base" } }]), {
|
|
50
|
+
status: 200,
|
|
51
|
+
})) as typeof fetch;
|
|
52
|
+
try {
|
|
53
|
+
const out = await handler(
|
|
54
|
+
{ jobKey: "j3", variables: { subjectKey: "owner/repo#7", task: { id: "issue-7" }, status: null, baseBranch: "epic/feat-x" } } as never,
|
|
55
|
+
fakeApp,
|
|
56
|
+
);
|
|
57
|
+
assertEquals(out, { reconciled: false, status: null, pr: null });
|
|
58
|
+
} finally {
|
|
59
|
+
globalThis.fetch = prevFetch;
|
|
60
|
+
if (prevToken === undefined) delete process.env.GITHUB_TOKEN;
|
|
61
|
+
else process.env.GITHUB_TOKEN = prevToken;
|
|
62
|
+
if (prevTransport === undefined) delete process.env.NANO_PR_GITHUB_TRANSPORT;
|
|
63
|
+
else process.env.NANO_PR_GITHUB_TRANSPORT = prevTransport;
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("no open PR on the branch: falls through to escalate (reconciled=false)", async () => {
|
|
68
|
+
const prevToken = process.env.GITHUB_TOKEN;
|
|
69
|
+
const prevTransport = process.env.NANO_PR_GITHUB_TRANSPORT;
|
|
70
|
+
const prevFetch = globalThis.fetch;
|
|
71
|
+
process.env.GITHUB_TOKEN = "t";
|
|
72
|
+
process.env.NANO_PR_GITHUB_TRANSPORT = "token";
|
|
73
|
+
globalThis.fetch = (async () => new Response(JSON.stringify([]), { status: 200 })) as typeof fetch;
|
|
74
|
+
try {
|
|
75
|
+
const out = await handler(
|
|
76
|
+
{ jobKey: "j2", variables: { subjectKey: "owner/repo#7", task: { id: "issue-7" }, status: null } } as never,
|
|
77
|
+
fakeApp,
|
|
78
|
+
);
|
|
79
|
+
assertEquals(out, { reconciled: false, status: null, pr: null });
|
|
80
|
+
} finally {
|
|
81
|
+
globalThis.fetch = prevFetch;
|
|
82
|
+
if (prevToken === undefined) delete process.env.GITHUB_TOKEN;
|
|
83
|
+
else process.env.GITHUB_TOKEN = prevToken;
|
|
84
|
+
if (prevTransport === undefined) delete process.env.NANO_PR_GITHUB_TRANSPORT;
|
|
85
|
+
else process.env.NANO_PR_GITHUB_TRANSPORT = prevTransport;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// pr.reconcile-implement — reconcile the implement-cell result from GitHub before escalating (issue #801).
|
|
2
|
+
//
|
|
3
|
+
// Runs on the shared `implement-cell`'s escalate arm (`ic_gw` "clean terminal?" → here), IMMEDIATELY
|
|
4
|
+
// before the human escalation, for EVERY caller that composes the cell: a standalone `feature` run and
|
|
5
|
+
// a plan-fanout wave slice. When the implement step returned no machine-readable `status` (a harness
|
|
6
|
+
// that opened a green PR but reported nothing — #796's implement-stage twin), it looks for an OPEN PR
|
|
7
|
+
// on the cell's deterministic `feat/<task.id>` branch and, when one exists, ADOPTS it — emitting
|
|
8
|
+
// `reconciled = true` (the `ic_reconcile_gw` gate routes straight to the cell's done end) plus
|
|
9
|
+
// `status = "opened"` and a `pr` key so the caller converges the adopted PR exactly as if the agent
|
|
10
|
+
// had reported it. Only when nothing is observable does it fall through (`reconciled = false`) to the
|
|
11
|
+
// human escalation as today.
|
|
12
|
+
//
|
|
13
|
+
// The decision + the injected GitHub read (`listPrsForHead`) live in the canonical, exhaustively
|
|
14
|
+
// tested `app/implementReconcile.ts` mirror — this handler is the thin engine seam. `taskId` is
|
|
15
|
+
// derived from the in-scope `task` process variable (`task.id`) — `implement-cell.bpmn` defines no
|
|
16
|
+
// `<zeebe:ioMapping>` input for this step; the engine populates `task` (and `subjectKey`, `status`,
|
|
17
|
+
// `pr`, `baseBranch`) from process scope. `pr` is passed through so the reconcile step's `pr` output
|
|
18
|
+
// never wipes a PR key the implement harness already set. `baseBranch` (the run's pinned base) is
|
|
19
|
+
// passed through so an open PR on the deterministic head branch is only adopted when it targets that
|
|
20
|
+
// base — never a stale/unrelated PR sharing the head branch but aimed at a different base.
|
|
21
|
+
import type { AppJobHandler } from "@nanobpm/urban";
|
|
22
|
+
import { listPrsForHead } from "../../app/github.ts";
|
|
23
|
+
import { type ReconcileImplementResult, reconcileImplement } from "../../app/implementReconcile.ts";
|
|
24
|
+
|
|
25
|
+
interface In extends Record<string, unknown> {
|
|
26
|
+
subjectKey?: unknown;
|
|
27
|
+
task?: unknown;
|
|
28
|
+
status?: unknown;
|
|
29
|
+
pr?: unknown;
|
|
30
|
+
baseBranch?: unknown;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The cell's deterministic branch is `feat/<task.id>`; `task` is the implement-cell's slice object.
|
|
34
|
+
* Read it defensively (the process variable is untyped at the engine seam). */
|
|
35
|
+
function taskId(task: unknown): unknown {
|
|
36
|
+
if (task !== null && typeof task === "object" && "id" in task) return task.id;
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const handler: AppJobHandler<In, ReconcileImplementResult> = async (job, app) => {
|
|
41
|
+
const res = await reconcileImplement(
|
|
42
|
+
{
|
|
43
|
+
status: job.variables.status,
|
|
44
|
+
subjectKey: job.variables.subjectKey,
|
|
45
|
+
taskId: taskId(job.variables.task),
|
|
46
|
+
pr: job.variables.pr,
|
|
47
|
+
baseBranch: job.variables.baseBranch,
|
|
48
|
+
},
|
|
49
|
+
listPrsForHead,
|
|
50
|
+
process.env.GITHUB_TOKEN ?? "",
|
|
51
|
+
);
|
|
52
|
+
app.log.info("reconcile-implement", {
|
|
53
|
+
subjectKey: job.variables.subjectKey ?? null,
|
|
54
|
+
reconciled: res.reconciled,
|
|
55
|
+
pr: res.pr,
|
|
56
|
+
});
|
|
57
|
+
return res;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export default handler;
|