@nanobpm/nano-workforce 0.26.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 +60 -0
- package/.github/workflows/release.yml +58 -0
- package/.releaserc.json +17 -0
- package/AGENTS.md +168 -0
- package/CHANGELOG.md +231 -0
- package/LICENSE +202 -0
- package/README.md +303 -0
- package/SPEC.md +492 -0
- package/actions/abandon.test.ts +93 -0
- package/actions/abandon.ts +23 -0
- package/actions/blackboard.test.ts +195 -0
- package/actions/blackboard.ts +76 -0
- package/actions/cancel.ts +29 -0
- package/actions/feature-answer-hook.ts +44 -0
- package/actions/message.ts +49 -0
- package/actions/plan-hook.ts +19 -0
- package/actions/plan-start.ts +17 -0
- package/actions/start.ts +19 -0
- package/actions/status.ts +22 -0
- package/actions/webhook-submit.ts +21 -0
- package/app/abandon.test.ts +97 -0
- package/app/abandon.ts +105 -0
- package/app/baseGuard.test.ts +35 -0
- package/app/baseGuard.ts +62 -0
- package/app/blackboard.test.ts +295 -0
- package/app/blackboard.ts +301 -0
- package/app/github.test.ts +59 -0
- package/app/github.ts +647 -0
- package/app/mergeExclusion.test.ts +168 -0
- package/app/mergeExclusion.ts +211 -0
- package/app/mergeProtocol.test.ts +124 -0
- package/app/mergeProtocol.ts +193 -0
- package/app/mergeRebaseArm.test.ts +72 -0
- package/app/mergeTrain.test.ts +91 -0
- package/app/mergeTrain.ts +117 -0
- package/app/persist-escalation.test.ts +119 -0
- package/app/persist-round.test.ts +65 -0
- package/app/plan.test.ts +317 -0
- package/app/plan.ts +321 -0
- package/app/record-plan-review.test.ts +38 -0
- package/app/reviewWait.test.ts +70 -0
- package/app/reviewWait.ts +59 -0
- package/app/rounds.test.ts +74 -0
- package/app/rounds.ts +48 -0
- package/app/service.test.ts +101 -0
- package/app/service.ts +895 -0
- package/app/taskDelta.test.ts +144 -0
- package/app/taskDelta.ts +175 -0
- package/app/trialMerge.test.ts +15 -0
- package/app/trialMerge.ts +102 -0
- package/app/waves.test.ts +128 -0
- package/app/waves.ts +116 -0
- package/assets/icon.svg +13 -0
- package/components/review-round.json +69 -0
- package/db/migrations/001_init.sql +46 -0
- package/db/migrations/002_transcript.sql +7 -0
- package/db/migrations/003_open_escalation.sql +8 -0
- package/db/migrations/004_merge.sql +36 -0
- package/db/migrations/004_planning.sql +37 -0
- package/db/migrations/005_job_activation.sql +15 -0
- package/db/migrations/005_plan_deps.sql +20 -0
- package/db/migrations/006_plan_review.sql +22 -0
- package/db/migrations/006_task_escalation.sql +52 -0
- package/db/migrations/007_plan_review_job_key.sql +14 -0
- package/db/migrations/007_wave_gate.sql +16 -0
- package/db/migrations/008_review_nudge.sql +9 -0
- package/db/migrations/009_plan_blackboard.sql +46 -0
- package/db/migrations/010_plan_task_deltas.sql +27 -0
- package/db/migrations/011_plan_merge_exclusions.sql +26 -0
- package/db/migrations/012_merge_protocol_attempt.sql +4 -0
- package/db/migrations/013_merge_train_waiting_lane.sql +6 -0
- package/db/migrations/014_plan_trial_merges.sql +21 -0
- package/db/migrations/015_pr_abandon_token.sql +9 -0
- package/deno.json +24 -0
- package/deno.lock +1776 -0
- package/main.ts +71 -0
- package/nano-ide.ext.json +7 -0
- package/nano.app.json +138 -0
- package/nanobpm.project.json +20 -0
- package/package.json +56 -0
- package/pages/epic.page.json +195 -0
- package/pages/home.page.json +296 -0
- package/prompts/feature.md +132 -0
- package/prompts/fix-ci.md +65 -0
- package/prompts/plan-review.md +69 -0
- package/prompts/plan.md +183 -0
- package/prompts/rebase.md +82 -0
- package/prompts/review-round.md +171 -0
- package/prompts/trial-merge.md +43 -0
- package/renovate.json +21 -0
- package/resources/processes/convergence-loop.bpmn +399 -0
- package/resources/processes/merge-loop.bpmn +585 -0
- package/resources/processes/plan-fanout.bpmn +546 -0
- package/scripts/check-agent-prompts.test.ts +84 -0
- package/scripts/check-agent-prompts.ts +143 -0
- package/scripts/layout-bpmn.ts +99 -0
- package/scripts/purge-db.ts +57 -0
- package/scripts/upgrade-from-pack.ts +334 -0
- package/tsconfig.json +51 -0
- package/workers/arm-merge/worker.ts +18 -0
- package/workers/finalize/worker.ts +89 -0
- package/workers/mark-merged/worker.ts +21 -0
- package/workers/merge/worker.ts +119 -0
- package/workers/persist-escalation/worker.ts +107 -0
- package/workers/persist-round/worker.ts +52 -0
- package/workers/persist-task-escalation/worker.ts +112 -0
- package/workers/record-plan/worker.ts +135 -0
- package/workers/record-plan-review/worker.ts +92 -0
- package/workers/record-results/worker.ts +30 -0
- package/workers/record-trial-merge/worker.test.ts +104 -0
- package/workers/record-trial-merge/worker.ts +88 -0
- package/workers/record-wave/worker.test.ts +221 -0
- package/workers/record-wave/worker.ts +308 -0
- package/workers/select-wave/worker.test.ts +130 -0
- package/workers/select-wave/worker.ts +84 -0
package/app/plan.test.ts
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// Red/green regression for the plan-review round-cap parsing (PR #26 review).
|
|
2
|
+
//
|
|
3
|
+
// `MAX_PLAN_REVIEW_ROUNDS` bounds the adversarial revise loop. If the env override parsed to
|
|
4
|
+
// `NaN`/`0` (e.g. unset, "", "abc"), the cap check `round + 1 >= cap` would never fire and the
|
|
5
|
+
// planner could revise forever. `positiveIntEnv` must fall back to the default on any value that
|
|
6
|
+
// is not a positive integer, so the loop is always bounded.
|
|
7
|
+
import { assertEquals } from "jsr:@std/assert@1";
|
|
8
|
+
import { positiveIntEnv } from "./plan.ts";
|
|
9
|
+
|
|
10
|
+
const KEY = "NANO_PLAN_REVIEW_ROUNDS_TEST";
|
|
11
|
+
|
|
12
|
+
function withEnv(value: string | undefined, run: () => void) {
|
|
13
|
+
const had = Object.prototype.hasOwnProperty.call(Deno.env.toObject(), KEY);
|
|
14
|
+
const prev = Deno.env.get(KEY);
|
|
15
|
+
try {
|
|
16
|
+
if (value === undefined) Deno.env.delete(KEY);
|
|
17
|
+
else Deno.env.set(KEY, value);
|
|
18
|
+
run();
|
|
19
|
+
} finally {
|
|
20
|
+
if (had && prev !== undefined) Deno.env.set(KEY, prev);
|
|
21
|
+
else Deno.env.delete(KEY);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
Deno.test("unset → fallback (bounded loop, never NaN)", () => {
|
|
26
|
+
withEnv(undefined, () => assertEquals(positiveIntEnv(KEY, 3), 3));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
Deno.test("blank/whitespace → fallback, not 0", () => {
|
|
30
|
+
withEnv("", () => assertEquals(positiveIntEnv(KEY, 3), 3));
|
|
31
|
+
withEnv(" ", () => assertEquals(positiveIntEnv(KEY, 3), 3));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
Deno.test("non-numeric → fallback, not NaN", () => {
|
|
35
|
+
withEnv("abc", () => assertEquals(positiveIntEnv(KEY, 3), 3));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
Deno.test("zero and negatives → fallback (cap must be >= 1)", () => {
|
|
39
|
+
withEnv("0", () => assertEquals(positiveIntEnv(KEY, 3), 3));
|
|
40
|
+
withEnv("-2", () => assertEquals(positiveIntEnv(KEY, 3), 3));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
Deno.test("non-integer → fallback", () => {
|
|
44
|
+
withEnv("2.5", () => assertEquals(positiveIntEnv(KEY, 3), 3));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
Deno.test("valid positive integer → honoured", () => {
|
|
48
|
+
withEnv("5", () => assertEquals(positiveIntEnv(KEY, 3), 5));
|
|
49
|
+
withEnv("1", () => assertEquals(positiveIntEnv(KEY, 3), 1));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Red/green regression for re-plan clearing `plan_reviews` (PR #26 review).
|
|
53
|
+
//
|
|
54
|
+
// `plan_reviews` is append-only and the review round is derived from `count(plan_reviews)`.
|
|
55
|
+
// When `startPlan` re-plans a previously finished issue it must clear the prior review rows,
|
|
56
|
+
// otherwise the stale count inflates the next round index and can trip `reviewExhausted` early,
|
|
57
|
+
// bypassing the adversarial gate. This drives `startPlan` against an in-memory data layer and
|
|
58
|
+
// asserts the `plan_reviews` rows for the plan key are gone after a re-plan.
|
|
59
|
+
import { startPlan } from "./plan.ts";
|
|
60
|
+
|
|
61
|
+
// deno-lint-ignore no-explicit-any
|
|
62
|
+
function memTable(rows: any[], key: string) {
|
|
63
|
+
return {
|
|
64
|
+
// deno-lint-ignore no-explicit-any
|
|
65
|
+
get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
|
|
66
|
+
// deno-lint-ignore no-explicit-any
|
|
67
|
+
find: (q: any) =>
|
|
68
|
+
Promise.resolve(
|
|
69
|
+
rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v)),
|
|
70
|
+
),
|
|
71
|
+
// deno-lint-ignore no-explicit-any
|
|
72
|
+
insert: (r: any) => {
|
|
73
|
+
rows.push(r);
|
|
74
|
+
return Promise.resolve(r);
|
|
75
|
+
},
|
|
76
|
+
// deno-lint-ignore no-explicit-any
|
|
77
|
+
update: (k: any, patch: any) => {
|
|
78
|
+
const r = rows.find((x) => x[key] === k);
|
|
79
|
+
if (r) Object.assign(r, patch);
|
|
80
|
+
return Promise.resolve(r);
|
|
81
|
+
},
|
|
82
|
+
// deno-lint-ignore no-explicit-any
|
|
83
|
+
delete: (k: any) => {
|
|
84
|
+
for (let i = rows.length - 1; i >= 0; i--) {
|
|
85
|
+
if (rows[i][key] === k) rows.splice(i, 1);
|
|
86
|
+
}
|
|
87
|
+
return Promise.resolve();
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
Deno.test("re-plan of a finished issue clears stale plan_reviews rows", async () => {
|
|
93
|
+
const PLAN_KEY = "owner/repo#7";
|
|
94
|
+
const stores: Record<string, { rows: unknown[]; key: string }> = {
|
|
95
|
+
plans: {
|
|
96
|
+
rows: [{ plan_key: PLAN_KEY, status: "done", task_count: 2 }],
|
|
97
|
+
key: "plan_key",
|
|
98
|
+
},
|
|
99
|
+
plan_tasks: {
|
|
100
|
+
rows: [{ id: 1, plan_key: PLAN_KEY }, { id: 2, plan_key: PLAN_KEY }],
|
|
101
|
+
key: "id",
|
|
102
|
+
},
|
|
103
|
+
plan_reviews: {
|
|
104
|
+
rows: [
|
|
105
|
+
{ plan_key: PLAN_KEY, round: 0 },
|
|
106
|
+
{ plan_key: PLAN_KEY, round: 1 },
|
|
107
|
+
],
|
|
108
|
+
key: "plan_key",
|
|
109
|
+
},
|
|
110
|
+
plan_task_deps: { rows: [], key: "plan_key" },
|
|
111
|
+
};
|
|
112
|
+
const data = {
|
|
113
|
+
table: (name: string, key: string) =>
|
|
114
|
+
memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
115
|
+
// deno-lint-ignore no-explicit-any
|
|
116
|
+
} as any;
|
|
117
|
+
const engine = {
|
|
118
|
+
createInstance: () => Promise.resolve({ processInstanceKey: "PI-1" }),
|
|
119
|
+
// deno-lint-ignore no-explicit-any
|
|
120
|
+
} as any;
|
|
121
|
+
|
|
122
|
+
await startPlan(data, engine, {
|
|
123
|
+
repo: "owner/repo",
|
|
124
|
+
number: 7,
|
|
125
|
+
url: "https://github.com/owner/repo/issues/7",
|
|
126
|
+
planKey: PLAN_KEY,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
assertEquals(stores.plan_reviews.rows.length, 0);
|
|
130
|
+
assertEquals(stores.plan_tasks.rows.length, 0);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Red/green regression for re-plan clearing stale open escalations (issue #25).
|
|
134
|
+
//
|
|
135
|
+
// `plan_escalations` is written by the implementation-phase escalation loop and denormalised onto
|
|
136
|
+
// the plan row (`open_task_*`). When `startPlan` re-plans a finished issue it deletes the prior
|
|
137
|
+
// `plan_tasks`, so any still-"open" escalation from that run points at a task that no longer
|
|
138
|
+
// exists. If those rows (and the plan's denormalised pointer) survive the re-plan,
|
|
139
|
+
// `refreshOpenTaskEscalation` re-surfaces a dead question in the answer form — the same
|
|
140
|
+
// stale-row class as `plan_reviews` above. This drives `startPlan` against the in-memory data
|
|
141
|
+
// layer and asserts both the escalation rows and the denormalised pointer are cleared.
|
|
142
|
+
Deno.test("re-plan of a finished issue clears stale open escalations and the denormalised open_task_* pointer", async () => {
|
|
143
|
+
const PLAN_KEY = "owner/repo#8";
|
|
144
|
+
const stores: Record<string, { rows: unknown[]; key: string }> = {
|
|
145
|
+
plans: {
|
|
146
|
+
rows: [{
|
|
147
|
+
plan_key: PLAN_KEY,
|
|
148
|
+
status: "done",
|
|
149
|
+
task_count: 1,
|
|
150
|
+
open_task_escalation_id: 5,
|
|
151
|
+
open_task_question: "stale question from prior run?",
|
|
152
|
+
open_task_corr_key: `${PLAN_KEY}:task-1`,
|
|
153
|
+
open_task_id: "task-1",
|
|
154
|
+
}],
|
|
155
|
+
key: "plan_key",
|
|
156
|
+
},
|
|
157
|
+
plan_tasks: { rows: [{ id: 1, plan_key: PLAN_KEY, task_id: "task-1" }], key: "id" },
|
|
158
|
+
plan_reviews: { rows: [], key: "plan_key" },
|
|
159
|
+
plan_escalations: {
|
|
160
|
+
rows: [{
|
|
161
|
+
id: 5,
|
|
162
|
+
plan_key: PLAN_KEY,
|
|
163
|
+
task_id: "task-1",
|
|
164
|
+
corr_key: `${PLAN_KEY}:task-1`,
|
|
165
|
+
question: "stale question from prior run?",
|
|
166
|
+
status: "open",
|
|
167
|
+
}],
|
|
168
|
+
key: "id",
|
|
169
|
+
},
|
|
170
|
+
plan_task_deps: { rows: [], key: "plan_key" },
|
|
171
|
+
};
|
|
172
|
+
const data = {
|
|
173
|
+
table: (name: string, key: string) =>
|
|
174
|
+
memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
175
|
+
// deno-lint-ignore no-explicit-any
|
|
176
|
+
} as any;
|
|
177
|
+
const engine = {
|
|
178
|
+
createInstance: () => Promise.resolve({ processInstanceKey: "PI-1" }),
|
|
179
|
+
// deno-lint-ignore no-explicit-any
|
|
180
|
+
} as any;
|
|
181
|
+
|
|
182
|
+
await startPlan(data, engine, {
|
|
183
|
+
repo: "owner/repo",
|
|
184
|
+
number: 8,
|
|
185
|
+
url: "https://github.com/owner/repo/issues/8",
|
|
186
|
+
planKey: PLAN_KEY,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Stale escalation rows from the prior run must not survive a re-plan …
|
|
190
|
+
assertEquals(stores.plan_escalations.rows.length, 0);
|
|
191
|
+
// … and the plan's denormalised "surfaced escalation" pointer must be reset,
|
|
192
|
+
// so `refreshOpenTaskEscalation` can't re-surface a question for a deleted task.
|
|
193
|
+
const plan = stores.plans.rows[0] as Record<string, unknown>;
|
|
194
|
+
assertEquals(plan.open_task_escalation_id, null);
|
|
195
|
+
assertEquals(plan.open_task_question, null);
|
|
196
|
+
assertEquals(plan.open_task_corr_key, null);
|
|
197
|
+
assertEquals(plan.open_task_id, null);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Red/green coverage for the implementation-phase escalation lifecycle (issue #25).
|
|
201
|
+
//
|
|
202
|
+
// `refreshOpenTaskEscalation` and `answerTaskEscalation` (issue #25) drive new stateful
|
|
203
|
+
// behaviour — denormalising the plan's "surfaced" escalation, mirroring the answer onto the
|
|
204
|
+
// task row, and publishing the correlated resume message — that had no unit coverage. These
|
|
205
|
+
// drive both against the in-memory data layer above and assert the oldest-first surfacing,
|
|
206
|
+
// the answer mirroring, and the published message.
|
|
207
|
+
import { answerTaskEscalation, refreshOpenTaskEscalation } from "./plan.ts";
|
|
208
|
+
|
|
209
|
+
function escalationStores(rows: unknown[]): Record<string, { rows: unknown[]; key: string }> {
|
|
210
|
+
return {
|
|
211
|
+
plans: { rows: [{ plan_key: "owner/repo#9" }], key: "plan_key" },
|
|
212
|
+
plan_escalations: { rows, key: "id" },
|
|
213
|
+
plan_tasks: { rows: [], key: "id" },
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// deno-lint-ignore no-explicit-any
|
|
218
|
+
function memData(stores: Record<string, { rows: any[]; key: string }>) {
|
|
219
|
+
return {
|
|
220
|
+
table: (name: string, key: string) =>
|
|
221
|
+
memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
222
|
+
// deno-lint-ignore no-explicit-any
|
|
223
|
+
} as any;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
Deno.test("refreshOpenTaskEscalation surfaces the OLDEST open escalation, then clears when none remain", async () => {
|
|
227
|
+
const stores = escalationStores([
|
|
228
|
+
{ id: 2, plan_key: "owner/repo#9", task_id: "b", corr_key: "owner/repo#9:b", question: "Q-b", status: "open" },
|
|
229
|
+
{ id: 1, plan_key: "owner/repo#9", task_id: "a", corr_key: "owner/repo#9:a", question: "Q-a", status: "open" },
|
|
230
|
+
]);
|
|
231
|
+
const data = memData(stores);
|
|
232
|
+
|
|
233
|
+
await refreshOpenTaskEscalation(data, "owner/repo#9");
|
|
234
|
+
// deno-lint-ignore no-explicit-any
|
|
235
|
+
let plan = stores.plans.rows[0] as any;
|
|
236
|
+
assertEquals(plan.open_task_escalation_id, 1);
|
|
237
|
+
assertEquals(plan.open_task_question, "Q-a");
|
|
238
|
+
assertEquals(plan.open_task_corr_key, "owner/repo#9:a");
|
|
239
|
+
assertEquals(plan.open_task_id, "a");
|
|
240
|
+
|
|
241
|
+
// Once the oldest is answered, the next-oldest is surfaced.
|
|
242
|
+
// deno-lint-ignore no-explicit-any
|
|
243
|
+
(stores.plan_escalations.rows.find((r: any) => r.id === 1) as any).status = "answered";
|
|
244
|
+
await refreshOpenTaskEscalation(data, "owner/repo#9");
|
|
245
|
+
// deno-lint-ignore no-explicit-any
|
|
246
|
+
plan = stores.plans.rows[0] as any;
|
|
247
|
+
assertEquals(plan.open_task_escalation_id, 2);
|
|
248
|
+
assertEquals(plan.open_task_id, "b");
|
|
249
|
+
|
|
250
|
+
// With nothing open the denormalised fields clear.
|
|
251
|
+
// deno-lint-ignore no-explicit-any
|
|
252
|
+
(stores.plan_escalations.rows.find((r: any) => r.id === 2) as any).status = "answered";
|
|
253
|
+
await refreshOpenTaskEscalation(data, "owner/repo#9");
|
|
254
|
+
// deno-lint-ignore no-explicit-any
|
|
255
|
+
plan = stores.plans.rows[0] as any;
|
|
256
|
+
assertEquals(plan.open_task_escalation_id, null);
|
|
257
|
+
assertEquals(plan.open_task_question, null);
|
|
258
|
+
assertEquals(plan.open_task_corr_key, null);
|
|
259
|
+
assertEquals(plan.open_task_id, null);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
Deno.test("answerTaskEscalation records the answer, mirrors it onto the task, publishes the resume message, and re-surfaces the next escalation", async () => {
|
|
263
|
+
const stores = escalationStores([
|
|
264
|
+
{ id: 1, plan_key: "owner/repo#9", task_id: "a", corr_key: "owner/repo#9:a", question: "Q-a", status: "open", answer: null },
|
|
265
|
+
{ id: 2, plan_key: "owner/repo#9", task_id: "b", corr_key: "owner/repo#9:b", question: "Q-b", status: "open", answer: null },
|
|
266
|
+
]);
|
|
267
|
+
stores.plan_tasks.rows.push({ id: 10, plan_key: "owner/repo#9", task_id: "a", answer: null });
|
|
268
|
+
const data = memData(stores);
|
|
269
|
+
|
|
270
|
+
// deno-lint-ignore no-explicit-any
|
|
271
|
+
const published: any[] = [];
|
|
272
|
+
const engine = {
|
|
273
|
+
// deno-lint-ignore no-explicit-any
|
|
274
|
+
publishMessage: (m: any) => {
|
|
275
|
+
published.push(m);
|
|
276
|
+
return Promise.resolve();
|
|
277
|
+
},
|
|
278
|
+
// deno-lint-ignore no-explicit-any
|
|
279
|
+
} as any;
|
|
280
|
+
|
|
281
|
+
const r = await answerTaskEscalation(data, engine, "owner/repo#9:a", "do it");
|
|
282
|
+
assertEquals(r.ok, true);
|
|
283
|
+
assertEquals(r.escalationId, 1);
|
|
284
|
+
assertEquals(r.planKey, "owner/repo#9");
|
|
285
|
+
assertEquals(r.taskId, "a");
|
|
286
|
+
|
|
287
|
+
// Escalation row marked answered with the recorded answer.
|
|
288
|
+
// deno-lint-ignore no-explicit-any
|
|
289
|
+
const esc = stores.plan_escalations.rows.find((x: any) => x.id === 1) as any;
|
|
290
|
+
assertEquals(esc.status, "answered");
|
|
291
|
+
assertEquals(esc.answer, "do it");
|
|
292
|
+
|
|
293
|
+
// Answer mirrored onto the task row.
|
|
294
|
+
// deno-lint-ignore no-explicit-any
|
|
295
|
+
assertEquals((stores.plan_tasks.rows[0] as any).answer, "do it");
|
|
296
|
+
|
|
297
|
+
// Correlated resume message published on the shared constant channel.
|
|
298
|
+
assertEquals(published.length, 1);
|
|
299
|
+
assertEquals(published[0].name, "feature-escalation-answered");
|
|
300
|
+
assertEquals(published[0].correlationKey, "owner/repo#9:a");
|
|
301
|
+
assertEquals(published[0].variables.answer, "do it");
|
|
302
|
+
|
|
303
|
+
// Next-oldest open escalation re-surfaced on the plan row.
|
|
304
|
+
// deno-lint-ignore no-explicit-any
|
|
305
|
+
assertEquals((stores.plans.rows[0] as any).open_task_escalation_id, 2);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
Deno.test("answerTaskEscalation is a no-op when no open escalation matches the correlation key", async () => {
|
|
309
|
+
const stores = escalationStores([]);
|
|
310
|
+
const data = memData(stores);
|
|
311
|
+
const engine = {
|
|
312
|
+
publishMessage: () => Promise.reject(new Error("should not publish")),
|
|
313
|
+
// deno-lint-ignore no-explicit-any
|
|
314
|
+
} as any;
|
|
315
|
+
const r = await answerTaskEscalation(data, engine, "owner/repo#9:missing", "x");
|
|
316
|
+
assertEquals(r.ok, false);
|
|
317
|
+
});
|
package/app/plan.ts
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
// nano-workforce — planning fan-out logic (issue #14).
|
|
2
|
+
//
|
|
3
|
+
// A planning agent (`senior:plan`) decomposes an issue into a list of tasks; the
|
|
4
|
+
// `plan-fanout` process then fans those tasks out over a parallel multi-instance
|
|
5
|
+
// service task (`senior:feature`), one implementation agent per task. Each agent
|
|
6
|
+
// opens a PR, which `record-results` enrolls into the existing convergence loop.
|
|
7
|
+
//
|
|
8
|
+
// This module is the seam the start actions and the record workers call: it owns
|
|
9
|
+
// issue parsing, the plan/plan_tasks row shapes, the prompt assets, and starting
|
|
10
|
+
// the process. Data access goes through the record gateway (`data.table`), never
|
|
11
|
+
// hand-written SQL — matching app/service.ts.
|
|
12
|
+
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
13
|
+
import { blackboardUrl, mintBlackboardToken, renderCoordinationBrief } from "./blackboard.ts";
|
|
14
|
+
import { clearTaskDeltas } from "./taskDelta.ts";
|
|
15
|
+
import { clearExclusions } from "./mergeExclusion.ts";
|
|
16
|
+
|
|
17
|
+
/** The BPMN process this module drives (resources/processes/plan-fanout.bpmn). */
|
|
18
|
+
export const PLAN_PROCESS_ID = "plan-fanout";
|
|
19
|
+
|
|
20
|
+
const now = () => new Date().toISOString();
|
|
21
|
+
|
|
22
|
+
// Agent prompts are no longer read by the host. The `senior:plan`, `senior:plan-review`, and
|
|
23
|
+
// `senior:feature` prompts are authored in the model as `{{plan}}` / `{{plan-review}}` /
|
|
24
|
+
// `{{feature}}` deploy-time templates (see `models.templates` in nano.app.json) substituted into
|
|
25
|
+
// each task's `io.nanobpm.agentTask.task.prompt` header. Per-instance dynamic context (a plan's
|
|
26
|
+
// rejection findings, a task's brief) rides `appendPrompt`, which the harness concatenates onto
|
|
27
|
+
// the header base. The host only carries runtime identity + `planFindings`.
|
|
28
|
+
|
|
29
|
+
export interface Plan {
|
|
30
|
+
plan_key: string;
|
|
31
|
+
repo: string;
|
|
32
|
+
issue_number: number;
|
|
33
|
+
issue_url: string;
|
|
34
|
+
title: string | null;
|
|
35
|
+
status: string;
|
|
36
|
+
task_count: number;
|
|
37
|
+
process_key: string | null;
|
|
38
|
+
outcome: string | null;
|
|
39
|
+
// Denormalised "oldest open task escalation" pointer (issue #25): the plans page
|
|
40
|
+
// detail has a single answer form per row, so the oldest still-open per-task
|
|
41
|
+
// escalation is surfaced here; answering re-points these at the next one (or
|
|
42
|
+
// clears them). See refreshOpenTaskEscalation.
|
|
43
|
+
open_task_escalation_id: number | null;
|
|
44
|
+
open_task_question: string | null;
|
|
45
|
+
open_task_corr_key: string | null;
|
|
46
|
+
open_task_id: string | null;
|
|
47
|
+
// Wave-merge barrier (007_wave_gate.sql): the wave index whose PRs the plan is currently
|
|
48
|
+
// waiting to see MERGED before dispatching the next wave, or null when not parked at the barrier.
|
|
49
|
+
gate_wave: number | null;
|
|
50
|
+
// Per-plan capability token for the coordination blackboard (009_plan_blackboard.sql, #51).
|
|
51
|
+
// Minted at plan start; baked into the blackboard URL handed to implementer agents. NULL for
|
|
52
|
+
// plans created before the blackboard shipped.
|
|
53
|
+
blackboard_token: string | null;
|
|
54
|
+
created_at: string;
|
|
55
|
+
updated_at: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface PlanTask {
|
|
59
|
+
id: number;
|
|
60
|
+
plan_key: string;
|
|
61
|
+
task_index: number;
|
|
62
|
+
task_id: string;
|
|
63
|
+
title: string | null;
|
|
64
|
+
prompt: string | null;
|
|
65
|
+
status: PlanTaskStatus;
|
|
66
|
+
pr_key: string | null;
|
|
67
|
+
summary: string | null;
|
|
68
|
+
wave: number | null;
|
|
69
|
+
// Implementation-phase escalation (issue #25): the agent's open question, the
|
|
70
|
+
// human's answer, the work-preserving draft PR, and the message correlation key
|
|
71
|
+
// (`<plan_key>:<task_id>`) the process parks on. NULL unless the task escalated.
|
|
72
|
+
open_question: string | null;
|
|
73
|
+
answer: string | null;
|
|
74
|
+
draft_pr_key: string | null;
|
|
75
|
+
corr_key: string | null;
|
|
76
|
+
created_at: string;
|
|
77
|
+
updated_at: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const PLAN_TASK_STATUSES = [
|
|
81
|
+
"pending",
|
|
82
|
+
"opened",
|
|
83
|
+
"blocked",
|
|
84
|
+
"skipped",
|
|
85
|
+
"escalated",
|
|
86
|
+
"waiting-for-lane",
|
|
87
|
+
] as const;
|
|
88
|
+
export type PlanTaskStatus = typeof PLAN_TASK_STATUSES[number];
|
|
89
|
+
|
|
90
|
+
/** One implementation-phase escalation (issue #25) — the per-task analogue of the
|
|
91
|
+
* review loop's `escalations` row. `status` is open | answered. */
|
|
92
|
+
export interface PlanEscalation {
|
|
93
|
+
id: number;
|
|
94
|
+
plan_key: string;
|
|
95
|
+
task_id: string;
|
|
96
|
+
corr_key: string;
|
|
97
|
+
question: string;
|
|
98
|
+
answer: string | null;
|
|
99
|
+
draft_pr_key: string | null;
|
|
100
|
+
status: string;
|
|
101
|
+
asked_at: string;
|
|
102
|
+
answered_at: string | null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const plans = (data: DataLayer) => data.table<Plan>("plans", "plan_key");
|
|
106
|
+
export const planTasks = (data: DataLayer) => data.table<PlanTask>("plan_tasks", "id");
|
|
107
|
+
export const planEscalations = (data: DataLayer) =>
|
|
108
|
+
data.table<PlanEscalation>("plan_escalations", "id");
|
|
109
|
+
|
|
110
|
+
/** The message the plan-fanout process catches to resume an escalated task; its
|
|
111
|
+
* subscription correlates on `<plan_key>:<task_id>` (see plan-fanout.bpmn). */
|
|
112
|
+
export const FEATURE_ESCALATION_MESSAGE = "feature-escalation-answered";
|
|
113
|
+
|
|
114
|
+
/** Build the per-task message correlation key the process parks on. */
|
|
115
|
+
export const featureCorrKey = (planKey: string, taskId: string) => `${planKey}:${taskId}`;
|
|
116
|
+
|
|
117
|
+
/** One dependency edge in the plan DAG (issue #20): `task_id` waits for `depends_on_task_id`.
|
|
118
|
+
* Keyed on `plan_key` so a single delete clears a plan's whole edge set (as pr_dependencies). */
|
|
119
|
+
export interface PlanTaskDep {
|
|
120
|
+
plan_key: string;
|
|
121
|
+
task_id: string;
|
|
122
|
+
depends_on_task_id: string;
|
|
123
|
+
}
|
|
124
|
+
export const planTaskDeps = (data: DataLayer) =>
|
|
125
|
+
data.table<PlanTaskDep>("plan_task_deps", "plan_key");
|
|
126
|
+
|
|
127
|
+
/** One adversarial plan-review round (006_plan_review.sql): the `senior:plan-review` agent's
|
|
128
|
+
* verdict on the plan before fan-out. Append-only within a plan run; the current round is
|
|
129
|
+
* `count(plan_reviews)`. Re-planning a finished issue clears the prior rows (see startPlan) so
|
|
130
|
+
* the round index restarts at 0.
|
|
131
|
+
* `job_key` is the engine job key that wrote the row — an idempotency guard so a retried job
|
|
132
|
+
* (crash/timeout after the insert) reuses its row instead of appending a duplicate round. */
|
|
133
|
+
export interface PlanReview {
|
|
134
|
+
plan_key: string;
|
|
135
|
+
round: number;
|
|
136
|
+
approved: number;
|
|
137
|
+
findings: string | null;
|
|
138
|
+
created_at: string;
|
|
139
|
+
job_key: string | null;
|
|
140
|
+
}
|
|
141
|
+
export const planReviews = (data: DataLayer) => data.table<PlanReview>("plan_reviews", "plan_key");
|
|
142
|
+
|
|
143
|
+
/** Read a positive-integer env override, falling back when unset/blank/invalid. A bad value
|
|
144
|
+
* (e.g. "", "abc", "0", "2.5") must NOT silently become `NaN`/`0` — that would make the round
|
|
145
|
+
* cap `round + 1 >= cap` always false and allow an unbounded revise loop. */
|
|
146
|
+
export function positiveIntEnv(name: string, fallback: number): number {
|
|
147
|
+
const raw = process.env[name];
|
|
148
|
+
if (raw == null || raw.trim() === "") return fallback;
|
|
149
|
+
const n = Number(raw);
|
|
150
|
+
return Number.isInteger(n) && n > 0 ? n : fallback;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Max adversarial plan-review rounds before the fan-out proceeds regardless (so a reviewer that
|
|
154
|
+
* never approves can't dead-lock the plan). The last round's findings are still recorded. */
|
|
155
|
+
export const MAX_PLAN_REVIEW_ROUNDS = positiveIntEnv("NANO_PLAN_REVIEW_ROUNDS", 3);
|
|
156
|
+
|
|
157
|
+
/** A plan is "done" in exactly these states; everything else (planning, dispatched)
|
|
158
|
+
* is in flight. The cancel guard and the active view key off this. */
|
|
159
|
+
export const PLAN_TERMINAL_STATUSES: readonly string[] = ["done", "failed", "abandoned"];
|
|
160
|
+
|
|
161
|
+
export interface ParsedIssue {
|
|
162
|
+
repo: string;
|
|
163
|
+
number: number;
|
|
164
|
+
url: string;
|
|
165
|
+
planKey: string;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Parse "owner/repo#123" or a canonical issue URL into its parts. Mirrors parsePr
|
|
169
|
+
* (app/service.ts) but for the /issues/ path. */
|
|
170
|
+
export function parseIssue(input: string): ParsedIssue | null {
|
|
171
|
+
const s = input.trim();
|
|
172
|
+
let m = s.match(/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)/i);
|
|
173
|
+
if (m) {
|
|
174
|
+
const repo = `${m[1]}/${m[2]}`;
|
|
175
|
+
const number = Number(m[3]);
|
|
176
|
+
return { repo, number, url: `https://github.com/${repo}/issues/${number}`, planKey: `${repo}#${number}` };
|
|
177
|
+
}
|
|
178
|
+
m = s.match(/^([^/]+\/[^#]+)#(\d+)$/);
|
|
179
|
+
if (m) {
|
|
180
|
+
const repo = m[1];
|
|
181
|
+
const number = Number(m[2]);
|
|
182
|
+
return { repo, number, url: `https://github.com/${repo}/issues/${number}`, planKey: `${repo}#${number}` };
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Register a plan row (if new) and start the plan-fanout process. Idempotent on
|
|
188
|
+
* planKey: a plan already in flight is not restarted. */
|
|
189
|
+
export async function startPlan(data: DataLayer, engine: EngineClient, parsed: ParsedIssue) {
|
|
190
|
+
const table = plans(data);
|
|
191
|
+
const existing = await table.get(parsed.planKey);
|
|
192
|
+
if (existing && !PLAN_TERMINAL_STATUSES.includes(existing.status)) {
|
|
193
|
+
return { planKey: parsed.planKey, alreadyRunning: true };
|
|
194
|
+
}
|
|
195
|
+
const ts = now();
|
|
196
|
+
// Mint (or reuse, on a re-plan) this plan's blackboard capability token, and render the
|
|
197
|
+
// coordination brief that carries its concrete URL. The token is the credential; agents reach
|
|
198
|
+
// the blackboard directly with the URL we seed into `appendPrompt` below (#51).
|
|
199
|
+
const token = existing?.blackboard_token ?? mintBlackboardToken();
|
|
200
|
+
const bbUrl = blackboardUrl(token);
|
|
201
|
+
if (existing) {
|
|
202
|
+
// Re-plan a previously finished issue: clear the old tasks and start fresh.
|
|
203
|
+
for (const t of await planTasks(data).find({ plan_key: parsed.planKey })) {
|
|
204
|
+
await planTasks(data).delete(t.id);
|
|
205
|
+
}
|
|
206
|
+
// `plan_reviews` is append-only and the review round is derived from
|
|
207
|
+
// `count(plan_reviews)`, so stale rows from the prior run would inflate the
|
|
208
|
+
// next round index and trip `reviewExhausted` early (bypassing the gate).
|
|
209
|
+
// Clear them here — the table is keyed on `plan_key`, so one delete drops the
|
|
210
|
+
// whole set (mirrors how record-plan clears `plan_task_deps`).
|
|
211
|
+
await planReviews(data).delete(parsed.planKey);
|
|
212
|
+
// Same class of stale-row bug for the implementation-phase escalation state
|
|
213
|
+
// (issue #25): `plan_escalations` is keyed on `id` (not `plan_key`), so drop
|
|
214
|
+
// the prior run's rows one-by-one. Otherwise a still-"open" escalation from
|
|
215
|
+
// the previous run survives the re-plan and `refreshOpenTaskEscalation`
|
|
216
|
+
// re-surfaces a question for a `task_id` we just deleted from `plan_tasks`.
|
|
217
|
+
for (const e of await planEscalations(data).find({ plan_key: parsed.planKey })) {
|
|
218
|
+
await planEscalations(data).delete(e.id);
|
|
219
|
+
}
|
|
220
|
+
// Same for the structured impl-change deltas (D5, #55): keyed on `id`, so drop the prior run's
|
|
221
|
+
// rows one-by-one, otherwise a stale delta lingers in the epic report for a task we just deleted.
|
|
222
|
+
await clearTaskDeltas(data, parsed.planKey);
|
|
223
|
+
// And the merge-exclusion graph (D1, #57): stale edges would mislead the merge-train.
|
|
224
|
+
await clearExclusions(data, parsed.planKey);
|
|
225
|
+
await table.update(parsed.planKey, {
|
|
226
|
+
status: "planning",
|
|
227
|
+
task_count: 0,
|
|
228
|
+
issue_url: parsed.url,
|
|
229
|
+
outcome: null,
|
|
230
|
+
// Reset the denormalised "surfaced escalation" pointer so nothing from the
|
|
231
|
+
// prior run lingers on the plan row (would otherwise show a dead answer form).
|
|
232
|
+
open_task_escalation_id: null,
|
|
233
|
+
open_task_question: null,
|
|
234
|
+
open_task_corr_key: null,
|
|
235
|
+
open_task_id: null,
|
|
236
|
+
blackboard_token: token,
|
|
237
|
+
updated_at: ts,
|
|
238
|
+
});
|
|
239
|
+
} else {
|
|
240
|
+
await table.insert({
|
|
241
|
+
plan_key: parsed.planKey,
|
|
242
|
+
repo: parsed.repo,
|
|
243
|
+
issue_number: parsed.number,
|
|
244
|
+
issue_url: parsed.url,
|
|
245
|
+
status: "planning",
|
|
246
|
+
task_count: 0,
|
|
247
|
+
blackboard_token: token,
|
|
248
|
+
created_at: ts,
|
|
249
|
+
updated_at: ts,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
const { processInstanceKey } = await engine.createInstance({
|
|
253
|
+
processDefinitionId: PLAN_PROCESS_ID,
|
|
254
|
+
variables: {
|
|
255
|
+
planKey: parsed.planKey,
|
|
256
|
+
repo: parsed.repo,
|
|
257
|
+
issue: parsed.planKey,
|
|
258
|
+
issueNumber: parsed.number,
|
|
259
|
+
issueUrl: parsed.url,
|
|
260
|
+
planFindings: null,
|
|
261
|
+
// Coordination blackboard (#51): the capability URL + the protocol brief that each
|
|
262
|
+
// implementer agent gets appended to its prompt (composed into `appendPrompt` in
|
|
263
|
+
// plan-fanout.bpmn's implement-task). Advisory shared state, delivered in-band, used
|
|
264
|
+
// out-of-band.
|
|
265
|
+
blackboardUrl: bbUrl,
|
|
266
|
+
blackboardBrief: renderCoordinationBrief(bbUrl),
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
if (processInstanceKey != null) {
|
|
270
|
+
await table.update(parsed.planKey, { process_key: String(processInstanceKey), updated_at: now() });
|
|
271
|
+
}
|
|
272
|
+
return { planKey: parsed.planKey, processKey: processInstanceKey };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Re-point a plan's denormalised "open task escalation" fields at its OLDEST
|
|
276
|
+
* still-open `plan_escalations` row (or clear them when none remain). The page
|
|
277
|
+
* runtime binds a single answer form per plan row, so parallel escalations are
|
|
278
|
+
* surfaced one at a time, oldest-first; this is called after opening an
|
|
279
|
+
* escalation and after answering one. */
|
|
280
|
+
export async function refreshOpenTaskEscalation(data: DataLayer, planKey: string) {
|
|
281
|
+
const open = (await planEscalations(data).find({ plan_key: planKey, status: "open" }))
|
|
282
|
+
.sort((a, b) => a.id - b.id)[0];
|
|
283
|
+
await plans(data).update(planKey, {
|
|
284
|
+
open_task_escalation_id: open ? open.id : null,
|
|
285
|
+
open_task_question: open ? open.question : null,
|
|
286
|
+
open_task_corr_key: open ? open.corr_key : null,
|
|
287
|
+
open_task_id: open ? open.task_id : null,
|
|
288
|
+
updated_at: now(),
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Answer an open implementation-phase escalation → record it, resume the parked
|
|
293
|
+
* task via the correlated `feature-escalation-answered` message, and re-surface
|
|
294
|
+
* the next-oldest open escalation (if any). Keyed by the correlation key
|
|
295
|
+
* (`<plan_key>:<task_id>`) so an external webhook and the page share one path.
|
|
296
|
+
* Idempotent-ish: a corr_key with no open escalation is a 404-style no-op. */
|
|
297
|
+
export async function answerTaskEscalation(
|
|
298
|
+
data: DataLayer,
|
|
299
|
+
engine: EngineClient,
|
|
300
|
+
corrKey: string,
|
|
301
|
+
answer: string,
|
|
302
|
+
) {
|
|
303
|
+
const open = (await planEscalations(data).find({ corr_key: corrKey, status: "open" }))
|
|
304
|
+
.sort((a, b) => b.id - a.id)[0];
|
|
305
|
+
if (!open) return { ok: false, reason: "no open escalation" };
|
|
306
|
+
const ts = now();
|
|
307
|
+
await planEscalations(data).update(open.id, { answer, status: "answered", answered_at: ts });
|
|
308
|
+
// Mirror onto the task row so a re-dispatched agent (and the UI) sees the answer.
|
|
309
|
+
for (const t of await planTasks(data).find({ plan_key: open.plan_key, task_id: open.task_id })) {
|
|
310
|
+
await planTasks(data).update(t.id, { answer, updated_at: ts });
|
|
311
|
+
}
|
|
312
|
+
// Resume the parked child: the process merges `answer` into the child scope and
|
|
313
|
+
// loops back to re-dispatch the SAME task on its existing branch.
|
|
314
|
+
await engine.publishMessage({
|
|
315
|
+
name: FEATURE_ESCALATION_MESSAGE,
|
|
316
|
+
correlationKey: corrKey,
|
|
317
|
+
variables: { answer },
|
|
318
|
+
});
|
|
319
|
+
await refreshOpenTaskEscalation(data, open.plan_key);
|
|
320
|
+
return { ok: true, escalationId: open.id, planKey: open.plan_key, taskId: open.task_id };
|
|
321
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Red/green regression for record-plan-review's defensive stringify (PR #26 review).
|
|
2
|
+
//
|
|
3
|
+
// `str()` normalizes an agent-emitted `findings` variable to a string before it is recorded and
|
|
4
|
+
// re-emitted as `planFindings`. `JSON.stringify` can THROW (BigInt, circular refs) or return
|
|
5
|
+
// `undefined` (functions/symbols); either would fail the whole job and wedge the process in a
|
|
6
|
+
// retry loop. `str()` must never throw and must always yield a string.
|
|
7
|
+
import { assertEquals } from "jsr:@std/assert@1";
|
|
8
|
+
import { str } from "../workers/record-plan-review/worker.ts";
|
|
9
|
+
|
|
10
|
+
Deno.test("strings pass through unchanged", () => {
|
|
11
|
+
assertEquals(str("hello"), "hello");
|
|
12
|
+
assertEquals(str(""), "");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
Deno.test("null/undefined → empty string", () => {
|
|
16
|
+
assertEquals(str(null), "");
|
|
17
|
+
assertEquals(str(undefined), "");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
Deno.test("plain objects/arrays → JSON", () => {
|
|
21
|
+
assertEquals(str({ a: 1 }), '{"a":1}');
|
|
22
|
+
assertEquals(str([1, 2]), "[1,2]");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
Deno.test("BigInt does not throw (JSON.stringify would) → String fallback", () => {
|
|
26
|
+
assertEquals(str(10n), "10");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
Deno.test("circular structure does not throw → String fallback", () => {
|
|
30
|
+
const circular: Record<string, unknown> = {};
|
|
31
|
+
circular.self = circular;
|
|
32
|
+
const out = str(circular);
|
|
33
|
+
assertEquals(typeof out, "string");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
Deno.test("value JSON.stringify renders as undefined → String fallback", () => {
|
|
37
|
+
assertEquals(str(() => 1), String(() => 1));
|
|
38
|
+
});
|