@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
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// pr.record-plan-review — record one adversarial plan-review round and decide whether the
|
|
2
|
+
// fan-out proceeds or the planner revises (issue: gate the plan before dispatch).
|
|
3
|
+
//
|
|
4
|
+
// The `senior:plan-review` agent critiqued the levelized plan and emitted `{ approved, findings }`.
|
|
5
|
+
// This worker:
|
|
6
|
+
// • derives the current round from the append-only `plan_reviews` log (no counter variable),
|
|
7
|
+
// using the engine jobKey as an idempotency guard so a retried job reuses its row,
|
|
8
|
+
// • records this round's verdict + findings,
|
|
9
|
+
// • decides the loop: `planApproved` (reviewer said yes) and `reviewExhausted` (the round cap
|
|
10
|
+
// is reached, so we proceed regardless rather than dead-lock on a reviewer that never
|
|
11
|
+
// approves), and re-emits the findings as `planFindings` so a revise round feeds the planner.
|
|
12
|
+
//
|
|
13
|
+
// The BPMN gateway proceeds to `select-wave` when `planApproved or reviewExhausted`, else loops
|
|
14
|
+
// back to `plan`. A missing/ambiguous `approved` is treated as NOT approved (revise) — but the
|
|
15
|
+
// round cap still bounds the loop, so the plan can never wedge.
|
|
16
|
+
import type { AppJobHandler } from "@nanobpm/urban";
|
|
17
|
+
import { MAX_PLAN_REVIEW_ROUNDS, type PlanReview, planReviews } from "../../app/plan.ts";
|
|
18
|
+
|
|
19
|
+
interface In extends Record<string, unknown> {
|
|
20
|
+
planKey: string;
|
|
21
|
+
approved?: unknown;
|
|
22
|
+
findings?: unknown;
|
|
23
|
+
}
|
|
24
|
+
interface Out extends Record<string, unknown> {
|
|
25
|
+
planApproved: boolean;
|
|
26
|
+
reviewExhausted: boolean;
|
|
27
|
+
planFindings: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Only an explicit boolean-true (or the string "true") approves; anything else — including a
|
|
31
|
+
// missing verdict — means revise. Bounded by the round cap, so this can't loop forever.
|
|
32
|
+
const isApproved = (v: unknown): boolean =>
|
|
33
|
+
v === true || (typeof v === "string" && v.trim().toLowerCase() === "true");
|
|
34
|
+
|
|
35
|
+
export const str = (v: unknown): string => {
|
|
36
|
+
if (typeof v === "string") return v;
|
|
37
|
+
if (v == null) return "";
|
|
38
|
+
// JSON.stringify can throw (BigInt, circular refs) or return undefined (functions/symbols).
|
|
39
|
+
// Never let a bad variable fail the whole job — fall back to String(v).
|
|
40
|
+
try {
|
|
41
|
+
return JSON.stringify(v) ?? String(v);
|
|
42
|
+
} catch {
|
|
43
|
+
return String(v);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
48
|
+
const planKey = job.variables.planKey;
|
|
49
|
+
const approved = isApproved(job.variables.approved);
|
|
50
|
+
const findings = str(job.variables.findings).trim();
|
|
51
|
+
const ts = new Date().toISOString();
|
|
52
|
+
const jobKey = job.jobKey;
|
|
53
|
+
|
|
54
|
+
const reviews = planReviews(app.data);
|
|
55
|
+
|
|
56
|
+
// Idempotency guard: deriving the round from count(plan_reviews) is not retry-safe on its own.
|
|
57
|
+
// A job retried after the insert (crash/timeout post-write) re-runs with the SAME jobKey — if
|
|
58
|
+
// this job already recorded a row, reuse it rather than appending a duplicate, which would
|
|
59
|
+
// inflate the count and trip `reviewExhausted` early. Otherwise this is the first attempt:
|
|
60
|
+
// derive the 0-based next round from the append-only log and record it under this jobKey.
|
|
61
|
+
const recorded: PlanReview = (await reviews.findOne({ plan_key: planKey, job_key: jobKey })) ??
|
|
62
|
+
await (async () => {
|
|
63
|
+
const round = await reviews.count({ plan_key: planKey }); // 0-based: next round index
|
|
64
|
+
const row: PlanReview = {
|
|
65
|
+
plan_key: planKey,
|
|
66
|
+
round,
|
|
67
|
+
approved: approved ? 1 : 0,
|
|
68
|
+
findings: findings || null,
|
|
69
|
+
created_at: ts,
|
|
70
|
+
job_key: jobKey,
|
|
71
|
+
};
|
|
72
|
+
await reviews.insert(row);
|
|
73
|
+
return row;
|
|
74
|
+
})();
|
|
75
|
+
|
|
76
|
+
const round = recorded.round;
|
|
77
|
+
const roundApproved = recorded.approved === 1;
|
|
78
|
+
const roundFindings = recorded.findings ?? "";
|
|
79
|
+
|
|
80
|
+
// Exhausted once this round is the last permitted one (round is 0-based).
|
|
81
|
+
const reviewExhausted = round + 1 >= MAX_PLAN_REVIEW_ROUNDS;
|
|
82
|
+
if (!roundApproved) {
|
|
83
|
+
app.log(reviewExhausted ? "warn" : "info", `record-plan-review: ${planKey} round ${round}`, {
|
|
84
|
+
approved: roundApproved,
|
|
85
|
+
reviewExhausted,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { planApproved: roundApproved, reviewExhausted, planFindings: roundFindings };
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export default handler;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// pr.record-results — the wave loop has finished; finalize the plan (issue #20).
|
|
2
|
+
//
|
|
3
|
+
// PR enrollment now happens per wave in `pr.record-wave` (so later waves can declare earlier
|
|
4
|
+
// waves' PRs as dependencies), leaving this worker as the terminal finalizer: it summarizes the
|
|
5
|
+
// plan from `plan_tasks` and marks it `done`. It reads no `results` — every task's outcome was
|
|
6
|
+
// already recorded by `record-wave` (opened / blocked) or `select-wave` (skipped).
|
|
7
|
+
import type { AppJobHandler } from "@nanobpm/urban";
|
|
8
|
+
import { planTasks } from "../../app/plan.ts";
|
|
9
|
+
|
|
10
|
+
interface In extends Record<string, unknown> {
|
|
11
|
+
planKey: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const handler: AppJobHandler<In> = async (job, app) => {
|
|
15
|
+
const { planKey } = job.variables;
|
|
16
|
+
const ts = new Date().toISOString();
|
|
17
|
+
|
|
18
|
+
const rows = await planTasks(app.data).find({ plan_key: planKey });
|
|
19
|
+
const opened = rows.filter((r) => r.status === "opened").length;
|
|
20
|
+
|
|
21
|
+
const patch: Record<string, unknown> = { status: "done", updated_at: ts };
|
|
22
|
+
// When the planner emitted no tasks, `record-plan` already set a meaningful outcome (its
|
|
23
|
+
// note) and moved the plan to `done`. Preserve it rather than overwriting with "0 PR(s)…".
|
|
24
|
+
if (rows.length > 0) patch.outcome = `${opened} PR(s) dispatched to convergence`;
|
|
25
|
+
await app.data.table("plans", "plan_key").update(planKey, patch);
|
|
26
|
+
|
|
27
|
+
return {};
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export default handler;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { assertEquals, assertStringIncludes } from "jsr:@std/assert@1";
|
|
2
|
+
import handler, { parseResult } from "./worker.ts";
|
|
3
|
+
|
|
4
|
+
function fakeApp() {
|
|
5
|
+
const inserts: Record<string, unknown[]> = { plan_trial_merges: [] };
|
|
6
|
+
const app = {
|
|
7
|
+
data: {
|
|
8
|
+
table(name: string, _key: string) {
|
|
9
|
+
return {
|
|
10
|
+
// deno-lint-ignore require-await
|
|
11
|
+
async find(_q: unknown) {
|
|
12
|
+
return [];
|
|
13
|
+
},
|
|
14
|
+
// deno-lint-ignore require-await
|
|
15
|
+
async insert(row: unknown) {
|
|
16
|
+
(inserts[name] ??= []).push(row);
|
|
17
|
+
return 7;
|
|
18
|
+
},
|
|
19
|
+
// deno-lint-ignore require-await
|
|
20
|
+
async update(_key: unknown, _patch: unknown) {},
|
|
21
|
+
};
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
log: () => undefined,
|
|
25
|
+
};
|
|
26
|
+
return { app, inserts };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
Deno.test("record-trial-merge shapes clean results as proceed", async () => {
|
|
30
|
+
const { app, inserts } = fakeApp();
|
|
31
|
+
const out = await handler(
|
|
32
|
+
// deno-lint-ignore no-explicit-any
|
|
33
|
+
{ key: 11, variables: { planKey: "o/r#69", currentWave: 2, result: "clean", summary: "green" } } as any,
|
|
34
|
+
// deno-lint-ignore no-explicit-any
|
|
35
|
+
app as any,
|
|
36
|
+
) as Record<string, unknown>;
|
|
37
|
+
|
|
38
|
+
assertEquals(out.trialMergeRed, false);
|
|
39
|
+
assertEquals(inserts.plan_trial_merges.length, 1);
|
|
40
|
+
// deno-lint-ignore no-explicit-any
|
|
41
|
+
assertEquals((inserts.plan_trial_merges[0] as any).result, "clean");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
Deno.test("parseResult trims valid agent result strings", () => {
|
|
45
|
+
assertEquals(parseResult("clean\n"), "clean");
|
|
46
|
+
assertEquals(parseResult(" merge-conflict "), "merge-conflict");
|
|
47
|
+
assertEquals(parseResult("unknown"), "suite-failed");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
Deno.test("record-trial-merge escalates only suite-failed results", async () => {
|
|
51
|
+
const { app } = fakeApp();
|
|
52
|
+
const out = await handler(
|
|
53
|
+
// deno-lint-ignore no-explicit-any
|
|
54
|
+
{
|
|
55
|
+
key: 12,
|
|
56
|
+
variables: {
|
|
57
|
+
planKey: "o/r#69",
|
|
58
|
+
currentWave: 3,
|
|
59
|
+
result: "suite-failed",
|
|
60
|
+
summary: "combined test failed",
|
|
61
|
+
failing: ["integration suite"],
|
|
62
|
+
},
|
|
63
|
+
} as any,
|
|
64
|
+
// deno-lint-ignore no-explicit-any
|
|
65
|
+
app as any,
|
|
66
|
+
) as Record<string, unknown>;
|
|
67
|
+
|
|
68
|
+
assertEquals(out.trialMergeRed, true);
|
|
69
|
+
assertEquals(out.task, { id: "trial-merge-wave-3", title: "Trial merge gate for wave 3" });
|
|
70
|
+
assertStringIncludes(String(out.question ?? ""), "combined test failed");
|
|
71
|
+
assertStringIncludes(String(out.question ?? ""), "proceed");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
Deno.test("record-trial-merge renders odd failing payloads without throwing", async () => {
|
|
75
|
+
const { app } = fakeApp();
|
|
76
|
+
const out = await handler(
|
|
77
|
+
// deno-lint-ignore no-explicit-any
|
|
78
|
+
{
|
|
79
|
+
variables: {
|
|
80
|
+
planKey: "o/r#69",
|
|
81
|
+
result: "suite-failed",
|
|
82
|
+
summary: "combined test failed",
|
|
83
|
+
failing: [1n],
|
|
84
|
+
},
|
|
85
|
+
} as any,
|
|
86
|
+
// deno-lint-ignore no-explicit-any
|
|
87
|
+
app as any,
|
|
88
|
+
) as Record<string, unknown>;
|
|
89
|
+
|
|
90
|
+
assertEquals(out.trialMergeRed, true);
|
|
91
|
+
assertStringIncludes(String(out.question ?? ""), "Failing:");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
Deno.test("record-trial-merge treats textual conflicts as pass-through", async () => {
|
|
95
|
+
const { app } = fakeApp();
|
|
96
|
+
const out = await handler(
|
|
97
|
+
// deno-lint-ignore no-explicit-any
|
|
98
|
+
{ variables: { planKey: "o/r#69", result: "merge-conflict", conflicts: [{ prs: [1, 2] }] } } as any,
|
|
99
|
+
// deno-lint-ignore no-explicit-any
|
|
100
|
+
app as any,
|
|
101
|
+
) as Record<string, unknown>;
|
|
102
|
+
|
|
103
|
+
assertEquals(out.trialMergeRed, false);
|
|
104
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// pr.record-trial-merge — persist the D3 trial-merge gate result and shape the BPMN gateway vars.
|
|
2
|
+
// Only `suite-failed` escalates. `clean` and textual `merge-conflict` proceed; D2/D6 own textual
|
|
3
|
+
// conflict ordering, while D3 owns clean-merge/combined-suite-red semantic conflicts.
|
|
4
|
+
import type { AppJobHandler } from "@nanobpm/urban";
|
|
5
|
+
import {
|
|
6
|
+
recordTrialMergeAudit,
|
|
7
|
+
trialMergeDecision,
|
|
8
|
+
trialMergeTaskId,
|
|
9
|
+
type TrialMergeResult,
|
|
10
|
+
} from "../../app/trialMerge.ts";
|
|
11
|
+
|
|
12
|
+
interface In extends Record<string, unknown> {
|
|
13
|
+
planKey: string;
|
|
14
|
+
currentWave?: unknown;
|
|
15
|
+
trialMergeWave?: unknown;
|
|
16
|
+
waveOpenHeads?: unknown;
|
|
17
|
+
result?: unknown;
|
|
18
|
+
conflicts?: unknown;
|
|
19
|
+
failing?: unknown;
|
|
20
|
+
summary?: unknown;
|
|
21
|
+
}
|
|
22
|
+
interface Out extends Record<string, unknown> {
|
|
23
|
+
trialMergeRed: boolean;
|
|
24
|
+
question?: string;
|
|
25
|
+
task?: { id: string; title: string };
|
|
26
|
+
summary?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const RESULTS = new Set<TrialMergeResult>(["clean", "merge-conflict", "suite-failed"]);
|
|
30
|
+
const str = (v: unknown): string | undefined =>
|
|
31
|
+
typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined;
|
|
32
|
+
const waveNo = (v: unknown): number => {
|
|
33
|
+
const n = Math.trunc(Number(v));
|
|
34
|
+
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
35
|
+
};
|
|
36
|
+
const safeJson = (v: unknown): string => {
|
|
37
|
+
try {
|
|
38
|
+
return JSON.stringify(v) ?? String(v);
|
|
39
|
+
} catch {
|
|
40
|
+
return String(v);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function parseResult(v: unknown): TrialMergeResult {
|
|
45
|
+
const s = typeof v === "string" ? v.trim() : "";
|
|
46
|
+
return RESULTS.has(s as TrialMergeResult) ? (s as TrialMergeResult) : "suite-failed";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
50
|
+
const planKey = job.variables.planKey;
|
|
51
|
+
const wave = waveNo(job.variables.trialMergeWave ?? job.variables.currentWave);
|
|
52
|
+
const result = parseResult(job.variables.result);
|
|
53
|
+
const summary = str(job.variables.summary) ??
|
|
54
|
+
(result === "suite-failed" ? "Trial merge suite failed or returned no machine-readable result" : result);
|
|
55
|
+
const rawJobKey = (job as { key?: unknown }).key;
|
|
56
|
+
const jobKey = rawJobKey == null ? null : String(rawJobKey);
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
await recordTrialMergeAudit(app.data, {
|
|
60
|
+
planKey,
|
|
61
|
+
wave,
|
|
62
|
+
result,
|
|
63
|
+
heads: job.variables.waveOpenHeads,
|
|
64
|
+
conflicts: job.variables.conflicts,
|
|
65
|
+
failing: job.variables.failing,
|
|
66
|
+
summary,
|
|
67
|
+
jobKey,
|
|
68
|
+
});
|
|
69
|
+
} catch (err) {
|
|
70
|
+
app.log("error", `record-trial-merge: audit persist failed for ${planKey} wave ${wave}`, { err: String(err) });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const trialMergeRed = trialMergeDecision(result) === "escalate";
|
|
74
|
+
if (!trialMergeRed) return { trialMergeRed, summary };
|
|
75
|
+
|
|
76
|
+
const failing = Array.isArray(job.variables.failing) && job.variables.failing.length > 0
|
|
77
|
+
? ` Failing: ${safeJson(job.variables.failing).slice(0, 1000)}`
|
|
78
|
+
: "";
|
|
79
|
+
return {
|
|
80
|
+
trialMergeRed,
|
|
81
|
+
summary,
|
|
82
|
+
task: { id: trialMergeTaskId(wave), title: `Trial merge gate for wave ${wave}` },
|
|
83
|
+
question: `${summary}${failing}\n\nD3 needs a human decision: either the PR heads merged cleanly and the combined suite failed, or the trial-merge agent did not return a valid machine-readable result. Decide the design/infrastructure fix, update the PR heads if needed, then answer to rerun the trial merge; answer exactly \"proceed\" only to override and continue without rerunning.`,
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export default handler;
|
|
88
|
+
export { parseResult };
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// Red/green regression for retrying a wave when `select-wave` deliberately left pending work
|
|
2
|
+
// behind a non-fatal wait (D7 / issue #63). Advancing past that wave would make `record-results`
|
|
3
|
+
// finish the plan with a still-pending task.
|
|
4
|
+
import { assertEquals } from "jsr:@std/assert@1";
|
|
5
|
+
import handler from "./worker.ts";
|
|
6
|
+
import type { PlanTaskStatus } from "../../app/plan.ts";
|
|
7
|
+
import { _clearMergeProtocolCache } from "../../app/mergeProtocol.ts";
|
|
8
|
+
|
|
9
|
+
interface Row {
|
|
10
|
+
id: number;
|
|
11
|
+
plan_key: string;
|
|
12
|
+
task_id: string;
|
|
13
|
+
status: PlanTaskStatus;
|
|
14
|
+
wave?: number | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function fakeApp(rows: Row[]) {
|
|
18
|
+
const planUpdates: Record<string, unknown>[] = [];
|
|
19
|
+
const stores: Record<string, Record<string, unknown>[]> = {
|
|
20
|
+
plan_tasks: rows as unknown as Record<string, unknown>[],
|
|
21
|
+
plan_task_deps: [],
|
|
22
|
+
pull_requests: [],
|
|
23
|
+
pr_dependencies: [],
|
|
24
|
+
escalations: [],
|
|
25
|
+
plans: [],
|
|
26
|
+
plan_task_deltas: [],
|
|
27
|
+
plan_merge_exclusions: [],
|
|
28
|
+
};
|
|
29
|
+
return {
|
|
30
|
+
app: {
|
|
31
|
+
data: {
|
|
32
|
+
table(name: string, key: string) {
|
|
33
|
+
const store = stores[name] ??= [];
|
|
34
|
+
return {
|
|
35
|
+
// deno-lint-ignore no-explicit-any
|
|
36
|
+
get: (k: any) =>
|
|
37
|
+
Promise.resolve(store.find((r) => ((r as unknown) as Record<string, unknown>)[key] === k)),
|
|
38
|
+
// deno-lint-ignore no-explicit-any
|
|
39
|
+
find: (q: any) =>
|
|
40
|
+
Promise.resolve(
|
|
41
|
+
store.filter((r) =>
|
|
42
|
+
Object.entries(q).every(([f, v]) =>
|
|
43
|
+
((r as unknown) as Record<string, unknown>)[f] === v
|
|
44
|
+
)
|
|
45
|
+
),
|
|
46
|
+
),
|
|
47
|
+
// deno-lint-ignore no-explicit-any
|
|
48
|
+
insert: (row: any) => {
|
|
49
|
+
store.push(row);
|
|
50
|
+
return Promise.resolve(store.length);
|
|
51
|
+
},
|
|
52
|
+
// deno-lint-ignore no-explicit-any
|
|
53
|
+
update: (k: any, patch: any) => {
|
|
54
|
+
if (name === "plans") {
|
|
55
|
+
planUpdates.push({ key: k, patch });
|
|
56
|
+
return Promise.resolve(undefined);
|
|
57
|
+
}
|
|
58
|
+
const row = store.find((r) =>
|
|
59
|
+
((r as unknown) as Record<string, unknown>)[key] === k
|
|
60
|
+
);
|
|
61
|
+
if (row) Object.assign(row, patch);
|
|
62
|
+
return Promise.resolve(row);
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
log: () => undefined,
|
|
68
|
+
engine: {
|
|
69
|
+
createInstance: () => Promise.resolve({ processInstanceKey: "pi" }),
|
|
70
|
+
},
|
|
71
|
+
// deno-lint-ignore no-explicit-any
|
|
72
|
+
} as any,
|
|
73
|
+
planUpdates,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function installGithubStub(method: "gh-merge" | "mergify-queue") {
|
|
78
|
+
_clearMergeProtocolCache();
|
|
79
|
+
const oldTransport = Deno.env.get("NANO_PR_GITHUB_TRANSPORT");
|
|
80
|
+
const oldToken = Deno.env.get("GITHUB_TOKEN");
|
|
81
|
+
const oldFetch = globalThis.fetch;
|
|
82
|
+
Deno.env.set("NANO_PR_GITHUB_TRANSPORT", "token");
|
|
83
|
+
Deno.env.set("GITHUB_TOKEN", "test-token");
|
|
84
|
+
globalThis.fetch = ((input: string | URL | Request) => {
|
|
85
|
+
const url = String(input);
|
|
86
|
+
if (url.includes("/contents/AGENTS.md")) {
|
|
87
|
+
if (method === "mergify-queue") {
|
|
88
|
+
return Promise.resolve(new Response('```merge-protocol\n{ "land": { "method": "mergify-queue" } }\n```'));
|
|
89
|
+
}
|
|
90
|
+
return Promise.resolve(new Response("not found", { status: 404 }));
|
|
91
|
+
}
|
|
92
|
+
if (url.includes("/contents/.github/merge-protocol.json")) {
|
|
93
|
+
return Promise.resolve(new Response(JSON.stringify({ land: { method } })));
|
|
94
|
+
}
|
|
95
|
+
const pr = url.match(/\/pulls\/(\d+)/)?.[1] ?? "0";
|
|
96
|
+
if (url.includes("/files")) return Promise.resolve(new Response("[]"));
|
|
97
|
+
if (url.includes("/pulls/")) {
|
|
98
|
+
return Promise.resolve(new Response(JSON.stringify({
|
|
99
|
+
title: `PR ${pr}`,
|
|
100
|
+
body: "",
|
|
101
|
+
head: { ref: `feature-${pr}`, sha: `sha-${pr}` },
|
|
102
|
+
})));
|
|
103
|
+
}
|
|
104
|
+
return Promise.resolve(new Response("not found", { status: 404 }));
|
|
105
|
+
}) as typeof fetch;
|
|
106
|
+
return () => {
|
|
107
|
+
if (oldTransport == null) Deno.env.delete("NANO_PR_GITHUB_TRANSPORT");
|
|
108
|
+
else Deno.env.set("NANO_PR_GITHUB_TRANSPORT", oldTransport);
|
|
109
|
+
if (oldToken == null) Deno.env.delete("GITHUB_TOKEN");
|
|
110
|
+
else Deno.env.set("GITHUB_TOKEN", oldToken);
|
|
111
|
+
globalThis.fetch = oldFetch;
|
|
112
|
+
_clearMergeProtocolCache();
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
Deno.test("record-wave retries the same wave when a task is still pending", async () => {
|
|
117
|
+
const rows: Row[] = [{
|
|
118
|
+
id: 2,
|
|
119
|
+
plan_key: "owner/repo#63",
|
|
120
|
+
task_id: "b",
|
|
121
|
+
status: "pending",
|
|
122
|
+
wave: 1,
|
|
123
|
+
}];
|
|
124
|
+
const { app, planUpdates } = fakeApp(rows);
|
|
125
|
+
|
|
126
|
+
const out = await handler(
|
|
127
|
+
// deno-lint-ignore no-explicit-any
|
|
128
|
+
{
|
|
129
|
+
variables: {
|
|
130
|
+
planKey: "owner/repo#63",
|
|
131
|
+
currentWave: 1,
|
|
132
|
+
waveCount: 2,
|
|
133
|
+
waveTasks: [],
|
|
134
|
+
waveResults: [],
|
|
135
|
+
},
|
|
136
|
+
} as any,
|
|
137
|
+
app,
|
|
138
|
+
) as Record<string, unknown>;
|
|
139
|
+
|
|
140
|
+
assertEquals(out, {
|
|
141
|
+
currentWave: 1,
|
|
142
|
+
hasMoreWaves: true,
|
|
143
|
+
waveOpenHeads: [],
|
|
144
|
+
runTrialMerge: false,
|
|
145
|
+
trialMergeWave: 1,
|
|
146
|
+
trialMergeSkipReason: "wave-still-pending",
|
|
147
|
+
});
|
|
148
|
+
assertEquals((planUpdates[0].patch as Record<string, unknown>).gate_wave, 1);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
Deno.test("record-wave skips trial merge for mergify-queue repos with 2+ heads", async () => {
|
|
152
|
+
const restore = installGithubStub("mergify-queue");
|
|
153
|
+
try {
|
|
154
|
+
const rows: Row[] = [
|
|
155
|
+
{ id: 1, plan_key: "owner/repo#69", task_id: "a", status: "pending", wave: 0 },
|
|
156
|
+
{ id: 2, plan_key: "owner/repo#69", task_id: "b", status: "pending", wave: 0 },
|
|
157
|
+
];
|
|
158
|
+
const { app } = fakeApp(rows);
|
|
159
|
+
const out = await handler(
|
|
160
|
+
// deno-lint-ignore no-explicit-any
|
|
161
|
+
{
|
|
162
|
+
variables: {
|
|
163
|
+
planKey: "owner/repo#69",
|
|
164
|
+
currentWave: 0,
|
|
165
|
+
waveCount: 1,
|
|
166
|
+
waveTasks: [{ id: "a" }, { id: "b" }],
|
|
167
|
+
waveResults: [
|
|
168
|
+
{ status: "opened", pr: "owner/repo#1" },
|
|
169
|
+
{ status: "opened", pr: "owner/repo#2" },
|
|
170
|
+
],
|
|
171
|
+
},
|
|
172
|
+
} as any,
|
|
173
|
+
app,
|
|
174
|
+
) as Record<string, unknown>;
|
|
175
|
+
|
|
176
|
+
assertEquals(out.runTrialMerge, false);
|
|
177
|
+
assertEquals(out.trialMergeSkipReason, "mergify-queue");
|
|
178
|
+
assertEquals(out.waveOpenHeads, [
|
|
179
|
+
{ repo: "owner/repo", prNumber: 1, headRef: "feature-1", headSha: "sha-1" },
|
|
180
|
+
{ repo: "owner/repo", prNumber: 2, headRef: "feature-2", headSha: "sha-2" },
|
|
181
|
+
]);
|
|
182
|
+
} finally {
|
|
183
|
+
restore();
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
Deno.test("record-wave runs trial merge for non-queue repos with populated heads", async () => {
|
|
188
|
+
const restore = installGithubStub("gh-merge");
|
|
189
|
+
try {
|
|
190
|
+
const rows: Row[] = [
|
|
191
|
+
{ id: 1, plan_key: "owner/repo#69", task_id: "a", status: "pending", wave: 0 },
|
|
192
|
+
{ id: 2, plan_key: "owner/repo#69", task_id: "b", status: "pending", wave: 0 },
|
|
193
|
+
];
|
|
194
|
+
const { app } = fakeApp(rows);
|
|
195
|
+
const out = await handler(
|
|
196
|
+
// deno-lint-ignore no-explicit-any
|
|
197
|
+
{
|
|
198
|
+
variables: {
|
|
199
|
+
planKey: "owner/repo#69",
|
|
200
|
+
currentWave: 0,
|
|
201
|
+
waveCount: 1,
|
|
202
|
+
waveTasks: [{ id: "a" }, { id: "b" }],
|
|
203
|
+
waveResults: [
|
|
204
|
+
{ status: "opened", pr: "owner/repo#1" },
|
|
205
|
+
{ status: "opened", pr: "owner/repo#2" },
|
|
206
|
+
],
|
|
207
|
+
},
|
|
208
|
+
} as any,
|
|
209
|
+
app,
|
|
210
|
+
) as Record<string, unknown>;
|
|
211
|
+
|
|
212
|
+
assertEquals(out.runTrialMerge, true);
|
|
213
|
+
assertEquals(out.trialMergeSkipReason, undefined);
|
|
214
|
+
assertEquals(out.waveOpenHeads, [
|
|
215
|
+
{ repo: "owner/repo", prNumber: 1, headRef: "feature-1", headSha: "sha-1" },
|
|
216
|
+
{ repo: "owner/repo", prNumber: 2, headRef: "feature-2", headSha: "sha-2" },
|
|
217
|
+
]);
|
|
218
|
+
} finally {
|
|
219
|
+
restore();
|
|
220
|
+
}
|
|
221
|
+
});
|