/*
leitstand.ship — resumable full-pipeline deployment composite command for the leitstand surface (RFC-0962).
- Do not re-implement underlying command logic — delegate via executeKernelCommand.
- Do not bypass certification authority — certify/deploy steps call the existing commands.
- Do not add --skip-step flags; --until is the only scope control (DNA-73).
- RFC-1249: wire werkstatt.commands.validate into packages.check + retire CMD-OUTPUT debt
- RFC-1297: preflight step 2b — readLiveChannelStates + liveDriftDiagnostics; LIVE-01 aborts the ship with a resolve-first hint, LIVE-02 logs only.
- RFC-1327: run-twice idempotency — declarations, command.idempotency.validate, rerun tests, reconcile/ship/abort convergence fixes
- RFC-1326: single Cloudflare permission registry, token preflight, birth provisioning
- RFC-1326: review-fix pass — siteName targeting for app-scoped provisioning, allow-list completion
RFC-0962, RFC-0986, RFC-1097, RFC-1149, RFC-1168, RFC-1190, RFC-1207, RFC-1217, RFC-1219, warpgogol-m000175
*/
import path from "node:path";
import { parse as parseYaml } from "yaml";
import type {
KernelCommandInput,
KernelCommandResult,
KernelRuntimeContext,
} from "@warpgogol/werkstatt-engine/kernel";
import { executeKernelCommand, KERNEL_UNIVERSAL_FLAGS } from "@warpgogol/werkstatt-engine/kernel";
import { getOrBuildWorkspaceRegistry } from "../kernel/runtime/registry-cache.ts";
import { runOperation, findIncompleteOperation } from "../journal/index.ts";
import type { OperationDefinition, OperationStep, StepResult } from "../journal/index.ts";
import { resolveCacheClonePath } from "../sternsystem/registry-io.ts";
import { readLiveChannelStates, liveDriftDiagnostics } from "../sternsystem/live-drift.ts";
import { resolveMissionDir } from "../kernel/mission-resolver.ts";
import { flagSite } from "./deploy-helpers.ts";
import { provisionExternalResources } from "../sternsystem/provision-external-resources.ts";
function flagString(input: KernelCommandInput, key: string): string | undefined {
const v = input.flags[key];
return typeof v === "string" ? v : undefined;
}
// ─── RFC-1168 progress formatting (pure, exported for tests) ────────────────
/** Preamble lines naming the journal path and the watch invocation. */
export function shipPreambleLines(
journalPath: string,
systemId: string,
missionId: string,
): string[] {
return [
`[ship] journal: ${journalPath}`,
`[ship] watch: werkstatt run leitstand.ship.status --site ${systemId} --mission ${missionId} --follow`,
];
}
/** One `[ship] step N/M: — ` line per journal transition. */
export function formatShipTransition(
record: {
kind: string;
step?: string;
seq?: number;
error?: string;
reason?: string;
failedStep?: string;
},
stepCount: number,
durationMs?: number,
): string | null {
if (record.seq === undefined) {
if (record.kind === "op-done") return "[ship] done";
if (record.kind === "op-abandoned") return `[ship] abandoned — ${record.reason}`;
if (record.kind === "op-interrupted") {
return `[ship] interrupted at step "${record.failedStep}" — run with --resume`;
}
return null;
}
const n = record.seq + 1;
if (record.kind === "step-started") {
return `[ship] step ${n}/${stepCount}: ${record.step} — running`;
}
if (record.kind === "step-done") {
const dur = durationMs !== undefined ? ` (${Math.round(durationMs / 1000)}s)` : "";
return `[ship] step ${n}/${stepCount}: ${record.step} — done${dur}`;
}
if (record.kind === "step-failed") {
const dur = durationMs !== undefined ? ` (${Math.round(durationMs / 1000)}s)` : "";
return `[ship] step ${n}/${stepCount}: ${record.step} — failed — ${record.error}${dur}`;
}
if (record.kind === "step-skipped") {
return `[ship] step ${n}/${stepCount}: ${record.step} — skipped`;
}
return null;
}
// ─── Types ───────────────────────────────────────────────────────────────────
export type ShipPhase = "validated" | "closed" | "dev" | "alt" | "main" | "archived";
const VALID_PHASES: readonly ShipPhase[] = [
"validated",
"closed",
"dev",
"alt",
"main",
"archived",
];
export interface ShipContext {
workspaceRoot: string;
systemId: string;
missionId: string;
/** Resolved mission directory — archive-aware (`missions/` or `missions/archive//`). */
missionDir: string;
workpieceDir: string;
cacheCloneDir: string;
releaseId: string | null;
/** True when mission.yaml declares `state: closed` — lifecycle steps are skipped. */
missionClosed: boolean;
/** RFC-1217: --allow-unreconciled downgrades the unreconciled-workpiece gate to a warning. */
allowUnreconciled: boolean;
logger: KernelRuntimeContext["logger"];
io: KernelRuntimeContext["io"];
}
export interface ShipResult {
siteId: string;
missionId: string;
releaseId: string | null;
reachedPhase: ShipPhase | "preflight";
steps: StepResult[];
completed: boolean;
failedStep?: string;
}
// ─── Phase mapping ───────────────────────────────────────────────────────────
const PHASE_STEP_COUNT: Record = {
validated: 2,
closed: 4,
dev: 10,
alt: 13,
main: 16,
archived: 18,
};
// ─── Phase runner (delegates to executeKernelCommand with flag validation) ───
async function runShipPhase(
ctx: ShipContext,
commandName: string,
argv: string[],
opts?: { siteName?: string },
): Promise<{ exitCode: number; data?: Record; summary?: string }> {
const registry = await getOrBuildWorkspaceRegistry(ctx.workspaceRoot);
const command = registry?.commands.get(commandName);
if (command) {
const validFlags = new Set([
...Object.keys(KERNEL_UNIVERSAL_FLAGS),
...Object.keys(command.flags ?? {}),
]);
for (const entry of argv) {
if (!entry.startsWith("--")) continue;
const flagName = entry.slice(2).split("=")[0];
if (flagName && !validFlags.has(flagName)) {
const msg = `Unknown flag "--${flagName}" for command "${commandName}" (called from leitstand.ship). Valid flags: ${[...validFlags].sort().join(", ")}`;
ctx.logger.info(` [ship] ${msg}`);
const failed: { exitCode: number; data?: Record; summary?: string } = {
exitCode: 1,
summary: msg,
};
return failed;
}
}
}
ctx.logger.info(` [ship] ${commandName} ${argv.join(" ")}`);
const result = (await executeKernelCommand({
workspaceRoot: ctx.workspaceRoot,
commandName,
argv,
outputFormat: "pretty",
...(opts?.siteName ? { siteName: opts.siteName, siteExplicit: true } : {}),
})) as { exitCode?: number; data?: Record; summary?: string };
return {
exitCode: result.exitCode ?? 0,
data: result.data,
summary: result.summary,
};
}
// ─── Step implementations ────────────────────────────────────────────────────
/**
* RFC-1217: count workpiece commits the cache clone has not merged — cache-side,
* the exact range fetch-and-merge will pull. `ok: false` means git was
* unreadable (caller warn-skips rather than blocking on "cannot determine").
*/
async function countUnreconciledWorkpieceCommits(
ctx: ShipContext,
): Promise<{ ok: true; count: number; oneline: string } | { ok: false; reason: string }> {
const io = ctx.io;
const gitAt = (dir: string, args: string[]) => io.exec("git", args, { cwd: dir });
const branchRes = await gitAt(ctx.workpieceDir, ["rev-parse", "--abbrev-ref", "HEAD"]);
const branch = branchRes.exitCode === 0 ? branchRes.stdout.trim() : "";
if (!branch || branch === "HEAD") {
return { ok: false, reason: "workpiece branch unreadable (detached HEAD or not a git repo)" };
}
const fetchRes = await gitAt(ctx.cacheCloneDir, ["fetch", ctx.workpieceDir, branch]);
if (fetchRes.exitCode !== 0) {
return { ok: false, reason: `git fetch from workpiece failed: ${fetchRes.stderr.trim()}` };
}
const countRes = await gitAt(ctx.cacheCloneDir, ["rev-list", "HEAD..FETCH_HEAD", "--count"]);
if (countRes.exitCode !== 0 || countRes.stdout.trim() === "") {
return { ok: false, reason: `rev-list failed: ${countRes.stderr.trim() || "empty output"}` };
}
const listRes = await gitAt(ctx.cacheCloneDir, ["rev-list", "HEAD..FETCH_HEAD", "--oneline"]);
return {
ok: true,
count: parseInt(countRes.stdout.trim(), 10),
oneline: listRes.exitCode === 0 ? listRes.stdout.trim() : "",
};
}
function buildPreflightStep(): OperationStep {
return {
name: "preflight",
run: async (ctx: ShipContext) => {
// 1. Playwright Chromium ensure (site plugin, dynamic import per DNA-64)
try {
const siteChecksModule = "@warpgogol/werkstatt-site/checks";
const { ensureChromium } = await import(siteChecksModule);
await ensureChromium(ctx.workspaceRoot, ctx.logger, ctx.io);
ctx.logger.info(" [ship] preflight: Chromium ensure ok");
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(`preflight: Chromium ensure failed: ${msg}`);
}
// 2. leitstand.status channel-drift report (RFC-0948)
const statusResult = await runShipPhase(ctx, "leitstand.status", [`--site=${ctx.systemId}`]);
if (statusResult.exitCode !== 0) {
throw new Error(`preflight: leitstand.status failed: ${statusResult.summary ?? ""}`);
}
ctx.logger.info(" [ship] preflight: channel-drift report ok");
// 2b. RFC-1297: recorded-vs-live release drift — shipping on a false
// premise about what is live is worse than a blocked ship. LIVE-02
// (inconclusive probe) only logs.
let liveDiags: ReturnType | null = null;
try {
const liveStates = await readLiveChannelStates({
workspaceRoot: ctx.workspaceRoot,
systemId: ctx.systemId,
io: ctx.io,
});
liveDiags = liveDriftDiagnostics(liveStates, ctx.systemId);
} catch (err) {
ctx.logger.info(
` [ship] preflight: live-drift check skipped (${err instanceof Error ? err.message : String(err)})`,
);
}
if (liveDiags !== null) {
for (const d of liveDiags) {
if (d.severity === "warning") ctx.logger.info(` [ship] preflight: ${d.message}`);
}
const drifted = liveDiags.filter((d) => d.severity === "error");
if (drifted.length > 0) {
throw new Error(
`preflight: live drift — ${drifted.map((d) => d.message).join("; ")}. Run leitstand.verify --site ${ctx.systemId} and resolve before shipping (RFC-1297).`,
);
}
ctx.logger.info(" [ship] preflight: live-drift check ok");
}
// 2c. RFC-1326: provision external resources before any deploy step —
// cloudflare.token.check names a missing permission; the Vectorize index
// is ensured while the mission is still open (mutating command).
const provision = await provisionExternalResources(ctx, {
systemId: ctx.systemId,
missionOpen: !ctx.missionClosed,
});
if (!provision.ok) {
throw new Error(`preflight: external resource provisioning failed — ${provision.error}`);
}
ctx.logger.info(" [ship] preflight: external resources provisioned");
// 3. Vectorize index ensure (best-effort, non-fatal — same pattern as deploy-execution.ts)
try {
await runShipPhase(ctx, "agent.search.warm", [], { siteName: ctx.systemId });
ctx.logger.info(" [ship] preflight: Vectorize index warm ok");
} catch {
ctx.logger.info(
" [ship] preflight: Vectorize index warm skipped (not configured or not available)",
);
}
// 4. Env vars presence check
// Closed missions persist .env to the cache clone at mission.close
// (RFC-0822); open missions carry it in the workpiece. Accept either
// location for closed missions, and name the checked paths in the
// error — a bare "not found in workpiece" hid both the archived-path
// and the close-persistence cases.
const envLocations = ctx.missionClosed
? [ctx.cacheCloneDir, ctx.workpieceDir]
: [ctx.workpieceDir];
let envFound = false;
for (const dir of envLocations) {
if (await ctx.io.exists(path.join(dir, ".env"))) {
envFound = true;
break;
}
}
if (!envFound) {
const where = envLocations.map((d) => path.join(d, ".env")).join(" or ");
const hint = ctx.missionClosed
? " (mission is closed — .env should have been persisted to the cache clone by mission.close)"
: "";
throw new Error(`preflight: .env file not found at ${where}${hint}`);
}
ctx.logger.info(" [ship] preflight: env vars presence check ok");
// 5. Stripped-archive notice — mission.archive removes workpiece
// node_modules unless the mission had no releaseId. release.prepare
// still resolves the archived workpiece but fails on
// WORKPIECE-IMPORTS-01; warn early with the recovery recipe.
if (
ctx.missionClosed &&
!(await ctx.io.exists(path.join(ctx.workpieceDir, "node_modules")))
) {
ctx.logger.info(
` [ship] preflight: WARN workpiece node_modules missing (archive cleanup) — if release.prepare fails on WORKPIECE-IMPORTS-01, move missions/archive/closed/${ctx.missionId} back to missions/${ctx.missionId} and run pnpm install --filter ${ctx.systemId}, then re-archive after release`,
);
}
// 6. Cache-clone git divergence check (RFC-0986)
// Warns if cache clone HEAD and bare repo HEAD have diverged —
// mirror sync will fail with non-fast-forward in this case.
const cacheDir = ctx.cacheCloneDir;
if (await ctx.io.exists(path.join(cacheDir, ".git"))) {
const cacheHeadResult = await ctx.io.exec("git", ["rev-parse", "HEAD"], { cwd: cacheDir });
const cacheHead = cacheHeadResult.exitCode === 0 ? cacheHeadResult.stdout.trim() : "";
const bareDir = path.resolve(ctx.workspaceRoot, "..", "systems-git", ctx.systemId);
if (!cacheHead) {
ctx.logger.info(
" [ship] preflight: cache-clone git divergence check skipped (cache clone unreadable)",
);
} else if (await ctx.io.exists(bareDir)) {
const bareHeadResult = await ctx.io.exec("git", ["rev-parse", "master"], {
cwd: bareDir,
});
if (bareHeadResult.exitCode !== 0) {
ctx.logger.info(
" [ship] preflight: cache-clone git divergence check skipped (bare repo unreadable)",
);
} else if (cacheHead !== bareHeadResult.stdout.trim()) {
ctx.logger.info(
` [ship] preflight: WARN cache clone and bare repo have diverged (cache=${cacheHead.slice(0, 8)}, bare=${bareHeadResult.stdout.trim().slice(0, 8)}) — mirror sync may fail with non-fast-forward`,
);
} else {
ctx.logger.info(" [ship] preflight: cache-clone git divergence check ok");
}
}
}
// 7. Unreconciled-workpiece gate (RFC-1217) — open missions only: closed
// missions never run mission-reconcile, so there is no merge to predict
// and no remedy to prescribe. Git-unreadable degrades to a warning (same
// posture as the divergence check above); a non-zero count blocks.
if (ctx.missionClosed) {
ctx.logger.info(
" [ship] preflight: unreconciled-commit gate skipped (mission closed — no reconcile in plan)",
);
} else {
const unreconciled = await countUnreconciledWorkpieceCommits(ctx);
if (!unreconciled.ok) {
ctx.logger.info(
` [ship] preflight: WARN unreconciled-commit gate indeterminate (${unreconciled.reason}) — continuing`,
);
} else if (unreconciled.count > 0) {
const listing = unreconciled.oneline
? `\n${unreconciled.oneline
.split("\n")
.map((l) => ` ${l}`)
.join("\n")}`
: "";
const msg =
`preflight: ${unreconciled.count} unreconciled workpiece commit(s) not present ` +
`in the cache clone:${listing}\n` +
`Run \`pnpm exec werkstatt run mission.reconcile --mission ${ctx.missionId}\` first, ` +
`or pass --allow-unreconciled for a deliberate manual-merge workflow.`;
if (ctx.allowUnreconciled) {
ctx.logger.info(` [ship] preflight: WARN ${msg}`);
} else {
throw new Error(msg);
}
} else {
ctx.logger.info(" [ship] preflight: unreconciled-commit gate ok");
}
}
},
};
}
function buildMissionValidateStep(): OperationStep {
return {
name: "mission-validate",
run: async (ctx: ShipContext) => {
const validateResult = await runShipPhase(ctx, "mission.validate", [
`--mission=${ctx.missionId}`,
]);
if (validateResult.exitCode !== 0) {
throw new Error(`mission.validate failed: ${validateResult.summary ?? ""}`);
}
// Self-healing: check for uncommitted generated files after validate
const statusResult = await ctx.io.exec("git", ["status", "--porcelain"], {
cwd: ctx.workpieceDir,
});
const gitStatus = statusResult.exitCode === 0 ? statusResult.stdout.trim() : "";
if (gitStatus !== "") {
ctx.logger.info(
" [ship] self-healing: uncommitted generated files detected, auto-committing",
);
const commitResult = await runShipPhase(ctx, "mission.git.commit", [
`--mission=${ctx.missionId}`,
`--message=ship: auto-commit generated files after validate`,
]);
if (commitResult.exitCode !== 0) {
throw new Error(`self-healing auto-commit failed: ${commitResult.summary ?? ""}`);
}
// Re-validate after auto-commit
const revalidateResult = await runShipPhase(ctx, "mission.validate", [
`--mission=${ctx.missionId}`,
]);
if (revalidateResult.exitCode !== 0) {
throw new Error(
`mission.validate (after auto-commit) failed: ${revalidateResult.summary ?? ""}`,
);
}
}
},
};
}
function buildSimpleStep(
stepName: string,
commandName: string,
argvBuilder: (ctx: ShipContext) => string[],
postRun?: (ctx: ShipContext, data: Record | undefined) => void,
): OperationStep {
return {
name: stepName,
run: async (ctx: ShipContext): Promise | void> => {
const result = await runShipPhase(ctx, commandName, argvBuilder(ctx));
if (result.exitCode !== 0) {
throw new Error(`${commandName} failed: ${result.summary ?? ""}`);
}
if (postRun) {
postRun(ctx, result.data);
}
return result.data;
},
};
}
function buildCertifyAndDeployStep(
stepName: string,
gate: string,
deployCommand: string,
deployArgvBuilder: (ctx: ShipContext, releaseId: string) => string[],
): OperationStep {
return {
name: stepName,
run: async (ctx: ShipContext): Promise | void> => {
const releaseId = ctx.releaseId ?? "";
if (!releaseId) {
throw new Error(`${stepName}: no releaseId in context — release-prepare must run first`);
}
// Certify
const certifyResult = await runShipPhase(ctx, "leitstand.certify", [
`--site=${ctx.systemId}`,
`--gate=${gate}`,
`--release=${releaseId}`,
]);
if (certifyResult.exitCode !== 0) {
throw new Error(`leitstand.certify --gate=${gate} failed: ${certifyResult.summary ?? ""}`);
}
// Deploy
const deployResult = await runShipPhase(
ctx,
deployCommand,
deployArgvBuilder(ctx, releaseId),
);
if (deployResult.exitCode !== 0) {
throw new Error(`${deployCommand} failed: ${deployResult.summary ?? ""}`);
}
// RFC-1207: journal the candidate-preview identity in step-done.meta so
// --resume can attribute evidence to the measured artifact (AC-6).
const certifyData = certifyResult.data as { preview?: Record } | undefined;
return {
gate,
releaseId,
...(certifyData?.preview ? { candidatePreview: certifyData.preview } : {}),
...(deployResult.data ?? {}),
};
},
};
}
// ─── buildShipPlan ───────────────────────────────────────────────────────────
async function syncCacheCloneBestEffort(ctx: ShipContext, stepName: string): Promise {
// RFC-0986: Auto-sync cache clone to bare repo after deployment phases
// that create commits. Non-fatal — sync failure is logged but does not
// block the deployment pipeline.
try {
const result = await runShipPhase(ctx, "sternsystem.sync", [`--id=${ctx.systemId}`]);
if (result.exitCode !== 0) {
ctx.logger.info(
` [ship] ${stepName}: sternsystem.sync failed (non-fatal): ${result.summary ?? ""}`,
);
} else {
ctx.logger.info(` [ship] ${stepName}: sternsystem.sync ok`);
}
} catch (err) {
ctx.logger.info(
` [ship] ${stepName}: sternsystem.sync skipped (${err instanceof Error ? err.message : String(err)})`,
);
}
}
function buildSyncStep(stepName: string): OperationStep {
return {
name: stepName,
run: (ctx: ShipContext) => syncCacheCloneBestEffort(ctx, stepName),
};
}
// RFC-1190: dedicated journaled evidence-sync steps after each certify-* —
// archival moves out of deploy-execution's invisible branch. Absent
// evidence/axiom or missing evidence-metadata.json is a clean no-op; a failed
// upload while evidence exists is blocking (EVIDENCE_SYNC_FAILED).
function buildEvidenceSyncStep(stepName: string): OperationStep {
return {
name: stepName,
run: async (ctx: ShipContext): Promise> => {
const evidenceDir = path.join(ctx.missionDir, "evidence", "axiom");
const metadataPath = path.join(evidenceDir, "evidence-metadata.json");
if (!(await ctx.io.exists(evidenceDir)) || !(await ctx.io.exists(metadataPath))) {
return {
missionId: ctx.missionId,
skipped: true,
reason: "no axiom evidence directory or evidence-metadata.json",
};
}
const result = await runShipPhase(ctx, "evidence.sync", [
`--site=${ctx.systemId}`,
`--mission=${ctx.missionId}`,
]);
if (result.exitCode !== 0) {
throw new Error(`EVIDENCE_SYNC_FAILED: ${result.summary ?? "evidence.sync failed"}`);
}
return { missionId: ctx.missionId, skipped: false, ...(result.data ?? {}) };
},
};
}
export function buildShipPlan(input: {
until: ShipPhase;
missionClosed?: boolean;
}): OperationDefinition {
const stepCount = PHASE_STEP_COUNT[input.until];
const skipLifecycle = input.missionClosed === true;
const allSteps: OperationStep[] = [
buildPreflightStep(),
buildMissionValidateStep(),
{
// RFC-1327: re-running ship after a successful reconcile must not
// re-merge — the reconciliation report records the folded workpiece head.
name: "mission-reconcile",
run: async (ctx: ShipContext) => {
const reportPath = `${ctx.missionDir}/evidence/reconciliation-report.json`;
try {
if (await ctx.io.exists(reportPath)) {
const report = JSON.parse(await ctx.io.readFile(reportPath)) as {
workpieceHeadAtReconcile?: string | null;
};
const headRes = await ctx.io.exec("git", ["rev-parse", "HEAD"], {
cwd: ctx.workpieceDir,
});
const head = headRes.exitCode === 0 ? headRes.stdout.trim() : "";
if (head && report.workpieceHeadAtReconcile === head) {
ctx.logger.info(
` [ship] mission-reconcile: skipped (workpiece head ${head.slice(0, 8)} already reconciled)`,
);
return { missionId: ctx.missionId, skipped: true, reason: "already-reconciled" };
}
}
} catch {
// Report unreadable or non-git workpiece — run reconcile normally.
}
const result = await runShipPhase(ctx, "mission.reconcile", [`--mission=${ctx.missionId}`]);
if (result.exitCode !== 0) {
throw new Error(`mission.reconcile failed: ${result.summary ?? "unknown"}`);
}
return { missionId: ctx.missionId, skipped: false, ...(result.data ?? {}) };
},
},
buildSimpleStep("mission-close", "mission.close", (ctx) => [`--mission=${ctx.missionId}`]),
buildSimpleStep(
"release-prepare",
"release.prepare",
(ctx) => [`--mission=${ctx.missionId}`],
(ctx, data) => {
const releaseId = (data as { releaseId?: string } | undefined)?.releaseId;
if (releaseId) {
ctx.releaseId = releaseId;
}
},
),
// RFC-0986: Auto-sync after release-prepare — release.prepare creates commits
// in the cache clone (release tag, system-state update). Sync to bare repo
// immediately so subsequent steps don't encounter a diverged bare repo.
buildSyncStep("sync-after-prepare"),
buildSimpleStep("release-ready", "release.ready", (ctx) => [
`--release=${ctx.releaseId ?? ""}`,
]),
buildCertifyAndDeployStep("certify-dev", "dev", "leitstand.dev-deploy", (ctx, releaseId) => [
`--site=${ctx.systemId}`,
`--release=${releaseId}`,
`--mission=${ctx.missionId}`,
]),
// RFC-1190: axiom evidence archival is a journaled step, not an invisible
// deploy-internal branch — runs after the certify+deploy pair produced it.
buildEvidenceSyncStep("evidence-sync-dev"),
// RFC-0986: Auto-sync after certify-dev — certification may create evidence
// commits in the cache clone.
buildSyncStep("sync-after-dev"),
buildCertifyAndDeployStep("certify-alt", "alt", "leitstand.propagate", (ctx, releaseId) => [
`--site=${ctx.systemId}`,
`--release=${releaseId}`,
`--mission=${ctx.missionId}`,
]),
buildEvidenceSyncStep("evidence-sync-alt"),
// RFC-0986: Auto-sync after certify-alt.
buildSyncStep("sync-after-alt"),
buildCertifyAndDeployStep("certify-main", "main", "leitstand.promote", (ctx, releaseId) => [
`--site=${ctx.systemId}`,
`--release=${releaseId}`,
`--mission=${ctx.missionId}`,
]),
buildEvidenceSyncStep("evidence-sync-main"),
// RFC-0986: Auto-sync after certify-main.
buildSyncStep("sync-after-main"),
buildSimpleStep("verify", "leitstand.verify", (ctx) => [`--site=${ctx.systemId}`]),
{
name: "mission-archive",
run: async (ctx: ShipContext) => {
// Best-effort: archive the mission after shipping. Non-fatal on failure.
try {
const result = await runShipPhase(ctx, "mission.archive", [
`--site=${ctx.systemId}`,
`--status=closed`,
]);
if (result.exitCode !== 0) {
ctx.logger.info(
` [ship] mission-archive: skipped (mission.archive unavailable or failed: ${result.summary ?? ""})`,
);
}
} catch {
ctx.logger.info(" [ship] mission-archive: skipped (mission.archive not registered)");
}
},
},
];
// When mission is already closed, skip lifecycle phases (validate, reconcile, close)
// and start from release-prepare. This allows resuming a deployment after a
// failed release.prepare without manually running individual steps.
if (skipLifecycle) {
const lifecycleSteps = new Set(["mission-validate", "mission-reconcile", "mission-close"]);
// PHASE_STEP_COUNT is indexed against the full step list — slice first,
// then drop lifecycle steps. Filtering before slicing overshoots the
// --until boundary by 3 (e.g. --until alt would include certify-main).
return {
op: "leitstand.ship",
steps: allSteps.slice(0, stepCount).filter((s) => !lifecycleSteps.has(s.name)),
};
}
return {
op: "leitstand.ship",
steps: allSteps.slice(0, stepCount),
};
}
// ─── Command handler ─────────────────────────────────────────────────────────
async function checkMissionClosed(
missionDir: string,
io: KernelRuntimeContext["io"],
): Promise {
const missionYamlPath = path.join(missionDir, "mission.yaml");
if (!(await io.exists(missionYamlPath))) return false;
try {
const raw = await io.readFile(missionYamlPath);
const parsed = parseYaml(raw) as { state?: string };
return parsed.state === "closed";
} catch {
return false;
}
}
function phaseFromStepCount(stepCount: number, missionClosed = false): ShipPhase | "preflight" {
if (missionClosed) {
// When lifecycle phases are skipped (mission already closed), step counts shift by -3.
switch (stepCount) {
case 0:
return "preflight";
case 7:
return "dev";
case 10:
return "alt";
case 13:
return "main";
case 15:
return "archived";
default:
return "preflight";
}
}
switch (stepCount) {
case 0:
return "preflight";
case 2:
return "validated";
case 4:
return "closed";
case 10:
return "dev";
case 13:
return "alt";
case 16:
return "main";
case 18:
return "archived";
default:
return "preflight";
}
}
export async function runLeitstandShip(
input: KernelCommandInput,
context: KernelRuntimeContext,
): Promise> {
const { workspaceRoot, logger } = context;
const systemId = flagSite(input);
if (!systemId) throw new Error("[leitstand.ship] --site is required");
const missionId = flagString(input, "mission");
if (!missionId) throw new Error("[leitstand.ship] --mission is required");
const untilFlag = flagString(input, "until") as ShipPhase | undefined;
const until = untilFlag ?? "archived";
if (!VALID_PHASES.includes(until)) {
throw new Error(
`[leitstand.ship] invalid --until value "${untilFlag}". Valid phases: ${VALID_PHASES.join(", ")}`,
);
}
const isResume = input.flags["resume"] === true;
const isForce = input.flags["force"] === true;
// Archive-aware mission resolution — closed missions may already sit in
// missions/archive// (deferred-release flow: close at session end,
// ship later). resolveMissionDir returns the primary path as a fallback,
// so verify the directory exists before deriving workpiece paths.
const missionDir = await resolveMissionDir(workspaceRoot, missionId, context.io);
if (!(await context.io.exists(missionDir))) {
throw new Error(
`[leitstand.ship] mission ${missionId} not found — expected a directory in missions/${missionId} or missions/archive/{closed,aborted}/${missionId}`,
);
}
const workpieceDir = path.join(missionDir, "workpiece");
const cacheCloneDir = resolveCacheClonePath(workspaceRoot, systemId);
const operationsDir = path.join(cacheCloneDir, "operations");
const journalPath = path.join(operationsDir, `ship-${missionId}.jsonl`);
// Lazily create operations directory
await context.io.mkdir(operationsDir);
// --force: delete stale ship journal to start fresh
if (isForce && (await context.io.exists(journalPath))) {
await context.io.rm(journalPath);
logger.info(` [ship] --force: deleted stale journal ${journalPath}`);
}
// Detect mission state — if already closed, skip lifecycle phases
const missionClosed = await checkMissionClosed(missionDir, context.io);
const ctx: ShipContext = {
workspaceRoot,
systemId,
missionId,
missionDir,
workpieceDir,
cacheCloneDir,
releaseId: null,
missionClosed,
allowUnreconciled: input.flags["allow-unreconciled"] === true,
logger,
io: context.io,
};
if (missionClosed) {
logger.info(
` [ship] mission ${missionId} is closed — skipping lifecycle phases (validate, reconcile, close)`,
);
}
const plan = buildShipPlan({ until, missionClosed });
const stepCount = plan.steps.length;
const reachedPhase = phaseFromStepCount(stepCount, missionClosed);
// RFC-1168: progress goes straight to stderr — context.logger buffers into
// the end-of-run envelope, which is exactly the opacity being fixed.
const emitProgress = (line: string): void => {
try {
process.stderr.write(`${line}\n`);
} catch {
// Closed stderr (detached harness) must not break the operation.
}
};
const stepStartTimes = new Map();
for (const line of shipPreambleLines(journalPath, systemId, missionId)) {
emitProgress(line);
}
// Check for incomplete operation
const existingRecords = await (async () => {
try {
const { readJournal } = await import("../journal/jsonl.ts");
return await readJournal(journalPath);
} catch {
return [];
}
})();
const incomplete = findIncompleteOperation(existingRecords);
// Restore releaseId from journal on resume
if (incomplete) {
for (const record of existingRecords) {
if (
record.kind === "step-done" &&
record.step === "release-prepare" &&
record.meta?.releaseId
) {
ctx.releaseId = record.meta.releaseId as string;
logger.info(` [ship] restored releaseId ${ctx.releaseId} from journal`);
break;
}
}
}
if (isResume && !incomplete) {
return {
data: {
siteId: systemId,
missionId,
releaseId: ctx.releaseId,
reachedPhase,
steps: [],
completed: true,
},
summary: "[leitstand.ship] nothing to resume — no incomplete ship operation",
exitCode: 0,
};
}
if (!isResume && incomplete && incomplete.op === "leitstand.ship") {
// Same-kind incomplete operation — auto-resume
logger.info(` [ship] incomplete operation found (${incomplete.opId}), resuming`);
}
if (incomplete?.status === "interrupted" && incomplete.failedStep) {
logger.info(` [ship] resuming interrupted op at failed step "${incomplete.failedStep}"`);
}
const opResult = await runOperation(journalPath, plan, ctx, {
missionId,
platformVersion: "",
resumeOpId: isResume ? incomplete?.opId : undefined,
// RFC-1168: one line per journal record — the live progress stream.
onTransition: (record) => {
if (record.kind === "step-started") {
stepStartTimes.set(record.seq, Date.now());
}
const seq = "seq" in record ? record.seq : undefined;
const started = seq !== undefined ? stepStartTimes.get(seq) : undefined;
const durationMs = started !== undefined ? Date.now() - started : undefined;
const line = formatShipTransition(record, stepCount, durationMs);
if (line !== null) emitProgress(line);
},
});
// Journal tail records (post-sync step-done/op-done) are appended after the
// last in-plan sync — sweep and propagate so the cache clone exits clean.
await syncCacheCloneBestEffort(ctx, "sync-final");
if (opResult.completed) {
return {
data: {
siteId: systemId,
missionId,
releaseId: ctx.releaseId,
reachedPhase,
steps: opResult.stepResults,
completed: true,
},
summary: `[leitstand.ship] ${missionId} shipped to ${reachedPhase}${ctx.releaseId ? ` (release ${ctx.releaseId})` : ""} — ${opResult.executed.length} steps executed, ${opResult.skipped.length} resumed`,
exitCode: 0,
};
}
return {
data: {
siteId: systemId,
missionId,
releaseId: ctx.releaseId,
reachedPhase,
steps: opResult.stepResults,
completed: false,
failedStep: opResult.failedStep,
},
summary: `[leitstand.ship] failed at step: ${opResult.failedStep ?? "unknown"} — ${opResult.failedStepError ?? ""}`,
exitCode: 1,
nextSteps: [
{
action: `Fix the failing step, then run: pnpm exec werkstatt run leitstand.ship --site ${systemId} --mission ${missionId} --resume`,
kind: "required",
},
],
};
}