@nanobpm/nano-workforce 0.167.0 → 0.167.2

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.
@@ -15,10 +15,19 @@
15
15
  // It rejects (409) a PR that is NOT terminal (still converging/waiting_review/…), so it can never
16
16
  // pre-seed the tick-off on a live PR: were `acknowledged_at` set early, the moment the PR later settled
17
17
  // the VIEW would drop it straight into History, skipping the operator dismiss this op exists to
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.
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.
20
29
 
21
- import { PR_TERMINAL_STATUSES } from "../app/pullRequestReadModel.ts";
30
+ import { PR_TERMINAL_STATUSES, PULL_REQUEST_READ_MODEL_NAME } from "../app/pullRequestReadModel.ts";
22
31
  import { defineOperation } from "../nano-generated/operations.ts";
23
32
 
24
33
  const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
@@ -32,28 +41,37 @@ export default defineOperation("acknowledgePr", async ({ body }, app) => {
32
41
  const prKey = str(body.pr_key);
33
42
  if (!prKey) return { status: 400, body: { ok: false, error: "pr_key is required" } };
34
43
 
35
- const table = app.data.table<{ pr_key: string; status: string; acknowledged_at: string | null; updated_at: string }>(
36
- "pull_requests",
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,
37
50
  "pr_key",
38
51
  );
39
- const pr = await table.get(prKey);
40
- if (!pr) {
52
+ const view = await readModel.get(prKey);
53
+ if (!view) {
41
54
  app.log.warn("acknowledge-pr: no such pull request", { prKey });
42
55
  return { status: 404, body: { ok: false, error: "no such pull request" } };
43
56
  }
44
57
 
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 });
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 });
51
65
  return { status: 409, body: { ok: false, error: "pull request is not terminal" } };
52
66
  }
53
67
 
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.
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
+ );
57
75
  const now = new Date().toISOString();
58
76
  await table.update(prKey, { acknowledged_at: now, updated_at: now });
59
77
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.167.0",
3
+ "version": "0.167.2",
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",
@@ -61,7 +61,7 @@
61
61
  },
62
62
  "dependencies": {
63
63
  "@nanobpm/agentic": "^0.4.0",
64
- "@nanobpm/urban": "^0.87.0",
64
+ "@nanobpm/urban": "^0.88.1",
65
65
  "bpmn-auto-layout": "^2.0.0-alpha.2"
66
66
  },
67
67
  "devDependencies": {
@@ -13,7 +13,13 @@
13
13
  // standalone — only the host element and injected endpoint config differ. The app has no browser build
14
14
  // step, so this consumes the library doors straight off the wire.
15
15
 
16
- import { DG_COMPOSE_FILL_MESSAGE } from "./mount.js";
16
+ import { DG_COMPOSE_FILL_ACK_MESSAGE, DG_COMPOSE_FILL_MESSAGE } from "./mount.js";
17
+
18
+ // The bounded budget within which the compose App-View must ACK a Reuse fill before we surface an honest
19
+ // "Couldn't reach the composer" error (issue #645). Mirrors the compose mount's own ACK_TIMEOUT_MS: long
20
+ // enough for the App-View relay round-trip (nano-ide #518), short enough that a dropped fill fails fast
21
+ // instead of leaving a misleading "✓ Loaded…" up. Overridable via config for tests.
22
+ const REUSE_ACK_TIMEOUT_MS = 2000;
17
23
 
18
24
  // The read behind the list: every saved library entry, newest first. Anchored to THIS MODULE's url
19
25
  // (import.meta.url), NOT the document base. The library App-View shell (library-embed.html /
@@ -149,6 +155,8 @@ export function mountDeliveryGraphLibrary(host, config = {}) {
149
155
 
150
156
  const libraryUrl = config.libraryUrl ?? DEFAULT_LIBRARY_URL;
151
157
  const refreshMs = typeof config.refreshMs === "number" && config.refreshMs > 0 ? config.refreshMs : DEFAULT_REFRESH_MS;
158
+ const ackTimeoutMs =
159
+ typeof config.ackTimeoutMs === "number" && config.ackTimeoutMs > 0 ? config.ackTimeoutMs : REUSE_ACK_TIMEOUT_MS;
152
160
  const headers = (url) => ({
153
161
  "content-type": "application/json",
154
162
  ...(config.hookSecret && isSameOrigin(url) ? { "x-hook-secret": config.hookSecret } : {}),
@@ -255,7 +263,22 @@ export function mountDeliveryGraphLibrary(host, config = {}) {
255
263
  // the host bridge, posting the shared `deliveryGraph.compose.fill` message UP to the console (the
256
264
  // INBOUND twin of the outbound `nano-navigate` DI-preview bridge). Standalone (not embedded) there is
257
265
  // no console to route it and no compose view to fill, so we say so instead of failing silently.
266
+ //
267
+ // The success toast is ACKNOWLEDGED, never optimistic (issue #645): a "✓ Loaded…" printed synchronously
268
+ // after the post lied whenever the fill was dropped (no relay, wrong origin, an unmounted compose view).
269
+ // Now we show a NEUTRAL in-progress status, tag the fill with a correlation `token`, and resolve it only
270
+ // when the compose mount echoes `DG_COMPOSE_FILL_ACK_MESSAGE` for that token — "✓ Loaded…" on the ack,
271
+ // "Couldn't reach the composer" on a short timeout. A fresh Reuse supersedes any pending one.
258
272
  const isEmbedded = typeof window !== "undefined" && window.parent && window.parent !== window;
273
+ let pendingReuse = null;
274
+ function clearPendingReuse() {
275
+ if (pendingReuse && pendingReuse.timer) clearTimeout(pendingReuse.timer);
276
+ pendingReuse = null;
277
+ }
278
+ function newReuseToken() {
279
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
280
+ return `reuse-${Date.now()}-${Math.random().toString(16).slice(2)}`;
281
+ }
259
282
  function doReuse(id) {
260
283
  const entry = entries.find((e) => e && e.id === id);
261
284
  if (!entry) {
@@ -270,12 +293,42 @@ export function mountDeliveryGraphLibrary(host, config = {}) {
270
293
  setStatus("Open this page inside the console to reuse a saved graph in the composer.", "err");
271
294
  return;
272
295
  }
296
+ clearPendingReuse();
297
+ const token = newReuseToken();
273
298
  window.parent.postMessage(
274
- { type: DG_COMPOSE_FILL_MESSAGE, graphJson: entry.graph },
299
+ { type: DG_COMPOSE_FILL_MESSAGE, graphJson: entry.graph, token },
275
300
  window.location.origin,
276
301
  );
302
+ // NEUTRAL in-progress status (no "✓") — success is only earned on the compose ack below (#645).
303
+ setStatus("Loading into the composer above\u2026", "");
304
+ const timer = setTimeout(() => {
305
+ if (!pendingReuse || pendingReuse.token !== token) return;
306
+ pendingReuse = null;
307
+ setStatus("Couldn't reach the composer \u2014 is the composer open above this list?", "err");
308
+ }, ackTimeoutMs);
309
+ pendingReuse = { token, timer };
310
+ }
311
+ // The compose App-View's acknowledgment that it filled (#645): a same-origin `DG_COMPOSE_FILL_ACK_MESSAGE`
312
+ // from the parent (the console, which relays it across from the compose sibling) whose `token` matches the
313
+ // pending Reuse resolves it to success. A foreign origin, a non-parent source, or a stale/mismatched token
314
+ // is ignored — an unrelated page (or a prior Reuse's late ack) can't forge or misattribute a success toast.
315
+ function onReuseAck(ev) {
316
+ if (!ev || typeof window === "undefined") return;
317
+ if (ev.origin !== window.location.origin) return;
318
+ if (ev.source !== window.parent) return;
319
+ const data = ev.data;
320
+ if (!data || data.type !== DG_COMPOSE_FILL_ACK_MESSAGE) return;
321
+ // Require a string `token` that exactly matches the pending Reuse. A null/undefined-token ack
322
+ // (the compose mount emits `token: null` when a fill arrives without a token) can't be correlated
323
+ // to this Reuse and must NOT resolve it — otherwise it reintroduces the false-positive "✓ Loaded…"
324
+ // toast (#645).
325
+ if (!pendingReuse || typeof data.token !== "string" || data.token !== pendingReuse.token) return;
326
+ clearPendingReuse();
277
327
  setStatus("\u2713 Loaded into the composer above \u2014 edit, Preview or Stage it.", "ok");
278
328
  }
329
+ if (isEmbedded && typeof window !== "undefined" && typeof window.addEventListener === "function") {
330
+ window.addEventListener("message", onReuseAck);
331
+ }
279
332
 
280
333
  // "Export": a purely client-side download of a saved entry's graph JSON as `<name>.deliverygraph.json`
281
334
  // (issue #525). No backend door — the list door already carries each entry's `graph` inline (the same
@@ -368,6 +421,10 @@ export function mountDeliveryGraphLibrary(host, config = {}) {
368
421
 
369
422
  return () => {
370
423
  disposed = true;
424
+ clearPendingReuse();
425
+ if (typeof window !== "undefined" && typeof window.removeEventListener === "function") {
426
+ window.removeEventListener("message", onReuseAck);
427
+ }
371
428
  clearInterval(timer);
372
429
  root.innerHTML = "";
373
430
  };
@@ -50,6 +50,32 @@ const DEFAULT_IMPORT_URL = new URL("../app/api/actions/delivery-graph/library/im
50
50
  // routes through the single `fillComposer()` seam below; keep new fill sources going through it.
51
51
  export const DG_COMPOSE_FILL_MESSAGE = "nano-delivery-graph-compose-fill";
52
52
 
53
+ // The ACK half of the reuse-fill bridge (issue #645). An optimistic "✓ Loaded…" toast printed
54
+ // synchronously after the Library posts the fill can never reflect the cross-frame OUTCOME — the fill
55
+ // message is relayed across App-Views (nano-ide #518) and could still be dropped (no relay, wrong
56
+ // origin, a compose view that isn't mounted), yet the toast claimed success regardless (the #645
57
+ // defect). So the delivery is now ACKNOWLEDGED: when THIS compose mount actually fills from a fill
58
+ // message, it posts this ack back UP to the host (relayed across to the Library sibling), echoing the
59
+ // producer's correlation `token`. The Library shows "✓ Loaded…" ONLY on that ack and a clear error on a
60
+ // short timeout — the success is earned, not assumed. The `type` is exported ONCE here as the single
61
+ // source of truth; the Library consumer imports it rather than re-declaring a synonym.
62
+ export const DG_COMPOSE_FILL_ACK_MESSAGE = "nano-delivery-graph-compose-fill-ack";
63
+
64
+ // The host acknowledgment of a `nano-navigate` (issue #645). "Preview generated DI" posts nano-navigate
65
+ // UP to the console; nano-ide #518 forwards it to the host explorer, but the host does not synchronously
66
+ // confirm, so an optimistic "✓ Opening…" toast printed right after the post claimed success even when the
67
+ // message was dropped (standalone, no relay, a console that never navigated). The compose view now treats
68
+ // Preview as fire-to-host with a bounded budget: it shows a NEUTRAL in-progress status, and only earns a
69
+ // "✓" when the host echoes this ack for the same `target`; if no host ack is observed within the budget it
70
+ // surfaces "Couldn't reach the console explorer" instead. Same-origin, from the parent (the console).
71
+ export const NANO_NAVIGATE_ACK_MESSAGE = "nano-navigate-ack";
72
+
73
+ // The bounded budget within which a cross-frame delivery (compose-fill relay, or a nano-navigate to the
74
+ // host) must be acknowledged before the producer surfaces an honest "couldn't reach …" error (#645). A
75
+ // couple of seconds is long enough for the App-View relay round-trip yet short enough that a dropped
76
+ // message fails fast instead of leaving a misleading toast up. Overridable via config for tests.
77
+ const ACK_TIMEOUT_MS = 2000;
78
+
53
79
  // A bounded timeout for every door request. Without it a hung endpoint leaves the fetch promise pending
54
80
  // forever, so the busy() lock never clears and the UI is stranded (buttons disabled, status stuck) with
55
81
  // no way to retry. On timeout the AbortController rejects the fetch, which surfaces as an error banner
@@ -230,6 +256,8 @@ export function mountDeliveryGraphs(host, config = {}) {
230
256
  const previewUrl = config.previewUrl ?? DEFAULT_PREVIEW_URL;
231
257
  const stageUrl = config.stageUrl ?? DEFAULT_STAGE_URL;
232
258
  const importUrl = config.importUrl ?? DEFAULT_IMPORT_URL;
259
+ const ackTimeoutMs =
260
+ typeof config.ackTimeoutMs === "number" && config.ackTimeoutMs > 0 ? config.ackTimeoutMs : ACK_TIMEOUT_MS;
233
261
  const headers = (url) => ({
234
262
  "content-type": "application/json",
235
263
  ...(config.hookSecret && isSameOrigin(url) ? { "x-hook-secret": config.hookSecret } : {}),
@@ -324,7 +352,23 @@ export function mountDeliveryGraphs(host, config = {}) {
324
352
  if (embedded && ev.source !== window.parent) return;
325
353
  const data = ev.data;
326
354
  if (!data || data.type !== DG_COMPOSE_FILL_MESSAGE || typeof data.graphJson !== "string") return;
327
- fillComposer(data.graphJson, { status: "Reused a saved graph \u2014 Preview or Stage it." });
355
+ const filled = fillComposer(data.graphJson, { status: "Reused a saved graph \u2014 Preview or Stage it." });
356
+ // ACK the fill back to the producer (issue #645): the Library shows "✓ Loaded…" ONLY on this ack,
357
+ // never optimistically. Post UP to the host, which nano-ide #518 relays across to the Library
358
+ // sibling App-View; echo the correlation `token` so a stale ack from a prior Reuse can't complete a
359
+ // newer one. Only when actually embedded (there is a parent to relay through) and only when the fill
360
+ // truly happened — a blank/bad payload earns no ack.
361
+ if (
362
+ filled &&
363
+ embedded &&
364
+ window.parent &&
365
+ typeof window.parent.postMessage === "function"
366
+ ) {
367
+ window.parent.postMessage(
368
+ { type: DG_COMPOSE_FILL_ACK_MESSAGE, token: typeof data.token === "string" ? data.token : null },
369
+ window.location.origin,
370
+ );
371
+ }
328
372
  }
329
373
  if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
330
374
  window.addEventListener("message", onFillMessage);
@@ -446,6 +490,17 @@ export function mountDeliveryGraphs(host, config = {}) {
446
490
  // the path. Standalone (not embedded) there is no host explorer to drive, so we say so instead of
447
491
  // failing silently.
448
492
  const isEmbedded = typeof window !== "undefined" && window.parent && window.parent !== window;
493
+ // A single in-flight Preview→host handshake (issue #645). Posting nano-navigate is fire-to-host: the
494
+ // console does not synchronously confirm it navigated, so we must not claim success up front. We hold
495
+ // the pending target + its timeout timer here; a host ack for that target resolves it to "✓ Opened…",
496
+ // and the timeout resolves it to an honest "Couldn't reach the console explorer". A fresh Preview
497
+ // supersedes any pending one (its timer is cleared) so only the latest attempt is ever resolved.
498
+ const DEFINITION_PREVIEW_TARGET = "definitionPreview";
499
+ let pendingPreview = null;
500
+ function clearPendingPreview() {
501
+ if (pendingPreview && pendingPreview.timer) clearTimeout(pendingPreview.timer);
502
+ pendingPreview = null;
503
+ }
449
504
  function doPreviewDi() {
450
505
  if (lastBpmn.trim() === "") {
451
506
  setStatus("Preview a graph first — the laid-out BPMN comes from the preview.", "err");
@@ -455,11 +510,40 @@ export function mountDeliveryGraphs(host, config = {}) {
455
510
  setStatus("Open this page inside the console cockpit to preview the generated DI.", "err");
456
511
  return;
457
512
  }
513
+ clearPendingPreview();
458
514
  window.parent.postMessage(
459
515
  { type: "nano-navigate", target: "definitionPreview", params: { xml: lastBpmn } },
460
516
  window.location.origin,
461
517
  );
462
- setStatus("\u2713 Opening the generated DI in the process explorer…", "ok");
518
+ // NEUTRAL in-progress status (no "✓") the success is only earned on the host ack below (#645).
519
+ setStatus("Opening the generated DI in the process explorer…", "");
520
+ const timer = setTimeout(() => {
521
+ if (!pendingPreview || pendingPreview.target !== DEFINITION_PREVIEW_TARGET) return;
522
+ pendingPreview = null;
523
+ setStatus("Couldn't reach the console explorer — is this page open inside the console?", "err");
524
+ }, ackTimeoutMs);
525
+ pendingPreview = { target: DEFINITION_PREVIEW_TARGET, timer };
526
+ }
527
+ // The host's acknowledgment of a Preview nano-navigate (#645): a same-origin `nano-navigate-ack` from
528
+ // the parent (the console) for the target we're awaiting resolves the pending Preview to success. A
529
+ // foreign origin, a non-parent source, or an ack for a different/absent target is ignored — an
530
+ // unrelated page can't forge a success toast.
531
+ function onNavAck(ev) {
532
+ if (!ev || typeof window === "undefined") return;
533
+ if (ev.origin !== window.location.origin) return;
534
+ if (ev.source !== window.parent) return;
535
+ const data = ev.data;
536
+ if (!data || data.type !== NANO_NAVIGATE_ACK_MESSAGE) return;
537
+ // Require a string `target` that exactly matches the pending preview. A target-less (or
538
+ // mismatched) ack must NOT resolve the Preview — otherwise any same-origin parent ack could
539
+ // forge a "✓ Opened…" toast the target never actually rendered (#645).
540
+ if (!pendingPreview || typeof data.target !== "string" || data.target !== pendingPreview.target)
541
+ return;
542
+ clearPendingPreview();
543
+ setStatus("\u2713 Opened the generated DI in the process explorer.", "ok");
544
+ }
545
+ if (isEmbedded && typeof window !== "undefined" && typeof window.addEventListener === "function") {
546
+ window.addEventListener("message", onNavAck);
463
547
  }
464
548
  outputEl.addEventListener("click", (ev) => {
465
549
  const btn = ev.target && ev.target.closest ? ev.target.closest("[data-preview-di]") : null;
@@ -469,8 +553,10 @@ export function mountDeliveryGraphs(host, config = {}) {
469
553
  });
470
554
 
471
555
  return () => {
556
+ clearPendingPreview();
472
557
  if (typeof window !== "undefined" && typeof window.removeEventListener === "function") {
473
558
  window.removeEventListener("message", onFillMessage);
559
+ window.removeEventListener("message", onNavAck);
474
560
  }
475
561
  root.innerHTML = "";
476
562
  };