@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 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
 
@@ -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 when an error is a SQLite UNIQUE-constraint violation (however the driver surfaces it). */
311
- function isUniqueViolation(err: unknown): boolean {
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 === "SQLITE_CONSTRAINT") return true;
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" && /UNIQUE constraint failed/i.test(message);
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
+ });