@nanobpm/nano-workforce 0.166.0 → 0.167.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/app/backfillAcknowledgedAt.test.ts +121 -0
- package/app/delivery.ts +15 -13
- package/app/deliveryGraphReadModel.test.ts +44 -8
- package/app/deliveryGraphReadModel.ts +19 -2
- package/app/deliveryGraphRun.ts +5 -0
- package/app/epicBucket.test.ts +9 -3
- package/app/featureReadModel.ts +7 -11
- package/app/listBucket.ts +86 -0
- package/app/planReadModel.test.ts +29 -19
- package/app/planReadModel.ts +32 -26
- package/app/pullRequestReadModel.test.ts +208 -0
- package/app/pullRequestReadModel.ts +76 -0
- package/db/migrations/093_pull_requests_acknowledged_at.sql +30 -0
- package/db/migrations/094_pull_requests_read_model.sql +65 -0
- package/db/migrations/095_delivery_graph_acknowledged_at.sql +24 -0
- package/db/migrations/096_delivery_graph_read_model_list_bucket.sql +63 -0
- package/db/migrations/097_plan_read_model_terminal_dismiss.sql +65 -0
- package/db/migrations/098_delivery_graph_units_acknowledged_at.sql +63 -0
- package/openapi.yaml +98 -0
- package/operations/acknowledgeDeliveryGraph.test.ts +93 -0
- package/operations/acknowledgeDeliveryGraph.ts +58 -0
- package/operations/acknowledgePr.test.ts +94 -0
- package/operations/acknowledgePr.ts +62 -0
- package/package.json +1 -1
- package/pages/delivery-graphs.page.json +12 -3
- package/pages/home.page.json +18 -27
- package/pages/overview.page.json +26 -17
- package/scripts/pages-contract.test.ts +80 -18
- package/workers/record-results/worker.test.ts +5 -3
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
-- Delivery Graphs: carry the new `acknowledged_at` dismissal stamp (095) through the `delivery_units`
|
|
2
|
+
-- aggregate (issue #641). Migration 095 added `acknowledged_at` to the base `delivery_graph_runs`
|
|
3
|
+
-- table, but its projection onto the ADR "declare-once" `delivery_units` aggregate (088-091) predates
|
|
4
|
+
-- the column: the delivery-graph sync triggers (089) never copied it and the `delivery_graph_runs__units`
|
|
5
|
+
-- compat VIEW (091) never re-exported it. That broke the aggregate's compat-view parity invariant
|
|
6
|
+
-- (`delivery_graph_runs__units` must be byte-identical to `delivery_graph_runs`), and — since the
|
|
7
|
+
-- aggregate is the S9 unified store the feature/epic surfaces already read `acknowledged_at`/`list_bucket`
|
|
8
|
+
-- from — left the delivery-graph unit rows without the dismissal stamp the other kinds carry.
|
|
9
|
+
--
|
|
10
|
+
-- This migration supersedes the two delivery-graph WRITE triggers (INSERT/UPDATE) to also project
|
|
11
|
+
-- `NEW.acknowledged_at`, re-creates the `delivery_graph_runs__units` compat VIEW to re-export it, and
|
|
12
|
+
-- backfills the stamp onto the existing aggregate rows (095's backfill fired the OLD trigger, which
|
|
13
|
+
-- dropped `acknowledged_at`, so the aggregate must be re-synced from the base once here). The feature
|
|
14
|
+
-- and epic triggers/views already project `acknowledged_at` (089/091) and are untouched; the DELETE
|
|
15
|
+
-- trigger is column-agnostic and is left as-is.
|
|
16
|
+
--
|
|
17
|
+
-- Migrations are forward-only and immutable once merged, so 089/091 are NOT edited — the triggers/VIEW
|
|
18
|
+
-- are DROPped and re-created here (numbered after 097). The runner wraps each file in its own
|
|
19
|
+
-- transaction — no BEGIN/COMMIT.
|
|
20
|
+
|
|
21
|
+
DROP TRIGGER IF EXISTS delivery_graph_runs__du_ai;
|
|
22
|
+
CREATE TRIGGER delivery_graph_runs__du_ai AFTER INSERT ON delivery_graph_runs
|
|
23
|
+
BEGIN
|
|
24
|
+
INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, process_key, process_definition_id, digest, status, side_effecting, node_count, human_node_count, side_effect_count, title, phase, phase_node_id, human_labels, acknowledged_at, created_at, updated_at)
|
|
25
|
+
VALUES ('delivery-graph:' || NEW.run_key, 'delivery-graph', NEW.run_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'awaiting-approval' THEN 'requested' WHEN NEW.status = 'running' THEN 'running' WHEN NEW.status = 'done' THEN 'done' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('awaiting-approval') THEN 'pending' WHEN NEW.status IN ('done', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('running') THEN 'dispatched' ELSE NULL END, NEW.process_key, NEW.process_definition_id, NEW.digest, NEW.status, NEW.side_effecting, NEW.node_count, NEW.human_node_count, NEW.side_effect_count, NEW.title, NEW.phase, NEW.phase_node_id, NEW.human_labels, NEW.acknowledged_at, NEW.created_at, NEW.updated_at);
|
|
26
|
+
END;
|
|
27
|
+
|
|
28
|
+
DROP TRIGGER IF EXISTS delivery_graph_runs__du_au;
|
|
29
|
+
CREATE TRIGGER delivery_graph_runs__du_au AFTER UPDATE ON delivery_graph_runs
|
|
30
|
+
BEGIN
|
|
31
|
+
INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, process_key, process_definition_id, digest, status, side_effecting, node_count, human_node_count, side_effect_count, title, phase, phase_node_id, human_labels, acknowledged_at, created_at, updated_at)
|
|
32
|
+
VALUES ('delivery-graph:' || NEW.run_key, 'delivery-graph', NEW.run_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'awaiting-approval' THEN 'requested' WHEN NEW.status = 'running' THEN 'running' WHEN NEW.status = 'done' THEN 'done' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('awaiting-approval') THEN 'pending' WHEN NEW.status IN ('done', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('running') THEN 'dispatched' ELSE NULL END, NEW.process_key, NEW.process_definition_id, NEW.digest, NEW.status, NEW.side_effecting, NEW.node_count, NEW.human_node_count, NEW.side_effect_count, NEW.title, NEW.phase, NEW.phase_node_id, NEW.human_labels, NEW.acknowledged_at, NEW.created_at, NEW.updated_at);
|
|
33
|
+
END;
|
|
34
|
+
|
|
35
|
+
DROP VIEW IF EXISTS delivery_graph_runs__units;
|
|
36
|
+
CREATE VIEW delivery_graph_runs__units AS
|
|
37
|
+
SELECT
|
|
38
|
+
du.legacy_key AS run_key,
|
|
39
|
+
du.process_key AS process_key,
|
|
40
|
+
du.process_definition_id AS process_definition_id,
|
|
41
|
+
du.digest AS digest,
|
|
42
|
+
du.status AS status,
|
|
43
|
+
du.side_effecting AS side_effecting,
|
|
44
|
+
du.node_count AS node_count,
|
|
45
|
+
du.human_node_count AS human_node_count,
|
|
46
|
+
du.side_effect_count AS side_effect_count,
|
|
47
|
+
du.title AS title,
|
|
48
|
+
du.phase AS phase,
|
|
49
|
+
du.phase_node_id AS phase_node_id,
|
|
50
|
+
du.human_labels AS human_labels,
|
|
51
|
+
du.acknowledged_at AS acknowledged_at,
|
|
52
|
+
du.created_at AS created_at,
|
|
53
|
+
du.updated_at AS updated_at
|
|
54
|
+
FROM delivery_units du
|
|
55
|
+
WHERE du.kind = 'delivery-graph';
|
|
56
|
+
|
|
57
|
+
-- Re-sync the stamp onto the existing aggregate rows (095's backfill fired the OLD, column-dropping
|
|
58
|
+
-- trigger). Keyed by the aggregate's `legacy_key` = the run's `run_key`.
|
|
59
|
+
UPDATE delivery_units
|
|
60
|
+
SET acknowledged_at = (
|
|
61
|
+
SELECT d.acknowledged_at FROM delivery_graph_runs d WHERE d.run_key = delivery_units.legacy_key
|
|
62
|
+
)
|
|
63
|
+
WHERE kind = 'delivery-graph';
|
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.
|
|
3
|
+
"version": "0.167.0",
|
|
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",
|
|
@@ -120,11 +120,11 @@
|
|
|
120
120
|
"source": "app",
|
|
121
121
|
"table": "delivery_graph_read_model",
|
|
122
122
|
"orderBy": { "field": "updated_at", "dir": "desc" },
|
|
123
|
-
"filter": [{ "field": "
|
|
123
|
+
"filter": [{ "field": "list_bucket", "in": ["active"] }]
|
|
124
124
|
},
|
|
125
125
|
"tabs": [
|
|
126
|
-
{ "label": "In-flight", "filter": [{ "field": "
|
|
127
|
-
{ "label": "History", "filter": [{ "field": "
|
|
126
|
+
{ "label": "In-flight", "filter": [{ "field": "list_bucket", "in": ["active"] }] },
|
|
127
|
+
{ "label": "History", "filter": [{ "field": "list_bucket", "in": ["history"] }] },
|
|
128
128
|
{ "label": "All", "filter": [] }
|
|
129
129
|
],
|
|
130
130
|
"columns": [
|
|
@@ -171,6 +171,15 @@
|
|
|
171
171
|
"path": "/app/api/actions/delivery-graph/library/save",
|
|
172
172
|
"body": { "name": "{{row.title}}", "digest": "{{row.digest}}" }
|
|
173
173
|
}
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
"label": "Dismiss",
|
|
177
|
+
"confirm": "Dismiss this finished delivery graph? It acknowledges the terminal run and files it under History.",
|
|
178
|
+
"showWhenField": "ack_open",
|
|
179
|
+
"action": {
|
|
180
|
+
"path": "/app/api/actions/acknowledge-delivery-graph",
|
|
181
|
+
"body": { "run_key": "{{row.run_key}}" }
|
|
182
|
+
}
|
|
174
183
|
}
|
|
175
184
|
]
|
|
176
185
|
}
|
package/pages/home.page.json
CHANGED
|
@@ -119,23 +119,15 @@
|
|
|
119
119
|
"data": {
|
|
120
120
|
"kind": "datasource",
|
|
121
121
|
"source": "app",
|
|
122
|
-
"table": "
|
|
122
|
+
"table": "pull_requests_read_model",
|
|
123
123
|
"orderBy": {
|
|
124
124
|
"field": "updated_at",
|
|
125
125
|
"dir": "desc"
|
|
126
126
|
},
|
|
127
127
|
"filter": [
|
|
128
128
|
{
|
|
129
|
-
"field": "
|
|
130
|
-
"in": [
|
|
131
|
-
"converging",
|
|
132
|
-
"waiting_review",
|
|
133
|
-
"escalated",
|
|
134
|
-
"waiting_deps",
|
|
135
|
-
"waiting_merge",
|
|
136
|
-
"queued",
|
|
137
|
-
"merging"
|
|
138
|
-
]
|
|
129
|
+
"field": "list_bucket",
|
|
130
|
+
"in": ["active"]
|
|
139
131
|
}
|
|
140
132
|
]
|
|
141
133
|
},
|
|
@@ -144,16 +136,8 @@
|
|
|
144
136
|
"label": "Active",
|
|
145
137
|
"filter": [
|
|
146
138
|
{
|
|
147
|
-
"field": "
|
|
148
|
-
"in": [
|
|
149
|
-
"converging",
|
|
150
|
-
"waiting_review",
|
|
151
|
-
"escalated",
|
|
152
|
-
"waiting_deps",
|
|
153
|
-
"waiting_merge",
|
|
154
|
-
"queued",
|
|
155
|
-
"merging"
|
|
156
|
-
]
|
|
139
|
+
"field": "list_bucket",
|
|
140
|
+
"in": ["active"]
|
|
157
141
|
}
|
|
158
142
|
]
|
|
159
143
|
},
|
|
@@ -161,12 +145,8 @@
|
|
|
161
145
|
"label": "History",
|
|
162
146
|
"filter": [
|
|
163
147
|
{
|
|
164
|
-
"field": "
|
|
165
|
-
"in": [
|
|
166
|
-
"converged",
|
|
167
|
-
"merged",
|
|
168
|
-
"abandoned"
|
|
169
|
-
]
|
|
148
|
+
"field": "list_bucket",
|
|
149
|
+
"in": ["history"]
|
|
170
150
|
}
|
|
171
151
|
]
|
|
172
152
|
}
|
|
@@ -224,6 +204,17 @@
|
|
|
224
204
|
"processInstanceKey": "{{row.process_key}}"
|
|
225
205
|
}
|
|
226
206
|
}
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
"label": "Dismiss",
|
|
210
|
+
"confirm": "Dismiss this finished PR? It acknowledges the terminal pull request and files it under History.",
|
|
211
|
+
"showWhenField": "ack_open",
|
|
212
|
+
"action": {
|
|
213
|
+
"path": "/app/api/actions/acknowledge-pr",
|
|
214
|
+
"body": {
|
|
215
|
+
"pr_key": "{{row.pr_key}}"
|
|
216
|
+
}
|
|
217
|
+
}
|
|
227
218
|
}
|
|
228
219
|
],
|
|
229
220
|
"detail": {
|