@nanobpm/nano-workforce 0.123.2 → 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.
Files changed (39) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +9 -5
  3. package/app/deliveryGraphDispatch.test.ts +143 -0
  4. package/app/deliveryGraphDispatch.ts +168 -0
  5. package/app/deliveryGraphProposals.test.ts +267 -0
  6. package/app/deliveryGraphProposals.ts +269 -0
  7. package/app/deliveryGraphRun.test.ts +6 -52
  8. package/app/deliveryGraphRun.ts +21 -76
  9. package/app/deliveryGraphText.ts +3 -3
  10. package/app/deliveryRunner.ts +4 -3
  11. package/app/featureReadModel.test.ts +97 -54
  12. package/app/featureReadModel.ts +152 -0
  13. package/app/service.ts +15 -0
  14. package/app/stage.ts +71 -77
  15. package/db/migrations/075_delivery_graph_proposals.sql +48 -0
  16. package/db/migrations/076_feature_read_model_declare_once.sql +53 -0
  17. package/docs/adr/0005-agent-authored-delivery-graphs.md +18 -0
  18. package/docs/adr/0006-delivery-units-one-representation.md +221 -0
  19. package/docs/agent-guide.md +50 -58
  20. package/e2e/delivery-graph-dispatch.e2e.ts +155 -0
  21. package/openapi.yaml +118 -161
  22. package/operations/compileDeliveryGraph.test.ts +100 -37
  23. package/operations/compileDeliveryGraph.ts +64 -18
  24. package/operations/dispatchDeliveryGraph.test.ts +171 -152
  25. package/operations/dispatchDeliveryGraph.ts +79 -99
  26. package/operations/getAgentInstructions.test.ts +10 -6
  27. package/operations/previewDeliveryGraph.test.ts +90 -51
  28. package/operations/previewDeliveryGraph.ts +45 -18
  29. package/package.json +2 -2
  30. package/pages/cockpit/mount.js +19 -12
  31. package/pages/delivery-graphs/mount.js +37 -137
  32. package/pages/delivery-graphs.page.json +50 -3
  33. package/scripts/check-migrations.test.ts +31 -0
  34. package/scripts/check-migrations.ts +40 -6
  35. package/test/cockpit-embed-endpoints.test.ts +59 -36
  36. package/test/delivery-graphs-embed.test.ts +36 -34
  37. package/e2e/delivery-graph-start.e2e.ts +0 -145
  38. package/operations/startDeliveryGraph.integration.test.ts +0 -316
  39. package/operations/startDeliveryGraph.ts +0 -222
@@ -1,39 +1,41 @@
1
- // Read-model VIEW coverage for the feature-run pipeline projection (issue #439 the status-driven
2
- // follow-up to epic #412, "Retire worker-maintained denormalized projections in favour of SQL
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 pipeline projection
6
- // (`stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket`) onto the `feature_runs` row,
7
- // written by the `featureRuns()` gateway (app/feature.ts) at WRITE TIME from the pure `deriveStage` /
8
- // `deriveListBucket` (app/stage.ts). That "the gateway is the sole write path" invariant did NOT hold
9
- // for the framework `instanceTracking` reconciler, which writes `feature_runs.status` through the RAW
10
- // datasource on a terminated instance bypassing the gateway and freezing the display columns.
11
- // 073_feature_read_model.sql retires the write-time projection: the derived columns are now a VIEW
12
- // over each row's own `status`/`pr_key`/`converge`/`auto_merge`/`acknowledged_at`, so there is no
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
- // This exercises the REAL SQLite view (073+075 applied to an in-memory DB, mirroring
18
- // app/plansReadModel.test.ts / app/mergesPerDayView.test.ts) and pins that its CASE expressions
19
- // reproduce `deriveStage` / `deriveListBucket` EXACTLY over the full status × open-task matrix — the
20
- // SAME pure helpers the acknowledge operations guard on plus RED/GREEN guards reproducing the
21
- // reconciler bypass (a RAW-datasource `status` write must leave the projection correct) and the #422
22
- // answered-escalation drift (a sticky `status='escalated'` with no open task must show no ).
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
- // The base `feature_runs` shape the view reads (028 + 030's delivery_label + 035's title + 039's
35
- // acknowledged_at & the now-vestigial stored stage/… columns), plus migration 073. The stored derived
36
- // columns are present precisely so the tests can seed STALE values and prove the VIEW ignores them.
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("073_feature_read_model.sql"));
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 from `status`.
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
- test("feature_read_model derives stage/stage_state/stage_skipped/attention EXACTLY like deriveStage, over every status × converge/auto_merge/pr_key × open-task combination", () => {
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), whose derivation reads open tasks for them exercise BOTH
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("feature_read_model derives list_bucket EXACTLY like deriveListBucket (history iff terminal AND acknowledged)", () => {
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("feature_read_model IGNORES any stale STORED projection columns — it reads only from status et al.", () => {
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 OLD VIEW
206
- // derived `attention` from that value and rendered a stale on Overview. The badge now derives from
207
- // engine truth the presence of an OPEN `feature-escalation` user task (`pollUserTasks` deletes the
208
- // row the moment it is answered) — so it clears immediately regardless of the stale status.
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. Under the OLD write-time projection
237
- // the stored `stage`/`stage_state`/`attention`/`list_bucket` would FREEZE at their pre-terminal
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/service.ts CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  } from "./conformance.ts";
29
29
  import { isUniqueConstraintFence } from "./dbFence.ts";
30
30
  import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
31
+ import { sweepExpiredProposals } from "./deliveryGraphProposals.ts";
31
32
  import { deliveryGraphRuns, deriveDeliveryPhase, parseHumanLabels } from "./deliveryGraphRun.ts";
32
33
  import { isDeliveryHumanElement } from "./deliveryHuman.ts";
33
34
  import { fleetSupportsDurableResume } from "./durableResume.ts";
@@ -2304,6 +2305,19 @@ export async function pollDeliveryGraphPhase(
2304
2305
  }
2305
2306
  }
2306
2307
 
2308
+ /** Poll pass (ADR 0005 Decision 7): age out staged delivery-graph proposals whose TTL has elapsed by
2309
+ * flipping them to `expired`, so they drop out of the cockpit's staged grid rather than lingering there
2310
+ * only to fail dispatch. The grid filters purely on `status = 'staged'` (its datasource cannot express an
2311
+ * `expires_at > now` comparison), so this reconciliation sweep is what realises the proposal TTL. It is
2312
+ * data-only and idempotent — a proposal already terminal is left untouched. */
2313
+ export async function pollDeliveryProposals(data: DataLayer) {
2314
+ try {
2315
+ await sweepExpiredProposals(data);
2316
+ } catch (err) {
2317
+ console.error(`[poller] delivery graph proposals sweep: ${err}`);
2318
+ }
2319
+ }
2320
+
2307
2321
  export async function pollUserTasks(
2308
2322
  data: DataLayer,
2309
2323
  engine: EngineClient,
@@ -2479,6 +2493,7 @@ export async function pollOnce(
2479
2493
  await pollLineage(data);
2480
2494
  await pollUserTasks(data, engine, engineRest);
2481
2495
  await pollDeliveryGraphPhase(data, engine);
2496
+ await pollDeliveryProposals(data);
2482
2497
  if (engineRest) {
2483
2498
  const base = engineRest.restAddress.replace(/\/+$/, "");
2484
2499
  const headers: Record<string, string> = { "content-type": "application/json" };