@nanobpm/nano-workforce 0.120.2 → 0.122.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.
@@ -1,8 +1,14 @@
1
1
  // Tests for the `plans` gateway's write-time epic-bucket projection (issue #298) and its one-shot
2
2
  // backfill. The gateway wraps the plain table so EVERY writer — startPlan, the record workers, the
3
- // delivery poller, the acknowledge-epic op — automatically gets a fresh `list_bucket`/`ack_open`
4
- // projection without passing them: the single write path is the only place `deriveEpicBucket` /
5
- // `epicIsAcknowledgeable` are applied. Mirrors app/featureGateway.test.ts.
3
+ // acknowledge-epic op — automatically gets a fresh `list_bucket`/`ack_open` projection without
4
+ // passing them: the single write path is the only place `deriveEpicBucket` / `epicIsAcknowledgeable`
5
+ // are applied. Mirrors app/featureGateway.test.ts.
6
+ //
7
+ // Since epic #412 retired the stored `plans.delivery` column, the gateway projects with `delivery`
8
+ // treated as UNKNOWN (null): `list_bucket` is provably identical to the delivery-aware value for
9
+ // every reachable state, and `ack_open` becomes a *candidate* (any unacknowledged `done` epic) that
10
+ // the `pollPlanBucket` read-model pass (implemented in app/service.ts, tested in app/delivery.test.ts)
11
+ // corrects with the read-time signal.
6
12
  import { test } from "node:test";
7
13
  import { assert, assertEquals } from "#test-assert";
8
14
  import type { DataLayer } from "@nanobpm/urban";
@@ -46,21 +52,20 @@ test("insert projects list_bucket/ack_open from status without the caller passin
46
52
  assertEquals(rows[0].ack_open, 0);
47
53
  });
48
54
 
49
- test("status flip to done+converging keeps the epic Active (no vanish)", async () => {
55
+ test("status flip to done keeps the epic Active and marks a Dismiss candidate (delivery-free gateway)", async () => {
50
56
  const { data, rows } = memData();
51
- rows.push({ plan_key: "o/r#2", status: "dispatched", delivery: null });
52
- // record-results marks the epic done; the delivery poller then sets converging.
57
+ rows.push({ plan_key: "o/r#2", status: "dispatched" });
58
+ // record-results marks the epic done. The delivery-free gateway keeps it Active and — not seeing
59
+ // the read-time delivery signal — marks it a Dismiss candidate (ack_open=1); `pollPlanBucket`
60
+ // clears ack_open while the epic is still converging.
53
61
  await plans(data).update("o/r#2", { status: "done" });
54
62
  assertEquals(rows[0].list_bucket, "active");
55
- await plans(data).update("o/r#2", { delivery: "converging", delivery_label: "1/2 slices merged, 1 converging" });
56
- assertEquals(rows[0].list_bucket, "active");
57
- assertEquals(rows[0].ack_open, 0);
63
+ assertEquals(rows[0].ack_open, 1);
58
64
  });
59
65
 
60
- test("delivery landing opens the Dismiss affordance (ack_open=1) but keeps it Active until acknowledged", async () => {
66
+ test("acknowledging a done epic flips it to History and closes the Dismiss affordance", async () => {
61
67
  const { data, rows } = memData();
62
- rows.push({ plan_key: "o/r#3", status: "done", delivery: "converging" });
63
- await plans(data).update("o/r#3", { delivery: "landed", delivery_label: "2/2 slices merged" });
68
+ await plans(data).insert({ plan_key: "o/r#3", status: "done" });
64
69
  assertEquals(rows[0].list_bucket, "active");
65
70
  assertEquals(rows[0].ack_open, 1);
66
71
  // Operator dismisses → gateway reprojects to History, ack_open closes.
@@ -69,30 +74,31 @@ test("delivery landing opens the Dismiss affordance (ack_open=1) but keeps it Ac
69
74
  assertEquals(rows[0].ack_open, 0);
70
75
  });
71
76
 
72
- test("a projection-irrelevant patch (wave_label only) does not disturb the stored bucket", async () => {
77
+ test("a projection-irrelevant patch (epic_phase only) does not disturb the stored bucket", async () => {
73
78
  const { data, rows } = memData();
74
- rows.push({ plan_key: "o/r#4", status: "done", delivery: "landed", list_bucket: "active", ack_open: 1 });
75
- await plans(data).update("o/r#4", { wave_label: "2/2" });
79
+ rows.push({ plan_key: "o/r#4", status: "done", list_bucket: "active", ack_open: 1 });
80
+ await plans(data).update("o/r#4", { epic_phase: "Finalizing" });
76
81
  assertEquals(rows[0].list_bucket, "active");
77
82
  assertEquals(rows[0].ack_open, 1);
78
83
  });
79
84
 
80
85
  test("a direct write to list_bucket is overridden by the canonical derivation (no bypass)", async () => {
81
86
  const { data, rows } = memData();
82
- rows.push({ plan_key: "o/r#5", status: "done", delivery: "converging" });
83
- // A caller tries to force History; the gateway re-derives from status+delivery and overrides it.
87
+ rows.push({ plan_key: "o/r#5", status: "done" });
88
+ // A caller tries to force History; the gateway re-derives from status (delivery-free) and overrides it.
84
89
  await plans(data).update("o/r#5", { list_bucket: "history" });
85
90
  assertEquals(rows[0].list_bucket, "active");
86
91
  });
87
92
 
88
93
  test("backfillPlanBuckets stamps only legacy (NULL list_bucket) rows, idempotently", async () => {
89
94
  const { data, rows } = memData();
90
- rows.push({ plan_key: "o/r#legacy", status: "done", delivery: "converging", list_bucket: null, ack_open: null });
95
+ rows.push({ plan_key: "o/r#legacy", status: "done", list_bucket: null, ack_open: null });
91
96
  rows.push({ plan_key: "o/r#fresh", status: "planning", list_bucket: "active", ack_open: 0 });
92
97
  const stamped = await backfillPlanBuckets(data);
93
98
  assertEquals(stamped, 1);
94
99
  assertEquals(rows[0].list_bucket, "active");
95
- assertEquals(rows[0].ack_open, 0);
100
+ // Delivery-free gateway marks the done epic a Dismiss candidate; pollPlanBucket corrects it later.
101
+ assertEquals(rows[0].ack_open, 1);
96
102
  // Second pass is a no-op: every row is now projected.
97
103
  assertEquals(await backfillPlanBuckets(data), 0);
98
104
  });
package/app/service.ts CHANGED
@@ -27,7 +27,7 @@ import {
27
27
  conformanceEscalationQuestion,
28
28
  } from "./conformance.ts";
29
29
  import { isUniqueConstraintFence } from "./dbFence.ts";
30
- import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
30
+ import { deriveDelivery, deriveEpicBucket, epicIsAcknowledgeable, 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";
@@ -52,19 +52,19 @@ 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
57
  backfillPlanBuckets,
59
58
  capabilityGates,
60
59
  inboundPlanDeps,
60
+ type Plan,
61
61
  planReviews,
62
62
  plans,
63
63
  planTaskDeps,
64
64
  planTaskNeeds,
65
65
  planTasks,
66
66
  } from "./plan.ts";
67
- import { derivePromotionState, isPromotable, promotionPrBody, promotionPrTitle } from "./promotion.ts";
67
+ import { derivePromotionState, isEpicIntegrationBranch, isPromotable, promotionPrBody, promotionPrTitle } from "./promotion.ts";
68
68
  import {
69
69
  defaultProbeExec,
70
70
  type ProbeExec,
@@ -1862,54 +1862,75 @@ export async function pollCapabilityGatesImpl(
1862
1862
  }
1863
1863
  }
1864
1864
 
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
1865
  /** Sentinel status fed to `deriveDelivery` for a `plan_tasks.pr_key` whose `pull_requests` row is
1871
1866
  * missing (DB desync). It is deliberately non-terminal and not `merged`, so a dangling PR counts as
1872
1867
  * in-flight — never a false-positive `landed` from a silently-dropped slice. */
1873
1868
  const MISSING_PR_STATUS = "missing";
1874
1869
 
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).
1870
+ /** Recompute an epic's derived `delivery` signal at READ TIME (epic #412) from the SAME pure
1871
+ * `deriveDelivery` the `plan_delivery` VIEW encodes by joining each slice `plan_tasks.pr_key`
1872
+ * `pull_requests.status` (a dangling `pr_key` counts as in-flight, never false-`landed`). The
1873
+ * `plans.delivery` column was RETIRED, so the pollers that still need the signal derive it here
1874
+ * rather than reading a denormalised column. Non-`done` plans short-circuit to `null` (the view's
1875
+ * behaviour) without the per-plan task join. `statusByPrKey` is an optional once-per-pass PR-status
1876
+ * map (the pollers preload it to avoid an N+1); when omitted (a one-off caller like the
1877
+ * acknowledge-epic op) it loads only THIS plan's slice PRs on demand — never the whole table. */
1878
+ export async function derivePlanDelivery(
1879
+ data: DataLayer,
1880
+ plan: Plan,
1881
+ statusByPrKey?: Map<string, string>,
1882
+ ): Promise<string | null> {
1883
+ if (plan.status !== "done") return null;
1884
+ const tasks = await planTasks(data).find({ plan_key: plan.plan_key });
1885
+ const prStatuses: string[] = [];
1886
+ for (const t of tasks) {
1887
+ if (!t.pr_key) continue;
1888
+ let status = statusByPrKey?.get(t.pr_key);
1889
+ if (status === undefined && !statusByPrKey) {
1890
+ // On-demand caller: fetch just this slice's PR row rather than loading the whole table.
1891
+ status = (await prs(data).get(t.pr_key))?.status;
1892
+ }
1893
+ prStatuses.push(status ?? MISSING_PR_STATUS);
1894
+ }
1895
+ return deriveDelivery(plan.status, prStatuses).delivery;
1896
+ }
1897
+
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`).
1878
1910
  const statusByPrKey = new Map<string, string>();
1879
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");
1880
1917
  for (const plan of await plans(data).all()) {
1881
1918
  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,
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,
1908
1929
  updated_at: now(),
1909
1930
  });
1910
1931
  }
1911
1932
  } catch (err) {
1912
- console.error(`[poller] delivery ${plan.plan_key}: ${err}`);
1933
+ console.error(`[poller] plan-bucket ${plan.plan_key}: ${err}`);
1913
1934
  }
1914
1935
  }
1915
1936
  }
@@ -1926,12 +1947,30 @@ export async function pollDelivery(data: DataLayer) {
1926
1947
  * a prior edge (e.g. an edge later removed) is cleared defensively so the read model never keeps a
1927
1948
  * phantom gate. */
1928
1949
  export async function pollWaitGate(data: DataLayer) {
1950
+ // Preload every plan_tasks row once per pass and group by plan_key, so deriving each plan's
1951
+ // fanned-out signal below is a map lookup rather than a per-plan `planTasks(data).find` (avoids
1952
+ // an N+1 that would double this pass's DB work alongside the per-plan `inboundPlanDeps` lookup).
1953
+ const wavesByPlanKey = new Map<string, number[]>();
1954
+ for (const t of await planTasks(data).all()) {
1955
+ if (t.wave == null) continue;
1956
+ const list = wavesByPlanKey.get(t.plan_key) ?? [];
1957
+ list.push(t.wave);
1958
+ wavesByPlanKey.set(t.plan_key, list);
1959
+ }
1929
1960
  for (const plan of await plans(data).all()) {
1930
1961
  try {
1931
1962
  const edges = await inboundPlanDeps(data, plan.plan_key);
1963
+ // `plans.current_wave` was retired (epic #412); `deriveWaitGate` only consumes its
1964
+ // NULL-ness (proof the epic's fan-out began). Derive that at read time: an epic has fanned out
1965
+ // iff it has ≥1 levelized `plan_tasks` row (a wave assigned). The value's magnitude is never
1966
+ // surfaced here — the epic index/detail read the display `current_wave` off the
1967
+ // `plan_wave_label`/`plan_read_model` VIEWs — so any non-null (the frontier's min wave) is
1968
+ // faithful for the gate's "has this epic ever fanned out?" test.
1969
+ const assignedWaves = wavesByPlanKey.get(plan.plan_key) ?? [];
1970
+ const current_wave = assignedWaves.length > 0 ? Math.min(...assignedWaves) : null;
1932
1971
  const { wait_gate, wait_gate_label } = deriveWaitGate(edges, {
1933
1972
  status: plan.status,
1934
- current_wave: plan.current_wave,
1973
+ current_wave,
1935
1974
  bound_artifacts: plan.bound_artifacts,
1936
1975
  created_at: plan.created_at,
1937
1976
  });
@@ -1951,8 +1990,8 @@ export async function pollWaitGate(data: DataLayer) {
1951
1990
  /** Idempotent promotion pass (issue #299): open — and then track — the `epic/* → <default>`
1952
1991
  * promotion PR for every epic that has LANDED on a custom integration branch. This is the missing
1953
1992
  * 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.
1993
+ * delivers the fully-landed branch to the default branch. Derives each epic's `delivery` signal at
1994
+ * read time (epic #412 — the `plans.delivery` column was retired) via the same pure `deriveDelivery`.
1956
1995
  *
1957
1996
  * Per promotable plan (`isPromotable`: `delivery = landed` AND base is `epic/*`):
1958
1997
  * • No promotion PR yet → open ONE `epic/* → <default>` PR (idempotent against a remote head-branch
@@ -1972,10 +2011,15 @@ export async function pollPromotion(data: DataLayer, engine: EngineClient, token
1972
2011
  const statusByPrKey = new Map<string, string>();
1973
2012
  for (const pr of await prs(data).all()) statusByPrKey.set(pr.pr_key, pr.status);
1974
2013
  for (const plan of await plans(data).all()) {
1975
- if (!isPromotable(plan)) continue;
1976
2014
  const base = plan.base_branch;
1977
- if (!base) continue; // narrowed by isPromotable, but keep the type-checker honest
2015
+ // A non-`epic/*` base is never promotable short-circuit before the per-plan delivery join.
2016
+ if (!isEpicIntegrationBranch(base)) continue;
1978
2017
  try {
2018
+ // `plans.delivery` was retired (epic #412) — derive the landed signal at READ TIME from the
2019
+ // slice PRs (same pure `deriveDelivery` the `plan_delivery` VIEW encodes) instead of reading a
2020
+ // denormalised column, then apply the pure `isPromotable` predicate.
2021
+ const delivery = await derivePlanDelivery(data, plan, statusByPrKey);
2022
+ if (!isPromotable({ delivery, base_branch: base })) continue;
1979
2023
  // Already opened → project state from the promotion PR's live status, and re-enroll it if its
1980
2024
  // convergence row went missing (a prior submit failed, or the app/engine store desynced).
1981
2025
  if (plan.promotion_pr) {
@@ -2433,12 +2477,11 @@ export async function pollOnce(
2433
2477
  }
2434
2478
  await pollReviews(data, engine, token);
2435
2479
  await pollMerges(data, engine, token);
2436
- await pollDelivery(data);
2480
+ await pollPlanBucket(data);
2437
2481
  await pollWaitGate(data);
2438
2482
  await pollPromotion(data, engine, token);
2439
2483
  await pollFeatureDelivery(data);
2440
2484
  await pollLineage(data);
2441
- await pollMergesPerDay(data);
2442
2485
  await pollUserTasks(data, engine, engineRest);
2443
2486
  await pollDeliveryGraphPhase(data, engine);
2444
2487
  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;
package/openapi.yaml CHANGED
@@ -1616,6 +1616,20 @@ components:
1616
1616
  diagram:
1617
1617
  type: string
1618
1618
  description: The mermaid flowchart of the compiled graph (preview).
1619
+ humanNodes:
1620
+ type: array
1621
+ items:
1622
+ $ref: "#/components/schemas/DeliveryHumanStop"
1623
+ description: >-
1624
+ The human stop-points the compiled graph parks on (preview) — where it waits for a person
1625
+ (or an agent answering on their behalf), rendered by the Delivery Graphs page (#441).
1626
+ sideEffects:
1627
+ type: array
1628
+ items:
1629
+ $ref: "#/components/schemas/DeliverySideEffect"
1630
+ description: >-
1631
+ The side-effecting (`agent`/`connector`) actions the compiled graph WILL perform (preview)
1632
+ — what an approval authorises (Decision 7), rendered by the Delivery Graphs page (#441).
1619
1633
  ResolvedDeliveryNode:
1620
1634
  description: >-
1621
1635
  A normalised node in the compiled graph (ADR 0005 slice S1) — its `id`, `kind`, the
@@ -1,9 +1,14 @@
1
1
  // Tests for the POST /app/api/actions/acknowledge-epic operation `acknowledgeEpic` (issue #298).
2
- // The nwf UI's "Dismiss" affordance for a RESOLVED epic — landed (`delivery=landed`) or
3
- // resolved-not-landed (`delivery=null`); only still-`converging` epics are rejected. It stamps
2
+ // The nwf UI's "Dismiss" affordance for a RESOLVED epic — landed (delivery=landed) or
3
+ // resolved-not-landed (delivery=null); only still-`converging` epics are rejected. It stamps
4
4
  // `acknowledged_at` via the plans gateway, which recomputes `list_bucket` to 'history' (and
5
5
  // `ack_open` to 0), dropping the resolved epic from Active into History. Unlike acknowledge-blocked
6
6
  // it completes NO user task (a resolved epic is not parked). The epic twin of acknowledge-done.
7
+ //
8
+ // Since epic #412 retired the stored `plans.delivery` column, the op derives the delivery signal at
9
+ // READ TIME (`derivePlanDelivery` → the pure `deriveDelivery`) by joining the epic's slice
10
+ // `plan_tasks.pr_key` → `pull_requests.status`. So these tests seed `plan_tasks` + `pull_requests`
11
+ // (not a `plans.delivery` column) to model a landed / converging / resolved-not-landed epic.
7
12
  import { test } from "node:test";
8
13
  import { assertEquals } from "#test-assert";
9
14
  import type { AppApi } from "@nanobpm/urban";
@@ -12,9 +17,13 @@ import { noopLog } from "../test/log.ts";
12
17
  import handler from "./acknowledgeEpic.ts";
13
18
 
14
19
  // An in-memory data layer wired through the REAL plans gateway proxy, so the test exercises the
15
- // gateway's list_bucket/ack_open projection exactly as production does.
16
- function memApp(seed: any[]): { app: AppApi; rows: any[] } {
17
- const stores: Record<string, any[]> = { plans: seed };
20
+ // gateway's list_bucket/ack_open projection exactly as production does. `extra` seeds the join
21
+ // surfaces (`plan_tasks` / `pull_requests`) the read-time delivery derivation reads.
22
+ function memApp(
23
+ seed: any[],
24
+ extra: Record<string, any[]> = {},
25
+ ): { app: AppApi; rows: any[] } {
26
+ const stores: Record<string, any[]> = { plans: seed, ...extra };
18
27
  function tbl(name: string, pk = "id") {
19
28
  const rows = (stores[name] ??= [] as any[]);
20
29
  const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
@@ -50,10 +59,21 @@ async function call(app: AppApi, body: unknown) {
50
59
  return (await handler({ req: {} as any, params: {}, query: {}, body } as any, app)) as any;
51
60
  }
52
61
 
62
+ /** Fire a projecting gateway write (re-writing `status`) so `list_bucket`/`ack_open` reflect the
63
+ * delivery-free gateway projection, mirroring the last real write before the op runs. */
64
+ async function project(app: AppApi, key: string, status: string) {
65
+ await plans(app.data).update(key, { status });
66
+ }
67
+
53
68
  test("acknowledge-epic: stamps acknowledged_at and flips list_bucket to 'history' on a landed epic", async () => {
54
- const { app, rows } = memApp([{ plan_key: "o/r#1", status: "done", delivery: "landed", acknowledged_at: null }]);
55
- // Seed the projection as the gateway would have on the last write (landed, unacknowledged → active).
56
- await plans(app.data).update("o/r#1", { delivery: "landed" });
69
+ const { app, rows } = memApp(
70
+ [{ plan_key: "o/r#1", status: "done", acknowledged_at: null }],
71
+ {
72
+ plan_tasks: [{ id: 1, plan_key: "o/r#1", pr_key: "o/r#100" }],
73
+ pull_requests: [{ pr_key: "o/r#100", status: "merged" }], // landed
74
+ },
75
+ );
76
+ await project(app, "o/r#1", "done");
57
77
  assertEquals(rows[0].list_bucket, "active");
58
78
  assertEquals(rows[0].ack_open, 1);
59
79
 
@@ -67,8 +87,20 @@ test("acknowledge-epic: stamps acknowledged_at and flips list_bucket to 'history
67
87
  });
68
88
 
69
89
  test("acknowledge-epic: a still-converging epic is rejected (409) and stays Active", async () => {
70
- const { app, rows } = memApp([{ plan_key: "o/r#2", status: "done", delivery: "converging", acknowledged_at: null }]);
71
- await plans(app.data).update("o/r#2", { delivery: "converging" });
90
+ const { app, rows } = memApp(
91
+ [{ plan_key: "o/r#2", status: "done", acknowledged_at: null }],
92
+ {
93
+ plan_tasks: [
94
+ { id: 1, plan_key: "o/r#2", pr_key: "o/r#200" },
95
+ { id: 2, plan_key: "o/r#2", pr_key: "o/r#201" },
96
+ ],
97
+ pull_requests: [
98
+ { pr_key: "o/r#200", status: "merged" },
99
+ { pr_key: "o/r#201", status: "converging" }, // still in flight → converging
100
+ ],
101
+ },
102
+ );
103
+ await project(app, "o/r#2", "done");
72
104
 
73
105
  const res = await call(app, { plan_key: "o/r#2" });
74
106
 
@@ -80,9 +112,20 @@ test("acknowledge-epic: a still-converging epic is rejected (409) and stays Acti
80
112
  });
81
113
 
82
114
  test("acknowledge-epic: a resolved-not-landed epic (delivery=null) is accepted (200) and flips to History", async () => {
83
- const { app, rows } = memApp([{ plan_key: "o/r#2b", status: "done", delivery: null, acknowledged_at: null }]);
84
- // Seed the projection as the gateway would have on the last write (resolved-not-landed, unacknowledged → active).
85
- await plans(app.data).update("o/r#2b", { delivery: null });
115
+ const { app, rows } = memApp(
116
+ [{ plan_key: "o/r#2b", status: "done", acknowledged_at: null }],
117
+ {
118
+ plan_tasks: [
119
+ { id: 1, plan_key: "o/r#2b", pr_key: "o/r#210" },
120
+ { id: 2, plan_key: "o/r#2b", pr_key: "o/r#211" },
121
+ ],
122
+ pull_requests: [
123
+ { pr_key: "o/r#210", status: "merged" },
124
+ { pr_key: "o/r#211", status: "abandoned" }, // all terminal, not all merged → delivery null
125
+ ],
126
+ },
127
+ );
128
+ await project(app, "o/r#2b", "done");
86
129
  assertEquals(rows[0].list_bucket, "active");
87
130
  assertEquals(rows[0].ack_open, 1);
88
131
 
@@ -96,7 +139,7 @@ test("acknowledge-epic: a resolved-not-landed epic (delivery=null) is accepted (
96
139
  });
97
140
 
98
141
  test("acknowledge-epic: a live (dispatched) epic is rejected (409)", async () => {
99
- const { app } = memApp([{ plan_key: "o/r#3", status: "dispatched", delivery: null, acknowledged_at: null }]);
142
+ const { app } = memApp([{ plan_key: "o/r#3", status: "dispatched", acknowledged_at: null }]);
100
143
  const res = await call(app, { plan_key: "o/r#3" });
101
144
  assertEquals(res.status, 409);
102
145
  });
@@ -114,8 +157,14 @@ test("acknowledge-epic: no matching epic → 404", async () => {
114
157
  });
115
158
 
116
159
  test("acknowledge-epic: idempotent — re-acknowledging a landed epic keeps it in History", async () => {
117
- const { app, rows } = memApp([{ plan_key: "o/r#5", status: "done", delivery: "landed", acknowledged_at: null }]);
118
- await plans(app.data).update("o/r#5", { delivery: "landed" });
160
+ const { app, rows } = memApp(
161
+ [{ plan_key: "o/r#5", status: "done", acknowledged_at: null }],
162
+ {
163
+ plan_tasks: [{ id: 1, plan_key: "o/r#5", pr_key: "o/r#500" }],
164
+ pull_requests: [{ pr_key: "o/r#500", status: "merged" }], // landed
165
+ },
166
+ );
167
+ await project(app, "o/r#5", "done");
119
168
 
120
169
  assertEquals((await call(app, { plan_key: "o/r#5" })).status, 200);
121
170
  const firstStamp = rows[0].acknowledged_at;
@@ -19,6 +19,7 @@
19
19
 
20
20
  import { epicIsAcknowledgeable } from "../app/delivery.ts";
21
21
  import { plans } from "../app/plan.ts";
22
+ import { derivePlanDelivery } from "../app/service.ts";
22
23
  import { defineOperation } from "../nano-generated/operations.ts";
23
24
 
24
25
  const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
@@ -43,11 +44,14 @@ export default defineOperation("acknowledgeEpic", async ({ body }, app) => {
43
44
  // affordance. Acknowledging a live/converging epic would pre-seed `acknowledged_at`, so the moment
44
45
  // it later resolved `deriveEpicBucket` would drop it straight into History, skipping the operator
45
46
  // tick-off this op exists to require — and a converging epic must stay visible while its slices land.
46
- if (!epicIsAcknowledgeable(plan.status, plan.delivery ?? null)) {
47
+ // The `plans.delivery` column was retired (epic #412), so derive the signal at read time from the
48
+ // slice PRs (the same pure `deriveDelivery` the `plan_delivery` VIEW encodes).
49
+ const delivery = await derivePlanDelivery(app.data, plan);
50
+ if (!epicIsAcknowledgeable(plan.status, delivery)) {
47
51
  app.log.warn("acknowledge-epic rejected: epic is not resolved", {
48
52
  planKey,
49
53
  status: plan.status,
50
- delivery: plan.delivery ?? null,
54
+ delivery,
51
55
  });
52
56
  return { status: 409, body: { ok: false, error: "epic is not resolved" } };
53
57
  }
@@ -35,6 +35,15 @@ test("preview-delivery-graph: a pasted well-formed graph → 200 summary with di
35
35
  assertEquals(res.body.sideEffecting, true);
36
36
  assert(typeof res.body.diagram === "string" && res.body.diagram.length > 0);
37
37
  assertEquals(res.body.title, "runbook");
38
+ // The FULL preview detail (#441) — the human stop-points and side-effecting actions the page
39
+ // renders, not just the counts. `a` is the side-effecting agent node; `b` is the human stop.
40
+ assert(Array.isArray(res.body.humanNodes) && res.body.humanNodes.length === 1);
41
+ assertEquals(res.body.humanNodes[0].nodeId, "b");
42
+ assertEquals(res.body.humanNodes[0].prompt, "do X");
43
+ assert(Array.isArray(res.body.sideEffects) && res.body.sideEffects.length === 1);
44
+ assertEquals(res.body.sideEffects[0].nodeId, "a");
45
+ assertEquals(res.body.sideEffects[0].kind, "agent");
46
+ assert(typeof res.body.sideEffects[0].description === "string" && res.body.sideEffects[0].description.length > 0);
38
47
  });
39
48
 
40
49
  test("preview-delivery-graph: is PURE — repeated previews return the identical digest", async () => {
@@ -54,6 +54,12 @@ export default defineOperation("previewDeliveryGraph", async ({ body }, app) =>
54
54
  humanNodeCount: compiled.humanNodes.length,
55
55
  sideEffectCount: compiled.sideEffects.length,
56
56
  diagram: compiled.diagram,
57
+ // The FULL extracted preview detail (not just the counts): the human stop-points and the
58
+ // side-effecting actions the operator is being asked to approve (Decision 7). The Delivery
59
+ // Graphs page renders these lists so the operator sees WHERE it parks on a person and WHAT it
60
+ // will do before dispatching — the "preview before dispatch" principle made visible (#441).
61
+ humanNodes: compiled.humanNodes,
62
+ sideEffects: compiled.sideEffects,
57
63
  },
58
64
  };
59
65
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.120.2",
3
+ "version": "0.122.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",