/*
RFC-0479: mission.migrate — apply pending migrators from the RFC-id-keyed
registry to the mission workpiece. Reads migratorCursor from the workpiece pin file,
filters the registry for unapplied migrators, applies them in RFC-id order, updates
the cursor, and writes a migration report to evidence/.
- Does not define mission lifecycle — that is RFC-0355.
- Does not materialize the workpiece — that is mission.materialize.
- RFC-0479: initial mission.migrate command handler.
- RFC-0580: auto-commit werkstatt side-effects (mission.yaml) after writeMissionManifest.
- RFC-1149: migrate ambient filesystem and process IO to the WorkspaceIO port across engine and shared
- RFC-1249: wire werkstatt.commands.validate into packages.check + retire CMD-OUTPUT debt
- RFC-1254: drop unused io-migration leftovers
*/
import path from "node:path";
import type {
KernelCommandInput,
KernelCommandResult,
KernelRuntimeContext,
} from "@warpgogol/werkstatt-engine/kernel";
import { readMissionManifest, writeMissionManifest, resolveMissionDir } from "./mission-io.ts";
import { acquireLock, releaseLock, commitWerkstattSideEffects } from "../werkstatt/index.ts";
import { atomicWriteFile } from "../werkstatt/atomic.ts";
import { appendAndCommitBordbuch } from "../bordbuch/bordbuch-commit-helper.ts";
import { migratorsToApply } from "../migrators/registry.ts";
import type { SternsystemData, MigrationContext, MigrationViolation } from "../migrators/types.ts";
import { MigrationError } from "../migrators/types.ts";
export interface MissionMigrateData {
missionId: string;
systemId: string;
appliedMigrators: string[];
skippedMigrators: string[];
blockedMigrator: string | null;
blockReason: string | null;
migratedAt: string;
}
export interface MissionMigrateResult {
command: "mission.migrate";
status: "pass" | "blocked" | "fail";
data?: MissionMigrateData;
violations: MigrationViolation[];
}
function flagString(input: KernelCommandInput, key: string): string | undefined {
const v = input.flags[key];
return typeof v === "string" ? v : undefined;
}
function flagBool(input: KernelCommandInput, key: string): boolean {
const v = input.flags[key];
return v === true || v === "true";
}
const STERNSYSTEM_DATA_PATHS = ["src/content", "public", "provenance"];
export async function runMissionMigrate(
input: KernelCommandInput,
context: KernelRuntimeContext,
): Promise> {
const { workspaceRoot, logger } = context;
const missionId = flagString(input, "mission");
const reportOnly = flagBool(input, "report-only");
if (!missionId) throw new Error("[mission.migrate] --mission is required");
const manifest = await readMissionManifest(workspaceRoot, missionId);
if (manifest.state !== "open") {
throw new Error(
`[mission.migrate] mission '${missionId}' is not open (state: ${manifest.state})`,
);
}
const operationId = manifest.operationId;
await acquireLock(
workspaceRoot,
`system:${manifest.systemId}`,
operationId,
"mission.migrate",
"agent",
);
await acquireLock(workspaceRoot, `mission:${missionId}`, operationId, "mission.migrate", "agent");
try {
const missionDir = await resolveMissionDir(workspaceRoot, missionId, context.io);
const workpieceDir = path.join(missionDir, "workpiece");
if (!(await context.io.exists(workpieceDir))) {
throw new Error("[mission.migrate] workpiece not found — run mission.materialize first");
}
const pinPath = path.join(workpieceDir, "system.pin.json");
if (!(await context.io.exists(pinPath))) {
throw new Error("[mission.migrate] system.pin.json not found in workpiece");
}
const pinRaw = await context.io.readFile(pinPath);
const pin = JSON.parse(pinRaw) as Record;
const cursorRaw = pin.migratorCursor;
const cursor: string[] = Array.isArray(cursorRaw) ? cursorRaw : [];
const toApply = migratorsToApply(cursor);
const skippedMigrators = cursor.filter((id) => !toApply.some((m) => m.id === id));
logger.section("[mission.migrate]");
logger.info(` Mission: ${missionId}`);
logger.info(` System: ${manifest.systemId}`);
logger.info(` Cursor: [${cursor.join(", ")}]`);
logger.info(` Pending: ${toApply.length} migrator(s)`);
if (reportOnly) {
return {
data: {
missionId,
systemId: manifest.systemId,
appliedMigrators: [],
skippedMigrators,
blockedMigrator: null,
blockReason: null,
migratedAt: new Date().toISOString(),
},
exitCode: 0,
summary: `[mission.migrate] ${missionId} report-only: ${toApply.length} pending`,
nextSteps: [
{
action: `Apply migrations: pnpm exec werkstatt run mission.migrate --mission ${missionId}`,
kind: "optional",
},
],
};
}
const dataPaths = STERNSYSTEM_DATA_PATHS.map((p) => path.join(workpieceDir, p));
const sternsystemData: SternsystemData = {
rootPath: workpieceDir,
dataPaths,
};
const appliedMigrators: string[] = [];
let blockedMigrator: string | null = null;
let blockReason: string | null = null;
const violations: MigrationViolation[] = [];
for (const migrator of toApply) {
const ctx: MigrationContext = {
systemId: manifest.systemId,
missionId,
logger: { info: (msg: string) => logger.info(msg) },
};
try {
await migrator.transform(sternsystemData, ctx);
appliedMigrators.push(migrator.id);
logger.info(` Applied: ${migrator.id}`);
} catch (err) {
if (err instanceof MigrationError) {
blockedMigrator = migrator.id;
blockReason = err.message;
violations.push({
migratorId: migrator.id,
filePath: err.filePath,
fieldPath: err.fieldPath,
reason: err.reason,
});
break;
}
blockedMigrator = migrator.id;
blockReason = err instanceof Error ? err.message : String(err);
violations.push({
migratorId: migrator.id,
filePath: "",
fieldPath: "",
reason: blockReason,
});
break;
}
}
const now = new Date().toISOString();
const status: "pass" | "blocked" | "fail" = blockedMigrator !== null ? "blocked" : "pass";
if (appliedMigrators.length > 0) {
const updatedPinRaw = await context.io.readFile(pinPath);
const updatedPin = JSON.parse(updatedPinRaw) as Record;
const currentCursor: string[] = Array.isArray(updatedPin.migratorCursor)
? updatedPin.migratorCursor
: [];
const newCursor = [...new Set([...currentCursor, ...appliedMigrators])];
updatedPin.migratorCursor = newCursor;
await context.io.writeFile(pinPath, JSON.stringify(updatedPin, null, 2) + "\n");
}
const evidenceDir = path.join(missionDir, "evidence");
await context.io.mkdir(evidenceDir);
const report = {
schemaVersion: "1.0.0",
missionId,
systemId: manifest.systemId,
appliedMigrators,
skippedMigrators,
blockedMigrator,
blockReason,
cursorBefore: cursor,
cursorAfter: Array.isArray(pin.migratorCursor) ? pin.migratorCursor : [],
migratedAt: now,
};
await atomicWriteFile(
path.join(evidenceDir, "migration-report.json"),
JSON.stringify(report, null, 2) + "\n",
);
manifest.migratedAt = now;
await writeMissionManifest(workspaceRoot, manifest);
// RFC-0580: auto-commit werkstatt side-effects
await commitWerkstattSideEffects(
workspaceRoot,
[path.join("missions", missionId, "mission.yaml")],
`werkstatt: mission.migrate ${missionId}`,
);
await appendAndCommitBordbuch(
workspaceRoot,
manifest.systemId,
"mission-migrate",
status === "pass"
? `Migration complete: ${appliedMigrators.length} migrator(s) applied`
: `Migration blocked at ${blockedMigrator}: ${blockReason}`,
"agent",
{
missionId,
status: status === "pass" ? "done" : "failed",
metadata: { appliedMigrators, skippedMigrators, blockedMigrator },
},
`Bordbuch: mission-migrate ${missionId}`,
);
if (status === "blocked") {
return {
data: {
missionId,
systemId: manifest.systemId,
appliedMigrators,
skippedMigrators,
blockedMigrator,
blockReason,
migratedAt: now,
},
exitCode: 1,
summary: `[mission.migrate] ${missionId} blocked at ${blockedMigrator}`,
nextSteps: [
{
action: `Fix the blocking migrator '${blockedMigrator}', then re-run: pnpm exec werkstatt run mission.migrate --mission ${missionId}`,
kind: "required",
},
],
};
}
logger.success(
`[mission.migrate] ${missionId} — ${appliedMigrators.length} migrator(s) applied`,
);
return {
data: {
missionId,
systemId: manifest.systemId,
appliedMigrators,
skippedMigrators,
blockedMigrator: null,
blockReason: null,
migratedAt: now,
},
exitCode: 0,
summary: `[mission.migrate] ${missionId} — ${appliedMigrators.length} migrator(s) applied`,
nextSteps: [
{
action: `Validate the mission: pnpm exec werkstatt run mission.validate --mission ${missionId}`,
kind: "optional",
},
],
};
} finally {
await releaseLock(workspaceRoot, `mission:${missionId}`);
await releaseLock(workspaceRoot, `system:${manifest.systemId}`);
}
}