@nanobpm/nano-workforce 0.167.2 → 0.167.4

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.
@@ -0,0 +1,242 @@
1
+ // Categorical parity test for the FOUR acknowledge/dismiss ops (issue #654), driven off the read
2
+ // models' derived `ack_open` — NOT a hand-maintained per-op terminal-status list. It proves the ONE
3
+ // shared `acknowledgeVia` helper (app/acknowledge.ts) gates every op on the SAME oracle the UI's
4
+ // Dismiss button consumes (`ack_open`), so the affordance and the guard cannot drift.
5
+ //
6
+ // The bug this guards against (manifested for PR as #652, latent for the other three): each op used to
7
+ // re-derive terminality from its BASE `status` column while the button read the read model's folded
8
+ // `derived_status`. When an instance is terminated OUT-OF-BAND the base `status` freezes non-terminal
9
+ // (`running`/`escalated`/`dispatched`) while the read model folds `derived_status='abandoned'` — so the
10
+ // button showed Dismiss but the op 409'd "not terminal". Each surface's RED case below is exactly that
11
+ // shape: a DERIVE-ONLY terminated row (base status frozen non-terminal, `derived_status='abandoned'`).
12
+ //
13
+ // FIDELITY. The fake `data.table(<view>)` computes each row's `ack_open`/`list_bucket` from the REAL
14
+ // read model applied to the base store row (PR/feature/DG via `ReadModel.evaluate`; epic via the
15
+ // `deriveEpicBucket`/`epicIsAcknowledgeable` oracles the `plan_read_model` VIEW mirrors) — so the fake
16
+ // VIEW is genuinely the read model over the base row, and stamping `acknowledged_at` on the base store
17
+ // re-folds the row to History on the next read, exactly as the SQL VIEW does in production.
18
+ import { test } from "node:test";
19
+ import { assertEquals } from "#test-assert";
20
+ import type { AppApi } from "@nanobpm/urban";
21
+ import { acknowledgeVia } from "../app/acknowledge.ts";
22
+ import { deliveryGraphReadModel, PR_COUNTS_LOOKUP } from "../app/deliveryGraphReadModel.ts";
23
+ import { deriveEpicBucket, epicIsAcknowledgeable } from "../app/delivery.ts";
24
+ import { featureReadModel, USER_TASKS_PROJECTION } from "../app/featureReadModel.ts";
25
+ import { planReadModel } from "../app/planReadModel.ts";
26
+ import { pullRequestReadModel } from "../app/pullRequestReadModel.ts";
27
+ import { noopLog } from "../test/log.ts";
28
+ import doneHandler from "./acknowledgeDone.ts";
29
+ import dgHandler from "./acknowledgeDeliveryGraph.ts";
30
+ import epicHandler from "./acknowledgeEpic.ts";
31
+ import prHandler from "./acknowledgePr.ts";
32
+
33
+ // biome-ignore lint/suspicious/noExplicitAny: test-only dynamic row shapes.
34
+ type Row = any;
35
+ // biome-ignore lint/suspicious/noExplicitAny: test-only op handler signature.
36
+ type Handler = (ctx: any, app: AppApi) => Promise<any>;
37
+
38
+ /** One dismissable surface under test: how to seed its base row, which VIEW the op reads `ack_open`
39
+ * off, and how that VIEW derives from the base row (the real read model, not a duplicated status set). */
40
+ interface Surface {
41
+ readonly id: string;
42
+ /** The read-model VIEW name the op reads `ack_open` off. */
43
+ readonly view: string;
44
+ /** The base RECORD store the op stamps `acknowledged_at` onto. */
45
+ readonly baseStore: string;
46
+ /** The primary-key column both are keyed on. */
47
+ readonly keyColumn: string;
48
+ /** The op handler. */
49
+ readonly handler: Handler;
50
+ /** The op's request-body key field (for 400/200 bodies). */
51
+ readonly bodyKey: string;
52
+ /** Base row for a DERIVE-ONLY terminated (dismissable) row: base `status` frozen NON-terminal, the
53
+ * derive edge folds `derived_status='abandoned'` — the exact out-of-band-terminate shape that
54
+ * manifested for PR. Extra join fields (`delivery`) let the epic VIEW fold from its slices. */
55
+ readonly terminalRow: (key: string) => Row;
56
+ /** Base row for a LIVE, non-dismissable row (`ack_open=0`, unacknowledged). */
57
+ readonly liveRow: (key: string) => Row;
58
+ /** Compute the read-model VIEW row (`ack_open`/`list_bucket` + pass-throughs) from the base row —
59
+ * the REAL read model over the base row. */
60
+ readonly deriveView: (baseRow: Row) => Row;
61
+ }
62
+
63
+ /** The effective (terminal-folded) status the tracking VIEW exposes: an explicit `derived_status`
64
+ * override (a terminated instance) else the frozen base `status` (the `ELSE base.status` branch). */
65
+ const effective = (row: Row): string => row.derived_status ?? row.status;
66
+
67
+ const SURFACES: Surface[] = [
68
+ {
69
+ id: "acknowledgePr",
70
+ view: pullRequestReadModel.decl.name,
71
+ baseStore: "pull_requests",
72
+ keyColumn: "pr_key",
73
+ handler: prHandler as Handler,
74
+ bodyKey: "pr_key",
75
+ terminalRow: (key) => ({ pr_key: key, status: "converging", derived_status: "abandoned", acknowledged_at: null }),
76
+ liveRow: (key) => ({ pr_key: key, status: "converging", acknowledged_at: null }),
77
+ deriveView: (r) => ({ ...r, ...pullRequestReadModel.evaluate({ ...r, derived_status: effective(r) }) }),
78
+ },
79
+ {
80
+ id: "acknowledgeDone",
81
+ view: featureReadModel.decl.name,
82
+ baseStore: "feature_runs",
83
+ keyColumn: "feature_key",
84
+ handler: doneHandler as Handler,
85
+ bodyKey: "feature_key",
86
+ terminalRow: (key) => ({ feature_key: key, status: "running", derived_status: "abandoned", acknowledged_at: null }),
87
+ liveRow: (key) => ({ feature_key: key, status: "running", acknowledged_at: null }),
88
+ deriveView: (r) => ({
89
+ ...r,
90
+ ...featureReadModel.evaluate({ ...r, derived_status: effective(r) }, { [USER_TASKS_PROJECTION]: [] }),
91
+ }),
92
+ },
93
+ {
94
+ id: "acknowledgeDeliveryGraph",
95
+ view: deliveryGraphReadModel.decl.name,
96
+ baseStore: "delivery_graph_runs",
97
+ keyColumn: "run_key",
98
+ handler: dgHandler as Handler,
99
+ bodyKey: "run_key",
100
+ terminalRow: (key) => ({ run_key: key, status: "running", derived_status: "abandoned", acknowledged_at: null }),
101
+ liveRow: (key) => ({ run_key: key, status: "running", acknowledged_at: null }),
102
+ deriveView: (r) => ({
103
+ ...r,
104
+ ...deliveryGraphReadModel.evaluate({ ...r, derived_status: effective(r) }, undefined, { [PR_COUNTS_LOOKUP]: [] }),
105
+ }),
106
+ },
107
+ {
108
+ id: "acknowledgeEpic",
109
+ view: planReadModel.decl.name,
110
+ baseStore: "plans",
111
+ keyColumn: "plan_key",
112
+ handler: epicHandler as Handler,
113
+ bodyKey: "plan_key",
114
+ // A `done` epic whose base status is frozen (modelled here) while the derive edge folds it
115
+ // `abandoned` — resolved-not-landed (`delivery=null`), so dismissable.
116
+ terminalRow: (key) => ({ plan_key: key, status: "dispatched", derived_status: "abandoned", delivery: null, acknowledged_at: null }),
117
+ liveRow: (key) => ({ plan_key: key, status: "dispatched", delivery: null, acknowledged_at: null }),
118
+ deriveView: (r) => {
119
+ const eff = effective(r);
120
+ const ackable = epicIsAcknowledgeable(eff, r.delivery ?? null);
121
+ return {
122
+ ...r,
123
+ ack_open: ackable && r.acknowledged_at == null ? 1 : 0,
124
+ list_bucket: deriveEpicBucket(eff, r.delivery ?? null, r.acknowledged_at ?? null),
125
+ };
126
+ },
127
+ },
128
+ ];
129
+
130
+ /** A fake `AppApi` whose `data.table(name)` serves the four read-model VIEWs off the base stores by
131
+ * applying the surface's real `deriveView`, and serves the base RECORD stores for the `acknowledged_at`
132
+ * stamp. Writes to a VIEW are never attempted (the helper stamps the base store). */
133
+ function memApp(surface: Surface, seed: Row[]): { app: AppApi; rows: Row[] } {
134
+ const stores: Record<string, Row[]> = { [surface.baseStore]: seed };
135
+ function baseTbl(name: string) {
136
+ const rows = (stores[name] ??= []);
137
+ return {
138
+ async get(id: unknown) {
139
+ return rows.find((r) => r[surface.keyColumn] === id);
140
+ },
141
+ async update(id: unknown, patch: Row) {
142
+ const r = rows.find((row) => row[surface.keyColumn] === id);
143
+ if (r) Object.assign(r, patch);
144
+ return r ? 1 : 0;
145
+ },
146
+ };
147
+ }
148
+ function viewTbl() {
149
+ const rows = stores[surface.baseStore];
150
+ return {
151
+ async get(id: unknown) {
152
+ const base = rows.find((r) => r[surface.keyColumn] === id);
153
+ return base ? surface.deriveView(base) : undefined;
154
+ },
155
+ };
156
+ }
157
+ const app = {
158
+ data: { table: (n: string) => (n === surface.view ? viewTbl() : baseTbl(n)) },
159
+ log: noopLog(),
160
+ } as unknown as AppApi;
161
+ return { app, rows: stores[surface.baseStore] };
162
+ }
163
+
164
+ async function call(app: AppApi, handler: Handler, body: unknown) {
165
+ return (await handler({ req: {} as unknown, params: {}, query: {}, body } as unknown, app)) as {
166
+ status: number;
167
+ body: { ok: boolean; error?: string; message?: string };
168
+ };
169
+ }
170
+
171
+ for (const s of SURFACES) {
172
+ test(`${s.id}: a derive-only-terminated row (base status frozen, derived_status='abandoned') is dismissable → 200, stamps, and folds to History`, async () => {
173
+ const key = "o/r#1";
174
+ const { app, rows } = memApp(s, [s.terminalRow(key)]);
175
+ // Precondition: the read-model VIEW offers Dismiss (ack_open=1) even though base status is frozen
176
+ // non-terminal — the exact drift that used to 409 the op.
177
+ assertEquals((await memApp(s, [s.terminalRow(key)]).app.data.table(s.view).get(key)).ack_open, 1);
178
+
179
+ const res = await call(app, s.handler, { [s.bodyKey]: key });
180
+
181
+ assertEquals(res.status, 200, `${s.id}: dismissable row returns 200`);
182
+ assertEquals(res.body.ok, true);
183
+ assertEquals(typeof rows[0].acknowledged_at, "string", `${s.id}: acknowledged_at stamped`);
184
+ // After the stamp the VIEW re-folds the row to History with the Dismiss affordance closed.
185
+ const folded = await app.data.table(s.view).get(key);
186
+ assertEquals(folded.list_bucket, "history", `${s.id}: folds to history`);
187
+ assertEquals(folded.ack_open, 0, `${s.id}: ack_open closes after dismissal`);
188
+ });
189
+
190
+ test(`${s.id}: a live (non-terminal) row is NOT dismissable → 409 and stays unstamped`, async () => {
191
+ const key = "o/r#2";
192
+ const { app, rows } = memApp(s, [s.liveRow(key)]);
193
+ assertEquals((await app.data.table(s.view).get(key)).ack_open, 0, `${s.id}: precondition ack_open=0`);
194
+
195
+ const res = await call(app, s.handler, { [s.bodyKey]: key });
196
+
197
+ assertEquals(res.status, 409, `${s.id}: live row rejected`);
198
+ assertEquals(res.body.ok, false);
199
+ assertEquals(rows[0].acknowledged_at, null, `${s.id}: no premature stamp`);
200
+ });
201
+
202
+ test(`${s.id}: an already-acknowledged row is an idempotent no-op → 200, no double-stamp`, async () => {
203
+ const key = "o/r#3";
204
+ const stamp = "2026-02-02T00:00:00Z";
205
+ const { app, rows } = memApp(s, [{ ...s.terminalRow(key), acknowledged_at: stamp }]);
206
+ // An acknowledged terminal row reads ack_open=0 (folded to History) via the VIEW.
207
+ assertEquals((await app.data.table(s.view).get(key)).ack_open, 0);
208
+
209
+ const res = await call(app, s.handler, { [s.bodyKey]: key });
210
+
211
+ assertEquals(res.status, 200, `${s.id}: re-acknowledge is idempotent 200`);
212
+ assertEquals(res.body.ok, true);
213
+ assertEquals(rows[0].acknowledged_at, stamp, `${s.id}: NOT re-stamped (no double-stamp)`);
214
+ });
215
+
216
+ test(`${s.id}: no such row → 404`, async () => {
217
+ const { app } = memApp(s, []);
218
+ assertEquals((await call(app, s.handler, { [s.bodyKey]: "o/r#404" })).status, 404);
219
+ });
220
+
221
+ test(`${s.id}: a missing/blank key → 400`, async () => {
222
+ const { app } = memApp(s, []);
223
+ assertEquals((await call(app, s.handler, {})).status, 400);
224
+ assertEquals((await call(app, s.handler, { [s.bodyKey]: " " })).status, 400);
225
+ });
226
+ }
227
+
228
+ // A direct helper-level guard: the 404 path when the read-model VIEW has no such row, independent of
229
+ // any single op's body parsing.
230
+ test("acknowledgeVia: absent VIEW row → 404", async () => {
231
+ const app = {
232
+ data: { table: () => ({ get: async () => undefined }) },
233
+ log: noopLog(),
234
+ } as unknown as AppApi;
235
+ const res = await acknowledgeVia(
236
+ app,
237
+ { view: "v", baseTable: "t", keyColumn: "k", label: "thing", notDismissableError: "not terminal" },
238
+ "missing",
239
+ );
240
+ assertEquals(res.status, 404);
241
+ assertEquals(res.body.ok, false);
242
+ });
@@ -1,23 +1,21 @@
1
- // POST /app/api/actions/acknowledge-delivery-graph → operationId `acknowledgeDeliveryGraph` (issue #641).
2
- // The nwf UI's "Dismiss" (Done ✓) affordance for a TERMINAL delivery-graph run: an operator dismisses a
3
- // finished run (done / failed / abandoned) directly from the Overview "Active Delivery Graphs" or
4
- // delivery-graphs "In-flight delivery graphs" grid so it drops out of the Active list into History. It
5
- // is the delivery-graph twin of `acknowledgeDone` / `acknowledgeEpic` / `acknowledgePr` a terminal
6
- // run is NOT parked at a user task, so this op completes no user task and touches no engine/ledger: it
7
- // simply stamps `acknowledged_at` on the `delivery_graph_runs` row.
1
+ // POST /app/api/actions/acknowledge-delivery-graph → operationId `acknowledgeDeliveryGraph` (issue
2
+ // #641; generalised by #654). The nwf UI's "Dismiss" (Done ✓) affordance for a TERMINAL delivery-graph
3
+ // run: an operator dismisses a finished run (done / failed / abandoned) directly from the Overview
4
+ // "Active Delivery Graphs" or delivery-graphs "In-flight delivery graphs" grid so it drops out of the
5
+ // Active list into History. A terminal run is NOT parked at a user task, so this op completes no user
6
+ // task and touches no engine/ledger: it simply stamps `acknowledged_at` on the `delivery_graph_runs`
7
+ // row. The delivery-graph twin of `acknowledgeDone` / `acknowledgeEpic` / `acknowledgePr`.
8
8
  //
9
- // `list_bucket`/`ack_open` are DERIVED by the `delivery_graph_read_model` VIEW (096, issue #641) from
10
- // the terminal-folded `derived_status` + `acknowledged_at` — a terminal, now-acknowledged run reads
11
- // `list_bucket` = 'history' and `ack_open` = 0 so this op NEVER writes a derived projection. Keyed on
12
- // the row's `run_key`. Idempotent-safe: re-acknowledging re-stamps the timestamp and keeps it in
13
- // History.
14
- //
15
- // It rejects (409) a run that is NOT terminal (still awaiting-approval/running), so it can never pre-
16
- // seed the tick-off on a live run. The terminal check reads the base `status`: the delivery-graph
17
- // poller (app/deliveryGraphRun.ts) persists the terminal outcome to base `status` (done/failed/
18
- // abandoned), and the reconciler's `onTerminated` edge covers an out-of-band terminate.
19
-
20
- import { DELIVERY_GRAPH_TERMINAL_STATUSES, deliveryGraphRuns } from "../app/deliveryGraphRun.ts";
9
+ // This op is now a one-liner over the shared `acknowledgeVia` helper (issue #654), which gates on the
10
+ // `delivery_graph_read_model` VIEW's derived `ack_open` — the SAME oracle the Dismiss button reads via
11
+ // `showWhenField` so the affordance and the guard cannot drift. This retires the old base-`status`
12
+ // guard (`DELIVERY_GRAPH_TERMINAL_STATUSES.includes(run.status)`), a latent twin of the PR drift
13
+ // (#652): a run terminated out-of-band froze base `status` while the tracking VIEW folded
14
+ // `derived_status='abandoned'`, so the button offered Dismiss but the op 409'd. The guard now reads the
15
+ // terminal-folded `ack_open`, never the base `status`.
16
+
17
+ import { acknowledgeVia } from "../app/acknowledge.ts";
18
+ import { deliveryGraphReadModel } from "../app/deliveryGraphReadModel.ts";
21
19
  import { defineOperation } from "../nano-generated/operations.ts";
22
20
 
23
21
  const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
@@ -31,28 +29,15 @@ export default defineOperation("acknowledgeDeliveryGraph", async ({ body }, app)
31
29
  const runKey = str(body.run_key);
32
30
  if (!runKey) return { status: 400, body: { ok: false, error: "run_key is required" } };
33
31
 
34
- const table = deliveryGraphRuns(app.data);
35
- const run = await table.get(runKey);
36
- if (!run) {
37
- app.log.warn("acknowledge-delivery-graph: no such run", { runKey });
38
- return { status: 404, body: { ok: false, error: "no such delivery-graph run" } };
39
- }
40
-
41
- // Guard: only a TERMINAL run (a `DELIVERY_GRAPH_TERMINAL_STATUSES` status — the same set the read
42
- // model's `list_bucket` folds to History) carries the Dismiss affordance. Acknowledging a live run
43
- // would pre-seed `acknowledged_at`, so the moment it later settled the VIEW would drop it straight
44
- // into History, skipping the operator tick-off this op exists to require.
45
- if (!DELIVERY_GRAPH_TERMINAL_STATUSES.includes(run.status)) {
46
- app.log.warn("acknowledge-delivery-graph rejected: run is not terminal", { runKey, status: run.status });
47
- return { status: 409, body: { ok: false, error: "delivery-graph run is not terminal" } };
48
- }
49
-
50
- // Stamp the dismissal. `list_bucket` (→ 'history') and `ack_open` (→ 0) are derived by the
51
- // `delivery_graph_read_model` VIEW from the terminal, now-acknowledged row, so we never hand-set them
52
- // here. Idempotent: re-acknowledging re-stamps and stays in History.
53
- const now = new Date().toISOString();
54
- await table.update(runKey, { acknowledged_at: now, updated_at: now });
55
-
56
- app.log.info("operator dismissed terminal delivery-graph run", { runKey });
57
- return { status: 200, body: { ok: true, message: "acknowledged" } };
32
+ return acknowledgeVia(
33
+ app,
34
+ {
35
+ view: deliveryGraphReadModel.decl.name,
36
+ baseTable: "delivery_graph_runs",
37
+ keyColumn: "run_key",
38
+ label: "delivery-graph run",
39
+ notDismissableError: "delivery-graph run is not terminal",
40
+ },
41
+ runKey,
42
+ );
58
43
  });
@@ -1,20 +1,20 @@
1
- // POST /app/api/actions/acknowledge-done → operationId `acknowledgeDone` (issue #254 §5).
2
- // The nwf UI's "tick off" affordance for a TERMINAL feature run: an operator dismisses a finished
3
- // run (Done ✓ / Done ✕) directly from the Feature / Overview pages so it drops out of the primary
4
- // Active list into History. It is the DONE twin of `acknowledgeBlocked` but a terminal run is NOT
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 plain `feature_runs` record table (the projecting
7
- // gateway this PR retired). It rejects (409) a run that
8
- // is not yet truly terminal, so it can never pre-seed the tick-off on a still-live run.
1
+ // POST /app/api/actions/acknowledge-done → operationId `acknowledgeDone` (issue #254 §5; generalised by
2
+ // #654). The nwf UI's "tick off" affordance for a TERMINAL feature run: an operator dismisses a
3
+ // finished run (Done ✓ / Done ✕) directly from the Feature / Overview pages so it drops out of the
4
+ // primary Active list into History. A terminal run is NOT parked at a user task, so this op completes
5
+ // no user task and touches no engine/ledger: it simply stamps `acknowledged_at` on the `feature_runs`
6
+ // row. The DONE twin of `acknowledgePr` / `acknowledgeDeliveryGraph` / `acknowledgeEpic`.
9
7
  //
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.
15
-
16
- import { featureRuns } from "../app/feature.ts";
17
- import { STAGE_DONE_STATUSES } from "../app/stage.ts";
8
+ // This op is now a one-liner over the shared `acknowledgeVia` helper (issue #654), which gates on the
9
+ // `feature_read_model` VIEW's derived `ack_open` (added by migration 099) the SAME oracle the Dismiss
10
+ // button reads via `showWhenField` so the affordance and the guard cannot drift. This retires the
11
+ // old base-`status` guard (`STAGE_DONE_STATUSES.includes(run.status)`), a latent twin of the PR drift
12
+ // (#652): a feature run terminated out-of-band froze base `status` at `running`/`escalated` while the
13
+ // tracking VIEW folded `derived_status='abandoned'`, so the button offered Dismiss but the op 409'd.
14
+ // The guard now reads the terminal-folded `ack_open`, never the base `status`.
15
+
16
+ import { acknowledgeVia } from "../app/acknowledge.ts";
17
+ import { featureReadModel } from "../app/featureReadModel.ts";
18
18
  import { defineOperation } from "../nano-generated/operations.ts";
19
19
 
20
20
  const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
@@ -28,28 +28,15 @@ export default defineOperation("acknowledgeDone", async ({ body }, app) => {
28
28
  const featureKey = str(body.feature_key);
29
29
  if (!featureKey) return { status: 400, body: { ok: false, error: "feature_key is required" } };
30
30
 
31
- const runs = featureRuns(app.data);
32
- const run = await runs.get(featureKey);
33
- if (!run) {
34
- app.log.warn("acknowledge-done: no such feature run", { featureKey });
35
- return { status: 404, body: { ok: false, error: "no such feature run" } };
36
- }
37
-
38
- // Guard: only a TRULY-terminal run (a `Done`-stage status — the same set `deriveListBucket` moves to
39
- // History) may be ticked off. Acknowledging a still-live run (e.g. `running`/`opened`/`converging`)
40
- // would pre-seed `acknowledged_at`, so the moment it later settles `deriveListBucket` would drop it
41
- // straight into History, skipping the operator tick-off this op exists to require.
42
- if (!STAGE_DONE_STATUSES.includes(run.status)) {
43
- app.log.warn("acknowledge-done rejected: run is not terminal", { featureKey, status: run.status });
44
- return { status: 409, body: { ok: false, error: "feature run is not terminal" } };
45
- }
46
-
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.
50
- const now = new Date().toISOString();
51
- await runs.update(featureKey, { acknowledged_at: now, updated_at: now });
52
-
53
- app.log.info("operator ticked off feature run", { featureKey });
54
- return { status: 200, body: { ok: true, message: "acknowledged" } };
31
+ return acknowledgeVia(
32
+ app,
33
+ {
34
+ view: featureReadModel.decl.name,
35
+ baseTable: "feature_runs",
36
+ keyColumn: "feature_key",
37
+ label: "feature run",
38
+ notDismissableError: "feature run is not terminal",
39
+ },
40
+ featureKey,
41
+ );
55
42
  });
@@ -1,26 +1,21 @@
1
- // POST /app/api/actions/acknowledge-epic → operationId `acknowledgeEpic` (issue #298).
2
- // The nwf UI's "Dismiss" affordance for a RESOLVED epic: an operator dismisses a `done` epic whose
3
- // fan-out has finished (all slice PRs reached a terminal state — whether all merged/landed or
4
- // resolved-not-landed) directly from the Epic / Overview pages so it drops out of the Active epic list
5
- // into History. It is the epic twin of `acknowledgeDone` (the feature-run tick-off) a resolved epic
6
- // is NOT parked at a user task, so this op completes no user task and touches no engine/ledger: it
7
- // simply stamps `acknowledged_at` on the `plans` row.
1
+ // POST /app/api/actions/acknowledge-epic → operationId `acknowledgeEpic` (issue #298; generalised by
2
+ // #654). The nwf UI's "Dismiss" affordance for a RESOLVED epic: an operator dismisses a `done` epic
3
+ // whose fan-out has finished (all slice PRs reached a terminal state — whether all merged/landed or
4
+ // resolved-not-landed), OR a `failed`/`abandoned` epic, directly from the Epic / Overview pages so it
5
+ // drops out of the Active epic list into History. A resolved epic is NOT parked at a user task, so this
6
+ // op completes no user task and touches no engine/ledger: it simply stamps `acknowledged_at` on the
7
+ // `plans` row. The epic twin of `acknowledgeDone` / `acknowledgePr` / `acknowledgeDeliveryGraph`.
8
8
  //
9
- // `list_bucket`/`ack_open` are DERIVED by the `plan_read_model` VIEW (074, issue #439) from
10
- // `status` + `acknowledged_at` + the derived `plan_delivery` signal a landed, now-acknowledged epic
11
- // reads `list_bucket` = 'history' and `ack_open` = 0 so this op NEVER writes a derived projection.
12
- // Keyed on the row's `plan_key`. Idempotent-safe: re-acknowledging re-stamps the timestamp and keeps
13
- // the row in History.
14
- //
15
- // It rejects (409) an epic that is NOT yet resolved i.e. anything the `epicIsAcknowledgeable`
16
- // guard refuses: a non-`done` status (`planning`/`dispatched`), or `done` but still `converging`. A
17
- // resolved epic is acknowledgeable whether all slices merged (`delivery=landed`) or it resolved-not-
18
- // landed (`delivery=null`); only a still-live or still-converging epic is refused, so those stay
19
- // visible in Active and can never pre-seed the tick-off.
20
-
21
- import { epicIsAcknowledgeable } from "../app/delivery.ts";
22
- import { plans } from "../app/plan.ts";
23
- import { derivePlanDelivery } from "../app/service.ts";
9
+ // This op is now a one-liner over the shared `acknowledgeVia` helper (issue #654), which gates on the
10
+ // `plan_read_model` VIEW's derived `ack_open` the SAME oracle the Dismiss button reads via
11
+ // `showWhenField`. The epic's `ack_open` additionally excludes a still-`converging` done epic (the
12
+ // VIEW folds delivery from the slice PRs), so a converging epic is correctly non-dismissable (409) and
13
+ // stays Active. This retires the op's bespoke `epicIsAcknowledgeable(plan.status, delivery)` guard: the
14
+ // affordance and the guard now read one oracle and cannot drift, and a `done` epic frozen non-terminal
15
+ // on base `status` while the read model folds it terminal no longer trips the guard.
16
+
17
+ import { acknowledgeVia } from "../app/acknowledge.ts";
18
+ import { planReadModel } from "../app/planReadModel.ts";
24
19
  import { defineOperation } from "../nano-generated/operations.ts";
25
20
 
26
21
  const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
@@ -34,35 +29,15 @@ export default defineOperation("acknowledgeEpic", async ({ body }, app) => {
34
29
  const planKey = str(body.plan_key);
35
30
  if (!planKey) return { status: 400, body: { ok: false, error: "plan_key is required" } };
36
31
 
37
- const table = plans(app.data);
38
- const plan = await table.get(planKey);
39
- if (!plan) {
40
- app.log.warn("acknowledge-epic: no such plan", { planKey });
41
- return { status: 404, body: { ok: false, error: "no such epic" } };
42
- }
43
-
44
- // Guard: only a RESOLVED epic (`status=done` and no longer `converging`) carries the Dismiss
45
- // affordance. Acknowledging a live/converging epic would pre-seed `acknowledged_at`, so the moment
46
- // it later resolved `deriveEpicBucket` would drop it straight into History, skipping the operator
47
- // tick-off this op exists to require — and a converging epic must stay visible while its slices land.
48
- // The `plans.delivery` column was retired (epic #412), so derive the signal at read time from the
49
- // slice PRs (the same pure `deriveDelivery` the `plan_delivery` VIEW encodes).
50
- const delivery = await derivePlanDelivery(app.data, plan);
51
- if (!epicIsAcknowledgeable(plan.status, delivery)) {
52
- app.log.warn("acknowledge-epic rejected: epic is not resolved", {
53
- planKey,
54
- status: plan.status,
55
- delivery,
56
- });
57
- return { status: 409, body: { ok: false, error: "epic is not resolved" } };
58
- }
59
-
60
- // Stamp the dismissal. `list_bucket` (→ 'history') and `ack_open` (→ 0) are derived by the
61
- // `plan_read_model` VIEW from the resolved, now-acknowledged row, so we never hand-set them here.
62
- // Idempotent: re-acknowledging re-stamps and stays in History.
63
- const now = new Date().toISOString();
64
- await table.update(planKey, { acknowledged_at: now, updated_at: now });
65
-
66
- app.log.info("operator dismissed resolved epic", { planKey });
67
- return { status: 200, body: { ok: true, message: "acknowledged" } };
32
+ return acknowledgeVia(
33
+ app,
34
+ {
35
+ view: planReadModel.decl.name,
36
+ baseTable: "plans",
37
+ keyColumn: "plan_key",
38
+ label: "epic",
39
+ notDismissableError: "epic is not resolved",
40
+ },
41
+ planKey,
42
+ );
68
43
  });
@@ -1,33 +1,20 @@
1
- // POST /app/api/actions/acknowledge-pr → operationId `acknowledgePr` (issue #641).
1
+ // POST /app/api/actions/acknowledge-pr → operationId `acknowledgePr` (issue #641; generalised by #654).
2
2
  // The nwf UI's "Dismiss" (Done ✓) affordance for a TERMINAL pull request: an operator dismisses a
3
3
  // finished PR (merged / converged / abandoned / closed / failed) directly from the Overview "Active PR
4
4
  // convergences" or home "Pull requests" grid so it drops out of the Active convergence list into
5
- // History. It is the PR twin of `acknowledgeDone` (the feature-run tick-off) and `acknowledgeEpic` — a
5
+ // History. It is the PR twin of `acknowledgeDone` / `acknowledgeDeliveryGraph` / `acknowledgeEpic` — a
6
6
  // terminal PR is NOT parked at a user task, so this op completes no user task and touches no engine/
7
7
  // ledger: it simply stamps `acknowledged_at` on the `pull_requests` row.
8
8
  //
9
- // `list_bucket`/`ack_open` are DERIVED by the `pull_requests_read_model` VIEW (094, issue #641) from
10
- // the terminal-folded `derived_status` + `acknowledged_at` — a terminal, now-acknowledged PR reads
11
- // `list_bucket` = 'history' and `ack_open` = 0 so this op NEVER writes a derived projection. Keyed on
12
- // the row's `pr_key`. Idempotent-safe: re-acknowledging re-stamps the timestamp and keeps it in
13
- // History.
14
- //
15
- // It rejects (409) a PR that is NOT terminal (still converging/waiting_review/…), so it can never
16
- // pre-seed the tick-off on a live PR: were `acknowledged_at` set early, the moment the PR later settled
17
- // the VIEW would drop it straight into History, skipping the operator dismiss this op exists to
18
- // require.
19
- //
20
- // TERMINALITY IS READ OFF THE READ MODEL, NOT THE BASE COLUMN (issue #652). PRs are the one surface
21
- // that folds terminal ON READ: since `app/abandon.ts` moved to ADR-0065 derive-only tracking, the
22
- // reconciler no longer WRITES 'abandoned' onto the base `pull_requests.status` on an out-of-band
23
- // terminate — the `pull_requests__tracking` VIEW folds it into `derived_status`, and the
24
- // `pull_requests_read_model` VIEW exposes that as its effective `status` and lights the Dismiss
25
- // affordance (`ack_open`). This op therefore consults the SAME source of truth as the affordance — the
26
- // read model's folded/effective `status` — rather than re-deriving terminality against the frozen base
27
- // column (which for an abandoned PR still reads its last transient, e.g. 'escalated', and would 409 a
28
- // row the UI shows Dismiss on). Reading the base column here was the drift #652 fixes.
29
-
30
- import { PR_TERMINAL_STATUSES, PULL_REQUEST_READ_MODEL_NAME } from "../app/pullRequestReadModel.ts";
9
+ // This op is now a one-liner over the shared `acknowledgeVia` helper (issue #654), which gates on the
10
+ // `pull_requests_read_model` VIEW's derived `ack_open` — the SAME oracle the Dismiss button reads via
11
+ // `showWhenField` so the affordance and the guard cannot drift. That folds in the #652 fix (a PR
12
+ // terminated out-of-band freezes base `status` non-terminal while the read model's `derived_status`
13
+ // reads `abandoned`, so the old base-`status` guard 409'd a Dismiss the UI offered) categorically: the
14
+ // guard now reads the terminal-folded `ack_open`, never the base `status`.
15
+
16
+ import { acknowledgeVia } from "../app/acknowledge.ts";
17
+ import { pullRequestReadModel } from "../app/pullRequestReadModel.ts";
31
18
  import { defineOperation } from "../nano-generated/operations.ts";
32
19
 
33
20
  const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
@@ -41,40 +28,15 @@ export default defineOperation("acknowledgePr", async ({ body }, app) => {
41
28
  const prKey = str(body.pr_key);
42
29
  if (!prKey) return { status: 400, body: { ok: false, error: "pr_key is required" } };
43
30
 
44
- // Read the FOLDED row from the read model VIEW — its effective `status` is
45
- // `COALESCE(derived_status, status)`, so an out-of-band-terminated PR whose base `status` is frozen
46
- // at a transient reads its engine-truth terminal (`abandoned`) here, exactly as the Dismiss
47
- // affordance (`ack_open`) does.
48
- const readModel = app.data.table<{ pr_key: string; status: string }>(
49
- PULL_REQUEST_READ_MODEL_NAME,
50
- "pr_key",
31
+ return acknowledgeVia(
32
+ app,
33
+ {
34
+ view: pullRequestReadModel.decl.name,
35
+ baseTable: "pull_requests",
36
+ keyColumn: "pr_key",
37
+ label: "pull request",
38
+ notDismissableError: "pull request is not terminal",
39
+ },
40
+ prKey,
51
41
  );
52
- const view = await readModel.get(prKey);
53
- if (!view) {
54
- app.log.warn("acknowledge-pr: no such pull request", { prKey });
55
- return { status: 404, body: { ok: false, error: "no such pull request" } };
56
- }
57
-
58
- // Guard: only a TERMINAL PR (a `PR_TERMINAL_STATUSES` effective status — the same terminal-folded
59
- // tier the read model's `list_bucket`/`ack_open` fold to History and gate the Dismiss button on)
60
- // carries the Dismiss affordance. Acknowledging a live PR would pre-seed `acknowledged_at`, so the
61
- // moment it later settled the VIEW would drop it straight into History, skipping the operator
62
- // tick-off this op exists to require.
63
- if (!PR_TERMINAL_STATUSES.includes(view.status)) {
64
- app.log.warn("acknowledge-pr rejected: PR is not terminal", { prKey, status: view.status });
65
- return { status: 409, body: { ok: false, error: "pull request is not terminal" } };
66
- }
67
-
68
- // Stamp the dismissal on the BASE `pull_requests` row. `list_bucket` (→ 'history') and `ack_open`
69
- // (→ 0) are derived by the `pull_requests_read_model` VIEW from the terminal, now-acknowledged row,
70
- // so we never hand-set them here. Idempotent: re-acknowledging re-stamps and stays in History.
71
- const table = app.data.table<{ pr_key: string; acknowledged_at: string | null; updated_at: string }>(
72
- "pull_requests",
73
- "pr_key",
74
- );
75
- const now = new Date().toISOString();
76
- await table.update(prKey, { acknowledged_at: now, updated_at: now });
77
-
78
- app.log.info("operator dismissed terminal pull request", { prKey });
79
- return { status: 200, body: { ok: true, message: "acknowledged" } };
80
42
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.167.2",
3
+ "version": "0.167.4",
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",
@@ -137,7 +137,7 @@
137
137
  {
138
138
  "label": "Dismiss",
139
139
  "confirm": "Tick off this finished run? It acknowledges the run as done and files it under History.",
140
- "showWhenField": "stage_state",
140
+ "showWhenField": "ack_open",
141
141
  "action": {
142
142
  "path": "/app/api/actions/acknowledge-done",
143
143
  "body": { "feature_key": "{{row.feature_key}}" }
@@ -189,7 +189,7 @@
189
189
  {
190
190
  "label": "Dismiss",
191
191
  "confirm": "Tick off this finished run? It acknowledges the run as done and files it under History.",
192
- "showWhenField": "stage_state",
192
+ "showWhenField": "ack_open",
193
193
  "action": {
194
194
  "path": "/app/api/actions/acknowledge-done",
195
195
  "body": { "feature_key": "{{row.feature_key}}" }