@nanobpm/nano-workforce 0.30.0 → 0.32.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/app/service.ts CHANGED
@@ -130,6 +130,13 @@ interface PullRequest {
130
130
  // running agent curls (GET /hooks/abandon?token=…) to learn whether this run was cancelled before
131
131
  // it performs a side effect. Minted at submit, reused across the convergence + merge instances.
132
132
  abandon_token: string | null;
133
+ // Technical-incident surfacing (017_pr_incident.sql, issue #94), written by the poller's
134
+ // `pollIncidents` pass. `incident_key` is the engine incidentKey of the ACTIVE incident parking
135
+ // this PR's instance and `incident_message` its errorMessage; both NULL when the instance has no
136
+ // active incident. Orthogonal to `status` — an incident is a cross-cutting liveness fault, not a
137
+ // workflow stage.
138
+ incident_key: string | null;
139
+ incident_message: string | null;
133
140
  }
134
141
 
135
142
  interface PrDependency {
@@ -836,6 +843,111 @@ async function pollJobActivation(
836
843
  }
837
844
  }
838
845
 
846
+ /** The subset of a Camunda-8 `/v2/incidents/search` result item this app reads. `incidentKey` is
847
+ * the unique incident id; `errorMessage` is the human-readable fault; `state` is the incident
848
+ * lifecycle (`ACTIVE` while it parks the token, `RESOLVED` once cleared); `creationTime` orders
849
+ * concurrent incidents. */
850
+ interface IncidentSearchItem {
851
+ incidentKey?: string;
852
+ errorMessage?: string | null;
853
+ state?: string;
854
+ creationTime?: string | null;
855
+ }
856
+
857
+ /** Incident-surfacing poll pass (issue #94). A convergence or merge process instance can hit a
858
+ * *technical* incident — an unhandled engine error that parks the token — and nothing on the PR
859
+ * row reflected it: the grid kept showing the last workflow status (`converging`, `merging`, …)
860
+ * while the run was actually dead in the water (a PR sat "converging" all day on an incident).
861
+ *
862
+ * This pass reads the engine's Camunda-8 `/v2/incidents/search` for each PR that still has a live
863
+ * instance (has a `process_key`, non-terminal status) and mirrors an ACTIVE incident onto two
864
+ * orthogonal columns — `incident_key` + `incident_message` — leaving `status` untouched. An
865
+ * incident is a cross-cutting liveness fault, not a workflow stage, so it must not overload the
866
+ * status machine. Clearing is idempotent: when the instance has no active incident (resolved, or
867
+ * never had one) the columns are nulled, so an incident raised or resolved out-of-band converges
868
+ * to the truth on the next pass. Best-effort transport: a failed query leaves the last-known
869
+ * values untouched and the next pass retries. Updates (and bumps `updated_at`) only on an actual
870
+ * change so a steady state doesn't churn the grid. */
871
+ async function pollIncidents(
872
+ data: DataLayer,
873
+ restAddress: string,
874
+ engineToken: string | undefined,
875
+ ) {
876
+ const base = restAddress.replace(/\/+$/, "");
877
+ const headers: Record<string, string> = { "content-type": "application/json" };
878
+ if (engineToken) headers.authorization = `Bearer ${engineToken}`;
879
+ await pollIncidentsImpl(data, base, headers);
880
+ }
881
+
882
+ /** Testable core of {@link pollIncidents}: given the normalised `base` URL and prepared auth
883
+ * `headers`, reconcile every PR row against the engine's active incidents. Split out so tests can
884
+ * exercise the reconciliation with a stubbed `fetch` without re-deriving transport wiring. */
885
+ export async function pollIncidentsImpl(
886
+ data: DataLayer,
887
+ base: string,
888
+ headers: Record<string, string>,
889
+ ) {
890
+ const all = await prs(data).all();
891
+ for (const pr of all) {
892
+ // No live instance to inspect (never created, mid-transition, or terminal — the run has
893
+ // finished or was given up, so its instance is gone) → make sure no stale incident lingers on
894
+ // the row, then move on. Reuses the canonical `TERMINAL_STATUSES` so incident logic can't drift
895
+ // from the rest of the status machine.
896
+ if (!pr.process_key || TERMINAL_STATUSES.includes(pr.status)) {
897
+ if (pr.incident_key || pr.incident_message) {
898
+ await prs(data).update(pr.pr_key, {
899
+ incident_key: null,
900
+ incident_message: null,
901
+ updated_at: now(),
902
+ });
903
+ }
904
+ continue;
905
+ }
906
+
907
+ let incidentKey: string | null = null;
908
+ let incidentMessage: string | null = null;
909
+ try {
910
+ const res = await fetch(`${base}/incidents/search`, {
911
+ method: "POST",
912
+ headers,
913
+ body: JSON.stringify({
914
+ filter: { processInstanceKey: pr.process_key, state: "ACTIVE" },
915
+ page: { limit: 20 },
916
+ }),
917
+ });
918
+ if (!res.ok) continue; // engine unhappy → keep last-known, retry next pass
919
+ const body = (await res.json()) as { items?: IncidentSearchItem[] };
920
+ // Surface the oldest ACTIVE incident (the first thing that broke — a stable choice if the
921
+ // instance somehow parks more than one). Re-filter on state defensively in case the wire
922
+ // filter is ignored. An incident with no `creationTime` sorts *last*, so a missing timestamp
923
+ // can never masquerade as the oldest.
924
+ const active = (body.items ?? [])
925
+ .filter((i) => (i.state ?? "ACTIVE") === "ACTIVE")
926
+ .sort((a, b) =>
927
+ (a.creationTime ?? "\uffff").localeCompare(b.creationTime ?? "\uffff")
928
+ )[0];
929
+ if (active) {
930
+ incidentKey = active.incidentKey ?? null;
931
+ incidentMessage = active.errorMessage ?? null;
932
+ }
933
+ } catch (err) {
934
+ console.error(`[poller] incidents ${pr.pr_key}: ${err}`);
935
+ continue;
936
+ }
937
+
938
+ if (
939
+ incidentKey !== (pr.incident_key ?? null) ||
940
+ incidentMessage !== (pr.incident_message ?? null)
941
+ ) {
942
+ await prs(data).update(pr.pr_key, {
943
+ incident_key: incidentKey,
944
+ incident_message: incidentMessage,
945
+ updated_at: now(),
946
+ });
947
+ }
948
+ }
949
+ }
950
+
839
951
  /** Wave-merge barrier poll pass. After `record-wave` hands off a wave that has a successor, the
840
952
  * plan-fanout instance parks at the `wait-wave-merged` catch event and `plans.gate_wave` records
841
953
  * that wave's index. Here we check whether every OPENED PR in that wave has MERGED and, if so,
@@ -879,9 +991,9 @@ async function pollWaveGates(data: DataLayer, engine: EngineClient, token: strin
879
991
  }
880
992
  }
881
993
 
882
- /** One full poll pass: advance the review stage, the merge stage, and (when the engine REST
883
- * endpoint is supplied) the job-activation visibility pass. Called on the self-scheduling loop
884
- * in `main.ts`. */
994
+ /** One full poll pass: advance the review stage, the merge stage, the wave-merge barrier, and
995
+ * (when the engine REST endpoint is supplied) the job-activation visibility pass and the
996
+ * technical-incident surfacing pass. Called on the self-scheduling loop in `main.ts`. */
885
997
  export async function pollOnce(
886
998
  data: DataLayer,
887
999
  engine: EngineClient,
@@ -891,5 +1003,8 @@ export async function pollOnce(
891
1003
  await pollReviews(data, engine, token);
892
1004
  await pollMerges(data, engine, token);
893
1005
  await pollWaveGates(data, engine, token);
894
- if (engineRest) await pollJobActivation(data, engineRest.restAddress, engineRest.token);
1006
+ if (engineRest) {
1007
+ await pollJobActivation(data, engineRest.restAddress, engineRest.token);
1008
+ await pollIncidents(data, engineRest.restAddress, engineRest.token);
1009
+ }
895
1010
  }
@@ -0,0 +1,14 @@
1
+ -- Technical-incident surfacing (issue #94). A convergence or merge process instance can hit a
2
+ -- *technical* incident — an unhandled engine error (an expression failure, a job that exhausted
3
+ -- its retries, …) that parks the token — and until now nothing on the PR row reflected it: the
4
+ -- grid kept showing the last workflow status (`converging`, `merging`, …) while the run was
5
+ -- actually stuck. A PR sat "converging" all day while its instance was dead on an incident.
6
+ --
7
+ -- These two orthogonal columns mirror an ACTIVE engine incident onto the PR row, written by the
8
+ -- poller's `pollIncidents` pass from a `/v2/incidents/search` filtered by the PR's `process_key`.
9
+ -- They are deliberately independent of `status`: an incident is a *cross-cutting* liveness fault,
10
+ -- not a workflow stage, so surfacing it must not overload the status machine. NULL means the
11
+ -- instance has no active incident (never had one, or it was resolved) — the poller clears the
12
+ -- columns idempotently, so an incident raised or resolved out-of-band converges on the next pass.
13
+ ALTER TABLE pull_requests ADD COLUMN incident_key TEXT; -- engine incidentKey of the active incident parking this PR's instance; NULL when none
14
+ ALTER TABLE pull_requests ADD COLUMN incident_message TEXT; -- the incident's errorMessage, surfaced on the grid; NULL when none
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.30.0",
3
+ "version": "0.32.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",
@@ -23,7 +23,7 @@
23
23
  "type": "text",
24
24
  "id": "subtitle",
25
25
  "props": {
26
- "text": "Read-only observability over each plan's wave state, merge-exclusion graph, coordination notes, and trial-merge gate results.",
26
+ "text": "Read-only observability over each plan's review trace, wave state, merge-exclusion graph, coordination notes, and trial-merge gate results.",
27
27
  "variant": "sub"
28
28
  }
29
29
  },
@@ -70,6 +70,27 @@
70
70
  }
71
71
  }
72
72
  },
73
+ {
74
+ "type": "dataGrid",
75
+ "id": "plan-reviews",
76
+ "props": {
77
+ "title": "Plan review trace",
78
+ "refreshMs": 5000,
79
+ "data": {
80
+ "kind": "datasource",
81
+ "source": "app",
82
+ "table": "plan_reviews",
83
+ "orderBy": { "field": "round", "dir": "asc" }
84
+ },
85
+ "columns": [
86
+ { "field": "plan_key", "header": "Plan" },
87
+ { "field": "round", "header": "Round" },
88
+ { "field": "approved", "header": "Approved? (1/0)" },
89
+ { "field": "findings", "header": "Reviewer findings" },
90
+ { "field": "created_at", "header": "Recorded" }
91
+ ]
92
+ }
93
+ },
73
94
  {
74
95
  "type": "dataGrid",
75
96
  "id": "wave-state",
@@ -84,6 +84,7 @@
84
84
  "columns": [
85
85
  { "field": "pr_key", "header": "PR", "linkField": "url" },
86
86
  { "field": "status", "header": "Status" },
87
+ { "field": "incident_message", "header": "Incident" },
87
88
  { "field": "current_round", "header": "Round" },
88
89
  { "field": "active_worker", "header": "Agent" },
89
90
  { "field": "updated_at", "header": "Updated" }
@@ -103,6 +104,8 @@
103
104
  { "field": "number", "label": "PR number" },
104
105
  { "field": "active_worker", "label": "Agent (leasing worker)" },
105
106
  { "field": "lease_until", "label": "Activation lease until" },
107
+ { "field": "incident_message", "label": "Incident" },
108
+ { "field": "incident_key", "label": "Incident key" },
106
109
  { "field": "merged_at", "label": "Merged at" },
107
110
  { "field": "outcome", "label": "Outcome" }
108
111
  ],
@@ -228,6 +231,20 @@
228
231
  { "field": "summary", "header": "Summary" }
229
232
  ]
230
233
  },
234
+ {
235
+ "title": "Plan reviews",
236
+ "source": "app",
237
+ "table": "plan_reviews",
238
+ "parentField": "plan_key",
239
+ "childField": "plan_key",
240
+ "orderBy": { "field": "round", "dir": "asc" },
241
+ "columns": [
242
+ { "field": "round", "header": "Round" },
243
+ { "field": "approved", "header": "Approved? (1/0)" },
244
+ { "field": "findings", "header": "Reviewer findings" },
245
+ { "field": "created_at", "header": "Recorded" }
246
+ ]
247
+ },
231
248
  {
232
249
  "title": "Escalations",
233
250
  "source": "app",
@@ -216,7 +216,7 @@
216
216
  <bpmn:sequenceFlow id="f_toRecordPlanReview" sourceRef="review-plan" targetRef="record-plan-review" />
217
217
  <bpmn:sequenceFlow id="f_toGwPlanReview" sourceRef="record-plan-review" targetRef="gw-plan-review" />
218
218
  <bpmn:sequenceFlow id="f_plan_proceed" name="approved" sourceRef="gw-plan-review" targetRef="select-wave">
219
- <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=planApproved or reviewExhausted</bpmn:conditionExpression>
219
+ <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=planApproved</bpmn:conditionExpression>
220
220
  </bpmn:sequenceFlow>
221
221
  <bpmn:sequenceFlow id="f_plan_revise" name="revise" sourceRef="gw-plan-review" targetRef="plan" />
222
222
  <bpmn:sequenceFlow id="f_toImplement" sourceRef="select-wave" targetRef="implement" />
@@ -0,0 +1,228 @@
1
+ // Static contract guard between the declarative pages (`pages/*.page.json`) and the app schema
2
+ // (`db/migrations/*.sql`).
3
+ //
4
+ // The Urban page runtime whitelists every datasource `table` and `column` against the LIVE schema
5
+ // (`PRAGMA table_info`): a grid that binds to a table or column the migrations never created 400s
6
+ // at request time — an invisible, runtime-only failure with no compile or `urban check` signal.
7
+ // This test closes that drift surface: every table and column referenced by any page must be
8
+ // derivable from the migrations, so a rename/typo/removed column fails CI instead of a live page.
9
+ //
10
+ // It also pins the issue #87 surfaces: the plan-review audit log (`plan_reviews`) — which is
11
+ // persisted but was surfaced on no page — must appear on the epic page (flat grid) and inside the
12
+ // home page's plan detail (child grid). Feature coverage so the trace can't silently regress out.
13
+ import { assert } from "jsr:@std/assert@1";
14
+
15
+ // Percent-decode the pathname: `new URL(..).pathname` can contain encoded characters (e.g. a space
16
+ // as `%20`), which `Deno.readDir`/`readTextFile` would fail to resolve. Matches the repo convention
17
+ // (see scripts/check-agent-prompts.test.ts).
18
+ const ROOT = decodeURIComponent(new URL("../", import.meta.url).pathname);
19
+
20
+ // ---- migrations -> { table -> Set<column> } -----------------------------------------------------
21
+
22
+ function parseSchema(sql: string, schema: Map<string, Set<string>>): void {
23
+ // Strip SQL comments first: an inline `-- ...` trailing one column line would otherwise become
24
+ // the leading token of the NEXT comma-split fragment, hiding the real column name.
25
+ sql = sql.replace(/--[^\n]*/g, "").replace(/\/\*[\s\S]*?\*\//g, "");
26
+ // CREATE TABLE [IF NOT EXISTS] <name> ( <body> )
27
+ const createRe = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(\w+)["`]?\s*\(/gi;
28
+ let m: RegExpExecArray | null;
29
+ while ((m = createRe.exec(sql)) !== null) {
30
+ const table = m[1];
31
+ const body = balancedBody(sql, createRe.lastIndex - 1); // start at the "("
32
+ if (body === null) continue;
33
+ const cols = schema.get(table) ?? new Set<string>();
34
+ for (const frag of splitTopLevel(body)) {
35
+ const first = frag.trim().split(/[\s(]/)[0];
36
+ if (!first) continue;
37
+ const upper = first.toUpperCase();
38
+ if (["PRIMARY", "FOREIGN", "UNIQUE", "CHECK", "CONSTRAINT"].includes(upper)) continue;
39
+ cols.add(first.replace(/["`]/g, ""));
40
+ }
41
+ schema.set(table, cols);
42
+ }
43
+ // ALTER TABLE <name> ADD [COLUMN] <col>
44
+ const alterRe = /ALTER\s+TABLE\s+["`]?(\w+)["`]?\s+ADD\s+(?:COLUMN\s+)?["`]?(\w+)["`]?/gi;
45
+ while ((m = alterRe.exec(sql)) !== null) {
46
+ const cols = schema.get(m[1]) ?? new Set<string>();
47
+ cols.add(m[2]);
48
+ schema.set(m[1], cols);
49
+ }
50
+ }
51
+
52
+ // Return the text inside the parentheses whose opener is at `openIdx`, honouring nesting.
53
+ function balancedBody(s: string, openIdx: number): string | null {
54
+ let depth = 0;
55
+ for (let i = openIdx; i < s.length; i++) {
56
+ if (s[i] === "(") depth++;
57
+ else if (s[i] === ")") {
58
+ depth--;
59
+ if (depth === 0) return s.slice(openIdx + 1, i);
60
+ }
61
+ }
62
+ return null;
63
+ }
64
+
65
+ // Split a CREATE TABLE body on top-level commas (commas inside nested parens stay attached).
66
+ function splitTopLevel(body: string): string[] {
67
+ const out: string[] = [];
68
+ let depth = 0, start = 0;
69
+ for (let i = 0; i < body.length; i++) {
70
+ const c = body[i];
71
+ if (c === "(") depth++;
72
+ else if (c === ")") depth--;
73
+ else if (c === "," && depth === 0) {
74
+ out.push(body.slice(start, i));
75
+ start = i + 1;
76
+ }
77
+ }
78
+ out.push(body.slice(start));
79
+ return out;
80
+ }
81
+
82
+ async function loadSchema(): Promise<Map<string, Set<string>>> {
83
+ const schema = new Map<string, Set<string>>();
84
+ const files: string[] = [];
85
+ for await (const e of Deno.readDir(`${ROOT}db/migrations`)) {
86
+ if (e.isFile && e.name.endsWith(".sql")) files.push(e.name);
87
+ }
88
+ files.sort(); // migration order doesn't matter for the union, but keep it deterministic
89
+ for (const f of files) {
90
+ parseSchema(await Deno.readTextFile(`${ROOT}db/migrations/${f}`), schema);
91
+ }
92
+ return schema;
93
+ }
94
+
95
+ // ---- pages -> datasource references -------------------------------------------------------------
96
+
97
+ // deno-lint-ignore no-explicit-any
98
+ type Json = any;
99
+
100
+ interface Ref {
101
+ page: string;
102
+ table: string;
103
+ source: string;
104
+ fields: string[]; // every column that must exist on `table` (displayed columns + binding fields)
105
+ columns: string[]; // only the visibly displayed grid columns (`columns[].field`)
106
+ }
107
+
108
+ // Pull `field` names out of a `filter` array ([{ field, in/eq/... }, ...]).
109
+ function filterFields(filter: Json): string[] {
110
+ if (!Array.isArray(filter)) return [];
111
+ return filter.map((f: Json) => f?.field).filter(Boolean);
112
+ }
113
+
114
+ function collectRefs(page: string, node: Json, out: Ref[]): void {
115
+ if (Array.isArray(node)) {
116
+ for (const v of node) collectRefs(page, v, out);
117
+ return;
118
+ }
119
+ if (!node || typeof node !== "object") return;
120
+
121
+ // Top-level datasource grid: the datasource lives at `node.data`, while `columns`, `rowKey`,
122
+ // `filter`/`tabs`, and `detail` are siblings on the same `node` (the grid props).
123
+ const data = node.data;
124
+ if (data && data.kind === "datasource" && typeof data.table === "string") {
125
+ const columns: string[] = (node.columns ?? []).map((c: Json) => c.field).filter(Boolean);
126
+ // Every reference that resolves to a column on this table — the runtime 400s on any of them if
127
+ // it names a column the migrations never created, so all must be guarded, not just displayed
128
+ // columns. `detail.fields`/`detail.linkField` render columns of the same top-level row.
129
+ const detail = node.detail ?? {};
130
+ const fields: string[] = [
131
+ ...columns,
132
+ ...(node.columns ?? []).map((c: Json) => c.linkField),
133
+ node.rowKey,
134
+ data.orderBy?.field,
135
+ ...filterFields(data.filter),
136
+ ...(node.tabs ?? []).flatMap((t: Json) => filterFields(t.filter)),
137
+ detail.linkField,
138
+ ...(detail.fields ?? []).flatMap((f: Json) => [f.field, f.linkField]),
139
+ // `detail.children[].parentField` joins each child grid back to a column on THIS (parent)
140
+ // table, so a rename/typo there 400s at request time — guard it against the parent schema.
141
+ ...(detail.children ?? []).map((c: Json) => c.parentField),
142
+ ].filter(Boolean);
143
+ out.push({ page, table: data.table, source: data.source ?? "app", fields, columns });
144
+ }
145
+
146
+ // Child grid inside a detail: { table, childField, parentField, orderBy, columns }
147
+ if (typeof node.table === "string" && typeof node.childField === "string") {
148
+ const columns: string[] = (node.columns ?? []).map((c: Json) => c.field).filter(Boolean);
149
+ out.push({
150
+ page,
151
+ table: node.table,
152
+ source: node.source ?? "app",
153
+ fields: [
154
+ ...columns,
155
+ ...(node.columns ?? []).map((c: Json) => c.linkField),
156
+ node.childField,
157
+ node.orderBy?.field,
158
+ node.lazyField?.field,
159
+ ].filter(Boolean),
160
+ columns,
161
+ });
162
+ }
163
+
164
+ for (const v of Object.values(node)) collectRefs(page, v, out);
165
+ }
166
+
167
+ async function loadRefs(): Promise<Ref[]> {
168
+ const refs: Ref[] = [];
169
+ for await (const e of Deno.readDir(`${ROOT}pages`)) {
170
+ if (!e.isFile || !e.name.endsWith(".page.json")) continue;
171
+ const page = JSON.parse(await Deno.readTextFile(`${ROOT}pages/${e.name}`));
172
+ collectRefs(e.name, page, refs);
173
+ }
174
+ return refs;
175
+ }
176
+
177
+ // ---- guards -------------------------------------------------------------------------------------
178
+
179
+ Deno.test("every page datasource table exists in the migrations", async () => {
180
+ const schema = await loadSchema();
181
+ const refs = await loadRefs();
182
+ assert(refs.length > 0, "no datasource references found — collector or pages are broken");
183
+ for (const r of refs) {
184
+ // Only the default app SQLite source is schema-backed; other sources aren't migration-defined.
185
+ if (r.source !== "app") continue;
186
+ assert(
187
+ schema.has(r.table),
188
+ `${r.page}: datasource table "${r.table}" has no CREATE TABLE in db/migrations/*.sql`,
189
+ );
190
+ }
191
+ });
192
+
193
+ Deno.test("every page datasource column exists on its table", async () => {
194
+ const schema = await loadSchema();
195
+ const refs = await loadRefs();
196
+ for (const r of refs) {
197
+ if (r.source !== "app") continue;
198
+ const cols = schema.get(r.table);
199
+ if (!cols) continue; // table-existence is asserted by the sibling test
200
+ for (const f of r.fields) {
201
+ assert(
202
+ cols.has(f),
203
+ `${r.page}: column "${f}" referenced on table "${r.table}" is not defined by any migration`,
204
+ );
205
+ }
206
+ }
207
+ });
208
+
209
+ Deno.test("issue #87: plan_reviews is surfaced on the epic and home pages", async () => {
210
+ const refs = await loadRefs();
211
+ const onEpic = refs.some((r) => r.page === "epic.page.json" && r.table === "plan_reviews");
212
+ const onHome = refs.some((r) => r.page === "home.page.json" && r.table === "plan_reviews");
213
+ assert(onEpic, "epic.page.json must bind a grid to plan_reviews (plan-review trace)");
214
+ assert(onHome, "home.page.json plan detail must include a plan_reviews child grid");
215
+
216
+ // The trace is only useful with the verdict + critique columns, so pin them. Assert against the
217
+ // visibly displayed `columns` (not `fields`, which also holds binding refs like orderBy.field) so
218
+ // a column silently dropped from the grid UI can't pass by being referenced elsewhere.
219
+ const required = ["round", "approved", "findings"];
220
+ for (const r of refs.filter((x) => x.table === "plan_reviews")) {
221
+ for (const col of required) {
222
+ assert(
223
+ r.columns.includes(col),
224
+ `${r.page}: plan_reviews grid must expose the "${col}" column`,
225
+ );
226
+ }
227
+ }
228
+ });
@@ -0,0 +1,82 @@
1
+ // Red/green for the plan-review gate (issue #86).
2
+ //
3
+ // Previously the fan-out PROCEEDED when the review-round cap was reached without approval
4
+ // ("proceed regardless rather than dead-lock"). That dispatched an un-vetted plan and — when the
5
+ // plan was empty (e.g. the planner agent couldn't persist its result) — completed the whole epic
6
+ // GREEN having done nothing (instance 21). We now HARD-FAIL: the terminal, unapproved round raises
7
+ // a non-retryable `PLAN_REJECTED` BpmnError (→ incident), so an un-approved plan never dispatches.
8
+ import { assertEquals, assertRejects } from "jsr:@std/assert@1";
9
+ import { BpmnError } from "@nanobpm/urban";
10
+ import handler from "./worker.ts";
11
+ import { MAX_PLAN_REVIEW_ROUNDS, type PlanReview } from "../../app/plan.ts";
12
+
13
+ function fakeApp(existing: PlanReview[] = []) {
14
+ const rows: PlanReview[] = [...existing];
15
+ const match = (r: PlanReview, q: Record<string, unknown>) =>
16
+ Object.entries(q).every(([f, v]) => (r as unknown as Record<string, unknown>)[f] === v);
17
+ return {
18
+ data: {
19
+ table() {
20
+ return {
21
+ // deno-lint-ignore no-explicit-any
22
+ findOne: (q: any) => Promise.resolve(rows.find((r) => match(r, q)) ?? null),
23
+ // deno-lint-ignore no-explicit-any
24
+ count: (q: any) => Promise.resolve(rows.filter((r) => match(r, q)).length),
25
+ insert: (row: PlanReview) => {
26
+ rows.push(row);
27
+ return Promise.resolve(row);
28
+ },
29
+ };
30
+ },
31
+ },
32
+ log: () => {},
33
+ _rows: rows,
34
+ // deno-lint-ignore no-explicit-any
35
+ } as any;
36
+ }
37
+
38
+ // Seed `n` prior recorded rounds for a plan so the next job lands on round `n` (0-based).
39
+ function priorRounds(planKey: string, n: number): PlanReview[] {
40
+ return Array.from({ length: n }, (_, i) => ({
41
+ plan_key: planKey,
42
+ round: i,
43
+ approved: 0,
44
+ findings: null,
45
+ created_at: "2026-01-01T00:00:00.000Z",
46
+ job_key: `prior-${i}`,
47
+ }));
48
+ }
49
+
50
+ const call = async (app: unknown, vars: Record<string, unknown>, jobKey = "j-new") =>
51
+ // deno-lint-ignore no-explicit-any
52
+ await handler({ variables: vars, jobKey } as any, app as any);
53
+
54
+ Deno.test("approved round proceeds (planApproved=true, no throw)", async () => {
55
+ const app = fakeApp(priorRounds("o/r#1", 0));
56
+ const out = await call(app, { planKey: "o/r#1", approved: true });
57
+ assertEquals((out as { planApproved: boolean }).planApproved, true);
58
+ });
59
+
60
+ Deno.test("unapproved, non-final round revises (planApproved=false, no throw)", async () => {
61
+ // First round of a 3-round cap: not final, so revise.
62
+ const app = fakeApp(priorRounds("o/r#2", 0));
63
+ const out = await call(app, { planKey: "o/r#2", approved: false, findings: "fix X" });
64
+ assertEquals((out as { planApproved: boolean; planFindings: string }).planApproved, false);
65
+ assertEquals((out as { planFindings: string }).planFindings, "fix X");
66
+ });
67
+
68
+ Deno.test("unapproved FINAL round hard-fails with PLAN_REJECTED incident", async () => {
69
+ // Seed cap-1 prior rounds so this job is the last permitted round; unapproved ⇒ must throw.
70
+ const app = fakeApp(priorRounds("o/r#3", MAX_PLAN_REVIEW_ROUNDS - 1));
71
+ const err = await assertRejects(
72
+ () => call(app, { planKey: "o/r#3", approved: false, findings: "still wrong" }),
73
+ BpmnError,
74
+ );
75
+ assertEquals((err as BpmnError).errorCode, "PLAN_REJECTED");
76
+ });
77
+
78
+ Deno.test("approved on the FINAL round still proceeds (no throw)", async () => {
79
+ const app = fakeApp(priorRounds("o/r#4", MAX_PLAN_REVIEW_ROUNDS - 1));
80
+ const out = await call(app, { planKey: "o/r#4", approved: true });
81
+ assertEquals((out as { planApproved: boolean }).planApproved, true);
82
+ });
@@ -6,13 +6,17 @@
6
6
  // • derives the current round from the append-only `plan_reviews` log (no counter variable),
7
7
  // using the engine jobKey as an idempotency guard so a retried job reuses its row,
8
8
  // • records this round's verdict + findings,
9
- // • decides the loop: `planApproved` (reviewer said yes) and `reviewExhausted` (the round cap
10
- // is reached, so we proceed regardless rather than dead-lock on a reviewer that never
11
- // approves), and re-emits the findings as `planFindings` so a revise round feeds the planner.
9
+ // • decides the loop: emits `planApproved` (reviewer said yes the BPMN gateway proceeds to
10
+ // `select-wave`) or, when unapproved, re-emits the findings as `planFindings` so a revise
11
+ // round feeds the planner and loops back to `plan`.
12
12
  //
13
- // The BPMN gateway proceeds to `select-wave` when `planApproved or reviewExhausted`, else loops
14
- // back to `plan`. A missing/ambiguous `approved` is treated as NOT approved (revise) — but the
15
- // round cap still bounds the loop, so the plan can never wedge.
13
+ // When the review-round cap is reached WITHOUT approval, this worker HARD-FAILS: it throws a
14
+ // non-retryable `PLAN_REJECTED` BpmnError (→ incident) rather than proceeding regardless
15
+ // (issue #86). Proceeding used to dispatch an un-vetted plan and — when the plan was empty — let
16
+ // the whole epic complete GREEN having done nothing. The cap still bounds the loop; it now bounds
17
+ // it into an incident, not a silent proceed. A missing/ambiguous `approved` is treated as NOT
18
+ // approved (revise until the cap).
19
+ import { BpmnError } from "@nanobpm/urban";
16
20
  import type { AppJobHandler } from "@nanobpm/urban";
17
21
  import { MAX_PLAN_REVIEW_ROUNDS, type PlanReview, planReviews } from "../../app/plan.ts";
18
22
 
@@ -23,7 +27,6 @@ interface In extends Record<string, unknown> {
23
27
  }
24
28
  interface Out extends Record<string, unknown> {
25
29
  planApproved: boolean;
26
- reviewExhausted: boolean;
27
30
  planFindings: string;
28
31
  }
29
32
 
@@ -56,7 +59,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
56
59
  // Idempotency guard: deriving the round from count(plan_reviews) is not retry-safe on its own.
57
60
  // A job retried after the insert (crash/timeout post-write) re-runs with the SAME jobKey — if
58
61
  // this job already recorded a row, reuse it rather than appending a duplicate, which would
59
- // inflate the count and trip `reviewExhausted` early. Otherwise this is the first attempt:
62
+ // inflate the count and reach the review-round cap early. Otherwise this is the first attempt:
60
63
  // derive the 0-based next round from the append-only log and record it under this jobKey.
61
64
  const recorded: PlanReview = (await reviews.findOne({ plan_key: planKey, job_key: jobKey })) ??
62
65
  await (async () => {
@@ -77,16 +80,29 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
77
80
  const roundApproved = recorded.approved === 1;
78
81
  const roundFindings = recorded.findings ?? "";
79
82
 
80
- // Exhausted once this round is the last permitted one (round is 0-based).
81
- const reviewExhausted = round + 1 >= MAX_PLAN_REVIEW_ROUNDS;
82
- if (!roundApproved) {
83
- app.log(reviewExhausted ? "warn" : "info", `record-plan-review: ${planKey} round ${round}`, {
84
- approved: roundApproved,
85
- reviewExhausted,
83
+ if (roundApproved) {
84
+ return { planApproved: true, planFindings: roundFindings };
85
+ }
86
+
87
+ // Not approved this round. Hard-fail once the round cap is reached (issue #86): previously the
88
+ // fan-out PROCEEDED regardless ("don't dead-lock on a reviewer that never approves"), which
89
+ // dispatched an un-vetted plan and — when the plan was empty — completed the epic GREEN having
90
+ // done nothing (instance 21). Instead raise a non-retryable BpmnError: no boundary catches
91
+ // `PLAN_REJECTED`, so the engine parks the instance on an incident rather than dispatching an
92
+ // un-approved plan. The round is 0-based, so `round + 1 >= cap` is the last permitted round.
93
+ if (round + 1 >= MAX_PLAN_REVIEW_ROUNDS) {
94
+ app.log("error", `record-plan-review: ${planKey} not approved after ${MAX_PLAN_REVIEW_ROUNDS} round(s)`, {
95
+ round,
86
96
  });
97
+ throw new BpmnError(
98
+ "PLAN_REJECTED",
99
+ `${planKey}: plan not approved after ${MAX_PLAN_REVIEW_ROUNDS} review round(s)`,
100
+ );
87
101
  }
88
102
 
89
- return { planApproved: roundApproved, reviewExhausted, planFindings: roundFindings };
103
+ // Otherwise loop: the planner revises against this round's findings.
104
+ app.log("info", `record-plan-review: ${planKey} round ${round} — revise`, { approved: false });
105
+ return { planApproved: false, planFindings: roundFindings };
90
106
  };
91
107
 
92
108
  export default handler;