@nanobpm/nano-workforce 0.36.0 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +7 -17
- package/AGENTS.md +8 -7
- package/CHANGELOG.md +14 -0
- package/README.md +6 -27
- package/SPEC.md +15 -13
- package/app/abandon.test.ts +14 -19
- package/app/abandon.ts +2 -2
- package/app/baseGuard.test.ts +7 -6
- package/app/blackboard.test.ts +25 -35
- package/app/blackboard.ts +2 -2
- package/app/ensure-pr.test.ts +10 -12
- package/app/github.test.ts +9 -8
- package/app/github.ts +1 -21
- package/app/instance-tracking.test.ts +8 -6
- package/app/mergeExclusion.test.ts +11 -20
- package/app/mergeProtocol.test.ts +14 -13
- package/app/mergeRebaseArm.test.ts +8 -6
- package/app/mergeTrain.test.ts +10 -9
- package/app/persist-escalation.test.ts +10 -31
- package/app/persist-round.test.ts +7 -20
- package/app/plan.test.ts +19 -42
- package/app/record-plan-review.test.ts +8 -7
- package/app/retro.test.ts +24 -48
- package/app/reviewWait.test.ts +12 -11
- package/app/rounds.test.ts +13 -12
- package/app/service.test.ts +14 -27
- package/app/service.ts +1 -1
- package/app/taskDelta.test.ts +8 -17
- package/app/trialMerge.test.ts +4 -3
- package/app/version.ts +6 -21
- package/app/waves.test.ts +17 -16
- package/biome.json +0 -2
- package/main.ts +3 -3
- package/nano.app.json +2 -25
- package/openapi.yaml +649 -0
- package/operations/answerFeatureEscalation.test.ts +100 -0
- package/operations/answerFeatureEscalation.ts +49 -0
- package/operations/appendBlackboard.ts +61 -0
- package/{actions → operations}/blackboard.test.ts +28 -30
- package/{actions/abandon.test.ts → operations/checkAbandon.test.ts} +12 -26
- package/{actions/abandon.ts → operations/checkAbandon.ts} +8 -7
- package/operations/getVersion.test.ts +8 -12
- package/operations/getVersion.ts +2 -3
- package/operations/listActivePrs.test.ts +8 -14
- package/operations/listActivePrs.ts +3 -4
- package/operations/postMessage.ts +1 -1
- package/operations/readBlackboard.ts +29 -0
- package/operations/startAndMessage.test.ts +11 -15
- package/operations/startConvergenceLoop.ts +17 -11
- package/operations/startPlanFanout.ts +13 -7
- package/package.json +9 -7
- package/pages/epic.page.json +1 -1
- package/pages/home.page.json +1 -1
- package/scripts/check-agent-prompts.test.ts +15 -11
- package/scripts/layout-bpmn.ts +6 -28
- package/scripts/pages-contract.test.ts +14 -13
- package/scripts/purge-db.ts +1 -1
- package/scripts/upgrade-from-pack.ts +1 -1
- package/test/assert.ts +74 -0
- package/tsconfig.json +4 -1
- package/workers/persist-task-escalation/worker.ts +1 -1
- package/workers/record-plan-review/worker.test.ts +6 -9
- package/workers/record-results/worker.test.ts +5 -8
- package/workers/record-trial-merge/worker.test.ts +7 -18
- package/workers/record-wave/worker.test.ts +13 -20
- package/workers/retro-gather/worker.test.ts +4 -17
- package/workers/retro-record/worker.test.ts +8 -27
- package/workers/select-wave/worker.test.ts +5 -8
- package/actions/blackboard.ts +0 -77
- package/actions/feature-answer-hook.ts +0 -45
- package/actions/plan-hook.ts +0 -20
- package/actions/webhook-submit.ts +0 -22
- package/deno.json +0 -24
- package/deno.lock +0 -1777
- package/openapi.json +0 -248
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
// passes `recordRound=false`, which must suppress the round insert while still opening the
|
|
7
7
|
// escalation. The agent-raised / max-rounds arms omit the flag (no prior round row) and must
|
|
8
8
|
// still record the round.
|
|
9
|
-
import {
|
|
9
|
+
import { test } from "node:test";
|
|
10
|
+
import { assert, assertEquals } from "#test-assert";
|
|
10
11
|
import handler from "../workers/persist-escalation/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 === "escalations" ? "id" : name === "rounds" ? "id" : "pr_key";
|
|
29
|
-
// deno-lint-ignore no-explicit-any
|
|
30
28
|
store.set((row as any)[pk], row);
|
|
31
29
|
return name === "escalations" ? 42 : 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
|
},
|
|
@@ -41,33 +38,29 @@ function fakeApp() {
|
|
|
41
38
|
return { app, inserts, updates, rows };
|
|
42
39
|
}
|
|
43
40
|
|
|
44
|
-
|
|
41
|
+
test("stalled arm (recordRound=false) does not insert a duplicate rounds row", async () => {
|
|
45
42
|
const { app, inserts } = fakeApp();
|
|
46
43
|
const job = {
|
|
47
44
|
variables: { prKey: "o/r#1", round: 3, status: "blocked", question: "stalled", recordRound: false },
|
|
48
45
|
};
|
|
49
|
-
// deno-lint-ignore no-explicit-any
|
|
50
46
|
const out = await handler(job as any, app as any);
|
|
51
47
|
assertEquals(inserts.rounds.length, 0, "no round row when the round was already recorded");
|
|
52
48
|
assertEquals(inserts.escalations.length, 1, "escalation is still opened");
|
|
53
|
-
// deno-lint-ignore no-explicit-any
|
|
54
49
|
assertEquals((out as any).escalationId, 42);
|
|
55
50
|
});
|
|
56
51
|
|
|
57
|
-
|
|
52
|
+
test("escalation arm without the flag still records the round", async () => {
|
|
58
53
|
const { app, inserts } = fakeApp();
|
|
59
54
|
const job = { variables: { prKey: "o/r#1", round: 3, status: "blocked", question: "max rounds" } };
|
|
60
|
-
// deno-lint-ignore no-explicit-any
|
|
61
55
|
await handler(job as any, app as any);
|
|
62
56
|
assertEquals(inserts.rounds.length, 1);
|
|
63
|
-
// deno-lint-ignore no-explicit-any
|
|
64
57
|
assertEquals((inserts.rounds[0] as any).round_no, 3);
|
|
65
58
|
});
|
|
66
59
|
|
|
67
60
|
// When the convergence-loop passes repo/prNumber and the FK parent is missing (engine/app.db
|
|
68
61
|
// desync), persist-escalation reconstructs the `pull_requests` row before the rounds/escalations
|
|
69
62
|
// inserts so opening an escalation never dies with an opaque FOREIGN KEY constraint failure.
|
|
70
|
-
|
|
63
|
+
test("persist-escalation heals a missing pull_requests parent before recording", async () => {
|
|
71
64
|
const { app, inserts, updates } = fakeApp();
|
|
72
65
|
const job = {
|
|
73
66
|
variables: {
|
|
@@ -79,32 +72,26 @@ Deno.test("persist-escalation heals a missing pull_requests parent before record
|
|
|
79
72
|
prNumber: 8,
|
|
80
73
|
},
|
|
81
74
|
};
|
|
82
|
-
// deno-lint-ignore no-explicit-any
|
|
83
75
|
await handler(job as any, app as any);
|
|
84
76
|
assertEquals(inserts.pull_requests?.length, 1, "the missing parent is reconstructed");
|
|
85
77
|
// Assert against the reconstruction insert payload (ensurePr) rather than the stored row: the
|
|
86
78
|
// fake update() doesn't apply patches, so the row would otherwise still read the insert's
|
|
87
79
|
// "converging" status and mask the worker's real final state.
|
|
88
|
-
// deno-lint-ignore no-explicit-any
|
|
89
80
|
const healed = inserts.pull_requests![0] as any;
|
|
90
81
|
assertEquals(healed.status, "converging", "the healed parent starts in the converging aggregate");
|
|
91
82
|
assertEquals(inserts.rounds.length, 1, "the round is still recorded");
|
|
92
83
|
assertEquals(inserts.escalations.length, 1, "the escalation is still opened");
|
|
93
84
|
// And the worker still moves the (now-present) PR to escalated as its final state.
|
|
94
85
|
assertEquals(updates.pull_requests!.length, 1, "the PR is updated once after the heal");
|
|
95
|
-
// deno-lint-ignore no-explicit-any
|
|
96
86
|
assertEquals((updates.pull_requests![0] as any).patch.status, "escalated");
|
|
97
87
|
});
|
|
98
88
|
|
|
99
89
|
|
|
100
|
-
|
|
90
|
+
test("a padded question is persisted trimmed (no whitespace drift)", async () => {
|
|
101
91
|
const { app, inserts, updates } = fakeApp();
|
|
102
92
|
const job = { variables: { prKey: "o/r#1", round: 4, status: "needs_input", question: " needs a decision " } };
|
|
103
|
-
// deno-lint-ignore no-explicit-any
|
|
104
93
|
await handler(job as any, app as any);
|
|
105
|
-
// deno-lint-ignore no-explicit-any
|
|
106
94
|
assertEquals((inserts.escalations[0] as any).question, "needs a decision", "escalation stores the trimmed question");
|
|
107
|
-
// deno-lint-ignore no-explicit-any
|
|
108
95
|
assertEquals((updates.pull_requests![0] as any).patch.open_escalation_question, "needs a decision", "denormalised question is trimmed too");
|
|
109
96
|
});
|
|
110
97
|
|
|
@@ -113,7 +100,7 @@ Deno.test("a padded question is persisted trimmed (no whitespace drift)", async
|
|
|
113
100
|
// on Magikcraft/nano-bpm #597/#599) must NOT throw (which parked an un-remediable JobNoRetries
|
|
114
101
|
// incident). It now opens an *answerable* escalation with a fabricated, concrete question and the
|
|
115
102
|
// agent's transcript attached, so a human can unblock the loop entirely from the UI.
|
|
116
|
-
|
|
103
|
+
test("blank question fabricates an answerable escalation (no throw, no incident)", async () => {
|
|
117
104
|
for (const question of [undefined, "", " "]) {
|
|
118
105
|
const { app, inserts, updates } = fakeApp();
|
|
119
106
|
const job = {
|
|
@@ -124,12 +111,9 @@ Deno.test("blank question fabricates an answerable escalation (no throw, no inci
|
|
|
124
111
|
"io.nanobpm.agentResult": { output: "the agent's prose review, no result file" },
|
|
125
112
|
},
|
|
126
113
|
};
|
|
127
|
-
// deno-lint-ignore no-explicit-any
|
|
128
114
|
const out = await handler(job as any, app as any);
|
|
129
|
-
// deno-lint-ignore no-explicit-any
|
|
130
115
|
assertEquals((out as any).escalationId, 42, "an escalation is opened, not refused");
|
|
131
116
|
assertEquals(inserts.escalations.length, 1, "escalation row written");
|
|
132
|
-
// deno-lint-ignore no-explicit-any
|
|
133
117
|
const esc = inserts.escalations[0] as any;
|
|
134
118
|
assert(esc.question.trim().length > 0, "fabricated question is concrete/non-blank");
|
|
135
119
|
assert(
|
|
@@ -139,7 +123,6 @@ Deno.test("blank question fabricates an answerable escalation (no throw, no inci
|
|
|
139
123
|
assertEquals(esc.transcript, "the agent's prose review, no result file", "transcript attached");
|
|
140
124
|
// Default status for an unclassified escalation is a question needing input.
|
|
141
125
|
assertEquals(esc.kind, "question");
|
|
142
|
-
// deno-lint-ignore no-explicit-any
|
|
143
126
|
const pr = updates.pull_requests![0] as any;
|
|
144
127
|
assertEquals(pr.patch.open_escalation_question, esc.question, "denormalised question set");
|
|
145
128
|
}
|
|
@@ -147,12 +130,10 @@ Deno.test("blank question fabricates an answerable escalation (no throw, no inci
|
|
|
147
130
|
|
|
148
131
|
// When a non-empty-but-unclassified status arrives with no question, the fabricated question
|
|
149
132
|
// names the status so the human sees what the agent reported.
|
|
150
|
-
|
|
133
|
+
test("unclassified status without a question names the status in the fabricated question", async () => {
|
|
151
134
|
const { app, inserts } = fakeApp();
|
|
152
135
|
const job = { variables: { prKey: "o/r#1", round: 3, status: "in_progress" } };
|
|
153
|
-
// deno-lint-ignore no-explicit-any
|
|
154
136
|
await handler(job as any, app as any);
|
|
155
|
-
// deno-lint-ignore no-explicit-any
|
|
156
137
|
const esc = inserts.escalations[0] as any;
|
|
157
138
|
assert(esc.question.includes("in_progress"), "fabricated question references the raw status");
|
|
158
139
|
assertEquals(esc.kind, "blocker", "a non needs_input status is a blocker escalation");
|
|
@@ -160,7 +141,7 @@ Deno.test("unclassified status without a question names the status in the fabric
|
|
|
160
141
|
|
|
161
142
|
// When repo/prNumber process variables are absent the heal still runs by parsing the canonical
|
|
162
143
|
// `owner/repo#N` prKey, so the escalation's FK parent is never left unguarded.
|
|
163
|
-
|
|
144
|
+
test("persist-escalation heals from the prKey when repo/prNumber are absent", async () => {
|
|
164
145
|
const { app, inserts } = fakeApp();
|
|
165
146
|
const job = {
|
|
166
147
|
variables: {
|
|
@@ -168,13 +149,11 @@ Deno.test("persist-escalation heals from the prKey when repo/prNumber are absent
|
|
|
168
149
|
round: 2,
|
|
169
150
|
status: "needs_input",
|
|
170
151
|
question: "decide",
|
|
171
|
-
abandonUrl: "https://host/hooks/abandon?token=TOK-en_123",
|
|
152
|
+
abandonUrl: "https://host/app/api/hooks/abandon?token=TOK-en_123",
|
|
172
153
|
},
|
|
173
154
|
};
|
|
174
|
-
// deno-lint-ignore no-explicit-any
|
|
175
155
|
await handler(job as any, app as any);
|
|
176
156
|
assertEquals(inserts.pull_requests?.length, 1, "the parent is reconstructed from the prKey");
|
|
177
|
-
// deno-lint-ignore no-explicit-any
|
|
178
157
|
const healed = inserts.pull_requests![0] as any;
|
|
179
158
|
assertEquals(healed.repo, "o/r");
|
|
180
159
|
assertEquals(healed.number, 12);
|
|
@@ -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,51 +70,44 @@ 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: {
|
|
114
103
|
prKey: "o/r#12",
|
|
115
104
|
round: 2,
|
|
116
105
|
status: "addressed",
|
|
117
|
-
abandonUrl: "https://host/hooks/abandon?token=TOK-en_123",
|
|
106
|
+
abandonUrl: "https://host/app/api/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);
|
|
@@ -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
|
});
|