@nanobpm/nano-workforce 0.105.0 → 0.106.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,6 +16,11 @@ jobs:
16
16
  steps:
17
17
  - name: Checkout
18
18
  uses: actions/checkout@v4
19
+ with:
20
+ # Full history + tags: the migration immutability gate (check:migrations) diffs against the
21
+ # merge-base with origin/main, and the upgrade smoke test materialises the previous release
22
+ # tag's migration set — both need history a shallow clone doesn't have (issue #357).
23
+ fetch-depth: 0
19
24
 
20
25
  - name: Setup Node.js
21
26
  uses: actions/setup-node@v4
package/AGENTS.md CHANGED
@@ -273,6 +273,36 @@ Migrations live in `db/migrations/*.sql` and are **auto-applied on boot** from
273
273
  prefix while `main` keeps advancing, so the branch-local "next" number collides
274
274
  on merge. Two files must never share a prefix; `npm run check:migrations`
275
275
  (a CI gate) enforces this and fails the build on any new duplicate.
276
+ - **A merged migration is IMMUTABLE — never rename, delete, or edit it.** The
277
+ runtime keys the `_urban_migrations` ledger by *filename*, so a renamed file is
278
+ a *new* migration to the runner: it re-runs its DDL against an already-migrated
279
+ DB and aborts boot (`duplicate column …`); a deleted file desyncs the ledger
280
+ from the schema; an edited file silently no-ops on every existing install (the
281
+ name is already recorded) while diverging fresh ones. To change a merged
282
+ migration's effect, add a NEW migration. `npm run check:migrations` also gates
283
+ this — it diffs `db/migrations/` against the merge-base with `origin/main` and
284
+ fails on any rename/delete/edit — and the upgrade smoke test
285
+ (`app/migration-upgrade-smoke.test.ts`) materialises the previous release's
286
+ migration set and upgrades it to the current set, catching non-idempotent DDL a
287
+ fresh-DB CI never exercises (issue #357). Both need history: CI checks out with
288
+ `fetch-depth: 0`.
289
+
290
+ ### Healing an install wedged by a renamed migration
291
+
292
+ If a live node fails to boot with `migration "NNN_…sql" failed and was rolled
293
+ back … duplicate column` because a migration was renamed *before* the immutability
294
+ gate existed (e.g. `043_user_tasks_subject_title.sql` → `046_…`, issue #357),
295
+ reconcile its ledger — this makes **no schema change**, only aliases the old
296
+ ledger row to the new filename, and is safe to re-run:
297
+
298
+ ```bash
299
+ npm run heal:migrations -- /path/to/the/app.sqlite # then restart the node
300
+ ```
301
+
302
+ The known renames live in `RENAMED_MIGRATIONS` (`app/migrationHeal.ts`), the
303
+ single source of truth the heal script and its tests share. This list only heals
304
+ the pre-gate past — the immutability gate above prevents any new entry from ever
305
+ being needed.
276
306
 
277
307
  ## Runtime & CI gates
278
308
 
@@ -286,7 +316,7 @@ npm run typecheck # tsc --noEmit (Node)
286
316
  npm run check # urban check (manifest validation)
287
317
  npm run layout:check # BPMN diagram freshness (no drift)
288
318
  npm run check:prompts # agent-prompt linkedResource resolution
289
- npm run check:migrations # migration prefixes (no collisions)
319
+ npm run check:migrations # migration prefixes + immutability (no rename/delete/edit of a merged migration)
290
320
  npm run check:contracts # contract registry (no synonyms / undeclared env keys)
291
321
  npm test # unit tests (node --test)
292
322
  ```
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## [0.106.1](https://github.com/nanobpm/nano-workforce/compare/v0.106.0...v0.106.1) (2026-08-20)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **migrations:** gate migration immutability + add upgrade smoke test ([#357](https://github.com/nanobpm/nano-workforce/issues/357)) ([#359](https://github.com/nanobpm/nano-workforce/issues/359)) ([7f7c6a6](https://github.com/nanobpm/nano-workforce/commit/7f7c6a6c59dac33602441c729dca897b3b23fabc)), closes [#311](https://github.com/nanobpm/nano-workforce/issues/311) [#316](https://github.com/nanobpm/nano-workforce/issues/316) [#351](https://github.com/nanobpm/nano-workforce/issues/351) [#355](https://github.com/nanobpm/nano-workforce/issues/355)
7
+
8
+ # [0.106.0](https://github.com/nanobpm/nano-workforce/compare/v0.105.0...v0.106.0) (2026-08-20)
9
+
10
+
11
+ ### Features
12
+
13
+ * escalate unmet/undisclosed conformance deviations to the Tasks inbox ([#356](https://github.com/nanobpm/nano-workforce/issues/356)) ([5ad24bc](https://github.com/nanobpm/nano-workforce/commit/5ad24bcadf833934804973a25fd96f4eb4d28a84)), closes [#354](https://github.com/nanobpm/nano-workforce/issues/354) [#354](https://github.com/nanobpm/nano-workforce/issues/354)
14
+
1
15
  # [0.105.0](https://github.com/nanobpm/nano-workforce/compare/v0.104.0...v0.105.0) (2026-08-19)
2
16
 
3
17
 
package/README.md CHANGED
@@ -153,7 +153,7 @@ capability):
153
153
  | `fix-ci` | `senior:fix-ci` | `merge-loop` | Green a `blocked` PR's failing checks |
154
154
  | `rebase` | `senior:rebase` | `merge-loop` | Rebase a conflicting PR up to date with its base |
155
155
  | `retro` | `senior:retro` | `retro` | Synthesize a finished epic's learnings and promote the recurring ones |
156
- | `conformance` | `senior:conformance` | `retro` | Examine a finished epic's implementation against its spec; report met/deviations on the issue |
156
+ | `conformance` | `senior:conformance` | `retro` | Examine a finished epic's implementation against its spec; report met/deviations on the issue, and escalate an unmet/undisclosed deviation to the Tasks inbox as a non-blocking ack |
157
157
 
158
158
  - `--command 'copilot -p - --allow-all-tools'` starts the Copilot CLI reading its
159
159
  prompt from **stdin** (`-p -`). The harness pipes the whole job JSON (prompt +
@@ -229,6 +229,34 @@ test("feature-blocked is HUMAN-completable but NOT agent-completable (issue #332
229
229
  assertEquals(completed[0].variables, { note: "reassigned to a human" });
230
230
  });
231
231
 
232
+ test("conformance-escalation is HUMAN-completable but NOT agent-completable (issue #216)", async () => {
233
+ // The retro conformance ack mirrors feature-blocked: a HUMAN operator retires it via
234
+ // `completeEscalationAsHuman`, but it stays OUTSIDE the agent surface (`ESCALATION_TASK_ELEMENTS`) —
235
+ // an agent must never acknowledge a conformance review on a human's behalf.
236
+ const stores = { task_completions: { rows: [] as any[], key: "id" } };
237
+ const data = memData(stores);
238
+ const { engine, completed } = fakeEngine([{ userTaskKey: "ut-c", elementId: "conformance-escalation" }]);
239
+
240
+ const asAgent = await completeEscalationAsAgent(data, engine, {
241
+ userTaskKey: "ut-c",
242
+ agentId: "bot",
243
+ variables: { note: "n" },
244
+ });
245
+ assertEquals(asAgent.ok, false, "the agent completer refuses conformance-escalation");
246
+ assertEquals(asAgent.reason, "not a completable task");
247
+ assertEquals(completed.length, 0);
248
+
249
+ const asHuman = await completeEscalationAsHuman(data, engine, {
250
+ userTaskKey: "ut-c",
251
+ operatorId: "alice",
252
+ variables: { note: "filed follow-up" },
253
+ });
254
+ assertEquals(asHuman.ok, true, "the human completer retires conformance-escalation");
255
+ assertEquals(asHuman.elementId, "conformance-escalation");
256
+ assertEquals(completed.length, 1);
257
+ assertEquals(completed[0].variables, { note: "filed follow-up" });
258
+ });
259
+
232
260
  test("human completer refuses a non-escalation user task and is a no-op for an unknown key", async () => {
233
261
  const stores = { task_completions: { rows: [] as any[], key: "id" } };
234
262
  const data = memData(stores);
@@ -22,6 +22,7 @@
22
22
 
23
23
  import { readFileSync } from "node:fs";
24
24
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
25
+ import { CONFORMANCE_ESCALATION_ELEMENT } from "./conformance.ts";
25
26
 
26
27
  const now = () => new Date().toISOString();
27
28
 
@@ -79,13 +80,23 @@ export const ESCALATION_TASK_ELEMENTS: ReadonlySet<string> = new Set([
79
80
  * `acknowledge-blocked` door onto the one canonical `complete-user-task` door). */
80
81
  export const FEATURE_BLOCKED_TASK_ELEMENT = "feature-blocked";
81
82
 
83
+ /** The `conformance-escalation` operator user-task element id (retro.bpmn) — the native ack a retro
84
+ * run parks on when the spec-conformance audit finds the epic did NOT cleanly meet its spec (a
85
+ * reduced / not-verified slice, or an unraised deviation). Like `feature-blocked` it is a human-only
86
+ * acknowledgement (never agent-answerable), so it lives OUTSIDE `ESCALATION_TASK_ELEMENTS` and only
87
+ * the HUMAN completer accepts it (issue #216). Re-exported from the canonical
88
+ * `CONFORMANCE_ESCALATION_ELEMENT` (app/conformance.ts) — one source of truth, no drift surface. */
89
+ export const CONFORMANCE_ESCALATION_TASK_ELEMENT = CONFORMANCE_ESCALATION_ELEMENT;
90
+
82
91
  /** The user-task `elementId`s a HUMAN operator may complete from the Tasks inbox via the one canonical
83
92
  * `complete-user-task` door: every agent-answerable escalation PLUS the human-only `feature-blocked`
84
- * acknowledgement. The AGENT completer stays scoped to `ESCALATION_TASK_ELEMENTS` (no `feature-blocked`),
85
- * so widening the human surface never lets an agent retire a blocked run. */
93
+ * and `conformance-escalation` acknowledgements. The AGENT completer stays scoped to
94
+ * `ESCALATION_TASK_ELEMENTS` (neither ack), so widening the human surface never lets an agent retire
95
+ * a blocked run or a conformance review. */
86
96
  export const HUMAN_COMPLETABLE_ELEMENTS: ReadonlySet<string> = new Set([
87
97
  ...ESCALATION_TASK_ELEMENTS,
88
98
  FEATURE_BLOCKED_TASK_ELEMENT,
99
+ CONFORMANCE_ESCALATION_TASK_ELEMENT,
89
100
  ]);
90
101
 
91
102
  /** Each escalation `elementId` → the `.form` whose contract governs its completion variables (the
@@ -98,6 +109,7 @@ const ESCALATION_FORM_BY_ELEMENT: Readonly<Record<string, string>> = {
98
109
  "wait-answer": "pr-escalation",
99
110
  "wait-merge-answer": "pr-escalation",
100
111
  "feature-blocked": "feature-blocked",
112
+ [CONFORMANCE_ESCALATION_TASK_ELEMENT]: "conformance-escalation",
101
113
  };
102
114
 
103
115
  /** A field's `conditional.hide` rule, parsed from the FEEL subset the `.form` files use
@@ -1,10 +1,12 @@
1
1
  // Unit tests for the spec-conformance review stage (app/conformance.ts, 052_plan_conformance.sql).
2
2
  import { test } from "node:test";
3
- import { assert, assertEquals, assertStringIncludes } from "#test-assert";
3
+ import { assert, assertEquals, assertRejects, assertStringIncludes } from "#test-assert";
4
4
  import type { DataLayer } from "@nanobpm/urban";
5
5
  import { memBlackboardSource } from "../test/blackboardDb.ts";
6
6
  import { appendEntry } from "./blackboard.ts";
7
7
  import {
8
+ acknowledgeConformance,
9
+ activeConformanceReviews,
8
10
  gatherConformance,
9
11
  hasDeliveredImplementation,
10
12
  hasDeliveredImplementationForPlan,
@@ -218,3 +220,94 @@ test("recordConformance: rethrows a non-unique (FOREIGN KEY) constraint error in
218
220
  assert(threw, "the FK error must propagate");
219
221
  assertEquals(updated, false, "must not silently fall back to update on a non-unique error");
220
222
  });
223
+
224
+ test("recordConformance: persists the retro instance key and the escalation review_status (issue #216)", async () => {
225
+ const { data, stores } = memData();
226
+ await recordConformance(data, PLAN, {
227
+ status: "filed",
228
+ commentUrl: "https://x/7#c",
229
+ hasDeviations: true,
230
+ summary: "slice 2 reduced",
231
+ processKey: "retro-inst-7",
232
+ reviewStatus: "reviewing",
233
+ });
234
+ const row = stores["plan_conformance"][0];
235
+ assertEquals(row.process_key, "retro-inst-7");
236
+ assertEquals(row.review_status, "reviewing");
237
+
238
+ // Absent tracking fields default: no processKey, and a settled `reviewed`.
239
+ await recordConformance(data, "acme/widgets#8", { status: "skipped" });
240
+ const clean = stores["plan_conformance"].find((r) => r.plan_key === "acme/widgets#8");
241
+ assertEquals(clean.process_key, null);
242
+ assertEquals(clean.review_status, "reviewed");
243
+ });
244
+
245
+ test("recordConformance: rejects an untrackable `reviewing` row with a null processKey (invariant guard)", async () => {
246
+ const { data, stores } = memData();
247
+ // A `reviewing` row with no `process_key` is unreachable: `pollUserTasks` skips rows without a
248
+ // key and the `instanceTracking` binding keys off `process_key`, so it would strand the review
249
+ // forever. The API must refuse to persist that state, not just the worker call site.
250
+ await assertRejects(
251
+ () =>
252
+ recordConformance(data, PLAN, {
253
+ status: "filed",
254
+ hasDeviations: true,
255
+ reviewStatus: "reviewing",
256
+ }),
257
+ Error,
258
+ "untrackable",
259
+ );
260
+ assertEquals(stores["plan_conformance"] ?? [], []);
261
+ });
262
+
263
+ test("activeConformanceReviews: returns only the rows still `reviewing`", async () => {
264
+ const { data, stores } = memData();
265
+ stores["plan_conformance"] = [
266
+ { plan_key: "a/b#1", process_key: "p1", review_status: "reviewing", summary: "s1" },
267
+ { plan_key: "a/b#2", process_key: "p2", review_status: "reviewed", summary: "s2" },
268
+ ];
269
+ const active = await activeConformanceReviews(data);
270
+ assertEquals(active.map((r) => r.plan_key), ["a/b#1"]);
271
+ });
272
+
273
+ test("acknowledgeConformance: settles the row at reviewed and folds the note into the summary", async () => {
274
+ const { data, stores } = memData();
275
+ stores["plan_conformance"] = [
276
+ { plan_key: PLAN, process_key: "p1", review_status: "reviewing", summary: "slice 2 reduced" },
277
+ ];
278
+ await acknowledgeConformance(data, PLAN, "filed follow-up #9");
279
+ const row = stores["plan_conformance"][0];
280
+ assertEquals(row.review_status, "reviewed");
281
+ assertEquals(row.summary, "slice 2 reduced\n\nOperator ack: filed follow-up #9");
282
+ });
283
+
284
+ test("acknowledgeConformance: fails loudly when the plan_conformance row is missing", async () => {
285
+ const { data } = memData();
286
+ await assertRejects(
287
+ () => acknowledgeConformance(data, PLAN, "filed follow-up #9"),
288
+ Error,
289
+ PLAN,
290
+ );
291
+ });
292
+
293
+ test("acknowledgeConformance: is idempotent — a retry after settling does not re-append the note", async () => {
294
+ const { data, stores } = memData();
295
+ stores["plan_conformance"] = [
296
+ { plan_key: PLAN, process_key: "p1", review_status: "reviewing", summary: "slice 2 reduced" },
297
+ ];
298
+ await acknowledgeConformance(data, PLAN, "filed follow-up #9");
299
+ await acknowledgeConformance(data, PLAN, "filed follow-up #9");
300
+ const row = stores["plan_conformance"][0];
301
+ assertEquals(row.review_status, "reviewed");
302
+ assertEquals(row.summary, "slice 2 reduced\n\nOperator ack: filed follow-up #9");
303
+ });
304
+
305
+ test("acknowledgeConformance: a blank note settles the row without changing the summary", async () => {
306
+ const { data, stores } = memData();
307
+ stores["plan_conformance"] = [
308
+ { plan_key: PLAN, process_key: "p1", review_status: "reviewing", summary: "auth cache unverified" },
309
+ ];
310
+ await acknowledgeConformance(data, PLAN, " ");
311
+ assertEquals(stores["plan_conformance"][0].review_status, "reviewed");
312
+ assertEquals(stores["plan_conformance"][0].summary, "auth cache unverified");
313
+ });
@@ -21,6 +21,14 @@ import { planTasks } from "./plan.ts";
21
21
 
22
22
  const now = () => new Date().toISOString();
23
23
 
24
+ /** The BPMN `elementId` of the conformance escalation user task (retro.bpmn). The inbox reconciler
25
+ * (`pollUserTasks`) and the human completer (`HUMAN_COMPLETABLE_ELEMENTS`) key off this. */
26
+ export const CONFORMANCE_ESCALATION_ELEMENT = "conformance-escalation";
27
+
28
+ /** The `review_status` a `plan_conformance` row carries while its escalation ack task is OPEN — the
29
+ * only status `pollUserTasks` scans (migration 054). Every settled run is `reviewed`. */
30
+ export const CONFORMANCE_REVIEWING_STATUS = "reviewing";
31
+
24
32
  /** A slice PR "landed" — its implementation is really in the tree and worth examining — when its
25
33
  * PR reached a terminal state that isn't `abandoned`. In auto-merge mode that terminal is `merged`;
26
34
  * in review-only mode it is `converged`. Derived from app/delivery.ts TERMINAL_STATUSES (the single
@@ -50,6 +58,30 @@ async function isLanded(data: DataLayer, prKey: string | null | undefined): Prom
50
58
  const conformanceTbl = (data: DataLayer) =>
51
59
  data.table<{ plan_key: string } & Record<string, unknown>>("plan_conformance", "plan_key");
52
60
 
61
+ /** A `plan_conformance` row viewed as a retro-run tracking record, for the inbox reconciler. */
62
+ export interface ConformanceReviewRow extends Record<string, unknown> {
63
+ plan_key: string;
64
+ process_key: string | null;
65
+ review_status: string;
66
+ summary: string | null;
67
+ }
68
+
69
+ const conformanceReviewsTbl = (data: DataLayer) =>
70
+ data.table<ConformanceReviewRow>("plan_conformance", "plan_key");
71
+
72
+ /** The conformance runs whose escalation ack task is still open (`review_status = 'reviewing'`) —
73
+ * the set `pollUserTasks` scans for an open `conformance-escalation` user task. */
74
+ export async function activeConformanceReviews(data: DataLayer): Promise<ConformanceReviewRow[]> {
75
+ return await conformanceReviewsTbl(data).find({ review_status: CONFORMANCE_REVIEWING_STATUS });
76
+ }
77
+
78
+ /** The escalation question shown in the inbox row: the agent's conformance summary (which names the
79
+ * reduced / not-verified items and the unraised deviations). Best-effort — NULL when none recorded. */
80
+ export function conformanceEscalationQuestion(row: { summary?: unknown } | undefined): string | null {
81
+ const s = row?.summary;
82
+ return typeof s === "string" && s.trim() ? s.trim() : null;
83
+ }
84
+
53
85
  /** One item of the spec the agent must verify against the code: the slice's planner-supplied
54
86
  * `prompt` (its acceptance brief), where it landed, and whether it landed at all. */
55
87
  export interface ConformanceSlice {
@@ -205,6 +237,12 @@ export interface ConformanceInput {
205
237
  hasDeviations?: boolean;
206
238
  summary?: string | null;
207
239
  report?: string | null;
240
+ /** The retro process instance this conformance ran in — the tracking key `pollUserTasks` reads to
241
+ * find an open escalation user task (migration 054). */
242
+ processKey?: string | null;
243
+ /** Escalation lifecycle: `reviewing` while the ack task is open (poller scans these), else
244
+ * `reviewed`. Defaults to `reviewed` — only an escalation flips it to `reviewing`. */
245
+ reviewStatus?: "reviewing" | "reviewed";
208
246
  }
209
247
 
210
248
  /** Upsert a plan's conformance row (idempotent on plan_key, so a job retry overwrites in place).
@@ -218,6 +256,19 @@ export async function recordConformance(
218
256
  input: ConformanceInput,
219
257
  ): Promise<void> {
220
258
  const ts = now();
259
+ const processKey = input.processKey ?? null;
260
+ const reviewStatus = input.reviewStatus ?? "reviewed";
261
+ // Invariant: a `reviewing` row must be trackable. `pollUserTasks` skips rows without a
262
+ // `process_key` and the `instanceTracking` binding keys off `process_key`, so a `reviewing` row
263
+ // with a null key can never be surfaced to an operator nor cleared — it wedges forever. The
264
+ // conformance-record worker already guards its own call site, but `recordConformance` is a public
265
+ // API: reject the untrackable combination here too so no future caller can encode it.
266
+ if (reviewStatus === CONFORMANCE_REVIEWING_STATUS && processKey == null) {
267
+ throw new Error(
268
+ `recordConformance: ${planKey} would persist review_status='reviewing' with no process_key — ` +
269
+ "refusing to record an untrackable escalation that no poller or onTerminated binding can clear",
270
+ );
271
+ }
221
272
  const fields = {
222
273
  status: input.status,
223
274
  comment_url: input.commentUrl ?? null,
@@ -229,6 +280,8 @@ export async function recordConformance(
229
280
  has_deviations: input.hasDeviations ? 1 : 0,
230
281
  summary: input.summary ?? null,
231
282
  report: input.report ?? null,
283
+ process_key: processKey,
284
+ review_status: reviewStatus,
232
285
  updated_at: ts,
233
286
  };
234
287
  try {
@@ -238,3 +291,39 @@ export async function recordConformance(
238
291
  await conformanceTbl(data).update(planKey, fields);
239
292
  }
240
293
  }
294
+
295
+ /** Settle a conformance run's escalation once the operator acknowledges it: flip `review_status` to
296
+ * `reviewed` so `pollUserTasks` stops scanning it (its inbox row is already gone once the ack task
297
+ * closes) and stamp the disposition note into `summary` for the audit trail. Needed because the
298
+ * `retro` instance COMPLETES normally after the ack — `instanceTracking.onTerminated` only fires on a
299
+ * TERMINATED (crashed) instance, never a completed one, so nothing else would clear `reviewing`. */
300
+ export async function acknowledgeConformance(
301
+ data: DataLayer,
302
+ planKey: string,
303
+ note?: string | null,
304
+ ): Promise<void> {
305
+ const trimmed = typeof note === "string" && note.trim() ? note.trim() : null;
306
+ const existing = await conformanceTbl(data).get(planKey);
307
+ // Invariant: the ack task only fires after the escalation parked this exact `planKey` at
308
+ // `review_status='reviewing'`, so the row must exist. A missing row means a wrong/mismatched
309
+ // `planKey` (or unexpected DB state); silently returning would let the `retro` instance COMPLETE
310
+ // while the real conformance row stays stuck in `reviewing`, so `pollUserTasks` scans it forever.
311
+ // Fail loudly so the job retries/alerts instead of silently encoding the mismatch.
312
+ if (!existing) {
313
+ throw new Error(
314
+ `acknowledgeConformance: no plan_conformance row for ${planKey} — ` +
315
+ "refusing to settle a missing/mismatched escalation that would leave the real row stuck in 'reviewing'",
316
+ );
317
+ }
318
+ // At-least-once worker semantics can retry `pr.conformance-ack` after a successful DB update; the
319
+ // row is already settled at `reviewed`, so short-circuit to keep the operation idempotent (a retry
320
+ // must not re-append a duplicate `Operator ack: …` block to the audit trail).
321
+ if (existing.review_status === "reviewed") return;
322
+ const prior = typeof existing.summary === "string" ? existing.summary : null;
323
+ const summary = trimmed ? (prior ? `${prior}\n\nOperator ack: ${trimmed}` : `Operator ack: ${trimmed}`) : prior;
324
+ await conformanceTbl(data).update(planKey, {
325
+ review_status: "reviewed",
326
+ summary,
327
+ updated_at: now(),
328
+ });
329
+ }
@@ -11,6 +11,7 @@ import { PR_ACTIVE_STATUSES, PLAN_ACTIVE_STATUSES, FEATURE_ACTIVE_STATUSES } fro
11
11
  import { TERMINAL_STATUSES } from "./delivery.ts";
12
12
  import { PLAN_TERMINAL_STATUSES } from "./plan.ts";
13
13
  import { FEATURE_TERMINAL_STATUSES } from "./feature.ts";
14
+ import { CONFORMANCE_REVIEWING_STATUS } from "./conformance.ts";
14
15
 
15
16
  interface Binding {
16
17
  table: string;
@@ -117,3 +118,26 @@ test("FEATURE_ACTIVE_STATUSES is derived from the manifest binding (no drift)",
117
118
  const b = bindingFor(await bindings(), "feature_runs");
118
119
  assertEquals([...FEATURE_ACTIVE_STATUSES].sort(), [...(b.activeStatuses ?? [])].sort());
119
120
  });
121
+
122
+ // The retro conformance-escalation lifecycle has exactly one in-flight `review_status` — `reviewing`
123
+ // (the only status `pollUserTasks` scans via `activeConformanceReviews`) — and settles to `reviewed`.
124
+ // Tie the manifest binding to the code's single source of truth (`CONFORMANCE_REVIEWING_STATUS`) so
125
+ // the two can't drift: if a future change adds a new in-flight status but forgets the manifest, a
126
+ // terminated retro instance would strand in `review_status='reviewing'` and never clear (issue #96
127
+ // class of drift — the exact gap Copilot flagged on this binding).
128
+ test("instanceTracking: plan_conformance activeStatuses is exactly the reviewing status (no drift)", async () => {
129
+ const b = bindingFor(await bindings(), "plan_conformance");
130
+ assertEquals([...(b.activeStatuses ?? [])].sort(), [CONFORMANCE_REVIEWING_STATUS]);
131
+ });
132
+
133
+ // The settled status the reconciler flips a terminated row to (`onTerminated.set.review_status`)
134
+ // must NOT itself be listed active — otherwise `onTerminated` would leave the row scannable and the
135
+ // reconciler could clobber a settled run (mirrors the "excludes every terminal status" guards above).
136
+ test("instanceTracking: plan_conformance onTerminated status is not active", async () => {
137
+ const b = bindingFor(await bindings(), "plan_conformance");
138
+ const settled = b.onTerminated.set.review_status;
139
+ assert(
140
+ typeof settled === "string" && !b.activeStatuses?.includes(settled),
141
+ `onTerminated review_status "${String(settled)}" must not be listed active`,
142
+ );
143
+ });
@@ -0,0 +1,55 @@
1
+ // Upgrade smoke test (issue #357) — CI only ever migrates a FRESH database, so it never exercises the
2
+ // one state where the forward-only contract actually breaks: a *pre-existing* install upgrading to
3
+ // the current set. This materialises the migration set at the previous release tag, applies the
4
+ // current set on top the way the runtime does (ledger keyed by filename, skip-applied / apply-new),
5
+ // and asserts the upgrade completes cleanly.
6
+ //
7
+ // It covers the whole failure class on a real release boundary — a renamed/deleted migration
8
+ // re-running, non-idempotent DDL, or an ordering assumption — rather than any single instance. When
9
+ // the tag is unavailable (a shallow clone without tags) it skips with a diagnostic; CI checks out
10
+ // full history + tags (`fetch-depth: 0`), so the real upgrade path is always exercised there.
11
+ import { DatabaseSync } from "node:sqlite";
12
+ import test from "node:test";
13
+ import { assert, assertEquals } from "#test-assert";
14
+ import {
15
+ applyMigrationSet,
16
+ previousReleaseTag,
17
+ readMigrationSetFromDisk,
18
+ readMigrationSetFromGit,
19
+ } from "#test-migrations";
20
+
21
+ test("upgrading a DB from the previous release's migrations to the current set applies cleanly", (t) => {
22
+ const tag = previousReleaseTag();
23
+ if (tag === null) {
24
+ t.skip("no previous release tag reachable (shallow clone without tags) — CI runs this with fetch-depth: 0");
25
+ return;
26
+ }
27
+ const baseline = readMigrationSetFromGit(tag);
28
+ if (baseline === null) {
29
+ t.skip(`migration set at ${tag} unavailable — CI runs this with fetch-depth: 0`);
30
+ return;
31
+ }
32
+
33
+ const db = new DatabaseSync(":memory:");
34
+ // Stand up the DB exactly as the previous release left it...
35
+ applyMigrationSet(db, baseline);
36
+ const ledgerBefore = new Set(
37
+ (db.prepare("SELECT name FROM _urban_migrations").all() as { name: string }[]).map((r) => r.name),
38
+ );
39
+
40
+ // ...then upgrade in place to the current set. A rename/delete/non-idempotent migration throws here.
41
+ const current = readMigrationSetFromDisk();
42
+ applyMigrationSet(db, current);
43
+
44
+ // Every current migration is now recorded, and nothing the baseline applied went missing.
45
+ const ledgerAfter = new Set(
46
+ (db.prepare("SELECT name FROM _urban_migrations").all() as { name: string }[]).map((r) => r.name),
47
+ );
48
+ for (const file of current) {
49
+ assert(ledgerAfter.has(file.name), `current migration ${file.name} is recorded as applied after upgrade`);
50
+ }
51
+ for (const name of ledgerBefore) {
52
+ assert(ledgerAfter.has(name), `baseline migration ${name} remains in the ledger after upgrade`);
53
+ }
54
+ assertEquals(ledgerAfter.size, current.length, "the upgraded ledger holds exactly the current set");
55
+ });
@@ -0,0 +1,75 @@
1
+ // Regression guard for issue #357 — a migration renamed after it merged (`043_user_tasks_subject_
2
+ // title.sql` -> `046_…` in #316) re-runs against an already-migrated DB and aborts boot with
3
+ // "duplicate column name: subject_title", because the ledger is keyed by filename.
4
+ //
5
+ // This reproduces that exact break on the CURRENT migration set (RED), then proves
6
+ // `healMigrationLedger` reconciles the ledger so the upgrade completes cleanly (GREEN) — the recovery
7
+ // path for installs that migrated under the old name before the rename landed.
8
+ import { DatabaseSync } from "node:sqlite";
9
+ import test from "node:test";
10
+ import { healMigrationLedger, RENAMED_MIGRATIONS } from "../app/migrationHeal.ts";
11
+ import { assert, assertEquals, assertThrows } from "#test-assert";
12
+ import { applyMigrationSet, readMigrationSetFromDisk } from "#test-migrations";
13
+
14
+ const NEW_NAME = "046_user_tasks_subject_title.sql";
15
+ const OLD_NAME = "043_user_tasks_subject_title.sql";
16
+
17
+ // A DB migrated fully with the CURRENT set, then rewound to model an install that applied the
18
+ // renamed migration under its OLD filename: the schema change is present, but the ledger records the
19
+ // old name — exactly the state a boot between #311 and #316 left behind.
20
+ function dbAppliedUnderOldName(): DatabaseSync {
21
+ const db = new DatabaseSync(":memory:");
22
+ applyMigrationSet(db, readMigrationSetFromDisk());
23
+ db.prepare("UPDATE _urban_migrations SET name=? WHERE name=?").run(OLD_NAME, NEW_NAME);
24
+ return db;
25
+ }
26
+
27
+ const applied = (db: DatabaseSync, name: string): boolean =>
28
+ (db.prepare("SELECT 1 AS one FROM _urban_migrations WHERE name=?").get(name) as
29
+ | { one: number }
30
+ | undefined) !== undefined;
31
+
32
+ test("RENAMED_MIGRATIONS pins the 043->046 user_tasks_subject_title rename", () => {
33
+ assertEquals(RENAMED_MIGRATIONS.get(NEW_NAME), OLD_NAME);
34
+ });
35
+
36
+ test("#357 repro: re-applying the current set to a DB migrated under the old name aborts on duplicate column", () => {
37
+ const db = dbAppliedUnderOldName();
38
+ // The renamed migration is unapplied under its NEW name, so the runtime re-runs its
39
+ // `ALTER TABLE user_tasks ADD COLUMN subject_title` against a table that already has the column.
40
+ const err = assertThrows(() => applyMigrationSet(db, readMigrationSetFromDisk()));
41
+ assert(
42
+ /duplicate column name: subject_title/.test(err.message),
43
+ `expected a duplicate-column abort, got: ${err.message}`,
44
+ );
45
+ });
46
+
47
+ test("heal reconciles the ledger so the upgrade completes cleanly", () => {
48
+ const db = dbAppliedUnderOldName();
49
+
50
+ const healed = healMigrationLedger(db);
51
+ assertEquals(healed, [NEW_NAME], "the new filename was aliased into the ledger");
52
+ assert(applied(db, NEW_NAME), "046 is now recorded as applied");
53
+ assert(applied(db, OLD_NAME), "the historical 043 ledger row is left intact");
54
+
55
+ // With the ledger reconciled, the renamed migration is skipped and the boot-time upgrade is clean.
56
+ const newly = applyMigrationSet(db, readMigrationSetFromDisk());
57
+ assertEquals(newly, [], "no migration re-runs after the heal");
58
+ });
59
+
60
+ test("heal is idempotent and a no-op on a healthy DB", () => {
61
+ // A DB that applied the migration under its CURRENT name needs no healing.
62
+ const clean = new DatabaseSync(":memory:");
63
+ applyMigrationSet(clean, readMigrationSetFromDisk());
64
+ assertEquals(healMigrationLedger(clean), [], "clean install: nothing to heal");
65
+
66
+ // Healing twice changes nothing the second time.
67
+ const broken = dbAppliedUnderOldName();
68
+ assertEquals(healMigrationLedger(broken), [NEW_NAME]);
69
+ assertEquals(healMigrationLedger(broken), [], "second heal is a no-op");
70
+ });
71
+
72
+ test("heal is a no-op on a fresh DB with no migration ledger", () => {
73
+ const fresh = new DatabaseSync(":memory:");
74
+ assertEquals(healMigrationLedger(fresh), [], "no ledger table => nothing to heal");
75
+ });
@@ -0,0 +1,74 @@
1
+ // migrationHeal — recover installs broken by a migration that was RENAMED after it had already
2
+ // merged and applied (issue #357).
3
+ //
4
+ // The runtime (`@nanobpm/urban` applyMigrations) keys the `_urban_migrations` ledger by FILENAME, so
5
+ // a renamed migration is a *different* migration to the runner: it re-runs its DDL against a schema
6
+ // that already has the change, and a bare `ALTER TABLE ... ADD COLUMN` then aborts boot with
7
+ // "duplicate column". The direct hazard is now blocked forward by the immutability gate
8
+ // (`scripts/check-migrations.ts`), but installs that migrated under the OLD name *before* the rename
9
+ // landed are already stuck and need their ledger reconciled.
10
+ //
11
+ // The fix is a ledger-alias, NOT a schema change and NOT an edit to the renamed migration (editing a
12
+ // merged migration is itself forbidden by the immutability gate, and would silently no-op on every
13
+ // already-migrated DB anyway): if the old filename is recorded as applied, the new filename names the
14
+ // exact same forward-only change, so it is safe — and correct — to record the new filename as applied
15
+ // too. `healMigrationLedger` does that, guarded so it only ever fires on the broken state.
16
+ //
17
+ // `RENAMED_MIGRATIONS` is the single source of truth for known historical renames. It exists ONLY to
18
+ // heal the pre-gate past — the immutability gate prevents any new entry from ever being needed.
19
+ import type { DatabaseSync } from "node:sqlite";
20
+
21
+ const MIGRATIONS_TABLE = "_urban_migrations";
22
+
23
+ // new filename -> old filename, for merged migrations that were renamed before the immutability gate
24
+ // existed. Each entry names the SAME forward-only change under two prefixes, so aliasing the ledger
25
+ // from the old name to the new is lossless.
26
+ //
27
+ // - `046_user_tasks_subject_title.sql` was merged as `043_user_tasks_subject_title.sql` (#311) and
28
+ // renumbered to 046 (#316) to break a prefix collision with `043_pr_epic_phase.sql`. Any DB that
29
+ // booted between those two PRs applied it as 043 and now re-runs it as 046 → "duplicate column
30
+ // name: subject_title" (issue #357).
31
+ export const RENAMED_MIGRATIONS: ReadonlyMap<string, string> = new Map([
32
+ ["046_user_tasks_subject_title.sql", "043_user_tasks_subject_title.sql"],
33
+ ]);
34
+
35
+ /** Does the `_urban_migrations` ledger table exist? A fresh/never-migrated DB has nothing to heal. */
36
+ function ledgerExists(db: DatabaseSync): boolean {
37
+ return (
38
+ db
39
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?")
40
+ .get(MIGRATIONS_TABLE) !== undefined
41
+ );
42
+ }
43
+
44
+ function isApplied(db: DatabaseSync, name: string): boolean {
45
+ return db.prepare(`SELECT 1 AS one FROM ${MIGRATIONS_TABLE} WHERE name=?`).get(name) !== undefined;
46
+ }
47
+
48
+ /**
49
+ * Reconcile the migration ledger for known historical renames, in place. For every rename where the
50
+ * OLD filename is recorded as applied but the NEW one is not, record the new filename as applied
51
+ * (aliasing the ledger) so the runtime stops re-running the renamed migration.
52
+ *
53
+ * Idempotent and safe on every state:
54
+ * - old applied, new missing -> alias inserted (the broken install; the only case that acts)
55
+ * - both applied -> no-op (overlay-drift install carrying both files)
56
+ * - only new applied -> no-op (clean install that never saw the old name)
57
+ * - neither applied / no ledger-> no-op (fresh DB)
58
+ *
59
+ * @returns the new filenames that were aliased in (empty when nothing needed healing).
60
+ */
61
+ export function healMigrationLedger(db: DatabaseSync, now: () => Date = () => new Date()): string[] {
62
+ if (!ledgerExists(db)) return [];
63
+ const healed: string[] = [];
64
+ for (const [newName, oldName] of RENAMED_MIGRATIONS) {
65
+ if (isApplied(db, oldName) && !isApplied(db, newName)) {
66
+ db.prepare(`INSERT OR IGNORE INTO ${MIGRATIONS_TABLE} (name, applied_at) VALUES (?, ?)`).run(
67
+ newName,
68
+ now().toISOString(),
69
+ );
70
+ healed.push(newName);
71
+ }
72
+ }
73
+ return healed;
74
+ }