@nanobpm/nano-workforce 0.28.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/app/blackboard.test.ts +17 -0
- package/app/blackboard.ts +8 -4
- package/app/retro.test.ts +353 -0
- package/app/retro.ts +281 -0
- package/db/migrations/016_plan_retro.sql +38 -0
- package/deno.json +1 -1
- package/deno.lock +5 -5
- package/nano.app.json +8 -0
- package/package.json +2 -2
- package/pages/epic.page.json +12 -0
- package/pages/home.page.json +12 -0
- package/prompts/retro.md +86 -0
- package/resources/processes/retro.bpmn +83 -0
- package/workers/finalize/worker.ts +9 -0
- package/workers/mark-merged/worker.ts +6 -0
- 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
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [0.30.0](https://github.com/nanobpm/nano-workforce/compare/v0.29.0...v0.30.0) (2026-08-09)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **retro:** epic retrospective workflow that promotes shared learnings ([#84](https://github.com/nanobpm/nano-workforce/issues/84)) ([306e46f](https://github.com/nanobpm/nano-workforce/commit/306e46ff333bc5baff199713bf3a71f0c294e897)), closes [#82](https://github.com/nanobpm/nano-workforce/issues/82)
|
|
7
|
+
|
|
8
|
+
# [0.29.0](https://github.com/nanobpm/nano-workforce/compare/v0.28.0...v0.29.0) (2026-08-08)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **app:** page nav bar + urban 0.28.0 multi-page navigation ([#83](https://github.com/nanobpm/nano-workforce/issues/83)) ([4011598](https://github.com/nanobpm/nano-workforce/commit/40115981211d4f50d6243ddf990c668e1a75c60c))
|
|
14
|
+
|
|
1
15
|
# [0.28.0](https://github.com/nanobpm/nano-workforce/compare/v0.27.0...v0.28.0) (2026-08-08)
|
|
2
16
|
|
|
3
17
|
|
package/app/blackboard.test.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
appendEntry,
|
|
6
6
|
blackboardUrl,
|
|
7
7
|
detectFileClaimConflicts,
|
|
8
|
+
isUniqueViolation,
|
|
8
9
|
mintBlackboardToken,
|
|
9
10
|
normalizeKind,
|
|
10
11
|
planKeyForToken,
|
|
@@ -297,3 +298,19 @@ Deno.test("detectFileClaimConflicts: beforeId restricts to strictly prior claims
|
|
|
297
298
|
assertEquals(conflicts[0].author_task, "gap-2");
|
|
298
299
|
assert(Number(later.id) > Number(mine.id));
|
|
299
300
|
});
|
|
301
|
+
|
|
302
|
+
Deno.test("isUniqueViolation: true for UNIQUE/PK, false for FOREIGN KEY and unrelated errors", () => {
|
|
303
|
+
// Extended SQLite codes.
|
|
304
|
+
assert(isUniqueViolation(Object.assign(new Error("x"), { code: "SQLITE_CONSTRAINT_UNIQUE" })));
|
|
305
|
+
assert(isUniqueViolation(Object.assign(new Error("x"), { code: "SQLITE_CONSTRAINT_PRIMARYKEY" })));
|
|
306
|
+
// Message-only (driver surfaced no code).
|
|
307
|
+
assert(isUniqueViolation(new Error("UNIQUE constraint failed: plan_retros.plan_key")));
|
|
308
|
+
assert(isUniqueViolation(new Error("PRIMARY KEY constraint failed")));
|
|
309
|
+
// The bug this guards: a bare "constraint" match would swallow an FK failure.
|
|
310
|
+
assert(!isUniqueViolation(new Error("FOREIGN KEY constraint failed")));
|
|
311
|
+
assert(!isUniqueViolation(Object.assign(new Error("fk"), { code: "SQLITE_CONSTRAINT_FOREIGNKEY" })));
|
|
312
|
+
// Unrelated / non-errors.
|
|
313
|
+
assert(!isUniqueViolation(new Error("network down")));
|
|
314
|
+
assert(!isUniqueViolation(null));
|
|
315
|
+
assert(!isUniqueViolation("nope"));
|
|
316
|
+
});
|
package/app/blackboard.ts
CHANGED
|
@@ -307,11 +307,15 @@ export async function appendEntry(
|
|
|
307
307
|
}
|
|
308
308
|
}
|
|
309
309
|
|
|
310
|
-
/** True
|
|
311
|
-
|
|
310
|
+
/** True only for a UNIQUE / PRIMARY-KEY / duplicate violation — never a foreign-key or other
|
|
311
|
+
* constraint failure. We match the *specific* violation (extended SQLite codes, or the specific
|
|
312
|
+
* words) rather than the bare word "constraint", so a `FOREIGN KEY constraint failed` (real data
|
|
313
|
+
* corruption, not a benign duplicate) is always rethrown rather than silently swallowed. */
|
|
314
|
+
export function isUniqueViolation(err: unknown): boolean {
|
|
312
315
|
if (!err || typeof err !== "object") return false;
|
|
313
316
|
const code = (err as { code?: unknown }).code;
|
|
314
|
-
if (code === "SQLITE_CONSTRAINT_UNIQUE" || code === "
|
|
317
|
+
if (code === "SQLITE_CONSTRAINT_UNIQUE" || code === "SQLITE_CONSTRAINT_PRIMARYKEY") return true;
|
|
315
318
|
const message = (err as { message?: unknown }).message;
|
|
316
|
-
return typeof message === "string" &&
|
|
319
|
+
return typeof message === "string" &&
|
|
320
|
+
/(unique|primary key) constraint failed|duplicate/i.test(message);
|
|
317
321
|
}
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
// Unit tests for the epic retrospective stage (app/retro.ts, 016_plan_retro.sql).
|
|
2
|
+
import { assert, assertEquals, assertStringIncludes } from "jsr:@std/assert@1";
|
|
3
|
+
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
4
|
+
import { appendEntry } from "./blackboard.ts";
|
|
5
|
+
import { recordTaskDelta } from "./taskDelta.ts";
|
|
6
|
+
import {
|
|
7
|
+
autoRetroEnabled,
|
|
8
|
+
gatherRetro,
|
|
9
|
+
isDigestEmpty,
|
|
10
|
+
isPlanComplete,
|
|
11
|
+
maybeStartRetro,
|
|
12
|
+
planKeyForPr,
|
|
13
|
+
recordRetro,
|
|
14
|
+
renderRetroBrief,
|
|
15
|
+
} from "./retro.ts";
|
|
16
|
+
|
|
17
|
+
// 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
|
+
function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
20
|
+
// deno-lint-ignore no-explicit-any
|
|
21
|
+
const stores: Record<string, any[]> = {};
|
|
22
|
+
const seq: Record<string, number> = {};
|
|
23
|
+
function tbl(name: string, pk = "id") {
|
|
24
|
+
// deno-lint-ignore no-explicit-any
|
|
25
|
+
const rows = (stores[name] ??= [] as any[]);
|
|
26
|
+
// deno-lint-ignore no-explicit-any
|
|
27
|
+
const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
|
|
28
|
+
return {
|
|
29
|
+
// deno-lint-ignore no-explicit-any require-await
|
|
30
|
+
async insert(row: any) {
|
|
31
|
+
if (pk !== "id" && rows.some((r) => r[pk] === row[pk])) {
|
|
32
|
+
throw new Error(`UNIQUE constraint failed: ${name}.${pk}`);
|
|
33
|
+
}
|
|
34
|
+
const id = (seq[name] = (seq[name] ?? 0) + 1);
|
|
35
|
+
rows.push(pk === "id" ? { id, ...row } : { ...row });
|
|
36
|
+
return pk === "id" ? id : row[pk];
|
|
37
|
+
},
|
|
38
|
+
// deno-lint-ignore no-explicit-any require-await
|
|
39
|
+
async find(where: any = {}) {
|
|
40
|
+
return rows.filter((r) => match(r, where));
|
|
41
|
+
},
|
|
42
|
+
// deno-lint-ignore no-explicit-any require-await
|
|
43
|
+
async findOne(where: any = {}) {
|
|
44
|
+
return rows.find((r) => match(r, where));
|
|
45
|
+
},
|
|
46
|
+
// deno-lint-ignore no-explicit-any require-await
|
|
47
|
+
async get(id: any) {
|
|
48
|
+
return rows.find((row) => row[pk] === id);
|
|
49
|
+
},
|
|
50
|
+
// deno-lint-ignore no-explicit-any require-await
|
|
51
|
+
async update(id: any, patch: any) {
|
|
52
|
+
const r = rows.find((row) => row[pk] === id);
|
|
53
|
+
if (r) Object.assign(r, patch);
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
// deno-lint-ignore no-explicit-any
|
|
58
|
+
const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
|
|
59
|
+
return { data, stores };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// A fake engine recording createInstance calls.
|
|
63
|
+
function fakeEngine(): { engine: EngineClient; started: { processDefinitionId: string; variables: Record<string, unknown> }[] } {
|
|
64
|
+
const started: { processDefinitionId: string; variables: Record<string, unknown> }[] = [];
|
|
65
|
+
// deno-lint-ignore no-explicit-any
|
|
66
|
+
const engine = {
|
|
67
|
+
// deno-lint-ignore no-explicit-any require-await
|
|
68
|
+
async createInstance(req: any) {
|
|
69
|
+
started.push({ processDefinitionId: req.processDefinitionId, variables: req.variables });
|
|
70
|
+
return { processInstanceKey: `PI-${started.length}` };
|
|
71
|
+
},
|
|
72
|
+
// deno-lint-ignore no-explicit-any require-await
|
|
73
|
+
} as any as EngineClient;
|
|
74
|
+
return { engine, started };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const PLAN = "acme/widgets#7";
|
|
78
|
+
|
|
79
|
+
// deno-lint-ignore no-explicit-any
|
|
80
|
+
function seedPlan(stores: Record<string, any[]>, over: Record<string, unknown> = {}) {
|
|
81
|
+
stores["plans"] = [{
|
|
82
|
+
plan_key: PLAN,
|
|
83
|
+
repo: "acme/widgets",
|
|
84
|
+
issue_url: "https://github.com/acme/widgets/issues/7",
|
|
85
|
+
title: "Widgets epic",
|
|
86
|
+
status: "done",
|
|
87
|
+
retro_started_at: null,
|
|
88
|
+
...over,
|
|
89
|
+
}];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// deno-lint-ignore no-explicit-any
|
|
93
|
+
function seedTask(stores: Record<string, any[]>, task: Record<string, unknown>) {
|
|
94
|
+
(stores["plan_tasks"] ??= []).push({ plan_key: PLAN, ...task });
|
|
95
|
+
}
|
|
96
|
+
// deno-lint-ignore no-explicit-any
|
|
97
|
+
function seedPr(stores: Record<string, any[]>, pr_key: string, status: string) {
|
|
98
|
+
(stores["pull_requests"] ??= []).push({ pr_key, status });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
Deno.test("autoRetroEnabled: on by default; disabled by 0/false/off/no", () => {
|
|
102
|
+
const prev = process.env.NANO_AUTO_RETRO;
|
|
103
|
+
try {
|
|
104
|
+
delete process.env.NANO_AUTO_RETRO;
|
|
105
|
+
assert(autoRetroEnabled());
|
|
106
|
+
for (const v of ["0", "false", "off", "no", "FALSE"]) {
|
|
107
|
+
process.env.NANO_AUTO_RETRO = v;
|
|
108
|
+
assertEquals(autoRetroEnabled(), false, `"${v}" should disable`);
|
|
109
|
+
}
|
|
110
|
+
process.env.NANO_AUTO_RETRO = "1";
|
|
111
|
+
assert(autoRetroEnabled());
|
|
112
|
+
} finally {
|
|
113
|
+
if (prev == null) delete process.env.NANO_AUTO_RETRO;
|
|
114
|
+
else process.env.NANO_AUTO_RETRO = prev;
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
Deno.test("planKeyForPr: resolves the plan a PR's task belongs to; undefined when unlinked", async () => {
|
|
119
|
+
const { data, stores } = memData();
|
|
120
|
+
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
121
|
+
assertEquals(await planKeyForPr(data, "acme/widgets#10"), PLAN);
|
|
122
|
+
assertEquals(await planKeyForPr(data, "acme/widgets#99"), undefined);
|
|
123
|
+
assertEquals(await planKeyForPr(data, ""), undefined);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
Deno.test("isPlanComplete: false while any task is still in flight", async () => {
|
|
127
|
+
const { data, stores } = memData();
|
|
128
|
+
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
129
|
+
seedTask(stores, { id: "t2", status: "pending", pr_key: null });
|
|
130
|
+
seedPr(stores, "acme/widgets#10", "merged");
|
|
131
|
+
// t2 is pending with no PR → not done.
|
|
132
|
+
assertEquals(await isPlanComplete(data, PLAN), false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
Deno.test("isPlanComplete: false when an opened task's PR is not yet terminal", async () => {
|
|
136
|
+
const { data, stores } = memData();
|
|
137
|
+
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
138
|
+
seedPr(stores, "acme/widgets#10", "waiting_deps"); // in the merge stage, not terminal
|
|
139
|
+
assertEquals(await isPlanComplete(data, PLAN), false);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
Deno.test("isPlanComplete: true when every task is settled (terminal PR or skipped/blocked)", async () => {
|
|
143
|
+
const { data, stores } = memData();
|
|
144
|
+
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
145
|
+
seedTask(stores, { id: "t2", status: "skipped", pr_key: null });
|
|
146
|
+
seedTask(stores, { id: "t3", status: "opened", pr_key: "acme/widgets#11" });
|
|
147
|
+
seedPr(stores, "acme/widgets#10", "merged");
|
|
148
|
+
seedPr(stores, "acme/widgets#11", "converged");
|
|
149
|
+
assertEquals(await isPlanComplete(data, PLAN), true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
Deno.test("isPlanComplete: an empty plan has nothing to retrospect", async () => {
|
|
153
|
+
const { data } = memData();
|
|
154
|
+
assertEquals(await isPlanComplete(data, PLAN), false);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
Deno.test("gatherRetro: separates learnings from notes and folds in deltas", async () => {
|
|
158
|
+
const { data, stores } = memData();
|
|
159
|
+
seedPlan(stores);
|
|
160
|
+
await appendEntry(data, PLAN, { author_task: "t1", kind: "learning", body: "regen the API surface before building" });
|
|
161
|
+
await appendEntry(data, PLAN, { author_task: "t2", kind: "learning", body: "use nextest not cargo test" });
|
|
162
|
+
await appendEntry(data, PLAN, { author_task: "t3", kind: "note", body: "just an FYI" });
|
|
163
|
+
await recordTaskDelta(data, PLAN, "t1", {
|
|
164
|
+
contractChange: "changed the envelope shape",
|
|
165
|
+
newlyTouches: ["shared/env.ts"],
|
|
166
|
+
affectsTasks: ["t2"],
|
|
167
|
+
constraint: "envelope must carry results[]",
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const d = await gatherRetro(data, PLAN);
|
|
171
|
+
assertEquals(d.counts.learnings, 2);
|
|
172
|
+
assertEquals(d.learnings.map((l) => l.author_task).sort(), ["t1", "t2"]);
|
|
173
|
+
assertEquals(d.notes.length, 1);
|
|
174
|
+
assertEquals(d.constraints.length, 1);
|
|
175
|
+
assertEquals(d.contractChanges.length, 1);
|
|
176
|
+
assert(d.touchedFiles.includes("shared/env.ts"));
|
|
177
|
+
assertEquals(d.repo, "acme/widgets");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
Deno.test("renderRetroBrief: renders learnings + constraints; states 'none' with no learnings", () => {
|
|
181
|
+
const empty = renderRetroBrief({
|
|
182
|
+
planKey: PLAN, repo: "acme/widgets", issueUrl: "", title: null,
|
|
183
|
+
learnings: [], touchedFiles: [], contractChanges: [], constraints: [], notes: [],
|
|
184
|
+
counts: { learnings: 0, deltas: 0, notes: 0 },
|
|
185
|
+
});
|
|
186
|
+
assertStringIncludes(empty, "none");
|
|
187
|
+
|
|
188
|
+
const brief = renderRetroBrief({
|
|
189
|
+
planKey: PLAN, repo: "acme/widgets", issueUrl: "https://x/7", title: "Epic",
|
|
190
|
+
learnings: [{ author_task: "t1", body: "regen first", created_at: "now" }],
|
|
191
|
+
touchedFiles: ["a.ts"],
|
|
192
|
+
contractChanges: [{ taskId: "t1", change: "shape" }],
|
|
193
|
+
constraints: [{ taskId: "t1", constraint: "must X" }],
|
|
194
|
+
notes: [{ author_task: "t2", kind: "note", body: "watch the release lane" }],
|
|
195
|
+
counts: { learnings: 1, deltas: 1, notes: 1 },
|
|
196
|
+
});
|
|
197
|
+
assertStringIncludes(brief, "regen first");
|
|
198
|
+
assertStringIncludes(brief, "must X");
|
|
199
|
+
assertStringIncludes(brief, "watch the release lane");
|
|
200
|
+
assertStringIncludes(brief, "acme/widgets");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
Deno.test("isDigestEmpty: true only when there are no learnings, deltas, or notes", () => {
|
|
204
|
+
const base = { planKey: PLAN, repo: "", issueUrl: "", title: null, learnings: [], touchedFiles: [], contractChanges: [], constraints: [], notes: [] };
|
|
205
|
+
assert(isDigestEmpty({ ...base, counts: { learnings: 0, deltas: 0, notes: 0 } }));
|
|
206
|
+
assert(!isDigestEmpty({ ...base, counts: { learnings: 1, deltas: 0, notes: 0 } }));
|
|
207
|
+
assert(!isDigestEmpty({ ...base, counts: { learnings: 0, deltas: 2, notes: 0 } }));
|
|
208
|
+
assert(!isDigestEmpty({ ...base, counts: { learnings: 0, deltas: 0, notes: 1 } }));
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
Deno.test("recordRetro: inserts then updates the same plan_key row in place", async () => {
|
|
212
|
+
const { data, stores } = memData();
|
|
213
|
+
await recordRetro(data, PLAN, { status: "filed", prKey: "acme/widgets#20", learnings: 3, summary: "promoted 2" });
|
|
214
|
+
assertEquals(stores["plan_retros"].length, 1);
|
|
215
|
+
assertEquals(stores["plan_retros"][0].status, "filed");
|
|
216
|
+
assertEquals(stores["plan_retros"][0].pr_key, "acme/widgets#20");
|
|
217
|
+
|
|
218
|
+
await recordRetro(data, PLAN, { status: "skipped", summary: "nothing to promote" });
|
|
219
|
+
assertEquals(stores["plan_retros"].length, 1, "same plan_key must not duplicate");
|
|
220
|
+
assertEquals(stores["plan_retros"][0].status, "skipped");
|
|
221
|
+
assertEquals(stores["plan_retros"][0].pr_key, null);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
Deno.test("recordRetro: rethrows a non-unique (FOREIGN KEY) constraint error instead of swallowing it", async () => {
|
|
225
|
+
// A FK failure (e.g. plan_key missing in plans) must NOT be treated as a benign duplicate and
|
|
226
|
+
// fall through to a silent update — that would make the write look successful while doing nothing.
|
|
227
|
+
let updated = false;
|
|
228
|
+
const table = {
|
|
229
|
+
// deno-lint-ignore require-await
|
|
230
|
+
async insert() {
|
|
231
|
+
throw new Error("FOREIGN KEY constraint failed");
|
|
232
|
+
},
|
|
233
|
+
// deno-lint-ignore require-await
|
|
234
|
+
async update() {
|
|
235
|
+
updated = true;
|
|
236
|
+
},
|
|
237
|
+
// deno-lint-ignore require-await
|
|
238
|
+
async get() {
|
|
239
|
+
return undefined;
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
// deno-lint-ignore no-explicit-any
|
|
243
|
+
const data = { table: () => table } as any as DataLayer;
|
|
244
|
+
let threw = false;
|
|
245
|
+
try {
|
|
246
|
+
await recordRetro(data, PLAN, { status: "filed", prKey: "acme/widgets#20" });
|
|
247
|
+
} catch (err) {
|
|
248
|
+
threw = true;
|
|
249
|
+
assertStringIncludes(String(err), "FOREIGN KEY");
|
|
250
|
+
}
|
|
251
|
+
assert(threw, "the FK error must propagate");
|
|
252
|
+
assertEquals(updated, false, "must not silently fall back to update on a non-unique error");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
Deno.test("maybeStartRetro: starts the retro exactly once when the last PR lands with material", async () => {
|
|
256
|
+
const { data, stores } = memData();
|
|
257
|
+
seedPlan(stores);
|
|
258
|
+
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
259
|
+
seedTask(stores, { id: "t2", status: "opened", pr_key: "acme/widgets#11" });
|
|
260
|
+
seedPr(stores, "acme/widgets#10", "merged");
|
|
261
|
+
seedPr(stores, "acme/widgets#11", "merged");
|
|
262
|
+
await appendEntry(data, PLAN, { author_task: "t1", kind: "learning", body: "regen first" });
|
|
263
|
+
const { engine, started } = fakeEngine();
|
|
264
|
+
|
|
265
|
+
const r1 = await maybeStartRetro(data, engine, "acme/widgets#11");
|
|
266
|
+
assertEquals(r1.started, true);
|
|
267
|
+
assertEquals(r1.planKey, PLAN);
|
|
268
|
+
assertEquals(started.length, 1);
|
|
269
|
+
assertEquals(started[0].processDefinitionId, "retro");
|
|
270
|
+
assertEquals(started[0].variables.planKey, PLAN);
|
|
271
|
+
assert(stores["plans"][0].retro_started_at, "retro_started_at must be stamped");
|
|
272
|
+
assertEquals(stores["plan_retro_starts"].length, 1);
|
|
273
|
+
|
|
274
|
+
// A sibling terminal PR of the same plan must NOT start a second retro.
|
|
275
|
+
const r2 = await maybeStartRetro(data, engine, "acme/widgets#10");
|
|
276
|
+
assertEquals(r2.started, false);
|
|
277
|
+
assertEquals(r2.reason, "already-started");
|
|
278
|
+
assertEquals(started.length, 1, "fire-once guard");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
Deno.test("maybeStartRetro: a pre-claimed retro start does not start a duplicate process", async () => {
|
|
282
|
+
const { data, stores } = memData();
|
|
283
|
+
seedPlan(stores);
|
|
284
|
+
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
285
|
+
seedPr(stores, "acme/widgets#10", "merged");
|
|
286
|
+
stores["plan_retro_starts"] = [{ plan_key: PLAN, started_at: "already" }];
|
|
287
|
+
await appendEntry(data, PLAN, { author_task: "t1", kind: "learning", body: "regen first" });
|
|
288
|
+
const { engine, started } = fakeEngine();
|
|
289
|
+
|
|
290
|
+
const r = await maybeStartRetro(data, engine, "acme/widgets#10");
|
|
291
|
+
assertEquals(r, { started: false, planKey: PLAN, reason: "already-started" });
|
|
292
|
+
assertEquals(started.length, 0);
|
|
293
|
+
assertEquals(stores["plans"][0].retro_started_at, null);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
Deno.test("maybeStartRetro: bails while the plan is incomplete", async () => {
|
|
297
|
+
const { data, stores } = memData();
|
|
298
|
+
seedPlan(stores);
|
|
299
|
+
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
300
|
+
seedTask(stores, { id: "t2", status: "pending", pr_key: null });
|
|
301
|
+
seedPr(stores, "acme/widgets#10", "merged");
|
|
302
|
+
const { engine, started } = fakeEngine();
|
|
303
|
+
|
|
304
|
+
const r = await maybeStartRetro(data, engine, "acme/widgets#10");
|
|
305
|
+
assertEquals(r.started, false);
|
|
306
|
+
assertEquals(r.reason, "incomplete");
|
|
307
|
+
assertEquals(started.length, 0);
|
|
308
|
+
assertEquals(stores["plans"][0].retro_started_at, null, "must not stamp an incomplete plan");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
Deno.test("maybeStartRetro: complete but empty → records a skipped retro, does not start the process", async () => {
|
|
312
|
+
const { data, stores } = memData();
|
|
313
|
+
seedPlan(stores);
|
|
314
|
+
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
315
|
+
seedPr(stores, "acme/widgets#10", "merged");
|
|
316
|
+
const { engine, started } = fakeEngine();
|
|
317
|
+
|
|
318
|
+
const r = await maybeStartRetro(data, engine, "acme/widgets#10");
|
|
319
|
+
assertEquals(r.started, false);
|
|
320
|
+
assertEquals(r.reason, "nothing-to-retro");
|
|
321
|
+
assertEquals(started.length, 0);
|
|
322
|
+
assert(stores["plans"][0].retro_started_at, "stamped so we don't re-check forever");
|
|
323
|
+
assertEquals(stores["plan_retros"][0].status, "skipped");
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
Deno.test("maybeStartRetro: a PR not part of any plan is a no-op", async () => {
|
|
327
|
+
const { data } = memData();
|
|
328
|
+
const { engine, started } = fakeEngine();
|
|
329
|
+
const r = await maybeStartRetro(data, engine, "acme/widgets#99");
|
|
330
|
+
assertEquals(r.started, false);
|
|
331
|
+
assertEquals(r.reason, "no-plan");
|
|
332
|
+
assertEquals(started.length, 0);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
Deno.test("maybeStartRetro: honours NANO_AUTO_RETRO=0", async () => {
|
|
336
|
+
const prev = process.env.NANO_AUTO_RETRO;
|
|
337
|
+
process.env.NANO_AUTO_RETRO = "0";
|
|
338
|
+
try {
|
|
339
|
+
const { data, stores } = memData();
|
|
340
|
+
seedPlan(stores);
|
|
341
|
+
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
342
|
+
seedPr(stores, "acme/widgets#10", "merged");
|
|
343
|
+
await appendEntry(data, PLAN, { author_task: "t1", kind: "learning", body: "x" });
|
|
344
|
+
const { engine, started } = fakeEngine();
|
|
345
|
+
const r = await maybeStartRetro(data, engine, "acme/widgets#10");
|
|
346
|
+
assertEquals(r.started, false);
|
|
347
|
+
assertEquals(r.reason, "disabled");
|
|
348
|
+
assertEquals(started.length, 0);
|
|
349
|
+
} finally {
|
|
350
|
+
if (prev == null) delete process.env.NANO_AUTO_RETRO;
|
|
351
|
+
else process.env.NANO_AUTO_RETRO = prev;
|
|
352
|
+
}
|
|
353
|
+
});
|