@nanobpm/nano-workforce 0.167.1 → 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.
- package/CHANGELOG.md +6 -0
- package/app/pullRequestReadModel.ts +6 -1
- package/operations/acknowledgePr.test.ts +64 -19
- package/operations/acknowledgePr.ts +34 -16
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [0.167.2](https://github.com/nanobpm/nano-workforce/compare/v0.167.1...v0.167.2) (2026-08-31)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* gate acknowledgePr on the read model's folded status, not the base column ([#653](https://github.com/nanobpm/nano-workforce/issues/653)) ([80446e2](https://github.com/nanobpm/nano-workforce/commit/80446e24af3c62570c041a109eef85eb34f8bb23)), closes [#652](https://github.com/nanobpm/nano-workforce/issues/652)
|
|
6
|
+
|
|
1
7
|
## [0.167.1](https://github.com/nanobpm/nano-workforce/compare/v0.167.0...v0.167.1) (2026-08-31)
|
|
2
8
|
|
|
3
9
|
### Bug Fixes
|
|
@@ -22,6 +22,11 @@
|
|
|
22
22
|
import { defineReadModel, type Expr, type ReadModel } from "@nanobpm/urban";
|
|
23
23
|
import { deriveAckOpenExpr, deriveListBucketExpr } from "./listBucket.ts";
|
|
24
24
|
|
|
25
|
+
/** The read model's VIEW name — the single source of truth for the table an operation queries when it
|
|
26
|
+
* must gate on the folded/effective status (e.g. `acknowledgePr`, issue #652) rather than re-derive
|
|
27
|
+
* terminality off the frozen base `pull_requests.status` column. */
|
|
28
|
+
export const PULL_REQUEST_READ_MODEL_NAME = "pull_requests_read_model";
|
|
29
|
+
|
|
25
30
|
/** The base table the read model reads: the auto-provisioned `pull_requests__tracking` derived VIEW
|
|
26
31
|
* (ADR-0065), NOT the raw `pull_requests` table — so the status-classifying derivations read the
|
|
27
32
|
* terminal-folded `derived_status` and a terminated PR drops to History with no worker write. */
|
|
@@ -66,7 +71,7 @@ export type PullRequestReadModelDerivedColumn = (typeof PULL_REQUEST_READ_MODEL_
|
|
|
66
71
|
* generated from THIS single declaration.
|
|
67
72
|
*/
|
|
68
73
|
export const pullRequestReadModel: ReadModel = defineReadModel({
|
|
69
|
-
name:
|
|
74
|
+
name: PULL_REQUEST_READ_MODEL_NAME,
|
|
70
75
|
baseTable: PULL_REQUEST_READ_MODEL_BASE_TABLE,
|
|
71
76
|
selectBaseColumns: false,
|
|
72
77
|
derive: {
|
|
@@ -1,25 +1,45 @@
|
|
|
1
|
-
// Tests for the POST /app/api/actions/acknowledge-pr operation `acknowledgePr` (issue #641).
|
|
1
|
+
// Tests for the POST /app/api/actions/acknowledge-pr operation `acknowledgePr` (issue #641, #652).
|
|
2
2
|
// The nwf UI's "Dismiss" (Done ✓) affordance for a TERMINAL pull request. It stamps `acknowledged_at`,
|
|
3
3
|
// which the `pull_requests_read_model` VIEW (094) derives into `list_bucket` = 'history' and `ack_open`
|
|
4
4
|
// = 0, dropping the finished PR from the Active convergence list into History. Unlike acknowledge-
|
|
5
5
|
// blocked it completes NO user task (a terminal PR is not parked). The PR twin of acknowledge-done /
|
|
6
6
|
// acknowledge-epic.
|
|
7
7
|
//
|
|
8
|
-
// The op's only write is the `acknowledged_at` (+ `updated_at`) stamp
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
8
|
+
// The op's only write is the `acknowledged_at` (+ `updated_at`) stamp on the base `pull_requests` row,
|
|
9
|
+
// gated on the TERMINAL tier (`PR_TERMINAL_STATUSES`) — but read off the `pull_requests_read_model`
|
|
10
|
+
// VIEW's FOLDED/effective `status` (issue #652), the SAME source of truth the Dismiss affordance
|
|
11
|
+
// (`ack_open`) derives from, NOT the frozen base column. These tests seed an in-memory read model +
|
|
12
|
+
// base store and assert the 400/404/409/200 gate + the stamp; the bucket derivation itself is proven
|
|
13
|
+
// in app/pullRequestReadModel.test.ts.
|
|
12
14
|
import { test } from "node:test";
|
|
13
15
|
import { assertEquals } from "#test-assert";
|
|
14
16
|
import type { AppApi } from "@nanobpm/urban";
|
|
15
17
|
import { noopLog } from "../test/log.ts";
|
|
16
|
-
import { PR_TERMINAL_STATUSES } from "../app/pullRequestReadModel.ts";
|
|
18
|
+
import { PR_TERMINAL_STATUSES, PULL_REQUEST_READ_MODEL_NAME } from "../app/pullRequestReadModel.ts";
|
|
17
19
|
import handler from "./acknowledgePr.ts";
|
|
18
20
|
|
|
21
|
+
// A read model row (folded/effective `status` the op gates on) and its base `pull_requests` row (the
|
|
22
|
+
// stamp target). `derivedStatus` folds over `status` exactly as `COALESCE(pr.derived_status, pr.status)`
|
|
23
|
+
// does in the 094 VIEW, so a seed can model an out-of-band-terminated PR (base `escalated`, folded
|
|
24
|
+
// `abandoned`) — the #652 drift case.
|
|
25
|
+
type Seed = { prKey: string; status: string; derivedStatus?: string; acknowledgedAt?: string | null };
|
|
26
|
+
|
|
19
27
|
// biome-ignore lint/suspicious/noExplicitAny: test-only fake data layer over dynamic row shapes.
|
|
20
|
-
function memApp(
|
|
28
|
+
function memApp(seeds: Seed[]): { app: AppApi; base: any[] } {
|
|
29
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only store.
|
|
30
|
+
const base: any[] = seeds.map((s) => ({
|
|
31
|
+
pr_key: s.prKey,
|
|
32
|
+
status: s.status,
|
|
33
|
+
acknowledged_at: s.acknowledgedAt ?? null,
|
|
34
|
+
}));
|
|
35
|
+
// The read model VIEW exposes the folded effective status as `status` (COALESCE(derived_status, status)).
|
|
36
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only store.
|
|
37
|
+
const readModel: any[] = seeds.map((s) => ({
|
|
38
|
+
pr_key: s.prKey,
|
|
39
|
+
status: s.derivedStatus ?? s.status,
|
|
40
|
+
}));
|
|
21
41
|
// biome-ignore lint/suspicious/noExplicitAny: test-only store.
|
|
22
|
-
const stores: Record<string, any[]> = { pull_requests:
|
|
42
|
+
const stores: Record<string, any[]> = { pull_requests: base, [PULL_REQUEST_READ_MODEL_NAME]: readModel };
|
|
23
43
|
function tbl(name: string, pk = "id") {
|
|
24
44
|
// biome-ignore lint/suspicious/noExplicitAny: test-only.
|
|
25
45
|
const rows = (stores[name] ??= [] as any[]);
|
|
@@ -40,7 +60,7 @@ function memApp(seed: any[]): { app: AppApi; rows: any[] } {
|
|
|
40
60
|
data: { table: (n: string, pk?: string) => tbl(n, pk) },
|
|
41
61
|
log: noopLog(),
|
|
42
62
|
} as unknown as AppApi;
|
|
43
|
-
return { app,
|
|
63
|
+
return { app, base };
|
|
44
64
|
}
|
|
45
65
|
|
|
46
66
|
async function call(app: AppApi, body: unknown) {
|
|
@@ -49,29 +69,54 @@ async function call(app: AppApi, body: unknown) {
|
|
|
49
69
|
}
|
|
50
70
|
|
|
51
71
|
test("acknowledge-pr: stamps acknowledged_at on a terminal PR and returns 200", async () => {
|
|
52
|
-
const { app,
|
|
72
|
+
const { app, base } = memApp([{ prKey: "o/r#1", status: "merged" }]);
|
|
53
73
|
const res = await call(app, { pr_key: "o/r#1" });
|
|
54
74
|
assertEquals(res.status, 200);
|
|
55
75
|
assertEquals(res.body.ok, true);
|
|
56
|
-
assertEquals(typeof
|
|
57
|
-
assertEquals(typeof
|
|
76
|
+
assertEquals(typeof base[0].acknowledged_at, "string");
|
|
77
|
+
assertEquals(typeof base[0].updated_at, "string");
|
|
58
78
|
});
|
|
59
79
|
|
|
60
80
|
test("acknowledge-pr: every terminal status is dismissable", async () => {
|
|
61
81
|
for (const status of PR_TERMINAL_STATUSES) {
|
|
62
|
-
const { app,
|
|
82
|
+
const { app, base } = memApp([{ prKey: "o/r#1", status }]);
|
|
63
83
|
const res = await call(app, { pr_key: "o/r#1" });
|
|
64
84
|
assertEquals(res.status, 200, `status ${status} must be dismissable`);
|
|
65
|
-
assertEquals(typeof
|
|
85
|
+
assertEquals(typeof base[0].acknowledged_at, "string");
|
|
66
86
|
}
|
|
67
87
|
});
|
|
68
88
|
|
|
89
|
+
// Issue #652 — the drift case. A PR terminated OUT OF BAND leaves base `status` frozen at its last
|
|
90
|
+
// transient (`escalated`) while the tracking VIEW folds `derived_status` to `abandoned`, so the read
|
|
91
|
+
// model reports the effective `status = abandoned` and lights `ack_open`. Gating on the base column
|
|
92
|
+
// (the old bug) 409'd "pull request is not terminal" on exactly the row the UI shows Dismiss on;
|
|
93
|
+
// gating on the folded read-model status accepts it.
|
|
94
|
+
test("acknowledge-pr: an out-of-band-terminated PR (base escalated, folded abandoned) is dismissable (200)", async () => {
|
|
95
|
+
const { app, base } = memApp([{ prKey: "o/r#1716", status: "escalated", derivedStatus: "abandoned" }]);
|
|
96
|
+
const res = await call(app, { pr_key: "o/r#1716" });
|
|
97
|
+
assertEquals(res.status, 200);
|
|
98
|
+
assertEquals(res.body.ok, true);
|
|
99
|
+
assertEquals(typeof base[0].acknowledged_at, "string");
|
|
100
|
+
// The base row's frozen transient is NOT rewritten — only the acknowledgement is stamped; the VIEW
|
|
101
|
+
// folds it to History (list_bucket = 'history', ack_open = 0) off derived_status + acknowledged_at.
|
|
102
|
+
assertEquals(base[0].status, "escalated");
|
|
103
|
+
});
|
|
104
|
+
|
|
69
105
|
test("acknowledge-pr: a live (still-converging) PR is rejected (409) and stays unstamped", async () => {
|
|
70
|
-
const { app,
|
|
106
|
+
const { app, base } = memApp([{ prKey: "o/r#2", status: "converging" }]);
|
|
71
107
|
const res = await call(app, { pr_key: "o/r#2" });
|
|
72
108
|
assertEquals(res.status, 409);
|
|
73
109
|
assertEquals(res.body.ok, false);
|
|
74
|
-
assertEquals(
|
|
110
|
+
assertEquals(base[0].acknowledged_at, null);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// A genuinely live PR whose engine instance is still ACTIVE has `derived_status === status` (a live PR
|
|
114
|
+
// has no terminal fold), so the read model reports the live transient — still not terminal → 409.
|
|
115
|
+
test("acknowledge-pr: a live PR awaiting review (instance active, folded == base) is rejected (409)", async () => {
|
|
116
|
+
const { app, base } = memApp([{ prKey: "o/r#3", status: "waiting_review", derivedStatus: "waiting_review" }]);
|
|
117
|
+
const res = await call(app, { pr_key: "o/r#3" });
|
|
118
|
+
assertEquals(res.status, 409);
|
|
119
|
+
assertEquals(base[0].acknowledged_at, null);
|
|
75
120
|
});
|
|
76
121
|
|
|
77
122
|
test("acknowledge-pr: a missing pr_key → 400", async () => {
|
|
@@ -86,9 +131,9 @@ test("acknowledge-pr: no matching PR → 404", async () => {
|
|
|
86
131
|
});
|
|
87
132
|
|
|
88
133
|
test("acknowledge-pr: idempotent — re-acknowledging a terminal PR re-stamps and returns 200", async () => {
|
|
89
|
-
const { app,
|
|
134
|
+
const { app, base } = memApp([{ prKey: "o/r#5", status: "converged" }]);
|
|
90
135
|
assertEquals((await call(app, { pr_key: "o/r#5" })).status, 200);
|
|
91
|
-
assertEquals(typeof
|
|
136
|
+
assertEquals(typeof base[0].acknowledged_at, "string");
|
|
92
137
|
assertEquals((await call(app, { pr_key: "o/r#5" })).status, 200);
|
|
93
|
-
assertEquals(typeof
|
|
138
|
+
assertEquals(typeof base[0].acknowledged_at, "string");
|
|
94
139
|
});
|
|
@@ -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.
|
|
19
|
-
//
|
|
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
|
-
|
|
36
|
-
|
|
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
|
|
40
|
-
if (!
|
|
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
|
|
46
|
-
// `list_bucket`
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
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`
|
|
55
|
-
// `pull_requests_read_model` VIEW from the terminal, now-acknowledged row,
|
|
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.
|
|
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",
|