@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.
@@ -101,22 +101,9 @@
101
101
  "data": {
102
102
  "kind": "datasource",
103
103
  "source": "app",
104
- "table": "pull_requests",
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": "status", "in": ["awaiting-approval", "running"] }]
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. The FEATURE and EPIC surfaces bucket on the
323
- // DERIVED `list_bucket` — NOT raw `status` — over their read-model VIEW (features: issue #637
324
- // fixing a drift where the grid read the vestigial base `feature_runs.status` with an allowlist that
325
- // hid every converging feature; epics: issue #298), so a `done`-but-unacknowledged item still
326
- // converging does not vanish from its in-flight section the instant `status` reads terminal. Guarding
327
- // the field here is the regression guard for that defect class. The feature surface binds the derived
328
- // `feature_read_model` VIEW (the single source of truth for the `list_bucket` activeness predicate,
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
- pull_requests: {
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. Both in-flight statuses
339
- // (`awaiting-approval` parked at the gate, `running` dispatched) show here. Binds the derived
340
- // `delivery_graph_read_model` VIEW (S7 / #541 the single source of truth for the pipeline
341
- // projection it also renders), which re-exports every run column plus the effective `status`.
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 (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");
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 () => {