@nanobpm/nano-workforce 0.136.0 → 0.138.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/openapi.yaml CHANGED
@@ -1590,12 +1590,30 @@ components:
1590
1590
  message:
1591
1591
  type: string
1592
1592
  description: Human-actionable description of the failure.
1593
- DeliveryGraphTextSubmit:
1593
+ DeliveryGraphPreviewSubmit:
1594
1594
  description: >-
1595
- The human-facing UI JSON-paste PREVIEW request (issue #386). The Delivery Graphs page's text
1596
- field cannot submit a structured object, so the operator's pasted delivery-graph is carried as
1595
+ The human-facing UI JSON-paste PREVIEW request (issues #386 + #516). The Delivery Graphs page's
1596
+ "Preview" action cannot submit a structured object, so the operator's pasted delivery-graph is
1597
+ carried as a raw JSON STRING (`graphJson`), parsed server-side and handed to the SAME pure
1598
+ `compileDeliveryGraph` compiler the agent-facing door uses. Preview compiles WITHOUT persisting.
1599
+ Per-operation schema (not shared with the stage door) so each door's request stays independently
1600
+ evolvable.
1601
+ type: object
1602
+ additionalProperties: false
1603
+ required:
1604
+ - graphJson
1605
+ properties:
1606
+ graphJson:
1607
+ type: string
1608
+ description: The pasted delivery-graph JSON (a serialised `DeliveryGraph`), parsed server-side.
1609
+ DeliveryGraphStageSubmit:
1610
+ description: >-
1611
+ The human-facing UI JSON-paste STAGE request (issue #516) — the commit half of the preview/stage
1612
+ split. The Delivery Graphs page's "Stage" action carries the operator's pasted delivery-graph as
1597
1613
  a raw JSON STRING (`graphJson`), parsed server-side and handed to the SAME pure
1598
- `compileDeliveryGraph` compiler the agent-facing door uses. No parallel compile path.
1614
+ `compileDeliveryGraph` compiler the preview/agent doors use, then persisted as a `staged`
1615
+ proposal. Per-operation schema (not shared with the preview door) so each door's request stays
1616
+ independently evolvable.
1599
1617
  type: object
1600
1618
  additionalProperties: false
1601
1619
  required:
@@ -1820,6 +1838,13 @@ components:
1820
1838
  description: >-
1821
1839
  The side-effecting (`agent`/`connector`) actions the compiled graph WILL perform (preview)
1822
1840
  — what an approval authorises (Decision 7), rendered by the Delivery Graphs page (#441).
1841
+ bpmn:
1842
+ type: string
1843
+ description: >-
1844
+ The compiled BPMN 2.0 XML INCLUDING diagram interchange (`bpmndi:BPMNDiagram`) — returned by
1845
+ the PURE preview door (`previewDeliveryGraph`) only, so the Delivery Graphs page can render
1846
+ the laid-out BPMN in the host explorer WITHOUT staging (#516). Byte-identical to what a
1847
+ dispatch would deploy. Omitted by the stage/dispatch outcomes.
1823
1848
  ResolvedDeliveryNode:
1824
1849
  description: >-
1825
1850
  A normalised node in the compiled graph (ADR 0005 slice S1) — its `id`, `kind`, the
@@ -2887,24 +2912,56 @@ paths:
2887
2912
  /actions/delivery-graph/preview:
2888
2913
  post:
2889
2914
  operationId: previewDeliveryGraph
2890
- summary: UI JSON-paste PREVIEW+STAGE — parse a pasted delivery-graph JSON string, compile it and stage it for operator dispatch. (ADR 0005 Decision 7 / #460)
2915
+ summary: UI JSON-paste PURE PREVIEW — parse a pasted delivery-graph JSON string and compile it, without staging. (ADR 0005 Decision 7 / #460 / #516)
2916
+ description: >-
2917
+ The human-facing UI JSON-paste PURE PREVIEW ingress. The Delivery Graphs page's "Preview"
2918
+ action posts the operator's pasted JSON as a STRING; this door parses it and runs the SAME
2919
+ `compileDeliveryGraph` compiler the agent-facing door uses, but — unlike the compile/stage doors
2920
+ — it does NOT persist anything (#516: preview and staging are separate operator actions). It
2921
+ returns a compact preview summary (`staged:false`, the `digest`, node/human/side-effect counts,
2922
+ the mermaid `diagram`, the human stops and side effects) PLUS the compiled `bpmn` (with diagram
2923
+ interchange) so the page can render the laid-out BPMN in the host explorer without staging. It
2924
+ never deploys, stages or dispatches. A blank/invalid JSON string, or a graph that fails
2925
+ validation, is a 400 carrying a human `error` (and path-qualified `errors` for a compile failure).
2926
+ requestBody:
2927
+ required: true
2928
+ content:
2929
+ application/json:
2930
+ schema:
2931
+ $ref: "#/components/schemas/DeliveryGraphPreviewSubmit"
2932
+ responses:
2933
+ "200":
2934
+ description: The pasted graph parsed, validated and compiled — the preview summary and compiled BPMN are returned; nothing is staged.
2935
+ content:
2936
+ application/json:
2937
+ schema:
2938
+ $ref: "#/components/schemas/DeliveryGraphTextResult"
2939
+ "400":
2940
+ description: The pasted text was not valid JSON, or the graph failed validation/compilation.
2941
+ content:
2942
+ application/json:
2943
+ schema:
2944
+ $ref: "#/components/schemas/DeliveryGraphTextResult"
2945
+ /actions/delivery-graph/stage:
2946
+ post:
2947
+ operationId: stageDeliveryGraph
2948
+ summary: UI JSON-paste STAGE — parse a pasted delivery-graph JSON string, compile it and stage it for operator dispatch. (ADR 0005 Decision 7 / #460 / #516)
2891
2949
  description: >-
2892
- The human-facing UI JSON-paste PREVIEW+STAGE ingress. The Delivery Graphs page's "Preview &
2893
- stage" action posts the operator's pasted JSON as a STRING; this door parses it and runs the
2894
- SAME `compileDeliveryGraph` compiler the agent-facing door uses, and like the agent compile
2895
- door (issue #460) — persists the compiled graph as a `staged` proposal (content-addressed by its
2896
- `digest`). It returns a compact preview summary (the `digest`, node/human/side-effect counts,
2897
- the mermaid `diagram`, the human stops and side effects) plus a navigational `reviewUrl`. It
2898
- never deploys or dispatches dispatch is a separate operator action on the staged proposal
2899
- (the Dispatch button on the staged-proposals grid). A blank/invalid JSON string, or a graph that
2900
- fails validation, is a 400 carrying a human `error` (and path-qualified `errors` for a compile
2901
- failure); nothing is staged.
2950
+ The human-facing UI JSON-paste STAGE ingress the deliberate commit half of the preview/stage
2951
+ split (#516). The Delivery Graphs page's "Stage" action posts the operator's pasted JSON as a
2952
+ STRING; this door parses it, runs the SAME `compileDeliveryGraph` compiler the preview/agent
2953
+ doors use, andon success — persists the compiled graph as a `staged` proposal
2954
+ (content-addressed by its `digest`). It returns the same preview summary as the preview door but
2955
+ with `staged:true`. It never deploys or dispatches dispatch is a separate operator action on
2956
+ the staged proposal (the Dispatch button on the staged-proposals grid). A blank/invalid JSON
2957
+ string, or a graph that fails validation, is a 400 carrying a human `error` (and path-qualified
2958
+ `errors` for a compile failure); nothing is staged.
2902
2959
  requestBody:
2903
2960
  required: true
2904
2961
  content:
2905
2962
  application/json:
2906
2963
  schema:
2907
- $ref: "#/components/schemas/DeliveryGraphTextSubmit"
2964
+ $ref: "#/components/schemas/DeliveryGraphStageSubmit"
2908
2965
  responses:
2909
2966
  "200":
2910
2967
  description: The pasted graph parsed, validated and compiled — staged for operator dispatch; the preview summary is returned.
@@ -1,9 +1,9 @@
1
1
  // Tests for the POST /app/api/actions/delivery-graph/preview operation `previewDeliveryGraph` (ADR
2
- // 0005 Decision 7, issue #460) — the human-facing UI JSON-paste PREVIEW+STAGE ingress. It parses the
3
- // operator's pasted JSON STRING, runs the SAME `compileDeliveryGraph` compiler the agent door uses,
4
- // and like the agent compile door persists the compiled graph as a `staged` proposal, returning a
5
- // compact summary (200, `staged:true` + `reviewUrl`) or a human `error` + path-qualified `errors`
6
- // (400). It never dispatches — that is a separate operator action on the staged proposal.
2
+ // 0005 Decision 7, issues #460 + #516) — the human-facing UI JSON-paste PURE PREVIEW ingress. It
3
+ // parses the operator's pasted JSON STRING, runs the SAME `compileDeliveryGraph` compiler the agent
4
+ // door uses, and returns a compact summary (200, `staged:false`, + the compiled `bpmn`) or a human
5
+ // `error` + path-qualified `errors` (400). Unlike the stage door it persists NOTHING — preview and
6
+ // staging are separate operator actions (#516).
7
7
  import { mkdtempSync, rmSync } from "node:fs";
8
8
  import { tmpdir } from "node:os";
9
9
  import { join, resolve } from "node:path";
@@ -42,12 +42,13 @@ const GOOD = JSON.stringify({
42
42
  edges: [{ from: "a", to: "b" }],
43
43
  });
44
44
 
45
- test("preview-delivery-graph: a pasted well-formed graph → 200 summary, staged, with digest + counts", async () => {
45
+ test("preview-delivery-graph: a pasted well-formed graph → 200 summary, NOT staged, with digest + counts + bpmn", async () => {
46
46
  await withApp(async (app, data) => {
47
47
  const res = await call(app, { graphJson: GOOD });
48
48
  assertEquals(res.status, 200);
49
49
  assertEquals(res.body.ok, true);
50
- assertEquals(res.body.staged, true);
50
+ // #516: preview is PURE — it compiles but never stages.
51
+ assertEquals(res.body.staged, false);
51
52
  assert(typeof res.body.digest === "string" && res.body.digest.length > 0);
52
53
  assert(typeof res.body.reviewUrl === "string" && res.body.reviewUrl.length > 0);
53
54
  assertEquals(res.body.nodeCount, 2);
@@ -56,8 +57,9 @@ test("preview-delivery-graph: a pasted well-formed graph → 200 summary, staged
56
57
  assertEquals(res.body.sideEffecting, true);
57
58
  assert(typeof res.body.diagram === "string" && res.body.diagram.length > 0);
58
59
  assertEquals(res.body.title, "runbook");
59
- // The FULL preview detail (#441) the human stop-points and side-effecting actions the page
60
- // renders, not just the counts. `a` is the side-effecting agent node; `b` is the human stop.
60
+ // The PURE preview returns the laid-out BPMN so the page can render the DI without staging (#516).
61
+ assert(typeof res.body.bpmn === "string" && res.body.bpmn.includes("bpmndi:BPMNDiagram"));
62
+ // The FULL preview detail (#441) — the human stop-points and side-effecting actions the page renders.
61
63
  assert(Array.isArray(res.body.humanNodes) && res.body.humanNodes.length === 1);
62
64
  assertEquals(res.body.humanNodes[0].nodeId, "b");
63
65
  assertEquals(res.body.humanNodes[0].prompt, "do X");
@@ -65,19 +67,19 @@ test("preview-delivery-graph: a pasted well-formed graph → 200 summary, staged
65
67
  assertEquals(res.body.sideEffects[0].nodeId, "a");
66
68
  assertEquals(res.body.sideEffects[0].kind, "agent");
67
69
  assert(typeof res.body.sideEffects[0].description === "string" && res.body.sideEffects[0].description.length > 0);
68
- // A staged proposal now exists for the operator to dispatch — and NO dispatch handle came back.
69
- assertEquals((await deliveryGraphProposals(data).get(res.body.digest))?.status, "staged");
70
+ // NOTHING was staged, and no dispatch handle came back.
71
+ assertEquals((await deliveryGraphProposals(data).all()).length, 0);
70
72
  assertEquals(res.body.runKey, undefined);
71
73
  assertEquals(res.body.processInstanceKey, undefined);
72
74
  });
73
75
  });
74
76
 
75
- test("preview-delivery-graph: repeated previews stage the identical digest idempotently (one live row)", async () => {
77
+ test("preview-delivery-graph: repeated previews are pure — the identical digest, still nothing staged", async () => {
76
78
  await withApp(async (app, data) => {
77
79
  const a = await call(app, { graphJson: GOOD });
78
80
  const b = await call(app, { graphJson: GOOD });
79
81
  assertEquals(a.body.digest, b.body.digest);
80
- assertEquals((await deliveryGraphProposals(data).find({ digest: a.body.digest })).length, 1);
82
+ assertEquals((await deliveryGraphProposals(data).all()).length, 0);
81
83
  });
82
84
  });
83
85
 
@@ -1,92 +1,34 @@
1
1
  // POST /app/api/actions/delivery-graph/preview → operationId `previewDeliveryGraph` (ADR 0005
2
- // Decision 7, issue #460). The human-facing UI JSON-paste PREVIEW+STAGE ingress: the Delivery Graphs
3
- // page's "Preview & stage" action posts the operator's pasted delivery-graph as a raw JSON STRING;
4
- // this door parses it (`parseDeliveryGraphText`) and runs the SAME `compileDeliveryGraph` compiler the
5
- // agent-facing door uses, and like the agent compile door persists the compiled graph as a
6
- // `staged` proposal (content-addressed by its `digest`).
2
+ // Decision 7, issues #460 + #516). The human-facing UI JSON-paste PURE PREVIEW ingress: the Delivery
3
+ // Graphs page's "Preview" action posts the operator's pasted delivery-graph as a raw JSON STRING; this
4
+ // door parses it (`parseDeliveryGraphText`) and runs the SAME `compileDeliveryGraph` compiler the
5
+ // agent-facing door uses — but, unlike the compile/stage doors, it does NOT persist anything. It is a
6
+ // side-effect-free compile: preview and STAGING are now separate operator actions (#516), so an
7
+ // operator can compile-and-inspect a graph (its diagram, human stop-points, side-effects) and iterate
8
+ // before committing it to the staged-proposals list via the separate "Stage" action (stageDeliveryGraph).
7
9
  //
8
- // It returns a compact preview summary (the `digest`, node/human/side-effect counts, the mermaid
9
- // `diagram`, and the full human-stop / side-effect detail) plus a navigational `reviewUrl`. It never
10
- // deploys or dispatches dispatch is a separate OPERATOR action on the staged proposal (the Dispatch
11
- // button on the staged-proposals grid). A blank/invalid paste, or a graph that fails validation, is a
12
- // 400 carrying a human `error` (and path-qualified `errors` for a compile failure); nothing is staged.
10
+ // It returns a compact preview summary (`staged:false`, the `digest`, node/human/side-effect counts,
11
+ // the mermaid `diagram`, the human stops and side effects) PLUS the compiled `bpmn` (with diagram
12
+ // interchange) so the page can render the laid-out BPMN in the host explorer WITHOUT staging. It never
13
+ // deploys or dispatches dispatch is a separate operator action on a staged proposal. A blank/invalid
14
+ // JSON string, or a graph that fails validation, is a 400 carrying a human `error` (and path-qualified
15
+ // `errors` for a compile failure); nothing is compiled past the failure.
13
16
 
14
- import { compileDeliveryGraph } from "../app/deliveryGraphCompiler.ts";
15
- import {
16
- buildProposalPreview,
17
- buildProposalRow,
18
- proposalLogicalKey,
19
- proposalReviewUrl,
20
- stageProposal,
21
- } from "../app/deliveryGraphProposals.ts";
22
- import { parseDeliveryGraphText } from "../app/deliveryGraphText.ts";
23
- import { deliveryGraphDigest } from "../app/deliveryRunner.ts";
17
+ import { buildTextPreviewBody, parseAndCompileText } from "../app/deliveryGraphTextIngress.ts";
24
18
  import { defineOperation } from "../nano-generated/operations.ts";
25
19
 
26
20
  export default defineOperation("previewDeliveryGraph", async ({ body }, app) => {
27
- const parsed = parseDeliveryGraphText(body);
28
- if (!parsed.ok) {
29
- app.log.warn("preview-delivery-graph rejected: parse", { message: parsed.error });
30
- return { status: 400, body: { ok: false, error: parsed.error } };
21
+ const ingress = await parseAndCompileText(body);
22
+ if (!ingress.ok) {
23
+ app.log.warn("preview-delivery-graph rejected", { message: ingress.body.error });
24
+ return { status: ingress.status, body: ingress.body };
31
25
  }
32
- const compiled = await compileDeliveryGraph(parsed.graph);
33
- if (!compiled.ok) {
34
- app.log.warn("preview-delivery-graph rejected: compile", { errors: compiled.errors.length });
35
- return {
36
- status: 400,
37
- body: {
38
- ok: false,
39
- error: `graph failed validation: ${compiled.errors.length} error(s)`,
40
- errors: compiled.errors,
41
- },
42
- };
43
- }
44
-
45
- const digest = deliveryGraphDigest(compiled.bpmn);
46
- const name =
47
- typeof compiled.resolved.name === "string" && compiled.resolved.name.trim() !== ""
48
- ? compiled.resolved.name.trim()
49
- : null;
50
- const preview = buildProposalPreview(compiled);
51
- await stageProposal(
52
- app.data,
53
- buildProposalRow({
54
- digest,
55
- logicalKey: proposalLogicalKey(name, digest),
56
- title: name,
57
- graphJson: JSON.stringify(parsed.graph),
58
- preview,
59
- nodeCount: compiled.resolved.nodes.length,
60
- humanNodeCount: compiled.humanNodes.length,
61
- sideEffectCount: compiled.sideEffects.length,
62
- sideEffecting: compiled.sideEffects.length > 0,
63
- }),
64
- );
65
26
 
66
- app.log.info("preview-delivery-graph staged", {
67
- nodes: compiled.resolved.nodes.length,
68
- humanNodes: compiled.humanNodes.length,
69
- sideEffects: compiled.sideEffects.length,
70
- digest,
27
+ app.log.info("preview-delivery-graph compiled (not staged)", {
28
+ nodes: ingress.compiled.resolved.nodes.length,
29
+ humanNodes: ingress.compiled.humanNodes.length,
30
+ sideEffects: ingress.compiled.sideEffects.length,
31
+ digest: ingress.digest,
71
32
  });
72
- return {
73
- status: 200,
74
- body: {
75
- ok: true,
76
- staged: true,
77
- digest,
78
- reviewUrl: proposalReviewUrl(digest),
79
- ...(name !== null ? { title: name } : {}),
80
- sideEffecting: compiled.sideEffects.length > 0,
81
- nodeCount: compiled.resolved.nodes.length,
82
- humanNodeCount: compiled.humanNodes.length,
83
- sideEffectCount: compiled.sideEffects.length,
84
- diagram: compiled.diagram,
85
- // The FULL extracted preview detail (not just the counts): the human stop-points and the
86
- // side-effecting actions. The Delivery Graphs page renders these so the operator sees WHERE it
87
- // parks on a person and WHAT it will do — the "preview before dispatch" principle made visible.
88
- humanNodes: compiled.humanNodes,
89
- sideEffects: compiled.sideEffects,
90
- },
91
- };
33
+ return { status: 200, body: buildTextPreviewBody(ingress, { staged: false, includeBpmn: true }) };
92
34
  });