/*
RFC-0480: mission.preview — blocking dev server for any mission (open, closed, aborted).
- Does not build — use mission.build for that.
- Does not validate — use mission.validate for that.
- ADR-0007: run content.ref-index.generate before dev server start to ensure fresh content reference index.
- RFC-0817: enforce materialization gate — auto-run mission.materialize when materializedAt is null and mission state is open. --skip-prepare does NOT bypass materialization.
- RFC-1097: step 6 — compass.migrate codemod run
Mechanical v1 to v2 header migration across the workspace: 942 files rewritten — CHANGE_SUMMARY windows collapsed into history, forbidden v1 blocks stripped, KEY_DECISIONS seeded from @ai-invariant comments (5 files) or TODO placeholders (103 files), blocks reordered to canonical order.
- RFC-1097: sweep — werkstatt-engine clean
Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics.
- RFC-1149: migrate ambient filesystem and process IO to the WorkspaceIO port across engine and shared
RFC-0480
*/
import path from "node:path";
// spawn is needed for the long-lived dev server (stdio: inherit streams live
// output) — io.exec is capture-buffered and cannot express interactive stdio.
// Dynamic import keeps the module surface free of a static node:child_process
// edge (kernel.io.lint scans static imports only).
import type {
DiscoveredSiteWorkspace,
KernelCommandInput,
KernelCommandResult,
KernelRuntimeContext,
} from "@warpgogol/werkstatt-engine/kernel";
import { executeKernelCommand } from "@warpgogol/werkstatt-engine/kernel";
import { readMissionManifest, resolveMissionDir } from "./mission-io.ts";
import { createDefaultIO, type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io";
interface DevCriticalFile {
readonly path: string;
readonly command: string;
readonly description: string;
readonly prerequisites?: readonly string[];
}
const DEV_CRITICAL_FILES: readonly DevCriticalFile[] = [
{
path: "src/content-ref-index.generated.yaml",
command: "content.ref-index.generate",
description: "Content reference index (resolves =(...) formula references in page props)",
},
{
path: "src/derived-prices.generated.json",
command: "derived-prices.materialize",
description: "Derived prices (currency-aware price display)",
prerequisites: ["entitlements.resolve", "rate-snapshot.resolve", "currency-pricing.compile"],
},
{
path: "src/video-manifest.generated.yaml",
command: "video.variants.generate",
description: "Video manifest (video section rendering)",
},
];
async function ensureDevCriticalFiles(
workpiecePath: string,
systemId: string,
workspaceRoot: string,
logger: KernelRuntimeContext["logger"],
io: WorkspaceIO = createDefaultIO().io,
): Promise {
const missing: DevCriticalFile[] = [];
for (const entry of DEV_CRITICAL_FILES) {
const fullPath = path.join(workpiecePath, entry.path);
if (!(await io.exists(fullPath))) {
missing.push(entry);
}
}
if (missing.length === 0) return;
logger.info(
` [pre-dev] ${missing.length} critical generated file(s) missing — auto-generating…`,
);
for (const entry of missing) {
logger.info(` - ${entry.path} (${entry.description})`);
}
for (const entry of missing) {
if (entry.prerequisites) {
for (const prereq of entry.prerequisites) {
logger.info(` [pre-dev] Running prerequisite: ${prereq}…`);
try {
const execResult = await executeKernelCommand({
workspaceRoot,
commandName: prereq,
siteName: systemId,
});
const single = Array.isArray(execResult) ? execResult[0] : execResult;
if (!single?.ok) {
throw new Error(
`[mission.preview] Prerequisite "${prereq}" failed for "${entry.path}". ` +
`This file is required for dev: ${entry.description}. ` +
`Fix the error above and re-run mission.preview. ` +
`To skip this check, use --skip-prepare (not recommended — the dev server will render without ${entry.description}).`,
);
}
} catch (err) {
if (err instanceof Error && err.message.startsWith("[mission.preview]")) throw err;
throw new Error(
`[mission.preview] Prerequisite "${prereq}" threw for "${entry.path}": ${err instanceof Error ? err.message : String(err)}. ` +
`This file is required for dev: ${entry.description}. ` +
`Fix the error above and re-run mission.preview. ` +
`To skip this check, use --skip-prepare (not recommended).`,
);
}
}
}
logger.info(` [pre-dev] Generating: ${entry.command}…`);
try {
const execResult = await executeKernelCommand({
workspaceRoot,
commandName: entry.command,
siteName: systemId,
});
const single = Array.isArray(execResult) ? execResult[0] : execResult;
if (!single?.ok) {
throw new Error(
`[mission.preview] "${entry.command}" failed to generate "${entry.path}". ` +
`This file is required for dev: ${entry.description}. ` +
`Fix the error above and re-run mission.preview. ` +
`To skip this check, use --skip-prepare (not recommended — the dev server will render without ${entry.description}).`,
);
}
} catch (err) {
if (err instanceof Error && err.message.startsWith("[mission.preview]")) throw err;
throw new Error(
`[mission.preview] "${entry.command}" threw while generating "${entry.path}": ${err instanceof Error ? err.message : String(err)}. ` +
`This file is required for dev: ${entry.description}. ` +
`Fix the error above and re-run mission.preview. ` +
`To skip this check, use --skip-prepare (not recommended).`,
);
}
const fullPath = path.join(workpiecePath, entry.path);
if (!(await io.exists(fullPath))) {
throw new Error(
`[mission.preview] "${entry.command}" completed but "${entry.path}" was not created. ` +
`This file is required for dev: ${entry.description}. ` +
`Investigate why the generator did not write the file and re-run mission.preview. ` +
`To skip this check, use --skip-prepare (not recommended).`,
);
}
logger.info(` [pre-dev] OK: ${entry.path}`);
}
logger.info(` [pre-dev] All critical generated files present.`);
}
export interface MissionPreviewData {
missionId: string;
systemId: string;
workpiecePath: string;
port: number;
production: boolean;
}
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";
}
export async function runMissionPreview(
input: KernelCommandInput,
context: KernelRuntimeContext,
): Promise> {
const { workspaceRoot, logger } = context;
const missionId = flagString(input, "mission");
const port = parseInt(flagString(input, "port") ?? "4321", 10);
const production = flagBool(input, "production");
const skipPrepare = flagBool(input, "skip-prepare");
if (!missionId) throw new Error("[mission.preview] --mission is required");
const manifest = await readMissionManifest(workspaceRoot, missionId);
// RFC-0480: preview works for open, closed, and aborted missions
const workpiecePath = path.join(
await resolveMissionDir(workspaceRoot, missionId, context.io),
"workpiece",
);
if (!(await context.io.exists(workpiecePath))) {
throw new Error(
`[mission.preview] workpiece not found for mission '${missionId}' — run mission.materialize first`,
);
}
// RFC-0817: Materialization gate — if the mission is open and has not been
// materialized yet, auto-run mission.materialize before starting the dev server.
// This ensures ownership map gaps, generated file issues, and pipeline step
// failures are caught at preview time, not hours later at mission.close.
// --skip-prepare does NOT bypass this gate — materialization is the formal
// lifecycle gate, not a convenience check.
if (manifest.state === "open" && !manifest.materializedAt) {
logger.info(
` [mission.preview] Mission '${missionId}' is open but not materialized (materializedAt is null). ` +
`Auto-running mission.materialize before starting dev server…`,
);
try {
const materializeResult = await executeKernelCommand({
workspaceRoot,
commandName: "mission.materialize",
siteName: manifest.systemId,
});
const single = Array.isArray(materializeResult) ? materializeResult[0] : materializeResult;
if (!single?.ok) {
throw new Error(
`[mission.preview] mission.materialize failed for mission '${missionId}'. ` +
`The dev server cannot start until materialization succeeds. ` +
`Fix the error above and re-run mission.preview. ` +
`Materialization is the formal lifecycle gate — --skip-prepare does NOT bypass it.`,
);
}
logger.info(` [mission.preview] Materialization complete.`);
} catch (err) {
if (err instanceof Error && err.message.startsWith("[mission.preview]")) throw err;
throw new Error(
`[mission.preview] mission.materialize threw for mission '${missionId}': ${err instanceof Error ? err.message : String(err)}. ` +
`The dev server cannot start until materialization succeeds. ` +
`Fix the error above and re-run mission.preview. ` +
`Materialization is the formal lifecycle gate — --skip-prepare does NOT bypass it.`,
);
}
}
const cmd = production ? "preview" : "dev";
// Stop any existing astro dev server on this port before starting a new one
await context.io.exec("pnpm", ["run", "stop"], { cwd: workpiecePath });
if (!skipPrepare) {
// Pre-dev critical file check: verify that generated files required for
// correct dev rendering exist, and auto-generate them if missing.
// This catches issues like missing prices (derived-prices.generated.json)
// or missing videos (video-manifest.generated.yaml) before the dev server
// starts, preventing silent rendering failures.
await ensureDevCriticalFiles(workpiecePath, manifest.systemId, workspaceRoot, logger);
} else {
logger.warn(
` [mission.preview] --skip-prepare: skipping critical file check. ` +
`If prices, videos, or formula references are missing, remove --skip-prepare and re-run.`,
);
}
// ADR-0007: regenerate content reference index before dev server start
// so the resolver reads fresh frontmatter values from source files.
// Call in-process with a synthetic site context pointing to the workpiece,
// not via --site flag (which relies on registry currentMission and fails
// for closed/aborted missions where currentMission is cleared).
const workpieceSite: DiscoveredSiteWorkspace = {
name: manifest.systemId,
directory: workpiecePath,
toolsDirectory: path.join(workpiecePath, "tools"),
};
try {
const codegenModule = "@warpgogol/werkstatt-site/codegen";
const { runContentRefIndexGenerate } = await import(codegenModule);
await runContentRefIndexGenerate({ argv: [], flags: {} }, { ...context, site: workpieceSite });
} catch (err) {
logger.warn(
` [ADR-0007] content.ref-index.generate failed: ${err instanceof Error ? err.message : String(err)} — index may be stale`,
);
}
logger.info(
` Starting astro ${cmd} on port ${port} for mission '${missionId}' (state: ${manifest.state})`,
);
logger.info(` Workpiece: ${workpiecePath}`);
logger.info(` Press Ctrl+C to stop the server.`);
const { spawn } = await import("node:child_process");
return new Promise((resolve) => {
const child = spawn("pnpm", ["exec", "astro", cmd, "--port", String(port)], {
cwd: workpiecePath,
stdio: "inherit",
});
child.on("exit", (code: number | null) => {
resolve({
data: {
missionId,
systemId: manifest.systemId,
workpiecePath,
port,
production,
},
summary: `[mission.preview] ${missionId} server stopped (exit code: ${code})`,
});
});
child.on("error", (err: Error) => {
resolve({
data: {
missionId,
systemId: manifest.systemId,
workpiecePath,
port,
production,
},
summary: `[mission.preview] ${missionId} server error: ${err.message}`,
});
});
});
}