@nanobpm/nano-workforce 0.136.0 → 0.137.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.
@@ -0,0 +1,127 @@
1
+ // The plan-family GROUP-BY rollups — DECLARED ONCE and compiled to BOTH backends via Urban's ADR-0065
2
+ // rollup primitive (`defineRollup`, `@nanobpm/urban`, capability nano-ide#468 / `@nanobpm/urban@0.82.0`).
3
+ //
4
+ // Background (issues #411 → #412 → #493). The Epic surfaces render DERIVED aggregate state — each epic's
5
+ // per-wave six-way task partition (059's `plan_wave_counts`), its wave FRONTIER (`wave_count`/
6
+ // `current_wave`, 060's `plan_wave_progress`), and its slice-PR landing counts (061's
7
+ // `plan_delivery_counts`). None of these are ground truth: each is a pure GROUP BY over `plan_tasks`
8
+ // joined to `pull_requests`. 059/060/061 expressed them as hand-authored SQL VIEWs *and* the runtime
9
+ // TS (`deriveDelivery` folded the same counts; the wave frontier was reproduced in the poller), kept in
10
+ // lockstep by hand-written parity tests — the ADR-0065 drift surface #2 (each aggregate authored twice).
11
+ //
12
+ // This module closes surface #2 for the aggregates STRUCTURALLY: each rollup is declared ONCE in Urban's
13
+ // closed GROUP-BY spec (`defineRollup`), and Urban compiles it to BOTH the managed `*_counts` VIEW
14
+ // (`viewDdl`/`sqlAggFor`, emitted verbatim into the superseding migration, drift-guarded) AND the
15
+ // runtime TS group-reduce (`reduce`, the sole engine behind the `deriveDelivery` façade in app/
16
+ // delivery.ts). The two lowerings fall out of the SAME closed spec, and `assertRollupParity` (app/
17
+ // planReadModel.test.ts) is the framework-owned regression guard that they agree.
18
+ //
19
+ // The per-row signals that CONSUME these counts (delivery / list_bucket / ack_open) live in
20
+ // app/planReadModel.ts (`defineReadModel` + key-correlated rollup lookups); the pre-formatted display
21
+ // strings (`bar`, `wave_label`, `delivery_label`) stay hand-authored over these derived columns (D3 —
22
+ // display formatting is out of the framework AST). Use app/featureReadModel.ts as the exemplar.
23
+
24
+ import { add, and, coalesce, col, count, countWhere, defineRollup, eq, fromRollup, gt, isNotNull, joinSource, lit, max, minWhere, not, or, type Rollup } from "@nanobpm/urban";
25
+ import { TERMINAL_STATUSES } from "./deliveryStatuses.ts";
26
+
27
+ /** The slice-PR relation the delivery/wave rollups join: the auto-provisioned `pull_requests__tracking`
28
+ * derived VIEW (ADR-0065), NOT the raw `pull_requests` table. It re-exports `pull_requests.*` plus a
29
+ * terminal-folded `derived_status` (`abandoned` on an out-of-band-terminated PR instance, else the base
30
+ * `pull_requests.status`). Reading `derived_status` here — the SAME column the canonical runtime reads
31
+ * via `prsTracking` (app/service.ts `derivePlanDelivery`) — keeps the SQL VIEW counts and the runtime
32
+ * in agreement: an out-of-band-terminated converging slice reads `abandoned` (resolved), so the VIEW
33
+ * can never wedge an epic at `delivery = 'converging'` after its PR was cancelled. Exported so the
34
+ * app/delivery.ts adapter feeds its synthesised leaf rows under the same relation name. */
35
+ export const PR_TRACKING_RELATION = "pull_requests__tracking";
36
+
37
+ /** `plan_tasks t LEFT JOIN pull_requests__tracking p ON p.pr_key = t.pr_key` as a rollup source, with a
38
+ * FLAT output namespace so the closed aggregate/predicate machinery reads unqualified column names.
39
+ * `pr_status` is the tracking VIEW's terminal-folded `derived_status` (see {@link PR_TRACKING_RELATION}).
40
+ * The two plan-family count rollups both fold over this two-hop join (D4). */
41
+ const planTasksJoinPrs = joinSource({
42
+ left: { relation: "plan_tasks", alias: "t" },
43
+ right: { relation: PR_TRACKING_RELATION, alias: "p" },
44
+ on: [{ left: "pr_key", right: "pr_key" }],
45
+ columns: {
46
+ plan_key: ["left", "plan_key"],
47
+ wave: ["left", "wave"],
48
+ task_status: ["left", "status"],
49
+ pr_key: ["left", "pr_key"],
50
+ pr_status: ["right", "derived_status"],
51
+ },
52
+ });
53
+
54
+ /** `<pr_status> <> 'merged'` under the shared "NULL → not-merged" rule: `not(eq(...))` compiles to
55
+ * `NOT COALESCE((pr_status = 'merged'), 0)` in SQL and the nullish-guarded negation in TS, so a task
56
+ * whose PR row is absent (`pr_status` NULL) is treated as NOT merged in BOTH backends — matching 059's
57
+ * `WHEN p.status = 'merged' THEN 0 …` fall-through (a NULL `p.status` never matches the merged arm). */
58
+ const prNotMerged = not(eq(col("pr_status"), lit("merged")));
59
+
60
+ /** `<task_status> = <value>` for a NON-merged task — the priority-ordered five-way bucket predicate the
61
+ * 059 partition uses (`WHEN p.status = 'merged' THEN 0 WHEN t.status = '<b>' THEN 1 ELSE 0`), so the
62
+ * five named buckets stay DISJOINT with `merged` and sum to `total`. */
63
+ const nonMergedTaskIs = (bucket: string) => and(prNotMerged, eq(col("task_status"), lit(bucket)));
64
+
65
+ /**
66
+ * `plan_wave_counts` — one row per `(plan_key, wave)` with the six-way task partition (059). A task is
67
+ * `merged` iff its PR reached `pull_requests.status = 'merged'`; otherwise it falls to its
68
+ * `plan_tasks.status` bucket, and everything else (pending/opened/waiting-for-lane/abandoned) is
69
+ * `in_flight`. The CASE priority (encoded as disjoint `countWhere` predicates) keeps the five named
70
+ * buckets DISJOINT so they always sum to `total`. `WHERE wave IS NOT NULL` drops un-levelized tasks.
71
+ */
72
+ export const planWaveCounts: Rollup = defineRollup({
73
+ name: "plan_wave_counts",
74
+ source: planTasksJoinPrs,
75
+ groupBy: ["plan_key", "wave"],
76
+ where: isNotNull(col("wave")),
77
+ aggregates: {
78
+ total: count(),
79
+ merged: countWhere(eq(col("pr_status"), lit("merged"))),
80
+ skipped: countWhere(nonMergedTaskIs("skipped")),
81
+ blocked: countWhere(nonMergedTaskIs("blocked")),
82
+ escalated: countWhere(nonMergedTaskIs("escalated")),
83
+ in_flight: countWhere(and(prNotMerged, not(or(eq(col("task_status"), lit("skipped")), eq(col("task_status"), lit("blocked")), eq(col("task_status"), lit("escalated")))))),
84
+ },
85
+ });
86
+
87
+ /**
88
+ * `plan_wave_progress` — one row per `plan_key` with the two wave-frontier projections (060), COMPOSED
89
+ * over `plan_wave_counts` (a rollup source — D1's composability):
90
+ * - `wave_count` = `MAX(wave) + 1` (the levelizer emits contiguous waves 0..N-1).
91
+ * - `current_wave` = the live FRONTIER: the lowest wave that still has an `in_flight` task, else —
92
+ * once every wave has settled — pinned to the last index `MAX(wave)`.
93
+ * A plan with no levelized tasks contributes no `plan_wave_counts` row, so it is absent here and reads
94
+ * NULL through the downstream read-model LEFT JOIN — matching the workers' taskless-plan behaviour.
95
+ */
96
+ export const planWaveProgress: Rollup = defineRollup({
97
+ name: "plan_wave_progress",
98
+ source: fromRollup(planWaveCounts),
99
+ groupBy: ["plan_key"],
100
+ aggregates: {
101
+ wave_count: add(max("wave"), 1),
102
+ current_wave: coalesce(minWhere("wave", gt(col("in_flight"), lit(0))), max("wave")),
103
+ },
104
+ });
105
+
106
+ /**
107
+ * `plan_delivery_counts` — one row per `plan_key` with the three counts `deriveDelivery` folds over the
108
+ * slice PRs (061). Only tasks that OPENED a PR count (`prs_opened = COUNT(pr_key)`, non-NULL). A
109
+ * `pr_key` with no `pull_requests` row (`pr_status` NULL, the poller's `MISSING_PR_STATUS` sentinel) is
110
+ * non-terminal, so it counts as `prs_in_flight` — a DB desync can never wrongly promote an epic to
111
+ * `landed`. `prs_in_flight` = opened PRs whose status is NOT in {@link TERMINAL_STATUSES}
112
+ * (a NULL status is not terminal), exactly `deriveDelivery`'s in-flight fold.
113
+ */
114
+ export const planDeliveryCounts: Rollup = defineRollup({
115
+ name: "plan_delivery_counts",
116
+ source: planTasksJoinPrs,
117
+ groupBy: ["plan_key"],
118
+ aggregates: {
119
+ prs_opened: count("pr_key"),
120
+ prs_merged: countWhere(and(isNotNull(col("pr_key")), eq(col("pr_status"), lit("merged")))),
121
+ prs_in_flight: countWhere(and(isNotNull(col("pr_key")), not(or(...TERMINAL_STATUSES.map((s) => eq(col("pr_status"), lit(s))))))),
122
+ },
123
+ });
124
+
125
+ /** Every plan-family rollup, in managed-VIEW dependency order (a composed rollup after the rollup it
126
+ * reads). The superseding migration emits their VIEW DDL in this order; the parity guard iterates it. */
127
+ export const PLAN_ROLLUPS: readonly Rollup[] = [planWaveCounts, planDeliveryCounts, planWaveProgress];
@@ -0,0 +1,74 @@
1
+ -- Plan-family GROUP-BY rollups, authored ONCE via Urban's ADR-0065 rollup primitive (`defineRollup`,
2
+ -- app/planRollups.ts, `@nanobpm/urban@0.82.0` / nano-ide#468) — issue #493 (the plan-family twin of
3
+ -- 076's feature read-model declare-once).
4
+ --
5
+ -- 059_plan_wave_summary.sql, 060_plan_wave_rollup.sql and 061_plan_delivery_rollup.sql hand-authored
6
+ -- the three plan-family aggregates — `plan_wave_counts` (per-(plan_key, wave) six-way task partition),
7
+ -- `plan_wave_progress` (the wave frontier `wave_count`/`current_wave`), and `plan_delivery_counts` (the
8
+ -- slice-PR landing counts) — as SQL VIEWs, AND folded the SAME counts a SECOND time in the runtime TS
9
+ -- (`deriveDelivery` folded the delivery counts; the poller reproduced the wave frontier). That is the
10
+ -- ADR-0065 drift surface #2 (each aggregate authored twice, kept in lockstep by hand-written parity
11
+ -- tests). This migration SUPERSEDES the three VIEW bodies with the VERBATIM DDL Urban compiles from the
12
+ -- ONE `defineRollup` declaration for each (app/planRollups.ts) — which ALSO drives the runtime TS
13
+ -- group-reduce (`reduce`, the sole engine behind `deriveDelivery` in app/delivery.ts). The two
14
+ -- lowerings fall out of the same closed GROUP-BY spec and cannot diverge; the drift guard
15
+ -- (app/planReadModel.test.ts) fails if this file stops matching the declaration, and
16
+ -- `assertRollupParity` proves the VIEW and TS reduce agree. 059/060/061 are MERGED, IMMUTABLE
17
+ -- migrations — never edited; this is a NEW migration superseding their VIEW bodies (the pattern by
18
+ -- which 081 superseded 076's feature VIEW).
19
+ --
20
+ -- SEMANTICS are unchanged from 059/060/061 (validated byte-identical over a random corpus): a task is
21
+ -- `merged` iff its PR reached `pull_requests__tracking.derived_status = 'merged'`, otherwise it falls to its
22
+ -- `plan_tasks.status` bucket; the five named buckets stay DISJOINT and sum to `total`; the delivery
23
+ -- counts fold only tasks that OPENED a PR, and a dangling `pr_key` (NULL status) counts as in-flight so
24
+ -- a DB desync can never wrongly promote an epic to `landed`. Each rollup emits `CREATE VIEW <name> AS
25
+ -- SELECT … FROM … GROUP BY …` (aggregates aliased in the select-list) so the static pages↔schema
26
+ -- contract guard (scripts/pages-contract.test.ts) still reads each VIEW's output columns.
27
+ --
28
+ -- Layered in dependency order: `plan_wave_counts` first (the leaf GROUP BY over `plan_tasks` LEFT JOIN
29
+ -- `pull_requests__tracking` (ADR-0065 derived VIEW; reads the terminal-folded `derived_status`, the
30
+ -- SAME column the canonical runtime reads), then `plan_delivery_counts` (a sibling GROUP BY over the same join), then
31
+ -- `plan_wave_progress` (COMPOSED over `plan_wave_counts` — D1's composability). The retained
32
+ -- `plan_wave_summary` (059, the `bar` glyph) and `plan_wave_label` (060) VIEWs read the recreated
33
+ -- `plan_wave_counts`/`plan_wave_progress` unchanged. A merged VIEW is not editable in place, so each is
34
+ -- DROP+CREATE; nothing structural changes, so every dependent VIEW and page binding stays valid.
35
+ --
36
+ -- Forward-only VIEW redefinition (DROP then CREATE). The runner wraps each file in its own transaction,
37
+ -- so this file must NOT contain BEGIN/COMMIT. Numbered after 081.
38
+
39
+ DROP VIEW IF EXISTS plan_wave_counts;
40
+
41
+ CREATE VIEW IF NOT EXISTS "plan_wave_counts" AS
42
+ SELECT
43
+ "t"."plan_key" AS "plan_key",
44
+ "t"."wave" AS "wave",
45
+ SUM(CASE WHEN COALESCE(((NOT COALESCE(COALESCE(("p"."derived_status" = 'merged'), 0), 0)) AND COALESCE(("t"."status" = 'blocked'), 0)), 0) THEN 1 ELSE 0 END) AS "blocked",
46
+ SUM(CASE WHEN COALESCE(((NOT COALESCE(COALESCE(("p"."derived_status" = 'merged'), 0), 0)) AND COALESCE(("t"."status" = 'escalated'), 0)), 0) THEN 1 ELSE 0 END) AS "escalated",
47
+ SUM(CASE WHEN COALESCE(((NOT COALESCE(COALESCE(("p"."derived_status" = 'merged'), 0), 0)) AND (NOT COALESCE(COALESCE((COALESCE(("t"."status" = 'skipped'), 0) OR COALESCE(("t"."status" = 'blocked'), 0) OR COALESCE(("t"."status" = 'escalated'), 0)), 0), 0))), 0) THEN 1 ELSE 0 END) AS "in_flight",
48
+ SUM(CASE WHEN COALESCE(("p"."derived_status" = 'merged'), 0) THEN 1 ELSE 0 END) AS "merged",
49
+ SUM(CASE WHEN COALESCE(((NOT COALESCE(COALESCE(("p"."derived_status" = 'merged'), 0), 0)) AND COALESCE(("t"."status" = 'skipped'), 0)), 0) THEN 1 ELSE 0 END) AS "skipped",
50
+ COUNT(*) AS "total"
51
+ FROM "plan_tasks" "t" LEFT JOIN "pull_requests__tracking" "p" ON "t"."pr_key" = "p"."pr_key"
52
+ WHERE (NOT COALESCE(("t"."wave" IS NULL), 0))
53
+ GROUP BY "t"."plan_key", "t"."wave";
54
+
55
+ DROP VIEW IF EXISTS plan_delivery_counts;
56
+
57
+ CREATE VIEW IF NOT EXISTS "plan_delivery_counts" AS
58
+ SELECT
59
+ "t"."plan_key" AS "plan_key",
60
+ SUM(CASE WHEN COALESCE(((NOT COALESCE(("t"."pr_key" IS NULL), 0)) AND (NOT COALESCE(COALESCE((COALESCE(("p"."derived_status" = 'converged'), 0) OR COALESCE(("p"."derived_status" = 'merged'), 0) OR COALESCE(("p"."derived_status" = 'abandoned'), 0)), 0), 0))), 0) THEN 1 ELSE 0 END) AS "prs_in_flight",
61
+ SUM(CASE WHEN COALESCE(((NOT COALESCE(("t"."pr_key" IS NULL), 0)) AND COALESCE(("p"."derived_status" = 'merged'), 0)), 0) THEN 1 ELSE 0 END) AS "prs_merged",
62
+ COUNT("t"."pr_key") AS "prs_opened"
63
+ FROM "plan_tasks" "t" LEFT JOIN "pull_requests__tracking" "p" ON "t"."pr_key" = "p"."pr_key"
64
+ GROUP BY "t"."plan_key";
65
+
66
+ DROP VIEW IF EXISTS plan_wave_progress;
67
+
68
+ CREATE VIEW IF NOT EXISTS "plan_wave_progress" AS
69
+ SELECT
70
+ "__urban_rollup_src"."plan_key" AS "plan_key",
71
+ COALESCE(MIN(CASE WHEN COALESCE(("__urban_rollup_src"."in_flight" > 0), 0) THEN "__urban_rollup_src"."wave" END), MAX("__urban_rollup_src"."wave")) AS "current_wave",
72
+ (MAX("__urban_rollup_src"."wave") + 1) AS "wave_count"
73
+ FROM "plan_wave_counts" "__urban_rollup_src"
74
+ GROUP BY "__urban_rollup_src"."plan_key";
@@ -0,0 +1,84 @@
1
+ -- Epic `plan_read_model` per-row signals, authored ONCE via Urban's ADR-0065 reconciling-read-model
2
+ -- primitive (`defineReadModel` + key-correlated rollup lookups, app/planReadModel.ts,
3
+ -- `@nanobpm/urban@0.82.0` / nano-ide#468) — issue #493 (the plan-family twin of 076/081's feature
4
+ -- read-model declare-once; the exemplar is app/featureReadModel.ts).
5
+ --
6
+ -- 061_plan_delivery_rollup.sql, 074_plan_read_model_derive_bucket.sql and
7
+ -- 080_plan_read_model_derive_terminal.sql hand-authored the epic per-row signals — `delivery`
8
+ -- (converging/landed/NULL), the Active/History `list_bucket`, and the operator-Dismiss `ack_open`
9
+ -- flag — as SQL `CASE` expressions inside the `plan_delivery` / `plan_read_model` VIEWs, AND a SECOND
10
+ -- time in the runtime TS (`deriveDelivery`/`deriveEpicBucket`/`epicIsAcknowledgeable`, app/delivery.ts),
11
+ -- kept in lockstep by hand-written parity tests (ADR-0065 drift surface #2). This migration SUPERSEDES
12
+ -- 080's `plan_read_model` VIEW body: every DERIVED column below is emitted VERBATIM from the ONE
13
+ -- `planReadModel` declaration (`planReadModel.sqlSelectFor(col, { baseAlias: "pl" })`), which ALSO
14
+ -- drives the runtime TS via `fnFor` (behind the app/delivery.ts adapters). The two lowerings fall out
15
+ -- of the same closed-DSL AST and cannot diverge; the drift guard (app/planReadModel.test.ts) fails if
16
+ -- this file stops matching the declaration, and `assertReadModelParity` proves the SQL and TS lowerings
17
+ -- agree. 061/074/080 are MERGED, IMMUTABLE migrations — never edited; this is a NEW migration
18
+ -- superseding 080's VIEW body (the pattern by which 080 superseded 074).
19
+ --
20
+ -- SEMANTICS are unchanged from 080 (validated byte-identical over a random corpus): the status-
21
+ -- classifying `list_bucket`/`ack_open` arms read the terminal-folded `derived_status` off the
22
+ -- auto-provisioned `plans__tracking` derived VIEW (so a cancelled epic drops out of Active with no
23
+ -- worker write, issue #503); `delivery` reads the base `plans.status` (`done` is already terminal, so
24
+ -- base and effective agree on the `= 'done'` gate, and this keeps `delivery` byte-identical to the
25
+ -- retired `plan_delivery` VIEW and to `deriveDelivery(plan.status, …)`'s call sites). The single-valued
26
+ -- rollup lookups the CASEs consume are `LEFT JOIN plan_delivery_counts dc` (slice-PR counts, defaulted
27
+ -- to 0 on a miss) and `LEFT JOIN plan_wave_progress wp` (the wave frontier), the D1 per-row half over
28
+ -- the rollups single-sourced in 082.
29
+ --
30
+ -- The pre-formatted DISPLAY strings stay hand-authored over these derived structured columns (D3 —
31
+ -- display formatting is out of the framework AST, so they carry no TS twin): `delivery_label` mirrors
32
+ -- the retired `plan_delivery` label CASE (over the base status + `dc` counts) and `wave_label` is the
33
+ -- 1-based "X/N" string over the `wp` frontier. The wave `bar` glyph stays in the retained
34
+ -- `plan_wave_summary` VIEW (059). Base columns stay aliased pass-throughs (so the static pages↔schema
35
+ -- contract guard still reads the VIEW's columns), sourced off `plans__tracking`'s re-export of the base
36
+ -- `plans.*`; `plans__tracking pl` is the sole top-level FROM.
37
+ --
38
+ -- The retired intermediate VIEWs `plan_delivery` (061) and `plan_wave_label` (060) were consumed ONLY
39
+ -- by `plan_read_model`; this migration folds their derivations/display into the composite and DROPs
40
+ -- them. `plan_read_model` is a leaf (no VIEW builds on it) and its output column set is UNCHANGED, so
41
+ -- the pages↔schema contract guard and every page binding stay valid. `plans__tracking` is the managed
42
+ -- VIEW urban provisions at mount; SQLite does not validate a view body at CREATE time, so this
43
+ -- migration (which runs before that mount) is created fine and resolves once the managed VIEW exists.
44
+ --
45
+ -- Forward-only VIEW redefinition (DROP then CREATE). The runner wraps each file in its own transaction,
46
+ -- so this file must NOT contain BEGIN/COMMIT. Numbered after 082.
47
+
48
+ DROP VIEW IF EXISTS plan_read_model;
49
+ DROP VIEW IF EXISTS plan_delivery;
50
+ DROP VIEW IF EXISTS plan_wave_label;
51
+
52
+ CREATE VIEW plan_read_model AS
53
+ SELECT
54
+ pl.plan_key AS plan_key,
55
+ pl.repo AS repo,
56
+ pl.issue_number AS issue_number,
57
+ pl.issue_url AS issue_url,
58
+ pl.title AS title,
59
+ COALESCE(pl.derived_status, pl.status) AS status,
60
+ pl.task_count AS task_count,
61
+ pl.process_key AS process_key,
62
+ pl.outcome AS outcome,
63
+ pl.updated_at AS updated_at,
64
+ pl.epic_phase AS epic_phase,
65
+ pl.base_branch AS base_branch,
66
+ pl.wait_gate_label AS wait_gate_label,
67
+ pl.bound_artifacts AS bound_artifacts,
68
+ pl.promotion_pr AS promotion_pr,
69
+ pl.promotion_state AS promotion_state,
70
+ CASE WHEN COALESCE(((NOT COALESCE(COALESCE(("pl"."status" = 'done'), 0), 0)) OR COALESCE((COALESCE("dc"."prs_opened", 0) = 0), 0)), 0) THEN NULL WHEN COALESCE((COALESCE("dc"."prs_in_flight", 0) > 0), 0) THEN 'converging' WHEN COALESCE((COALESCE("dc"."prs_merged", 0) = COALESCE("dc"."prs_opened", 0)), 0) THEN 'landed' ELSE NULL END AS delivery,
71
+ "wp"."wave_count" AS wave_count,
72
+ "wp"."current_wave" AS current_wave,
73
+ CASE WHEN COALESCE((COALESCE(("pl"."derived_status" = 'planning'), 0) OR COALESCE(("pl"."derived_status" = 'dispatched'), 0)), 0) THEN 'active' WHEN COALESCE((COALESCE(("pl"."derived_status" = 'done'), 0) AND COALESCE((CASE WHEN COALESCE(((NOT COALESCE(COALESCE(("pl"."status" = 'done'), 0), 0)) OR COALESCE((COALESCE("dc"."prs_opened", 0) = 0), 0)), 0) THEN NULL WHEN COALESCE((COALESCE("dc"."prs_in_flight", 0) > 0), 0) THEN 'converging' WHEN COALESCE((COALESCE("dc"."prs_merged", 0) = COALESCE("dc"."prs_opened", 0)), 0) THEN 'landed' ELSE NULL END = 'converging'), 0)), 0) THEN 'active' WHEN COALESCE((COALESCE(("pl"."derived_status" = 'done'), 0) AND ("pl"."acknowledged_at" IS NULL)), 0) THEN 'active' WHEN COALESCE(("pl"."derived_status" = 'done'), 0) THEN 'history' ELSE 'history' END AS list_bucket,
74
+ CASE WHEN COALESCE((COALESCE(("pl"."derived_status" = 'done'), 0) AND (NOT COALESCE(COALESCE((CASE WHEN COALESCE(((NOT COALESCE(COALESCE(("pl"."status" = 'done'), 0), 0)) OR COALESCE((COALESCE("dc"."prs_opened", 0) = 0), 0)), 0) THEN NULL WHEN COALESCE((COALESCE("dc"."prs_in_flight", 0) > 0), 0) THEN 'converging' WHEN COALESCE((COALESCE("dc"."prs_merged", 0) = COALESCE("dc"."prs_opened", 0)), 0) THEN 'landed' ELSE NULL END = 'converging'), 0), 0)) AND ("pl"."acknowledged_at" IS NULL)), 0) THEN 1 ELSE 0 END AS ack_open,
75
+ CASE
76
+ WHEN pl.status IS NOT 'done' OR COALESCE(dc.prs_opened, 0) = 0 THEN NULL
77
+ WHEN COALESCE(dc.prs_in_flight, 0) > 0 THEN dc.prs_merged || '/' || dc.prs_opened || ' slices merged, ' || dc.prs_in_flight || ' converging'
78
+ WHEN dc.prs_merged = dc.prs_opened THEN dc.prs_opened || '/' || dc.prs_opened || ' slices merged'
79
+ ELSE NULL
80
+ END AS delivery_label,
81
+ (wp.current_wave + 1) || '/' || wp.wave_count AS wave_label
82
+ FROM plans__tracking pl
83
+ LEFT JOIN plan_delivery_counts dc ON pl.plan_key = dc.plan_key
84
+ LEFT JOIN plan_wave_progress wp ON pl.plan_key = wp.plan_key;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.136.0",
3
+ "version": "0.137.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",
@@ -1,76 +0,0 @@
1
- // Read-model derivation test for the epic delivery signal (issue #171). `deriveDelivery` is the
2
- // single source of truth the `plan_delivery` VIEW (061) encodes and the pollers derive at READ TIME
3
- // (epic #412 retired the stored `plans.delivery` / `plans.delivery_label` columns). It must cleanly
4
- // distinguish an epic whose fan-out is `done` but whose slices are still CONVERGING from one where
5
- // every slice PR has LANDED, and count abandoned/converged PRs as resolved-not-landed (never
6
- // `landed`). The delivery-aware `list_bucket`/`ack_open` bucket derivation now lives in the
7
- // `plan_read_model` VIEW (074), cross-checked against the pure helpers in app/plansReadModel.test.ts.
8
- import { test } from "node:test";
9
- import { assert, assertEquals } from "#test-assert";
10
- import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
11
-
12
- test("all slice PRs merged -> landed", () => {
13
- const r = deriveDelivery("done", ["merged", "merged", "merged"]);
14
- assertEquals(r.delivery, "landed");
15
- assertEquals(r.prsOpened, 3);
16
- assertEquals(r.prsMerged, 3);
17
- assertEquals(r.prsInFlight, 0);
18
- assertEquals(r.label, "3/3 slices merged");
19
- });
20
-
21
- test("one slice PR still in flight -> converging", () => {
22
- const r = deriveDelivery("done", ["merged", "converging", "merged"]);
23
- assertEquals(r.delivery, "converging");
24
- assertEquals(r.prsOpened, 3);
25
- assertEquals(r.prsMerged, 2);
26
- assertEquals(r.prsInFlight, 1);
27
- assertEquals(r.label, "2/3 slices merged, 1 converging");
28
- });
29
-
30
- test("mixed merged/abandoned (all terminal, not all merged) -> resolved-not-landed (null)", () => {
31
- const r = deriveDelivery("done", ["merged", "abandoned", "merged"]);
32
- assertEquals(r.delivery, null);
33
- assertEquals(r.label, null);
34
- assertEquals(r.prsOpened, 3);
35
- assertEquals(r.prsMerged, 2);
36
- // abandoned is terminal, so it is NOT counted as in flight.
37
- assertEquals(r.prsInFlight, 0);
38
- });
39
-
40
- test("a converged (review-only, unmerged) slice keeps the epic out of landed", () => {
41
- // `converged` is terminal but not `merged`: resolved-not-landed, like abandoned.
42
- const r = deriveDelivery("done", ["merged", "converged"]);
43
- assertEquals(r.delivery, null);
44
- assertEquals(r.prsInFlight, 0);
45
- assertEquals(r.prsMerged, 1);
46
- });
47
-
48
- test("plan not yet done -> no delivery signal even with slice PRs", () => {
49
- for (const status of ["planning", "dispatched"]) {
50
- const r = deriveDelivery(status, ["merged", "converging"]);
51
- assertEquals(r.delivery, null, `status=${status}`);
52
- assertEquals(r.label, null, `status=${status}`);
53
- }
54
- });
55
-
56
- test("done but zero slice PRs -> no delivery signal", () => {
57
- const r = deriveDelivery("done", []);
58
- assertEquals(r.delivery, null);
59
- assertEquals(r.prsOpened, 0);
60
- });
61
-
62
- test("a single in-flight slice on a done plan is converging, not landed", () => {
63
- const r = deriveDelivery("done", ["waiting_review"]);
64
- assertEquals(r.delivery, "converging");
65
- assertEquals(r.label, "0/1 slices merged, 1 converging");
66
- });
67
-
68
- test("every non-terminal status counts as in flight", () => {
69
- const inFlight = ["converging", "waiting_review", "escalated", "queued", "open", "opened"];
70
- for (const s of inFlight) {
71
- assert(!TERMINAL_STATUSES.includes(s), `${s} must not be terminal`);
72
- const r = deriveDelivery("done", [s]);
73
- assertEquals(r.delivery, "converging", `status ${s}`);
74
- assertEquals(r.prsInFlight, 1, `status ${s}`);
75
- }
76
- });
@@ -1,170 +0,0 @@
1
- // Coverage for the Epic-detail wave visualization + task→representation links (issue #411).
2
- //
3
- // Two guards, mirroring the repo's split between a derived-read-model test (migration042.test.ts —
4
- // apply the migration to a real in-memory DB and assert its output) and a page-projection guard
5
- // (waitGateVisibility.test.ts — pure text assertions that the declarative page wires the surface):
6
- //
7
- // 1. The VIEW rollup over sample `plan_tasks` × `pull_requests` rows: the six-way per-wave count
8
- // partition and the pre-formatted `bar` string. Because `plan_wave_summary` is a VIEW (the whole
9
- // point of #411 — a single derived source of truth, enabled by nano-ide#424) this exercises the
10
- // real SQLite view, not a re-implementation.
11
- // 2. The epic-detail page projects the wave banner, the per-wave summary section, and the
12
- // task→representation links (PR url + processExplorer instance) on the wave-state grid.
13
- import { readFileSync } from "node:fs";
14
- import { DatabaseSync } from "node:sqlite";
15
- import { test } from "node:test";
16
- import { fileURLToPath } from "node:url";
17
- import { assert, assertEquals } from "#test-assert";
18
-
19
- const MIGRATION = fileURLToPath(new URL("../db/migrations/059_plan_wave_summary.sql", import.meta.url));
20
- const PAGE = fileURLToPath(new URL("../pages/epic-detail.page.json", import.meta.url));
21
-
22
- /** A DB with the base `plan_tasks` / `pull_requests` shapes the views read, plus the views applied. */
23
- function viewDb(): DatabaseSync {
24
- const db = new DatabaseSync(":memory:");
25
- db.exec(
26
- `CREATE TABLE plan_tasks (
27
- id INTEGER PRIMARY KEY, plan_key TEXT, task_index INTEGER, task_id TEXT, title TEXT,
28
- prompt TEXT, status TEXT, pr_key TEXT, summary TEXT, created_at TEXT, updated_at TEXT,
29
- wave INTEGER, open_question TEXT, answer TEXT, draft_pr_key TEXT, corr_key TEXT);
30
- CREATE TABLE pull_requests (pr_key TEXT PRIMARY KEY, url TEXT, status TEXT, process_key TEXT);`,
31
- );
32
- db.exec(readFileSync(MIGRATION, "utf8"));
33
- return db;
34
- }
35
-
36
- function addTask(
37
- db: DatabaseSync,
38
- plan_key: string,
39
- task_index: number,
40
- status: string,
41
- wave: number,
42
- pr?: { pr_key: string; url: string; status: string; process_key: string },
43
- ): void {
44
- db.prepare(
45
- "INSERT INTO plan_tasks (plan_key, task_index, task_id, status, pr_key, wave) VALUES (?, ?, ?, ?, ?, ?)",
46
- ).run(plan_key, task_index, `t${task_index}`, status, pr?.pr_key ?? null, wave);
47
- if (pr) {
48
- db.prepare(
49
- "INSERT INTO pull_requests (pr_key, url, status, process_key) VALUES (?, ?, ?, ?)",
50
- ).run(pr.pr_key, pr.url, pr.status, pr.process_key);
51
- }
52
- }
53
-
54
- test("plan_wave_summary partitions each wave's tasks and pre-formats the progress bar", () => {
55
- const db = viewDb();
56
- const plan = "o/r#1";
57
- // Wave 0 — 5 tasks: 3 merged, 1 converging (in-flight), 1 blocked (no PR).
58
- addTask(db, plan, 0, "opened", 0, { pr_key: "o/r#10", url: "https://gh/10", status: "merged", process_key: "P10" });
59
- addTask(db, plan, 1, "opened", 0, { pr_key: "o/r#11", url: "https://gh/11", status: "merged", process_key: "P11" });
60
- addTask(db, plan, 2, "opened", 0, { pr_key: "o/r#12", url: "https://gh/12", status: "merged", process_key: "P12" });
61
- addTask(db, plan, 3, "opened", 0, { pr_key: "o/r#13", url: "https://gh/13", status: "converging", process_key: "P13" });
62
- addTask(db, plan, 4, "blocked", 0);
63
- // Wave 1 — an escalated slice (with a draft PR) and a skipped slice.
64
- addTask(db, plan, 5, "escalated", 1, { pr_key: "o/r#14", url: "https://gh/14", status: "escalated", process_key: "P14" });
65
- addTask(db, plan, 6, "skipped", 1);
66
-
67
- const rows = db
68
- .prepare("SELECT * FROM plan_wave_summary WHERE plan_key = ? ORDER BY wave")
69
- .all(plan) as Array<Record<string, unknown>>;
70
- assertEquals(rows.length, 2);
71
-
72
- const w0 = rows[0];
73
- assertEquals(w0.total, 5);
74
- assertEquals(w0.merged, 3);
75
- assertEquals(w0.in_flight, 1);
76
- assertEquals(w0.blocked, 1);
77
- assertEquals(w0.escalated, 0);
78
- assertEquals(w0.skipped, 0);
79
- // 3 filled + 2 empty glyphs (width = total), then the named non-zero categories.
80
- assertEquals(w0.bar, "▓▓▓░░ 3/5 merged · 1 in-flight · 1 blocked");
81
-
82
- const w1 = rows[1];
83
- assertEquals(w1.total, 2);
84
- assertEquals(w1.merged, 0);
85
- assertEquals(w1.in_flight, 0);
86
- assertEquals(w1.escalated, 1);
87
- assertEquals(w1.skipped, 1);
88
- assertEquals(w1.bar, "░░ 0/2 merged · 1 escalated · 1 skipped");
89
- });
90
-
91
- test("a merged PR wins over the task's own status, and unlevelized tasks are excluded", () => {
92
- const db = viewDb();
93
- const plan = "o/r#2";
94
- // An escalated task whose PR nonetheless merged counts as merged, not escalated (PR wins).
95
- addTask(db, plan, 0, "escalated", 0, { pr_key: "o/r#20", url: "https://gh/20", status: "merged", process_key: "P20" });
96
- // A task with no wave yet (not levelized) must not appear in any wave row.
97
- db.prepare(
98
- "INSERT INTO plan_tasks (plan_key, task_index, task_id, status, wave) VALUES (?, ?, ?, ?, NULL)",
99
- ).run(plan, 1, "t1", "pending");
100
-
101
- const rows = db
102
- .prepare("SELECT wave, total, merged, escalated FROM plan_wave_summary WHERE plan_key = ?")
103
- .all(plan) as Array<Record<string, unknown>>;
104
- assertEquals(rows.length, 1);
105
- assertEquals(rows[0].wave, 0);
106
- assertEquals(rows[0].total, 1);
107
- assertEquals(rows[0].merged, 1);
108
- assertEquals(rows[0].escalated, 0);
109
- });
110
-
111
- test("plan_wave_tasks carries each task's PR url + process_key link targets", () => {
112
- const db = viewDb();
113
- addTask(db, "o/r#3", 0, "opened", 0, { pr_key: "o/r#30", url: "https://gh/30", status: "converging", process_key: "P30" });
114
- addTask(db, "o/r#3", 1, "blocked", 0); // no PR → null link targets
115
-
116
- const rows = db
117
- .prepare("SELECT task_id, pr_key, pr_url, process_key FROM plan_wave_tasks WHERE plan_key = ? ORDER BY task_index")
118
- .all("o/r#3") as Array<Record<string, unknown>>;
119
- assertEquals(rows[0].pr_url, "https://gh/30");
120
- assertEquals(rows[0].process_key, "P30");
121
- assertEquals(rows[1].pr_url, null);
122
- assertEquals(rows[1].process_key, null);
123
- });
124
-
125
- test("epic-detail projects the wave banner, the per-wave summary, and task→representation links", () => {
126
- const page = JSON.parse(readFileSync(PAGE, "utf8"));
127
- const byId = (id: string) => page.nodes.find((n: { id: string }) => n.id === id);
128
-
129
- // 1. The epic-level wave banner: a prose node reading wave_label + epic_phase off the derived
130
- // `plan_read_model` VIEW (epic #412 — retiring the worker-maintained plans.wave_label column;
131
- // the banner now reads the single-source-of-truth view instead of the raw `plans` table).
132
- const banner = byId("wave-banner");
133
- assert(banner, "epic detail must show the epic-level wave banner");
134
- assertEquals(banner.props.data.table, "plan_read_model");
135
- assert(
136
- banner.props.data.filter.some((f: { field: string; eqParam?: boolean }) => f.field === "plan_key" && f.eqParam),
137
- "the banner is scoped to this epic",
138
- );
139
- assert(/\{\{\s*wave_label\s*\}\}/.test(banner.props.header), "the banner surfaces the wave_label");
140
- assert(/\{\{\s*epic_phase\s*\}\}/.test(banner.props.header), "the banner surfaces the epic phase");
141
-
142
- // 2. The per-wave summary section: a grid over the derived VIEW, ordered by wave, with the bar.
143
- const summary = byId("wave-summary");
144
- assert(summary, "epic detail must show the per-wave progress summary");
145
- assertEquals(summary.props.data.table, "plan_wave_summary");
146
- assertEquals(summary.props.data.orderBy.field, "wave");
147
- const summaryCols: string[] = summary.props.columns.map((c: { field: string }) => c.field);
148
- for (const f of ["wave", "bar", "merged", "in_flight", "blocked", "escalated", "skipped", "total"]) {
149
- assert(summaryCols.includes(f), `the summary grid shows ${f}`);
150
- }
151
-
152
- // 3. The wave-state grid links each in-flight task to its representation (PR + process instance).
153
- const waveState = byId("wave-state");
154
- assert(waveState, "epic detail must keep the wave-state grid");
155
- assertEquals(waveState.props.data.table, "plan_wave_tasks");
156
- const cols: Array<Record<string, unknown>> = waveState.props.columns;
157
- const prCol = cols.find((c) => c.field === "pr_key");
158
- assertEquals(prCol?.linkField, "pr_url", "the PR cell links to the GitHub PR url");
159
- const statusCol = cols.find((c) => c.field === "status") as {
160
- link?: { kind?: string; keyField?: string };
161
- };
162
- assertEquals(statusCol.link?.kind, "processExplorer", "the status cell links to the process instance");
163
- assertEquals(statusCol.link?.keyField, "process_key");
164
- // The existing tabs (Active / Skipped / All) and detail drawer must still be present.
165
- assertEquals(waveState.props.tabs.length, 3);
166
- const detailFields: string[] = waveState.props.detail.fields.map((f: { field: string }) => f.field);
167
- for (const f of ["open_question", "answer", "draft_pr_key", "prompt"]) {
168
- assert(detailFields.includes(f), `the detail drawer keeps ${f}`);
169
- }
170
- });