@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.
- package/CHANGELOG.md +12 -0
- package/app/delivery.ts +100 -48
- package/app/deliveryGraphTextIngress.ts +94 -0
- package/app/deliveryStatuses.ts +13 -0
- package/app/planReadModel.test.ts +470 -0
- package/app/planReadModel.ts +159 -0
- package/app/planRollups.ts +127 -0
- package/db/migrations/082_plan_rollups_declare_once.sql +74 -0
- package/db/migrations/083_plan_read_model_declare_once.sql +84 -0
- package/openapi.yaml +73 -16
- package/operations/previewDeliveryGraph.test.ts +15 -13
- package/operations/previewDeliveryGraph.ts +24 -82
- package/operations/stageDeliveryGraph.test.ts +106 -0
- package/operations/stageDeliveryGraph.ts +53 -0
- package/package.json +5 -5
- package/pages/delivery-graphs/delivery-graphs.css +47 -0
- package/pages/delivery-graphs/embed.html +1 -1
- package/pages/delivery-graphs/mount.js +110 -87
- package/pages/delivery-graphs/standalone.html +1 -1
- package/test/delivery-graphs-embed.test.ts +47 -35
- package/app/delivery.test.ts +0 -76
- package/app/planWaveSummary.test.ts +0 -170
- package/app/plansReadModel.test.ts +0 -406
|
@@ -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
|
-
|
|
1593
|
+
DeliveryGraphPreviewSubmit:
|
|
1594
1594
|
description: >-
|
|
1595
|
-
The human-facing UI JSON-paste PREVIEW request (
|
|
1596
|
-
|
|
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
|
|
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
|
|
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
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
`digest`). It returns
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
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, and — on 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/
|
|
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,
|
|
3
|
-
// operator's pasted JSON STRING, runs the SAME `compileDeliveryGraph` compiler the agent
|
|
4
|
-
// and
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
|
|
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
|
|
60
|
-
|
|
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
|
-
//
|
|
69
|
-
assertEquals((await deliveryGraphProposals(data).
|
|
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
|
|
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).
|
|
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,
|
|
3
|
-
// page's "Preview
|
|
4
|
-
//
|
|
5
|
-
// agent-facing door uses
|
|
6
|
-
//
|
|
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,
|
|
9
|
-
// `diagram`,
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// 400 carrying a human `error` (and path-qualified
|
|
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 {
|
|
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
|
|
28
|
-
if (!
|
|
29
|
-
app.log.warn("preview-delivery-graph rejected
|
|
30
|
-
return { status:
|
|
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
|
});
|