nx 23.3.0-beta.2 → 23.3.0-beta.3

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.
Files changed (58) hide show
  1. package/dist/bin/init-local.js +7 -1
  2. package/dist/src/command-line/add/add.js +5 -1
  3. package/dist/src/command-line/init/implementation/utils.js +6 -1
  4. package/dist/src/command-line/migrate/agentic/capture-generator-output.d.ts +25 -0
  5. package/dist/src/command-line/migrate/agentic/capture-generator-output.js +121 -5
  6. package/dist/src/command-line/migrate/agentic/close-agent-session.d.ts +21 -0
  7. package/dist/src/command-line/migrate/agentic/close-agent-session.js +126 -0
  8. package/dist/src/command-line/migrate/agentic/definitions.d.ts +24 -0
  9. package/dist/src/command-line/migrate/agentic/definitions.js +5 -2
  10. package/dist/src/command-line/migrate/agentic/handoff.d.ts +23 -0
  11. package/dist/src/command-line/migrate/agentic/handoff.js +62 -3
  12. package/dist/src/command-line/migrate/agentic/master/invocations.d.ts +12 -0
  13. package/dist/src/command-line/migrate/agentic/master/invocations.js +54 -0
  14. package/dist/src/command-line/migrate/agentic/master/run-master-session.d.ts +12 -0
  15. package/dist/src/command-line/migrate/agentic/master/run-master-session.js +99 -0
  16. package/dist/src/command-line/migrate/agentic/master/spawn-master.d.ts +31 -0
  17. package/dist/src/command-line/migrate/agentic/master/spawn-master.js +217 -0
  18. package/dist/src/command-line/migrate/agentic/prompts/fragments.js +3 -0
  19. package/dist/src/command-line/migrate/agentic/runner.d.ts +0 -21
  20. package/dist/src/command-line/migrate/agentic/runner.js +12 -249
  21. package/dist/src/command-line/migrate/agentic/terminal-repair.d.ts +1 -0
  22. package/dist/src/command-line/migrate/agentic/terminal-repair.js +27 -0
  23. package/dist/src/command-line/migrate/agentic/windows-cmd.d.ts +33 -0
  24. package/dist/src/command-line/migrate/agentic/windows-cmd.js +88 -0
  25. package/dist/src/command-line/migrate/deferred-output.d.ts +44 -0
  26. package/dist/src/command-line/migrate/deferred-output.js +92 -0
  27. package/dist/src/command-line/migrate/execute-migration.d.ts +9 -3
  28. package/dist/src/command-line/migrate/execute-migration.js +53 -16
  29. package/dist/src/command-line/migrate/migrate-commits.d.ts +4 -4
  30. package/dist/src/command-line/migrate/migrate-commits.js +7 -6
  31. package/dist/src/command-line/migrate/migrate.js +28 -13
  32. package/dist/src/command-line/migrate/run/broker.d.ts +138 -0
  33. package/dist/src/command-line/migrate/run/broker.js +507 -0
  34. package/dist/src/command-line/migrate/run/clean-retry.d.ts +12 -0
  35. package/dist/src/command-line/migrate/run/clean-retry.js +98 -0
  36. package/dist/src/command-line/migrate/run/index.d.ts +4 -3
  37. package/dist/src/command-line/migrate/run/index.js +7 -1
  38. package/dist/src/command-line/migrate/run/issues.d.ts +8 -1
  39. package/dist/src/command-line/migrate/run/issues.js +40 -6
  40. package/dist/src/command-line/migrate/run/orchestrator.d.ts +18 -1
  41. package/dist/src/command-line/migrate/run/orchestrator.js +409 -377
  42. package/dist/src/command-line/migrate/run/run-state.d.ts +16 -0
  43. package/dist/src/command-line/migrate/run/run-state.js +33 -2
  44. package/dist/src/command-line/migrate/run/runbook.js +8 -4
  45. package/dist/src/command-line/migrate/run/state-machine.d.ts +24 -1
  46. package/dist/src/command-line/migrate/run/state-machine.js +112 -21
  47. package/dist/src/command-line/migrate/run/util.d.ts +2 -1
  48. package/dist/src/command-line/migrate/run/util.js +3 -3
  49. package/dist/src/command-line/migrate/run/worker.js +170 -164
  50. package/dist/src/core/graph/main.js +1 -1
  51. package/dist/src/daemon/server/server.js +11 -8
  52. package/dist/src/native/nx.wasm32-wasi.debug.wasm +0 -0
  53. package/dist/src/native/nx.wasm32-wasi.wasm +0 -0
  54. package/dist/src/tasks-runner/run-command.js +3 -0
  55. package/dist/src/utils/git-utils.d.ts +12 -0
  56. package/dist/src/utils/git-utils.js +76 -30
  57. package/dist/src/utils/package-json.js +8 -0
  58. package/package.json +11 -11
@@ -0,0 +1,507 @@
1
+ "use strict";
2
+ // A master session's agent runs the dispensed `nx migrate` commands inside
3
+ // its own sandbox, where a dependency install has no network and a commit
4
+ // cannot write `.git`. The parent nx that spawned the session advertises
5
+ // itself through NX_MIGRATE_BROKER, and a step then hands its install, and
6
+ // its commit when one is due, over a request/result file pair under
7
+ // <runDir>/broker/ instead of running them itself. Nothing here authorizes:
8
+ // every file the parent could consult is writable from the sandbox, so the
9
+ // parent's own invocation decides what it installs and commits.
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.MigrateCommitBroker = exports.TreeBusyError = exports.BrokerUnavailableError = exports.BrokerStaleRequestError = exports.BROKER_ENV_VAR = void 0;
12
+ exports.acquireTreeOperation = acquireTreeOperation;
13
+ exports.releaseTreeOperation = releaseTreeOperation;
14
+ exports.liveTreeOperation = liveTreeOperation;
15
+ exports.treeBusyMessage = treeBusyMessage;
16
+ exports.treeOperationLabel = treeOperationLabel;
17
+ exports.brokerDir = brokerDir;
18
+ exports.commitStepTree = commitStepTree;
19
+ exports.installStepTree = installStepTree;
20
+ exports.resetStepTree = resetStepTree;
21
+ const crypto_1 = require("crypto");
22
+ const fs_1 = require("fs");
23
+ const path_1 = require("path");
24
+ const native_1 = require("../../../native");
25
+ const fileutils_1 = require("../../../utils/fileutils");
26
+ const json_1 = require("../../../utils/json");
27
+ const handoff_1 = require("../agentic/handoff");
28
+ const deferred_output_1 = require("../deferred-output");
29
+ const execute_migration_1 = require("../execute-migration");
30
+ const migrate_commits_1 = require("../migrate-commits");
31
+ const atomic_write_1 = require("./atomic-write");
32
+ const run_state_1 = require("./run-state");
33
+ const state_lock_1 = require("./state-lock");
34
+ const state_machine_1 = require("./state-machine");
35
+ const issues_1 = require("./issues");
36
+ const clean_retry_1 = require("./clean-retry");
37
+ const util_1 = require("./util");
38
+ exports.BROKER_ENV_VAR = 'NX_MIGRATE_BROKER';
39
+ const BROKER_DIR_NAME = 'broker';
40
+ const CHILD_POLL_INTERVAL_MS = 250;
41
+ /** The request no longer matches the step: another attempt owns it. */
42
+ class BrokerStaleRequestError extends Error {
43
+ }
44
+ exports.BrokerStaleRequestError = BrokerStaleRequestError;
45
+ /**
46
+ * The advertised parent could not accept the request, or went away before
47
+ * answering it. It may have landed the install or the commit; the tree and
48
+ * the run state say.
49
+ */
50
+ class BrokerUnavailableError extends Error {
51
+ }
52
+ exports.BrokerUnavailableError = BrokerUnavailableError;
53
+ /** Another live process holds the working tree for an operation of its own. */
54
+ class TreeBusyError extends Error {
55
+ }
56
+ exports.TreeBusyError = TreeBusyError;
57
+ /**
58
+ * Reserves the working tree for one operation in a single fresh-state write:
59
+ * the request must still be at its seam (else the attempt moved on and the
60
+ * request is stale), and no other live process may hold a reservation.
61
+ * A reservation whose owner process is gone holds nothing.
62
+ */
63
+ function acquireTreeOperation(dir, request, owner = (0, crypto_1.randomBytes)(4).toString('hex')) {
64
+ // A commit marks its step as started; a mark already there belongs to an
65
+ // operation whose outcome is still unknown, and this one leaves it.
66
+ let marks = false;
67
+ (0, state_lock_1.updateRunState)(dir, (fresh) => {
68
+ if (!atSeam(fresh, request)) {
69
+ throw new BrokerStaleRequestError(`The request for this step no longer matches its attempt; nothing was installed or committed.`);
70
+ }
71
+ const held = liveTreeOperation(fresh, owner);
72
+ if (held)
73
+ throw new TreeBusyError(treeBusyMessage(held));
74
+ marks =
75
+ request.kind === 'commit' &&
76
+ fresh.steps.find((s) => s.id === request.stepId)?.commitStarted !== true;
77
+ return {
78
+ ...(marks ? (0, state_machine_1.markCommitStarted)(fresh, request.stepId) : fresh),
79
+ treeOperation: {
80
+ kind: request.kind,
81
+ ...(request.stepId !== undefined ? { stepId: request.stepId } : {}),
82
+ ...(request.attempt !== undefined ? { attempt: request.attempt } : {}),
83
+ owner,
84
+ pid: process.pid,
85
+ },
86
+ };
87
+ });
88
+ const lease = {
89
+ owner,
90
+ ...(marks ? { markedStepId: request.stepId } : {}),
91
+ release: () => releaseTreeOperation(dir, owner, lease.markedStepId),
92
+ };
93
+ return lease;
94
+ }
95
+ /**
96
+ * Owner-checked: a lease released late never drops a newer reservation, nor
97
+ * the mark it set.
98
+ */
99
+ function releaseTreeOperation(dir, owner, markedStepId) {
100
+ (0, state_lock_1.updateRunState)(dir, (fresh) => {
101
+ if (fresh.treeOperation?.owner !== owner)
102
+ return null;
103
+ const released = { ...fresh, treeOperation: undefined };
104
+ return markedStepId ? (0, state_machine_1.clearCommitStarted)(released, markedStepId) : released;
105
+ });
106
+ }
107
+ /** The reservation a live process other than `owner` holds, if any. */
108
+ function liveTreeOperation(state, owner) {
109
+ const held = state.treeOperation;
110
+ if (!held || held.owner === owner || !(0, util_1.isPidAlive)(held.pid))
111
+ return undefined;
112
+ return held;
113
+ }
114
+ function treeBusyMessage(held) {
115
+ return `The working tree is held by process ${held.pid} for ${treeOperationLabel(held)}; run the reconcile again once it finishes.`;
116
+ }
117
+ function treeOperationLabel(held) {
118
+ return held.kind === 'checkpoint'
119
+ ? 'the checkpoint commit'
120
+ : `the ${held.kind === 'commit' || held.kind === 'reset' ? held.kind : 'install'} of step '${held.stepId}'`;
121
+ }
122
+ function atSeam(state, request) {
123
+ if (request.kind === 'checkpoint') {
124
+ return (state.createCommits &&
125
+ state.checkpointFailed === true &&
126
+ state.steps.every((s) => s.status === 'pending'));
127
+ }
128
+ return (Object.hasOwn(SEAM_STATUSES, request.kind) &&
129
+ request.stepId !== undefined &&
130
+ request.attempt !== undefined &&
131
+ isAtSeam(state.steps.find((s) => s.id === request.stepId), request));
132
+ }
133
+ const SEAM_STATUSES = {
134
+ commit: new Set(['running', 'awaiting-prompt-outcome', 'died']),
135
+ install: new Set(['running']),
136
+ 'fold-install': new Set(['awaiting-prompt-outcome']),
137
+ 'action-install': new Set(['failed', 'died']),
138
+ reset: new Set(['failed', 'died']),
139
+ };
140
+ function isAtSeam(step, request) {
141
+ return (step !== undefined &&
142
+ step.attempt === request.attempt &&
143
+ SEAM_STATUSES[request.kind].has(step.status));
144
+ }
145
+ function brokerDir(runDirPath) {
146
+ return (0, path_1.join)(runDirPath, BROKER_DIR_NAME);
147
+ }
148
+ function lockPath(runDirPath, nonce) {
149
+ return (0, path_1.join)(brokerDir(runDirPath), `${nonce}.lock`);
150
+ }
151
+ function requestPath(runDirPath, id) {
152
+ return (0, path_1.join)(brokerDir(runDirPath), `${id}.request.json`);
153
+ }
154
+ function resultPath(runDirPath, id) {
155
+ return (0, path_1.join)(brokerDir(runDirPath), `${id}.result.json`);
156
+ }
157
+ // A request file lives in the agent-writable broker directory, so it could be
158
+ // a planted symlink or FIFO; read it without reading a symlink's target or
159
+ // blocking on a FIFO.
160
+ function readRequestFile(filePath) {
161
+ return (0, json_1.parseJson)((0, handoff_1.readAtomicallyPublishedFile)(filePath, `${filePath} is not a regular file.`));
162
+ }
163
+ /**
164
+ * Runs the step's install and commit where they can land: in this process
165
+ * unless a parent session advertised its broker, in which case the request
166
+ * goes to the parent and the answer comes back with the output the parent
167
+ * collected, printed here. The absorbed step ids come from whichever side
168
+ * ran the commit, so the ledger entry names what its `git add -A` took.
169
+ */
170
+ async function commitStepTree(dir, step, absorbedStepIds, commitInProcess, scope) {
171
+ const request = {
172
+ kind: 'commit',
173
+ stepId: step.id,
174
+ attempt: step.attempt,
175
+ };
176
+ const nonce = process.env[exports.BROKER_ENV_VAR];
177
+ if (!nonce) {
178
+ const lease = acquireTreeOperation(dir, request);
179
+ scope.lease = lease;
180
+ const result = await commitInProcess();
181
+ if (gitRan(result))
182
+ lease.markedStepId = undefined;
183
+ return { result, absorbedStepIds, recorded: false };
184
+ }
185
+ const answer = await ask(dir, nonce, request);
186
+ if (answer.kind !== 'commit') {
187
+ throw new Error(`Unexpected '${answer.kind}' answer to a commit request.`);
188
+ }
189
+ return {
190
+ result: answer.result,
191
+ absorbedStepIds: answer.absorbedStepIds,
192
+ recorded: true,
193
+ };
194
+ }
195
+ // Whether git may have written history for this result. A landed commit's
196
+ // mark is the ledger entry's to clear, and a failure reported once git ran
197
+ // (a hook's output overflowing the subprocess buffer after the commit) cannot
198
+ // vouch that nothing landed, so only the other results release the mark.
199
+ function gitRan(result) {
200
+ switch (result.status) {
201
+ case 'committed':
202
+ case 'failed':
203
+ return true;
204
+ case 'no-changes':
205
+ case 'disabled':
206
+ return false;
207
+ default: {
208
+ const exhaustive = result;
209
+ throw new Error(`Unhandled commit result: ${JSON.stringify(exhaustive)}`);
210
+ }
211
+ }
212
+ }
213
+ /**
214
+ * The same for a step that owes only its install: no commit is due, or the
215
+ * commit waits for a fold.
216
+ */
217
+ async function installStepTree(dir, step, seam, installInProcess, scope) {
218
+ const request = {
219
+ kind: seam,
220
+ stepId: step.id,
221
+ attempt: step.attempt,
222
+ };
223
+ const nonce = process.env[exports.BROKER_ENV_VAR];
224
+ if (!nonce) {
225
+ scope.lease = acquireTreeOperation(dir, request);
226
+ return installInProcess();
227
+ }
228
+ const answer = await ask(dir, nonce, request);
229
+ if (answer.kind !== 'installed') {
230
+ throw new Error(`Unexpected '${answer.kind}' answer to an install request.`);
231
+ }
232
+ }
233
+ /**
234
+ * The same for the reset a clean retry of a failed or died step needs. Runs
235
+ * `resetInProcess` under the reservation, or asks the parent, which resets
236
+ * against the state it reads then; a reset that could not run throws.
237
+ */
238
+ async function resetStepTree(dir, step, resetInProcess, scope) {
239
+ const request = {
240
+ kind: 'reset',
241
+ stepId: step.id,
242
+ attempt: step.attempt,
243
+ invocation: (0, crypto_1.randomBytes)(4).toString('hex'),
244
+ };
245
+ const nonce = process.env[exports.BROKER_ENV_VAR];
246
+ if (!nonce) {
247
+ scope.lease = acquireTreeOperation(dir, request);
248
+ return resetInProcess();
249
+ }
250
+ const answer = await ask(dir, nonce, request);
251
+ if (answer.kind !== 'reset') {
252
+ throw new Error(`Unexpected '${answer.kind}' answer to a reset request.`);
253
+ }
254
+ }
255
+ async function ask(dir, nonce, request) {
256
+ const id = `${nonce}-${request.stepId}-${request.attempt}-${request.kind}${request.invocation ? `-${request.invocation}` : ''}`;
257
+ const path = resultPath(dir, id);
258
+ // A repeat reads the first answer, whatever became of the session since.
259
+ if ((0, fs_1.existsSync)(path)) {
260
+ return settle((0, fileutils_1.readJsonFile)(path));
261
+ }
262
+ // No deadline: an install or a commit over a large tree takes as long as it
263
+ // takes; the poll ends with an answer or a released lock. The probe is built
264
+ // first so a request is never left unwatched, and once: each instance holds
265
+ // a descriptor, and `wait()` would pin this process until the session ends.
266
+ let lock = null;
267
+ try {
268
+ lock = native_1.IS_WASM ? null : new native_1.FileLock(lockPath(dir, nonce));
269
+ }
270
+ catch (e) {
271
+ if ((0, fs_1.existsSync)(path)) {
272
+ return settle((0, fileutils_1.readJsonFile)(path));
273
+ }
274
+ throw notAccepting(e);
275
+ }
276
+ try {
277
+ (0, atomic_write_1.publishFileAtomically)(requestPath(dir, id), (tmpPath) => (0, fileutils_1.writeJsonFile)(tmpPath, request));
278
+ }
279
+ catch (e) {
280
+ throw notAccepting(e);
281
+ }
282
+ for (;;) {
283
+ if ((0, fs_1.existsSync)(path)) {
284
+ return settle((0, fileutils_1.readJsonFile)(path));
285
+ }
286
+ if (lock && lockIsFree(lock)) {
287
+ // Answered and closed between the two checks: the answer stays on disk.
288
+ if ((0, fs_1.existsSync)(path))
289
+ continue;
290
+ throw new BrokerUnavailableError(`The nx migrate session that started this step ended before its request was answered. The install or the commit may still have landed; check the working tree and git log.`);
291
+ }
292
+ await new Promise((resolve) => setTimeout(resolve, CHILD_POLL_INTERVAL_MS));
293
+ }
294
+ }
295
+ function notAccepting(e) {
296
+ return new BrokerUnavailableError(`The nx migrate session that started this step is not accepting its request (${e instanceof Error ? e.message : String(e)}).`);
297
+ }
298
+ // A probe that fails says nothing about the parent; keep waiting.
299
+ function lockIsFree(lock) {
300
+ try {
301
+ return !lock.check();
302
+ }
303
+ catch {
304
+ return false;
305
+ }
306
+ }
307
+ function settle(result) {
308
+ switch (result.kind) {
309
+ case 'commit':
310
+ case 'installed':
311
+ (0, deferred_output_1.replayDeferredOutput)(result.output);
312
+ return result;
313
+ case 'install-failed':
314
+ (0, deferred_output_1.replayDeferredOutput)(result.output);
315
+ // The CLI catch returns 1 on the typed error instead of logging again.
316
+ throw result.peerDeps
317
+ ? new execute_migration_1.NpmPeerDepsInstallError()
318
+ : new Error(result.message);
319
+ case 'reset':
320
+ if (result.error !== undefined)
321
+ throw new Error(result.error);
322
+ return result;
323
+ case 'stale':
324
+ throw new BrokerStaleRequestError(`The request for this step no longer matches its attempt; nothing was installed or committed.`);
325
+ default: {
326
+ const exhaustive = result;
327
+ throw new Error(`Unhandled broker result: ${JSON.stringify(exhaustive)}`);
328
+ }
329
+ }
330
+ }
331
+ /**
332
+ * The parent side. Holds one exclusive lock for the session's lifetime so a
333
+ * waiting step can tell a slow parent from a dead one, answers each request
334
+ * once, and removes its own requests on close; its answers stay for the steps
335
+ * still reading them. Requests carrying another session's nonce belong to
336
+ * that session and are never touched. Whether to install or commit comes from
337
+ * the policy the session started with, never from run state, which the
338
+ * agent's sandbox can write.
339
+ */
340
+ class MigrateCommitBroker {
341
+ constructor(root, dir, reconcileCommand, policy) {
342
+ this.root = root;
343
+ this.dir = dir;
344
+ this.reconcileCommand = reconcileCommand;
345
+ this.policy = policy;
346
+ this.nonce = (0, crypto_1.randomBytes)(4).toString('hex');
347
+ this.handled = new Set();
348
+ this.inFlight = null;
349
+ (0, handoff_1.ensureRunSubdir)(brokerDir(dir), () => this.notADirectory());
350
+ this.lock = native_1.IS_WASM ? null : new native_1.FileLock(lockPath(dir, this.nonce));
351
+ this.lock?.lock();
352
+ }
353
+ /** The request whose operation this process is running right now. */
354
+ get requestInFlight() {
355
+ return this.inFlight;
356
+ }
357
+ /** Answers this session's unanswered requests, one at a time. */
358
+ async service() {
359
+ // Refused rather than followed: a symlink swapped in would send the reads
360
+ // and the answers wherever it points, or leave requests unanswered.
361
+ if ((0, handoff_1.handoffsDirState)(brokerDir(this.dir)) !== 'directory') {
362
+ throw this.notADirectory();
363
+ }
364
+ const prefix = `${this.nonce}-`;
365
+ const suffix = '.request.json';
366
+ for (const name of (0, fs_1.readdirSync)(brokerDir(this.dir))) {
367
+ if (!name.startsWith(prefix) || !name.endsWith(suffix))
368
+ continue;
369
+ const id = name.slice(0, -suffix.length);
370
+ if (this.handled.has(id))
371
+ continue;
372
+ const request = readRequestFile(requestPath(this.dir, id));
373
+ // Reserved before anything runs, released after the record is written.
374
+ // A tree held by another live process is left for a later pass, not
375
+ // marked handled; a request no longer at its seam is answered stale.
376
+ let lease = null;
377
+ try {
378
+ lease = acquireTreeOperation(this.dir, request, this.nonce);
379
+ }
380
+ catch (e) {
381
+ if (e instanceof TreeBusyError)
382
+ continue;
383
+ if (!(e instanceof BrokerStaleRequestError))
384
+ throw e;
385
+ }
386
+ this.handled.add(id);
387
+ let result;
388
+ try {
389
+ if (lease)
390
+ this.inFlight = request;
391
+ result = lease ? await this.answer(request) : { kind: 'stale' };
392
+ // Recorded by the process that ran the commit, before the answer: the
393
+ // step reading it can die with the commit already in history. A failed
394
+ // record throws and ends the session rather than losing the entry.
395
+ if (result.kind === 'commit') {
396
+ if (gitRan(result.result))
397
+ lease.markedStepId = undefined;
398
+ this.record(request, result);
399
+ }
400
+ }
401
+ finally {
402
+ this.inFlight = null;
403
+ lease?.release();
404
+ }
405
+ // Published after the release, so the step reading the answer never
406
+ // finds this request's reservation still standing over its own write.
407
+ (0, atomic_write_1.publishFileAtomically)(resultPath(this.dir, id), (tmpPath) => (0, fileutils_1.writeJsonFile)(tmpPath, result));
408
+ }
409
+ }
410
+ // Appends the entry and leaves the step a receipt for it, on the attempt the
411
+ // request named: a rearmed step owes nothing to the old attempt's commit.
412
+ record(request, result) {
413
+ const entry = (0, state_machine_1.commitResultToLedgerEntry)(result.result, request.stepId, result.absorbedStepIds);
414
+ if (!entry)
415
+ return;
416
+ (0, state_lock_1.updateRunState)(this.dir, (fresh) => {
417
+ const index = fresh.commits.length;
418
+ const next = (0, state_machine_1.appendCommit)(fresh, (0, issues_1.attachIssueIdsToCommitEntry)(fresh, entry));
419
+ return {
420
+ ...next,
421
+ steps: next.steps.map((s) => s.id === request.stepId && s.attempt === request.attempt
422
+ ? { ...s, commitLedgerIndex: index }
423
+ : s),
424
+ };
425
+ });
426
+ }
427
+ async answer(request) {
428
+ const state = (0, run_state_1.readRunState)(this.dir);
429
+ const step = state.steps.find((s) => s.id === request.stepId);
430
+ if (!Object.hasOwn(SEAM_STATUSES, request.kind) ||
431
+ !isAtSeam(step, request) ||
432
+ ((request.kind === 'commit' || request.kind === 'reset') &&
433
+ !this.policy.createCommits)) {
434
+ return { kind: 'stale' };
435
+ }
436
+ if (request.kind === 'reset') {
437
+ try {
438
+ (0, clean_retry_1.resetForCleanRetry)(this.root, this.dir, step.id);
439
+ return { kind: 'reset' };
440
+ }
441
+ catch (e) {
442
+ return {
443
+ kind: 'reset',
444
+ error: e instanceof Error ? e.message : String(e),
445
+ };
446
+ }
447
+ }
448
+ const output = new deferred_output_1.DeferredOutputCollector();
449
+ const install = () => (0, util_1.installDepsChangedSinceDispense)(this.root, this.dir, step, this.policy.skipInstall, this.reconcileCommand, output);
450
+ try {
451
+ if (request.kind !== 'commit') {
452
+ await install();
453
+ return { kind: 'installed', output: output.render('drop') };
454
+ }
455
+ const absorbedStepIds = (0, state_machine_1.uncoveredFailedStepIds)(state).filter((id) => id !== step.id);
456
+ const result = await (0, migrate_commits_1.commitMigrationIfRequested)(this.root, { name: (0, state_machine_1.splitMigrationId)(step.migrationId).name }, true, state.commitPrefix, install, (0, state_machine_1.stepsToPendingMigrations)(state, absorbedStepIds), undefined, output);
457
+ return {
458
+ kind: 'commit',
459
+ result,
460
+ absorbedStepIds,
461
+ output: output.render('drop'),
462
+ };
463
+ }
464
+ catch (e) {
465
+ // The install is the only thing that throws: the commit reports through
466
+ // its result, and the install's own bookkeeping never throws. Marked
467
+ // here, where the install ran, on the attempt it ran for: a reconcile
468
+ // may have rearmed the step meanwhile, and that attempt owes nothing.
469
+ (0, state_lock_1.updateRunState)(this.dir, (fresh) => isAtSeam(fresh.steps.find((s) => s.id === step.id), request)
470
+ ? (0, state_machine_1.markInstallFailed)(fresh, step.id)
471
+ : null);
472
+ return {
473
+ kind: 'install-failed',
474
+ message: e instanceof Error ? e.message : String(e),
475
+ peerDeps: e instanceof execute_migration_1.NpmPeerDepsInstallError,
476
+ // The package manager's own output: for pnpm, Yarn and Bun the error
477
+ // says only that the command failed.
478
+ output: output.render('keep'),
479
+ };
480
+ }
481
+ }
482
+ /** Releases the lock; call after the last `service` settled. */
483
+ close() {
484
+ this.lock?.unlock();
485
+ // A reservation this session still holds would only expire with its pid.
486
+ try {
487
+ releaseTreeOperation(this.dir, this.nonce);
488
+ }
489
+ catch { }
490
+ // Hygiene only; a file left behind is never read by another session.
491
+ try {
492
+ if ((0, handoff_1.handoffsDirState)(brokerDir(this.dir)) !== 'directory')
493
+ return;
494
+ for (const name of (0, fs_1.readdirSync)(brokerDir(this.dir))) {
495
+ if (name === `${this.nonce}.lock` ||
496
+ (name.startsWith(`${this.nonce}-`) && name.endsWith('.request.json'))) {
497
+ (0, fs_1.rmSync)((0, path_1.join)(brokerDir(this.dir), name), { force: true });
498
+ }
499
+ }
500
+ }
501
+ catch { }
502
+ }
503
+ notADirectory() {
504
+ return new Error(`The migrate run has something other than a directory at ${brokerDir(this.dir)}; remove it and try again.`);
505
+ }
506
+ }
507
+ exports.MigrateCommitBroker = MigrateCommitBroker;
@@ -0,0 +1,12 @@
1
+ import type { MigrateRunState, MigrateStep } from './run-state';
2
+ export declare function canOfferCleanRetry(root: string, state: MigrateRunState, step: MigrateStep, head: string | null): boolean;
3
+ export declare function cleanRetryUnavailableReason(root: string, state: MigrateRunState, step: MigrateStep, head: string | null): string;
4
+ /**
5
+ * The reset a clean retry of `stepId` needs, run with the tree reserved. The
6
+ * retry is re-checked against the state read then, and the attempt's
7
+ * generator run and the fixes its resolutions claimed are forgotten in a
8
+ * write of their own before git runs, so a reset that fails midway leaves
9
+ * the plain retry gated. Throws the reason when the retry is no longer
10
+ * available, or what git said when the reset fails.
11
+ */
12
+ export declare function resetForCleanRetry(root: string, dir: string, stepId: string): void;
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.canOfferCleanRetry = canOfferCleanRetry;
4
+ exports.cleanRetryUnavailableReason = cleanRetryUnavailableReason;
5
+ exports.resetForCleanRetry = resetForCleanRetry;
6
+ const git_utils_1 = require("../../../utils/git-utils");
7
+ const types_1 = require("../agentic/types");
8
+ const agent_output_1 = require("./agent-output");
9
+ const issues_1 = require("./issues");
10
+ const state_lock_1 = require("./state-lock");
11
+ const state_machine_1 = require("./state-machine");
12
+ const util_1 = require("./util");
13
+ // A clean retry resets the tree to the step's `gitRefBefore`, discarding
14
+ // everything the ref does not account for. It is offered only when it does
15
+ // account for the whole tree: every prior diff committed, the tree clean when
16
+ // the step was dispensed, and HEAD still at the ref. An absent or unreadable
17
+ // value is no restore point, never a clean one. The debt check is run-wide:
18
+ // the reset discards every step's uncommitted work, not only this one's.
19
+ function canOfferCleanRetry(root, state, step, head) {
20
+ return (state.createCommits &&
21
+ !state.checkpointFailed &&
22
+ !(0, state_machine_1.hasPendingCommitDebt)(state) &&
23
+ !!step.gitRefBefore &&
24
+ head === step.gitRefBefore &&
25
+ step.treeCleanAtDispense === true &&
26
+ !endangeredLandedEntry(root, state, step));
27
+ }
28
+ // The last landed entry covering the step whose commit a reset to the step's
29
+ // `gitRefBefore` would discard: one that is not a verified ancestor of it.
30
+ function endangeredLandedEntry(root, state, step) {
31
+ let endangered = null;
32
+ for (const entry of (0, state_machine_1.coveringLandedEntries)(state, step.id)) {
33
+ if (!entry.sha ||
34
+ !step.gitRefBefore ||
35
+ !(0, git_utils_1.isAncestorCommit)(entry.sha, step.gitRefBefore, root)) {
36
+ endangered = entry;
37
+ }
38
+ }
39
+ return endangered;
40
+ }
41
+ function cleanRetryUnavailableReason(root, state, step, head) {
42
+ const endangered = endangeredLandedEntry(root, state, step);
43
+ if (endangered) {
44
+ return endangered.sha
45
+ ? `this migration's changes already landed in commit ${endangered.sha}, which a reset would discard.`
46
+ : `this migration's changes already landed in a commit, which a reset would discard.`;
47
+ }
48
+ if (step.gitRefBefore && head !== step.gitRefBefore) {
49
+ return `HEAD is at ${head ?? '(unreadable)'} rather than the ${step.gitRefBefore} this migration started from, so a reset would discard what was committed in between.`;
50
+ }
51
+ return `resetting the tree could discard uncommitted work that no restore point accounts for.`;
52
+ }
53
+ // Best-effort: run.json records the reverted dispositions and stays
54
+ // authoritative, so a failed append loses only the archive's trail record of
55
+ // the revert.
56
+ function archiveReopenedResolutions(dir, state, updates, migrationId) {
57
+ if (updates.length === 0)
58
+ return;
59
+ const reconstructedIds = [];
60
+ try {
61
+ (0, issues_1.archiveIssues)(dir, { state, newIssues: [], updates }, reconstructedIds);
62
+ }
63
+ catch (e) {
64
+ (0, agent_output_1.warnToAgent)({
65
+ title: `The reverted issue resolutions for ${migrationId} could not be archived (${(0, util_1.summarizeError)(e)}).`,
66
+ bodyLines: [
67
+ `run.json stays authoritative for the dispositions; the archived files under the run's issues directory miss the revert records, so their last entries may still read resolved.`,
68
+ ],
69
+ });
70
+ }
71
+ (0, issues_1.warnReconstructedArchives)(reconstructedIds);
72
+ }
73
+ /**
74
+ * The reset a clean retry of `stepId` needs, run with the tree reserved. The
75
+ * retry is re-checked against the state read then, and the attempt's
76
+ * generator run and the fixes its resolutions claimed are forgotten in a
77
+ * write of their own before git runs, so a reset that fails midway leaves
78
+ * the plain retry gated. Throws the reason when the retry is no longer
79
+ * available, or what git said when the reset fails.
80
+ */
81
+ function resetForCleanRetry(root, dir, stepId) {
82
+ const head = (0, git_utils_1.getLatestCommitSha)(root);
83
+ let step;
84
+ let updates = [];
85
+ const prepared = (0, state_lock_1.updateRunState)(dir, (fresh) => {
86
+ step = fresh.steps.find((s) => s.id === stepId);
87
+ if (!step)
88
+ throw new Error(`step '${stepId}' is not in the run.`);
89
+ if (!canOfferCleanRetry(root, fresh, step, head)) {
90
+ throw new Error(cleanRetryUnavailableReason(root, fresh, step, head));
91
+ }
92
+ const reopened = (0, issues_1.reopenResolutionsForStep)((0, state_machine_1.discardGeneratorRun)(fresh, stepId), stepId);
93
+ updates = reopened.updates;
94
+ return reopened.state;
95
+ });
96
+ archiveReopenedResolutions(dir, prepared, updates, step.migrationId);
97
+ (0, git_utils_1.resetWorkingTree)(step.gitRefBefore, [types_1.MIGRATE_RUNS_RELATIVE_DIR], root);
98
+ }
@@ -1,9 +1,10 @@
1
- export { CURRENT_RUN_STATE_FORMAT_VERSION, NewerRunStateFormatError, migrateRunsDir, runDir, readRunState, writeRunState, findActiveRun, createRun, } from './run-state';
2
- export type { MigrateRunStatus, MigrateRunRound, MigrateStepStatus, PromptOutcomeStatus, MigrateStepAwaitingKind, MigrateStepOutcome, MigrateStepPromptOutcome, MigrateStep, MigrateCommitKind, MigrateCommitLedgerEntry, MigrateIssueDisposition, MigrateRunIssue, MigrateRunAnalytics, MigrateRunNoProgress, MigrateRunState, } from './run-state';
1
+ export { CURRENT_RUN_STATE_FORMAT_VERSION, NewerRunStateFormatError, migrateRunsDir, runDir, runHandoffsDir, readRunState, writeRunState, findActiveRun, createRun, } from './run-state';
2
+ export type { MigrateRunStatus, MigrateRunRound, MigrateStepStatus, PromptOutcomeStatus, MigrateStepAwaitingKind, MigrateStepOutcome, MigrateStepPromptOutcome, MigrateStep, MigrateCommitKind, MigrateCommitLedgerEntry, MigrateIssueDisposition, MigrateRunIssue, MigrateRunAnalytics, MigrateRunNoProgress, MigrateRunPolicy, MigrateRunState, } from './run-state';
3
3
  export { applyStepEvent, hasPendingCommitDebt } from './state-machine';
4
4
  export type { StepAction, StepEvent, ApplyStepEventResult, } from './state-machine';
5
5
  export { createRunId, computePlanHash } from './run-id';
6
6
  export { runSingleMigrationWorker } from './worker';
7
7
  export type { RunSingleMigrationWorkerInput } from './worker';
8
- export { runOrchestratorInit, runOrchestratorReconcile } from './orchestrator';
8
+ export { completionWarnings, runOrchestratorInit, runOrchestratorReconcile, } from './orchestrator';
9
9
  export type { RunOrchestratorInitInput, RunOrchestratorReconcileInput, } from './orchestrator';
10
+ export { BROKER_ENV_VAR, MigrateCommitBroker, treeOperationLabel, } from './broker';
@@ -1,11 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.runOrchestratorReconcile = exports.runOrchestratorInit = exports.runSingleMigrationWorker = exports.computePlanHash = exports.createRunId = exports.hasPendingCommitDebt = exports.applyStepEvent = exports.createRun = exports.findActiveRun = exports.writeRunState = exports.readRunState = exports.runDir = exports.migrateRunsDir = exports.NewerRunStateFormatError = exports.CURRENT_RUN_STATE_FORMAT_VERSION = void 0;
3
+ exports.treeOperationLabel = exports.MigrateCommitBroker = exports.BROKER_ENV_VAR = exports.runOrchestratorReconcile = exports.runOrchestratorInit = exports.completionWarnings = exports.runSingleMigrationWorker = exports.computePlanHash = exports.createRunId = exports.hasPendingCommitDebt = exports.applyStepEvent = exports.createRun = exports.findActiveRun = exports.writeRunState = exports.readRunState = exports.runHandoffsDir = exports.runDir = exports.migrateRunsDir = exports.NewerRunStateFormatError = exports.CURRENT_RUN_STATE_FORMAT_VERSION = void 0;
4
4
  var run_state_1 = require("./run-state");
5
5
  Object.defineProperty(exports, "CURRENT_RUN_STATE_FORMAT_VERSION", { enumerable: true, get: function () { return run_state_1.CURRENT_RUN_STATE_FORMAT_VERSION; } });
6
6
  Object.defineProperty(exports, "NewerRunStateFormatError", { enumerable: true, get: function () { return run_state_1.NewerRunStateFormatError; } });
7
7
  Object.defineProperty(exports, "migrateRunsDir", { enumerable: true, get: function () { return run_state_1.migrateRunsDir; } });
8
8
  Object.defineProperty(exports, "runDir", { enumerable: true, get: function () { return run_state_1.runDir; } });
9
+ Object.defineProperty(exports, "runHandoffsDir", { enumerable: true, get: function () { return run_state_1.runHandoffsDir; } });
9
10
  Object.defineProperty(exports, "readRunState", { enumerable: true, get: function () { return run_state_1.readRunState; } });
10
11
  Object.defineProperty(exports, "writeRunState", { enumerable: true, get: function () { return run_state_1.writeRunState; } });
11
12
  Object.defineProperty(exports, "findActiveRun", { enumerable: true, get: function () { return run_state_1.findActiveRun; } });
@@ -19,5 +20,10 @@ Object.defineProperty(exports, "computePlanHash", { enumerable: true, get: funct
19
20
  var worker_1 = require("./worker");
20
21
  Object.defineProperty(exports, "runSingleMigrationWorker", { enumerable: true, get: function () { return worker_1.runSingleMigrationWorker; } });
21
22
  var orchestrator_1 = require("./orchestrator");
23
+ Object.defineProperty(exports, "completionWarnings", { enumerable: true, get: function () { return orchestrator_1.completionWarnings; } });
22
24
  Object.defineProperty(exports, "runOrchestratorInit", { enumerable: true, get: function () { return orchestrator_1.runOrchestratorInit; } });
23
25
  Object.defineProperty(exports, "runOrchestratorReconcile", { enumerable: true, get: function () { return orchestrator_1.runOrchestratorReconcile; } });
26
+ var broker_1 = require("./broker");
27
+ Object.defineProperty(exports, "BROKER_ENV_VAR", { enumerable: true, get: function () { return broker_1.BROKER_ENV_VAR; } });
28
+ Object.defineProperty(exports, "MigrateCommitBroker", { enumerable: true, get: function () { return broker_1.MigrateCommitBroker; } });
29
+ Object.defineProperty(exports, "treeOperationLabel", { enumerable: true, get: function () { return broker_1.treeOperationLabel; } });