@nanobpm/nano-workforce 0.133.0 → 0.133.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/app/featureReadModel.test.ts +62 -17
- package/app/featureReadModel.ts +28 -9
- package/app/interEpicRegression.test.ts +3 -2
- package/app/plan.test.ts +5 -4
- package/app/plan.ts +26 -3
- package/app/planLowering.test.ts +2 -2
- package/app/plansReadModel.test.ts +38 -1
- package/app/service.ts +41 -16
- package/app/stage.ts +8 -3
- package/app/terminalReaderBehaviour.test.ts +289 -0
- package/app/terminalReaderGuard.test.ts +106 -0
- package/db/migrations/080_plan_read_model_derive_terminal.sql +75 -0
- package/db/migrations/081_feature_read_model_derive_terminal.sql +65 -0
- package/operations/listActivePrs.test.ts +2 -1
- package/operations/startEpicSet.admission.integration.test.ts +3 -2
- package/operations/startPlanFanout.admission.integration.test.ts +2 -1
- package/package.json +1 -1
- package/test/trackingViews.ts +11 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [0.133.1](https://github.com/nanobpm/nano-workforce/compare/v0.133.0...v0.133.1) (2026-08-24)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **read-model:** migrate remaining terminal-edge readers to derived_status ([#503](https://github.com/nanobpm/nano-workforce/issues/503)) ([#508](https://github.com/nanobpm/nano-workforce/issues/508)) ([663f113](https://github.com/nanobpm/nano-workforce/commit/663f1135a79a14436565f2a6b2320ad62bb0db6b))
|
|
6
|
+
|
|
1
7
|
## [0.133.0](https://github.com/nanobpm/nano-workforce/compare/v0.132.0...v0.133.0) (2026-08-24)
|
|
2
8
|
|
|
3
9
|
### Features
|
|
@@ -10,12 +10,12 @@
|
|
|
10
10
|
// emitted from the ONE `featureReadModel` declaration, which ALSO drives the TS via `fnFor`. This
|
|
11
11
|
// suite therefore guards THREE things:
|
|
12
12
|
//
|
|
13
|
-
// 1. DRIFT GUARD — migration
|
|
13
|
+
// 1. DRIFT GUARD — migration 080 embeds each derived column's SQL VERBATIM from
|
|
14
14
|
// `featureReadModel.sqlSelectFor(...)`, so the checked-in VIEW cannot drift from the declaration.
|
|
15
15
|
// 2. FRAMEWORK PARITY GUARD — `assertReadModelParity` proves the SQL and TS lowerings the ONE
|
|
16
16
|
// declaration compiles to agree (the role the old hand-written lockstep test played, now
|
|
17
17
|
// framework-owned).
|
|
18
|
-
// 3. END-TO-END BEHAVIOUR on the REAL migration VIEW (
|
|
18
|
+
// 3. END-TO-END BEHAVIOUR on the REAL migration VIEW (080 applied to an in-memory DB): the full
|
|
19
19
|
// status × open-task matrix vs the model-derived oracle, the stale-stored-column ignore, the
|
|
20
20
|
// reconciler `status`-bypass, the #422 answered-escalation drift, and the page binding.
|
|
21
21
|
import { readFileSync } from "node:fs";
|
|
@@ -31,11 +31,14 @@ import { deriveListBucket, deriveStage } from "./stage.ts";
|
|
|
31
31
|
const MIG = (name: string) => readFileSync(fileURLToPath(new URL(`../db/migrations/${name}`, import.meta.url)), "utf8");
|
|
32
32
|
const PAGE = (name: string) => JSON.parse(readFileSync(fileURLToPath(new URL(`../pages/${name}`, import.meta.url)), "utf8"));
|
|
33
33
|
|
|
34
|
-
const
|
|
34
|
+
const MIGRATION_LATEST = "081_feature_read_model_derive_terminal.sql";
|
|
35
35
|
|
|
36
36
|
// The base `feature_runs` shape the VIEW reads, plus the `user_tasks` inbox (034) the `attention`
|
|
37
|
-
// derivation `EXISTS`-reads
|
|
38
|
-
//
|
|
37
|
+
// derivation `EXISTS`-reads, plus a stand-in for the managed `feature_runs__tracking` derived VIEW
|
|
38
|
+
// (ADR-0065) the read model now reads its terminal-folded `derived_status` off. The stored derived
|
|
39
|
+
// columns are present precisely so the tests can seed STALE values and prove the VIEW ignores them;
|
|
40
|
+
// `derived_status_override` lets a test model the reconciler's derive edge (a terminated instance ⇒
|
|
41
|
+
// `abandoned` while base `status` stays frozen).
|
|
39
42
|
function viewDb(): DatabaseSync {
|
|
40
43
|
const db = new DatabaseSync(":memory:");
|
|
41
44
|
db.exec(
|
|
@@ -43,14 +46,23 @@ function viewDb(): DatabaseSync {
|
|
|
43
46
|
feature_key TEXT PRIMARY KEY, repo TEXT, issue_number INTEGER, issue_url TEXT, title TEXT,
|
|
44
47
|
base_branch TEXT, status TEXT, process_key TEXT, pr_key TEXT, converge INTEGER, auto_merge INTEGER,
|
|
45
48
|
outcome TEXT, delivery_label TEXT, acknowledged_at TEXT, created_at TEXT, updated_at TEXT,
|
|
46
|
-
stage TEXT, stage_state TEXT, stage_skipped TEXT, attention TEXT, list_bucket TEXT
|
|
49
|
+
stage TEXT, stage_state TEXT, stage_skipped TEXT, attention TEXT, list_bucket TEXT,
|
|
50
|
+
derived_status_override TEXT);`,
|
|
47
51
|
);
|
|
48
52
|
db.exec(
|
|
49
53
|
`CREATE TABLE user_tasks (
|
|
50
54
|
user_task_key TEXT PRIMARY KEY, element_id TEXT NOT NULL, subject_type TEXT NOT NULL,
|
|
51
55
|
subject_key TEXT NOT NULL);`,
|
|
52
56
|
);
|
|
53
|
-
|
|
57
|
+
// Stand-in for the managed `feature_runs__tracking` VIEW urban provisions at mount: re-exports
|
|
58
|
+
// `feature_runs.*` plus the terminal-folded `derived_status` the read model (migration 080) reads. A
|
|
59
|
+
// test seeds `derived_status_override` to model the reconciler's derive edge; absent, it falls through
|
|
60
|
+
// to the base `status`, exactly as the real VIEW's `ELSE base.status` branch does.
|
|
61
|
+
db.exec(
|
|
62
|
+
`CREATE VIEW feature_runs__tracking AS
|
|
63
|
+
SELECT f.*, COALESCE(f.derived_status_override, f.status) AS derived_status FROM feature_runs f;`,
|
|
64
|
+
);
|
|
65
|
+
db.exec(MIG(MIGRATION_LATEST));
|
|
54
66
|
return db;
|
|
55
67
|
}
|
|
56
68
|
|
|
@@ -126,22 +138,22 @@ function parityDb(db: DatabaseSync): ParityDb {
|
|
|
126
138
|
};
|
|
127
139
|
}
|
|
128
140
|
|
|
129
|
-
test("DRIFT GUARD: migration
|
|
130
|
-
const sql = MIG(
|
|
141
|
+
test("DRIFT GUARD: migration 080 embeds each derived column VERBATIM from featureReadModel.sqlSelectFor (the VIEW cannot drift from the declaration)", () => {
|
|
142
|
+
const sql = MIG(MIGRATION_LATEST);
|
|
131
143
|
for (const col of FEATURE_READ_MODEL_DERIVED) {
|
|
132
144
|
const emitted = featureReadModel.sqlSelectFor(col, { baseAlias: FEATURE_READ_MODEL_BASE_ALIAS });
|
|
133
145
|
assert(
|
|
134
146
|
sql.includes(`${emitted} AS ${col}`),
|
|
135
|
-
`migration ${
|
|
147
|
+
`migration ${MIGRATION_LATEST} no longer embeds the declaration's SQL for "${col}" — regenerate it ` +
|
|
136
148
|
`from featureReadModel (or add a new superseding migration). Expected to contain:\n ${emitted} AS ${col}`,
|
|
137
149
|
);
|
|
138
150
|
}
|
|
139
151
|
// The VIEW is a DROP+CREATE that supersedes 073/075, and keeps every base column as an aliased
|
|
140
152
|
// pass-through so the static pages↔schema contract guard still sees them.
|
|
141
|
-
assert(/DROP VIEW IF EXISTS feature_read_model;/.test(sql), "
|
|
142
|
-
assert(/CREATE VIEW feature_read_model AS/.test(sql), "
|
|
153
|
+
assert(/DROP VIEW IF EXISTS feature_read_model;/.test(sql), "080 must DROP the superseded VIEW first");
|
|
154
|
+
assert(/CREATE VIEW feature_read_model AS/.test(sql), "080 must (re)create feature_read_model");
|
|
143
155
|
for (const base of ["feature_key", "status", "pr_key", "converge", "auto_merge", "acknowledged_at", "title", "repo"]) {
|
|
144
|
-
assert(sql.includes(`fr.${base} AS ${base}`), `
|
|
156
|
+
assert(sql.includes(`fr.${base} AS ${base}`), `080 must pass base column "${base}" through the VIEW`);
|
|
145
157
|
}
|
|
146
158
|
});
|
|
147
159
|
|
|
@@ -157,7 +169,11 @@ test("FRAMEWORK PARITY GUARD: featureReadModel's SQL and TS lowerings agree over
|
|
|
157
169
|
const userTasks =
|
|
158
170
|
openTask && el !== null ? [{ subject_type: "feature", subject_key: "self", element_id: el }] : [];
|
|
159
171
|
samples.push({
|
|
160
|
-
|
|
172
|
+
// The status-classifying derivations read the tracking VIEW's terminal-folded
|
|
173
|
+
// `derived_status`; for a live (non-terminated) run it equals the base transient, so
|
|
174
|
+
// parity samples set it from `status`. (The parity guard's fixture table is named for the
|
|
175
|
+
// model's baseTable, `feature_runs__tracking`.)
|
|
176
|
+
baseRow: { feature_key: "self", status, derived_status: status, pr_key, converge, auto_merge, acknowledged_at },
|
|
161
177
|
projections: { user_tasks: userTasks },
|
|
162
178
|
});
|
|
163
179
|
}
|
|
@@ -172,7 +188,7 @@ test("FRAMEWORK PARITY GUARD: featureReadModel's SQL and TS lowerings agree over
|
|
|
172
188
|
db.close();
|
|
173
189
|
});
|
|
174
190
|
|
|
175
|
-
test("the migration
|
|
191
|
+
test("the migration 080 VIEW derives stage/stage_state/stage_skipped/attention EXACTLY like deriveStage, over every status × converge/auto_merge/pr_key × open-task combination", () => {
|
|
176
192
|
const db = viewDb();
|
|
177
193
|
const cases: Array<{ key: string; run: SampleRun; hasOpenBlockedTask: boolean; hasOpenEscalationTask: boolean }> = [];
|
|
178
194
|
let i = 0;
|
|
@@ -217,7 +233,7 @@ test("the migration 076 VIEW derives stage/stage_state/stage_skipped/attention E
|
|
|
217
233
|
}
|
|
218
234
|
});
|
|
219
235
|
|
|
220
|
-
test("the migration
|
|
236
|
+
test("the migration 080 VIEW derives list_bucket EXACTLY like deriveListBucket (history iff terminal AND acknowledged)", () => {
|
|
221
237
|
const db = viewDb();
|
|
222
238
|
let i = 0;
|
|
223
239
|
const cases: Array<{ key: string; status: string; ackAt: string | null }> = [];
|
|
@@ -237,7 +253,7 @@ test("the migration 076 VIEW derives list_bucket EXACTLY like deriveListBucket (
|
|
|
237
253
|
}
|
|
238
254
|
});
|
|
239
255
|
|
|
240
|
-
test("the migration
|
|
256
|
+
test("the migration 080 VIEW IGNORES any stale STORED projection columns — it reads only from status et al.", () => {
|
|
241
257
|
const db = viewDb();
|
|
242
258
|
// A merged run whose STORED columns lie (frozen from when it was `running`). The VIEW must re-derive.
|
|
243
259
|
addRun(db, "o/r#stale", {
|
|
@@ -312,6 +328,35 @@ test("RED/GREEN GUARD: a RAW-datasource feature_runs.status write (the instanceT
|
|
|
312
328
|
assertEquals(row.list_bucket, deriveListBucket("abandoned", null));
|
|
313
329
|
});
|
|
314
330
|
|
|
331
|
+
test("RED/GREEN #503: a DERIVE-ONLY terminated run (base status frozen at 'running', derived_status='abandoned') renders Done/failed, not wedged 'Implementing'", () => {
|
|
332
|
+
// ADR-0065 (urban 0.81.0): cancel/terminate is DERIVE-ONLY — the reconciler feeds urban's projection
|
|
333
|
+
// and `feature_runs__tracking.derived_status` recomputes `abandoned` on READ; it does NOT write the
|
|
334
|
+
// terminal onto the base `feature_runs.status` column. So the base row stays frozen at its last
|
|
335
|
+
// transient (`running`) while the run is really terminated. Before 080 the read model classified off
|
|
336
|
+
// the frozen base `status` and rendered the dead run "Implementing" on the Feature history grid
|
|
337
|
+
// forever (the #503 phantom). 080 reads the terminal-folded `derived_status`, so it renders Done/
|
|
338
|
+
// failed with no worker write.
|
|
339
|
+
const db = viewDb();
|
|
340
|
+
// Seed a run whose engine instance was terminated out-of-band: base status still `running`, but the
|
|
341
|
+
// derive edge reports `abandoned` (modelled via the feature_runs__tracking stand-in's override).
|
|
342
|
+
addRun(db, "o/r#term", {
|
|
343
|
+
status: "running",
|
|
344
|
+
stored: { stage: "Implementing", stage_state: undefined, attention: "⚠", list_bucket: "active" },
|
|
345
|
+
});
|
|
346
|
+
assertEquals(projection(db, "o/r#term").stage, "Implementing", "precondition: the live transient renders Implementing");
|
|
347
|
+
|
|
348
|
+
db.prepare("UPDATE feature_runs SET derived_status_override = 'abandoned' WHERE feature_key = ?").run("o/r#term");
|
|
349
|
+
|
|
350
|
+
const row = projection(db, "o/r#term");
|
|
351
|
+
const oracle = deriveStage({ status: "abandoned", pr_key: null, converge: 0, auto_merge: 0 });
|
|
352
|
+
assertEquals(row.stage, "Done", "a derive-only terminated run is Done, not wedged at Implementing (the #503 phantom)");
|
|
353
|
+
assertEquals(row.stage, oracle.stage);
|
|
354
|
+
assertEquals(row.stage_state, "failed", "the derived terminal renders a FAILED state (was frozen NULL/Implementing)");
|
|
355
|
+
assertEquals(row.stage_state, oracle.state);
|
|
356
|
+
assertEquals(row.attention, null, "the stale ⚠ badge is gone once the run is terminated");
|
|
357
|
+
assertEquals(row.list_bucket, "active", "a just-cancelled run sits in Active until dismissed");
|
|
358
|
+
});
|
|
359
|
+
|
|
315
360
|
test("the Feature page binds the derived feature_read_model VIEW (not the raw feature_runs table)", () => {
|
|
316
361
|
// `feature.page.json`'s runs grid is the ONLY thing making the UI consume the derived projection.
|
|
317
362
|
const page = PAGE("feature.page.json");
|
package/app/featureReadModel.ts
CHANGED
|
@@ -47,20 +47,38 @@ export const STAGE_DONE_STATUSES: readonly string[] = ["merged", "converged", "b
|
|
|
47
47
|
* promote this to the framework's canonical `urban_open_user_tasks` projection.) */
|
|
48
48
|
export const USER_TASKS_PROJECTION = "user_tasks";
|
|
49
49
|
|
|
50
|
-
/**
|
|
51
|
-
|
|
50
|
+
/** The base table the read model reads: the auto-provisioned `feature_runs__tracking` derived VIEW
|
|
51
|
+
* (ADR-0065, urban 0.81.0), NOT the raw `feature_runs` table. The VIEW re-exports `feature_runs.*`
|
|
52
|
+
* plus a `derived_status` column that folds the `instanceTracking` reconciler's terminal edge
|
|
53
|
+
* (out-of-band terminate / in-app cancel → `abandoned`) over the worker-owned transient `status`. The
|
|
54
|
+
* status-classifying derivations below read `derived_status`, so a terminated run renders `Done`/
|
|
55
|
+
* `failed` instead of freezing at its last transient (`Implementing` forever — issue #503). The
|
|
56
|
+
* non-status base columns (`pr_key`/`converge`/`auto_merge`/`acknowledged_at`/`feature_key`) come off
|
|
57
|
+
* the same VIEW's pass-through of `base.*`. */
|
|
58
|
+
export const FEATURE_READ_MODEL_BASE_TABLE = "feature_runs__tracking";
|
|
52
59
|
|
|
53
|
-
/** The
|
|
60
|
+
/** The effective-status column the status-classifying derivations read: the tracking VIEW's ADR-0065
|
|
61
|
+
* `derived_status` (terminal-folded), NOT the frozen base `status`. Single source of truth for the
|
|
62
|
+
* column name so the derivations can't drift from it. */
|
|
63
|
+
export const EFFECTIVE_STATUS_COLUMN = "derived_status";
|
|
64
|
+
|
|
65
|
+
/** `derived_status IN (…)` as a closed-DSL predicate: an OR of equalities over the tracking VIEW's
|
|
66
|
+
* terminal-folded effective status. */
|
|
67
|
+
const statusIn = (...statuses: readonly string[]): Expr =>
|
|
68
|
+
or(...statuses.map((s) => eq(col(EFFECTIVE_STATUS_COLUMN), lit(s))));
|
|
69
|
+
|
|
70
|
+
/** The terminal tier — the row's effective (terminal-folded) status is one of the 6 `Done` statuses. */
|
|
54
71
|
const isDone: Expr = statusIn(...STAGE_DONE_STATUSES);
|
|
55
72
|
|
|
56
73
|
/** The canonical pipeline `stage`. TOTAL over all 11 statuses. Terminal → `Done`; else `converging`
|
|
57
74
|
* → `Converging`; else a raised PR (`pr_key` set, mirroring `(pr_key ?? "") !== ""`) or `opened` →
|
|
58
|
-
* `PR open`; else a live/parked implementation status → `Implementing`; else `Requested`.
|
|
75
|
+
* `PR open`; else a live/parked implementation status → `Implementing`; else `Requested`. Classifies
|
|
76
|
+
* on the terminal-folded `derived_status` so a cancelled/terminated run is `Done`, not frozen. */
|
|
59
77
|
const stage: Expr = caseWhen(
|
|
60
78
|
[
|
|
61
79
|
when(isDone, lit("Done")),
|
|
62
|
-
when(eq(col(
|
|
63
|
-
when(or(neq(col("pr_key"), lit("")), eq(col(
|
|
80
|
+
when(eq(col(EFFECTIVE_STATUS_COLUMN), lit("converging")), lit("Converging")),
|
|
81
|
+
when(or(neq(col("pr_key"), lit("")), eq(col(EFFECTIVE_STATUS_COLUMN), lit("opened"))), lit("PR open")),
|
|
64
82
|
when(statusIn("running", "escalated", "awaiting_operator"), lit("Implementing")),
|
|
65
83
|
],
|
|
66
84
|
lit("Requested"),
|
|
@@ -68,11 +86,12 @@ const stage: Expr = caseWhen(
|
|
|
68
86
|
|
|
69
87
|
/** The active stage's render state in the `kind:"pipeline"` column's vocabulary: `ok` (merged/
|
|
70
88
|
* converged), `blocked` (terminal blocked), `failed` (failed/skipped/abandoned), else NULL (in
|
|
71
|
-
* progress). A pure function of `
|
|
89
|
+
* progress). A pure function of the terminal-folded `derived_status`, so a terminated run renders a
|
|
90
|
+
* terminal `failed` state instead of a frozen NULL. */
|
|
72
91
|
const stageState: Expr = caseWhen(
|
|
73
92
|
[
|
|
74
93
|
when(statusIn("merged", "converged"), lit("ok")),
|
|
75
|
-
when(eq(col(
|
|
94
|
+
when(eq(col(EFFECTIVE_STATUS_COLUMN), lit("blocked")), lit("blocked")),
|
|
76
95
|
when(statusIn("failed", "skipped", "abandoned"), lit("failed")),
|
|
77
96
|
],
|
|
78
97
|
lit(null),
|
|
@@ -136,7 +155,7 @@ export type FeatureReadModelDerivedColumn = (typeof FEATURE_READ_MODEL_DERIVED)[
|
|
|
136
155
|
*/
|
|
137
156
|
export const featureReadModel: ReadModel = defineReadModel({
|
|
138
157
|
name: "feature_read_model",
|
|
139
|
-
baseTable:
|
|
158
|
+
baseTable: FEATURE_READ_MODEL_BASE_TABLE,
|
|
140
159
|
selectBaseColumns: false,
|
|
141
160
|
derive: {
|
|
142
161
|
stage,
|
|
@@ -18,6 +18,7 @@ import { resetDefaultBranchCache } from "./github.ts";
|
|
|
18
18
|
import type { PlanDep } from "./plan.ts";
|
|
19
19
|
import { EpicSetValidationError, validateEpicSet } from "./plan.ts";
|
|
20
20
|
import { capabilityProbeForEdge, deriveEpicSchedule, lowerAdmittedSet } from "./planLowering.ts";
|
|
21
|
+
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
21
22
|
import {
|
|
22
23
|
type GithubRelease,
|
|
23
24
|
matchCapability,
|
|
@@ -80,7 +81,7 @@ function makeApp(seedPlans: Record<string, unknown>[] = []) {
|
|
|
80
81
|
};
|
|
81
82
|
};
|
|
82
83
|
const app = {
|
|
83
|
-
data: { table },
|
|
84
|
+
data: { table: withTrackingViews(table) },
|
|
84
85
|
engine: {
|
|
85
86
|
createInstance: (req: { processDefinitionId: string; variables?: Record<string, unknown> }) => {
|
|
86
87
|
started.push(req);
|
|
@@ -210,7 +211,7 @@ function makeData() {
|
|
|
210
211
|
return Promise.resolve({ processInstanceKey: `PI-${started.length}` });
|
|
211
212
|
},
|
|
212
213
|
} as unknown as EngineClient;
|
|
213
|
-
const data = { table } as unknown as DataLayer;
|
|
214
|
+
const data = { table: withTrackingViews(table) } as unknown as DataLayer;
|
|
214
215
|
return { data, engine, tables, started };
|
|
215
216
|
}
|
|
216
217
|
|
package/app/plan.test.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// is not a positive integer, so the loop is always bounded.
|
|
7
7
|
import { after, test } from "node:test";
|
|
8
8
|
import { assertEquals, assertRejects, assertThrows } from "#test-assert";
|
|
9
|
+
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
9
10
|
import { positiveIntEnv } from "./plan.ts";
|
|
10
11
|
|
|
11
12
|
const KEY = "NANO_PLAN_REVIEW_ROUNDS_TEST";
|
|
@@ -126,8 +127,8 @@ test("re-plan of a finished issue clears stale plan_reviews rows", async () => {
|
|
|
126
127
|
plan_task_deps: { rows: [], key: "plan_key" },
|
|
127
128
|
};
|
|
128
129
|
const data = {
|
|
129
|
-
table: (name: string, key: string) =>
|
|
130
|
-
memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
130
|
+
table: withTrackingViews((name: string, key: string) =>
|
|
131
|
+
memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
131
132
|
} as any;
|
|
132
133
|
const engine = {
|
|
133
134
|
createInstance: () => Promise.resolve({ processInstanceKey: "PI-1" }),
|
|
@@ -146,8 +147,8 @@ test("re-plan of a finished issue clears stale plan_reviews rows", async () => {
|
|
|
146
147
|
|
|
147
148
|
function memData(stores: Record<string, { rows: any[]; key: string }>) {
|
|
148
149
|
return {
|
|
149
|
-
table: (name: string, key: string) =>
|
|
150
|
-
memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
150
|
+
table: withTrackingViews((name: string, key: string) =>
|
|
151
|
+
memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
151
152
|
} as any;
|
|
152
153
|
}
|
|
153
154
|
|
package/app/plan.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
fetchDefaultBranch,
|
|
22
22
|
fetchIssueTitle,
|
|
23
23
|
} from "./github.ts";
|
|
24
|
+
import { derivedTrackingTable } from "./instanceTracking.ts";
|
|
24
25
|
import { clearExclusions } from "./mergeExclusion.ts";
|
|
25
26
|
import type { ReadinessProbe } from "./readiness.ts";
|
|
26
27
|
import { clearTaskDeltas } from "./taskDelta.ts";
|
|
@@ -197,6 +198,17 @@ export type PlanTaskStatus = typeof PLAN_TASK_STATUSES[number];
|
|
|
197
198
|
* offers Dismiss without a poller pass. The pure helpers stay the acknowledge-epic guard and the VIEW's
|
|
198
199
|
* test oracle (app/plansReadModel.test.ts). */
|
|
199
200
|
export const plans = (data: DataLayer) => data.table<Plan>("plans", "plan_key");
|
|
201
|
+
/** A plan row as seen through its derived tracking VIEW (`plans__tracking`): the base columns plus
|
|
202
|
+
* urban's ADR-0065 `derived_status`, which folds the reconciler's terminal edge (out-of-band
|
|
203
|
+
* terminate / in-app cancel → `abandoned`) over the worker-owned transient. */
|
|
204
|
+
type TrackedPlan = Plan & { derived_status: string };
|
|
205
|
+
/** Read-only accessor over the plan derived tracking VIEW. Use this — and read `derived_status`, not
|
|
206
|
+
* `status` — for any terminal/active classification (the shared-base admission filter, the
|
|
207
|
+
* epic-admission idempotency gate), so an out-of-band-terminated epic (whose base row is still
|
|
208
|
+
* `planning`/`dispatched`) is correctly seen as `abandoned`. Worker-written terminals (`done`/
|
|
209
|
+
* `failed`) pass through unchanged. Writes stay on `plans`. */
|
|
210
|
+
export const plansTracking = (data: DataLayer) =>
|
|
211
|
+
derivedTrackingTable<TrackedPlan>(data, "plans", "plan_key");
|
|
200
212
|
export const planTasks = (data: DataLayer) => data.table<PlanTask>("plan_tasks", "id");
|
|
201
213
|
|
|
202
214
|
/** One dependency edge in the plan DAG (issue #20): `task_id` waits for `depends_on_task_id`.
|
|
@@ -590,8 +602,12 @@ export async function findActivePlansByBase(
|
|
|
590
602
|
repo: string,
|
|
591
603
|
base: string,
|
|
592
604
|
): Promise<Plan[]> {
|
|
593
|
-
const rows = await
|
|
594
|
-
|
|
605
|
+
const rows = await plansTracking(data).find({ repo, base_branch: base });
|
|
606
|
+
// ADR-0065: classify "active" on the DERIVED terminal edge, not the base transient — an epic whose
|
|
607
|
+
// engine instance was terminated out-of-band (or by an ordinary in-app cancel) keeps its base
|
|
608
|
+
// `status` frozen at `planning`/`dispatched` but reads `abandoned` on `plans__tracking.derived_status`.
|
|
609
|
+
// Reading the base `status` here counted a dead epic as ACTIVE and raised a false same-repo conflict.
|
|
610
|
+
return rows.filter((p) => !PLAN_TERMINAL_STATUSES.some((s) => s === p.derived_status));
|
|
595
611
|
}
|
|
596
612
|
|
|
597
613
|
/** Options gating the confirm-default (rule 3) and shared-base (rule 4) admission rules. Both
|
|
@@ -926,7 +942,14 @@ export async function startPlan(
|
|
|
926
942
|
}
|
|
927
943
|
const table = plans(data);
|
|
928
944
|
const existing = await table.get(parsed.planKey);
|
|
929
|
-
|
|
945
|
+
// ADR-0065: classify "already running" on the DERIVED terminal edge, not the base transient. An epic
|
|
946
|
+
// whose engine instance was terminated out-of-band (or by an ordinary in-app cancel — derive-only
|
|
947
|
+
// under urban 0.81.0) has a base row frozen at `planning`/`dispatched` but a
|
|
948
|
+
// `plans__tracking.derived_status` of `abandoned`; reading the base `status` here wedged a cancelled
|
|
949
|
+
// epic `alreadyRunning` (the `submitPr`-wedge twin). Route the idempotency gate through the derived
|
|
950
|
+
// view so a terminated epic is correctly seen terminal and RE-ADMITTABLE.
|
|
951
|
+
const trackedExisting = existing ? await plansTracking(data).get(parsed.planKey) : undefined;
|
|
952
|
+
if (trackedExisting && !PLAN_TERMINAL_STATUSES.some((s) => s === trackedExisting.derived_status)) {
|
|
930
953
|
return { planKey: parsed.planKey, alreadyRunning: true };
|
|
931
954
|
}
|
|
932
955
|
const base = normalizeBaseBranch(baseBranch);
|
package/app/planLowering.test.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { assert, assertEquals } from "#test-assert";
|
|
|
8
8
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
9
9
|
import type { PlanDep } from "./plan.ts";
|
|
10
10
|
import { capabilityProbeForEdge, deriveEpicSchedule, lowerAdmittedSet } from "./planLowering.ts";
|
|
11
|
-
|
|
11
|
+
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
12
12
|
const edge = (consumer: string, producer: string, pkg = "@scope/pkg", capRef = producer): PlanDep => ({
|
|
13
13
|
plan_key: consumer,
|
|
14
14
|
depends_on_plan_key: producer,
|
|
@@ -54,7 +54,7 @@ function makeData() {
|
|
|
54
54
|
return Promise.resolve({ processInstanceKey: `PI-${started.length}` });
|
|
55
55
|
},
|
|
56
56
|
} as unknown as EngineClient;
|
|
57
|
-
const data = { table } as unknown as DataLayer;
|
|
57
|
+
const data = { table: withTrackingViews(table) } as unknown as DataLayer;
|
|
58
58
|
return { data, engine, tables, started };
|
|
59
59
|
}
|
|
60
60
|
|
|
@@ -34,19 +34,32 @@ function viewDb(): DatabaseSync {
|
|
|
34
34
|
plan_key TEXT PRIMARY KEY, repo TEXT, issue_number INTEGER, issue_url TEXT, title TEXT,
|
|
35
35
|
status TEXT, task_count INTEGER, process_key TEXT, outcome TEXT, created_at TEXT,
|
|
36
36
|
updated_at TEXT, epic_phase TEXT, base_branch TEXT, wait_gate_label TEXT, bound_artifacts TEXT,
|
|
37
|
-
promotion_pr TEXT, promotion_state TEXT, acknowledged_at TEXT, list_bucket TEXT, ack_open INTEGER
|
|
37
|
+
promotion_pr TEXT, promotion_state TEXT, acknowledged_at TEXT, list_bucket TEXT, ack_open INTEGER,
|
|
38
|
+
derived_status_override TEXT);
|
|
38
39
|
CREATE TABLE plan_tasks (
|
|
39
40
|
id INTEGER PRIMARY KEY, plan_key TEXT, task_index INTEGER, task_id TEXT, title TEXT,
|
|
40
41
|
prompt TEXT, status TEXT, pr_key TEXT, summary TEXT, created_at TEXT, updated_at TEXT,
|
|
41
42
|
wave INTEGER, open_question TEXT, answer TEXT, draft_pr_key TEXT, corr_key TEXT);
|
|
42
43
|
CREATE TABLE pull_requests (pr_key TEXT PRIMARY KEY, url TEXT, status TEXT, process_key TEXT);`,
|
|
43
44
|
);
|
|
45
|
+
// Stand-in for the managed `plans__tracking` VIEW urban provisions at mount (ADR-0065): re-exports
|
|
46
|
+
// `plans.*` plus the `derived_status` the terminal-edge reader (migration 079) reads. A test seeds
|
|
47
|
+
// `derived_status_override` to model the reconciler's derive edge (a terminated instance ⇒
|
|
48
|
+
// `abandoned` while base `status` stays frozen); absent, it falls through to the base `status`, exactly
|
|
49
|
+
// as the real VIEW's `ELSE base.status` branch does.
|
|
50
|
+
db.exec(
|
|
51
|
+
`CREATE VIEW plans__tracking AS
|
|
52
|
+
SELECT p.*, COALESCE(p.derived_status_override, p.status) AS derived_status FROM plans p;`,
|
|
53
|
+
);
|
|
44
54
|
db.exec(MIG("059_plan_wave_summary.sql"));
|
|
45
55
|
db.exec(MIG("060_plan_wave_rollup.sql"));
|
|
46
56
|
db.exec(MIG("061_plan_delivery_rollup.sql"));
|
|
47
57
|
// 074 redefines plan_read_model to DERIVE list_bucket/ack_open from status + acknowledged_at + the
|
|
48
58
|
// derived plan_delivery signal (issue #439), instead of reading the denormalised base columns.
|
|
49
59
|
db.exec(MIG("074_plan_read_model_derive_bucket.sql"));
|
|
60
|
+
// 079 re-points plan_read_model's status/bucket derivations at the derived plans__tracking VIEW so a
|
|
61
|
+
// terminated (derive-only `abandoned`) epic drops out of Active (issue #503).
|
|
62
|
+
db.exec(MIG("080_plan_read_model_derive_terminal.sql"));
|
|
50
63
|
return db;
|
|
51
64
|
}
|
|
52
65
|
|
|
@@ -367,3 +380,27 @@ test("RED/GREEN GUARD: a RAW-datasource plans.status write (the instanceTracking
|
|
|
367
380
|
assertEquals(b.ack_open, epicIsAcknowledgeable("abandoned", b.delivery) ? 1 : 0);
|
|
368
381
|
assertEquals(b.ack_open, 0, "no phantom Dismiss on a reconciler-cancelled epic");
|
|
369
382
|
});
|
|
383
|
+
|
|
384
|
+
test("RED/GREEN #503: a DERIVE-ONLY terminated epic (base status frozen, derived_status='abandoned') drops out of Active", () => {
|
|
385
|
+
// ADR-0065 (urban 0.81.0): cancel/terminate is DERIVE-ONLY — the reconciler feeds urban's projection
|
|
386
|
+
// and `plans__tracking.derived_status` recomputes `abandoned` on READ; it does NOT write the terminal
|
|
387
|
+
// onto the base `plans.status` column. So the base row stays frozen at its last transient
|
|
388
|
+
// (`dispatched`) while the epic is really terminated. Before 079 `plan_read_model` bucketed off the
|
|
389
|
+
// frozen base column and rendered the dead epic ACTIVE on the epic index/detail forever (the #503 /
|
|
390
|
+
// #497 phantom). 079 reads the effective status off `plans__tracking`, so it drops to History.
|
|
391
|
+
const db = viewDb();
|
|
392
|
+
// Seed a plan whose engine instance was terminated out-of-band: base status still `dispatched`, but
|
|
393
|
+
// the derive edge reports `abandoned` (modelled via the plans__tracking stand-in's override column).
|
|
394
|
+
db.prepare(
|
|
395
|
+
"INSERT INTO plans (plan_key, repo, issue_number, issue_url, status, task_count, updated_at, acknowledged_at, list_bucket, derived_status_override) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
396
|
+
).run("o/r#term", "o/r", 7, "https://gh/o/r#term", "dispatched", 0, "2026-01-01T00:00:00Z", null, "active", "abandoned");
|
|
397
|
+
|
|
398
|
+
const r = db
|
|
399
|
+
.prepare("SELECT status, list_bucket, ack_open FROM plan_read_model WHERE plan_key = ?")
|
|
400
|
+
.get("o/r#term") as { status: string; list_bucket: string; ack_open: number };
|
|
401
|
+
|
|
402
|
+
assertEquals(r.status, "abandoned", "plan_read_model surfaces the DERIVED terminal, not the frozen base transient");
|
|
403
|
+
assertEquals(r.list_bucket, "history", "a derive-only terminated epic is filed under History, not wedged Active");
|
|
404
|
+
assertEquals(r.list_bucket, deriveEpicBucket("abandoned", null, null));
|
|
405
|
+
assertEquals(r.ack_open, 0, "no phantom Dismiss on a derive-only terminated epic");
|
|
406
|
+
});
|
package/app/service.ts
CHANGED
|
@@ -511,7 +511,14 @@ export async function submitPr(
|
|
|
511
511
|
) {
|
|
512
512
|
const table = prs(data);
|
|
513
513
|
const existing = await table.get(parsed.prKey);
|
|
514
|
-
|
|
514
|
+
// ADR-0065: classify "already running" on the DERIVED terminal edge, not the base transient. A
|
|
515
|
+
// PR whose engine instance was terminated out-of-band (or by an ordinary in-app cancel — derive-only
|
|
516
|
+
// under urban 0.81.0) has a base row frozen at its last worker transient (e.g. `converging`) but a
|
|
517
|
+
// `pull_requests__tracking.derived_status` of `abandoned`; reading the base `status` here would wedge
|
|
518
|
+
// it `alreadyRunning` forever (the #497 phantom). Route the idempotency gate through the derived view
|
|
519
|
+
// so a cancelled PR is correctly seen terminal and RESUBMITTABLE.
|
|
520
|
+
const trackedExisting = existing ? await prsTracking(data).get(parsed.prKey) : undefined;
|
|
521
|
+
if (trackedExisting && !TERMINAL_STATUSES.includes(trackedExisting.derived_status)) {
|
|
515
522
|
return { prKey: parsed.prKey, alreadyRunning: true };
|
|
516
523
|
}
|
|
517
524
|
|
|
@@ -747,9 +754,17 @@ export interface ActivePr {
|
|
|
747
754
|
* BOTH loops' escalations uniformly. Once answered the row leaves `open`, so `openEscalation`
|
|
748
755
|
* derives back to null. */
|
|
749
756
|
export async function activePrs(data: DataLayer): Promise<ActivePr[]> {
|
|
750
|
-
const all = await
|
|
757
|
+
const all = await prsTracking(data).all();
|
|
751
758
|
const active = all
|
|
752
|
-
.
|
|
759
|
+
// ADR-0065: classify "in flight" on the DERIVED terminal edge, not the base transient. A PR whose
|
|
760
|
+
// engine instance was terminated out-of-band (or by an ordinary in-app cancel — derive-only under
|
|
761
|
+
// urban 0.81.0) keeps its base `status` frozen at the last worker transient but reads `abandoned`
|
|
762
|
+
// on `pull_requests__tracking.derived_status`; filtering on the base `status` here left the
|
|
763
|
+
// Convergence tab showing a cancelled PR active indefinitely (the #497 phantom). The base
|
|
764
|
+
// `status`/worker-owned transient columns are still surfaced on each row below (the view re-exports
|
|
765
|
+
// `base.*`), and `pull_requests` has no `onWaitingHuman` edge, so for a still-active PR
|
|
766
|
+
// `derived_status === status`.
|
|
767
|
+
.filter((p) => !TERMINAL_STATUSES.includes(p.derived_status))
|
|
753
768
|
.sort((a, b) => (a.updated_at < b.updated_at ? 1 : a.updated_at > b.updated_at ? -1 : 0));
|
|
754
769
|
// Only an `escalated` PR is parked awaiting a human answer (either loop). Surface the question
|
|
755
770
|
// from its latest still-open `escalations` row; a resubmit retires stale rows and finalize/merge
|
|
@@ -972,9 +987,9 @@ async function classifyWaveTarget(
|
|
|
972
987
|
prKey: string,
|
|
973
988
|
token: string,
|
|
974
989
|
): Promise<"cleared" | "closed" | "pending"> {
|
|
975
|
-
const tracked = await
|
|
976
|
-
if (tracked && tracked.status === "merged") return "cleared";
|
|
977
|
-
if (tracked && tracked.
|
|
990
|
+
const tracked = await prsTracking(data).get(prKey);
|
|
991
|
+
if (tracked && tracked.status === "merged") return "cleared"; // worker-owned terminal, passes through the derive edge unchanged
|
|
992
|
+
if (tracked && tracked.derived_status === ABANDONED_STATUS) return "cleared"; // ADR-0065 derive-only terminal → non-blocking, no re-reconcile needed
|
|
978
993
|
const parsed = parsePr(prKey);
|
|
979
994
|
if (!parsed) return "cleared"; // unparseable ref can't be checked → never wedge the barrier
|
|
980
995
|
let st: Awaited<ReturnType<typeof fetchPrState>>;
|
|
@@ -1016,7 +1031,7 @@ async function flipToMergingThenPublish(
|
|
|
1016
1031
|
}
|
|
1017
1032
|
}
|
|
1018
1033
|
|
|
1019
|
-
async function mergeLaneDecisionForPr(data: DataLayer, prKey: string): Promise<PrLaneDecision | null> {
|
|
1034
|
+
export async function mergeLaneDecisionForPr(data: DataLayer, prKey: string): Promise<PrLaneDecision | null> {
|
|
1020
1035
|
const taskRows = await planTasks(data).find({ pr_key: prKey });
|
|
1021
1036
|
const task = taskRows[0];
|
|
1022
1037
|
if (!task) return null;
|
|
@@ -1034,8 +1049,12 @@ async function mergeLaneDecisionForPr(data: DataLayer, prKey: string): Promise<P
|
|
|
1034
1049
|
const lanePrKeys = new Set([...taskToPr.values()]);
|
|
1035
1050
|
const completedPrKeys = new Set<string>();
|
|
1036
1051
|
for (const lanePrKey of lanePrKeys) {
|
|
1037
|
-
const lanePr = await
|
|
1038
|
-
|
|
1052
|
+
const lanePr = await prsTracking(data).get(lanePrKey);
|
|
1053
|
+
// ADR-0065: a lane member is complete when it MERGED (worker-owned terminal, base `status`) OR was
|
|
1054
|
+
// cancelled/terminated (derive-only terminal → `derived_status === "abandoned"`; the base row is
|
|
1055
|
+
// still frozen at its transient). Reading only base `status` here missed a cancelled member and
|
|
1056
|
+
// stalled/misrouted the lane.
|
|
1057
|
+
if (lanePr && (lanePr.status === "merged" || lanePr.derived_status === "abandoned")) {
|
|
1039
1058
|
completedPrKeys.add(lanePr.pr_key);
|
|
1040
1059
|
}
|
|
1041
1060
|
}
|
|
@@ -1400,12 +1419,15 @@ async function pollJobActivation(
|
|
|
1400
1419
|
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
1401
1420
|
if (engineToken) headers.authorization = `Bearer ${engineToken}`;
|
|
1402
1421
|
|
|
1403
|
-
const all = await
|
|
1422
|
+
const all = await prsTracking(data).all();
|
|
1404
1423
|
for (const pr of all) {
|
|
1405
1424
|
// Only a `converging` PR has a live review-round job. Any other status with a stale worker
|
|
1406
1425
|
// set (e.g. it just moved to `waiting_review`) gets cleared so the grid can't show a
|
|
1407
|
-
// phantom "agent working".
|
|
1408
|
-
|
|
1426
|
+
// phantom "agent working". ADR-0065: classify on the DERIVED status, not the base transient —
|
|
1427
|
+
// a PR whose instance was terminated out-of-band keeps its base `status` frozen at `converging`
|
|
1428
|
+
// but reads `abandoned` on `derived_status`, so a base read would leave `active_worker`/
|
|
1429
|
+
// `lease_until` set on a dead run (a phantom "agent working"). Writes stay on the base `prs`.
|
|
1430
|
+
if (pr.derived_status !== "converging") {
|
|
1409
1431
|
if (pr.active_worker || pr.lease_until) {
|
|
1410
1432
|
await prs(data).update(pr.pr_key, {
|
|
1411
1433
|
active_worker: null,
|
|
@@ -1512,13 +1534,16 @@ export async function pollIncidentsImpl(
|
|
|
1512
1534
|
base: string,
|
|
1513
1535
|
headers: Record<string, string>,
|
|
1514
1536
|
) {
|
|
1515
|
-
const all = await
|
|
1537
|
+
const all = await prsTracking(data).all();
|
|
1516
1538
|
for (const pr of all) {
|
|
1517
1539
|
// No live instance to inspect (never created, mid-transition, or terminal — the run has
|
|
1518
1540
|
// finished or was given up, so its instance is gone) → make sure no stale incident lingers on
|
|
1519
|
-
// the row, then move on.
|
|
1520
|
-
//
|
|
1521
|
-
|
|
1541
|
+
// the row, then move on. ADR-0065: classify on the DERIVED terminal edge, not the base transient
|
|
1542
|
+
// — a PR terminated out-of-band keeps its base `status` frozen at `converging` but reads
|
|
1543
|
+
// `abandoned` on `derived_status`, so a base read would keep reconciling incidents against a dead
|
|
1544
|
+
// instance. Reuses the canonical `TERMINAL_STATUSES` so incident logic can't drift from the rest
|
|
1545
|
+
// of the status machine. Writes stay on the base `prs`.
|
|
1546
|
+
if (!pr.process_key || TERMINAL_STATUSES.includes(pr.derived_status)) {
|
|
1522
1547
|
if (pr.incident_key || pr.incident_message) {
|
|
1523
1548
|
await prs(data).update(pr.pr_key, {
|
|
1524
1549
|
incident_key: null,
|
package/app/stage.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// anywhere (not in SQL, not in the page, not in each poller/worker): every reader flows through the
|
|
15
15
|
// VIEW or these adapters, both sourced from the one declaration.
|
|
16
16
|
|
|
17
|
-
import { type FeatureReadModelDerivedColumn, featureReadModel, STAGE_DONE_STATUSES, USER_TASKS_PROJECTION } from "./featureReadModel.ts";
|
|
17
|
+
import { EFFECTIVE_STATUS_COLUMN, type FeatureReadModelDerivedColumn, featureReadModel, STAGE_DONE_STATUSES, USER_TASKS_PROJECTION } from "./featureReadModel.ts";
|
|
18
18
|
|
|
19
19
|
// Re-exported for back-compat with existing importers (operations/acknowledgeDone.ts). Its canonical
|
|
20
20
|
// home is now app/featureReadModel.ts, where it feeds the terminal tier of the derived columns.
|
|
@@ -90,7 +90,12 @@ function evalDerived<T>(column: FeatureReadModelDerivedColumn, baseRow: Record<s
|
|
|
90
90
|
export function deriveStage(run: StageInput): DerivedStage {
|
|
91
91
|
const baseRow = {
|
|
92
92
|
feature_key: SELF_KEY,
|
|
93
|
-
status
|
|
93
|
+
// The status-classifying derivations read the tracking VIEW's terminal-folded `derived_status`
|
|
94
|
+
// (ADR-0065), so this façade feeds the caller's effective `status` under that column name — the SQL
|
|
95
|
+
// VIEW reads `fr."derived_status"` off `feature_runs__tracking`, and both lowerings agree by
|
|
96
|
+
// construction (`assertReadModelParity`). Callers off the write path pass the run's effective
|
|
97
|
+
// status (which equals the base transient for any non-terminated run).
|
|
98
|
+
[EFFECTIVE_STATUS_COLUMN]: run.status,
|
|
94
99
|
pr_key: run.pr_key ?? null,
|
|
95
100
|
converge: run.converge ?? null,
|
|
96
101
|
auto_merge: run.auto_merge ?? null,
|
|
@@ -111,5 +116,5 @@ export function deriveStage(run: StageInput): DerivedStage {
|
|
|
111
116
|
* `feature_runs.list_bucket` base column is vestigial (retired as a write projection, issue #439). This
|
|
112
117
|
* adapter is the TS lowering of that derivation, used off the write path (redispatch gating, tests). */
|
|
113
118
|
export function deriveListBucket(status: string, acknowledgedAt: string | null | undefined): "active" | "history" {
|
|
114
|
-
return evalDerived<"active" | "history">("list_bucket", { status, acknowledged_at: acknowledgedAt ?? null });
|
|
119
|
+
return evalDerived<"active" | "history">("list_bucket", { [EFFECTIVE_STATUS_COLUMN]: status, acknowledged_at: acknowledgedAt ?? null });
|
|
115
120
|
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// Behaviour coverage for issue #503 — the ADR-0065 derive-only-terminal divergence at the terminal
|
|
2
|
+
// EDGE readers. Under `@nanobpm/urban@0.81.0` the `instanceTracking` reconciler no longer WRITES the
|
|
3
|
+
// terminal `abandoned`/`failed`/`reviewed` onto the base `status`; it re-derives it on read via the
|
|
4
|
+
// `<table>__tracking.derived_status` VIEW. A PR/plan whose engine instance was terminated out-of-band
|
|
5
|
+
// (or by an ordinary in-app cancel) therefore keeps its base `status` frozen at its last worker
|
|
6
|
+
// transient (e.g. `converging`/`dispatched`) while `derived_status` reads `abandoned`.
|
|
7
|
+
//
|
|
8
|
+
// Each test seeds that EXACT divergence (base row `status: "converging"`, `derived_status: "abandoned"`)
|
|
9
|
+
// via the `withTrackingViews` seam, and asserts the reader classifies on the derived edge:
|
|
10
|
+
// - a terminated PR is RESUBMITTABLE (not wedged `alreadyRunning`) and absent from `activePrs`,
|
|
11
|
+
// - a terminated instance sheds its stale incident,
|
|
12
|
+
// - a terminated lane member counts as COMPLETE (does not stall the merge lane),
|
|
13
|
+
// - a terminated epic is not counted active (no false same-base conflict) and is RE-ADMITTABLE.
|
|
14
|
+
// Reading only the base `status` (the pre-#503 behaviour) fails every one of these — the RED.
|
|
15
|
+
|
|
16
|
+
import { test } from "node:test";
|
|
17
|
+
import { assertEquals } from "#test-assert";
|
|
18
|
+
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
19
|
+
import { findActivePlansByBase, startPlan } from "./plan.ts";
|
|
20
|
+
import { activePrs, mergeLaneDecisionForPr, pollIncidentsImpl, pollWaveGatesImpl, submitPr } from "./service.ts";
|
|
21
|
+
|
|
22
|
+
function memTable(rows: any[], key: string) {
|
|
23
|
+
return {
|
|
24
|
+
get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
|
|
25
|
+
all: () => Promise.resolve([...rows]),
|
|
26
|
+
find: (q: any) =>
|
|
27
|
+
Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
|
|
28
|
+
findOne: (q: any) =>
|
|
29
|
+
Promise.resolve(rows.find((r) => Object.entries(q).every(([f, v]) => r[f] === v)) ?? null),
|
|
30
|
+
count: (q: any) =>
|
|
31
|
+
Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v)).length),
|
|
32
|
+
insert: (r: any) => {
|
|
33
|
+
rows.push(r);
|
|
34
|
+
return Promise.resolve(r);
|
|
35
|
+
},
|
|
36
|
+
update: (k: any, patch: any) => {
|
|
37
|
+
const r = rows.find((x) => x[key] === k);
|
|
38
|
+
if (r) Object.assign(r, patch);
|
|
39
|
+
return Promise.resolve(r);
|
|
40
|
+
},
|
|
41
|
+
delete: (k: any) => {
|
|
42
|
+
for (let i = rows.length - 1; i >= 0; i--) if (rows[i][key] === k) rows.splice(i, 1);
|
|
43
|
+
return Promise.resolve();
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type Stores = Record<string, { rows: any[]; key: string }>;
|
|
49
|
+
|
|
50
|
+
function memData(stores: Stores) {
|
|
51
|
+
return {
|
|
52
|
+
table: withTrackingViews((name: string, key: string) =>
|
|
53
|
+
memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
54
|
+
} as any;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function withGithubOff(run: () => Promise<void>): Promise<void> {
|
|
58
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
59
|
+
const prevTok = process.env["GITHUB_TOKEN"];
|
|
60
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token"; // no token below -> fetchPrMeta returns null
|
|
61
|
+
delete process.env["GITHUB_TOKEN"];
|
|
62
|
+
return run().finally(() => {
|
|
63
|
+
if (prevMode !== undefined) process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
64
|
+
else delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
65
|
+
if (prevTok !== undefined) process.env["GITHUB_TOKEN"] = prevTok;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// #503 / #497: `submitPr`'s idempotency gate reads `derived_status`, so a derive-only-terminated PR
|
|
70
|
+
// (base frozen at `converging`, `derived_status = abandoned`) is seen terminal and RESUBMITTABLE —
|
|
71
|
+
// it re-opens for a fresh convergence run instead of wedging `alreadyRunning`.
|
|
72
|
+
test("submitPr re-opens a derive-only-terminated PR (base 'converging', derived 'abandoned') — not alreadyRunning", async () => {
|
|
73
|
+
await withGithubOff(async () => {
|
|
74
|
+
const PR_KEY = "owner/repo#42";
|
|
75
|
+
const stores: Stores = {
|
|
76
|
+
pull_requests: {
|
|
77
|
+
rows: [{
|
|
78
|
+
pr_key: PR_KEY,
|
|
79
|
+
repo: "owner/repo",
|
|
80
|
+
number: 42,
|
|
81
|
+
url: "https://github.com/owner/repo/pull/42",
|
|
82
|
+
title: "t",
|
|
83
|
+
status: "converging", // base transient FROZEN — reconciler no longer writes the terminal
|
|
84
|
+
derived_status: "abandoned", // ADR-0065 derive-only terminal
|
|
85
|
+
current_round: 3,
|
|
86
|
+
}],
|
|
87
|
+
key: "pr_key",
|
|
88
|
+
},
|
|
89
|
+
escalations: { rows: [], key: "id" },
|
|
90
|
+
pr_dependencies: { rows: [], key: "pr_key" },
|
|
91
|
+
};
|
|
92
|
+
const data = memData(stores);
|
|
93
|
+
const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-9" }) } as any;
|
|
94
|
+
|
|
95
|
+
const res = await submitPr(data, engine, {
|
|
96
|
+
repo: "owner/repo",
|
|
97
|
+
number: 42,
|
|
98
|
+
url: "https://github.com/owner/repo/pull/42",
|
|
99
|
+
prKey: PR_KEY,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
assertEquals((res as any).alreadyRunning, undefined); // NOT wedged
|
|
103
|
+
assertEquals(res.processKey, "PI-9");
|
|
104
|
+
const pr = stores.pull_requests.rows[0];
|
|
105
|
+
assertEquals(pr.status, "converging");
|
|
106
|
+
assertEquals(pr.current_round, 1); // re-opened for a fresh run
|
|
107
|
+
assertEquals(pr.process_key, "PI-9");
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// #503 / #497 phantom: `activePrs` filters on `derived_status`, so a derive-only-terminated PR drops
|
|
112
|
+
// off the Convergence tab instead of showing "active" indefinitely.
|
|
113
|
+
test("activePrs excludes a derive-only-terminated PR and keeps a genuinely active one", async () => {
|
|
114
|
+
const stores: Stores = {
|
|
115
|
+
pull_requests: {
|
|
116
|
+
rows: [
|
|
117
|
+
{ pr_key: "o/r#1", repo: "o/r", number: 1, url: "u1", status: "converging", derived_status: "abandoned", current_round: 2, updated_at: "2024-01-02" },
|
|
118
|
+
{ pr_key: "o/r#2", repo: "o/r", number: 2, url: "u2", status: "converging", derived_status: "converging", current_round: 1, updated_at: "2024-01-01" },
|
|
119
|
+
],
|
|
120
|
+
key: "pr_key",
|
|
121
|
+
},
|
|
122
|
+
escalations: { rows: [], key: "id" },
|
|
123
|
+
};
|
|
124
|
+
const active = await activePrs(memData(stores));
|
|
125
|
+
assertEquals(active.map((p) => p.prKey), ["o/r#2"]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// #503: `pollIncidentsImpl` classifies a dead instance on `derived_status`, so a derive-only-terminated
|
|
129
|
+
// PR sheds its stale incident (rather than re-reconciling against a gone instance), and NEVER queries
|
|
130
|
+
// the engine for it. A live PR still gets queried.
|
|
131
|
+
test("pollIncidentsImpl clears a stale incident off a derive-only-terminated PR without querying the engine", async () => {
|
|
132
|
+
const stores: Stores = {
|
|
133
|
+
pull_requests: {
|
|
134
|
+
rows: [{
|
|
135
|
+
pr_key: "o/r#7",
|
|
136
|
+
repo: "o/r",
|
|
137
|
+
number: 7,
|
|
138
|
+
url: "u",
|
|
139
|
+
status: "converging",
|
|
140
|
+
derived_status: "abandoned",
|
|
141
|
+
process_key: "PI-DEAD",
|
|
142
|
+
incident_key: "INC-1",
|
|
143
|
+
incident_message: "boom",
|
|
144
|
+
}],
|
|
145
|
+
key: "pr_key",
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
const prevFetch = globalThis.fetch;
|
|
149
|
+
let queried = false;
|
|
150
|
+
globalThis.fetch = (() => {
|
|
151
|
+
queried = true;
|
|
152
|
+
throw new Error("engine must not be queried for a derive-only-terminated PR");
|
|
153
|
+
}) as any;
|
|
154
|
+
try {
|
|
155
|
+
await pollIncidentsImpl(memData(stores), "http://engine", {});
|
|
156
|
+
} finally {
|
|
157
|
+
globalThis.fetch = prevFetch;
|
|
158
|
+
}
|
|
159
|
+
assertEquals(queried, false);
|
|
160
|
+
const pr = stores.pull_requests.rows[0];
|
|
161
|
+
assertEquals(pr.incident_key, null);
|
|
162
|
+
assertEquals(pr.incident_message, null);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// #503: the merge-lane decision counts a lane member COMPLETE on the derived terminal edge, so a
|
|
166
|
+
// derive-only-abandoned member (base 'converging') no longer holds its lane-mate behind a dead PR.
|
|
167
|
+
test("mergeLaneDecisionForPr treats a derive-only-abandoned lane member as complete (does not hold the lane)", async () => {
|
|
168
|
+
const PLAN_KEY = "o/r#100";
|
|
169
|
+
const stores: Stores = {
|
|
170
|
+
plan_tasks: {
|
|
171
|
+
rows: [
|
|
172
|
+
{ id: 1, plan_key: PLAN_KEY, task_id: "a", pr_key: "o/r#1" },
|
|
173
|
+
{ id: 2, plan_key: PLAN_KEY, task_id: "b", pr_key: "o/r#2" },
|
|
174
|
+
],
|
|
175
|
+
key: "id",
|
|
176
|
+
},
|
|
177
|
+
plan_merge_exclusions: {
|
|
178
|
+
// a & b collide on a shared surface → one landing lane, land one-at-a-time
|
|
179
|
+
rows: [{ id: 1, plan_key: PLAN_KEY, task_a: "a", task_b: "b", files: JSON.stringify(["shared.ts"]), source: "file-overlap" }],
|
|
180
|
+
key: "id",
|
|
181
|
+
},
|
|
182
|
+
plan_task_deps: { rows: [], key: "plan_key" },
|
|
183
|
+
pull_requests: {
|
|
184
|
+
rows: [
|
|
185
|
+
// lane head candidate `a`: derive-only-terminated (base frozen, derived abandoned)
|
|
186
|
+
{ pr_key: "o/r#1", repo: "o/r", number: 1, status: "converging", derived_status: "abandoned" },
|
|
187
|
+
// `b`: the PR we ask about — still converging
|
|
188
|
+
{ pr_key: "o/r#2", repo: "o/r", number: 2, status: "converging", derived_status: "converging" },
|
|
189
|
+
],
|
|
190
|
+
key: "pr_key",
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
const decision = await mergeLaneDecisionForPr(memData(stores), "o/r#2");
|
|
194
|
+
// With `a` counted complete, `b` is free to land — NOT held behind the dead member.
|
|
195
|
+
assertEquals(decision?.isHeld, false);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// #503: `classifyWaveTarget` classifies a wave member on the derived terminal edge, so a
|
|
199
|
+
// derive-only-abandoned member (base frozen at `converging`, still notionally open) is treated
|
|
200
|
+
// NON-BLOCKING (`cleared`) WITHOUT a GitHub round-trip — the wave gate advances instead of wedging on
|
|
201
|
+
// a dead member. Driven through `pollWaveGatesImpl`: with the sole gate member cleared and the token
|
|
202
|
+
// parked at `wait-wave-merged`, the barrier is released (`wave-merged` published). Reading the base
|
|
203
|
+
// `status` (the RED) would fall through to a GitHub liveness read (which, with no live "merged"
|
|
204
|
+
// signal, returns `pending`) and never publish.
|
|
205
|
+
test("classifyWaveTarget treats a derive-only-abandoned wave member as cleared and releases the gate (no GitHub read)", async () => {
|
|
206
|
+
await withGithubOff(async () => {
|
|
207
|
+
const PLAN_KEY = "o/r#200";
|
|
208
|
+
const stores: Stores = {
|
|
209
|
+
plans: { rows: [{ plan_key: PLAN_KEY, gate_wave: 0, process_key: "PI-1" }], key: "plan_key" },
|
|
210
|
+
plan_tasks: {
|
|
211
|
+
rows: [{ id: 1, plan_key: PLAN_KEY, task_id: "a", wave: 0, status: "opened", pr_key: "o/r#1" }],
|
|
212
|
+
key: "id",
|
|
213
|
+
},
|
|
214
|
+
pull_requests: {
|
|
215
|
+
rows: [{ pr_key: "o/r#1", repo: "o/r", number: 1, status: "converging", derived_status: "abandoned" }],
|
|
216
|
+
key: "pr_key",
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
const published: unknown[] = [];
|
|
220
|
+
const engine = { publishMessage: (m: unknown) => (published.push(m), Promise.resolve()) } as any;
|
|
221
|
+
const prevFetch = globalThis.fetch;
|
|
222
|
+
// Confirm the token is parked at the `wave-merged` wait so the barrier is releasable; a GitHub
|
|
223
|
+
// liveness read for the abandoned member would be a bug (it's classified `cleared` off the view).
|
|
224
|
+
globalThis.fetch = ((url: string) => {
|
|
225
|
+
if (String(url).endsWith("/message-subscriptions/search")) {
|
|
226
|
+
return Promise.resolve(
|
|
227
|
+
new Response(
|
|
228
|
+
JSON.stringify({ items: [{ messageName: "wave-merged", correlationKey: PLAN_KEY, messageSubscriptionState: "CREATED" }] }),
|
|
229
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
230
|
+
),
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
throw new Error(`no GitHub read expected for a derive-only-abandoned wave member: ${url}`);
|
|
234
|
+
}) as any;
|
|
235
|
+
try {
|
|
236
|
+
await pollWaveGatesImpl(memData(stores), engine, "", "http://engine", {});
|
|
237
|
+
} finally {
|
|
238
|
+
globalThis.fetch = prevFetch;
|
|
239
|
+
}
|
|
240
|
+
assertEquals(published.length, 1); // wave released — the abandoned member did not block it
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
// derive-only-abandoned epic (base frozen at `dispatched`) is NOT counted active and raises no false
|
|
246
|
+
// same-base conflict.
|
|
247
|
+
test("findActivePlansByBase excludes a derive-only-abandoned epic", async () => {
|
|
248
|
+
const stores: Stores = {
|
|
249
|
+
plans: {
|
|
250
|
+
rows: [
|
|
251
|
+
{ plan_key: "o/r#10", repo: "o/r", base_branch: "epic/x", status: "dispatched", derived_status: "abandoned" },
|
|
252
|
+
{ plan_key: "o/r#11", repo: "o/r", base_branch: "epic/x", status: "dispatched", derived_status: "dispatched" },
|
|
253
|
+
],
|
|
254
|
+
key: "plan_key",
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
const active = await findActivePlansByBase(memData(stores), "o/r", "epic/x");
|
|
258
|
+
assertEquals(active.map((p) => p.plan_key), ["o/r#11"]);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// #503: `startPlan`'s idempotency gate reads `derived_status`, so a derive-only-abandoned epic (base
|
|
262
|
+
// frozen at `dispatched`) is seen terminal and RE-ADMITTABLE — it re-plans instead of wedging
|
|
263
|
+
// `alreadyRunning`.
|
|
264
|
+
test("startPlan re-admits a derive-only-abandoned epic (base 'dispatched', derived 'abandoned') — not alreadyRunning", async () => {
|
|
265
|
+
await withGithubOff(async () => {
|
|
266
|
+
const PLAN_KEY = "owner/repo#7";
|
|
267
|
+
const stores: Stores = {
|
|
268
|
+
plans: {
|
|
269
|
+
rows: [{ plan_key: PLAN_KEY, repo: "owner/repo", base_branch: "epic/x", status: "dispatched", derived_status: "abandoned", task_count: 1 }],
|
|
270
|
+
key: "plan_key",
|
|
271
|
+
},
|
|
272
|
+
plan_tasks: { rows: [{ id: 1, plan_key: PLAN_KEY }], key: "id" },
|
|
273
|
+
plan_reviews: { rows: [], key: "plan_key" },
|
|
274
|
+
plan_task_deps: { rows: [], key: "plan_key" },
|
|
275
|
+
};
|
|
276
|
+
const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-1" }) } as any;
|
|
277
|
+
|
|
278
|
+
const res = await startPlan(memData(stores), engine, {
|
|
279
|
+
repo: "owner/repo",
|
|
280
|
+
number: 7,
|
|
281
|
+
url: "https://github.com/owner/repo/issues/7",
|
|
282
|
+
planKey: PLAN_KEY,
|
|
283
|
+
}, "epic/x");
|
|
284
|
+
|
|
285
|
+
assertEquals((res as any).alreadyRunning, undefined); // NOT wedged — re-planned
|
|
286
|
+
// The prior epic's tasks were cleared on the re-plan path (proves it did NOT short-circuit).
|
|
287
|
+
assertEquals(stores.plan_tasks.rows.length, 0);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Class guard for the ADR-0065 terminal-edge reader migration (issue #503).
|
|
2
|
+
//
|
|
3
|
+
// Since ADR-0065 (`@nanobpm/urban@0.81.0`) the `instanceTracking` reconciler is a SOURCE, not a
|
|
4
|
+
// writer: on cancel/terminate it feeds urban's instance projection and the terminal edge
|
|
5
|
+
// (`onTerminated`) is RECOMPUTED ON READ as `<table>__tracking.derived_status` — it NO LONGER writes
|
|
6
|
+
// the terminal (`abandoned`/`failed`/`reviewed`) onto the base `status` column. A classifying reader
|
|
7
|
+
// that inspects the BASE `status` column of a DERIVE-ONLY tracked table therefore sees a row frozen at
|
|
8
|
+
// its last worker-owned transient after the instance ends → phantom-active / wedged-idempotency bugs
|
|
9
|
+
// (the #497 / #503 class).
|
|
10
|
+
//
|
|
11
|
+
// This is a SOURCE-SCAN guard over the defect CLASS, not a single instance: it asserts that every
|
|
12
|
+
// terminal/active classification (`TERMINAL_STATUSES.includes` / `=== ABANDONED_STATUS` /
|
|
13
|
+
// `PLAN_TERMINAL_STATUSES` / the feature read model's status DSL) for the three derive-only tracked
|
|
14
|
+
// tables (`pull_requests`, `plans`, `feature_runs`) reads the DERIVED effective status
|
|
15
|
+
// (`.derived_status`), never the frozen base `.status`. A future reader that silently re-drifts onto
|
|
16
|
+
// the base column fails here.
|
|
17
|
+
//
|
|
18
|
+
// Worker-owned terminals that PASS THROUGH the derive edge unchanged (`merged`) are exempt: a base
|
|
19
|
+
// `=== "merged"` read is legitimate (see `isDepMerged` / `classifyWaveTarget` / `mergeLaneDecisionForPr`
|
|
20
|
+
// in app/service.ts). Only the DERIVE-ONLY terminals (`abandoned`/`failed`/`reviewed`) must route
|
|
21
|
+
// through the derived accessor. Writers (`data.table(<table>).update({ status: … })`) are unaffected —
|
|
22
|
+
// they still write the base column.
|
|
23
|
+
import { readFileSync } from "node:fs";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
import { test } from "node:test";
|
|
26
|
+
import { assert, assertEquals } from "#test-assert";
|
|
27
|
+
import { EFFECTIVE_STATUS_COLUMN } from "./featureReadModel.ts";
|
|
28
|
+
|
|
29
|
+
const SRC = (name: string): string => readFileSync(fileURLToPath(new URL(`./${name}`, import.meta.url)), "utf8");
|
|
30
|
+
|
|
31
|
+
/** Strip line (`//`) and block (`/* … */`) comments so the scan only inspects executable code — a
|
|
32
|
+
* doc comment may legitimately mention `.status` in prose without being a classification. The line
|
|
33
|
+
* stripper skips a `//` preceded by `:` so a URL scheme inside a string/template literal (e.g.
|
|
34
|
+
* `https://…` in app/service.ts) is not mistaken for a comment start and does not corrupt the scan. */
|
|
35
|
+
function stripComments(src: string): string {
|
|
36
|
+
return src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test("class guard: every TERMINAL_STATUSES classification in service.ts reads derived_status, not base status", () => {
|
|
40
|
+
const code = stripComments(SRC("service.ts"));
|
|
41
|
+
const calls = [...code.matchAll(/TERMINAL_STATUSES\.includes\(([^)]*)\)/g)];
|
|
42
|
+
assert(calls.length > 0, "expected TERMINAL_STATUSES.includes classifications in service.ts");
|
|
43
|
+
for (const m of calls) {
|
|
44
|
+
const arg = m[1];
|
|
45
|
+
assert(
|
|
46
|
+
/\.derived_status\b/.test(arg),
|
|
47
|
+
`TERMINAL_STATUSES.includes(${arg}) classifies on the BASE status of a derive-only tracked table — ` +
|
|
48
|
+
`route it through prsTracking and read \`.derived_status\` (ADR-0065, #503)`,
|
|
49
|
+
);
|
|
50
|
+
assert(
|
|
51
|
+
!/[A-Za-z0-9_)\]]\.status\b/.test(arg),
|
|
52
|
+
`TERMINAL_STATUSES.includes(${arg}) still reads a base \`.status\` — the terminal edge is derive-only (#503)`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("class guard: no base `.status === ABANDONED_STATUS` / `.status === \"abandoned\"` read classification in service.ts", () => {
|
|
58
|
+
const code = stripComments(SRC("service.ts"));
|
|
59
|
+
// A READ classification against the derive-only `abandoned` terminal must use `.derived_status`. A
|
|
60
|
+
// base `.status === ABANDONED_STATUS`/`"abandoned"` would miss a derive-only-terminated PR. (Writers
|
|
61
|
+
// use the object-literal form `{ status: ABANDONED_STATUS }`, which this pattern never matches.)
|
|
62
|
+
const bad = [
|
|
63
|
+
...code.matchAll(/[A-Za-z0-9_)\]]\.status\s*===\s*ABANDONED_STATUS/g),
|
|
64
|
+
...code.matchAll(/[A-Za-z0-9_)\]]\.status\s*===\s*["']abandoned["']/g),
|
|
65
|
+
];
|
|
66
|
+
assertEquals(
|
|
67
|
+
bad.length,
|
|
68
|
+
0,
|
|
69
|
+
`a base \`.status\` is compared to the derive-only \`abandoned\` terminal — read \`.derived_status\` off ` +
|
|
70
|
+
`prsTracking instead (ADR-0065, #503): ${bad.map((m) => m[0]).join(", ")}`,
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("class guard: every PLAN_TERMINAL_STATUSES classification in plan.ts reads derived_status, not base status", () => {
|
|
75
|
+
const code = stripComments(SRC("plan.ts"));
|
|
76
|
+
// Match the `.some((s) => s === <ref>)` classification form used at both admission sites.
|
|
77
|
+
const calls = [...code.matchAll(/PLAN_TERMINAL_STATUSES\.some\(\([^)]*\)\s*=>\s*[^)]*===\s*([A-Za-z0-9_.]+)\)/g)];
|
|
78
|
+
assert(calls.length > 0, "expected PLAN_TERMINAL_STATUSES classifications in plan.ts");
|
|
79
|
+
for (const m of calls) {
|
|
80
|
+
const ref = m[1];
|
|
81
|
+
assert(
|
|
82
|
+
/\.derived_status$/.test(ref),
|
|
83
|
+
`PLAN_TERMINAL_STATUSES classification reads \`${ref}\` — route it through plansTracking and read ` +
|
|
84
|
+
`\`.derived_status\` so a derive-only-terminated epic is seen terminal (ADR-0065, #503)`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("class guard: the feature read model classifies on derived_status, never the base status column", () => {
|
|
90
|
+
// The feature history read model (app/featureReadModel.ts) buckets a run's pipeline `stage`/
|
|
91
|
+
// `list_bucket` off its status. Under ADR-0065 that must be the terminal-folded `derived_status`
|
|
92
|
+
// (off `feature_runs__tracking`), or a terminated run renders "Implementing" forever. Assert the DSL
|
|
93
|
+
// never references the base `col("status")` for classification and that the effective-status column
|
|
94
|
+
// is the derived one.
|
|
95
|
+
assertEquals(EFFECTIVE_STATUS_COLUMN, "derived_status", "the feature read model's effective status must be the derived column");
|
|
96
|
+
const code = stripComments(SRC("featureReadModel.ts"));
|
|
97
|
+
assert(
|
|
98
|
+
!/col\(\s*["']status["']\s*\)/.test(code),
|
|
99
|
+
'app/featureReadModel.ts still references col("status") — the status-classifying derivations must ' +
|
|
100
|
+
'read col("derived_status") off feature_runs__tracking (ADR-0065, #503)',
|
|
101
|
+
);
|
|
102
|
+
assert(
|
|
103
|
+
/baseTable:\s*FEATURE_READ_MODEL_BASE_TABLE/.test(code) || /feature_runs__tracking/.test(code),
|
|
104
|
+
"the feature read model must be based on the feature_runs__tracking derived VIEW (ADR-0065, #503)",
|
|
105
|
+
);
|
|
106
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
-- Fold the ADR-0065 DERIVED terminal edge into the epic read model (issue #503 — the plans row of the
|
|
2
|
+
-- "migrate remaining terminal-edge readers to derived_status" class).
|
|
3
|
+
--
|
|
4
|
+
-- Since ADR-0065 (`@nanobpm/urban@0.81.0`) the `instanceTracking` reconciler is a SOURCE, not a writer:
|
|
5
|
+
-- on cancel/terminate it feeds urban's instance projection and the terminal edge (`onTerminated →
|
|
6
|
+
-- abandoned`) is RECOMPUTED ON READ as `plans__tracking.derived_status` — it NO LONGER writes
|
|
7
|
+
-- `abandoned` onto the base `plans.status` column. `plans` had NO derived reader, so a terminated epic's
|
|
8
|
+
-- base row stayed frozen at `planning`/`dispatched` and 074's `plan_read_model` bucketed it ACTIVE
|
|
9
|
+
-- forever (a terminated epic rendered active on the epic index/detail).
|
|
10
|
+
--
|
|
11
|
+
-- 074_plan_read_model_derive_bucket.sql made `plan_read_model` the composite VIEW the epic pages bind
|
|
12
|
+
-- and DERIVED `list_bucket`/`ack_open` from the base `plans.status` (+ `acknowledged_at` + the derived
|
|
13
|
+
-- `plan_delivery.delivery` signal). This migration redefines `plan_read_model` (same name, so no page
|
|
14
|
+
-- repoint is needed) to read the EFFECTIVE status off the auto-provisioned `plans__tracking` derived
|
|
15
|
+
-- VIEW instead of the frozen base column: the projected `status` and the bucket/ack derivations now
|
|
16
|
+
-- fold in the reconciler's terminal edge, so a cancelled/terminated epic drops out of Active with no
|
|
17
|
+
-- worker write and no poller pass.
|
|
18
|
+
--
|
|
19
|
+
-- `plans__tracking` is the managed VIEW urban provisions at mount (`<table>__tracking`, ADR-0065),
|
|
20
|
+
-- re-exporting `plans.*` plus a `derived_status` column that is `abandoned` on a terminated instance and
|
|
21
|
+
-- the base `plans.status` otherwise. SQLite does not validate a view body at CREATE time, so this
|
|
22
|
+
-- migration (which runs before the runtime mount that provisions `plans__tracking`) is created fine and
|
|
23
|
+
-- resolves once the managed VIEW exists. The `COALESCE(t.derived_status, pl.status)` fallback degrades to
|
|
24
|
+
-- the previous base-column behaviour only for an unexpected NULL `derived_status` or a missing joined row
|
|
25
|
+
-- (the LEFT JOIN yielding no `t` row) — it does NOT protect against the `plans__tracking` VIEW being
|
|
26
|
+
-- absent, which would fail this VIEW's query at read time.
|
|
27
|
+
--
|
|
28
|
+
-- A merged view is not editable in place (that would edit a shipped migration), so this DROPs and
|
|
29
|
+
-- re-CREATEs it. `plan_read_model` is a leaf — no other view builds on it — so the DROP is safe. Its
|
|
30
|
+
-- output column set is UNCHANGED (only the SOURCE of `status`/`list_bucket`/`ack_open` moved from the
|
|
31
|
+
-- base column to the derived VIEW), so the pages↔schema contract guard and every page binding stay
|
|
32
|
+
-- valid.
|
|
33
|
+
--
|
|
34
|
+
-- Forward-only. NO BEGIN/COMMIT — the runner wraps each file in its own transaction. Numbered after 078.
|
|
35
|
+
|
|
36
|
+
DROP VIEW plan_read_model;
|
|
37
|
+
|
|
38
|
+
CREATE VIEW plan_read_model AS
|
|
39
|
+
SELECT
|
|
40
|
+
pl.plan_key AS plan_key,
|
|
41
|
+
pl.repo AS repo,
|
|
42
|
+
pl.issue_number AS issue_number,
|
|
43
|
+
pl.issue_url AS issue_url,
|
|
44
|
+
pl.title AS title,
|
|
45
|
+
COALESCE(t.derived_status, pl.status) AS status,
|
|
46
|
+
pl.task_count AS task_count,
|
|
47
|
+
pl.process_key AS process_key,
|
|
48
|
+
pl.outcome AS outcome,
|
|
49
|
+
pl.updated_at AS updated_at,
|
|
50
|
+
pl.epic_phase AS epic_phase,
|
|
51
|
+
pl.base_branch AS base_branch,
|
|
52
|
+
pl.wait_gate_label AS wait_gate_label,
|
|
53
|
+
pl.bound_artifacts AS bound_artifacts,
|
|
54
|
+
pl.promotion_pr AS promotion_pr,
|
|
55
|
+
pl.promotion_state AS promotion_state,
|
|
56
|
+
(CASE
|
|
57
|
+
WHEN COALESCE(t.derived_status, pl.status) IN ('planning', 'dispatched') THEN 'active'
|
|
58
|
+
WHEN COALESCE(t.derived_status, pl.status) = 'done' AND d.delivery = 'converging' THEN 'active'
|
|
59
|
+
WHEN COALESCE(t.derived_status, pl.status) = 'done' AND pl.acknowledged_at IS NULL THEN 'active'
|
|
60
|
+
WHEN COALESCE(t.derived_status, pl.status) = 'done' THEN 'history'
|
|
61
|
+
ELSE 'history'
|
|
62
|
+
END) AS list_bucket,
|
|
63
|
+
(CASE
|
|
64
|
+
WHEN COALESCE(t.derived_status, pl.status) = 'done' AND d.delivery IS NOT 'converging' AND pl.acknowledged_at IS NULL THEN 1
|
|
65
|
+
ELSE 0
|
|
66
|
+
END) AS ack_open,
|
|
67
|
+
wl.wave_count AS wave_count,
|
|
68
|
+
wl.current_wave AS current_wave,
|
|
69
|
+
wl.wave_label AS wave_label,
|
|
70
|
+
d.delivery AS delivery,
|
|
71
|
+
d.delivery_label AS delivery_label
|
|
72
|
+
FROM plans pl
|
|
73
|
+
LEFT JOIN plans__tracking t ON t.plan_key = pl.plan_key
|
|
74
|
+
LEFT JOIN plan_wave_label wl ON wl.plan_key = pl.plan_key
|
|
75
|
+
LEFT JOIN plan_delivery d ON d.plan_key = pl.plan_key;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
-- Feature-run read model: fold the ADR-0065 DERIVED terminal edge into the projection (issue #503 —
|
|
2
|
+
-- the feature_runs row of the "migrate remaining terminal-edge readers to derived_status" class).
|
|
3
|
+
--
|
|
4
|
+
-- Since ADR-0065 (`@nanobpm/urban@0.81.0`) the `instanceTracking` reconciler is a SOURCE, not a writer:
|
|
5
|
+
-- on cancel/terminate it feeds urban's instance projection and the terminal edge (`onTerminated →
|
|
6
|
+
-- abandoned`) is RECOMPUTED ON READ as `feature_runs__tracking.derived_status` — it NO LONGER writes
|
|
7
|
+
-- `abandoned` onto the base `feature_runs.status` column. `feature_runs` had NO derived reader, so a
|
|
8
|
+
-- terminated feature run's base row stayed frozen at `running`/`escalated`/`awaiting_operator` and
|
|
9
|
+
-- 076's `feature_read_model` rendered it "Implementing" (never "failed") on the Feature history grid
|
|
10
|
+
-- forever.
|
|
11
|
+
--
|
|
12
|
+
-- 076_feature_read_model_declare_once.sql authored the projection ONCE (`defineReadModel`,
|
|
13
|
+
-- app/featureReadModel.ts) and emitted each derived column VERBATIM from that declaration; it read the
|
|
14
|
+
-- base `feature_runs.status`. This migration SUPERSEDES 076's VIEW body: the declaration's `baseTable`
|
|
15
|
+
-- is now the auto-provisioned `feature_runs__tracking` derived VIEW (which re-exports `feature_runs.*`
|
|
16
|
+
-- plus a terminal-folded `derived_status`), and every status-classifying derived column below reads
|
|
17
|
+
-- `fr."derived_status"` instead of `fr."status"`. So a cancelled/terminated run renders `Done`/`failed`
|
|
18
|
+
-- with no worker write. 076 is a MERGED, IMMUTABLE migration — never edited; this is a NEW migration
|
|
19
|
+
-- superseding its VIEW body (the same pattern by which 076 superseded 073/075).
|
|
20
|
+
--
|
|
21
|
+
-- Every DERIVED column body is emitted VERBATIM from the ONE declaration
|
|
22
|
+
-- (`featureReadModel.sqlSelectFor(col, { baseAlias: "fr" })`), which ALSO drives the runtime TS via
|
|
23
|
+
-- `fnFor` — the two lowerings fall out of the same closed-DSL AST and cannot diverge. The drift guard
|
|
24
|
+
-- (app/featureReadModel.test.ts) fails if this file stops matching the declaration, and
|
|
25
|
+
-- `assertReadModelParity` proves the SQL and TS lowerings agree. SEMANTICS are unchanged from 076 EXCEPT
|
|
26
|
+
-- the status source (base transient → terminal-folded `derived_status`): `attention` still derives from
|
|
27
|
+
-- ENGINE TRUTH (an OPEN `user_tasks` row, issue #422); `stage_skipped` is still a pure function of
|
|
28
|
+
-- `converge`/`auto_merge`.
|
|
29
|
+
--
|
|
30
|
+
-- `feature_runs__tracking` is the managed VIEW urban provisions at mount (`<table>__tracking`); SQLite
|
|
31
|
+
-- does not validate a view body at CREATE time, so this migration (which runs before the runtime mount
|
|
32
|
+
-- that provisions the managed VIEW) is created fine and resolves once the managed VIEW exists. Base
|
|
33
|
+
-- columns stay aliased pass-throughs (so the static pages↔schema contract guard still sees the VIEW
|
|
34
|
+
-- columns), now sourced off `feature_runs__tracking`'s re-export of `base.*`; `feature_runs__tracking fr`
|
|
35
|
+
-- is the sole top-level FROM (the user_tasks lookups are nested EXISTS subqueries at paren depth >= 1).
|
|
36
|
+
--
|
|
37
|
+
-- Forward-only VIEW redefinition (DROP then CREATE). The runner wraps each file in its own transaction,
|
|
38
|
+
-- so this file must NOT contain BEGIN/COMMIT. Numbered after 079.
|
|
39
|
+
|
|
40
|
+
DROP VIEW IF EXISTS feature_read_model;
|
|
41
|
+
|
|
42
|
+
CREATE VIEW feature_read_model AS
|
|
43
|
+
SELECT
|
|
44
|
+
fr.feature_key AS feature_key,
|
|
45
|
+
fr.repo AS repo,
|
|
46
|
+
fr.issue_number AS issue_number,
|
|
47
|
+
fr.issue_url AS issue_url,
|
|
48
|
+
fr.title AS title,
|
|
49
|
+
fr.base_branch AS base_branch,
|
|
50
|
+
fr.status AS status,
|
|
51
|
+
fr.process_key AS process_key,
|
|
52
|
+
fr.pr_key AS pr_key,
|
|
53
|
+
fr.converge AS converge,
|
|
54
|
+
fr.auto_merge AS auto_merge,
|
|
55
|
+
fr.outcome AS outcome,
|
|
56
|
+
fr.delivery_label AS delivery_label,
|
|
57
|
+
fr.acknowledged_at AS acknowledged_at,
|
|
58
|
+
fr.created_at AS created_at,
|
|
59
|
+
fr.updated_at AS updated_at,
|
|
60
|
+
CASE WHEN COALESCE((COALESCE(("fr"."derived_status" = 'merged'), 0) OR COALESCE(("fr"."derived_status" = 'converged'), 0) OR COALESCE(("fr"."derived_status" = 'blocked'), 0) OR COALESCE(("fr"."derived_status" = 'failed'), 0) OR COALESCE(("fr"."derived_status" = 'skipped'), 0) OR COALESCE(("fr"."derived_status" = 'abandoned'), 0)), 0) THEN 'Done' WHEN COALESCE(("fr"."derived_status" = 'converging'), 0) THEN 'Converging' WHEN COALESCE((COALESCE(("fr"."pr_key" <> ''), 0) OR COALESCE(("fr"."derived_status" = 'opened'), 0)), 0) THEN 'PR open' WHEN COALESCE((COALESCE(("fr"."derived_status" = 'running'), 0) OR COALESCE(("fr"."derived_status" = 'escalated'), 0) OR COALESCE(("fr"."derived_status" = 'awaiting_operator'), 0)), 0) THEN 'Implementing' ELSE 'Requested' END AS stage,
|
|
61
|
+
CASE WHEN COALESCE((COALESCE(("fr"."derived_status" = 'merged'), 0) OR COALESCE(("fr"."derived_status" = 'converged'), 0)), 0) THEN 'ok' WHEN COALESCE(("fr"."derived_status" = 'blocked'), 0) THEN 'blocked' WHEN COALESCE((COALESCE(("fr"."derived_status" = 'failed'), 0) OR COALESCE(("fr"."derived_status" = 'skipped'), 0) OR COALESCE(("fr"."derived_status" = 'abandoned'), 0)), 0) THEN 'failed' ELSE NULL END AS stage_state,
|
|
62
|
+
CASE WHEN (NOT COALESCE("fr"."converge", 0)) THEN 'Converging Merging' WHEN (NOT COALESCE("fr"."auto_merge", 0)) THEN 'Merging' ELSE '' END AS stage_skipped,
|
|
63
|
+
CASE WHEN EXISTS (SELECT 1 FROM "user_tasks" AS "__urban_proj_0" WHERE COALESCE((COALESCE(("__urban_proj_0"."subject_type" = 'feature'), 0) AND COALESCE(("__urban_proj_0"."subject_key" = "fr"."feature_key"), 0) AND COALESCE(("__urban_proj_0"."element_id" = 'feature-blocked'), 0)), 0)) THEN 'blocked' WHEN EXISTS (SELECT 1 FROM "user_tasks" AS "__urban_proj_0" WHERE COALESCE((COALESCE(("__urban_proj_0"."subject_type" = 'feature'), 0) AND COALESCE(("__urban_proj_0"."subject_key" = "fr"."feature_key"), 0) AND COALESCE(("__urban_proj_0"."element_id" = 'feature-escalation'), 0)), 0)) THEN '⚠' ELSE NULL END AS attention,
|
|
64
|
+
CASE WHEN COALESCE((COALESCE((COALESCE(("fr"."derived_status" = 'merged'), 0) OR COALESCE(("fr"."derived_status" = 'converged'), 0) OR COALESCE(("fr"."derived_status" = 'blocked'), 0) OR COALESCE(("fr"."derived_status" = 'failed'), 0) OR COALESCE(("fr"."derived_status" = 'skipped'), 0) OR COALESCE(("fr"."derived_status" = 'abandoned'), 0)), 0) AND COALESCE(("fr"."acknowledged_at" = "fr"."acknowledged_at"), 0)), 0) THEN 'history' ELSE 'active' END AS list_bucket
|
|
65
|
+
FROM feature_runs__tracking fr;
|
|
@@ -5,6 +5,7 @@ import { test } from "node:test";
|
|
|
5
5
|
import { assert, assertEquals } from "#test-assert";
|
|
6
6
|
import type { AppApi } from "@nanobpm/urban";
|
|
7
7
|
import { noopLog } from "../test/log.ts";
|
|
8
|
+
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
8
9
|
import handler from "./listActivePrs.ts";
|
|
9
10
|
|
|
10
11
|
function memApp(rows: any[], escalations: any[] = []): AppApi {
|
|
@@ -24,7 +25,7 @@ function memApp(rows: any[], escalations: any[] = []): AppApi {
|
|
|
24
25
|
},
|
|
25
26
|
};
|
|
26
27
|
};
|
|
27
|
-
return { data: { table }, log: noopLog() } as any as AppApi;
|
|
28
|
+
return { data: { table: withTrackingViews(table) }, log: noopLog() } as any as AppApi;
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
function input(headers: Record<string, string> = {}) {
|
|
@@ -18,6 +18,7 @@ import { assertEquals } from "#test-assert";
|
|
|
18
18
|
import type { AppApi } from "@nanobpm/urban";
|
|
19
19
|
import { resetDefaultBranchCache } from "../app/github.ts";
|
|
20
20
|
import { noopLog } from "../test/log.ts";
|
|
21
|
+
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
21
22
|
import startEpicSet from "./startEpicSet.ts";
|
|
22
23
|
|
|
23
24
|
// ── in-memory github model (mirrors startPlanFanout.admission.integration.test.ts) ───────────────
|
|
@@ -116,7 +117,7 @@ function makeApp(seedPlans: Record<string, unknown>[] = []) {
|
|
|
116
117
|
};
|
|
117
118
|
};
|
|
118
119
|
const app = {
|
|
119
|
-
data: { table },
|
|
120
|
+
data: { table: withTrackingViews(table) },
|
|
120
121
|
engine: {
|
|
121
122
|
createInstance: (req: { processDefinitionId: string; variables?: Record<string, unknown> }) => {
|
|
122
123
|
started.push(req);
|
|
@@ -526,7 +527,7 @@ function makeSqliteApp(
|
|
|
526
527
|
delete: () => Promise.resolve(),
|
|
527
528
|
});
|
|
528
529
|
const app = {
|
|
529
|
-
data: { table },
|
|
530
|
+
data: { table: withTrackingViews(table) },
|
|
530
531
|
engine: { createInstance: () => Promise.resolve({ processInstanceKey: "PI-1" }) },
|
|
531
532
|
log: noopLog(),
|
|
532
533
|
} as any as AppApi;
|
|
@@ -9,6 +9,7 @@ import { assertEquals } from "#test-assert";
|
|
|
9
9
|
import type { AppApi } from "@nanobpm/urban";
|
|
10
10
|
import { resetDefaultBranchCache } from "../app/github.ts";
|
|
11
11
|
import { noopLog } from "../test/log.ts";
|
|
12
|
+
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
12
13
|
import startPlanFanout from "./startPlanFanout.ts";
|
|
13
14
|
|
|
14
15
|
// ── in-memory github model ───────────────────────────────────────────────────
|
|
@@ -112,7 +113,7 @@ function makeApp(seedPlans: Record<string, unknown>[] = []) {
|
|
|
112
113
|
};
|
|
113
114
|
};
|
|
114
115
|
const app = {
|
|
115
|
-
data: { table },
|
|
116
|
+
data: { table: withTrackingViews(table) },
|
|
116
117
|
engine: {
|
|
117
118
|
createInstance: (req: { processDefinitionId: string; variables?: Record<string, unknown> }) => {
|
|
118
119
|
started.push(req);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.133.
|
|
3
|
+
"version": "0.133.1",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
package/test/trackingViews.ts
CHANGED
|
@@ -30,7 +30,17 @@ export function withTrackingViews<F extends TableFn>(base: F): F {
|
|
|
30
30
|
const statusField = baseStatusFieldFor(baseName);
|
|
31
31
|
// biome-ignore lint/suspicious/noExplicitAny: test-only projection over dynamic row shapes.
|
|
32
32
|
const project = (row: any) =>
|
|
33
|
-
row == null
|
|
33
|
+
row == null
|
|
34
|
+
? row
|
|
35
|
+
: // Honor an explicitly-seeded `derived_status` so a test can model the ADR-0065 divergence a
|
|
36
|
+
// real terminated instance produces — the base `<statusField>` frozen at its last transient
|
|
37
|
+
// while the derive edge reports the terminal (`abandoned`/`failed`/`reviewed`). When a row
|
|
38
|
+
// seeds no derived column the VIEW's `ELSE base.<statusField>` fall-through applies, so it
|
|
39
|
+
// stays byte-for-byte the pass-through the previous behaviour modelled.
|
|
40
|
+
{
|
|
41
|
+
...row,
|
|
42
|
+
[derivedColumn]: row[derivedColumn] ?? row[statusField],
|
|
43
|
+
};
|
|
34
44
|
// biome-ignore lint/suspicious/noExplicitAny: test-only Proxy over a dynamic DataLayer table.
|
|
35
45
|
return new Proxy(inner, {
|
|
36
46
|
get(target: any, prop: string) {
|