@nanobpm/nano-workforce 0.166.0 → 0.167.1

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.
Files changed (44) hide show
  1. package/.github/workflows/mirror-install-dispatch.yml +54 -0
  2. package/CHANGELOG.md +13 -0
  3. package/app/backfillAcknowledgedAt.test.ts +121 -0
  4. package/app/contracts.ts +17 -1
  5. package/app/delivery.ts +15 -13
  6. package/app/deliveryGraphReadModel.test.ts +44 -8
  7. package/app/deliveryGraphReadModel.ts +19 -2
  8. package/app/deliveryGraphRun.ts +5 -0
  9. package/app/epicBucket.test.ts +9 -3
  10. package/app/featureReadModel.ts +7 -11
  11. package/app/listBucket.ts +86 -0
  12. package/app/planReadModel.test.ts +29 -19
  13. package/app/planReadModel.ts +32 -26
  14. package/app/pollUserTasks.test.ts +165 -0
  15. package/app/pullRequestReadModel.test.ts +208 -0
  16. package/app/pullRequestReadModel.ts +76 -0
  17. package/app/service.ts +52 -0
  18. package/db/migrations/093_pull_requests_acknowledged_at.sql +30 -0
  19. package/db/migrations/094_pull_requests_read_model.sql +65 -0
  20. package/db/migrations/095_delivery_graph_acknowledged_at.sql +24 -0
  21. package/db/migrations/096_delivery_graph_read_model_list_bucket.sql +63 -0
  22. package/db/migrations/097_plan_read_model_terminal_dismiss.sql +65 -0
  23. package/db/migrations/098_delivery_graph_units_acknowledged_at.sql +63 -0
  24. package/e2e/feature-preflight.e2e.ts +3 -2
  25. package/e2e/feature-run.e2e.ts +60 -5
  26. package/nano.app.json +4 -0
  27. package/openapi.yaml +98 -0
  28. package/operations/acknowledgeDeliveryGraph.test.ts +93 -0
  29. package/operations/acknowledgeDeliveryGraph.ts +58 -0
  30. package/operations/acknowledgePr.test.ts +94 -0
  31. package/operations/acknowledgePr.ts +62 -0
  32. package/package.json +2 -2
  33. package/pages/delivery-graphs/library.mount.js +59 -2
  34. package/pages/delivery-graphs/mount.js +88 -2
  35. package/pages/delivery-graphs.page.json +12 -3
  36. package/pages/home.page.json +18 -27
  37. package/pages/overview.page.json +26 -17
  38. package/resources/processes/feature.bpmn +90 -69
  39. package/scripts/pages-contract.test.ts +80 -18
  40. package/test/delivery-graphs-ack-or-timeout.test.ts +280 -0
  41. package/test/delivery-graphs-library-embed.test.ts +1 -1
  42. package/workers/record-feature-implementing/worker.test.ts +57 -0
  43. package/workers/record-feature-implementing/worker.ts +31 -0
  44. package/workers/record-results/worker.test.ts +5 -3
@@ -319,27 +319,21 @@ test("issue #205: overview is the landing page and first nav item", async () =>
319
319
 
320
320
  // Four collapsible active-work sections, one per dispatch surface, each with a
321
321
  // live count in its header (showCount) and a persisted collapse toggle (collapsible). Each filters
322
- // its Active list on a `{field, in:[...]}` predicate. The FEATURE and EPIC surfaces bucket on the
323
- // DERIVED `list_bucket` — NOT raw `status` — over their read-model VIEW (features: issue #637
324
- // fixing a drift where the grid read the vestigial base `feature_runs.status` with an allowlist that
325
- // hid every converging feature; epics: issue #298), so a `done`-but-unacknowledged item still
326
- // converging does not vanish from its in-flight section the instant `status` reads terminal. Guarding
327
- // the field here is the regression guard for that defect class. The feature surface binds the derived
328
- // `feature_read_model` VIEW (the single source of truth for the `list_bucket` activeness predicate,
329
- // shared byte-for-byte with the Feature tab); the epic surface binds `plan_read_model` (epic #412),
330
- // not the raw `plans` table. PRs / delivery graphs still bucket on `status` (see #637 follow-up).
322
+ // its Active list on a `{field, in:[...]}` predicate. ALL FOUR surfaces now bucket on the DERIVED
323
+ // `list_bucket` — NOT raw `status` — over their read-model VIEW (features: issue #637; epics: #298;
324
+ // PRs + delivery graphs: issue #641 retiring the last two base-`status` allowlists), so a terminal-
325
+ // but-unacknowledged item stays in its in-flight section until an operator dismisses it, instead of
326
+ // vanishing the instant `status` reads terminal. Guarding the field here is the regression guard for
327
+ // that defect class. Each surface binds its declare-once read-model VIEW (the single source of truth
328
+ // for the `list_bucket` activeness predicate), never a raw base table.
331
329
  const expected: Record<string, { field: string; in: string[] }> = {
332
- pull_requests: {
333
- field: "status",
334
- in: ["converging", "waiting_review", "escalated", "waiting_deps", "waiting_merge", "queued", "merging"],
335
- },
330
+ pull_requests_read_model: { field: "list_bucket", in: ["active"] },
336
331
  plan_read_model: { field: "list_bucket", in: ["active"] },
337
332
  feature_read_model: { field: "list_bucket", in: ["active"] },
338
- // The 4th dispatch surface (issue #386) — active delivery graphs. Both in-flight statuses
339
- // (`awaiting-approval` parked at the gate, `running` dispatched) show here. Binds the derived
340
- // `delivery_graph_read_model` VIEW (S7 / #541 the single source of truth for the pipeline
341
- // projection it also renders), which re-exports every run column plus the effective `status`.
342
- delivery_graph_read_model: { field: "status", in: ["awaiting-approval", "running"] },
333
+ // The 4th dispatch surface (issue #386) — active delivery graphs. Binds the derived
334
+ // `delivery_graph_read_model` VIEW (S7 / #541), now filtered on `list_bucket` (issue #641) so a
335
+ // terminal run stays Active until dismissed rather than dropping on its base `status`.
336
+ delivery_graph_read_model: { field: "list_bucket", in: ["active"] },
343
337
  };
344
338
  const grids = (overview.nodes ?? []).filter((n: Json) => n.type === "dataGrid");
345
339
  for (const [table, { field, in: values }] of Object.entries(expected)) {
@@ -357,6 +351,74 @@ test("issue #205: overview is the landing page and first nav item", async () =>
357
351
  });
358
352
 
359
353
 
354
+ test("issue #641: no Active/History grid filters a base-`status` allowlist — every dispatch Active list buckets on a derived read-model VIEW", async () => {
355
+ // The drift class this closes (the last mile of #637): an "Active …" dispatch list that re-encodes
356
+ // activeness as a hardcoded base-`status` allowlist (`{ field: "status", in: [...] }`) instead of the
357
+ // declare-once derived `list_bucket` over its read-model VIEW. Such an allowlist silently drops a row
358
+ // out of Active the instant its `status` leaves the set — with NO operator dismiss — and must be kept
359
+ // hand-synced with every status rename. #641 retired the last two (PRs + delivery graphs); this guard
360
+ // makes the whole class a BUILD FAILURE so it can never creep back in on a new/edited grid.
361
+ //
362
+ // SCOPE — an "Active/History surface" is a top-level grid that partitions rows into active vs.
363
+ // finished: either its title starts with "Active " (the Overview sections) OR it carries a `History`
364
+ // tab (the Feature/Epic/PR/Delivery-Graph/lineage grids). This deliberately EXCLUDES the param-scoped
365
+ // per-epic slice grids (epic-detail's "Wave state" Active/Skipped/All, "Trial-merge results"
366
+ // Needs-attention/Clean/All), which legitimately filter a domain `status`/`result` within one epic —
367
+ // they have no `History` tab and no "Active " title, so they are not activeness partitions.
368
+ const BASE_TABLES = new Set(["pull_requests", "plans", "feature_runs", "delivery_graph_runs"]);
369
+ const grids: Json[] = [];
370
+ const walk = (n: Json): void => {
371
+ if (Array.isArray(n)) return n.forEach(walk);
372
+ if (n && typeof n === "object") {
373
+ if (n.type === "dataGrid") grids.push(n);
374
+ for (const v of Object.values(n)) walk(v);
375
+ }
376
+ };
377
+ for (const e of readdirSync(`${ROOT}pages`, { withFileTypes: true })) {
378
+ if (!e.isFile() || !e.name.endsWith(".page.json")) continue;
379
+ const page = JSON.parse(readFileSync(`${ROOT}pages/${e.name}`, "utf8"));
380
+ walk(page.nodes ?? page);
381
+ // Tag each grid with its page for messages.
382
+ for (const g of grids) if (g.__page === undefined) g.__page = e.name;
383
+ }
384
+
385
+ let surfaces = 0;
386
+ for (const g of grids) {
387
+ const props = g.props ?? {};
388
+ const title: string = typeof props.title === "string" ? props.title : "";
389
+ const tabs: Json[] = Array.isArray(props.tabs) ? props.tabs : [];
390
+ const hasHistoryTab = tabs.some((t) => typeof t?.label === "string" && /history/i.test(t.label));
391
+ const activeTitle = /^active\s/i.test(title);
392
+ if (!hasHistoryTab && !activeTitle) continue; // not an activeness partition — skip
393
+ surfaces++;
394
+
395
+ const table: string = g.props?.data?.table ?? "";
396
+ // 1) It must bind a DERIVED read-model relation, never a raw base table (whose only activeness
397
+ // signal is the base `status` this guard forbids filtering on).
398
+ assert(
399
+ !BASE_TABLES.has(table),
400
+ `${g.__page}: Active/History grid "${title || tabs.map((t) => t.label).join("/")}" binds the raw base table "${table}" — bind its derived read-model VIEW (…_read_model) so it can filter the declared list_bucket`,
401
+ );
402
+
403
+ // 2) No filter — the grid's main filter OR any tab filter — may re-encode activeness as a
404
+ // base-`status` allowlist. (A domain filter on another column is fine; only `status IN […]` is
405
+ // the banned drift surface.)
406
+ const allFilters: Json[] = [g.props?.data?.filter, ...tabs.map((t) => t?.filter)];
407
+ for (const filter of allFilters) {
408
+ if (!Array.isArray(filter)) continue;
409
+ for (const pred of filter) {
410
+ assert(
411
+ !(pred?.field === "status" && (Array.isArray(pred.in) || pred.eq !== undefined)),
412
+ `${g.__page}: Active/History grid "${title || tabs.map((t) => t.label).join("/")}" filters a base-\`status\` allowlist ` +
413
+ `(${JSON.stringify(pred)}) — bucket on the derived \`list_bucket\` over the read-model VIEW instead (issue #641).`,
414
+ );
415
+ }
416
+ }
417
+ }
418
+ assert(surfaces >= 6, `expected to find the dispatch Active/History surfaces, found only ${surfaces}`);
419
+ });
420
+
421
+
360
422
  test("issue #386: the human-facing Delivery Graphs surface is wired (nav tab, page, detail, overview)", async () => {
361
423
  // 1) A `Delivery Graphs` nav tab in the single-source nav, pointing at the delivery-graphs page.
362
424
  const nav = JSON.parse(readFileSync(`${ROOT}pages/_nav.json`, "utf8"));
@@ -0,0 +1,280 @@
1
+ // Red/green behavioural guard for the delivery-graph Preview & Reuse "honest toast" fix (issue #645).
2
+ //
3
+ // The DEFECT: both cross-frame producers printed an UNCONDITIONAL "✓ …" success toast synchronously
4
+ // right after `postMessage`, even though the message can be dropped by the host relay boundary — so the
5
+ // UI claimed success while nothing happened. The FIX makes the success EARNED, not assumed:
6
+ // • Library "Reuse" (library.mount.js `doReuse`) posts the compose-fill UP to the host (relayed across
7
+ // to the compose sibling App-View by nano-ide #518), shows a NEUTRAL in-progress status, and only
8
+ // renders "✓ Loaded…" when the compose mount acks the fill (matching a correlation token) — a clear
9
+ // "Couldn't reach the composer" on a short timeout.
10
+ // • Compose "Preview generated DI" (mount.js `doPreviewDi`) posts `nano-navigate` UP to the console,
11
+ // shows a NEUTRAL in-progress status, and only renders "✓ Opened…" on a host `nano-navigate-ack` —
12
+ // "Couldn't reach the console explorer" on a short timeout.
13
+ //
14
+ // These tests drive the REAL mounts over a linkedom DOM inside a fake EMBEDDED App-View window whose
15
+ // `parent.postMessage` is recorded and whose `message` listeners can be fed synthetic acks. They fail
16
+ // against the pre-fix mounts (which render "✓" synchronously with no ack) and pass after.
17
+ import { test } from "node:test";
18
+ import { assert, assertEquals } from "#test-assert";
19
+ import { parseHTML } from "linkedom";
20
+ import { mountDeliveryGraphs, DG_COMPOSE_FILL_ACK_MESSAGE, NANO_NAVIGATE_ACK_MESSAGE } from "../pages/delivery-graphs/mount.js";
21
+ import { mountDeliveryGraphLibrary } from "../pages/delivery-graphs/library.mount.js";
22
+
23
+ const ORIGIN = "https://app.test";
24
+ const CHECK = "\u2713"; // the "✓" a success toast must EARN, never assume
25
+
26
+ interface PostedMessage {
27
+ data: { type?: string; graphJson?: string; token?: string | null; target?: string; params?: unknown };
28
+ targetOrigin: string;
29
+ }
30
+
31
+ /** Boot a mount inside a fake EMBEDDED App-View window (parent !== window) with a recording
32
+ * parent.postMessage and a capturable `message` listener set, over a linkedom DOM. */
33
+ function harness(
34
+ mountFn: (host: unknown, config: Record<string, unknown>) => () => void,
35
+ config: Record<string, unknown>,
36
+ fetchImpl?: (input: unknown, init?: unknown) => Promise<Response>,
37
+ ) {
38
+ const { window: domWindow, document } = parseHTML("<!doctype html><html><body><div id='host'></div></body></html>");
39
+ const host = document.getElementById("host");
40
+ assert(host, "harness host element must exist");
41
+
42
+ const posted: PostedMessage[] = [];
43
+ const messageListeners: Array<(ev: unknown) => void> = [];
44
+ const parentWindow = {
45
+ postMessage: (data: PostedMessage["data"], targetOrigin: string) => {
46
+ posted.push({ data, targetOrigin });
47
+ },
48
+ };
49
+ const fakeWindow = {
50
+ location: { origin: ORIGIN, href: `${ORIGIN}/delivery-graphs/` },
51
+ parent: parentWindow, // parent !== fakeWindow ⇒ embedded
52
+ addEventListener: (type: string, fn: (ev: unknown) => void) => {
53
+ if (type === "message") messageListeners.push(fn);
54
+ },
55
+ removeEventListener: (type: string, fn: (ev: unknown) => void) => {
56
+ if (type !== "message") return;
57
+ const i = messageListeners.indexOf(fn);
58
+ if (i >= 0) messageListeners.splice(i, 1);
59
+ },
60
+ };
61
+
62
+ const origFetch = globalThis.fetch;
63
+ if (fetchImpl) globalThis.fetch = fetchImpl as typeof fetch;
64
+ const origWindow = Reflect.get(globalThis, "window");
65
+ Reflect.set(globalThis, "window", fakeWindow);
66
+
67
+ // Restore the mutated globals if the mount itself throws, so a failing mount can't
68
+ // leak `fetch`/`window` into later tests and make them fail in confusing ways.
69
+ let dispose: () => void;
70
+ try {
71
+ dispose = mountFn(host, config);
72
+ } catch (err) {
73
+ if (fetchImpl) globalThis.fetch = origFetch;
74
+ Reflect.set(globalThis, "window", origWindow);
75
+ throw err;
76
+ }
77
+
78
+ const teardown = () => {
79
+ dispose();
80
+ if (fetchImpl) globalThis.fetch = origFetch;
81
+ Reflect.set(globalThis, "window", origWindow);
82
+ };
83
+ const flush = async () => {
84
+ for (let i = 0; i < 4; i++) await new Promise((r) => setTimeout(r, 0));
85
+ };
86
+ const click = (el: { dispatchEvent: (ev: unknown) => boolean } | null) => {
87
+ assert(el, "click target must exist");
88
+ el!.dispatchEvent(new domWindow.Event("click", { bubbles: true, cancelable: true }));
89
+ };
90
+ // Deliver a synthetic same-origin message from the parent (the console relay) to the mount's listeners.
91
+ const emitFromParent = (data: unknown) => {
92
+ for (const fn of [...messageListeners]) fn({ origin: ORIGIN, source: parentWindow, data });
93
+ };
94
+ const status = () => host!.querySelector(".status") as { textContent: string | null; className: string } | null;
95
+
96
+ return { host, posted, teardown, flush, click, emitFromParent, status };
97
+ }
98
+
99
+ const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
100
+
101
+ // ── Library "Reuse" ────────────────────────────────────────────────────────────────────────────────
102
+ const LIBRARY_URL = `${ORIGIN}/app/api/delivery-graph/library`;
103
+ const ENTRY = { id: "lib-1", name: "Onboarding", graph: '{"nodes":[],"edges":[]}' };
104
+ const libraryFetch = async (input: unknown) => {
105
+ if (String(input).startsWith(LIBRARY_URL)) return new Response(JSON.stringify({ entries: [ENTRY] }), { status: 200 });
106
+ return new Response(JSON.stringify({}), { status: 404 });
107
+ };
108
+
109
+ function libraryHarness() {
110
+ return harness(
111
+ mountDeliveryGraphLibrary as never,
112
+ { libraryUrl: LIBRARY_URL, refreshMs: 1_000_000_000, ackTimeoutMs: 40 },
113
+ libraryFetch,
114
+ );
115
+ }
116
+
117
+ test("#645: Reuse shows NO optimistic ✓ toast — a neutral in-progress status until the compose acks", async () => {
118
+ const h = libraryHarness();
119
+ try {
120
+ await h.flush();
121
+ h.click(h.host!.querySelector("[data-reuse]"));
122
+ // Synchronously after the post: the fill message went UP to the host, but there is NO success yet.
123
+ const fill = h.posted.find((p) => p.data.type === "nano-delivery-graph-compose-fill");
124
+ assert(fill, "Reuse must post the compose-fill message up to the host");
125
+ assertEquals(fill!.data.graphJson, ENTRY.graph, "the fill carries the saved graph JSON");
126
+ assert(typeof fill!.data.token === "string" && fill!.data.token, "the fill carries a correlation token for the ack");
127
+ const s = h.status();
128
+ assert(s && !s.textContent!.includes(CHECK), "Reuse must NOT render a ✓ success toast synchronously (the #645 defect)");
129
+ assert(s && !/status-ok/.test(s.className), "the pre-ack Reuse status must not carry the success (ok) tone");
130
+ } finally {
131
+ h.teardown();
132
+ }
133
+ });
134
+
135
+ test("#645: Reuse renders ✓ Loaded only once the compose acks the matching fill token", async () => {
136
+ const h = libraryHarness();
137
+ try {
138
+ await h.flush();
139
+ h.click(h.host!.querySelector("[data-reuse]"));
140
+ const fill = h.posted.find((p) => p.data.type === "nano-delivery-graph-compose-fill");
141
+ assert(fill, "Reuse must post the compose-fill message");
142
+ // A stale/mismatched-token ack must NOT complete this Reuse.
143
+ h.emitFromParent({ type: DG_COMPOSE_FILL_ACK_MESSAGE, token: "some-other-token" });
144
+ assert(!h.status()!.textContent!.includes(CHECK), "an ack for a different token must not satisfy this Reuse");
145
+ // The real ack (echoing our token) earns the success toast.
146
+ h.emitFromParent({ type: DG_COMPOSE_FILL_ACK_MESSAGE, token: fill!.data.token });
147
+ const s = h.status();
148
+ assert(s!.textContent!.includes(CHECK) && /Loaded/.test(s!.textContent!), "on ack Reuse renders the ✓ Loaded toast");
149
+ assert(/status-ok/.test(s!.className), "the acked Reuse status carries the success tone");
150
+ } finally {
151
+ h.teardown();
152
+ }
153
+ });
154
+
155
+ test("#645: Reuse ignores an ack with a null/absent token — it can't be correlated, so no ✓", async () => {
156
+ const h = libraryHarness();
157
+ try {
158
+ await h.flush();
159
+ h.click(h.host!.querySelector("[data-reuse]"));
160
+ const fill = h.posted.find((p) => p.data.type === "nano-delivery-graph-compose-fill");
161
+ assert(fill, "Reuse must post the compose-fill message");
162
+ // The compose mount emits `token: null` when a fill arrives without a token; such an ack can't be
163
+ // correlated to this Reuse and must NOT satisfy it (else the #645 false-positive toast returns).
164
+ h.emitFromParent({ type: DG_COMPOSE_FILL_ACK_MESSAGE, token: null });
165
+ assert(!h.status()!.textContent!.includes(CHECK), "a null-token ack must not satisfy this Reuse");
166
+ h.emitFromParent({ type: DG_COMPOSE_FILL_ACK_MESSAGE });
167
+ assert(!h.status()!.textContent!.includes(CHECK), "a token-less ack must not satisfy this Reuse");
168
+ // The real ack (echoing our token) still earns the success toast.
169
+ h.emitFromParent({ type: DG_COMPOSE_FILL_ACK_MESSAGE, token: fill!.data.token });
170
+ assert(h.status()!.textContent!.includes(CHECK), "the matching-token ack still resolves the Reuse");
171
+ } finally {
172
+ h.teardown();
173
+ }
174
+ });
175
+
176
+ test("#645: Reuse surfaces an honest error when the compose never acks (short timeout)", async () => {
177
+ const h = libraryHarness();
178
+ try {
179
+ await h.flush();
180
+ h.click(h.host!.querySelector("[data-reuse]"));
181
+ assert(!h.status()!.textContent!.includes(CHECK), "no success before the timeout");
182
+ await wait(80); // > ackTimeoutMs (40)
183
+ const s = h.status();
184
+ assert(s && /Couldn't reach the composer/.test(s.textContent!), "on timeout Reuse surfaces 'Couldn't reach the composer'");
185
+ assert(!s!.textContent!.includes(CHECK) && /status-err/.test(s!.className), "the timed-out Reuse is an error, never a ✓ success");
186
+ } finally {
187
+ h.teardown();
188
+ }
189
+ });
190
+
191
+ // ── Compose "Preview generated DI" ───────────────────────────────────────────────────────────────────
192
+ const PREVIEW_URL = `${ORIGIN}/app/api/actions/delivery-graph/preview`;
193
+ const STAGE_URL = `${ORIGIN}/app/api/actions/delivery-graph/stage`;
194
+ const IMPORT_URL = `${ORIGIN}/app/api/actions/delivery-graph/library/import`;
195
+ const composeFetch = async (input: unknown) => {
196
+ if (String(input) === PREVIEW_URL) {
197
+ return new Response(
198
+ JSON.stringify({ ok: true, title: "T", sideEffecting: false, nodeCount: 1, humanNodeCount: 0, sideEffectCount: 0, digest: "d1", sideEffects: [], mermaid: "graph TD", bpmn: "<xml/>" }),
199
+ { status: 200 },
200
+ );
201
+ }
202
+ return new Response(JSON.stringify({}), { status: 404 });
203
+ };
204
+
205
+ /** Boot the compose mount and drive Preview so a laid-out BPMN exists, then click "Preview generated DI". */
206
+ async function composePreviewHarness() {
207
+ const h = harness(
208
+ mountDeliveryGraphs as never,
209
+ { previewUrl: PREVIEW_URL, stageUrl: STAGE_URL, importUrl: IMPORT_URL, ackTimeoutMs: 40 },
210
+ composeFetch,
211
+ );
212
+ const jsonEl = h.host!.querySelector("#dg-json") as { value: string } | null;
213
+ assert(jsonEl, "compose mount must render the #dg-json textarea");
214
+ jsonEl!.value = '{"nodes":[],"edges":[]}';
215
+ h.click(h.host!.querySelector("#dg-preview"));
216
+ await h.flush(); // resolves the preview door → lastBpmn set → renders the "Preview generated DI" button
217
+ return h;
218
+ }
219
+
220
+ test("#645: 'Preview generated DI' shows NO optimistic ✓ toast — a neutral status until the host acks", async () => {
221
+ const h = await composePreviewHarness();
222
+ try {
223
+ const before = h.posted.length;
224
+ h.click(h.host!.querySelector("[data-preview-di]"));
225
+ const nav = h.posted.slice(before).find((p) => p.data.type === "nano-navigate");
226
+ assert(nav, "Preview DI must post nano-navigate up to the console");
227
+ assertEquals(nav!.data.target, "definitionPreview", "the nano-navigate targets the definitionPreview view");
228
+ const s = h.status();
229
+ assert(s && !s.textContent!.includes(CHECK), "Preview DI must NOT render a ✓ 'Opening…' toast synchronously (the #645 defect)");
230
+ assert(s && !/status-ok/.test(s.className), "the pre-ack Preview status must not carry the success (ok) tone");
231
+ } finally {
232
+ h.teardown();
233
+ }
234
+ });
235
+
236
+ test("#645: 'Preview generated DI' renders ✓ Opened only once the host acks the nano-navigate", async () => {
237
+ const h = await composePreviewHarness();
238
+ try {
239
+ h.click(h.host!.querySelector("[data-preview-di]"));
240
+ assert(!h.status()!.textContent!.includes(CHECK), "no success before the host ack");
241
+ h.emitFromParent({ type: NANO_NAVIGATE_ACK_MESSAGE, target: "definitionPreview" });
242
+ const s = h.status();
243
+ assert(s!.textContent!.includes(CHECK) && /Opened/.test(s!.textContent!), "on host ack Preview DI renders the ✓ Opened toast");
244
+ assert(/status-ok/.test(s!.className), "the acked Preview status carries the success tone");
245
+ } finally {
246
+ h.teardown();
247
+ }
248
+ });
249
+
250
+ test("#645: 'Preview generated DI' ignores a target-less nav ack — no forged ✓ Opened", async () => {
251
+ const h = await composePreviewHarness();
252
+ try {
253
+ h.click(h.host!.querySelector("[data-preview-di]"));
254
+ // An ack that omits `target` (or names a different one) could be any unrelated same-origin parent
255
+ // ack — it must NOT resolve the pending Preview (else the #645 false-positive toast returns).
256
+ h.emitFromParent({ type: NANO_NAVIGATE_ACK_MESSAGE });
257
+ assert(!h.status()!.textContent!.includes(CHECK), "a target-less nav ack must not satisfy the Preview");
258
+ h.emitFromParent({ type: NANO_NAVIGATE_ACK_MESSAGE, target: "someOtherView" });
259
+ assert(!h.status()!.textContent!.includes(CHECK), "an ack for a different target must not satisfy the Preview");
260
+ // The real ack (matching target) still earns the success toast.
261
+ h.emitFromParent({ type: NANO_NAVIGATE_ACK_MESSAGE, target: "definitionPreview" });
262
+ assert(h.status()!.textContent!.includes(CHECK), "the matching-target ack still resolves the Preview");
263
+ } finally {
264
+ h.teardown();
265
+ }
266
+ });
267
+
268
+ test("#645: 'Preview generated DI' surfaces an honest error when the host never acks (short timeout)", async () => {
269
+ const h = await composePreviewHarness();
270
+ try {
271
+ h.click(h.host!.querySelector("[data-preview-di]"));
272
+ assert(!h.status()!.textContent!.includes(CHECK), "no success before the timeout");
273
+ await wait(80); // > ackTimeoutMs (40)
274
+ const s = h.status();
275
+ assert(s && /Couldn't reach the console explorer/.test(s.textContent!), "on timeout Preview DI surfaces 'Couldn't reach the console explorer'");
276
+ assert(!s!.textContent!.includes(CHECK) && /status-err/.test(s!.className), "the timed-out Preview is an error, never a ✓ success");
277
+ } finally {
278
+ h.teardown();
279
+ }
280
+ });
@@ -71,7 +71,7 @@ test("#523: Reuse drives the compose fill seam over the shared host-bridge messa
71
71
  // Reuse loads the saved graph back into the SEPARATE compose App-View, so it posts the ONE shared
72
72
  // fill message (its type imported from ./mount.js, never re-declared) UP over the App-View boundary.
73
73
  assert(
74
- /import \{ DG_COMPOSE_FILL_MESSAGE \} from "\.\/mount\.js"/.test(LIBRARY_JS),
74
+ /import \{[^}]*\bDG_COMPOSE_FILL_MESSAGE\b[^}]*\} from "\.\/mount\.js"/.test(LIBRARY_JS),
75
75
  "library.mount.js must import DG_COMPOSE_FILL_MESSAGE from ./mount.js (the ONE source of truth for the fill message type)",
76
76
  );
77
77
  assert(/data-reuse=/.test(LIBRARY_JS), "library.mount.js must render a per-row Reuse affordance carrying the entry id");
@@ -0,0 +1,57 @@
1
+ // Unit coverage for pr.record-feature-implementing (issue #642) — the twin of
2
+ // `record-feature-escalation`. It runs on BOTH edges into `implement-task` (first entry + answer
3
+ // re-entry) and must flip the run to the non-terminal `running` status so `escalated` holds ONLY
4
+ // while parked on the native `feature-escalation` user task. Without it, the answer loop-back left
5
+ // `feature_runs.status` a stale `escalated` through the whole re-implementation (the #632 tear).
6
+ import { test } from "node:test";
7
+ import { assertEquals } from "#test-assert";
8
+ import { noopLog } from "../../test/log.ts";
9
+ import handler from "./worker.ts";
10
+
11
+ // biome-ignore lint/suspicious/noExplicitAny: tiny in-memory app double, mirrors record-feature-escalation.worker.test
12
+ function fakeApp(rows: Record<string, unknown>[]): any {
13
+ const stores: Record<string, Record<string, unknown>[]> = { feature_runs: rows };
14
+ return {
15
+ stores,
16
+ data: {
17
+ table(name: string, key: string) {
18
+ const store = (stores[name] ??= []);
19
+ return {
20
+ // biome-ignore lint/suspicious/noExplicitAny: test double
21
+ get: (k: any) => Promise.resolve(store.find((r) => r[key] === k)),
22
+ // biome-ignore lint/suspicious/noExplicitAny: test double
23
+ update: (k: any, patch: any) => {
24
+ const row = store.find((r) => r[key] === k);
25
+ if (row) Object.assign(row, patch);
26
+ return Promise.resolve(row);
27
+ },
28
+ };
29
+ },
30
+ },
31
+ log: noopLog(),
32
+ };
33
+ }
34
+
35
+ test("record-feature-implementing: resets an escalated run back to running on the answer re-entry", async () => {
36
+ // The answer loop-back routes through this task before re-dispatching implement-task; it must clear
37
+ // the stale `escalated` the run parked on so the Overview no longer reads it as escalated while it
38
+ // re-implements (issue #642 — the #632 tear).
39
+ const rows = [{ feature_key: "owner/repo#7", status: "escalated", updated_at: "2025-01-01T00:00:00.000Z" }];
40
+ const app = fakeApp(rows);
41
+ const out = await handler({ jobKey: "job-1", variables: { featureKey: "owner/repo#7" } } as never, app);
42
+ assertEquals(out, {});
43
+ assertEquals(rows[0].status, "running");
44
+ assertEquals(rows[0].updated_at !== "2025-01-01T00:00:00.000Z", true, "updated_at was refreshed");
45
+ });
46
+
47
+ test("record-feature-implementing: a confirming write on the first entry (already running) keeps status running and still refreshes updated_at", async () => {
48
+ // On `f_toImplement` (first entry) the row is already `running` from dispatch — re-stamping is a
49
+ // harmless idempotent confirming write for the STATUS (a retried at-least-once job never regresses it),
50
+ // but the worker still refreshes `updated_at` on every invocation, so assert that timestamp write too
51
+ // (a future refactor must not silently stop stamping it — the self-heal grace window keys on it).
52
+ const rows = [{ feature_key: "owner/repo#8", status: "running", updated_at: "2025-01-01T00:00:00.000Z" }];
53
+ const app = fakeApp(rows);
54
+ await handler({ jobKey: "job-8", variables: { featureKey: "owner/repo#8" } } as never, app);
55
+ assertEquals(rows[0].status, "running");
56
+ assertEquals(rows[0].updated_at !== "2025-01-01T00:00:00.000Z", true, "updated_at was refreshed on the confirming write");
57
+ });
@@ -0,0 +1,31 @@
1
+ // pr.record-feature-implementing — the twin of `record-feature-escalation` (issue #642). This
2
+ // service task sits on BOTH edges into `implement-task`: the first entry (`f_toImplement`, off
3
+ // `ensure-base-branch`) AND the answer re-entry (`w_answerLoop`, off the `w_gw_answer` gateway).
4
+ // It stamps `feature_runs.status="running"` so the run is `escalated` ONLY while a token is parked
5
+ // on the native `feature-escalation` user task — honouring the invariant `record-feature-escalation`
6
+ // (the sole `escalated` writer) would otherwise violate on the answer loop-back: it had no symmetric
7
+ // reset, so `status` stayed a stale `escalated` through the ENTIRE post-answer re-implementation
8
+ // (the #632 tear). Parity with the PR `status="escalated"` contract, which holds only while parked.
9
+ //
10
+ // Idempotent-safe: re-stamping `running` is a no-op FOR THE STATUS, so the at-least-once job can retry
11
+ // freely; it does still refresh `updated_at` on every invocation (a confirming timestamp write), and
12
+ // stamping `running` on the very first entry (when the row is already `running` from dispatch) is a
13
+ // harmless confirming write.
14
+ import type { AppJobHandler } from "@nanobpm/urban";
15
+ import { featureRuns } from "../../app/feature.ts";
16
+ import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
17
+
18
+ // Input typed off the model data envelope (`RecordFeatureImplementingIn` in feature.bpmn) — ADR 0040.
19
+ type In = WorkerInputs["pr.record-feature-implementing"];
20
+
21
+ const handler: AppJobHandler<In> = async (job, app) => {
22
+ const featureKey = job.variables.featureKey;
23
+ await featureRuns(app.data).update(featureKey, {
24
+ status: "running",
25
+ updated_at: new Date().toISOString(),
26
+ });
27
+ app.log.info("record-feature-implementing", { featureKey });
28
+ return {};
29
+ };
30
+
31
+ export default handler;
@@ -78,9 +78,11 @@ test("no opened PRs (empty plan) hard-fails with NO_WORK_DISPATCHED", async () =
78
78
  const plan = app._plans.at(-1) as Record<string, unknown>;
79
79
  assertEquals(plan.status, "failed");
80
80
  assertEquals(plan.outcome, "no work dispatched — the planner produced no tasks");
81
- // `list_bucket` is derived by the `plan_read_model` VIEW (074): a failed epic reads as History
82
- // (no tick-off needed). Cross-checked against the pure `deriveEpicBucket` oracle the VIEW mirrors.
83
- assertEquals(deriveEpicBucket(plan.status as string, null, plan.acknowledged_at as string | null), "history");
81
+ // `list_bucket` is derived by the `plan_read_model` VIEW: under the uniform acknowledge-to-dismiss
82
+ // rule (issue #641) a terminal-but-UNACKNOWLEDGED epic including a `failed` one STAYS in Active
83
+ // (offering the operator a Dismiss) until it is acknowledged, rather than vanishing straight to
84
+ // History. Cross-checked against the pure `deriveEpicBucket` oracle the VIEW mirrors.
85
+ assertEquals(deriveEpicBucket(plan.status as string, null, plan.acknowledged_at as string | null), "active");
84
86
  });
85
87
 
86
88
  test("tasks present but none opened (all skipped/blocked) hard-fails", async () => {