@nanobpm/nano-workforce 0.167.1 → 0.167.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/app/acknowledge.ts +98 -0
- package/app/featureReadModel.test.ts +6 -3
- package/app/featureReadModel.ts +15 -5
- package/app/planReadModel.test.ts +80 -1
- package/app/pullRequestReadModel.ts +6 -1
- package/db/migrations/099_feature_read_model_ack_open.sql +58 -0
- package/db/migrations/100_plan_read_model_pass_acknowledged_at.sql +63 -0
- package/operations/acknowledge.parity.test.ts +242 -0
- package/operations/acknowledgeDeliveryGraph.ts +28 -43
- package/operations/acknowledgeDone.ts +27 -40
- package/operations/acknowledgeEpic.ts +28 -53
- package/operations/acknowledgePr.ts +21 -41
- package/package.json +1 -1
- package/pages/feature.page.json +1 -1
- package/pages/overview.page.json +1 -1
- package/operations/acknowledgeDeliveryGraph.test.ts +0 -93
- package/operations/acknowledgeDone.test.ts +0 -110
- package/operations/acknowledgeEpic.test.ts +0 -173
- package/operations/acknowledgePr.test.ts +0 -94
|
@@ -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
|
|
2
|
-
// The nwf UI's "Dismiss" (Done ✓) affordance for a TERMINAL delivery-graph
|
|
3
|
-
// finished run (done / failed / abandoned) directly from the Overview
|
|
4
|
-
// delivery-graphs "In-flight delivery graphs" grid so it drops out of the
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// `
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
3
|
-
// run (Done ✓ / Done ✕) directly from the Feature / Overview pages so it drops out of the
|
|
4
|
-
// Active list into History.
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
//
|
|
11
|
-
// `
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
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
|
|
5
|
-
// into History.
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
-
//
|
|
10
|
-
// `
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// the
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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,24 +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`
|
|
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
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// `
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
// require. The terminal check reads the base `status` — the reconciler's `onTerminated` edge persists
|
|
19
|
-
// an out-of-band terminate to base `status` = 'abandoned', so a resolved PR is matched here too.
|
|
20
|
-
|
|
21
|
-
import { PR_TERMINAL_STATUSES } 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";
|
|
22
18
|
import { defineOperation } from "../nano-generated/operations.ts";
|
|
23
19
|
|
|
24
20
|
const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
|
|
@@ -32,31 +28,15 @@ export default defineOperation("acknowledgePr", async ({ body }, app) => {
|
|
|
32
28
|
const prKey = str(body.pr_key);
|
|
33
29
|
if (!prKey) return { status: 400, body: { ok: false, error: "pr_key is required" } };
|
|
34
30
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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,
|
|
38
41
|
);
|
|
39
|
-
const pr = await table.get(prKey);
|
|
40
|
-
if (!pr) {
|
|
41
|
-
app.log.warn("acknowledge-pr: no such pull request", { prKey });
|
|
42
|
-
return { status: 404, body: { ok: false, error: "no such pull request" } };
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// Guard: only a TERMINAL PR (a `PR_TERMINAL_STATUSES` status — the same set the read model's
|
|
46
|
-
// `list_bucket` folds to History) carries the Dismiss affordance. Acknowledging a live PR would
|
|
47
|
-
// pre-seed `acknowledged_at`, so the moment it later settled the VIEW would drop it straight into
|
|
48
|
-
// History, skipping the operator tick-off this op exists to require.
|
|
49
|
-
if (!PR_TERMINAL_STATUSES.includes(pr.status)) {
|
|
50
|
-
app.log.warn("acknowledge-pr rejected: PR is not terminal", { prKey, status: pr.status });
|
|
51
|
-
return { status: 409, body: { ok: false, error: "pull request is not terminal" } };
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// Stamp the dismissal. `list_bucket` (→ 'history') and `ack_open` (→ 0) are derived by the
|
|
55
|
-
// `pull_requests_read_model` VIEW from the terminal, now-acknowledged row, so we never hand-set them
|
|
56
|
-
// here. Idempotent: re-acknowledging re-stamps and stays in History.
|
|
57
|
-
const now = new Date().toISOString();
|
|
58
|
-
await table.update(prKey, { acknowledged_at: now, updated_at: now });
|
|
59
|
-
|
|
60
|
-
app.log.info("operator dismissed terminal pull request", { prKey });
|
|
61
|
-
return { status: 200, body: { ok: true, message: "acknowledged" } };
|
|
62
42
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.167.
|
|
3
|
+
"version": "0.167.3",
|
|
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",
|
package/pages/feature.page.json
CHANGED
|
@@ -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": "
|
|
140
|
+
"showWhenField": "ack_open",
|
|
141
141
|
"action": {
|
|
142
142
|
"path": "/app/api/actions/acknowledge-done",
|
|
143
143
|
"body": { "feature_key": "{{row.feature_key}}" }
|
package/pages/overview.page.json
CHANGED
|
@@ -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": "
|
|
192
|
+
"showWhenField": "ack_open",
|
|
193
193
|
"action": {
|
|
194
194
|
"path": "/app/api/actions/acknowledge-done",
|
|
195
195
|
"body": { "feature_key": "{{row.feature_key}}" }
|