@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.
- package/CHANGELOG.md +20 -0
- package/README.md +9 -5
- package/app/deliveryGraphDispatch.test.ts +143 -0
- package/app/deliveryGraphDispatch.ts +168 -0
- package/app/deliveryGraphProposals.test.ts +267 -0
- package/app/deliveryGraphProposals.ts +269 -0
- package/app/deliveryGraphRun.test.ts +6 -52
- package/app/deliveryGraphRun.ts +21 -76
- package/app/deliveryGraphText.ts +3 -3
- package/app/deliveryRunner.ts +4 -3
- package/app/featureReadModel.test.ts +97 -54
- package/app/featureReadModel.ts +152 -0
- package/app/service.ts +15 -0
- package/app/stage.ts +71 -77
- package/db/migrations/075_delivery_graph_proposals.sql +48 -0
- package/db/migrations/076_feature_read_model_declare_once.sql +53 -0
- package/docs/adr/0005-agent-authored-delivery-graphs.md +18 -0
- package/docs/adr/0006-delivery-units-one-representation.md +221 -0
- package/docs/agent-guide.md +50 -58
- package/e2e/delivery-graph-dispatch.e2e.ts +155 -0
- package/openapi.yaml +118 -161
- package/operations/compileDeliveryGraph.test.ts +100 -37
- package/operations/compileDeliveryGraph.ts +64 -18
- package/operations/dispatchDeliveryGraph.test.ts +171 -152
- package/operations/dispatchDeliveryGraph.ts +79 -99
- package/operations/getAgentInstructions.test.ts +10 -6
- package/operations/previewDeliveryGraph.test.ts +90 -51
- package/operations/previewDeliveryGraph.ts +45 -18
- package/package.json +2 -2
- package/pages/cockpit/mount.js +19 -12
- package/pages/delivery-graphs/mount.js +37 -137
- package/pages/delivery-graphs.page.json +50 -3
- package/scripts/check-migrations.test.ts +31 -0
- package/scripts/check-migrations.ts +40 -6
- package/test/cockpit-embed-endpoints.test.ts +59 -36
- package/test/delivery-graphs-embed.test.ts +36 -34
- package/e2e/delivery-graph-start.e2e.ts +0 -145
- package/operations/startDeliveryGraph.integration.test.ts +0 -316
- package/operations/startDeliveryGraph.ts +0 -222
|
@@ -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/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" };
|