@nanobpm/nano-workforce 0.121.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 +14 -0
- package/app/delivery.test.ts +6 -105
- package/app/feature.ts +30 -128
- package/app/featureReadModel.test.ts +210 -0
- package/app/lineage.test.ts +0 -5
- package/app/lineage.ts +24 -8
- package/app/mergesPerDay.test.ts +8 -92
- package/app/mergesPerDay.ts +7 -60
- package/app/plan.ts +36 -108
- package/app/plansReadModel.test.ts +112 -5
- package/app/service.ts +55 -81
- package/db/migrations/070_drop_plan_projection_columns.sql +31 -0
- package/db/migrations/071_drop_merges_per_day_table.sql +21 -0
- package/db/migrations/072_drop_lineage_view_backed_columns.sql +23 -0
- 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 +76 -34
- package/operations/acknowledgeEpic.ts +15 -10
- package/package.json +1 -1
- package/pages/epic.page.json +1 -1
- package/pages/feature.page.json +1 -1
- package/workers/record-plan/worker.test.ts +11 -9
- package/workers/record-plan/worker.ts +4 -8
- package/workers/record-results/worker.test.ts +7 -4
- package/workers/record-wave/worker.test.ts +13 -11
- package/workers/record-wave/worker.ts +8 -12
- package/workers/select-wave/worker.test.ts +16 -14
- package/workers/select-wave/worker.ts +5 -6
- package/app/featureGateway.test.ts +0 -202
- package/app/planGateway.test.ts +0 -106
|
@@ -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
|
-
});
|
package/app/planGateway.test.ts
DELETED
|
@@ -1,106 +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
|
-
// delivery poller, the acknowledge-epic op — automatically gets a fresh `list_bucket`/`ack_open`
|
|
4
|
-
// projection without passing them: the single write path is the only place `deriveEpicBucket` /
|
|
5
|
-
// `epicIsAcknowledgeable` are applied. Mirrors app/featureGateway.test.ts.
|
|
6
|
-
import { test } from "node:test";
|
|
7
|
-
import { assert, assertEquals } from "#test-assert";
|
|
8
|
-
import type { DataLayer } from "@nanobpm/urban";
|
|
9
|
-
import { backfillPlanBuckets, plans } from "./plan.ts";
|
|
10
|
-
|
|
11
|
-
// In-memory record gateway with the same semantics the real Table exposes. The `plans` proxy wraps
|
|
12
|
-
// 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("insert projects list_bucket/ack_open from status without the caller passing them", async () => {
|
|
43
|
-
const { data, rows } = memData();
|
|
44
|
-
await plans(data).insert({ plan_key: "o/r#1", status: "planning" });
|
|
45
|
-
assertEquals(rows[0].list_bucket, "active");
|
|
46
|
-
assertEquals(rows[0].ack_open, 0);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test("status flip to done+converging keeps the epic Active (no vanish)", async () => {
|
|
50
|
-
const { data, rows } = memData();
|
|
51
|
-
rows.push({ plan_key: "o/r#2", status: "dispatched", delivery: null });
|
|
52
|
-
// record-results marks the epic done; the delivery poller then sets converging.
|
|
53
|
-
await plans(data).update("o/r#2", { status: "done" });
|
|
54
|
-
assertEquals(rows[0].list_bucket, "active");
|
|
55
|
-
await plans(data).update("o/r#2", { delivery: "converging", delivery_label: "1/2 slices merged, 1 converging" });
|
|
56
|
-
assertEquals(rows[0].list_bucket, "active");
|
|
57
|
-
assertEquals(rows[0].ack_open, 0);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
test("delivery landing opens the Dismiss affordance (ack_open=1) but keeps it Active until acknowledged", async () => {
|
|
61
|
-
const { data, rows } = memData();
|
|
62
|
-
rows.push({ plan_key: "o/r#3", status: "done", delivery: "converging" });
|
|
63
|
-
await plans(data).update("o/r#3", { delivery: "landed", delivery_label: "2/2 slices merged" });
|
|
64
|
-
assertEquals(rows[0].list_bucket, "active");
|
|
65
|
-
assertEquals(rows[0].ack_open, 1);
|
|
66
|
-
// Operator dismisses → gateway reprojects to History, ack_open closes.
|
|
67
|
-
await plans(data).update("o/r#3", { acknowledged_at: "2024-01-01T00:00:00Z" });
|
|
68
|
-
assertEquals(rows[0].list_bucket, "history");
|
|
69
|
-
assertEquals(rows[0].ack_open, 0);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
test("a projection-irrelevant patch (wave_label only) does not disturb the stored bucket", async () => {
|
|
73
|
-
const { data, rows } = memData();
|
|
74
|
-
rows.push({ plan_key: "o/r#4", status: "done", delivery: "landed", list_bucket: "active", ack_open: 1 });
|
|
75
|
-
await plans(data).update("o/r#4", { wave_label: "2/2" });
|
|
76
|
-
assertEquals(rows[0].list_bucket, "active");
|
|
77
|
-
assertEquals(rows[0].ack_open, 1);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
test("a direct write to list_bucket is overridden by the canonical derivation (no bypass)", async () => {
|
|
81
|
-
const { data, rows } = memData();
|
|
82
|
-
rows.push({ plan_key: "o/r#5", status: "done", delivery: "converging" });
|
|
83
|
-
// A caller tries to force History; the gateway re-derives from status+delivery and overrides it.
|
|
84
|
-
await plans(data).update("o/r#5", { list_bucket: "history" });
|
|
85
|
-
assertEquals(rows[0].list_bucket, "active");
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
test("backfillPlanBuckets stamps only legacy (NULL list_bucket) rows, idempotently", async () => {
|
|
89
|
-
const { data, rows } = memData();
|
|
90
|
-
rows.push({ plan_key: "o/r#legacy", status: "done", delivery: "converging", list_bucket: null, ack_open: null });
|
|
91
|
-
rows.push({ plan_key: "o/r#fresh", status: "planning", list_bucket: "active", ack_open: 0 });
|
|
92
|
-
const stamped = await backfillPlanBuckets(data);
|
|
93
|
-
assertEquals(stamped, 1);
|
|
94
|
-
assertEquals(rows[0].list_bucket, "active");
|
|
95
|
-
assertEquals(rows[0].ack_open, 0);
|
|
96
|
-
// Second pass is a no-op: every row is now projected.
|
|
97
|
-
assertEquals(await backfillPlanBuckets(data), 0);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
test("terminal failed epic buckets to History", async () => {
|
|
101
|
-
const { data, rows } = memData();
|
|
102
|
-
rows.push({ plan_key: "o/r#6", status: "dispatched" });
|
|
103
|
-
await plans(data).update("o/r#6", { status: "failed" });
|
|
104
|
-
assertEquals(rows[0].list_bucket, "history");
|
|
105
|
-
assert(!rows[0].ack_open);
|
|
106
|
-
});
|