@nanobpm/nano-workforce 0.123.2 → 0.125.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +9 -5
  3. package/app/deliveryGraphDispatch.test.ts +143 -0
  4. package/app/deliveryGraphDispatch.ts +168 -0
  5. package/app/deliveryGraphProposals.test.ts +267 -0
  6. package/app/deliveryGraphProposals.ts +269 -0
  7. package/app/deliveryGraphRun.test.ts +6 -52
  8. package/app/deliveryGraphRun.ts +21 -76
  9. package/app/deliveryGraphText.ts +3 -3
  10. package/app/deliveryRunner.ts +4 -3
  11. package/app/featureReadModel.test.ts +97 -54
  12. package/app/featureReadModel.ts +152 -0
  13. package/app/service.ts +15 -0
  14. package/app/stage.ts +71 -77
  15. package/db/migrations/075_delivery_graph_proposals.sql +48 -0
  16. package/db/migrations/076_feature_read_model_declare_once.sql +53 -0
  17. package/docs/adr/0005-agent-authored-delivery-graphs.md +18 -0
  18. package/docs/adr/0006-delivery-units-one-representation.md +221 -0
  19. package/docs/agent-guide.md +50 -58
  20. package/e2e/delivery-graph-dispatch.e2e.ts +155 -0
  21. package/openapi.yaml +118 -161
  22. package/operations/compileDeliveryGraph.test.ts +100 -37
  23. package/operations/compileDeliveryGraph.ts +64 -18
  24. package/operations/dispatchDeliveryGraph.test.ts +171 -152
  25. package/operations/dispatchDeliveryGraph.ts +79 -99
  26. package/operations/getAgentInstructions.test.ts +10 -6
  27. package/operations/previewDeliveryGraph.test.ts +90 -51
  28. package/operations/previewDeliveryGraph.ts +45 -18
  29. package/package.json +2 -2
  30. package/pages/cockpit/mount.js +19 -12
  31. package/pages/delivery-graphs/mount.js +37 -137
  32. package/pages/delivery-graphs.page.json +50 -3
  33. package/scripts/check-migrations.test.ts +31 -0
  34. package/scripts/check-migrations.ts +40 -6
  35. package/test/cockpit-embed-endpoints.test.ts +59 -36
  36. package/test/delivery-graphs-embed.test.ts +36 -34
  37. package/e2e/delivery-graph-start.e2e.ts +0 -145
  38. package/operations/startDeliveryGraph.integration.test.ts +0 -316
  39. package/operations/startDeliveryGraph.ts +0 -222
package/app/stage.ts CHANGED
@@ -1,41 +1,36 @@
1
- // Canonical feature-run pipeline stage model (issue #254 §1) — the ONE source of truth for the
2
- // derived pipeline surface the Feature view renders. Mirrors the single-source-of-truth style of
3
- // `deriveDelivery` (app/delivery.ts) and `classifyEscalation` (app/escalationTaxonomy.ts): a PURE,
4
- // read-only function with no data access. The feature_runs gateway (app/feature.ts) projects its
5
- // output onto the stored `stage`/`stage_state`/`stage_skipped`/`attention` columns at write time,
6
- // exactly as `delivery_label` is projected — so the declarative dataGrid page consumes ready, stored
7
- // columns (bound by `{"field":…}`) and never has to call TS or express OR/null in its flat filter DSL.
1
+ // Canonical feature-run pipeline stage model (issue #254 §1) — the ergonomic TS façade over the ONE
2
+ // derived-read-model declaration in app/featureReadModel.ts. The pipeline surface the Feature view
3
+ // renders (`stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket`) is DERIVED, never ground
4
+ // truth: each column is a pure function of a feature run's own `status`/`pr_key`/`converge`/
5
+ // `auto_merge`/`acknowledged_at` plus, for `attention`, the engine's OPEN-user-task set.
8
6
  //
9
- // The mapping is TOTAL and DETERMINISTIC over all 11 FEATURE_RUN_STATUSES, computed from ONLY the
10
- // fields stored on the row — never a "previous"/"underlying" stage, because a FeatureRun stores only
11
- // its CURRENT status (any prior stage was overwritten on transition). Do NOT duplicate this mapping
12
- // anywhere (not in SQL, not in the page, not in each poller/worker): every writer flows through the
13
- // gateway, which is the single caller.
7
+ // SINGLE SOURCE OF TRUTH (ADR-0065, nano-ide#452). These derivations are declared ONCE, in Urban's
8
+ // closed expression DSL (`featureReadModel`, app/featureReadModel.ts), and compiled to BOTH the SQLite
9
+ // VIEW (migration 076) and the runtime TS functions used here. `deriveStage`/`deriveListBucket` are
10
+ // now thin ADAPTERS that evaluate that declaration's compiled functions (`fnFor`) they do NOT
11
+ // re-express the CASE/EXISTS logic in TypeScript, so the SQL and TS lowerings cannot drift (the former
12
+ // hand-written parity test becomes the framework-owned `assertReadModelParity` regression guard). The
13
+ // mapping remains TOTAL and DETERMINISTIC over all 11 FEATURE_RUN_STATUSES. Do NOT duplicate it
14
+ // anywhere (not in SQL, not in the page, not in each poller/worker): every reader flows through the
15
+ // VIEW or these adapters, both sourced from the one declaration.
16
+
17
+ import { type FeatureReadModelDerivedColumn, featureReadModel, STAGE_DONE_STATUSES, USER_TASKS_PROJECTION } from "./featureReadModel.ts";
18
+
19
+ // Re-exported for back-compat with existing importers (operations/acknowledgeDone.ts). Its canonical
20
+ // home is now app/featureReadModel.ts, where it feeds the terminal tier of the derived columns.
21
+ export { STAGE_DONE_STATUSES };
14
22
 
15
23
  /** The canonical pipeline stage keys, in path order. `Merging` is a path/visual stage the renderer
16
24
  * fills as upcoming — no status maps to it as the ACTIVE stage (intentional). */
17
25
  export const STAGE_KEYS = ["Requested", "Implementing", "PR open", "Converging", "Merging", "Done"] as const;
18
26
  export type StageKey = (typeof STAGE_KEYS)[number];
19
27
 
20
- /** The active stage's render state, in the urban 0.53.0 `kind:"pipeline"` column's EXACT vocabulary:
28
+ /** The active stage's render state, in the urban `kind:"pipeline"` column's EXACT vocabulary:
21
29
  * `ok` (Done ✓ success), `failed` (Done ✕ failure), `blocked` (blocked glyph), or `null` (in-progress
22
30
  * → the renderer treats it as `active`). Any OTHER string silently degrades to `active` in the
23
31
  * renderer, so a failed run MUST emit `'failed'` (not `'fail'`) to render as a failure. */
24
32
  export type StageState = "ok" | "failed" | "blocked" | null;
25
33
 
26
- /** The 6 TRULY-terminal statuses that map to the `Done` stage. Distinct from
27
- * `FEATURE_TERMINAL_STATUSES` (app/feature.ts), which is the redispatch-settled set and also counts
28
- * `opened`/`converging` as terminal — those are LIVE pipeline stages (`PR open`/`Converging`), NOT
29
- * Done, so this list must stay separate. Also the basis of the `list_bucket` history partition. */
30
- export const STAGE_DONE_STATUSES: readonly string[] = [
31
- "merged",
32
- "converged",
33
- "blocked",
34
- "failed",
35
- "skipped",
36
- "abandoned",
37
- ];
38
-
39
34
  /** The subset of a FeatureRun `deriveStage` reads. FeatureRun (app/feature.ts) structurally satisfies
40
35
  * this; keeping the input structural avoids a stage.ts ↔ feature.ts import cycle. */
41
36
  export interface StageInput {
@@ -48,7 +43,8 @@ export interface StageInput {
48
43
  * authoritative "who is waiting on a human" set). `attention` derives from THESE, never from the
49
44
  * drift-prone `status` variable — so once an escalation is answered (its `user_tasks` row deleted)
50
45
  * the badge clears immediately even while `status` still reads a stale `"escalated"`. Omitted/false
51
- * ⇒ no open task ⇒ no badge. `feature_read_model` (075) mirrors this with correlated EXISTS lookups. */
46
+ * ⇒ no open task ⇒ no badge. These booleans are lowered into synthetic `user_tasks` projection rows
47
+ * so this façade evaluates the SAME `exists(...)` derivation the VIEW compiles. */
52
48
  hasOpenBlockedTask?: boolean | null;
53
49
  hasOpenEscalationTask?: boolean | null;
54
50
  }
@@ -62,60 +58,58 @@ export interface DerivedStage {
62
58
  attention: string | null;
63
59
  }
64
60
 
65
- const truthy = (v: number | boolean | null | undefined): boolean => v === true || (typeof v === "number" && v !== 0);
66
-
67
- /** Derive the canonical pipeline stage, its render state, its not-in-path set, and its attention badge
68
- * for one feature run. Pure and read-only TOTAL over all 11 statuses (never returns undefined). */
69
- export function deriveStage(run: StageInput): DerivedStage {
70
- const { status } = run;
61
+ /** The synthetic correlation key threaded through the `attention` derivation: the base row's
62
+ * `feature_key` and each synthesised open-task row's `subject_key` share this value so the compiled
63
+ * `exists(... WHERE subject_key = feature_key ...)` predicate matches. Its concrete value is
64
+ * immaterial (it never leaves this function); it only has to be consistent between the two. */
65
+ const SELF_KEY = "self";
71
66
 
72
- // TERMINAL tier the 6 truly-terminal statuses collapse to Done. Unconditional: terminal `blocked`
73
- // is the issue §1 'Done ✕' row (state `blocked`), NOT Implementing.
74
- let stage: StageKey;
75
- let state: StageState;
76
- if (STAGE_DONE_STATUSES.includes(status)) {
77
- stage = "Done";
78
- state =
79
- status === "merged" || status === "converged"
80
- ? "ok"
81
- : status === "blocked"
82
- ? "blocked"
83
- : "failed"; // failed / skipped / abandoned
84
- } else {
85
- // LIVE/PARKED tier — one shared rule for every non-terminal status. `escalated`/`awaiting_operator`
86
- // are parked but their stored fields still describe WHERE in the pipeline they stalled, so they run
87
- // through the same rule as a live run; their attention comes from the badge (below), not the stage.
88
- if (status === "converging") stage = "Converging";
89
- else if ((run.pr_key ?? "") !== "" || status === "opened") stage = "PR open";
90
- else if (status === "running" || status === "escalated" || status === "awaiting_operator") stage = "Implementing";
91
- else stage = "Requested";
92
- state = null;
93
- }
94
-
95
- // `skipped`: stages not in this row's path, purely from converge/auto_merge.
96
- const converge = truthy(run.converge);
97
- const autoMerge = truthy(run.auto_merge);
98
- const skippedKeys: StageKey[] = !converge ? ["Converging", "Merging"] : !autoMerge ? ["Merging"] : [];
67
+ /** The `user_tasks` projection rows a `StageInput`'s open-task booleans stand for one row per OPEN
68
+ * operator user task, keyed exactly as `pollUserTasks` records them, so the compiled `attention`
69
+ * derivation sees the same engine-truth shape at runtime and here. */
70
+ function openTaskRows(run: StageInput): Array<Record<string, unknown>> {
71
+ const rows: Array<Record<string, unknown>> = [];
72
+ if (run.hasOpenBlockedTask === true) rows.push({ subject_type: "feature", subject_key: SELF_KEY, element_id: "feature-blocked" });
73
+ if (run.hasOpenEscalationTask === true) rows.push({ subject_type: "feature", subject_key: SELF_KEY, element_id: "feature-escalation" });
74
+ return rows;
75
+ }
99
76
 
100
- // `attention`: a short badge for the active stage (the renderer colours it from `state`). This is how
101
- // a parked `awaiting_operator`/`escalated` run surfaces as attention WITHOUT altering its stage.
102
- // Derived from ENGINE TRUTH the presence of an OPEN native user task (issue #422), NOT from the
103
- // `status` variable. `status` is worker-written imperatively and goes stale on the answer-loop back
104
- // into `implement-task` (the process does not reset it), so a run whose escalation was already
105
- // ANSWERED still reads `status="escalated"` until its next job completes; sourcing the badge from
106
- // that value made the read model lie (a resolved run flagged ⚠ on Overview). The authoritative
107
- // "who is waiting on a human" set is the `user_tasks` inbox (`pollUserTasks`), which holds a row
108
- // IFF the task is open and deletes it the moment it is answered — so a run shows the blocked glyph
109
- // IFF an open `feature-blocked` task exists, and ⚠ IFF an open `feature-escalation` task exists.
110
- // Once answered, the row is gone and the badge clears regardless of the stale `status`.
111
- const attention = truthy(run.hasOpenBlockedTask) ? "blocked" : truthy(run.hasOpenEscalationTask) ? "⚠" : null;
77
+ /** Evaluate one derived column from the ONE `featureReadModel` declaration for a base row (plus any
78
+ * projection rows the derivation correlates against). This is the single seam where the framework's
79
+ * compiled-function boundary is crossed: `fnFor(column)` returns `unknown` (a derivation can compile to
80
+ * any DSL value), while the caller statically knows the declared `lit(...)` shape of each column. */
81
+ function evalDerived<T>(column: FeatureReadModelDerivedColumn, baseRow: Record<string, unknown>, projections?: Record<string, Array<Record<string, unknown>>>): T {
82
+ // biome-ignore lint/plugin: runtime/framework contract boundary — `fnFor` returns `unknown`; each derived column yields one of its declared DSL `lit(...)` values, whose type the caller knows.
83
+ return featureReadModel.fnFor(column)(baseRow, projections) as T;
84
+ }
112
85
 
113
- return { stage, state, skipped: skippedKeys.join(" "), attention };
86
+ /** Derive the canonical pipeline stage, its render state, its not-in-path set, and its attention badge
87
+ * for one feature run. Pure and read-only — TOTAL over all 11 statuses (never returns undefined). The
88
+ * four columns are evaluated from the ONE `featureReadModel` declaration (app/featureReadModel.ts), so
89
+ * this façade and the SQLite VIEW compute byte-identical values by construction. */
90
+ export function deriveStage(run: StageInput): DerivedStage {
91
+ const baseRow = {
92
+ feature_key: SELF_KEY,
93
+ status: run.status,
94
+ pr_key: run.pr_key ?? null,
95
+ converge: run.converge ?? null,
96
+ auto_merge: run.auto_merge ?? null,
97
+ };
98
+ const projections = { [USER_TASKS_PROJECTION]: openTaskRows(run) };
99
+ return {
100
+ stage: evalDerived<StageKey>("stage", baseRow, projections),
101
+ state: evalDerived<StageState>("stage_state", baseRow, projections),
102
+ skipped: evalDerived<string>("stage_skipped", baseRow, projections),
103
+ attention: evalDerived<string | null>("attention", baseRow, projections),
104
+ };
114
105
  }
115
106
 
116
- /** The Active/History partition label (§5), maintained at write time so the flat-DSL page tabs filter
117
- * on a stored `list_bucket` column with only `in` clauses. `history` iff the row is in a truly-terminal
118
- * status AND acknowledged; otherwise `active` (live runs + terminal-but-UNACKNOWLEDGED runs). */
107
+ /** The Active/History partition label (§5): `history` iff the row is in a truly-terminal status AND
108
+ * acknowledged; otherwise `active` (live runs + terminal-but-UNACKNOWLEDGED runs). DERIVED on read from
109
+ * the ONE `featureReadModel` `list_bucket` declaration the same AST the VIEW compiles (migration 076),
110
+ * NOT a write-time projection: the page's tabs filter the VIEW's derived `list_bucket`, and the stored
111
+ * `feature_runs.list_bucket` base column is vestigial (retired as a write projection, issue #439). This
112
+ * adapter is the TS lowering of that derivation, used off the write path (redispatch gating, tests). */
119
113
  export function deriveListBucket(status: string, acknowledgedAt: string | null | undefined): "active" | "history" {
120
- return STAGE_DONE_STATUSES.includes(status) && acknowledgedAt != null ? "history" : "active";
114
+ return evalDerived<"active" | "history">("list_bucket", { status, acknowledged_at: acknowledgedAt ?? null });
121
115
  }
@@ -0,0 +1,48 @@
1
+ -- The `staged` delivery-graph proposal store (ADR 0005 Decision 7, issue #460). This realises
2
+ -- `propose → preview → approve → dispatch` as intended: the agent-facing surface ends at
3
+ -- propose → compile → STAGE, and a HUMAN dispatches the staged proposal from the cockpit. The old
4
+ -- `approvalToken` was a REPLAYABLE content digest handed back to the same caller, so any holder of
5
+ -- the API credential self-approved. Removing the dispatch affordance from the agent surface (there is
6
+ -- no `start` endpoint) dissolves that hole: the compile door persists the compiled graph HERE as a
7
+ -- `staged` proposal and returns only a preview + a navigational `reviewUrl` — nothing that can trigger
8
+ -- a run. The cockpit lists these rows, renders the preview, and dispatches the one the operator picks.
9
+ --
10
+ -- • digest (PK) — the content address of the compiled graph (`sha256(compiled.bpmn)[:12]`), the
11
+ -- SAME digest the runner uses for the content-addressed deploy id. It NAMES the proposal so the
12
+ -- agent can hand the operator an unambiguous "dispatch <digest>" and the operator dispatches
13
+ -- EXACTLY the digest they previewed. A re-compile of the same bytes is idempotent (same PK).
14
+ -- • logical_key — the LOGICAL graph identity (the graph's `name`, else the digest) used to
15
+ -- SUPERSEDE: staging a changed graph (new digest) for the same logical key retires the prior
16
+ -- staged proposal, so the cockpit shows one live proposal per logical graph, not every recompile.
17
+ -- • graph — the original `DeliveryGraph` JSON, retained so the cockpit dispatch action can run the
18
+ -- runner for the previewed digest without the agent re-submitting anything.
19
+ -- • preview — the rendered preview JSON (`{ diagram, sideEffects, humanNodes }`) the cockpit shows,
20
+ -- stamped at stage time so the list renders without recompiling.
21
+ -- • status — `staged` (awaiting operator review), `superseded` (replaced by a newer digest for its
22
+ -- logical key), `dispatched` (the operator launched it), or `expired` (aged out of its TTL before
23
+ -- dispatch). Only `staged` rows show in the cockpit; the poller sweeps aged-out `staged` rows to
24
+ -- `expired` (the grid's datasource filter is equality-only, so expiry is realised by that status
25
+ -- flip, not an `expires_at > now` clause).
26
+ -- • expires_at — the TTL horizon. Staged proposals age out of the cockpit list so a stale entry an
27
+ -- operator never dispatched does not linger; the poller flips an aged-out `staged` row to `expired`.
28
+ CREATE TABLE IF NOT EXISTS delivery_graph_proposals (
29
+ digest TEXT PRIMARY KEY,
30
+ logical_key TEXT NOT NULL,
31
+ title TEXT,
32
+ graph TEXT NOT NULL,
33
+ preview TEXT NOT NULL,
34
+ node_count INTEGER NOT NULL DEFAULT 0,
35
+ human_node_count INTEGER NOT NULL DEFAULT 0,
36
+ side_effect_count INTEGER NOT NULL DEFAULT 0,
37
+ side_effecting INTEGER NOT NULL DEFAULT 0,
38
+ status TEXT NOT NULL DEFAULT 'staged',
39
+ created_at TEXT NOT NULL,
40
+ updated_at TEXT NOT NULL,
41
+ expires_at TEXT NOT NULL
42
+ );
43
+
44
+ -- Supersede scans by logical_key; the cockpit list filters by status + expiry.
45
+ CREATE INDEX IF NOT EXISTS ix_delivery_graph_proposals_logical
46
+ ON delivery_graph_proposals (logical_key);
47
+ CREATE INDEX IF NOT EXISTS ix_delivery_graph_proposals_status
48
+ ON delivery_graph_proposals (status);
@@ -0,0 +1,53 @@
1
+ -- Feature-run read model: DECLARE ONCE, compile to BOTH backends (ADR-0065, nano-ide#452).
2
+ --
3
+ -- This migration SUPERSEDES the hand-wired `feature_read_model` VIEW of 073_feature_read_model.sql and
4
+ -- 075_feature_read_model_attention_from_user_tasks.sql. Those authored each derived column TWICE — the
5
+ -- SQL CASE/EXISTS here AND the TypeScript oracle (deriveStage/deriveListBucket, app/stage.ts) — kept in
6
+ -- lockstep by a hand-written parity test (drift surface #2, ADR-0065). This VIEW is no longer
7
+ -- hand-written: every DERIVED column body below is emitted VERBATIM from the ONE declaration in
8
+ -- app/featureReadModel.ts (`featureReadModel.sqlSelectFor(col, { baseAlias: "fr" })`), which ALSO drives the
9
+ -- runtime TS via `fnFor` — the two lowerings fall out of the same closed-DSL AST and cannot diverge.
10
+ -- A drift guard (app/featureReadModel.test.ts) fails if this file stops matching the declaration, and
11
+ -- `assertReadModelParity` proves the SQL and TS lowerings agree.
12
+ --
13
+ -- SEMANTICS ARE UNCHANGED from 075: `attention` still derives from ENGINE TRUTH — an OPEN native user
14
+ -- task in the `user_tasks` inbox (a `feature-blocked` row => blocked glyph, a `feature-escalation` row
15
+ -- => `⚠`), NOT the drift-prone `status` variable (issue #422); `stage`/`stage_state`/`stage_skipped`/
16
+ -- `list_bucket` are the same pure functions of the row. Only the AUTHORING moves to the framework
17
+ -- primitive; the projected values are identical.
18
+ --
19
+ -- Forward-only VIEW redefinition (DROP then CREATE). 073/075 are MERGED, IMMUTABLE migrations — never
20
+ -- edited; this is a NEW migration superseding their VIEW body. `feature_runs` (028+) and `user_tasks`
21
+ -- (034) already exist earlier in the chain, and 075 already created the supporting
22
+ -- `idx_user_tasks_subject_element` index the correlated attention EXISTS lookups seek, so it is not
23
+ -- re-created here. Base columns are plain identity pass-throughs (aliased so the static pages<->schema
24
+ -- contract guard sees the VIEW columns); `feature_runs fr` stays the sole top-level FROM (the user_tasks
25
+ -- lookups are nested EXISTS subqueries at paren depth >= 1). The runner wraps each file in its own
26
+ -- transaction, so this file must NOT contain BEGIN/COMMIT.
27
+
28
+ DROP VIEW IF EXISTS feature_read_model;
29
+
30
+ CREATE VIEW feature_read_model AS
31
+ SELECT
32
+ fr.feature_key AS feature_key,
33
+ fr.repo AS repo,
34
+ fr.issue_number AS issue_number,
35
+ fr.issue_url AS issue_url,
36
+ fr.title AS title,
37
+ fr.base_branch AS base_branch,
38
+ fr.status AS status,
39
+ fr.process_key AS process_key,
40
+ fr.pr_key AS pr_key,
41
+ fr.converge AS converge,
42
+ fr.auto_merge AS auto_merge,
43
+ fr.outcome AS outcome,
44
+ fr.delivery_label AS delivery_label,
45
+ fr.acknowledged_at AS acknowledged_at,
46
+ fr.created_at AS created_at,
47
+ fr.updated_at AS updated_at,
48
+ CASE WHEN COALESCE((COALESCE(("fr"."status" = 'merged'), 0) OR COALESCE(("fr"."status" = 'converged'), 0) OR COALESCE(("fr"."status" = 'blocked'), 0) OR COALESCE(("fr"."status" = 'failed'), 0) OR COALESCE(("fr"."status" = 'skipped'), 0) OR COALESCE(("fr"."status" = 'abandoned'), 0)), 0) THEN 'Done' WHEN COALESCE(("fr"."status" = 'converging'), 0) THEN 'Converging' WHEN COALESCE((COALESCE(("fr"."pr_key" <> ''), 0) OR COALESCE(("fr"."status" = 'opened'), 0)), 0) THEN 'PR open' WHEN COALESCE((COALESCE(("fr"."status" = 'running'), 0) OR COALESCE(("fr"."status" = 'escalated'), 0) OR COALESCE(("fr"."status" = 'awaiting_operator'), 0)), 0) THEN 'Implementing' ELSE 'Requested' END AS stage,
49
+ CASE WHEN COALESCE((COALESCE(("fr"."status" = 'merged'), 0) OR COALESCE(("fr"."status" = 'converged'), 0)), 0) THEN 'ok' WHEN COALESCE(("fr"."status" = 'blocked'), 0) THEN 'blocked' WHEN COALESCE((COALESCE(("fr"."status" = 'failed'), 0) OR COALESCE(("fr"."status" = 'skipped'), 0) OR COALESCE(("fr"."status" = 'abandoned'), 0)), 0) THEN 'failed' ELSE NULL END AS stage_state,
50
+ CASE WHEN (NOT COALESCE("fr"."converge", 0)) THEN 'Converging Merging' WHEN (NOT COALESCE("fr"."auto_merge", 0)) THEN 'Merging' ELSE '' END AS stage_skipped,
51
+ CASE WHEN EXISTS (SELECT 1 FROM "user_tasks" AS "__urban_proj_0" WHERE COALESCE((COALESCE(("__urban_proj_0"."subject_type" = 'feature'), 0) AND COALESCE(("__urban_proj_0"."subject_key" = "fr"."feature_key"), 0) AND COALESCE(("__urban_proj_0"."element_id" = 'feature-blocked'), 0)), 0)) THEN 'blocked' WHEN EXISTS (SELECT 1 FROM "user_tasks" AS "__urban_proj_0" WHERE COALESCE((COALESCE(("__urban_proj_0"."subject_type" = 'feature'), 0) AND COALESCE(("__urban_proj_0"."subject_key" = "fr"."feature_key"), 0) AND COALESCE(("__urban_proj_0"."element_id" = 'feature-escalation'), 0)), 0)) THEN '⚠' ELSE NULL END AS attention,
52
+ CASE WHEN COALESCE((COALESCE((COALESCE(("fr"."status" = 'merged'), 0) OR COALESCE(("fr"."status" = 'converged'), 0) OR COALESCE(("fr"."status" = 'blocked'), 0) OR COALESCE(("fr"."status" = 'failed'), 0) OR COALESCE(("fr"."status" = 'skipped'), 0) OR COALESCE(("fr"."status" = 'abandoned'), 0)), 0) AND COALESCE(("fr"."acknowledged_at" = "fr"."acknowledged_at"), 0)), 0) THEN 'history' ELSE 'active' END AS list_bucket
53
+ FROM feature_runs fr;
@@ -160,6 +160,11 @@ shared JSON contract means either can be swapped in later without touching the a
160
160
 
161
161
  ### 7. Submission is propose → preview → approve → dispatch, idempotent, over the self-describing endpoint
162
162
 
163
+ > **Superseded — see the *Amendment (issue #460)* at the end of this section.** The `POST
164
+ > /actions/start/delivery-graph` agent endpoint described in the following paragraph was **never
165
+ > shipped and has been removed**; the agent surface ends at propose → compile → stage and dispatch is
166
+ > operator-only. The paragraph below is retained as the original (Proposed) decision record.
167
+
163
168
  Graphs are submitted exactly as epics are today — via a **new (proposed)** `POST
164
169
  /actions/start/delivery-graph` endpoint (paths are relative to the agent guide's `__BASE__` prefix,
165
170
  matching the guide's style) with the JSON body, discovered via the agent guide (which already
@@ -174,6 +179,19 @@ at-least-once execution (mirroring the release workflow's `npx semantic-release`
174
179
  "skip already-published" discipline — `.github/workflows/release.yml`) so a
175
180
  resume cannot double-fire.
176
181
 
182
+ > **Amendment (issue #460): dispatch is operator-only.** As implemented, the "approve → dispatch"
183
+ > half of this decision is **not** an agent endpoint. The agent surface ends at **propose → compile →
184
+ > stage**: the `POST /actions/compile-delivery-graph` door validates + previews the graph and, on
185
+ > success, **stages** it as a proposal (a durable `delivery_graph_proposals` row, content-addressed by
186
+ > `digest`, superseded per logical graph + TTL-bounded), returning only a preview + a navigational
187
+ > `reviewUrl` — no run key, token, or PIK. A **human dispatches** the staged proposal from the cockpit's
188
+ > Delivery Graphs page (`POST /actions/delivery-graph/dispatch` by `digest`, an operator route). The
189
+ > originally-proposed agent `POST /actions/start/delivery-graph` door — where the same caller was handed
190
+ > a content-addressed `approvalToken` to re-submit with — was **removed**: that "approval" was a
191
+ > **replayable** digest returned to the approver, so any holder of the API credential self-approved.
192
+ > Dispatch-by-absence (there is no agent start door) closes that hole categorically; the idempotent
193
+ > at-most-once launch fence is retained on the operator dispatch path.
194
+
177
195
  ## Consequences
178
196
 
179
197
  - nwf gains a **generic delivery-graph runner** that composes its existing primitives; the motivating
@@ -0,0 +1,221 @@
1
+ # ADR 0006 — Delivery units: one representation for feature / epic / delivery-graph
2
+
3
+ Status: **Proposed.**
4
+ Date: 2026-08-22.
5
+
6
+ > **Scope note.** This is a **nano-workforce-local** ADR — it governs how *this app* represents a
7
+ > unit of delivery work internally. Platform-wide ADRs live in `Magikcraft/nano-bpm/docs/adr`
8
+ > (referenced by number + repo). nano-workforce's own series continues here after ADR 0005.
9
+
10
+ Relates to:
11
+ nano-workforce **ADR 0005** (agent-authored delivery graphs — this ADR carries out 0005's
12
+ already-stated framing that the delivery graph is the *general* form, of which an epic is a waved DAG
13
+ and a feature a degenerate 1-node graph, by converging the data and process encodings onto it),
14
+ nano-workforce **ADR 0001** (cross-repo epics + the generic `ReadinessProbe` wait-gate — the epic
15
+ substrate being consolidated),
16
+ nano-workforce **ADR 0002** (escalations are user tasks + forms — the human-escalation cell that is
17
+ one of the copy-pasted subprocesses),
18
+ nano-bpm **ADR 0065** (reconciling read models / `defineReadModel` — the derivation mechanism S1 uses
19
+ to collapse three bespoke status unions into one),
20
+ nano-ide **#424** (datasource can read a SQL VIEW — the *data-level* unlock),
21
+ nano-workforce **#416** (the PR bumping the testkit to engine-wasm 0.7.2, which executes `callActivity`
22
+ — the *process-level* unlock),
23
+ nano-workforce **#464** (the tracking issue with slices S1–S5),
24
+ nano-workforce **#305** (consolidate escalations on native `user_tasks` — a natural sub-step of S1/S3).
25
+
26
+ ## Context
27
+
28
+ ### One aggregate, encoded three times
29
+
30
+ nano-workforce models the same real-world thing — **a scheduled unit of work driven to a delivery
31
+ outcome** (typically an agent taking one slice to one merged PR, but a unit may instead `wait`, ask a
32
+ `human`, call a `connector`, or — for a feature — terminate `opened`/`converged` without a merged PR;
33
+ and an epic aggregates *many* such units/PRs) — in three separate representations, each with its own
34
+ table, status union, operator read-surface, `instanceTracking` binding, and dispatch door:
35
+
36
+ | Representation | Data | Process | Shape |
37
+ |---|---|---|---|
38
+ | **Feature** | `feature_runs` (mig. 028) | `resources/processes/feature.bpmn` | one issue → one PR (1-node) |
39
+ | **Epic** | `plans` + `plan_tasks` (mig. 004) | `resources/processes/plan-fanout.bpmn` | fan-out of slices → waves (N-node) |
40
+ | **Delivery graph** | `delivery_graph_runs` (mig. 058) + compiled nodes | compiled BPMN (`app/deliveryGraphCompiler.ts`) | arbitrary DAG |
41
+
42
+ All three are keyed by an issue/run key and carry their own status union. Feature and epic each also
43
+ project a dedicated display read-model VIEW; delivery-graph pages instead bind **directly** to
44
+ `delivery_graph_runs` (`pages/delivery-graphs.page.json`, `pages/delivery-graph-detail.page.json`).
45
+ Feature and epic execute through **hand-authored** BPMN (`feature.bpmn`, `plan-fanout.bpmn`) and
46
+ dispatch hard-coded `senior:*` implementation jobs that funnel downstream to the *same* `pull_requests`
47
+ table (keyed by `pr_key`) — the convergence/merge loop, which they correctly do **not** duplicate.
48
+ Delivery graphs are the exception: only they are **compiled** to BPMN from JSON at runtime, they accept
49
+ the submitted node kind and `agent.jobType` (including `wait`, `human`, and `connector` nodes), and
50
+ `delivery_graph_runs` carries no `pr_key`, so a graph is not inherently PR-producing. So for the
51
+ feature/epic implementation path the *downstream* half of the aggregate is already factored to a single
52
+ source of truth; only the *upstream* "unit of work" half is triplicated.
53
+
54
+ ADR 0005 already names this: "plan-fanout is an epic — a `RecordPlanTask[]` + `dependsOn[]` DAG with
55
+ waves", "convergence-loop is one PR", and a feature is the degenerate one-node graph. The delivery
56
+ graph is the general form. What 0005 did *not* do is converge the data and process encodings onto that
57
+ general form — so we still carry three near-duplicate sources of truth for one aggregate. That is
58
+ precisely the "no drift surfaces / derivation over duplication" hazard this project treats as a defect
59
+ class: a change to the meaning of "a unit of work" has to be made, by hand, in three places that can
60
+ silently drift.
61
+
62
+ ### The duplication was *forced* by two "can't-reference" constraints — both now lifted
63
+
64
+ The triplication is not a design preference; it was compelled by two symmetric constraints, one on
65
+ each encoding. Both have now been removed on `main`, which is why consolidation becomes possible now
66
+ rather than earlier.
67
+
68
+ **Data — "the datasource can't read a SQL VIEW."** Because the read layer could not read a VIEW, every
69
+ *display projection* had to be a physically **denormalized table** — or denormalized columns
70
+ hand-maintained on a source table (e.g. migrations 022/029 on `plans`) — rather than a VIEW derived
71
+ from its source. That is what made a *shared* display projection impossible: each representation grew
72
+ its own hand-maintained projection. (The delivery-unit source tables themselves —
73
+ `feature_runs`/028, `plans`+`plan_tasks`/004, `delivery_graph_runs`/058 — are domain stores, not
74
+ projection artifacts.) **Unlocked by nano-ide#424** (the datasource can now read a VIEW) → the derived
75
+ read-model VIEWs added in migrations 059–062, 064, and 073 (later refined by 074/075), with 070–072
76
+ dropping the now-redundant denormalized columns/tables. **Live today.**
77
+
78
+ **Process — "the pinned WASM engine no-ops `callActivity`."** `app/deliveryGraphCompiler.ts:47-51`
79
+ (and again at 604-607) records the constraint verbatim: `callActivity` is "a no-op on the pinned WASM
80
+ engine (the child is never instantiated)", so the compiler — and every hand-written process —
81
+ **inlines** the subprocess body instead of referencing it. The consequence is that the atomic
82
+ *"agent-implement cell"* (`implement-task (senior:*) → "escalated?" gateway → record-escalation →
83
+ user-task → SLA boundary → answer gateway`) exists as **two hand-authored copies** — `feature.bpmn` and
84
+ the multi-instance `implement` subprocess in `plan-fanout.bpmn` — plus a **third generator** in the
85
+ compiler that re-emits it once per graph node. Sibling cells
86
+ (readiness-poll, human-escalation) duplicate the same way. **No `callActivity` exists in any diagram**
87
+ because it did nothing. **Unlocked by #416** (engine-wasm 0.4.0 → **0.7.2**). engine-core executes
88
+ `callActivity` by inline-expanding the called process at deploy (`engine-core/src/model.rs:1217/1255`).
89
+ **Verified live:** a `callActivity` parent+child model deployed through engine-wasm 0.7.2 runs to
90
+ `COMPLETED`. Caveat: #416 bumps only the **dev-only** `@nanobpm/urban-testkit`; the production
91
+ `@nanobpm/urban` broker does not itself pin `engine-wasm`, so this verification proves the in-process
92
+ testkit, not the broker/runtime that will execute future `callActivity` models. S4/S5 therefore also
93
+ carry a **deployment-runtime prerequisite** — the deployed broker's `engine-core` must carry the same
94
+ `callActivity` support — which green testkit CI does not by itself guarantee.
95
+
96
+ ### The two constraints are the *same* constraint
97
+
98
+ Both are "an encoding can't *reference* a shared definition, so it *inlines a copy* of it." Data
99
+ inlined projection tables; process inlined subprocess bodies. Both share the same aggregate (the
100
+ delivery unit), the same fix shape (remove the can't-reference constraint, then reference instead of
101
+ copy), and both already have their downstream half factored correctly (`pull_requests`; the
102
+ convergence/merge loop). Removing one constraint without the other would leave the aggregate
103
+ half-consolidated; removing both is what makes a single representation reachable.
104
+
105
+ ## Decision
106
+
107
+ Adopt a single internal aggregate — the **delivery unit** — defined around its **nodes**: a node is one
108
+ **scheduled unit of work** whose executor may be an **agent, probe, human, or connector** (ADR 0005
109
+ `wait`/`human`/`connector` nodes are first-class, not exceptions). Its terminal is *typically* one merged
110
+ PR, but PR-less nodes and delivery graphs (`delivery_graph_runs` has no `pr_key`) make PR production
111
+ **optional**.
112
+ It has **two encodings**, each of which now *references* the shared definition rather than inlining
113
+ a copy — expressed here as the **target** state:
114
+
115
+ ### 1. Data encoding — one `delivery_unit` aggregate
116
+
117
+ - A `delivery_units` table is the single source of truth for "a unit of work." Feature = a 1-node
118
+ unit; Epic = an N-node waved unit; DeliveryGraph = an arbitrary-DAG unit — a **shape**, not a
119
+ separate table.
120
+ - `feature_runs` / `plans` + `plan_tasks` / `delivery_graph_runs` become **derived VIEWs / rows** over
121
+ `delivery_units` (using the nano-ide#424 VIEW capability), not independent tables. The epic case
122
+ covers **both** levels: the `plans` aggregate row (process key, status, title, lifecycle) becomes a
123
+ row/VIEW over `delivery_units`, and each `plan_tasks` slice becomes a node under it.
124
+ - The three `instanceTracking` bindings and `senior:*` dispatch doors collapse toward one, keyed on
125
+ the delivery unit.
126
+ - **Identity.** `delivery_units` carries a stable `unit_id` plus `(unit_id, node_id)` for the N-node
127
+ cases. The legacy keys are not interchangeable — a feature run and an active epic may share one
128
+ `<owner>/<repo>#<N>` key (`app/feature.ts`), while delivery graphs key on a caller idempotency key or
129
+ content digest (`app/deliveryGraphRun.ts` `computeRunKey`) — so S2's compatibility VIEWs map each
130
+ legacy key onto the new identity, ensuring unrelated runs are never merged onto one row.
131
+
132
+ ### 2. Process encoding — shared cells composed by `callActivity`
133
+
134
+ - Extract the atomic *implement-cell* (and its sibling wait-gate and human-escalation cells) into
135
+ standalone processes (`resources/processes/implement-cell.bpmn`, …).
136
+ - Compose them by reference — replacing only the inlined *implement/escalation segment*, not the
137
+ surrounding orchestration: in **feature** (`feature.bpmn`) the readiness preflight, base-branch setup,
138
+ `record-feature`, and convergence handoff are retained; only the implement-cell segment becomes one
139
+ `callActivity`. **Epic** = the multi-instance `implement` body is a `callActivity`; **delivery graph**
140
+ = the compiler *emits* `callActivity` references, not inlined subprocess copies.
141
+ - This is gated on the engine-wasm 0.7.2 unlock, which is now live on `main`.
142
+
143
+ ### 3. Status lifecycle — one derived union
144
+
145
+ The three bespoke status unions collapse into **one derived union** via ADR 0065's `defineReadModel`,
146
+ so a change to lifecycle semantics is made once and derived everywhere, not re-declared per
147
+ representation. These unions are **not** identical today — features use
148
+ `running`/`escalated`/`awaiting_operator`/…, plans use `planning`/`dispatched`/`done`, and graphs use
149
+ `running`/`done` with a reserved `awaiting-approval` (`app/feature.ts`, `app/plan.ts`,
150
+ `app/deliveryGraphRun.ts`); §1 additionally makes each `plan_tasks` row a **node**, which carries its own
151
+ `PlanTaskStatus` (`pending`/`waiting-for-lane`/…, `app/plan.ts`). So S1 owns defining the canonical
152
+ **aggregate** state set *and* explicitly deciding whether **node** status is part of that union or a
153
+ separate node contract — plus the per-shape mapping and precedence and the write/`instanceTracking`
154
+ behavior — not merely projecting an existing value.
155
+
156
+ ### 4. Preserve — the static-vs-adaptive execution axis (do NOT bundle it)
157
+
158
+ This ADR consolidates the *representation*, not the *execution strategy*. ADR 0005's deliberate
159
+ distinction stays intact: **plan-fanout remains adaptive** (agent-discovered slices, waves that adapt),
160
+ **delivery graphs remain static/compiled**. Both are still *delivery units*; they differ only in how
161
+ their topology is produced. Unifying that axis is explicitly out of scope here.
162
+
163
+ ## Consequences
164
+
165
+ - **Single source of truth for a unit of work.** A change to the meaning of "a delivery unit" — a new
166
+ status, a lifecycle rule, a step in the implement cell — is made once and derived into every
167
+ representation, eliminating the three-way drift surface this project treats as a defect class.
168
+ - **Renderability + executability both improve.** One implement-cell process is one thing to keep
169
+ deploy-valid and lay out, instead of **two hand-authored copies plus a compiler generator** that can
170
+ silently diverge (a graph can render and still fail deploy — the copies are exactly where that
171
+ divergence hides).
172
+ - **Migration is incremental and forward-only.** The VIEWs preserve every current read shape while the
173
+ physical model consolidates underneath, and each slice below is independently shippable. Consistent
174
+ with this repo's forward-only, expand-and-contract migration contract (see
175
+ `070_drop_plan_projection_columns.sql`, which treats dropping projection columns as a later contract
176
+ phase), a slice is **not** reverted by reverting the app: rolling back a writer-repointing or
177
+ table-to-VIEW slice requires a **separately designed recovery/compatibility migration**, not a plain
178
+ revert.
179
+ - **Cost.** A backfill/migration for `delivery_units`; a one-time extraction of the shared cells; and
180
+ the process slices are sequenced behind the (now-live) engine-wasm unlock. No behaviour change is
181
+ intended — this is a representation consolidation, guarded by parity tests against the existing VIEWs
182
+ and by the deploy+run engine tests.
183
+
184
+ ## Rollout (see #464 for the live checklist)
185
+
186
+ Each slice is independently shippable; the process slices (S4/S5) are sequenced behind the engine-wasm
187
+ 0.7.2 unlock. The **dev-testkit** side of that unlock has landed (#416, verified in-process above); S4/S5
188
+ additionally gate on the **deployed broker/runtime** carrying verified `callActivity` support (the
189
+ deployment-runtime prerequisite noted above), not on #416 alone.
190
+
191
+ - **S0 · ADR** — this record.
192
+ - **S1 · status lifecycle** — one derived status union via ADR 0065 `defineReadModel`, replacing the
193
+ three bespoke unions. This **depends on and overlaps** #305 (consolidate escalations on native
194
+ `user_tasks`) but does not subsume it: #305 additionally retires the `feature_runs` escalation
195
+ columns and the bespoke completion doors and updates the escalation UI/forms, which remain #305's
196
+ scope (an adjacent sub-step of S1/S3 per #464).
197
+ - **S2 · `delivery_units` table** — the aggregate. Because current code still **writes**
198
+ `feature_runs` / `plans` / `plan_tasks` / `delivery_graph_runs` directly (`app/feature.ts`,
199
+ `app/plan.ts`, `app/deliveryGraphRun.ts`) — **and** the framework's `instanceTracking` bindings in
200
+ `nano.app.json` write termination status to `feature_runs`, `plans`, and `delivery_graph_runs` — and a
201
+ SQLite VIEW is read-only, follow expand/contract **order**: (a) add `delivery_units` and dual-write it
202
+ alongside the legacy tables; (b) backfill existing/legacy rows; (c) repoint reads to VIEWs/rows derived
203
+ from `delivery_units`, guarded by read-model parity tests. The legacy tables stay **physical
204
+ (writable) through S2** — they must **not** become read-only VIEWs while any writer, including the
205
+ `instanceTracking` termination-reconciliation bindings, still targets them, or reconciliation fails
206
+ with no writable target left for S3 to move. (d) Only after **S3** has moved those `instanceTracking`
207
+ bindings and every other writer off the legacy tables does the table-to-VIEW contract phase retire the
208
+ legacy write paths.
209
+ - **S3 · collapse doors** — unify the three `instanceTracking` bindings + `senior:*` dispatch doors.
210
+ - **S4 · shared cells** — extract the atomic `implement-cell.bpmn` **and its sibling wait-gate and
211
+ human-escalation cells** (Decision §2) into standalone processes; `feature.bpmn` + the `plan-fanout`
212
+ MI body compose them via `callActivity`.
213
+ - **S5 · compiler emits calls** — `deliveryGraphCompiler` references shared cells instead of inlining
214
+ per-node copies.
215
+
216
+ ## Non-goals / deferred
217
+
218
+ - **Unifying the static-vs-adaptive execution axis** (see Decision §4) — preserved deliberately.
219
+ - **Changing the downstream PR/convergence loop** — already single-sourced (`pull_requests`); untouched.
220
+ - **Cross-repo/platform representation** — this ADR is nano-workforce-local; any platform-wide delivery
221
+ aggregate would be a separate nano-bpm ADR.