@nanobpm/nano-workforce 0.166.0 → 0.167.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 +6 -0
- package/app/backfillAcknowledgedAt.test.ts +121 -0
- package/app/delivery.ts +15 -13
- package/app/deliveryGraphReadModel.test.ts +44 -8
- package/app/deliveryGraphReadModel.ts +19 -2
- package/app/deliveryGraphRun.ts +5 -0
- package/app/epicBucket.test.ts +9 -3
- package/app/featureReadModel.ts +7 -11
- package/app/listBucket.ts +86 -0
- package/app/planReadModel.test.ts +29 -19
- package/app/planReadModel.ts +32 -26
- package/app/pullRequestReadModel.test.ts +208 -0
- package/app/pullRequestReadModel.ts +76 -0
- package/db/migrations/093_pull_requests_acknowledged_at.sql +30 -0
- package/db/migrations/094_pull_requests_read_model.sql +65 -0
- package/db/migrations/095_delivery_graph_acknowledged_at.sql +24 -0
- package/db/migrations/096_delivery_graph_read_model_list_bucket.sql +63 -0
- package/db/migrations/097_plan_read_model_terminal_dismiss.sql +65 -0
- package/db/migrations/098_delivery_graph_units_acknowledged_at.sql +63 -0
- package/openapi.yaml +98 -0
- package/operations/acknowledgeDeliveryGraph.test.ts +93 -0
- package/operations/acknowledgeDeliveryGraph.ts +58 -0
- package/operations/acknowledgePr.test.ts +94 -0
- package/operations/acknowledgePr.ts +62 -0
- package/package.json +1 -1
- package/pages/delivery-graphs.page.json +12 -3
- package/pages/home.page.json +18 -27
- package/pages/overview.page.json +26 -17
- package/scripts/pages-contract.test.ts +80 -18
- package/workers/record-results/worker.test.ts +5 -3
package/pages/overview.page.json
CHANGED
|
@@ -101,22 +101,9 @@
|
|
|
101
101
|
"data": {
|
|
102
102
|
"kind": "datasource",
|
|
103
103
|
"source": "app",
|
|
104
|
-
"table": "
|
|
104
|
+
"table": "pull_requests_read_model",
|
|
105
105
|
"orderBy": { "field": "updated_at", "dir": "desc" },
|
|
106
|
-
"filter": [
|
|
107
|
-
{
|
|
108
|
-
"field": "status",
|
|
109
|
-
"in": [
|
|
110
|
-
"converging",
|
|
111
|
-
"waiting_review",
|
|
112
|
-
"escalated",
|
|
113
|
-
"waiting_deps",
|
|
114
|
-
"waiting_merge",
|
|
115
|
-
"queued",
|
|
116
|
-
"merging"
|
|
117
|
-
]
|
|
118
|
-
}
|
|
119
|
-
]
|
|
106
|
+
"filter": [{ "field": "list_bucket", "in": ["active"] }]
|
|
120
107
|
},
|
|
121
108
|
"columns": [
|
|
122
109
|
{ "field": "title", "template": "{{title}}", "header": "Item", "subtitleField": "pr_key", "truncate": true, "width": "36%", "link": { "kind": "page", "page": "home", "keyField": "pr_key" } },
|
|
@@ -124,6 +111,17 @@
|
|
|
124
111
|
{ "field": "incident_message", "header": "Incident", "truncate": true, "badge": { "tone": "danger", "label": "1" } },
|
|
125
112
|
{ "field": "current_round", "template": "{{current_round}} · {{active_worker}}", "header": "Round · Agent" },
|
|
126
113
|
{ "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
|
|
114
|
+
],
|
|
115
|
+
"rowActions": [
|
|
116
|
+
{
|
|
117
|
+
"label": "Dismiss",
|
|
118
|
+
"confirm": "Dismiss this finished PR? It acknowledges the terminal pull request and files it under History.",
|
|
119
|
+
"showWhenField": "ack_open",
|
|
120
|
+
"action": {
|
|
121
|
+
"path": "/app/api/actions/acknowledge-pr",
|
|
122
|
+
"body": { "pr_key": "{{row.pr_key}}" }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
127
125
|
]
|
|
128
126
|
}
|
|
129
127
|
},
|
|
@@ -220,7 +218,7 @@
|
|
|
220
218
|
"source": "app",
|
|
221
219
|
"table": "delivery_graph_read_model",
|
|
222
220
|
"orderBy": { "field": "updated_at", "dir": "desc" },
|
|
223
|
-
"filter": [{ "field": "
|
|
221
|
+
"filter": [{ "field": "list_bucket", "in": ["active"] }]
|
|
224
222
|
},
|
|
225
223
|
"columns": [
|
|
226
224
|
{ "field": "title", "template": "{{title}}", "header": "Item", "subtitleField": "run_key", "truncate": true, "width": "30%", "link": { "kind": "page", "page": "delivery-graph-detail", "keyField": "run_key" } },
|
|
@@ -252,7 +250,18 @@
|
|
|
252
250
|
{ "field": "human_node_count", "label": "Human nodes" },
|
|
253
251
|
{ "field": "side_effect_count", "label": "Side effects" }
|
|
254
252
|
]
|
|
255
|
-
}
|
|
253
|
+
},
|
|
254
|
+
"rowActions": [
|
|
255
|
+
{
|
|
256
|
+
"label": "Dismiss",
|
|
257
|
+
"confirm": "Dismiss this finished delivery graph? It acknowledges the terminal run and files it under History.",
|
|
258
|
+
"showWhenField": "ack_open",
|
|
259
|
+
"action": {
|
|
260
|
+
"path": "/app/api/actions/acknowledge-delivery-graph",
|
|
261
|
+
"body": { "run_key": "{{row.run_key}}" }
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
]
|
|
256
265
|
}
|
|
257
266
|
}
|
|
258
267
|
]
|
|
@@ -319,27 +319,21 @@ test("issue #205: overview is the landing page and first nav item", async () =>
|
|
|
319
319
|
|
|
320
320
|
// Four collapsible active-work sections, one per dispatch surface, each with a
|
|
321
321
|
// live count in its header (showCount) and a persisted collapse toggle (collapsible). Each filters
|
|
322
|
-
// its Active list on a `{field, in:[...]}` predicate.
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
// shared byte-for-byte with the Feature tab); the epic surface binds `plan_read_model` (epic #412),
|
|
330
|
-
// not the raw `plans` table. PRs / delivery graphs still bucket on `status` (see #637 follow-up).
|
|
322
|
+
// its Active list on a `{field, in:[...]}` predicate. ALL FOUR surfaces now bucket on the DERIVED
|
|
323
|
+
// `list_bucket` — NOT raw `status` — over their read-model VIEW (features: issue #637; epics: #298;
|
|
324
|
+
// PRs + delivery graphs: issue #641 — retiring the last two base-`status` allowlists), so a terminal-
|
|
325
|
+
// but-unacknowledged item stays in its in-flight section until an operator dismisses it, instead of
|
|
326
|
+
// vanishing the instant `status` reads terminal. Guarding the field here is the regression guard for
|
|
327
|
+
// that defect class. Each surface binds its declare-once read-model VIEW (the single source of truth
|
|
328
|
+
// for the `list_bucket` activeness predicate), never a raw base table.
|
|
331
329
|
const expected: Record<string, { field: string; in: string[] }> = {
|
|
332
|
-
|
|
333
|
-
field: "status",
|
|
334
|
-
in: ["converging", "waiting_review", "escalated", "waiting_deps", "waiting_merge", "queued", "merging"],
|
|
335
|
-
},
|
|
330
|
+
pull_requests_read_model: { field: "list_bucket", in: ["active"] },
|
|
336
331
|
plan_read_model: { field: "list_bucket", in: ["active"] },
|
|
337
332
|
feature_read_model: { field: "list_bucket", in: ["active"] },
|
|
338
|
-
// The 4th dispatch surface (issue #386) — active delivery graphs.
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
|
|
342
|
-
delivery_graph_read_model: { field: "status", in: ["awaiting-approval", "running"] },
|
|
333
|
+
// The 4th dispatch surface (issue #386) — active delivery graphs. Binds the derived
|
|
334
|
+
// `delivery_graph_read_model` VIEW (S7 / #541), now filtered on `list_bucket` (issue #641) so a
|
|
335
|
+
// terminal run stays Active until dismissed rather than dropping on its base `status`.
|
|
336
|
+
delivery_graph_read_model: { field: "list_bucket", in: ["active"] },
|
|
343
337
|
};
|
|
344
338
|
const grids = (overview.nodes ?? []).filter((n: Json) => n.type === "dataGrid");
|
|
345
339
|
for (const [table, { field, in: values }] of Object.entries(expected)) {
|
|
@@ -357,6 +351,74 @@ test("issue #205: overview is the landing page and first nav item", async () =>
|
|
|
357
351
|
});
|
|
358
352
|
|
|
359
353
|
|
|
354
|
+
test("issue #641: no Active/History grid filters a base-`status` allowlist — every dispatch Active list buckets on a derived read-model VIEW", async () => {
|
|
355
|
+
// The drift class this closes (the last mile of #637): an "Active …" dispatch list that re-encodes
|
|
356
|
+
// activeness as a hardcoded base-`status` allowlist (`{ field: "status", in: [...] }`) instead of the
|
|
357
|
+
// declare-once derived `list_bucket` over its read-model VIEW. Such an allowlist silently drops a row
|
|
358
|
+
// out of Active the instant its `status` leaves the set — with NO operator dismiss — and must be kept
|
|
359
|
+
// hand-synced with every status rename. #641 retired the last two (PRs + delivery graphs); this guard
|
|
360
|
+
// makes the whole class a BUILD FAILURE so it can never creep back in on a new/edited grid.
|
|
361
|
+
//
|
|
362
|
+
// SCOPE — an "Active/History surface" is a top-level grid that partitions rows into active vs.
|
|
363
|
+
// finished: either its title starts with "Active " (the Overview sections) OR it carries a `History`
|
|
364
|
+
// tab (the Feature/Epic/PR/Delivery-Graph/lineage grids). This deliberately EXCLUDES the param-scoped
|
|
365
|
+
// per-epic slice grids (epic-detail's "Wave state" Active/Skipped/All, "Trial-merge results"
|
|
366
|
+
// Needs-attention/Clean/All), which legitimately filter a domain `status`/`result` within one epic —
|
|
367
|
+
// they have no `History` tab and no "Active " title, so they are not activeness partitions.
|
|
368
|
+
const BASE_TABLES = new Set(["pull_requests", "plans", "feature_runs", "delivery_graph_runs"]);
|
|
369
|
+
const grids: Json[] = [];
|
|
370
|
+
const walk = (n: Json): void => {
|
|
371
|
+
if (Array.isArray(n)) return n.forEach(walk);
|
|
372
|
+
if (n && typeof n === "object") {
|
|
373
|
+
if (n.type === "dataGrid") grids.push(n);
|
|
374
|
+
for (const v of Object.values(n)) walk(v);
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
for (const e of readdirSync(`${ROOT}pages`, { withFileTypes: true })) {
|
|
378
|
+
if (!e.isFile() || !e.name.endsWith(".page.json")) continue;
|
|
379
|
+
const page = JSON.parse(readFileSync(`${ROOT}pages/${e.name}`, "utf8"));
|
|
380
|
+
walk(page.nodes ?? page);
|
|
381
|
+
// Tag each grid with its page for messages.
|
|
382
|
+
for (const g of grids) if (g.__page === undefined) g.__page = e.name;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
let surfaces = 0;
|
|
386
|
+
for (const g of grids) {
|
|
387
|
+
const props = g.props ?? {};
|
|
388
|
+
const title: string = typeof props.title === "string" ? props.title : "";
|
|
389
|
+
const tabs: Json[] = Array.isArray(props.tabs) ? props.tabs : [];
|
|
390
|
+
const hasHistoryTab = tabs.some((t) => typeof t?.label === "string" && /history/i.test(t.label));
|
|
391
|
+
const activeTitle = /^active\s/i.test(title);
|
|
392
|
+
if (!hasHistoryTab && !activeTitle) continue; // not an activeness partition — skip
|
|
393
|
+
surfaces++;
|
|
394
|
+
|
|
395
|
+
const table: string = g.props?.data?.table ?? "";
|
|
396
|
+
// 1) It must bind a DERIVED read-model relation, never a raw base table (whose only activeness
|
|
397
|
+
// signal is the base `status` this guard forbids filtering on).
|
|
398
|
+
assert(
|
|
399
|
+
!BASE_TABLES.has(table),
|
|
400
|
+
`${g.__page}: Active/History grid "${title || tabs.map((t) => t.label).join("/")}" binds the raw base table "${table}" — bind its derived read-model VIEW (…_read_model) so it can filter the declared list_bucket`,
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
// 2) No filter — the grid's main filter OR any tab filter — may re-encode activeness as a
|
|
404
|
+
// base-`status` allowlist. (A domain filter on another column is fine; only `status IN […]` is
|
|
405
|
+
// the banned drift surface.)
|
|
406
|
+
const allFilters: Json[] = [g.props?.data?.filter, ...tabs.map((t) => t?.filter)];
|
|
407
|
+
for (const filter of allFilters) {
|
|
408
|
+
if (!Array.isArray(filter)) continue;
|
|
409
|
+
for (const pred of filter) {
|
|
410
|
+
assert(
|
|
411
|
+
!(pred?.field === "status" && (Array.isArray(pred.in) || pred.eq !== undefined)),
|
|
412
|
+
`${g.__page}: Active/History grid "${title || tabs.map((t) => t.label).join("/")}" filters a base-\`status\` allowlist ` +
|
|
413
|
+
`(${JSON.stringify(pred)}) — bucket on the derived \`list_bucket\` over the read-model VIEW instead (issue #641).`,
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
assert(surfaces >= 6, `expected to find the dispatch Active/History surfaces, found only ${surfaces}`);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
|
|
360
422
|
test("issue #386: the human-facing Delivery Graphs surface is wired (nav tab, page, detail, overview)", async () => {
|
|
361
423
|
// 1) A `Delivery Graphs` nav tab in the single-source nav, pointing at the delivery-graphs page.
|
|
362
424
|
const nav = JSON.parse(readFileSync(`${ROOT}pages/_nav.json`, "utf8"));
|
|
@@ -78,9 +78,11 @@ test("no opened PRs (empty plan) hard-fails with NO_WORK_DISPATCHED", async () =
|
|
|
78
78
|
const plan = app._plans.at(-1) as Record<string, unknown>;
|
|
79
79
|
assertEquals(plan.status, "failed");
|
|
80
80
|
assertEquals(plan.outcome, "no work dispatched — the planner produced no tasks");
|
|
81
|
-
// `list_bucket` is derived by the `plan_read_model` VIEW
|
|
82
|
-
// (
|
|
83
|
-
|
|
81
|
+
// `list_bucket` is derived by the `plan_read_model` VIEW: under the uniform acknowledge-to-dismiss
|
|
82
|
+
// rule (issue #641) a terminal-but-UNACKNOWLEDGED epic — including a `failed` one — STAYS in Active
|
|
83
|
+
// (offering the operator a Dismiss) until it is acknowledged, rather than vanishing straight to
|
|
84
|
+
// History. Cross-checked against the pure `deriveEpicBucket` oracle the VIEW mirrors.
|
|
85
|
+
assertEquals(deriveEpicBucket(plan.status as string, null, plan.acknowledged_at as string | null), "active");
|
|
84
86
|
});
|
|
85
87
|
|
|
86
88
|
test("tasks present but none opened (all skipped/blocked) hard-fails", async () => {
|