@nanobpm/nano-workforce 0.122.0 → 0.123.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.
@@ -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
- });
@@ -1,112 +0,0 @@
1
- // Tests for the `plans` gateway's write-time epic-bucket projection (issue #298) and its one-shot
2
- // backfill. The gateway wraps the plain table so EVERY writer — startPlan, the record workers, the
3
- // acknowledge-epic op — automatically gets a fresh `list_bucket`/`ack_open` projection without
4
- // passing them: the single write path is the only place `deriveEpicBucket` / `epicIsAcknowledgeable`
5
- // are applied. Mirrors app/featureGateway.test.ts.
6
- //
7
- // Since epic #412 retired the stored `plans.delivery` column, the gateway projects with `delivery`
8
- // treated as UNKNOWN (null): `list_bucket` is provably identical to the delivery-aware value for
9
- // every reachable state, and `ack_open` becomes a *candidate* (any unacknowledged `done` epic) that
10
- // the `pollPlanBucket` read-model pass (implemented in app/service.ts, tested in app/delivery.test.ts)
11
- // corrects with the read-time signal.
12
- import { test } from "node:test";
13
- import { assert, assertEquals } from "#test-assert";
14
- import type { DataLayer } from "@nanobpm/urban";
15
- import { backfillPlanBuckets, plans } from "./plan.ts";
16
-
17
- // In-memory record gateway with the same semantics the real Table exposes. The `plans` proxy wraps
18
- // whatever data.table returns, so this exercises the real proxy.
19
- function memData(): { data: DataLayer; rows: any[] } {
20
- const rows: any[] = [];
21
- function tbl(_name: string, pk = "id") {
22
- const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
23
- return {
24
- async all() {
25
- return rows.slice();
26
- },
27
- async get(id: any) {
28
- return rows.find((r) => r[pk] === id);
29
- },
30
- async find(where: any = {}) {
31
- return rows.filter((r) => match(r, where));
32
- },
33
- async insert(row: any) {
34
- rows.push({ ...row });
35
- return row[pk];
36
- },
37
- async update(id: any, patch: any) {
38
- const r = rows.find((row) => row[pk] === id);
39
- if (r) Object.assign(r, patch);
40
- return r ? 1 : 0;
41
- },
42
- };
43
- }
44
- const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
45
- return { data, rows };
46
- }
47
-
48
- test("insert projects list_bucket/ack_open from status without the caller passing them", async () => {
49
- const { data, rows } = memData();
50
- await plans(data).insert({ plan_key: "o/r#1", status: "planning" });
51
- assertEquals(rows[0].list_bucket, "active");
52
- assertEquals(rows[0].ack_open, 0);
53
- });
54
-
55
- test("status flip to done keeps the epic Active and marks a Dismiss candidate (delivery-free gateway)", async () => {
56
- const { data, rows } = memData();
57
- rows.push({ plan_key: "o/r#2", status: "dispatched" });
58
- // record-results marks the epic done. The delivery-free gateway keeps it Active and — not seeing
59
- // the read-time delivery signal — marks it a Dismiss candidate (ack_open=1); `pollPlanBucket`
60
- // clears ack_open while the epic is still converging.
61
- await plans(data).update("o/r#2", { status: "done" });
62
- assertEquals(rows[0].list_bucket, "active");
63
- assertEquals(rows[0].ack_open, 1);
64
- });
65
-
66
- test("acknowledging a done epic flips it to History and closes the Dismiss affordance", async () => {
67
- const { data, rows } = memData();
68
- await plans(data).insert({ plan_key: "o/r#3", status: "done" });
69
- assertEquals(rows[0].list_bucket, "active");
70
- assertEquals(rows[0].ack_open, 1);
71
- // Operator dismisses → gateway reprojects to History, ack_open closes.
72
- await plans(data).update("o/r#3", { acknowledged_at: "2024-01-01T00:00:00Z" });
73
- assertEquals(rows[0].list_bucket, "history");
74
- assertEquals(rows[0].ack_open, 0);
75
- });
76
-
77
- test("a projection-irrelevant patch (epic_phase only) does not disturb the stored bucket", async () => {
78
- const { data, rows } = memData();
79
- rows.push({ plan_key: "o/r#4", status: "done", list_bucket: "active", ack_open: 1 });
80
- await plans(data).update("o/r#4", { epic_phase: "Finalizing" });
81
- assertEquals(rows[0].list_bucket, "active");
82
- assertEquals(rows[0].ack_open, 1);
83
- });
84
-
85
- test("a direct write to list_bucket is overridden by the canonical derivation (no bypass)", async () => {
86
- const { data, rows } = memData();
87
- rows.push({ plan_key: "o/r#5", status: "done" });
88
- // A caller tries to force History; the gateway re-derives from status (delivery-free) and overrides it.
89
- await plans(data).update("o/r#5", { list_bucket: "history" });
90
- assertEquals(rows[0].list_bucket, "active");
91
- });
92
-
93
- test("backfillPlanBuckets stamps only legacy (NULL list_bucket) rows, idempotently", async () => {
94
- const { data, rows } = memData();
95
- rows.push({ plan_key: "o/r#legacy", status: "done", list_bucket: null, ack_open: null });
96
- rows.push({ plan_key: "o/r#fresh", status: "planning", list_bucket: "active", ack_open: 0 });
97
- const stamped = await backfillPlanBuckets(data);
98
- assertEquals(stamped, 1);
99
- assertEquals(rows[0].list_bucket, "active");
100
- // Delivery-free gateway marks the done epic a Dismiss candidate; pollPlanBucket corrects it later.
101
- assertEquals(rows[0].ack_open, 1);
102
- // Second pass is a no-op: every row is now projected.
103
- assertEquals(await backfillPlanBuckets(data), 0);
104
- });
105
-
106
- test("terminal failed epic buckets to History", async () => {
107
- const { data, rows } = memData();
108
- rows.push({ plan_key: "o/r#6", status: "dispatched" });
109
- await plans(data).update("o/r#6", { status: "failed" });
110
- assertEquals(rows[0].list_bucket, "history");
111
- assert(!rows[0].ack_open);
112
- });