@nanobpm/nano-workforce 0.153.0 → 0.155.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 CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.155.0](https://github.com/nanobpm/nano-workforce/compare/v0.154.0...v0.155.0) (2026-08-29)
2
+
3
+ ### Features
4
+
5
+ * **delivery-units:** delivery_units aggregate table (ADR 0006 S2, [#589](https://github.com/nanobpm/nano-workforce/issues/589)) ([#601](https://github.com/nanobpm/nano-workforce/issues/601)) ([6949fc4](https://github.com/nanobpm/nano-workforce/commit/6949fc4d3ee567b95497e43391ba15e9367a88e0))
6
+
7
+ ## [0.154.0](https://github.com/nanobpm/nano-workforce/compare/v0.153.0...v0.154.0) (2026-08-29)
8
+
9
+ ### Features
10
+
11
+ * **onboarding:** three-state GitHub credential preflight in install.sh ([#599](https://github.com/nanobpm/nano-workforce/issues/599)) ([7e9f592](https://github.com/nanobpm/nano-workforce/commit/7e9f592632fc85cfea9f89843252c0128cec6c8d)), closes [#588](https://github.com/nanobpm/nano-workforce/issues/588)
12
+
1
13
  ## [0.153.0](https://github.com/nanobpm/nano-workforce/compare/v0.152.0...v0.153.0) (2026-08-29)
2
14
 
3
15
  ### Features
package/README.md CHANGED
@@ -97,6 +97,23 @@ instance count each), hires each with `--rank senior --protocol acp --permission
97
97
  yolo`, composes a declarative **workforce manifest** (`c8 nano workforce add`),
98
98
  and brings it up (`c8 nano start` then `c8 nano workforce start`).
99
99
 
100
+ **GitHub access preflight.** Because a workforce with no GitHub credential can
101
+ neither review, push, nor merge, the preflight checks for a **usable** credential
102
+ on this host before hiring: a `GITHUB_TOKEN`/`GH_TOKEN` in the environment
103
+ *(validated with `gh api user` / a raw `api.github.com/user` probe, not trusted
104
+ merely for being set — in the rare case where neither `gh` nor `curl` is present
105
+ to probe with, it cannot validate and warns that it is proceeding on trust)*, or
106
+ the `gh` CLI *installed, authenticated, and proven with
107
+ `gh api user`*. It
108
+ distinguishes gh missing, installed-but-unauthenticated, and
109
+ authenticated-but-unusable (expired token, missing `repo` scope, SAML SSO), warns
110
+ (but does not fail) on a missing `workflow` scope, and checks `git` too. It prints
111
+ a platform-aware install hint and the `gh auth login` / `GITHUB_TOKEN` remediation
112
+ — it never runs `gh auth login` for you. Interactive runs let you fix it and
113
+ re-check, or confirm-to-continue (default no); non-interactive runs **fail** unless
114
+ you pass `--allow-no-github`. The check covers **this host only** — remote worker
115
+ hosts each need their own credential.
116
+
100
117
  **Phase 2 — the app.** It then talks to the nano console the engine brought up to
101
118
  install the `@nanobpm/nano-workforce` extension, scaffold a **Workforce** project
102
119
  from the `nano-workforce` template, write its `ProjectConfig.env` (including
@@ -0,0 +1,244 @@
1
+ // Coverage for ADR 0006 slice S2 (#589) — the `delivery_units` aggregate and its DB-level sync.
2
+ //
3
+ // Proves, against a REAL in-memory SQLite with the full migration set applied:
4
+ // 1. VOCABULARY PARITY — the TS `DELIVERY_UNIT_KINDS` closed enum matches the `CHECK (kind IN (…))`
5
+ // constraint in migration 088 (the two lowerings of the §2 kind enum can't drift).
6
+ // 2. DERIVATION PARITY — for every source status of every shape, the DB triggers/backfill derive the
7
+ // same canonical `delivery_status` as the S1 read models (app/deliveryUnitStatus.ts) and the same
8
+ // `dispatch_status` as the TS `dispatchStatusForDelivery` (the SQL and TS lowerings agree).
9
+ // 3. IDENTITY — the derived `unit_id` matches the TS `*UnitId` helpers, and an epic slice node hangs
10
+ // under its epic (`parent_unit_id`), so the aggregate's universal key is what the door will name.
11
+ // 4. COMPAT-VIEW PARITY — each legacy-shaped `<table>__units` VIEW is row-for-row identical to its
12
+ // base table over insert/update/delete, so a read path can swap onto the aggregate losslessly.
13
+ import { DatabaseSync } from "node:sqlite";
14
+ import { test } from "node:test";
15
+ import { applyMigrationSet, readMigrationSetFromDisk } from "#test-migrations";
16
+ import { assert, assertEquals } from "#test-assert";
17
+ import {
18
+ DELIVERY_UNIT_KINDS,
19
+ deliveryGraphUnitId,
20
+ dispatchStatusForDelivery,
21
+ epicUnitId,
22
+ featureUnitId,
23
+ planTaskUnitId,
24
+ } from "./deliveryUnit.ts";
25
+ import {
26
+ type DeliveryUnitStatus,
27
+ deliveryGraphDeliveryStatus,
28
+ featureDeliveryStatus,
29
+ planDeliveryStatus,
30
+ PLAN_STATUSES,
31
+ planTaskDeliveryStatus,
32
+ toDeliveryUnitStatus,
33
+ } from "./deliveryUnitStatus.ts";
34
+ import { DELIVERY_GRAPH_RUN_STATUSES } from "./deliveryGraphRun.ts";
35
+ import { FEATURE_RUN_STATUSES } from "./feature.ts";
36
+ import { PLAN_TASK_STATUSES } from "./plan.ts";
37
+
38
+ function freshDb(): DatabaseSync {
39
+ const db = new DatabaseSync(":memory:");
40
+ db.exec("PRAGMA foreign_keys = ON;");
41
+ applyMigrationSet(db, readMigrationSetFromDisk());
42
+ return db;
43
+ }
44
+
45
+ const NOW = "2026-01-01T00:00:00Z";
46
+ const row = (db: DatabaseSync, sql: string, ...p: unknown[]) => db.prepare(sql).get(...(p as never[])) as Record<string, unknown>;
47
+ const rows = (db: DatabaseSync, sql: string, ...p: unknown[]) => db.prepare(sql).all(...(p as never[])) as Record<string, unknown>[];
48
+ const exec = (db: DatabaseSync, sql: string, ...p: unknown[]) => db.prepare(sql).run(...(p as never[]));
49
+
50
+ test("VOCABULARY PARITY: DELIVERY_UNIT_KINDS matches migration 088's CHECK (kind IN (…))", () => {
51
+ const mig = readMigrationSetFromDisk().find((m) => m.name === "088_delivery_units.sql");
52
+ assert(mig, "088_delivery_units.sql must exist");
53
+ const m = mig.sql.match(/kind IN \(([^)]*)\)/);
54
+ assert(m, "088 must declare a CHECK (kind IN (…)) constraint");
55
+ const declared = m[1].split(",").map((s) => s.trim().replace(/^'|'$/g, ""));
56
+ assertEquals(declared, [...DELIVERY_UNIT_KINDS], "the SQL kind enum and the TS enum must agree");
57
+ });
58
+
59
+ test("DERIVATION PARITY: feature triggers derive the S1 canonical + dispatch status for every source status", () => {
60
+ const db = freshDb();
61
+ FEATURE_RUN_STATUSES.forEach((status, i) => {
62
+ const key = `o/r#${100 + i}`;
63
+ exec(
64
+ db,
65
+ `INSERT INTO feature_runs(feature_key,repo,issue_number,issue_url,base_branch,status,created_at,updated_at)
66
+ VALUES(?,?,?,?,?,?,?,?)`,
67
+ key, "o/r", 100 + i, "u", "main", status, NOW, NOW,
68
+ );
69
+ const u = row(db, "SELECT * FROM delivery_units WHERE unit_id=?", featureUnitId(key));
70
+ const canonical = toDeliveryUnitStatus(featureDeliveryStatus, status) as DeliveryUnitStatus;
71
+ assertEquals(u.kind, "feature");
72
+ assertEquals(u.delivery_status, canonical, `feature ${status} → canonical`);
73
+ assertEquals(u.dispatch_status, dispatchStatusForDelivery(canonical), `feature ${status} → dispatch`);
74
+ assertEquals(u.status, status, "raw legacy status is preserved verbatim");
75
+ });
76
+ });
77
+
78
+ test("DERIVATION PARITY: epic (plans) triggers derive the S1 canonical + dispatch status for every source status", () => {
79
+ const db = freshDb();
80
+ PLAN_STATUSES.forEach((status, i) => {
81
+ const key = `o/r#${200 + i}`;
82
+ exec(
83
+ db,
84
+ `INSERT INTO plans(plan_key,repo,issue_number,issue_url,status,created_at,updated_at)
85
+ VALUES(?,?,?,?,?,?,?)`,
86
+ key, "o/r", 200 + i, "u", status, NOW, NOW,
87
+ );
88
+ const u = row(db, "SELECT * FROM delivery_units WHERE unit_id=?", epicUnitId(key));
89
+ const canonical = toDeliveryUnitStatus(planDeliveryStatus, status) as DeliveryUnitStatus;
90
+ assertEquals(u.kind, "epic");
91
+ assertEquals(u.delivery_status, canonical, `epic ${status} → canonical`);
92
+ assertEquals(u.dispatch_status, dispatchStatusForDelivery(canonical), `epic ${status} → dispatch`);
93
+ });
94
+ });
95
+
96
+ test("DERIVATION PARITY + IDENTITY: plan-task nodes derive status and hang under their epic", () => {
97
+ const db = freshDb();
98
+ const planKey = "o/r#300";
99
+ exec(
100
+ db,
101
+ `INSERT INTO plans(plan_key,repo,issue_number,issue_url,status,created_at,updated_at) VALUES(?,?,?,?,?,?,?)`,
102
+ planKey, "o/r", 300, "u", "dispatched", NOW, NOW,
103
+ );
104
+ PLAN_TASK_STATUSES.forEach((status, i) => {
105
+ exec(
106
+ db,
107
+ `INSERT INTO plan_tasks(plan_key,task_index,task_id,status,created_at,updated_at) VALUES(?,?,?,?,?,?)`,
108
+ planKey, i, `t${i}`, status, NOW, NOW,
109
+ );
110
+ const u = row(db, "SELECT * FROM delivery_units WHERE unit_id=?", planTaskUnitId(planKey, i));
111
+ const canonical = toDeliveryUnitStatus(planTaskDeliveryStatus, status) as DeliveryUnitStatus;
112
+ assertEquals(u.kind, "plan-task");
113
+ assertEquals(u.delivery_status, canonical, `plan-task ${status} → canonical`);
114
+ assertEquals(u.dispatch_status, dispatchStatusForDelivery(canonical), `plan-task ${status} → dispatch`);
115
+ assertEquals(u.parent_unit_id, epicUnitId(planKey), "a node hangs under its epic composition");
116
+ assertEquals(u.node_index, i, "the node carries its slice ordinal");
117
+ });
118
+ });
119
+
120
+ test("DERIVATION PARITY: delivery-graph triggers derive the S1 canonical + dispatch status for every source status", () => {
121
+ const db = freshDb();
122
+ DELIVERY_GRAPH_RUN_STATUSES.forEach((status, i) => {
123
+ const key = `dg${i}`;
124
+ exec(
125
+ db,
126
+ `INSERT INTO delivery_graph_runs(run_key,digest,status,created_at,updated_at) VALUES(?,?,?,?,?)`,
127
+ key, "abc", status, NOW, NOW,
128
+ );
129
+ const u = row(db, "SELECT * FROM delivery_units WHERE unit_id=?", deliveryGraphUnitId(key));
130
+ const canonical = toDeliveryUnitStatus(deliveryGraphDeliveryStatus, status) as DeliveryUnitStatus;
131
+ assertEquals(u.kind, "delivery-graph");
132
+ assertEquals(u.delivery_status, canonical, `delivery-graph ${status} → canonical`);
133
+ assertEquals(u.dispatch_status, dispatchStatusForDelivery(canonical), `delivery-graph ${status} → dispatch`);
134
+ });
135
+ });
136
+
137
+ test("FAIL CLOSED: an unknown legacy status yields NULL delivery_status AND NULL dispatch_status (no inconsistent row)", () => {
138
+ // The legacy `status` columns are plain TEXT NOT NULL without a CHECK, so an unexpected value can
139
+ // reach the triggers. `dispatch_status` is derived FROM the canonical `delivery_status`, so when the
140
+ // status is unrecognised (delivery_status → NULL) dispatch_status MUST also be NULL — never a
141
+ // dangling 'dispatched'/'settled' on a row whose delivery_status is NULL.
142
+ const db = freshDb();
143
+ const cases: [string, () => void, string][] = [
144
+ [
145
+ "feature",
146
+ () =>
147
+ exec(
148
+ db,
149
+ `INSERT INTO feature_runs(feature_key,repo,issue_number,issue_url,base_branch,status,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)`,
150
+ "o/r#900", "o/r", 900, "u", "main", "bogus-status", NOW, NOW,
151
+ ),
152
+ featureUnitId("o/r#900"),
153
+ ],
154
+ [
155
+ "epic",
156
+ () =>
157
+ exec(
158
+ db,
159
+ `INSERT INTO plans(plan_key,repo,issue_number,issue_url,status,created_at,updated_at) VALUES(?,?,?,?,?,?,?)`,
160
+ "o/r#901", "o/r", 901, "u", "bogus-status", NOW, NOW,
161
+ ),
162
+ epicUnitId("o/r#901"),
163
+ ],
164
+ [
165
+ "delivery-graph",
166
+ () =>
167
+ exec(
168
+ db,
169
+ `INSERT INTO delivery_graph_runs(run_key,digest,status,created_at,updated_at) VALUES(?,?,?,?,?)`,
170
+ "dg900", "abc", "bogus-status", NOW, NOW,
171
+ ),
172
+ deliveryGraphUnitId("dg900"),
173
+ ],
174
+ ];
175
+ for (const [kind, insert, unitId] of cases) {
176
+ insert();
177
+ const u = row(db, "SELECT delivery_status, dispatch_status FROM delivery_units WHERE unit_id=?", unitId);
178
+ assertEquals(u.delivery_status, null, `${kind} unknown status → NULL delivery_status`);
179
+ assertEquals(u.dispatch_status, null, `${kind} unknown status → NULL dispatch_status (fail closed)`);
180
+ }
181
+
182
+ // plan-task needs a parent epic row first.
183
+ exec(db, `INSERT INTO plans(plan_key,repo,issue_number,issue_url,status,created_at,updated_at) VALUES(?,?,?,?,?,?,?)`, "o/r#902", "o/r", 902, "u", "planning", NOW, NOW);
184
+ exec(db, `INSERT INTO plan_tasks(plan_key,task_index,task_id,status,created_at,updated_at) VALUES(?,?,?,?,?,?)`, "o/r#902", 0, "t0", "bogus-status", NOW, NOW);
185
+ const pt = row(db, "SELECT delivery_status, dispatch_status FROM delivery_units WHERE unit_id=?", planTaskUnitId("o/r#902", 0));
186
+ assertEquals(pt.delivery_status, null, "plan-task unknown status → NULL delivery_status");
187
+ assertEquals(pt.dispatch_status, null, "plan-task unknown status → NULL dispatch_status (fail closed)");
188
+ });
189
+
190
+ // ── COMPAT-VIEW PARITY — each `<table>__units` VIEW is byte-identical to its base table. ────────────
191
+ const norm = (r: Record<string, unknown>[]) =>
192
+ JSON.stringify(r.map((x) => Object.fromEntries(Object.entries(x).sort())));
193
+
194
+ function assertViewParity(db: DatabaseSync, base: string, view: string) {
195
+ assertEquals(norm(rows(db, `SELECT * FROM ${base} ORDER BY 1`)), norm(rows(db, `SELECT * FROM ${view} ORDER BY 1`)), `${view} must equal ${base}`);
196
+ }
197
+
198
+ test("COMPAT-VIEW PARITY: every legacy surface is served row-for-row from the aggregate across insert/update/delete", () => {
199
+ const db = freshDb();
200
+ exec(
201
+ db,
202
+ `INSERT INTO feature_runs(feature_key,repo,issue_number,issue_url,base_branch,status,converge,auto_merge,outcome,delivery_label,title,created_at,updated_at)
203
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`,
204
+ "o/r#1", "o/r", 1, "u", "main", "running", 1, 0, null, null, "Feat", NOW, NOW,
205
+ );
206
+ exec(
207
+ db,
208
+ `INSERT INTO plans(plan_key,repo,issue_number,issue_url,title,status,task_count,base_branch,epic_phase,created_at,updated_at)
209
+ VALUES(?,?,?,?,?,?,?,?,?,?,?)`,
210
+ "o/r#2", "o/r", 2, "u", "Epic", "planning", 2, "main", "Planning", NOW, NOW,
211
+ );
212
+ exec(
213
+ db,
214
+ `INSERT INTO plan_tasks(plan_key,task_index,task_id,title,prompt,status,pr_key,summary,wave,created_at,updated_at)
215
+ VALUES(?,?,?,?,?,?,?,?,?,?,?)`,
216
+ "o/r#2", 0, "t0", "Slice", "do it", "pending", null, null, 1, NOW, NOW,
217
+ );
218
+ exec(
219
+ db,
220
+ `INSERT INTO delivery_graph_runs(run_key,process_key,digest,status,side_effecting,node_count,human_node_count,side_effect_count,title,created_at,updated_at)
221
+ VALUES(?,?,?,?,?,?,?,?,?,?,?)`,
222
+ "dg1", null, "deadbeef", "awaiting-approval", 1, 5, 2, 3, "Graph", NOW, NOW,
223
+ );
224
+
225
+ const pairs: [string, string][] = [
226
+ ["feature_runs", "feature_runs__units"],
227
+ ["plans", "plans__units"],
228
+ ["plan_tasks", "plan_tasks__units"],
229
+ ["delivery_graph_runs", "delivery_graph_runs__units"],
230
+ ];
231
+ for (const [b, v] of pairs) assertViewParity(db, b, v);
232
+
233
+ // UPDATE — a status transition (and a projected column) must re-project onto the aggregate.
234
+ exec(db, "UPDATE feature_runs SET status=?, pr_key=?, updated_at=? WHERE feature_key=?", "merged", "o/r#5", "2026-02", "o/r#1");
235
+ const fu = row(db, "SELECT delivery_status, dispatch_status FROM delivery_units WHERE unit_id=?", featureUnitId("o/r#1"));
236
+ assertEquals(fu.delivery_status, "merged");
237
+ assertEquals(fu.dispatch_status, "settled");
238
+ for (const [b, v] of pairs) assertViewParity(db, b, v);
239
+
240
+ // DELETE — the aggregate row is dropped with the legacy row.
241
+ exec(db, "DELETE FROM plan_tasks WHERE plan_key=?", "o/r#2");
242
+ assertEquals(rows(db, "SELECT * FROM delivery_units WHERE kind='plan-task'").length, 0, "deleting a slice drops its node unit");
243
+ assertViewParity(db, "plan_tasks", "plan_tasks__units");
244
+ });
@@ -0,0 +1,109 @@
1
+ // ADR 0006 slice S2 (#589) — the `delivery_units` aggregate, TS side.
2
+ //
3
+ // S2 collapses the four legacy delivery-unit representations (`feature_runs`, `plans`, `plan_tasks`,
4
+ // `delivery_graph_runs`) onto ONE kind-tagged aggregate table (`db/migrations/088_delivery_units.sql`).
5
+ // The data-level sync is done at the DB layer — triggers mirror every legacy write into the aggregate
6
+ // (089), a backfill seeds pre-existing rows (090), and legacy-shaped compat VIEWs are served FROM the
7
+ // aggregate (091) — so this module carries NO write path of its own; it is the canonical TS home for
8
+ // the aggregate's VOCABULARY (the closed `kind` enum, the `dispatch_status` lifecycle), the universal
9
+ // `unit_id` derivation, and the `dispatch_status` derivation. The last two are also lowered to SQL in
10
+ // the trigger/backfill migrations; app/deliveryUnit.test.ts proves the two lowerings agree over the
11
+ // full status/shape matrix (the same declare-once / parity-guard discipline S1 uses for the status
12
+ // union — derivation over duplication).
13
+ //
14
+ // SCOPE (S2). This owns the aggregate's identity + dispatch vocabulary and a read gateway. The single
15
+ // dispatch door (S3) will key on `dispatch_status`; repointing live writers onto `delivery_units` and
16
+ // retiring the legacy write paths is the later CONTRACT phase (S3), sequenced after the
17
+ // `instanceTracking` doors move. Nothing here changes existing behaviour.
18
+
19
+ import type { DataLayer, Table } from "@nanobpm/urban";
20
+ import { type DeliveryUnitStatus, isDeliveryUnitSettled } from "./deliveryUnitStatus.ts";
21
+
22
+ /**
23
+ * The closed §2 `kind` enum covering every delivery unit. A run of an epic is ONE unit (`kind='epic'`,
24
+ * a composition over its slices); each slice is a `plan-task` node under it. `feature` is the
25
+ * degenerate 1-node unit; `delivery-graph` is the arbitrary-DAG unit. `bugfix`/`chore` are reserved
26
+ * §2 members with no legacy table yet. Kept in lockstep with the `CHECK (kind IN (…))` constraint in
27
+ * migration 088 — a parity test pins the two.
28
+ */
29
+ export const DELIVERY_UNIT_KINDS = ["feature", "epic", "plan-task", "delivery-graph", "bugfix", "chore"] as const;
30
+ export type DeliveryUnitKind = (typeof DELIVERY_UNIT_KINDS)[number];
31
+
32
+ /**
33
+ * The `dispatch_status` lifecycle — the single dispatch door's status (ADR 0006 §4; the S3 door
34
+ * collapses onto it). Derived from the canonical {@link DeliveryUnitStatus}:
35
+ * - `pending` — created, not yet dispatched to an executor (canonical `requested`).
36
+ * - `dispatched` — a live executor/instance is working the unit (a live/parked non-terminal status).
37
+ * - `settled` — terminal, or a live PR resting stage (`opened`/`converging`): re-dispatchable. This
38
+ * is exactly the {@link isDeliveryUnitSettled} predicate, so the dispatch door's
39
+ * short-circuit gate matches the redispatch-settled semantics S1 defined.
40
+ */
41
+ export const DISPATCH_STATUSES = ["pending", "dispatched", "settled"] as const;
42
+ export type DeliveryUnitDispatchStatus = (typeof DISPATCH_STATUSES)[number];
43
+
44
+ /**
45
+ * Derive the `dispatch_status` from a canonical delivery-unit status — the TS lowering of the CASE the
46
+ * sync-trigger/backfill migrations (089/090) apply in SQL. `requested` ⇒ `pending`; a settled status
47
+ * (terminal or a PR resting stage) ⇒ `settled`; every other (live/parked) status ⇒ `dispatched`.
48
+ */
49
+ export function dispatchStatusForDelivery(status: DeliveryUnitStatus): DeliveryUnitDispatchStatus {
50
+ if (status === "requested") return "pending";
51
+ if (isDeliveryUnitSettled(status)) return "settled";
52
+ return "dispatched";
53
+ }
54
+
55
+ // ── Universal `unit_id` — the cross-representation fact every agent can name and the dispatch door
56
+ // keys on (#464 "What survives" #4). Kept in lockstep with the `unit_id` expressions the
57
+ // trigger/backfill migrations build; app/deliveryUnit.test.ts pins the two. ─────────────────────
58
+
59
+ /** The `feature:<feature_key>` unit id for a single-issue feature run. */
60
+ export const featureUnitId = (featureKey: string): string => `feature:${featureKey}`;
61
+
62
+ /** The `epic:<plan_key>` unit id for an epic (the `plans` aggregate row). */
63
+ export const epicUnitId = (planKey: string): string => `epic:${planKey}`;
64
+
65
+ /**
66
+ * The `plan-task:<plan_key>#<task_index>` unit id for one epic slice NODE. Its composition parent is
67
+ * {@link epicUnitId}(planKey) — the epic unit it hangs under.
68
+ */
69
+ export const planTaskUnitId = (planKey: string, taskIndex: number): string => `plan-task:${planKey}#${taskIndex}`;
70
+
71
+ /** The `delivery-graph:<run_key>` unit id for a delivery-graph run. */
72
+ export const deliveryGraphUnitId = (runKey: string): string => `delivery-graph:${runKey}`;
73
+
74
+ /**
75
+ * One row of the `delivery_units` aggregate. `unit_id`/`kind` are the identity pair; `delivery_status`
76
+ * is the canonical S1 union value; `status` is the raw legacy source status (kept verbatim so the
77
+ * compat VIEWs reconstruct legacy rows losslessly); `dispatch_status` is the door lifecycle. The
78
+ * per-shape legacy columns ride the same row (nullable off-kind). Read-only for S2 — the physical
79
+ * write target stays the legacy tables (the triggers mirror in).
80
+ */
81
+ export interface DeliveryUnitRow {
82
+ unit_id: string;
83
+ kind: DeliveryUnitKind;
84
+ legacy_key: string | null;
85
+ legacy_id: number | null;
86
+ parent_unit_id: string | null;
87
+ node_index: number | null;
88
+ delivery_status: DeliveryUnitStatus | null;
89
+ dispatch_status: DeliveryUnitDispatchStatus | null;
90
+ status: string | null;
91
+ repo: string | null;
92
+ issue_number: number | null;
93
+ issue_url: string | null;
94
+ title: string | null;
95
+ base_branch: string | null;
96
+ process_key: string | null;
97
+ pr_key: string | null;
98
+ outcome: string | null;
99
+ created_at: string | null;
100
+ updated_at: string | null;
101
+ }
102
+
103
+ /**
104
+ * The read gateway over the `delivery_units` aggregate, keyed on the universal `unit_id`. Read-only in
105
+ * S2 (writes flow through the legacy tables + the sync triggers); the S3 dispatch door will own the
106
+ * write path once the legacy writers retire.
107
+ */
108
+ export const deliveryUnits = (data: DataLayer): Table<DeliveryUnitRow> =>
109
+ data.table<DeliveryUnitRow>("delivery_units", "unit_id");
@@ -0,0 +1,94 @@
1
+ -- ADR 0006 slice S2 (#589) — the `delivery_units` aggregate: ONE kind-tagged table consolidating the
2
+ -- four legacy delivery-unit representations (`feature_runs`, `plans`, `plan_tasks`,
3
+ -- `delivery_graph_runs`) that ADR 0006 §2 declared to be one aggregate. S1 (#494) delivered the derived
4
+ -- status union (app/deliveryUnitStatus.ts); this is its data-aggregate half.
5
+ --
6
+ -- EXPAND phase (ADR 0006 §S2 rollout, expand-and-contract): this migration + 089 (sync triggers) + 090
7
+ -- (backfill) ADD the aggregate and keep it in lockstep with the legacy tables, which stay the physical
8
+ -- WRITE target through S2 (the framework `instanceTracking` bindings + every app writer still target
9
+ -- them; ADR 0065 makes `instanceTracking` a SOURCE that no longer writes base rows, so the legacy base
10
+ -- `status` is only ever written through the app gateways — mirrored here at the DB level). 091 adds the
11
+ -- legacy-shaped compat VIEWs served FROM this aggregate, parity-tested against the base tables
12
+ -- (app/deliveryUnit.test.ts). Repointing live writers onto `delivery_units` and retiring the legacy
13
+ -- write paths is the later CONTRACT phase (S3), which the ADR sequences after the `instanceTracking`
14
+ -- doors move.
15
+ --
16
+ -- Identity (#464 "What survives"): `unit_id` is the universal, human-nameable cross-representation key
17
+ -- the dispatch door (S3) keys on — `feature:<key>` / `epic:<plan_key>` / `plan-task:<plan_key>#<idx>` /
18
+ -- `delivery-graph:<run_key>`. A run of an epic is ONE unit (`kind='epic'`); each of its slices is a
19
+ -- `kind='plan-task'` node under it (`parent_unit_id = 'epic:<plan_key>'`). `kind` is the closed §2 enum.
20
+ -- `dispatch_status` is the single dispatch door's status (S3 collapses onto it): 'pending' (created,
21
+ -- not yet dispatched), 'dispatched' (a live executor/instance), 'settled' (terminal or a live PR
22
+ -- resting stage — re-dispatchable), derived from the canonical `delivery_status`.
23
+ --
24
+ -- Every column except the identity pair (`unit_id`/`kind`) is nullable so a sync trigger over a
25
+ -- partial legacy row can never trip a NOT NULL. `delivery_status` is the canonical S1 union value;
26
+ -- `status` is the raw legacy source status (kept verbatim so the compat VIEWs reconstruct the legacy
27
+ -- rows losslessly). The runner wraps each file in its own transaction — no BEGIN/COMMIT here. Numbered
28
+ -- after the current highest prefix (087).
29
+
30
+ CREATE TABLE IF NOT EXISTS delivery_units (
31
+ unit_id TEXT PRIMARY KEY,
32
+ kind TEXT NOT NULL CHECK (kind IN ('feature', 'epic', 'plan-task', 'delivery-graph', 'bugfix', 'chore')),
33
+ legacy_key TEXT,
34
+ legacy_id INTEGER,
35
+ parent_unit_id TEXT,
36
+ node_index INTEGER,
37
+ delivery_status TEXT,
38
+ dispatch_status TEXT,
39
+ repo TEXT,
40
+ issue_number INTEGER,
41
+ issue_url TEXT,
42
+ title TEXT,
43
+ base_branch TEXT,
44
+ status TEXT,
45
+ process_key TEXT,
46
+ pr_key TEXT,
47
+ outcome TEXT,
48
+ acknowledged_at TEXT,
49
+ list_bucket TEXT,
50
+ created_at TEXT,
51
+ updated_at TEXT,
52
+ converge INTEGER,
53
+ auto_merge INTEGER,
54
+ delivery_label TEXT,
55
+ stage TEXT,
56
+ stage_state TEXT,
57
+ stage_skipped TEXT,
58
+ attention TEXT,
59
+ task_count INTEGER,
60
+ gate_wave INTEGER,
61
+ blackboard_token TEXT,
62
+ retro_started_at TEXT,
63
+ epic_phase TEXT,
64
+ promotion_pr TEXT,
65
+ promotion_state TEXT,
66
+ ack_open INTEGER,
67
+ wait_gate TEXT,
68
+ wait_gate_label TEXT,
69
+ bound_artifacts TEXT,
70
+ task_id TEXT,
71
+ prompt TEXT,
72
+ summary TEXT,
73
+ wave INTEGER,
74
+ open_question TEXT,
75
+ answer TEXT,
76
+ draft_pr_key TEXT,
77
+ corr_key TEXT,
78
+ process_definition_id TEXT,
79
+ digest TEXT,
80
+ side_effecting INTEGER,
81
+ node_count INTEGER,
82
+ human_node_count INTEGER,
83
+ side_effect_count INTEGER,
84
+ phase TEXT,
85
+ phase_node_id TEXT,
86
+ human_labels TEXT
87
+ );
88
+
89
+ CREATE INDEX IF NOT EXISTS ix_delivery_units_kind ON delivery_units (kind);
90
+ CREATE INDEX IF NOT EXISTS ix_delivery_units_dispatch_status ON delivery_units (dispatch_status);
91
+ CREATE INDEX IF NOT EXISTS ix_delivery_units_parent ON delivery_units (parent_unit_id);
92
+ CREATE INDEX IF NOT EXISTS ix_delivery_units_process_key ON delivery_units (process_key);
93
+ CREATE INDEX IF NOT EXISTS ix_delivery_units_pr_key ON delivery_units (pr_key);
94
+ CREATE INDEX IF NOT EXISTS ix_delivery_units_legacy_key ON delivery_units (legacy_key);
@@ -0,0 +1,94 @@
1
+ -- ADR 0006 slice S2 (#589) — DB-level sync triggers mirroring every legacy delivery-unit write into
2
+ -- the `delivery_units` aggregate (088). AFTER INSERT/UPDATE/DELETE on each of the four legacy tables
3
+ -- keeps the aggregate in perfect lockstep regardless of WHICH writer touched the base row — the app
4
+ -- gateways (app/feature.ts, app/plan.ts, app/deliveryGraphRun.ts), the plan/wave/results workers, the
5
+ -- service pollers, AND the raw-SQL compare-and-swap in `claimRunForLaunch` (app/deliveryGraphRun.ts)
6
+ -- that bypasses the gateway. A trigger fires at the DB level, so there is no drift surface and no
7
+ -- write-path code to keep in sync (derivation over duplication). ADR 0065 makes `instanceTracking` a
8
+ -- SOURCE that no longer writes base rows, so the base `status` these triggers read is always the
9
+ -- worker-owned transient — the terminal fold stays derived on the legacy `__tracking` VIEWs.
10
+ --
11
+ -- `INSERT OR REPLACE` on the derived `unit_id` makes each trigger idempotent (an update re-projects
12
+ -- the whole row). `delivery_status` (canonical S1 union) and `dispatch_status` are computed by the
13
+ -- same CASE lowerings app/deliveryUnit.ts mirrors in TS, guarded at parity by app/deliveryUnit.test.ts.
14
+ -- The runner wraps each file in its own transaction — no BEGIN/COMMIT here.
15
+
16
+ DROP TRIGGER IF EXISTS feature_runs__du_ai;
17
+ CREATE TRIGGER feature_runs__du_ai AFTER INSERT ON feature_runs
18
+ BEGIN
19
+ INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, repo, issue_number, issue_url, title, base_branch, status, process_key, pr_key, converge, auto_merge, outcome, delivery_label, acknowledged_at, stage, stage_state, stage_skipped, attention, list_bucket, created_at, updated_at)
20
+ VALUES ('feature:' || NEW.feature_key, 'feature', NEW.feature_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'running' THEN 'running' WHEN NEW.status = 'escalated' THEN 'escalated' WHEN NEW.status = 'opened' THEN 'opened' WHEN NEW.status = 'converging' THEN 'converging' WHEN NEW.status = 'awaiting_operator' THEN 'awaiting_operator' WHEN NEW.status = 'merged' THEN 'merged' WHEN NEW.status = 'converged' THEN 'converged' WHEN NEW.status = 'blocked' THEN 'blocked' WHEN NEW.status = 'skipped' THEN 'skipped' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('opened', 'converging', 'merged', 'converged', 'blocked', 'skipped', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('running', 'escalated', 'awaiting_operator') THEN 'dispatched' ELSE NULL END, NEW.repo, NEW.issue_number, NEW.issue_url, NEW.title, NEW.base_branch, NEW.status, NEW.process_key, NEW.pr_key, NEW.converge, NEW.auto_merge, NEW.outcome, NEW.delivery_label, NEW.acknowledged_at, NEW.stage, NEW.stage_state, NEW.stage_skipped, NEW.attention, NEW.list_bucket, NEW.created_at, NEW.updated_at);
21
+ END;
22
+
23
+ DROP TRIGGER IF EXISTS feature_runs__du_au;
24
+ CREATE TRIGGER feature_runs__du_au AFTER UPDATE ON feature_runs
25
+ BEGIN
26
+ INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, repo, issue_number, issue_url, title, base_branch, status, process_key, pr_key, converge, auto_merge, outcome, delivery_label, acknowledged_at, stage, stage_state, stage_skipped, attention, list_bucket, created_at, updated_at)
27
+ VALUES ('feature:' || NEW.feature_key, 'feature', NEW.feature_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'running' THEN 'running' WHEN NEW.status = 'escalated' THEN 'escalated' WHEN NEW.status = 'opened' THEN 'opened' WHEN NEW.status = 'converging' THEN 'converging' WHEN NEW.status = 'awaiting_operator' THEN 'awaiting_operator' WHEN NEW.status = 'merged' THEN 'merged' WHEN NEW.status = 'converged' THEN 'converged' WHEN NEW.status = 'blocked' THEN 'blocked' WHEN NEW.status = 'skipped' THEN 'skipped' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('opened', 'converging', 'merged', 'converged', 'blocked', 'skipped', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('running', 'escalated', 'awaiting_operator') THEN 'dispatched' ELSE NULL END, NEW.repo, NEW.issue_number, NEW.issue_url, NEW.title, NEW.base_branch, NEW.status, NEW.process_key, NEW.pr_key, NEW.converge, NEW.auto_merge, NEW.outcome, NEW.delivery_label, NEW.acknowledged_at, NEW.stage, NEW.stage_state, NEW.stage_skipped, NEW.attention, NEW.list_bucket, NEW.created_at, NEW.updated_at);
28
+ END;
29
+
30
+ DROP TRIGGER IF EXISTS feature_runs__du_ad;
31
+ CREATE TRIGGER feature_runs__du_ad AFTER DELETE ON feature_runs
32
+ BEGIN
33
+ DELETE FROM delivery_units WHERE unit_id = 'feature:' || OLD.feature_key;
34
+ END;
35
+
36
+ DROP TRIGGER IF EXISTS plans__du_ai;
37
+ CREATE TRIGGER plans__du_ai AFTER INSERT ON plans
38
+ BEGIN
39
+ INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, repo, issue_number, issue_url, title, status, task_count, process_key, outcome, gate_wave, blackboard_token, retro_started_at, base_branch, epic_phase, promotion_pr, promotion_state, acknowledged_at, list_bucket, ack_open, wait_gate, wait_gate_label, bound_artifacts, created_at, updated_at)
40
+ VALUES ('epic:' || NEW.plan_key, 'epic', NEW.plan_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'planning' THEN 'requested' WHEN NEW.status = 'dispatched' THEN 'running' WHEN NEW.status = 'done' THEN 'done' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('planning') THEN 'pending' WHEN NEW.status IN ('done', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('dispatched') THEN 'dispatched' ELSE NULL END, NEW.repo, NEW.issue_number, NEW.issue_url, NEW.title, NEW.status, NEW.task_count, NEW.process_key, NEW.outcome, NEW.gate_wave, NEW.blackboard_token, NEW.retro_started_at, NEW.base_branch, NEW.epic_phase, NEW.promotion_pr, NEW.promotion_state, NEW.acknowledged_at, NEW.list_bucket, NEW.ack_open, NEW.wait_gate, NEW.wait_gate_label, NEW.bound_artifacts, NEW.created_at, NEW.updated_at);
41
+ END;
42
+
43
+ DROP TRIGGER IF EXISTS plans__du_au;
44
+ CREATE TRIGGER plans__du_au AFTER UPDATE ON plans
45
+ BEGIN
46
+ INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, repo, issue_number, issue_url, title, status, task_count, process_key, outcome, gate_wave, blackboard_token, retro_started_at, base_branch, epic_phase, promotion_pr, promotion_state, acknowledged_at, list_bucket, ack_open, wait_gate, wait_gate_label, bound_artifacts, created_at, updated_at)
47
+ VALUES ('epic:' || NEW.plan_key, 'epic', NEW.plan_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'planning' THEN 'requested' WHEN NEW.status = 'dispatched' THEN 'running' WHEN NEW.status = 'done' THEN 'done' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('planning') THEN 'pending' WHEN NEW.status IN ('done', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('dispatched') THEN 'dispatched' ELSE NULL END, NEW.repo, NEW.issue_number, NEW.issue_url, NEW.title, NEW.status, NEW.task_count, NEW.process_key, NEW.outcome, NEW.gate_wave, NEW.blackboard_token, NEW.retro_started_at, NEW.base_branch, NEW.epic_phase, NEW.promotion_pr, NEW.promotion_state, NEW.acknowledged_at, NEW.list_bucket, NEW.ack_open, NEW.wait_gate, NEW.wait_gate_label, NEW.bound_artifacts, NEW.created_at, NEW.updated_at);
48
+ END;
49
+
50
+ DROP TRIGGER IF EXISTS plans__du_ad;
51
+ CREATE TRIGGER plans__du_ad AFTER DELETE ON plans
52
+ BEGIN
53
+ DELETE FROM delivery_units WHERE unit_id = 'epic:' || OLD.plan_key;
54
+ END;
55
+
56
+ DROP TRIGGER IF EXISTS plan_tasks__du_ai;
57
+ CREATE TRIGGER plan_tasks__du_ai AFTER INSERT ON plan_tasks
58
+ BEGIN
59
+ INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, title, prompt, status, pr_key, summary, wave, open_question, answer, draft_pr_key, corr_key, created_at, updated_at, task_id)
60
+ VALUES ('plan-task:' || NEW.plan_key || '#' || NEW.task_index, 'plan-task', NEW.plan_key, NEW.id, 'epic:' || NEW.plan_key, NEW.task_index, CASE WHEN NEW.status = 'pending' THEN 'requested' WHEN NEW.status = 'opened' THEN 'opened' WHEN NEW.status = 'blocked' THEN 'blocked' WHEN NEW.status = 'skipped' THEN 'skipped' WHEN NEW.status = 'escalated' THEN 'escalated' WHEN NEW.status = 'waiting-for-lane' THEN 'waiting' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('pending') THEN 'pending' WHEN NEW.status IN ('opened', 'blocked', 'skipped', 'abandoned') THEN 'settled' WHEN NEW.status IN ('escalated', 'waiting-for-lane') THEN 'dispatched' ELSE NULL END, NEW.title, NEW.prompt, NEW.status, NEW.pr_key, NEW.summary, NEW.wave, NEW.open_question, NEW.answer, NEW.draft_pr_key, NEW.corr_key, NEW.created_at, NEW.updated_at, NEW.task_id);
61
+ END;
62
+
63
+ DROP TRIGGER IF EXISTS plan_tasks__du_au;
64
+ CREATE TRIGGER plan_tasks__du_au AFTER UPDATE ON plan_tasks
65
+ BEGIN
66
+ INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, title, prompt, status, pr_key, summary, wave, open_question, answer, draft_pr_key, corr_key, created_at, updated_at, task_id)
67
+ VALUES ('plan-task:' || NEW.plan_key || '#' || NEW.task_index, 'plan-task', NEW.plan_key, NEW.id, 'epic:' || NEW.plan_key, NEW.task_index, CASE WHEN NEW.status = 'pending' THEN 'requested' WHEN NEW.status = 'opened' THEN 'opened' WHEN NEW.status = 'blocked' THEN 'blocked' WHEN NEW.status = 'skipped' THEN 'skipped' WHEN NEW.status = 'escalated' THEN 'escalated' WHEN NEW.status = 'waiting-for-lane' THEN 'waiting' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('pending') THEN 'pending' WHEN NEW.status IN ('opened', 'blocked', 'skipped', 'abandoned') THEN 'settled' WHEN NEW.status IN ('escalated', 'waiting-for-lane') THEN 'dispatched' ELSE NULL END, NEW.title, NEW.prompt, NEW.status, NEW.pr_key, NEW.summary, NEW.wave, NEW.open_question, NEW.answer, NEW.draft_pr_key, NEW.corr_key, NEW.created_at, NEW.updated_at, NEW.task_id);
68
+ END;
69
+
70
+ DROP TRIGGER IF EXISTS plan_tasks__du_ad;
71
+ CREATE TRIGGER plan_tasks__du_ad AFTER DELETE ON plan_tasks
72
+ BEGIN
73
+ DELETE FROM delivery_units WHERE unit_id = 'plan-task:' || OLD.plan_key || '#' || OLD.task_index;
74
+ END;
75
+
76
+ DROP TRIGGER IF EXISTS delivery_graph_runs__du_ai;
77
+ CREATE TRIGGER delivery_graph_runs__du_ai AFTER INSERT ON delivery_graph_runs
78
+ BEGIN
79
+ INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, process_key, process_definition_id, digest, status, side_effecting, node_count, human_node_count, side_effect_count, title, phase, phase_node_id, human_labels, created_at, updated_at)
80
+ VALUES ('delivery-graph:' || NEW.run_key, 'delivery-graph', NEW.run_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'awaiting-approval' THEN 'requested' WHEN NEW.status = 'running' THEN 'running' WHEN NEW.status = 'done' THEN 'done' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('awaiting-approval') THEN 'pending' WHEN NEW.status IN ('done', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('running') THEN 'dispatched' ELSE NULL END, NEW.process_key, NEW.process_definition_id, NEW.digest, NEW.status, NEW.side_effecting, NEW.node_count, NEW.human_node_count, NEW.side_effect_count, NEW.title, NEW.phase, NEW.phase_node_id, NEW.human_labels, NEW.created_at, NEW.updated_at);
81
+ END;
82
+
83
+ DROP TRIGGER IF EXISTS delivery_graph_runs__du_au;
84
+ CREATE TRIGGER delivery_graph_runs__du_au AFTER UPDATE ON delivery_graph_runs
85
+ BEGIN
86
+ INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, process_key, process_definition_id, digest, status, side_effecting, node_count, human_node_count, side_effect_count, title, phase, phase_node_id, human_labels, created_at, updated_at)
87
+ VALUES ('delivery-graph:' || NEW.run_key, 'delivery-graph', NEW.run_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'awaiting-approval' THEN 'requested' WHEN NEW.status = 'running' THEN 'running' WHEN NEW.status = 'done' THEN 'done' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('awaiting-approval') THEN 'pending' WHEN NEW.status IN ('done', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('running') THEN 'dispatched' ELSE NULL END, NEW.process_key, NEW.process_definition_id, NEW.digest, NEW.status, NEW.side_effecting, NEW.node_count, NEW.human_node_count, NEW.side_effect_count, NEW.title, NEW.phase, NEW.phase_node_id, NEW.human_labels, NEW.created_at, NEW.updated_at);
88
+ END;
89
+
90
+ DROP TRIGGER IF EXISTS delivery_graph_runs__du_ad;
91
+ CREATE TRIGGER delivery_graph_runs__du_ad AFTER DELETE ON delivery_graph_runs
92
+ BEGIN
93
+ DELETE FROM delivery_units WHERE unit_id = 'delivery-graph:' || OLD.run_key;
94
+ END;
@@ -0,0 +1,120 @@
1
+ -- ADR 0006 slice S2 (#589) — backfill the `delivery_units` aggregate (088) from the existing rows of
2
+ -- the four legacy tables, using the SAME projection the sync triggers (089) apply going forward. Idempotent
3
+ -- (`INSERT OR IGNORE` on the `unit_id` PK) so re-running the migration set — or upgrading an install
4
+ -- whose triggers already mirrored some rows — never double-inserts. Runs AFTER the triggers so a fresh
5
+ -- install with seeded legacy rows is fully covered either way (triggers fire on live writes; this seeds
6
+ -- pre-existing rows). The runner wraps each file in its own transaction — no BEGIN/COMMIT here.
7
+
8
+ INSERT OR IGNORE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, repo, issue_number, issue_url, title, base_branch, status, process_key, pr_key, converge, auto_merge, outcome, delivery_label, acknowledged_at, stage, stage_state, stage_skipped, attention, list_bucket, created_at, updated_at)
9
+ SELECT 'feature:' || feature_runs.feature_key AS unit_id,
10
+ 'feature' AS kind,
11
+ feature_runs.feature_key AS legacy_key,
12
+ NULL AS legacy_id,
13
+ NULL AS parent_unit_id,
14
+ NULL AS node_index,
15
+ CASE WHEN feature_runs.status = 'running' THEN 'running' WHEN feature_runs.status = 'escalated' THEN 'escalated' WHEN feature_runs.status = 'opened' THEN 'opened' WHEN feature_runs.status = 'converging' THEN 'converging' WHEN feature_runs.status = 'awaiting_operator' THEN 'awaiting_operator' WHEN feature_runs.status = 'merged' THEN 'merged' WHEN feature_runs.status = 'converged' THEN 'converged' WHEN feature_runs.status = 'blocked' THEN 'blocked' WHEN feature_runs.status = 'skipped' THEN 'skipped' WHEN feature_runs.status = 'failed' THEN 'failed' WHEN feature_runs.status = 'abandoned' THEN 'abandoned' ELSE NULL END AS delivery_status,
16
+ CASE WHEN feature_runs.status IN ('opened', 'converging', 'merged', 'converged', 'blocked', 'skipped', 'failed', 'abandoned') THEN 'settled' WHEN feature_runs.status IN ('running', 'escalated', 'awaiting_operator') THEN 'dispatched' ELSE NULL END AS dispatch_status,
17
+ feature_runs.repo AS repo,
18
+ feature_runs.issue_number AS issue_number,
19
+ feature_runs.issue_url AS issue_url,
20
+ feature_runs.title AS title,
21
+ feature_runs.base_branch AS base_branch,
22
+ feature_runs.status AS status,
23
+ feature_runs.process_key AS process_key,
24
+ feature_runs.pr_key AS pr_key,
25
+ feature_runs.converge AS converge,
26
+ feature_runs.auto_merge AS auto_merge,
27
+ feature_runs.outcome AS outcome,
28
+ feature_runs.delivery_label AS delivery_label,
29
+ feature_runs.acknowledged_at AS acknowledged_at,
30
+ feature_runs.stage AS stage,
31
+ feature_runs.stage_state AS stage_state,
32
+ feature_runs.stage_skipped AS stage_skipped,
33
+ feature_runs.attention AS attention,
34
+ feature_runs.list_bucket AS list_bucket,
35
+ feature_runs.created_at AS created_at,
36
+ feature_runs.updated_at AS updated_at
37
+ FROM feature_runs;
38
+
39
+ INSERT OR IGNORE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, repo, issue_number, issue_url, title, status, task_count, process_key, outcome, gate_wave, blackboard_token, retro_started_at, base_branch, epic_phase, promotion_pr, promotion_state, acknowledged_at, list_bucket, ack_open, wait_gate, wait_gate_label, bound_artifacts, created_at, updated_at)
40
+ SELECT 'epic:' || plans.plan_key AS unit_id,
41
+ 'epic' AS kind,
42
+ plans.plan_key AS legacy_key,
43
+ NULL AS legacy_id,
44
+ NULL AS parent_unit_id,
45
+ NULL AS node_index,
46
+ CASE WHEN plans.status = 'planning' THEN 'requested' WHEN plans.status = 'dispatched' THEN 'running' WHEN plans.status = 'done' THEN 'done' WHEN plans.status = 'failed' THEN 'failed' WHEN plans.status = 'abandoned' THEN 'abandoned' ELSE NULL END AS delivery_status,
47
+ CASE WHEN plans.status IN ('planning') THEN 'pending' WHEN plans.status IN ('done', 'failed', 'abandoned') THEN 'settled' WHEN plans.status IN ('dispatched') THEN 'dispatched' ELSE NULL END AS dispatch_status,
48
+ plans.repo AS repo,
49
+ plans.issue_number AS issue_number,
50
+ plans.issue_url AS issue_url,
51
+ plans.title AS title,
52
+ plans.status AS status,
53
+ plans.task_count AS task_count,
54
+ plans.process_key AS process_key,
55
+ plans.outcome AS outcome,
56
+ plans.gate_wave AS gate_wave,
57
+ plans.blackboard_token AS blackboard_token,
58
+ plans.retro_started_at AS retro_started_at,
59
+ plans.base_branch AS base_branch,
60
+ plans.epic_phase AS epic_phase,
61
+ plans.promotion_pr AS promotion_pr,
62
+ plans.promotion_state AS promotion_state,
63
+ plans.acknowledged_at AS acknowledged_at,
64
+ plans.list_bucket AS list_bucket,
65
+ plans.ack_open AS ack_open,
66
+ plans.wait_gate AS wait_gate,
67
+ plans.wait_gate_label AS wait_gate_label,
68
+ plans.bound_artifacts AS bound_artifacts,
69
+ plans.created_at AS created_at,
70
+ plans.updated_at AS updated_at
71
+ FROM plans;
72
+
73
+ INSERT OR IGNORE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, title, prompt, status, pr_key, summary, wave, open_question, answer, draft_pr_key, corr_key, created_at, updated_at, task_id)
74
+ SELECT 'plan-task:' || plan_tasks.plan_key || '#' || plan_tasks.task_index AS unit_id,
75
+ 'plan-task' AS kind,
76
+ plan_tasks.plan_key AS legacy_key,
77
+ plan_tasks.id AS legacy_id,
78
+ 'epic:' || plan_tasks.plan_key AS parent_unit_id,
79
+ plan_tasks.task_index AS node_index,
80
+ CASE WHEN plan_tasks.status = 'pending' THEN 'requested' WHEN plan_tasks.status = 'opened' THEN 'opened' WHEN plan_tasks.status = 'blocked' THEN 'blocked' WHEN plan_tasks.status = 'skipped' THEN 'skipped' WHEN plan_tasks.status = 'escalated' THEN 'escalated' WHEN plan_tasks.status = 'waiting-for-lane' THEN 'waiting' WHEN plan_tasks.status = 'abandoned' THEN 'abandoned' ELSE NULL END AS delivery_status,
81
+ CASE WHEN plan_tasks.status IN ('pending') THEN 'pending' WHEN plan_tasks.status IN ('opened', 'blocked', 'skipped', 'abandoned') THEN 'settled' WHEN plan_tasks.status IN ('escalated', 'waiting-for-lane') THEN 'dispatched' ELSE NULL END AS dispatch_status,
82
+ plan_tasks.title AS title,
83
+ plan_tasks.prompt AS prompt,
84
+ plan_tasks.status AS status,
85
+ plan_tasks.pr_key AS pr_key,
86
+ plan_tasks.summary AS summary,
87
+ plan_tasks.wave AS wave,
88
+ plan_tasks.open_question AS open_question,
89
+ plan_tasks.answer AS answer,
90
+ plan_tasks.draft_pr_key AS draft_pr_key,
91
+ plan_tasks.corr_key AS corr_key,
92
+ plan_tasks.created_at AS created_at,
93
+ plan_tasks.updated_at AS updated_at,
94
+ plan_tasks.task_id AS task_id
95
+ FROM plan_tasks;
96
+
97
+ INSERT OR IGNORE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, process_key, process_definition_id, digest, status, side_effecting, node_count, human_node_count, side_effect_count, title, phase, phase_node_id, human_labels, created_at, updated_at)
98
+ SELECT 'delivery-graph:' || delivery_graph_runs.run_key AS unit_id,
99
+ 'delivery-graph' AS kind,
100
+ delivery_graph_runs.run_key AS legacy_key,
101
+ NULL AS legacy_id,
102
+ NULL AS parent_unit_id,
103
+ NULL AS node_index,
104
+ CASE WHEN delivery_graph_runs.status = 'awaiting-approval' THEN 'requested' WHEN delivery_graph_runs.status = 'running' THEN 'running' WHEN delivery_graph_runs.status = 'done' THEN 'done' WHEN delivery_graph_runs.status = 'failed' THEN 'failed' WHEN delivery_graph_runs.status = 'abandoned' THEN 'abandoned' ELSE NULL END AS delivery_status,
105
+ CASE WHEN delivery_graph_runs.status IN ('awaiting-approval') THEN 'pending' WHEN delivery_graph_runs.status IN ('done', 'failed', 'abandoned') THEN 'settled' WHEN delivery_graph_runs.status IN ('running') THEN 'dispatched' ELSE NULL END AS dispatch_status,
106
+ delivery_graph_runs.process_key AS process_key,
107
+ delivery_graph_runs.process_definition_id AS process_definition_id,
108
+ delivery_graph_runs.digest AS digest,
109
+ delivery_graph_runs.status AS status,
110
+ delivery_graph_runs.side_effecting AS side_effecting,
111
+ delivery_graph_runs.node_count AS node_count,
112
+ delivery_graph_runs.human_node_count AS human_node_count,
113
+ delivery_graph_runs.side_effect_count AS side_effect_count,
114
+ delivery_graph_runs.title AS title,
115
+ delivery_graph_runs.phase AS phase,
116
+ delivery_graph_runs.phase_node_id AS phase_node_id,
117
+ delivery_graph_runs.human_labels AS human_labels,
118
+ delivery_graph_runs.created_at AS created_at,
119
+ delivery_graph_runs.updated_at AS updated_at
120
+ FROM delivery_graph_runs;
@@ -0,0 +1,111 @@
1
+ -- ADR 0006 slice S2 (#589) — legacy-shaped compatibility VIEWs served FROM the `delivery_units`
2
+ -- aggregate (088). Each VIEW reconstructs one legacy table's exact column set from the kind-tagged
3
+ -- aggregate row, so a read path can swap onto the aggregate with byte-identical results — the
4
+ -- "legacy tables become VIEWs/rows over the aggregate" surface of ADR 0006 §2, guarded by the parity
5
+ -- tests in app/deliveryUnit.test.ts (each VIEW is proven row-for-row equal to its base table over the
6
+ -- full status/shape matrix). These are ADDITIVE read surfaces; the live pages/read-models keep binding
7
+ -- the physical base tables until S3 moves the writers, so no behaviour changes here.
8
+ --
9
+ -- A single plain `CREATE VIEW … SELECT … FROM delivery_units` per shape (one top-level FROM, every
10
+ -- column aliased) so the static pages<->schema contract guard can parse the column list. The runner
11
+ -- wraps each file in its own transaction — no BEGIN/COMMIT here.
12
+
13
+ DROP VIEW IF EXISTS feature_runs__units;
14
+ CREATE VIEW feature_runs__units AS
15
+ SELECT
16
+ du.legacy_key AS feature_key,
17
+ du.repo AS repo,
18
+ du.issue_number AS issue_number,
19
+ du.issue_url AS issue_url,
20
+ du.base_branch AS base_branch,
21
+ du.status AS status,
22
+ du.process_key AS process_key,
23
+ du.pr_key AS pr_key,
24
+ du.converge AS converge,
25
+ du.auto_merge AS auto_merge,
26
+ du.outcome AS outcome,
27
+ du.created_at AS created_at,
28
+ du.updated_at AS updated_at,
29
+ du.delivery_label AS delivery_label,
30
+ du.title AS title,
31
+ du.acknowledged_at AS acknowledged_at,
32
+ du.stage AS stage,
33
+ du.stage_state AS stage_state,
34
+ du.stage_skipped AS stage_skipped,
35
+ du.attention AS attention,
36
+ du.list_bucket AS list_bucket
37
+ FROM delivery_units du
38
+ WHERE du.kind = 'feature';
39
+
40
+ DROP VIEW IF EXISTS plans__units;
41
+ CREATE VIEW plans__units AS
42
+ SELECT
43
+ du.legacy_key AS plan_key,
44
+ du.repo AS repo,
45
+ du.issue_number AS issue_number,
46
+ du.issue_url AS issue_url,
47
+ du.title AS title,
48
+ du.status AS status,
49
+ du.task_count AS task_count,
50
+ du.process_key AS process_key,
51
+ du.outcome AS outcome,
52
+ du.created_at AS created_at,
53
+ du.updated_at AS updated_at,
54
+ du.gate_wave AS gate_wave,
55
+ du.blackboard_token AS blackboard_token,
56
+ du.retro_started_at AS retro_started_at,
57
+ du.base_branch AS base_branch,
58
+ du.epic_phase AS epic_phase,
59
+ du.promotion_pr AS promotion_pr,
60
+ du.promotion_state AS promotion_state,
61
+ du.acknowledged_at AS acknowledged_at,
62
+ du.list_bucket AS list_bucket,
63
+ du.ack_open AS ack_open,
64
+ du.wait_gate AS wait_gate,
65
+ du.wait_gate_label AS wait_gate_label,
66
+ du.bound_artifacts AS bound_artifacts
67
+ FROM delivery_units du
68
+ WHERE du.kind = 'epic';
69
+
70
+ DROP VIEW IF EXISTS plan_tasks__units;
71
+ CREATE VIEW plan_tasks__units AS
72
+ SELECT
73
+ du.legacy_id AS id,
74
+ du.legacy_key AS plan_key,
75
+ du.node_index AS task_index,
76
+ du.task_id AS task_id,
77
+ du.title AS title,
78
+ du.prompt AS prompt,
79
+ du.status AS status,
80
+ du.pr_key AS pr_key,
81
+ du.summary AS summary,
82
+ du.created_at AS created_at,
83
+ du.updated_at AS updated_at,
84
+ du.wave AS wave,
85
+ du.open_question AS open_question,
86
+ du.answer AS answer,
87
+ du.draft_pr_key AS draft_pr_key,
88
+ du.corr_key AS corr_key
89
+ FROM delivery_units du
90
+ WHERE du.kind = 'plan-task';
91
+
92
+ DROP VIEW IF EXISTS delivery_graph_runs__units;
93
+ CREATE VIEW delivery_graph_runs__units AS
94
+ SELECT
95
+ du.legacy_key AS run_key,
96
+ du.process_key AS process_key,
97
+ du.process_definition_id AS process_definition_id,
98
+ du.digest AS digest,
99
+ du.status AS status,
100
+ du.side_effecting AS side_effecting,
101
+ du.node_count AS node_count,
102
+ du.human_node_count AS human_node_count,
103
+ du.side_effect_count AS side_effect_count,
104
+ du.title AS title,
105
+ du.phase AS phase,
106
+ du.phase_node_id AS phase_node_id,
107
+ du.human_labels AS human_labels,
108
+ du.created_at AS created_at,
109
+ du.updated_at AS updated_at
110
+ FROM delivery_units du
111
+ WHERE du.kind = 'delivery-graph';
package/install.sh CHANGED
@@ -79,6 +79,8 @@ DRY_RUN=0
79
79
  ASSUME_YES=0
80
80
  SHELL_OVERRIDE=''
81
81
  INSTALL_ADAPTERS=0 # auto-install missing adapters without prompting
82
+ ALLOW_NO_GITHUB=0 # non-interactive: proceed even without usable GitHub access
83
+ GITHUB_DEGRADED='' # non-empty => the GitHub preflight passed in a degraded state; repeated in the final summary
82
84
  SKIP_APP=0 # phase 1 only: bring up engine + workforce, don't install the app
83
85
  PROJECT_NAME='' # console project name for the Nano Workforce app (default: Workforce)
84
86
  CLI_HARNESS_SPECS='' # newline-separated name[:model][:instances] from --harness
@@ -97,6 +99,19 @@ ADAPTERS_PRESENT="${NANO_INSTALL_ADAPTERS_PRESENT:-}"
97
99
  if [ "${NANO_INSTALL_HARNESSES_OVERRIDE+set}" = set ]; then HARNESS_OVERRIDE_SET=1; else HARNESS_OVERRIDE_SET=0; fi
98
100
  if [ "${NANO_INSTALL_ADAPTERS_PRESENT+set}" = set ]; then ADAPTERS_PRESENT_SET=1; else ADAPTERS_PRESENT_SET=0; fi
99
101
 
102
+ # Test hooks for the GitHub credential preflight (undocumented; keep the smoke
103
+ # test hermetic on runners that may or may not ship an authenticated gh):
104
+ # NANO_INSTALL_GH_STATE — force gh state: missing|unauthed|unusable|ok
105
+ # NANO_INSTALL_GH_SCOPES — comma/space list of token scopes gh reports
106
+ # NANO_INSTALL_GIT_STATE — force git state: missing|present
107
+ # NANO_INSTALL_TOKEN_STATE — force env-token validity: ok|bad (skips the real
108
+ # gh/api.github.com probe so tests need no network)
109
+ # A set-but-empty override still counts as set, so the probe never falls back to
110
+ # the real command -v / gh and the smoke test stays hermetic.
111
+ if [ "${NANO_INSTALL_GH_STATE+set}" = set ]; then GH_STATE_OVERRIDE_SET=1; else GH_STATE_OVERRIDE_SET=0; fi
112
+ if [ "${NANO_INSTALL_GIT_STATE+set}" = set ]; then GIT_STATE_OVERRIDE_SET=1; else GIT_STATE_OVERRIDE_SET=0; fi
113
+ if [ "${NANO_INSTALL_TOKEN_STATE+set}" = set ]; then TOKEN_STATE_OVERRIDE_SET=1; else TOKEN_STATE_OVERRIDE_SET=0; fi
114
+
100
115
  CLI='' # resolved c8ctl / c8 binary
101
116
  TTY='' # /dev/tty if usable, else empty
102
117
 
@@ -219,6 +234,10 @@ Options:
219
234
  -y, --yes Skip the confirmation summary.
220
235
  --install-adapters Auto-install a selected harness's missing ACP adapter
221
236
  (claude/pi) instead of prompting/skipping.
237
+ --allow-no-github Proceed even when no usable GitHub access is detected. In a
238
+ non-interactive run the preflight otherwise fails, since a
239
+ workforce with no GitHub credential cannot review, push, or
240
+ merge. Interactive runs prompt instead.
222
241
  --project-name <name>
223
242
  Console project name for the Nano Workforce app
224
243
  (default: Workforce). [A-Za-z0-9._-] only.
@@ -261,6 +280,7 @@ parse_args() {
261
280
  shift ;;
262
281
  --yes|-y) ASSUME_YES=1; shift ;;
263
282
  --install-adapters) INSTALL_ADAPTERS=1; shift ;;
283
+ --allow-no-github) ALLOW_NO_GITHUB=1; shift ;;
264
284
  --skip-app) SKIP_APP=1; shift ;;
265
285
  --project-name)
266
286
  [ $# -ge 2 ] || die "--project-name requires a value"
@@ -277,6 +297,210 @@ parse_args() {
277
297
  done
278
298
  }
279
299
 
300
+ # ---------------------------------------------------------------------------
301
+ # GitHub credential preflight (nanobpm/nano-workforce#588)
302
+ #
303
+ # Two consumers need GitHub access and fail differently: the nwf app (review
304
+ # poller + merge stage) and each hired agent worker (shells out to gh/git). The
305
+ # real predicate is "a usable credential exists on this host", not "gh is
306
+ # installed" — a token in GITHUB_TOKEN/GH_TOKEN satisfies both without gh.
307
+ #
308
+ # Detection distinguishes three states beyond present/absent: not installed,
309
+ # installed-but-unauthenticated, and authenticated-but-unusable (a push/PR that
310
+ # 403s only later — expired token, missing scope, SAML SSO, fine-grained PAT
311
+ # without repo access). We prove usability with `gh api user`, never trusting
312
+ # `gh auth status` alone.
313
+ # ---------------------------------------------------------------------------
314
+ gh_present() { # 0 if the gh CLI is available (honours the test hook)
315
+ if [ "$GH_STATE_OVERRIDE_SET" -eq 1 ]; then
316
+ case "$NANO_INSTALL_GH_STATE" in missing) return 1 ;; *) return 0 ;; esac
317
+ fi
318
+ command -v gh >/dev/null 2>&1
319
+ }
320
+
321
+ gh_authed() { # 0 if gh auth status succeeds (honours the test hook)
322
+ if [ "$GH_STATE_OVERRIDE_SET" -eq 1 ]; then
323
+ case "$NANO_INSTALL_GH_STATE" in missing|unauthed) return 1 ;; *) return 0 ;; esac
324
+ fi
325
+ gh auth status >/dev/null 2>&1
326
+ }
327
+
328
+ gh_usable() { # 0 if gh api user actually works — validates token + network + SSO
329
+ if [ "$GH_STATE_OVERRIDE_SET" -eq 1 ]; then
330
+ case "$NANO_INSTALL_GH_STATE" in ok) return 0 ;; *) return 1 ;; esac
331
+ fi
332
+ gh api user --jq .login >/dev/null 2>&1
333
+ }
334
+
335
+ gh_token_usable() { # 0 if the env GITHUB_TOKEN/GH_TOKEN actually authenticates
336
+ # An expired/revoked/underscoped token is the *exact* late failure this
337
+ # preflight exists to catch, so a non-empty token is not trusted blindly — we
338
+ # prove it works (gh api user, or a raw api.github.com/user probe when gh is
339
+ # absent). Honours the test hook so the smoke suite needs no network.
340
+ if [ "$TOKEN_STATE_OVERRIDE_SET" -eq 1 ]; then
341
+ case "$NANO_INSTALL_TOKEN_STATE" in ok) return 0 ;; *) return 1 ;; esac
342
+ fi
343
+ _tok="${GITHUB_TOKEN:-${GH_TOKEN:-}}"
344
+ if gh_present; then
345
+ GITHUB_TOKEN="$_tok" GH_TOKEN="$_tok" gh api user --jq .login >/dev/null 2>&1
346
+ elif command -v curl >/dev/null 2>&1; then
347
+ # Pass the token via --config on stdin so it never lands in curl's argv
348
+ # (readable by other users via `ps` on a multi-user host).
349
+ printf 'header = "Authorization: Bearer %s"\n' "$_tok" | \
350
+ curl -fsS --connect-timeout 5 --max-time 10 --config - \
351
+ -H "User-Agent: nano-workforce-install" \
352
+ https://api.github.com/user >/dev/null 2>&1
353
+ else
354
+ # No gh and no curl to validate with — we cannot prove the token works,
355
+ # so warn explicitly rather than let an unvalidated token look "usable".
356
+ warn "cannot validate GITHUB_TOKEN/GH_TOKEN — neither gh nor curl is available on this host; proceeding on trust (an expired or under-scoped token will only fail later)."
357
+ return 0
358
+ fi
359
+ }
360
+
361
+ gh_scopes() { # print space-separated token scopes gh reports (honours the test hook)
362
+ if [ "$GH_STATE_OVERRIDE_SET" -eq 1 ]; then
363
+ printf '%s' "${NANO_INSTALL_GH_SCOPES:-}" | tr ',' ' '
364
+ return 0
365
+ fi
366
+ # gh auth status prints e.g.: - Token scopes: 'gist', 'read:org', 'repo', 'workflow'
367
+ gh auth status 2>&1 | sed -n "s/.*Token scopes:[ ]*//p" | tr -d "'" | tr ',' ' '
368
+ }
369
+
370
+ git_present() { # 0 if git is available (honours the test hook)
371
+ if [ "$GIT_STATE_OVERRIDE_SET" -eq 1 ]; then
372
+ case "$NANO_INSTALL_GIT_STATE" in missing) return 1 ;; *) return 0 ;; esac
373
+ fi
374
+ command -v git >/dev/null 2>&1
375
+ }
376
+
377
+ # Platform-aware install + remediation hint. We print the command; we NEVER run
378
+ # `gh auth login` for the user (it is an interactive device/OAuth flow that
379
+ # should stay in their hands).
380
+ gh_remediation_hint() {
381
+ note "GitHub access not detected. The workforce cannot review, push, or merge without it."
382
+ note ""
383
+ note " macOS brew install gh"
384
+ note " Debian/Ubuntu sudo apt install gh"
385
+ note " Fedora sudo dnf install gh"
386
+ note " Arch sudo pacman -S gh"
387
+ note " other https://github.com/cli/cli#installation"
388
+ note ""
389
+ note "Then: gh auth login (or export GITHUB_TOKEN=…)"
390
+ }
391
+
392
+ # One probe pass. Sets GH_PROBE_MSG (human summary) and returns 0 when a usable
393
+ # credential exists, 1 otherwise. Warns (non-fatal) when the workflow scope is
394
+ # absent, since that only bites on workflow-file changes.
395
+ probe_github() {
396
+ GH_PROBE_MSG=''
397
+ # 1. token in env → validate it (don't trust a non-empty value), skip gh
398
+ # scope checks (a raw token exposes no scopes here) once it authenticates.
399
+ if [ -n "${GITHUB_TOKEN:-${GH_TOKEN:-}}" ]; then
400
+ if ! gh_token_usable; then
401
+ GH_PROBE_MSG="a GITHUB_TOKEN/GH_TOKEN is set but it failed validation (gh api user / api.github.com/user) — it is expired, revoked, or lacks access. Fix or unset it."
402
+ return 1
403
+ fi
404
+ if gh_present; then
405
+ GH_PROBE_MSG="GitHub token detected in the environment (GITHUB_TOKEN/GH_TOKEN); gh CLI also present."
406
+ else
407
+ GH_PROBE_MSG="GitHub token detected in the environment (GITHUB_TOKEN/GH_TOKEN)."
408
+ note "A token is set, so the nwf app is fine (NANO_PR_GITHUB_TRANSPORT=token)."
409
+ note "But gh is NOT installed on this host — harnesses that shell out to 'gh' still won't work. Install gh too."
410
+ fi
411
+ return 0
412
+ fi
413
+ # 2. otherwise the host CLI must be present, authenticated, and usable.
414
+ if ! gh_present; then
415
+ GH_PROBE_MSG="no gh CLI and no GITHUB_TOKEN/GH_TOKEN in the environment."
416
+ return 1
417
+ fi
418
+ if ! gh_authed; then
419
+ GH_PROBE_MSG="gh is installed but not authenticated (gh auth status failed)."
420
+ return 1
421
+ fi
422
+ # 3. prove it actually works — don't just trust status.
423
+ if ! gh_usable; then
424
+ GH_PROBE_MSG="gh reports authenticated but 'gh api user' failed — the credential is unusable (expired/revoked token, network, or SAML SSO not authorized for the target org)."
425
+ return 1
426
+ fi
427
+ # 4. authenticated + usable: the repo scope is required, workflow only warns.
428
+ _scopes=$(gh_scopes)
429
+ case " $_scopes " in
430
+ *" repo "*) : ;;
431
+ *)
432
+ GH_PROBE_MSG="gh is authenticated but the token is missing the 'repo' scope — pushes and PR creation will 403. Re-auth with: gh auth refresh -s repo"
433
+ return 1 ;;
434
+ esac
435
+ case " $_scopes " in
436
+ *" workflow "*) : ;;
437
+ *) warn "gh token is missing the 'workflow' scope — harmless unless agents edit workflow files; add it with: gh auth refresh -s workflow" ;;
438
+ esac
439
+ GH_PROBE_MSG="GitHub access OK via the host gh CLI."
440
+ return 0
441
+ }
442
+
443
+ # The full preflight: probe, remediate, and decide whether to continue. Records
444
+ # a degraded state (repeated in the final summary) whenever we proceed without a
445
+ # usable credential, so a degraded install never *looks* complete.
446
+ add_degraded() { # accumulate a degraded reason so a later one never masks an earlier
447
+ if [ -n "$GITHUB_DEGRADED" ]; then
448
+ GITHUB_DEGRADED="${GITHUB_DEGRADED}; $1"
449
+ else
450
+ GITHUB_DEGRADED="$1"
451
+ fi
452
+ }
453
+ github_preflight() {
454
+ info "Checking GitHub access (this host only)"
455
+
456
+ # git is needed by every worker (clone/commit/push) — check it alongside gh.
457
+ if git_present; then
458
+ ok "git present"
459
+ else
460
+ warn "git not found on this host — agents cannot clone, commit, or push without it."
461
+ note "Install git from https://git-scm.com/downloads, then re-run."
462
+ add_degraded "git is not installed on this host"
463
+ fi
464
+
465
+ while : ; do
466
+ if probe_github; then
467
+ ok "$GH_PROBE_MSG"
468
+ note "This check covers THIS host only — remote worker hosts each need their own gh auth / token."
469
+ break
470
+ fi
471
+
472
+ warn "$GH_PROBE_MSG"
473
+ gh_remediation_hint
474
+ note "This check covers THIS host only — remote worker hosts each need their own gh auth / token."
475
+
476
+ # Non-interactive (--yes, no /dev/tty, or --dry-run): no prompt is possible.
477
+ if [ "$ASSUME_YES" -eq 1 ] || [ "$DRY_RUN" -eq 1 ] || [ -z "$TTY" ]; then
478
+ if [ "$ALLOW_NO_GITHUB" -eq 1 ]; then
479
+ warn "continuing without GitHub access (--allow-no-github) — the agents will not be able to do GitHub work."
480
+ add_degraded "$GH_PROBE_MSG"
481
+ break
482
+ fi
483
+ # Dry-run changes nothing, so never fail on it — just report what a real run would do.
484
+ if [ "$DRY_RUN" -eq 1 ]; then
485
+ note "dry-run: not blocking on GitHub access (a real run would fail here without --allow-no-github)."
486
+ break
487
+ fi
488
+ die "GitHub access not detected and running non-interactively — fix it (see above) and re-run, or pass --allow-no-github to proceed anyway."
489
+ fi
490
+
491
+ # Interactive: let the user fix it and re-check without re-running the whole
492
+ # script, else confirm-to-continue (default no).
493
+ if confirm "I've set up GitHub access (installed gh / ran gh auth login / exported a token). Re-check now?"; then
494
+ continue
495
+ fi
496
+ if confirm "Continue anyway? The agents won't be able to do GitHub work."; then
497
+ add_degraded "$GH_PROBE_MSG"
498
+ break
499
+ fi
500
+ die "aborted — set up GitHub access (see above) and re-run."
501
+ done
502
+ }
503
+
280
504
  # ---------------------------------------------------------------------------
281
505
  # Step 1 — Preflight
282
506
  # ---------------------------------------------------------------------------
@@ -290,18 +514,10 @@ preflight() {
290
514
  fi
291
515
  ok "node $_nv (>= ${MIN_NODE}), npm present"
292
516
 
293
- # Non-fatal: agents need GitHub access to do useful work.
294
- if [ -z "${GITHUB_TOKEN:-}" ] && [ -z "${GH_TOKEN:-}" ]; then
295
- if command -v gh >/dev/null 2>&1; then
296
- if ! gh auth status >/dev/null 2>&1; then
297
- warn "gh is installed but not authenticated, and no GITHUB_TOKEN/GH_TOKEN is set."
298
- note "Agents need GitHub access — run 'gh auth login' or export GITHUB_TOKEN before they start pulling work."
299
- fi
300
- else
301
- warn "no gh CLI and no GITHUB_TOKEN/GH_TOKEN in the environment."
302
- note "Agents need GitHub access — install gh (https://cli.github.com) and 'gh auth login', or export GITHUB_TOKEN."
303
- fi
304
- fi
517
+ # GitHub access a workforce with no usable credential can do nothing useful,
518
+ # so this is a real three-state check with remediation (nanobpm/nano-workforce#588),
519
+ # not a passing mention. Covers this host only; remote worker hosts need their own.
520
+ github_preflight
305
521
  }
306
522
 
307
523
  # ---------------------------------------------------------------------------
@@ -939,6 +1155,9 @@ confirm_app() {
939
1155
  note "project : ${PROJECT} (from template 'nano-workforce')"
940
1156
  if [ -n "${GITHUB_TOKEN:-${GH_TOKEN:-}}" ]; then _ghkey='GITHUB_TOKEN'; else _ghkey='NANO_PR_GITHUB_TRANSPORT'; fi
941
1157
  note "env keys written : ${_ghkey}, NANOBPMN_BASE_URL, NANO_WORKFORCE_BASE_URL, PR_REVIEW_PORT (values not shown)"
1158
+ if [ -z "${GITHUB_TOKEN:-${GH_TOKEN:-}}" ]; then
1159
+ note "no token in env : the app relies on the host gh CLI (NANO_PR_GITHUB_TRANSPORT=auto)."
1160
+ fi
942
1161
  note "app-view URL : ${APPVIEW_BASE}/"
943
1162
  if [ "$ASSUME_YES" -eq 1 ] || [ "$DRY_RUN" -eq 1 ]; then
944
1163
  [ "$DRY_RUN" -eq 1 ] && note "dry-run: not asking for confirmation."
@@ -1147,6 +1366,11 @@ install_app() {
1147
1366
  # Partial-success report
1148
1367
  # ---------------------------------------------------------------------------
1149
1368
  report_and_exit() {
1369
+ # A degraded GitHub-access install must never *look* complete — repeat the
1370
+ # warning here and exit non-zero so the failure is visible, not deferred.
1371
+ if [ -n "$GITHUB_DEGRADED" ] && [ "$DRY_RUN" -eq 0 ]; then
1372
+ record_failure "GitHub/git preflight degraded (this host only): ${GITHUB_DEGRADED} — the workforce cannot review, push, or merge until it is fixed"
1373
+ fi
1150
1374
  if [ -n "$FAILURES" ]; then
1151
1375
  warn "Completed with some steps skipped or failed:"
1152
1376
  printf '%s\n' "$FAILURES" | sed '/^$/d' | while IFS= read -r _f; do note "- $_f"; done
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.153.0",
3
+ "version": "0.155.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -99,6 +99,115 @@ assert_contains "s5: later selection defaults to 1 instance" \
99
99
  assert_contains "s5: default model => bare adapter command, empty --model" \
100
100
  "--command 'claude-code-acp' --model ''" "$OUT"
101
101
 
102
+ # ===========================================================================
103
+ # GitHub credential preflight — three-state check (nano-workforce#588)
104
+ # ===========================================================================
105
+ # The probe is stubbed hermetically via NANO_INSTALL_GH_STATE / NANO_INSTALL_GH_SCOPES
106
+ # / NANO_INSTALL_GIT_STATE so no real gh/git auth is needed. All run under
107
+ # --dry-run (never blocks) unless a scenario specifically exercises the
108
+ # non-interactive fail path.
109
+
110
+ # --- Scenario G1: token in env is usable, gh absent → app-fine caveat -------
111
+ OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" NANO_INSTALL_GH_STATE="missing" \
112
+ NANO_INSTALL_TOKEN_STATE="ok" GITHUB_TOKEN="fake-usable-token" \
113
+ sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run 2>&1)
114
+ assert_contains "sG1: token detected treated as usable" \
115
+ "GitHub token detected in the environment" "$OUT"
116
+ assert_contains "sG1: token+no-gh caveat about harnesses shelling out to gh" \
117
+ "harnesses that shell out to 'gh' still won't work" "$OUT"
118
+
119
+ # --- Scenario G1b: a set-but-invalid token FAILS validation (not trusted) ---
120
+ # A non-empty token is not trusted blindly: an expired/revoked/underscoped
121
+ # token is the exact late failure this preflight exists to surface early.
122
+ OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" NANO_INSTALL_GH_STATE="missing" \
123
+ NANO_INSTALL_TOKEN_STATE="bad" GITHUB_TOKEN="fake-invalid-token" \
124
+ sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run 2>&1)
125
+ assert_contains "sG1b: invalid token reported as failing validation" \
126
+ "failed validation" "$OUT"
127
+ assert_contains "sG1b: invalid token names remediation (fix or unset)" \
128
+ "expired, revoked, or lacks access" "$OUT"
129
+
130
+ # --- Scenario G2: gh missing, no token → not-detected + platform hint -------
131
+ OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" NANO_INSTALL_GH_STATE="missing" \
132
+ sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run 2>&1)
133
+ assert_contains "sG2: no gh + no token reported" \
134
+ "no gh CLI and no GITHUB_TOKEN/GH_TOKEN" "$OUT"
135
+ assert_contains "sG2: not-detected headline" \
136
+ "GitHub access not detected" "$OUT"
137
+ assert_contains "sG2: Debian/Ubuntu install hint" "sudo apt install gh" "$OUT"
138
+ assert_contains "sG2: macOS install hint" "brew install gh" "$OUT"
139
+ assert_contains "sG2: gh auth login remediation printed" "gh auth login" "$OUT"
140
+ assert_contains "sG2: this-host-only caveat" "covers THIS host only" "$OUT"
141
+ # The script must NEVER run gh auth login itself — only print it.
142
+ assert_not_contains "sG2: never runs gh auth login (no 'Running' style exec)" \
143
+ "would run: gh auth login" "$OUT"
144
+
145
+ # --- Scenario G3: gh present but unauthenticated ---------------------------
146
+ OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" NANO_INSTALL_GH_STATE="unauthed" \
147
+ sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run 2>&1)
148
+ assert_contains "sG3: installed-but-unauthenticated state" \
149
+ "gh is installed but not authenticated" "$OUT"
150
+
151
+ # --- Scenario G4: gh authenticated but unusable (gh api user fails) ---------
152
+ OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" NANO_INSTALL_GH_STATE="unusable" \
153
+ sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run 2>&1)
154
+ assert_contains "sG4: authenticated-but-unusable state (gh api user failed)" \
155
+ "'gh api user' failed" "$OUT"
156
+ assert_contains "sG4: names SAML SSO as a likely cause" "SAML SSO" "$OUT"
157
+
158
+ # --- Scenario G5: authenticated + usable, missing repo scope FAILS ----------
159
+ OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" NANO_INSTALL_GH_STATE="ok" \
160
+ NANO_INSTALL_GH_SCOPES="gist,read:org" \
161
+ sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run 2>&1)
162
+ assert_contains "sG5: missing repo scope fails the check" \
163
+ "missing the 'repo' scope" "$OUT"
164
+
165
+ # --- Scenario G6: usable with repo but missing workflow scope only WARNS ----
166
+ OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" NANO_INSTALL_GH_STATE="ok" \
167
+ NANO_INSTALL_GH_SCOPES="repo,read:org" \
168
+ sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run 2>&1)
169
+ assert_contains "sG6: missing workflow scope warns (non-fatal)" \
170
+ "missing the 'workflow' scope" "$OUT"
171
+ assert_contains "sG6: repo+ passes the check" "GitHub access OK via the host gh CLI" "$OUT"
172
+
173
+ # --- Scenario G7: git absence is flagged -----------------------------------
174
+ OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" NANO_INSTALL_GH_STATE="ok" \
175
+ NANO_INSTALL_GH_SCOPES="repo,workflow" NANO_INSTALL_GIT_STATE="missing" \
176
+ sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run 2>&1)
177
+ assert_contains "sG7: git presence checked alongside gh" \
178
+ "git not found on this host" "$OUT"
179
+
180
+ # --- Scenario G8: non-interactive (--yes) FAILS without --allow-no-github ----
181
+ if NANO_INSTALL_HARNESSES_OVERRIDE="copilot" NANO_INSTALL_GH_STATE="missing" \
182
+ sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes >/dev/null 2>&1; then
183
+ fail "sG8: non-interactive run with no GitHub access should exit non-zero"
184
+ else
185
+ pass "sG8: non-interactive run with no GitHub access exits non-zero"
186
+ fi
187
+
188
+ # --- Scenario G9: --allow-no-github lets a non-interactive run proceed -------
189
+ # Under --dry-run this exercises the --allow-no-github *continue* decision (the
190
+ # preflight records GITHUB_DEGRADED and breaks instead of dying). The final
191
+ # summary's degraded-failure line is intentionally NOT emitted here: dry-run
192
+ # makes no changes, so report_and_exit() only records the degraded failure when
193
+ # DRY_RUN=0 — that real-run summary path is a deliberate no-op under --dry-run.
194
+ OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" NANO_INSTALL_GH_STATE="missing" \
195
+ sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run --allow-no-github 2>&1)
196
+ assert_contains "sG9: --allow-no-github continues" \
197
+ "continuing without GitHub access" "$OUT"
198
+
199
+ # --- Scenario G9b: git-missing AND github-missing both surface (no masking) --
200
+ # The GitHub-degraded reason must ACCUMULATE onto the earlier git-missing one,
201
+ # not overwrite it — so a host lacking both never loses the git problem behind
202
+ # the GitHub message. Both warnings must appear in the same run.
203
+ OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" NANO_INSTALL_GH_STATE="missing" \
204
+ NANO_INSTALL_GIT_STATE="missing" \
205
+ sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run --allow-no-github 2>&1)
206
+ assert_contains "sG9b: git-missing warning still shown alongside github" \
207
+ "git not found on this host" "$OUT"
208
+ assert_contains "sG9b: github-missing continue also shown" \
209
+ "continuing without GitHub access" "$OUT"
210
+
102
211
  # ===========================================================================
103
212
  # Phase 2 — install & run the Nano Workforce app (nano-workforce#583)
104
213
  # ===========================================================================
@@ -157,10 +266,11 @@ assert_contains "s6b: project name in the run URL" \
157
266
  "POST http://localhost:8080/console/api/projects/Fleet/run" "$OUT"
158
267
 
159
268
  # --- Scenario 6c: GITHUB_TOKEN is redacted in dry-run, never printed --------
160
- OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" GITHUB_TOKEN="ghp_SHOULD_NOT_APPEAR" \
269
+ OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" GITHUB_TOKEN="SHOULD-NOT-APPEAR-TOKEN" \
270
+ NANO_INSTALL_TOKEN_STATE="ok" \
161
271
  sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run 2>&1)
162
272
  assert_contains "s6c: token key present, masked" '"GITHUB_TOKEN":"***"' "$OUT"
163
- assert_not_contains "s6c: token value never printed" "ghp_SHOULD_NOT_APPEAR" "$OUT"
273
+ assert_not_contains "s6c: token value never printed" "SHOULD-NOT-APPEAR-TOKEN" "$OUT"
164
274
 
165
275
  # --- Scenario 7: --skip-app runs phase 1 only, emits no console calls -------
166
276
  OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" \
@@ -303,7 +413,7 @@ CJS
303
413
  # newline) must be rejected with a clear error before any JSON config body
304
414
  # is emitted, never producing invalid JSON that fails the console API
305
415
  # opaquely. The token flows through json_str() unredacted on the live PUT.
306
- _ctrl_token="$(printf 'ghp_bad\ntoken')"
416
+ _ctrl_token="$(printf 'fake-bad\ntoken')"
307
417
  export GITHUB_TOKEN="$_ctrl_token"
308
418
  run_phase2 happy
309
419
  unset GITHUB_TOKEN