@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
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.138.0](https://github.com/nanobpm/nano-workforce/compare/v0.137.0...v0.138.0) (2026-08-24)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* **delivery-graphs:** split preview from stage + collapsible compose + fix example ([#517](https://github.com/nanobpm/nano-workforce/issues/517)) ([abb4a10](https://github.com/nanobpm/nano-workforce/commit/abb4a10e9a8f7f53be40488d9991a3660a9f7518)), closes [#516](https://github.com/nanobpm/nano-workforce/issues/516) [#460](https://github.com/nanobpm/nano-workforce/issues/460) [#516](https://github.com/nanobpm/nano-workforce/issues/516)
|
|
6
|
+
|
|
7
|
+
## [0.137.0](https://github.com/nanobpm/nano-workforce/compare/v0.136.0...v0.137.0) (2026-08-24)
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* single-source plan-family read models onto @nanobpm/urban defineRollup/defineReadModel ([#493](https://github.com/nanobpm/nano-workforce/issues/493)) ([#512](https://github.com/nanobpm/nano-workforce/issues/512)) ([2837636](https://github.com/nanobpm/nano-workforce/commit/283763689cc2d0780393efe7c51b09d7c2bef767)), closes [468/#469](https://github.com/468/nano-workforce/issues/469) [#2](https://github.com/nanobpm/nano-workforce/issues/2) [#503](https://github.com/nanobpm/nano-workforce/issues/503)
|
|
12
|
+
|
|
1
13
|
## [0.136.0](https://github.com/nanobpm/nano-workforce/compare/v0.135.0...v0.136.0) (2026-08-24)
|
|
2
14
|
|
|
3
15
|
### Features
|
package/app/delivery.ts
CHANGED
|
@@ -3,13 +3,38 @@
|
|
|
3
3
|
// without importing `service.ts` — which imports `pollLineage` back from `lineage.ts` and would
|
|
4
4
|
// otherwise form a `service.ts` ↔ `lineage.ts` module cycle (fragile in ESM). This is the single
|
|
5
5
|
// source of truth for both; `service.ts` re-uses it and remains free to import `pollLineage`.
|
|
6
|
+
//
|
|
7
|
+
// ADR-0065 / issue #493. `deriveDelivery`/`deriveEpicBucket`/`epicIsAcknowledgeable` are no longer
|
|
8
|
+
// hand-authored oracles: they are now THIN ADAPTERS over the ONE `plan_read_model` declaration
|
|
9
|
+
// (app/planReadModel.ts) and the `plan_delivery_counts` rollup (app/planRollups.ts). Each routes
|
|
10
|
+
// through the framework's runtime backend — `planDeliveryCounts.reduce` (the TS group-reduce) for the
|
|
11
|
+
// slice-PR counts, and `planReadModel.evaluate` (the TS derivation) for the per-row `delivery` /
|
|
12
|
+
// `list_bucket` / `ack_open` signals — so these façades and the superseding SQLite VIEWs (migrations
|
|
13
|
+
// 082/083) compute byte-identical values by construction, guarded by `assertReadModelParity` /
|
|
14
|
+
// `assertRollupParity` (app/planReadModel.test.ts). Only the pre-formatted `label` display string is
|
|
15
|
+
// still assembled here (D3 — display formatting stays out of the framework AST).
|
|
6
16
|
|
|
7
|
-
|
|
8
|
-
|
|
17
|
+
import { TERMINAL_STATUSES } from "./deliveryStatuses.ts";
|
|
18
|
+
import {
|
|
19
|
+
DELIVERY_COUNTS_LOOKUP,
|
|
20
|
+
EFFECTIVE_STATUS_COLUMN,
|
|
21
|
+
planReadModel,
|
|
22
|
+
WAVE_PROGRESS_LOOKUP,
|
|
23
|
+
} from "./planReadModel.ts";
|
|
24
|
+
import { PR_TRACKING_RELATION, planDeliveryCounts } from "./planRollups.ts";
|
|
25
|
+
|
|
26
|
+
/** The synthetic correlation key threaded through the adapters: the base row's `plan_key` and each
|
|
27
|
+
* synthesised slice `plan_tasks`/`plan_delivery_counts` row share this value so the compiled rollup
|
|
28
|
+
* lookup / group-reduce correlate exactly as they do on real rows (mirrors app/stage.ts `SELF_KEY`). */
|
|
29
|
+
const SELF_KEY = "self";
|
|
30
|
+
|
|
31
|
+
/** The PR statuses that are TERMINAL for delivery — re-exported from the canonical leaf module
|
|
32
|
+
* (app/deliveryStatuses.ts) that BOTH this façade's consumers and the `plan_delivery_counts` rollup
|
|
33
|
+
* (app/planRollups.ts) read, so the SQL VIEW counts and the TS adapters can never drift. `converged`
|
|
9
34
|
* is terminal only in review-only mode (AUTO_MERGE off); with auto-merge on, a converged PR
|
|
10
|
-
* transitions into the merge stage and lands as `merged`. The status endpoint and the cancel
|
|
11
|
-
*
|
|
12
|
-
export
|
|
35
|
+
* transitions into the merge stage and lands as `merged`. The status endpoint and the cancel guard
|
|
36
|
+
* both key off this set. */
|
|
37
|
+
export { TERMINAL_STATUSES };
|
|
13
38
|
|
|
14
39
|
/** The derived epic delivery signal (issue #171). Distinct from `plan.status`: `status = done`
|
|
15
40
|
* means "the fan-out finished and ≥1 slice opened a PR, dispatched to convergence" (record-results
|
|
@@ -41,38 +66,35 @@ export function deriveDelivery(
|
|
|
41
66
|
planStatus: string,
|
|
42
67
|
prStatuses: readonly string[],
|
|
43
68
|
): DeliveryRollup {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
74
|
-
// Every slice PR is terminal but not all merged (some abandoned/converged): resolved, not landed.
|
|
75
|
-
return { delivery: null, label: null, prsOpened, prsMerged, prsInFlight };
|
|
69
|
+
// Lower the caller's per-slice PR statuses into the two LEAF relations the `plan_delivery_counts`
|
|
70
|
+
// rollup reduces over (`plan_tasks` LEFT JOIN `pull_requests__tracking`): one opened slice per PR
|
|
71
|
+
// status. Callers already resolve the PR's terminal-folded `derived_status` (service.ts reads it via
|
|
72
|
+
// `prsTracking`), so it is fed under the tracking relation's `derived_status` column — the SAME
|
|
73
|
+
// column the managed VIEW joins — and the framework group-reduce folds the SAME three counts
|
|
74
|
+
// (`prs_opened`/`prs_merged`/`prs_in_flight`) the VIEW does; a missing/dangling PR status counts as
|
|
75
|
+
// in-flight, never false-`landed`.
|
|
76
|
+
const taskRows = prStatuses.map((_, i) => ({ plan_key: SELF_KEY, pr_key: `pr${i}`, wave: null, status: "opened" }));
|
|
77
|
+
const prRows = prStatuses.map((s, i) => ({ pr_key: `pr${i}`, derived_status: s }));
|
|
78
|
+
const [counts] = planDeliveryCounts.reduce({ plan_tasks: taskRows, [PR_TRACKING_RELATION]: prRows });
|
|
79
|
+
const prsOpened = Number(counts?.prs_opened ?? 0);
|
|
80
|
+
const prsMerged = Number(counts?.prs_merged ?? 0);
|
|
81
|
+
const prsInFlight = Number(counts?.prs_in_flight ?? 0);
|
|
82
|
+
|
|
83
|
+
// Derive the `delivery` signal from the ONE `plan_read_model` declaration, feeding the folded counts
|
|
84
|
+
// as the `plan_delivery_counts` lookup's single candidate row (the TS twin of the VIEW's LEFT JOIN).
|
|
85
|
+
const raw = planReadModel.evaluate(
|
|
86
|
+
{ plan_key: SELF_KEY, status: planStatus, [EFFECTIVE_STATUS_COLUMN]: planStatus, acknowledged_at: null },
|
|
87
|
+
undefined,
|
|
88
|
+
{ [DELIVERY_COUNTS_LOOKUP]: counts ? [counts] : [], [WAVE_PROGRESS_LOOKUP]: [] },
|
|
89
|
+
).delivery;
|
|
90
|
+
const delivery: Delivery | null = raw === "converging" || raw === "landed" ? raw : null;
|
|
91
|
+
|
|
92
|
+
// The pre-formatted human label stays hand-authored here (D3 — display formatting is out of the
|
|
93
|
+
// framework AST); it mirrors the `plan_read_model` VIEW's `delivery_label` display column (083).
|
|
94
|
+
let label: string | null = null;
|
|
95
|
+
if (delivery === "converging") label = `${prsMerged}/${prsOpened} slices merged, ${prsInFlight} converging`;
|
|
96
|
+
else if (delivery === "landed") label = `${prsOpened}/${prsOpened} slices merged`;
|
|
97
|
+
return { delivery, label, prsOpened, prsMerged, prsInFlight };
|
|
76
98
|
}
|
|
77
99
|
|
|
78
100
|
/** The `plan.status` values that mean the epic's fan-out lifecycle is still LIVE — the planner is
|
|
@@ -108,16 +130,8 @@ export function deriveEpicBucket(
|
|
|
108
130
|
delivery: string | null | undefined,
|
|
109
131
|
acknowledgedAt: string | null | undefined,
|
|
110
132
|
): "active" | "history" {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
// A still-`converging` epic is Active regardless of any (stray) acknowledged_at — it is genuinely
|
|
114
|
-
// working and is not acknowledgeable, so it can never be ticked off mid-flight (fail-closed).
|
|
115
|
-
if (delivery === "converging") return "active";
|
|
116
|
-
// Otherwise `done` — landed or resolved-not-landed/poller-pending (`delivery = null`): stay Active
|
|
117
|
-
// until the operator dismisses it, so a just-`done` epic never flickers into History.
|
|
118
|
-
return (acknowledgedAt ?? null) === null ? "active" : "history";
|
|
119
|
-
}
|
|
120
|
-
return "history";
|
|
133
|
+
const raw = evalEpicRow(status, delivery, acknowledgedAt).list_bucket;
|
|
134
|
+
return raw === "active" ? "active" : "history";
|
|
121
135
|
}
|
|
122
136
|
|
|
123
137
|
/** True iff an epic carries the operator "Dismiss" (acknowledge) affordance — a `done` epic whose
|
|
@@ -133,5 +147,43 @@ export function epicIsAcknowledgeable(
|
|
|
133
147
|
status: string,
|
|
134
148
|
delivery: string | null | undefined,
|
|
135
149
|
): boolean {
|
|
136
|
-
|
|
150
|
+
// The `ack_open` derivation folds in the `acknowledged_at IS NULL` gate; evaluate it with a null
|
|
151
|
+
// acknowledgement to isolate the "acknowledgeABLE" predicate (`done` ∧ resolved) from "ack OPEN".
|
|
152
|
+
return evalEpicRow(status, delivery, null).ack_open === 1;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Evaluate the `plan_read_model` per-row derivations (`list_bucket`/`ack_open`) for an epic whose
|
|
156
|
+
* effective status, already-computed `delivery`, and acknowledgement the caller supplies. The model
|
|
157
|
+
* recomputes `delivery` internally from its `plan_delivery_counts` lookup + base status, so — the twin
|
|
158
|
+
* of app/stage.ts's `openTaskRows` synthesis — we SYNTHESISE the lookup row + base status that make the
|
|
159
|
+
* model's internal `delivery` equal the passed value: `converging`/`landed` need a `done` base status
|
|
160
|
+
* with an in-flight / all-merged count row; a null/other `delivery` needs an empty count (`prs_opened =
|
|
161
|
+
* 0` ⇒ the model's first CASE arm ⇒ null). The status-classifying arms read the effective status under
|
|
162
|
+
* `derived_status`, so the caller's `status` is fed there verbatim. */
|
|
163
|
+
function evalEpicRow(
|
|
164
|
+
status: string,
|
|
165
|
+
delivery: string | null | undefined,
|
|
166
|
+
acknowledgedAt: string | null | undefined,
|
|
167
|
+
): Record<string, unknown> {
|
|
168
|
+
const merged = delivery === "landed";
|
|
169
|
+
const inFlight = delivery === "converging";
|
|
170
|
+
const opened = merged || inFlight;
|
|
171
|
+
const dcRow = {
|
|
172
|
+
plan_key: SELF_KEY,
|
|
173
|
+
prs_opened: opened ? 1 : 0,
|
|
174
|
+
prs_merged: merged ? 1 : 0,
|
|
175
|
+
prs_in_flight: inFlight ? 1 : 0,
|
|
176
|
+
};
|
|
177
|
+
return planReadModel.evaluate(
|
|
178
|
+
{
|
|
179
|
+
plan_key: SELF_KEY,
|
|
180
|
+
// Force the model's internal `delivery` to the passed value: a `done` base status enables the
|
|
181
|
+
// non-null arms for converging/landed; any status with a zero-opened count folds to null.
|
|
182
|
+
status: opened ? "done" : status,
|
|
183
|
+
[EFFECTIVE_STATUS_COLUMN]: status,
|
|
184
|
+
acknowledged_at: acknowledgedAt ?? null,
|
|
185
|
+
},
|
|
186
|
+
undefined,
|
|
187
|
+
{ [DELIVERY_COUNTS_LOOKUP]: [dcRow], [WAVE_PROGRESS_LOOKUP]: [] },
|
|
188
|
+
);
|
|
137
189
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// app/deliveryGraphTextIngress.ts — the shared PARSE → COMPILE → PROJECT pipeline behind the two
|
|
2
|
+
// human-facing delivery-graph text-ingress doors (issue #516):
|
|
3
|
+
// • `previewDeliveryGraph` — PURE preview: compile + project, NO staging.
|
|
4
|
+
// • `stageDeliveryGraph` — compile + project + STAGE a proposal for operator dispatch.
|
|
5
|
+
//
|
|
6
|
+
// Splitting preview from staging means both doors run the IDENTICAL parse+compile+project step and
|
|
7
|
+
// differ ONLY in whether they persist a staged proposal. That step therefore lives here ONCE
|
|
8
|
+
// (derivation over duplication) rather than being copied per door — the previous single door inlined
|
|
9
|
+
// it, and forking it would have created two drift-prone compile paths. Neither door dispatches; the
|
|
10
|
+
// #460 boundary (dispatch is an operator action on a staged proposal) is untouched.
|
|
11
|
+
|
|
12
|
+
import type { DeliveryGraphTextResult } from "../nano-generated/api-io.d.ts";
|
|
13
|
+
import { compileDeliveryGraph } from "./deliveryGraphCompiler.ts";
|
|
14
|
+
import { proposalReviewUrl } from "./deliveryGraphProposals.ts";
|
|
15
|
+
import { parseDeliveryGraphText } from "./deliveryGraphText.ts";
|
|
16
|
+
import { deliveryGraphDigest } from "./deliveryRunner.ts";
|
|
17
|
+
|
|
18
|
+
type CompileResult = Awaited<ReturnType<typeof compileDeliveryGraph>>;
|
|
19
|
+
type CompiledOk = Extract<CompileResult, { ok: true }>;
|
|
20
|
+
type CompileErrors = Extract<CompileResult, { ok: false }>["errors"];
|
|
21
|
+
|
|
22
|
+
/** A parse/validation failure, already shaped as the door's 400 response. */
|
|
23
|
+
export interface TextIngressFailure {
|
|
24
|
+
ok: false;
|
|
25
|
+
status: 400;
|
|
26
|
+
body: { ok: false; error: string; errors?: CompileErrors };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A well-formed, compiled graph ready to project (and, for the stage door, to persist). */
|
|
30
|
+
export interface TextIngressOk {
|
|
31
|
+
ok: true;
|
|
32
|
+
graph: unknown;
|
|
33
|
+
compiled: CompiledOk;
|
|
34
|
+
digest: string;
|
|
35
|
+
name: string | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type TextIngressResult = TextIngressOk | TextIngressFailure;
|
|
39
|
+
|
|
40
|
+
/** Parse a UI JSON-paste body (`{ graphJson }`), then run the SAME pure compiler the agent door uses.
|
|
41
|
+
* A blank/invalid paste or a graph that fails validation is returned as a ready-to-send 400; success
|
|
42
|
+
* carries the compiled graph plus its content `digest` and human `name`. Never throws / never a 500. */
|
|
43
|
+
export async function parseAndCompileText(body: unknown): Promise<TextIngressResult> {
|
|
44
|
+
const parsed = parseDeliveryGraphText(body);
|
|
45
|
+
if (!parsed.ok) {
|
|
46
|
+
return { ok: false, status: 400, body: { ok: false, error: parsed.error } };
|
|
47
|
+
}
|
|
48
|
+
const compiled = await compileDeliveryGraph(parsed.graph);
|
|
49
|
+
if (!compiled.ok) {
|
|
50
|
+
return {
|
|
51
|
+
ok: false,
|
|
52
|
+
status: 400,
|
|
53
|
+
body: {
|
|
54
|
+
ok: false,
|
|
55
|
+
error: `graph failed validation: ${compiled.errors.length} error(s)`,
|
|
56
|
+
errors: compiled.errors,
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const digest = deliveryGraphDigest(compiled.bpmn);
|
|
61
|
+
const name =
|
|
62
|
+
typeof compiled.resolved.name === "string" && compiled.resolved.name.trim() !== ""
|
|
63
|
+
? compiled.resolved.name.trim()
|
|
64
|
+
: null;
|
|
65
|
+
return { ok: true, graph: parsed.graph, compiled, digest, name };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Project a compiled graph into the shared `DeliveryGraphTextResult` 200 body both doors return: the
|
|
69
|
+
* `digest`, node/human/side-effect counts, the mermaid `diagram`, and the full human-stop / side-effect
|
|
70
|
+
* detail the Delivery Graphs page renders (#441). `staged` records whether a proposal was persisted;
|
|
71
|
+
* `includeBpmn` attaches the compiled BPMN (with DI) so the PURE preview door can drive the host
|
|
72
|
+
* explorer's DI preview WITHOUT staging (the stage door omits it — the staged grid recompiles by
|
|
73
|
+
* digest). */
|
|
74
|
+
export function buildTextPreviewBody(
|
|
75
|
+
ok: TextIngressOk,
|
|
76
|
+
opts: { staged: boolean; includeBpmn?: boolean },
|
|
77
|
+
): DeliveryGraphTextResult {
|
|
78
|
+
const { compiled, digest, name } = ok;
|
|
79
|
+
return {
|
|
80
|
+
ok: true,
|
|
81
|
+
staged: opts.staged,
|
|
82
|
+
digest,
|
|
83
|
+
reviewUrl: proposalReviewUrl(digest),
|
|
84
|
+
...(name !== null ? { title: name } : {}),
|
|
85
|
+
sideEffecting: compiled.sideEffects.length > 0,
|
|
86
|
+
nodeCount: compiled.resolved.nodes.length,
|
|
87
|
+
humanNodeCount: compiled.humanNodes.length,
|
|
88
|
+
sideEffectCount: compiled.sideEffects.length,
|
|
89
|
+
diagram: compiled.diagram,
|
|
90
|
+
humanNodes: compiled.humanNodes,
|
|
91
|
+
sideEffects: compiled.sideEffects,
|
|
92
|
+
...(opts.includeBpmn ? { bpmn: compiled.bpmn } : {}),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// The PR statuses that are TERMINAL for epic delivery — a slice PR in any of these is resolved (not
|
|
2
|
+
// in flight). This is the ONE canonical set, factored into a dependency-neutral leaf module (imports
|
|
3
|
+
// nothing) so BOTH sides of the single-sourced delivery derivation read the SAME value and can never
|
|
4
|
+
// drift: the runtime adapters + consumers via `app/delivery.ts` (which re-exports it as
|
|
5
|
+
// `TERMINAL_STATUSES`), and the `plan_delivery_counts` / `plan_wave_counts` rollups' in-flight fold via
|
|
6
|
+
// `app/planRollups.ts`. Adding or removing a terminal state here changes both the SQL VIEW counts and
|
|
7
|
+
// the TS reduce at once.
|
|
8
|
+
//
|
|
9
|
+
// `converged` is terminal only in review-only mode (AUTO_MERGE off); with auto-merge on, a converged PR
|
|
10
|
+
// transitions into the merge stage and lands as `merged`. `merged` is the landed state; `abandoned` is
|
|
11
|
+
// the resolved-not-landed state. Everything else (converging, waiting_review, escalated, and the
|
|
12
|
+
// merge-stage waiting_deps/waiting_merge/waiting_lane/queued) is in flight.
|
|
13
|
+
export const TERMINAL_STATUSES: readonly string[] = ["converged", "merged", "abandoned"];
|