@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/CHANGELOG.md +21 -0
- package/SPEC.md +4 -1
- package/app/plan.test.ts +2 -2
- package/app/plan.ts +4 -3
- package/app/retro.test.ts +115 -2
- package/app/retro.ts +98 -14
- package/app/service.test.ts +153 -1
- package/app/service.ts +119 -4
- package/db/migrations/017_pr_incident.sql +14 -0
- package/package.json +1 -1
- package/pages/epic.page.json +22 -1
- package/pages/home.page.json +17 -0
- package/resources/processes/plan-fanout.bpmn +1 -1
- package/scripts/pages-contract.test.ts +228 -0
- package/workers/record-plan-review/worker.test.ts +82 -0
- package/workers/record-plan-review/worker.ts +31 -15
- package/workers/record-results/worker.test.ts +91 -0
- package/workers/record-results/worker.ts +37 -7
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,
|
|
883
|
-
* endpoint is supplied) the job-activation visibility pass
|
|
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)
|
|
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.
|
|
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",
|
package/pages/epic.page.json
CHANGED
|
@@ -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",
|
package/pages/home.page.json
CHANGED
|
@@ -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
|
|
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
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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;
|