@nanobpm/nano-workforce 0.29.0 → 0.31.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/blackboard.test.ts +17 -0
- package/app/blackboard.ts +8 -4
- package/app/plan.test.ts +2 -2
- package/app/plan.ts +4 -3
- package/app/retro.test.ts +466 -0
- package/app/retro.ts +365 -0
- package/db/migrations/016_plan_retro.sql +38 -0
- package/nano.app.json +8 -0
- package/package.json +1 -1
- package/pages/epic.page.json +22 -1
- package/pages/home.page.json +14 -0
- package/prompts/retro.md +86 -0
- package/resources/processes/plan-fanout.bpmn +1 -1
- package/resources/processes/retro.bpmn +83 -0
- package/scripts/pages-contract.test.ts +228 -0
- package/workers/finalize/worker.ts +9 -0
- package/workers/mark-merged/worker.ts +6 -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/workers/retro-gather/worker.test.ts +78 -0
- package/workers/retro-gather/worker.ts +28 -0
- package/workers/retro-record/worker.test.ts +127 -0
- package/workers/retro-record/worker.ts +60 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:zeebe="http://camunda.org/schema/zeebe/1.0" xmlns:nano="https://nanobpm.io/schema/shapes/1.0" id="Definitions_nano_workforce_retro" targetNamespace="http://nanobpm.io/nano-workforce">
|
|
3
|
+
<bpmn:process id="retro" name="Epic Retrospective" isExecutable="true">
|
|
4
|
+
<bpmn:startEvent id="Start" name="Epic complete">
|
|
5
|
+
<bpmn:outgoing>f_start</bpmn:outgoing>
|
|
6
|
+
</bpmn:startEvent>
|
|
7
|
+
<bpmn:serviceTask id="gather" name="Gather learnings">
|
|
8
|
+
<bpmn:extensionElements>
|
|
9
|
+
<zeebe:taskDefinition type="pr.retro-gather" />
|
|
10
|
+
</bpmn:extensionElements>
|
|
11
|
+
<bpmn:incoming>f_start</bpmn:incoming>
|
|
12
|
+
<bpmn:outgoing>f_toSynthesize</bpmn:outgoing>
|
|
13
|
+
</bpmn:serviceTask>
|
|
14
|
+
<bpmn:serviceTask id="synthesize" name="Synthesize & promote (agent)">
|
|
15
|
+
<bpmn:extensionElements>
|
|
16
|
+
<zeebe:taskDefinition type="senior:retro" />
|
|
17
|
+
<zeebe:taskHeaders>
|
|
18
|
+
<zeebe:header key="io.nanobpm.agentTask.task.prompt" value="{{retro}}" />
|
|
19
|
+
</zeebe:taskHeaders>
|
|
20
|
+
<zeebe:ioMapping>
|
|
21
|
+
<zeebe:input source="=retroDigest" target="appendPrompt" />
|
|
22
|
+
</zeebe:ioMapping>
|
|
23
|
+
</bpmn:extensionElements>
|
|
24
|
+
<bpmn:incoming>f_toSynthesize</bpmn:incoming>
|
|
25
|
+
<bpmn:outgoing>f_toRecord</bpmn:outgoing>
|
|
26
|
+
</bpmn:serviceTask>
|
|
27
|
+
<bpmn:serviceTask id="record" name="Record retro">
|
|
28
|
+
<bpmn:extensionElements>
|
|
29
|
+
<zeebe:taskDefinition type="pr.retro-record" />
|
|
30
|
+
</bpmn:extensionElements>
|
|
31
|
+
<bpmn:incoming>f_toRecord</bpmn:incoming>
|
|
32
|
+
<bpmn:outgoing>f_toEnd</bpmn:outgoing>
|
|
33
|
+
</bpmn:serviceTask>
|
|
34
|
+
<bpmn:endEvent id="End" name="Retro filed">
|
|
35
|
+
<bpmn:incoming>f_toEnd</bpmn:incoming>
|
|
36
|
+
</bpmn:endEvent>
|
|
37
|
+
<bpmn:sequenceFlow id="f_start" sourceRef="Start" targetRef="gather" />
|
|
38
|
+
<bpmn:sequenceFlow id="f_toSynthesize" sourceRef="gather" targetRef="synthesize" />
|
|
39
|
+
<bpmn:sequenceFlow id="f_toRecord" sourceRef="synthesize" targetRef="record" />
|
|
40
|
+
<bpmn:sequenceFlow id="f_toEnd" sourceRef="record" targetRef="End" />
|
|
41
|
+
</bpmn:process>
|
|
42
|
+
<bpmndi:BPMNDiagram id="BPMNDiagram_retro">
|
|
43
|
+
<bpmndi:BPMNPlane id="BPMNPlane_retro" bpmnElement="retro">
|
|
44
|
+
<bpmndi:BPMNShape id="BPMNShape_Start" bpmnElement="Start">
|
|
45
|
+
<dc:Bounds x="80" y="102" width="36" height="36" />
|
|
46
|
+
<bpmndi:BPMNLabel>
|
|
47
|
+
<dc:Bounds x="67" y="143" width="63" height="28" />
|
|
48
|
+
</bpmndi:BPMNLabel>
|
|
49
|
+
</bpmndi:BPMNShape>
|
|
50
|
+
<bpmndi:BPMNShape id="BPMNShape_gather" bpmnElement="gather">
|
|
51
|
+
<dc:Bounds x="216" y="80" width="100" height="80" />
|
|
52
|
+
</bpmndi:BPMNShape>
|
|
53
|
+
<bpmndi:BPMNShape id="BPMNShape_synthesize" bpmnElement="synthesize">
|
|
54
|
+
<dc:Bounds x="416" y="80" width="100" height="80" />
|
|
55
|
+
</bpmndi:BPMNShape>
|
|
56
|
+
<bpmndi:BPMNShape id="BPMNShape_record" bpmnElement="record">
|
|
57
|
+
<dc:Bounds x="616" y="80" width="100" height="80" />
|
|
58
|
+
</bpmndi:BPMNShape>
|
|
59
|
+
<bpmndi:BPMNShape id="BPMNShape_End" bpmnElement="End">
|
|
60
|
+
<dc:Bounds x="816" y="102" width="36" height="36" />
|
|
61
|
+
<bpmndi:BPMNLabel>
|
|
62
|
+
<dc:Bounds x="794" y="143" width="80" height="14" />
|
|
63
|
+
</bpmndi:BPMNLabel>
|
|
64
|
+
</bpmndi:BPMNShape>
|
|
65
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_start" bpmnElement="f_start">
|
|
66
|
+
<di:waypoint x="116" y="120" />
|
|
67
|
+
<di:waypoint x="216" y="120" />
|
|
68
|
+
</bpmndi:BPMNEdge>
|
|
69
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_toSynthesize" bpmnElement="f_toSynthesize">
|
|
70
|
+
<di:waypoint x="316" y="120" />
|
|
71
|
+
<di:waypoint x="416" y="120" />
|
|
72
|
+
</bpmndi:BPMNEdge>
|
|
73
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_toRecord" bpmnElement="f_toRecord">
|
|
74
|
+
<di:waypoint x="516" y="120" />
|
|
75
|
+
<di:waypoint x="616" y="120" />
|
|
76
|
+
</bpmndi:BPMNEdge>
|
|
77
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_toEnd" bpmnElement="f_toEnd">
|
|
78
|
+
<di:waypoint x="716" y="120" />
|
|
79
|
+
<di:waypoint x="816" y="120" />
|
|
80
|
+
</bpmndi:BPMNEdge>
|
|
81
|
+
</bpmndi:BPMNPlane>
|
|
82
|
+
</bpmndi:BPMNDiagram>
|
|
83
|
+
</bpmn:definitions>
|
|
@@ -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
|
+
});
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// is on, or (b) close the PR out as `converged` (review-only mode).
|
|
4
4
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
5
5
|
import { AUTO_MERGE, startMerge } from "../../app/service.ts";
|
|
6
|
+
import { maybeStartRetro } from "../../app/retro.ts";
|
|
6
7
|
|
|
7
8
|
// Extends Record so the declared fields are typed while the job may still carry
|
|
8
9
|
// other process variables (e.g. io.nanobpm.agentResult, read by transcriptOf).
|
|
@@ -83,6 +84,14 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
83
84
|
open_escalation_question: null,
|
|
84
85
|
});
|
|
85
86
|
|
|
87
|
+
// Only the review-only terminal path ends the PR here as `converged` — in auto-merge mode the
|
|
88
|
+
// terminal point is pr.mark-merged (which triggers the retro), and a PR parked in `waiting_deps`
|
|
89
|
+
// is still in flight. So fire the retro trigger only when this PR actually reached its terminal
|
|
90
|
+
// state in finalize. Best-effort: must never fail the finalize job.
|
|
91
|
+
if (status === "converged") {
|
|
92
|
+
await maybeStartRetro(app.data, app.engine, prKey, app.log);
|
|
93
|
+
}
|
|
94
|
+
|
|
86
95
|
return {};
|
|
87
96
|
};
|
|
88
97
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// pr.mark-merged — the PR has landed (directly or via the merge queue). Record the terminal
|
|
2
2
|
// `merged` state; the merge audit trail is written by pr.merge, so this only closes the row out.
|
|
3
3
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
4
|
+
import { maybeStartRetro } from "../../app/retro.ts";
|
|
4
5
|
|
|
5
6
|
interface In extends Record<string, unknown> {
|
|
6
7
|
prKey: string;
|
|
@@ -15,6 +16,11 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
15
16
|
open_escalation_id: null,
|
|
16
17
|
open_escalation_question: null,
|
|
17
18
|
});
|
|
19
|
+
|
|
20
|
+
// If this PR was the last of its epic to land, kick off the retrospective. Best-effort: a
|
|
21
|
+
// failure here (or no epic) must never fail marking the PR merged — the retro is advisory.
|
|
22
|
+
await maybeStartRetro(app.data, app.engine, job.variables.prKey, app.log);
|
|
23
|
+
|
|
18
24
|
return {};
|
|
19
25
|
};
|
|
20
26
|
|
|
@@ -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;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Red/green for the no-work terminal guard (issue #86).
|
|
2
|
+
//
|
|
3
|
+
// `record-results` is the epic's finalizer. Before this guard it always marked the plan `done` and
|
|
4
|
+
// completed the process GREEN — even when ZERO PRs were opened (empty plan, or every task
|
|
5
|
+
// blocked/skipped). A no-op run was indistinguishable from success (instance 21). It now raises a
|
|
6
|
+
// non-retryable `NO_WORK_DISPATCHED` BpmnError (→ incident) when the epic finalizes with no opened
|
|
7
|
+
// PR, recording a `failed` terminal status + outcome first, so "accomplished nothing" surfaces
|
|
8
|
+
// instead of masquerading as a completed epic.
|
|
9
|
+
import { assertEquals, assertRejects } from "jsr:@std/assert@1";
|
|
10
|
+
import { BpmnError } from "@nanobpm/urban";
|
|
11
|
+
import handler from "./worker.ts";
|
|
12
|
+
import type { PlanTaskStatus } from "../../app/plan.ts";
|
|
13
|
+
|
|
14
|
+
interface Row {
|
|
15
|
+
id: number;
|
|
16
|
+
plan_key: string;
|
|
17
|
+
task_id: string;
|
|
18
|
+
status: PlanTaskStatus;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function fakeApp(rows: Row[]) {
|
|
22
|
+
const plans: Record<string, unknown>[] = [];
|
|
23
|
+
return {
|
|
24
|
+
data: {
|
|
25
|
+
table(name: string, key: string) {
|
|
26
|
+
if (name === "plans") {
|
|
27
|
+
return {
|
|
28
|
+
// deno-lint-ignore no-explicit-any
|
|
29
|
+
update: (k: any, patch: any) => {
|
|
30
|
+
plans.push({ [key]: k, ...patch });
|
|
31
|
+
return Promise.resolve(patch);
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
// plan_tasks
|
|
36
|
+
return {
|
|
37
|
+
// deno-lint-ignore no-explicit-any
|
|
38
|
+
find: (q: any) =>
|
|
39
|
+
Promise.resolve(
|
|
40
|
+
rows.filter((r) =>
|
|
41
|
+
Object.entries(q).every(([f, v]) =>
|
|
42
|
+
(r as unknown as Record<string, unknown>)[f] === v
|
|
43
|
+
)
|
|
44
|
+
),
|
|
45
|
+
),
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
log: () => {},
|
|
50
|
+
_plans: plans,
|
|
51
|
+
// deno-lint-ignore no-explicit-any
|
|
52
|
+
} as any;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const call = async (app: unknown, planKey = "o/r#1") =>
|
|
56
|
+
// deno-lint-ignore no-explicit-any
|
|
57
|
+
await handler({ variables: { planKey } } as any, app as any);
|
|
58
|
+
|
|
59
|
+
Deno.test("no opened PRs (empty plan) hard-fails with NO_WORK_DISPATCHED", async () => {
|
|
60
|
+
const app = fakeApp([]);
|
|
61
|
+
const err = await assertRejects(() => call(app), BpmnError);
|
|
62
|
+
assertEquals((err as BpmnError).errorCode, "NO_WORK_DISPATCHED");
|
|
63
|
+
// The failure outcome + terminal `failed` status must be recorded before throwing, so the DB
|
|
64
|
+
// state matches the parked incident and startPlan can re-plan it.
|
|
65
|
+
const plan = app._plans.at(-1) as Record<string, unknown>;
|
|
66
|
+
assertEquals(plan.status, "failed");
|
|
67
|
+
assertEquals(plan.outcome, "no work dispatched — the planner produced no tasks");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
Deno.test("tasks present but none opened (all skipped/blocked) hard-fails", async () => {
|
|
71
|
+
const app = fakeApp([
|
|
72
|
+
{ id: 1, plan_key: "o/r#1", task_id: "a", status: "skipped" },
|
|
73
|
+
{ id: 2, plan_key: "o/r#1", task_id: "b", status: "blocked" },
|
|
74
|
+
]);
|
|
75
|
+
const err = await assertRejects(() => call(app), BpmnError);
|
|
76
|
+
assertEquals((err as BpmnError).errorCode, "NO_WORK_DISPATCHED");
|
|
77
|
+
const plan = app._plans.at(-1) as Record<string, unknown>;
|
|
78
|
+
assertEquals(plan.status, "failed");
|
|
79
|
+
assertEquals(plan.outcome, "no work dispatched — every task was blocked or skipped");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
Deno.test("at least one opened PR finalizes cleanly (no throw)", async () => {
|
|
83
|
+
const app = fakeApp([
|
|
84
|
+
{ id: 1, plan_key: "o/r#1", task_id: "a", status: "opened" },
|
|
85
|
+
{ id: 2, plan_key: "o/r#1", task_id: "b", status: "skipped" },
|
|
86
|
+
]);
|
|
87
|
+
await call(app);
|
|
88
|
+
const plan = app._plans.at(-1) as Record<string, unknown>;
|
|
89
|
+
assertEquals(plan.status, "done");
|
|
90
|
+
assertEquals(plan.outcome, "1 PR(s) dispatched to convergence");
|
|
91
|
+
});
|