@nanobpm/nano-workforce 0.208.4 → 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/CHANGELOG.md +6 -0
- package/app/agentCompletion.test.ts +155 -0
- package/app/agentCompletion.ts +39 -0
- 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/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/openapi.yaml +13 -6
- package/operations/listActivePrs.test.ts +207 -3
- package/operations/listActivePrs.ts +13 -9
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [0.208.5](https://github.com/nanobpm/nano-workforce/compare/v0.208.4...v0.208.5) (2026-11-11)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **status:** serve the /status merge policy from a durable pull_requests projection ([#950](https://github.com/nanobpm/nano-workforce/issues/950)) ([c3f19d5](https://github.com/nanobpm/nano-workforce/commit/c3f19d50ef6cf4f731f50ab778b5cd7ebdb3454e)), closes [#937](https://github.com/nanobpm/nano-workforce/issues/937) [#937](https://github.com/nanobpm/nano-workforce/issues/937) [#947](https://github.com/nanobpm/nano-workforce/issues/947)
|
|
6
|
+
|
|
1
7
|
## [0.208.4](https://github.com/nanobpm/nano-workforce/compare/v0.208.3...v0.208.4) (2026-11-11)
|
|
2
8
|
|
|
3
9
|
### Bug Fixes
|
|
@@ -85,6 +85,21 @@ function memData(
|
|
|
85
85
|
}
|
|
86
86
|
return { changed: 0 };
|
|
87
87
|
}
|
|
88
|
+
// The guarded merge-approval projection stamp (issue #947, PR #950 review): ownership is checked
|
|
89
|
+
// IN the UPDATE predicate, so only a row still owned by the approving instance is stamped.
|
|
90
|
+
const mp = /^UPDATE "pull_requests" SET "merge_human_approval" = 0 WHERE "process_key" = \?$/.exec(sql);
|
|
91
|
+
if (mp) {
|
|
92
|
+
const store = stores.pull_requests;
|
|
93
|
+
if (!store) throw new Error("no such table: pull_requests");
|
|
94
|
+
let changed = 0;
|
|
95
|
+
for (const r of store.rows) {
|
|
96
|
+
if (r.process_key === params[0]) {
|
|
97
|
+
r.merge_human_approval = 0;
|
|
98
|
+
changed++;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { changed };
|
|
102
|
+
}
|
|
88
103
|
// The FIRST-HAND agent-revert tombstone keyed on `source_completion_id` (Copilot review of #806) —
|
|
89
104
|
// `invalidateAdjudicationByCompletion`. Tombstones EVERY live row this completion produced (a
|
|
90
105
|
// completion settles one question, so at most one) so reverting a first-hand agent answer (which has
|
|
@@ -991,3 +1006,143 @@ test("snapshotUserTaskFormOptions: an unresolvable form_key never throws (safe r
|
|
|
991
1006
|
"an unresolvable form_key falls back to the element's static form",
|
|
992
1007
|
);
|
|
993
1008
|
});
|
|
1009
|
+
|
|
1010
|
+
// ── Issue #947: a merge-approval APPROVE mirrors onto the durable /status merge-policy projection ──
|
|
1011
|
+
// `submitPr` stamps `merge_human_approval = 1` when it gates a loop; the human's approve at the
|
|
1012
|
+
// `merge-approval` task clears the loop's `humanApproval` variable via the task's output mapping.
|
|
1013
|
+
// The canonical completer must stamp the SAME decision onto the row column `/status` reads, or the
|
|
1014
|
+
// projection reports `blockedBy: "human-approval"` long after approval landed.
|
|
1015
|
+
|
|
1016
|
+
test("a merge-approval APPROVE stamps merge_human_approval=0 on the owning PR row (issue #947)", async () => {
|
|
1017
|
+
const stores = {
|
|
1018
|
+
task_completions: { rows: [] as any[], key: "id" },
|
|
1019
|
+
pull_requests: {
|
|
1020
|
+
rows: [
|
|
1021
|
+
{ pr_key: "owner/repo#7", process_key: "PI-7", merge_requested: 1, merge_human_approval: 1 },
|
|
1022
|
+
],
|
|
1023
|
+
key: "pr_key",
|
|
1024
|
+
},
|
|
1025
|
+
};
|
|
1026
|
+
const data = memData(stores);
|
|
1027
|
+
const { engine } = fakeEngine([{ userTaskKey: "ut-ma", elementId: "merge-approval", processInstanceKey: "PI-7" }]);
|
|
1028
|
+
|
|
1029
|
+
const r = await completeEscalationAsHuman(data, engine, {
|
|
1030
|
+
userTaskKey: "ut-ma",
|
|
1031
|
+
operatorId: "alice",
|
|
1032
|
+
variables: { mergeDecision: "approve" },
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
assertEquals(r.ok, true);
|
|
1036
|
+
const row = stores.pull_requests.rows[0];
|
|
1037
|
+
assertEquals(row.merge_human_approval, 0, "the approval is mirrored onto the /status projection");
|
|
1038
|
+
assertEquals(row.merge_requested, 1, "the merge intent is untouched");
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
test("a merge-approval REVISE leaves the projection gated (issue #947)", async () => {
|
|
1042
|
+
const stores = {
|
|
1043
|
+
task_completions: { rows: [] as any[], key: "id" },
|
|
1044
|
+
pull_requests: {
|
|
1045
|
+
rows: [{ pr_key: "owner/repo#8", process_key: "PI-8", merge_requested: 1, merge_human_approval: 1 }],
|
|
1046
|
+
key: "pr_key",
|
|
1047
|
+
},
|
|
1048
|
+
};
|
|
1049
|
+
const data = memData(stores);
|
|
1050
|
+
const { engine } = fakeEngine([{ userTaskKey: "ut-mr", elementId: "merge-approval", processInstanceKey: "PI-8" }]);
|
|
1051
|
+
|
|
1052
|
+
const r = await completeEscalationAsHuman(data, engine, {
|
|
1053
|
+
userTaskKey: "ut-mr",
|
|
1054
|
+
operatorId: "alice",
|
|
1055
|
+
variables: { mergeDecision: "revise", answer: "split the migration" },
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
assertEquals(r.ok, true);
|
|
1059
|
+
assertEquals(stores.pull_requests.rows[0].merge_human_approval, 1, "a revise keeps the gate outstanding");
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
test("a merge-approval approve against a SUPERSEDED instance never clears the fresh run's gate (issue #947)", async () => {
|
|
1063
|
+
// The stamp is ownership-guarded on the instance the task parked on: after a reopen re-pointed
|
|
1064
|
+
// `process_key` at the fresh run, a straggler completion resolved against the OLD instance must
|
|
1065
|
+
// not clear the new run's projection.
|
|
1066
|
+
const stores = {
|
|
1067
|
+
task_completions: { rows: [] as any[], key: "id" },
|
|
1068
|
+
pull_requests: {
|
|
1069
|
+
rows: [{ pr_key: "owner/repo#9", process_key: "PI-NEW", merge_requested: 1, merge_human_approval: 1 }],
|
|
1070
|
+
key: "pr_key",
|
|
1071
|
+
},
|
|
1072
|
+
};
|
|
1073
|
+
const data = memData(stores);
|
|
1074
|
+
const { engine } = fakeEngine([{ userTaskKey: "ut-stale", elementId: "merge-approval", processInstanceKey: "PI-OLD" }]);
|
|
1075
|
+
|
|
1076
|
+
const r = await completeEscalationAsHuman(data, engine, {
|
|
1077
|
+
userTaskKey: "ut-stale",
|
|
1078
|
+
operatorId: "alice",
|
|
1079
|
+
variables: { mergeDecision: "approve" },
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
assertEquals(r.ok, true, "the engine completion itself still lands");
|
|
1083
|
+
assertEquals(stores.pull_requests.rows[0].merge_human_approval, 1, "the fresh run's gate is not cleared by a stale instance");
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
test("a reopen racing the approval stamp can never let the PREVIOUS run clear the successor's gate (PR #950 review)", async () => {
|
|
1087
|
+
// A reopen re-points `process_key` the moment the stamp looks the row up (simulated in the
|
|
1088
|
+
// table's `findOne`). A read-then-write stamp would then clear the FRESH run's gate; the guarded
|
|
1089
|
+
// single-statement UPDATE checks ownership atomically, so the successor's gate survives.
|
|
1090
|
+
const stores = {
|
|
1091
|
+
task_completions: { rows: [] as any[], key: "id" },
|
|
1092
|
+
pull_requests: {
|
|
1093
|
+
rows: [{ pr_key: "owner/repo#10", process_key: "PI-OLD", merge_requested: 1, merge_human_approval: 1 }],
|
|
1094
|
+
key: "pr_key",
|
|
1095
|
+
},
|
|
1096
|
+
};
|
|
1097
|
+
const base = memData(stores);
|
|
1098
|
+
const reopen = () => {
|
|
1099
|
+
const live = stores.pull_requests.rows[0];
|
|
1100
|
+
live.process_key = "PI-NEW";
|
|
1101
|
+
live.merge_human_approval = 1; // the fresh gated run restarts outstanding
|
|
1102
|
+
};
|
|
1103
|
+
const data: any = Object.create(base);
|
|
1104
|
+
data.table = (name: string, key: string) => {
|
|
1105
|
+
const t: any = base.table(name, key);
|
|
1106
|
+
if (name !== "pull_requests") return t;
|
|
1107
|
+
return {
|
|
1108
|
+
...t,
|
|
1109
|
+
findOne: async (q: any) => {
|
|
1110
|
+
const row = await t.findOne(q);
|
|
1111
|
+
reopen(); // the reopen lands between a lookup and its write
|
|
1112
|
+
return row;
|
|
1113
|
+
},
|
|
1114
|
+
};
|
|
1115
|
+
};
|
|
1116
|
+
// An atomic statement leaves no window: the reopen can only land after it (and resets the gate).
|
|
1117
|
+
data.open = () => {
|
|
1118
|
+
const db: any = base.open();
|
|
1119
|
+
return { ...db, exec: async (sql: string, params?: unknown[]) => {
|
|
1120
|
+
const res = await db.exec(sql, params);
|
|
1121
|
+
reopen();
|
|
1122
|
+
return res;
|
|
1123
|
+
} };
|
|
1124
|
+
};
|
|
1125
|
+
const { engine } = fakeEngine([{ userTaskKey: "ut-race", elementId: "merge-approval", processInstanceKey: "PI-OLD" }]);
|
|
1126
|
+
const r = await completeEscalationAsHuman(data, engine, {
|
|
1127
|
+
userTaskKey: "ut-race",
|
|
1128
|
+
operatorId: "alice",
|
|
1129
|
+
variables: { mergeDecision: "approve" },
|
|
1130
|
+
});
|
|
1131
|
+
assertEquals(r.ok, true);
|
|
1132
|
+
assertEquals(stores.pull_requests.rows[0].merge_human_approval, 1, "the successor's gate stays outstanding");
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
test("a merge-approval projection-stamp failure never masks the engine completion (issue #947)", async () => {
|
|
1136
|
+
const stores = { task_completions: { rows: [] as any[], key: "id" } };
|
|
1137
|
+
const data = memData(stores); // no pull_requests store — the stamp's table read throws
|
|
1138
|
+
const { engine, completed } = fakeEngine([{ userTaskKey: "ut-mx", elementId: "merge-approval", processInstanceKey: "PI-X" }]);
|
|
1139
|
+
|
|
1140
|
+
const r = await completeEscalationAsHuman(data, engine, {
|
|
1141
|
+
userTaskKey: "ut-mx",
|
|
1142
|
+
operatorId: "alice",
|
|
1143
|
+
variables: { mergeDecision: "approve" },
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
assertEquals(r.ok, true, "best-effort: the projection miss is swallowed");
|
|
1147
|
+
assertEquals(completed.length, 1, "the engine resume already happened");
|
|
1148
|
+
});
|
package/app/agentCompletion.ts
CHANGED
|
@@ -25,6 +25,7 @@ import type { DataLayer, EngineClient, GatewayDataSource } from "@nanobpm/urban"
|
|
|
25
25
|
import { invalidateAdjudication, invalidateAdjudicationByCompletion } from "./adjudications.ts";
|
|
26
26
|
import { CONFORMANCE_ESCALATION_ELEMENT } from "./conformance.ts";
|
|
27
27
|
import { DELIVERY_HUMAN_ELEMENT, ESCALATION_FORM, GENERIC_HUMAN_FORM, isDeliveryEscalationTwin, isDeliveryHumanElement, isDeliveryWaitEscalationTwin } from "./deliveryHuman.ts";
|
|
28
|
+
import { withMergePolicyLock } from "./mergePolicyLock.ts";
|
|
28
29
|
import { ACP_PERMISSION_ELEMENT, EMPTY_PLAN_ELEMENT, PR_MERGE_APPROVAL_ELEMENT, READINESS_ESCALATION_ELEMENT, READINESS_ESCALATION_PF_ELEMENT } from "./userTasks.ts";
|
|
29
30
|
|
|
30
31
|
const now = () => new Date().toISOString();
|
|
@@ -432,6 +433,11 @@ export async function completeUserTaskAttributed(
|
|
|
432
433
|
created_at: now(),
|
|
433
434
|
});
|
|
434
435
|
const completionId = Number(id);
|
|
436
|
+
// Merge-approval approve: serialize the engine completion + projection stamp against gated adoption
|
|
437
|
+
// on the same loop (PR #950 review) so a concurrent adopter's stale `1` can never land after our `0`.
|
|
438
|
+
const approvingMerge = target.elementId === PR_MERGE_APPROVAL_ELEMENT && target.variables.mergeDecision === "approve";
|
|
439
|
+
const lockKey = approvingMerge ? target.processInstanceKey?.trim() : undefined;
|
|
440
|
+
const resume = async (): Promise<void> => {
|
|
435
441
|
try {
|
|
436
442
|
// Carry the completed user-task's identity forward on the resumed token (Copilot review of #806).
|
|
437
443
|
// `pr.answer-escalation` (record-answer) reconciles the durable adjudication AFTER this completion
|
|
@@ -461,9 +467,42 @@ export async function completeUserTaskAttributed(
|
|
|
461
467
|
}
|
|
462
468
|
throw err;
|
|
463
469
|
}
|
|
470
|
+
// Merge-policy projection (issue #947): a human APPROVE at the convergence loop's `merge-approval`
|
|
471
|
+
// task clears the loop's `humanApproval` variable via the task's output mapping — mirror that
|
|
472
|
+
// decision onto the durable row column `/status` reads, so the projection lifts the
|
|
473
|
+
// `human-approval` blocker the moment approval lands instead of reporting a stale block until
|
|
474
|
+
// `startMerge` re-stamps. Best-effort (the engine resume above already succeeded — a projection
|
|
475
|
+
// miss only leaves the row reading blocked until the merge hand-off, never blocks the merge) and
|
|
476
|
+
// ownership-guarded on the instance the task parked on, so a completion replayed against a stale
|
|
477
|
+
// instance can never clear a SUCCESSOR run's gate after a reopen re-pointed `process_key`.
|
|
478
|
+
if (approvingMerge) {
|
|
479
|
+
await stampMergeApprovalProjection(data, target.processInstanceKey);
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
if (lockKey) await withMergePolicyLock(lockKey, resume);
|
|
483
|
+
else await resume();
|
|
464
484
|
return { completionId };
|
|
465
485
|
}
|
|
466
486
|
|
|
487
|
+
/** Mirror a `merge-approval` approve onto the durable `/status` merge-policy projection
|
|
488
|
+
* (`merge_human_approval = 0` — decided-and-approved; 132_pull_requests_merge_policy.sql). The
|
|
489
|
+
* stamp is keyed by the OWNING instance: the row is only updated while its `process_key` still
|
|
490
|
+
* names the instance the completed task parked on, so a late/stray completion against an
|
|
491
|
+
* already-replaced run is a no-op. Best-effort and never throws — the engine-side completion is
|
|
492
|
+
* the authority; this column only feeds the read projection. */
|
|
493
|
+
async function stampMergeApprovalProjection(data: DataLayer, processInstanceKey: string | null | undefined): Promise<void> {
|
|
494
|
+
const key = processInstanceKey?.trim();
|
|
495
|
+
if (!key) return;
|
|
496
|
+
try {
|
|
497
|
+
// ONE guarded UPDATE (PR #950 review): the ownership check and the stamp are a single atomic
|
|
498
|
+
// statement, so a reopen that re-points `process_key` between a lookup and a write can never let
|
|
499
|
+
// the PREVIOUS run's approval clear its successor's gate (no read-then-write window).
|
|
500
|
+
await data.open().exec(`UPDATE "pull_requests" SET "merge_human_approval" = 0 WHERE "process_key" = ?`, [key]);
|
|
501
|
+
} catch {
|
|
502
|
+
// swallow — a projection miss never masks a completion the engine has already resumed
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
467
506
|
/** The newest recorded completion for a user-task key, or undefined. */
|
|
468
507
|
export async function latestCompletion(
|
|
469
508
|
data: DataLayer,
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { withMergePolicyLock } from "./mergePolicyLock.ts";
|
|
4
|
+
|
|
5
|
+
// PR #950 review: gated adoption (engine humanApproval=true, stamp 1) and merge approval (engine
|
|
6
|
+
// complete, stamp 0) must never interleave on one loop, or the projection ends at a stale 1.
|
|
7
|
+
test("engine-write/projection-stamp pairs on the same loop never interleave", async () => {
|
|
8
|
+
const log: string[] = [];
|
|
9
|
+
let projection: number | null = null;
|
|
10
|
+
let releaseAdopt!: () => void;
|
|
11
|
+
const adoptEngineWritten = new Promise<void>((r) => {
|
|
12
|
+
releaseAdopt = r;
|
|
13
|
+
});
|
|
14
|
+
const adopt = withMergePolicyLock("loop-1", async () => {
|
|
15
|
+
log.push("adopt:engine");
|
|
16
|
+
await adoptEngineWritten; // the approval tries to run while adoption is mid-pair
|
|
17
|
+
projection = 1;
|
|
18
|
+
log.push("adopt:stamp");
|
|
19
|
+
});
|
|
20
|
+
const approve = withMergePolicyLock("loop-1", async () => {
|
|
21
|
+
log.push("approve:engine");
|
|
22
|
+
projection = 0;
|
|
23
|
+
log.push("approve:stamp");
|
|
24
|
+
});
|
|
25
|
+
await Promise.resolve();
|
|
26
|
+
releaseAdopt();
|
|
27
|
+
await Promise.all([adopt, approve]);
|
|
28
|
+
assert.deepEqual(log, ["adopt:engine", "adopt:stamp", "approve:engine", "approve:stamp"]);
|
|
29
|
+
assert.equal(projection, 0);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("a failing holder releases the lock; other keys run independently", async () => {
|
|
33
|
+
await assert.rejects(
|
|
34
|
+
withMergePolicyLock("loop-2", async () => {
|
|
35
|
+
throw new Error("boom");
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
assert.equal(await withMergePolicyLock("loop-2", async () => "next"), "next");
|
|
39
|
+
let release!: () => void;
|
|
40
|
+
const held = withMergePolicyLock("loop-3", () => new Promise<void>((r) => (release = r)));
|
|
41
|
+
assert.equal(await withMergePolicyLock("loop-4", async () => "free"), "free");
|
|
42
|
+
release();
|
|
43
|
+
await held;
|
|
44
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** In-process keyed serializer for the merge-policy projection (issue #947, PR #950 review).
|
|
2
|
+
*
|
|
3
|
+
* Two writers touch one convergence loop's `humanApproval` policy as an engine-write +
|
|
4
|
+
* projection-stamp PAIR: gated adoption (`submitPr`: `setVariables(humanApproval: true)` then stamp
|
|
5
|
+
* `merge_human_approval = 1`) and a `merge-approval` approve (`completeUserTask` then stamp `0`).
|
|
6
|
+
* Interleaved, the adopter's stale `1` can land after the approval's `0`, so `/status` reports a
|
|
7
|
+
* phantom `human-approval` block. Every such pair runs under this lock keyed by the loop's process
|
|
8
|
+
* instance key, so the pairs are applied in a single total order and the projection always matches
|
|
9
|
+
* the last engine write. The app is a single node process, so an in-memory promise chain suffices. */
|
|
10
|
+
const tails = new Map<string, Promise<unknown>>();
|
|
11
|
+
|
|
12
|
+
export async function withMergePolicyLock<T>(processKey: string, fn: () => Promise<T>): Promise<T> {
|
|
13
|
+
const prev = tails.get(processKey) ?? Promise.resolve();
|
|
14
|
+
const run = prev.then(fn, fn);
|
|
15
|
+
const tail = run.then(
|
|
16
|
+
() => undefined,
|
|
17
|
+
() => undefined,
|
|
18
|
+
);
|
|
19
|
+
tails.set(processKey, tail);
|
|
20
|
+
try {
|
|
21
|
+
return await run;
|
|
22
|
+
} finally {
|
|
23
|
+
if (tails.get(processKey) === tail) tails.delete(processKey);
|
|
24
|
+
}
|
|
25
|
+
}
|
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.
|