@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
package/openapi.yaml CHANGED
@@ -5436,6 +5436,104 @@ paths:
5436
5436
  application/json:
5437
5437
  schema:
5438
5438
  $ref: "#/components/schemas/MessageResult"
5439
+ /actions/acknowledge-pr:
5440
+ post:
5441
+ operationId: acknowledgePr
5442
+ summary: "Dismiss a TERMINAL pull request (issue #641). Stamps `acknowledged_at` on the
5443
+ `pull_requests` row so the `pull_requests_read_model` VIEW recomputes its `list_bucket` to
5444
+ 'history' (and `ack_open` to 0), dropping the finished PR out of the Active convergence list
5445
+ into History. The PR twin of acknowledge-done/acknowledge-epic: a PR is not parked at a user
5446
+ task, so this completes no user task and touches no engine/ledger — it only stamps the row.
5447
+ Keyed on the PR's `pr_key`. Rejects (409) a PR that is not yet terminal (still converging), so a
5448
+ live PR stays visible in Active. Idempotent-safe (re-acknowledging keeps it in History)."
5449
+ requestBody:
5450
+ required: true
5451
+ content:
5452
+ application/json:
5453
+ schema:
5454
+ type: object
5455
+ additionalProperties: false
5456
+ required:
5457
+ - pr_key
5458
+ properties:
5459
+ pr_key:
5460
+ type: string
5461
+ minLength: 1
5462
+ description: The pull request's key (pull_requests.pr_key).
5463
+ responses:
5464
+ "200":
5465
+ description: The terminal PR was acknowledged and moved to History.
5466
+ content:
5467
+ application/json:
5468
+ schema:
5469
+ $ref: "#/components/schemas/MessageResult"
5470
+ "400":
5471
+ description: A required field (pr_key) was missing or invalid.
5472
+ content:
5473
+ application/json:
5474
+ schema:
5475
+ $ref: "#/components/schemas/MessageResult"
5476
+ "404":
5477
+ description: No pull request matches the pr_key.
5478
+ content:
5479
+ application/json:
5480
+ schema:
5481
+ $ref: "#/components/schemas/MessageResult"
5482
+ "409":
5483
+ description: The pull request is not terminal, so it cannot be dismissed yet.
5484
+ content:
5485
+ application/json:
5486
+ schema:
5487
+ $ref: "#/components/schemas/MessageResult"
5488
+ /actions/acknowledge-delivery-graph:
5489
+ post:
5490
+ operationId: acknowledgeDeliveryGraph
5491
+ summary: "Dismiss a TERMINAL delivery-graph run (issue #641). Stamps `acknowledged_at` on the
5492
+ `delivery_graph_runs` row so the `delivery_graph_read_model` VIEW recomputes its `list_bucket`
5493
+ to 'history' (and `ack_open` to 0), dropping the finished run out of the Active delivery-graph
5494
+ list into History. The delivery-graph twin of acknowledge-done/acknowledge-epic/acknowledge-pr:
5495
+ it completes no user task and touches no engine/ledger — it only stamps the row. Keyed on the
5496
+ run's `run_key`. Rejects (409) a run that is not yet terminal (still awaiting-approval/running),
5497
+ so a live run stays visible in Active. Idempotent-safe (re-acknowledging keeps it in History)."
5498
+ requestBody:
5499
+ required: true
5500
+ content:
5501
+ application/json:
5502
+ schema:
5503
+ type: object
5504
+ additionalProperties: false
5505
+ required:
5506
+ - run_key
5507
+ properties:
5508
+ run_key:
5509
+ type: string
5510
+ minLength: 1
5511
+ description: The delivery-graph run's key (delivery_graph_runs.run_key).
5512
+ responses:
5513
+ "200":
5514
+ description: The terminal delivery-graph run was acknowledged and moved to History.
5515
+ content:
5516
+ application/json:
5517
+ schema:
5518
+ $ref: "#/components/schemas/MessageResult"
5519
+ "400":
5520
+ description: A required field (run_key) was missing or invalid.
5521
+ content:
5522
+ application/json:
5523
+ schema:
5524
+ $ref: "#/components/schemas/MessageResult"
5525
+ "404":
5526
+ description: No delivery-graph run matches the run_key.
5527
+ content:
5528
+ application/json:
5529
+ schema:
5530
+ $ref: "#/components/schemas/MessageResult"
5531
+ "409":
5532
+ description: The delivery-graph run is not terminal, so it cannot be dismissed yet.
5533
+ content:
5534
+ application/json:
5535
+ schema:
5536
+ $ref: "#/components/schemas/MessageResult"
5439
5537
  /hooks/agent-complete:
5440
5538
  post:
5441
5539
  operationId: agentCompleteEscalation
@@ -0,0 +1,93 @@
1
+ // Tests for the POST /app/api/actions/acknowledge-delivery-graph operation `acknowledgeDeliveryGraph`
2
+ // (issue #641). The nwf UI's "Dismiss" (Done ✓) affordance for a TERMINAL delivery-graph run. It stamps
3
+ // `acknowledged_at`, which the `delivery_graph_read_model` VIEW (096) derives into `list_bucket` =
4
+ // 'history' and `ack_open` = 0, dropping the finished run from the Active list into History. The
5
+ // delivery-graph twin of acknowledge-done / acknowledge-epic / acknowledge-pr.
6
+ //
7
+ // The op's only write is the `acknowledged_at` (+ `updated_at`) stamp, gated on the base `status`
8
+ // belonging to the terminal tier (`DELIVERY_GRAPH_TERMINAL_STATUSES`). These tests seed an in-memory
9
+ // `delivery_graph_runs` store and assert the 400/404/409/200 gate + the stamp; the bucket derivation
10
+ // itself is proven in app/deliveryGraphReadModel.test.ts.
11
+ import { test } from "node:test";
12
+ import { assertEquals } from "#test-assert";
13
+ import type { AppApi } from "@nanobpm/urban";
14
+ import { noopLog } from "../test/log.ts";
15
+ import { DELIVERY_GRAPH_TERMINAL_STATUSES } from "../app/deliveryGraphRun.ts";
16
+ import handler from "./acknowledgeDeliveryGraph.ts";
17
+
18
+ // biome-ignore lint/suspicious/noExplicitAny: test-only fake data layer over dynamic row shapes.
19
+ function memApp(seed: any[]): { app: AppApi; rows: any[] } {
20
+ // biome-ignore lint/suspicious/noExplicitAny: test-only store.
21
+ const stores: Record<string, any[]> = { delivery_graph_runs: seed };
22
+ function tbl(name: string, pk = "id") {
23
+ // biome-ignore lint/suspicious/noExplicitAny: test-only.
24
+ const rows = (stores[name] ??= [] as any[]);
25
+ return {
26
+ // biome-ignore lint/suspicious/noExplicitAny: test-only.
27
+ async get(id: any) {
28
+ return rows.find((r) => r[pk] === id);
29
+ },
30
+ // biome-ignore lint/suspicious/noExplicitAny: test-only.
31
+ async update(id: any, patch: any) {
32
+ const r = rows.find((row) => row[pk] === id);
33
+ if (r) Object.assign(r, patch);
34
+ return r ? 1 : 0;
35
+ },
36
+ };
37
+ }
38
+ const app = {
39
+ data: { table: (n: string, pk?: string) => tbl(n, pk) },
40
+ log: noopLog(),
41
+ } as unknown as AppApi;
42
+ return { app, rows: stores.delivery_graph_runs };
43
+ }
44
+
45
+ async function call(app: AppApi, body: unknown) {
46
+ // biome-ignore lint/suspicious/noExplicitAny: test-only op invocation.
47
+ return (await handler({ req: {} as any, params: {}, query: {}, body } as any, app)) as any;
48
+ }
49
+
50
+ test("acknowledge-delivery-graph: stamps acknowledged_at on a terminal run and returns 200", async () => {
51
+ const { app, rows } = memApp([{ run_key: "run-1", status: "done", acknowledged_at: null }]);
52
+ const res = await call(app, { run_key: "run-1" });
53
+ assertEquals(res.status, 200);
54
+ assertEquals(res.body.ok, true);
55
+ assertEquals(typeof rows[0].acknowledged_at, "string");
56
+ assertEquals(typeof rows[0].updated_at, "string");
57
+ });
58
+
59
+ test("acknowledge-delivery-graph: every terminal status is dismissable", async () => {
60
+ for (const status of DELIVERY_GRAPH_TERMINAL_STATUSES) {
61
+ const { app, rows } = memApp([{ run_key: "run-1", status, acknowledged_at: null }]);
62
+ const res = await call(app, { run_key: "run-1" });
63
+ assertEquals(res.status, 200, `status ${status} must be dismissable`);
64
+ assertEquals(typeof rows[0].acknowledged_at, "string");
65
+ }
66
+ });
67
+
68
+ test("acknowledge-delivery-graph: a live (running) run is rejected (409) and stays unstamped", async () => {
69
+ const { app, rows } = memApp([{ run_key: "run-2", status: "running", acknowledged_at: null }]);
70
+ const res = await call(app, { run_key: "run-2" });
71
+ assertEquals(res.status, 409);
72
+ assertEquals(res.body.ok, false);
73
+ assertEquals(rows[0].acknowledged_at, null);
74
+ });
75
+
76
+ test("acknowledge-delivery-graph: a missing run_key → 400", async () => {
77
+ const { app } = memApp([]);
78
+ assertEquals((await call(app, {})).status, 400);
79
+ assertEquals((await call(app, { run_key: " " })).status, 400);
80
+ });
81
+
82
+ test("acknowledge-delivery-graph: no matching run → 404", async () => {
83
+ const { app } = memApp([]);
84
+ assertEquals((await call(app, { run_key: "run-404" })).status, 404);
85
+ });
86
+
87
+ test("acknowledge-delivery-graph: idempotent — re-acknowledging a terminal run re-stamps and returns 200", async () => {
88
+ const { app, rows } = memApp([{ run_key: "run-5", status: "failed", acknowledged_at: null }]);
89
+ assertEquals((await call(app, { run_key: "run-5" })).status, 200);
90
+ assertEquals(typeof rows[0].acknowledged_at, "string");
91
+ assertEquals((await call(app, { run_key: "run-5" })).status, 200);
92
+ assertEquals(typeof rows[0].acknowledged_at, "string");
93
+ });
@@ -0,0 +1,58 @@
1
+ // POST /app/api/actions/acknowledge-delivery-graph → operationId `acknowledgeDeliveryGraph` (issue #641).
2
+ // The nwf UI's "Dismiss" (Done ✓) affordance for a TERMINAL delivery-graph run: an operator dismisses a
3
+ // finished run (done / failed / abandoned) directly from the Overview "Active Delivery Graphs" or
4
+ // delivery-graphs "In-flight delivery graphs" grid so it drops out of the Active list into History. It
5
+ // is the delivery-graph twin of `acknowledgeDone` / `acknowledgeEpic` / `acknowledgePr` — a terminal
6
+ // run is NOT parked at a user task, so this op completes no user task and touches no engine/ledger: it
7
+ // simply stamps `acknowledged_at` on the `delivery_graph_runs` row.
8
+ //
9
+ // `list_bucket`/`ack_open` are DERIVED by the `delivery_graph_read_model` VIEW (096, issue #641) from
10
+ // the terminal-folded `derived_status` + `acknowledged_at` — a terminal, now-acknowledged run reads
11
+ // `list_bucket` = 'history' and `ack_open` = 0 — so this op NEVER writes a derived projection. Keyed on
12
+ // the row's `run_key`. Idempotent-safe: re-acknowledging re-stamps the timestamp and keeps it in
13
+ // History.
14
+ //
15
+ // It rejects (409) a run that is NOT terminal (still awaiting-approval/running), so it can never pre-
16
+ // seed the tick-off on a live run. The terminal check reads the base `status`: the delivery-graph
17
+ // poller (app/deliveryGraphRun.ts) persists the terminal outcome to base `status` (done/failed/
18
+ // abandoned), and the reconciler's `onTerminated` edge covers an out-of-band terminate.
19
+
20
+ import { DELIVERY_GRAPH_TERMINAL_STATUSES, deliveryGraphRuns } from "../app/deliveryGraphRun.ts";
21
+ import { defineOperation } from "../nano-generated/operations.ts";
22
+
23
+ const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
24
+
25
+ export default defineOperation("acknowledgeDeliveryGraph", async ({ body }, app) => {
26
+ if (!body || typeof body !== "object") {
27
+ app.log.warn("acknowledge-delivery-graph rejected: missing request body");
28
+ return { status: 400, body: { ok: false, error: "run_key is required" } };
29
+ }
30
+
31
+ const runKey = str(body.run_key);
32
+ if (!runKey) return { status: 400, body: { ok: false, error: "run_key is required" } };
33
+
34
+ const table = deliveryGraphRuns(app.data);
35
+ const run = await table.get(runKey);
36
+ if (!run) {
37
+ app.log.warn("acknowledge-delivery-graph: no such run", { runKey });
38
+ return { status: 404, body: { ok: false, error: "no such delivery-graph run" } };
39
+ }
40
+
41
+ // Guard: only a TERMINAL run (a `DELIVERY_GRAPH_TERMINAL_STATUSES` status — the same set the read
42
+ // model's `list_bucket` folds to History) carries the Dismiss affordance. Acknowledging a live run
43
+ // would pre-seed `acknowledged_at`, so the moment it later settled the VIEW would drop it straight
44
+ // into History, skipping the operator tick-off this op exists to require.
45
+ if (!DELIVERY_GRAPH_TERMINAL_STATUSES.includes(run.status)) {
46
+ app.log.warn("acknowledge-delivery-graph rejected: run is not terminal", { runKey, status: run.status });
47
+ return { status: 409, body: { ok: false, error: "delivery-graph run is not terminal" } };
48
+ }
49
+
50
+ // Stamp the dismissal. `list_bucket` (→ 'history') and `ack_open` (→ 0) are derived by the
51
+ // `delivery_graph_read_model` VIEW from the terminal, now-acknowledged row, so we never hand-set them
52
+ // here. Idempotent: re-acknowledging re-stamps and stays in History.
53
+ const now = new Date().toISOString();
54
+ await table.update(runKey, { acknowledged_at: now, updated_at: now });
55
+
56
+ app.log.info("operator dismissed terminal delivery-graph run", { runKey });
57
+ return { status: 200, body: { ok: true, message: "acknowledged" } };
58
+ });
@@ -0,0 +1,94 @@
1
+ // Tests for the POST /app/api/actions/acknowledge-pr operation `acknowledgePr` (issue #641).
2
+ // The nwf UI's "Dismiss" (Done ✓) affordance for a TERMINAL pull request. It stamps `acknowledged_at`,
3
+ // which the `pull_requests_read_model` VIEW (094) derives into `list_bucket` = 'history' and `ack_open`
4
+ // = 0, dropping the finished PR from the Active convergence list into History. Unlike acknowledge-
5
+ // blocked it completes NO user task (a terminal PR is not parked). The PR twin of acknowledge-done /
6
+ // acknowledge-epic.
7
+ //
8
+ // The op's only write is the `acknowledged_at` (+ `updated_at`) stamp, gated on the base `status`
9
+ // belonging to the terminal tier (`PR_TERMINAL_STATUSES`). These tests seed an in-memory `pull_requests`
10
+ // store and assert the 400/404/409/200 gate + the stamp; the bucket derivation itself is proven in
11
+ // app/pullRequestReadModel.test.ts.
12
+ import { test } from "node:test";
13
+ import { assertEquals } from "#test-assert";
14
+ import type { AppApi } from "@nanobpm/urban";
15
+ import { noopLog } from "../test/log.ts";
16
+ import { PR_TERMINAL_STATUSES } from "../app/pullRequestReadModel.ts";
17
+ import handler from "./acknowledgePr.ts";
18
+
19
+ // biome-ignore lint/suspicious/noExplicitAny: test-only fake data layer over dynamic row shapes.
20
+ function memApp(seed: any[]): { app: AppApi; rows: any[] } {
21
+ // biome-ignore lint/suspicious/noExplicitAny: test-only store.
22
+ const stores: Record<string, any[]> = { pull_requests: seed };
23
+ function tbl(name: string, pk = "id") {
24
+ // biome-ignore lint/suspicious/noExplicitAny: test-only.
25
+ const rows = (stores[name] ??= [] as any[]);
26
+ return {
27
+ // biome-ignore lint/suspicious/noExplicitAny: test-only.
28
+ async get(id: any) {
29
+ return rows.find((r) => r[pk] === id);
30
+ },
31
+ // biome-ignore lint/suspicious/noExplicitAny: test-only.
32
+ async update(id: any, patch: any) {
33
+ const r = rows.find((row) => row[pk] === id);
34
+ if (r) Object.assign(r, patch);
35
+ return r ? 1 : 0;
36
+ },
37
+ };
38
+ }
39
+ const app = {
40
+ data: { table: (n: string, pk?: string) => tbl(n, pk) },
41
+ log: noopLog(),
42
+ } as unknown as AppApi;
43
+ return { app, rows: stores.pull_requests };
44
+ }
45
+
46
+ async function call(app: AppApi, body: unknown) {
47
+ // biome-ignore lint/suspicious/noExplicitAny: test-only op invocation.
48
+ return (await handler({ req: {} as any, params: {}, query: {}, body } as any, app)) as any;
49
+ }
50
+
51
+ test("acknowledge-pr: stamps acknowledged_at on a terminal PR and returns 200", async () => {
52
+ const { app, rows } = memApp([{ pr_key: "o/r#1", status: "merged", acknowledged_at: null }]);
53
+ const res = await call(app, { pr_key: "o/r#1" });
54
+ assertEquals(res.status, 200);
55
+ assertEquals(res.body.ok, true);
56
+ assertEquals(typeof rows[0].acknowledged_at, "string");
57
+ assertEquals(typeof rows[0].updated_at, "string");
58
+ });
59
+
60
+ test("acknowledge-pr: every terminal status is dismissable", async () => {
61
+ for (const status of PR_TERMINAL_STATUSES) {
62
+ const { app, rows } = memApp([{ pr_key: "o/r#1", status, acknowledged_at: null }]);
63
+ const res = await call(app, { pr_key: "o/r#1" });
64
+ assertEquals(res.status, 200, `status ${status} must be dismissable`);
65
+ assertEquals(typeof rows[0].acknowledged_at, "string");
66
+ }
67
+ });
68
+
69
+ test("acknowledge-pr: a live (still-converging) PR is rejected (409) and stays unstamped", async () => {
70
+ const { app, rows } = memApp([{ pr_key: "o/r#2", status: "converging", acknowledged_at: null }]);
71
+ const res = await call(app, { pr_key: "o/r#2" });
72
+ assertEquals(res.status, 409);
73
+ assertEquals(res.body.ok, false);
74
+ assertEquals(rows[0].acknowledged_at, null);
75
+ });
76
+
77
+ test("acknowledge-pr: a missing pr_key → 400", async () => {
78
+ const { app } = memApp([]);
79
+ assertEquals((await call(app, {})).status, 400);
80
+ assertEquals((await call(app, { pr_key: " " })).status, 400);
81
+ });
82
+
83
+ test("acknowledge-pr: no matching PR → 404", async () => {
84
+ const { app } = memApp([]);
85
+ assertEquals((await call(app, { pr_key: "o/r#404" })).status, 404);
86
+ });
87
+
88
+ test("acknowledge-pr: idempotent — re-acknowledging a terminal PR re-stamps and returns 200", async () => {
89
+ const { app, rows } = memApp([{ pr_key: "o/r#5", status: "converged", acknowledged_at: null }]);
90
+ assertEquals((await call(app, { pr_key: "o/r#5" })).status, 200);
91
+ assertEquals(typeof rows[0].acknowledged_at, "string");
92
+ assertEquals((await call(app, { pr_key: "o/r#5" })).status, 200);
93
+ assertEquals(typeof rows[0].acknowledged_at, "string");
94
+ });
@@ -0,0 +1,62 @@
1
+ // POST /app/api/actions/acknowledge-pr → operationId `acknowledgePr` (issue #641).
2
+ // The nwf UI's "Dismiss" (Done ✓) affordance for a TERMINAL pull request: an operator dismisses a
3
+ // finished PR (merged / converged / abandoned / closed / failed) directly from the Overview "Active PR
4
+ // convergences" or home "Pull requests" grid so it drops out of the Active convergence list into
5
+ // History. It is the PR twin of `acknowledgeDone` (the feature-run tick-off) and `acknowledgeEpic` — a
6
+ // terminal PR is NOT parked at a user task, so this op completes no user task and touches no engine/
7
+ // ledger: it simply stamps `acknowledged_at` on the `pull_requests` row.
8
+ //
9
+ // `list_bucket`/`ack_open` are DERIVED by the `pull_requests_read_model` VIEW (094, issue #641) from
10
+ // the terminal-folded `derived_status` + `acknowledged_at` — a terminal, now-acknowledged PR reads
11
+ // `list_bucket` = 'history' and `ack_open` = 0 — so this op NEVER writes a derived projection. Keyed on
12
+ // the row's `pr_key`. Idempotent-safe: re-acknowledging re-stamps the timestamp and keeps it in
13
+ // History.
14
+ //
15
+ // It rejects (409) a PR that is NOT terminal (still converging/waiting_review/…), so it can never
16
+ // pre-seed the tick-off on a live PR: were `acknowledged_at` set early, the moment the PR later settled
17
+ // the VIEW would drop it straight into History, skipping the operator dismiss this op exists to
18
+ // require. 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";
22
+ import { defineOperation } from "../nano-generated/operations.ts";
23
+
24
+ const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
25
+
26
+ export default defineOperation("acknowledgePr", async ({ body }, app) => {
27
+ if (!body || typeof body !== "object") {
28
+ app.log.warn("acknowledge-pr rejected: missing request body");
29
+ return { status: 400, body: { ok: false, error: "pr_key is required" } };
30
+ }
31
+
32
+ const prKey = str(body.pr_key);
33
+ if (!prKey) return { status: 400, body: { ok: false, error: "pr_key is required" } };
34
+
35
+ const table = app.data.table<{ pr_key: string; status: string; acknowledged_at: string | null; updated_at: string }>(
36
+ "pull_requests",
37
+ "pr_key",
38
+ );
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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.166.0",
3
+ "version": "0.167.1",
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
  };