@nanobpm/nano-workforce 0.208.3 → 0.208.5
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/AGENTS.md +17 -0
- package/CHANGELOG.md +12 -0
- package/app/agentCompletion.test.ts +155 -0
- package/app/agentCompletion.ts +39 -0
- package/app/contracts.ts +1 -1
- package/app/feature.ts +5 -2
- package/app/implementBranchContract.test.ts +168 -0
- package/app/implementReconcile.ts +8 -2
- package/app/mergePolicyLock.test.ts +44 -0
- package/app/mergePolicyLock.ts +25 -0
- package/app/mergeStatus.test.ts +14 -12
- package/app/migration132.test.ts +72 -0
- package/app/pullRequestReadModel.test.ts +1 -1
- package/app/repoEnvelope.test.ts +11 -75
- package/app/repoEnvelope.ts +122 -0
- package/app/service.test.ts +168 -1
- package/app/service.ts +182 -26
- package/build-info.json +4 -4
- package/db/migrations/132_pull_requests_merge_policy.sql +22 -0
- package/db/migrations/133_pull_requests_read_model_merge_policy.sql +51 -0
- package/e2e/feature-preflight.e2e.ts +2 -2
- package/e2e/plan-fanout.e2e.ts +105 -9
- package/nano.app.json +4 -0
- package/openapi.yaml +13 -6
- package/operations/listActivePrs.test.ts +207 -3
- package/operations/listActivePrs.ts +13 -9
- package/package.json +1 -1
- package/resources/processes/implement-cell.bpmn +80 -65
- package/workers/caps-prepare/worker.test.ts +11 -0
- package/workers/slice-branch/worker.test.ts +31 -0
- package/workers/slice-branch/worker.ts +39 -0
package/app/mergeStatus.test.ts
CHANGED
|
@@ -55,7 +55,9 @@ function memData(stores: Stores) {
|
|
|
55
55
|
|
|
56
56
|
/** A fake engine whose `searchVariables` serves the given per-instance variables. A name-less,
|
|
57
57
|
* key-less query (`{}` — the batched /status read, issue #936) returns EVERY variable across all
|
|
58
|
-
* instances, each row carrying its owning `processInstanceKey`, exactly as the real engine does.
|
|
58
|
+
* instances, each row carrying its owning `processInstanceKey`, exactly as the real engine does.
|
|
59
|
+
* These tests exercise the LEGACY fallback path (issue #947): the row carries no
|
|
60
|
+
* `merge_requested` projection, so the policy is read back off the engine. */
|
|
59
61
|
function fakeEngine(varsByInstance: Record<string, Record<string, unknown>>) {
|
|
60
62
|
return {
|
|
61
63
|
searchVariables: (q: { processInstanceKey?: string; scopeKey?: string; name?: string }) => {
|
|
@@ -112,20 +114,20 @@ async function mergeOfWith(
|
|
|
112
114
|
|
|
113
115
|
test("merge policy: a loop seeded autoMerge=true reads requested+effective (the #916 phantom)", async () => {
|
|
114
116
|
const merge = await mergeOf({ autoMerge: true, convergeOnly: false, humanApproval: false });
|
|
115
|
-
assertEquals(merge, { requested: true, enabled: true, effective: true, blockedBy: null });
|
|
117
|
+
assertEquals(merge, { requested: true, enabled: true, effective: true, blockedBy: null, stale: false });
|
|
116
118
|
});
|
|
117
119
|
|
|
118
120
|
test("merge policy: a legacy in-flight loop (convergeOnly only) derives requested from its inverse", async () => {
|
|
119
121
|
// Seeded BEFORE `autoMerge` existed: only the legacy inverted variable is present.
|
|
120
122
|
const review = await mergeOf({ convergeOnly: true });
|
|
121
|
-
assertEquals(review, { requested: false, enabled: true, effective: false, blockedBy: null });
|
|
123
|
+
assertEquals(review, { requested: false, enabled: true, effective: false, blockedBy: null, stale: false });
|
|
122
124
|
const merging = await mergeOf({ convergeOnly: false });
|
|
123
|
-
assertEquals(merging, { requested: true, enabled: true, effective: true, blockedBy: null });
|
|
125
|
+
assertEquals(merging, { requested: true, enabled: true, effective: true, blockedBy: null, stale: false });
|
|
124
126
|
});
|
|
125
127
|
|
|
126
128
|
test("merge policy: humanApproval gates the effective merge and is named as the blocker", async () => {
|
|
127
129
|
const merge = await mergeOf({ autoMerge: true, humanApproval: true });
|
|
128
|
-
assertEquals(merge, { requested: true, enabled: true, effective: false, blockedBy: "human-approval" });
|
|
130
|
+
assertEquals(merge, { requested: true, enabled: true, effective: false, blockedBy: "human-approval", stale: false });
|
|
129
131
|
});
|
|
130
132
|
|
|
131
133
|
test("merge policy: an approved PR with finalization still pending reports no blocker (PR #917 review)", async () => {
|
|
@@ -135,12 +137,12 @@ test("merge policy: an approved PR with finalization still pending reports no bl
|
|
|
135
137
|
// an approval that is already complete. (`status` stays non-terminal: `converged_at` is only
|
|
136
138
|
// stamped once `pr.finalize` actually runs.)
|
|
137
139
|
const merge = await mergeOf({ autoMerge: true, humanApproval: false }, { status: "converging" });
|
|
138
|
-
assertEquals(merge, { requested: true, enabled: true, effective: true, blockedBy: null });
|
|
140
|
+
assertEquals(merge, { requested: true, enabled: true, effective: true, blockedBy: null, stale: false });
|
|
139
141
|
});
|
|
140
142
|
|
|
141
143
|
test("merge policy: a review-only request reads requested=false (distinct from env/human blocking)", async () => {
|
|
142
144
|
const merge = await mergeOf({ autoMerge: false, convergeOnly: true });
|
|
143
|
-
assertEquals(merge, { requested: false, enabled: true, effective: false, blockedBy: null });
|
|
145
|
+
assertEquals(merge, { requested: false, enabled: true, effective: false, blockedBy: null, stale: false });
|
|
144
146
|
});
|
|
145
147
|
|
|
146
148
|
test("merge policy: no readable instance key (or neither variable) defaults to the historical merge default", async () => {
|
|
@@ -151,9 +153,9 @@ test("merge policy: no readable instance key (or neither variable) defaults to t
|
|
|
151
153
|
user_tasks: { rows: [], key: "id" },
|
|
152
154
|
};
|
|
153
155
|
const [noKey] = await activePrs(memData(stores), fakeEngine({}));
|
|
154
|
-
assertEquals(noKey.merge, { requested: true, enabled: true, effective: true, blockedBy: null });
|
|
156
|
+
assertEquals(noKey.merge, { requested: true, enabled: true, effective: true, blockedBy: null, stale: true });
|
|
155
157
|
const bare = await mergeOf({});
|
|
156
|
-
assertEquals(bare, { requested: true, enabled: true, effective: true, blockedBy: null });
|
|
158
|
+
assertEquals(bare, { requested: true, enabled: true, effective: true, blockedBy: null, stale: true });
|
|
157
159
|
});
|
|
158
160
|
|
|
159
161
|
test("merge policy: NANO_PR_AUTO_MERGE=0 blocks a requested merge as env, never a review-only one", async () => {
|
|
@@ -165,9 +167,9 @@ test("merge policy: NANO_PR_AUTO_MERGE=0 blocks a requested merge as env, never
|
|
|
165
167
|
try {
|
|
166
168
|
const mod: typeof import("./service.ts") = await import(`./service.ts?mergeStatusAutoMergeOff=${Date.now()}`);
|
|
167
169
|
const requested = await mergeOfWith(mod, { autoMerge: true, humanApproval: true });
|
|
168
|
-
assertEquals(requested, { requested: true, enabled: false, effective: false, blockedBy: "env" });
|
|
170
|
+
assertEquals(requested, { requested: true, enabled: false, effective: false, blockedBy: "env", stale: false });
|
|
169
171
|
const reviewOnly = await mergeOfWith(mod, { autoMerge: false });
|
|
170
|
-
assertEquals(reviewOnly, { requested: false, enabled: false, effective: false, blockedBy: null });
|
|
172
|
+
assertEquals(reviewOnly, { requested: false, enabled: false, effective: false, blockedBy: null, stale: false });
|
|
171
173
|
} finally {
|
|
172
174
|
if (prev === undefined) delete process.env["NANO_PR_AUTO_MERGE"];
|
|
173
175
|
else process.env["NANO_PR_AUTO_MERGE"] = prev;
|
|
@@ -220,5 +222,5 @@ test("merge policy: a delayed successful probe cannot resurrect a policy after a
|
|
|
220
222
|
} as any;
|
|
221
223
|
const prs = await activePrs(memData(stores), engine);
|
|
222
224
|
const pr = prs.find((p: { prKey?: string; pr_key?: string; number?: number }) => p.number === 1);
|
|
223
|
-
assertEquals(pr?.merge, { requested: true, enabled: true, effective: true, blockedBy: null }, "failed probe → default, not a half-read policy");
|
|
225
|
+
assertEquals(pr?.merge, { requested: true, enabled: true, effective: true, blockedBy: null, stale: true }, "failed probe → default, not a half-read policy");
|
|
224
226
|
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Migration guard for 132_pull_requests_merge_policy.sql (issue #947): the durable merge-policy
|
|
2
|
+
// projection `/status` serves. Applies the migration to a real in-memory SQLite DB over a
|
|
3
|
+
// pre-existing `pull_requests` table and asserts the expand-and-contract contract:
|
|
4
|
+
// * both columns are added NULLABLE with no default — a pre-existing row reads
|
|
5
|
+
// `merge_requested IS NULL` (the legacy marker `activePrs` falls back to the #937 batched
|
|
6
|
+
// engine read for), never a fabricated 0/1;
|
|
7
|
+
// * the writers' stamped values round-trip (0/1 for `merge_requested`, NULL/0/1 for
|
|
8
|
+
// `merge_human_approval`).
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { DatabaseSync } from "node:sqlite";
|
|
11
|
+
import { test } from "node:test";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { assertEquals } from "#test-assert";
|
|
14
|
+
|
|
15
|
+
const MIGRATION_132 = fileURLToPath(
|
|
16
|
+
new URL("../db/migrations/132_pull_requests_merge_policy.sql", import.meta.url),
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
function migratedDb(): DatabaseSync {
|
|
20
|
+
const db = new DatabaseSync(":memory:");
|
|
21
|
+
db.exec(
|
|
22
|
+
`CREATE TABLE pull_requests (
|
|
23
|
+
pr_key TEXT PRIMARY KEY,
|
|
24
|
+
repo TEXT NOT NULL,
|
|
25
|
+
number INTEGER NOT NULL,
|
|
26
|
+
status TEXT NOT NULL,
|
|
27
|
+
process_key TEXT
|
|
28
|
+
);
|
|
29
|
+
INSERT INTO pull_requests (pr_key, repo, number, status, process_key)
|
|
30
|
+
VALUES ('owner/repo#1', 'owner/repo', 1, 'converging', 'PI-1');`,
|
|
31
|
+
);
|
|
32
|
+
db.exec(readFileSync(MIGRATION_132, "utf8"));
|
|
33
|
+
return db;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test("migration 132 adds both merge-policy columns NULLABLE — a pre-existing row reads legacy (NULL)", () => {
|
|
37
|
+
const db = migratedDb();
|
|
38
|
+
// node:sqlite rows are null-prototype objects; spread to a plain object for the deep-equal.
|
|
39
|
+
const row = {
|
|
40
|
+
...(db
|
|
41
|
+
.prepare(`SELECT merge_requested, merge_human_approval FROM pull_requests WHERE pr_key = 'owner/repo#1'`)
|
|
42
|
+
.get() as { merge_requested: number | null; merge_human_approval: number | null }),
|
|
43
|
+
};
|
|
44
|
+
assertEquals(
|
|
45
|
+
row,
|
|
46
|
+
{ merge_requested: null, merge_human_approval: null },
|
|
47
|
+
"a row written before the projection existed must NOT be back-filled — NULL is the legacy marker the /status fallback keys on",
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("migration 132 columns round-trip the writers' stamped values", () => {
|
|
52
|
+
const db = migratedDb();
|
|
53
|
+
db.prepare(
|
|
54
|
+
`UPDATE pull_requests SET merge_requested = 1, merge_human_approval = 0 WHERE pr_key = 'owner/repo#1'`,
|
|
55
|
+
).run();
|
|
56
|
+
const row = {
|
|
57
|
+
...(db
|
|
58
|
+
.prepare(`SELECT merge_requested, merge_human_approval FROM pull_requests WHERE pr_key = 'owner/repo#1'`)
|
|
59
|
+
.get() as { merge_requested: number | null; merge_human_approval: number | null }),
|
|
60
|
+
};
|
|
61
|
+
assertEquals(row, { merge_requested: 1, merge_human_approval: 0 });
|
|
62
|
+
// A review-only run stamps 0; the approval gate stamps 1 while outstanding.
|
|
63
|
+
db.prepare(
|
|
64
|
+
`UPDATE pull_requests SET merge_requested = 0, merge_human_approval = 1 WHERE pr_key = 'owner/repo#1'`,
|
|
65
|
+
).run();
|
|
66
|
+
const gated = {
|
|
67
|
+
...(db
|
|
68
|
+
.prepare(`SELECT merge_requested, merge_human_approval FROM pull_requests WHERE pr_key = 'owner/repo#1'`)
|
|
69
|
+
.get() as { merge_requested: number | null; merge_human_approval: number | null }),
|
|
70
|
+
};
|
|
71
|
+
assertEquals(gated, { merge_requested: 0, merge_human_approval: 1 });
|
|
72
|
+
});
|
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
const MIG = (name: string) => readFileSync(fileURLToPath(new URL(`../db/migrations/${name}`, import.meta.url)), "utf8");
|
|
34
34
|
const PAGE = (name: string) => JSON.parse(readFileSync(fileURLToPath(new URL(`../pages/${name}`, import.meta.url)), "utf8"));
|
|
35
35
|
|
|
36
|
-
const READ_MODEL_MIGRATION = "
|
|
36
|
+
const READ_MODEL_MIGRATION = "133_pull_requests_read_model_merge_policy.sql";
|
|
37
37
|
|
|
38
38
|
// The real base `pull_requests` columns, in schema order — DERIVED from the migration chain (not a
|
|
39
39
|
// hand-kept list that could silently omit one), used by both the drift guard and the e2e stand-in.
|
package/app/repoEnvelope.test.ts
CHANGED
|
@@ -12,82 +12,18 @@
|
|
|
12
12
|
// consumer together so they cannot drift again.
|
|
13
13
|
import { test } from "node:test";
|
|
14
14
|
import { assert, assertEquals } from "#test-assert";
|
|
15
|
-
import { agentNodeRepoEnvelope, flattenAgentTaskEnvelope, repoEnvelopeVars } from "./repoEnvelope.ts";
|
|
15
|
+
import { agentNodeRepoEnvelope, flattenAgentTaskEnvelope, recoverBranchCreate, repoEnvelopeVars } from "./repoEnvelope.ts";
|
|
16
16
|
|
|
17
|
-
// ───
|
|
18
|
-
//
|
|
19
|
-
// (
|
|
20
|
-
// `io.nanobpm.agentTask` namespace out of BOTH a whole-object/JSON value AND flattened
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
29
|
-
return v != null && typeof v === "object" && !Array.isArray(v);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function deepMerge(base: unknown, over: unknown): unknown {
|
|
33
|
-
if (!isPlainObject(base)) return isPlainObject(over) ? deepMerge({}, over) : over;
|
|
34
|
-
const out: Record<string, unknown> = { ...base };
|
|
35
|
-
if (!isPlainObject(over)) return out;
|
|
36
|
-
for (const [k, v] of Object.entries(over)) {
|
|
37
|
-
if (v === undefined) continue;
|
|
38
|
-
out[k] = isPlainObject(v) && isPlainObject(out[k]) ? deepMerge(out[k], v) : v;
|
|
39
|
-
}
|
|
40
|
-
return out;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function setPath(obj: Record<string, unknown>, path: string[], value: unknown): void {
|
|
44
|
-
let cur = obj;
|
|
45
|
-
for (let i = 0; i < path.length - 1; i++) {
|
|
46
|
-
const k = path[i];
|
|
47
|
-
if (!isPlainObject(cur[k])) cur[k] = {};
|
|
48
|
-
cur = cur[k] as Record<string, unknown>;
|
|
49
|
-
}
|
|
50
|
-
cur[path[path.length - 1]] = value;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Mirror of the harness's `collectEnvelopeFrom`: reassemble the reserved namespace from a
|
|
54
|
-
* whole-object/JSON value AND flattened dotpath keys (element templates / task headers emit the
|
|
55
|
-
* latter). */
|
|
56
|
-
function collectEnvelopeFrom(source: Record<string, unknown>): Record<string, unknown> {
|
|
57
|
-
const out: Record<string, unknown> = {};
|
|
58
|
-
if (!isPlainObject(source)) return out;
|
|
59
|
-
const whole = source[AGENT_TASK_NS];
|
|
60
|
-
if (whole != null) {
|
|
61
|
-
let val: unknown = whole;
|
|
62
|
-
if (typeof whole === "string") {
|
|
63
|
-
try {
|
|
64
|
-
val = JSON.parse(whole);
|
|
65
|
-
} catch {
|
|
66
|
-
val = undefined;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
if (isPlainObject(val)) Object.assign(out, deepMerge(out, val));
|
|
70
|
-
}
|
|
71
|
-
const prefix = `${AGENT_TASK_NS}.`;
|
|
72
|
-
for (const [key, value] of Object.entries(source)) {
|
|
73
|
-
if (!key.startsWith(prefix)) continue;
|
|
74
|
-
const rest = key.slice(prefix.length);
|
|
75
|
-
if (!rest) continue;
|
|
76
|
-
setPath(out, rest.split("."), value);
|
|
77
|
-
}
|
|
78
|
-
return out;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** Mirror of the harness's `normalizeTaskEnvelope` branch slice: it reads `branch` from the TOP
|
|
82
|
-
* LEVEL of the merged envelope — `isPlainObject(raw.branch) ? raw.branch : {}` — never from
|
|
83
|
-
* `raw.repository.branch`. Returns the consumer's recovered `branch.create` (undefined when the
|
|
84
|
-
* producer put it somewhere the consumer doesn't look). */
|
|
85
|
-
function harnessReadsBranchCreate(customHeaders: Record<string, unknown>, variables: Record<string, unknown>): string | undefined {
|
|
86
|
-
const raw = deepMerge(collectEnvelopeFrom(customHeaders), collectEnvelopeFrom(variables)) as Record<string, unknown>;
|
|
87
|
-
const branch = isPlainObject(raw.branch) ? raw.branch : {};
|
|
88
|
-
const create = branch.create;
|
|
89
|
-
return create == null ? undefined : String(create);
|
|
90
|
-
}
|
|
17
|
+
// ─── The consumer mirror ─────────────────────────────────────────────────────────────
|
|
18
|
+
// `recoverBranchCreate` (app/repoEnvelope.ts) is the ONE canonical mirror of the c8ctl harness's
|
|
19
|
+
// consumer shape (`collectEnvelopeFrom` + `normalizeTaskEnvelope`): it reassembles the reserved
|
|
20
|
+
// `io.nanobpm.agentTask` namespace out of BOTH a whole-object/JSON value AND flattened dotpath keys,
|
|
21
|
+
// `deepMerge`s the two sources (variables over headers), and reads `branch` from the TOP LEVEL of the
|
|
22
|
+
// merged envelope (`isPlainObject(raw.branch)`), NOT from `raw.repository.branch`. Keeping that mirror
|
|
23
|
+
// in lock-step with the harness is the point of this test: if the producer emits a shape the consumer
|
|
24
|
+
// doesn't read, the round-trip below drops `branch.create` and fails. Aliased here under the
|
|
25
|
+
// harness-centric name the assertions read naturally with.
|
|
26
|
+
const harnessReadsBranchCreate = recoverBranchCreate;
|
|
91
27
|
|
|
92
28
|
// ─── The round-trip contract ──────────────────────────────────────────────────────────
|
|
93
29
|
|
package/app/repoEnvelope.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// rebase) and the PRE-PR implementation dispatch (`feature.ts`: `startFeature`; `plan.ts`:
|
|
9
9
|
// `startPlan` → the epic's `implement-cell`) derive from this single implementation without a
|
|
10
10
|
// `plan.ts ↔ service.ts` import cycle (AGENTS.md "derivation over duplication — no drift surfaces").
|
|
11
|
+
import { isPlausibleBranchName } from "./baseBranch.ts";
|
|
11
12
|
import { readEnvOr } from "./contracts.ts";
|
|
12
13
|
import { isCommitSha } from "./world/index.ts";
|
|
13
14
|
|
|
@@ -34,6 +35,43 @@ export function isResolvableRepo(repo: unknown): repo is string {
|
|
|
34
35
|
return typeof repo === "string" && isPlainOwnerRepo(repo.trim());
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
/** The FLAT, dotted process-variable NAME a per-slice worker emits to attach the deterministic
|
|
39
|
+
* `branch.create` to the implement-cell's agent-job envelope (issue #945). The c8ctl harness's
|
|
40
|
+
* `collectEnvelopeFrom` reassembles the reserved namespace from BOTH the whole-object
|
|
41
|
+
* `io.nanobpm.agentTask` variable (the whole-epic seed, which carries `repository` + `task` but no
|
|
42
|
+
* per-slice `branch`) AND any dotted `io.nanobpm.agentTask.*` variables in scope, deep-merging them —
|
|
43
|
+
* so a dotted-name variable set in the MI-child scope lands on the SAME envelope the seed started,
|
|
44
|
+
* and `normalizeTaskEnvelope` then reads `branch.create` from the merged top level. A
|
|
45
|
+
* `<zeebe:ioMapping>` target with dots canNOT produce this: it builds a NESTED
|
|
46
|
+
* `{io:{nanobpm:{agentTask:…}}}` object the flat-key harness never reads (see {@link
|
|
47
|
+
* flattenAgentTaskEnvelope}). Only a worker result key literally NAMED with this dotted string (the
|
|
48
|
+
* engine propagates worker result variables verbatim, mapped or not) materialises the dotted
|
|
49
|
+
* variable. */
|
|
50
|
+
export const AGENT_TASK_BRANCH_CREATE_VAR = `${AGENT_TASK_NS}.branch.create`;
|
|
51
|
+
|
|
52
|
+
/** The ONE canonical derivation of an implementation slice's deterministic pre-PR branch —
|
|
53
|
+
* `feat/<taskId>` (the agent-guide's `feat/*` convention, resources/prompts/feature.md). This is the
|
|
54
|
+
* single source of truth for BOTH the whole-run feature seed (`feature.ts` `startFeature`, where the
|
|
55
|
+
* branch is known at seed time and rides the envelope's `branch.create` directly) AND the epic
|
|
56
|
+
* plan-fanout's per-slice emission (`workers/slice-branch`, run inside each implement-cell instance, which stamps it onto the implement-cell
|
|
57
|
+
* envelope via {@link AGENT_TASK_BRANCH_CREATE_VAR} because each slice's `task.id` differs per child
|
|
58
|
+
* and can't be named at the whole-epic seed). Issue #945: the plan-fanout implement-cell previously
|
|
59
|
+
* carried NO `branch.create`, so every c8ctl retry cut a random `nano/agent-work/*` fallback branch
|
|
60
|
+
* and the durable WIP checkpoint (keyed on `feat/<task.id>`) was never restored — each retry discarded
|
|
61
|
+
* the prior session's work. Deriving it here once keeps the envelope and the prompt's branch
|
|
62
|
+
* instruction (which tells the agent to use `feat/<task.id>`) from drifting into two sources.
|
|
63
|
+
*
|
|
64
|
+
* Returns `null` for a `taskId` whose derived branch is not a plausible git ref (mirrors the
|
|
65
|
+
* delivery-runner's `feat/<node.id>` guard, #776): an ill-formed `branch.create` would turn the
|
|
66
|
+
* isolation guarantee into a launch/checkout failure, so a blank/implausible id degrades to omission
|
|
67
|
+
* (the pre-#945 agent-cuts-its-own-branch behaviour) rather than emit an unusable value. */
|
|
68
|
+
export function sliceBranchCreate(taskId: string | null | undefined): string | null {
|
|
69
|
+
const id = typeof taskId === "string" ? taskId.trim() : "";
|
|
70
|
+
if (id === "") return null;
|
|
71
|
+
const branch = `feat/${id}`;
|
|
72
|
+
return isPlausibleBranchName(branch) ? branch : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
37
75
|
/** Flatten a repository-isolation envelope object (as built by {@link repoEnvelopeVars} —
|
|
38
76
|
* `{ "io.nanobpm.agentTask": { repository: { … } } }`) into the FLAT, dotted `io.nanobpm.agentTask.*`
|
|
39
77
|
* key → string-value pairs the c8ctl worker harness reads from a service task's `<zeebe:taskHeaders>`
|
|
@@ -66,6 +104,90 @@ export function flattenAgentTaskEnvelope(envelope: Record<string, unknown>): Rec
|
|
|
66
104
|
return out;
|
|
67
105
|
}
|
|
68
106
|
|
|
107
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
108
|
+
return v != null && typeof v === "object" && !Array.isArray(v);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function deepMerge(base: unknown, over: unknown): unknown {
|
|
112
|
+
if (!isPlainObject(base)) return isPlainObject(over) ? deepMerge({}, over) : over;
|
|
113
|
+
const out: Record<string, unknown> = { ...base };
|
|
114
|
+
if (!isPlainObject(over)) return out;
|
|
115
|
+
for (const [k, v] of Object.entries(over)) {
|
|
116
|
+
if (v === undefined) continue;
|
|
117
|
+
out[k] = isPlainObject(v) && isPlainObject(out[k]) ? deepMerge(out[k], v) : v;
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function setPath(obj: Record<string, unknown>, path: string[], value: unknown): void {
|
|
123
|
+
let cur = obj;
|
|
124
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
125
|
+
const k = path[i];
|
|
126
|
+
const next = cur[k];
|
|
127
|
+
// After the ensure-object step the slot is always a plain object; narrow via the type guard
|
|
128
|
+
// (no `as` — the ban-`as` gate) so the descent stays type-safe.
|
|
129
|
+
if (!isPlainObject(next)) {
|
|
130
|
+
const fresh: Record<string, unknown> = {};
|
|
131
|
+
cur[k] = fresh;
|
|
132
|
+
cur = fresh;
|
|
133
|
+
} else {
|
|
134
|
+
cur = next;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
cur[path[path.length - 1]] = value;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Mirror of the c8ctl harness's `collectEnvelopeFrom`: reassemble the reserved namespace out of BOTH
|
|
141
|
+
* a whole-object/JSON `io.nanobpm.agentTask` value AND flattened dotted `io.nanobpm.agentTask.*` keys
|
|
142
|
+
* (the channel a per-slice worker's dotted-name result variable uses, issue #945), deep-merging the
|
|
143
|
+
* two. This is the ONE consumer-side mirror shared by the round-trip contract tests
|
|
144
|
+
* (`app/repoEnvelope.test.ts`) and the e2e fan-out proof (`e2e/plan-fanout.e2e.ts`) so producer and
|
|
145
|
+
* consumer can never drift into two readings of where `branch.create` lives. */
|
|
146
|
+
function collectEnvelopeFrom(source: Record<string, unknown>): Record<string, unknown> {
|
|
147
|
+
const out: Record<string, unknown> = {};
|
|
148
|
+
if (!isPlainObject(source)) return out;
|
|
149
|
+
const whole = source[AGENT_TASK_NS];
|
|
150
|
+
if (whole != null) {
|
|
151
|
+
let val: unknown = whole;
|
|
152
|
+
if (typeof whole === "string") {
|
|
153
|
+
try {
|
|
154
|
+
val = JSON.parse(whole);
|
|
155
|
+
} catch {
|
|
156
|
+
val = undefined;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (isPlainObject(val)) Object.assign(out, deepMerge(out, val));
|
|
160
|
+
}
|
|
161
|
+
const prefix = `${AGENT_TASK_NS}.`;
|
|
162
|
+
for (const [key, value] of Object.entries(source)) {
|
|
163
|
+
if (!key.startsWith(prefix)) continue;
|
|
164
|
+
const rest = key.slice(prefix.length);
|
|
165
|
+
if (!rest) continue;
|
|
166
|
+
setPath(out, rest.split("."), value);
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Mirror of the c8ctl harness's `normalizeTaskEnvelope` branch slice: deep-merge the envelope
|
|
172
|
+
* recovered from `customHeaders` under that recovered from `variables` (variables win), then read
|
|
173
|
+
* `branch` from the TOP LEVEL of the merged envelope (`isPlainObject(raw.branch)`), NEVER from
|
|
174
|
+
* `raw.repository.branch`. Returns the consumer's recovered `branch.create` (`undefined` when the
|
|
175
|
+
* producer put it somewhere the consumer doesn't look, or omitted it). This is the ONE place tests
|
|
176
|
+
* assert "the harness sees this branch" — keeping producer (`repoEnvelopeVars` / the per-slice dotted
|
|
177
|
+
* variable) and consumer pinned together (issues #882/#945). */
|
|
178
|
+
export function recoverBranchCreate(
|
|
179
|
+
customHeaders: Record<string, unknown>,
|
|
180
|
+
variables: Record<string, unknown>,
|
|
181
|
+
): string | undefined {
|
|
182
|
+
const merged = deepMerge(collectEnvelopeFrom(customHeaders), collectEnvelopeFrom(variables));
|
|
183
|
+
// deepMerge of two plain-object envelope slices is always a plain object; narrow via the type guard
|
|
184
|
+
// (no `as` — the ban-`as` gate). A non-object merge (impossible here) reads as "no branch".
|
|
185
|
+
const raw = isPlainObject(merged) ? merged : {};
|
|
186
|
+
const branch = isPlainObject(raw.branch) ? raw.branch : {};
|
|
187
|
+
const create = branch.create;
|
|
188
|
+
return create == null ? undefined : String(create);
|
|
189
|
+
}
|
|
190
|
+
|
|
69
191
|
/** Raised by `requireRepoEnvelopeVars` when the repository-isolation envelope is REQUIRED on a fan-out
|
|
70
192
|
* path but its inputs are unresolved (a blank base/head `ref`, or a `repo` that is not a plain
|
|
71
193
|
* `owner/repo`). Issue #729: the fan-out dispatch paths must fail loudly here rather than let
|
package/app/service.test.ts
CHANGED
|
@@ -62,6 +62,16 @@ function memOpen(stores: Record<string, { rows: any[]; key: string }>, ops?: str
|
|
|
62
62
|
}
|
|
63
63
|
return { changed };
|
|
64
64
|
}
|
|
65
|
+
if (/^UPDATE "pull_requests" SET "merge_human_approval" = 1 WHERE "pr_key" = \? AND "process_key" = \? AND "converged_at" IS NULL$/.test(sql)) {
|
|
66
|
+
let changed = 0;
|
|
67
|
+
for (const r of stores.pull_requests?.rows ?? []) {
|
|
68
|
+
if (r.pr_key === params[0] && r.process_key === params[1] && r.converged_at == null) {
|
|
69
|
+
r.merge_human_approval = 1;
|
|
70
|
+
changed++;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { changed };
|
|
74
|
+
}
|
|
65
75
|
throw new Error(`unexpected exec sql: ${sql}`);
|
|
66
76
|
},
|
|
67
77
|
};
|
|
@@ -1259,6 +1269,25 @@ function adoptLoop(status: string, loopState: string, convergedAt: string | null
|
|
|
1259
1269
|
return { data, engine, narrowed };
|
|
1260
1270
|
}
|
|
1261
1271
|
|
|
1272
|
+
test("a gated submitPr stamps an OUTSTANDING approval matching the engine seed (PR #950 review)", async () => {
|
|
1273
|
+
await withGithubOff(async () => {
|
|
1274
|
+
const { data, engine, stores, vars } = captureRoot();
|
|
1275
|
+
await submitPr(
|
|
1276
|
+
data,
|
|
1277
|
+
engine,
|
|
1278
|
+
{ repo: "owner/repo", number: 25, url: "https://github.com/owner/repo/pull/25", prKey: "owner/repo#25" },
|
|
1279
|
+
[],
|
|
1280
|
+
20,
|
|
1281
|
+
false,
|
|
1282
|
+
null,
|
|
1283
|
+
true, // humanApproval
|
|
1284
|
+
);
|
|
1285
|
+
const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
|
|
1286
|
+
assertEquals(pr.merge_human_approval, vars()?.humanApproval === true ? 1 : 0, "projection agrees with the engine seed");
|
|
1287
|
+
assertEquals(pr.merge_human_approval, 1, "a gated run projects the outstanding gate");
|
|
1288
|
+
});
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1262
1291
|
test("a gated submitPr adopting a live loop narrows it to humanApproval (issue #826)", async () => {
|
|
1263
1292
|
await withGithubOff(async () => {
|
|
1264
1293
|
const pr = { repo: "owner/repo", number: 14, url: "https://github.com/owner/repo/pull/14", prKey: "owner/repo#14" };
|
|
@@ -1464,6 +1493,140 @@ test("startMerge seeds the merge-policy triple so /status needs no per-instance
|
|
|
1464
1493
|
});
|
|
1465
1494
|
});
|
|
1466
1495
|
|
|
1496
|
+
// ── Issue #947: the durable merge-policy projection the writers stamp ────────────────────────────
|
|
1497
|
+
// `/status` serves the merge policy off the `pull_requests` row (zero engine reads) — these pin the
|
|
1498
|
+
// writer side of that contract: every path that KNOWS the policy must stamp it.
|
|
1499
|
+
|
|
1500
|
+
test("submitPr stamps the merge-policy projection on the row (insert path)", async () => {
|
|
1501
|
+
await withGithubOff(async () => {
|
|
1502
|
+
const { data, engine, stores } = captureRoot();
|
|
1503
|
+
await submitPr(data, engine, {
|
|
1504
|
+
repo: "owner/repo",
|
|
1505
|
+
number: 21,
|
|
1506
|
+
url: "https://github.com/owner/repo/pull/21",
|
|
1507
|
+
prKey: "owner/repo#21",
|
|
1508
|
+
});
|
|
1509
|
+
const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
|
|
1510
|
+
assertEquals(pr.merge_requested, 1, "the default submit requests the merge");
|
|
1511
|
+
assertEquals(pr.merge_human_approval, 0, "an ungated run mirrors the engine seed humanApproval:false (PR #950 review)");
|
|
1512
|
+
});
|
|
1513
|
+
});
|
|
1514
|
+
|
|
1515
|
+
test("submitPr stamps a review-only intent as merge_requested 0 (convergeOnly)", async () => {
|
|
1516
|
+
await withGithubOff(async () => {
|
|
1517
|
+
const { data, engine, stores } = captureRoot();
|
|
1518
|
+
await submitPr(
|
|
1519
|
+
data,
|
|
1520
|
+
engine,
|
|
1521
|
+
{ repo: "owner/repo", number: 22, url: "https://github.com/owner/repo/pull/22", prKey: "owner/repo#22" },
|
|
1522
|
+
[],
|
|
1523
|
+
20,
|
|
1524
|
+
true, // convergeOnly
|
|
1525
|
+
);
|
|
1526
|
+
const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
|
|
1527
|
+
assertEquals(pr.merge_requested, 0, "a review-only run projects requested=false with no engine read");
|
|
1528
|
+
assertEquals(pr.merge_human_approval, 0, "no gate on a review-only run");
|
|
1529
|
+
});
|
|
1530
|
+
});
|
|
1531
|
+
|
|
1532
|
+
test("submitPr reopen restamps the projection to the FRESH run's intent (run-scoped reset)", async () => {
|
|
1533
|
+
await withGithubOff(async () => {
|
|
1534
|
+
const { data, engine, stores } = captureRoot();
|
|
1535
|
+
(stores.pull_requests.rows as unknown[]).push({
|
|
1536
|
+
pr_key: "owner/repo#23",
|
|
1537
|
+
repo: "owner/repo",
|
|
1538
|
+
number: 23,
|
|
1539
|
+
url: "https://github.com/owner/repo/pull/23",
|
|
1540
|
+
status: "converged", // terminal -> reopen path
|
|
1541
|
+
current_round: 2,
|
|
1542
|
+
process_key: "PI-OLD",
|
|
1543
|
+
// The PRIOR run's projection: review-only, approval decided. A fresh default submit must
|
|
1544
|
+
// overwrite both — a carried-over stamp would misreport the new run's policy.
|
|
1545
|
+
merge_requested: 0,
|
|
1546
|
+
merge_human_approval: 0,
|
|
1547
|
+
});
|
|
1548
|
+
await submitPr(data, engine, {
|
|
1549
|
+
repo: "owner/repo",
|
|
1550
|
+
number: 23,
|
|
1551
|
+
url: "https://github.com/owner/repo/pull/23",
|
|
1552
|
+
prKey: "owner/repo#23",
|
|
1553
|
+
});
|
|
1554
|
+
const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
|
|
1555
|
+
assertEquals(pr.merge_requested, 1, "the fresh run's intent wins");
|
|
1556
|
+
assertEquals(pr.merge_human_approval, 0, "the approval state mirrors the fresh ungated seed");
|
|
1557
|
+
});
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
test("a gated submitPr adopting a live loop stamps merge_human_approval on the row (issue #947)", async () => {
|
|
1561
|
+
await withGithubOff(async () => {
|
|
1562
|
+
const pr = { repo: "owner/repo", number: 14, url: "https://github.com/owner/repo/pull/14", prKey: "owner/repo#14" };
|
|
1563
|
+
const live = adoptLoop("converging", "ACTIVE");
|
|
1564
|
+
await submitPr(live.data, live.engine, pr, [], 20, false, null, true);
|
|
1565
|
+
const row = (await live.data.table("pull_requests", "pr_key").get(pr.prKey)) as {
|
|
1566
|
+
merge_human_approval?: number | null;
|
|
1567
|
+
} | null;
|
|
1568
|
+
assertEquals(row?.merge_human_approval, 1, "the gate is mirrored onto the /status projection");
|
|
1569
|
+
|
|
1570
|
+
const ungated = adoptLoop("converging", "ACTIVE");
|
|
1571
|
+
await submitPr(ungated.data, ungated.engine, pr);
|
|
1572
|
+
const ungatedRow = (await ungated.data.table("pull_requests", "pr_key").get(pr.prKey)) as {
|
|
1573
|
+
merge_human_approval?: number | null;
|
|
1574
|
+
} | null;
|
|
1575
|
+
assertEquals(ungatedRow?.merge_human_approval ?? null, null, "an ungated adopter leaves the projection undecided");
|
|
1576
|
+
});
|
|
1577
|
+
});
|
|
1578
|
+
|
|
1579
|
+
test("a gated adopter never gates a merge-loop or a row re-pointed mid-handoff (PR #950 review)", async () => {
|
|
1580
|
+
await withGithubOff(async () => {
|
|
1581
|
+
const pr = { repo: "owner/repo", number: 14, url: "https://github.com/owner/repo/pull/14", prKey: "owner/repo#14" };
|
|
1582
|
+
// Variant 1: the key already resolves to the merge-loop (startMerge re-pointed it, converged_at unset).
|
|
1583
|
+
const merge = adoptLoop("converging", "ACTIVE");
|
|
1584
|
+
merge.engine.searchProcessInstances = () =>
|
|
1585
|
+
Promise.resolve([{ processInstanceKey: "PI-LOOP", state: "ACTIVE", processDefinitionId: "merge-loop" }]);
|
|
1586
|
+
await submitPr(merge.data, merge.engine, pr, [], 20, false, null, true);
|
|
1587
|
+
const mergeRow = (await merge.data.table("pull_requests", "pr_key").get(pr.prKey)) as any;
|
|
1588
|
+
assertEquals(mergeRow?.merge_human_approval ?? null, null, "merge-loop policy untouched");
|
|
1589
|
+
assertEquals(merge.narrowed.length, 0, "no humanApproval set on the merge-loop");
|
|
1590
|
+
// Variant 2: the handoff re-points the row between the engine read and the stamp.
|
|
1591
|
+
const race = adoptLoop("converging", "ACTIVE");
|
|
1592
|
+
race.engine.setVariables = async () => {
|
|
1593
|
+
const row = (await race.data.table("pull_requests", "pr_key").get(pr.prKey)) as any;
|
|
1594
|
+
row.process_key = "PI-MERGE";
|
|
1595
|
+
row.merge_human_approval = 0;
|
|
1596
|
+
};
|
|
1597
|
+
await submitPr(race.data, race.engine, pr, [], 20, false, null, true);
|
|
1598
|
+
const raceRow = (await race.data.table("pull_requests", "pr_key").get(pr.prKey)) as any;
|
|
1599
|
+
assertEquals(raceRow?.merge_human_approval, 0, "the handoff's merge-stage policy survives");
|
|
1600
|
+
});
|
|
1601
|
+
});
|
|
1602
|
+
|
|
1603
|
+
test("startMerge stamps the merge-stage policy on the row (issue #947)", async () => {
|
|
1604
|
+
await withGithubOff(async () => {
|
|
1605
|
+
const { data, engine, stores } = captureRoot();
|
|
1606
|
+
(stores.pull_requests.rows as unknown[]).push({
|
|
1607
|
+
pr_key: "owner/repo#24",
|
|
1608
|
+
repo: "owner/repo",
|
|
1609
|
+
number: 24,
|
|
1610
|
+
url: "https://github.com/owner/repo/pull/24",
|
|
1611
|
+
status: "converged",
|
|
1612
|
+
current_round: 1,
|
|
1613
|
+
process_key: "PI-CONV",
|
|
1614
|
+
merge_requested: 1,
|
|
1615
|
+
merge_human_approval: 1, // the convergence-stage gate — decided by the merge hand-off
|
|
1616
|
+
});
|
|
1617
|
+
await startMerge(data, engine, {
|
|
1618
|
+
repo: "owner/repo",
|
|
1619
|
+
number: 24,
|
|
1620
|
+
url: "https://github.com/owner/repo/pull/24",
|
|
1621
|
+
prKey: "owner/repo#24",
|
|
1622
|
+
round: 1,
|
|
1623
|
+
});
|
|
1624
|
+
const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
|
|
1625
|
+
assertEquals(pr.merge_requested, 1, "a merge loop only starts for a requested merge");
|
|
1626
|
+
assertEquals(pr.merge_human_approval, 0, "…and an approved one — the gate is behind it");
|
|
1627
|
+
});
|
|
1628
|
+
});
|
|
1629
|
+
|
|
1467
1630
|
// The repository envelope drives the c8ctl harness's isolated workspace provisioning: it is
|
|
1468
1631
|
// emitted under the reserved `io.nanobpm.agentTask` namespace with the PR head branch as the
|
|
1469
1632
|
// checkout ref, and omitted entirely when the head branch couldn't be resolved (so the harness
|
|
@@ -2681,7 +2844,7 @@ test("submitPr handoffFrom: a failed reset restores the prior owner so the hand-
|
|
|
2681
2844
|
const PR_KEY = "owner/repo#79";
|
|
2682
2845
|
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
2683
2846
|
pull_requests: {
|
|
2684
|
-
rows: [{ pr_key: PR_KEY, repo: "owner/repo", number: 79, url: "https://github.com/owner/repo/pull/79", title: "t", status: "waiting_merge", current_round: 4, process_key: "PI-MERGE" }],
|
|
2847
|
+
rows: [{ pr_key: PR_KEY, repo: "owner/repo", number: 79, url: "https://github.com/owner/repo/pull/79", title: "t", status: "waiting_merge", current_round: 4, process_key: "PI-MERGE", merge_requested: 0, merge_human_approval: 1 }],
|
|
2685
2848
|
key: "pr_key",
|
|
2686
2849
|
},
|
|
2687
2850
|
escalations: { rows: [], key: "id" },
|
|
@@ -2722,6 +2885,10 @@ test("submitPr handoffFrom: a failed reset restores the prior owner so the hand-
|
|
|
2722
2885
|
assertEquals(row.process_key, "PI-MERGE", "rollback restores the prior owner, never leaves the cancelled key");
|
|
2723
2886
|
assertEquals(row.status, "waiting_merge", "rollback restores the prior status");
|
|
2724
2887
|
assertEquals(row.current_round, 4);
|
|
2888
|
+
// PR #950 review: the merge-policy projection is restored WITH ownership, never left as the
|
|
2889
|
+
// failed run's policy under the old owner (which /status would serve as authoritative).
|
|
2890
|
+
assertEquals(row.merge_requested, 0, "rollback restores the prior merge intent");
|
|
2891
|
+
assertEquals(row.merge_human_approval, 1, "rollback restores the prior approval state");
|
|
2725
2892
|
|
|
2726
2893
|
failReset = false;
|
|
2727
2894
|
const retry = await submitPr(data, engine, parsed, [], undefined, false, null, false, false, true, "PI-MERGE");
|