@nanobpm/nano-workforce 0.124.0 → 0.125.0
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 +7 -0
- package/app/featureReadModel.test.ts +97 -54
- package/app/featureReadModel.ts +152 -0
- package/app/stage.ts +71 -77
- package/db/migrations/076_feature_read_model_declare_once.sql +53 -0
- package/package.json +2 -2
- package/scripts/check-migrations.test.ts +22 -0
- package/scripts/check-migrations.ts +32 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# [0.125.0](https://github.com/nanobpm/nano-workforce/compare/v0.124.0...v0.125.0) (2026-08-23)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **read-model:** declare feature_read_model once via Urban defineReadModel (ADR-0065 step 2) ([#472](https://github.com/nanobpm/nano-workforce/issues/472)) ([6424efb](https://github.com/nanobpm/nano-workforce/commit/6424efbeabec60366567e218f12edd996bb96780)), closes [#422](https://github.com/nanobpm/nano-workforce/issues/422)
|
|
7
|
+
|
|
1
8
|
# [0.124.0](https://github.com/nanobpm/nano-workforce/compare/v0.123.2...v0.124.0) (2026-08-23)
|
|
2
9
|
|
|
3
10
|
|
|
@@ -1,39 +1,41 @@
|
|
|
1
|
-
// Read-model
|
|
2
|
-
//
|
|
3
|
-
// VIEWs").
|
|
1
|
+
// Read-model coverage for the feature-run pipeline projection (issues #412 → #439 → #422), now
|
|
2
|
+
// authored via Urban's ADR-0065 declare-once primitive (`defineReadModel`, app/featureReadModel.ts).
|
|
4
3
|
//
|
|
5
|
-
// 039_feature_pipeline_stage.sql denormalised the
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// stored column and no write-path for any writer to leave stale. 075_feature_read_model_attention_
|
|
14
|
-
// from_user_tasks.sql then moves `attention` off the drift-prone `status` variable onto ENGINE TRUTH
|
|
15
|
-
// — an OPEN `feature-blocked`/`feature-escalation` row in the `user_tasks` inbox (issue #422).
|
|
4
|
+
// 039_feature_pipeline_stage.sql denormalised the projection (`stage`/`stage_state`/`stage_skipped`/
|
|
5
|
+
// `attention`/`list_bucket`) onto the `feature_runs` row at WRITE TIME; 073 retired that into a VIEW
|
|
6
|
+
// over each row's own columns; 075 moved `attention` onto ENGINE TRUTH (an OPEN `user_tasks` row,
|
|
7
|
+
// issue #422). Each of those hand-wired the derived columns TWICE — the SQL CASE/EXISTS AND the TS
|
|
8
|
+
// oracle (`deriveStage`/`deriveListBucket`) — kept in lockstep by a bespoke parity test (drift
|
|
9
|
+
// surface #2). 076_feature_read_model_declare_once.sql supersedes them: every derived column is now
|
|
10
|
+
// emitted from the ONE `featureReadModel` declaration, which ALSO drives the TS via `fnFor`. This
|
|
11
|
+
// suite therefore guards THREE things:
|
|
16
12
|
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
13
|
+
// 1. DRIFT GUARD — migration 076 embeds each derived column's SQL VERBATIM from
|
|
14
|
+
// `featureReadModel.sqlSelectFor(...)`, so the checked-in VIEW cannot drift from the declaration.
|
|
15
|
+
// 2. FRAMEWORK PARITY GUARD — `assertReadModelParity` proves the SQL and TS lowerings the ONE
|
|
16
|
+
// declaration compiles to agree (the role the old hand-written lockstep test played, now
|
|
17
|
+
// framework-owned).
|
|
18
|
+
// 3. END-TO-END BEHAVIOUR on the REAL migration VIEW (076 applied to an in-memory DB): the full
|
|
19
|
+
// status × open-task matrix vs the model-derived oracle, the stale-stored-column ignore, the
|
|
20
|
+
// reconciler `status`-bypass, the #422 answered-escalation drift, and the page binding.
|
|
23
21
|
import { readFileSync } from "node:fs";
|
|
24
22
|
import { DatabaseSync } from "node:sqlite";
|
|
25
23
|
import { test } from "node:test";
|
|
26
24
|
import { fileURLToPath } from "node:url";
|
|
25
|
+
import { assertReadModelParity, type ParityDb, type ParitySample } from "@nanobpm/urban";
|
|
27
26
|
import { assert, assertEquals } from "#test-assert";
|
|
28
27
|
import { FEATURE_RUN_STATUSES } from "./feature.ts";
|
|
28
|
+
import { FEATURE_READ_MODEL_BASE_ALIAS, FEATURE_READ_MODEL_DERIVED, featureReadModel } from "./featureReadModel.ts";
|
|
29
29
|
import { deriveListBucket, deriveStage } from "./stage.ts";
|
|
30
30
|
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
//
|
|
34
|
+
const MIGRATION_076 = "076_feature_read_model_declare_once.sql";
|
|
35
|
+
|
|
36
|
+
// The base `feature_runs` shape the VIEW reads, plus the `user_tasks` inbox (034) the `attention`
|
|
37
|
+
// derivation `EXISTS`-reads. The stored derived columns are present precisely so the tests can seed
|
|
38
|
+
// STALE values and prove the VIEW ignores them.
|
|
37
39
|
function viewDb(): DatabaseSync {
|
|
38
40
|
const db = new DatabaseSync(":memory:");
|
|
39
41
|
db.exec(
|
|
@@ -43,16 +45,12 @@ function viewDb(): DatabaseSync {
|
|
|
43
45
|
outcome TEXT, delivery_label TEXT, acknowledged_at TEXT, created_at TEXT, updated_at TEXT,
|
|
44
46
|
stage TEXT, stage_state TEXT, stage_skipped TEXT, attention TEXT, list_bucket TEXT);`,
|
|
45
47
|
);
|
|
46
|
-
// The `user_tasks` inbox (034_user_tasks_inbox.sql) — the engine-truth source the 075 VIEW derives
|
|
47
|
-
// `attention` from (a row IFF an escalation user task is OPEN). Minimal shape: the three columns the
|
|
48
|
-
// correlated EXISTS lookups read, plus its PK.
|
|
49
48
|
db.exec(
|
|
50
49
|
`CREATE TABLE user_tasks (
|
|
51
50
|
user_task_key TEXT PRIMARY KEY, element_id TEXT NOT NULL, subject_type TEXT NOT NULL,
|
|
52
51
|
subject_key TEXT NOT NULL);`,
|
|
53
52
|
);
|
|
54
|
-
db.exec(MIG(
|
|
55
|
-
db.exec(MIG("075_feature_read_model_attention_from_user_tasks.sql"));
|
|
53
|
+
db.exec(MIG(MIGRATION_076));
|
|
56
54
|
return db;
|
|
57
55
|
}
|
|
58
56
|
|
|
@@ -71,7 +69,7 @@ interface SampleRun {
|
|
|
71
69
|
auto_merge?: number;
|
|
72
70
|
acknowledged_at?: string | null;
|
|
73
71
|
// Deliberately-stale STORED projection columns (simulating a row the gateway last projected while
|
|
74
|
-
// in a different status). The VIEW must ignore these and re-derive
|
|
72
|
+
// in a different status). The VIEW must ignore these and re-derive.
|
|
75
73
|
stored?: Partial<Record<"stage" | "stage_state" | "stage_skipped" | "attention" | "list_bucket", string>>;
|
|
76
74
|
}
|
|
77
75
|
|
|
@@ -114,7 +112,67 @@ function projection(db: DatabaseSync, feature_key: string): Record<string, unkno
|
|
|
114
112
|
return { ...r };
|
|
115
113
|
}
|
|
116
114
|
|
|
117
|
-
|
|
115
|
+
// A `ParityDb` over node:sqlite's `DatabaseSync` for `assertReadModelParity` (which needs positional
|
|
116
|
+
// `exec`/`all`/`run`, whereas `DatabaseSync` exposes query methods on prepared statements).
|
|
117
|
+
function parityDb(db: DatabaseSync): ParityDb {
|
|
118
|
+
return {
|
|
119
|
+
exec: (sql) => db.exec(sql),
|
|
120
|
+
all: <T = Record<string, unknown>>(sql: string, params: unknown[] = []) =>
|
|
121
|
+
db.prepare(sql).all(...(params as never[])) as T[],
|
|
122
|
+
run: (sql, params: unknown[] = []) => {
|
|
123
|
+
const r = db.prepare(sql).run(...(params as never[]));
|
|
124
|
+
return { changes: Number(r.changes), lastInsertRowid: r.lastInsertRowid };
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
test("DRIFT GUARD: migration 076 embeds each derived column VERBATIM from featureReadModel.sqlSelectFor (the VIEW cannot drift from the declaration)", () => {
|
|
130
|
+
const sql = MIG(MIGRATION_076);
|
|
131
|
+
for (const col of FEATURE_READ_MODEL_DERIVED) {
|
|
132
|
+
const emitted = featureReadModel.sqlSelectFor(col, { baseAlias: FEATURE_READ_MODEL_BASE_ALIAS });
|
|
133
|
+
assert(
|
|
134
|
+
sql.includes(`${emitted} AS ${col}`),
|
|
135
|
+
`migration ${MIGRATION_076} no longer embeds the declaration's SQL for "${col}" — regenerate it ` +
|
|
136
|
+
`from featureReadModel (or add a new superseding migration). Expected to contain:\n ${emitted} AS ${col}`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
// The VIEW is a DROP+CREATE that supersedes 073/075, and keeps every base column as an aliased
|
|
140
|
+
// pass-through so the static pages↔schema contract guard still sees them.
|
|
141
|
+
assert(/DROP VIEW IF EXISTS feature_read_model;/.test(sql), "076 must DROP the superseded VIEW first");
|
|
142
|
+
assert(/CREATE VIEW feature_read_model AS/.test(sql), "076 must (re)create feature_read_model");
|
|
143
|
+
for (const base of ["feature_key", "status", "pr_key", "converge", "auto_merge", "acknowledged_at", "title", "repo"]) {
|
|
144
|
+
assert(sql.includes(`fr.${base} AS ${base}`), `076 must pass base column "${base}" through the VIEW`);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("FRAMEWORK PARITY GUARD: featureReadModel's SQL and TS lowerings agree over the full status × open-task matrix (assertReadModelParity)", () => {
|
|
149
|
+
const samples: ParitySample[] = [];
|
|
150
|
+
for (const status of FEATURE_RUN_STATUSES) {
|
|
151
|
+
for (const converge of [0, 1]) {
|
|
152
|
+
for (const auto_merge of [0, 1]) {
|
|
153
|
+
for (const pr_key of [null, "o/r#pr"]) {
|
|
154
|
+
for (const acknowledged_at of [null, "2026-02-02T00:00:00Z"]) {
|
|
155
|
+
const el = status === "escalated" ? "feature-escalation" : status === "awaiting_operator" ? "feature-blocked" : null;
|
|
156
|
+
for (const openTask of el !== null ? [false, true] : [false]) {
|
|
157
|
+
const userTasks =
|
|
158
|
+
openTask && el !== null ? [{ subject_type: "feature", subject_key: "self", element_id: el }] : [];
|
|
159
|
+
samples.push({
|
|
160
|
+
baseRow: { feature_key: "self", status, pr_key, converge, auto_merge, acknowledged_at },
|
|
161
|
+
projections: { user_tasks: userTasks },
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// A bare in-memory handle: the guard builds/drops its OWN TEMP fixtures, so it needs no schema.
|
|
170
|
+
const db = new DatabaseSync(":memory:");
|
|
171
|
+
assertReadModelParity(featureReadModel, parityDb(db), samples);
|
|
172
|
+
db.close();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("the migration 076 VIEW derives stage/stage_state/stage_skipped/attention EXACTLY like deriveStage, over every status × converge/auto_merge/pr_key × open-task combination", () => {
|
|
118
176
|
const db = viewDb();
|
|
119
177
|
const cases: Array<{ key: string; run: SampleRun; hasOpenBlockedTask: boolean; hasOpenEscalationTask: boolean }> = [];
|
|
120
178
|
let i = 0;
|
|
@@ -123,10 +181,7 @@ test("feature_read_model derives stage/stage_state/stage_skipped/attention EXACT
|
|
|
123
181
|
for (const auto_merge of [0, 1]) {
|
|
124
182
|
for (const pr_key of [null, `o/r#pr${i}`]) {
|
|
125
183
|
// The open-task dimension only matters for the two human-wait statuses (escalated/
|
|
126
|
-
// awaiting_operator)
|
|
127
|
-
// task-present and task-absent (the #422 drift case = the task already gone). Every other
|
|
128
|
-
// status ignores open tasks (`el` is null, so no task is ever created), so iterating the
|
|
129
|
-
// dimension there would only duplicate identical cases; iterate [false] alone.
|
|
184
|
+
// awaiting_operator); every other status ignores open tasks, so iterate [false] alone there.
|
|
130
185
|
const el = status === "escalated" ? "feature-escalation" : status === "awaiting_operator" ? "feature-blocked" : null;
|
|
131
186
|
for (const openTask of el !== null ? [false, true] : [false]) {
|
|
132
187
|
const key = `o/r#${i++}`;
|
|
@@ -162,7 +217,7 @@ test("feature_read_model derives stage/stage_state/stage_skipped/attention EXACT
|
|
|
162
217
|
}
|
|
163
218
|
});
|
|
164
219
|
|
|
165
|
-
test("
|
|
220
|
+
test("the migration 076 VIEW derives list_bucket EXACTLY like deriveListBucket (history iff terminal AND acknowledged)", () => {
|
|
166
221
|
const db = viewDb();
|
|
167
222
|
let i = 0;
|
|
168
223
|
const cases: Array<{ key: string; status: string; ackAt: string | null }> = [];
|
|
@@ -182,7 +237,7 @@ test("feature_read_model derives list_bucket EXACTLY like deriveListBucket (hist
|
|
|
182
237
|
}
|
|
183
238
|
});
|
|
184
239
|
|
|
185
|
-
test("
|
|
240
|
+
test("the migration 076 VIEW IGNORES any stale STORED projection columns — it reads only from status et al.", () => {
|
|
186
241
|
const db = viewDb();
|
|
187
242
|
// A merged run whose STORED columns lie (frozen from when it was `running`). The VIEW must re-derive.
|
|
188
243
|
addRun(db, "o/r#stale", {
|
|
@@ -202,10 +257,10 @@ test("feature_read_model IGNORES any stale STORED projection columns — it read
|
|
|
202
257
|
test("RED/GREEN GUARD #422: an ANSWERED escalation (status sticky 'escalated', no open user task) shows NO ⚠; the badge tracks the OPEN task, not status", () => {
|
|
203
258
|
// The `feature` process answer-loop returns the token to `implement-task` without resetting the
|
|
204
259
|
// `status` variable, so a run whose escalation was already answered still reads `status="escalated"`
|
|
205
|
-
// until its next agent job completes (observed live on merlin: feature instance 31779). The
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
260
|
+
// until its next agent job completes (observed live on merlin: feature instance 31779). The badge
|
|
261
|
+
// now derives from engine truth — the presence of an OPEN `feature-escalation` user task
|
|
262
|
+
// (`pollUserTasks` deletes the row the moment it is answered) — so it clears immediately regardless
|
|
263
|
+
// of the stale status.
|
|
209
264
|
const db = viewDb();
|
|
210
265
|
|
|
211
266
|
// Answered escalation: status STILL 'escalated' (stale) + a stored ⚠ that lied, but NO open task.
|
|
@@ -233,44 +288,32 @@ test("RED/GREEN GUARD #422: an ANSWERED escalation (status sticky 'escalated', n
|
|
|
233
288
|
test("RED/GREEN GUARD: a RAW-datasource feature_runs.status write (the instanceTracking reconciler bypass) leaves the projection CORRECT (stage=Done, terminal stage_state, attention=null, Dismiss renderable, still Active)", () => {
|
|
234
289
|
// Reproduce the framework `instanceTracking` reconciler class of bug: on a terminated (cancelled)
|
|
235
290
|
// process instance it writes `{status:"abandoned"}` to `feature_runs` through the RAW datasource,
|
|
236
|
-
// bypassing the (now retired) projecting `featureRuns` gateway.
|
|
237
|
-
// the
|
|
238
|
-
// values — the merlin symptom: a cancelled run wedged in Active as a live-looking `Implementing ⚠`,
|
|
239
|
-
// its Dismiss gated shut on a NULL stage_state. Because the projection is now a VIEW over `status`,
|
|
240
|
-
// the read model stays correct with no write-path for any writer to leave it stale.
|
|
291
|
+
// bypassing the (now retired) projecting `featureRuns` gateway. Because the projection is a VIEW over
|
|
292
|
+
// `status`, the read model stays correct with no write-path for any writer to leave it stale.
|
|
241
293
|
const db = viewDb();
|
|
242
|
-
// A live run mid-flight — its (soon-stale) stored projection says Implementing / ⚠ / active.
|
|
243
294
|
addRun(db, "o/r#kill", {
|
|
244
295
|
status: "running",
|
|
245
296
|
stored: { stage: "Implementing", stage_state: undefined, attention: undefined, list_bucket: "active" },
|
|
246
297
|
});
|
|
247
298
|
assertEquals(projection(db, "o/r#kill").stage, "Implementing", "precondition: live run renders Implementing");
|
|
248
299
|
|
|
249
|
-
// The reconciler flips status terminal via the RAW table — NOT the gateway. (Simulated with a raw
|
|
250
|
-
// UPDATE, exactly what the raw datasource emits.) It touches none of the display columns.
|
|
251
300
|
db.prepare("UPDATE feature_runs SET status = 'abandoned' WHERE feature_key = ?").run("o/r#kill");
|
|
252
301
|
|
|
253
302
|
const row = projection(db, "o/r#kill");
|
|
254
303
|
const oracle = deriveStage({ status: "abandoned", pr_key: null, converge: 0, auto_merge: 0 });
|
|
255
|
-
// The projection tracks `status` through the VIEW — the merlin drift can no longer happen.
|
|
256
304
|
assertEquals(row.stage, "Done", "an abandoned run is Done, not wedged at Implementing");
|
|
257
305
|
assertEquals(row.stage, oracle.stage);
|
|
258
306
|
assertEquals(row.stage_state, "failed", "abandoned renders a terminal FAILED state (was frozen NULL)");
|
|
259
307
|
assertEquals(row.stage_state, oracle.state);
|
|
260
308
|
assertEquals(row.attention, null, "the stale ⚠ badge is gone");
|
|
261
309
|
assertEquals(row.attention, oracle.attention);
|
|
262
|
-
// Dismiss's `showWhenField` is `stage_state`: a non-null terminal state makes it renderable.
|
|
263
310
|
assert(row.stage_state != null, "Dismiss is renderable (stage_state is non-null) so the run can be ticked off");
|
|
264
|
-
// Unacknowledged terminal → still Active (History only after the operator dismisses it).
|
|
265
311
|
assertEquals(row.list_bucket, "active", "a just-cancelled run sits in Active until dismissed");
|
|
266
312
|
assertEquals(row.list_bucket, deriveListBucket("abandoned", null));
|
|
267
313
|
});
|
|
268
314
|
|
|
269
315
|
test("the Feature page binds the derived feature_read_model VIEW (not the raw feature_runs table)", () => {
|
|
270
316
|
// `feature.page.json`'s runs grid is the ONLY thing making the UI consume the derived projection.
|
|
271
|
-
// `feature_runs` remains a valid schema table, so reverting this binding would leave every SQL-view
|
|
272
|
-
// test green while the display silently resumed reading the stale stored columns; pin it here
|
|
273
|
-
// (suppressed advisory feature.page.json — issue #439).
|
|
274
317
|
const page = PAGE("feature.page.json");
|
|
275
318
|
const runs = (page.nodes ?? []).find((n: { id: string }) => n.id === "feature-runs");
|
|
276
319
|
assert(runs, "feature page must keep the Feature runs grid");
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// The `feature_read_model` derived read model — DECLARED ONCE and compiled to BOTH backends via
|
|
2
|
+
// Urban's ADR-0065 reconciling-read-model primitive (`defineReadModel`, `@nanobpm/urban`).
|
|
3
|
+
//
|
|
4
|
+
// Background (issues #412 → #439 → #422). The Feature Runs surface renders DERIVED state — a run's
|
|
5
|
+
// pipeline `stage`, its `stage_state`/`stage_skipped`, its `attention` badge, its Active/History
|
|
6
|
+
// `list_bucket`. None of these are ground truth: each is a pure function of the row's own
|
|
7
|
+
// `status`/`pr_key`/`converge`/`auto_merge`/`acknowledged_at` plus, for `attention`, the engine's
|
|
8
|
+
// open-user-task set. #412/#439 retired the WRITE-TIME denormalised projection into a SQLite VIEW
|
|
9
|
+
// (`feature_read_model`) so no writer can freeze a stored column; #458 (migration 075) then re-pointed
|
|
10
|
+
// `attention` off the drift-prone `status` variable onto ENGINE TRUTH — an OPEN `user_tasks` row.
|
|
11
|
+
//
|
|
12
|
+
// Two drift surfaces survived that hand-wired VIEW (ADR-0065, nano-ide#452):
|
|
13
|
+
// * #2 — every derived column was authored TWICE: the SQL `CASE`/`EXISTS` inside the migration VIEW
|
|
14
|
+
// AND the TypeScript oracle (`deriveStage`/`deriveListBucket`, app/stage.ts), kept in lockstep by
|
|
15
|
+
// a hand-written parity test. The lockstep test does not remove the duplication; it only alarms
|
|
16
|
+
// when the two copies diverge.
|
|
17
|
+
// * #3 — the migration, the VIEW DDL and the parity test were all hand-wired per projection.
|
|
18
|
+
//
|
|
19
|
+
// This module closes surface #2 STRUCTURALLY (ADR-0065 rollout step 2 — "declare once → compile to
|
|
20
|
+
// both", no engine-truth change): each derivation is expressed ONCE in Urban's closed expression DSL,
|
|
21
|
+
// and Urban compiles it to BOTH the SQLite VIEW select-list (`sqlSelectFor` — emitted verbatim into
|
|
22
|
+
// migration 076, drift-guarded) AND the runtime TS function (`fnFor` — the sole engine behind the
|
|
23
|
+
// `deriveStage`/`deriveListBucket` adapters in app/stage.ts). There is nothing left to keep in
|
|
24
|
+
// lockstep: the two lowerings fall out of the SAME AST, and `assertReadModelParity` (app/
|
|
25
|
+
// featureReadModel.test.ts) is now a framework-owned regression guard that the two lowerings agree,
|
|
26
|
+
// not an app-authored mirror of two hand-maintained copies.
|
|
27
|
+
//
|
|
28
|
+
// SCOPE (step 2). The `attention` derivation reads the app's own `user_tasks` inbox by name (the same
|
|
29
|
+
// engine-truth source migration 075 used) via the DSL's `exists(...)`; promoting that to the framework
|
|
30
|
+
// canonical `urban_open_user_tasks` projection and inverting `instanceTracking` writer→source are the
|
|
31
|
+
// LATER ADR-0065 rollout steps (3/4), deliberately out of scope here.
|
|
32
|
+
|
|
33
|
+
import { and, caseWhen, col, defineReadModel, type Expr, eq, exists, lit, neq, not, or, pcol, type ReadModel, when } from "@nanobpm/urban";
|
|
34
|
+
|
|
35
|
+
/** The 6 TRULY-terminal statuses that map to the `Done` stage — the single source of truth for the
|
|
36
|
+
* terminal tier of BOTH the pipeline `stage`/`stage_state` derivations and the `list_bucket` history
|
|
37
|
+
* partition. Distinct from `FEATURE_TERMINAL_STATUSES` (app/feature.ts), the redispatch-settled set,
|
|
38
|
+
* which also counts `opened`/`converging` as terminal — those are LIVE pipeline stages (`PR open`/
|
|
39
|
+
* `Converging`), NOT `Done`, so the two lists must stay separate. Re-exported from app/stage.ts for
|
|
40
|
+
* back-compat with its existing importers. */
|
|
41
|
+
export const STAGE_DONE_STATUSES: readonly string[] = ["merged", "converged", "blocked", "failed", "skipped", "abandoned"];
|
|
42
|
+
|
|
43
|
+
/** The DSL projection name the `attention` derivation `exists(...)`-reads. Unregistered in the
|
|
44
|
+
* `projectionRegistry`, so it resolves to the same-named physical table — the app's own `user_tasks`
|
|
45
|
+
* inbox (034_user_tasks_inbox.sql), reconciled by `pollUserTasks` (app/service.ts): a row exists IFF a
|
|
46
|
+
* native operator user task is currently OPEN, deleted the moment it closes. (ADR-0065 step 3 will
|
|
47
|
+
* promote this to the framework's canonical `urban_open_user_tasks` projection.) */
|
|
48
|
+
export const USER_TASKS_PROJECTION = "user_tasks";
|
|
49
|
+
|
|
50
|
+
/** `status IN (…)` as a closed-DSL predicate: an OR of equalities over the base row's `status`. */
|
|
51
|
+
const statusIn = (...statuses: readonly string[]): Expr => or(...statuses.map((s) => eq(col("status"), lit(s))));
|
|
52
|
+
|
|
53
|
+
/** The terminal tier — the row's `status` is one of the 6 `Done` statuses. */
|
|
54
|
+
const isDone: Expr = statusIn(...STAGE_DONE_STATUSES);
|
|
55
|
+
|
|
56
|
+
/** The canonical pipeline `stage`. TOTAL over all 11 statuses. Terminal → `Done`; else `converging`
|
|
57
|
+
* → `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`. */
|
|
59
|
+
const stage: Expr = caseWhen(
|
|
60
|
+
[
|
|
61
|
+
when(isDone, lit("Done")),
|
|
62
|
+
when(eq(col("status"), lit("converging")), lit("Converging")),
|
|
63
|
+
when(or(neq(col("pr_key"), lit("")), eq(col("status"), lit("opened"))), lit("PR open")),
|
|
64
|
+
when(statusIn("running", "escalated", "awaiting_operator"), lit("Implementing")),
|
|
65
|
+
],
|
|
66
|
+
lit("Requested"),
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
/** The active stage's render state in the `kind:"pipeline"` column's vocabulary: `ok` (merged/
|
|
70
|
+
* converged), `blocked` (terminal blocked), `failed` (failed/skipped/abandoned), else NULL (in
|
|
71
|
+
* progress). A pure function of `status`, so it is correct even for a parked/live status (NULL). */
|
|
72
|
+
const stageState: Expr = caseWhen(
|
|
73
|
+
[
|
|
74
|
+
when(statusIn("merged", "converged"), lit("ok")),
|
|
75
|
+
when(eq(col("status"), lit("blocked")), lit("blocked")),
|
|
76
|
+
when(statusIn("failed", "skipped", "abandoned"), lit("failed")),
|
|
77
|
+
],
|
|
78
|
+
lit(null),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
/** The space-separated set of pipeline stages NOT in this row's path, purely from `converge`/
|
|
82
|
+
* `auto_merge`: no converge ⇒ both `Converging` and `Merging` are skipped; converge but no auto-merge
|
|
83
|
+
* ⇒ only `Merging`; else none. (`not(col(...))` mirrors the TS `!truthy(...)` under the shared
|
|
84
|
+
* "NULL → false" rule.) */
|
|
85
|
+
const stageSkipped: Expr = caseWhen(
|
|
86
|
+
[
|
|
87
|
+
when(not(col("converge")), lit("Converging Merging")),
|
|
88
|
+
when(not(col("auto_merge")), lit("Merging")),
|
|
89
|
+
],
|
|
90
|
+
lit(""),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
/** The correlation predicate for an OPEN operator user task of `elementId` on this feature run: a
|
|
94
|
+
* `user_tasks` row keyed `subject_type='feature'`, `subject_key=<this row's feature_key>` (how
|
|
95
|
+
* `pollUserTasks` keys them — app/service.ts `DEFAULT_SUBJECT_TYPE`/`contextFor`). */
|
|
96
|
+
const openTaskWhere = (elementId: string): Expr =>
|
|
97
|
+
and(eq(pcol("subject_type"), lit("feature")), eq(pcol("subject_key"), col("feature_key")), eq(pcol("element_id"), lit(elementId)));
|
|
98
|
+
|
|
99
|
+
/** The `attention` badge, derived from ENGINE TRUTH — the presence of an OPEN native user task — NOT
|
|
100
|
+
* from the drift-prone `status` variable (issue #422). `blocked` glyph IFF an open `feature-blocked`
|
|
101
|
+
* task exists; `⚠` IFF an open `feature-escalation` task exists; else no badge. Once a task is
|
|
102
|
+
* answered its `user_tasks` row is gone, so the badge clears immediately even while `status` still
|
|
103
|
+
* reads a stale `"escalated"` on the answer-loop back into `implement-task`. */
|
|
104
|
+
const attention: Expr = caseWhen(
|
|
105
|
+
[
|
|
106
|
+
when(exists(USER_TASKS_PROJECTION, openTaskWhere("feature-blocked")), lit("blocked")),
|
|
107
|
+
when(exists(USER_TASKS_PROJECTION, openTaskWhere("feature-escalation")), lit("⚠")),
|
|
108
|
+
],
|
|
109
|
+
lit(null),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
/** `<col> IS NOT NULL` in the closed DSL, which has no dedicated null-test operator: a SELF-equality.
|
|
113
|
+
* `eq` collapses a nullish operand to false in BOTH backends (`COALESCE(x = x, 0)` in SQL, the nullish
|
|
114
|
+
* guard in `compareValues` for TS), and any NON-null value equals itself, so this is true IFF the column
|
|
115
|
+
* is non-NULL — faithful to 073/075's `acknowledged_at IS NOT NULL` and free of the SQLite string→number
|
|
116
|
+
* truthiness coercion a bare `col(...)` boolean predicate would otherwise rely on (e.g. `''`/`'abc'`). */
|
|
117
|
+
const isNotNull = (name: string): Expr => eq(col(name), col(name));
|
|
118
|
+
|
|
119
|
+
/** The Active/History partition: `history` IFF the row is in a truly-terminal status AND has been
|
|
120
|
+
* acknowledged; otherwise `active` (live runs + terminal-but-UNACKNOWLEDGED runs). `acknowledged_at IS
|
|
121
|
+
* NOT NULL` is expressed via {@link isNotNull} so it stays byte-equivalent to 073/075's VIEW and does
|
|
122
|
+
* not depend on string→number coercion in either backend. */
|
|
123
|
+
const listBucket: Expr = caseWhen([when(and(isDone, isNotNull("acknowledged_at")), lit("history"))], lit("active"));
|
|
124
|
+
|
|
125
|
+
/** The keys of {@link featureReadModel}'s DERIVED columns, in the order migration 076 emits them.
|
|
126
|
+
* Base columns are identity pass-throughs (not derivations) and are listed in the migration directly. */
|
|
127
|
+
export const FEATURE_READ_MODEL_DERIVED = ["stage", "stage_state", "stage_skipped", "attention", "list_bucket"] as const;
|
|
128
|
+
export type FeatureReadModelDerivedColumn = (typeof FEATURE_READ_MODEL_DERIVED)[number];
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The declare-once `feature_read_model` derived columns. `selectBaseColumns: false` because the base
|
|
132
|
+
* columns are plain identity pass-throughs enumerated in migration 076 (so the static pages↔schema
|
|
133
|
+
* contract guard, which reads a VIEW's columns off an aliased select-list, sees them); this model owns
|
|
134
|
+
* only the five real DERIVATIONS. Both the migration VIEW (`sqlSelectFor`, drift-guarded) and the
|
|
135
|
+
* runtime TS oracle (`fnFor`, behind app/stage.ts) are generated from THIS single declaration.
|
|
136
|
+
*/
|
|
137
|
+
export const featureReadModel: ReadModel = defineReadModel({
|
|
138
|
+
name: "feature_read_model",
|
|
139
|
+
baseTable: "feature_runs",
|
|
140
|
+
selectBaseColumns: false,
|
|
141
|
+
derive: {
|
|
142
|
+
stage,
|
|
143
|
+
stage_state: stageState,
|
|
144
|
+
stage_skipped: stageSkipped,
|
|
145
|
+
attention,
|
|
146
|
+
list_bucket: listBucket,
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
/** The base alias the managed VIEW gives `feature_runs` — pinned so the emitted derived-column SQL
|
|
151
|
+
* (`fr."col"`) matches migration 076 exactly (the drift guard compares against this alias). */
|
|
152
|
+
export const FEATURE_READ_MODEL_BASE_ALIAS = "fr";
|
package/app/stage.ts
CHANGED
|
@@ -1,41 +1,36 @@
|
|
|
1
|
-
// Canonical feature-run pipeline stage model (issue #254 §1) — the
|
|
2
|
-
// derived pipeline surface the Feature view
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// exactly as `delivery_label` is projected — so the declarative dataGrid page consumes ready, stored
|
|
7
|
-
// columns (bound by `{"field":…}`) and never has to call TS or express OR/null in its flat filter DSL.
|
|
1
|
+
// Canonical feature-run pipeline stage model (issue #254 §1) — the ergonomic TS façade over the ONE
|
|
2
|
+
// derived-read-model declaration in app/featureReadModel.ts. The pipeline surface the Feature view
|
|
3
|
+
// renders (`stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket`) is DERIVED, never ground
|
|
4
|
+
// truth: each column is a pure function of a feature run's own `status`/`pr_key`/`converge`/
|
|
5
|
+
// `auto_merge`/`acknowledged_at` plus, for `attention`, the engine's OPEN-user-task set.
|
|
8
6
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
7
|
+
// SINGLE SOURCE OF TRUTH (ADR-0065, nano-ide#452). These derivations are declared ONCE, in Urban's
|
|
8
|
+
// closed expression DSL (`featureReadModel`, app/featureReadModel.ts), and compiled to BOTH the SQLite
|
|
9
|
+
// VIEW (migration 076) and the runtime TS functions used here. `deriveStage`/`deriveListBucket` are
|
|
10
|
+
// now thin ADAPTERS that evaluate that declaration's compiled functions (`fnFor`) — they do NOT
|
|
11
|
+
// re-express the CASE/EXISTS logic in TypeScript, so the SQL and TS lowerings cannot drift (the former
|
|
12
|
+
// hand-written parity test becomes the framework-owned `assertReadModelParity` regression guard). The
|
|
13
|
+
// mapping remains TOTAL and DETERMINISTIC over all 11 FEATURE_RUN_STATUSES. Do NOT duplicate it
|
|
14
|
+
// anywhere (not in SQL, not in the page, not in each poller/worker): every reader flows through the
|
|
15
|
+
// VIEW or these adapters, both sourced from the one declaration.
|
|
16
|
+
|
|
17
|
+
import { type FeatureReadModelDerivedColumn, featureReadModel, STAGE_DONE_STATUSES, USER_TASKS_PROJECTION } from "./featureReadModel.ts";
|
|
18
|
+
|
|
19
|
+
// Re-exported for back-compat with existing importers (operations/acknowledgeDone.ts). Its canonical
|
|
20
|
+
// home is now app/featureReadModel.ts, where it feeds the terminal tier of the derived columns.
|
|
21
|
+
export { STAGE_DONE_STATUSES };
|
|
14
22
|
|
|
15
23
|
/** The canonical pipeline stage keys, in path order. `Merging` is a path/visual stage the renderer
|
|
16
24
|
* fills as upcoming — no status maps to it as the ACTIVE stage (intentional). */
|
|
17
25
|
export const STAGE_KEYS = ["Requested", "Implementing", "PR open", "Converging", "Merging", "Done"] as const;
|
|
18
26
|
export type StageKey = (typeof STAGE_KEYS)[number];
|
|
19
27
|
|
|
20
|
-
/** The active stage's render state, in the urban
|
|
28
|
+
/** The active stage's render state, in the urban `kind:"pipeline"` column's EXACT vocabulary:
|
|
21
29
|
* `ok` (Done ✓ success), `failed` (Done ✕ failure), `blocked` (blocked glyph), or `null` (in-progress
|
|
22
30
|
* → the renderer treats it as `active`). Any OTHER string silently degrades to `active` in the
|
|
23
31
|
* renderer, so a failed run MUST emit `'failed'` (not `'fail'`) to render as a failure. */
|
|
24
32
|
export type StageState = "ok" | "failed" | "blocked" | null;
|
|
25
33
|
|
|
26
|
-
/** The 6 TRULY-terminal statuses that map to the `Done` stage. Distinct from
|
|
27
|
-
* `FEATURE_TERMINAL_STATUSES` (app/feature.ts), which is the redispatch-settled set and also counts
|
|
28
|
-
* `opened`/`converging` as terminal — those are LIVE pipeline stages (`PR open`/`Converging`), NOT
|
|
29
|
-
* Done, so this list must stay separate. Also the basis of the `list_bucket` history partition. */
|
|
30
|
-
export const STAGE_DONE_STATUSES: readonly string[] = [
|
|
31
|
-
"merged",
|
|
32
|
-
"converged",
|
|
33
|
-
"blocked",
|
|
34
|
-
"failed",
|
|
35
|
-
"skipped",
|
|
36
|
-
"abandoned",
|
|
37
|
-
];
|
|
38
|
-
|
|
39
34
|
/** The subset of a FeatureRun `deriveStage` reads. FeatureRun (app/feature.ts) structurally satisfies
|
|
40
35
|
* this; keeping the input structural avoids a stage.ts ↔ feature.ts import cycle. */
|
|
41
36
|
export interface StageInput {
|
|
@@ -48,7 +43,8 @@ export interface StageInput {
|
|
|
48
43
|
* authoritative "who is waiting on a human" set). `attention` derives from THESE, never from the
|
|
49
44
|
* drift-prone `status` variable — so once an escalation is answered (its `user_tasks` row deleted)
|
|
50
45
|
* the badge clears immediately even while `status` still reads a stale `"escalated"`. Omitted/false
|
|
51
|
-
* ⇒ no open task ⇒ no badge.
|
|
46
|
+
* ⇒ no open task ⇒ no badge. These booleans are lowered into synthetic `user_tasks` projection rows
|
|
47
|
+
* so this façade evaluates the SAME `exists(...)` derivation the VIEW compiles. */
|
|
52
48
|
hasOpenBlockedTask?: boolean | null;
|
|
53
49
|
hasOpenEscalationTask?: boolean | null;
|
|
54
50
|
}
|
|
@@ -62,60 +58,58 @@ export interface DerivedStage {
|
|
|
62
58
|
attention: string | null;
|
|
63
59
|
}
|
|
64
60
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
*
|
|
69
|
-
|
|
70
|
-
const { status } = run;
|
|
61
|
+
/** The synthetic correlation key threaded through the `attention` derivation: the base row's
|
|
62
|
+
* `feature_key` and each synthesised open-task row's `subject_key` share this value so the compiled
|
|
63
|
+
* `exists(... WHERE subject_key = feature_key ...)` predicate matches. Its concrete value is
|
|
64
|
+
* immaterial (it never leaves this function); it only has to be consistent between the two. */
|
|
65
|
+
const SELF_KEY = "self";
|
|
71
66
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
: status === "blocked"
|
|
82
|
-
? "blocked"
|
|
83
|
-
: "failed"; // failed / skipped / abandoned
|
|
84
|
-
} else {
|
|
85
|
-
// LIVE/PARKED tier — one shared rule for every non-terminal status. `escalated`/`awaiting_operator`
|
|
86
|
-
// are parked but their stored fields still describe WHERE in the pipeline they stalled, so they run
|
|
87
|
-
// through the same rule as a live run; their attention comes from the badge (below), not the stage.
|
|
88
|
-
if (status === "converging") stage = "Converging";
|
|
89
|
-
else if ((run.pr_key ?? "") !== "" || status === "opened") stage = "PR open";
|
|
90
|
-
else if (status === "running" || status === "escalated" || status === "awaiting_operator") stage = "Implementing";
|
|
91
|
-
else stage = "Requested";
|
|
92
|
-
state = null;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// `skipped`: stages not in this row's path, purely from converge/auto_merge.
|
|
96
|
-
const converge = truthy(run.converge);
|
|
97
|
-
const autoMerge = truthy(run.auto_merge);
|
|
98
|
-
const skippedKeys: StageKey[] = !converge ? ["Converging", "Merging"] : !autoMerge ? ["Merging"] : [];
|
|
67
|
+
/** The `user_tasks` projection rows a `StageInput`'s open-task booleans stand for — one row per OPEN
|
|
68
|
+
* operator user task, keyed exactly as `pollUserTasks` records them, so the compiled `attention`
|
|
69
|
+
* derivation sees the same engine-truth shape at runtime and here. */
|
|
70
|
+
function openTaskRows(run: StageInput): Array<Record<string, unknown>> {
|
|
71
|
+
const rows: Array<Record<string, unknown>> = [];
|
|
72
|
+
if (run.hasOpenBlockedTask === true) rows.push({ subject_type: "feature", subject_key: SELF_KEY, element_id: "feature-blocked" });
|
|
73
|
+
if (run.hasOpenEscalationTask === true) rows.push({ subject_type: "feature", subject_key: SELF_KEY, element_id: "feature-escalation" });
|
|
74
|
+
return rows;
|
|
75
|
+
}
|
|
99
76
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
// IFF the task is open and deletes it the moment it is answered — so a run shows the blocked glyph
|
|
109
|
-
// IFF an open `feature-blocked` task exists, and ⚠ IFF an open `feature-escalation` task exists.
|
|
110
|
-
// Once answered, the row is gone and the badge clears regardless of the stale `status`.
|
|
111
|
-
const attention = truthy(run.hasOpenBlockedTask) ? "blocked" : truthy(run.hasOpenEscalationTask) ? "⚠" : null;
|
|
77
|
+
/** Evaluate one derived column from the ONE `featureReadModel` declaration for a base row (plus any
|
|
78
|
+
* projection rows the derivation correlates against). This is the single seam where the framework's
|
|
79
|
+
* compiled-function boundary is crossed: `fnFor(column)` returns `unknown` (a derivation can compile to
|
|
80
|
+
* any DSL value), while the caller statically knows the declared `lit(...)` shape of each column. */
|
|
81
|
+
function evalDerived<T>(column: FeatureReadModelDerivedColumn, baseRow: Record<string, unknown>, projections?: Record<string, Array<Record<string, unknown>>>): T {
|
|
82
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary — `fnFor` returns `unknown`; each derived column yields one of its declared DSL `lit(...)` values, whose type the caller knows.
|
|
83
|
+
return featureReadModel.fnFor(column)(baseRow, projections) as T;
|
|
84
|
+
}
|
|
112
85
|
|
|
113
|
-
|
|
86
|
+
/** Derive the canonical pipeline stage, its render state, its not-in-path set, and its attention badge
|
|
87
|
+
* for one feature run. Pure and read-only — TOTAL over all 11 statuses (never returns undefined). The
|
|
88
|
+
* four columns are evaluated from the ONE `featureReadModel` declaration (app/featureReadModel.ts), so
|
|
89
|
+
* this façade and the SQLite VIEW compute byte-identical values by construction. */
|
|
90
|
+
export function deriveStage(run: StageInput): DerivedStage {
|
|
91
|
+
const baseRow = {
|
|
92
|
+
feature_key: SELF_KEY,
|
|
93
|
+
status: run.status,
|
|
94
|
+
pr_key: run.pr_key ?? null,
|
|
95
|
+
converge: run.converge ?? null,
|
|
96
|
+
auto_merge: run.auto_merge ?? null,
|
|
97
|
+
};
|
|
98
|
+
const projections = { [USER_TASKS_PROJECTION]: openTaskRows(run) };
|
|
99
|
+
return {
|
|
100
|
+
stage: evalDerived<StageKey>("stage", baseRow, projections),
|
|
101
|
+
state: evalDerived<StageState>("stage_state", baseRow, projections),
|
|
102
|
+
skipped: evalDerived<string>("stage_skipped", baseRow, projections),
|
|
103
|
+
attention: evalDerived<string | null>("attention", baseRow, projections),
|
|
104
|
+
};
|
|
114
105
|
}
|
|
115
106
|
|
|
116
|
-
/** The Active/History partition label (§5)
|
|
117
|
-
*
|
|
118
|
-
*
|
|
107
|
+
/** The Active/History partition label (§5): `history` iff the row is in a truly-terminal status AND
|
|
108
|
+
* acknowledged; otherwise `active` (live runs + terminal-but-UNACKNOWLEDGED runs). DERIVED on read from
|
|
109
|
+
* the ONE `featureReadModel` `list_bucket` declaration — the same AST the VIEW compiles (migration 076),
|
|
110
|
+
* NOT a write-time projection: the page's tabs filter the VIEW's derived `list_bucket`, and the stored
|
|
111
|
+
* `feature_runs.list_bucket` base column is vestigial (retired as a write projection, issue #439). This
|
|
112
|
+
* adapter is the TS lowering of that derivation, used off the write path (redispatch gating, tests). */
|
|
119
113
|
export function deriveListBucket(status: string, acknowledgedAt: string | null | undefined): "active" | "history" {
|
|
120
|
-
return
|
|
114
|
+
return evalDerived<"active" | "history">("list_bucket", { status, acknowledged_at: acknowledgedAt ?? null });
|
|
121
115
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
-- Feature-run read model: DECLARE ONCE, compile to BOTH backends (ADR-0065, nano-ide#452).
|
|
2
|
+
--
|
|
3
|
+
-- This migration SUPERSEDES the hand-wired `feature_read_model` VIEW of 073_feature_read_model.sql and
|
|
4
|
+
-- 075_feature_read_model_attention_from_user_tasks.sql. Those authored each derived column TWICE — the
|
|
5
|
+
-- SQL CASE/EXISTS here AND the TypeScript oracle (deriveStage/deriveListBucket, app/stage.ts) — kept in
|
|
6
|
+
-- lockstep by a hand-written parity test (drift surface #2, ADR-0065). This VIEW is no longer
|
|
7
|
+
-- hand-written: every DERIVED column body below is emitted VERBATIM from the ONE declaration in
|
|
8
|
+
-- app/featureReadModel.ts (`featureReadModel.sqlSelectFor(col, { baseAlias: "fr" })`), which ALSO drives the
|
|
9
|
+
-- runtime TS via `fnFor` — the two lowerings fall out of the same closed-DSL AST and cannot diverge.
|
|
10
|
+
-- A drift guard (app/featureReadModel.test.ts) fails if this file stops matching the declaration, and
|
|
11
|
+
-- `assertReadModelParity` proves the SQL and TS lowerings agree.
|
|
12
|
+
--
|
|
13
|
+
-- SEMANTICS ARE UNCHANGED from 075: `attention` still derives from ENGINE TRUTH — an OPEN native user
|
|
14
|
+
-- task in the `user_tasks` inbox (a `feature-blocked` row => blocked glyph, a `feature-escalation` row
|
|
15
|
+
-- => `⚠`), NOT the drift-prone `status` variable (issue #422); `stage`/`stage_state`/`stage_skipped`/
|
|
16
|
+
-- `list_bucket` are the same pure functions of the row. Only the AUTHORING moves to the framework
|
|
17
|
+
-- primitive; the projected values are identical.
|
|
18
|
+
--
|
|
19
|
+
-- Forward-only VIEW redefinition (DROP then CREATE). 073/075 are MERGED, IMMUTABLE migrations — never
|
|
20
|
+
-- edited; this is a NEW migration superseding their VIEW body. `feature_runs` (028+) and `user_tasks`
|
|
21
|
+
-- (034) already exist earlier in the chain, and 075 already created the supporting
|
|
22
|
+
-- `idx_user_tasks_subject_element` index the correlated attention EXISTS lookups seek, so it is not
|
|
23
|
+
-- re-created here. Base columns are plain identity pass-throughs (aliased so the static pages<->schema
|
|
24
|
+
-- contract guard sees the VIEW columns); `feature_runs fr` stays the sole top-level FROM (the user_tasks
|
|
25
|
+
-- lookups are nested EXISTS subqueries at paren depth >= 1). The runner wraps each file in its own
|
|
26
|
+
-- transaction, so this file must NOT contain BEGIN/COMMIT.
|
|
27
|
+
|
|
28
|
+
DROP VIEW IF EXISTS feature_read_model;
|
|
29
|
+
|
|
30
|
+
CREATE VIEW feature_read_model AS
|
|
31
|
+
SELECT
|
|
32
|
+
fr.feature_key AS feature_key,
|
|
33
|
+
fr.repo AS repo,
|
|
34
|
+
fr.issue_number AS issue_number,
|
|
35
|
+
fr.issue_url AS issue_url,
|
|
36
|
+
fr.title AS title,
|
|
37
|
+
fr.base_branch AS base_branch,
|
|
38
|
+
fr.status AS status,
|
|
39
|
+
fr.process_key AS process_key,
|
|
40
|
+
fr.pr_key AS pr_key,
|
|
41
|
+
fr.converge AS converge,
|
|
42
|
+
fr.auto_merge AS auto_merge,
|
|
43
|
+
fr.outcome AS outcome,
|
|
44
|
+
fr.delivery_label AS delivery_label,
|
|
45
|
+
fr.acknowledged_at AS acknowledged_at,
|
|
46
|
+
fr.created_at AS created_at,
|
|
47
|
+
fr.updated_at AS updated_at,
|
|
48
|
+
CASE WHEN COALESCE((COALESCE(("fr"."status" = 'merged'), 0) OR COALESCE(("fr"."status" = 'converged'), 0) OR COALESCE(("fr"."status" = 'blocked'), 0) OR COALESCE(("fr"."status" = 'failed'), 0) OR COALESCE(("fr"."status" = 'skipped'), 0) OR COALESCE(("fr"."status" = 'abandoned'), 0)), 0) THEN 'Done' WHEN COALESCE(("fr"."status" = 'converging'), 0) THEN 'Converging' WHEN COALESCE((COALESCE(("fr"."pr_key" <> ''), 0) OR COALESCE(("fr"."status" = 'opened'), 0)), 0) THEN 'PR open' WHEN COALESCE((COALESCE(("fr"."status" = 'running'), 0) OR COALESCE(("fr"."status" = 'escalated'), 0) OR COALESCE(("fr"."status" = 'awaiting_operator'), 0)), 0) THEN 'Implementing' ELSE 'Requested' END AS stage,
|
|
49
|
+
CASE WHEN COALESCE((COALESCE(("fr"."status" = 'merged'), 0) OR COALESCE(("fr"."status" = 'converged'), 0)), 0) THEN 'ok' WHEN COALESCE(("fr"."status" = 'blocked'), 0) THEN 'blocked' WHEN COALESCE((COALESCE(("fr"."status" = 'failed'), 0) OR COALESCE(("fr"."status" = 'skipped'), 0) OR COALESCE(("fr"."status" = 'abandoned'), 0)), 0) THEN 'failed' ELSE NULL END AS stage_state,
|
|
50
|
+
CASE WHEN (NOT COALESCE("fr"."converge", 0)) THEN 'Converging Merging' WHEN (NOT COALESCE("fr"."auto_merge", 0)) THEN 'Merging' ELSE '' END AS stage_skipped,
|
|
51
|
+
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,
|
|
52
|
+
CASE WHEN COALESCE((COALESCE((COALESCE(("fr"."status" = 'merged'), 0) OR COALESCE(("fr"."status" = 'converged'), 0) OR COALESCE(("fr"."status" = 'blocked'), 0) OR COALESCE(("fr"."status" = 'failed'), 0) OR COALESCE(("fr"."status" = 'skipped'), 0) OR COALESCE(("fr"."status" = 'abandoned'), 0)), 0) AND COALESCE(("fr"."acknowledged_at" = "fr"."acknowledged_at"), 0)), 0) THEN 'history' ELSE 'active' END AS list_bucket
|
|
53
|
+
FROM feature_runs fr;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.125.0",
|
|
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",
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
},
|
|
60
60
|
"dependencies": {
|
|
61
61
|
"@nanobpm/agentic": "^0.1.0",
|
|
62
|
-
"@nanobpm/urban": "^0.
|
|
62
|
+
"@nanobpm/urban": "^0.80.0",
|
|
63
63
|
"bpmn-auto-layout": "^2.0.0-alpha.2"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
@@ -103,3 +103,25 @@ test("collision detector flags a non-NNN shape and grandfathers historical dupes
|
|
|
103
103
|
"grandfathered prefix 075 is not a new violation",
|
|
104
104
|
);
|
|
105
105
|
});
|
|
106
|
+
|
|
107
|
+
// The grandfather is keyed by the EXACT historical FILENAME set, not just the prefix (Copilot review,
|
|
108
|
+
// #472): pardoning a prefix wholesale would let any future `075_*.sql` slip in unnoticed, defeating the
|
|
109
|
+
// gate for that slot forever. A THIRD file taking a grandfathered prefix is a genuinely NEW collision
|
|
110
|
+
// and must still fail — only the specific already-merged files are exempt.
|
|
111
|
+
test("a NEW file joining a grandfathered prefix is still caught (exact-filename exemption)", () => {
|
|
112
|
+
// The two real, pardoned 075 files alone: exempt.
|
|
113
|
+
assertEquals(
|
|
114
|
+
collisionErrorsFromFiles(["075_delivery_graph_proposals.sql", "075_feature_read_model_attention_from_user_tasks.sql"]),
|
|
115
|
+
[],
|
|
116
|
+
"the exact historical 075 pair stays exempt",
|
|
117
|
+
);
|
|
118
|
+
// A third file at the same grandfathered prefix is NEW → must fail, naming the offender only.
|
|
119
|
+
const errors = collisionErrorsFromFiles([
|
|
120
|
+
"075_delivery_graph_proposals.sql",
|
|
121
|
+
"075_feature_read_model_attention_from_user_tasks.sql",
|
|
122
|
+
"075_someone_elses_next_free_prefix.sql",
|
|
123
|
+
]);
|
|
124
|
+
assertEquals(errors.length, 1, "a new file on a grandfathered prefix is one violation");
|
|
125
|
+
assert(/prefix 075/.test(errors[0]));
|
|
126
|
+
assert(/075_someone_elses_next_free_prefix.sql is NEW/.test(errors[0]), "the NEW file is named as the offender");
|
|
127
|
+
});
|
|
@@ -52,6 +52,7 @@ const MIGRATIONS_DIR = join(REPO_ROOT, "db", "migrations");
|
|
|
52
52
|
// boot, issue #357). The two create disjoint tables (`worker_durable_resume`, `plan_conformance`), so
|
|
53
53
|
// apply order is irrelevant. Grandfather 052; any NEW duplicate prefix still fails the build.
|
|
54
54
|
//
|
|
55
|
+
//
|
|
55
56
|
// 075 is the same merge-skew story across two PRs that never saw each other (issue #470): #458 landed
|
|
56
57
|
// `075_feature_read_model_attention_from_user_tasks` and #460/#463 landed `075_delivery_graph_proposals`,
|
|
57
58
|
// each the branch-local "next" prefix, colliding silently only once both were on main (releases then
|
|
@@ -59,9 +60,23 @@ const MIGRATIONS_DIR = join(REPO_ROOT, "db", "migrations");
|
|
|
59
60
|
// a merged migration would re-run it and abort boot, and the immutability check would itself flag the
|
|
60
61
|
// rename. They create DISJOINT objects (`075_delivery_graph_proposals` adds the `delivery_graph_proposals`
|
|
61
62
|
// table + its indexes; `075_feature_read_model_…` redefines the `feature_read_model` VIEW and adds one
|
|
62
|
-
// `user_tasks` index), so their relative apply order is irrelevant. Grandfather 075
|
|
63
|
-
//
|
|
64
|
-
|
|
63
|
+
// `user_tasks` index), so their relative apply order is irrelevant. Grandfather 075 (the next migration
|
|
64
|
+
// is 076, which supersedes 075's `feature_read_model` VIEW body — a NEW, unique prefix).
|
|
65
|
+
//
|
|
66
|
+
// The exemption is keyed by the EXACT set of colliding FILENAMES per prefix, not merely the prefix, so
|
|
67
|
+
// it only pardons the specific historical files that already merged — a THIRD file taking a
|
|
68
|
+
// grandfathered prefix (a fresh 075_*.sql, say) is NOT in the set and still fails the gate. This keeps
|
|
69
|
+
// "any NEW duplicate prefix still fails" literally true even for prefixes that already carry a pardoned
|
|
70
|
+
// collision (Copilot review, #472).
|
|
71
|
+
const GRANDFATHERED_DUPES: ReadonlyMap<string, ReadonlySet<string>> = new Map([
|
|
72
|
+
["004", new Set(["004_merge.sql", "004_planning.sql"])],
|
|
73
|
+
["005", new Set(["005_job_activation.sql", "005_plan_deps.sql"])],
|
|
74
|
+
["006", new Set(["006_plan_review.sql", "006_task_escalation.sql"])],
|
|
75
|
+
["007", new Set(["007_plan_review_job_key.sql", "007_wave_gate.sql"])],
|
|
76
|
+
["049", new Set(["049_drop_feature_escalation_surface.sql", "049_plan_task_needs.sql", "049_world_checkpoint.sql"])],
|
|
77
|
+
["052", new Set(["052_plan_conformance.sql", "052_worker_durable_resume.sql"])],
|
|
78
|
+
["075", new Set(["075_delivery_graph_proposals.sql", "075_feature_read_model_attention_from_user_tasks.sql"])],
|
|
79
|
+
]);
|
|
65
80
|
|
|
66
81
|
const PREFIX = /^(\d{3})_[^/]*\.sql$/;
|
|
67
82
|
|
|
@@ -185,13 +200,22 @@ export function collisionErrorsFromFiles(files: readonly string[]): string[] {
|
|
|
185
200
|
}
|
|
186
201
|
|
|
187
202
|
for (const [prefix, group] of byPrefix) {
|
|
188
|
-
if (group.length
|
|
189
|
-
|
|
190
|
-
|
|
203
|
+
if (group.length <= 1) continue;
|
|
204
|
+
// Keyed by the EXACT historical filename set: a grandfathered prefix pardons ONLY those specific
|
|
205
|
+
// already-merged files. Any file in the group NOT in that set is a NEW collision and still fails.
|
|
206
|
+
const exempt = GRANDFATHERED_DUPES.get(prefix);
|
|
207
|
+
const unexpected = exempt ? group.filter((f) => !exempt.has(f)) : group;
|
|
208
|
+
if (unexpected.length === 0) continue;
|
|
209
|
+
errors.push(
|
|
210
|
+
exempt
|
|
211
|
+
? ` prefix ${prefix} is used by ${group.length} files: ${[...group].sort().join(", ")} — only the ` +
|
|
212
|
+
`grandfathered historical set (${[...exempt].sort().join(", ")}) may share this prefix; ` +
|
|
213
|
+
`${[...unexpected].sort().join(", ")} is NEW. Renumber it to the next free prefix ` +
|
|
214
|
+
`(check origin/main, not your branch point).`
|
|
215
|
+
: ` prefix ${prefix} is used by ${group.length} files: ${[...group].sort().join(", ")} — ` +
|
|
191
216
|
`two migrations cannot share an apply-order slot. Renumber the newer one to the next ` +
|
|
192
217
|
`free prefix (check origin/main, not your branch point).`,
|
|
193
|
-
|
|
194
|
-
}
|
|
218
|
+
);
|
|
195
219
|
}
|
|
196
220
|
|
|
197
221
|
return errors;
|