@nanobpm/nano-workforce 0.56.0 → 0.58.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 -0
- package/AGENTS.md +83 -1
- package/CHANGELOG.md +14 -0
- package/README.md +10 -2
- package/SPEC.md +27 -24
- package/app/agentCompletion.test.ts +337 -0
- package/app/agentCompletion.ts +219 -0
- package/app/agentGuide.ts +1 -1
- package/app/answer-escalation.test.ts +106 -0
- package/app/answerEscalation.test.ts +67 -0
- package/app/baseGuard.test.ts +9 -1
- package/app/baseGuard.ts +11 -0
- package/app/escalationSla.test.ts +39 -0
- package/app/escalationSla.ts +28 -0
- package/app/escalationTaxonomy.test.ts +115 -0
- package/app/escalationTaxonomy.ts +115 -0
- package/app/feature.test.ts +161 -0
- package/app/feature.ts +173 -0
- package/app/github.test.ts +179 -1
- package/app/github.ts +132 -0
- package/app/mergeProtocol.test.ts +15 -0
- package/app/mergeProtocol.ts +10 -0
- package/app/persist-escalation.test.ts +34 -36
- package/app/plan.test.ts +267 -313
- package/app/plan.ts +173 -231
- package/app/reviewWait.ts +12 -4
- package/app/roundResultDefault.test.ts +111 -2
- package/app/roundResultDefault.ts +35 -0
- package/app/service.test.ts +6 -7
- package/app/service.ts +52 -35
- package/db/migrations/026_agent_completion.sql +36 -0
- package/db/migrations/027_retire_escalation_subsystem.sql +43 -0
- package/db/migrations/028_feature_runs.sql +28 -0
- package/e2e/agent-answerable.e2e.ts +185 -0
- package/e2e/convergence-escalation.e2e.ts +180 -0
- package/e2e/convergence-loop.e2e.ts +1 -1
- package/e2e/feature-run.e2e.ts +231 -0
- package/e2e/plan-fanout-sla.e2e.ts +238 -0
- package/e2e/plan-fanout.e2e.ts +303 -0
- package/e2e/retire-escalation-subsystem.e2e.ts +223 -0
- package/e2e/support/github-admit.ts +99 -0
- package/e2e/user-task-spine.e2e.ts +155 -0
- package/nano.app.json +41 -11
- package/openapi.yaml +237 -84
- package/operations/agentCompleteEscalation.ts +53 -0
- package/operations/listActivePrs.test.ts +39 -6
- package/operations/postMessage.ts +10 -41
- package/operations/revertEscalationCompletion.ts +44 -0
- package/operations/startAndMessage.test.ts +62 -60
- package/operations/startFeature.ts +127 -0
- package/operations/startPlanFanout.admission.integration.test.ts +263 -0
- package/operations/startPlanFanout.ts +70 -11
- package/package.json +4 -1
- package/pages/cockpit.page.json +1 -0
- package/pages/epic-detail.page.json +11 -37
- package/pages/epic.page.json +5 -2
- package/pages/feature.page.json +82 -0
- package/pages/home.page.json +6 -18
- package/resources/agent-guide.md +90 -26
- package/resources/forms/feature-escalation.form +27 -0
- package/resources/forms/plan-review-decision.form +27 -0
- package/resources/forms/pr-escalation.form +23 -0
- package/resources/forms/spine-demo.form +15 -0
- package/resources/forms/trial-merge-decision.form +25 -0
- package/resources/processes/convergence-loop.bpmn +127 -75
- package/resources/processes/feature.bpmn +240 -0
- package/resources/processes/plan-fanout.bpmn +322 -222
- package/resources/processes/spine-demo.bpmn +72 -0
- package/scripts/check-migrations.ts +68 -0
- package/workers/answer-escalation/worker.ts +78 -0
- package/workers/converge-feature/worker.ts +51 -0
- package/workers/ensure-base-branch/head-task.integration.test.ts +126 -0
- package/workers/ensure-base-branch/worker.test.ts +104 -0
- package/workers/ensure-base-branch/worker.ts +31 -0
- package/workers/finalize/worker.ts +0 -2
- package/workers/mark-merged/worker.ts +0 -2
- package/workers/merge/worker.ts +6 -5
- package/workers/persist-escalation/worker.ts +28 -32
- package/workers/record-feature/worker.ts +61 -0
- package/workers/record-plan-review/worker.test.ts +9 -10
- package/workers/record-plan-review/worker.ts +15 -5
- package/workers/resolve-trial-attention/worker.test.ts +77 -0
- package/workers/resolve-trial-attention/worker.ts +43 -0
- package/operations/answerFeatureEscalation.test.ts +0 -112
- package/operations/answerFeatureEscalation.ts +0 -58
- package/operations/answerPlanEscalation.test.ts +0 -115
- package/operations/answerPlanEscalation.ts +0 -41
- package/workers/persist-plan-escalation/worker.test.ts +0 -80
- package/workers/persist-plan-escalation/worker.ts +0 -73
- package/workers/persist-task-escalation/worker.ts +0 -120
package/app/plan.test.ts
CHANGED
|
@@ -121,100 +121,12 @@ test("re-plan of a finished issue clears stale plan_reviews rows", async () => {
|
|
|
121
121
|
number: 7,
|
|
122
122
|
url: "https://github.com/owner/repo/issues/7",
|
|
123
123
|
planKey: PLAN_KEY,
|
|
124
|
-
});
|
|
124
|
+
}, "epic/agent-protocol");
|
|
125
125
|
|
|
126
126
|
assertEquals(stores.plan_reviews.rows.length, 0);
|
|
127
127
|
assertEquals(stores.plan_tasks.rows.length, 0);
|
|
128
128
|
});
|
|
129
129
|
|
|
130
|
-
// Red/green regression for re-plan clearing stale open escalations (issue #25).
|
|
131
|
-
//
|
|
132
|
-
// `plan_escalations` is written by the implementation-phase escalation loop and denormalised onto
|
|
133
|
-
// the plan row (`open_task_*`). When `startPlan` re-plans a finished issue it deletes the prior
|
|
134
|
-
// `plan_tasks`, so any still-"open" escalation from that run points at a task that no longer
|
|
135
|
-
// exists. If those rows (and the plan's denormalised pointer) survive the re-plan,
|
|
136
|
-
// `refreshOpenTaskEscalation` re-surfaces a dead question in the answer form — the same
|
|
137
|
-
// stale-row class as `plan_reviews` above. This drives `startPlan` against the in-memory data
|
|
138
|
-
// layer and asserts both the escalation rows and the denormalised pointer are cleared.
|
|
139
|
-
test("re-plan of a finished issue clears stale open escalations and the denormalised open_task_* pointer", async () => {
|
|
140
|
-
const PLAN_KEY = "owner/repo#8";
|
|
141
|
-
const stores: Record<string, { rows: unknown[]; key: string }> = {
|
|
142
|
-
plans: {
|
|
143
|
-
rows: [{
|
|
144
|
-
plan_key: PLAN_KEY,
|
|
145
|
-
status: "done",
|
|
146
|
-
task_count: 1,
|
|
147
|
-
open_task_escalation_id: 5,
|
|
148
|
-
open_task_question: "stale question from prior run?",
|
|
149
|
-
open_task_corr_key: `${PLAN_KEY}:task-1`,
|
|
150
|
-
open_task_id: "task-1",
|
|
151
|
-
}],
|
|
152
|
-
key: "plan_key",
|
|
153
|
-
},
|
|
154
|
-
plan_tasks: { rows: [{ id: 1, plan_key: PLAN_KEY, task_id: "task-1" }], key: "id" },
|
|
155
|
-
plan_reviews: { rows: [], key: "plan_key" },
|
|
156
|
-
plan_escalations: {
|
|
157
|
-
rows: [{
|
|
158
|
-
id: 5,
|
|
159
|
-
plan_key: PLAN_KEY,
|
|
160
|
-
task_id: "task-1",
|
|
161
|
-
corr_key: `${PLAN_KEY}:task-1`,
|
|
162
|
-
question: "stale question from prior run?",
|
|
163
|
-
status: "open",
|
|
164
|
-
}],
|
|
165
|
-
key: "id",
|
|
166
|
-
},
|
|
167
|
-
plan_task_deps: { rows: [], key: "plan_key" },
|
|
168
|
-
};
|
|
169
|
-
const data = {
|
|
170
|
-
table: (name: string, key: string) =>
|
|
171
|
-
memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
172
|
-
} as any;
|
|
173
|
-
const engine = {
|
|
174
|
-
createInstance: () => Promise.resolve({ processInstanceKey: "PI-1" }),
|
|
175
|
-
} as any;
|
|
176
|
-
|
|
177
|
-
await startPlan(data, engine, {
|
|
178
|
-
repo: "owner/repo",
|
|
179
|
-
number: 8,
|
|
180
|
-
url: "https://github.com/owner/repo/issues/8",
|
|
181
|
-
planKey: PLAN_KEY,
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
// Stale escalation rows from the prior run must not survive a re-plan …
|
|
185
|
-
assertEquals(stores.plan_escalations.rows.length, 0);
|
|
186
|
-
// … and the plan's denormalised "surfaced escalation" pointer must be reset,
|
|
187
|
-
// so `refreshOpenTaskEscalation` can't re-surface a question for a deleted task.
|
|
188
|
-
const plan = stores.plans.rows[0] as Record<string, unknown>;
|
|
189
|
-
assertEquals(plan.open_task_escalation_id, null);
|
|
190
|
-
assertEquals(plan.open_task_question, null);
|
|
191
|
-
assertEquals(plan.open_task_corr_key, null);
|
|
192
|
-
assertEquals(plan.open_task_id, null);
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
// Red/green coverage for the implementation-phase escalation lifecycle (issue #25).
|
|
196
|
-
//
|
|
197
|
-
// `refreshOpenTaskEscalation` and `answerTaskEscalation` (issue #25) drive new stateful
|
|
198
|
-
// behaviour — denormalising the plan's "surfaced" escalation, mirroring the answer onto the
|
|
199
|
-
// task row, and publishing the correlated resume message — that had no unit coverage. These
|
|
200
|
-
// drive both against the in-memory data layer above and assert the oldest-first surfacing,
|
|
201
|
-
// the answer mirroring, and the published message.
|
|
202
|
-
import {
|
|
203
|
-
answerPlanEscalation,
|
|
204
|
-
answerTaskEscalation,
|
|
205
|
-
currentPlanReviewEpoch,
|
|
206
|
-
PLAN_ESCALATION_MESSAGE,
|
|
207
|
-
refreshOpenTaskEscalation,
|
|
208
|
-
} from "./plan.ts";
|
|
209
|
-
|
|
210
|
-
function escalationStores(rows: unknown[]): Record<string, { rows: unknown[]; key: string }> {
|
|
211
|
-
return {
|
|
212
|
-
plans: { rows: [{ plan_key: "owner/repo#9" }], key: "plan_key" },
|
|
213
|
-
plan_escalations: { rows, key: "id" },
|
|
214
|
-
plan_tasks: { rows: [], key: "id" },
|
|
215
|
-
};
|
|
216
|
-
}
|
|
217
|
-
|
|
218
130
|
function memData(stores: Record<string, { rows: any[]; key: string }>) {
|
|
219
131
|
return {
|
|
220
132
|
table: (name: string, key: string) =>
|
|
@@ -222,227 +134,22 @@ function memData(stores: Record<string, { rows: any[]; key: string }>) {
|
|
|
222
134
|
} as any;
|
|
223
135
|
}
|
|
224
136
|
|
|
225
|
-
|
|
226
|
-
const stores = escalationStores([
|
|
227
|
-
{ id: 2, plan_key: "owner/repo#9", task_id: "b", corr_key: "owner/repo#9:b", question: "Q-b", status: "open" },
|
|
228
|
-
{ id: 1, plan_key: "owner/repo#9", task_id: "a", corr_key: "owner/repo#9:a", question: "Q-a", status: "open" },
|
|
229
|
-
]);
|
|
230
|
-
const data = memData(stores);
|
|
231
|
-
|
|
232
|
-
await refreshOpenTaskEscalation(data, "owner/repo#9");
|
|
233
|
-
let plan = stores.plans.rows[0] as any;
|
|
234
|
-
assertEquals(plan.open_task_escalation_id, 1);
|
|
235
|
-
assertEquals(plan.open_task_question, "Q-a");
|
|
236
|
-
assertEquals(plan.open_task_corr_key, "owner/repo#9:a");
|
|
237
|
-
assertEquals(plan.open_task_id, "a");
|
|
238
|
-
|
|
239
|
-
// Once the oldest is answered, the next-oldest is surfaced.
|
|
240
|
-
(stores.plan_escalations.rows.find((r: any) => r.id === 1) as any).status = "answered";
|
|
241
|
-
await refreshOpenTaskEscalation(data, "owner/repo#9");
|
|
242
|
-
plan = stores.plans.rows[0] as any;
|
|
243
|
-
assertEquals(plan.open_task_escalation_id, 2);
|
|
244
|
-
assertEquals(plan.open_task_id, "b");
|
|
245
|
-
|
|
246
|
-
// With nothing open the denormalised fields clear.
|
|
247
|
-
(stores.plan_escalations.rows.find((r: any) => r.id === 2) as any).status = "answered";
|
|
248
|
-
await refreshOpenTaskEscalation(data, "owner/repo#9");
|
|
249
|
-
plan = stores.plans.rows[0] as any;
|
|
250
|
-
assertEquals(plan.open_task_escalation_id, null);
|
|
251
|
-
assertEquals(plan.open_task_question, null);
|
|
252
|
-
assertEquals(plan.open_task_corr_key, null);
|
|
253
|
-
assertEquals(plan.open_task_id, null);
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
test("answerTaskEscalation records the answer, mirrors it onto the task, publishes the resume message, and re-surfaces the next escalation", async () => {
|
|
257
|
-
const stores = escalationStores([
|
|
258
|
-
{ id: 1, plan_key: "owner/repo#9", task_id: "a", corr_key: "owner/repo#9:a", question: "Q-a", status: "open", answer: null },
|
|
259
|
-
{ id: 2, plan_key: "owner/repo#9", task_id: "b", corr_key: "owner/repo#9:b", question: "Q-b", status: "open", answer: null },
|
|
260
|
-
]);
|
|
261
|
-
stores.plan_tasks.rows.push({ id: 10, plan_key: "owner/repo#9", task_id: "a", answer: null });
|
|
262
|
-
const data = memData(stores);
|
|
263
|
-
|
|
264
|
-
const published: any[] = [];
|
|
265
|
-
const engine = {
|
|
266
|
-
publishMessage: (m: any) => {
|
|
267
|
-
published.push(m);
|
|
268
|
-
return Promise.resolve();
|
|
269
|
-
},
|
|
270
|
-
} as any;
|
|
271
|
-
|
|
272
|
-
const r = await answerTaskEscalation(data, engine, "owner/repo#9:a", "do it");
|
|
273
|
-
assertEquals(r.ok, true);
|
|
274
|
-
assertEquals(r.escalationId, 1);
|
|
275
|
-
assertEquals(r.planKey, "owner/repo#9");
|
|
276
|
-
assertEquals(r.taskId, "a");
|
|
277
|
-
|
|
278
|
-
// Escalation row marked answered with the recorded answer.
|
|
279
|
-
const esc = stores.plan_escalations.rows.find((x: any) => x.id === 1) as any;
|
|
280
|
-
assertEquals(esc.status, "answered");
|
|
281
|
-
assertEquals(esc.answer, "do it");
|
|
282
|
-
|
|
283
|
-
// Answer mirrored onto the task row.
|
|
284
|
-
assertEquals((stores.plan_tasks.rows[0] as any).answer, "do it");
|
|
285
|
-
|
|
286
|
-
// Correlated resume message published on the shared constant channel.
|
|
287
|
-
assertEquals(published.length, 1);
|
|
288
|
-
assertEquals(published[0].name, "feature-escalation-answered");
|
|
289
|
-
assertEquals(published[0].correlationKey, "owner/repo#9:a");
|
|
290
|
-
assertEquals(published[0].variables.answer, "do it");
|
|
291
|
-
|
|
292
|
-
// Next-oldest open escalation re-surfaced on the plan row.
|
|
293
|
-
assertEquals((stores.plans.rows[0] as any).open_task_escalation_id, 2);
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
test("answerTaskEscalation is a no-op when no open escalation matches the correlation key", async () => {
|
|
297
|
-
const stores = escalationStores([]);
|
|
298
|
-
const data = memData(stores);
|
|
299
|
-
const engine = {
|
|
300
|
-
publishMessage: () => Promise.reject(new Error("should not publish")),
|
|
301
|
-
} as any;
|
|
302
|
-
const r = await answerTaskEscalation(data, engine, "owner/repo#9:missing", "x");
|
|
303
|
-
assertEquals(r.ok, false);
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
// Red/green regression (PR #131 suppressed advisory, app/plan.ts:455).
|
|
137
|
+
// Coverage for the epic base-branch control (issue nano-ide #124 / 019_plan_base_branch.sql; ADR 0003).
|
|
307
138
|
//
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
corr_key: "owner/repo#9:trial-merge-wave-0",
|
|
323
|
-
question: "Q",
|
|
324
|
-
status: "open",
|
|
325
|
-
answer: null,
|
|
326
|
-
},
|
|
327
|
-
]);
|
|
328
|
-
stores.plan_trial_merges = {
|
|
329
|
-
rows: [{ id: 100, plan_key: "owner/repo#9", wave: 0, resolved: 0 }],
|
|
330
|
-
key: "id",
|
|
331
|
-
};
|
|
332
|
-
const base = memData(stores);
|
|
333
|
-
// Inject a transient failure in the trial-merge audit table's `update` only.
|
|
334
|
-
const data = {
|
|
335
|
-
table: (name: string, key: string) => {
|
|
336
|
-
const t = base.table(name, key);
|
|
337
|
-
if (name === "plan_trial_merges") {
|
|
338
|
-
return { ...t, update: () => Promise.reject(new Error("transient DB error")) };
|
|
339
|
-
}
|
|
340
|
-
return t;
|
|
341
|
-
},
|
|
342
|
-
} as any;
|
|
343
|
-
|
|
344
|
-
const published: any[] = [];
|
|
345
|
-
const engine = {
|
|
346
|
-
publishMessage: (m: any) => {
|
|
347
|
-
published.push(m);
|
|
348
|
-
return Promise.resolve();
|
|
349
|
-
},
|
|
350
|
-
} as any;
|
|
351
|
-
|
|
352
|
-
await assertRejects(() =>
|
|
353
|
-
answerTaskEscalation(data, engine, "owner/repo#9:trial-merge-wave-0", "proceed")
|
|
354
|
-
);
|
|
355
|
-
|
|
356
|
-
// Escalation must remain OPEN so a retry can recover (never committed as answered).
|
|
357
|
-
const esc = stores.plan_escalations.rows.find((x: any) => x.id === 1) as any;
|
|
358
|
-
assertEquals(esc.status, "open");
|
|
359
|
-
assertEquals(esc.answer, null);
|
|
360
|
-
// No orphaned resume message was published.
|
|
361
|
-
assertEquals(published.length, 0);
|
|
362
|
-
});
|
|
363
|
-
|
|
364
|
-
test("currentPlanReviewEpoch counts answered plan-review escalations only", async () => {
|
|
365
|
-
const stores = {
|
|
366
|
-
plan_review_escalations: {
|
|
367
|
-
rows: [
|
|
368
|
-
{ id: 1, plan_key: "owner/repo#10", status: "answered" },
|
|
369
|
-
{ id: 2, plan_key: "owner/repo#10", status: "open" },
|
|
370
|
-
{ id: 3, plan_key: "owner/repo#other", status: "answered" },
|
|
371
|
-
],
|
|
372
|
-
key: "id",
|
|
373
|
-
},
|
|
374
|
-
};
|
|
375
|
-
assertEquals(await currentPlanReviewEpoch(memData(stores), "owner/repo#10"), 1);
|
|
376
|
-
});
|
|
377
|
-
|
|
378
|
-
test("answerPlanEscalation records directive, clears the plan pointer, and publishes the resume message", async () => {
|
|
379
|
-
const stores = {
|
|
380
|
-
plans: {
|
|
381
|
-
rows: [{
|
|
382
|
-
plan_key: "owner/repo#11",
|
|
383
|
-
open_plan_escalation_id: 7,
|
|
384
|
-
open_plan_findings: "reviewer findings",
|
|
385
|
-
open_plan_round: 2,
|
|
386
|
-
}],
|
|
387
|
-
key: "plan_key",
|
|
388
|
-
},
|
|
389
|
-
plan_review_escalations: {
|
|
390
|
-
rows: [{
|
|
391
|
-
id: 7,
|
|
392
|
-
plan_key: "owner/repo#11",
|
|
393
|
-
epoch: 0,
|
|
394
|
-
round: 2,
|
|
395
|
-
findings: "reviewer findings",
|
|
396
|
-
status: "open",
|
|
397
|
-
directive: null,
|
|
398
|
-
note: null,
|
|
399
|
-
}],
|
|
400
|
-
key: "id",
|
|
401
|
-
},
|
|
402
|
-
};
|
|
403
|
-
const published: any[] = [];
|
|
404
|
-
const engine = {
|
|
405
|
-
publishMessage: (m: any) => {
|
|
406
|
-
published.push(m);
|
|
407
|
-
return Promise.resolve();
|
|
408
|
-
},
|
|
409
|
-
} as any;
|
|
410
|
-
|
|
411
|
-
const r = await answerPlanEscalation(memData(stores), engine, "owner/repo#11", "revise", "Use issue-1 as seam.");
|
|
412
|
-
assertEquals(r.ok, true);
|
|
413
|
-
assertEquals(r.directive, "revise");
|
|
414
|
-
const esc = stores.plan_review_escalations.rows[0] as any;
|
|
415
|
-
assertEquals(esc.status, "answered");
|
|
416
|
-
assertEquals(esc.directive, "revise");
|
|
417
|
-
assertEquals(esc.note, "Use issue-1 as seam.");
|
|
418
|
-
const plan = stores.plans.rows[0] as any;
|
|
419
|
-
assertEquals(plan.open_plan_escalation_id, null);
|
|
420
|
-
assertEquals(plan.open_plan_findings, null);
|
|
421
|
-
assertEquals(plan.open_plan_round, null);
|
|
422
|
-
assertEquals(published[0].name, PLAN_ESCALATION_MESSAGE);
|
|
423
|
-
assertEquals(published[0].correlationKey, "owner/repo#11");
|
|
424
|
-
assertEquals(published[0].variables.planEscalationDirective, "revise");
|
|
425
|
-
assertEquals(
|
|
426
|
-
String(published[0].variables.planFindings).includes("Use issue-1 as seam."),
|
|
427
|
-
true,
|
|
428
|
-
);
|
|
429
|
-
});
|
|
430
|
-
|
|
431
|
-
// Coverage for the epic base-branch control (issue nano-ide #124 / 019_plan_base_branch.sql).
|
|
432
|
-
//
|
|
433
|
-
// A plan may pin a base branch so the fleet branches off — and opens every PR against — a long-lived
|
|
434
|
-
// integration branch instead of the repo default, keeping an epic off the default branch (and off any
|
|
435
|
-
// merge-to-default side effect such as auto-publishing) until the integration branch is deliberately
|
|
436
|
-
// merged. `normalizeBaseBranch` decides "unset" (fall back to default), `renderBaseBranchBrief` is the
|
|
437
|
-
// authoritative prompt override, and `startPlan` must persist the branch and seed BOTH the `baseBranch`
|
|
438
|
-
// variable and the `baseBranchBrief` (which rides `appendPrompt`) — or leave them null when unpinned.
|
|
439
|
-
import { InvalidBaseBranchError, normalizeBaseBranch, renderBaseBranchBrief } from "./plan.ts";
|
|
440
|
-
|
|
441
|
-
test("normalizeBaseBranch: blank/whitespace/undefined → null; a real branch is trimmed", () => {
|
|
442
|
-
assertEquals(normalizeBaseBranch(undefined), null);
|
|
443
|
-
assertEquals(normalizeBaseBranch(null), null);
|
|
444
|
-
assertEquals(normalizeBaseBranch(""), null);
|
|
445
|
-
assertEquals(normalizeBaseBranch(" "), null);
|
|
139
|
+
// Every plan must pin a base branch so the fleet branches off — and opens every PR against — a
|
|
140
|
+
// long-lived integration branch instead of the repo default, keeping an epic off the default branch
|
|
141
|
+
// (and off any merge-to-default side effect such as auto-publishing) until the integration branch is
|
|
142
|
+
// deliberately merged. Since ADR 0003, base is REQUIRED: `normalizeBaseBranch` rejects a blank/absent
|
|
143
|
+
// value (`MissingBaseBranchError`) instead of falling back to the default. `renderBaseBranchBrief` is
|
|
144
|
+
// the authoritative prompt override, and `startPlan` persists the branch and seeds BOTH the
|
|
145
|
+
// `baseBranch` variable and the `baseBranchBrief` (which rides `appendPrompt`) unconditionally.
|
|
146
|
+
import { InvalidBaseBranchError, MissingBaseBranchError, normalizeBaseBranch, renderBaseBranchBrief } from "./plan.ts";
|
|
147
|
+
|
|
148
|
+
test("normalizeBaseBranch: blank/whitespace/undefined → MissingBaseBranchError; a real branch is trimmed", () => {
|
|
149
|
+
assertThrows(() => normalizeBaseBranch(undefined), MissingBaseBranchError);
|
|
150
|
+
assertThrows(() => normalizeBaseBranch(null), MissingBaseBranchError);
|
|
151
|
+
assertThrows(() => normalizeBaseBranch(""), MissingBaseBranchError);
|
|
152
|
+
assertThrows(() => normalizeBaseBranch(" "), MissingBaseBranchError);
|
|
446
153
|
assertEquals(normalizeBaseBranch(" epic/agent-protocol "), "epic/agent-protocol");
|
|
447
154
|
});
|
|
448
155
|
|
|
@@ -450,6 +157,8 @@ test("normalizeBaseBranch: accepts conservative git-branch shapes", () => {
|
|
|
450
157
|
assertEquals(normalizeBaseBranch("main"), "main");
|
|
451
158
|
assertEquals(normalizeBaseBranch("release-1.2"), "release-1.2");
|
|
452
159
|
assertEquals(normalizeBaseBranch("feature/x_y.z"), "feature/x_y.z");
|
|
160
|
+
// A plausible `epic/*` integration branch (the 019 convention) is returned unchanged.
|
|
161
|
+
assertEquals(normalizeBaseBranch("epic/agent-protocol"), "epic/agent-protocol");
|
|
453
162
|
});
|
|
454
163
|
|
|
455
164
|
test("normalizeBaseBranch: rejects injection-prone / implausible branch names", () => {
|
|
@@ -516,7 +225,7 @@ test("startPlan pins the base branch: persisted on the row + seeded as baseBranc
|
|
|
516
225
|
assertEquals(seen.baseBranchBrief.includes("gh pr create --base epic/agent-protocol"), true);
|
|
517
226
|
});
|
|
518
227
|
|
|
519
|
-
test("startPlan
|
|
228
|
+
test("startPlan renders baseBranchBrief unconditionally now that base is required", async () => {
|
|
520
229
|
const PLAN_KEY = "owner/repo#200";
|
|
521
230
|
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
522
231
|
plans: { rows: [], key: "plan_key" },
|
|
@@ -539,9 +248,254 @@ test("startPlan without a base branch keeps default-branch behaviour (null row +
|
|
|
539
248
|
number: 200,
|
|
540
249
|
url: "https://github.com/owner/repo/issues/200",
|
|
541
250
|
planKey: PLAN_KEY,
|
|
251
|
+
}, "epic/gate-branch");
|
|
252
|
+
|
|
253
|
+
assertEquals((stores.plans.rows[0] as any).base_branch, "epic/gate-branch");
|
|
254
|
+
assertEquals(seen.baseBranch, "epic/gate-branch");
|
|
255
|
+
// The brief is always rendered — there is no null fork any more.
|
|
256
|
+
assertEquals(seen.baseBranchBrief.includes("gh pr create --base epic/gate-branch"), true);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("startPlan grandfathers a pre-existing null base_branch row: re-plan reads it without error", async () => {
|
|
260
|
+
// Pre-ADR-0003 / in-flight rows carry base_branch = null (the column stays nullable). Re-planning
|
|
261
|
+
// such a finished issue must read the old null row without error and re-pin it to the new explicit
|
|
262
|
+
// base — the required-ness is enforced at admission of the new launch, not by a DB NOT NULL.
|
|
263
|
+
const PLAN_KEY = "owner/repo#201";
|
|
264
|
+
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
265
|
+
plans: { rows: [{ plan_key: PLAN_KEY, status: "done", task_count: 0, base_branch: null }], key: "plan_key" },
|
|
266
|
+
plan_tasks: { rows: [], key: "id" },
|
|
267
|
+
plan_reviews: { rows: [], key: "plan_key" },
|
|
268
|
+
plan_escalations: { rows: [], key: "id" },
|
|
269
|
+
plan_task_deps: { rows: [], key: "plan_key" },
|
|
270
|
+
};
|
|
271
|
+
const data = memData(stores);
|
|
272
|
+
let seen: any = null;
|
|
273
|
+
const engine = {
|
|
274
|
+
createInstance: (req: any) => {
|
|
275
|
+
seen = req.variables;
|
|
276
|
+
return Promise.resolve({ processInstanceKey: "PI-3" });
|
|
277
|
+
},
|
|
278
|
+
} as any;
|
|
279
|
+
|
|
280
|
+
await startPlan(data, engine, {
|
|
281
|
+
repo: "owner/repo",
|
|
282
|
+
number: 201,
|
|
283
|
+
url: "https://github.com/owner/repo/issues/201",
|
|
284
|
+
planKey: PLAN_KEY,
|
|
285
|
+
}, "epic/gate-branch");
|
|
286
|
+
|
|
287
|
+
// The grandfathered null row is re-pinned to the new explicit base without throwing.
|
|
288
|
+
assertEquals((stores.plans.rows[0] as any).base_branch, "epic/gate-branch");
|
|
289
|
+
assertEquals(seen.baseBranch, "epic/gate-branch");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// ── admitPlan decision matrix (ADR 0003 §Decision, rules 1-4) ────────────────
|
|
293
|
+
// The fail-fast admission gate composes four ORDERED rules before any fan-out. These drive it
|
|
294
|
+
// through a faked github transport (token mode + stubbed `globalThis.fetch`) and an in-memory
|
|
295
|
+
// `plans` table, asserting each rule's accept/reject and that the ORDER is load-bearing.
|
|
296
|
+
import { BaseBranchMustExistError } from "./github.ts";
|
|
297
|
+
import { admitPlan, DefaultBaseNotConfirmedError, findActivePlansByBase, SharedBaseError } from "./plan.ts";
|
|
298
|
+
|
|
299
|
+
interface AdmitRepo {
|
|
300
|
+
repo: string;
|
|
301
|
+
defaultBranch: string;
|
|
302
|
+
branches: Set<string>;
|
|
303
|
+
creates: string[]; // refs created via POST
|
|
304
|
+
metaCalls: number; // GETs to /repos/:repo (default-branch resolution)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function admitFetch(state: AdmitRepo) {
|
|
308
|
+
return (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
309
|
+
const u = new URL(String(url));
|
|
310
|
+
const method = (init?.method ?? "GET").toUpperCase();
|
|
311
|
+
const path = u.pathname;
|
|
312
|
+
if (method === "GET" && path === `/repos/${state.repo}`) {
|
|
313
|
+
state.metaCalls += 1;
|
|
314
|
+
return Promise.resolve(
|
|
315
|
+
new Response(JSON.stringify({ default_branch: state.defaultBranch }), {
|
|
316
|
+
status: 200,
|
|
317
|
+
headers: { "content-type": "application/json" },
|
|
318
|
+
}),
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
const refPrefix = `/repos/${state.repo}/git/ref/heads/`;
|
|
322
|
+
if (method === "GET" && path.startsWith(refPrefix)) {
|
|
323
|
+
const branch = decodeURIComponent(path.slice(refPrefix.length));
|
|
324
|
+
if (!state.branches.has(branch)) return Promise.resolve(new Response("Not Found", { status: 404 }));
|
|
325
|
+
return Promise.resolve(
|
|
326
|
+
new Response(JSON.stringify({ ref: `refs/heads/${branch}`, object: { sha: `${branch}-sha` } }), {
|
|
327
|
+
status: 200,
|
|
328
|
+
headers: { "content-type": "application/json" },
|
|
329
|
+
}),
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
if (method === "POST" && path === `/repos/${state.repo}/git/refs`) {
|
|
333
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
334
|
+
const body = JSON.parse(String(init?.body ?? "{}")) as { ref?: string };
|
|
335
|
+
const ref = String(body.ref ?? "");
|
|
336
|
+
const branch = ref.replace(/^refs\/heads\//, "");
|
|
337
|
+
if (state.branches.has(branch)) {
|
|
338
|
+
return Promise.resolve(new Response(JSON.stringify({ message: "Reference already exists" }), { status: 422 }));
|
|
339
|
+
}
|
|
340
|
+
state.creates.push(ref);
|
|
341
|
+
state.branches.add(branch);
|
|
342
|
+
return Promise.resolve(new Response(JSON.stringify({ ref }), { status: 201 }));
|
|
343
|
+
}
|
|
344
|
+
return Promise.resolve(new Response(`unexpected ${method} ${path}`, { status: 500 }));
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function withAdmit<T>(state: AdmitRepo, fn: () => Promise<T>): Promise<T> {
|
|
349
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
350
|
+
const prevFetch = globalThis.fetch;
|
|
351
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
352
|
+
globalThis.fetch = admitFetch(state) as typeof fetch;
|
|
353
|
+
try {
|
|
354
|
+
return await fn();
|
|
355
|
+
} finally {
|
|
356
|
+
globalThis.fetch = prevFetch;
|
|
357
|
+
if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
358
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function admitData(planRows: any[] = []) {
|
|
363
|
+
return memData({ plans: { rows: planRows, key: "plan_key" } });
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
test("admitPlan rule 1: blank/absent base → MissingBaseBranchError (before any github call)", async () => {
|
|
367
|
+
const state: AdmitRepo = { repo: "o/r1", defaultBranch: "main", branches: new Set(["main"]), creates: [], metaCalls: 0 };
|
|
368
|
+
await withAdmit(state, async () => {
|
|
369
|
+
await assertRejects(() => admitPlan(admitData(), state.repo, "", "tok"), MissingBaseBranchError);
|
|
370
|
+
await assertRejects(() => admitPlan(admitData(), state.repo, null, "tok"), MissingBaseBranchError);
|
|
371
|
+
});
|
|
372
|
+
// Rule 1 fires before rule 2/3 — the default-branch endpoint is never hit.
|
|
373
|
+
assertEquals(state.metaCalls, 0);
|
|
374
|
+
assertEquals(state.creates.length, 0);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("admitPlan rule 1: implausible base → InvalidBaseBranchError", async () => {
|
|
378
|
+
const state: AdmitRepo = { repo: "o/r1b", defaultBranch: "main", branches: new Set(["main"]), creates: [], metaCalls: 0 };
|
|
379
|
+
await withAdmit(state, async () => {
|
|
380
|
+
await assertRejects(() => admitPlan(admitData(), state.repo, "bad branch;rm -rf", "tok"), InvalidBaseBranchError);
|
|
542
381
|
});
|
|
382
|
+
assertEquals(state.metaCalls, 0);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("admitPlan rule 2: missing non-epic/* base → BaseBranchMustExistError (synchronous edge-400 path)", async () => {
|
|
386
|
+
const state: AdmitRepo = { repo: "o/r2", defaultBranch: "main", branches: new Set(["main"]), creates: [], metaCalls: 0 };
|
|
387
|
+
await withAdmit(state, async () => {
|
|
388
|
+
await assertRejects(() => admitPlan(admitData(), state.repo, "release-9", "tok"), BaseBranchMustExistError);
|
|
389
|
+
});
|
|
390
|
+
assertEquals(state.creates.length, 0);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("admitPlan rule 2: missing epic/* base → created off default HEAD, then admitted", async () => {
|
|
394
|
+
const state: AdmitRepo = { repo: "o/r3", defaultBranch: "main", branches: new Set(["main"]), creates: [], metaCalls: 0 };
|
|
395
|
+
const base = await withAdmit(state, () => admitPlan(admitData(), state.repo, "epic/new-thing", "tok"));
|
|
396
|
+
assertEquals(base, "epic/new-thing");
|
|
397
|
+
assertEquals(state.creates, ["refs/heads/epic/new-thing"]);
|
|
398
|
+
});
|
|
543
399
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
400
|
+
test("admitPlan rule 3: default-branch target WITHOUT confirmDefaultBase → DefaultBaseNotConfirmedError", async () => {
|
|
401
|
+
const state: AdmitRepo = { repo: "o/r4", defaultBranch: "main", branches: new Set(["main"]), creates: [], metaCalls: 0 };
|
|
402
|
+
await withAdmit(state, async () => {
|
|
403
|
+
await assertRejects(() => admitPlan(admitData(), state.repo, "main", "tok"), DefaultBaseNotConfirmedError);
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("admitPlan rule 3: default-branch target WITH confirmDefaultBase → admitted", async () => {
|
|
408
|
+
const state: AdmitRepo = { repo: "o/r5", defaultBranch: "main", branches: new Set(["main"]), creates: [], metaCalls: 0 };
|
|
409
|
+
const base = await withAdmit(state, () =>
|
|
410
|
+
admitPlan(admitData(), state.repo, "main", "tok", { confirmDefaultBase: true }),
|
|
411
|
+
);
|
|
412
|
+
assertEquals(base, "main");
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test("admitPlan rule 4: active shared CUSTOM base WITHOUT allowSharedBase → SharedBaseError", async () => {
|
|
416
|
+
const state: AdmitRepo = { repo: "o/r6", defaultBranch: "main", branches: new Set(["main", "epic/shared"]), creates: [], metaCalls: 0 };
|
|
417
|
+
const planRows = [{ plan_key: "o/r6#1", repo: "o/r6", base_branch: "epic/shared", status: "planning" }];
|
|
418
|
+
await withAdmit(state, async () => {
|
|
419
|
+
await assertRejects(() => admitPlan(admitData(planRows), state.repo, "epic/shared", "tok"), SharedBaseError);
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("admitPlan rule 4: same-issue re-submit is admitted — selfPlanKey excludes the launch's OWN active row", async () => {
|
|
424
|
+
// Idempotency regression: startPlan short-circuits an in-flight plan to `alreadyRunning`, but that
|
|
425
|
+
// reachable only if admitPlan does NOT 409 the retry against the plan's own active row. With
|
|
426
|
+
// selfPlanKey set, the shared-base guard excludes that row, so the same-issue re-submit is admitted.
|
|
427
|
+
const state: AdmitRepo = { repo: "o/r6b", defaultBranch: "main", branches: new Set(["main", "epic/shared"]), creates: [], metaCalls: 0 };
|
|
428
|
+
const planRows = [{ plan_key: "o/r6b#1", repo: "o/r6b", base_branch: "epic/shared", status: "planning" }];
|
|
429
|
+
const base = await withAdmit(state, () =>
|
|
430
|
+
admitPlan(admitData(planRows), state.repo, "epic/shared", "tok", { selfPlanKey: "o/r6b#1" }),
|
|
431
|
+
);
|
|
432
|
+
assertEquals(base, "epic/shared");
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test("admitPlan rule 4: a DIFFERENT active plan on the same base still trips the guard even with selfPlanKey set", async () => {
|
|
436
|
+
// selfPlanKey excludes only the launch's own row — a genuine collision with another epic still 409s.
|
|
437
|
+
const state: AdmitRepo = { repo: "o/r6c", defaultBranch: "main", branches: new Set(["main", "epic/shared"]), creates: [], metaCalls: 0 };
|
|
438
|
+
const planRows = [{ plan_key: "o/r6c#2", repo: "o/r6c", base_branch: "epic/shared", status: "planning" }];
|
|
439
|
+
await withAdmit(state, async () => {
|
|
440
|
+
await assertRejects(
|
|
441
|
+
() => admitPlan(admitData(planRows), state.repo, "epic/shared", "tok", { selfPlanKey: "o/r6c#1" }),
|
|
442
|
+
SharedBaseError,
|
|
443
|
+
);
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test("admitPlan rule 4: same custom base WITH allowSharedBase → admitted", async () => {
|
|
448
|
+
const state: AdmitRepo = { repo: "o/r7", defaultBranch: "main", branches: new Set(["main", "epic/shared"]), creates: [], metaCalls: 0 };
|
|
449
|
+
const planRows = [{ plan_key: "o/r7#1", repo: "o/r7", base_branch: "epic/shared", status: "planning" }];
|
|
450
|
+
const base = await withAdmit(state, () =>
|
|
451
|
+
admitPlan(admitData(planRows), state.repo, "epic/shared", "tok", { allowSharedBase: true }),
|
|
452
|
+
);
|
|
453
|
+
assertEquals(base, "epic/shared");
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
test("admitPlan rule 4: two plans sharing the DEFAULT branch → always admitted (exempt)", async () => {
|
|
457
|
+
const state: AdmitRepo = { repo: "o/r8", defaultBranch: "main", branches: new Set(["main"]), creates: [], metaCalls: 0 };
|
|
458
|
+
// Another active plan already targets the default branch — the shared-base guard exempts it.
|
|
459
|
+
const planRows = [{ plan_key: "o/r8#1", repo: "o/r8", base_branch: "main", status: "planning" }];
|
|
460
|
+
const base = await withAdmit(state, () =>
|
|
461
|
+
admitPlan(admitData(planRows), state.repo, "main", "tok", { confirmDefaultBase: true }),
|
|
462
|
+
);
|
|
463
|
+
assertEquals(base, "main");
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test("admitPlan rule 4: a TERMINAL-status plan on the same base does NOT trip the guard", async () => {
|
|
467
|
+
const state: AdmitRepo = { repo: "o/r9", defaultBranch: "main", branches: new Set(["main", "epic/done-base"]), creates: [], metaCalls: 0 };
|
|
468
|
+
const planRows = [{ plan_key: "o/r9#1", repo: "o/r9", base_branch: "epic/done-base", status: "done" }];
|
|
469
|
+
const base = await withAdmit(state, () => admitPlan(admitData(planRows), state.repo, "epic/done-base", "tok"));
|
|
470
|
+
assertEquals(base, "epic/done-base");
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test("admitPlan ORDER: a blank base is rejected before confirm-default / shared-base run", async () => {
|
|
474
|
+
// Even with an active shared plan present AND the base being the default, rule 1 must fire first.
|
|
475
|
+
const state: AdmitRepo = { repo: "o/r10", defaultBranch: "main", branches: new Set(["main"]), creates: [], metaCalls: 0 };
|
|
476
|
+
const planRows = [{ plan_key: "o/r10#1", repo: "o/r10", base_branch: "main", status: "planning" }];
|
|
477
|
+
await withAdmit(state, async () => {
|
|
478
|
+
await assertRejects(() => admitPlan(admitData(planRows), state.repo, " ", "tok"), MissingBaseBranchError);
|
|
479
|
+
});
|
|
480
|
+
assertEquals(state.metaCalls, 0); // never reached rule 3
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
test("admitPlan ORDER: a typo'd non-epic base is rejected (rule 2) before confirm-default (rule 3)", async () => {
|
|
484
|
+
const state: AdmitRepo = { repo: "o/r11", defaultBranch: "main", branches: new Set(["main"]), creates: [], metaCalls: 0 };
|
|
485
|
+
await withAdmit(state, async () => {
|
|
486
|
+
await assertRejects(() => admitPlan(admitData(), state.repo, "mian", "tok"), BaseBranchMustExistError);
|
|
487
|
+
});
|
|
488
|
+
// ensureBaseBranch (rule 2) throws for the missing non-epic/* branch before fetchDefaultBranch (rule 3).
|
|
489
|
+
assertEquals(state.metaCalls, 0);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test("findActivePlansByBase returns only non-terminal plans on the matching repo + base", async () => {
|
|
493
|
+
const rows = [
|
|
494
|
+
{ plan_key: "o/x#1", repo: "o/x", base_branch: "epic/b", status: "planning" },
|
|
495
|
+
{ plan_key: "o/x#2", repo: "o/x", base_branch: "epic/b", status: "done" },
|
|
496
|
+
{ plan_key: "o/x#3", repo: "o/x", base_branch: "epic/other", status: "planning" },
|
|
497
|
+
];
|
|
498
|
+
const active = await findActivePlansByBase(admitData(rows), "o/x", "epic/b");
|
|
499
|
+
assertEquals(active.length, 1);
|
|
500
|
+
assertEquals(active[0].plan_key, "o/x#1");
|
|
547
501
|
});
|