@yemi33/minions 0.1.2197 → 0.1.2198

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.
@@ -0,0 +1,370 @@
1
+ /**
2
+ * engine/pr-clone-keep.js — Clone & keep fix executor: auto-clone a PR's repo and
3
+ * register it as a PERSISTENT project so ongoing tracking / auto-fix works.
4
+ *
5
+ * Plan: projectless-pr-actions, Phase 2, item P-ppa10007. This is the second
6
+ * mutating executor in the chain (after the ephemeral temp-clone, P-ppa10006).
7
+ * The execution-target choice surface (engine/pr-fix-target.js) routes
8
+ * `clone-keep` here once the user has explicitly picked it — the "never clone
9
+ * silently" invariant lives upstream; by the time we run, the user has chosen to
10
+ * clone AND keep.
11
+ *
12
+ * Contract (from the choice surface's description): "Auto-clone the repo and
13
+ * register it as a persistent project so ongoing tracking/auto-fix works.
14
+ * Disk-resident."
15
+ *
16
+ * planCloneKeep(input, opts) — resolve the PR ref, confirm auth + the base
17
+ * repo's clone URL + default branch, and compute
18
+ * the PERSISTENT project dir + name. PLANNING
19
+ * ONLY: nothing is created on disk, nothing is
20
+ * cloned.
21
+ * executeCloneKeep(plan, opts) — clone the BASE repo into the persistent dir
22
+ * and register it as a project via
23
+ * engine/projects.js#addProject. The repo then
24
+ * becomes `project-ready` so the fix itself
25
+ * flows through the engine's normal project fix
26
+ * path. On registration failure the partial
27
+ * clone is rolled back so no orphan dir leaks.
28
+ *
29
+ * Load-bearing invariants (read before extending):
30
+ * - PERSISTENT (the whole point vs temp-clone): the clone dir is durable and is
31
+ * handed to addProject as the project's `localPath`. It is NOT discarded on
32
+ * success. It IS rolled back if registration fails (so a half-registered repo
33
+ * never leaves an orphan directory behind).
34
+ * - BASE REPO, NOT FORK: a persistent project tracks the canonical PR target
35
+ * repo (`ref.slug`), never a contributor's fork — ongoing auto-fix is about the
36
+ * base repo. The base clone URL is derived from the ref, independent of the
37
+ * PR head repo.
38
+ * - REGISTER-THEN-DONE: this executor sets up the ENVIRONMENT (clone + project
39
+ * registration). It does NOT run the fix/tests/push itself — once the project
40
+ * is registered the repo is `project-ready` and the fix routes through the
41
+ * existing project worktree/fix path. Keeping the two concerns separate is why
42
+ * a single dispatch can never both clone-silently and skip the choice.
43
+ * - IDEMPOTENT RE-CHECK: a stale clone-keep plan must never re-clone a repo that
44
+ * has since become a configured project. executeCloneKeep re-derives the
45
+ * configured-project check from current config and short-circuits to
46
+ * `project-ready` (no clone) when the repo is already linked.
47
+ * - All git/network/register steps are injectable seams so unit tests never clone
48
+ * a real repo; the defaults are real (shellSafeGit / projects.addProject).
49
+ */
50
+
51
+ const fs = require('fs');
52
+ const path = require('path');
53
+
54
+ const shared = require('./shared');
55
+ const prResolve = require('./pr-resolve');
56
+ const ghToken = require('./gh-token');
57
+ const prFixTarget = require('./pr-fix-target');
58
+ const { PrActionError } = require('./pr-action');
59
+
60
+ const CLONE_KEEP_DIR_NAME = 'cloned-repos'; // persistent root, under MINIONS_DIR
61
+ const CLONE_TIMEOUT_MS = 10 * 60 * 1000; // full clone can be large
62
+ const GIT_OP_TIMEOUT_MS = 60 * 1000;
63
+
64
+ // ── Ref resolution ────────────────────────────────────────────────────────────
65
+
66
+ /**
67
+ * Resolve `{ url }` (raw PR URL / canonical id), an already-normalized ref, or a
68
+ * routed plan (carrying `.ref`) into a normalized PR ref. Throws
69
+ * `PrActionError` (400) on missing/unrecognized input — mirrors the read-only,
70
+ * choice-surface, and temp-clone paths so the dashboard reflects a 400, not a 500.
71
+ */
72
+ function _resolveRef(input) {
73
+ if (input && typeof input === 'object') {
74
+ if (input.host && input.slug && input.number) return input; // already normalized
75
+ if (input.ref && input.ref.host) return input.ref; // plan/handle carrying a ref
76
+ }
77
+ const rawUrl = input && typeof input === 'object'
78
+ ? (typeof input.url === 'string' ? input.url.trim() : '')
79
+ : (typeof input === 'string' ? input.trim() : '');
80
+ if (!rawUrl) throw new PrActionError('url required');
81
+ const ref = prResolve.normalizePrRef(rawUrl);
82
+ if (!ref) throw new PrActionError(`unrecognized PR reference: ${JSON.stringify(rawUrl.slice(0, 120))}`);
83
+ return ref;
84
+ }
85
+
86
+ function _prId(ref) {
87
+ if (!ref) return null;
88
+ return ref.id || (ref.host && ref.slug && ref.number ? `${ref.host}:${ref.slug}#${ref.number}` : null);
89
+ }
90
+
91
+ /** Filesystem-safe slug for the persistent project dir name (from the repo name). */
92
+ function _safeDirSlug(name) {
93
+ return String(name || 'repo').replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'repo';
94
+ }
95
+
96
+ /** The persistent-clone root — durable, OUTSIDE the worktree pool + tmp roots. */
97
+ function _cloneRoot(opts = {}) {
98
+ return opts.cloneRoot || path.join(shared.MINIONS_DIR, CLONE_KEEP_DIR_NAME);
99
+ }
100
+
101
+ /**
102
+ * The BASE repo clone URL for a normalized ref (the PR TARGET repo, never a fork).
103
+ * GitHub is derived from the validated slug; ADO uses the base repo remoteUrl that
104
+ * fetchPrBranches already resolved (ADO PRs are same-repo, so it is the base repo).
105
+ */
106
+ function _baseCloneUrl(ref, branches) {
107
+ if (ref.host === 'github') {
108
+ const slug = shared.validateGhSlug(ref.slug);
109
+ return `https://github.com/${slug}.git`;
110
+ }
111
+ if (ref.host === 'ado') {
112
+ return (branches && branches.cloneUrl) || null;
113
+ }
114
+ return null;
115
+ }
116
+
117
+ // ── Planning ──────────────────────────────────────────────────────────────────
118
+
119
+ /**
120
+ * Plan a clone-keep fix: resolve the PR ref, confirm auth + the base repo's clone
121
+ * URL + default branch (via fetchPrBranches, mirroring temp-clone), and compute
122
+ * the persistent project dir + name. NOTHING is created or cloned here.
123
+ *
124
+ * `input` — `{ url }`, a normalized ref, or a routed plan from
125
+ * engine/pr-fix-target.js#resolvePrFixTarget.
126
+ * `opts.fetchPrBranches` — override the branch fetch (test seam).
127
+ * `opts.prUrl` — original user URL (preferred for surfacing).
128
+ * `opts.cloneRoot` — override the persistent-clone root.
129
+ * `opts.projectName` — override the derived project name.
130
+ *
131
+ * Returns `{ status:'clone-keep-planned', target:'clone-keep', ref, prId, prUrl,
132
+ * cloneUrl, targetBranch, projectDir, projectName, validates:true }`.
133
+ * Throws `PrActionError` (400) on bad input; re-throws fetch errors.
134
+ */
135
+ async function planCloneKeep(input = {}, opts = {}) {
136
+ const ref = _resolveRef(input);
137
+ const prUrl = opts.prUrl || (input && typeof input === 'object' ? input.url : undefined) || null;
138
+ const prId = _prId(ref);
139
+
140
+ // Confirm the PR exists + auth works, and learn the base (target) branch. This
141
+ // mirrors temp-clone — a clone-keep on a repo we cannot reach should fail in
142
+ // planning, before we touch the disk.
143
+ const fetchBranches = opts.fetchPrBranches || prResolve.fetchPrBranches;
144
+ const branches = await fetchBranches(ref, opts);
145
+
146
+ const cloneUrl = _baseCloneUrl(ref, branches);
147
+ if (!cloneUrl) throw new PrActionError(`could not resolve a base clone URL for ${prId || 'PR'}`, 502);
148
+
149
+ const projectName = _safeDirSlug(opts.projectName || ref.repo || ref.slug);
150
+ const projectDir = path.join(_cloneRoot(opts), projectName);
151
+
152
+ return {
153
+ status: 'clone-keep-planned',
154
+ target: 'clone-keep',
155
+ ref,
156
+ prId,
157
+ prUrl,
158
+ cloneUrl,
159
+ targetBranch: (branches && branches.targetBranch) || null,
160
+ projectDir,
161
+ projectName,
162
+ validates: true,
163
+ };
164
+ }
165
+
166
+ // ── Default git seam (real implementation; tests inject their own) ─────────────
167
+
168
+ /** Resolve an auth token for the PR's host (GitHub PAT per-slug / ADO bearer). */
169
+ async function _resolveToken(plan, opts = {}) {
170
+ if (opts.token) return opts.token;
171
+ if (plan.ref.host === 'github') {
172
+ const resolve = opts._resolveTokenForSlug || ghToken.resolveTokenForSlug;
173
+ return resolve(plan.ref.slug) || null;
174
+ }
175
+ if (plan.ref.host === 'ado') {
176
+ const acquire = opts._acquireAdoToken || require('./ado-token').acquireAdoToken;
177
+ const acquired = await acquire();
178
+ return (acquired && acquired.token) || null;
179
+ }
180
+ return null;
181
+ }
182
+
183
+ /** Build an HTTPS clone URL with an embedded GitHub token (private base repos). */
184
+ function _authedGithubUrl(cloneUrl, token) {
185
+ if (!token) return cloneUrl;
186
+ try {
187
+ const u = new URL(cloneUrl);
188
+ u.username = 'x-access-token';
189
+ u.password = token;
190
+ return u.toString();
191
+ } catch {
192
+ return cloneUrl;
193
+ }
194
+ }
195
+
196
+ /** Per-invocation git auth args. ADO injects a bearer header; GitHub embeds in URL. */
197
+ function _gitAuthArgs(plan, token) {
198
+ if (plan.ref.host === 'ado' && token) {
199
+ return ['-c', `http.extraHeader=Authorization: Bearer ${token}`];
200
+ }
201
+ return [];
202
+ }
203
+
204
+ async function _defaultGitClone(plan, token, opts = {}) {
205
+ const runGit = opts._shellSafeGit || shared.shellSafeGit;
206
+ const url = plan.ref.host === 'github' ? _authedGithubUrl(plan.cloneUrl, token) : plan.cloneUrl;
207
+ // Full clone (NOT --depth 1 / --single-branch): a persistent project needs the
208
+ // whole history + every branch so the engine can later create worktrees and
209
+ // fetch the PR branch through the normal project fix path.
210
+ await runGit(
211
+ ['clone', url, plan.projectDir],
212
+ { timeout: CLONE_TIMEOUT_MS, gitExtraArgs: _gitAuthArgs(plan, token) },
213
+ );
214
+ }
215
+
216
+ /**
217
+ * Best-effort recursive remove of a partial clone dir on rollback. NEVER throws —
218
+ * rollback runs after a failure and must not mask the real error.
219
+ */
220
+ async function _rollbackClone(dir, opts = {}) {
221
+ if (!dir) return true;
222
+ const rm = opts._rm || ((d) => fs.promises.rm(d, { recursive: true, force: true, maxRetries: 6, retryDelay: 200 }));
223
+ try {
224
+ await rm(dir);
225
+ return true;
226
+ } catch (e) {
227
+ shared.log('warn', `pr-clone-keep: failed to roll back partial clone ${dir}: ${e?.message || e}`);
228
+ return false;
229
+ }
230
+ }
231
+
232
+ // ── Execution ─────────────────────────────────────────────────────────────────
233
+
234
+ function _record(plan, fields) {
235
+ return {
236
+ status: 'failed',
237
+ target: 'clone-keep',
238
+ ref: plan.ref,
239
+ prId: plan.prId,
240
+ prUrl: plan.prUrl,
241
+ cloneUrl: plan.cloneUrl,
242
+ projectDir: plan.projectDir,
243
+ projectName: plan.projectName,
244
+ cloned: false,
245
+ registered: false,
246
+ project: null,
247
+ rolledBack: false,
248
+ failure_class: null,
249
+ retryable: false,
250
+ error: null,
251
+ ...fields,
252
+ };
253
+ }
254
+
255
+ /**
256
+ * Execute the clone-keep lifecycle for a planned (or raw) PR fix: clone the BASE
257
+ * repo into a persistent dir and register it as a project so the repo becomes
258
+ * `project-ready`. The fix itself is NOT run here — once registered it flows
259
+ * through the engine's normal project fix path.
260
+ *
261
+ * `planOrInput` — a plan from `planCloneKeep`, a routed plan from pr-fix-target.js,
262
+ * a normalized ref, or `{ url }`.
263
+ * `opts`:
264
+ * - `config` — full config; re-checks configured-project so a stale plan can't
265
+ * re-clone a repo that is already linked.
266
+ * - `registerOptions` — passed through to projects.addProject (name override,
267
+ * worktreeMode, observeAuthors, …).
268
+ * - test seams: `gitClone`, `addProject`, `findConfiguredProject`, `fetchPrBranches`,
269
+ * `token`/`_resolveTokenForSlug`/`_acquireAdoToken`, `cloneRoot`, `_rm`, `_mkdir`.
270
+ *
271
+ * Returns:
272
+ * - `{ status:'project-ready', alreadyConfigured:true, project, projectName,
273
+ * cloned:false, registered:false, ... }` — repo is already a configured
274
+ * project; NOTHING is cloned, route to the existing project fix path.
275
+ * - `{ status:'done', registered:true, project, projectDir, projectName,
276
+ * cloned:true, ... }` — clone + registration succeeded.
277
+ * - a `failed` record on any downstream failure (partial clone rolled back).
278
+ *
279
+ * Throws `PrActionError` (400) only for bad input.
280
+ */
281
+ async function executeCloneKeep(planOrInput = {}, opts = {}) {
282
+ // 0) Idempotent re-check: never re-clone a repo that is ALREADY a configured
283
+ // project (a stale awaiting-choice plan must route to the project path).
284
+ const ref = _resolveRef(planOrInput); // PrActionError(400) on bad input propagates
285
+ const findConfigured = opts.findConfiguredProject || prFixTarget.findConfiguredProjectForRef;
286
+ const existing = findConfigured(ref, opts.config);
287
+ if (existing) {
288
+ return {
289
+ status: 'project-ready',
290
+ target: 'clone-keep',
291
+ ref,
292
+ prId: _prId(ref),
293
+ prUrl: (planOrInput && typeof planOrInput === 'object' ? (planOrInput.prUrl || planOrInput.url) : null) || null,
294
+ alreadyConfigured: true,
295
+ project: existing,
296
+ projectName: existing.name || null,
297
+ cloned: false,
298
+ registered: false,
299
+ failure_class: null,
300
+ retryable: false,
301
+ error: null,
302
+ };
303
+ }
304
+
305
+ // Accept an already-built plan, else plan now (resolves clone URL + project dir).
306
+ let plan;
307
+ if (planOrInput && planOrInput.status === 'clone-keep-planned' && planOrInput.projectDir) {
308
+ plan = planOrInput;
309
+ } else {
310
+ plan = await planCloneKeep(planOrInput, opts); // PrActionError(400) on bad input propagates
311
+ }
312
+
313
+ const gitClone = opts.gitClone || ((p, token) => _defaultGitClone(p, token, opts));
314
+ const addProject = opts.addProject || ((target, registerOptions) => require('./projects').addProject(target, registerOptions));
315
+ const mkdir = opts._mkdir || ((dir) => fs.promises.mkdir(dir, { recursive: true }));
316
+
317
+ const rec = _record(plan, {});
318
+ let token = null;
319
+
320
+ // 1) Persistent clone of the BASE repo.
321
+ try {
322
+ token = await _resolveToken(plan, opts);
323
+ await mkdir(path.dirname(plan.projectDir));
324
+ await gitClone(plan, token);
325
+ rec.cloned = true;
326
+ } catch (e) {
327
+ rec.failure_class = shared.FAILURE_CLASS.NETWORK_ERROR;
328
+ rec.retryable = true;
329
+ rec.error = `clone failed: ${e?.message || e}`;
330
+ // Best-effort rollback of any partial clone dir before reporting.
331
+ rec.rolledBack = await _rollbackClone(plan.projectDir, opts);
332
+ return rec;
333
+ }
334
+
335
+ // 2) Register the clone as a persistent project.
336
+ let registered;
337
+ try {
338
+ registered = await addProject(plan.projectDir, { ...(opts.registerOptions || {}) });
339
+ } catch (e) {
340
+ rec.failure_class = shared.FAILURE_CLASS.CONFIG_ERROR;
341
+ rec.retryable = false;
342
+ rec.error = `project registration failed: ${e?.message || e}`;
343
+ // Roll back the clone we created so a failed registration leaves no orphan.
344
+ rec.rolledBack = await _rollbackClone(plan.projectDir, opts);
345
+ rec.cloned = false;
346
+ return rec;
347
+ }
348
+
349
+ rec.status = 'done';
350
+ rec.registered = true;
351
+ rec.project = (registered && registered.project) || registered || null;
352
+ rec.projectName = (rec.project && rec.project.name) || plan.projectName;
353
+ rec.failure_class = null;
354
+ rec.retryable = false;
355
+ rec.error = null;
356
+ return rec;
357
+ }
358
+
359
+ module.exports = {
360
+ CLONE_KEEP_DIR_NAME,
361
+ planCloneKeep,
362
+ executeCloneKeep,
363
+ // Exported for testing.
364
+ _resolveRef,
365
+ _safeDirSlug,
366
+ _baseCloneUrl,
367
+ _authedGithubUrl,
368
+ _gitAuthArgs,
369
+ _rollbackClone,
370
+ };
@@ -0,0 +1,322 @@
1
+ /**
2
+ * engine/pr-devbox.js — DevBox execution target (the "never touches your machine"
3
+ * option for a projectless one-off PR fix).
4
+ *
5
+ * Plan: projectless-pr-actions, Phase 4 (stretch), item P-ppa10011. Like
6
+ * engine/pr-temp-clone.js this is an EXECUTOR the choice surface
7
+ * (engine/pr-fix-target.js) routes to once the user has EXPLICITLY picked it —
8
+ * the "never clone silently" invariant lives upstream; by the time we run, the
9
+ * user has chosen the DevBox target.
10
+ *
11
+ * Contract (from the choice surface's description): "Clone ephemerally ON A
12
+ * DEVBOX, run the fix there, push from the DevBox. Zero local footprint — the
13
+ * answer for high PR volume."
14
+ *
15
+ * planDevBox(input, opts) — resolve the PR ref + its source branch + a
16
+ * clone URL and compute the REMOTE clone dir (on
17
+ * the DevBox). PLANNING ONLY: nothing is created
18
+ * on the local machine, nothing is cloned. The
19
+ * branch fetch is a network metadata call, not a
20
+ * local checkout.
21
+ * executeDevBox(plan, opts) — delegate the full remote lifecycle to the
22
+ * injected DevBox runner (clone → fix → validate
23
+ * → push, all ON the DevBox), then ALWAYS tear
24
+ * the remote clone down. The engine never writes
25
+ * to local disk on this path.
26
+ *
27
+ * Load-bearing invariants (read before extending):
28
+ * - ZERO LOCAL FOOTPRINT: this module touches NO local disk. It pulls in no local
29
+ * filesystem module, never runs a local clone, and every record it returns
30
+ * flags `localFootprint: false`. A source-inspection test guards this. The
31
+ * clone, build/test, and push all happen on the DevBox.
32
+ * - DELEGATED LIFECYCLE: the engine daemon cannot call the DevBox MCP tools
33
+ * directly (they are agent-side tools — see the `devbox-remote-control` skill).
34
+ * So the ENTIRE remote lifecycle is performed by the injected `opts.runOnDevBox`
35
+ * runner (the engine/agent wires the DevBox MCP `task_resource` calls). Without
36
+ * a runner there is nothing to execute — we fail fast (CONFIG_ERROR) BEFORE
37
+ * selecting/provisioning a DevBox rather than spin one up for nothing.
38
+ * - ALWAYS TEAR DOWN: the remote ephemeral clone is removed in a `finally` —
39
+ * success, failure, or noop — so nothing persists on the DevBox either.
40
+ * - EXPLICIT CONSENT ONLY: DevBox is never the default target and is never
41
+ * auto-selected; it only runs when the user picks `devbox` at the choice
42
+ * surface (engine/pr-fix-target.js).
43
+ */
44
+
45
+ const shared = require('./shared');
46
+ const prResolve = require('./pr-resolve');
47
+ const { PrActionError } = require('./pr-action');
48
+
49
+ const DEVBOX_TMP_PREFIX = 'pr-devbox-';
50
+ // DevBoxes are Windows (Windows App, WindowsTerminal, powershell — see the
51
+ // devbox-remote-control skill), so the remote ephemeral-clone root defaults to a
52
+ // Windows temp path. This is a REMOTE path string only — never created locally.
53
+ const DEFAULT_REMOTE_TMP_ROOT = 'C:/minions-devbox-tmp';
54
+
55
+ // ── Ref resolution ────────────────────────────────────────────────────────────
56
+
57
+ /**
58
+ * Resolve `{ url }` (raw PR URL / canonical id), an already-normalized ref, or a
59
+ * routed/devbox plan (carrying `.ref`) into a normalized PR ref. Throws
60
+ * `PrActionError` (400) on missing/unrecognized input — mirrors the other
61
+ * projectless paths so the dashboard reflects a 400, not a 500.
62
+ */
63
+ function _resolveRef(input) {
64
+ if (input && typeof input === 'object') {
65
+ if (input.host && input.slug && input.number) return input; // already normalized
66
+ if (input.ref && input.ref.host) return input.ref; // plan/handle carrying a ref
67
+ }
68
+ const rawUrl = input && typeof input === 'object'
69
+ ? (typeof input.url === 'string' ? input.url.trim() : '')
70
+ : (typeof input === 'string' ? input.trim() : '');
71
+ if (!rawUrl) throw new PrActionError('url required');
72
+ const ref = prResolve.normalizePrRef(rawUrl);
73
+ if (!ref) throw new PrActionError(`unrecognized PR reference: ${JSON.stringify(rawUrl.slice(0, 120))}`);
74
+ return ref;
75
+ }
76
+
77
+ function _prId(ref) {
78
+ if (!ref) return null;
79
+ return ref.id || (ref.host && ref.slug && ref.number ? `${ref.host}:${ref.slug}#${ref.number}` : null);
80
+ }
81
+
82
+ /** Filesystem-safe slug for a PR id (used in the remote dir name). */
83
+ function _safeDirSlug(prId) {
84
+ return String(prId || 'pr').replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'pr';
85
+ }
86
+
87
+ /**
88
+ * Join a remote root + child into a single forward-slashed REMOTE path. We do NOT
89
+ * use path.join — that would apply the LOCAL separator and risks coupling the
90
+ * remote path to the host OS; the runner interprets the string on the DevBox.
91
+ */
92
+ function _remoteJoin(root, name) {
93
+ return `${String(root || DEFAULT_REMOTE_TMP_ROOT).replace(/[\\/]+$/, '')}/${name}`;
94
+ }
95
+
96
+ // ── Planning ──────────────────────────────────────────────────────────────────
97
+
98
+ /**
99
+ * Plan a DevBox fix: resolve the PR ref, fetch its source branch + clone URL
100
+ * (network metadata only), and compute the REMOTE ephemeral clone dir. NOTHING is
101
+ * created on the local machine and NOTHING is cloned here.
102
+ *
103
+ * `input` — `{ url }`, a normalized ref, or a routed plan from
104
+ * engine/pr-fix-target.js#resolvePrFixTarget.
105
+ * `opts.fetchPrBranches` — override the branch fetch (test seam).
106
+ * `opts.prUrl` — original user URL (preferred for surfacing).
107
+ * `opts.remoteTmpRoot` — override the DevBox-side ephemeral-clone root.
108
+ *
109
+ * Returns `{ status:'devbox-planned', target:'devbox', ref, prId, prUrl,
110
+ * sourceBranch, targetBranch, cloneUrl, sourceRepoSlug, isFork, remoteCloneDir,
111
+ * validates:true, localFootprint:false }`.
112
+ * Throws `PrActionError` (400) on bad input; re-throws fetch errors.
113
+ */
114
+ async function planDevBox(input = {}, opts = {}) {
115
+ const ref = _resolveRef(input);
116
+ const prUrl = opts.prUrl || (input && typeof input === 'object' ? input.url : undefined) || null;
117
+ const prId = _prId(ref);
118
+
119
+ const fetchBranches = opts.fetchPrBranches || prResolve.fetchPrBranches;
120
+ const branches = await fetchBranches(ref, opts);
121
+ const sourceBranch = branches && branches.sourceBranch;
122
+ if (!sourceBranch) throw new PrActionError(`could not resolve source branch for ${prId || 'PR'}`, 502);
123
+ // Defensive re-validation (fetchPrBranches already validates, but a custom seam
124
+ // might not) — a poisoned ref name must never reach `git` on the DevBox.
125
+ shared.validateGitRef(sourceBranch);
126
+
127
+ const remoteCloneDir = _remoteJoin(opts.remoteTmpRoot, `${DEVBOX_TMP_PREFIX}${_safeDirSlug(prId)}-${shared.uid()}`);
128
+
129
+ return {
130
+ status: 'devbox-planned',
131
+ target: 'devbox',
132
+ ref,
133
+ prId,
134
+ prUrl,
135
+ sourceBranch,
136
+ targetBranch: branches.targetBranch || null,
137
+ cloneUrl: branches.cloneUrl || null,
138
+ sourceRepoSlug: branches.sourceRepoSlug || ref.slug,
139
+ isFork: !!branches.isFork,
140
+ remoteCloneDir,
141
+ validates: true,
142
+ localFootprint: false,
143
+ };
144
+ }
145
+
146
+ // ── Execution ─────────────────────────────────────────────────────────────────
147
+
148
+ function _record(plan, fields) {
149
+ return {
150
+ status: 'failed',
151
+ target: 'devbox',
152
+ ref: plan.ref,
153
+ prId: plan.prId,
154
+ prUrl: plan.prUrl,
155
+ sourceBranch: plan.sourceBranch || null,
156
+ remoteCloneDir: plan.remoteCloneDir || null,
157
+ devBox: plan.devBox || null,
158
+ changed: false,
159
+ validated: false,
160
+ validationOk: null,
161
+ pushed: false,
162
+ remoteDiscarded: false,
163
+ localFootprint: false,
164
+ output: '',
165
+ failure_class: null,
166
+ retryable: false,
167
+ error: null,
168
+ ...fields,
169
+ };
170
+ }
171
+
172
+ /**
173
+ * Execute the DevBox fix lifecycle for a planned (or raw) PR fix. The engine never
174
+ * touches local disk here: it selects/provisions a DevBox, hands the whole
175
+ * clone → fix → validate → push lifecycle to the injected `opts.runOnDevBox`
176
+ * runner (which drives the DevBox MCP), then ALWAYS tears the remote clone down.
177
+ *
178
+ * `planOrInput` — a plan from `planDevBox`, a routed plan from pr-fix-target.js, a
179
+ * normalized ref, or `{ url }`.
180
+ * `opts`:
181
+ * - `runOnDevBox({ devBox, remoteCloneDir, ref, prId, sourceBranch, targetBranch,
182
+ * cloneUrl, isFork })` — REQUIRED. Performs the ENTIRE remote lifecycle on the
183
+ * DevBox (clone the source branch, run the fix, run tests, push to the PR
184
+ * branch). Returns `{ changed, validated, validationOk, pushed, output,
185
+ * failure_class?, retryable?, error? }`. Throws to fail the run.
186
+ * - `selectDevBox()` — OPTIONAL. Resolves/provisions the target DevBox descriptor
187
+ * (`{ name, projectName, devCenterName, locationInUri, powerState }`). Defaults
188
+ * to the DevBox already on the plan, else null (the runner may pick its own).
189
+ * - `teardown(remoteCloneDir, devBox)` — OPTIONAL. Removes the remote ephemeral
190
+ * clone. Best-effort, never throws. Defaults to a no-op that returns true
191
+ * (the runner is expected to clean up its own remote dir).
192
+ *
193
+ * Returns a terminal record. Throws `PrActionError` (400) only for bad input;
194
+ * every downstream failure is captured in a `failed` record (remote clone still
195
+ * torn down).
196
+ */
197
+ async function executeDevBox(planOrInput = {}, opts = {}) {
198
+ // Fail fast BEFORE selecting/provisioning a DevBox when there is no runner — the
199
+ // remote lifecycle is delegated; spinning up a DevBox for nothing wastes a slot.
200
+ const runOnDevBox = opts.runOnDevBox;
201
+ if (typeof runOnDevBox !== 'function') {
202
+ let ref = null; let prId = null; let prUrl = null;
203
+ try { ref = _resolveRef(planOrInput); prId = _prId(ref); } catch { /* leave null */ }
204
+ prUrl = (planOrInput && typeof planOrInput === 'object' ? (planOrInput.prUrl || planOrInput.url) : null) || null;
205
+ return _record({ ref, prId, prUrl, sourceBranch: null, remoteCloneDir: null }, {
206
+ status: 'failed',
207
+ failure_class: shared.FAILURE_CLASS.CONFIG_ERROR,
208
+ retryable: false,
209
+ error: 'devbox executor requires a DevBox runner (opts.runOnDevBox)',
210
+ });
211
+ }
212
+
213
+ // Accept an already-built plan, else plan now (resolves branch + remote dir).
214
+ let plan;
215
+ if (planOrInput && planOrInput.status === 'devbox-planned' && planOrInput.remoteCloneDir) {
216
+ plan = planOrInput;
217
+ } else {
218
+ plan = await planDevBox(planOrInput, opts); // PrActionError(400) on bad input propagates
219
+ }
220
+
221
+ const teardown = opts.teardown || (async () => true);
222
+ let provisioned = false;
223
+ const rec = _record(plan, {});
224
+
225
+ try {
226
+ // 1) Select / provision the DevBox (no local disk touched).
227
+ if (plan.devBox) {
228
+ rec.devBox = plan.devBox;
229
+ } else if (typeof opts.selectDevBox === 'function') {
230
+ try {
231
+ rec.devBox = await opts.selectDevBox();
232
+ provisioned = true;
233
+ } catch (e) {
234
+ rec.failure_class = shared.FAILURE_CLASS.CONFIG_ERROR;
235
+ rec.retryable = true;
236
+ rec.error = `devbox selection failed: ${e?.message || e}`;
237
+ return rec;
238
+ }
239
+ }
240
+
241
+ // 2) Delegate the ENTIRE remote lifecycle (clone → fix → validate → push).
242
+ let result;
243
+ try {
244
+ result = await runOnDevBox({
245
+ devBox: rec.devBox,
246
+ remoteCloneDir: plan.remoteCloneDir,
247
+ ref: plan.ref,
248
+ prId: plan.prId,
249
+ sourceBranch: plan.sourceBranch,
250
+ targetBranch: plan.targetBranch,
251
+ cloneUrl: plan.cloneUrl,
252
+ isFork: plan.isFork,
253
+ });
254
+ } catch (e) {
255
+ rec.failure_class = shared.FAILURE_CLASS.UNKNOWN;
256
+ rec.retryable = true;
257
+ rec.error = `devbox runner failed: ${e?.message || e}`;
258
+ return rec;
259
+ }
260
+
261
+ result = result || {};
262
+ rec.changed = !!result.changed;
263
+ rec.validated = !!result.validated;
264
+ rec.validationOk = result.validationOk == null ? null : !!result.validationOk;
265
+ rec.pushed = !!result.pushed;
266
+ if (typeof result.output === 'string' && result.output) rec.output = result.output;
267
+
268
+ // 3) Classify the outcome the runner reported.
269
+ if (rec.pushed) {
270
+ rec.status = 'done';
271
+ rec.failure_class = null;
272
+ rec.retryable = false;
273
+ rec.error = null;
274
+ return rec;
275
+ }
276
+ if (!rec.changed) {
277
+ // A clean tree is a legit noop — nothing to push.
278
+ rec.status = 'done';
279
+ rec.noop = true;
280
+ rec.failure_class = null;
281
+ rec.retryable = false;
282
+ rec.error = null;
283
+ return rec;
284
+ }
285
+ if (rec.validated && rec.validationOk === false) {
286
+ // Changed but tests failed on the DevBox — not pushed (validate-then-push).
287
+ rec.failure_class = shared.FAILURE_CLASS.BUILD_FAILURE;
288
+ rec.retryable = true;
289
+ rec.error = result.error || 'validation failed on the DevBox — fix not pushed';
290
+ return rec;
291
+ }
292
+ // Changed but the runner did not push and gave no validation verdict — surface
293
+ // whatever it reported.
294
+ rec.failure_class = result.failure_class || shared.FAILURE_CLASS.UNKNOWN;
295
+ rec.retryable = result.retryable != null ? !!result.retryable : true;
296
+ rec.error = result.error || 'devbox fix did not push (no validation verdict)';
297
+ return rec;
298
+ } finally {
299
+ // ALWAYS tear the remote ephemeral clone down — nothing persists on the DevBox.
300
+ if (plan && plan.remoteCloneDir) {
301
+ try { rec.remoteDiscarded = await teardown(plan.remoteCloneDir, rec.devBox); }
302
+ catch (e) {
303
+ shared.log('warn', `pr-devbox: failed to tear down remote clone ${plan.remoteCloneDir}: ${e?.message || e}`);
304
+ rec.remoteDiscarded = false;
305
+ }
306
+ } else {
307
+ rec.remoteDiscarded = true;
308
+ }
309
+ void provisioned; // (kept for readability; teardown above is unconditional)
310
+ }
311
+ }
312
+
313
+ module.exports = {
314
+ DEVBOX_TMP_PREFIX,
315
+ DEFAULT_REMOTE_TMP_ROOT,
316
+ planDevBox,
317
+ executeDevBox,
318
+ // Exported for testing.
319
+ _resolveRef,
320
+ _safeDirSlug,
321
+ _remoteJoin,
322
+ };