@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,20 +1,32 @@
|
|
|
1
1
|
// Tests for the POST /app/api/actions/acknowledge-epic operation `acknowledgeEpic` (issue #298).
|
|
2
|
-
// The nwf UI's "Dismiss" affordance for a RESOLVED epic — landed (
|
|
3
|
-
// resolved-not-landed (
|
|
4
|
-
// `acknowledged_at
|
|
5
|
-
// `ack_open`
|
|
6
|
-
// it completes NO user task (a resolved epic is not parked). The epic twin of
|
|
2
|
+
// The nwf UI's "Dismiss" affordance for a RESOLVED epic — landed (delivery=landed) or
|
|
3
|
+
// resolved-not-landed (delivery=null); only still-`converging` epics are rejected. It stamps
|
|
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.
|
|
8
|
+
//
|
|
9
|
+
// Since epic #412 retired the stored `plans.delivery` column, the op derives the delivery signal at
|
|
10
|
+
// READ TIME (`derivePlanDelivery` → the pure `deriveDelivery`) by joining the epic's slice
|
|
11
|
+
// `plan_tasks.pr_key` → `pull_requests.status`. So these tests seed `plan_tasks` + `pull_requests`
|
|
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.
|
|
7
16
|
import { test } from "node:test";
|
|
8
17
|
import { assertEquals } from "#test-assert";
|
|
9
18
|
import type { AppApi } from "@nanobpm/urban";
|
|
10
|
-
import {
|
|
19
|
+
import { deriveEpicBucket, epicIsAcknowledgeable } from "../app/delivery.ts";
|
|
11
20
|
import { noopLog } from "../test/log.ts";
|
|
12
21
|
import handler from "./acknowledgeEpic.ts";
|
|
13
22
|
|
|
14
|
-
// An in-memory data layer wired through the
|
|
15
|
-
//
|
|
16
|
-
function memApp(
|
|
17
|
-
|
|
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.
|
|
25
|
+
function memApp(
|
|
26
|
+
seed: any[],
|
|
27
|
+
extra: Record<string, any[]> = {},
|
|
28
|
+
): { app: AppApi; rows: any[] } {
|
|
29
|
+
const stores: Record<string, any[]> = { plans: seed, ...extra };
|
|
18
30
|
function tbl(name: string, pk = "id") {
|
|
19
31
|
const rows = (stores[name] ??= [] as any[]);
|
|
20
32
|
const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
|
|
@@ -50,53 +62,78 @@ async function call(app: AppApi, body: unknown) {
|
|
|
50
62
|
return (await handler({ req: {} as any, params: {}, query: {}, body } as any, app)) as any;
|
|
51
63
|
}
|
|
52
64
|
|
|
53
|
-
test("acknowledge-epic: stamps acknowledged_at and
|
|
54
|
-
const { app, rows } = memApp(
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
65
|
+
test("acknowledge-epic: stamps acknowledged_at and (via the VIEW) buckets a landed epic into History", async () => {
|
|
66
|
+
const { app, rows } = memApp(
|
|
67
|
+
[{ plan_key: "o/r#1", status: "done", acknowledged_at: null }],
|
|
68
|
+
{
|
|
69
|
+
plan_tasks: [{ id: 1, plan_key: "o/r#1", pr_key: "o/r#100" }],
|
|
70
|
+
pull_requests: [{ pr_key: "o/r#100", status: "merged" }], // landed
|
|
71
|
+
},
|
|
72
|
+
);
|
|
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);
|
|
59
76
|
|
|
60
77
|
const res = await call(app, { plan_key: "o/r#1" });
|
|
61
78
|
|
|
62
79
|
assertEquals(res.status, 200);
|
|
63
80
|
assertEquals(res.body.ok, true);
|
|
64
81
|
assertEquals(typeof rows[0].acknowledged_at, "string");
|
|
65
|
-
|
|
66
|
-
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");
|
|
67
84
|
});
|
|
68
85
|
|
|
69
86
|
test("acknowledge-epic: a still-converging epic is rejected (409) and stays Active", async () => {
|
|
70
|
-
const { app, rows } = memApp(
|
|
71
|
-
|
|
87
|
+
const { app, rows } = memApp(
|
|
88
|
+
[{ plan_key: "o/r#2", status: "done", acknowledged_at: null }],
|
|
89
|
+
{
|
|
90
|
+
plan_tasks: [
|
|
91
|
+
{ id: 1, plan_key: "o/r#2", pr_key: "o/r#200" },
|
|
92
|
+
{ id: 2, plan_key: "o/r#2", pr_key: "o/r#201" },
|
|
93
|
+
],
|
|
94
|
+
pull_requests: [
|
|
95
|
+
{ pr_key: "o/r#200", status: "merged" },
|
|
96
|
+
{ pr_key: "o/r#201", status: "converging" }, // still in flight → converging
|
|
97
|
+
],
|
|
98
|
+
},
|
|
99
|
+
);
|
|
72
100
|
|
|
73
101
|
const res = await call(app, { plan_key: "o/r#2" });
|
|
74
102
|
|
|
75
103
|
assertEquals(res.status, 409);
|
|
76
104
|
assertEquals(res.body.ok, false);
|
|
77
|
-
// Untouched: no premature acknowledged_at
|
|
105
|
+
// Untouched: no premature acknowledged_at; still Active through the VIEW (converging → active).
|
|
78
106
|
assertEquals(rows[0].acknowledged_at, null);
|
|
79
|
-
assertEquals(rows[0].
|
|
107
|
+
assertEquals(deriveEpicBucket("done", "converging", rows[0].acknowledged_at), "active");
|
|
80
108
|
});
|
|
81
109
|
|
|
82
110
|
test("acknowledge-epic: a resolved-not-landed epic (delivery=null) is accepted (200) and flips to History", async () => {
|
|
83
|
-
const { app, rows } = memApp(
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
111
|
+
const { app, rows } = memApp(
|
|
112
|
+
[{ plan_key: "o/r#2b", status: "done", acknowledged_at: null }],
|
|
113
|
+
{
|
|
114
|
+
plan_tasks: [
|
|
115
|
+
{ id: 1, plan_key: "o/r#2b", pr_key: "o/r#210" },
|
|
116
|
+
{ id: 2, plan_key: "o/r#2b", pr_key: "o/r#211" },
|
|
117
|
+
],
|
|
118
|
+
pull_requests: [
|
|
119
|
+
{ pr_key: "o/r#210", status: "merged" },
|
|
120
|
+
{ pr_key: "o/r#211", status: "abandoned" }, // all terminal, not all merged → delivery null
|
|
121
|
+
],
|
|
122
|
+
},
|
|
123
|
+
);
|
|
124
|
+
assertEquals(deriveEpicBucket("done", null, rows[0].acknowledged_at), "active");
|
|
125
|
+
assertEquals(epicIsAcknowledgeable("done", null), true);
|
|
88
126
|
|
|
89
127
|
const res = await call(app, { plan_key: "o/r#2b" });
|
|
90
128
|
|
|
91
129
|
assertEquals(res.status, 200);
|
|
92
130
|
assertEquals(res.body.ok, true);
|
|
93
131
|
assertEquals(typeof rows[0].acknowledged_at, "string");
|
|
94
|
-
assertEquals(rows[0].
|
|
95
|
-
assertEquals(rows[0].ack_open, 0);
|
|
132
|
+
assertEquals(deriveEpicBucket("done", null, rows[0].acknowledged_at), "history");
|
|
96
133
|
});
|
|
97
134
|
|
|
98
135
|
test("acknowledge-epic: a live (dispatched) epic is rejected (409)", async () => {
|
|
99
|
-
const { app } = memApp([{ plan_key: "o/r#3", status: "dispatched",
|
|
136
|
+
const { app } = memApp([{ plan_key: "o/r#3", status: "dispatched", acknowledged_at: null }]);
|
|
100
137
|
const res = await call(app, { plan_key: "o/r#3" });
|
|
101
138
|
assertEquals(res.status, 409);
|
|
102
139
|
});
|
|
@@ -114,16 +151,21 @@ test("acknowledge-epic: no matching epic → 404", async () => {
|
|
|
114
151
|
});
|
|
115
152
|
|
|
116
153
|
test("acknowledge-epic: idempotent — re-acknowledging a landed epic keeps it in History", async () => {
|
|
117
|
-
const { app, rows } = memApp(
|
|
118
|
-
|
|
154
|
+
const { app, rows } = memApp(
|
|
155
|
+
[{ plan_key: "o/r#5", status: "done", acknowledged_at: null }],
|
|
156
|
+
{
|
|
157
|
+
plan_tasks: [{ id: 1, plan_key: "o/r#5", pr_key: "o/r#500" }],
|
|
158
|
+
pull_requests: [{ pr_key: "o/r#500", status: "merged" }], // landed
|
|
159
|
+
},
|
|
160
|
+
);
|
|
119
161
|
|
|
120
162
|
assertEquals((await call(app, { plan_key: "o/r#5" })).status, 200);
|
|
121
163
|
const firstStamp = rows[0].acknowledged_at;
|
|
122
|
-
assertEquals(rows[0].
|
|
164
|
+
assertEquals(deriveEpicBucket("done", "landed", rows[0].acknowledged_at), "history");
|
|
123
165
|
|
|
124
166
|
const res2 = await call(app, { plan_key: "o/r#5" });
|
|
125
167
|
assertEquals(res2.status, 200);
|
|
126
|
-
assertEquals(rows[0].
|
|
168
|
+
assertEquals(deriveEpicBucket("done", "landed", rows[0].acknowledged_at), "history");
|
|
127
169
|
// Re-stamped (a fresh timestamp) but still resolved.
|
|
128
170
|
assertEquals(typeof rows[0].acknowledged_at, "string");
|
|
129
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
|
|
@@ -19,6 +20,7 @@
|
|
|
19
20
|
|
|
20
21
|
import { epicIsAcknowledgeable } from "../app/delivery.ts";
|
|
21
22
|
import { plans } from "../app/plan.ts";
|
|
23
|
+
import { derivePlanDelivery } from "../app/service.ts";
|
|
22
24
|
import { defineOperation } from "../nano-generated/operations.ts";
|
|
23
25
|
|
|
24
26
|
const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
|
|
@@ -43,18 +45,21 @@ export default defineOperation("acknowledgeEpic", async ({ body }, app) => {
|
|
|
43
45
|
// affordance. Acknowledging a live/converging epic would pre-seed `acknowledged_at`, so the moment
|
|
44
46
|
// it later resolved `deriveEpicBucket` would drop it straight into History, skipping the operator
|
|
45
47
|
// tick-off this op exists to require — and a converging epic must stay visible while its slices land.
|
|
46
|
-
|
|
48
|
+
// The `plans.delivery` column was retired (epic #412), so derive the signal at read time from the
|
|
49
|
+
// slice PRs (the same pure `deriveDelivery` the `plan_delivery` VIEW encodes).
|
|
50
|
+
const delivery = await derivePlanDelivery(app.data, plan);
|
|
51
|
+
if (!epicIsAcknowledgeable(plan.status, delivery)) {
|
|
47
52
|
app.log.warn("acknowledge-epic rejected: epic is not resolved", {
|
|
48
53
|
planKey,
|
|
49
54
|
status: plan.status,
|
|
50
|
-
delivery
|
|
55
|
+
delivery,
|
|
51
56
|
});
|
|
52
57
|
return { status: 409, body: { ok: false, error: "epic is not resolved" } };
|
|
53
58
|
}
|
|
54
59
|
|
|
55
|
-
// Stamp the dismissal.
|
|
56
|
-
// the
|
|
57
|
-
// 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.
|
|
58
63
|
const now = new Date().toISOString();
|
|
59
64
|
await table.update(planKey, { acknowledged_at: now, updated_at: now });
|
|
60
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
|
@@ -59,7 +59,7 @@ function fakeApp() {
|
|
|
59
59
|
return { app, plans, planTasks, planTaskNeeds };
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
test("record-plan
|
|
62
|
+
test("record-plan dispatches a taskful plan and levelizes its tasks (wave progress is now VIEW-derived)", async () => {
|
|
63
63
|
const { app, plans } = fakeApp();
|
|
64
64
|
await handler(
|
|
65
65
|
{
|
|
@@ -74,22 +74,24 @@ test("record-plan initializes wave progress fields for a taskful plan", async ()
|
|
|
74
74
|
app,
|
|
75
75
|
);
|
|
76
76
|
assertEquals(plans[0].status, "dispatched");
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
// Wave progress (wave_count/current_wave/wave_label) was retired as a stored projection (epic
|
|
78
|
+
// #412) — it is derived from `plan_tasks` by the plan_wave_label/plan_read_model VIEWs — so
|
|
79
|
+
// record-plan no longer writes it onto the plans row.
|
|
80
|
+
assertEquals(plans[0].wave_count, undefined);
|
|
81
|
+
assertEquals(plans[0].current_wave, undefined);
|
|
82
|
+
assertEquals(plans[0].wave_label, undefined);
|
|
80
83
|
});
|
|
81
84
|
|
|
82
|
-
test("record-plan
|
|
85
|
+
test("record-plan marks a taskless plan done (no wave-progress columns written)", async () => {
|
|
83
86
|
const { app, plans } = fakeApp();
|
|
84
87
|
await handler(
|
|
85
88
|
{ variables: { planKey: "owner/repo#137", tasks: [], note: "planner emitted no tasks" } } as any,
|
|
86
89
|
app,
|
|
87
90
|
);
|
|
88
91
|
assertEquals(plans[0].status, "done");
|
|
89
|
-
|
|
90
|
-
assertEquals(plans[0].
|
|
91
|
-
assertEquals(plans[0].
|
|
92
|
-
assertEquals(plans[0].wave_label, null);
|
|
92
|
+
assertEquals(plans[0].wave_count, undefined);
|
|
93
|
+
assertEquals(plans[0].current_wave, undefined);
|
|
94
|
+
assertEquals(plans[0].wave_label, undefined);
|
|
93
95
|
});
|
|
94
96
|
|
|
95
97
|
test("record-plan persists per-task capability needs into plan_task_needs (issue #289)", async () => {
|
|
@@ -141,14 +141,10 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
141
141
|
const patch: Record<string, unknown> = {
|
|
142
142
|
status: tasks.length > 0 ? "dispatched" : "done",
|
|
143
143
|
task_count: tasks.length,
|
|
144
|
-
// Operator-visibility progress
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
// misleading wave_count: 0 while current_wave/wave_label are NULL (inconsistent projection).
|
|
149
|
-
wave_count: tasks.length > 0 ? waveCount : null,
|
|
150
|
-
current_wave: tasks.length > 0 ? 0 : null,
|
|
151
|
-
wave_label: tasks.length > 0 ? `1/${waveCount}` : null,
|
|
144
|
+
// Operator-visibility wave progress (wave_count / current_wave / wave_label) was RETIRED as a
|
|
145
|
+
// stored projection (epic #412) — the epics-index reads it from the `plan_wave_label` /
|
|
146
|
+
// `plan_read_model` SQL VIEWs (060/061) derived from `plan_tasks`, so this worker no longer
|
|
147
|
+
// denormalises it onto the `plans` row.
|
|
152
148
|
updated_at: ts,
|
|
153
149
|
};
|
|
154
150
|
// Domain-phase projection (#261): recording the plan hands the epic to the `review-plan` agent,
|
|
@@ -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
|
});
|
|
@@ -142,8 +142,9 @@ test("record-wave retries the same wave when a task is still pending", async ()
|
|
|
142
142
|
trialMergeSkipReason: "wave-still-pending",
|
|
143
143
|
});
|
|
144
144
|
assertEquals((planUpdates[0].patch as Record<string, unknown>).gate_wave, 1);
|
|
145
|
-
//
|
|
146
|
-
|
|
145
|
+
// Wave progress (current_wave/wave_label) was retired as a stored projection (epic #412) — derived
|
|
146
|
+
// from `plan_tasks` by the plan_wave_label VIEW — so record-wave no longer writes it.
|
|
147
|
+
assertEquals("current_wave" in (planUpdates[0].patch as Record<string, unknown>), false);
|
|
147
148
|
// Domain-phase projection (#261): more waves remain, so the epic stays Implementing (wave n/t).
|
|
148
149
|
assertEquals((planUpdates[0].patch as Record<string, unknown>).epic_phase, "Implementing (wave 2/2)");
|
|
149
150
|
});
|
|
@@ -171,20 +172,21 @@ test("record-wave pins current_wave to the last index and clears gate_wave on th
|
|
|
171
172
|
app,
|
|
172
173
|
);
|
|
173
174
|
|
|
174
|
-
// Final wave (2 of 3): no successor wave — gate cleared
|
|
175
|
-
//
|
|
175
|
+
// Final wave (2 of 3): no successor wave — gate cleared. Wave progress (current_wave/wave_label)
|
|
176
|
+
// is no longer a stored column (epic #412; derived from `plan_tasks` by the plan_wave_label VIEW),
|
|
177
|
+
// so record-wave writes neither.
|
|
176
178
|
assertEquals((planUpdates[0].patch as Record<string, unknown>).gate_wave, null);
|
|
177
|
-
assertEquals((planUpdates[0].patch as Record<string, unknown>)
|
|
178
|
-
assertEquals((planUpdates[0].patch as Record<string, unknown>)
|
|
179
|
+
assertEquals("current_wave" in (planUpdates[0].patch as Record<string, unknown>), false);
|
|
180
|
+
assertEquals("wave_label" in (planUpdates[0].patch as Record<string, unknown>), false);
|
|
179
181
|
// Domain-phase projection (#261): the final wave landed with no successor and no trial merge, so
|
|
180
182
|
// the epic enters Finalizing (record-results then advances to the Dispatched terminal).
|
|
181
183
|
assertEquals((planUpdates[0].patch as Record<string, unknown>).epic_phase, "Finalizing");
|
|
182
184
|
});
|
|
183
185
|
|
|
184
|
-
test("record-wave
|
|
186
|
+
test("record-wave writes no wave-progress columns for a taskless plan (waveCount 0)", async () => {
|
|
185
187
|
// A taskless plan runs record-wave with waveCount 0 (the MI `implement` step completed
|
|
186
|
-
// immediately).
|
|
187
|
-
//
|
|
188
|
+
// immediately). Wave progress was retired as a stored projection (epic #412), so record-wave never
|
|
189
|
+
// writes current_wave/wave_label regardless.
|
|
188
190
|
const { app, planUpdates } = fakeApp([]);
|
|
189
191
|
|
|
190
192
|
await handler(
|
|
@@ -201,8 +203,8 @@ test("record-wave keeps all wave-progress fields NULL for a taskless plan (waveC
|
|
|
201
203
|
);
|
|
202
204
|
|
|
203
205
|
const patch = planUpdates[0].patch as Record<string, unknown>;
|
|
204
|
-
assertEquals(patch
|
|
205
|
-
assertEquals(patch
|
|
206
|
+
assertEquals("current_wave" in patch, false);
|
|
207
|
+
assertEquals("wave_label" in patch, false);
|
|
206
208
|
});
|
|
207
209
|
|
|
208
210
|
test("record-wave skips trial merge for mergify-queue repos with 2+ heads", async () => {
|
|
@@ -302,17 +302,15 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
302
302
|
const nextWave = stillPendingCurrentWave ? currentWave : currentWave + 1;
|
|
303
303
|
const hasMoreWaves = stillPendingCurrentWave || nextWave < waveCount;
|
|
304
304
|
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
//
|
|
305
|
+
// Wave index the epic is now on (issue #137): while more waves remain, the wave about to run; on
|
|
306
|
+
// the final wave, pinned to the last index so a finished epic reads N/N (nextWave would be
|
|
307
|
+
// waveCount, one past the last band). This is a LOCAL value only — it is used below to derive the
|
|
308
|
+
// `epic_phase` (Implementing wave n/t) and the domain phase.
|
|
309
309
|
const projectedCurrentWave = hasMoreWaves ? nextWave : Math.max(0, waveCount - 1);
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
const currentWaveProjection = waveCount > 0 ? projectedCurrentWave : null;
|
|
315
|
-
const waveLabel = waveCount > 0 ? `${projectedCurrentWave + 1}/${waveCount}` : null;
|
|
310
|
+
// Operator-visibility wave progress (current_wave / wave_label) was RETIRED as a stored projection
|
|
311
|
+
// (epic #412) — it is now derived from `plan_tasks` by the `plan_wave_label` / `plan_read_model`
|
|
312
|
+
// VIEWs (060/061), so this worker no longer denormalises it (select-wave no longer writes it
|
|
313
|
+
// either). `projectedCurrentWave` above is not persisted; it only feeds the phase derivation.
|
|
316
314
|
|
|
317
315
|
// Domain-phase projection (#261): the wave landed — stamp the phase the epic is ENTERING next,
|
|
318
316
|
// which is data-dependent here (unlike the structural spine writers). A trial merge runs → Trial
|
|
@@ -336,8 +334,6 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
336
334
|
try {
|
|
337
335
|
await plans(app.data).update(planKey, {
|
|
338
336
|
gate_wave: hasMoreWaves ? currentWave : null,
|
|
339
|
-
current_wave: currentWaveProjection,
|
|
340
|
-
wave_label: waveLabel,
|
|
341
337
|
epic_phase: epicPhase,
|
|
342
338
|
updated_at: ts,
|
|
343
339
|
});
|
|
@@ -67,21 +67,22 @@ async function selectWave(rows: Row[], deps: DepRow[]) {
|
|
|
67
67
|
return out as { waveTasks: unknown[] };
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
test("select-wave
|
|
70
|
+
test("select-wave dispatches the active wave without writing wave-progress columns", async () => {
|
|
71
71
|
const rows: Row[] = [
|
|
72
72
|
{ id: 1, plan_key: "owner/repo#63", task_id: "a", title: "A", prompt: "do A", status: "pending", wave: 1 },
|
|
73
73
|
];
|
|
74
|
-
const plans: Record<string, unknown>[] = [{ plan_key: "owner/repo#63"
|
|
74
|
+
const plans: Record<string, unknown>[] = [{ plan_key: "owner/repo#63" }];
|
|
75
75
|
const out = await handler(
|
|
76
76
|
{ variables: { planKey: "owner/repo#63", currentWave: 1 }, elementId: "select-wave" } as any,
|
|
77
77
|
fakeApp(rows, [], plans),
|
|
78
78
|
);
|
|
79
79
|
assertEquals((out as { waveTasks: unknown[] }).waveTasks.length, 1);
|
|
80
|
-
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
assertEquals(plans[0].
|
|
84
|
-
assertEquals(plans[0].
|
|
80
|
+
// Wave progress (current_wave/wave_count/wave_label) was retired as a stored projection (epic
|
|
81
|
+
// #412; the columns are dropped by migration 070) — it is derived from `plan_tasks` by the
|
|
82
|
+
// plan_wave_label VIEW — so select-wave introduces no wave-progress field onto the plan row.
|
|
83
|
+
assertEquals(plans[0].current_wave, undefined);
|
|
84
|
+
assertEquals(plans[0].wave_count, undefined);
|
|
85
|
+
assertEquals(plans[0].wave_label, undefined);
|
|
85
86
|
// Domain-phase projection (#261): dispatching the wave marks the epic Implementing (wave n/t),
|
|
86
87
|
// derived from this worker's BPMN element id + the levelize records.
|
|
87
88
|
assertEquals(plans[0].epic_phase, "Implementing (wave 2/2)");
|
|
@@ -124,17 +125,18 @@ test("select-wave leaves bound_artifacts untouched for a root epic (no resolvedA
|
|
|
124
125
|
assertEquals(plans[0].bound_artifacts, undefined);
|
|
125
126
|
});
|
|
126
127
|
|
|
127
|
-
test("select-wave
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
|
|
128
|
+
test("select-wave writes no wave-progress columns when there are no levelized rows", async () => {
|
|
129
|
+
// Wave progress was retired as a stored projection (epic #412; the columns are dropped by
|
|
130
|
+
// migration 070; it is derived from `plan_tasks` by the plan_wave_label VIEW), so select-wave
|
|
131
|
+
// introduces no wave-progress field onto the plan row.
|
|
132
|
+
const plans: Record<string, unknown>[] = [{ plan_key: "owner/repo#63" }];
|
|
131
133
|
await handler(
|
|
132
134
|
{ variables: { planKey: "owner/repo#63", currentWave: 0 } } as any,
|
|
133
135
|
fakeApp([], [], plans),
|
|
134
136
|
);
|
|
135
|
-
assertEquals(plans[0].current_wave,
|
|
136
|
-
assertEquals(plans[0].wave_count,
|
|
137
|
-
assertEquals(plans[0].wave_label,
|
|
137
|
+
assertEquals(plans[0].current_wave, undefined);
|
|
138
|
+
assertEquals(plans[0].wave_count, undefined);
|
|
139
|
+
assertEquals(plans[0].wave_label, undefined);
|
|
138
140
|
});
|
|
139
141
|
|
|
140
142
|
test("select-wave leaves dependents pending behind a waiting-for-lane dependency", async () => {
|
|
@@ -78,17 +78,16 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
78
78
|
: [];
|
|
79
79
|
try {
|
|
80
80
|
await plans(app.data).update(planKey, {
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
wave_label: waveCount > 0 ? `${currentWave + 1}/${waveCount}` : null,
|
|
81
|
+
// Operator-visibility wave progress (current_wave / wave_count / wave_label) was RETIRED as a
|
|
82
|
+
// stored projection (epic #412) — it is now derived from `plan_tasks` by the `plan_wave_label`
|
|
83
|
+
// / `plan_read_model` VIEWs (060/061), so select-wave no longer denormalises it. This write
|
|
84
|
+
// still stamps the derived domain phase and the inter-epic gate's bound artifacts.
|
|
86
85
|
...(epicPhase ? { epic_phase: epicPhase } : {}),
|
|
87
86
|
...(boundArtifacts.length > 0 ? { bound_artifacts: JSON.stringify(boundArtifacts) } : {}),
|
|
88
87
|
updated_at: ts,
|
|
89
88
|
});
|
|
90
89
|
} catch (err) {
|
|
91
|
-
app.log.error(`select-wave: projecting
|
|
90
|
+
app.log.error(`select-wave: projecting plan row (epic phase / bound artifacts) failed for ${planKey}`, {
|
|
92
91
|
err: String(err),
|
|
93
92
|
});
|
|
94
93
|
}
|