@nanobpm/nano-workforce 0.35.2 → 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 +10 -17
- package/AGENTS.md +8 -7
- package/CHANGELOG.md +14 -0
- package/README.md +1 -21
- package/actions/abandon.test.ts +8 -16
- package/actions/blackboard.test.ts +13 -21
- package/actions/blackboard.ts +1 -0
- package/actions/feature-answer-hook.ts +1 -0
- package/actions/plan-hook.ts +2 -1
- package/actions/webhook-submit.ts +2 -1
- package/app/abandon.test.ts +10 -15
- package/app/baseGuard.test.ts +7 -6
- package/app/blackboard.test.ts +22 -32
- package/app/blackboard.ts +7 -6
- package/app/ensure-pr.test.ts +10 -12
- package/app/github.test.ts +9 -8
- package/app/github.ts +24 -22
- package/app/instance-tracking.test.ts +8 -6
- package/app/mergeExclusion.test.ts +11 -20
- package/app/mergeExclusion.ts +8 -3
- package/app/mergeProtocol.test.ts +14 -13
- package/app/mergeProtocol.ts +1 -0
- 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/plan.ts +1 -1
- package/app/record-plan-review.test.ts +8 -7
- package/app/retro.test.ts +24 -48
- package/app/retro.ts +4 -2
- package/app/reviewWait.test.ts +12 -11
- package/app/rounds.test.ts +13 -12
- package/app/service.test.ts +14 -27
- package/app/service.ts +8 -4
- package/app/taskDelta.test.ts +8 -17
- package/app/taskDelta.ts +1 -0
- package/app/trialMerge.test.ts +4 -3
- package/app/version.ts +9 -21
- package/app/waves.test.ts +17 -16
- package/biome.json +143 -0
- 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/postMessage.ts +3 -3
- package/operations/startAndMessage.test.ts +6 -12
- package/package.json +9 -4
- package/plugins/no-unsafe-type-assertion.grit +12 -0
- package/scripts/check-agent-prompts.test.ts +15 -11
- package/scripts/check-agent-prompts.ts +4 -2
- package/scripts/layout-bpmn.ts +6 -26
- package/scripts/pages-contract.test.ts +14 -13
- package/scripts/purge-db.ts +3 -2
- package/scripts/upgrade-from-pack.ts +9 -5
- package/test/assert.ts +74 -0
- package/tsconfig.json +4 -1
- package/workers/finalize/worker.ts +2 -1
- package/workers/merge/worker.ts +3 -3
- package/workers/persist-escalation/worker.ts +2 -1
- package/workers/persist-round/worker.ts +2 -1
- package/workers/record-plan-review/worker.test.ts +6 -9
- package/workers/record-plan-review/worker.ts +2 -1
- package/workers/record-results/worker.test.ts +5 -8
- package/workers/record-results/worker.ts +2 -1
- package/workers/record-trial-merge/worker.test.ts +7 -18
- package/workers/record-trial-merge/worker.ts +6 -6
- package/workers/record-wave/worker.test.ts +13 -20
- package/workers/record-wave/worker.ts +7 -8
- package/workers/retro-gather/worker.test.ts +4 -17
- package/workers/retro-record/worker.test.ts +8 -27
- package/workers/retro-record/worker.ts +3 -3
- package/workers/select-wave/worker.test.ts +5 -8
- package/deno.json +0 -24
- package/deno.lock +0 -1776
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 {
|
package/app/retro.ts
CHANGED
|
@@ -14,16 +14,17 @@
|
|
|
14
14
|
// Data access goes through the record gateway (`data.table`), never hand-written SQL — matching
|
|
15
15
|
// app/plan.ts, app/blackboard.ts, and app/taskDelta.ts.
|
|
16
16
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
17
|
-
import { planReviews, planTasks } from "./plan.ts";
|
|
18
17
|
import { isUniqueViolation, readBlackboard } from "./blackboard.ts";
|
|
19
|
-
import {
|
|
18
|
+
import { planReviews, planTasks } from "./plan.ts";
|
|
20
19
|
import { TERMINAL_STATUSES } from "./service.ts";
|
|
20
|
+
import { aggregateEpicDeltas } from "./taskDelta.ts";
|
|
21
21
|
|
|
22
22
|
export const RETRO_PROCESS_ID = "retro";
|
|
23
23
|
|
|
24
24
|
/** Opt-out env toggle. Retro runs by default; set NANO_AUTO_RETRO=0/false to disable (e.g. in a
|
|
25
25
|
* review-only deployment that doesn't want the fleet opening promotion PRs). */
|
|
26
26
|
export function autoRetroEnabled(): boolean {
|
|
27
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
27
28
|
const v = (globalThis as { process?: { env?: Record<string, string | undefined> } })
|
|
28
29
|
.process?.env?.NANO_AUTO_RETRO;
|
|
29
30
|
if (v == null) return true;
|
|
@@ -128,6 +129,7 @@ export async function gatherRetro(data: DataLayer, planKey: string): Promise<Ret
|
|
|
128
129
|
.sort((a, b) => a.round - b.round);
|
|
129
130
|
const reviewRejections = reviews
|
|
130
131
|
.filter((r) => r.approved === 0 && (r.findings ?? "").trim() !== "")
|
|
132
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
131
133
|
.map((r) => ({ round: r.round, findings: r.findings as string }));
|
|
132
134
|
const planApproved = reviews.length > 0 && reviews[reviews.length - 1].approved === 1;
|
|
133
135
|
|
package/app/reviewWait.test.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Contract for the review-wait liveness policy — the ISO-8601 timeout validation handed to the
|
|
2
2
|
// process's timer catch, and the Copilot re-request nudge cooldown. Both are env-driven, so a
|
|
3
3
|
// blank/malformed operator value must fall back to a sane default rather than deploy an
|
|
4
|
-
// uninterpretable timer or a runaway nudge interval. Run with `
|
|
5
|
-
import {
|
|
4
|
+
// uninterpretable timer or a runaway nudge interval. Run with `node --test`.
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import { assertEquals } from "#test-assert";
|
|
6
7
|
import {
|
|
7
8
|
clampNudgeMinutes,
|
|
8
9
|
DEFAULT_REVIEW_NUDGE_MINUTES,
|
|
@@ -11,7 +12,7 @@ import {
|
|
|
11
12
|
reviewWaitTimeout,
|
|
12
13
|
} from "./reviewWait.ts";
|
|
13
14
|
|
|
14
|
-
|
|
15
|
+
test("reviewWaitTimeout: blank / absent / malformed → default", () => {
|
|
15
16
|
assertEquals(reviewWaitTimeout(undefined), DEFAULT_REVIEW_WAIT_TIMEOUT);
|
|
16
17
|
assertEquals(reviewWaitTimeout(""), DEFAULT_REVIEW_WAIT_TIMEOUT);
|
|
17
18
|
assertEquals(reviewWaitTimeout(" "), DEFAULT_REVIEW_WAIT_TIMEOUT);
|
|
@@ -21,7 +22,7 @@ Deno.test("reviewWaitTimeout: blank / absent / malformed → default", () => {
|
|
|
21
22
|
assertEquals(reviewWaitTimeout("garbage"), DEFAULT_REVIEW_WAIT_TIMEOUT);
|
|
22
23
|
});
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
test("reviewWaitTimeout: a valid ISO-8601 duration is honoured and upper-cased", () => {
|
|
25
26
|
assertEquals(reviewWaitTimeout("PT30M"), "PT30M");
|
|
26
27
|
assertEquals(reviewWaitTimeout("pt30m"), "PT30M");
|
|
27
28
|
assertEquals(reviewWaitTimeout(" PT1H30M "), "PT1H30M");
|
|
@@ -30,12 +31,12 @@ Deno.test("reviewWaitTimeout: a valid ISO-8601 duration is honoured and upper-ca
|
|
|
30
31
|
assertEquals(reviewWaitTimeout("P1DT2H"), "P1DT2H");
|
|
31
32
|
});
|
|
32
33
|
|
|
33
|
-
|
|
34
|
+
test("reviewWaitTimeout: a custom fallback is used when the value is invalid", () => {
|
|
34
35
|
assertEquals(reviewWaitTimeout("nope", "PT10M"), "PT10M");
|
|
35
36
|
assertEquals(reviewWaitTimeout(undefined, "PT10M"), "PT10M");
|
|
36
37
|
});
|
|
37
38
|
|
|
38
|
-
|
|
39
|
+
test("clampNudgeMinutes: blank / absent / non-numeric → fallback", () => {
|
|
39
40
|
assertEquals(clampNudgeMinutes(""), DEFAULT_REVIEW_NUDGE_MINUTES);
|
|
40
41
|
assertEquals(clampNudgeMinutes(" "), DEFAULT_REVIEW_NUDGE_MINUTES);
|
|
41
42
|
assertEquals(clampNudgeMinutes(undefined), DEFAULT_REVIEW_NUDGE_MINUTES);
|
|
@@ -44,27 +45,27 @@ Deno.test("clampNudgeMinutes: blank / absent / non-numeric → fallback", () =>
|
|
|
44
45
|
assertEquals(clampNudgeMinutes(NaN), DEFAULT_REVIEW_NUDGE_MINUTES);
|
|
45
46
|
});
|
|
46
47
|
|
|
47
|
-
|
|
48
|
+
test("clampNudgeMinutes: a valid positive value (string or number) is honoured", () => {
|
|
48
49
|
assertEquals(clampNudgeMinutes("10"), 10);
|
|
49
50
|
assertEquals(clampNudgeMinutes(15), 15);
|
|
50
51
|
assertEquals(clampNudgeMinutes(" 7 "), 7);
|
|
51
52
|
});
|
|
52
53
|
|
|
53
|
-
|
|
54
|
+
test("clampNudgeMinutes: fractional values are truncated", () => {
|
|
54
55
|
assertEquals(clampNudgeMinutes("5.9"), 5);
|
|
55
56
|
assertEquals(clampNudgeMinutes(3.2), 3);
|
|
56
57
|
});
|
|
57
58
|
|
|
58
|
-
|
|
59
|
+
test("clampNudgeMinutes: zero and negatives fall back (a 0 cooldown would hammer the API)", () => {
|
|
59
60
|
assertEquals(clampNudgeMinutes(0), DEFAULT_REVIEW_NUDGE_MINUTES);
|
|
60
61
|
assertEquals(clampNudgeMinutes("-4"), DEFAULT_REVIEW_NUDGE_MINUTES);
|
|
61
62
|
});
|
|
62
63
|
|
|
63
|
-
|
|
64
|
+
test("clampNudgeMinutes: above the ceiling is clamped, not rejected", () => {
|
|
64
65
|
assertEquals(clampNudgeMinutes(100_000), MAX_REVIEW_NUDGE_MINUTES);
|
|
65
66
|
assertEquals(clampNudgeMinutes(String(MAX_REVIEW_NUDGE_MINUTES + 1)), MAX_REVIEW_NUDGE_MINUTES);
|
|
66
67
|
});
|
|
67
68
|
|
|
68
|
-
|
|
69
|
+
test("clampNudgeMinutes: an oversized fallback is itself clamped to the ceiling", () => {
|
|
69
70
|
assertEquals(clampNudgeMinutes("", MAX_REVIEW_NUDGE_MINUTES + 50), MAX_REVIEW_NUDGE_MINUTES);
|
|
70
71
|
});
|
package/app/rounds.test.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// Contract for clampRounds — the per-submit round-cap coercion. The submit form renders every
|
|
2
2
|
// field as a text input (the runtime hardcodes type="text"), so the override arrives as a string
|
|
3
|
-
// and a blank field must fall back to the fleet default. Run with `
|
|
4
|
-
import {
|
|
3
|
+
// and a blank field must fall back to the fleet default. Run with `node --test`.
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import { assertEquals } from "#test-assert";
|
|
5
6
|
import { clampCiFixBudget, clampRounds, MAX_CI_FIX_CEILING, MAX_ROUNDS_CEILING } from "./rounds.ts";
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
test("blank / absent / non-numeric → fallback", () => {
|
|
8
9
|
assertEquals(clampRounds("", 20), 20);
|
|
9
10
|
assertEquals(clampRounds(" ", 20), 20);
|
|
10
11
|
assertEquals(clampRounds(undefined, 20), 20);
|
|
@@ -13,28 +14,28 @@ Deno.test("blank / absent / non-numeric → fallback", () => {
|
|
|
13
14
|
assertEquals(clampRounds(NaN, 20), 20);
|
|
14
15
|
});
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
test("a valid positive value (string or number) is honoured", () => {
|
|
17
18
|
assertEquals(clampRounds("5", 20), 5);
|
|
18
19
|
assertEquals(clampRounds(30, 20), 30);
|
|
19
20
|
assertEquals(clampRounds(" 12 ", 20), 12);
|
|
20
21
|
});
|
|
21
22
|
|
|
22
|
-
|
|
23
|
+
test("fractional values are truncated to a whole round", () => {
|
|
23
24
|
assertEquals(clampRounds("7.9", 20), 7);
|
|
24
25
|
assertEquals(clampRounds(3.2, 20), 3);
|
|
25
26
|
});
|
|
26
27
|
|
|
27
|
-
|
|
28
|
+
test("zero and negatives fall back (a cap < 1 would escalate before round 1)", () => {
|
|
28
29
|
assertEquals(clampRounds(0, 20), 20);
|
|
29
30
|
assertEquals(clampRounds("-4", 20), 20);
|
|
30
31
|
});
|
|
31
32
|
|
|
32
|
-
|
|
33
|
+
test("above the ceiling is clamped, not rejected", () => {
|
|
33
34
|
assertEquals(clampRounds(1000, 20), MAX_ROUNDS_CEILING);
|
|
34
35
|
assertEquals(clampRounds(String(MAX_ROUNDS_CEILING + 1), 20), MAX_ROUNDS_CEILING);
|
|
35
36
|
});
|
|
36
37
|
|
|
37
|
-
|
|
38
|
+
test("an oversized fallback is itself clamped to the ceiling", () => {
|
|
38
39
|
// A blank/invalid input falls back — but the fallback must not bypass the ceiling.
|
|
39
40
|
assertEquals(clampRounds("", MAX_ROUNDS_CEILING + 50), MAX_ROUNDS_CEILING);
|
|
40
41
|
assertEquals(clampRounds(" ", MAX_ROUNDS_CEILING + 1), MAX_ROUNDS_CEILING);
|
|
@@ -45,7 +46,7 @@ Deno.test("an oversized fallback is itself clamped to the ceiling", () => {
|
|
|
45
46
|
// clampCiFixBudget — the merge stage's CI-fix attempt budget (NANO_PR_MAX_CI_FIX_ROUNDS). Unlike
|
|
46
47
|
// clampRounds it ALLOWS 0, because 0 is a meaningful setting: disable auto-fix so a blocked PR
|
|
47
48
|
// escalates to a human immediately.
|
|
48
|
-
|
|
49
|
+
test("ci-fix budget: blank / non-numeric / negative → fallback", () => {
|
|
49
50
|
assertEquals(clampCiFixBudget("", 3), 3);
|
|
50
51
|
assertEquals(clampCiFixBudget(" ", 3), 3);
|
|
51
52
|
assertEquals(clampCiFixBudget(undefined, 3), 3);
|
|
@@ -55,20 +56,20 @@ Deno.test("ci-fix budget: blank / non-numeric / negative → fallback", () => {
|
|
|
55
56
|
assertEquals(clampCiFixBudget("-1", 3), 3);
|
|
56
57
|
});
|
|
57
58
|
|
|
58
|
-
|
|
59
|
+
test("ci-fix budget: 0 is honoured (disables auto-fix), unlike clampRounds", () => {
|
|
59
60
|
assertEquals(clampCiFixBudget(0, 3), 0);
|
|
60
61
|
assertEquals(clampCiFixBudget("0", 3), 0);
|
|
61
62
|
// Contrast: the round cap treats 0 as invalid and falls back.
|
|
62
63
|
assertEquals(clampRounds(0, 3), 3);
|
|
63
64
|
});
|
|
64
65
|
|
|
65
|
-
|
|
66
|
+
test("ci-fix budget: a valid positive value is honoured and truncated", () => {
|
|
66
67
|
assertEquals(clampCiFixBudget("5", 3), 5);
|
|
67
68
|
assertEquals(clampCiFixBudget(2, 3), 2);
|
|
68
69
|
assertEquals(clampCiFixBudget("4.8", 3), 4);
|
|
69
70
|
});
|
|
70
71
|
|
|
71
|
-
|
|
72
|
+
test("ci-fix budget: above the ceiling is clamped; oversized fallback too", () => {
|
|
72
73
|
assertEquals(clampCiFixBudget(1000, 3), MAX_CI_FIX_CEILING);
|
|
73
74
|
assertEquals(clampCiFixBudget("", MAX_CI_FIX_CEILING + 5), MAX_CI_FIX_CEILING);
|
|
74
75
|
});
|
package/app/service.test.ts
CHANGED
|
@@ -5,30 +5,25 @@
|
|
|
5
5
|
// dead "(no question provided)" question on the re-opened PR (the same stale-row class the plan
|
|
6
6
|
// loop already guards in `startPlan`). Drives `submitPr` against an in-memory data layer with the
|
|
7
7
|
// GitHub transport forced off so it is hermetic.
|
|
8
|
-
import {
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import { assertEquals } from "#test-assert";
|
|
9
10
|
import { pollIncidentsImpl, submitPr } from "./service.ts";
|
|
10
11
|
|
|
11
|
-
// deno-lint-ignore no-explicit-any
|
|
12
12
|
function memTable(rows: any[], key: string) {
|
|
13
13
|
return {
|
|
14
|
-
// deno-lint-ignore no-explicit-any
|
|
15
14
|
get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
|
|
16
15
|
all: () => Promise.resolve([...rows]),
|
|
17
|
-
// deno-lint-ignore no-explicit-any
|
|
18
16
|
find: (q: any) =>
|
|
19
17
|
Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
|
|
20
|
-
// deno-lint-ignore no-explicit-any
|
|
21
18
|
insert: (r: any) => {
|
|
22
19
|
rows.push(r);
|
|
23
20
|
return Promise.resolve(r);
|
|
24
21
|
},
|
|
25
|
-
// deno-lint-ignore no-explicit-any
|
|
26
22
|
update: (k: any, patch: any) => {
|
|
27
23
|
const r = rows.find((x) => x[key] === k);
|
|
28
24
|
if (r) Object.assign(r, patch);
|
|
29
25
|
return Promise.resolve(r);
|
|
30
26
|
},
|
|
31
|
-
// deno-lint-ignore no-explicit-any
|
|
32
27
|
delete: (k: any) => {
|
|
33
28
|
for (let i = rows.length - 1; i >= 0; i--) if (rows[i][key] === k) rows.splice(i, 1);
|
|
34
29
|
return Promise.resolve();
|
|
@@ -37,18 +32,18 @@ function memTable(rows: any[], key: string) {
|
|
|
37
32
|
}
|
|
38
33
|
|
|
39
34
|
function withGithubOff(run: () => Promise<void>): Promise<void> {
|
|
40
|
-
const prevMode =
|
|
41
|
-
const prevTok =
|
|
42
|
-
|
|
43
|
-
|
|
35
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
36
|
+
const prevTok = process.env["GITHUB_TOKEN"];
|
|
37
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token"; // no token below -> fetchPrMeta returns null
|
|
38
|
+
delete process.env["GITHUB_TOKEN"];
|
|
44
39
|
return run().finally(() => {
|
|
45
|
-
if (prevMode !== undefined)
|
|
46
|
-
else
|
|
47
|
-
if (prevTok !== undefined)
|
|
40
|
+
if (prevMode !== undefined) process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
41
|
+
else delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
42
|
+
if (prevTok !== undefined) process.env["GITHUB_TOKEN"] = prevTok;
|
|
48
43
|
});
|
|
49
44
|
}
|
|
50
45
|
|
|
51
|
-
|
|
46
|
+
test("re-submit of a cancelled PR clears stale open escalations + the denormalised pointer", async () => {
|
|
52
47
|
await withGithubOff(async () => {
|
|
53
48
|
const PR_KEY = "owner/repo#42";
|
|
54
49
|
const stores: Record<string, { rows: unknown[]; key: string }> = {
|
|
@@ -74,11 +69,9 @@ Deno.test("re-submit of a cancelled PR clears stale open escalations + the denor
|
|
|
74
69
|
};
|
|
75
70
|
const data = {
|
|
76
71
|
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
77
|
-
// deno-lint-ignore no-explicit-any
|
|
78
72
|
} as any;
|
|
79
73
|
const engine = {
|
|
80
74
|
createInstance: () => Promise.resolve({ processInstanceKey: "PI-9" }),
|
|
81
|
-
// deno-lint-ignore no-explicit-any
|
|
82
75
|
} as any;
|
|
83
76
|
|
|
84
77
|
await submitPr(data, engine, {
|
|
@@ -125,7 +118,7 @@ function incidentFetch(byInstance: Record<string, unknown[]>) {
|
|
|
125
118
|
};
|
|
126
119
|
}
|
|
127
120
|
|
|
128
|
-
|
|
121
|
+
test("pollIncidents mirrors an ACTIVE incident onto the PR row, then clears it, leaving status untouched", async () => {
|
|
129
122
|
const row = {
|
|
130
123
|
pr_key: "owner/repo#7",
|
|
131
124
|
repo: "owner/repo",
|
|
@@ -141,7 +134,6 @@ Deno.test("pollIncidents mirrors an ACTIVE incident onto the PR row, then clears
|
|
|
141
134
|
};
|
|
142
135
|
const data = {
|
|
143
136
|
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
144
|
-
// deno-lint-ignore no-explicit-any
|
|
145
137
|
} as any;
|
|
146
138
|
const headers = { "content-type": "application/json" };
|
|
147
139
|
|
|
@@ -173,7 +165,7 @@ Deno.test("pollIncidents mirrors an ACTIVE incident onto the PR row, then clears
|
|
|
173
165
|
assertEquals(row.status, "converging");
|
|
174
166
|
});
|
|
175
167
|
|
|
176
|
-
|
|
168
|
+
test("pollIncidents never queries a PR with no live instance and clears any stale incident", async () => {
|
|
177
169
|
const noKey = {
|
|
178
170
|
pr_key: "owner/repo#8",
|
|
179
171
|
status: "converging",
|
|
@@ -195,7 +187,6 @@ Deno.test("pollIncidents never queries a PR with no live instance and clears any
|
|
|
195
187
|
};
|
|
196
188
|
const data = {
|
|
197
189
|
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
198
|
-
// deno-lint-ignore no-explicit-any
|
|
199
190
|
} as any;
|
|
200
191
|
const headers = { "content-type": "application/json" };
|
|
201
192
|
|
|
@@ -215,7 +206,7 @@ Deno.test("pollIncidents never queries a PR with no live instance and clears any
|
|
|
215
206
|
assertEquals(terminal.incident_message, null);
|
|
216
207
|
});
|
|
217
208
|
|
|
218
|
-
|
|
209
|
+
test("pollIncidents picks the oldest incident by creationTime, sorting a missing timestamp last", async () => {
|
|
219
210
|
const row = {
|
|
220
211
|
pr_key: "owner/repo#11",
|
|
221
212
|
status: "converging",
|
|
@@ -229,7 +220,6 @@ Deno.test("pollIncidents picks the oldest incident by creationTime, sorting a mi
|
|
|
229
220
|
};
|
|
230
221
|
const data = {
|
|
231
222
|
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
232
|
-
// deno-lint-ignore no-explicit-any
|
|
233
223
|
} as any;
|
|
234
224
|
const headers = { "content-type": "application/json" };
|
|
235
225
|
|
|
@@ -259,7 +249,7 @@ Deno.test("pollIncidents picks the oldest incident by creationTime, sorting a mi
|
|
|
259
249
|
// source also keeps the returned key aligned with the DB-persisted `String(...)` value and dodges
|
|
260
250
|
// JS 53-bit precision limits for large 64-bit keys (which is why keys travel as strings in
|
|
261
251
|
// practice). `submitPr` must stringify it both in the returned body and the persisted row.
|
|
262
|
-
|
|
252
|
+
test("submitPr stringifies a numeric processInstanceKey (contract: string | null)", async () => {
|
|
263
253
|
await withGithubOff(async () => {
|
|
264
254
|
const PR_KEY = "owner/repo#7";
|
|
265
255
|
const stores: Record<string, { rows: unknown[]; key: string }> = {
|
|
@@ -269,14 +259,12 @@ Deno.test("submitPr stringifies a numeric processInstanceKey (contract: string |
|
|
|
269
259
|
};
|
|
270
260
|
const data = {
|
|
271
261
|
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
272
|
-
// deno-lint-ignore no-explicit-any
|
|
273
262
|
} as any;
|
|
274
263
|
const engine = {
|
|
275
264
|
// A large key delivered as a JS number — the exact case that breaks dev response validation
|
|
276
265
|
// (number vs the `string | null` contract). Kept within MAX_SAFE_INTEGER so the fixture
|
|
277
266
|
// itself is exact; true 64-bit keys travel as strings for the same precision reason.
|
|
278
267
|
createInstance: () => Promise.resolve({ processInstanceKey: 2251799813685249 }),
|
|
279
|
-
// deno-lint-ignore no-explicit-any
|
|
280
268
|
} as any;
|
|
281
269
|
|
|
282
270
|
const res = await submitPr(data, engine, {
|
|
@@ -286,7 +274,6 @@ Deno.test("submitPr stringifies a numeric processInstanceKey (contract: string |
|
|
|
286
274
|
prKey: PR_KEY,
|
|
287
275
|
});
|
|
288
276
|
|
|
289
|
-
// deno-lint-ignore no-explicit-any
|
|
290
277
|
const processKey = (res as any).processKey;
|
|
291
278
|
assertEquals(typeof processKey, "string");
|
|
292
279
|
assertEquals(processKey, "2251799813685249");
|