@nanobpm/nano-workforce 0.120.2 → 0.122.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 +76 -43
- 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 +26 -28
- package/app/planGateway.test.ts +25 -19
- package/app/service.ts +88 -45
- 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/openapi.yaml +14 -0
- package/operations/acknowledgeEpic.test.ts +65 -16
- package/operations/acknowledgeEpic.ts +6 -2
- package/operations/previewDeliveryGraph.test.ts +9 -0
- package/operations/previewDeliveryGraph.ts +6 -0
- package/package.json +1 -1
- package/pages/delivery-graphs/delivery-graphs.css +273 -0
- package/pages/delivery-graphs/embed.html +32 -0
- package/pages/delivery-graphs/mount.js +365 -0
- package/pages/delivery-graphs/standalone.html +38 -0
- package/pages/delivery-graphs.page.json +7 -31
- package/scripts/pages-contract.test.ts +8 -16
- package/test/delivery-graphs-embed.test.ts +85 -0
- package/workers/record-plan/worker.test.ts +11 -9
- package/workers/record-plan/worker.ts +4 -8
- 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
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
6
|
+
<title>Delivery graphs — compose · preview · dispatch</title>
|
|
7
|
+
<link rel="stylesheet" href="./delivery-graphs.css" />
|
|
8
|
+
<style>
|
|
9
|
+
html, body { margin: 0; height: 100%; background: #0b0f14; }
|
|
10
|
+
</style>
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<!--
|
|
14
|
+
Standalone shell (phone / direct link). Loads the SAME ./mount.js the console App-View embed uses,
|
|
15
|
+
so the standalone and embedded views render identically. Endpoints default to the current origin;
|
|
16
|
+
override the preview/dispatch endpoints via ?preview= / ?dispatch=. For a secured deployment, pass
|
|
17
|
+
the guard secret via the URL fragment #secret= (sent as x-hook-secret) — NOT the query string, so
|
|
18
|
+
it never leaks via server access logs, browser history, or the Referer header. The fragment is
|
|
19
|
+
stripped from the address bar immediately after it is read.
|
|
20
|
+
-->
|
|
21
|
+
<main id="delivery-graphs-root"></main>
|
|
22
|
+
<script type="module">
|
|
23
|
+
import { mountDeliveryGraphs } from "./mount.js";
|
|
24
|
+
|
|
25
|
+
const params = new URLSearchParams(location.search);
|
|
26
|
+
const secrets = new URLSearchParams(location.hash.slice(1));
|
|
27
|
+
const hookSecret = secrets.get("secret") ?? undefined;
|
|
28
|
+
if (location.hash) {
|
|
29
|
+
history.replaceState(null, "", location.pathname + location.search);
|
|
30
|
+
}
|
|
31
|
+
mountDeliveryGraphs(document.getElementById("delivery-graphs-root"), {
|
|
32
|
+
previewUrl: params.get("preview") ?? undefined,
|
|
33
|
+
dispatchUrl: params.get("dispatch") ?? undefined,
|
|
34
|
+
hookSecret,
|
|
35
|
+
});
|
|
36
|
+
</script>
|
|
37
|
+
</body>
|
|
38
|
+
</html>
|
|
@@ -70,42 +70,18 @@
|
|
|
70
70
|
"type": "text",
|
|
71
71
|
"id": "subtitle",
|
|
72
72
|
"props": {
|
|
73
|
-
"text": "The human front door for delivery graphs (ADR 0005).
|
|
73
|
+
"text": "The human front door for delivery graphs (ADR 0005). Compose an agent-authored delivery-graph JSON, Preview it (a pure compile \u2014 nothing is dispatched) to SEE the rendered plan \u2014 its mermaid diagram, the human stop-points where it parks on a person, and the side effects it will perform \u2014 and fix any path-qualified validation errors inline. Then Dispatch it: a graph with any side-effecting node (it merges PRs / publishes packages) parks for an explicit Approve & dispatch confirm of the rendered preview; a graph with only wait/human nodes dispatches straight away. The compose \u2192 preview \u2192 dispatch view is a thin UI over the same compileDeliveryGraph / startDeliveryGraph doors \u2014 there is no parallel submit path.",
|
|
74
74
|
"variant": "sub"
|
|
75
75
|
}
|
|
76
76
|
},
|
|
77
77
|
{
|
|
78
|
-
"type": "
|
|
79
|
-
"id": "delivery-
|
|
78
|
+
"type": "appView",
|
|
79
|
+
"id": "delivery-graphs-compose",
|
|
80
80
|
"props": {
|
|
81
|
-
"title": "
|
|
82
|
-
"
|
|
83
|
-
"
|
|
84
|
-
|
|
85
|
-
"body": "{{form}}",
|
|
86
|
-
"successLabel": "\u2713 Valid \u2014 the graph compiled. Nothing was dispatched. Review its shape in the in-flight grid, then Dispatch it below."
|
|
87
|
-
},
|
|
88
|
-
"fields": [
|
|
89
|
-
{ "key": "graphJson", "label": "Delivery-graph JSON \u2014 paste the agent-authored DeliveryGraph (nodes/edges). Preview validates + compiles it without dispatching.", "type": "text", "required": true, "requiredMessage": "Paste a delivery-graph JSON to preview" }
|
|
90
|
-
]
|
|
91
|
-
}
|
|
92
|
-
},
|
|
93
|
-
{
|
|
94
|
-
"type": "actionForm",
|
|
95
|
-
"id": "delivery-graph-dispatch",
|
|
96
|
-
"props": {
|
|
97
|
-
"title": "2 \u00b7 Dispatch (start \u2014 gated, idempotent)",
|
|
98
|
-
"submitLabel": "Dispatch",
|
|
99
|
-
"action": {
|
|
100
|
-
"path": "/app/api/actions/delivery-graph/dispatch",
|
|
101
|
-
"body": "{{form}}",
|
|
102
|
-
"successLabel": "\u2713 Dispatched \u2014 watch it advance in the in-flight grid below."
|
|
103
|
-
},
|
|
104
|
-
"fields": [
|
|
105
|
-
{ "key": "graphJson", "label": "Delivery-graph JSON \u2014 paste the same DeliveryGraph you previewed.", "type": "text", "required": true, "requiredMessage": "Paste the delivery-graph JSON to dispatch" },
|
|
106
|
-
{ "key": "approve", "label": "Approve \u2014 I reviewed the preview and approve dispatching this graph's side effects (required for any agent/connector node)", "type": "checkbox" },
|
|
107
|
-
{ "key": "idempotencyKey", "label": "Idempotency key (optional) \u2014 a re-dispatch with the same key (or, blank, the same graph) will not double-launch an in-flight run", "type": "text" }
|
|
108
|
-
]
|
|
81
|
+
"title": "Compose \u2192 preview \u2192 dispatch",
|
|
82
|
+
"embed": "./delivery-graphs/embed.html",
|
|
83
|
+
"standalone": "./delivery-graphs/standalone.html",
|
|
84
|
+
"fill": true
|
|
109
85
|
}
|
|
110
86
|
},
|
|
111
87
|
{
|
|
@@ -360,24 +360,16 @@ test("issue #386: the human-facing Delivery Graphs surface is wired (nav tab, pa
|
|
|
360
360
|
);
|
|
361
361
|
assert(tab, "pages/_nav.json must carry a `Delivery Graphs` nav tab → the delivery-graphs page");
|
|
362
362
|
|
|
363
|
-
// 2) The
|
|
364
|
-
// in-flight grid over the delivery_graph_runs aggregate
|
|
363
|
+
// 2) The page carries the compose → preview → dispatch App View (issue #441 — the rendered preview
|
|
364
|
+
// that consumes the compile output), plus an in-flight grid over the delivery_graph_runs aggregate
|
|
365
|
+
// that links to the per-graph detail page. The rich preview (mermaid diagram + humanNodes[] +
|
|
366
|
+
// sideEffects[] + inline errors) can't render in a bare `actionForm` (its response is discarded),
|
|
367
|
+
// so the surface is an `appView` embed over the SAME compile/dispatch doors.
|
|
365
368
|
const page = JSON.parse(readFileSync(`${ROOT}pages/delivery-graphs.page.json`, "utf8"));
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
const dispatch = forms.find((f: Json) => f.props?.action?.path === "/app/api/actions/delivery-graph/dispatch");
|
|
369
|
-
assert(preview, "delivery-graphs page must have a Preview form posting to /app/api/actions/delivery-graph/preview");
|
|
370
|
-
assert(dispatch, "delivery-graphs page must have a Dispatch form posting to /app/api/actions/delivery-graph/dispatch");
|
|
371
|
-
// Both forms take the pasted graph JSON; Dispatch also carries the explicit approve gate.
|
|
372
|
-
assert(
|
|
373
|
-
(preview.props?.fields ?? []).some((fl: Json) => fl.key === "graphJson"),
|
|
374
|
-
"the Preview form must have a graphJson paste field",
|
|
375
|
-
);
|
|
376
|
-
assert(
|
|
377
|
-
(dispatch.props?.fields ?? []).some((fl: Json) => fl.key === "graphJson") &&
|
|
378
|
-
(dispatch.props?.fields ?? []).some((fl: Json) => fl.key === "approve"),
|
|
379
|
-
"the Dispatch form must have a graphJson paste field and an approve gate",
|
|
369
|
+
const compose = (page.nodes ?? []).find(
|
|
370
|
+
(n: Json) => n.type === "appView" && typeof n.props?.embed === "string" && n.props.embed.includes("delivery-graphs/embed.html"),
|
|
380
371
|
);
|
|
372
|
+
assert(compose, "delivery-graphs page must have an appView embedding ./delivery-graphs/embed.html (the compose → preview → dispatch view, #441)");
|
|
381
373
|
const grid = (page.nodes ?? []).find(
|
|
382
374
|
(n: Json) => n.type === "dataGrid" && n.props?.data?.table === "delivery_graph_runs",
|
|
383
375
|
);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Contract guard for the Delivery Graphs compose → preview → dispatch App View (issue #441).
|
|
2
|
+
//
|
|
3
|
+
// The rich compile preview (mermaid diagram + humanNodes[] stop-points + sideEffects[] + inline
|
|
4
|
+
// path-qualified errors) and the gated approve → dispatch two-step are surfaced by an `appView` embed
|
|
5
|
+
// (pages/delivery-graphs/) over the EXISTING previewDeliveryGraph / dispatchDeliveryGraph doors — a
|
|
6
|
+
// bare `actionForm` discards its response and so can render none of that. This test pins the wiring so
|
|
7
|
+
// it can't silently regress: the sidecars exist, mount.js hits BOTH doors with base-relative defaults
|
|
8
|
+
// (the #279 App-View resolution class — a leading-slash path 404s through the console iframe), it
|
|
9
|
+
// renders each preview facet, and it implements the awaiting-approval → approve re-submit.
|
|
10
|
+
import { test } from "node:test";
|
|
11
|
+
import { assert } from "#test-assert";
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
|
|
14
|
+
const ROOT = decodeURIComponent(new URL("../", import.meta.url).pathname);
|
|
15
|
+
const DIR = `${ROOT}pages/delivery-graphs`;
|
|
16
|
+
const MOUNT_JS = readFileSync(`${DIR}/mount.js`, "utf8");
|
|
17
|
+
const EMBED_HTML = readFileSync(`${DIR}/embed.html`, "utf8");
|
|
18
|
+
const STANDALONE_HTML = readFileSync(`${DIR}/standalone.html`, "utf8");
|
|
19
|
+
|
|
20
|
+
// Pull the string default out of `const <name> = config.<name> ?? "<default>";` (or a module const).
|
|
21
|
+
function defaultUrl(name: string): string {
|
|
22
|
+
const m = MOUNT_JS.match(new RegExp(`${name}\\s*=\\s*config\\.\\w+\\s*\\?\\?\\s*(\\w+);`));
|
|
23
|
+
assert(m, `mount.js must default ${name} from config with a fallback constant`);
|
|
24
|
+
const constM = MOUNT_JS.match(new RegExp(`const ${m![1]}\\s*=\\s*"([^"]*)"`));
|
|
25
|
+
assert(constM, `mount.js must declare the ${m![1]} fallback as a string literal`);
|
|
26
|
+
return constM![1];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
test("#441: the delivery-graphs App View mounts the same module standalone and embedded", () => {
|
|
30
|
+
assert(/mountDeliveryGraphs/.test(MOUNT_JS), "mount.js must export mountDeliveryGraphs");
|
|
31
|
+
for (const [file, html] of [["embed.html", EMBED_HTML], ["standalone.html", STANDALONE_HTML]] as const) {
|
|
32
|
+
assert(/import \{ mountDeliveryGraphs \} from "\.\/mount\.js"/.test(html), `${file} must import mountDeliveryGraphs from ./mount.js`);
|
|
33
|
+
assert(/mountDeliveryGraphs\(/.test(html), `${file} must call mountDeliveryGraphs`);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("#441: mount.js wires BOTH the preview and dispatch doors", () => {
|
|
38
|
+
const previewUrl = defaultUrl("previewUrl");
|
|
39
|
+
const dispatchUrl = defaultUrl("dispatchUrl");
|
|
40
|
+
assert(previewUrl.endsWith("actions/delivery-graph/preview"), `previewUrl default "${previewUrl}" must hit the previewDeliveryGraph door`);
|
|
41
|
+
assert(dispatchUrl.endsWith("actions/delivery-graph/dispatch"), `dispatchUrl default "${dispatchUrl}" must hit the dispatchDeliveryGraph door`);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// The #279 App-View resolution class: a default endpoint must be BASE-RELATIVE (no leading slash) so
|
|
45
|
+
// it resolves under the console app-view base, not the console origin root (which 404s the door).
|
|
46
|
+
for (const name of ["previewUrl", "dispatchUrl"] as const) {
|
|
47
|
+
test(`#441/#279: default ${name} is base-relative (no leading slash)`, () => {
|
|
48
|
+
const def = defaultUrl(name);
|
|
49
|
+
assert(!def.startsWith("/"), `default ${name} "${def}" must not start with "/" — a leading-slash path resolves against the console iframe ORIGIN, not the app-view base, so the door 404s`);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
test("#441: the preview render consumes every compile facet the door returns", () => {
|
|
54
|
+
// The whole point of the issue: the preview data (diagram / humanNodes / sideEffects / errors) is
|
|
55
|
+
// rich but was consumed by nothing. Assert the renderer touches each facet at a CONCRETE call site
|
|
56
|
+
// (not a bare word, which a comment/string could satisfy) so a renderer that stops reading a field
|
|
57
|
+
// fails this guard.
|
|
58
|
+
const facetUse: Record<string, RegExp> = {
|
|
59
|
+
diagram: /esc\(result\.diagram\)/,
|
|
60
|
+
humanNodes: /renderHumanNodes\(result\.humanNodes\)/,
|
|
61
|
+
sideEffects: /renderSideEffects\(result\.sideEffects\)/,
|
|
62
|
+
errors: /renderErrors\(body\.error,\s*body\.errors\)/,
|
|
63
|
+
};
|
|
64
|
+
for (const [facet, re] of Object.entries(facetUse)) {
|
|
65
|
+
assert(re.test(MOUNT_JS), `mount.js must render the preview's \`${facet}\` via ${re.source}`);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("#441: dispatch implements the gated awaiting-approval → approve two-step", () => {
|
|
70
|
+
// Structural, not substring: pin the actual park-recognition branch and the approve re-submit call
|
|
71
|
+
// site, so a comment mentioning "approve" can't satisfy the guard.
|
|
72
|
+
assert(/body\.status === "awaiting-approval"/.test(MOUNT_JS), "mount.js must branch on the awaiting-approval park from the dispatch door");
|
|
73
|
+
assert(/doDispatch\(true/.test(MOUNT_JS), "mount.js must re-submit with approve (doDispatch(true, …)) on the operator's confirm");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("#441: approval binds to the frozen previewed graph, not the live (editable) textarea", () => {
|
|
77
|
+
// The server derives the approval digest from whatever body it receives, so an operator who edits
|
|
78
|
+
// the textarea after parking would silently approve+dispatch a DIFFERENT graph than the one
|
|
79
|
+
// previewed. mount.js must (a) capture the exact graph at park time and dispatch THAT on confirm,
|
|
80
|
+
// and (b) lock the compose inputs while approval is pending so they can't drift.
|
|
81
|
+
// Structural assertions (concrete call sites), not bare-word substrings a comment could satisfy:
|
|
82
|
+
assert(/const graph = frozen \? frozen\.graphJson : graphJson\(\)/.test(MOUNT_JS), "doDispatch must dispatch the frozen (park-time) graph, not the live textarea");
|
|
83
|
+
assert(/lockCompose\(true\)/.test(MOUNT_JS), "mount.js must lock the compose inputs (lockCompose(true)) while a graph is parked awaiting approval");
|
|
84
|
+
assert(/jsonEl\.readOnly = on/.test(MOUNT_JS), "lockCompose must make the graph input read-only while approval is pending");
|
|
85
|
+
});
|
|
@@ -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,
|
|
@@ -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
|
}
|