@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
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
// PR in `waiting_review` so the deterministic poller (app/service.ts) starts soliciting a review.
|
|
7
7
|
// A `waiting` round is what replaced the old failure mode where an agent with nothing to do
|
|
8
8
|
// re-requested the review destructively and escalated `blocked`.
|
|
9
|
-
import {
|
|
9
|
+
import { test } from "node:test";
|
|
10
|
+
import { assertEquals } from "#test-assert";
|
|
10
11
|
import handler from "../workers/persist-round/worker.ts";
|
|
11
12
|
|
|
12
13
|
function fakeApp() {
|
|
@@ -18,19 +19,15 @@ function fakeApp() {
|
|
|
18
19
|
table(name: string, _key: string) {
|
|
19
20
|
const store = (rows[name] ??= new Map());
|
|
20
21
|
return {
|
|
21
|
-
// deno-lint-ignore require-await
|
|
22
22
|
async get(key: string) {
|
|
23
23
|
return store.get(key);
|
|
24
24
|
},
|
|
25
|
-
// deno-lint-ignore require-await
|
|
26
25
|
async insert(row: unknown) {
|
|
27
26
|
(inserts[name] ??= []).push(row);
|
|
28
27
|
const pk = name === "rounds" ? "id" : "pr_key";
|
|
29
|
-
// deno-lint-ignore no-explicit-any
|
|
30
28
|
store.set((row as any)[pk], row);
|
|
31
29
|
return 1;
|
|
32
30
|
},
|
|
33
|
-
// deno-lint-ignore require-await
|
|
34
31
|
async update(key: string, patch: unknown) {
|
|
35
32
|
(updates[name] ??= []).push({ key, patch });
|
|
36
33
|
},
|
|
@@ -42,20 +39,17 @@ function fakeApp() {
|
|
|
42
39
|
}
|
|
43
40
|
|
|
44
41
|
for (const status of ["addressed", "waiting"]) {
|
|
45
|
-
|
|
42
|
+
test(`persist-round records a '${status}' round and parks the PR in waiting_review`, async () => {
|
|
46
43
|
const { app, inserts, updates } = fakeApp();
|
|
47
44
|
const job = { variables: { prKey: "o/r#1", round: 1, status, summary: `round was ${status}` } };
|
|
48
|
-
// deno-lint-ignore no-explicit-any
|
|
49
45
|
await handler(job as any, app as any);
|
|
50
46
|
|
|
51
47
|
assertEquals(inserts.rounds.length, 1, "the round is recorded");
|
|
52
|
-
// deno-lint-ignore no-explicit-any
|
|
53
48
|
const round = inserts.rounds[0] as any;
|
|
54
49
|
assertEquals(round.status, status, "the round carries the agent's status");
|
|
55
50
|
assertEquals(round.round_no, 1);
|
|
56
51
|
|
|
57
52
|
assertEquals(updates.pull_requests!.length, 1, "the PR is updated once");
|
|
58
|
-
// deno-lint-ignore no-explicit-any
|
|
59
53
|
const patch = (updates.pull_requests![0] as any).patch;
|
|
60
54
|
assertEquals(patch.status, "waiting_review", "the PR parks in waiting_review for the poller");
|
|
61
55
|
assertEquals(patch.current_round, 1);
|
|
@@ -65,7 +59,7 @@ for (const status of ["addressed", "waiting"]) {
|
|
|
65
59
|
// When the convergence-loop passes repo/prNumber and the FK parent is missing (engine/app.db
|
|
66
60
|
// desync), persist-round reconstructs the `pull_requests` row before recording the round so the
|
|
67
61
|
// insert never dies with an opaque FOREIGN KEY constraint failure.
|
|
68
|
-
|
|
62
|
+
test("persist-round heals a missing pull_requests parent before recording the round", async () => {
|
|
69
63
|
const { app, inserts, updates } = fakeApp();
|
|
70
64
|
const job = {
|
|
71
65
|
variables: {
|
|
@@ -76,38 +70,33 @@ Deno.test("persist-round heals a missing pull_requests parent before recording t
|
|
|
76
70
|
prNumber: 7,
|
|
77
71
|
},
|
|
78
72
|
};
|
|
79
|
-
// deno-lint-ignore no-explicit-any
|
|
80
73
|
await handler(job as any, app as any);
|
|
81
74
|
|
|
82
75
|
assertEquals(inserts.pull_requests?.length, 1, "the missing parent is reconstructed");
|
|
83
76
|
// Assert against the reconstruction insert payload (ensurePr) rather than the stored row: the
|
|
84
77
|
// fake update() doesn't apply patches, so the row would otherwise still read the insert's
|
|
85
78
|
// "converging" status and mask the worker's real final state.
|
|
86
|
-
// deno-lint-ignore no-explicit-any
|
|
87
79
|
const healed = inserts.pull_requests![0] as any;
|
|
88
80
|
assertEquals(healed.status, "converging", "the healed parent starts in the converging aggregate");
|
|
89
81
|
assertEquals(healed.url, "https://github.com/o/r/pull/7", "URL is derived canonically");
|
|
90
82
|
assertEquals(inserts.rounds.length, 1, "the round is still recorded after the heal");
|
|
91
83
|
// And the worker still parks the (now-present) PR in waiting_review as its final state.
|
|
92
84
|
assertEquals(updates.pull_requests!.length, 1, "the PR is updated once after the heal");
|
|
93
|
-
// deno-lint-ignore no-explicit-any
|
|
94
85
|
assertEquals((updates.pull_requests![0] as any).patch.status, "waiting_review");
|
|
95
86
|
});
|
|
96
87
|
|
|
97
88
|
// rather than writing a NULL status — the round history stays readable.
|
|
98
|
-
|
|
89
|
+
test("persist-round defaults a missing status to 'addressed'", async () => {
|
|
99
90
|
const { app, inserts } = fakeApp();
|
|
100
91
|
const job = { variables: { prKey: "o/r#1", round: 2 } };
|
|
101
|
-
// deno-lint-ignore no-explicit-any
|
|
102
92
|
await handler(job as any, app as any);
|
|
103
|
-
// deno-lint-ignore no-explicit-any
|
|
104
93
|
assertEquals((inserts.rounds[0] as any).status, "addressed");
|
|
105
94
|
});
|
|
106
95
|
|
|
107
96
|
// When repo/prNumber process variables are absent (an older in-flight instance, or a regression)
|
|
108
97
|
// the heal still runs by parsing the canonical `owner/repo#N` prKey, so the FK-child insert is
|
|
109
98
|
// never left unguarded.
|
|
110
|
-
|
|
99
|
+
test("persist-round heals from the prKey when repo/prNumber are absent", async () => {
|
|
111
100
|
const { app, inserts } = fakeApp();
|
|
112
101
|
const job = {
|
|
113
102
|
variables: {
|
|
@@ -117,10 +106,8 @@ Deno.test("persist-round heals from the prKey when repo/prNumber are absent", as
|
|
|
117
106
|
abandonUrl: "https://host/hooks/abandon?token=TOK-en_123",
|
|
118
107
|
},
|
|
119
108
|
};
|
|
120
|
-
// deno-lint-ignore no-explicit-any
|
|
121
109
|
await handler(job as any, app as any);
|
|
122
110
|
assertEquals(inserts.pull_requests?.length, 1, "the parent is reconstructed from the prKey");
|
|
123
|
-
// deno-lint-ignore no-explicit-any
|
|
124
111
|
const healed = inserts.pull_requests![0] as any;
|
|
125
112
|
assertEquals(healed.repo, "o/r");
|
|
126
113
|
assertEquals(healed.number, 12);
|
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);
|
package/app/plan.ts
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
// hand-written SQL — matching app/service.ts.
|
|
12
12
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
13
13
|
import { blackboardUrl, mintBlackboardToken, renderCoordinationBrief } from "./blackboard.ts";
|
|
14
|
-
import { clearTaskDeltas } from "./taskDelta.ts";
|
|
15
14
|
import { clearExclusions } from "./mergeExclusion.ts";
|
|
15
|
+
import { clearTaskDeltas } from "./taskDelta.ts";
|
|
16
16
|
|
|
17
17
|
/** The BPMN process this module drives (resources/processes/plan-fanout.bpmn). */
|
|
18
18
|
export const PLAN_PROCESS_ID = "plan-fanout";
|
|
@@ -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
|
});
|