@nanobpm/nano-workforce 0.88.0 → 0.89.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,255 @@
1
+ // Integration tests for the epic promotion poller pass (issue #299). `pollPromotion` is the missing
2
+ // counterpart to `ensureBaseBranch`: once an epic has LANDED on its custom `epic/*` integration
3
+ // branch (every slice PR merged → `plans.delivery = landed`), it opens exactly ONE `epic/* →
4
+ // <default>` promotion PR and enrolls it into the convergence + merge loop. These tests exercise the
5
+ // issue's red/green plan against an in-memory data layer + a stubbed GitHub (token) transport + a
6
+ // recording engine: open exactly one PR, never a duplicate on re-run, never for a converging epic,
7
+ // and never for a `main`-based epic.
8
+ import { test } from "node:test";
9
+ import { assert, assertEquals } from "#test-assert";
10
+ import type { DataLayer, EngineClient } from "@nanobpm/urban";
11
+ import { resetDefaultBranchCache } from "./github.ts";
12
+ import { pollPromotion } from "./service.ts";
13
+
14
+ // In-memory record gateway (all/get/find/insert/update/delete), mirroring app/delivery.test.ts but
15
+ // with `delete` (submitPr's `registerDependencies` clears the PR's dep set on submit).
16
+ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
17
+ const stores: Record<string, any[]> = {};
18
+ function tbl(name: string, pk = "id") {
19
+ const rows = (stores[name] ??= [] as any[]);
20
+ const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
21
+ return {
22
+ async all() {
23
+ return rows.slice();
24
+ },
25
+ async get(id: any) {
26
+ return rows.find((r) => r[pk] === id);
27
+ },
28
+ async find(where: any = {}) {
29
+ return rows.filter((r) => match(r, where));
30
+ },
31
+ async insert(row: any) {
32
+ rows.push({ ...row });
33
+ return row[pk];
34
+ },
35
+ async update(id: any, patch: any) {
36
+ const r = rows.find((row) => row[pk] === id);
37
+ if (r) Object.assign(r, patch);
38
+ },
39
+ async delete(id: any) {
40
+ for (let i = rows.length - 1; i >= 0; i--) if (rows[i][pk] === id) rows.splice(i, 1);
41
+ },
42
+ };
43
+ }
44
+ const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
45
+ return { data, stores };
46
+ }
47
+
48
+ // A recording engine stub: every `submitPr` starts a convergence instance via `createInstance`.
49
+ function recordingEngine(): { engine: EngineClient; instances: any[] } {
50
+ const instances: any[] = [];
51
+ const engine = {
52
+ async createInstance(req: any) {
53
+ instances.push(req);
54
+ return { processInstanceKey: `pi-${instances.length}` };
55
+ },
56
+ } as any as EngineClient;
57
+ return { engine, instances };
58
+ }
59
+
60
+ // A fake GitHub repo model served over the token transport. Tracks the default branch and the PRs
61
+ // keyed by head branch; records every create so a test can assert exactly-once.
62
+ interface FakeRepo {
63
+ repo: string;
64
+ defaultBranch: string;
65
+ prsByHead: Map<string, { number: number; state: string; baseRef: string }[]>;
66
+ creates: { head: string; base: string; title: string; number: number }[];
67
+ nextNumber: number;
68
+ }
69
+
70
+ function githubFetch(state: FakeRepo) {
71
+ return (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
72
+ const u = new URL(String(url));
73
+ const method = (init?.method ?? "GET").toUpperCase();
74
+ const path = u.pathname;
75
+ const json = (obj: unknown, status = 200) =>
76
+ Promise.resolve(new Response(JSON.stringify(obj), { status, headers: { "content-type": "application/json" } }));
77
+
78
+ if (method === "GET" && path === `/repos/${state.repo}`) {
79
+ return json({ default_branch: state.defaultBranch });
80
+ }
81
+ if (method === "GET" && path === `/repos/${state.repo}/pulls`) {
82
+ // listPrsForHead: ?head=owner:branch
83
+ const head = (u.searchParams.get("head") ?? "").split(":").pop() ?? "";
84
+ const list = state.prsByHead.get(head) ?? [];
85
+ return json(
86
+ list.map((p) => ({
87
+ number: p.number,
88
+ html_url: `https://github.com/${state.repo}/pull/${p.number}`,
89
+ state: p.state,
90
+ base: { ref: p.baseRef },
91
+ })),
92
+ );
93
+ }
94
+ if (method === "POST" && path === `/repos/${state.repo}/pulls`) {
95
+ // biome-ignore lint/plugin: test fixture parsing an external body shape
96
+ const body = JSON.parse(String(init?.body ?? "{}")) as { head?: string; base?: string; title?: string };
97
+ const head = String(body.head ?? "");
98
+ const number = state.nextNumber++;
99
+ state.creates.push({ head, base: String(body.base ?? ""), title: String(body.title ?? ""), number });
100
+ const arr = state.prsByHead.get(head) ?? [];
101
+ arr.push({ number, state: "open", baseRef: String(body.base ?? "") });
102
+ state.prsByHead.set(head, arr);
103
+ return json({ number, html_url: `https://github.com/${state.repo}/pull/${number}` }, 201);
104
+ }
105
+ return Promise.resolve(new Response(`unexpected ${method} ${path}`, { status: 500 }));
106
+ };
107
+ }
108
+
109
+ async function withGithub<T>(state: FakeRepo, fn: () => Promise<T>): Promise<T> {
110
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
111
+ const prevFetch = globalThis.fetch;
112
+ const prevToken = process.env.GITHUB_TOKEN;
113
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
114
+ // Leave GITHUB_TOKEN empty so submitPr's best-effort PR-meta enrichment is skipped (no fetch),
115
+ // while pollPromotion's own GitHub calls use the explicit "tok" argument.
116
+ delete process.env.GITHUB_TOKEN;
117
+ globalThis.fetch = githubFetch(state) as typeof fetch;
118
+ resetDefaultBranchCache();
119
+ try {
120
+ return await fn();
121
+ } finally {
122
+ globalThis.fetch = prevFetch;
123
+ resetDefaultBranchCache();
124
+ if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
125
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
126
+ if (prevToken === undefined) delete process.env.GITHUB_TOKEN;
127
+ else process.env.GITHUB_TOKEN = prevToken;
128
+ }
129
+ }
130
+
131
+ function freshRepo(defaultBranch = "main"): FakeRepo {
132
+ return { repo: "o/r", defaultBranch, prsByHead: new Map(), creates: [], nextNumber: 500 };
133
+ }
134
+
135
+ test("pollPromotion: a landed epic on an epic/* base opens exactly one epic/*→default PR", async () => {
136
+ const { data, stores } = memData();
137
+ const { engine, instances } = recordingEngine();
138
+ stores.plans = [
139
+ { plan_key: "o/r#295", repo: "o/r", title: "Assertion DSL", status: "done", base_branch: "epic/test-dsl", delivery: "landed", promotion_pr: null, promotion_state: null },
140
+ ];
141
+ stores.plan_tasks = [
142
+ { id: 1, plan_key: "o/r#295", pr_key: "o/r#299" },
143
+ { id: 2, plan_key: "o/r#295", pr_key: "o/r#304" },
144
+ ];
145
+ stores.pull_requests = [
146
+ { pr_key: "o/r#299", status: "merged" },
147
+ { pr_key: "o/r#304", status: "merged" },
148
+ ];
149
+ const state = freshRepo();
150
+
151
+ await withGithub(state, () => pollPromotion(data, engine, "tok"));
152
+
153
+ assertEquals(state.creates.length, 1);
154
+ assertEquals(state.creates[0].head, "epic/test-dsl");
155
+ assertEquals(state.creates[0].base, "main");
156
+ assertEquals(stores.plans[0].promotion_pr, "o/r#500");
157
+ assertEquals(stores.plans[0].promotion_state, "open");
158
+ // The promotion PR was enrolled into the convergence loop (a real PR, not an auto-merge).
159
+ assertEquals(instances.length, 1);
160
+ assertEquals(instances[0].variables.prKey, "o/r#500");
161
+ const prRow = stores.pull_requests.find((p) => p.pr_key === "o/r#500");
162
+ assert(prRow, "promotion PR row registered by submitPr");
163
+ });
164
+
165
+ test("pollPromotion: re-running is idempotent — no duplicate promotion PR", async () => {
166
+ const { data, stores } = memData();
167
+ const { engine } = recordingEngine();
168
+ stores.plans = [
169
+ { plan_key: "o/r#295", repo: "o/r", title: "Assertion DSL", status: "done", base_branch: "epic/test-dsl", delivery: "landed", promotion_pr: null, promotion_state: null },
170
+ ];
171
+ stores.plan_tasks = [{ id: 1, plan_key: "o/r#295", pr_key: "o/r#299" }];
172
+ stores.pull_requests = [{ pr_key: "o/r#299", status: "merged" }];
173
+ const state = freshRepo();
174
+
175
+ await withGithub(state, async () => {
176
+ await pollPromotion(data, engine, "tok");
177
+ await pollPromotion(data, engine, "tok");
178
+ await pollPromotion(data, engine, "tok");
179
+ });
180
+
181
+ assertEquals(state.creates.length, 1, "exactly one promotion PR across three passes");
182
+ assertEquals(stores.plans[0].promotion_pr, "o/r#500");
183
+ });
184
+
185
+ test("pollPromotion: a still-converging epic opens no promotion PR", async () => {
186
+ const { data, stores } = memData();
187
+ const { engine } = recordingEngine();
188
+ stores.plans = [
189
+ { plan_key: "o/r#296", repo: "o/r", title: "WIP", status: "done", base_branch: "epic/wip", delivery: "converging", promotion_pr: null, promotion_state: null },
190
+ ];
191
+ stores.plan_tasks = [{ id: 1, plan_key: "o/r#296", pr_key: "o/r#310" }];
192
+ stores.pull_requests = [{ pr_key: "o/r#310", status: "converging" }];
193
+ const state = freshRepo();
194
+
195
+ await withGithub(state, () => pollPromotion(data, engine, "tok"));
196
+
197
+ assertEquals(state.creates.length, 0);
198
+ assertEquals(stores.plans[0].promotion_pr, null);
199
+ assertEquals(stores.plans[0].promotion_state, null);
200
+ });
201
+
202
+ test("pollPromotion: a main-based epic has nothing to promote", async () => {
203
+ const { data, stores } = memData();
204
+ const { engine } = recordingEngine();
205
+ stores.plans = [
206
+ { plan_key: "o/r#297", repo: "o/r", title: "Direct", status: "done", base_branch: "main", delivery: "landed", promotion_pr: null, promotion_state: null },
207
+ ];
208
+ stores.plan_tasks = [{ id: 1, plan_key: "o/r#297", pr_key: "o/r#320" }];
209
+ stores.pull_requests = [{ pr_key: "o/r#320", status: "merged" }];
210
+ const state = freshRepo();
211
+
212
+ await withGithub(state, () => pollPromotion(data, engine, "tok"));
213
+
214
+ assertEquals(state.creates.length, 0);
215
+ assertEquals(stores.plans[0].promotion_pr, null);
216
+ assertEquals(stores.plans[0].promotion_state, null);
217
+ });
218
+
219
+ test("pollPromotion: reuses an existing PR from the integration branch (crash-recovery idempotency)", async () => {
220
+ const { data, stores } = memData();
221
+ const { engine } = recordingEngine();
222
+ stores.plans = [
223
+ { plan_key: "o/r#295", repo: "o/r", title: "Assertion DSL", status: "done", base_branch: "epic/test-dsl", delivery: "landed", promotion_pr: null, promotion_state: null },
224
+ ];
225
+ stores.plan_tasks = [{ id: 1, plan_key: "o/r#295", pr_key: "o/r#299" }];
226
+ stores.pull_requests = [{ pr_key: "o/r#299", status: "merged" }];
227
+ const state = freshRepo();
228
+ // A prior pass created the PR on GitHub but crashed before persisting `promotion_pr`.
229
+ state.prsByHead.set("epic/test-dsl", [{ number: 777, state: "open", baseRef: "main" }]);
230
+
231
+ await withGithub(state, () => pollPromotion(data, engine, "tok"));
232
+
233
+ assertEquals(state.creates.length, 0, "existing PR reused, not duplicated");
234
+ assertEquals(stores.plans[0].promotion_pr, "o/r#777");
235
+ assertEquals(stores.plans[0].promotion_state, "open");
236
+ });
237
+
238
+ test("pollPromotion: projects `promoted` once the promotion PR merges", async () => {
239
+ const { data, stores } = memData();
240
+ const { engine } = recordingEngine();
241
+ stores.plans = [
242
+ { plan_key: "o/r#295", repo: "o/r", title: "Assertion DSL", status: "done", base_branch: "epic/test-dsl", delivery: "landed", promotion_pr: "o/r#500", promotion_state: "open" },
243
+ ];
244
+ stores.plan_tasks = [{ id: 1, plan_key: "o/r#295", pr_key: "o/r#299" }];
245
+ stores.pull_requests = [
246
+ { pr_key: "o/r#299", status: "merged" },
247
+ { pr_key: "o/r#500", status: "merged" },
248
+ ];
249
+ const state = freshRepo();
250
+
251
+ await withGithub(state, () => pollPromotion(data, engine, "tok"));
252
+
253
+ assertEquals(state.creates.length, 0);
254
+ assertEquals(stores.plans[0].promotion_state, "promoted");
255
+ });
package/app/service.ts CHANGED
@@ -17,6 +17,8 @@ import {
17
17
  classifyMergeability,
18
18
  coalesceTitle,
19
19
  ensureFreshHeadRun,
20
+ ensurePromotionPr,
21
+ fetchDefaultBranch,
20
22
  fetchPrHead,
21
23
  fetchPrMeta,
22
24
  fetchPrReviews,
@@ -32,6 +34,7 @@ import { mergeLanes, readExclusions } from "./mergeExclusion.ts";
32
34
  import { freshHeadRunAction, headRunPresenceCount, loadMergeProtocol } from "./mergeProtocol.ts";
33
35
  import { type PrLaneDecision, planPrLane, taskDependencyDepths } from "./mergeTrain.ts";
34
36
  import { planReviews, plans, planTaskDeps, planTasks } from "./plan.ts";
37
+ import { derivePromotionState, isPromotable, promotionPrBody, promotionPrTitle } from "./promotion.ts";
35
38
  import { clampNudgeMinutes, reviewWaitTimeout } from "./reviewWait.ts";
36
39
  import { trialMergeAudits } from "./trialMerge.ts";
37
40
  import {
@@ -1347,6 +1350,91 @@ export async function pollDelivery(data: DataLayer) {
1347
1350
  }
1348
1351
  }
1349
1352
 
1353
+ /** Idempotent promotion pass (issue #299): open — and then track — the `epic/* → <default>`
1354
+ * promotion PR for every epic that has LANDED on a custom integration branch. This is the missing
1355
+ * counterpart to `ensureBaseBranch`: that creates the `epic/*` branch slices merge into; this
1356
+ * delivers the fully-landed branch to the default branch. Runs AFTER `pollDelivery` so it reads the
1357
+ * freshly-projected `delivery = landed` signal.
1358
+ *
1359
+ * Per promotable plan (`isPromotable`: `delivery = landed` AND base is `epic/*`):
1360
+ * • No promotion PR yet → open ONE `epic/* → <default>` PR (idempotent against a remote head-branch
1361
+ * lookup, so a crash between GitHub-create and the `promotion_pr` write can't duplicate it),
1362
+ * record `promotion_pr`, mark `promotion_state = open`, and enroll it into the convergence + merge
1363
+ * loop via `submitPr` (a real PR that must go green + converge before it merges — never an
1364
+ * auto-merge). If the PR can't be opened this pass (no default branch resolvable, no transport),
1365
+ * leave it at `promotion_state = ready` and retry next pass.
1366
+ * • Promotion PR already recorded → project `promotion_state` from its live status
1367
+ * (`merged → promoted`, else `open`); if its `pull_requests` row is absent (a prior `submitPr`
1368
+ * failed / DB desync) re-enroll it (idempotent).
1369
+ *
1370
+ * A `main`-based epic (base is not `epic/*`) is never promotable — its slices already landed on the
1371
+ * default branch, so there is nothing to promote. Best-effort + per-plan isolated. */
1372
+ export async function pollPromotion(data: DataLayer, engine: EngineClient, token: string) {
1373
+ // Preload every PR status once per pass (mirrors pollDelivery — avoids an N+1 `prs(data).get`).
1374
+ const statusByPrKey = new Map<string, string>();
1375
+ for (const pr of await prs(data).all()) statusByPrKey.set(pr.pr_key, pr.status);
1376
+ for (const plan of await plans(data).all()) {
1377
+ if (!isPromotable(plan)) continue;
1378
+ const base = plan.base_branch;
1379
+ if (!base) continue; // narrowed by isPromotable, but keep the type-checker honest
1380
+ try {
1381
+ // Already opened → project state from the promotion PR's live status, and re-enroll it if its
1382
+ // convergence row went missing (a prior submit failed, or the app/engine store desynced).
1383
+ if (plan.promotion_pr) {
1384
+ const prStatus = statusByPrKey.get(plan.promotion_pr) ?? null;
1385
+ const nextState = derivePromotionState(true, prStatus === "merged");
1386
+ if (plan.promotion_state !== nextState) {
1387
+ await plans(data).update(plan.plan_key, { promotion_state: nextState, updated_at: now() });
1388
+ }
1389
+ if (prStatus === null) {
1390
+ const parsed = parsePr(plan.promotion_pr);
1391
+ if (parsed) await submitPr(data, engine, parsed);
1392
+ }
1393
+ continue;
1394
+ }
1395
+ // Not opened yet: this epic is ready to promote. Resolve the target (default) branch; without
1396
+ // it we can't open the PR this pass, so surface `ready` and retry.
1397
+ const target = await fetchDefaultBranch(plan.repo, token);
1398
+ if (!target || target === base) {
1399
+ // `target === base` is a defensive guard (an `epic/*` base can't be the default), but never
1400
+ // open a branch-into-itself PR. Either way, mark ready and retry.
1401
+ if (plan.promotion_state !== "ready") {
1402
+ await plans(data).update(plan.plan_key, { promotion_state: "ready", updated_at: now() });
1403
+ }
1404
+ continue;
1405
+ }
1406
+ const tasks = await planTasks(data).find({ plan_key: plan.plan_key });
1407
+ const slicePrKeys = tasks.map((t) => t.pr_key).filter((k): k is string => !!k);
1408
+ const epicTitle = coalesceTitle(plan.title, plan.plan_key);
1409
+ const title = promotionPrTitle(base, target, epicTitle);
1410
+ const body = promotionPrBody(base, target, plan.plan_key, slicePrKeys);
1411
+ const result = await ensurePromotionPr(plan.repo, base, target, title, body, token);
1412
+ if (!result) {
1413
+ // No transport this pass — surface ready-to-promote and retry.
1414
+ if (plan.promotion_state !== "ready") {
1415
+ await plans(data).update(plan.plan_key, { promotion_state: "ready", updated_at: now() });
1416
+ }
1417
+ continue;
1418
+ }
1419
+ const promotionPrKey = `${plan.repo}#${result.number}`;
1420
+ // Persist the idempotency key + state BEFORE enrolling, so a submit failure can never lead a
1421
+ // later pass to open a second PR (it will see `promotion_pr` set and only re-enroll).
1422
+ await plans(data).update(plan.plan_key, {
1423
+ promotion_pr: promotionPrKey,
1424
+ promotion_state: "open",
1425
+ updated_at: now(),
1426
+ });
1427
+ const parsed = parsePr(promotionPrKey);
1428
+ if (parsed) await submitPr(data, engine, parsed);
1429
+ console.log(
1430
+ `[poller] promotion PR ${result.created ? "opened" : "reused"} ${promotionPrKey} (${base} -> ${target})`,
1431
+ );
1432
+ } catch (err) {
1433
+ console.error(`[poller] promotion ${plan.plan_key}: ${err}`);
1434
+ }
1435
+ }
1436
+ }
1437
+
1350
1438
  /** Reconcile each in-flight FEATURE run against its handed-off PR (fix: Feature history stuck at
1351
1439
  * `converging`). A feature run ends its own process with `status = converging` and its PR's live
1352
1440
  * outcome (merged / converged / abandoned) thereafter lives only on the `pull_requests` row keyed
@@ -1410,7 +1498,7 @@ export async function pollFeatureEscalations(data: DataLayer, engine: EngineClie
1410
1498
  for (const run of candidates) {
1411
1499
  if (!run.process_key) continue;
1412
1500
  try {
1413
- const tasks = await engine.searchUserTasks({ processInstanceKey: run.process_key });
1501
+ const tasks = await engine.openUserTasks({ processInstanceKey: run.process_key });
1414
1502
  const task = tasks.find((t) => t.elementId === FEATURE_ESCALATION_ELEMENT);
1415
1503
  const parked = task ? { userTaskKey: task.userTaskKey } : null;
1416
1504
  const patch = deriveFeatureEscalationPatch(run, parked);
@@ -1444,7 +1532,7 @@ export async function pollFeatureBlocked(data: DataLayer, engine: EngineClient)
1444
1532
  for (const run of await featureRuns(data).find({ status: "awaiting_operator" })) {
1445
1533
  if (!run.process_key) continue;
1446
1534
  try {
1447
- const tasks = await engine.searchUserTasks({ processInstanceKey: run.process_key });
1535
+ const tasks = await engine.openUserTasks({ processInstanceKey: run.process_key });
1448
1536
  const task = tasks.find((t) => t.elementId === FEATURE_BLOCKED_ELEMENT);
1449
1537
  const parked = task ? { userTaskKey: task.userTaskKey } : null;
1450
1538
  const patch = deriveFeatureBlockedPatch(run, parked);
@@ -1577,7 +1665,7 @@ export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
1577
1665
  planSeen.add(plan.plan_key);
1578
1666
  let tasks: { userTaskKey: string; elementId?: string }[];
1579
1667
  try {
1580
- tasks = await engine.searchUserTasks({ processInstanceKey: plan.process_key });
1668
+ tasks = await engine.openUserTasks({ processInstanceKey: plan.process_key });
1581
1669
  } catch (err) {
1582
1670
  console.error(`[poller] user tasks (plan ${plan.plan_key}): ${err}`);
1583
1671
  continue;
@@ -1632,7 +1720,7 @@ export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
1632
1720
  prSeen.add(pr.pr_key);
1633
1721
  let tasks: { userTaskKey: string; elementId?: string }[];
1634
1722
  try {
1635
- tasks = await engine.searchUserTasks({ processInstanceKey: pr.process_key });
1723
+ tasks = await engine.openUserTasks({ processInstanceKey: pr.process_key });
1636
1724
  } catch (err) {
1637
1725
  console.error(`[poller] user tasks (pr ${pr.pr_key}): ${err}`);
1638
1726
  continue;
@@ -1699,6 +1787,7 @@ export async function pollOnce(
1699
1787
  await pollReviews(data, engine, token);
1700
1788
  await pollMerges(data, engine, token);
1701
1789
  await pollDelivery(data);
1790
+ await pollPromotion(data, engine, token);
1702
1791
  await pollFeatureDelivery(data);
1703
1792
  await pollLineage(data);
1704
1793
  await pollFeatureEscalations(data, engine);
@@ -0,0 +1,38 @@
1
+ -- 041_inter_epic_plan_deps.sql — issue #292 slice S1: first-class INTER-epic dependency edge.
2
+ --
3
+ -- `plan_task_deps` (005_plan_deps.sql) records the INTRA-epic task DAG — edges that order tasks
4
+ -- *within a single plan* into waves. There is no way today to say "epic B depends-on epic A": that
5
+ -- the whole of epic B must wait until epic A has published a capability before B may fan out.
6
+ --
7
+ -- This table adds that second, coarser grain: one row per INTER-epic edge. `plan_key` is the
8
+ -- dependent/consumer epic that waits; `depends_on_plan_key` is the producer epic it waits for. The
9
+ -- edge also carries the GATING CONTRACT DESCRIPTOR the later capability probe (slice S3) needs to
10
+ -- resolve which published `pkg@version` first carries the awaited capability: `package` is the
11
+ -- producer's published package name, and `capability_ref` is the producer epic's issue handle used
12
+ -- to resolve that version. This slice (S1) only lands the durable schema + typed read/write surface;
13
+ -- admission (S2), lowering into a readiness gate (S3), and visibility (S4) build on it later.
14
+ --
15
+ -- Constraints mirror how `plan_task_deps` is constrained:
16
+ -- • PRIMARY KEY (plan_key, depends_on_plan_key) — one edge per consumer→producer pair, so a
17
+ -- re-submitted set cannot duplicate an edge.
18
+ -- • CHECK (plan_key <> depends_on_plan_key) — an epic cannot depend on itself.
19
+ -- • plan_key REFERENCES plans(plan_key) — the consumer is an admitted plan (as plan_task_deps FKs
20
+ -- its plan_key). `depends_on_plan_key` is intentionally NOT FK-constrained: a batch admission
21
+ -- (S2) may insert edges before every producer row exists, and the set validator enforces that
22
+ -- every edge names a submitted epic. An index on plan_key backs the inbound read.
23
+ --
24
+ -- Numbered after the current highest prefix on origin/main (040); the runner wraps each file in its
25
+ -- own transaction, so this file must NOT contain BEGIN/COMMIT.
26
+
27
+ CREATE TABLE plan_deps (
28
+ plan_key TEXT NOT NULL REFERENCES plans(plan_key), -- dependent/consumer epic that waits
29
+ depends_on_plan_key TEXT NOT NULL, -- producer epic it waits for
30
+ package TEXT NOT NULL, -- producer's published package name
31
+ capability_ref TEXT NOT NULL, -- producer epic issue handle → pkg@version
32
+ created_at TEXT NOT NULL,
33
+ PRIMARY KEY (plan_key, depends_on_plan_key),
34
+ CHECK (plan_key <> depends_on_plan_key)
35
+ );
36
+
37
+ CREATE INDEX idx_plan_deps_plan ON plan_deps(plan_key);
38
+ CREATE INDEX idx_plan_deps_producer ON plan_deps(depends_on_plan_key);
@@ -0,0 +1,31 @@
1
+ -- 042_plan_promotion.sql — issue #299: automate the epic integration-branch → default-branch
2
+ -- promotion PR once an epic LANDS on its custom `epic/*` integration branch.
3
+ --
4
+ -- Today an epic that targets a custom `epic/*` integration branch fans slices out that PR *into*
5
+ -- that branch; once every slice merges (`plans.delivery = landed`, projected by 029), the epic is
6
+ -- delivered on the integration branch but NOTHING opens the final `epic/* → <default>` promotion
7
+ -- PR — the operator has to notice, find the branch, and raise it by hand. This migration adds the
8
+ -- durable read/idempotency surface the poller's new `pollPromotion` pass needs to open (and then
9
+ -- track) exactly one promotion PR per landed epic, reusing the same convergence + merge protocol as
10
+ -- every other PR:
11
+ --
12
+ -- • promotion_pr — the `owner/repo#N` key of the promotion PR the poller opened for this epic,
13
+ -- or NULL until one exists. This is the PRIMARY idempotency key: a pass that
14
+ -- finds it set never opens a second PR (the poller also reconciles against a
15
+ -- remote head-branch lookup, so a crash between GitHub-create and this write
16
+ -- can never duplicate the PR either).
17
+ -- • promotion_state — the epic-card progression for the landed→delivered arc (issue #298's
18
+ -- "keep landed epics visible until acknowledged"): one of
19
+ -- 'ready' — landed on an `epic/*` base, promotion PR not yet opened.
20
+ -- 'open' — the promotion PR is open and converging toward merge.
21
+ -- 'promoted' — the promotion PR merged; the epic is delivered on the
22
+ -- default branch.
23
+ -- NULL until the epic first becomes promotable (grandfathers pre-#299 rows
24
+ -- and every `main`-based epic, which has nothing to promote).
25
+ --
26
+ -- Additive/derived only: no change to the plan lifecycle (`status`) — both columns are projected
27
+ -- idempotently by the poller, mirroring the `delivery` / `delivery_label` read-model columns (029).
28
+ -- Numbered after the current highest prefix on origin/main (041); the runner wraps each file in its
29
+ -- own transaction, so this file must NOT contain BEGIN/COMMIT.
30
+ ALTER TABLE plans ADD COLUMN promotion_pr TEXT;
31
+ ALTER TABLE plans ADD COLUMN promotion_state TEXT;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.88.0",
3
+ "version": "0.89.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",
@@ -61,6 +61,7 @@
61
61
  { "field": "epic_phase", "header": "Phase" },
62
62
  { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
63
63
  { "field": "delivery", "header": "Delivery" },
64
+ { "field": "promotion_state", "header": "Promotion" },
64
65
  { "field": "wave_label", "header": "Wave" },
65
66
  { "field": "task_count", "header": "Tasks" },
66
67
  { "field": "updated_at", "header": "Updated", "width": "9rem" }
@@ -72,6 +73,7 @@
72
73
  { "field": "issue_number", "label": "Issue number" },
73
74
  { "field": "base_branch", "label": "Base branch (blank = repo default)" },
74
75
  { "field": "delivery_label", "label": "Delivery rollup (slices merged / converging)" },
76
+ { "field": "promotion_pr", "label": "Promotion PR (epic/* → default branch)" },
75
77
  { "field": "outcome", "label": "Outcome" }
76
78
  ]
77
79
  }
@@ -79,6 +79,7 @@
79
79
  { "field": "epic_phase", "header": "Phase" },
80
80
  { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
81
81
  { "field": "delivery", "header": "Delivery" },
82
+ { "field": "promotion_state", "header": "Promotion" },
82
83
  { "field": "base_branch", "header": "Base branch" },
83
84
  { "field": "wave_label", "header": "Wave" },
84
85
  { "field": "task_count", "header": "Tasks" },