@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 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
+ });
@@ -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
+ }
@@ -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 = "131_pull_requests_read_model_termination_reason.sql";
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.