@nanobpm/nano-workforce 0.58.1 → 0.60.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 +14 -0
- package/app/delivery.test.ts +174 -0
- package/app/plan.ts +7 -0
- package/app/service.ts +117 -0
- package/db/migrations/029_plan_delivery.sql +29 -0
- package/package.json +2 -2
- package/pages/epic-detail.page.json +2 -0
- package/pages/epic.page.json +3 -2
- package/pages/feature.page.json +2 -2
- package/pages/home.page.json +3 -1
- package/prompts/feature.md +9 -0
- package/resources/processes/convergence-loop.bpmn +8 -8
- package/resources/processes/merge-loop.bpmn +4 -14
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [0.60.0](https://github.com/nanobpm/nano-workforce/compare/v0.59.0...v0.60.0) (2026-08-13)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **pages:** mark required form fields for inline validation hints ([#199](https://github.com/nanobpm/nano-workforce/issues/199)) ([15f0038](https://github.com/nanobpm/nano-workforce/commit/15f003809d0ec3294415161a79d73ef53c2703f2)), closes [nano-ide#223](https://github.com/nano-ide/issues/223) [nano-ide#223](https://github.com/nano-ide/issues/223)
|
|
7
|
+
|
|
8
|
+
# [0.59.0](https://github.com/nanobpm/nano-workforce/compare/v0.58.1...v0.59.0) (2026-08-13)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* derive epic delivery signal (converging vs landed) ([#197](https://github.com/nanobpm/nano-workforce/issues/197)) ([39ab5f1](https://github.com/nanobpm/nano-workforce/commit/39ab5f1b2628aadd350e9043ab0114103a24a1e2)), closes [#171](https://github.com/nanobpm/nano-workforce/issues/171) [#171](https://github.com/nanobpm/nano-workforce/issues/171)
|
|
14
|
+
|
|
1
15
|
## [0.58.1](https://github.com/nanobpm/nano-workforce/compare/v0.58.0...v0.58.1) (2026-08-13)
|
|
2
16
|
|
|
3
17
|
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Read-model derivation test for the epic delivery signal (issue #171). `deriveDelivery` is the
|
|
2
|
+
// single source of truth for the denormalised `plans.delivery` / `plans.delivery_label` columns the
|
|
3
|
+
// poller projects. It must cleanly distinguish an epic whose fan-out is `done` but whose slices are
|
|
4
|
+
// still CONVERGING from one where every slice PR has LANDED, and count abandoned/converged PRs as
|
|
5
|
+
// resolved-not-landed (never `landed`).
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { assert, assertEquals } from "#test-assert";
|
|
8
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
9
|
+
import { deriveDelivery, pollDelivery, TERMINAL_STATUSES } from "./service.ts";
|
|
10
|
+
|
|
11
|
+
// A tiny in-memory record gateway (all/find/update/insert), mirroring the fake-app style used
|
|
12
|
+
// across the app tests (see app/taskDelta.test.ts), enough to exercise the `pollDelivery` projection.
|
|
13
|
+
function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
14
|
+
const stores: Record<string, any[]> = {};
|
|
15
|
+
function tbl(name: string, pk = "id") {
|
|
16
|
+
const rows = (stores[name] ??= [] as any[]);
|
|
17
|
+
const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
|
|
18
|
+
return {
|
|
19
|
+
async all() {
|
|
20
|
+
return rows.slice();
|
|
21
|
+
},
|
|
22
|
+
async get(id: any) {
|
|
23
|
+
return rows.find((r) => r[pk] === id);
|
|
24
|
+
},
|
|
25
|
+
async find(where: any = {}) {
|
|
26
|
+
return rows.filter((r) => match(r, where));
|
|
27
|
+
},
|
|
28
|
+
async insert(row: any) {
|
|
29
|
+
rows.push({ ...row });
|
|
30
|
+
return row[pk];
|
|
31
|
+
},
|
|
32
|
+
async update(id: any, patch: any) {
|
|
33
|
+
const r = rows.find((row) => row[pk] === id);
|
|
34
|
+
if (r) Object.assign(r, patch);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
|
|
39
|
+
return { data, stores };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test("all slice PRs merged -> landed", () => {
|
|
43
|
+
const r = deriveDelivery("done", ["merged", "merged", "merged"]);
|
|
44
|
+
assertEquals(r.delivery, "landed");
|
|
45
|
+
assertEquals(r.prsOpened, 3);
|
|
46
|
+
assertEquals(r.prsMerged, 3);
|
|
47
|
+
assertEquals(r.prsInFlight, 0);
|
|
48
|
+
assertEquals(r.label, "3/3 slices merged");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("one slice PR still in flight -> converging", () => {
|
|
52
|
+
const r = deriveDelivery("done", ["merged", "converging", "merged"]);
|
|
53
|
+
assertEquals(r.delivery, "converging");
|
|
54
|
+
assertEquals(r.prsOpened, 3);
|
|
55
|
+
assertEquals(r.prsMerged, 2);
|
|
56
|
+
assertEquals(r.prsInFlight, 1);
|
|
57
|
+
assertEquals(r.label, "2/3 slices merged, 1 converging");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("mixed merged/abandoned (all terminal, not all merged) -> resolved-not-landed (null)", () => {
|
|
61
|
+
const r = deriveDelivery("done", ["merged", "abandoned", "merged"]);
|
|
62
|
+
assertEquals(r.delivery, null);
|
|
63
|
+
assertEquals(r.label, null);
|
|
64
|
+
assertEquals(r.prsOpened, 3);
|
|
65
|
+
assertEquals(r.prsMerged, 2);
|
|
66
|
+
// abandoned is terminal, so it is NOT counted as in flight.
|
|
67
|
+
assertEquals(r.prsInFlight, 0);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("a converged (review-only, unmerged) slice keeps the epic out of landed", () => {
|
|
71
|
+
// `converged` is terminal but not `merged`: resolved-not-landed, like abandoned.
|
|
72
|
+
const r = deriveDelivery("done", ["merged", "converged"]);
|
|
73
|
+
assertEquals(r.delivery, null);
|
|
74
|
+
assertEquals(r.prsInFlight, 0);
|
|
75
|
+
assertEquals(r.prsMerged, 1);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("plan not yet done -> no delivery signal even with slice PRs", () => {
|
|
79
|
+
for (const status of ["planning", "dispatched"]) {
|
|
80
|
+
const r = deriveDelivery(status, ["merged", "converging"]);
|
|
81
|
+
assertEquals(r.delivery, null, `status=${status}`);
|
|
82
|
+
assertEquals(r.label, null, `status=${status}`);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("done but zero slice PRs -> no delivery signal", () => {
|
|
87
|
+
const r = deriveDelivery("done", []);
|
|
88
|
+
assertEquals(r.delivery, null);
|
|
89
|
+
assertEquals(r.prsOpened, 0);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("a single in-flight slice on a done plan is converging, not landed", () => {
|
|
93
|
+
const r = deriveDelivery("done", ["waiting_review"]);
|
|
94
|
+
assertEquals(r.delivery, "converging");
|
|
95
|
+
assertEquals(r.label, "0/1 slices merged, 1 converging");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("every non-terminal status counts as in flight", () => {
|
|
99
|
+
const inFlight = ["converging", "waiting_review", "escalated", "queued", "open", "opened"];
|
|
100
|
+
for (const s of inFlight) {
|
|
101
|
+
assert(!TERMINAL_STATUSES.includes(s), `${s} must not be terminal`);
|
|
102
|
+
const r = deriveDelivery("done", [s]);
|
|
103
|
+
assertEquals(r.delivery, "converging", `status ${s}`);
|
|
104
|
+
assertEquals(r.prsInFlight, 1, `status ${s}`);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("pollDelivery: a dangling pr_key (missing PR row) counts as in-flight, never false-landed", async () => {
|
|
109
|
+
const { data, stores } = memData();
|
|
110
|
+
stores.plans = [
|
|
111
|
+
{ plan_key: "epic-1", status: "done", delivery: null, delivery_label: null },
|
|
112
|
+
];
|
|
113
|
+
stores.plan_tasks = [
|
|
114
|
+
{ id: 1, plan_key: "epic-1", pr_key: "o/r#1" },
|
|
115
|
+
{ id: 2, plan_key: "epic-1", pr_key: "o/r#2" }, // no matching pull_requests row (DB desync)
|
|
116
|
+
];
|
|
117
|
+
stores.pull_requests = [{ pr_key: "o/r#1", status: "merged" }];
|
|
118
|
+
|
|
119
|
+
await pollDelivery(data);
|
|
120
|
+
|
|
121
|
+
// Without the dangling PR being treated as in-flight, this would wrongly become `landed` (1/1).
|
|
122
|
+
assertEquals(stores.plans[0].delivery, "converging");
|
|
123
|
+
assertEquals(stores.plans[0].delivery_label, "1/2 slices merged, 1 converging");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("pollDelivery: all slice PR rows present and merged -> landed", async () => {
|
|
127
|
+
const { data, stores } = memData();
|
|
128
|
+
stores.plans = [
|
|
129
|
+
{ plan_key: "epic-2", status: "done", delivery: null, delivery_label: null },
|
|
130
|
+
];
|
|
131
|
+
stores.plan_tasks = [
|
|
132
|
+
{ id: 1, plan_key: "epic-2", pr_key: "o/r#10" },
|
|
133
|
+
{ id: 2, plan_key: "epic-2", pr_key: "o/r#11" },
|
|
134
|
+
];
|
|
135
|
+
stores.pull_requests = [
|
|
136
|
+
{ pr_key: "o/r#10", status: "merged" },
|
|
137
|
+
{ pr_key: "o/r#11", status: "merged" },
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
await pollDelivery(data);
|
|
141
|
+
|
|
142
|
+
assertEquals(stores.plans[0].delivery, "landed");
|
|
143
|
+
assertEquals(stores.plans[0].delivery_label, "2/2 slices merged");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("pollDelivery: a non-done plan is skipped and any stale projection is cleared", async () => {
|
|
147
|
+
const { data, stores } = memData();
|
|
148
|
+
stores.plans = [
|
|
149
|
+
// Regressed out of `done` while carrying a stale `converging` projection.
|
|
150
|
+
{ plan_key: "epic-3", status: "in_progress", delivery: "converging", delivery_label: "1/2 slices merged, 1 converging" },
|
|
151
|
+
];
|
|
152
|
+
// A task join here would be wasted work for a non-done plan; assert it is never consulted.
|
|
153
|
+
let taskLookups = 0;
|
|
154
|
+
stores.plan_tasks = [{ id: 1, plan_key: "epic-3", pr_key: "o/r#20" }];
|
|
155
|
+
stores.pull_requests = [{ pr_key: "o/r#20", status: "merged" }];
|
|
156
|
+
const origTable = (data as any).table.bind(data);
|
|
157
|
+
(data as any).table = (n: string, pk?: string) => {
|
|
158
|
+
const t = origTable(n, pk);
|
|
159
|
+
if (n === "plan_tasks") {
|
|
160
|
+
const origFind = t.find.bind(t);
|
|
161
|
+
t.find = async (where: any) => {
|
|
162
|
+
taskLookups++;
|
|
163
|
+
return origFind(where);
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
return t;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
await pollDelivery(data);
|
|
170
|
+
|
|
171
|
+
assertEquals(stores.plans[0].delivery, null);
|
|
172
|
+
assertEquals(stores.plans[0].delivery_label, null);
|
|
173
|
+
assertEquals(taskLookups, 0);
|
|
174
|
+
});
|
package/app/plan.ts
CHANGED
|
@@ -72,6 +72,13 @@ export interface Plan {
|
|
|
72
72
|
// admission); the column stays NULLABLE ONLY to grandfather pre-ADR-0003 / in-flight rows that
|
|
73
73
|
// carry NULL — those must remain readable, so do NOT add a NOT NULL migration.
|
|
74
74
|
base_branch: string | null;
|
|
75
|
+
// Derived epic delivery signal (029_plan_delivery.sql, #171): separates "fan-out dispatched to
|
|
76
|
+
// convergence" (status=done) from "all slice PRs actually merged". Recomputed idempotently by the
|
|
77
|
+
// poller's `pollDelivery` pass by joining each plan_tasks.pr_key → pull_requests.status — never
|
|
78
|
+
// written by the plan lifecycle. `delivery` is 'converging' | 'landed' | NULL (see deriveDelivery
|
|
79
|
+
// in app/service.ts); `delivery_label` is the human rollup for the epic detail view. Display-only.
|
|
80
|
+
delivery: string | null;
|
|
81
|
+
delivery_label: string | null;
|
|
75
82
|
created_at: string;
|
|
76
83
|
updated_at: string;
|
|
77
84
|
}
|
package/app/service.ts
CHANGED
|
@@ -99,6 +99,70 @@ const now = () => new Date().toISOString();
|
|
|
99
99
|
* guard both key off this set. */
|
|
100
100
|
export const TERMINAL_STATUSES: readonly string[] = ["converged", "merged", "abandoned"];
|
|
101
101
|
|
|
102
|
+
/** The derived epic delivery signal (issue #171). Distinct from `plan.status`: `status = done`
|
|
103
|
+
* means "the fan-out finished and ≥1 slice opened a PR, dispatched to convergence" (record-results
|
|
104
|
+
* sets it as soon as one PR opened — other slices may be blocked/skipped), which conflates hand-off
|
|
105
|
+
* with landing. `delivery` reports whether those slice PRs have actually MERGED. */
|
|
106
|
+
export type Delivery = "converging" | "landed";
|
|
107
|
+
|
|
108
|
+
/** Rollup of a plan's slice-PR landing state, derived by joining `plan_tasks.pr_key` →
|
|
109
|
+
* `pull_requests.status`. Pure and read-only — the single source of truth for the denormalised
|
|
110
|
+
* `plans.delivery` / `plans.delivery_label` columns the poller projects. */
|
|
111
|
+
export interface DeliveryRollup {
|
|
112
|
+
delivery: Delivery | null;
|
|
113
|
+
label: string | null;
|
|
114
|
+
prsOpened: number;
|
|
115
|
+
prsMerged: number;
|
|
116
|
+
prsInFlight: number;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Derive the delivery signal for one plan from its status and the statuses of its slice PRs.
|
|
120
|
+
*
|
|
121
|
+
* - `converging` — the plan is `done` but ≥1 slice PR is still non-terminal (in flight).
|
|
122
|
+
* - `landed` — every slice PR merged: `prsInFlight == 0 && prsMerged == prsOpened && prsOpened > 0`.
|
|
123
|
+
* - `null` — no positive signal yet: the plan isn't `done`, it opened no PRs, or every PR is
|
|
124
|
+
* terminal but not all merged (some `abandoned`/`converged` — resolved-not-landed, per the issue).
|
|
125
|
+
*
|
|
126
|
+
* A slice's PR status is "in flight" iff it is NOT in `TERMINAL_STATUSES`; `abandoned`/`converged`
|
|
127
|
+
* count as resolved-not-landed (terminal but not merged), so they never make an epic `landed`. */
|
|
128
|
+
export function deriveDelivery(
|
|
129
|
+
planStatus: string,
|
|
130
|
+
prStatuses: readonly string[],
|
|
131
|
+
): DeliveryRollup {
|
|
132
|
+
const prsOpened = prStatuses.length;
|
|
133
|
+
let prsMerged = 0;
|
|
134
|
+
let prsInFlight = 0;
|
|
135
|
+
for (const s of prStatuses) {
|
|
136
|
+
if (s === "merged") prsMerged++;
|
|
137
|
+
else if (!TERMINAL_STATUSES.includes(s)) prsInFlight++;
|
|
138
|
+
}
|
|
139
|
+
// `delivery` is only meaningful once the fan-out has been dispatched (`status = done`) and at
|
|
140
|
+
// least one slice PR exists; otherwise there is nothing to have landed yet.
|
|
141
|
+
if (planStatus !== "done" || prsOpened === 0) {
|
|
142
|
+
return { delivery: null, label: null, prsOpened, prsMerged, prsInFlight };
|
|
143
|
+
}
|
|
144
|
+
if (prsInFlight > 0) {
|
|
145
|
+
return {
|
|
146
|
+
delivery: "converging",
|
|
147
|
+
label: `${prsMerged}/${prsOpened} slices merged, ${prsInFlight} converging`,
|
|
148
|
+
prsOpened,
|
|
149
|
+
prsMerged,
|
|
150
|
+
prsInFlight,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
if (prsMerged === prsOpened) {
|
|
154
|
+
return {
|
|
155
|
+
delivery: "landed",
|
|
156
|
+
label: `${prsOpened}/${prsOpened} slices merged`,
|
|
157
|
+
prsOpened,
|
|
158
|
+
prsMerged,
|
|
159
|
+
prsInFlight,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
// Every slice PR is terminal but not all merged (some abandoned/converged): resolved, not landed.
|
|
163
|
+
return { delivery: null, label: null, prsOpened, prsMerged, prsInFlight };
|
|
164
|
+
}
|
|
165
|
+
|
|
102
166
|
interface PullRequest {
|
|
103
167
|
pr_key: string;
|
|
104
168
|
repo: string;
|
|
@@ -1132,6 +1196,58 @@ async function pollWaveGates(data: DataLayer, engine: EngineClient, token: strin
|
|
|
1132
1196
|
}
|
|
1133
1197
|
}
|
|
1134
1198
|
|
|
1199
|
+
/** Idempotent read-model pass: recompute each plan's derived `delivery` signal (issue #171) by
|
|
1200
|
+
* joining its slice tasks' `pr_key` → `pull_requests.status`, and denormalise it onto the `plans`
|
|
1201
|
+
* row so the epics overview / detail views can read it as a flat column (Urban's datasource can't
|
|
1202
|
+
* read a SQL VIEW). Never touches `plan.status` — additive/derived only. Writes only when the
|
|
1203
|
+
* projection actually changes, so a steady-state pass is a no-op. */
|
|
1204
|
+
/** Sentinel status fed to `deriveDelivery` for a `plan_tasks.pr_key` whose `pull_requests` row is
|
|
1205
|
+
* missing (DB desync). It is deliberately non-terminal and not `merged`, so a dangling PR counts as
|
|
1206
|
+
* in-flight — never a false-positive `landed` from a silently-dropped slice. */
|
|
1207
|
+
const MISSING_PR_STATUS = "missing";
|
|
1208
|
+
|
|
1209
|
+
export async function pollDelivery(data: DataLayer) {
|
|
1210
|
+
// Preload every PR status once per pass into a pr_key→status map (avoids the prior N+1
|
|
1211
|
+
// `prs(data).get` per task; mirrors how `activePrs` reads `prs(data).all()` once).
|
|
1212
|
+
const statusByPrKey = new Map<string, string>();
|
|
1213
|
+
for (const pr of await prs(data).all()) statusByPrKey.set(pr.pr_key, pr.status);
|
|
1214
|
+
for (const plan of await plans(data).all()) {
|
|
1215
|
+
try {
|
|
1216
|
+
// `deriveDelivery` always yields `{null, null}` for a non-`done` plan, so skip the per-plan
|
|
1217
|
+
// task join for those — but still clear any stale projection defensively (e.g. a plan that
|
|
1218
|
+
// regressed out of `done`) so the read model never keeps a phantom `converging`/`landed`.
|
|
1219
|
+
if (plan.status !== "done") {
|
|
1220
|
+
if (plan.delivery !== null || plan.delivery_label !== null) {
|
|
1221
|
+
await plans(data).update(plan.plan_key, {
|
|
1222
|
+
delivery: null,
|
|
1223
|
+
delivery_label: null,
|
|
1224
|
+
updated_at: now(),
|
|
1225
|
+
});
|
|
1226
|
+
}
|
|
1227
|
+
continue;
|
|
1228
|
+
}
|
|
1229
|
+
const tasks = await planTasks(data).find({ plan_key: plan.plan_key });
|
|
1230
|
+
const prStatuses: string[] = [];
|
|
1231
|
+
for (const t of tasks) {
|
|
1232
|
+
if (!t.pr_key) continue;
|
|
1233
|
+
// A dangling pr_key (row missing) is treated as in-flight, not dropped, so a DB desync
|
|
1234
|
+
// can never wrongly promote an epic to `landed`.
|
|
1235
|
+
prStatuses.push(statusByPrKey.get(t.pr_key) ?? MISSING_PR_STATUS);
|
|
1236
|
+
}
|
|
1237
|
+
const { delivery, label } = deriveDelivery(plan.status, prStatuses);
|
|
1238
|
+
if (plan.delivery !== delivery || plan.delivery_label !== label) {
|
|
1239
|
+
await plans(data).update(plan.plan_key, {
|
|
1240
|
+
delivery,
|
|
1241
|
+
delivery_label: label,
|
|
1242
|
+
updated_at: now(),
|
|
1243
|
+
});
|
|
1244
|
+
}
|
|
1245
|
+
} catch (err) {
|
|
1246
|
+
console.error(`[poller] delivery ${plan.plan_key}: ${err}`);
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1135
1251
|
/** One full poll pass: advance the review stage, the merge stage, the wave-merge barrier, and
|
|
1136
1252
|
* (when the engine REST endpoint is supplied) the job-activation visibility pass and the
|
|
1137
1253
|
* technical-incident surfacing pass. Called on the self-scheduling loop in `main.ts`. */
|
|
@@ -1144,6 +1260,7 @@ export async function pollOnce(
|
|
|
1144
1260
|
await pollReviews(data, engine, token);
|
|
1145
1261
|
await pollMerges(data, engine, token);
|
|
1146
1262
|
await pollWaveGates(data, engine, token);
|
|
1263
|
+
await pollDelivery(data);
|
|
1147
1264
|
if (engineRest) {
|
|
1148
1265
|
await pollJobActivation(data, engineRest.restAddress, engineRest.token);
|
|
1149
1266
|
await pollIncidents(data, engineRest.restAddress, engineRest.token);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
-- Derived epic delivery signal: separate "fan-out dispatched to convergence" from "all slice
|
|
2
|
+
-- PRs actually merged" (issue #171).
|
|
3
|
+
--
|
|
4
|
+
-- `plans.status = done` means "the fan-out finished and ≥1 slice opened a PR and was handed off to
|
|
5
|
+
-- convergence" (record-results marks it as soon as one PR opened — other slices may be
|
|
6
|
+
-- blocked/skipped), NOT "every PR merged". Convergence + merge
|
|
7
|
+
-- then run as separate async per-PR processes, so a `done` epic can still have slice PRs in
|
|
8
|
+
-- flight. That conflation is misleading on the epics overview.
|
|
9
|
+
--
|
|
10
|
+
-- We leave `plans.status` untouched (automation depends on `done` == dispatched) and add a
|
|
11
|
+
-- DERIVED delivery signal, computed by joining each `plan_tasks.pr_key` → `pull_requests.status`.
|
|
12
|
+
-- Urban's datasource cannot read a SQL VIEW (gateway.ts schema() whitelists only type='table'),
|
|
13
|
+
-- so — following the codebase convention for read-model projections onto `plans` (wave_label,
|
|
14
|
+
-- gate_wave, …) — the poller denormalises two flat columns, recomputed idempotently each pass:
|
|
15
|
+
--
|
|
16
|
+
-- • delivery — the derived signal, one of:
|
|
17
|
+
-- 'converging' — plan `done` but ≥1 slice PR still non-terminal.
|
|
18
|
+
-- 'landed' — every slice PR merged (prs_in_flight == 0 &&
|
|
19
|
+
-- prs_merged == prs_opened && prs_opened > 0).
|
|
20
|
+
-- NULL when there is no positive signal yet: the plan is not `done`, it
|
|
21
|
+
-- opened no PRs, or every PR is terminal but not all merged (some
|
|
22
|
+
-- abandoned/converged — resolved-not-landed). `landed` is the honest
|
|
23
|
+
-- "epic delivered" state and the precondition for ready-to-promote (#160).
|
|
24
|
+
-- • delivery_label — the human rollup for the epic detail view, e.g.
|
|
25
|
+
-- "4/5 slices merged, 1 converging". NULL when delivery is NULL.
|
|
26
|
+
--
|
|
27
|
+
-- Additive/derived only: no new writes to the plan lifecycle (`status`).
|
|
28
|
+
ALTER TABLE plans ADD COLUMN delivery TEXT;
|
|
29
|
+
ALTER TABLE plans ADD COLUMN delivery_label TEXT;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.60.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",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
53
|
"@nanobpm/agentic": "^0.1.0",
|
|
54
|
-
"@nanobpm/urban": "^0.
|
|
54
|
+
"@nanobpm/urban": "^0.47.0"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@biomejs/biome": "^2.4.11",
|
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
"columns": [
|
|
56
56
|
{ "field": "plan_key", "header": "Issue", "linkField": "issue_url" },
|
|
57
57
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
58
|
+
{ "field": "delivery", "header": "Delivery" },
|
|
58
59
|
{ "field": "wave_label", "header": "Wave" },
|
|
59
60
|
{ "field": "task_count", "header": "Tasks" },
|
|
60
61
|
{ "field": "updated_at", "header": "Updated" }
|
|
@@ -65,6 +66,7 @@
|
|
|
65
66
|
{ "field": "repo", "label": "Repository" },
|
|
66
67
|
{ "field": "issue_number", "label": "Issue number" },
|
|
67
68
|
{ "field": "base_branch", "label": "Base branch (blank = repo default)" },
|
|
69
|
+
{ "field": "delivery_label", "label": "Delivery rollup (slices merged / converging)" },
|
|
68
70
|
{ "field": "outcome", "label": "Outcome" }
|
|
69
71
|
]
|
|
70
72
|
}
|
package/pages/epic.page.json
CHANGED
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"submitLabel": "Plan & implement",
|
|
39
39
|
"action": { "path": "/app/api/actions/start/plan-fanout", "body": "{{form}}" },
|
|
40
40
|
"fields": [
|
|
41
|
-
{ "key": "issue", "label": "owner/repo#123 or a GitHub issue URL", "type": "text" },
|
|
42
|
-
{ "key": "baseBranch", "label": "Base branch
|
|
41
|
+
{ "key": "issue", "label": "owner/repo#123 or a GitHub issue URL", "type": "text", "required": true, "requiredMessage": "An issue reference is required (owner/repo#123 or an issue URL)" },
|
|
42
|
+
{ "key": "baseBranch", "label": "Base branch: e.g. epic/agent-protocol to land the whole epic on an integration branch. A missing epic/* branch is auto-created off default HEAD; a non-epic/* branch must already exist.", "type": "text", "required": true, "requiredMessage": "Name the branch the PR targets (e.g. main or epic/agent-protocol)" },
|
|
43
43
|
{ "key": "confirmDefaultBase", "label": "Confirm landing on the default branch \u2014 required only when the base above IS the repository default (every task lands directly on it, with any merge-to-default side effect firing per task)", "type": "checkbox" },
|
|
44
44
|
{ "key": "allowSharedBase", "label": "Allow sharing a custom integration branch with another active epic \u2014 required only when another in-flight epic already targets this same custom base", "type": "checkbox" }
|
|
45
45
|
]
|
|
@@ -73,6 +73,7 @@
|
|
|
73
73
|
"columns": [
|
|
74
74
|
{ "field": "plan_key", "header": "Epic", "link": { "kind": "page", "page": "epic-detail", "keyField": "plan_key" } },
|
|
75
75
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
76
|
+
{ "field": "delivery", "header": "Delivery" },
|
|
76
77
|
{ "field": "base_branch", "header": "Base branch" },
|
|
77
78
|
{ "field": "wave_label", "header": "Wave" },
|
|
78
79
|
{ "field": "task_count", "header": "Tasks" },
|
package/pages/feature.page.json
CHANGED
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"submitLabel": "Implement & raise PR",
|
|
39
39
|
"action": { "path": "/app/api/actions/start/feature", "body": "{{form}}" },
|
|
40
40
|
"fields": [
|
|
41
|
-
{ "key": "issue", "label": "owner/repo#123 or a GitHub issue URL", "type": "text" },
|
|
42
|
-
{ "key": "baseBranch", "label": "Base branch
|
|
41
|
+
{ "key": "issue", "label": "owner/repo#123 or a GitHub issue URL", "type": "text", "required": true, "requiredMessage": "An issue reference is required (owner/repo#123 or an issue URL)" },
|
|
42
|
+
{ "key": "baseBranch", "label": "Base branch: the branch the PR targets, e.g. main. A missing epic/* branch is auto-created off default HEAD; a non-epic/* branch must already exist.", "type": "text", "required": true, "requiredMessage": "Name the branch the PR targets (e.g. main or epic/agent-protocol)" },
|
|
43
43
|
{ "key": "converge", "label": "Converge \u2014 hand the raised PR to the review-convergence loop", "type": "checkbox" },
|
|
44
44
|
{ "key": "autoMerge", "label": "Auto-merge \u2014 after convergence, drive the merge-loop (only applies when Converge is on)", "type": "checkbox" },
|
|
45
45
|
{ "key": "confirmDefaultBase", "label": "Confirm landing on the default branch \u2014 required only when the base above IS the repository default", "type": "checkbox" },
|
package/pages/home.page.json
CHANGED
package/prompts/feature.md
CHANGED
|
@@ -142,3 +142,12 @@ inside its slice. Use:
|
|
|
142
142
|
- `constraint` — a constraint you discovered that changes another task's direction.
|
|
143
143
|
|
|
144
144
|
This is advisory context, not an escalation — it never blocks you or anyone else.
|
|
145
|
+
|
|
146
|
+
If the repo has an **append-ordered namespace** — files chosen by "the next"
|
|
147
|
+
monotonic value (DB migration prefixes, ADR numbers, changelog fragments,
|
|
148
|
+
ordered fixtures) — `file-claim` your intended slot on the coordination
|
|
149
|
+
blackboard *before* authoring, and read existing claims first. Parallel siblings
|
|
150
|
+
otherwise pick the same value and collide silently (names don't textually
|
|
151
|
+
conflict, so git merges both). This is advisory best-effort; the repo's own CI
|
|
152
|
+
gate, if any, remains the guarantee. Check the repo's AGENTS.md for which
|
|
153
|
+
namespaces are ordered.
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
<nano:extend name="status" type="string" optional="true" />
|
|
30
30
|
<nano:extend name="summary" type="string" optional="true" />
|
|
31
31
|
</nano:shape>
|
|
32
|
-
<nano:shape id="
|
|
32
|
+
<nano:shape id="EscalationIn" name="Record escalation — input">
|
|
33
33
|
<nano:extend name="prKey" type="string" />
|
|
34
34
|
<nano:extend name="round" type="integer" />
|
|
35
35
|
<nano:extend name="status" type="string" optional="true" />
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
<nano:extend name="question" type="string" optional="true" />
|
|
38
38
|
<nano:extend name="recordRound" type="boolean" optional="true" />
|
|
39
39
|
</nano:shape>
|
|
40
|
-
<nano:shape id="
|
|
40
|
+
<nano:shape id="EscalationOut" name="Record escalation — result">
|
|
41
41
|
<nano:extend name="escalationId" type="integer" optional="true" />
|
|
42
42
|
<nano:extend name="escalated" type="boolean" optional="true" />
|
|
43
43
|
<nano:extend name="question" type="string" optional="true" />
|
|
@@ -134,8 +134,8 @@
|
|
|
134
134
|
<bpmn:extensionElements>
|
|
135
135
|
<zeebe:taskDefinition type="pr.persist-escalation" />
|
|
136
136
|
<zeebe:properties>
|
|
137
|
-
<zeebe:property name="io.nanobpm.dataEnvelope.in" value="
|
|
138
|
-
<zeebe:property name="io.nanobpm.dataEnvelope.out" value="
|
|
137
|
+
<zeebe:property name="io.nanobpm.dataEnvelope.in" value="EscalationIn" />
|
|
138
|
+
<zeebe:property name="io.nanobpm.dataEnvelope.out" value="EscalationOut" />
|
|
139
139
|
</zeebe:properties>
|
|
140
140
|
<zeebe:ioMapping>
|
|
141
141
|
<zeebe:input source="="blocked"" target="status" />
|
|
@@ -150,8 +150,8 @@
|
|
|
150
150
|
<bpmn:extensionElements>
|
|
151
151
|
<zeebe:taskDefinition type="pr.persist-escalation" />
|
|
152
152
|
<zeebe:properties>
|
|
153
|
-
<zeebe:property name="io.nanobpm.dataEnvelope.in" value="
|
|
154
|
-
<zeebe:property name="io.nanobpm.dataEnvelope.out" value="
|
|
153
|
+
<zeebe:property name="io.nanobpm.dataEnvelope.in" value="EscalationIn" />
|
|
154
|
+
<zeebe:property name="io.nanobpm.dataEnvelope.out" value="EscalationOut" />
|
|
155
155
|
</zeebe:properties>
|
|
156
156
|
</bpmn:extensionElements>
|
|
157
157
|
<bpmn:incoming>f_escalate</bpmn:incoming>
|
|
@@ -166,8 +166,8 @@
|
|
|
166
166
|
<bpmn:extensionElements>
|
|
167
167
|
<zeebe:taskDefinition type="pr.persist-escalation" />
|
|
168
168
|
<zeebe:properties>
|
|
169
|
-
<zeebe:property name="io.nanobpm.dataEnvelope.in" value="
|
|
170
|
-
<zeebe:property name="io.nanobpm.dataEnvelope.out" value="
|
|
169
|
+
<zeebe:property name="io.nanobpm.dataEnvelope.in" value="EscalationIn" />
|
|
170
|
+
<zeebe:property name="io.nanobpm.dataEnvelope.out" value="EscalationOut" />
|
|
171
171
|
</zeebe:properties>
|
|
172
172
|
<zeebe:ioMapping>
|
|
173
173
|
<zeebe:input source="="blocked"" target="status" />
|
|
@@ -47,16 +47,6 @@
|
|
|
47
47
|
<nano:extend name="status" type="string" optional="true" />
|
|
48
48
|
<nano:extend name="question" type="string" optional="true" />
|
|
49
49
|
</nano:shape>
|
|
50
|
-
<nano:shape id="MergeEscalationIn" name="Record merge escalation — input">
|
|
51
|
-
<nano:extend name="prKey" type="string" />
|
|
52
|
-
<nano:extend name="round" type="integer" />
|
|
53
|
-
<nano:extend name="status" type="string" optional="true" />
|
|
54
|
-
<nano:extend name="summary" type="string" optional="true" />
|
|
55
|
-
<nano:extend name="question" type="string" optional="true" />
|
|
56
|
-
</nano:shape>
|
|
57
|
-
<nano:shape id="MergeEscalationOut" name="Record merge escalation — result">
|
|
58
|
-
<nano:extend name="escalationId" type="integer" />
|
|
59
|
-
</nano:shape>
|
|
60
50
|
<nano:shape id="MarkMergedIn" name="Mark merged — input">
|
|
61
51
|
<nano:extend name="prKey" type="string" />
|
|
62
52
|
</nano:shape>
|
|
@@ -186,8 +176,8 @@
|
|
|
186
176
|
<bpmn:extensionElements>
|
|
187
177
|
<zeebe:taskDefinition type="pr.persist-escalation" />
|
|
188
178
|
<zeebe:properties>
|
|
189
|
-
<zeebe:property name="io.nanobpm.dataEnvelope.in" value="
|
|
190
|
-
<zeebe:property name="io.nanobpm.dataEnvelope.out" value="
|
|
179
|
+
<zeebe:property name="io.nanobpm.dataEnvelope.in" value="EscalationIn" />
|
|
180
|
+
<zeebe:property name="io.nanobpm.dataEnvelope.out" value="EscalationOut" />
|
|
191
181
|
</zeebe:properties>
|
|
192
182
|
<zeebe:ioMapping>
|
|
193
183
|
<zeebe:input source="="blocked"" target="status" />
|
|
@@ -203,8 +193,8 @@
|
|
|
203
193
|
<bpmn:extensionElements>
|
|
204
194
|
<zeebe:taskDefinition type="pr.persist-escalation" />
|
|
205
195
|
<zeebe:properties>
|
|
206
|
-
<zeebe:property name="io.nanobpm.dataEnvelope.in" value="
|
|
207
|
-
<zeebe:property name="io.nanobpm.dataEnvelope.out" value="
|
|
196
|
+
<zeebe:property name="io.nanobpm.dataEnvelope.in" value="EscalationIn" />
|
|
197
|
+
<zeebe:property name="io.nanobpm.dataEnvelope.out" value="EscalationOut" />
|
|
208
198
|
</zeebe:properties>
|
|
209
199
|
</bpmn:extensionElements>
|
|
210
200
|
<bpmn:incoming>f_m_gBlocked</bpmn:incoming>
|