@nanobpm/nano-workforce 0.121.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/app/service.ts CHANGED
@@ -31,7 +31,7 @@ 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 { backfillFeatureStages, deriveFeatureDelivery, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRunStatus, featureEscalations, featureRuns } from "./feature.ts";
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,
@@ -52,19 +52,18 @@ import {
52
52
  import { pollLineage } from "./lineage.ts";
53
53
  import { mergeLanes, readExclusions } from "./mergeExclusion.ts";
54
54
  import { freshHeadRunAction, headRunPresenceCount, loadMergeProtocol } from "./mergeProtocol.ts";
55
- import { pollMergesPerDay } from "./mergesPerDay.ts";
56
55
  import { type PrLaneDecision, planPrLane, taskDependencyDepths } from "./mergeTrain.ts";
57
56
  import {
58
- backfillPlanBuckets,
59
57
  capabilityGates,
60
58
  inboundPlanDeps,
59
+ type Plan,
61
60
  planReviews,
62
61
  plans,
63
62
  planTaskDeps,
64
63
  planTaskNeeds,
65
64
  planTasks,
66
65
  } from "./plan.ts";
67
- import { derivePromotionState, isPromotable, promotionPrBody, promotionPrTitle } from "./promotion.ts";
66
+ import { derivePromotionState, isEpicIntegrationBranch, isPromotable, promotionPrBody, promotionPrTitle } from "./promotion.ts";
68
67
  import {
69
68
  defaultProbeExec,
70
69
  type ProbeExec,
@@ -1862,56 +1861,37 @@ export async function pollCapabilityGatesImpl(
1862
1861
  }
1863
1862
  }
1864
1863
 
1865
- /** Idempotent read-model pass: recompute each plan's derived `delivery` signal (issue #171) by
1866
- * joining its slice tasks' `pr_key` → `pull_requests.status`, and denormalise it onto the `plans`
1867
- * row so the epics overview / detail views can read it as a flat column (Urban's datasource can't
1868
- * read a SQL VIEW). Never touches `plan.status` — additive/derived only. Writes only when the
1869
- * projection actually changes, so a steady-state pass is a no-op. */
1870
1864
  /** Sentinel status fed to `deriveDelivery` for a `plan_tasks.pr_key` whose `pull_requests` row is
1871
1865
  * missing (DB desync). It is deliberately non-terminal and not `merged`, so a dangling PR counts as
1872
1866
  * in-flight — never a false-positive `landed` from a silently-dropped slice. */
1873
1867
  const MISSING_PR_STATUS = "missing";
1874
1868
 
1875
- export async function pollDelivery(data: DataLayer) {
1876
- // Preload every PR status once per pass into a pr_key→status map (avoids the prior N+1
1877
- // `prs(data).get` per task; mirrors how `activePrs` reads `prs(data).all()` once).
1878
- const statusByPrKey = new Map<string, string>();
1879
- for (const pr of await prs(data).all()) statusByPrKey.set(pr.pr_key, pr.status);
1880
- for (const plan of await plans(data).all()) {
1881
- try {
1882
- // `deriveDelivery` always yields `{null, null}` for a non-`done` plan, so skip the per-plan
1883
- // task join for those — but still clear any stale projection defensively (e.g. a plan that
1884
- // regressed out of `done`) so the read model never keeps a phantom `converging`/`landed`.
1885
- if (plan.status !== "done") {
1886
- if (plan.delivery !== null || plan.delivery_label !== null) {
1887
- await plans(data).update(plan.plan_key, {
1888
- delivery: null,
1889
- delivery_label: null,
1890
- updated_at: now(),
1891
- });
1892
- }
1893
- continue;
1894
- }
1895
- const tasks = await planTasks(data).find({ plan_key: plan.plan_key });
1896
- const prStatuses: string[] = [];
1897
- for (const t of tasks) {
1898
- if (!t.pr_key) continue;
1899
- // A dangling pr_key (row missing) is treated as in-flight, not dropped, so a DB desync
1900
- // can never wrongly promote an epic to `landed`.
1901
- prStatuses.push(statusByPrKey.get(t.pr_key) ?? MISSING_PR_STATUS);
1902
- }
1903
- const { delivery, label } = deriveDelivery(plan.status, prStatuses);
1904
- if (plan.delivery !== delivery || plan.delivery_label !== label) {
1905
- await plans(data).update(plan.plan_key, {
1906
- delivery,
1907
- delivery_label: label,
1908
- updated_at: now(),
1909
- });
1910
- }
1911
- } catch (err) {
1912
- console.error(`[poller] delivery ${plan.plan_key}: ${err}`);
1869
+ /** Recompute an epic's derived `delivery` signal at READ TIME (epic #412) from the SAME pure
1870
+ * `deriveDelivery` the `plan_delivery` VIEW encodes by joining each slice `plan_tasks.pr_key`
1871
+ * `pull_requests.status` (a dangling `pr_key` counts as in-flight, never false-`landed`). The
1872
+ * `plans.delivery` column was RETIRED, so the pollers that still need the signal derive it here
1873
+ * rather than reading a denormalised column. Non-`done` plans short-circuit to `null` (the view's
1874
+ * behaviour) without the per-plan task join. `statusByPrKey` is an optional once-per-pass PR-status
1875
+ * map (the pollers preload it to avoid an N+1); when omitted (a one-off caller like the
1876
+ * acknowledge-epic op) it loads only THIS plan's slice PRs on demand — never the whole table. */
1877
+ export async function derivePlanDelivery(
1878
+ data: DataLayer,
1879
+ plan: Plan,
1880
+ statusByPrKey?: Map<string, string>,
1881
+ ): Promise<string | null> {
1882
+ if (plan.status !== "done") return null;
1883
+ const tasks = await planTasks(data).find({ plan_key: plan.plan_key });
1884
+ const prStatuses: string[] = [];
1885
+ for (const t of tasks) {
1886
+ if (!t.pr_key) continue;
1887
+ let status = statusByPrKey?.get(t.pr_key);
1888
+ if (status === undefined && !statusByPrKey) {
1889
+ // On-demand caller: fetch just this slice's PR row rather than loading the whole table.
1890
+ status = (await prs(data).get(t.pr_key))?.status;
1913
1891
  }
1892
+ prStatuses.push(status ?? MISSING_PR_STATUS);
1914
1893
  }
1894
+ return deriveDelivery(plan.status, prStatuses).delivery;
1915
1895
  }
1916
1896
 
1917
1897
  /** Idempotent read-model pass (issue #292 slice S4): project each DEPENDENT epic's inter-epic gate
@@ -1926,12 +1906,30 @@ export async function pollDelivery(data: DataLayer) {
1926
1906
  * a prior edge (e.g. an edge later removed) is cleared defensively so the read model never keeps a
1927
1907
  * phantom gate. */
1928
1908
  export async function pollWaitGate(data: DataLayer) {
1909
+ // Preload every plan_tasks row once per pass and group by plan_key, so deriving each plan's
1910
+ // fanned-out signal below is a map lookup rather than a per-plan `planTasks(data).find` (avoids
1911
+ // an N+1 that would double this pass's DB work alongside the per-plan `inboundPlanDeps` lookup).
1912
+ const wavesByPlanKey = new Map<string, number[]>();
1913
+ for (const t of await planTasks(data).all()) {
1914
+ if (t.wave == null) continue;
1915
+ const list = wavesByPlanKey.get(t.plan_key) ?? [];
1916
+ list.push(t.wave);
1917
+ wavesByPlanKey.set(t.plan_key, list);
1918
+ }
1929
1919
  for (const plan of await plans(data).all()) {
1930
1920
  try {
1931
1921
  const edges = await inboundPlanDeps(data, plan.plan_key);
1922
+ // `plans.current_wave` was retired (epic #412); `deriveWaitGate` only consumes its
1923
+ // NULL-ness (proof the epic's fan-out began). Derive that at read time: an epic has fanned out
1924
+ // iff it has ≥1 levelized `plan_tasks` row (a wave assigned). The value's magnitude is never
1925
+ // surfaced here — the epic index/detail read the display `current_wave` off the
1926
+ // `plan_wave_label`/`plan_read_model` VIEWs — so any non-null (the frontier's min wave) is
1927
+ // faithful for the gate's "has this epic ever fanned out?" test.
1928
+ const assignedWaves = wavesByPlanKey.get(plan.plan_key) ?? [];
1929
+ const current_wave = assignedWaves.length > 0 ? Math.min(...assignedWaves) : null;
1932
1930
  const { wait_gate, wait_gate_label } = deriveWaitGate(edges, {
1933
1931
  status: plan.status,
1934
- current_wave: plan.current_wave,
1932
+ current_wave,
1935
1933
  bound_artifacts: plan.bound_artifacts,
1936
1934
  created_at: plan.created_at,
1937
1935
  });
@@ -1951,8 +1949,8 @@ export async function pollWaitGate(data: DataLayer) {
1951
1949
  /** Idempotent promotion pass (issue #299): open — and then track — the `epic/* → <default>`
1952
1950
  * promotion PR for every epic that has LANDED on a custom integration branch. This is the missing
1953
1951
  * counterpart to `ensureBaseBranch`: that creates the `epic/*` branch slices merge into; this
1954
- * delivers the fully-landed branch to the default branch. Runs AFTER `pollDelivery` so it reads the
1955
- * freshly-projected `delivery = landed` signal.
1952
+ * delivers the fully-landed branch to the default branch. Derives each epic's `delivery` signal at
1953
+ * read time (epic #412 — the `plans.delivery` column was retired) via the same pure `deriveDelivery`.
1956
1954
  *
1957
1955
  * Per promotable plan (`isPromotable`: `delivery = landed` AND base is `epic/*`):
1958
1956
  * • No promotion PR yet → open ONE `epic/* → <default>` PR (idempotent against a remote head-branch
@@ -1972,10 +1970,15 @@ export async function pollPromotion(data: DataLayer, engine: EngineClient, token
1972
1970
  const statusByPrKey = new Map<string, string>();
1973
1971
  for (const pr of await prs(data).all()) statusByPrKey.set(pr.pr_key, pr.status);
1974
1972
  for (const plan of await plans(data).all()) {
1975
- if (!isPromotable(plan)) continue;
1976
1973
  const base = plan.base_branch;
1977
- if (!base) continue; // narrowed by isPromotable, but keep the type-checker honest
1974
+ // A non-`epic/*` base is never promotable short-circuit before the per-plan delivery join.
1975
+ if (!isEpicIntegrationBranch(base)) continue;
1978
1976
  try {
1977
+ // `plans.delivery` was retired (epic #412) — derive the landed signal at READ TIME from the
1978
+ // slice PRs (same pure `deriveDelivery` the `plan_delivery` VIEW encodes) instead of reading a
1979
+ // denormalised column, then apply the pure `isPromotable` predicate.
1980
+ const delivery = await derivePlanDelivery(data, plan, statusByPrKey);
1981
+ if (!isPromotable({ delivery, base_branch: base })) continue;
1979
1982
  // Already opened → project state from the promotion PR's live status, and re-enroll it if its
1980
1983
  // convergence row went missing (a prior submit failed, or the app/engine store desynced).
1981
1984
  if (plan.promotion_pr) {
@@ -2398,47 +2401,18 @@ export async function pollUserTasks(
2398
2401
  * The wave-merge barrier is now level-triggered and probes the engine's message-subscription state
2399
2402
  * over the same raw-REST search surface, so it runs only when `engineRest` is supplied (as in
2400
2403
  * production — `main.ts` always passes it). */
2401
- /** One-shot guard so the feature-stage backfill (`backfillFeatureStages`) runs at most once per
2402
- * process, on the first `pollOnce`. Idempotent regardless, but there is no need to re-scan every row
2403
- * on every poll. */
2404
- let featureStagesBackfilled = false;
2405
-
2406
- /** One-shot guard so the epic-bucket backfill (`backfillPlanBuckets`, #298) runs at most once per
2407
- * process, on the first `pollOnce` — re-projecting pre-migration-042 `plans` rows whose
2408
- * `list_bucket` is still NULL. The gateway keeps every future write fresh; idempotent regardless. */
2409
- let planBucketsBackfilled = false;
2410
-
2411
2404
  export async function pollOnce(
2412
2405
  data: DataLayer,
2413
2406
  engine: EngineClient,
2414
2407
  token: string,
2415
2408
  engineRest?: { restAddress: string; token?: string },
2416
2409
  ) {
2417
- // One-shot: re-project any pre-#254 feature_runs rows whose pipeline columns are still NULL. The
2418
- // gateway keeps every future write fresh, so this only needs to run once per process and is safe to
2419
- // re-run (it re-derives from each row's own stored fields).
2420
- if (!featureStagesBackfilled) {
2421
- // Only arm the one-shot guard AFTER a successful backfill: setting it first would swallow a
2422
- // transient failure (e.g. a DB blip) and leave legacy rows unprojected forever, since every later
2423
- // pass would skip. On a throw the guard stays false and the next `pollOnce` retries.
2424
- await backfillFeatureStages(data);
2425
- featureStagesBackfilled = true;
2426
- }
2427
- // One-shot: re-project any pre-#298 `plans` rows whose `list_bucket` is still NULL, so a legacy
2428
- // epic buckets correctly into Active/History from the first pass. Guard armed only after success so
2429
- // a transient failure retries next pass (mirrors the feature-stage backfill above).
2430
- if (!planBucketsBackfilled) {
2431
- await backfillPlanBuckets(data);
2432
- planBucketsBackfilled = true;
2433
- }
2434
2410
  await pollReviews(data, engine, token);
2435
2411
  await pollMerges(data, engine, token);
2436
- await pollDelivery(data);
2437
2412
  await pollWaitGate(data);
2438
2413
  await pollPromotion(data, engine, token);
2439
2414
  await pollFeatureDelivery(data);
2440
2415
  await pollLineage(data);
2441
- await pollMergesPerDay(data);
2442
2416
  await pollUserTasks(data, engine, engineRest);
2443
2417
  await pollDeliveryGraphPhase(data, engine);
2444
2418
  if (engineRest) {
@@ -0,0 +1,31 @@
1
+ -- Contract phase for the worker-maintained `plans` projection columns (epic #412: retire
2
+ -- worker-maintained denormalised projections in favour of SQL VIEWs).
3
+ --
4
+ -- Wave-0 of this epic re-expressed every one of these denormalised `plans` columns as a DERIVED SQL
5
+ -- VIEW computed from the SAME pure helpers that used to feed the write-path, and repointed every
6
+ -- operator page onto those views:
7
+ -- • `wave_count` / `current_wave` / `wave_label` (022_plan_wave_progress.sql) → `plan_wave_progress`
8
+ -- / `plan_wave_label` / `plan_read_model` (060/061), derived from the levelized `plan_tasks`.
9
+ -- • `delivery` / `delivery_label` (029_plan_delivery.sql) → `plan_delivery` / `plan_read_model`
10
+ -- (061), derived via the pure `deriveDelivery`/`TERMINAL_STATUSES` (app/delivery.ts) by joining
11
+ -- each slice `plan_tasks.pr_key` → `pull_requests.status`.
12
+ --
13
+ -- This wave-1 cleanup deletes the now-dead write-paths (the `record-plan`/`record-wave`/`select-wave`
14
+ -- worker writes and the `pollDelivery` poller) and repoints the last internal readers onto read-time
15
+ -- derivation (`pollPlanBucket`/`pollPromotion` recompute `delivery` via `deriveDelivery`;
16
+ -- `pollWaitGate` derives "has the epic fanned out?" from `plan_tasks`). With no remaining writer or
17
+ -- reader, these five columns are dead schema — a drift surface with no source of truth behind them —
18
+ -- so drop them. The `plan_read_model` / `plan_wave_label` / `plan_delivery` VIEWs DERIVE their
19
+ -- wave/delivery columns from the join surfaces, never from these `plans` columns, so the pages the
20
+ -- views back are unaffected.
21
+ --
22
+ -- Forward-only contract migration numbered in this task's disjoint 070-079 block (029/022 are
23
+ -- forward-only and immutable, so this is the standard expand→contract follow-up, not an edit to
24
+ -- them). All five columns were nullable with no default and have no remaining writer or reader, and
25
+ -- no index references them, so a plain `ALTER TABLE … DROP COLUMN` suffices. The runner wraps each
26
+ -- file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
27
+ ALTER TABLE plans DROP COLUMN wave_count;
28
+ ALTER TABLE plans DROP COLUMN current_wave;
29
+ ALTER TABLE plans DROP COLUMN wave_label;
30
+ ALTER TABLE plans DROP COLUMN delivery;
31
+ ALTER TABLE plans DROP COLUMN delivery_label;
@@ -0,0 +1,21 @@
1
+ -- Contract phase for the worker-maintained `merges_per_day` read table (epic #412: retire
2
+ -- worker-maintained denormalised projections in favour of SQL VIEWs).
3
+ --
4
+ -- 051_merges_per_day.sql created the flat `merges_per_day` read table the Velocity page bound, kept
5
+ -- fresh each poll pass by `pollMergesPerDay` (app/mergesPerDay.ts) projecting the pure
6
+ -- `deriveMergesPerDay` over the `merges` audit trail. Wave-0 re-expressed that aggregate as the
7
+ -- DERIVED `merges_per_day_view` VIEW (062) — computed directly from `merges` with the same
8
+ -- `COUNT(DISTINCT pr_key)` / local-day bucketing — and repointed `pages/velocity.page.json` onto it.
9
+ --
10
+ -- This wave-1 cleanup deletes the `pollMergesPerDay` write-path and its base-table accessors. With no
11
+ -- remaining writer or reader, the `merges_per_day` table is dead schema, so drop it. The pure
12
+ -- `deriveMergesPerDay`/`barFor` helpers (app/mergesPerDay.ts) stay — they are the single source of
13
+ -- truth the view's SQL mirrors and are still exercised by the view's read-model guard. The
14
+ -- `merges_per_day_view` reads the `merges` audit table, NOT this dropped table, so the Velocity page
15
+ -- is unaffected.
16
+ --
17
+ -- Forward-only contract migration numbered in this task's disjoint 070-079 block. The table has no
18
+ -- remaining writer or reader and nothing references it by foreign key, so a plain `DROP TABLE`
19
+ -- suffices. The runner wraps each file in its own transaction, so this file must NOT contain
20
+ -- BEGIN/COMMIT.
21
+ DROP TABLE merges_per_day;
@@ -0,0 +1,23 @@
1
+ -- Contract phase for the view-migrated `lineage_threads` identity columns (epic #412: retire
2
+ -- worker-maintained denormalised projections in favour of SQL VIEWs).
3
+ --
4
+ -- 064_lineage_thread_view.sql introduced the `lineage_thread_view` VIEW the Lineage page now binds.
5
+ -- The lineage audit (lineage-views-audit) split `lineage_threads`' columns into two groups:
6
+ -- • VIEW-BACKED (fully derivable from the `plans`/`feature_runs` origin joins): `kind` and
7
+ -- `issue_url`. The view DERIVES both — `kind` from which origin table the root matches, `issue_url`
8
+ -- from that origin's `issue_url` (NULL for a self-rooted PR) — and NEVER reads `lt.kind` /
9
+ -- `lt.issue_url`. `pollLineage` (app/lineage.ts) no longer writes them (this wave-1 cleanup).
10
+ -- • PROCEDURAL (kept, still poller-written, still read by the view): `title` (a self-rooted PR's
11
+ -- representative-PR pick), `stage`, `stage_label`, `process_key`, `pr_keys`, `pr_count`, `active`,
12
+ -- `created_at`, `updated_at`. These are NOT dropped — the view passes them through from
13
+ -- `lineage_threads`, and `pollLineage` remains their single writer.
14
+ --
15
+ -- So drop ONLY the two fully view-backed columns; the procedural remainder stays. Both dropped
16
+ -- columns were nullable with no default and now have no writer or reader, and no index references
17
+ -- them, so a plain `ALTER TABLE … DROP COLUMN` suffices. Because a procedural remainder survives, the
18
+ -- `lineage_threads` table itself is NOT dropped.
19
+ --
20
+ -- Forward-only contract migration numbered in this task's disjoint 070-079 block. The runner wraps
21
+ -- each file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
22
+ ALTER TABLE lineage_threads DROP COLUMN kind;
23
+ ALTER TABLE lineage_threads DROP COLUMN issue_url;
@@ -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 one-shot feature-stage backfill
113
- // runs before the UI is relied upon the Feature Runs grid/tabs filter on the stored `list_bucket`
114
- // projection, which is NULL on legacy rows until `backfillFeatureStages()` runs inside `pollOnce()`.
115
- // Deferring the first pass would leave those rows missing from Active/History for up to POLL_MS.
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> {
@@ -1,17 +1,19 @@
1
1
  // Tests for the POST /app/api/actions/acknowledge-done operation `acknowledgeDone` (issue #254 §5).
2
- // The nwf UI's "tick off" affordance for a TERMINAL feature run: it stamps `acknowledged_at` via the
3
- // feature_runs gateway, which recomputes `list_bucket` to 'history', dropping the run from Active into
4
- // History. Unlike acknowledgeBlocked it completes NO user task (a terminal run is not parked). Mirrors
5
- // the acknowledge-blocked twin's shape.
2
+ // The nwf UI's "tick off" affordance for a TERMINAL feature run: it stamps `acknowledged_at`, which
3
+ // the `feature_read_model` VIEW (073, issue #439) derives into `list_bucket` = 'history', dropping the
4
+ // run from Active into History. Unlike acknowledgeBlocked it completes NO user task (a terminal run is
5
+ // not parked). Since `list_bucket` is now a VIEW over `status`/`acknowledged_at` (no stored column),
6
+ // these tests assert the operation's real write — the `acknowledged_at` stamp — and cross-check the
7
+ // resulting bucket through the pure `deriveListBucket` oracle the VIEW mirrors.
6
8
  import { test } from "node:test";
7
9
  import { assertEquals } from "#test-assert";
8
10
  import type { AppApi } from "@nanobpm/urban";
9
- import { featureRuns } from "../app/feature.ts";
11
+ import { deriveListBucket } from "../app/stage.ts";
10
12
  import { noopLog } from "../test/log.ts";
11
13
  import handler from "./acknowledgeDone.ts";
12
14
 
13
- // An in-memory data layer wired through the REAL featureRuns gateway proxy, so the test exercises the
14
- // gateway's list_bucket projection exactly as production does.
15
+ // An in-memory data layer wired through the `featureRuns` gateway (now a plain record table), so the
16
+ // test exercises the operation's write path exactly as production does.
15
17
  function memApp(seed: any[]): { app: AppApi; rows: any[] } {
16
18
  const stores: Record<string, any[]> = { feature_runs: seed };
17
19
  function tbl(name: string, pk = "id") {
@@ -49,30 +51,30 @@ async function call(app: AppApi, body: unknown) {
49
51
  return (await handler({ req: {} as any, params: {}, query: {}, body } as any, app)) as any;
50
52
  }
51
53
 
52
- test("acknowledge-done: stamps acknowledged_at and flips list_bucket to 'history' on a terminal row", async () => {
54
+ test("acknowledge-done: stamps acknowledged_at and (via the VIEW) buckets a terminal row into History", async () => {
53
55
  const { app, rows } = memApp([{ feature_key: "o/r#1", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: null }]);
54
- // Seed the projection as the gateway would have on the last write (terminal, unacknowledged → active).
55
- await featureRuns(app.data).update("o/r#1", { status: "merged" });
56
- assertEquals(rows[0].list_bucket, "active");
56
+ // Before dismissal a terminal-but-unacknowledged run reads as Active through the VIEW.
57
+ assertEquals(deriveListBucket(rows[0].status, rows[0].acknowledged_at), "active");
57
58
 
58
59
  const res = await call(app, { feature_key: "o/r#1" });
59
60
 
60
61
  assertEquals(res.status, 200);
61
62
  assertEquals(res.body.ok, true);
62
63
  assertEquals(typeof rows[0].acknowledged_at, "string");
63
- assertEquals(rows[0].list_bucket, "history");
64
+ // The op's only write is the stamp; the VIEW derives 'history' from (terminal status + acknowledged).
65
+ assertEquals(deriveListBucket(rows[0].status, rows[0].acknowledged_at), "history");
64
66
  });
65
67
 
66
68
  test("acknowledge-done: idempotent-safe — re-acknowledging keeps the row in History", async () => {
67
69
  const { app, rows } = memApp([{ feature_key: "o/r#2", status: "failed", converge: 1, auto_merge: 1, acknowledged_at: null }]);
68
70
  const first = await call(app, { feature_key: "o/r#2" });
69
71
  assertEquals(first.status, 200);
70
- assertEquals(rows[0].list_bucket, "history");
72
+ assertEquals(deriveListBucket(rows[0].status, rows[0].acknowledged_at), "history");
71
73
  const firstStamp = rows[0].acknowledged_at;
72
74
  // Re-acknowledge — still 200, still history.
73
75
  const second = await call(app, { feature_key: "o/r#2" });
74
76
  assertEquals(second.status, 200);
75
- assertEquals(rows[0].list_bucket, "history");
77
+ assertEquals(deriveListBucket(rows[0].status, rows[0].acknowledged_at), "history");
76
78
  assertEquals(typeof firstStamp, "string");
77
79
  });
78
80
 
@@ -92,12 +94,12 @@ test("acknowledge-done: no such feature run → 404", async () => {
92
94
 
93
95
  test("acknowledge-done: a non-terminal run → 409, no acknowledged_at stamped", async () => {
94
96
  const { app, rows } = memApp([{ feature_key: "o/r#live", status: "running", converge: 1, auto_merge: 1, acknowledged_at: null }]);
95
- await featureRuns(app.data).update("o/r#live", { status: "running" });
96
97
  const res = await call(app, { feature_key: "o/r#live" });
97
98
  assertEquals(res.status, 409);
98
99
  assertEquals(res.body.ok, false);
99
100
  assertEquals(rows[0].acknowledged_at, null);
100
- assertEquals(rows[0].list_bucket, "active");
101
+ // A live run stays Active through the VIEW (no stamp → not History).
102
+ assertEquals(deriveListBucket(rows[0].status, rows[0].acknowledged_at), "active");
101
103
  });
102
104
 
103
105
  test("acknowledge-done: a converging (redispatch-terminal but live) run → 409", async () => {
@@ -3,13 +3,15 @@
3
3
  // run (Done ✓ / Done ✕) directly from the Feature / Overview pages so it drops out of the primary
4
4
  // Active list into History. It is the DONE twin of `acknowledgeBlocked` — but a terminal run is NOT
5
5
  // parked at a user task, so this op does NOT complete a user task and touches no engine/ledger: it
6
- // simply stamps `acknowledged_at` on the row via the feature_runs gateway. It rejects (409) a run that
6
+ // simply stamps `acknowledged_at` on the row via the plain `feature_runs` record table (the projecting
7
+ // gateway this PR retired). It rejects (409) a run that
7
8
  // is not yet truly terminal, so it can never pre-seed the tick-off on a still-live run.
8
9
  //
9
- // The gateway (app/feature.ts) recomputes `list_bucket` on that write a terminal row with
10
- // `acknowledged_at` set flips to 'history' so this op NEVER hand-sets `list_bucket` (or any other
11
- // projection). Keyed on the row's `feature_key`. Idempotent-safe: re-acknowledging simply re-stamps
12
- // the timestamp and keeps the row in History.
10
+ // The `list_bucket` partition is DERIVED by the `feature_read_model` VIEW (073, issue #439) from
11
+ // `status` + `acknowledged_at`a terminal row with `acknowledged_at` set reads as 'history' — so
12
+ // this op NEVER writes `list_bucket` (or any projection): stamping `acknowledged_at` is the whole
13
+ // contract. Keyed on the row's `feature_key`. Idempotent-safe: re-acknowledging simply re-stamps the
14
+ // timestamp and keeps the row in History.
13
15
 
14
16
  import { featureRuns } from "../app/feature.ts";
15
17
  import { STAGE_DONE_STATUSES } from "../app/stage.ts";
@@ -42,9 +44,9 @@ export default defineOperation("acknowledgeDone", async ({ body }, app) => {
42
44
  return { status: 409, body: { ok: false, error: "feature run is not terminal" } };
43
45
  }
44
46
 
45
- // Stamp the dismissal. The gateway recomputes `list_bucket` from the merged row (→ 'history' for a
46
- // terminal run), so we never hand-set it here. Idempotent: re-acknowledging re-stamps and stays in
47
- // History.
47
+ // Stamp the dismissal. `list_bucket` is derived by the `feature_read_model` VIEW (→ 'history' for a
48
+ // terminal, acknowledged row), so we never hand-set it here. Idempotent: re-acknowledging re-stamps
49
+ // and stays in History.
48
50
  const now = new Date().toISOString();
49
51
  await runs.update(featureKey, { acknowledged_at: now, updated_at: now });
50
52