@nanobpm/nano-workforce 0.36.0 → 0.37.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/.github/workflows/ci.yml +7 -17
- package/AGENTS.md +8 -7
- package/CHANGELOG.md +7 -0
- package/README.md +1 -21
- package/actions/abandon.test.ts +8 -16
- package/actions/blackboard.test.ts +13 -21
- package/app/abandon.test.ts +10 -15
- package/app/baseGuard.test.ts +7 -6
- package/app/blackboard.test.ts +22 -32
- package/app/ensure-pr.test.ts +10 -12
- package/app/github.test.ts +9 -8
- package/app/github.ts +1 -21
- package/app/instance-tracking.test.ts +8 -6
- package/app/mergeExclusion.test.ts +11 -20
- package/app/mergeProtocol.test.ts +14 -13
- package/app/mergeRebaseArm.test.ts +8 -6
- package/app/mergeTrain.test.ts +10 -9
- package/app/persist-escalation.test.ts +9 -30
- package/app/persist-round.test.ts +6 -19
- package/app/plan.test.ts +19 -42
- package/app/record-plan-review.test.ts +8 -7
- package/app/retro.test.ts +24 -48
- package/app/reviewWait.test.ts +12 -11
- package/app/rounds.test.ts +13 -12
- package/app/service.test.ts +14 -27
- package/app/taskDelta.test.ts +8 -17
- package/app/trialMerge.test.ts +4 -3
- package/app/version.ts +6 -21
- package/app/waves.test.ts +17 -16
- package/operations/getVersion.test.ts +8 -12
- package/operations/getVersion.ts +2 -3
- package/operations/listActivePrs.test.ts +8 -14
- package/operations/listActivePrs.ts +2 -3
- package/operations/startAndMessage.test.ts +6 -12
- package/package.json +6 -4
- package/scripts/check-agent-prompts.test.ts +15 -11
- package/scripts/layout-bpmn.ts +6 -28
- package/scripts/pages-contract.test.ts +14 -13
- package/scripts/purge-db.ts +1 -1
- package/scripts/upgrade-from-pack.ts +1 -1
- package/test/assert.ts +74 -0
- package/tsconfig.json +4 -1
- package/workers/record-plan-review/worker.test.ts +6 -9
- package/workers/record-results/worker.test.ts +5 -8
- package/workers/record-trial-merge/worker.test.ts +7 -18
- package/workers/record-wave/worker.test.ts +13 -20
- package/workers/retro-gather/worker.test.ts +4 -17
- package/workers/retro-record/worker.test.ts +8 -27
- package/workers/select-wave/worker.test.ts +5 -8
- package/deno.json +0 -24
- package/deno.lock +0 -1777
package/app/plan.test.ts
CHANGED
|
@@ -4,47 +4,48 @@
|
|
|
4
4
|
// `NaN`/`0` (e.g. unset, "", "abc"), the cap check `round + 1 >= cap` would never fire and the
|
|
5
5
|
// planner could revise forever. `positiveIntEnv` must fall back to the default on any value that
|
|
6
6
|
// is not a positive integer, so the loop is always bounded.
|
|
7
|
-
import {
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import { assertEquals } from "#test-assert";
|
|
8
9
|
import { positiveIntEnv } from "./plan.ts";
|
|
9
10
|
|
|
10
11
|
const KEY = "NANO_PLAN_REVIEW_ROUNDS_TEST";
|
|
11
12
|
|
|
12
13
|
function withEnv(value: string | undefined, run: () => void) {
|
|
13
|
-
const had = Object.prototype.hasOwnProperty.call(
|
|
14
|
-
const prev =
|
|
14
|
+
const had = Object.prototype.hasOwnProperty.call(process.env, KEY);
|
|
15
|
+
const prev = process.env[KEY];
|
|
15
16
|
try {
|
|
16
|
-
if (value === undefined)
|
|
17
|
-
else
|
|
17
|
+
if (value === undefined) delete process.env[KEY];
|
|
18
|
+
else process.env[KEY] = value;
|
|
18
19
|
run();
|
|
19
20
|
} finally {
|
|
20
|
-
if (had && prev !== undefined)
|
|
21
|
-
else
|
|
21
|
+
if (had && prev !== undefined) process.env[KEY] = prev;
|
|
22
|
+
else delete process.env[KEY];
|
|
22
23
|
}
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
test("unset → fallback (bounded loop, never NaN)", () => {
|
|
26
27
|
withEnv(undefined, () => assertEquals(positiveIntEnv(KEY, 3), 3));
|
|
27
28
|
});
|
|
28
29
|
|
|
29
|
-
|
|
30
|
+
test("blank/whitespace → fallback, not 0", () => {
|
|
30
31
|
withEnv("", () => assertEquals(positiveIntEnv(KEY, 3), 3));
|
|
31
32
|
withEnv(" ", () => assertEquals(positiveIntEnv(KEY, 3), 3));
|
|
32
33
|
});
|
|
33
34
|
|
|
34
|
-
|
|
35
|
+
test("non-numeric → fallback, not NaN", () => {
|
|
35
36
|
withEnv("abc", () => assertEquals(positiveIntEnv(KEY, 3), 3));
|
|
36
37
|
});
|
|
37
38
|
|
|
38
|
-
|
|
39
|
+
test("zero and negatives → fallback (cap must be >= 1)", () => {
|
|
39
40
|
withEnv("0", () => assertEquals(positiveIntEnv(KEY, 3), 3));
|
|
40
41
|
withEnv("-2", () => assertEquals(positiveIntEnv(KEY, 3), 3));
|
|
41
42
|
});
|
|
42
43
|
|
|
43
|
-
|
|
44
|
+
test("non-integer → fallback", () => {
|
|
44
45
|
withEnv("2.5", () => assertEquals(positiveIntEnv(KEY, 3), 3));
|
|
45
46
|
});
|
|
46
47
|
|
|
47
|
-
|
|
48
|
+
test("valid positive integer → honoured", () => {
|
|
48
49
|
withEnv("5", () => assertEquals(positiveIntEnv(KEY, 3), 5));
|
|
49
50
|
withEnv("1", () => assertEquals(positiveIntEnv(KEY, 3), 1));
|
|
50
51
|
});
|
|
@@ -58,28 +59,22 @@ Deno.test("valid positive integer → honoured", () => {
|
|
|
58
59
|
// asserts the `plan_reviews` rows for the plan key are gone after a re-plan.
|
|
59
60
|
import { startPlan } from "./plan.ts";
|
|
60
61
|
|
|
61
|
-
// deno-lint-ignore no-explicit-any
|
|
62
62
|
function memTable(rows: any[], key: string) {
|
|
63
63
|
return {
|
|
64
|
-
// deno-lint-ignore no-explicit-any
|
|
65
64
|
get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
|
|
66
|
-
// deno-lint-ignore no-explicit-any
|
|
67
65
|
find: (q: any) =>
|
|
68
66
|
Promise.resolve(
|
|
69
67
|
rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v)),
|
|
70
68
|
),
|
|
71
|
-
// deno-lint-ignore no-explicit-any
|
|
72
69
|
insert: (r: any) => {
|
|
73
70
|
rows.push(r);
|
|
74
71
|
return Promise.resolve(r);
|
|
75
72
|
},
|
|
76
|
-
// deno-lint-ignore no-explicit-any
|
|
77
73
|
update: (k: any, patch: any) => {
|
|
78
74
|
const r = rows.find((x) => x[key] === k);
|
|
79
75
|
if (r) Object.assign(r, patch);
|
|
80
76
|
return Promise.resolve(r);
|
|
81
77
|
},
|
|
82
|
-
// deno-lint-ignore no-explicit-any
|
|
83
78
|
delete: (k: any) => {
|
|
84
79
|
for (let i = rows.length - 1; i >= 0; i--) {
|
|
85
80
|
if (rows[i][key] === k) rows.splice(i, 1);
|
|
@@ -89,7 +84,7 @@ function memTable(rows: any[], key: string) {
|
|
|
89
84
|
};
|
|
90
85
|
}
|
|
91
86
|
|
|
92
|
-
|
|
87
|
+
test("re-plan of a finished issue clears stale plan_reviews rows", async () => {
|
|
93
88
|
const PLAN_KEY = "owner/repo#7";
|
|
94
89
|
const stores: Record<string, { rows: unknown[]; key: string }> = {
|
|
95
90
|
plans: {
|
|
@@ -112,11 +107,9 @@ Deno.test("re-plan of a finished issue clears stale plan_reviews rows", async ()
|
|
|
112
107
|
const data = {
|
|
113
108
|
table: (name: string, key: string) =>
|
|
114
109
|
memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
115
|
-
// deno-lint-ignore no-explicit-any
|
|
116
110
|
} as any;
|
|
117
111
|
const engine = {
|
|
118
112
|
createInstance: () => Promise.resolve({ processInstanceKey: "PI-1" }),
|
|
119
|
-
// deno-lint-ignore no-explicit-any
|
|
120
113
|
} as any;
|
|
121
114
|
|
|
122
115
|
await startPlan(data, engine, {
|
|
@@ -139,7 +132,7 @@ Deno.test("re-plan of a finished issue clears stale plan_reviews rows", async ()
|
|
|
139
132
|
// `refreshOpenTaskEscalation` re-surfaces a dead question in the answer form — the same
|
|
140
133
|
// stale-row class as `plan_reviews` above. This drives `startPlan` against the in-memory data
|
|
141
134
|
// layer and asserts both the escalation rows and the denormalised pointer are cleared.
|
|
142
|
-
|
|
135
|
+
test("re-plan of a finished issue clears stale open escalations and the denormalised open_task_* pointer", async () => {
|
|
143
136
|
const PLAN_KEY = "owner/repo#8";
|
|
144
137
|
const stores: Record<string, { rows: unknown[]; key: string }> = {
|
|
145
138
|
plans: {
|
|
@@ -172,11 +165,9 @@ Deno.test("re-plan of a finished issue clears stale open escalations and the den
|
|
|
172
165
|
const data = {
|
|
173
166
|
table: (name: string, key: string) =>
|
|
174
167
|
memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
175
|
-
// deno-lint-ignore no-explicit-any
|
|
176
168
|
} as any;
|
|
177
169
|
const engine = {
|
|
178
170
|
createInstance: () => Promise.resolve({ processInstanceKey: "PI-1" }),
|
|
179
|
-
// deno-lint-ignore no-explicit-any
|
|
180
171
|
} as any;
|
|
181
172
|
|
|
182
173
|
await startPlan(data, engine, {
|
|
@@ -214,16 +205,14 @@ function escalationStores(rows: unknown[]): Record<string, { rows: unknown[]; ke
|
|
|
214
205
|
};
|
|
215
206
|
}
|
|
216
207
|
|
|
217
|
-
// deno-lint-ignore no-explicit-any
|
|
218
208
|
function memData(stores: Record<string, { rows: any[]; key: string }>) {
|
|
219
209
|
return {
|
|
220
210
|
table: (name: string, key: string) =>
|
|
221
211
|
memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
222
|
-
// deno-lint-ignore no-explicit-any
|
|
223
212
|
} as any;
|
|
224
213
|
}
|
|
225
214
|
|
|
226
|
-
|
|
215
|
+
test("refreshOpenTaskEscalation surfaces the OLDEST open escalation, then clears when none remain", async () => {
|
|
227
216
|
const stores = escalationStores([
|
|
228
217
|
{ id: 2, plan_key: "owner/repo#9", task_id: "b", corr_key: "owner/repo#9:b", question: "Q-b", status: "open" },
|
|
229
218
|
{ id: 1, plan_key: "owner/repo#9", task_id: "a", corr_key: "owner/repo#9:a", question: "Q-a", status: "open" },
|
|
@@ -231,7 +220,6 @@ Deno.test("refreshOpenTaskEscalation surfaces the OLDEST open escalation, then c
|
|
|
231
220
|
const data = memData(stores);
|
|
232
221
|
|
|
233
222
|
await refreshOpenTaskEscalation(data, "owner/repo#9");
|
|
234
|
-
// deno-lint-ignore no-explicit-any
|
|
235
223
|
let plan = stores.plans.rows[0] as any;
|
|
236
224
|
assertEquals(plan.open_task_escalation_id, 1);
|
|
237
225
|
assertEquals(plan.open_task_question, "Q-a");
|
|
@@ -239,19 +227,15 @@ Deno.test("refreshOpenTaskEscalation surfaces the OLDEST open escalation, then c
|
|
|
239
227
|
assertEquals(plan.open_task_id, "a");
|
|
240
228
|
|
|
241
229
|
// Once the oldest is answered, the next-oldest is surfaced.
|
|
242
|
-
// deno-lint-ignore no-explicit-any
|
|
243
230
|
(stores.plan_escalations.rows.find((r: any) => r.id === 1) as any).status = "answered";
|
|
244
231
|
await refreshOpenTaskEscalation(data, "owner/repo#9");
|
|
245
|
-
// deno-lint-ignore no-explicit-any
|
|
246
232
|
plan = stores.plans.rows[0] as any;
|
|
247
233
|
assertEquals(plan.open_task_escalation_id, 2);
|
|
248
234
|
assertEquals(plan.open_task_id, "b");
|
|
249
235
|
|
|
250
236
|
// With nothing open the denormalised fields clear.
|
|
251
|
-
// deno-lint-ignore no-explicit-any
|
|
252
237
|
(stores.plan_escalations.rows.find((r: any) => r.id === 2) as any).status = "answered";
|
|
253
238
|
await refreshOpenTaskEscalation(data, "owner/repo#9");
|
|
254
|
-
// deno-lint-ignore no-explicit-any
|
|
255
239
|
plan = stores.plans.rows[0] as any;
|
|
256
240
|
assertEquals(plan.open_task_escalation_id, null);
|
|
257
241
|
assertEquals(plan.open_task_question, null);
|
|
@@ -259,7 +243,7 @@ Deno.test("refreshOpenTaskEscalation surfaces the OLDEST open escalation, then c
|
|
|
259
243
|
assertEquals(plan.open_task_id, null);
|
|
260
244
|
});
|
|
261
245
|
|
|
262
|
-
|
|
246
|
+
test("answerTaskEscalation records the answer, mirrors it onto the task, publishes the resume message, and re-surfaces the next escalation", async () => {
|
|
263
247
|
const stores = escalationStores([
|
|
264
248
|
{ id: 1, plan_key: "owner/repo#9", task_id: "a", corr_key: "owner/repo#9:a", question: "Q-a", status: "open", answer: null },
|
|
265
249
|
{ id: 2, plan_key: "owner/repo#9", task_id: "b", corr_key: "owner/repo#9:b", question: "Q-b", status: "open", answer: null },
|
|
@@ -267,15 +251,12 @@ Deno.test("answerTaskEscalation records the answer, mirrors it onto the task, pu
|
|
|
267
251
|
stores.plan_tasks.rows.push({ id: 10, plan_key: "owner/repo#9", task_id: "a", answer: null });
|
|
268
252
|
const data = memData(stores);
|
|
269
253
|
|
|
270
|
-
// deno-lint-ignore no-explicit-any
|
|
271
254
|
const published: any[] = [];
|
|
272
255
|
const engine = {
|
|
273
|
-
// deno-lint-ignore no-explicit-any
|
|
274
256
|
publishMessage: (m: any) => {
|
|
275
257
|
published.push(m);
|
|
276
258
|
return Promise.resolve();
|
|
277
259
|
},
|
|
278
|
-
// deno-lint-ignore no-explicit-any
|
|
279
260
|
} as any;
|
|
280
261
|
|
|
281
262
|
const r = await answerTaskEscalation(data, engine, "owner/repo#9:a", "do it");
|
|
@@ -285,13 +266,11 @@ Deno.test("answerTaskEscalation records the answer, mirrors it onto the task, pu
|
|
|
285
266
|
assertEquals(r.taskId, "a");
|
|
286
267
|
|
|
287
268
|
// Escalation row marked answered with the recorded answer.
|
|
288
|
-
// deno-lint-ignore no-explicit-any
|
|
289
269
|
const esc = stores.plan_escalations.rows.find((x: any) => x.id === 1) as any;
|
|
290
270
|
assertEquals(esc.status, "answered");
|
|
291
271
|
assertEquals(esc.answer, "do it");
|
|
292
272
|
|
|
293
273
|
// Answer mirrored onto the task row.
|
|
294
|
-
// deno-lint-ignore no-explicit-any
|
|
295
274
|
assertEquals((stores.plan_tasks.rows[0] as any).answer, "do it");
|
|
296
275
|
|
|
297
276
|
// Correlated resume message published on the shared constant channel.
|
|
@@ -301,16 +280,14 @@ Deno.test("answerTaskEscalation records the answer, mirrors it onto the task, pu
|
|
|
301
280
|
assertEquals(published[0].variables.answer, "do it");
|
|
302
281
|
|
|
303
282
|
// Next-oldest open escalation re-surfaced on the plan row.
|
|
304
|
-
// deno-lint-ignore no-explicit-any
|
|
305
283
|
assertEquals((stores.plans.rows[0] as any).open_task_escalation_id, 2);
|
|
306
284
|
});
|
|
307
285
|
|
|
308
|
-
|
|
286
|
+
test("answerTaskEscalation is a no-op when no open escalation matches the correlation key", async () => {
|
|
309
287
|
const stores = escalationStores([]);
|
|
310
288
|
const data = memData(stores);
|
|
311
289
|
const engine = {
|
|
312
290
|
publishMessage: () => Promise.reject(new Error("should not publish")),
|
|
313
|
-
// deno-lint-ignore no-explicit-any
|
|
314
291
|
} as any;
|
|
315
292
|
const r = await answerTaskEscalation(data, engine, "owner/repo#9:missing", "x");
|
|
316
293
|
assertEquals(r.ok, false);
|
|
@@ -4,35 +4,36 @@
|
|
|
4
4
|
// re-emitted as `planFindings`. `JSON.stringify` can THROW (BigInt, circular refs) or return
|
|
5
5
|
// `undefined` (functions/symbols); either would fail the whole job and wedge the process in a
|
|
6
6
|
// retry loop. `str()` must never throw and must always yield a string.
|
|
7
|
-
import {
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import { assertEquals } from "#test-assert";
|
|
8
9
|
import { str } from "../workers/record-plan-review/worker.ts";
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
test("strings pass through unchanged", () => {
|
|
11
12
|
assertEquals(str("hello"), "hello");
|
|
12
13
|
assertEquals(str(""), "");
|
|
13
14
|
});
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
test("null/undefined → empty string", () => {
|
|
16
17
|
assertEquals(str(null), "");
|
|
17
18
|
assertEquals(str(undefined), "");
|
|
18
19
|
});
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
test("plain objects/arrays → JSON", () => {
|
|
21
22
|
assertEquals(str({ a: 1 }), '{"a":1}');
|
|
22
23
|
assertEquals(str([1, 2]), "[1,2]");
|
|
23
24
|
});
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
test("BigInt does not throw (JSON.stringify would) → String fallback", () => {
|
|
26
27
|
assertEquals(str(10n), "10");
|
|
27
28
|
});
|
|
28
29
|
|
|
29
|
-
|
|
30
|
+
test("circular structure does not throw → String fallback", () => {
|
|
30
31
|
const circular: Record<string, unknown> = {};
|
|
31
32
|
circular.self = circular;
|
|
32
33
|
const out = str(circular);
|
|
33
34
|
assertEquals(typeof out, "string");
|
|
34
35
|
});
|
|
35
36
|
|
|
36
|
-
|
|
37
|
+
test("value JSON.stringify renders as undefined → String fallback", () => {
|
|
37
38
|
assertEquals(str(() => 1), String(() => 1));
|
|
38
39
|
});
|
package/app/retro.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Unit tests for the epic retrospective stage (app/retro.ts, 016_plan_retro.sql).
|
|
2
|
-
import {
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { assert, assertEquals, assertStringIncludes } from "#test-assert";
|
|
3
4
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
4
5
|
import { appendEntry } from "./blackboard.ts";
|
|
5
6
|
import { recordTaskDelta } from "./taskDelta.ts";
|
|
@@ -15,18 +16,13 @@ import {
|
|
|
15
16
|
} from "./retro.ts";
|
|
16
17
|
|
|
17
18
|
// In-memory record gateway matching the Table<T> subset retro.ts uses: insert/find/findOne/get/update.
|
|
18
|
-
// deno-lint-ignore no-explicit-any
|
|
19
19
|
function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
20
|
-
// deno-lint-ignore no-explicit-any
|
|
21
20
|
const stores: Record<string, any[]> = {};
|
|
22
21
|
const seq: Record<string, number> = {};
|
|
23
22
|
function tbl(name: string, pk = "id") {
|
|
24
|
-
// deno-lint-ignore no-explicit-any
|
|
25
23
|
const rows = (stores[name] ??= [] as any[]);
|
|
26
|
-
// deno-lint-ignore no-explicit-any
|
|
27
24
|
const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
|
|
28
25
|
return {
|
|
29
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
30
26
|
async insert(row: any) {
|
|
31
27
|
if (pk !== "id" && rows.some((r) => r[pk] === row[pk])) {
|
|
32
28
|
throw new Error(`UNIQUE constraint failed: ${name}.${pk}`);
|
|
@@ -35,26 +31,21 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
|
35
31
|
rows.push(pk === "id" ? { id, ...row } : { ...row });
|
|
36
32
|
return pk === "id" ? id : row[pk];
|
|
37
33
|
},
|
|
38
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
39
34
|
async find(where: any = {}) {
|
|
40
35
|
return rows.filter((r) => match(r, where));
|
|
41
36
|
},
|
|
42
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
43
37
|
async findOne(where: any = {}) {
|
|
44
38
|
return rows.find((r) => match(r, where));
|
|
45
39
|
},
|
|
46
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
47
40
|
async get(id: any) {
|
|
48
41
|
return rows.find((row) => row[pk] === id);
|
|
49
42
|
},
|
|
50
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
51
43
|
async update(id: any, patch: any) {
|
|
52
44
|
const r = rows.find((row) => row[pk] === id);
|
|
53
45
|
if (r) Object.assign(r, patch);
|
|
54
46
|
},
|
|
55
47
|
};
|
|
56
48
|
}
|
|
57
|
-
// deno-lint-ignore no-explicit-any
|
|
58
49
|
const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
|
|
59
50
|
return { data, stores };
|
|
60
51
|
}
|
|
@@ -62,21 +53,17 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
|
62
53
|
// A fake engine recording createInstance calls.
|
|
63
54
|
function fakeEngine(): { engine: EngineClient; started: { processDefinitionId: string; variables: Record<string, unknown> }[] } {
|
|
64
55
|
const started: { processDefinitionId: string; variables: Record<string, unknown> }[] = [];
|
|
65
|
-
// deno-lint-ignore no-explicit-any
|
|
66
56
|
const engine = {
|
|
67
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
68
57
|
async createInstance(req: any) {
|
|
69
58
|
started.push({ processDefinitionId: req.processDefinitionId, variables: req.variables });
|
|
70
59
|
return { processInstanceKey: `PI-${started.length}` };
|
|
71
60
|
},
|
|
72
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
73
61
|
} as any as EngineClient;
|
|
74
62
|
return { engine, started };
|
|
75
63
|
}
|
|
76
64
|
|
|
77
65
|
const PLAN = "acme/widgets#7";
|
|
78
66
|
|
|
79
|
-
// deno-lint-ignore no-explicit-any
|
|
80
67
|
function seedPlan(stores: Record<string, any[]>, over: Record<string, unknown> = {}) {
|
|
81
68
|
stores["plans"] = [{
|
|
82
69
|
plan_key: PLAN,
|
|
@@ -89,20 +76,17 @@ function seedPlan(stores: Record<string, any[]>, over: Record<string, unknown> =
|
|
|
89
76
|
}];
|
|
90
77
|
}
|
|
91
78
|
|
|
92
|
-
// deno-lint-ignore no-explicit-any
|
|
93
79
|
function seedTask(stores: Record<string, any[]>, task: Record<string, unknown>) {
|
|
94
80
|
(stores["plan_tasks"] ??= []).push({ plan_key: PLAN, ...task });
|
|
95
81
|
}
|
|
96
|
-
// deno-lint-ignore no-explicit-any
|
|
97
82
|
function seedPr(stores: Record<string, any[]>, pr_key: string, status: string) {
|
|
98
83
|
(stores["pull_requests"] ??= []).push({ pr_key, status });
|
|
99
84
|
}
|
|
100
|
-
// deno-lint-ignore no-explicit-any
|
|
101
85
|
function seedReview(stores: Record<string, any[]>, round: number, approved: number, findings: string | null) {
|
|
102
86
|
(stores["plan_reviews"] ??= []).push({ plan_key: PLAN, round, approved, findings, created_at: `t${round}`, job_key: null });
|
|
103
87
|
}
|
|
104
88
|
|
|
105
|
-
|
|
89
|
+
test("autoRetroEnabled: on by default; disabled by 0/false/off/no", () => {
|
|
106
90
|
const prev = process.env.NANO_AUTO_RETRO;
|
|
107
91
|
try {
|
|
108
92
|
delete process.env.NANO_AUTO_RETRO;
|
|
@@ -119,7 +103,7 @@ Deno.test("autoRetroEnabled: on by default; disabled by 0/false/off/no", () => {
|
|
|
119
103
|
}
|
|
120
104
|
});
|
|
121
105
|
|
|
122
|
-
|
|
106
|
+
test("planKeyForPr: resolves the plan a PR's task belongs to; undefined when unlinked", async () => {
|
|
123
107
|
const { data, stores } = memData();
|
|
124
108
|
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
125
109
|
assertEquals(await planKeyForPr(data, "acme/widgets#10"), PLAN);
|
|
@@ -127,7 +111,7 @@ Deno.test("planKeyForPr: resolves the plan a PR's task belongs to; undefined whe
|
|
|
127
111
|
assertEquals(await planKeyForPr(data, ""), undefined);
|
|
128
112
|
});
|
|
129
113
|
|
|
130
|
-
|
|
114
|
+
test("isPlanComplete: false while any task is still in flight", async () => {
|
|
131
115
|
const { data, stores } = memData();
|
|
132
116
|
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
133
117
|
seedTask(stores, { id: "t2", status: "pending", pr_key: null });
|
|
@@ -136,14 +120,14 @@ Deno.test("isPlanComplete: false while any task is still in flight", async () =>
|
|
|
136
120
|
assertEquals(await isPlanComplete(data, PLAN), false);
|
|
137
121
|
});
|
|
138
122
|
|
|
139
|
-
|
|
123
|
+
test("isPlanComplete: false when an opened task's PR is not yet terminal", async () => {
|
|
140
124
|
const { data, stores } = memData();
|
|
141
125
|
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
142
126
|
seedPr(stores, "acme/widgets#10", "waiting_deps"); // in the merge stage, not terminal
|
|
143
127
|
assertEquals(await isPlanComplete(data, PLAN), false);
|
|
144
128
|
});
|
|
145
129
|
|
|
146
|
-
|
|
130
|
+
test("isPlanComplete: true when every task is settled (terminal PR or skipped/blocked)", async () => {
|
|
147
131
|
const { data, stores } = memData();
|
|
148
132
|
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
149
133
|
seedTask(stores, { id: "t2", status: "skipped", pr_key: null });
|
|
@@ -153,12 +137,12 @@ Deno.test("isPlanComplete: true when every task is settled (terminal PR or skipp
|
|
|
153
137
|
assertEquals(await isPlanComplete(data, PLAN), true);
|
|
154
138
|
});
|
|
155
139
|
|
|
156
|
-
|
|
140
|
+
test("isPlanComplete: an empty plan has nothing to retrospect", async () => {
|
|
157
141
|
const { data } = memData();
|
|
158
142
|
assertEquals(await isPlanComplete(data, PLAN), false);
|
|
159
143
|
});
|
|
160
144
|
|
|
161
|
-
|
|
145
|
+
test("gatherRetro: separates learnings from notes and folds in deltas", async () => {
|
|
162
146
|
const { data, stores } = memData();
|
|
163
147
|
seedPlan(stores);
|
|
164
148
|
await appendEntry(data, PLAN, { author_task: "t1", kind: "learning", body: "regen the API surface before building" });
|
|
@@ -181,7 +165,7 @@ Deno.test("gatherRetro: separates learnings from notes and folds in deltas", asy
|
|
|
181
165
|
assertEquals(d.repo, "acme/widgets");
|
|
182
166
|
});
|
|
183
167
|
|
|
184
|
-
|
|
168
|
+
test("gatherRetro: folds in the plan-review trace and task-outcome shape", async () => {
|
|
185
169
|
const { data, stores } = memData();
|
|
186
170
|
seedPlan(stores);
|
|
187
171
|
seedTask(stores, { id: "t1", task_id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
@@ -201,7 +185,7 @@ Deno.test("gatherRetro: folds in the plan-review trace and task-outcome shape",
|
|
|
201
185
|
assertEquals(d.taskOutcomes.byStatus, { opened: 2, skipped: 1 });
|
|
202
186
|
});
|
|
203
187
|
|
|
204
|
-
|
|
188
|
+
test("gatherRetro: no reviews → zero rounds, not approved, empty rejections", async () => {
|
|
205
189
|
const { data, stores } = memData();
|
|
206
190
|
seedPlan(stores);
|
|
207
191
|
const d = await gatherRetro(data, PLAN);
|
|
@@ -211,7 +195,7 @@ Deno.test("gatherRetro: no reviews → zero rounds, not approved, empty rejectio
|
|
|
211
195
|
assertEquals(d.taskOutcomes, { total: 0, byStatus: {} });
|
|
212
196
|
});
|
|
213
197
|
|
|
214
|
-
|
|
198
|
+
test("renderRetroBrief: renders learnings + constraints; states 'none' with no learnings", () => {
|
|
215
199
|
const empty = renderRetroBrief({
|
|
216
200
|
planKey: PLAN, repo: "acme/widgets", issueUrl: "", title: null,
|
|
217
201
|
learnings: [], touchedFiles: [], contractChanges: [], constraints: [], notes: [],
|
|
@@ -243,7 +227,7 @@ Deno.test("renderRetroBrief: renders learnings + constraints; states 'none' with
|
|
|
243
227
|
assertStringIncludes(brief, "Task outcomes");
|
|
244
228
|
});
|
|
245
229
|
|
|
246
|
-
|
|
230
|
+
test("isDigestEmpty: empty only with no learnings/deltas/notes AND no review rejections", () => {
|
|
247
231
|
const base = { planKey: PLAN, repo: "", issueUrl: "", title: null, learnings: [], touchedFiles: [], contractChanges: [], constraints: [], notes: [], reviewRounds: 0, reviewRejections: [], planApproved: false, taskOutcomes: { total: 0, byStatus: {} } };
|
|
248
232
|
assert(isDigestEmpty({ ...base, counts: { learnings: 0, deltas: 0, notes: 0 } }));
|
|
249
233
|
assert(!isDigestEmpty({ ...base, counts: { learnings: 1, deltas: 0, notes: 0 } }));
|
|
@@ -255,7 +239,7 @@ Deno.test("isDigestEmpty: empty only with no learnings/deltas/notes AND no revie
|
|
|
255
239
|
assert(isDigestEmpty({ ...base, reviewRounds: 1, planApproved: true, taskOutcomes: { total: 3, byStatus: { opened: 3 } }, counts: { learnings: 0, deltas: 0, notes: 0 } }));
|
|
256
240
|
});
|
|
257
241
|
|
|
258
|
-
|
|
242
|
+
test("recordRetro: inserts then updates the same plan_key row in place", async () => {
|
|
259
243
|
const { data, stores } = memData();
|
|
260
244
|
await recordRetro(data, PLAN, { status: "filed", prKey: "acme/widgets#20", learnings: 3, summary: "promoted 2" });
|
|
261
245
|
assertEquals(stores["plan_retros"].length, 1);
|
|
@@ -268,25 +252,21 @@ Deno.test("recordRetro: inserts then updates the same plan_key row in place", as
|
|
|
268
252
|
assertEquals(stores["plan_retros"][0].pr_key, null);
|
|
269
253
|
});
|
|
270
254
|
|
|
271
|
-
|
|
255
|
+
test("recordRetro: rethrows a non-unique (FOREIGN KEY) constraint error instead of swallowing it", async () => {
|
|
272
256
|
// A FK failure (e.g. plan_key missing in plans) must NOT be treated as a benign duplicate and
|
|
273
257
|
// fall through to a silent update — that would make the write look successful while doing nothing.
|
|
274
258
|
let updated = false;
|
|
275
259
|
const table = {
|
|
276
|
-
// deno-lint-ignore require-await
|
|
277
260
|
async insert() {
|
|
278
261
|
throw new Error("FOREIGN KEY constraint failed");
|
|
279
262
|
},
|
|
280
|
-
// deno-lint-ignore require-await
|
|
281
263
|
async update() {
|
|
282
264
|
updated = true;
|
|
283
265
|
},
|
|
284
|
-
// deno-lint-ignore require-await
|
|
285
266
|
async get() {
|
|
286
267
|
return undefined;
|
|
287
268
|
},
|
|
288
269
|
};
|
|
289
|
-
// deno-lint-ignore no-explicit-any
|
|
290
270
|
const data = { table: () => table } as any as DataLayer;
|
|
291
271
|
let threw = false;
|
|
292
272
|
try {
|
|
@@ -299,7 +279,7 @@ Deno.test("recordRetro: rethrows a non-unique (FOREIGN KEY) constraint error ins
|
|
|
299
279
|
assertEquals(updated, false, "must not silently fall back to update on a non-unique error");
|
|
300
280
|
});
|
|
301
281
|
|
|
302
|
-
|
|
282
|
+
test("maybeStartRetro: starts the retro exactly once when the last PR lands with material", async () => {
|
|
303
283
|
const { data, stores } = memData();
|
|
304
284
|
seedPlan(stores);
|
|
305
285
|
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
@@ -325,13 +305,12 @@ Deno.test("maybeStartRetro: starts the retro exactly once when the last PR lands
|
|
|
325
305
|
assertEquals(started.length, 1, "fire-once guard");
|
|
326
306
|
});
|
|
327
307
|
|
|
328
|
-
|
|
308
|
+
test("maybeStartRetro: a createInstance failure records a blocked retro (fire-once guard already consumed)", async () => {
|
|
329
309
|
const { data, stores } = memData();
|
|
330
310
|
seedPlan(stores);
|
|
331
311
|
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
332
312
|
seedPr(stores, "acme/widgets#10", "merged");
|
|
333
313
|
await appendEntry(data, PLAN, { author_task: "t1", kind: "learning", body: "regen first" });
|
|
334
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
335
314
|
const engine = { async createInstance() { throw new Error("gateway down"); } } as any as EngineClient;
|
|
336
315
|
|
|
337
316
|
const r = await maybeStartRetro(data, engine, "acme/widgets#10");
|
|
@@ -346,19 +325,16 @@ Deno.test("maybeStartRetro: a createInstance failure records a blocked retro (fi
|
|
|
346
325
|
assertStringIncludes(String(stores["plan_retros"][0].summary), "gateway down");
|
|
347
326
|
});
|
|
348
327
|
|
|
349
|
-
|
|
328
|
+
test("maybeStartRetro: a secondary blocked-retro persistence failure still returns start-failed (not error)", async () => {
|
|
350
329
|
const { data, stores } = memData();
|
|
351
330
|
seedPlan(stores);
|
|
352
331
|
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
353
332
|
seedPr(stores, "acme/widgets#10", "merged");
|
|
354
333
|
await appendEntry(data, PLAN, { author_task: "t1", kind: "learning", body: "regen first" });
|
|
355
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
356
334
|
const engine = { async createInstance() { throw new Error("gateway down"); } } as any as EngineClient;
|
|
357
335
|
// recordRetro rethrows non-unique DB errors; simulate the blocked-retro insert hitting a
|
|
358
336
|
// FOREIGN KEY failure so the persistence in the createInstance-failure handler throws.
|
|
359
|
-
// deno-lint-ignore no-explicit-any
|
|
360
337
|
const failingData = {
|
|
361
|
-
// deno-lint-ignore no-explicit-any
|
|
362
338
|
table: (name: string, pk?: string) => {
|
|
363
339
|
const t = (data as any).table(name, pk);
|
|
364
340
|
if (name !== "plan_retros") return t;
|
|
@@ -374,7 +350,7 @@ Deno.test("maybeStartRetro: a secondary blocked-retro persistence failure still
|
|
|
374
350
|
assertEquals(stores["plan_retros"].length, 0);
|
|
375
351
|
});
|
|
376
352
|
|
|
377
|
-
|
|
353
|
+
test("maybeStartRetro: a pre-claimed retro start does not start a duplicate process", async () => {
|
|
378
354
|
const { data, stores } = memData();
|
|
379
355
|
seedPlan(stores);
|
|
380
356
|
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
@@ -389,7 +365,7 @@ Deno.test("maybeStartRetro: a pre-claimed retro start does not start a duplicate
|
|
|
389
365
|
assertEquals(stores["plans"][0].retro_started_at, null);
|
|
390
366
|
});
|
|
391
367
|
|
|
392
|
-
|
|
368
|
+
test("maybeStartRetro: bails while the plan is incomplete", async () => {
|
|
393
369
|
const { data, stores } = memData();
|
|
394
370
|
seedPlan(stores);
|
|
395
371
|
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
@@ -404,7 +380,7 @@ Deno.test("maybeStartRetro: bails while the plan is incomplete", async () => {
|
|
|
404
380
|
assertEquals(stores["plans"][0].retro_started_at, null, "must not stamp an incomplete plan");
|
|
405
381
|
});
|
|
406
382
|
|
|
407
|
-
|
|
383
|
+
test("maybeStartRetro: complete but empty → records a skipped retro, does not start the process", async () => {
|
|
408
384
|
const { data, stores } = memData();
|
|
409
385
|
seedPlan(stores);
|
|
410
386
|
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
@@ -419,7 +395,7 @@ Deno.test("maybeStartRetro: complete but empty → records a skipped retro, does
|
|
|
419
395
|
assertEquals(stores["plan_retros"][0].status, "skipped");
|
|
420
396
|
});
|
|
421
397
|
|
|
422
|
-
|
|
398
|
+
test("maybeStartRetro: a rejected review round alone is enough to fire the retro", async () => {
|
|
423
399
|
const { data, stores } = memData();
|
|
424
400
|
seedPlan(stores);
|
|
425
401
|
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
@@ -436,7 +412,7 @@ Deno.test("maybeStartRetro: a rejected review round alone is enough to fire the
|
|
|
436
412
|
assert(stores["plans"][0].retro_started_at, "retro_started_at must be stamped");
|
|
437
413
|
});
|
|
438
414
|
|
|
439
|
-
|
|
415
|
+
test("maybeStartRetro: a PR not part of any plan is a no-op", async () => {
|
|
440
416
|
const { data } = memData();
|
|
441
417
|
const { engine, started } = fakeEngine();
|
|
442
418
|
const r = await maybeStartRetro(data, engine, "acme/widgets#99");
|
|
@@ -445,7 +421,7 @@ Deno.test("maybeStartRetro: a PR not part of any plan is a no-op", async () => {
|
|
|
445
421
|
assertEquals(started.length, 0);
|
|
446
422
|
});
|
|
447
423
|
|
|
448
|
-
|
|
424
|
+
test("maybeStartRetro: honours NANO_AUTO_RETRO=0", async () => {
|
|
449
425
|
const prev = process.env.NANO_AUTO_RETRO;
|
|
450
426
|
process.env.NANO_AUTO_RETRO = "0";
|
|
451
427
|
try {
|