@nanobpm/nano-workforce 0.122.0 → 0.123.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/delivery.test.ts +2 -134
- package/app/feature.ts +30 -128
- package/app/featureReadModel.test.ts +210 -0
- package/app/plan.ts +26 -96
- package/app/plansReadModel.test.ts +112 -5
- package/app/service.ts +2 -71
- package/db/migrations/073_feature_read_model.sql +80 -0
- package/db/migrations/074_plan_read_model_derive_bucket.sql +74 -0
- package/main.ts +5 -4
- package/operations/acknowledgeDone.test.ts +18 -16
- package/operations/acknowledgeDone.ts +10 -8
- package/operations/acknowledgeEpic.test.ts +24 -31
- package/operations/acknowledgeEpic.ts +9 -8
- package/package.json +1 -1
- package/pages/epic.page.json +1 -1
- package/pages/feature.page.json +1 -1
- package/workers/record-results/worker.test.ts +7 -4
- package/app/featureGateway.test.ts +0 -202
- package/app/planGateway.test.ts +0 -112
package/app/plan.ts
CHANGED
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
13
13
|
import { blackboardUrl, mintBlackboardToken, renderCoordinationBrief } from "./blackboard.ts";
|
|
14
14
|
import { capsWaitTimeout, DEFAULT_CAPS_WAIT_TIMEOUT } from "./capsWait.ts";
|
|
15
|
-
import { deriveEpicBucket, epicIsAcknowledgeable } from "./delivery.ts";
|
|
16
15
|
import { EPIC_PHASE } from "./epicPhase.ts";
|
|
17
16
|
import { DEFAULT_ESCALATION_SLA_TIMEOUT, escalationSlaTimeout } from "./escalationSla.ts";
|
|
18
17
|
import {
|
|
@@ -93,8 +92,9 @@ export interface Plan {
|
|
|
93
92
|
// #412): `delivery` / `delivery_label` are now a DERIVED SQL VIEW (`plan_delivery` /
|
|
94
93
|
// `plan_read_model`, 061), computed from the SAME pure `deriveDelivery`/`TERMINAL_STATUSES`
|
|
95
94
|
// (app/delivery.ts). The pages read them off the view; the pollers that still need the signal
|
|
96
|
-
// (`
|
|
97
|
-
// read time via `deriveDelivery
|
|
95
|
+
// (`pollPromotion` for `isPromotable`) recompute it at
|
|
96
|
+
// read time via `deriveDelivery`, as does the `plan_read_model` VIEW's bucket derivation (074).
|
|
97
|
+
// There is no stored column and no write-path any more.
|
|
98
98
|
// Derived epic domain phase (038_plan_epic_phase.sql, #261): the epic's own lifecycle phase —
|
|
99
99
|
// Planning / Reviewing / Implementing (wave n/t) / Trial merging / Finalizing / Dispatched —
|
|
100
100
|
// projected at write time from plan-fanout.bpmn's named activities (app/epicPhase.ts), so the epic
|
|
@@ -112,16 +112,18 @@ export interface Plan {
|
|
|
112
112
|
// which has nothing to promote). Display-only; projected by the poller.
|
|
113
113
|
promotion_pr: string | null;
|
|
114
114
|
promotion_state: string | null;
|
|
115
|
-
// Active/History partition + operator tick-off (044_plan_list_bucket.sql, #298).
|
|
116
|
-
// write-time
|
|
117
|
-
// `
|
|
118
|
-
//
|
|
119
|
-
//
|
|
115
|
+
// Active/History partition + operator tick-off (044_plan_list_bucket.sql, #298). RETIRED as a
|
|
116
|
+
// write-time projection (issue #439): `list_bucket`/`ack_open` are now DERIVED by the
|
|
117
|
+
// `plan_read_model` VIEW (074) from `status`, `acknowledged_at`, and the derived `plan_delivery`
|
|
118
|
+
// signal — mirroring the pure `deriveEpicBucket` / `epicIsAcknowledgeable` (app/delivery.ts). The
|
|
119
|
+
// Epics pages bind the VIEW, never these base columns, so a raw-datasource `status` write (the
|
|
120
|
+
// `instanceTracking` reconciler) can no longer leave them stale, and the delivery-aware
|
|
121
|
+
// `pollPlanBucket` correction is retired (the VIEW sees the live signal). The base columns survive
|
|
122
|
+
// (expand/contract — a later migration drops them) but are no longer written or read.
|
|
120
123
|
// • acknowledged_at — NULL until an operator dismisses a resolved `done` epic (acknowledge-epic).
|
|
121
|
-
//
|
|
122
|
-
// •
|
|
123
|
-
//
|
|
124
|
-
// until `backfillPlanBuckets`.
|
|
124
|
+
// Still written; the sole live input the derivation reads off the row.
|
|
125
|
+
// • list_bucket — 'active' | 'history': VESTIGIAL base column; the pages filter the VIEW's.
|
|
126
|
+
// • ack_open — 1 | 0: VESTIGIAL base column; the pages gate Dismiss on the VIEW's.
|
|
125
127
|
acknowledged_at: string | null;
|
|
126
128
|
list_bucket: string | null;
|
|
127
129
|
ack_open: number | null;
|
|
@@ -182,91 +184,19 @@ export const PLAN_TASK_STATUSES = [
|
|
|
182
184
|
] as const;
|
|
183
185
|
export type PlanTaskStatus = typeof PLAN_TASK_STATUSES[number];
|
|
184
186
|
|
|
185
|
-
|
|
186
|
-
const table = data.table<Plan>("plans", "plan_key");
|
|
187
|
-
return new Proxy(table, {
|
|
188
|
-
get(target, prop) {
|
|
189
|
-
if (prop === "insert") {
|
|
190
|
-
return (row: Partial<Plan>) => target.insert({ ...row, ...projectPlanBucket(row) });
|
|
191
|
-
}
|
|
192
|
-
if (prop === "update") {
|
|
193
|
-
return async (id: unknown, patch: Partial<Plan>) => {
|
|
194
|
-
// Only re-read + reproject when the patch changes a projection input (status /
|
|
195
|
-
// acknowledged_at) or writes a derived column directly. A projection-irrelevant patch (e.g.
|
|
196
|
-
// an `updated_at`-only write — including the direct `data.table` writes in
|
|
197
|
-
// e.g. `app/retro.ts` that stamp `retro_started_at`) leaves the stored projection correct, so
|
|
198
|
-
// skip the extra `get` roundtrip and delegate straight. Any bucket-relevant write
|
|
199
|
-
// (status/acknowledged_at or a derived column) MUST go through this gateway to stay
|
|
200
|
-
// reprojected.
|
|
201
|
-
if (!patchAffectsPlanProjection(patch)) return target.update(id, patch);
|
|
202
|
-
const existing = await target.get(id);
|
|
203
|
-
const merged: Partial<Plan> = { ...existing, ...patch };
|
|
204
|
-
return target.update(id, { ...patch, ...projectPlanBucket(merged) });
|
|
205
|
-
};
|
|
206
|
-
}
|
|
207
|
-
// Delegate every other method straight through. Bind functions to the real target so the
|
|
208
|
-
// gateway's private class fields resolve — a Proxy `this` would not carry them.
|
|
209
|
-
const value = Reflect.get(target, prop, target);
|
|
210
|
-
return typeof value === "function" ? value.bind(target) : value;
|
|
211
|
-
},
|
|
212
|
-
});
|
|
213
|
-
};
|
|
214
|
-
|
|
215
|
-
/** The `plans` fields the bucket projection READS: a patch touching none of these (and none it
|
|
216
|
-
* writes) cannot change `list_bucket`/`ack_open`, so the gateway skips the read-back+reproject. Kept
|
|
217
|
-
* adjacent to {@link projectPlanBucket} so the two stay in lockstep. */
|
|
218
|
-
const PLAN_PROJECTION_INPUT_KEYS: readonly (keyof Plan)[] = ["status", "acknowledged_at"];
|
|
219
|
-
|
|
220
|
-
/** The `plans` fields the bucket projection WRITES. Included in the reproject trigger so a caller who
|
|
221
|
-
* writes a derived column directly (e.g. `list_bucket`/`ack_open`) can never bypass derivation: the
|
|
222
|
-
* gateway re-reads, recomputes, and OVERRIDES the raw value with the canonical derived one. */
|
|
223
|
-
const PLAN_PROJECTION_OUTPUT_KEYS: readonly (keyof Plan)[] = ["list_bucket", "ack_open"];
|
|
224
|
-
|
|
225
|
-
/** True when a patch changes at least one field the bucket projection derives from OR one it writes —
|
|
226
|
-
* i.e. the projection must be recomputed (mirrors feature.ts `patchAffectsProjection`). */
|
|
227
|
-
function patchAffectsPlanProjection(patch: Partial<Plan>): boolean {
|
|
228
|
-
return (
|
|
229
|
-
PLAN_PROJECTION_INPUT_KEYS.some((k) => k in patch) ||
|
|
230
|
-
PLAN_PROJECTION_OUTPUT_KEYS.some((k) => k in patch)
|
|
231
|
-
);
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/** Compute the write-time bucket projection columns for a merged `plans` row. Centralised so the
|
|
235
|
-
* gateway is the ONE place `deriveEpicBucket` / `epicIsAcknowledgeable` are applied — the page, SQL,
|
|
236
|
-
* pollers and workers never re-derive the mapping (AGENTS.md "derivation over duplication").
|
|
187
|
+
/** The `plans` record gateway (keyed on `plan_key`) — a plain record table.
|
|
237
188
|
*
|
|
238
|
-
* The `
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
ack_open:
|
|
250
|
-
epicIsAcknowledgeable(row.status, null) && (row.acknowledged_at ?? null) === null ? 1 : 0,
|
|
251
|
-
};
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
/** Re-project every `plans` row through the gateway so rows written before migration 042 (whose
|
|
255
|
-
* `list_bucket`/`ack_open` are NULL) get a correct Active/History bucket. Idempotent and safe to
|
|
256
|
-
* re-run: it re-derives from each row's own stored fields, so a second pass is a no-op. Runs once at
|
|
257
|
-
* boot (pollOnce) — the gateway keeps every future write fresh, so this only needs to catch legacy
|
|
258
|
-
* rows. Returns the count actually stamped. Mirrors `backfillFeatureStages` (app/feature.ts). */
|
|
259
|
-
export async function backfillPlanBuckets(data: DataLayer): Promise<number> {
|
|
260
|
-
const table = plans(data);
|
|
261
|
-
let stamped = 0;
|
|
262
|
-
for (const row of await table.all()) {
|
|
263
|
-
// Only touch rows the projection has never reached — a legacy row whose `list_bucket` is NULL.
|
|
264
|
-
if (row.list_bucket != null) continue;
|
|
265
|
-
await table.update(row.plan_key, projectPlanBucket(row));
|
|
266
|
-
stamped++;
|
|
267
|
-
}
|
|
268
|
-
return stamped;
|
|
269
|
-
}
|
|
189
|
+
* The epic-bucket projection (`list_bucket`/`ack_open`) is NO LONGER a write-time projection here
|
|
190
|
+
* (issue #439): it is DERIVED by the `plan_read_model` VIEW (074) from each row's own `status` /
|
|
191
|
+
* `acknowledged_at` and the derived `plan_delivery` signal, mirroring `deriveEpicBucket` /
|
|
192
|
+
* `epicIsAcknowledgeable` (app/delivery.ts). The Epics pages bind the VIEW, never this table's stored
|
|
193
|
+
* derived columns. Removing the write-time projection closes the drift the framework
|
|
194
|
+
* `instanceTracking` reconciler opened (a raw-datasource `{status:"abandoned"}` write on a terminated
|
|
195
|
+
* instance bypassed the projecting gateway and froze the bucket) AND retires the read-time
|
|
196
|
+
* `pollPlanBucket` correction: the VIEW sees the live delivery signal, so a still-converging epic never
|
|
197
|
+
* offers Dismiss without a poller pass. The pure helpers stay the acknowledge-epic guard and the VIEW's
|
|
198
|
+
* test oracle (app/plansReadModel.test.ts). */
|
|
199
|
+
export const plans = (data: DataLayer) => data.table<Plan>("plans", "plan_key");
|
|
270
200
|
export const planTasks = (data: DataLayer) => data.table<PlanTask>("plan_tasks", "id");
|
|
271
201
|
|
|
272
202
|
/** One dependency edge in the plan DAG (issue #20): `task_id` waits for `depends_on_task_id`.
|
|
@@ -19,7 +19,7 @@ import { DatabaseSync } from "node:sqlite";
|
|
|
19
19
|
import { test } from "node:test";
|
|
20
20
|
import { fileURLToPath } from "node:url";
|
|
21
21
|
import { assert, assertEquals } from "#test-assert";
|
|
22
|
-
import { deriveDelivery } from "./delivery.ts";
|
|
22
|
+
import { deriveDelivery, deriveEpicBucket, epicIsAcknowledgeable } from "./delivery.ts";
|
|
23
23
|
|
|
24
24
|
const MIG = (name: string) => readFileSync(fileURLToPath(new URL(`../db/migrations/${name}`, import.meta.url)), "utf8");
|
|
25
25
|
const PAGE = (name: string) => JSON.parse(readFileSync(fileURLToPath(new URL(`../pages/${name}`, import.meta.url)), "utf8"));
|
|
@@ -34,7 +34,7 @@ function viewDb(): DatabaseSync {
|
|
|
34
34
|
plan_key TEXT PRIMARY KEY, repo TEXT, issue_number INTEGER, issue_url TEXT, title TEXT,
|
|
35
35
|
status TEXT, task_count INTEGER, process_key TEXT, outcome TEXT, created_at TEXT,
|
|
36
36
|
updated_at TEXT, epic_phase TEXT, base_branch TEXT, wait_gate_label TEXT, bound_artifacts TEXT,
|
|
37
|
-
promotion_pr TEXT, promotion_state TEXT, list_bucket TEXT, ack_open INTEGER);
|
|
37
|
+
promotion_pr TEXT, promotion_state TEXT, acknowledged_at TEXT, list_bucket TEXT, ack_open INTEGER);
|
|
38
38
|
CREATE TABLE plan_tasks (
|
|
39
39
|
id INTEGER PRIMARY KEY, plan_key TEXT, task_index INTEGER, task_id TEXT, title TEXT,
|
|
40
40
|
prompt TEXT, status TEXT, pr_key TEXT, summary TEXT, created_at TEXT, updated_at TEXT,
|
|
@@ -44,6 +44,9 @@ function viewDb(): DatabaseSync {
|
|
|
44
44
|
db.exec(MIG("059_plan_wave_summary.sql"));
|
|
45
45
|
db.exec(MIG("060_plan_wave_rollup.sql"));
|
|
46
46
|
db.exec(MIG("061_plan_delivery_rollup.sql"));
|
|
47
|
+
// 074 redefines plan_read_model to DERIVE list_bucket/ack_open from status + acknowledged_at + the
|
|
48
|
+
// derived plan_delivery signal (issue #439), instead of reading the denormalised base columns.
|
|
49
|
+
db.exec(MIG("074_plan_read_model_derive_bucket.sql"));
|
|
47
50
|
return db;
|
|
48
51
|
}
|
|
49
52
|
|
|
@@ -66,10 +69,16 @@ const MISSING_PR_STATUS = "missing";
|
|
|
66
69
|
// Insert a plan plus its tasks (and each task's PR, if any). PR keys are derived so the test rows
|
|
67
70
|
// stay terse. Returns the flat `pull_requests.status` list `deriveDelivery` consumes (only tasks
|
|
68
71
|
// that opened a PR), so the delivery assertions can cross-check the view against it.
|
|
69
|
-
function addPlan(
|
|
72
|
+
function addPlan(
|
|
73
|
+
db: DatabaseSync,
|
|
74
|
+
plan_key: string,
|
|
75
|
+
status: string,
|
|
76
|
+
tasks: SampleTask[],
|
|
77
|
+
opts: { acknowledged_at?: string | null } = {},
|
|
78
|
+
): string[] {
|
|
70
79
|
db.prepare(
|
|
71
|
-
"INSERT INTO plans (plan_key, repo, issue_number, issue_url, status, task_count, updated_at, list_bucket) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
72
|
-
).run(plan_key, "o/r", 1, `https://gh/${plan_key}`, status, tasks.length, "2026-01-01T00:00:00Z", "active");
|
|
80
|
+
"INSERT INTO plans (plan_key, repo, issue_number, issue_url, status, task_count, updated_at, acknowledged_at, list_bucket) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
81
|
+
).run(plan_key, "o/r", 1, `https://gh/${plan_key}`, status, tasks.length, "2026-01-01T00:00:00Z", opts.acknowledged_at ?? null, "active");
|
|
73
82
|
const prStatuses: string[] = [];
|
|
74
83
|
tasks.forEach((t, i) => {
|
|
75
84
|
const prKey = t.pr || t.danglingPr ? `${plan_key}::pr${i}` : null;
|
|
@@ -107,6 +116,16 @@ function counts(db: DatabaseSync, plan_key: string): { prs_opened: number; prs_m
|
|
|
107
116
|
return { ...r };
|
|
108
117
|
}
|
|
109
118
|
|
|
119
|
+
// The derived Active/History bucket flags the epics pages bind — read straight off `plan_read_model`
|
|
120
|
+
// (074), plus the delivery signal the derivation folds in, so the assertions can cross-check the
|
|
121
|
+
// VIEW against the pure `deriveEpicBucket` / `epicIsAcknowledgeable` oracles.
|
|
122
|
+
function bucket(db: DatabaseSync, plan_key: string): { list_bucket: unknown; ack_open: unknown; delivery: string | null } {
|
|
123
|
+
const r = db
|
|
124
|
+
.prepare("SELECT list_bucket, ack_open, delivery FROM plan_read_model WHERE plan_key = ?")
|
|
125
|
+
.get(plan_key) as { list_bucket: unknown; ack_open: unknown; delivery: string | null };
|
|
126
|
+
return { ...r };
|
|
127
|
+
}
|
|
128
|
+
|
|
110
129
|
function waveLabel(db: DatabaseSync, plan_key: string): Record<string, unknown> | undefined {
|
|
111
130
|
const r = db.prepare("SELECT wave_count, current_wave, wave_label FROM plan_wave_label WHERE plan_key = ?").get(plan_key) as
|
|
112
131
|
| Record<string, unknown>
|
|
@@ -259,4 +278,92 @@ test("the operator pages read the derived plan_read_model VIEW for the wave/deli
|
|
|
259
278
|
assertEquals(byId("epic-plan").props.data.table, "plan_read_model");
|
|
260
279
|
assert(/\{\{\s*wave_label\s*\}\}/.test(byId("wave-banner").props.header), "the banner surfaces wave_label");
|
|
261
280
|
assertEquals(byId("wave-banner").props.body, "delivery_label", "the banner body is the delivery_label");
|
|
281
|
+
|
|
282
|
+
// Epic INDEX page (epic.page.json) — the standalone Epics grid must also read the derived VIEW, not
|
|
283
|
+
// the raw `plans` table. `plans` stays a valid schema table, so a regression here (reverting the
|
|
284
|
+
// binding) would leave every OTHER test green while the index silently resumed reading stale
|
|
285
|
+
// list_bucket/ack_open; this pins it (suppressed advisory epic.page.json — issue #439).
|
|
286
|
+
const epicIndex = PAGE("epic.page.json");
|
|
287
|
+
const epicPlans = (epicIndex.nodes ?? []).find((n: { id: string }) => n.id === "epic-plans");
|
|
288
|
+
assert(epicPlans, "epic index must keep the Epics grid");
|
|
289
|
+
assertEquals(epicPlans.props.data.table, "plan_read_model");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("plan_read_model DERIVES list_bucket / ack_open from status + delivery + acknowledged_at, matching deriveEpicBucket / epicIsAcknowledgeable (issue #439)", () => {
|
|
293
|
+
const db = viewDb();
|
|
294
|
+
// done, still converging (a slice in flight): Active, Dismiss suppressed — never ticked off mid-flight.
|
|
295
|
+
const converging = addPlan(db, "o/r#conv", "done", [
|
|
296
|
+
{ status: "opened", wave: 0, pr: { status: "merged" } },
|
|
297
|
+
{ status: "opened", wave: 1, pr: { status: "converging" } },
|
|
298
|
+
]);
|
|
299
|
+
// done, fully landed, unacknowledged: Active with Dismiss OPEN (ack_open=1).
|
|
300
|
+
const landed = addPlan(db, "o/r#land", "done", [
|
|
301
|
+
{ status: "opened", wave: 0, pr: { status: "merged" } },
|
|
302
|
+
{ status: "opened", wave: 0, pr: { status: "merged" } },
|
|
303
|
+
]);
|
|
304
|
+
// done, landed AND acknowledged: History, Dismiss closed.
|
|
305
|
+
const acked = addPlan(
|
|
306
|
+
db,
|
|
307
|
+
"o/r#ack",
|
|
308
|
+
"done",
|
|
309
|
+
[{ status: "opened", wave: 0, pr: { status: "merged" } }],
|
|
310
|
+
{ acknowledged_at: "2026-02-02T00:00:00Z" },
|
|
311
|
+
);
|
|
312
|
+
// resolved-not-landed (one abandoned), unacknowledged: delivery null → acknowledgeable, Active + Dismiss.
|
|
313
|
+
const resolved = addPlan(db, "o/r#res", "done", [
|
|
314
|
+
{ status: "opened", wave: 0, pr: { status: "merged" } },
|
|
315
|
+
{ status: "opened", wave: 0, pr: { status: "abandoned" } },
|
|
316
|
+
]);
|
|
317
|
+
// live (dispatched) epic: Active, not acknowledgeable.
|
|
318
|
+
const live = addPlan(db, "o/r#live", "dispatched", [{ status: "opened", wave: 0, pr: { status: "converging" } }]);
|
|
319
|
+
|
|
320
|
+
for (const [plan_key, status, prStatuses, ackAt] of [
|
|
321
|
+
["o/r#conv", "done", converging, null],
|
|
322
|
+
["o/r#land", "done", landed, null],
|
|
323
|
+
["o/r#ack", "done", acked, "2026-02-02T00:00:00Z"],
|
|
324
|
+
["o/r#res", "done", resolved, null],
|
|
325
|
+
["o/r#live", "dispatched", live, null],
|
|
326
|
+
] as const) {
|
|
327
|
+
const b = bucket(db, plan_key);
|
|
328
|
+
const expectedDelivery = deriveDelivery(status, prStatuses).delivery;
|
|
329
|
+
assertEquals(b.delivery, expectedDelivery, `${plan_key}: delivery`);
|
|
330
|
+
// Cross-check the VIEW against the pure helpers — the SAME oracle the acknowledge-epic op guards on.
|
|
331
|
+
assertEquals(
|
|
332
|
+
b.list_bucket,
|
|
333
|
+
deriveEpicBucket(status, expectedDelivery, ackAt),
|
|
334
|
+
`${plan_key}: list_bucket must equal deriveEpicBucket`,
|
|
335
|
+
);
|
|
336
|
+
const expectedAckOpen = epicIsAcknowledgeable(status, expectedDelivery) && ackAt === null ? 1 : 0;
|
|
337
|
+
assertEquals(b.ack_open, expectedAckOpen, `${plan_key}: ack_open must equal epicIsAcknowledgeable`);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Pin the human-visible outcomes so a derivation drift can't hide behind the cross-check.
|
|
341
|
+
assertEquals(bucket(db, "o/r#conv"), { list_bucket: "active", ack_open: 0, delivery: "converging" });
|
|
342
|
+
assertEquals(bucket(db, "o/r#land"), { list_bucket: "active", ack_open: 1, delivery: "landed" });
|
|
343
|
+
assertEquals(bucket(db, "o/r#ack"), { list_bucket: "history", ack_open: 0, delivery: "landed" });
|
|
344
|
+
assertEquals(bucket(db, "o/r#res"), { list_bucket: "active", ack_open: 1, delivery: null });
|
|
345
|
+
assertEquals(bucket(db, "o/r#live"), { list_bucket: "active", ack_open: 0, delivery: null });
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("RED/GREEN GUARD: a RAW-datasource plans.status write (the instanceTracking reconciler bypass) leaves plan_read_model's bucket CONSISTENT", () => {
|
|
349
|
+
// Reproduce the framework `instanceTracking` reconciler class of bug: on a terminated process
|
|
350
|
+
// instance it writes `{status:"abandoned"}` to `plans` through the RAW datasource — bypassing the
|
|
351
|
+
// (now retired) projecting `plans` gateway. Under the OLD write-time projection the stored
|
|
352
|
+
// `list_bucket`/`ack_open` would freeze at their pre-terminal values; because they are now a VIEW
|
|
353
|
+
// over `status`, the read model stays correct with no write-path for any writer to leave stale.
|
|
354
|
+
const db = viewDb();
|
|
355
|
+
// A live epic mid-flight — Active, its (stale) stored projection says active/converging.
|
|
356
|
+
addPlan(db, "o/r#kill", "dispatched", [{ status: "opened", wave: 0, pr: { status: "converging" } }]);
|
|
357
|
+
assertEquals(bucket(db, "o/r#kill").list_bucket, "active");
|
|
358
|
+
|
|
359
|
+
// The reconciler flips status terminal via the RAW table — NOT the gateway. (Simulated with a raw
|
|
360
|
+
// UPDATE, exactly what the raw datasource emits.) It touches neither list_bucket nor ack_open.
|
|
361
|
+
db.prepare("UPDATE plans SET status = 'abandoned' WHERE plan_key = ?").run("o/r#kill");
|
|
362
|
+
|
|
363
|
+
// `abandoned` is a terminal non-`done` status: History, never acknowledgeable — matches the oracle.
|
|
364
|
+
const b = bucket(db, "o/r#kill");
|
|
365
|
+
assertEquals(b.list_bucket, deriveEpicBucket("abandoned", b.delivery, null), "list_bucket tracks status via the VIEW");
|
|
366
|
+
assertEquals(b.list_bucket, "history", "an abandoned epic is filed under History, not wedged in Active");
|
|
367
|
+
assertEquals(b.ack_open, epicIsAcknowledgeable("abandoned", b.delivery) ? 1 : 0);
|
|
368
|
+
assertEquals(b.ack_open, 0, "no phantom Dismiss on a reconciler-cancelled epic");
|
|
262
369
|
});
|
package/app/service.ts
CHANGED
|
@@ -27,11 +27,11 @@ import {
|
|
|
27
27
|
conformanceEscalationQuestion,
|
|
28
28
|
} from "./conformance.ts";
|
|
29
29
|
import { isUniqueConstraintFence } from "./dbFence.ts";
|
|
30
|
-
import { deriveDelivery,
|
|
30
|
+
import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
|
|
31
31
|
import { deliveryGraphRuns, deriveDeliveryPhase, parseHumanLabels } from "./deliveryGraphRun.ts";
|
|
32
32
|
import { isDeliveryHumanElement } from "./deliveryHuman.ts";
|
|
33
33
|
import { fleetSupportsDurableResume } from "./durableResume.ts";
|
|
34
|
-
import {
|
|
34
|
+
import { deriveFeatureDelivery, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRunStatus, featureEscalations, featureRuns } from "./feature.ts";
|
|
35
35
|
import {
|
|
36
36
|
classifyMergeability,
|
|
37
37
|
classifyPrLiveness,
|
|
@@ -54,7 +54,6 @@ import { mergeLanes, readExclusions } from "./mergeExclusion.ts";
|
|
|
54
54
|
import { freshHeadRunAction, headRunPresenceCount, loadMergeProtocol } from "./mergeProtocol.ts";
|
|
55
55
|
import { type PrLaneDecision, planPrLane, taskDependencyDepths } from "./mergeTrain.ts";
|
|
56
56
|
import {
|
|
57
|
-
backfillPlanBuckets,
|
|
58
57
|
capabilityGates,
|
|
59
58
|
inboundPlanDeps,
|
|
60
59
|
type Plan,
|
|
@@ -1895,46 +1894,6 @@ export async function derivePlanDelivery(
|
|
|
1895
1894
|
return deriveDelivery(plan.status, prStatuses).delivery;
|
|
1896
1895
|
}
|
|
1897
1896
|
|
|
1898
|
-
/** Idempotent read-model pass (epic #412 — successor to the retired `pollDelivery`): keep each
|
|
1899
|
-
* epic's Active/History `list_bucket` + `ack_open` tick-off flags fresh now that the `plans.delivery`
|
|
1900
|
-
* column is gone. The `plans` gateway projects both at write time, but with `delivery` treated as
|
|
1901
|
-
* UNKNOWN (null) — provably correct for `list_bucket`, but it cannot clear `ack_open` while a `done`
|
|
1902
|
-
* epic is still CONVERGING (its slices not all merged). This pass supplies the delivery-aware
|
|
1903
|
-
* correction: it recomputes `delivery` at read time via {@link derivePlanDelivery} and re-derives
|
|
1904
|
-
* `list_bucket`/`ack_open` from the pure `deriveEpicBucket`/`epicIsAcknowledgeable` helpers, writing
|
|
1905
|
-
* the result via the RAW `plans` table so the gateway's delivery-free reprojection can't clobber the
|
|
1906
|
-
* corrected value. Writes only when the projection actually changes, so a steady-state pass is a
|
|
1907
|
-
* no-op. Never touches `plan.status` — additive/derived only. */
|
|
1908
|
-
export async function pollPlanBucket(data: DataLayer) {
|
|
1909
|
-
// Preload every PR status once per pass into a pr_key→status map (avoids an N+1 `prs(data).get`).
|
|
1910
|
-
const statusByPrKey = new Map<string, string>();
|
|
1911
|
-
for (const pr of await prs(data).all()) statusByPrKey.set(pr.pr_key, pr.status);
|
|
1912
|
-
// Write through the RAW table, NOT the `plans` gateway: the gateway reprojects `list_bucket`/
|
|
1913
|
-
// `ack_open` with `delivery=null` (it can't see the read-time signal), which would undo the
|
|
1914
|
-
// delivery-aware `ack_open` correction below. This pass is the authoritative writer of the
|
|
1915
|
-
// delivery-aware bucket flags.
|
|
1916
|
-
const plansTable = data.table<Plan>("plans", "plan_key");
|
|
1917
|
-
for (const plan of await plans(data).all()) {
|
|
1918
|
-
try {
|
|
1919
|
-
const delivery = await derivePlanDelivery(data, plan, statusByPrKey);
|
|
1920
|
-
const listBucket = deriveEpicBucket(plan.status, delivery, plan.acknowledged_at ?? null);
|
|
1921
|
-
const ackOpen =
|
|
1922
|
-
epicIsAcknowledgeable(plan.status, delivery) && (plan.acknowledged_at ?? null) === null
|
|
1923
|
-
? 1
|
|
1924
|
-
: 0;
|
|
1925
|
-
if (plan.list_bucket !== listBucket || plan.ack_open !== ackOpen) {
|
|
1926
|
-
await plansTable.update(plan.plan_key, {
|
|
1927
|
-
list_bucket: listBucket,
|
|
1928
|
-
ack_open: ackOpen,
|
|
1929
|
-
updated_at: now(),
|
|
1930
|
-
});
|
|
1931
|
-
}
|
|
1932
|
-
} catch (err) {
|
|
1933
|
-
console.error(`[poller] plan-bucket ${plan.plan_key}: ${err}`);
|
|
1934
|
-
}
|
|
1935
|
-
}
|
|
1936
|
-
}
|
|
1937
|
-
|
|
1938
1897
|
/** Idempotent read-model pass (issue #292 slice S4): project each DEPENDENT epic's inter-epic gate
|
|
1939
1898
|
* state onto its `plans` row so the epic index/detail views can show — as flat columns — which
|
|
1940
1899
|
* producer/package a parked dependent is blocked on, its poll cadence + escalation deadline, and the
|
|
@@ -2442,42 +2401,14 @@ export async function pollUserTasks(
|
|
|
2442
2401
|
* The wave-merge barrier is now level-triggered and probes the engine's message-subscription state
|
|
2443
2402
|
* over the same raw-REST search surface, so it runs only when `engineRest` is supplied (as in
|
|
2444
2403
|
* production — `main.ts` always passes it). */
|
|
2445
|
-
/** One-shot guard so the feature-stage backfill (`backfillFeatureStages`) runs at most once per
|
|
2446
|
-
* process, on the first `pollOnce`. Idempotent regardless, but there is no need to re-scan every row
|
|
2447
|
-
* on every poll. */
|
|
2448
|
-
let featureStagesBackfilled = false;
|
|
2449
|
-
|
|
2450
|
-
/** One-shot guard so the epic-bucket backfill (`backfillPlanBuckets`, #298) runs at most once per
|
|
2451
|
-
* process, on the first `pollOnce` — re-projecting pre-migration-042 `plans` rows whose
|
|
2452
|
-
* `list_bucket` is still NULL. The gateway keeps every future write fresh; idempotent regardless. */
|
|
2453
|
-
let planBucketsBackfilled = false;
|
|
2454
|
-
|
|
2455
2404
|
export async function pollOnce(
|
|
2456
2405
|
data: DataLayer,
|
|
2457
2406
|
engine: EngineClient,
|
|
2458
2407
|
token: string,
|
|
2459
2408
|
engineRest?: { restAddress: string; token?: string },
|
|
2460
2409
|
) {
|
|
2461
|
-
// One-shot: re-project any pre-#254 feature_runs rows whose pipeline columns are still NULL. The
|
|
2462
|
-
// gateway keeps every future write fresh, so this only needs to run once per process and is safe to
|
|
2463
|
-
// re-run (it re-derives from each row's own stored fields).
|
|
2464
|
-
if (!featureStagesBackfilled) {
|
|
2465
|
-
// Only arm the one-shot guard AFTER a successful backfill: setting it first would swallow a
|
|
2466
|
-
// transient failure (e.g. a DB blip) and leave legacy rows unprojected forever, since every later
|
|
2467
|
-
// pass would skip. On a throw the guard stays false and the next `pollOnce` retries.
|
|
2468
|
-
await backfillFeatureStages(data);
|
|
2469
|
-
featureStagesBackfilled = true;
|
|
2470
|
-
}
|
|
2471
|
-
// One-shot: re-project any pre-#298 `plans` rows whose `list_bucket` is still NULL, so a legacy
|
|
2472
|
-
// epic buckets correctly into Active/History from the first pass. Guard armed only after success so
|
|
2473
|
-
// a transient failure retries next pass (mirrors the feature-stage backfill above).
|
|
2474
|
-
if (!planBucketsBackfilled) {
|
|
2475
|
-
await backfillPlanBuckets(data);
|
|
2476
|
-
planBucketsBackfilled = true;
|
|
2477
|
-
}
|
|
2478
2410
|
await pollReviews(data, engine, token);
|
|
2479
2411
|
await pollMerges(data, engine, token);
|
|
2480
|
-
await pollPlanBucket(data);
|
|
2481
2412
|
await pollWaitGate(data);
|
|
2482
2413
|
await pollPromotion(data, engine, token);
|
|
2483
2414
|
await pollFeatureDelivery(data);
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
-- Feature-run display projection as a derived SQL VIEW (issue #439 — the status-driven follow-up to
|
|
2
|
+
-- epic #412).
|
|
3
|
+
--
|
|
4
|
+
-- 039_feature_pipeline_stage.sql denormalised the pipeline projection
|
|
5
|
+
-- (`stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket`) onto the `feature_runs` row,
|
|
6
|
+
-- projected at WRITE TIME by the `featureRuns()` gateway (app/feature.ts) from the pure `deriveStage`
|
|
7
|
+
-- / `deriveListBucket` (app/stage.ts). That "the gateway is the sole write path" invariant held for
|
|
8
|
+
-- app-layer writes but NOT for the framework `instanceTracking` reconciler, which writes
|
|
9
|
+
-- `feature_runs.status` through the RAW datasource (`{status:"abandoned"}` on a terminated instance,
|
|
10
|
+
-- see nano.app.json → instanceTracking) — bypassing the gateway, so `status` flipped terminal while
|
|
11
|
+
-- the display columns stayed frozen at their pre-terminal values (a cancelled run wedged in Active as
|
|
12
|
+
-- a live-looking `Implementing ⚠`, its Dismiss gated shut on a null `stage_state`).
|
|
13
|
+
--
|
|
14
|
+
-- The fix is the same technique #412/#411 established (projection → VIEW, enabled by nano-ide#424):
|
|
15
|
+
-- express the derived columns as a VIEW over `status` (+ `pr_key`/`converge`/`auto_merge`/
|
|
16
|
+
-- `acknowledged_at`), removing the write-time projection entirely. There is then no stored column and
|
|
17
|
+
-- no write-path for ANY writer (the reconciler or a future one) to leave stale — the projection is a
|
|
18
|
+
-- pure function of the row's own base columns, recomputed on every read. `deriveStage` /
|
|
19
|
+
-- `deriveListBucket` remain the canonical TS implementation (used by the acknowledge operations and
|
|
20
|
+
-- as the test oracle); this VIEW's CASE expressions MIRROR them exactly, and
|
|
21
|
+
-- app/featureReadModel.test.ts pins the two in lockstep over the full status matrix.
|
|
22
|
+
--
|
|
23
|
+
-- A single plain `CREATE VIEW <name> AS SELECT … FROM …` (no CTE / no select-list subquery), so its
|
|
24
|
+
-- output columns stay parseable by the static pages↔schema contract guard (scripts/pages-contract.
|
|
25
|
+
-- test.ts) — every projected column is aliased and the derived ones are wrapped so nothing but the
|
|
26
|
+
-- real table reference reads as the top-level FROM. It projects the `feature_runs` columns the Feature
|
|
27
|
+
-- page references PLUS the five derived columns (same shape as 061's plan_read_model): the base
|
|
28
|
+
-- `stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket` columns are deliberately NOT
|
|
29
|
+
-- selected — the derived CASE expressions take those names — so a stale stored value can never surface.
|
|
30
|
+
--
|
|
31
|
+
-- Forward-only, additive (a new read model, no schema change to feature_runs). The runner wraps each
|
|
32
|
+
-- file in its own transaction, so this file must NOT contain BEGIN/COMMIT. Numbered after the current
|
|
33
|
+
-- highest prefix (072 — the #412 wave-1 contract cleanup landed a disjoint 070-079 block).
|
|
34
|
+
|
|
35
|
+
CREATE VIEW feature_read_model AS
|
|
36
|
+
SELECT
|
|
37
|
+
fr.feature_key AS feature_key,
|
|
38
|
+
fr.repo AS repo,
|
|
39
|
+
fr.issue_number AS issue_number,
|
|
40
|
+
fr.issue_url AS issue_url,
|
|
41
|
+
fr.title AS title,
|
|
42
|
+
fr.base_branch AS base_branch,
|
|
43
|
+
fr.status AS status,
|
|
44
|
+
fr.process_key AS process_key,
|
|
45
|
+
fr.pr_key AS pr_key,
|
|
46
|
+
fr.converge AS converge,
|
|
47
|
+
fr.auto_merge AS auto_merge,
|
|
48
|
+
fr.outcome AS outcome,
|
|
49
|
+
fr.delivery_label AS delivery_label,
|
|
50
|
+
fr.acknowledged_at AS acknowledged_at,
|
|
51
|
+
fr.created_at AS created_at,
|
|
52
|
+
fr.updated_at AS updated_at,
|
|
53
|
+
(CASE
|
|
54
|
+
WHEN fr.status IN ('merged', 'converged', 'blocked', 'failed', 'skipped', 'abandoned') THEN 'Done'
|
|
55
|
+
WHEN fr.status = 'converging' THEN 'Converging'
|
|
56
|
+
WHEN (fr.pr_key IS NOT NULL AND fr.pr_key <> '') OR fr.status = 'opened' THEN 'PR open'
|
|
57
|
+
WHEN fr.status IN ('running', 'escalated', 'awaiting_operator') THEN 'Implementing'
|
|
58
|
+
ELSE 'Requested'
|
|
59
|
+
END) AS stage,
|
|
60
|
+
(CASE
|
|
61
|
+
WHEN fr.status IN ('merged', 'converged') THEN 'ok'
|
|
62
|
+
WHEN fr.status = 'blocked' THEN 'blocked'
|
|
63
|
+
WHEN fr.status IN ('failed', 'skipped', 'abandoned') THEN 'failed'
|
|
64
|
+
ELSE NULL
|
|
65
|
+
END) AS stage_state,
|
|
66
|
+
(CASE
|
|
67
|
+
WHEN NOT (fr.converge IS NOT NULL AND fr.converge <> 0) THEN 'Converging Merging'
|
|
68
|
+
WHEN NOT (fr.auto_merge IS NOT NULL AND fr.auto_merge <> 0) THEN 'Merging'
|
|
69
|
+
ELSE ''
|
|
70
|
+
END) AS stage_skipped,
|
|
71
|
+
(CASE
|
|
72
|
+
WHEN fr.status = 'awaiting_operator' THEN 'blocked'
|
|
73
|
+
WHEN fr.status = 'escalated' THEN '⚠'
|
|
74
|
+
ELSE NULL
|
|
75
|
+
END) AS attention,
|
|
76
|
+
(CASE
|
|
77
|
+
WHEN fr.status IN ('merged', 'converged', 'blocked', 'failed', 'skipped', 'abandoned') AND fr.acknowledged_at IS NOT NULL THEN 'history'
|
|
78
|
+
ELSE 'active'
|
|
79
|
+
END) AS list_bucket
|
|
80
|
+
FROM feature_runs fr;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
-- Derive the epic Active/History bucket in the read-model VIEW instead of at write time (issue #439 —
|
|
2
|
+
-- the status-driven follow-up to epic #412).
|
|
3
|
+
--
|
|
4
|
+
-- 044_plan_list_bucket.sql denormalised `plans.list_bucket` / `plans.ack_open` onto the row, projected
|
|
5
|
+
-- at WRITE TIME by the `plans()` gateway (app/plan.ts) from the pure `deriveEpicBucket` /
|
|
6
|
+
-- `epicIsAcknowledgeable` (app/delivery.ts), with a read-time `pollPlanBucket` pass (app/service.ts)
|
|
7
|
+
-- supplying the delivery-aware correction the delivery-free gateway could not. As with feature_runs
|
|
8
|
+
-- (073), that write path is bypassed by the framework `instanceTracking` reconciler, which writes
|
|
9
|
+
-- `plans.status` through the RAW datasource (`{status:"abandoned"}` on a terminated instance) —
|
|
10
|
+
-- leaving `list_bucket`/`ack_open` frozen at their pre-terminal values, so a cancelled epic drifts the
|
|
11
|
+
-- same way a cancelled feature run does.
|
|
12
|
+
--
|
|
13
|
+
-- 061_plan_delivery_rollup.sql already made `plan_read_model` the composite VIEW the operator pages
|
|
14
|
+
-- bind, but it still READ `pl.list_bucket` / `pl.ack_open` straight off the denormalised columns. This
|
|
15
|
+
-- migration redefines `plan_read_model` (same name, so no page repoint is needed) to DERIVE both from
|
|
16
|
+
-- the base inputs — `status`, `acknowledged_at`, and the already-derived `plan_delivery.delivery`
|
|
17
|
+
-- signal — removing the last read of the stored columns. The bucket is now a pure function with no
|
|
18
|
+
-- write-path: there is nothing for the reconciler (or `pollPlanBucket`, which this retires) to leave
|
|
19
|
+
-- stale. The CASE expressions MIRROR `deriveEpicBucket` / `epicIsAcknowledgeable` exactly, cross-checked
|
|
20
|
+
-- against those pure functions in app/plansReadModel.test.ts.
|
|
21
|
+
--
|
|
22
|
+
-- Deriving from the REAL delivery signal is strictly MORE correct than the old write-time projection,
|
|
23
|
+
-- which had to assume `delivery = null` (it could not see the read-time signal) and relied on
|
|
24
|
+
-- `pollPlanBucket` to clear `ack_open` while a `done` epic was still converging. The view sees the live
|
|
25
|
+
-- `plan_delivery.delivery`, so a still-converging epic never offers Dismiss (`ack_open = 0`) without a
|
|
26
|
+
-- poller pass.
|
|
27
|
+
--
|
|
28
|
+
-- A merged view is not editable in place (that would edit a shipped migration), so this DROPs and
|
|
29
|
+
-- re-CREATEs it. `plan_read_model` is a leaf — no other view builds on it — so the DROP is safe. Its
|
|
30
|
+
-- output column set is UNCHANGED (list_bucket/ack_open are still projected, only their derivation
|
|
31
|
+
-- changed), so the pages↔schema contract guard and every page binding stay valid.
|
|
32
|
+
--
|
|
33
|
+
-- Forward-only. NO BEGIN/COMMIT — the runner wraps each file in its own transaction. Numbered after
|
|
34
|
+
-- 073.
|
|
35
|
+
|
|
36
|
+
DROP VIEW plan_read_model;
|
|
37
|
+
|
|
38
|
+
CREATE VIEW plan_read_model AS
|
|
39
|
+
SELECT
|
|
40
|
+
pl.plan_key AS plan_key,
|
|
41
|
+
pl.repo AS repo,
|
|
42
|
+
pl.issue_number AS issue_number,
|
|
43
|
+
pl.issue_url AS issue_url,
|
|
44
|
+
pl.title AS title,
|
|
45
|
+
pl.status AS status,
|
|
46
|
+
pl.task_count AS task_count,
|
|
47
|
+
pl.process_key AS process_key,
|
|
48
|
+
pl.outcome AS outcome,
|
|
49
|
+
pl.updated_at AS updated_at,
|
|
50
|
+
pl.epic_phase AS epic_phase,
|
|
51
|
+
pl.base_branch AS base_branch,
|
|
52
|
+
pl.wait_gate_label AS wait_gate_label,
|
|
53
|
+
pl.bound_artifacts AS bound_artifacts,
|
|
54
|
+
pl.promotion_pr AS promotion_pr,
|
|
55
|
+
pl.promotion_state AS promotion_state,
|
|
56
|
+
(CASE
|
|
57
|
+
WHEN pl.status IN ('planning', 'dispatched') THEN 'active'
|
|
58
|
+
WHEN pl.status = 'done' AND d.delivery = 'converging' THEN 'active'
|
|
59
|
+
WHEN pl.status = 'done' AND pl.acknowledged_at IS NULL THEN 'active'
|
|
60
|
+
WHEN pl.status = 'done' THEN 'history'
|
|
61
|
+
ELSE 'history'
|
|
62
|
+
END) AS list_bucket,
|
|
63
|
+
(CASE
|
|
64
|
+
WHEN pl.status = 'done' AND d.delivery IS NOT 'converging' AND pl.acknowledged_at IS NULL THEN 1
|
|
65
|
+
ELSE 0
|
|
66
|
+
END) AS ack_open,
|
|
67
|
+
wl.wave_count AS wave_count,
|
|
68
|
+
wl.current_wave AS current_wave,
|
|
69
|
+
wl.wave_label AS wave_label,
|
|
70
|
+
d.delivery AS delivery,
|
|
71
|
+
d.delivery_label AS delivery_label
|
|
72
|
+
FROM plans pl
|
|
73
|
+
LEFT JOIN plan_wave_label wl ON wl.plan_key = pl.plan_key
|
|
74
|
+
LEFT JOIN plan_delivery d ON d.plan_key = pl.plan_key;
|
package/main.ts
CHANGED
|
@@ -109,10 +109,11 @@ async function pollLoop(): Promise<void> {
|
|
|
109
109
|
}
|
|
110
110
|
if (!shuttingDown) pollTimer = setTimeout(() => void pollLoop(), POLL_MS);
|
|
111
111
|
}
|
|
112
|
-
// Run the first pass immediately at boot (not after POLL_MS) so the
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
112
|
+
// Run the first pass immediately at boot (not after POLL_MS) so the read-model pollers (delivery,
|
|
113
|
+
// wait-gate, promotion, lineage) reconcile before the UI is relied upon rather than after up to
|
|
114
|
+
// POLL_MS. The Feature Runs grid/tabs now filter the `feature_read_model` VIEW's derived `stage`/
|
|
115
|
+
// `list_bucket` (issue #439), computed from each row's own `status`, so no boot-time backfill of a
|
|
116
|
+
// stored projection is required.
|
|
116
117
|
if (app.data) void pollLoop();
|
|
117
118
|
|
|
118
119
|
async function drainAndExit(): Promise<void> {
|