@nanobpm/nano-workforce 0.122.0 → 0.123.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 +7 -0
- package/app/delivery.test.ts +2 -134
- package/app/feature.ts +30 -128
- package/app/featureReadModel.test.ts +210 -0
- package/app/plan.ts +26 -96
- package/app/plansReadModel.test.ts +112 -5
- package/app/service.ts +2 -71
- package/db/migrations/073_feature_read_model.sql +80 -0
- package/db/migrations/074_plan_read_model_derive_bucket.sql +74 -0
- package/main.ts +5 -4
- package/operations/acknowledgeDone.test.ts +18 -16
- package/operations/acknowledgeDone.ts +10 -8
- package/operations/acknowledgeEpic.test.ts +24 -31
- package/operations/acknowledgeEpic.ts +9 -8
- package/package.json +1 -1
- package/pages/epic.page.json +1 -1
- package/pages/feature.page.json +1 -1
- package/workers/record-results/worker.test.ts +7 -4
- package/app/featureGateway.test.ts +0 -202
- package/app/planGateway.test.ts +0 -112
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
// Tests for the POST /app/api/actions/acknowledge-done operation `acknowledgeDone` (issue #254 §5).
|
|
2
|
-
// The nwf UI's "tick off" affordance for a TERMINAL feature run: it stamps `acknowledged_at
|
|
3
|
-
//
|
|
4
|
-
// History. Unlike acknowledgeBlocked it completes NO user task (a terminal run is
|
|
5
|
-
//
|
|
2
|
+
// The nwf UI's "tick off" affordance for a TERMINAL feature run: it stamps `acknowledged_at`, which
|
|
3
|
+
// the `feature_read_model` VIEW (073, issue #439) derives into `list_bucket` = 'history', dropping the
|
|
4
|
+
// run from Active into History. Unlike acknowledgeBlocked it completes NO user task (a terminal run is
|
|
5
|
+
// not parked). Since `list_bucket` is now a VIEW over `status`/`acknowledged_at` (no stored column),
|
|
6
|
+
// these tests assert the operation's real write — the `acknowledged_at` stamp — and cross-check the
|
|
7
|
+
// resulting bucket through the pure `deriveListBucket` oracle the VIEW mirrors.
|
|
6
8
|
import { test } from "node:test";
|
|
7
9
|
import { assertEquals } from "#test-assert";
|
|
8
10
|
import type { AppApi } from "@nanobpm/urban";
|
|
9
|
-
import {
|
|
11
|
+
import { deriveListBucket } from "../app/stage.ts";
|
|
10
12
|
import { noopLog } from "../test/log.ts";
|
|
11
13
|
import handler from "./acknowledgeDone.ts";
|
|
12
14
|
|
|
13
|
-
// An in-memory data layer wired through the
|
|
14
|
-
//
|
|
15
|
+
// An in-memory data layer wired through the `featureRuns` gateway (now a plain record table), so the
|
|
16
|
+
// test exercises the operation's write path exactly as production does.
|
|
15
17
|
function memApp(seed: any[]): { app: AppApi; rows: any[] } {
|
|
16
18
|
const stores: Record<string, any[]> = { feature_runs: seed };
|
|
17
19
|
function tbl(name: string, pk = "id") {
|
|
@@ -49,30 +51,30 @@ async function call(app: AppApi, body: unknown) {
|
|
|
49
51
|
return (await handler({ req: {} as any, params: {}, query: {}, body } as any, app)) as any;
|
|
50
52
|
}
|
|
51
53
|
|
|
52
|
-
test("acknowledge-done: stamps acknowledged_at and
|
|
54
|
+
test("acknowledge-done: stamps acknowledged_at and (via the VIEW) buckets a terminal row into History", async () => {
|
|
53
55
|
const { app, rows } = memApp([{ feature_key: "o/r#1", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: null }]);
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
assertEquals(rows[0].list_bucket, "active");
|
|
56
|
+
// Before dismissal a terminal-but-unacknowledged run reads as Active through the VIEW.
|
|
57
|
+
assertEquals(deriveListBucket(rows[0].status, rows[0].acknowledged_at), "active");
|
|
57
58
|
|
|
58
59
|
const res = await call(app, { feature_key: "o/r#1" });
|
|
59
60
|
|
|
60
61
|
assertEquals(res.status, 200);
|
|
61
62
|
assertEquals(res.body.ok, true);
|
|
62
63
|
assertEquals(typeof rows[0].acknowledged_at, "string");
|
|
63
|
-
|
|
64
|
+
// The op's only write is the stamp; the VIEW derives 'history' from (terminal status + acknowledged).
|
|
65
|
+
assertEquals(deriveListBucket(rows[0].status, rows[0].acknowledged_at), "history");
|
|
64
66
|
});
|
|
65
67
|
|
|
66
68
|
test("acknowledge-done: idempotent-safe — re-acknowledging keeps the row in History", async () => {
|
|
67
69
|
const { app, rows } = memApp([{ feature_key: "o/r#2", status: "failed", converge: 1, auto_merge: 1, acknowledged_at: null }]);
|
|
68
70
|
const first = await call(app, { feature_key: "o/r#2" });
|
|
69
71
|
assertEquals(first.status, 200);
|
|
70
|
-
assertEquals(rows[0].
|
|
72
|
+
assertEquals(deriveListBucket(rows[0].status, rows[0].acknowledged_at), "history");
|
|
71
73
|
const firstStamp = rows[0].acknowledged_at;
|
|
72
74
|
// Re-acknowledge — still 200, still history.
|
|
73
75
|
const second = await call(app, { feature_key: "o/r#2" });
|
|
74
76
|
assertEquals(second.status, 200);
|
|
75
|
-
assertEquals(rows[0].
|
|
77
|
+
assertEquals(deriveListBucket(rows[0].status, rows[0].acknowledged_at), "history");
|
|
76
78
|
assertEquals(typeof firstStamp, "string");
|
|
77
79
|
});
|
|
78
80
|
|
|
@@ -92,12 +94,12 @@ test("acknowledge-done: no such feature run → 404", async () => {
|
|
|
92
94
|
|
|
93
95
|
test("acknowledge-done: a non-terminal run → 409, no acknowledged_at stamped", async () => {
|
|
94
96
|
const { app, rows } = memApp([{ feature_key: "o/r#live", status: "running", converge: 1, auto_merge: 1, acknowledged_at: null }]);
|
|
95
|
-
await featureRuns(app.data).update("o/r#live", { status: "running" });
|
|
96
97
|
const res = await call(app, { feature_key: "o/r#live" });
|
|
97
98
|
assertEquals(res.status, 409);
|
|
98
99
|
assertEquals(res.body.ok, false);
|
|
99
100
|
assertEquals(rows[0].acknowledged_at, null);
|
|
100
|
-
|
|
101
|
+
// A live run stays Active through the VIEW (no stamp → not History).
|
|
102
|
+
assertEquals(deriveListBucket(rows[0].status, rows[0].acknowledged_at), "active");
|
|
101
103
|
});
|
|
102
104
|
|
|
103
105
|
test("acknowledge-done: a converging (redispatch-terminal but live) run → 409", async () => {
|
|
@@ -3,13 +3,15 @@
|
|
|
3
3
|
// run (Done ✓ / Done ✕) directly from the Feature / Overview pages so it drops out of the primary
|
|
4
4
|
// Active list into History. It is the DONE twin of `acknowledgeBlocked` — but a terminal run is NOT
|
|
5
5
|
// parked at a user task, so this op does NOT complete a user task and touches no engine/ledger: it
|
|
6
|
-
// simply stamps `acknowledged_at` on the row via the feature_runs
|
|
6
|
+
// simply stamps `acknowledged_at` on the row via the plain `feature_runs` record table (the projecting
|
|
7
|
+
// gateway this PR retired). It rejects (409) a run that
|
|
7
8
|
// is not yet truly terminal, so it can never pre-seed the tick-off on a still-live run.
|
|
8
9
|
//
|
|
9
|
-
// The
|
|
10
|
-
// `
|
|
11
|
-
//
|
|
12
|
-
//
|
|
10
|
+
// The `list_bucket` partition is DERIVED by the `feature_read_model` VIEW (073, issue #439) from
|
|
11
|
+
// `status` + `acknowledged_at` — a terminal row with `acknowledged_at` set reads as 'history' — so
|
|
12
|
+
// this op NEVER writes `list_bucket` (or any projection): stamping `acknowledged_at` is the whole
|
|
13
|
+
// contract. Keyed on the row's `feature_key`. Idempotent-safe: re-acknowledging simply re-stamps the
|
|
14
|
+
// timestamp and keeps the row in History.
|
|
13
15
|
|
|
14
16
|
import { featureRuns } from "../app/feature.ts";
|
|
15
17
|
import { STAGE_DONE_STATUSES } from "../app/stage.ts";
|
|
@@ -42,9 +44,9 @@ export default defineOperation("acknowledgeDone", async ({ body }, app) => {
|
|
|
42
44
|
return { status: 409, body: { ok: false, error: "feature run is not terminal" } };
|
|
43
45
|
}
|
|
44
46
|
|
|
45
|
-
// Stamp the dismissal.
|
|
46
|
-
// terminal
|
|
47
|
-
// History.
|
|
47
|
+
// Stamp the dismissal. `list_bucket` is derived by the `feature_read_model` VIEW (→ 'history' for a
|
|
48
|
+
// terminal, acknowledged row), so we never hand-set it here. Idempotent: re-acknowledging re-stamps
|
|
49
|
+
// and stays in History.
|
|
48
50
|
const now = new Date().toISOString();
|
|
49
51
|
await runs.update(featureKey, { acknowledged_at: now, updated_at: now });
|
|
50
52
|
|
|
@@ -1,24 +1,27 @@
|
|
|
1
1
|
// Tests for the POST /app/api/actions/acknowledge-epic operation `acknowledgeEpic` (issue #298).
|
|
2
2
|
// The nwf UI's "Dismiss" affordance for a RESOLVED epic — landed (delivery=landed) or
|
|
3
3
|
// resolved-not-landed (delivery=null); only still-`converging` epics are rejected. It stamps
|
|
4
|
-
// `acknowledged_at
|
|
5
|
-
// `ack_open`
|
|
6
|
-
// it completes NO user task (a resolved epic is not parked). The epic twin of
|
|
4
|
+
// `acknowledged_at`, which the `plan_read_model` VIEW (074, issue #439) derives into `list_bucket` =
|
|
5
|
+
// 'history' and `ack_open` = 0, dropping the resolved epic from Active into History. Unlike
|
|
6
|
+
// acknowledge-blocked it completes NO user task (a resolved epic is not parked). The epic twin of
|
|
7
|
+
// acknowledge-done.
|
|
7
8
|
//
|
|
8
9
|
// Since epic #412 retired the stored `plans.delivery` column, the op derives the delivery signal at
|
|
9
10
|
// READ TIME (`derivePlanDelivery` → the pure `deriveDelivery`) by joining the epic's slice
|
|
10
11
|
// `plan_tasks.pr_key` → `pull_requests.status`. So these tests seed `plan_tasks` + `pull_requests`
|
|
11
|
-
// (not a `plans.delivery` column) to model a landed / converging / resolved-not-landed epic.
|
|
12
|
+
// (not a `plans.delivery` column) to model a landed / converging / resolved-not-landed epic. Because
|
|
13
|
+
// `list_bucket`/`ack_open` are now a VIEW (no stored column), the tests assert the operation's real
|
|
14
|
+
// write — the `acknowledged_at` stamp and the 200/409 gate — and cross-check the resulting bucket
|
|
15
|
+
// through the pure `deriveEpicBucket` / `epicIsAcknowledgeable` oracles the VIEW mirrors.
|
|
12
16
|
import { test } from "node:test";
|
|
13
17
|
import { assertEquals } from "#test-assert";
|
|
14
18
|
import type { AppApi } from "@nanobpm/urban";
|
|
15
|
-
import {
|
|
19
|
+
import { deriveEpicBucket, epicIsAcknowledgeable } from "../app/delivery.ts";
|
|
16
20
|
import { noopLog } from "../test/log.ts";
|
|
17
21
|
import handler from "./acknowledgeEpic.ts";
|
|
18
22
|
|
|
19
|
-
// An in-memory data layer wired through the
|
|
20
|
-
//
|
|
21
|
-
// surfaces (`plan_tasks` / `pull_requests`) the read-time delivery derivation reads.
|
|
23
|
+
// An in-memory data layer wired through the `plans` gateway (now a plain record table). `extra` seeds
|
|
24
|
+
// the join surfaces (`plan_tasks` / `pull_requests`) the read-time delivery derivation reads.
|
|
22
25
|
function memApp(
|
|
23
26
|
seed: any[],
|
|
24
27
|
extra: Record<string, any[]> = {},
|
|
@@ -59,13 +62,7 @@ async function call(app: AppApi, body: unknown) {
|
|
|
59
62
|
return (await handler({ req: {} as any, params: {}, query: {}, body } as any, app)) as any;
|
|
60
63
|
}
|
|
61
64
|
|
|
62
|
-
|
|
63
|
-
* delivery-free gateway projection, mirroring the last real write before the op runs. */
|
|
64
|
-
async function project(app: AppApi, key: string, status: string) {
|
|
65
|
-
await plans(app.data).update(key, { status });
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
test("acknowledge-epic: stamps acknowledged_at and flips list_bucket to 'history' on a landed epic", async () => {
|
|
65
|
+
test("acknowledge-epic: stamps acknowledged_at and (via the VIEW) buckets a landed epic into History", async () => {
|
|
69
66
|
const { app, rows } = memApp(
|
|
70
67
|
[{ plan_key: "o/r#1", status: "done", acknowledged_at: null }],
|
|
71
68
|
{
|
|
@@ -73,17 +70,17 @@ test("acknowledge-epic: stamps acknowledged_at and flips list_bucket to 'history
|
|
|
73
70
|
pull_requests: [{ pr_key: "o/r#100", status: "merged" }], // landed
|
|
74
71
|
},
|
|
75
72
|
);
|
|
76
|
-
|
|
77
|
-
assertEquals(rows[0].
|
|
78
|
-
assertEquals(
|
|
73
|
+
// Before dismissal a landed-but-unacknowledged epic reads as Active with Dismiss open through the VIEW.
|
|
74
|
+
assertEquals(deriveEpicBucket("done", "landed", rows[0].acknowledged_at), "active");
|
|
75
|
+
assertEquals(epicIsAcknowledgeable("done", "landed"), true);
|
|
79
76
|
|
|
80
77
|
const res = await call(app, { plan_key: "o/r#1" });
|
|
81
78
|
|
|
82
79
|
assertEquals(res.status, 200);
|
|
83
80
|
assertEquals(res.body.ok, true);
|
|
84
81
|
assertEquals(typeof rows[0].acknowledged_at, "string");
|
|
85
|
-
|
|
86
|
-
assertEquals(rows[0].
|
|
82
|
+
// The op's only write is the stamp; the VIEW derives 'history' + ack_open 0 from the acknowledged row.
|
|
83
|
+
assertEquals(deriveEpicBucket("done", "landed", rows[0].acknowledged_at), "history");
|
|
87
84
|
});
|
|
88
85
|
|
|
89
86
|
test("acknowledge-epic: a still-converging epic is rejected (409) and stays Active", async () => {
|
|
@@ -100,15 +97,14 @@ test("acknowledge-epic: a still-converging epic is rejected (409) and stays Acti
|
|
|
100
97
|
],
|
|
101
98
|
},
|
|
102
99
|
);
|
|
103
|
-
await project(app, "o/r#2", "done");
|
|
104
100
|
|
|
105
101
|
const res = await call(app, { plan_key: "o/r#2" });
|
|
106
102
|
|
|
107
103
|
assertEquals(res.status, 409);
|
|
108
104
|
assertEquals(res.body.ok, false);
|
|
109
|
-
// Untouched: no premature acknowledged_at
|
|
105
|
+
// Untouched: no premature acknowledged_at; still Active through the VIEW (converging → active).
|
|
110
106
|
assertEquals(rows[0].acknowledged_at, null);
|
|
111
|
-
assertEquals(rows[0].
|
|
107
|
+
assertEquals(deriveEpicBucket("done", "converging", rows[0].acknowledged_at), "active");
|
|
112
108
|
});
|
|
113
109
|
|
|
114
110
|
test("acknowledge-epic: a resolved-not-landed epic (delivery=null) is accepted (200) and flips to History", async () => {
|
|
@@ -125,17 +121,15 @@ test("acknowledge-epic: a resolved-not-landed epic (delivery=null) is accepted (
|
|
|
125
121
|
],
|
|
126
122
|
},
|
|
127
123
|
);
|
|
128
|
-
|
|
129
|
-
assertEquals(
|
|
130
|
-
assertEquals(rows[0].ack_open, 1);
|
|
124
|
+
assertEquals(deriveEpicBucket("done", null, rows[0].acknowledged_at), "active");
|
|
125
|
+
assertEquals(epicIsAcknowledgeable("done", null), true);
|
|
131
126
|
|
|
132
127
|
const res = await call(app, { plan_key: "o/r#2b" });
|
|
133
128
|
|
|
134
129
|
assertEquals(res.status, 200);
|
|
135
130
|
assertEquals(res.body.ok, true);
|
|
136
131
|
assertEquals(typeof rows[0].acknowledged_at, "string");
|
|
137
|
-
assertEquals(rows[0].
|
|
138
|
-
assertEquals(rows[0].ack_open, 0);
|
|
132
|
+
assertEquals(deriveEpicBucket("done", null, rows[0].acknowledged_at), "history");
|
|
139
133
|
});
|
|
140
134
|
|
|
141
135
|
test("acknowledge-epic: a live (dispatched) epic is rejected (409)", async () => {
|
|
@@ -164,15 +158,14 @@ test("acknowledge-epic: idempotent — re-acknowledging a landed epic keeps it i
|
|
|
164
158
|
pull_requests: [{ pr_key: "o/r#500", status: "merged" }], // landed
|
|
165
159
|
},
|
|
166
160
|
);
|
|
167
|
-
await project(app, "o/r#5", "done");
|
|
168
161
|
|
|
169
162
|
assertEquals((await call(app, { plan_key: "o/r#5" })).status, 200);
|
|
170
163
|
const firstStamp = rows[0].acknowledged_at;
|
|
171
|
-
assertEquals(rows[0].
|
|
164
|
+
assertEquals(deriveEpicBucket("done", "landed", rows[0].acknowledged_at), "history");
|
|
172
165
|
|
|
173
166
|
const res2 = await call(app, { plan_key: "o/r#5" });
|
|
174
167
|
assertEquals(res2.status, 200);
|
|
175
|
-
assertEquals(rows[0].
|
|
168
|
+
assertEquals(deriveEpicBucket("done", "landed", rows[0].acknowledged_at), "history");
|
|
176
169
|
// Re-stamped (a fresh timestamp) but still resolved.
|
|
177
170
|
assertEquals(typeof rows[0].acknowledged_at, "string");
|
|
178
171
|
void firstStamp;
|
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
// resolved-not-landed) directly from the Epic / Overview pages so it drops out of the Active epic list
|
|
5
5
|
// into History. It is the epic twin of `acknowledgeDone` (the feature-run tick-off) — a resolved epic
|
|
6
6
|
// 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 `plans` row
|
|
7
|
+
// simply stamps `acknowledged_at` on the `plans` row.
|
|
8
8
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// the
|
|
9
|
+
// `list_bucket`/`ack_open` are DERIVED by the `plan_read_model` VIEW (074, issue #439) from
|
|
10
|
+
// `status` + `acknowledged_at` + the derived `plan_delivery` signal — a landed, now-acknowledged epic
|
|
11
|
+
// reads `list_bucket` = 'history' and `ack_open` = 0 — so this op NEVER writes a derived projection.
|
|
12
|
+
// Keyed on the row's `plan_key`. Idempotent-safe: re-acknowledging re-stamps the timestamp and keeps
|
|
13
|
+
// the row in History.
|
|
13
14
|
//
|
|
14
15
|
// It rejects (409) an epic that is NOT yet resolved — i.e. anything the `epicIsAcknowledgeable`
|
|
15
16
|
// guard refuses: a non-`done` status (`planning`/`dispatched`), or `done` but still `converging`. A
|
|
@@ -56,9 +57,9 @@ export default defineOperation("acknowledgeEpic", async ({ body }, app) => {
|
|
|
56
57
|
return { status: 409, body: { ok: false, error: "epic is not resolved" } };
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
// Stamp the dismissal.
|
|
60
|
-
// the
|
|
61
|
-
// in History.
|
|
60
|
+
// Stamp the dismissal. `list_bucket` (→ 'history') and `ack_open` (→ 0) are derived by the
|
|
61
|
+
// `plan_read_model` VIEW from the resolved, now-acknowledged row, so we never hand-set them here.
|
|
62
|
+
// Idempotent: re-acknowledging re-stamps and stays in History.
|
|
62
63
|
const now = new Date().toISOString();
|
|
63
64
|
await table.update(planKey, { acknowledged_at: now, updated_at: now });
|
|
64
65
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.123.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",
|
package/pages/epic.page.json
CHANGED
package/pages/feature.page.json
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { test } from "node:test";
|
|
10
10
|
import { assertEquals, assertRejects } from "#test-assert";
|
|
11
11
|
import { BpmnError } from "@nanobpm/urban";
|
|
12
|
+
import { deriveEpicBucket } from "../../app/delivery.ts";
|
|
12
13
|
import { noopLog } from "../../test/log.ts";
|
|
13
14
|
import handler from "./worker.ts";
|
|
14
15
|
import type { PlanTaskStatus } from "../../app/plan.ts";
|
|
@@ -77,8 +78,9 @@ test("no opened PRs (empty plan) hard-fails with NO_WORK_DISPATCHED", async () =
|
|
|
77
78
|
const plan = app._plans.at(-1) as Record<string, unknown>;
|
|
78
79
|
assertEquals(plan.status, "failed");
|
|
79
80
|
assertEquals(plan.outcome, "no work dispatched — the planner produced no tasks");
|
|
80
|
-
//
|
|
81
|
-
|
|
81
|
+
// `list_bucket` is derived by the `plan_read_model` VIEW (074): a failed epic reads as History
|
|
82
|
+
// (no tick-off needed). Cross-checked against the pure `deriveEpicBucket` oracle the VIEW mirrors.
|
|
83
|
+
assertEquals(deriveEpicBucket(plan.status as string, null, plan.acknowledged_at as string | null), "history");
|
|
82
84
|
});
|
|
83
85
|
|
|
84
86
|
test("tasks present but none opened (all skipped/blocked) hard-fails", async () => {
|
|
@@ -102,6 +104,7 @@ test("at least one opened PR finalizes cleanly (no throw)", async () => {
|
|
|
102
104
|
const plan = app._plans.at(-1) as Record<string, unknown>;
|
|
103
105
|
assertEquals(plan.status, "done");
|
|
104
106
|
assertEquals(plan.outcome, "1 PR(s) dispatched to convergence");
|
|
105
|
-
// A just-`done` epic (delivery not yet
|
|
106
|
-
|
|
107
|
+
// A just-`done` epic (delivery not yet converging, unacknowledged) reads as Active through the VIEW —
|
|
108
|
+
// it must not vanish (#298). Cross-checked against the pure `deriveEpicBucket` oracle.
|
|
109
|
+
assertEquals(deriveEpicBucket(plan.status as string, null, plan.acknowledged_at as string | null), "active");
|
|
107
110
|
});
|
|
@@ -1,202 +0,0 @@
|
|
|
1
|
-
// Tests for the feature_runs gateway's write-time pipeline projection (issue #254 §1/§3) and the
|
|
2
|
-
// one-shot backfill. The gateway wraps the plain table so EVERY writer — no matter which module —
|
|
3
|
-
// automatically gets a fresh `stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket`
|
|
4
|
-
// projection, without passing them: the single write path is the only place `deriveStage` /
|
|
5
|
-
// `deriveListBucket` are applied.
|
|
6
|
-
import { test } from "node:test";
|
|
7
|
-
import { assert, assertEquals } from "#test-assert";
|
|
8
|
-
import type { DataLayer } from "@nanobpm/urban";
|
|
9
|
-
import { backfillFeatureStages, featureRuns } from "./feature.ts";
|
|
10
|
-
|
|
11
|
-
// An in-memory record gateway with the same semantics the real Table exposes (get/all/find/insert/
|
|
12
|
-
// update). The featureRuns proxy wraps whatever data.table returns, so this exercises the real proxy.
|
|
13
|
-
function memData(): { data: DataLayer; rows: any[] } {
|
|
14
|
-
const rows: any[] = [];
|
|
15
|
-
function tbl(_name: string, pk = "id") {
|
|
16
|
-
const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
|
|
17
|
-
return {
|
|
18
|
-
async all() {
|
|
19
|
-
return rows.slice();
|
|
20
|
-
},
|
|
21
|
-
async get(id: any) {
|
|
22
|
-
return rows.find((r) => r[pk] === id);
|
|
23
|
-
},
|
|
24
|
-
async find(where: any = {}) {
|
|
25
|
-
return rows.filter((r) => match(r, where));
|
|
26
|
-
},
|
|
27
|
-
async insert(row: any) {
|
|
28
|
-
rows.push({ ...row });
|
|
29
|
-
return row[pk];
|
|
30
|
-
},
|
|
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 data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
|
|
39
|
-
return { data, rows };
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
test("update with only {status:'opened'} projects PR open / null / active without the caller passing them", async () => {
|
|
43
|
-
const { data, rows } = memData();
|
|
44
|
-
rows.push({ feature_key: "o/r#1", status: "running", pr_key: null, converge: 1, auto_merge: 1 });
|
|
45
|
-
await featureRuns(data).update("o/r#1", { status: "opened" });
|
|
46
|
-
assertEquals(rows[0].stage, "PR open");
|
|
47
|
-
assertEquals(rows[0].stage_state, null);
|
|
48
|
-
assertEquals(rows[0].list_bucket, "active");
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
test("update {status:'failed'} projects Done / failed", async () => {
|
|
52
|
-
const { data, rows } = memData();
|
|
53
|
-
rows.push({ feature_key: "o/r#2", status: "running", converge: 1, auto_merge: 1 });
|
|
54
|
-
await featureRuns(data).update("o/r#2", { status: "failed" });
|
|
55
|
-
assertEquals(rows[0].stage, "Done");
|
|
56
|
-
assertEquals(rows[0].stage_state, "failed");
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
test("update {status:'blocked'} projects Done / blocked", async () => {
|
|
60
|
-
const { data, rows } = memData();
|
|
61
|
-
rows.push({ feature_key: "o/r#3", status: "running", converge: 1, auto_merge: 1 });
|
|
62
|
-
await featureRuns(data).update("o/r#3", { status: "blocked" });
|
|
63
|
-
assertEquals(rows[0].stage, "Done");
|
|
64
|
-
assertEquals(rows[0].stage_state, "blocked");
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
test("update {status:'escalated'} on a row with no pr_key projects Implementing / null", async () => {
|
|
68
|
-
const { data, rows } = memData();
|
|
69
|
-
rows.push({ feature_key: "o/r#4", status: "running", pr_key: null, converge: 1, auto_merge: 1 });
|
|
70
|
-
await featureRuns(data).update("o/r#4", { status: "escalated" });
|
|
71
|
-
assertEquals(rows[0].stage, "Implementing");
|
|
72
|
-
assertEquals(rows[0].stage_state, null);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
test("a run with converge=false projects stage_skipped containing Converging and Merging", async () => {
|
|
76
|
-
const { data, rows } = memData();
|
|
77
|
-
rows.push({ feature_key: "o/r#5", status: "running", converge: 0, auto_merge: 0 });
|
|
78
|
-
await featureRuns(data).update("o/r#5", { status: "running" });
|
|
79
|
-
assert(rows[0].stage_skipped.includes("Converging"));
|
|
80
|
-
assert(rows[0].stage_skipped.includes("Merging"));
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
test("insert projects the pipeline columns from status", async () => {
|
|
84
|
-
const { data, rows } = memData();
|
|
85
|
-
await featureRuns(data).insert({ feature_key: "o/r#6", status: "running", converge: 1, auto_merge: 1 } as any);
|
|
86
|
-
assertEquals(rows[0].stage, "Implementing");
|
|
87
|
-
assertEquals(rows[0].stage_state, null);
|
|
88
|
-
assertEquals(rows[0].list_bucket, "active");
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
test("setting acknowledged_at on a terminal row flips list_bucket to 'history'", async () => {
|
|
92
|
-
const { data, rows } = memData();
|
|
93
|
-
rows.push({ feature_key: "o/r#7", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: null });
|
|
94
|
-
// Terminal but unacknowledged → still Active.
|
|
95
|
-
await featureRuns(data).update("o/r#7", { status: "merged" });
|
|
96
|
-
assertEquals(rows[0].list_bucket, "active");
|
|
97
|
-
// Acknowledge → History.
|
|
98
|
-
await featureRuns(data).update("o/r#7", { acknowledged_at: "2024-01-01T00:00:00Z" });
|
|
99
|
-
assertEquals(rows[0].list_bucket, "history");
|
|
100
|
-
assertEquals(rows[0].stage, "Done");
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
test("update touching only projection-irrelevant fields skips the read-back and reproject", async () => {
|
|
104
|
-
const { data, rows } = memData();
|
|
105
|
-
rows.push({
|
|
106
|
-
feature_key: "o/r#skip",
|
|
107
|
-
status: "merged",
|
|
108
|
-
converge: 1,
|
|
109
|
-
auto_merge: 1,
|
|
110
|
-
acknowledged_at: null,
|
|
111
|
-
stage: "Done",
|
|
112
|
-
stage_state: "ok",
|
|
113
|
-
stage_skipped: "",
|
|
114
|
-
attention: null,
|
|
115
|
-
list_bucket: "active",
|
|
116
|
-
});
|
|
117
|
-
// Count get() calls to prove the projection-irrelevant path does no read-back roundtrip.
|
|
118
|
-
let gets = 0;
|
|
119
|
-
const raw = (data as any).table;
|
|
120
|
-
(data as any).table = (n: string, pk?: string) => {
|
|
121
|
-
const t = raw(n, pk);
|
|
122
|
-
const origGet = t.get;
|
|
123
|
-
t.get = async (id: any) => {
|
|
124
|
-
gets++;
|
|
125
|
-
return origGet.call(t, id);
|
|
126
|
-
};
|
|
127
|
-
return t;
|
|
128
|
-
};
|
|
129
|
-
await featureRuns(data).update("o/r#skip", { updated_at: "2024-06-01T00:00:00Z" });
|
|
130
|
-
assertEquals(gets, 0);
|
|
131
|
-
assertEquals(rows[0].updated_at, "2024-06-01T00:00:00Z");
|
|
132
|
-
// Untouched projection stays as stored.
|
|
133
|
-
assertEquals(rows[0].list_bucket, "active");
|
|
134
|
-
|
|
135
|
-
// A projection-input change (acknowledged_at) DOES read back and reproject.
|
|
136
|
-
await featureRuns(data).update("o/r#skip", { acknowledged_at: "2024-06-01T00:00:00Z" });
|
|
137
|
-
assertEquals(gets, 1);
|
|
138
|
-
assertEquals(rows[0].list_bucket, "history");
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
test("a direct write of a projection output field forces reprojection and overrides the raw value", async () => {
|
|
142
|
-
const { data, rows } = memData();
|
|
143
|
-
rows.push({
|
|
144
|
-
feature_key: "o/r#bypass",
|
|
145
|
-
status: "running",
|
|
146
|
-
pr_key: null,
|
|
147
|
-
converge: 1,
|
|
148
|
-
auto_merge: 1,
|
|
149
|
-
stage: "Implementing",
|
|
150
|
-
stage_state: null,
|
|
151
|
-
list_bucket: "active",
|
|
152
|
-
});
|
|
153
|
-
// A caller tries to bypass derivation by writing the derived column directly. The gateway must NOT
|
|
154
|
-
// persist the raw value: it re-reads, recomputes from the merged inputs, and overrides it.
|
|
155
|
-
await featureRuns(data).update("o/r#bypass", { stage: "Done", list_bucket: "history" });
|
|
156
|
-
assertEquals(rows[0].stage, "Implementing");
|
|
157
|
-
assertEquals(rows[0].list_bucket, "active");
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
test("backfillFeatureStages stamps legacy terminal and live rows with helper-derived values", async () => {
|
|
161
|
-
const { data, rows } = memData();
|
|
162
|
-
// Legacy rows written before migration 039 → projection columns absent/NULL.
|
|
163
|
-
rows.push({ feature_key: "o/r#8", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: "2024-01-01T00:00:00Z" });
|
|
164
|
-
rows.push({ feature_key: "o/r#9", status: "running", pr_key: null, converge: 0, auto_merge: 0 });
|
|
165
|
-
// A legacy row parked at a LIVE escalation: its projection columns are absent/NULL, so backfill MUST
|
|
166
|
-
// reproject its pipeline stage (issue #254). Issue #332 dropped the escalation_* denormalisation.
|
|
167
|
-
rows.push({ feature_key: "o/r#esc", status: "escalated", pr_key: null, converge: 1, auto_merge: 1 });
|
|
168
|
-
|
|
169
|
-
const stamped = await backfillFeatureStages(data);
|
|
170
|
-
assertEquals(stamped, 3);
|
|
171
|
-
|
|
172
|
-
const terminal = rows.find((r) => r.feature_key === "o/r#8");
|
|
173
|
-
assertEquals(terminal.stage, "Done");
|
|
174
|
-
assertEquals(terminal.stage_state, "ok");
|
|
175
|
-
assertEquals(terminal.list_bucket, "history");
|
|
176
|
-
|
|
177
|
-
const live = rows.find((r) => r.feature_key === "o/r#9");
|
|
178
|
-
assertEquals(live.stage, "Implementing");
|
|
179
|
-
assertEquals(live.stage_state, null);
|
|
180
|
-
assertEquals(live.stage_skipped, "Converging Merging");
|
|
181
|
-
assertEquals(live.list_bucket, "active");
|
|
182
|
-
|
|
183
|
-
const escalated = rows.find((r) => r.feature_key === "o/r#esc");
|
|
184
|
-
assertEquals(escalated.stage, "Implementing");
|
|
185
|
-
assertEquals(escalated.attention, "⚠");
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
test("backfillFeatureStages skips already-projected rows and counts only rows it stamps", async () => {
|
|
189
|
-
const { data, rows } = memData();
|
|
190
|
-
// One legacy row (no projection) + one already-projected row (gateway kept it fresh).
|
|
191
|
-
rows.push({ feature_key: "o/r#legacy", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: null });
|
|
192
|
-
rows.push({ feature_key: "o/r#fresh", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: null, stage: "Done", stage_state: "ok", stage_skipped: "", attention: null, list_bucket: "active", updated_at: "0" });
|
|
193
|
-
|
|
194
|
-
const stamped = await backfillFeatureStages(data);
|
|
195
|
-
// Only the legacy row is stamped; the already-projected row is skipped.
|
|
196
|
-
assertEquals(stamped, 1);
|
|
197
|
-
const legacy = rows.find((r) => r.feature_key === "o/r#legacy");
|
|
198
|
-
assertEquals(legacy.stage, "Done");
|
|
199
|
-
// The already-projected row was not re-written (its sentinel updated_at is untouched).
|
|
200
|
-
const fresh = rows.find((r) => r.feature_key === "o/r#fresh");
|
|
201
|
-
assertEquals(fresh.updated_at, "0");
|
|
202
|
-
});
|