nx 23.2.0-beta.7 → 23.2.0-beta.8

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 (130) hide show
  1. package/dist/bin/init-local.js +3 -5
  2. package/dist/release/changelog-renderer/index.js +4 -5
  3. package/dist/src/adapter/ngcli-adapter.js +60 -1
  4. package/dist/src/command-line/completion/command-object.js +7 -12
  5. package/dist/src/command-line/configure-ai-agents/configure-ai-agents.js +17 -23
  6. package/dist/src/command-line/generate/generate.js +35 -35
  7. package/dist/src/command-line/import/import.js +18 -40
  8. package/dist/src/command-line/init/ai-agent-prompts.js +5 -11
  9. package/dist/src/command-line/init/command-object.js +4 -2
  10. package/dist/src/command-line/init/implementation/add-nx-to-monorepo.js +12 -32
  11. package/dist/src/command-line/init/implementation/add-nx-to-nest.js +8 -21
  12. package/dist/src/command-line/init/implementation/add-nx-to-npm-repo.js +8 -21
  13. package/dist/src/command-line/init/implementation/angular/index.js +6 -15
  14. package/dist/src/command-line/init/init-v1.js +8 -18
  15. package/dist/src/command-line/init/init-v2.js +26 -56
  16. package/dist/src/command-line/migrate/agentic/definitions.js +35 -8
  17. package/dist/src/command-line/migrate/agentic/handoff-gitignore.d.ts +22 -3
  18. package/dist/src/command-line/migrate/agentic/handoff-gitignore.js +13 -10
  19. package/dist/src/command-line/migrate/agentic/handoff.d.ts +3 -1
  20. package/dist/src/command-line/migrate/agentic/handoff.js +4 -2
  21. package/dist/src/command-line/migrate/agentic/run-step.js +1 -0
  22. package/dist/src/command-line/migrate/agentic/runner.js +24 -24
  23. package/dist/src/command-line/migrate/agentic/select.js +20 -30
  24. package/dist/src/command-line/migrate/agentic/types.d.ts +19 -3
  25. package/dist/src/command-line/migrate/agentic/types.js +13 -4
  26. package/dist/src/command-line/migrate/command-object.d.ts +3 -0
  27. package/dist/src/command-line/migrate/command-object.js +13 -0
  28. package/dist/src/command-line/migrate/execute-migration.d.ts +14 -0
  29. package/dist/src/command-line/migrate/execute-migration.js +30 -9
  30. package/dist/src/command-line/migrate/migrate-analytics.d.ts +28 -0
  31. package/dist/src/command-line/migrate/migrate-analytics.js +51 -1
  32. package/dist/src/command-line/migrate/migrate-commits.d.ts +8 -2
  33. package/dist/src/command-line/migrate/migrate-commits.js +66 -19
  34. package/dist/src/command-line/migrate/migrate-config.d.ts +4 -2
  35. package/dist/src/command-line/migrate/migrate-config.js +12 -4
  36. package/dist/src/command-line/migrate/migrate.d.ts +9 -2
  37. package/dist/src/command-line/migrate/migrate.js +179 -56
  38. package/dist/src/command-line/migrate/multi-major.js +7 -10
  39. package/dist/src/command-line/migrate/resolve-package-version.js +4 -8
  40. package/dist/src/command-line/migrate/run/agent-output.d.ts +20 -0
  41. package/dist/src/command-line/migrate/run/agent-output.js +65 -0
  42. package/dist/src/command-line/migrate/run/index.d.ts +7 -0
  43. package/dist/src/command-line/migrate/run/index.js +19 -1
  44. package/dist/src/command-line/migrate/run/orchestrator.d.ts +20 -0
  45. package/dist/src/command-line/migrate/run/orchestrator.js +1200 -0
  46. package/dist/src/command-line/migrate/run/run-id.d.ts +16 -0
  47. package/dist/src/command-line/migrate/run/run-id.js +56 -0
  48. package/dist/src/command-line/migrate/run/run-state.d.ts +157 -0
  49. package/dist/src/command-line/migrate/run/run-state.js +438 -0
  50. package/dist/src/command-line/migrate/run/state-lock.d.ts +25 -0
  51. package/dist/src/command-line/migrate/run/state-lock.js +76 -0
  52. package/dist/src/command-line/migrate/run/state-machine.d.ts +84 -0
  53. package/dist/src/command-line/migrate/run/state-machine.js +315 -0
  54. package/dist/src/command-line/migrate/run/util.d.ts +58 -0
  55. package/dist/src/command-line/migrate/run/util.js +139 -7
  56. package/dist/src/command-line/migrate/run/worker.d.ts +1 -0
  57. package/dist/src/command-line/migrate/run/worker.js +308 -45
  58. package/dist/src/command-line/migrate/safe-prompt.d.ts +14 -26
  59. package/dist/src/command-line/migrate/safe-prompt.js +32 -43
  60. package/dist/src/command-line/migrate/step-actions.d.ts +5 -0
  61. package/dist/src/command-line/migrate/step-actions.js +12 -0
  62. package/dist/src/command-line/migrate/text.d.ts +18 -0
  63. package/dist/src/command-line/migrate/text.js +26 -0
  64. package/dist/src/command-line/migrate/version-skew-guard.d.ts +6 -4
  65. package/dist/src/command-line/migrate/version-skew-guard.js +34 -14
  66. package/dist/src/command-line/nx-cloud/connect/connect-to-nx-cloud.js +16 -20
  67. package/dist/src/command-line/release/changelog.js +6 -17
  68. package/dist/src/command-line/release/plan.js +15 -28
  69. package/dist/src/command-line/release/release.js +6 -17
  70. package/dist/src/command-line/release/utils/remote-release-clients/github.js +16 -22
  71. package/dist/src/command-line/release/utils/remote-release-clients/gitlab.js +13 -22
  72. package/dist/src/command-line/release/utils/resolve-semver-specifier.js +19 -41
  73. package/dist/src/command-line/release/version/resolve-current-version.js +7 -10
  74. package/dist/src/core/graph/main.js +1 -1
  75. package/dist/src/devkit-internals.d.ts +2 -0
  76. package/dist/src/devkit-internals.js +12 -4
  77. package/dist/src/migrations/update-23-2-0/set-cache-on-executor-target-defaults.d.ts +12 -0
  78. package/dist/src/migrations/update-23-2-0/set-cache-on-executor-target-defaults.js +250 -0
  79. package/dist/src/migrations/update-23-2-0/set-cache-on-executor-target-defaults.md +50 -0
  80. package/dist/src/native/nx.wasm32-wasi.debug.wasm +0 -0
  81. package/dist/src/native/nx.wasm32-wasi.wasm +0 -0
  82. package/dist/src/plugins/js/lock-file/bun-parser.js +43 -10
  83. package/dist/src/project-graph/utils/project-configuration/target-normalization.js +128 -2
  84. package/dist/src/tasks-runner/run-command.js +17 -32
  85. package/dist/src/tasks-runner/task-env.js +5 -2
  86. package/dist/src/tasks-runner/utils.js +2 -6
  87. package/dist/src/utils/analytics-prompt.js +4 -7
  88. package/dist/src/utils/exit-codes.d.ts +9 -0
  89. package/dist/src/utils/exit-codes.js +19 -0
  90. package/dist/src/utils/fileutils.d.ts +5 -0
  91. package/dist/src/utils/fileutils.js +7 -2
  92. package/dist/src/utils/git-utils.d.ts +33 -2
  93. package/dist/src/utils/git-utils.js +156 -8
  94. package/dist/src/utils/long-running-target.d.ts +10 -0
  95. package/dist/src/utils/long-running-target.js +19 -0
  96. package/dist/src/utils/min-release-age/behavior/bun.js +7 -11
  97. package/dist/src/utils/min-release-age/behavior/npm.js +5 -2
  98. package/dist/src/utils/min-release-age/packument.js +1 -1
  99. package/dist/src/utils/nx-console-prompt.js +3 -6
  100. package/dist/src/utils/package-manager-config/bunfig.d.ts +14 -0
  101. package/dist/src/utils/package-manager-config/bunfig.js +49 -0
  102. package/dist/src/utils/package-manager-config/npmrc.d.ts +23 -0
  103. package/dist/src/utils/package-manager-config/npmrc.js +124 -0
  104. package/dist/src/utils/package-manager-config/pnpm-config.d.ts +17 -0
  105. package/dist/src/utils/package-manager-config/pnpm-config.js +60 -0
  106. package/dist/src/utils/package-manager.d.ts +21 -2
  107. package/dist/src/utils/package-manager.js +244 -47
  108. package/dist/src/utils/params.d.ts +7 -1
  109. package/dist/src/utils/params.js +64 -6
  110. package/dist/src/utils/prompt-helpers.d.ts +53 -0
  111. package/dist/src/utils/prompt-helpers.js +111 -0
  112. package/dist/src/utils/provenance.js +6 -14
  113. package/dist/src/utils/registry-config/bun.d.ts +2 -0
  114. package/dist/src/utils/registry-config/bun.js +257 -0
  115. package/dist/src/utils/registry-config/index.d.ts +13 -0
  116. package/dist/src/utils/registry-config/index.js +102 -0
  117. package/dist/src/utils/registry-config/pnpm.d.ts +2 -0
  118. package/dist/src/utils/registry-config/pnpm.js +1551 -0
  119. package/dist/src/utils/registry-config/utils.d.ts +195 -0
  120. package/dist/src/utils/registry-config/utils.js +496 -0
  121. package/dist/src/utils/registry-config/yarn-berry.d.ts +2 -0
  122. package/dist/src/utils/registry-config/yarn-berry.js +612 -0
  123. package/dist/src/utils/registry-config/yarn-classic.d.ts +2 -0
  124. package/dist/src/utils/registry-config/yarn-classic.js +1001 -0
  125. package/dist/src/utils/safe-spawn.d.ts +24 -0
  126. package/dist/src/utils/safe-spawn.js +104 -0
  127. package/migrations.json +6 -0
  128. package/package.json +17 -13
  129. package/dist/src/utils/min-release-age/npmrc.d.ts +0 -15
  130. package/dist/src/utils/min-release-age/npmrc.js +0 -45
@@ -0,0 +1,1200 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runOrchestratorInit = runOrchestratorInit;
4
+ exports.runOrchestratorReconcile = runOrchestratorReconcile;
5
+ const child_process_1 = require("child_process");
6
+ const fs_1 = require("fs");
7
+ const path_1 = require("path");
8
+ const fileutils_1 = require("../../../utils/fileutils");
9
+ const git_utils_1 = require("../../../utils/git-utils");
10
+ const versions_1 = require("../../../utils/versions");
11
+ const handoff_1 = require("../agentic/handoff");
12
+ const handoff_gitignore_1 = require("../agentic/handoff-gitignore");
13
+ const types_1 = require("../agentic/types");
14
+ const migrate_commits_1 = require("../migrate-commits");
15
+ const migrate_analytics_1 = require("../migrate-analytics");
16
+ const sort_migrations_1 = require("../sort-migrations");
17
+ const run_id_1 = require("./run-id");
18
+ const run_state_1 = require("./run-state");
19
+ const state_lock_1 = require("./state-lock");
20
+ const state_machine_1 = require("./state-machine");
21
+ const migration_shape_1 = require("../migration-shape");
22
+ const util_1 = require("./util");
23
+ const text_1 = require("../text");
24
+ const agent_output_1 = require("./agent-output");
25
+ // The dark migrate orchestrator: drives a durable run one dispense at a time.
26
+ // An outer AI agent runs each dispensed command and re-invokes `nx migrate
27
+ // --run-id=<id>` to reconcile; there is no long-lived process.
28
+ const PLAN_SNAPSHOT_0 = 'plan-0.json';
29
+ // A running worker older than this may be hung; the still-running dispense
30
+ // escalates so the agent can verify or kill it.
31
+ const HANG_THRESHOLD_MS = 15 * 60 * 1000;
32
+ // Steps in these statuses are done; every other status needs a dispense.
33
+ const TERMINAL_STATUSES = new Set(['succeeded', 'skipped']);
34
+ const INIT_CONTINUE_HINT = 're-run the command, or unset NX_MIGRATE_ORCHESTRATOR to use the standard migrate flow.';
35
+ function continueRunHint(runId) {
36
+ return `re-run the command to continue run '${runId}'.`;
37
+ }
38
+ // Refuses to proceed when the run's `git add -A` commits could sweep in the
39
+ // scratch under .nx/migrate-runs. Only commit-creating runs probe: without
40
+ // commits the worst case is git-status noise. Fails closed on an unusable
41
+ // probe: createCommits reaching the orchestrator means git was a repository
42
+ // at resolution time (resolveCreateCommits), so an unusable git here is an
43
+ // anomaly, and proceeding would risk absorbing run state into commits, where
44
+ // a later retry-clean `git reset --hard` could roll the tracked state back
45
+ // to a stale snapshot.
46
+ function assertScratchDirSafeForCommits(root, thenWhat) {
47
+ refuseUnsafeScratchExposure((0, git_utils_1.getPathCommitExposure)(types_1.MIGRATE_RUNS_RELATIVE_DIR, root), thenWhat);
48
+ }
49
+ function refuseUnsafeScratchExposure(exposure, thenWhat) {
50
+ switch (exposure) {
51
+ case 'ignored':
52
+ return;
53
+ case 'tracked':
54
+ throw new Error(`Files under ${types_1.MIGRATE_RUNS_RELATIVE_DIR} are committed to git, and ignore rules do not apply to tracked files, so migrate's commits would keep capturing this run's scratch state. ` +
55
+ `Untrack them with \`git rm -r --cached ${types_1.MIGRATE_RUNS_RELATIVE_DIR}\`, commit that change, make sure .gitignore lists ${types_1.MIGRATE_RUNS_RELATIVE_DIR}, then ${thenWhat}`);
56
+ case 'unignored':
57
+ throw new Error(`${types_1.MIGRATE_RUNS_RELATIVE_DIR} is not ignored by git, so migrate's commits would capture this run's scratch state. ` +
58
+ `Add a \`${types_1.MIGRATE_RUNS_RELATIVE_DIR}\` entry to .gitignore, then ${thenWhat}`);
59
+ case 'unknown':
60
+ throw new Error(`Could not verify with git that ${types_1.MIGRATE_RUNS_RELATIVE_DIR} is ignored, so migrate's commits could capture this run's scratch state. ` +
61
+ `Make sure git is usable in this workspace, then ${thenWhat}`);
62
+ default: {
63
+ const exhaustive = exposure;
64
+ throw new Error(`Unrecognized scratch exposure '${exhaustive}'.`);
65
+ }
66
+ }
67
+ }
68
+ async function runOrchestratorInit(input) {
69
+ const { root, migrationsJson, createCommits, commitPrefix, skipInstall, installedNxVersion, } = input;
70
+ const planHash = (0, run_id_1.computePlanHash)(migrationsJson);
71
+ // An active run means a prior init already happened (e.g. it crashed before
72
+ // the agent's first reconcile); starting a second run would compete with it.
73
+ // Same plan: resume it. Different plan: refuse rather than guess which plan
74
+ // the agent means. NewerRunStateFormatError propagates.
75
+ const active = findActiveRunForPlan(root, planHash);
76
+ // Dispensed commands interpolate migration ids verbatim, so every init
77
+ // (fresh or resumed) validates the incoming plan's ids. After the mismatch
78
+ // check: a plan that will be refused anyway should get the more actionable
79
+ // mismatch error, not this one.
80
+ const migrations = (migrationsJson.migrations ?? []);
81
+ const sorted = (0, sort_migrations_1.sortMigrations)(migrations.slice(), {
82
+ hoistHandoffGitignore: true,
83
+ });
84
+ for (const m of sorted) {
85
+ const id = `${m.package}:${m.name}`;
86
+ if (!run_state_1.SHELL_SAFE_VALUE.test(id)) {
87
+ throw new Error(`The migration id '${id}' contains characters that are not shell-safe. Orchestrated runs require shell-safe migration ids.`);
88
+ }
89
+ }
90
+ if (active) {
91
+ resumeRun(root, active.runId, active.state);
92
+ return;
93
+ }
94
+ const runId = (0, run_id_1.createRunId)();
95
+ const dir = (0, run_state_1.runDir)(root, runId);
96
+ // Probe before any git side effect: the checkpoint below is a `git add -A`
97
+ // commit, so on a workspace where scratch is committable it would sweep in
98
+ // prior runs' directories and manufacture the very tracked state the probe
99
+ // refuses. Missing ignore coverage alone is not refused yet, because the
100
+ // fallback below may still add the entry. 'ignored' also stands in for
101
+ // "no hazard" when commits are off.
102
+ const scratchExposure = createCommits
103
+ ? (0, git_utils_1.getPathCommitExposure)(types_1.MIGRATE_RUNS_RELATIVE_DIR, root)
104
+ : 'ignored';
105
+ if (scratchExposure !== 'unignored') {
106
+ refuseUnsafeScratchExposure(scratchExposure, INIT_CONTINUE_HINT);
107
+ }
108
+ // Applied before the checkpoint so the entry (when it can be added) already
109
+ // covers older scratch by the time the checkpoint's `git add -A` runs; the
110
+ // fallback's standalone commit is suppressed because that checkpoint
111
+ // carries the edit. Unlike the classic loop, a planned ignore migration
112
+ // can't be deferred to: the run dir is created below, before that
113
+ // migration runs.
114
+ await (0, handoff_gitignore_1.applyAgenticHandoffGitignoreFallback)({
115
+ migrations: sorted,
116
+ installedNxVersion,
117
+ effectiveCreateCommits: createCommits,
118
+ commitPrefix,
119
+ root,
120
+ applyWhenPlanned: true,
121
+ commitStandalone: false,
122
+ });
123
+ if (scratchExposure === 'unignored') {
124
+ // The fallback was the workspace's last chance at ignore coverage;
125
+ // refuse when it could not add the entry (v23+ conscious removal, no
126
+ // .gitignore, Lerna without nx.json).
127
+ refuseUnsafeScratchExposure((0, git_utils_1.getPathCommitExposure)(types_1.MIGRATE_RUNS_RELATIVE_DIR, root), INIT_CONTINUE_HINT);
128
+ }
129
+ // Checkpoint pre-existing working-tree state BEFORE the run dir exists, so
130
+ // the checkpoint's `git add -A` can't track this run's scratch and a clean
131
+ // tree stays uncommitted (writing run.json would otherwise dirty it and fire
132
+ // a spurious checkpoint). A crash between here and createRun leaves the
133
+ // committed changes orphaned but never lost; the next init re-checkpoints a
134
+ // now-clean tree as a no-op.
135
+ const checkpoint = createCommits ? checkpointEntry(root, commitPrefix) : null;
136
+ // The preflight checkpoint swallows its own failures, so the tree itself is
137
+ // the only reliable signal: anything still uncommitted here predates every
138
+ // step's gitRefBefore and rules out clean retries for the whole run. A
139
+ // failed probe counts as dirty: mistaking it for clean would let a later
140
+ // retry-clean reset destroy the very work this flag exists to protect.
141
+ const checkpointFailed = createCommits && (0, git_utils_1.getWorkingTreeStatus)(root) !== 'clean';
142
+ const state = {
143
+ formatVersion: run_state_1.CURRENT_RUN_STATE_FORMAT_VERSION,
144
+ runId,
145
+ createdAt: (0, util_1.nowIso)(),
146
+ nxVersion: versions_1.nxVersion,
147
+ status: 'active',
148
+ createCommits,
149
+ commitPrefix,
150
+ ...(skipInstall ? { skipInstall: true } : {}),
151
+ rounds: [
152
+ {
153
+ index: 0,
154
+ planHash,
155
+ planSnapshot: PLAN_SNAPSHOT_0,
156
+ },
157
+ ],
158
+ steps: buildSteps(sorted),
159
+ commits: checkpoint ? [checkpoint] : [],
160
+ ...(checkpointFailed ? { checkpointFailed: true } : {}),
161
+ analytics: { startEmitted: false, completeEmitted: false },
162
+ };
163
+ // The check/create boundary runs under the creation lock: without it, two
164
+ // concurrent inits could both observe no active run above and create
165
+ // competing runs against the same workspace. The git side effects above
166
+ // stay outside the lock (locked sections must remain synchronous); a losing
167
+ // init's checkpoint commit is the same orphan shape as the crash window
168
+ // above, and the fallback's .gitignore edit is idempotent.
169
+ const winner = (0, state_lock_1.withRunCreationLock)(root, () => {
170
+ const nowActive = findActiveRunForPlan(root, planHash);
171
+ if (nowActive) {
172
+ return nowActive;
173
+ }
174
+ // The snapshot must exist before run.json makes the run discoverable: a
175
+ // crash in between must not leave an active run without its plan.
176
+ (0, fs_1.mkdirSync)(dir, { recursive: true });
177
+ (0, fileutils_1.writeJsonFile)((0, path_1.join)(dir, PLAN_SNAPSHOT_0), migrationsJson);
178
+ (0, run_state_1.createRun)(root, state);
179
+ return null;
180
+ });
181
+ if (winner) {
182
+ resumeRun(root, winner.runId, winner.state);
183
+ return;
184
+ }
185
+ finishInit(root, dir, runId, state);
186
+ }
187
+ // Reads the newest active run, refusing one whose plan differs from the
188
+ // incoming plan; null when no run is active. Uninterpretable run dirs refuse
189
+ // a fresh start (one of them could be an active run this init would compete
190
+ // with) but only warn when a healthy active run is being resumed.
191
+ // NewerRunStateFormatError propagates from the read.
192
+ function findActiveRunForPlan(root, planHash) {
193
+ const { active, uninterpretable } = (0, run_state_1.findActiveRun)(root);
194
+ if (uninterpretable.length > 0) {
195
+ const noun = uninterpretable.length === 1 ? 'directory' : 'directories';
196
+ // A directory name is whatever is on disk and a reason quotes what it
197
+ // found, so neither can be trusted to stay on the line it is put on.
198
+ // Sanitized here rather than left to the gateway: these same lines are
199
+ // joined into the throw below, which leaves through handleErrors.
200
+ const details = uninterpretable.map((u) => `${types_1.MIGRATE_RUNS_RELATIVE_DIR}/${(0, text_1.singleLine)(u.dirName)}: ${(0, text_1.singleLine)(u.reason)}`);
201
+ if (!active) {
202
+ throw new Error([
203
+ `Whether a migrate run is still active could not be determined; starting a new run could re-apply migrations an unfinished run already applied.`,
204
+ ...details,
205
+ `Fix or remove the listed ${noun} (removing a run directory abandons that run; migrations it already applied remain applied), then re-run the command.`,
206
+ ].join('\n'));
207
+ }
208
+ (0, agent_output_1.warnToAgent)({
209
+ title: `Ignoring ${uninterpretable.length} migrate run ${noun} that could not be read.`,
210
+ bodyLines: details,
211
+ });
212
+ }
213
+ if (active && (0, state_machine_1.latestRound)(active.state)?.planHash !== planHash) {
214
+ throw new Error(`A migrate run '${active.runId}' is already active with a different plan. ` +
215
+ `Finish it first by running \`${reconcileCommand(root, active.runId)}\`, ` +
216
+ `or remove ${types_1.MIGRATE_RUNS_RELATIVE_DIR}/${active.runId} to abandon it.`);
217
+ }
218
+ return active;
219
+ }
220
+ // Shared resume tail for an active run found before or under the creation
221
+ // lock, so the two discovery points cannot drift apart.
222
+ function resumeRun(root, runId, state) {
223
+ const dir = (0, run_state_1.runDir)(root, runId);
224
+ // Ignore/index state can change while a durable run is paused (a checkout,
225
+ // a .gitignore edit, a forced add). Probe before the checkpoint retry:
226
+ // ensureCheckpoint is a `git add -A` commit, so on a workspace that became
227
+ // unsafe it would absorb the run's own scratch.
228
+ if (state.createCommits) {
229
+ assertScratchDirSafeForCommits(root, continueRunHint(runId));
230
+ }
231
+ // A run flagged checkpointFailed gets one more chance to capture the
232
+ // pre-existing tree state before its first migration commit absorbs it.
233
+ const resumed = ensureCheckpoint(root, dir, state);
234
+ announceResume(runId, resumed);
235
+ finishInit(root, dir, runId, resumed);
236
+ }
237
+ // The dispense that follows says nothing about the steps already behind it, so
238
+ // a resumed run is otherwise indistinguishable from a fresh one that happens
239
+ // to start partway down the plan.
240
+ function announceResume(runId, state) {
241
+ const applied = state.steps.filter((s) => s.status === 'succeeded').length;
242
+ const skipped = state.steps.filter((s) => s.status === 'skipped').length;
243
+ const remaining = state.steps.length - applied - skipped;
244
+ // A subset of `remaining`, called out separately: a run is resumed most often
245
+ // because one of these is waiting on a decision, and the count alone would
246
+ // read as work that has not been reached yet.
247
+ const stalled = state.steps.filter((s) => s.status === 'failed' || s.status === 'died').length;
248
+ (0, agent_output_1.logToAgent)({
249
+ title: `nx migrate: resuming run ${runId}`,
250
+ bodyLines: [
251
+ ` started: ${state.createdAt}`,
252
+ ` progress: ${applied} applied, ${skipped} skipped, ${remaining} remaining${stalled > 0 ? ` (${stalled} awaiting a decision)` : ''}`,
253
+ ],
254
+ });
255
+ }
256
+ // Resume-only checkpoint retry, gated on checkpointFailed: a fresh init always
257
+ // evaluates the checkpoint before the run dir exists, so an unflagged run
258
+ // without a checkpoint entry started from a clean tree and there is nothing to
259
+ // capture (retrying there would commit the run's own scratch instead). Skipped
260
+ // once any migration step has advanced (a late checkpoint would absorb an
261
+ // already-run migration's changes).
262
+ function ensureCheckpoint(root, dir, state) {
263
+ if (!state.createCommits || !state.checkpointFailed)
264
+ return state;
265
+ if (state.steps.some((s) => s.status !== 'pending'))
266
+ return state;
267
+ // The checkpoint commit is a git side effect, so it runs before the lock; the
268
+ // ledger append and flag clear then apply to the fresh on-disk state.
269
+ const checkpoint = checkpointEntry(root, state.commitPrefix);
270
+ // The retried checkpoint captured everything, so clean retries are safe
271
+ // again. Only a verified-clean tree clears the flag: a failed probe proves
272
+ // nothing was captured.
273
+ const cleared = (0, git_utils_1.getWorkingTreeStatus)(root) === 'clean';
274
+ if (!checkpoint && !cleared)
275
+ return state;
276
+ return (0, state_lock_1.updateRunState)(dir, (fresh) => {
277
+ // Re-check both guards on the fresh state: a concurrent reconcile may have
278
+ // cleared the flag or advanced a step while the commit ran. Skipping here
279
+ // can leave that commit unledgered, the documented crash-window shape.
280
+ if (!fresh.checkpointFailed ||
281
+ fresh.steps.some((s) => s.status !== 'pending')) {
282
+ return null;
283
+ }
284
+ const next = checkpoint ? appendCommit(fresh, checkpoint) : fresh;
285
+ return cleared ? { ...next, checkpointFailed: false } : next;
286
+ });
287
+ }
288
+ // Commits pre-existing working-tree state so the first migration's commit can't
289
+ // absorb it, returning the ledger entry only when a commit verifiably landed.
290
+ // A clean tree is a no-op. Failure detection is the caller's job: the commit
291
+ // helper swallows failures, so callers re-check the tree afterwards.
292
+ function checkpointEntry(root, commitPrefix) {
293
+ // Skip only on a verified-clean tree; on a failed probe the commit attempt
294
+ // below re-probes and may succeed once the transient failure passes.
295
+ if ((0, git_utils_1.getWorkingTreeStatus)(root) === 'clean') {
296
+ return null;
297
+ }
298
+ const before = (0, git_utils_1.getLatestCommitSha)(root);
299
+ (0, migrate_commits_1.commitCheckpointBeforeMigrations)(root, commitPrefix);
300
+ const after = (0, git_utils_1.getLatestCommitSha)(root);
301
+ if (after && after !== before) {
302
+ return { kind: 'checkpoint', sha: after, stepIds: [] };
303
+ }
304
+ return null;
305
+ }
306
+ // Shared tail of a fresh and a resumed init: emit the init analytics once per
307
+ // run (watermark-guarded) and emit the current dispense.
308
+ function finishInit(root, dir, runId, state) {
309
+ let current = state;
310
+ if (!current.analytics.startEmitted) {
311
+ // Claim the watermark on the fresh state first: of two concurrent inits
312
+ // exactly one flips it, and only that one reports.
313
+ let claimed = false;
314
+ current = (0, state_lock_1.updateRunState)(dir, (fresh) => {
315
+ if (fresh.analytics.startEmitted)
316
+ return null;
317
+ claimed = true;
318
+ return {
319
+ ...fresh,
320
+ analytics: { ...fresh.analytics, startEmitted: true },
321
+ };
322
+ });
323
+ if (claimed) {
324
+ (0, migrate_analytics_1.reportMigrateOrchestratorInit)({
325
+ migrationCount: current.steps.length,
326
+ createCommits: current.createCommits,
327
+ });
328
+ }
329
+ }
330
+ advanceAndDispense(root, dir, runId, current);
331
+ }
332
+ async function runOrchestratorReconcile(input) {
333
+ const { root, runId, stepAction } = input;
334
+ if (!run_id_1.RUN_ID_SAFE.test(runId)) {
335
+ throw new Error(`Invalid run id '${runId}'.`);
336
+ }
337
+ const dir = (0, run_state_1.runDir)(root, runId);
338
+ if (!(0, run_state_1.hasRunState)(dir)) {
339
+ // No remediation beyond the id: starting a run is a separate, gated entry
340
+ // point, so pointing at it here would hand most callers a command that
341
+ // does something else entirely.
342
+ throw new Error(`No migrate run '${runId}' was found under ${types_1.MIGRATE_RUNS_RELATIVE_DIR}.`);
343
+ }
344
+ // Version refusal (NewerRunStateFormatError) propagates.
345
+ let state = (0, run_state_1.readRunState)(dir);
346
+ // Ignore/index state can change while a durable run is paused (a checkout,
347
+ // a .gitignore edit, a forced add); re-verify before foldHandoffs, which
348
+ // can itself commit a settled prompt step.
349
+ if (state.createCommits) {
350
+ assertScratchDirSafeForCommits(root, continueRunHint(runId));
351
+ }
352
+ // (a) fold handoffs into prompt outcomes (committing completed ones).
353
+ state = await foldHandoffs(root, dir, state);
354
+ // (b) reclassify running steps whose worker process is gone.
355
+ state = detectDeaths(dir, state);
356
+ // (c) apply the decision relay to the single failed/died step.
357
+ if (stepAction) {
358
+ const result = applyReconcileStepAction(root, state, stepAction);
359
+ if (result.kind === 'error') {
360
+ emitError(root, runId, result.reason);
361
+ return; // state untouched
362
+ }
363
+ const target = result.targetStep;
364
+ // An adopted death commits its working tree; that git side effect runs
365
+ // before the lock (locked sections must stay synchronous), then the
366
+ // transition and its ledger entry land in one fresh-state write so a
367
+ // crash can't leave the step succeeded unrecorded. As with a fold, that
368
+ // window is wide, and a rejected reapply after the commit landed is
369
+ // equivalent to commitForStep's crash-refold window: the commit stays in
370
+ // history, the ledger misses it, and the rejection names it below so the
371
+ // agent re-decides against the moved HEAD.
372
+ // Without commits the adopted tree is still this migration's result, and
373
+ // it can carry package.json edits the dead worker never installed; the
374
+ // install has to run here or the next dispense captures the modified
375
+ // dependencies as its own baseline and nothing is left to detect them.
376
+ // A skip leaves the tree as it stands too, so it owes the same install
377
+ // and, with commits on, the same debt record as a prompt that did not
378
+ // complete. Retries owe nothing: the rearmed attempt reconciles itself.
379
+ const { entry, installFailed } = stepAction === 'adopt'
380
+ ? state.createCommits
381
+ ? await commitForStep(root, dir, state, target)
382
+ : {
383
+ entry: null,
384
+ installFailed: await installFailedForStep(root, dir, state, target),
385
+ }
386
+ : stepAction === 'skip'
387
+ ? await retainedTreeSideEffects(root, dir, state, target)
388
+ : { entry: null, installFailed: false };
389
+ // A rearm starts a fresh attempt; drop the stale handoff before the rearm
390
+ // is persisted so a crash in between can't refold the old outcome into the
391
+ // new attempt. Losing the handoff without the rearm is safe: the step is
392
+ // still failed/died and the agent re-issues the action.
393
+ if (stepAction === 'retry' || stepAction === 'retry-clean') {
394
+ removeHandoff(dir, target.migrationId);
395
+ }
396
+ // Re-validate the transition against the fresh disk state: if a concurrent
397
+ // reconcile already resolved this step, surface the state machine's own
398
+ // rejection through the same emitError path rather than writing over it.
399
+ // The bound attempt keeps the acceptance checks above honest: they ran
400
+ // against `state`, and a step that was re-armed and failed again in
401
+ // between is a different attempt those checks never saw.
402
+ let freshRejection;
403
+ const written = (0, state_lock_1.updateRunState)(dir, (fresh) => {
404
+ const reapplied = (0, state_machine_1.applyStepEvent)(fresh, {
405
+ type: 'stepAction',
406
+ stepId: target.id,
407
+ action: stepAction,
408
+ attempt: target.attempt,
409
+ });
410
+ if (reapplied.kind === 'error') {
411
+ freshRejection = reapplied.reason;
412
+ return null;
413
+ }
414
+ const next = installFailed
415
+ ? (0, state_machine_1.markInstallFailed)(reapplied.state, target.id)
416
+ : reapplied.state;
417
+ return entry ? appendCommit(next, entry) : next;
418
+ });
419
+ if (freshRejection) {
420
+ emitError(root, runId, entry?.kind === 'landed' && entry.sha
421
+ ? `${freshRejection} Note: this action's commit ${entry.sha} had already landed and stays in history; resolve the step against the tree as it stands now.`
422
+ : freshRejection);
423
+ return;
424
+ }
425
+ state = written;
426
+ }
427
+ // (d) choose and emit the next dispense.
428
+ advanceAndDispense(root, dir, runId, state);
429
+ }
430
+ function buildSteps(sortedMigrations) {
431
+ return sortedMigrations.map((m, index) => ({
432
+ id: `step-${index + 1}`,
433
+ roundIndex: 0,
434
+ migrationId: `${m.package}:${m.name}`,
435
+ status: 'pending',
436
+ attempt: 1,
437
+ dispenseCount: 0,
438
+ hasGenerator: !(0, migration_shape_1.isPromptOnlyMigration)(m),
439
+ }));
440
+ }
441
+ // --- reconcile phases -------------------------------------------------------
442
+ async function foldHandoffs(root, dir, state) {
443
+ let current = state;
444
+ // Step ids are fixed for the life of a run, so the ids come from the caller's
445
+ // snapshot while every status read comes from `current`: each iteration can
446
+ // have advanced the run.
447
+ for (const { id } of state.steps) {
448
+ const step = current.steps.find((s) => s.id === id);
449
+ if (step.status !== 'awaiting-prompt-outcome')
450
+ continue;
451
+ const result = (0, handoff_1.readHandoffWithReason)(handoffPath(dir, (0, state_machine_1.splitMigrationId)(step.migrationId)));
452
+ if (!result.ok)
453
+ continue; // still awaiting; the dispense asks to settle it
454
+ const promptOutcome = handoffToPromptOutcome(result.handoff);
455
+ // The commit and the install are side effects, so they happen before the
456
+ // fold, outside the lock; the transition and its ledger entry then land in
457
+ // one fresh-state write. A crash cannot leave the step settled with its
458
+ // commit forgotten.
459
+ const { entry, installFailed } = await foldLedgerEntry(root, dir, current, step, promptOutcome);
460
+ // The fold re-validates against fresh disk state, on the attempt this
461
+ // handoff was read for. That window is wide (a git commit plus a package
462
+ // install), and 'awaiting-prompt-outcome' recurs, so without the attempt
463
+ // check a concurrent reconcile's retry could take this outcome as its own.
464
+ // A dropped fold is equivalent to the crash-refold window: the commit
465
+ // landed but the ledger misses it.
466
+ let folded = false;
467
+ current = (0, state_lock_1.updateRunState)(dir, (fresh) => {
468
+ const applied = (0, state_machine_1.applyStepEvent)(fresh, {
469
+ type: 'foldPromptOutcome',
470
+ stepId: step.id,
471
+ attempt: step.attempt,
472
+ promptOutcome,
473
+ });
474
+ if (applied.kind === 'error')
475
+ return null;
476
+ folded = true;
477
+ const next = installFailed
478
+ ? (0, state_machine_1.markInstallFailed)(applied.state, step.id)
479
+ : applied.state;
480
+ return entry ? appendCommit(next, entry) : next;
481
+ });
482
+ // Only the handoff this fold consumed is removed. A rejected fold leaves
483
+ // it in place: it belongs to whichever attempt is on disk now, and that
484
+ // attempt's own reconcile still has to read it.
485
+ if (folded)
486
+ removeHandoff(dir, step.migrationId);
487
+ }
488
+ return current;
489
+ }
490
+ // What a folded prompt outcome owes the run state. A completed prompt with
491
+ // commits on is committed and its result classified as usual, the install
492
+ // riding in on the commit path. Every other outcome still reconciles the
493
+ // dependencies itself: the prompt (or the generator half before it) can have
494
+ // edited package.json whether or not it completed, and skipping the install
495
+ // there strands that change with nothing left to detect it, since the next
496
+ // step's dispense captures the already-modified state as its own baseline.
497
+ //
498
+ // A failed or skipped prompt is not committed, but it can still have left
499
+ // edits behind, so a tree that is not verifiably clean records debt: the
500
+ // changes then read as pending for a later commit to absorb, and the
501
+ // completion warning knows about them. A failed probe counts as dirty,
502
+ // matching every other retry-safety decision in this file; debt a later landed
503
+ // entry covers costs nothing.
504
+ async function foldLedgerEntry(root, dir, state, step, promptOutcome) {
505
+ if (promptOutcome.status === 'completed') {
506
+ if (state.createCommits) {
507
+ return commitForStep(root, dir, state, step);
508
+ }
509
+ return {
510
+ entry: null,
511
+ installFailed: await installFailedForStep(root, dir, state, step),
512
+ };
513
+ }
514
+ return retainedTreeSideEffects(root, dir, state, step);
515
+ }
516
+ // Shared by prompts that did not complete and by skipped failed or died steps:
517
+ // the tree is kept as it stands, so the step still owes the install of any
518
+ // dependency edits it left and, with commits on, a debt record when the tree
519
+ // is not verifiably clean (see foldLedgerEntry for why).
520
+ async function retainedTreeSideEffects(root, dir, state, step) {
521
+ const installFailed = await installFailedForStep(root, dir, state, step);
522
+ const entry = state.createCommits && (0, git_utils_1.getWorkingTreeStatus)(root) !== 'clean'
523
+ ? { kind: 'failed', stepIds: [step.id] }
524
+ : null;
525
+ return { entry, installFailed };
526
+ }
527
+ // Installs the dependency changes a step's tree may carry when no commit path
528
+ // will do it (the fold of a prompt outcome that lands no commit, or a
529
+ // non-commit adopt), returning whether the install failed. A failure is
530
+ // recorded rather than thrown: reconcile still owes the agent a dispense, and
531
+ // a warning alone dies with this process.
532
+ async function installFailedForStep(root, dir, state, step) {
533
+ try {
534
+ await (0, util_1.installDepsChangedSinceDispense)(root, dir, step, state.skipInstall === true, reconcileCommand(root, state.runId));
535
+ return false;
536
+ }
537
+ catch (e) {
538
+ (0, agent_output_1.warnToAgent)({
539
+ title: `The dependencies changed by ${step.migrationId} could not be installed (${(0, util_1.summarizeError)(e)}).`,
540
+ bodyLines: [`Run \`${(0, util_1.pmInstallCommand)(root)}\` before continuing.`],
541
+ });
542
+ return true;
543
+ }
544
+ }
545
+ // A failed handoff fails the prompt; a success handoff completes it, unless it
546
+ // marks the prompt not applicable via `extras.outcome === 'skipped'`.
547
+ function handoffToPromptOutcome(handoff) {
548
+ if (handoff.status === 'failed') {
549
+ return { status: 'failed', summary: handoff.summary };
550
+ }
551
+ if (handoff.extras && handoff.extras['outcome'] === 'skipped') {
552
+ return { status: 'skipped', summary: handoff.summary };
553
+ }
554
+ return { status: 'completed', summary: handoff.summary };
555
+ }
556
+ function detectDeaths(dir, state) {
557
+ let current = state;
558
+ // As in foldHandoffs: ids from the caller's snapshot, statuses from
559
+ // `current`, so an earlier iteration's write is visible to the next.
560
+ for (const { id } of state.steps) {
561
+ const step = current.steps.find((s) => s.id === id);
562
+ if (step.status !== 'running')
563
+ continue;
564
+ if (step.pid === undefined || (0, util_1.isPidAlive)(step.pid))
565
+ continue;
566
+ // markDied re-validates against fresh disk state, on the attempt and pid
567
+ // this observation was made for: if the worker finished between the
568
+ // snapshot and the write, or a retry already put a live worker on the
569
+ // step, the transition is rejected and the step is left as recorded.
570
+ current = (0, state_lock_1.updateRunState)(dir, (fresh) => {
571
+ const applied = (0, state_machine_1.applyStepEvent)(fresh, {
572
+ type: 'markDied',
573
+ stepId: step.id,
574
+ attempt: step.attempt,
575
+ });
576
+ return applied.kind === 'ok' ? applied.state : null;
577
+ });
578
+ }
579
+ return current;
580
+ }
581
+ function applyReconcileStepAction(root, state, action) {
582
+ const candidates = state.steps.filter((s) => s.status === 'failed' || s.status === 'died');
583
+ if (candidates.length === 0) {
584
+ return {
585
+ kind: 'error',
586
+ reason: `No step is failed or died, so there is nothing for --step-action=${action} to target.`,
587
+ };
588
+ }
589
+ if (candidates.length > 1) {
590
+ return {
591
+ kind: 'error',
592
+ reason: `More than one step is failed or died; --step-action targets exactly one. Resolve them one at a time.`,
593
+ };
594
+ }
595
+ const step = candidates[0];
596
+ // A retry-clean the dispense would not have offered must be refused here
597
+ // too, or a hand-crafted reconcile could reset a tree with no restore point
598
+ // and destroy prior steps' work.
599
+ if (action === 'retry-clean') {
600
+ const head = (0, git_utils_1.getLatestCommitSha)(root);
601
+ const fallback = step.status === 'died'
602
+ ? `Use 'adopt' or 'skip' instead.`
603
+ : `Use 'retry' or 'skip' instead.`;
604
+ if (!canOfferCleanRetry(root, state, step, head)) {
605
+ return {
606
+ kind: 'error',
607
+ reason: `Cannot apply action 'retry-clean' to step '${step.id}': ${cleanRetryUnavailableReason(root, state, step, head)} ${fallback}`,
608
+ };
609
+ }
610
+ // The reset itself is delegated to the caller, and every check above
611
+ // passes identically whether or not it ran, so only the tree can say
612
+ // whether the reset actually happened. Anything but a verified-clean tree
613
+ // is refused: accepting would drop the generator marker and rerun the
614
+ // generator over the previous attempt's output.
615
+ if ((0, git_utils_1.getWorkingTreeStatus)(root) !== 'clean') {
616
+ return {
617
+ kind: 'error',
618
+ reason: `Cannot apply action 'retry-clean' to step '${step.id}': the working tree is not verifiably clean, so the reset this action requires has not happened. Run \`git reset --hard ${step.gitRefBefore}\` then \`git clean -fd -e ${types_1.MIGRATE_RUNS_RELATIVE_DIR}\` first, then re-run it. ${fallback}`,
619
+ };
620
+ }
621
+ }
622
+ // A failed generator can have written to the tree before throwing, and a
623
+ // plain retry reruns it, so a pre-marker retry is accepted only when git
624
+ // can see nothing of the failed attempt in the tree. The state machine is
625
+ // pure and cannot read the tree, which is why the gate lives here.
626
+ if (action === 'retry' &&
627
+ step.status === 'failed' &&
628
+ generatorPending(step)) {
629
+ const safety = assessPreMarkerRetry(root, step);
630
+ if (safety.kind === 'unsafe') {
631
+ return {
632
+ kind: 'error',
633
+ reason: `Cannot apply action 'retry' to step '${step.id}': ${safety.reason} Use 'retry-clean' where offered, or 'skip'.`,
634
+ };
635
+ }
636
+ if (safety.kind === 'warned') {
637
+ (0, agent_output_1.warnToAgent)({
638
+ title: `Retrying ${step.migrationId} without verification`,
639
+ bodyLines: [safety.warning],
640
+ });
641
+ }
642
+ }
643
+ const applied = (0, state_machine_1.applyStepEvent)(state, {
644
+ type: 'stepAction',
645
+ stepId: step.id,
646
+ action,
647
+ attempt: step.attempt,
648
+ });
649
+ if (applied.kind === 'error') {
650
+ return applied;
651
+ }
652
+ return { kind: 'ok', state: applied.state, targetStep: step };
653
+ }
654
+ // Commits the working tree left by a folded prompt outcome or an adopted
655
+ // death, returning the ledger entry the caller persists together with the
656
+ // step transition (null when there was nothing to commit). The worker's
657
+ // recorded-commit path classifies through the same commitResultToLedgerEntry.
658
+ //
659
+ // Remaining narrow window: a crash after the git commit but before the state
660
+ // write refolds on the next reconcile, where the commit attempt sees a clean
661
+ // tree ('no-changes') and the ledger simply misses that landed entry; the
662
+ // changes themselves are never lost. A lost landed entry can also strand the
663
+ // failed entries it had absorbed, which is why completion double-checks the
664
+ // tree before warning about debt.
665
+ async function commitForStep(root, dir, state, step) {
666
+ const { name } = (0, state_machine_1.splitMigrationId)(step.migrationId);
667
+ const absorbedStepIds = (0, state_machine_1.uncoveredFailedStepIds)(state).filter((id) => id !== step.id);
668
+ let result;
669
+ try {
670
+ result = await (0, migrate_commits_1.commitMigrationIfRequested)(root, { name }, true, state.commitPrefix, () => (0, util_1.installDepsChangedSinceDispense)(root, dir, step, state.skipInstall === true, reconcileCommand(root, state.runId)), (0, state_machine_1.stepsToPendingMigrations)(state, absorbedStepIds));
671
+ }
672
+ catch (e) {
673
+ // The dependency install is the only thing that throws here: the commit
674
+ // attempt itself reports through result.status, and the install's own
675
+ // bookkeeping never throws. Both consequences are recorded, and neither
676
+ // aborts reconcile so the next dispense still fires. The debt cannot stand
677
+ // in for the install failure: a later step's commit absorbs this diff and
678
+ // lands an entry naming this step, which clears the debt while the
679
+ // dependencies are still missing.
680
+ (0, util_1.warnCommitFailed)(name, e);
681
+ return {
682
+ entry: { kind: 'failed', stepIds: [step.id] },
683
+ installFailed: true,
684
+ };
685
+ }
686
+ if (result.status === 'failed') {
687
+ (0, util_1.warnCommitFailed)(name);
688
+ }
689
+ return {
690
+ entry: (0, state_machine_1.commitResultToLedgerEntry)(result, step.id, absorbedStepIds),
691
+ installFailed: false,
692
+ };
693
+ }
694
+ // --- dispense ---------------------------------------------------------------
695
+ function advanceAndDispense(root, dir, runId, state) {
696
+ const step = firstActionableStep(state);
697
+ if (!step) {
698
+ completeRun(root, dir, runId, state);
699
+ return;
700
+ }
701
+ switch (step.status) {
702
+ case 'pending':
703
+ dispenseNextStep(root, dir, runId, state, step);
704
+ break;
705
+ case 'dispensed':
706
+ // Re-entry before the worker advanced the step; re-emit its command.
707
+ emitNextStep(root, runId, step);
708
+ break;
709
+ case 'failed':
710
+ emitRetryFailed(root, runId, state, step);
711
+ break;
712
+ case 'died':
713
+ emitDied(root, runId, state, step);
714
+ break;
715
+ case 'running':
716
+ emitStillRunning(root, runId, step);
717
+ break;
718
+ case 'awaiting-prompt-outcome':
719
+ emitAwaitPrompt(root, dir, runId, step);
720
+ break;
721
+ case 'succeeded':
722
+ case 'skipped':
723
+ // firstActionableStep already excludes these via TERMINAL_STATUSES;
724
+ // landing here means an already-terminal step slipped through
725
+ // unclassified rather than being left to stall the run silently.
726
+ throw new Error(`Orchestrator could not dispense step '${step.id}': step is already ${step.status}.`);
727
+ default: {
728
+ // A new MigrateStepStatus member with no case above fails typecheck
729
+ // here until it is classified.
730
+ const exhaustive = step.status;
731
+ throw new Error(`Orchestrator could not dispense step '${step.id}': unrecognized status '${exhaustive}'.`);
732
+ }
733
+ }
734
+ }
735
+ function firstActionableStep(state) {
736
+ return state.steps.find((s) => !TERMINAL_STATUSES.has(s.status));
737
+ }
738
+ function dispenseNextStep(root, dir, runId, state, step) {
739
+ // Read the pre-migration baselines (git and package.json reads) before the
740
+ // lock; the dispense transition and the baselines then apply to the fresh
741
+ // state in one write.
742
+ const baselines = {
743
+ gitRefBefore: (0, git_utils_1.getLatestCommitSha)(root) ?? undefined,
744
+ treeCleanAtDispense: (0, git_utils_1.getWorkingTreeStatus)(root) === 'clean',
745
+ depsHashAtDispense: (0, util_1.depsHash)(root),
746
+ };
747
+ let advancedElsewhere = false;
748
+ const current = (0, state_lock_1.updateRunState)(dir, (fresh) => {
749
+ // A concurrent init or reconcile may have dispensed (or further advanced)
750
+ // this step since the caller's read; reclassify against the fresh state
751
+ // below instead of failing the duplicate transition.
752
+ if (fresh.steps.find((s) => s.id === step.id)?.status !== 'pending') {
753
+ advancedElsewhere = true;
754
+ return null;
755
+ }
756
+ const dispensed = applyEventOrThrow(fresh, {
757
+ type: 'dispense',
758
+ stepId: step.id,
759
+ });
760
+ return setDispenseBaselines(dispensed, step.id, baselines);
761
+ });
762
+ if (advancedElsewhere) {
763
+ // Terminates: step statuses only advance, so each re-entry observes
764
+ // strictly later state and lands in a non-pending branch of the dispatch.
765
+ advanceAndDispense(root, dir, runId, current);
766
+ return;
767
+ }
768
+ emitNextStep(root, runId, current.steps.find((s) => s.id === step.id));
769
+ }
770
+ function emitNextStep(root, runId, step) {
771
+ const migrationId = step.migrationId;
772
+ emit(runId, step, 'next-step', {
773
+ command: workerCommand(root, migrationId, runId),
774
+ next: reconcileCommand(root, runId),
775
+ instructionLines: [
776
+ `Apply migration ${migrationId} by running the command below, then run the "next" command to record the outcome and get the next step.`,
777
+ ],
778
+ });
779
+ }
780
+ function emitRetryFailed(root, runId, state, step) {
781
+ const migrationId = step.migrationId;
782
+ // A worker failure records its summary on the outcome; a prompt the agent
783
+ // reported as failed carries the agent's own reason on the prompt outcome.
784
+ const summary = step.outcome?.summary ?? step.promptOutcome?.summary;
785
+ const head = (0, git_utils_1.getLatestCommitSha)(root);
786
+ const tree = dirtyTreeSummary(root);
787
+ const cleanRetry = canOfferCleanRetry(root, state, step, head);
788
+ // A failure recorded before the generator marker can still have written to
789
+ // the tree (a direct fs or exec side effect, or a crash mid-flush); a
790
+ // marker means only the install and commit are left, so plain retry is
791
+ // safe outright. So is retrying a step with no generator half to rerun.
792
+ const pending = generatorPending(step);
793
+ const retrySafety = pending
794
+ ? assessPreMarkerRetry(root, step)
795
+ : { kind: 'safe' };
796
+ const lines = [
797
+ `Migration ${migrationId} failed${summary ? `: ${summary}` : ''}.`,
798
+ ` started from: ${step.gitRefBefore ?? '(unknown)'}`,
799
+ ` current HEAD: ${head ?? '(unknown)'}`,
800
+ ` working tree: ${tree === null ? '(unknown)' : tree ? `\n${tree}` : '(clean)'}`,
801
+ ``,
802
+ `Decide how to proceed and re-run reconcile with one of:`,
803
+ retryOptionLine(retrySafety, reconcileCommand(root, runId, 'retry')),
804
+ ];
805
+ if (cleanRetry) {
806
+ lines.push(` retry-clean: restore the tree to ${step.gitRefBefore ?? 'the pre-migration ref'} first (e.g. \`git reset --hard ${step.gitRefBefore ?? '<ref>'}\` then \`git clean -fd -e ${types_1.MIGRATE_RUNS_RELATIVE_DIR}\`, keeping the run state out of the clean), then retry from that clean state by running: ${reconcileCommand(root, runId, 'retry-clean')}`);
807
+ }
808
+ lines.push(` skip: ${reconcileCommand(root, runId, 'skip')}`);
809
+ if (pending) {
810
+ lines.push(UNVERIFIABLE_WRITES_LINE);
811
+ }
812
+ // A step whose generator may still run gets no `next`, whichever retry the
813
+ // checks above would accept: git can vouch for the tracked tree only, and
814
+ // an agent that follows `next` blindly must not rerun a generator over
815
+ // writes nothing here could see. Choosing a retry has to be explicit.
816
+ emit(runId, step, 'retry-failed', {
817
+ ...(pending ? {} : { next: reconcileCommand(root, runId, 'retry') }),
818
+ instructionLines: lines,
819
+ });
820
+ }
821
+ // Whether the step's generator half may still have to run: it exists and no
822
+ // attempt has recorded running it. Only then can a retry apply a generator
823
+ // twice, so only then is a continuation withheld from `next`. A step with no
824
+ // generator (prompt-only) is retried by re-prompting the agent over the tree
825
+ // it already knows, which is the designed recovery; a step recorded before the
826
+ // kind was persisted counts as having one.
827
+ function generatorPending(step) {
828
+ return step.generatorCompleted !== true && step.hasGenerator !== false;
829
+ }
830
+ // Appended to the failed and died dispenses of a step whose generator may rerun.
831
+ const UNVERIFIABLE_WRITES_LINE = `None of these can be verified against writes git does not see (ignored paths, files outside the repository); if this migration writes there, inspect that state before choosing.`;
832
+ function retryOptionLine(safety, command) {
833
+ switch (safety.kind) {
834
+ case 'safe':
835
+ return ` retry: re-run over the current tree: ${command}`;
836
+ case 'warned':
837
+ return ` retry: re-run over the current tree; without git nothing can verify what the failed attempt left, so inspect the tree first: ${command}`;
838
+ case 'unsafe':
839
+ return ` retry: re-run over the current tree; refused until the working tree is clean and HEAD is at the started-from ref: ${command}`;
840
+ default: {
841
+ const exhaustive = safety;
842
+ return exhaustive;
843
+ }
844
+ }
845
+ }
846
+ // A clean retry resets the tree to the step's captured pre-migration ref.
847
+ // That is only safe when every prior diff is already committed: without
848
+ // per-migration commits the ref is the run's starting commit (the reset would
849
+ // wipe all prior steps' uncommitted work); a failed init checkpoint or a
850
+ // pending step commit means the ref predates diffs the reset would also
851
+ // destroy; without a captured ref there is nothing to reset to; edits already
852
+ // in the tree when this step was dispensed (the user's own, or an earlier
853
+ // step's the checkpoint never saw) are not represented by the ref either; and
854
+ // HEAD anywhere other than the ref means something was committed since the
855
+ // step was dispensed that the reset would discard, whether that is this step's
856
+ // own commit (recorded, or made in the window before the worker died writing
857
+ // its ledger entry) or one the user made alongside the run.
858
+ // Cleanliness and position both have to say so explicitly: a failed tree probe
859
+ // records dirty, a run created before that field existed carries nothing to
860
+ // check, and an unreadable HEAD is no ref at all, so none of the three can be
861
+ // read as a restore point that exists.
862
+ function canOfferCleanRetry(root, state, step, head) {
863
+ return (state.createCommits &&
864
+ !state.checkpointFailed &&
865
+ !(0, state_machine_1.hasPendingCommitDebt)(state) &&
866
+ !!step.gitRefBefore &&
867
+ head === step.gitRefBefore &&
868
+ step.treeCleanAtDispense === true &&
869
+ !endangeredLandedEntry(root, state, step));
870
+ }
871
+ // The last landed ledger entry covering the step whose commit a reset to the
872
+ // step's gitRefBefore would discard. Entries from earlier attempts predate the
873
+ // ref re-captured at re-dispense and survive the reset; only a commit that is
874
+ // not an ancestor of the ref (or cannot be verified as one) is endangered.
875
+ function endangeredLandedEntry(root, state, step) {
876
+ let endangered = null;
877
+ for (const entry of (0, state_machine_1.coveringLandedEntries)(state, step.id)) {
878
+ if (!entry.sha ||
879
+ !step.gitRefBefore ||
880
+ !(0, git_utils_1.isAncestorCommit)(entry.sha, step.gitRefBefore, root)) {
881
+ endangered = entry;
882
+ }
883
+ }
884
+ return endangered;
885
+ }
886
+ // Explains why retry-clean is withheld for a failed or died step; feeds the
887
+ // death dispense and a rejected --step-action=retry-clean.
888
+ function cleanRetryUnavailableReason(root, state, step, head) {
889
+ const endangered = endangeredLandedEntry(root, state, step);
890
+ if (endangered) {
891
+ return endangered.sha
892
+ ? `this migration's changes already landed in commit ${endangered.sha}, which a reset would discard.`
893
+ : `this migration's changes already landed in a commit, which a reset would discard.`;
894
+ }
895
+ if (step.gitRefBefore && head !== step.gitRefBefore) {
896
+ 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.`;
897
+ }
898
+ return `resetting the tree could discard uncommitted work that no restore point accounts for.`;
899
+ }
900
+ function assessPreMarkerRetry(root, step) {
901
+ const repo = (0, git_utils_1.getGitRepositoryStatus)(root);
902
+ if (repo === 'not-git') {
903
+ return {
904
+ kind: 'warned',
905
+ warning: `The workspace is not a git repository, so nothing can verify whether the failed attempt left partial changes in the tree. The retry reruns the generator over whatever is there; confirm the tree yourself first.`,
906
+ };
907
+ }
908
+ if (repo === 'unknown') {
909
+ return {
910
+ kind: 'unsafe',
911
+ reason: `the git repository state could not be determined, so nothing can verify whether the failed attempt left changes in the tree.`,
912
+ };
913
+ }
914
+ const head = (0, git_utils_1.getLatestCommitSha)(root);
915
+ if (!step.gitRefBefore || head !== step.gitRefBefore) {
916
+ return {
917
+ kind: 'unsafe',
918
+ reason: `HEAD is at ${head ?? '(unreadable)'} rather than the ${step.gitRefBefore ?? '(unrecorded)'} this migration started from, so the failed attempt's changes may already be committed and rerunning the generator could apply them twice.`,
919
+ };
920
+ }
921
+ if ((0, git_utils_1.getWorkingTreeStatus)(root) !== 'clean') {
922
+ return {
923
+ kind: 'unsafe',
924
+ reason: `the working tree is not verifiably clean, and the failed attempt may have written to it before failing; rerunning the generator over those changes could apply them twice.`,
925
+ };
926
+ }
927
+ return { kind: 'safe' };
928
+ }
929
+ function emitDied(root, runId, state, step) {
930
+ const migrationId = step.migrationId;
931
+ const ref = step.gitRefBefore;
932
+ const head = (0, git_utils_1.getLatestCommitSha)(root);
933
+ const tree = dirtyTreeSummary(root);
934
+ const cleanRetry = canOfferCleanRetry(root, state, step, head);
935
+ // The generator half is recorded (or the step never had one), so a retry
936
+ // that keeps the tree as it stands has the rest of the step left to run: a
937
+ // prompt, or the install and commit its worker never reached.
938
+ const resume = !generatorPending(step);
939
+ const lines = [
940
+ `The worker for ${migrationId} died; its process is gone.`,
941
+ ` started from: ${ref ?? '(unknown)'}`,
942
+ ` current HEAD: ${head ?? '(unknown)'}`,
943
+ ` working tree: ${tree === null ? '(unknown)' : tree ? `\n${tree}` : '(clean)'}`,
944
+ ``,
945
+ ];
946
+ const options = [];
947
+ if (resume) {
948
+ options.push(` retry: keep everything this migration already produced (its commit, if any, and the current tree) and run only the part that did not complete, then run: ${reconcileCommand(root, runId, 'retry')}`);
949
+ }
950
+ if (cleanRetry) {
951
+ options.push(
952
+ // Two commands rather than one `&&` chain: the agent runs these in its
953
+ // own shell, and not every shell joins statements that way.
954
+ ` retry-clean: restore the tree to ${ref ?? 'the pre-migration ref'} first (e.g. \`git reset --hard ${ref ?? '<ref>'}\` then \`git clean -fd -e ${types_1.MIGRATE_RUNS_RELATIVE_DIR}\`, keeping the run state out of the clean), then retry from that clean state by running: ${reconcileCommand(root, runId, 'retry-clean')}`);
955
+ }
956
+ else {
957
+ lines.push(`A clean retry is unavailable: ${cleanRetryUnavailableReason(root, state, step, head)}`);
958
+ }
959
+ options.push(` adopt: keep the current working-tree state as this migration's result, then run: ${reconcileCommand(root, runId, 'adopt')}`, ` skip: leave the tree as it stands and move on without this migration, then run: ${reconcileCommand(root, runId, 'skip')}`);
960
+ lines.push(`Choose exactly one:`);
961
+ lines.push(...options);
962
+ if (!resume) {
963
+ lines.push(UNVERIFIABLE_WRITES_LINE);
964
+ }
965
+ // `retry` is preselected wherever it is legal: it is the only resolution
966
+ // that neither discards work nor records a result the run never produced.
967
+ // While the generator may still run there is no `next` at all: a reset
968
+ // cannot be verified against writes git does not see, and adopting records
969
+ // a result nothing checked, so an agent that follows `next` blindly must
970
+ // land on neither.
971
+ emit(runId, step, 'died', {
972
+ ...(resume ? { next: reconcileCommand(root, runId, 'retry') } : {}),
973
+ instructionLines: lines,
974
+ });
975
+ }
976
+ function emitStillRunning(root, runId, step) {
977
+ const migrationId = step.migrationId;
978
+ const ageMs = step.startedAt ? Date.now() - Date.parse(step.startedAt) : 0;
979
+ const lines = [
980
+ `The worker for ${migrationId} (pid ${step.pid}) is still running. Wait for it to finish, then run the "next" command.`,
981
+ ];
982
+ if (ageMs >= HANG_THRESHOLD_MS) {
983
+ lines.push(`It has been running for ${Math.floor(ageMs / 60000)} minutes and may be hung. Verify pid ${step.pid}; either keep waiting, or kill it so the next reconcile can classify it as died.`);
984
+ }
985
+ emit(runId, step, 'still-running', {
986
+ next: reconcileCommand(root, runId),
987
+ instructionLines: lines,
988
+ });
989
+ }
990
+ function emitAwaitPrompt(root, dir, runId, step) {
991
+ const migrationId = step.migrationId;
992
+ const { package: pkg, name } = (0, state_machine_1.splitMigrationId)(migrationId);
993
+ const filePath = handoffPath(dir, { package: pkg, name });
994
+ // The package id becomes real path segments, so handing over the path
995
+ // without its directory is what would force the agent to `mkdir -p`. Same
996
+ // reason the classic runner pre-creates it in run-step.ts.
997
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(filePath), { recursive: true });
998
+ const lines = [
999
+ `Migration ${migrationId} is a prompt-based migration awaiting your outcome.`,
1000
+ `Apply the prompt (see the worker's earlier <nx_migrate_prompt> block), then write the handoff file and run the "next" command.`,
1001
+ `Handoff file: ${filePath}`,
1002
+ `Handoff JSON: { "status": "success" | "failed", "summary": "<what you did>" }. To mark the prompt not applicable, use "status": "success" with "outcome": "skipped".`,
1003
+ ];
1004
+ // A handoff that exists but can't be read/parsed/validated is a rejection,
1005
+ // not a still-awaited outcome. Naming why stops the run from re-emitting the
1006
+ // same await forever while the agent leaves the bad file in place.
1007
+ const rejection = describeRejectedHandoff(filePath);
1008
+ if (rejection.length > 0) {
1009
+ lines.push('', ...rejection);
1010
+ }
1011
+ emit(runId, step, 'await-prompt', {
1012
+ next: reconcileCommand(root, runId),
1013
+ instructionLines: lines,
1014
+ });
1015
+ }
1016
+ // Empty unless a handoff file is present but unusable; wording mirrors the
1017
+ // classic runner's ambiguous-outcome cause lines.
1018
+ function describeRejectedHandoff(handoffPath) {
1019
+ const result = (0, handoff_1.readHandoffWithReason)(handoffPath);
1020
+ if (result.ok)
1021
+ return [];
1022
+ const { reason, detail } = result;
1023
+ if (reason === 'missing')
1024
+ return [];
1025
+ const followUp = 'Rewrite the handoff file, then run the "next" command.';
1026
+ switch (reason) {
1027
+ case 'read-error':
1028
+ return [
1029
+ `The handoff file was rejected: it could not be read${detail ? ` (${detail})` : ''}.`,
1030
+ followUp,
1031
+ ];
1032
+ case 'parse-error':
1033
+ return [
1034
+ `The handoff file was rejected: it contained invalid JSON${detail ? ` (${detail})` : ''}.`,
1035
+ followUp,
1036
+ ];
1037
+ case 'shape-mismatch':
1038
+ return [
1039
+ 'The handoff file was rejected: it was missing required fields or had an unexpected shape.',
1040
+ followUp,
1041
+ ];
1042
+ default: {
1043
+ const exhaustive = reason;
1044
+ throw new Error(`Unrecognized handoff rejection reason '${exhaustive}'.`);
1045
+ }
1046
+ }
1047
+ }
1048
+ // A refused --step-action still exits 0: the tagged error block is the answer
1049
+ // to the request, and it carries the reconcile command to run next. A non-zero
1050
+ // exit would tell the driving agent that reconcile itself crashed, and it
1051
+ // would stop reading for the correction it is being handed.
1052
+ function emitError(root, runId, reason) {
1053
+ (0, agent_output_1.warnToAgent)({
1054
+ title: 'The requested --step-action could not be applied.',
1055
+ bodyLines: [reason],
1056
+ });
1057
+ (0, agent_output_1.emitStepBlock)(runId, '-', 'error', {
1058
+ next: reconcileCommand(root, runId),
1059
+ instructions: reason,
1060
+ });
1061
+ (0, migrate_analytics_1.reportMigrateOrchestratorDispense)({ action: 'error', attempt: 0 });
1062
+ }
1063
+ function completeRun(root, dir, runId, state) {
1064
+ let current = state;
1065
+ const completed = current.steps.filter((s) => s.status === 'succeeded').length;
1066
+ const skipped = current.steps.filter((s) => s.status === 'skipped').length;
1067
+ const dispenseCount = current.steps.reduce((n, s) => n + s.dispenseCount, 0);
1068
+ // The crash-refold window can strand a failed ledger entry whose diff was in
1069
+ // fact absorbed; suppress the warning only on a verified-clean tree. A dirty
1070
+ // tree can still be unrelated edits, so the warning only claims the changes
1071
+ // "may remain".
1072
+ const commitDebt = (0, state_machine_1.hasPendingCommitDebt)(current) && (0, git_utils_1.getWorkingTreeStatus)(root) !== 'clean';
1073
+ // Persist the terminal status and claim the watermark in one fresh-state
1074
+ // write before emitting: a crash between the write and the output can't
1075
+ // double-count the completion, and of two concurrent reconciles exactly one
1076
+ // claims the report.
1077
+ let shouldEmit = false;
1078
+ if (current.status !== 'completed' || !current.analytics.completeEmitted) {
1079
+ current = (0, state_lock_1.updateRunState)(dir, (fresh) => {
1080
+ if (fresh.status === 'completed' && fresh.analytics.completeEmitted) {
1081
+ return null;
1082
+ }
1083
+ shouldEmit = !fresh.analytics.completeEmitted;
1084
+ return {
1085
+ ...fresh,
1086
+ status: 'completed',
1087
+ analytics: { ...fresh.analytics, completeEmitted: true },
1088
+ };
1089
+ });
1090
+ }
1091
+ if (shouldEmit) {
1092
+ (0, migrate_analytics_1.reportMigrateOrchestratorComplete)({
1093
+ completed,
1094
+ skipped,
1095
+ dispenseCount,
1096
+ });
1097
+ }
1098
+ const debtLine = 'Some migration changes could not be committed and may remain in the working tree; review and commit them manually.';
1099
+ if (commitDebt) {
1100
+ (0, agent_output_1.warnToAgent)({ title: debtLine });
1101
+ }
1102
+ const uninstalled = current.steps.filter((s) => s.installFailed);
1103
+ const installLine = uninstalled.length > 0
1104
+ ? `The dependency changes made by ${uninstalled
1105
+ .map((s) => s.migrationId)
1106
+ .join(', ')} were not installed; run \`${(0, util_1.pmInstallCommand)(root)}\` before using the workspace.`
1107
+ : null;
1108
+ if (installLine) {
1109
+ (0, agent_output_1.warnToAgent)({ title: installLine });
1110
+ }
1111
+ const instructionLines = [
1112
+ `Migrate run ${runId} is complete.`,
1113
+ ` applied: ${completed}`,
1114
+ ` skipped: ${skipped}`,
1115
+ ...(commitDebt ? [debtLine] : []),
1116
+ ...(installLine ? [installLine] : []),
1117
+ ];
1118
+ (0, agent_output_1.logToAgent)({ title: 'nx migrate: complete', bodyLines: instructionLines });
1119
+ (0, agent_output_1.emitStepBlock)(runId, '-', 'complete', {
1120
+ instructions: instructionLines.join('\n'),
1121
+ });
1122
+ }
1123
+ function emit(runId, step, action, payload) {
1124
+ const { instructionLines, ...rest } = payload;
1125
+ // One sanitized array feeds both, so the block payload says exactly what the
1126
+ // human echo said.
1127
+ const lines = instructionLines ? (0, agent_output_1.safeLines)(instructionLines) : undefined;
1128
+ (0, agent_output_1.logToAgent)({ title: `nx migrate: ${action}`, bodyLines: lines });
1129
+ (0, agent_output_1.emitStepBlock)(runId, step.id, action, {
1130
+ ...rest,
1131
+ ...(lines ? { instructions: lines.join('\n') } : {}),
1132
+ });
1133
+ (0, migrate_analytics_1.reportMigrateOrchestratorDispense)({ action, attempt: step.attempt });
1134
+ }
1135
+ // Raw argv is forwarded verbatim across the wrapper hops, so every flag is a
1136
+ // single `--flag=value` token. Interpolated values are validated shell-safe at
1137
+ // init (migration ids; resumed run ids are gated by the run-dir scan) and
1138
+ // reconcile entry (run id).
1139
+ function workerCommand(root, migrationId, runId) {
1140
+ return `${(0, util_1.pmExecPrefix)(root)} nx migrate --run-migration=${migrationId} --run-id=${runId}`;
1141
+ }
1142
+ function reconcileCommand(root, runId, action) {
1143
+ const base = `${(0, util_1.pmExecPrefix)(root)} nx migrate --run-id=${runId}`;
1144
+ return action ? `${base} --step-action=${action}` : base;
1145
+ }
1146
+ // --- helpers ----------------------------------------------------------------
1147
+ function appendCommit(state, entry) {
1148
+ return { ...state, commits: [...state.commits, entry] };
1149
+ }
1150
+ // Records what the workspace looked like as this attempt starts. The git ref
1151
+ // and the tree state are re-captured per dispense, since a retry restarts from
1152
+ // wherever the tree is now. The dependency baseline is not: it tracks the last
1153
+ // dependencies that were actually installed, moving only when an install
1154
+ // lands, so a retry that only has the commit left to do still sees the
1155
+ // previous attempt's package.json edits as needing one.
1156
+ function setDispenseBaselines(state, stepId, baselines) {
1157
+ const depsBaseline = state.steps.find((s) => s.id === stepId)?.depsHashAtDispense;
1158
+ return {
1159
+ ...state,
1160
+ steps: state.steps.map((s) => s.id === stepId
1161
+ ? {
1162
+ ...s,
1163
+ gitRefBefore: baselines.gitRefBefore,
1164
+ treeCleanAtDispense: baselines.treeCleanAtDispense,
1165
+ depsHashAtDispense: depsBaseline ?? baselines.depsHashAtDispense ?? undefined,
1166
+ }
1167
+ : s),
1168
+ };
1169
+ }
1170
+ // Applies a step event to fresh state or throws the orchestrator's advance
1171
+ // error. Pure; callers persist the result via updateRunState.
1172
+ function applyEventOrThrow(state, event) {
1173
+ const result = (0, state_machine_1.applyStepEvent)(state, event);
1174
+ if (result.kind === 'error') {
1175
+ throw new Error(`Orchestrator could not advance the run: ${result.reason}`);
1176
+ }
1177
+ return result.state;
1178
+ }
1179
+ function handoffPath(dir, migration) {
1180
+ return (0, handoff_1.stepHandoffPath)(dir, migration);
1181
+ }
1182
+ function removeHandoff(dir, migrationId) {
1183
+ (0, fs_1.rmSync)(handoffPath(dir, (0, state_machine_1.splitMigrationId)(migrationId)), { force: true });
1184
+ }
1185
+ // null means the probe itself failed; the death dispense renders that as
1186
+ // '(unknown)', because '(clean)' would invite a retry-clean reset over
1187
+ // evidence that was never gathered (getWorkingTreeStatus's contract).
1188
+ function dirtyTreeSummary(root) {
1189
+ try {
1190
+ return (0, child_process_1.execSync)('git status --porcelain', {
1191
+ encoding: 'utf8',
1192
+ cwd: root,
1193
+ stdio: ['ignore', 'pipe', 'pipe'],
1194
+ windowsHide: true,
1195
+ }).trim();
1196
+ }
1197
+ catch {
1198
+ return null;
1199
+ }
1200
+ }