nx
Version:
1,201 lines • 62.2 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.runOrchestratorInit = runOrchestratorInit;
exports.runOrchestratorReconcile = runOrchestratorReconcile;
const child_process_1 = require("child_process");
const fs_1 = require("fs");
const path_1 = require("path");
const fileutils_1 = require("../../../utils/fileutils");
const git_utils_1 = require("../../../utils/git-utils");
const versions_1 = require("../../../utils/versions");
const handoff_1 = require("../agentic/handoff");
const handoff_gitignore_1 = require("../agentic/handoff-gitignore");
const types_1 = require("../agentic/types");
const migrate_commits_1 = require("../migrate-commits");
const migrate_analytics_1 = require("../migrate-analytics");
const sort_migrations_1 = require("../sort-migrations");
const run_id_1 = require("./run-id");
const run_state_1 = require("./run-state");
const state_lock_1 = require("./state-lock");
const state_machine_1 = require("./state-machine");
const migration_shape_1 = require("../migration-shape");
const util_1 = require("./util");
const text_1 = require("../text");
const agent_output_1 = require("./agent-output");
// The dark migrate orchestrator: drives a durable run one dispense at a time.
// An outer AI agent runs each dispensed command and re-invokes `nx migrate
// --run-id=<id>` to reconcile; there is no long-lived process.
const PLAN_SNAPSHOT_0 = 'plan-0.json';
// A running worker older than this may be hung; the still-running dispense
// escalates so the agent can verify or kill it.
const HANG_THRESHOLD_MS = 15 * 60 * 1000;
// Steps in these statuses are done; every other status needs a dispense.
const TERMINAL_STATUSES = new Set(['succeeded', 'skipped']);
const INIT_CONTINUE_HINT = 're-run the command, or unset NX_MIGRATE_ORCHESTRATOR to use the standard migrate flow.';
function continueRunHint(runId) {
return `re-run the command to continue run '${runId}'.`;
}
// Refuses to proceed when the run's `git add -A` commits could sweep in the
// scratch under .nx/migrate-runs. Only commit-creating runs probe: without
// commits the worst case is git-status noise. Fails closed on an unusable
// probe: createCommits reaching the orchestrator means git was a repository
// at resolution time (resolveCreateCommits), so an unusable git here is an
// anomaly, and proceeding would risk absorbing run state into commits, where
// a later retry-clean `git reset --hard` could roll the tracked state back
// to a stale snapshot.
function assertScratchDirSafeForCommits(root, thenWhat) {
refuseUnsafeScratchExposure((0, git_utils_1.getPathCommitExposure)(types_1.MIGRATE_RUNS_RELATIVE_DIR, root), thenWhat);
}
function refuseUnsafeScratchExposure(exposure, thenWhat) {
switch (exposure) {
case 'ignored':
return;
case 'tracked':
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. ` +
`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}`);
case 'unignored':
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. ` +
`Add a \`${types_1.MIGRATE_RUNS_RELATIVE_DIR}\` entry to .gitignore, then ${thenWhat}`);
case 'unknown':
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. ` +
`Make sure git is usable in this workspace, then ${thenWhat}`);
default: {
const exhaustive = exposure;
throw new Error(`Unrecognized scratch exposure '${exhaustive}'.`);
}
}
}
async function runOrchestratorInit(input) {
const { root, migrationsJson, createCommits, commitPrefix, skipInstall, installedNxVersion, } = input;
const planHash = (0, run_id_1.computePlanHash)(migrationsJson);
// An active run means a prior init already happened (e.g. it crashed before
// the agent's first reconcile); starting a second run would compete with it.
// Same plan: resume it. Different plan: refuse rather than guess which plan
// the agent means. NewerRunStateFormatError propagates.
const active = findActiveRunForPlan(root, planHash);
// Dispensed commands interpolate migration ids verbatim, so every init
// (fresh or resumed) validates the incoming plan's ids. After the mismatch
// check: a plan that will be refused anyway should get the more actionable
// mismatch error, not this one.
const migrations = (migrationsJson.migrations ?? []);
const sorted = (0, sort_migrations_1.sortMigrations)(migrations.slice(), {
hoistHandoffGitignore: true,
});
for (const m of sorted) {
const id = `${m.package}:${m.name}`;
if (!run_state_1.SHELL_SAFE_VALUE.test(id)) {
throw new Error(`The migration id '${id}' contains characters that are not shell-safe. Orchestrated runs require shell-safe migration ids.`);
}
}
if (active) {
resumeRun(root, active.runId, active.state);
return;
}
const runId = (0, run_id_1.createRunId)();
const dir = (0, run_state_1.runDir)(root, runId);
// Probe before any git side effect: the checkpoint below is a `git add -A`
// commit, so on a workspace where scratch is committable it would sweep in
// prior runs' directories and manufacture the very tracked state the probe
// refuses. Missing ignore coverage alone is not refused yet, because the
// fallback below may still add the entry. 'ignored' also stands in for
// "no hazard" when commits are off.
const scratchExposure = createCommits
? (0, git_utils_1.getPathCommitExposure)(types_1.MIGRATE_RUNS_RELATIVE_DIR, root)
: 'ignored';
if (scratchExposure !== 'unignored') {
refuseUnsafeScratchExposure(scratchExposure, INIT_CONTINUE_HINT);
}
// Applied before the checkpoint so the entry (when it can be added) already
// covers older scratch by the time the checkpoint's `git add -A` runs; the
// fallback's standalone commit is suppressed because that checkpoint
// carries the edit. Unlike the classic loop, a planned ignore migration
// can't be deferred to: the run dir is created below, before that
// migration runs.
await (0, handoff_gitignore_1.applyAgenticHandoffGitignoreFallback)({
migrations: sorted,
installedNxVersion,
effectiveCreateCommits: createCommits,
commitPrefix,
root,
applyWhenPlanned: true,
commitStandalone: false,
});
if (scratchExposure === 'unignored') {
// The fallback was the workspace's last chance at ignore coverage;
// refuse when it could not add the entry (v23+ conscious removal, no
// .gitignore, Lerna without nx.json).
refuseUnsafeScratchExposure((0, git_utils_1.getPathCommitExposure)(types_1.MIGRATE_RUNS_RELATIVE_DIR, root), INIT_CONTINUE_HINT);
}
// Checkpoint pre-existing working-tree state BEFORE the run dir exists, so
// the checkpoint's `git add -A` can't track this run's scratch and a clean
// tree stays uncommitted (writing run.json would otherwise dirty it and fire
// a spurious checkpoint). A crash between here and createRun leaves the
// committed changes orphaned but never lost; the next init re-checkpoints a
// now-clean tree as a no-op.
const checkpoint = createCommits ? checkpointEntry(root, commitPrefix) : null;
// The preflight checkpoint swallows its own failures, so the tree itself is
// the only reliable signal: anything still uncommitted here predates every
// step's gitRefBefore and rules out clean retries for the whole run. A
// failed probe counts as dirty: mistaking it for clean would let a later
// retry-clean reset destroy the very work this flag exists to protect.
const checkpointFailed = createCommits && (0, git_utils_1.getWorkingTreeStatus)(root) !== 'clean';
const state = {
formatVersion: run_state_1.CURRENT_RUN_STATE_FORMAT_VERSION,
runId,
createdAt: (0, util_1.nowIso)(),
nxVersion: versions_1.nxVersion,
status: 'active',
createCommits,
commitPrefix,
...(skipInstall ? { skipInstall: true } : {}),
rounds: [
{
index: 0,
planHash,
planSnapshot: PLAN_SNAPSHOT_0,
},
],
steps: buildSteps(sorted),
commits: checkpoint ? [checkpoint] : [],
...(checkpointFailed ? { checkpointFailed: true } : {}),
analytics: { startEmitted: false, completeEmitted: false },
};
// The check/create boundary runs under the creation lock: without it, two
// concurrent inits could both observe no active run above and create
// competing runs against the same workspace. The git side effects above
// stay outside the lock (locked sections must remain synchronous); a losing
// init's checkpoint commit is the same orphan shape as the crash window
// above, and the fallback's .gitignore edit is idempotent.
const winner = (0, state_lock_1.withRunCreationLock)(root, () => {
const nowActive = findActiveRunForPlan(root, planHash);
if (nowActive) {
return nowActive;
}
// The snapshot must exist before run.json makes the run discoverable: a
// crash in between must not leave an active run without its plan.
(0, fs_1.mkdirSync)(dir, { recursive: true });
(0, fileutils_1.writeJsonFile)((0, path_1.join)(dir, PLAN_SNAPSHOT_0), migrationsJson);
(0, run_state_1.createRun)(root, state);
return null;
});
if (winner) {
resumeRun(root, winner.runId, winner.state);
return;
}
finishInit(root, dir, runId, state);
}
// Reads the newest active run, refusing one whose plan differs from the
// incoming plan; null when no run is active. Uninterpretable run dirs refuse
// a fresh start (one of them could be an active run this init would compete
// with) but only warn when a healthy active run is being resumed.
// NewerRunStateFormatError propagates from the read.
function findActiveRunForPlan(root, planHash) {
const { active, uninterpretable } = (0, run_state_1.findActiveRun)(root);
if (uninterpretable.length > 0) {
const noun = uninterpretable.length === 1 ? 'directory' : 'directories';
// A directory name is whatever is on disk and a reason quotes what it
// found, so neither can be trusted to stay on the line it is put on.
// Sanitized here rather than left to the gateway: these same lines are
// joined into the throw below, which leaves through handleErrors.
const details = uninterpretable.map((u) => `${types_1.MIGRATE_RUNS_RELATIVE_DIR}/${(0, text_1.singleLine)(u.dirName)}: ${(0, text_1.singleLine)(u.reason)}`);
if (!active) {
throw new Error([
`Whether a migrate run is still active could not be determined; starting a new run could re-apply migrations an unfinished run already applied.`,
...details,
`Fix or remove the listed ${noun} (removing a run directory abandons that run; migrations it already applied remain applied), then re-run the command.`,
].join('\n'));
}
(0, agent_output_1.warnToAgent)({
title: `Ignoring ${uninterpretable.length} migrate run ${noun} that could not be read.`,
bodyLines: details,
});
}
if (active && (0, state_machine_1.latestRound)(active.state)?.planHash !== planHash) {
throw new Error(`A migrate run '${active.runId}' is already active with a different plan. ` +
`Finish it first by running \`${reconcileCommand(root, active.runId)}\`, ` +
`or remove ${types_1.MIGRATE_RUNS_RELATIVE_DIR}/${active.runId} to abandon it.`);
}
return active;
}
// Shared resume tail for an active run found before or under the creation
// lock, so the two discovery points cannot drift apart.
function resumeRun(root, runId, state) {
const dir = (0, run_state_1.runDir)(root, runId);
// Ignore/index state can change while a durable run is paused (a checkout,
// a .gitignore edit, a forced add). Probe before the checkpoint retry:
// ensureCheckpoint is a `git add -A` commit, so on a workspace that became
// unsafe it would absorb the run's own scratch.
if (state.createCommits) {
assertScratchDirSafeForCommits(root, continueRunHint(runId));
}
// A run flagged checkpointFailed gets one more chance to capture the
// pre-existing tree state before its first migration commit absorbs it.
const resumed = ensureCheckpoint(root, dir, state);
announceResume(runId, resumed);
finishInit(root, dir, runId, resumed);
}
// The dispense that follows says nothing about the steps already behind it, so
// a resumed run is otherwise indistinguishable from a fresh one that happens
// to start partway down the plan.
function announceResume(runId, state) {
const applied = state.steps.filter((s) => s.status === 'succeeded').length;
const skipped = state.steps.filter((s) => s.status === 'skipped').length;
const remaining = state.steps.length - applied - skipped;
// A subset of `remaining`, called out separately: a run is resumed most often
// because one of these is waiting on a decision, and the count alone would
// read as work that has not been reached yet.
const stalled = state.steps.filter((s) => s.status === 'failed' || s.status === 'died').length;
(0, agent_output_1.logToAgent)({
title: `nx migrate: resuming run ${runId}`,
bodyLines: [
` started: ${state.createdAt}`,
` progress: ${applied} applied, ${skipped} skipped, ${remaining} remaining${stalled > 0 ? ` (${stalled} awaiting a decision)` : ''}`,
],
});
}
// Resume-only checkpoint retry, gated on checkpointFailed: a fresh init always
// evaluates the checkpoint before the run dir exists, so an unflagged run
// without a checkpoint entry started from a clean tree and there is nothing to
// capture (retrying there would commit the run's own scratch instead). Skipped
// once any migration step has advanced (a late checkpoint would absorb an
// already-run migration's changes).
function ensureCheckpoint(root, dir, state) {
if (!state.createCommits || !state.checkpointFailed)
return state;
if (state.steps.some((s) => s.status !== 'pending'))
return state;
// The checkpoint commit is a git side effect, so it runs before the lock; the
// ledger append and flag clear then apply to the fresh on-disk state.
const checkpoint = checkpointEntry(root, state.commitPrefix);
// The retried checkpoint captured everything, so clean retries are safe
// again. Only a verified-clean tree clears the flag: a failed probe proves
// nothing was captured.
const cleared = (0, git_utils_1.getWorkingTreeStatus)(root) === 'clean';
if (!checkpoint && !cleared)
return state;
return (0, state_lock_1.updateRunState)(dir, (fresh) => {
// Re-check both guards on the fresh state: a concurrent reconcile may have
// cleared the flag or advanced a step while the commit ran. Skipping here
// can leave that commit unledgered, the documented crash-window shape.
if (!fresh.checkpointFailed ||
fresh.steps.some((s) => s.status !== 'pending')) {
return null;
}
const next = checkpoint ? appendCommit(fresh, checkpoint) : fresh;
return cleared ? { ...next, checkpointFailed: false } : next;
});
}
// Commits pre-existing working-tree state so the first migration's commit can't
// absorb it, returning the ledger entry only when a commit verifiably landed.
// A clean tree is a no-op. Failure detection is the caller's job: the commit
// helper swallows failures, so callers re-check the tree afterwards.
function checkpointEntry(root, commitPrefix) {
// Skip only on a verified-clean tree; on a failed probe the commit attempt
// below re-probes and may succeed once the transient failure passes.
if ((0, git_utils_1.getWorkingTreeStatus)(root) === 'clean') {
return null;
}
const before = (0, git_utils_1.getLatestCommitSha)(root);
(0, migrate_commits_1.commitCheckpointBeforeMigrations)(root, commitPrefix);
const after = (0, git_utils_1.getLatestCommitSha)(root);
if (after && after !== before) {
return { kind: 'checkpoint', sha: after, stepIds: [] };
}
return null;
}
// Shared tail of a fresh and a resumed init: emit the init analytics once per
// run (watermark-guarded) and emit the current dispense.
function finishInit(root, dir, runId, state) {
let current = state;
if (!current.analytics.startEmitted) {
// Claim the watermark on the fresh state first: of two concurrent inits
// exactly one flips it, and only that one reports.
let claimed = false;
current = (0, state_lock_1.updateRunState)(dir, (fresh) => {
if (fresh.analytics.startEmitted)
return null;
claimed = true;
return {
...fresh,
analytics: { ...fresh.analytics, startEmitted: true },
};
});
if (claimed) {
(0, migrate_analytics_1.reportMigrateOrchestratorInit)({
migrationCount: current.steps.length,
createCommits: current.createCommits,
});
}
}
advanceAndDispense(root, dir, runId, current);
}
async function runOrchestratorReconcile(input) {
const { root, runId, stepAction } = input;
if (!run_id_1.RUN_ID_SAFE.test(runId)) {
throw new Error(`Invalid run id '${runId}'.`);
}
const dir = (0, run_state_1.runDir)(root, runId);
if (!(0, run_state_1.hasRunState)(dir)) {
// No remediation beyond the id: starting a run is a separate, gated entry
// point, so pointing at it here would hand most callers a command that
// does something else entirely.
throw new Error(`No migrate run '${runId}' was found under ${types_1.MIGRATE_RUNS_RELATIVE_DIR}.`);
}
// Version refusal (NewerRunStateFormatError) propagates.
let state = (0, run_state_1.readRunState)(dir);
// Ignore/index state can change while a durable run is paused (a checkout,
// a .gitignore edit, a forced add); re-verify before foldHandoffs, which
// can itself commit a settled prompt step.
if (state.createCommits) {
assertScratchDirSafeForCommits(root, continueRunHint(runId));
}
// (a) fold handoffs into prompt outcomes (committing completed ones).
state = await foldHandoffs(root, dir, state);
// (b) reclassify running steps whose worker process is gone.
state = detectDeaths(dir, state);
// (c) apply the decision relay to the single failed/died step.
if (stepAction) {
const result = applyReconcileStepAction(root, state, stepAction);
if (result.kind === 'error') {
emitError(root, runId, result.reason);
return; // state untouched
}
const target = result.targetStep;
// An adopted death commits its working tree; that git side effect runs
// before the lock (locked sections must stay synchronous), then the
// transition and its ledger entry land in one fresh-state write so a
// crash can't leave the step succeeded unrecorded. As with a fold, that
// window is wide, and a rejected reapply after the commit landed is
// equivalent to commitForStep's crash-refold window: the commit stays in
// history, the ledger misses it, and the rejection names it below so the
// agent re-decides against the moved HEAD.
// Without commits the adopted tree is still this migration's result, and
// it can carry package.json edits the dead worker never installed; the
// install has to run here or the next dispense captures the modified
// dependencies as its own baseline and nothing is left to detect them.
// A skip leaves the tree as it stands too, so it owes the same install
// and, with commits on, the same debt record as a prompt that did not
// complete. Retries owe nothing: the rearmed attempt reconciles itself.
const { entry, installFailed } = stepAction === 'adopt'
? state.createCommits
? await commitForStep(root, dir, state, target)
: {
entry: null,
installFailed: await installFailedForStep(root, dir, state, target),
}
: stepAction === 'skip'
? await retainedTreeSideEffects(root, dir, state, target)
: { entry: null, installFailed: false };
// A rearm starts a fresh attempt; drop the stale handoff before the rearm
// is persisted so a crash in between can't refold the old outcome into the
// new attempt. Losing the handoff without the rearm is safe: the step is
// still failed/died and the agent re-issues the action.
if (stepAction === 'retry' || stepAction === 'retry-clean') {
removeHandoff(dir, target.migrationId);
}
// Re-validate the transition against the fresh disk state: if a concurrent
// reconcile already resolved this step, surface the state machine's own
// rejection through the same emitError path rather than writing over it.
// The bound attempt keeps the acceptance checks above honest: they ran
// against `state`, and a step that was re-armed and failed again in
// between is a different attempt those checks never saw.
let freshRejection;
const written = (0, state_lock_1.updateRunState)(dir, (fresh) => {
const reapplied = (0, state_machine_1.applyStepEvent)(fresh, {
type: 'stepAction',
stepId: target.id,
action: stepAction,
attempt: target.attempt,
});
if (reapplied.kind === 'error') {
freshRejection = reapplied.reason;
return null;
}
const next = installFailed
? (0, state_machine_1.markInstallFailed)(reapplied.state, target.id)
: reapplied.state;
return entry ? appendCommit(next, entry) : next;
});
if (freshRejection) {
emitError(root, runId, entry?.kind === 'landed' && entry.sha
? `${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.`
: freshRejection);
return;
}
state = written;
}
// (d) choose and emit the next dispense.
advanceAndDispense(root, dir, runId, state);
}
function buildSteps(sortedMigrations) {
return sortedMigrations.map((m, index) => ({
id: `step-${index + 1}`,
roundIndex: 0,
migrationId: `${m.package}:${m.name}`,
status: 'pending',
attempt: 1,
dispenseCount: 0,
hasGenerator: !(0, migration_shape_1.isPromptOnlyMigration)(m),
}));
}
// --- reconcile phases -------------------------------------------------------
async function foldHandoffs(root, dir, state) {
let current = state;
// Step ids are fixed for the life of a run, so the ids come from the caller's
// snapshot while every status read comes from `current`: each iteration can
// have advanced the run.
for (const { id } of state.steps) {
const step = current.steps.find((s) => s.id === id);
if (step.status !== 'awaiting-prompt-outcome')
continue;
const result = (0, handoff_1.readHandoffWithReason)(handoffPath(dir, (0, state_machine_1.splitMigrationId)(step.migrationId)));
if (!result.ok)
continue; // still awaiting; the dispense asks to settle it
const promptOutcome = handoffToPromptOutcome(result.handoff);
// The commit and the install are side effects, so they happen before the
// fold, outside the lock; the transition and its ledger entry then land in
// one fresh-state write. A crash cannot leave the step settled with its
// commit forgotten.
const { entry, installFailed } = await foldLedgerEntry(root, dir, current, step, promptOutcome);
// The fold re-validates against fresh disk state, on the attempt this
// handoff was read for. That window is wide (a git commit plus a package
// install), and 'awaiting-prompt-outcome' recurs, so without the attempt
// check a concurrent reconcile's retry could take this outcome as its own.
// A dropped fold is equivalent to the crash-refold window: the commit
// landed but the ledger misses it.
let folded = false;
current = (0, state_lock_1.updateRunState)(dir, (fresh) => {
const applied = (0, state_machine_1.applyStepEvent)(fresh, {
type: 'foldPromptOutcome',
stepId: step.id,
attempt: step.attempt,
promptOutcome,
});
if (applied.kind === 'error')
return null;
folded = true;
const next = installFailed
? (0, state_machine_1.markInstallFailed)(applied.state, step.id)
: applied.state;
return entry ? appendCommit(next, entry) : next;
});
// Only the handoff this fold consumed is removed. A rejected fold leaves
// it in place: it belongs to whichever attempt is on disk now, and that
// attempt's own reconcile still has to read it.
if (folded)
removeHandoff(dir, step.migrationId);
}
return current;
}
// What a folded prompt outcome owes the run state. A completed prompt with
// commits on is committed and its result classified as usual, the install
// riding in on the commit path. Every other outcome still reconciles the
// dependencies itself: the prompt (or the generator half before it) can have
// edited package.json whether or not it completed, and skipping the install
// there strands that change with nothing left to detect it, since the next
// step's dispense captures the already-modified state as its own baseline.
//
// A failed or skipped prompt is not committed, but it can still have left
// edits behind, so a tree that is not verifiably clean records debt: the
// changes then read as pending for a later commit to absorb, and the
// completion warning knows about them. A failed probe counts as dirty,
// matching every other retry-safety decision in this file; debt a later landed
// entry covers costs nothing.
async function foldLedgerEntry(root, dir, state, step, promptOutcome) {
if (promptOutcome.status === 'completed') {
if (state.createCommits) {
return commitForStep(root, dir, state, step);
}
return {
entry: null,
installFailed: await installFailedForStep(root, dir, state, step),
};
}
return retainedTreeSideEffects(root, dir, state, step);
}
// Shared by prompts that did not complete and by skipped failed or died steps:
// the tree is kept as it stands, so the step still owes the install of any
// dependency edits it left and, with commits on, a debt record when the tree
// is not verifiably clean (see foldLedgerEntry for why).
async function retainedTreeSideEffects(root, dir, state, step) {
const installFailed = await installFailedForStep(root, dir, state, step);
const entry = state.createCommits && (0, git_utils_1.getWorkingTreeStatus)(root) !== 'clean'
? { kind: 'failed', stepIds: [step.id] }
: null;
return { entry, installFailed };
}
// Installs the dependency changes a step's tree may carry when no commit path
// will do it (the fold of a prompt outcome that lands no commit, or a
// non-commit adopt), returning whether the install failed. A failure is
// recorded rather than thrown: reconcile still owes the agent a dispense, and
// a warning alone dies with this process.
async function installFailedForStep(root, dir, state, step) {
try {
await (0, util_1.installDepsChangedSinceDispense)(root, dir, step, state.skipInstall === true, reconcileCommand(root, state.runId));
return false;
}
catch (e) {
(0, agent_output_1.warnToAgent)({
title: `The dependencies changed by ${step.migrationId} could not be installed (${(0, util_1.summarizeError)(e)}).`,
bodyLines: [`Run \`${(0, util_1.pmInstallCommand)(root)}\` before continuing.`],
});
return true;
}
}
// A failed handoff fails the prompt; a success handoff completes it, unless it
// marks the prompt not applicable via `extras.outcome === 'skipped'`.
function handoffToPromptOutcome(handoff) {
if (handoff.status === 'failed') {
return { status: 'failed', summary: handoff.summary };
}
if (handoff.extras && handoff.extras['outcome'] === 'skipped') {
return { status: 'skipped', summary: handoff.summary };
}
return { status: 'completed', summary: handoff.summary };
}
function detectDeaths(dir, state) {
let current = state;
// As in foldHandoffs: ids from the caller's snapshot, statuses from
// `current`, so an earlier iteration's write is visible to the next.
for (const { id } of state.steps) {
const step = current.steps.find((s) => s.id === id);
if (step.status !== 'running')
continue;
if (step.pid === undefined || (0, util_1.isPidAlive)(step.pid))
continue;
// markDied re-validates against fresh disk state, on the attempt and pid
// this observation was made for: if the worker finished between the
// snapshot and the write, or a retry already put a live worker on the
// step, the transition is rejected and the step is left as recorded.
current = (0, state_lock_1.updateRunState)(dir, (fresh) => {
const applied = (0, state_machine_1.applyStepEvent)(fresh, {
type: 'markDied',
stepId: step.id,
attempt: step.attempt,
});
return applied.kind === 'ok' ? applied.state : null;
});
}
return current;
}
function applyReconcileStepAction(root, state, action) {
const candidates = state.steps.filter((s) => s.status === 'failed' || s.status === 'died');
if (candidates.length === 0) {
return {
kind: 'error',
reason: `No step is failed or died, so there is nothing for --step-action=${action} to target.`,
};
}
if (candidates.length > 1) {
return {
kind: 'error',
reason: `More than one step is failed or died; --step-action targets exactly one. Resolve them one at a time.`,
};
}
const step = candidates[0];
// A retry-clean the dispense would not have offered must be refused here
// too, or a hand-crafted reconcile could reset a tree with no restore point
// and destroy prior steps' work.
if (action === 'retry-clean') {
const head = (0, git_utils_1.getLatestCommitSha)(root);
const fallback = step.status === 'died'
? `Use 'adopt' or 'skip' instead.`
: `Use 'retry' or 'skip' instead.`;
if (!canOfferCleanRetry(root, state, step, head)) {
return {
kind: 'error',
reason: `Cannot apply action 'retry-clean' to step '${step.id}': ${cleanRetryUnavailableReason(root, state, step, head)} ${fallback}`,
};
}
// The reset itself is delegated to the caller, and every check above
// passes identically whether or not it ran, so only the tree can say
// whether the reset actually happened. Anything but a verified-clean tree
// is refused: accepting would drop the generator marker and rerun the
// generator over the previous attempt's output.
if ((0, git_utils_1.getWorkingTreeStatus)(root) !== 'clean') {
return {
kind: 'error',
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}`,
};
}
}
// A failed generator can have written to the tree before throwing, and a
// plain retry reruns it, so a pre-marker retry is accepted only when git
// can see nothing of the failed attempt in the tree. The state machine is
// pure and cannot read the tree, which is why the gate lives here.
if (action === 'retry' &&
step.status === 'failed' &&
generatorPending(step)) {
const safety = assessPreMarkerRetry(root, step);
if (safety.kind === 'unsafe') {
return {
kind: 'error',
reason: `Cannot apply action 'retry' to step '${step.id}': ${safety.reason} Use 'retry-clean' where offered, or 'skip'.`,
};
}
if (safety.kind === 'warned') {
(0, agent_output_1.warnToAgent)({
title: `Retrying ${step.migrationId} without verification`,
bodyLines: [safety.warning],
});
}
}
const applied = (0, state_machine_1.applyStepEvent)(state, {
type: 'stepAction',
stepId: step.id,
action,
attempt: step.attempt,
});
if (applied.kind === 'error') {
return applied;
}
return { kind: 'ok', state: applied.state, targetStep: step };
}
// Commits the working tree left by a folded prompt outcome or an adopted
// death, returning the ledger entry the caller persists together with the
// step transition (null when there was nothing to commit). The worker's
// recorded-commit path classifies through the same commitResultToLedgerEntry.
//
// Remaining narrow window: a crash after the git commit but before the state
// write refolds on the next reconcile, where the commit attempt sees a clean
// tree ('no-changes') and the ledger simply misses that landed entry; the
// changes themselves are never lost. A lost landed entry can also strand the
// failed entries it had absorbed, which is why completion double-checks the
// tree before warning about debt.
async function commitForStep(root, dir, state, step) {
const { name } = (0, state_machine_1.splitMigrationId)(step.migrationId);
const absorbedStepIds = (0, state_machine_1.uncoveredFailedStepIds)(state).filter((id) => id !== step.id);
let result;
try {
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));
}
catch (e) {
// The dependency install is the only thing that throws here: the commit
// attempt itself reports through result.status, and the install's own
// bookkeeping never throws. Both consequences are recorded, and neither
// aborts reconcile so the next dispense still fires. The debt cannot stand
// in for the install failure: a later step's commit absorbs this diff and
// lands an entry naming this step, which clears the debt while the
// dependencies are still missing.
(0, util_1.warnCommitFailed)(name, e);
return {
entry: { kind: 'failed', stepIds: [step.id] },
installFailed: true,
};
}
if (result.status === 'failed') {
(0, util_1.warnCommitFailed)(name);
}
return {
entry: (0, state_machine_1.commitResultToLedgerEntry)(result, step.id, absorbedStepIds),
installFailed: false,
};
}
// --- dispense ---------------------------------------------------------------
function advanceAndDispense(root, dir, runId, state) {
const step = firstActionableStep(state);
if (!step) {
completeRun(root, dir, runId, state);
return;
}
switch (step.status) {
case 'pending':
dispenseNextStep(root, dir, runId, state, step);
break;
case 'dispensed':
// Re-entry before the worker advanced the step; re-emit its command.
emitNextStep(root, runId, step);
break;
case 'failed':
emitRetryFailed(root, runId, state, step);
break;
case 'died':
emitDied(root, runId, state, step);
break;
case 'running':
emitStillRunning(root, runId, step);
break;
case 'awaiting-prompt-outcome':
emitAwaitPrompt(root, dir, runId, step);
break;
case 'succeeded':
case 'skipped':
// firstActionableStep already excludes these via TERMINAL_STATUSES;
// landing here means an already-terminal step slipped through
// unclassified rather than being left to stall the run silently.
throw new Error(`Orchestrator could not dispense step '${step.id}': step is already ${step.status}.`);
default: {
// A new MigrateStepStatus member with no case above fails typecheck
// here until it is classified.
const exhaustive = step.status;
throw new Error(`Orchestrator could not dispense step '${step.id}': unrecognized status '${exhaustive}'.`);
}
}
}
function firstActionableStep(state) {
return state.steps.find((s) => !TERMINAL_STATUSES.has(s.status));
}
function dispenseNextStep(root, dir, runId, state, step) {
// Read the pre-migration baselines (git and package.json reads) before the
// lock; the dispense transition and the baselines then apply to the fresh
// state in one write.
const baselines = {
gitRefBefore: (0, git_utils_1.getLatestCommitSha)(root) ?? undefined,
treeCleanAtDispense: (0, git_utils_1.getWorkingTreeStatus)(root) === 'clean',
depsHashAtDispense: (0, util_1.depsHash)(root),
};
let advancedElsewhere = false;
const current = (0, state_lock_1.updateRunState)(dir, (fresh) => {
// A concurrent init or reconcile may have dispensed (or further advanced)
// this step since the caller's read; reclassify against the fresh state
// below instead of failing the duplicate transition.
if (fresh.steps.find((s) => s.id === step.id)?.status !== 'pending') {
advancedElsewhere = true;
return null;
}
const dispensed = applyEventOrThrow(fresh, {
type: 'dispense',
stepId: step.id,
});
return setDispenseBaselines(dispensed, step.id, baselines);
});
if (advancedElsewhere) {
// Terminates: step statuses only advance, so each re-entry observes
// strictly later state and lands in a non-pending branch of the dispatch.
advanceAndDispense(root, dir, runId, current);
return;
}
emitNextStep(root, runId, current.steps.find((s) => s.id === step.id));
}
function emitNextStep(root, runId, step) {
const migrationId = step.migrationId;
emit(runId, step, 'next-step', {
command: workerCommand(root, migrationId, runId),
next: reconcileCommand(root, runId),
instructionLines: [
`Apply migration ${migrationId} by running the command below, then run the "next" command to record the outcome and get the next step.`,
],
});
}
function emitRetryFailed(root, runId, state, step) {
const migrationId = step.migrationId;
// A worker failure records its summary on the outcome; a prompt the agent
// reported as failed carries the agent's own reason on the prompt outcome.
const summary = step.outcome?.summary ?? step.promptOutcome?.summary;
const head = (0, git_utils_1.getLatestCommitSha)(root);
const tree = dirtyTreeSummary(root);
const cleanRetry = canOfferCleanRetry(root, state, step, head);
// A failure recorded before the generator marker can still have written to
// the tree (a direct fs or exec side effect, or a crash mid-flush); a
// marker means only the install and commit are left, so plain retry is
// safe outright. So is retrying a step with no generator half to rerun.
const pending = generatorPending(step);
const retrySafety = pending
? assessPreMarkerRetry(root, step)
: { kind: 'safe' };
const lines = [
`Migration ${migrationId} failed${summary ? `: ${summary}` : ''}.`,
` started from: ${step.gitRefBefore ?? '(unknown)'}`,
` current HEAD: ${head ?? '(unknown)'}`,
` working tree: ${tree === null ? '(unknown)' : tree ? `\n${tree}` : '(clean)'}`,
``,
`Decide how to proceed and re-run reconcile with one of:`,
retryOptionLine(retrySafety, reconcileCommand(root, runId, 'retry')),
];
if (cleanRetry) {
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')}`);
}
lines.push(` skip: ${reconcileCommand(root, runId, 'skip')}`);
if (pending) {
lines.push(UNVERIFIABLE_WRITES_LINE);
}
// A step whose generator may still run gets no `next`, whichever retry the
// checks above would accept: git can vouch for the tracked tree only, and
// an agent that follows `next` blindly must not rerun a generator over
// writes nothing here could see. Choosing a retry has to be explicit.
emit(runId, step, 'retry-failed', {
...(pending ? {} : { next: reconcileCommand(root, runId, 'retry') }),
instructionLines: lines,
});
}
// Whether the step's generator half may still have to run: it exists and no
// attempt has recorded running it. Only then can a retry apply a generator
// twice, so only then is a continuation withheld from `next`. A step with no
// generator (prompt-only) is retried by re-prompting the agent over the tree
// it already knows, which is the designed recovery; a step recorded before the
// kind was persisted counts as having one.
function generatorPending(step) {
return step.generatorCompleted !== true && step.hasGenerator !== false;
}
// Appended to the failed and died dispenses of a step whose generator may rerun.
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.`;
function retryOptionLine(safety, command) {
switch (safety.kind) {
case 'safe':
return ` retry: re-run over the current tree: ${command}`;
case 'warned':
return ` retry: re-run over the current tree; without git nothing can verify what the failed attempt left, so inspect the tree first: ${command}`;
case 'unsafe':
return ` retry: re-run over the current tree; refused until the working tree is clean and HEAD is at the started-from ref: ${command}`;
default: {
const exhaustive = safety;
return exhaustive;
}
}
}
// A clean retry resets the tree to the step's captured pre-migration ref.
// That is only safe when every prior diff is already committed: without
// per-migration commits the ref is the run's starting commit (the reset would
// wipe all prior steps' uncommitted work); a failed init checkpoint or a
// pending step commit means the ref predates diffs the reset would also
// destroy; without a captured ref there is nothing to reset to; edits already
// in the tree when this step was dispensed (the user's own, or an earlier
// step's the checkpoint never saw) are not represented by the ref either; and
// HEAD anywhere other than the ref means something was committed since the
// step was dispensed that the reset would discard, whether that is this step's
// own commit (recorded, or made in the window before the worker died writing
// its ledger entry) or one the user made alongside the run.
// Cleanliness and position both have to say so explicitly: a failed tree probe
// records dirty, a run created before that field existed carries nothing to
// check, and an unreadable HEAD is no ref at all, so none of the three can be
// read as a restore point that exists.
function canOfferCleanRetry(root, state, step, head) {
return (state.createCommits &&
!state.checkpointFailed &&
!(0, state_machine_1.hasPendingCommitDebt)(state) &&
!!step.gitRefBefore &&
head === step.gitRefBefore &&
step.treeCleanAtDispense === true &&
!endangeredLandedEntry(root, state, step));
}
// The last landed ledger entry covering the step whose commit a reset to the
// step's gitRefBefore would discard. Entries from earlier attempts predate the
// ref re-captured at re-dispense and survive the reset; only a commit that is
// not an ancestor of the ref (or cannot be verified as one) is endangered.
function endangeredLandedEntry(root, state, step) {
let endangered = null;
for (const entry of (0, state_machine_1.coveringLandedEntries)(state, step.id)) {
if (!entry.sha ||
!step.gitRefBefore ||
!(0, git_utils_1.isAncestorCommit)(entry.sha, step.gitRefBefore, root)) {
endangered = entry;
}
}
return endangered;
}
// Explains why retry-clean is withheld for a failed or died step; feeds the
// death dispense and a rejected --step-action=retry-clean.
function cleanRetryUnavailableReason(root, state, step, head) {
const endangered = endangeredLandedEntry(root, state, step);
if (endangered) {
return endangered.sha
? `this migration's changes already landed in commit ${endangered.sha}, which a reset would discard.`
: `this migration's changes already landed in a commit, which a reset would discard.`;
}
if (step.gitRefBefore && head !== step.gitRefBefore) {
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.`;
}
return `resetting the tree could discard uncommitted work that no restore point accounts for.`;
}
function assessPreMarkerRetry(root, step) {
const repo = (0, git_utils_1.getGitRepositoryStatus)(root);
if (repo === 'not-git') {
return {
kind: 'warned',
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.`,
};
}
if (repo === 'unknown') {
return {
kind: 'unsafe',
reason: `the git repository state could not be determined, so nothing can verify whether the failed attempt left changes in the tree.`,
};
}
const head = (0, git_utils_1.getLatestCommitSha)(root);
if (!step.gitRefBefore || head !== step.gitRefBefore) {
return {
kind: 'unsafe',
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.`,
};
}
if ((0, git_utils_1.getWorkingTreeStatus)(root) !== 'clean') {
return {
kind: 'unsafe',
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.`,
};
}
return { kind: 'safe' };
}
function emitDied(root, runId, state, step) {
const migrationId = step.migrationId;
const ref = step.gitRefBefore;
const head = (0, git_utils_1.getLatestCommitSha)(root);
const tree = dirtyTreeSummary(root);
const cleanRetry = canOfferCleanRetry(root, state, step, head);
// The generator half is recorded (or the step never had one), so a retry
// that keeps the tree as it stands has the rest of the step left to run: a
// prompt, or the install and commit its worker never reached.
const resume = !generatorPending(step);
const lines = [
`The worker for ${migrationId} died; its process is gone.`,
` started from: ${ref ?? '(unknown)'}`,
` current HEAD: ${head ?? '(unknown)'}`,
` working tree: ${tree === null ? '(unknown)' : tree ? `\n${tree}` : '(clean)'}`,
``,
];
const options = [];
if (resume) {
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')}`);
}
if (cleanRetry) {
options.push(
// Two commands rather than one `&&` chain: the agent runs these in its
// own shell, and not every shell joins statements that way.
` 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')}`);
}
else {
lines.push(`A clean retry is unavailable: ${cleanRetryUnavailableReason(root, state, step, head)}`);
}
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')}`);
lines.push(`Choose exactly one:`);
lines.push(...options);
if (!resume) {
lines.push(UNVERIFIABLE_WRITES_LINE);
}
// `retry` is preselected wherever it is legal: it is the only resolution
// that neither discards work nor records a result the run never produced.
// While the generator may still run there is no `next` at all: a reset
// cannot be verified against writes git does not see, and adopting records
// a result nothing checked, so an agent that follows `next` blindly must
// land on neither.
emit(runId, step, 'died', {
...(resume ? { next: reconcileCommand(root, runId, 'retry') } : {}),
instructionLines: lines,
});
}
function emitStillRunning(root, runId, step) {
const migrationId = step.migrationId;
const ageMs = step.startedAt ? Date.now() - Date.parse(step.startedAt) : 0;
const lines = [
`The worker for ${migrationId} (pid ${step.pid}) is still running. Wait for it to finish, then run the "next" command.`,
];
if (ageMs >= HANG_THRESHOLD_MS) {
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.`);
}
emit(runId, step, 'still-running', {
next: reconcileCommand(root, runId),
instructionLines: lines,
});
}
function emitAwaitPrompt(root, dir, runId, step) {
const migrationId = step.migrationId;
const { package: pkg, name } = (0, state_machine_1.splitMigrationId)(migrationId);
const filePath = handoffPath(dir, { package: pkg, name });
// The package id becomes real path segments, so handing over the path
// without its directory is what would force the agent to `mkdir -p`. Same
// reason the classic runner pre-creates it in run-step.ts.
(0, fs_1.mkdirSync)((0, path_1.dirname)(filePath), { recursive: true });
const lines = [
`Migration ${migrationId} is a prompt-based migration awaiting your outcome.`,
`Apply the prompt (see the worker's earlier <nx_migrate_prompt> block), then write the handoff file and run the "next" command.`,
`Handoff file: ${filePath}`,
`Handoff JSON: { "status": "success" | "failed", "summary": "<what you did>" }. To mark the prompt not applicable, use "status": "success" with "outcome": "skipped".`,
];
// A handoff that exists but can't be read/parsed/validated is a rejection,
// not a still-awaited outcome. Naming why stops the run from re-emitting the
// same await forever while the agent leaves the bad file in place.
const rejection = describeRejectedHandoff(filePath);
if (rejection.length > 0) {
lines.push('', ...rejection);
}
emit(runId, step, 'await-prompt', {
next: reconcileCommand(root, runId),
instructionLines: lines,
});
}
// Empty unless a handoff file is present but unusable; wording mirrors the
// classic runner's ambiguous-outcome cause lines.
function describeRejectedHandoff(handoffPath) {
const result = (0, handoff_1.readHandoffWithReason)(handoffPath);
if (result.ok)
return [];
const { reason, detail } = result;
if (reason === 'missing')
return [];
const followUp = 'Rewrite the handoff file, then run the "next" command.';
switch (reason) {
case 'read-error':
return [
`The handoff file was rejected: it could not be read${detail ? ` (${detail})` : ''}.`,
followUp,
];
case 'parse-error':
return [
`The handoff file was rejected: it contained invalid JSON${detail ? ` (${detail})` : ''}.`,
followUp,
];
case 'shape-mismatch':
return [
'The handoff file was rejected: it was missing required fields or had an unexpected shape.',
followUp,
];
default: {
const exhaustive = reason;
throw new Error(`Unrecognized handoff rejection reason '${exhaustive}'.`);
}
}
}
// A refused --step-action still exits 0: the tagged error block is the answer
// to the request, and it carries the reconcile command to run next. A non-zero
// exit would tell the driving agent that reconcile itself crashed, and it
// would stop reading for the correction it is being handed.
function emitError(root, runId, reason) {
(0, agent_output_1.warnToAgent)({
title: 'The requested --step-action could not be applied.',
bodyLines: [reason],
});
(0, agent_output_1.emitStepBlock)(runId, '-', 'error', {
next: reconcileCommand(root, runId),
instructions: reason,
});
(0, migrate_analytics_1.reportMigrateOrchestratorDispense)({ action: 'error', attempt: 0 });
}
function completeRun(root, dir, runId, state) {
let current = state;
const completed = current.steps.filter((s) => s.status === 'succeeded').length;
const skipped = current.steps.filter((s) => s.status === 'skipped').length;
const dispenseCount = current.steps.reduce((n, s) => n + s.dispenseCount, 0);
// The crash-refold window can strand a failed ledger entry whose diff was in
// fact absorbed; suppress the warning only on a verified-clean tree. A dirty
// tree can still be unrelated edits, so the warning only claims the changes
// "may remain".
const commitDebt = (0, state_machine_1.hasPendingCommitDebt)(current) && (0, git_utils_1.getWorkingTreeStatus)(root) !== 'clean';
// Persist the terminal status and claim the watermark in one fresh-state
// write before emitting: a crash between the write and the output can't
// double-count the completion, and of two concurrent reconciles exactly one
// claims the report.
let shouldEmit = false;
if (current.status !== 'completed' || !current.analytics.completeEmitted) {
current = (0, state_lock_1.updateRunState)(dir, (fresh) => {
if (fresh.status === 'completed' && fresh.analytics.completeEmitted) {
return null;
}
shouldEmit = !fresh.analytics.completeEmitted;
return {
...fresh,
status: 'completed',
analytics: { ...fresh.analytics, completeEmitted: true },
};
});
}
if (shouldEmit) {
(0, migrate_analytics_1.reportMigrateOrchestratorComplete)({
completed,
skipped,
dispenseCount,
});
}
const debtLine = 'Some migration changes could not be committed and may remain in the working tree; review and commit them manually.';
if (commitDebt) {
(0, agent_output_1.warnToAgent)({ title: debtLine });
}
const uninstalled = current.steps.filter((s) => s.installFailed);
const installLine = uninstalled.length > 0
? `The dependency changes made by ${uninstalled
.map((s) => s.migrationId)
.join(', ')} were not installed; run \`${(0, util_1.pmInstallCommand)(root)}\` before using the workspace.`
: null;
if (installLine) {
(0, agent_output_1.warnToAgent)({ title: installLine });
}
const instructionLines = [
`Migrate run ${runId} is complete.`,
` applied: ${completed}`,
` skipped: ${skipped}`,
...(commitDebt ? [debtLine] : []),
...(installLine ? [installLine] : []),
];
(0, agent_output_1.logToAgent)({ title: 'nx migrate: complete', bodyLines: instructionLines });
(0, agent_output_1.emitStepBlock)(runId, '-', 'complete', {
instructions: instructionLines.join('\n'),
});
}
function emit(runId, step, action, payload) {
const { instructionLines, ...rest } = payload;
// One sanitized array feeds both, so the block payload says exactly what the
// human echo said.
const lines = instructionLines ? (0, agent_output_1.safeLines)(instructionLines) : undefined;
(0, agent_output_1.logToAgent)({ title: `nx migrate: ${action}`, bodyLines: lines });
(0, agent_output_1.emitStepBlock)(runId, step.id, action, {
...rest,
...(lines ? { instructions: lines.join('\n') } : {}),
});
(0, migrate_analytics_1.reportMigrateOrchestratorDispense)({ action, attempt: step.attempt });
}
// Raw argv is forwarded verbatim across the wrapper hops, so every flag is a
// single `--flag=value` token. Interpolated values are validated shell-safe at
// init (migration ids; resumed run ids are gated by the run-dir scan) and
// reconcile entry (run id).
function workerCommand(root, migrationId, runId) {
return `${(0, util_1.pmExecPrefix)(root)} nx migrate --run-migration=${migrationId} --run-id=${runId}`;
}
function reconcileCommand(root, runId, action) {
const base = `${(0, util_1.pmExecPrefix)(root)} nx migrate --run-id=${runId}`;
return action ? `${base} --step-action=${action}` : base;
}
// --- helpers ----------------------------------------------------------------
function appendCommit(state, entry) {
return { ...state, commits: [...state.commits, entry] };
}
// Records what the workspace looked like as this attempt starts. The git ref
// and the tree state are re-captured per dispense, since a retry restarts from
// wherever the tree is now. The dependency baseline is not: it tracks the last
// dependencies that were actually installed, moving only when an install
// lands, so a retry that only has the commit left to do still sees the
// previous attempt's package.json edits as needing one.
function setDispenseBaselines(state, stepId, baselines) {
const depsBaseline = state.steps.find((s) => s.id === stepId)?.depsHashAtDispense;
return {
...state,
steps: state.steps.map((s) => s.id === stepId
? {
...s,
gitRefBefore: baselines.gitRefBefore,
treeCleanAtDispense: baselines.treeCleanAtDispense,
depsHashAtDispense: depsBaseline ?? baselines.depsHashAtDispense ?? undefined,
}
: s),
};
}
// Applies a step event to fresh state or throws the orchestrator's advance
// error. Pure; callers persist the result via updateRunState.
function applyEventOrThrow(state, event) {
const result = (0, state_machine_1.applyStepEvent)(state, event);
if (result.kind === 'error') {
throw new Error(`Orchestrator could not advance the run: ${result.reason}`);
}
return result.state;
}
function handoffPath(dir, migration) {
return (0, handoff_1.stepHandoffPath)(dir, migration);
}
function removeHandoff(dir, migrationId) {
(0, fs_1.rmSync)(handoffPath(dir, (0, state_machine_1.splitMigrationId)(migrationId)), { force: true });
}
// null means the probe itself failed; the death dispense renders that as
// '(unknown)', because '(clean)' would invite a retry-clean reset over
// evidence that was never gathered (getWorkingTreeStatus's contract).
function dirtyTreeSummary(root) {
try {
return (0, child_process_1.execSync)('git status --porcelain', {
encoding: 'utf8',
cwd: root,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
}).trim();
}
catch {
return null;
}
}