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.
- package/dist/bin/init-local.js +3 -5
- package/dist/release/changelog-renderer/index.js +4 -5
- package/dist/src/adapter/ngcli-adapter.js +60 -1
- package/dist/src/command-line/completion/command-object.js +7 -12
- package/dist/src/command-line/configure-ai-agents/configure-ai-agents.js +17 -23
- package/dist/src/command-line/generate/generate.js +35 -35
- package/dist/src/command-line/import/import.js +18 -40
- package/dist/src/command-line/init/ai-agent-prompts.js +5 -11
- package/dist/src/command-line/init/command-object.js +4 -2
- package/dist/src/command-line/init/implementation/add-nx-to-monorepo.js +12 -32
- package/dist/src/command-line/init/implementation/add-nx-to-nest.js +8 -21
- package/dist/src/command-line/init/implementation/add-nx-to-npm-repo.js +8 -21
- package/dist/src/command-line/init/implementation/angular/index.js +6 -15
- package/dist/src/command-line/init/init-v1.js +8 -18
- package/dist/src/command-line/init/init-v2.js +26 -56
- package/dist/src/command-line/migrate/agentic/definitions.js +35 -8
- package/dist/src/command-line/migrate/agentic/handoff-gitignore.d.ts +22 -3
- package/dist/src/command-line/migrate/agentic/handoff-gitignore.js +13 -10
- package/dist/src/command-line/migrate/agentic/handoff.d.ts +3 -1
- package/dist/src/command-line/migrate/agentic/handoff.js +4 -2
- package/dist/src/command-line/migrate/agentic/run-step.js +1 -0
- package/dist/src/command-line/migrate/agentic/runner.js +24 -24
- package/dist/src/command-line/migrate/agentic/select.js +20 -30
- package/dist/src/command-line/migrate/agentic/types.d.ts +19 -3
- package/dist/src/command-line/migrate/agentic/types.js +13 -4
- package/dist/src/command-line/migrate/command-object.d.ts +3 -0
- package/dist/src/command-line/migrate/command-object.js +13 -0
- package/dist/src/command-line/migrate/execute-migration.d.ts +14 -0
- package/dist/src/command-line/migrate/execute-migration.js +30 -9
- package/dist/src/command-line/migrate/migrate-analytics.d.ts +28 -0
- package/dist/src/command-line/migrate/migrate-analytics.js +51 -1
- package/dist/src/command-line/migrate/migrate-commits.d.ts +8 -2
- package/dist/src/command-line/migrate/migrate-commits.js +66 -19
- package/dist/src/command-line/migrate/migrate-config.d.ts +4 -2
- package/dist/src/command-line/migrate/migrate-config.js +12 -4
- package/dist/src/command-line/migrate/migrate.d.ts +9 -2
- package/dist/src/command-line/migrate/migrate.js +179 -56
- package/dist/src/command-line/migrate/multi-major.js +7 -10
- package/dist/src/command-line/migrate/resolve-package-version.js +4 -8
- package/dist/src/command-line/migrate/run/agent-output.d.ts +20 -0
- package/dist/src/command-line/migrate/run/agent-output.js +65 -0
- package/dist/src/command-line/migrate/run/index.d.ts +7 -0
- package/dist/src/command-line/migrate/run/index.js +19 -1
- package/dist/src/command-line/migrate/run/orchestrator.d.ts +20 -0
- package/dist/src/command-line/migrate/run/orchestrator.js +1200 -0
- package/dist/src/command-line/migrate/run/run-id.d.ts +16 -0
- package/dist/src/command-line/migrate/run/run-id.js +56 -0
- package/dist/src/command-line/migrate/run/run-state.d.ts +157 -0
- package/dist/src/command-line/migrate/run/run-state.js +438 -0
- package/dist/src/command-line/migrate/run/state-lock.d.ts +25 -0
- package/dist/src/command-line/migrate/run/state-lock.js +76 -0
- package/dist/src/command-line/migrate/run/state-machine.d.ts +84 -0
- package/dist/src/command-line/migrate/run/state-machine.js +315 -0
- package/dist/src/command-line/migrate/run/util.d.ts +58 -0
- package/dist/src/command-line/migrate/run/util.js +139 -7
- package/dist/src/command-line/migrate/run/worker.d.ts +1 -0
- package/dist/src/command-line/migrate/run/worker.js +308 -45
- package/dist/src/command-line/migrate/safe-prompt.d.ts +14 -26
- package/dist/src/command-line/migrate/safe-prompt.js +32 -43
- package/dist/src/command-line/migrate/step-actions.d.ts +5 -0
- package/dist/src/command-line/migrate/step-actions.js +12 -0
- package/dist/src/command-line/migrate/text.d.ts +18 -0
- package/dist/src/command-line/migrate/text.js +26 -0
- package/dist/src/command-line/migrate/version-skew-guard.d.ts +6 -4
- package/dist/src/command-line/migrate/version-skew-guard.js +34 -14
- package/dist/src/command-line/nx-cloud/connect/connect-to-nx-cloud.js +16 -20
- package/dist/src/command-line/release/changelog.js +6 -17
- package/dist/src/command-line/release/plan.js +15 -28
- package/dist/src/command-line/release/release.js +6 -17
- package/dist/src/command-line/release/utils/remote-release-clients/github.js +16 -22
- package/dist/src/command-line/release/utils/remote-release-clients/gitlab.js +13 -22
- package/dist/src/command-line/release/utils/resolve-semver-specifier.js +19 -41
- package/dist/src/command-line/release/version/resolve-current-version.js +7 -10
- package/dist/src/core/graph/main.js +1 -1
- package/dist/src/devkit-internals.d.ts +2 -0
- package/dist/src/devkit-internals.js +12 -4
- package/dist/src/migrations/update-23-2-0/set-cache-on-executor-target-defaults.d.ts +12 -0
- package/dist/src/migrations/update-23-2-0/set-cache-on-executor-target-defaults.js +250 -0
- package/dist/src/migrations/update-23-2-0/set-cache-on-executor-target-defaults.md +50 -0
- package/dist/src/native/nx.wasm32-wasi.debug.wasm +0 -0
- package/dist/src/native/nx.wasm32-wasi.wasm +0 -0
- package/dist/src/plugins/js/lock-file/bun-parser.js +43 -10
- package/dist/src/project-graph/utils/project-configuration/target-normalization.js +128 -2
- package/dist/src/tasks-runner/run-command.js +17 -32
- package/dist/src/tasks-runner/task-env.js +5 -2
- package/dist/src/tasks-runner/utils.js +2 -6
- package/dist/src/utils/analytics-prompt.js +4 -7
- package/dist/src/utils/exit-codes.d.ts +9 -0
- package/dist/src/utils/exit-codes.js +19 -0
- package/dist/src/utils/fileutils.d.ts +5 -0
- package/dist/src/utils/fileutils.js +7 -2
- package/dist/src/utils/git-utils.d.ts +33 -2
- package/dist/src/utils/git-utils.js +156 -8
- package/dist/src/utils/long-running-target.d.ts +10 -0
- package/dist/src/utils/long-running-target.js +19 -0
- package/dist/src/utils/min-release-age/behavior/bun.js +7 -11
- package/dist/src/utils/min-release-age/behavior/npm.js +5 -2
- package/dist/src/utils/min-release-age/packument.js +1 -1
- package/dist/src/utils/nx-console-prompt.js +3 -6
- package/dist/src/utils/package-manager-config/bunfig.d.ts +14 -0
- package/dist/src/utils/package-manager-config/bunfig.js +49 -0
- package/dist/src/utils/package-manager-config/npmrc.d.ts +23 -0
- package/dist/src/utils/package-manager-config/npmrc.js +124 -0
- package/dist/src/utils/package-manager-config/pnpm-config.d.ts +17 -0
- package/dist/src/utils/package-manager-config/pnpm-config.js +60 -0
- package/dist/src/utils/package-manager.d.ts +21 -2
- package/dist/src/utils/package-manager.js +244 -47
- package/dist/src/utils/params.d.ts +7 -1
- package/dist/src/utils/params.js +64 -6
- package/dist/src/utils/prompt-helpers.d.ts +53 -0
- package/dist/src/utils/prompt-helpers.js +111 -0
- package/dist/src/utils/provenance.js +6 -14
- package/dist/src/utils/registry-config/bun.d.ts +2 -0
- package/dist/src/utils/registry-config/bun.js +257 -0
- package/dist/src/utils/registry-config/index.d.ts +13 -0
- package/dist/src/utils/registry-config/index.js +102 -0
- package/dist/src/utils/registry-config/pnpm.d.ts +2 -0
- package/dist/src/utils/registry-config/pnpm.js +1551 -0
- package/dist/src/utils/registry-config/utils.d.ts +195 -0
- package/dist/src/utils/registry-config/utils.js +496 -0
- package/dist/src/utils/registry-config/yarn-berry.d.ts +2 -0
- package/dist/src/utils/registry-config/yarn-berry.js +612 -0
- package/dist/src/utils/registry-config/yarn-classic.d.ts +2 -0
- package/dist/src/utils/registry-config/yarn-classic.js +1001 -0
- package/dist/src/utils/safe-spawn.d.ts +24 -0
- package/dist/src/utils/safe-spawn.js +104 -0
- package/migrations.json +6 -0
- package/package.json +17 -13
- package/dist/src/utils/min-release-age/npmrc.d.ts +0 -15
- 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
|
+
}
|