@nanobpm/nano-workforce 0.167.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.
@@ -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;