/* RFC-0355 §5.1: mission.open — open a new mission for a Sternsystem. Do not introduce app-specific runtime composition or deployment behavior into this reusable package source file. RFC-1149: migrate ambient filesystem and process IO to the WorkspaceIO port across engine and shared RFC-1157: auto-trash cache-only mission husks in cleanupStaleMissionEntries; logger-threaded isCacheOnlyMissionRemnant. RFC-1159: step 3 — io.exec exit-code sweep via execOrThrow RFC-1249: wire werkstatt.commands.validate into packages.check + retire CMD-OUTPUT debt RFC-1263 ADR-0030, RFC-0355, RFC-0477, RFC-0560, RFC-0580, RFC-0593, RFC-0796, RFC-0951, RFC-0958, RFC-1097, RFC-1139 */ import path from "node:path"; import { createDefaultIO, execOrThrow, type WorkspaceIO, } from "@warpgogol/werkstatt-shared/kernel/workspace-io"; import type { KernelCommandInput, KernelCommandResult, KernelRuntimeContext, } from "@warpgogol/werkstatt-engine/kernel"; import type { MissionManifest, SystemState } from "@warpgogol/werkstatt-engine/schemas"; import { readSystemConfig, readSystemState, writeSystemState, resolveCacheClonePath, discoverSystems, } from "../sternsystem/registry-io.ts"; import { createMissionDirectories, writeMissionManifest, missionExists } from "./mission-io.ts"; import { readBordbuch, deriveNextMissionNumberSafe, validateBordbuch, type BordbuchViolation, } from "../bordbuch/bordbuch-io.ts"; import { appendAndCommitBordbuch } from "../bordbuch/bordbuch-commit-helper.ts"; import { acquireLock, releaseLock, generateOperationId, commitWerkstattSideEffects, } from "../werkstatt/index.ts"; import { resolveActor } from "./actor-identity.ts"; import { trashPath } from "@warpgogol/forge/utils"; import { gitExec } from "../werkstatt/git-exec.ts"; import { runMissionMaterializeInternal } from "./mission-materialize.ts"; import { runOperation } from "../journal/runner.ts"; import { checkDifferentKindOperation } from "../journal/index.ts"; import type { OperationStep, OperationDefinition } from "../journal/index.ts"; import { getDefaultScopeManager } from "../scope/scope.ts"; import { applyOverlay } from "../runtime/overlay-store.ts"; export interface StaleEntryCheck { removedPaths: string[]; skipped: string[]; } export interface MissionOpenData { missionId: string; systemId: string; state: "open"; brief: string; openedAt: string; pinAtOpen: string; operationId: string; staleEntries: StaleEntryCheck; materializedAt: string | null; } const REPAIRABLE_RULES = new Set(["orphan-mission-close", "unmatched-mission-open"]); // RFC-0982 parity with mission.archive: cache entries are dotfiles/dotdirs // (.astro, .cache, .turbo, .wrangler, .closed), node_modules and dist — // transient artifacts that never constitute recoverable mission content. function isMissionCacheEntry(name: string): boolean { return name.startsWith(".") || name === "node_modules" || name === "dist"; } /** * RFC-1157: a mission dir without mission.yaml is auto-trashable only when * every entry — at the root and inside workpiece/ — is a cache artifact * (husks left behind by mission.archive's post-rename cleanup or watchers). * Any real file makes it a manual-inspection remnant (RFC-1139 warn path). */ async function isCacheOnlyMissionRemnant( missionDir: string, io: WorkspaceIO, logger: { warn: (msg: string) => void }, ): Promise { try { for (const entry of await io.readdir(missionDir)) { if (entry.name === "workpiece" && entry.isDirectory) continue; if (!isMissionCacheEntry(entry.name)) return false; } const workpieceDir = path.join(missionDir, "workpiece"); if (await io.exists(workpieceDir)) { for (const wp of await io.readdir(workpieceDir)) { if (!isMissionCacheEntry(wp.name)) return false; } } return true; } catch (err) { // Fail-safe: never auto-trash on inspection failure, but keep the reason. logger.warn( ` Could not inspect ${missionDir} for cache-only content: ${err instanceof Error ? err.message : String(err)} — treating as manual-inspection remnant`, ); return false; } } export async function cleanupStaleMissionEntries( workspaceRoot: string, logger: { info: (msg: string) => void; warn: (msg: string) => void }, io: WorkspaceIO = createDefaultIO().io, ): Promise { const missionsPath = path.join(workspaceRoot, "missions"); const result: StaleEntryCheck = { removedPaths: [], skipped: [] }; if (!(await io.exists(missionsPath))) return result; // Scan all entries in missions/ root for stale symlinks and empty dirs const entries = await io.readdir(missionsPath); for (const entry of entries) { if (entry.name === "archive") continue; const entryPath = path.join(missionsPath, entry.name); const sourceRel = `missions/${entry.name}`; // Detect symlinks via test -L (port DirEntry/stat follow symlinks) const linkCheck = await io.exec("test", ["-L", entryPath]); if (linkCheck.exitCode === 0) { await trashPath(entryPath); result.removedPaths.push(sourceRel); logger.info(` Cleaned stale symlink: ${sourceRel}`); } else if (entry.isDirectory) { // Check if directory is empty let isEmpty = true; try { const dirEntries = await io.readdir(entryPath); isEmpty = dirEntries.length === 0; } catch { isEmpty = false; } if (isEmpty) { await trashPath(entryPath); result.removedPaths.push(sourceRel); logger.info(` Cleaned empty directory: ${sourceRel}`); } else if (!(await io.exists(path.join(entryPath, "mission.yaml")))) { if (await isCacheOnlyMissionRemnant(entryPath, io, logger)) { // RFC-1157: cache-only husks (post-archive workpiece/ shells with // only .astro/node_modules/dist) carry no recoverable content — // trash them here instead of re-warning on every mission.open. await trashPath(entryPath); result.removedPaths.push(sourceRel); logger.info(` Cleaned cache-only mission remnant: ${sourceRel}`); } else { // RFC-1139: non-empty dir without mission.yaml that holds real // content is a stale remnant — report it with the cleanup path. result.skipped.push(sourceRel); logger.warn( ` Stale mission remnant: ${sourceRel} (no mission.yaml) — clean via \`pnpm exec werkstatt run mission.archive --clean-orphans\``, ); } } else { result.skipped.push(sourceRel); logger.warn(` Skipping non-empty real directory: ${sourceRel}`); } } } return result; } function flagString(input: KernelCommandInput, key: string): string | undefined { const v = input.flags[key]; return typeof v === "string" ? v : undefined; } async function preflightBordbuch( workspaceRoot: string, systemId: string, ): Promise<{ passed: boolean; violations: BordbuchViolation[] }> { const { violations } = await validateBordbuch(workspaceRoot, systemId); return { passed: violations.length === 0, violations }; } export async function runMissionOpen( input: KernelCommandInput, context: KernelRuntimeContext, ): Promise> { const { workspaceRoot, logger } = context; const systemId = flagString(input, "system"); const brief = flagString(input, "brief"); const actor = resolveActor(input); // RFC-1263: --lane declares the mission's lifecycle lane on the record — // "content" marks a content fast-lane mission (mission.content-edit). const laneFlag = flagString(input, "lane"); if (laneFlag && laneFlag !== "content" && laneFlag !== "full") { throw new Error(`[mission.open] --lane must be "content" or "full" (got "${laneFlag}")`); } if (!systemId) throw new Error("[mission.open] --system is required"); if (!brief) throw new Error("[mission.open] --brief is required"); // Check system exists before proceeding — list available systems on failure try { await readSystemConfig(workspaceRoot, systemId); } catch { const { systems: available } = await discoverSystems(workspaceRoot); const ids = available.map((s) => s.id).join(", "); throw new Error( `[mission.open] system '${systemId}' not found. Available systems: ${ids || "(none)"}`, ); } // RFC-0987 Fix 3: Pre-preflight cache clone git sanity check — must run before // bordbuch.repair to prevent rebase cascade from blocking mission.open. A stale // rebase-merge from a previous failed commitAndPushBordbuch will cause bordbuch.repair's // git pull --rebase to fail with "It seems that there is already a rebase-merge directory". // Auto-recover: abort the stale rebase and log a warning. Detached HEAD remains fail-fast. const cacheDir = resolveCacheClonePath(workspaceRoot, systemId); if (await context.io.exists(path.join(cacheDir, ".git"))) { // Check detached HEAD — fail-fast (requires operator decision) const symRef = await context.io.exec("git", ["symbolic-ref", "HEAD"], { cwd: cacheDir, }); if (symRef.exitCode !== 0) { throw new Error( `[mission.open] cache clone for '${systemId}' is in detached HEAD. ` + `Run: git -C ${cacheDir} checkout main`, ); } // Check stale rebase-merge — auto-recover + warn if (await context.io.exists(path.join(cacheDir, ".git", "rebase-merge"))) { logger.warn( `[mission.open] cache clone for '${systemId}' has stale rebase-merge — auto-aborting before bordbuch validation.`, ); let abortSucceeded = false; const abortResult = await context.io.exec("git", ["rebase", "--abort"], { cwd: cacheDir, timeoutMs: 10_000, }); if (abortResult.exitCode === 0) { abortSucceeded = true; } else { // If git rebase --abort fails, manually remove the rebase state directories try { await context.io.rm(path.join(cacheDir, ".git", "rebase-merge"), { recursive: true }); await context.io .rm(path.join(cacheDir, ".git", "rebase-apply"), { recursive: true }) .catch(() => {}); abortSucceeded = true; } catch {} } if (!abortSucceeded) { throw new Error( `[mission.open] stale rebase-merge found in cache clone for '${systemId}' and auto-abort failed. ` + `Run: git -C ${cacheDir} rebase --abort`, ); } } } // RFC-0593: pre-flight bordbuch validation gate — refuse to open a new mission // if the system's bordbuch has any violations. This runs before lock acquisition // to avoid holding locks during validation. Known TOCTOU limitation: bordbuch.repair // (operator-only) could change the bordbuch concurrently — low risk, failed attempt // exits with code 1 before any side effects. // // Auto-repair: if all violations are orphan-mission-close (safe to repair per // RFC-0583), automatically call bordbuch.repair instead of requiring manual // intervention. This is the most common violation after a manual bordbuch edit // or a crashed mission.close, and repair is deterministic and safe. const bordbuchCheck = await preflightBordbuch(workspaceRoot, systemId); if (!bordbuchCheck.passed) { const allRepairable = bordbuchCheck.violations.every((v) => REPAIRABLE_RULES.has(v.rule)); if (allRepairable) { try { const { executeKernelCommand } = await import("@warpgogol/werkstatt-engine/kernel"); await executeKernelCommand({ workspaceRoot, commandName: "bordbuch.repair", argv: [`--system=${systemId}`], }); const recheck = await preflightBordbuch(workspaceRoot, systemId); if (!recheck.passed) { const violationLines = recheck.violations .map((v) => ` ${v.rule}: ${v.message}`) .join("\n"); throw new Error( `[mission.open] bordbuch.repair ran but ${recheck.violations.length} violation${recheck.violations.length === 1 ? "" : "s"} remain for system '${systemId}'\n${violationLines}`, ); } // bordbuch.repair now auto-commits its output, so no separate // commitAndPushBordbuch call is needed here. The cache clone is // clean after repair, preventing git pull --rebase failures. } catch (repairErr) { throw new Error( `[mission.open] bordbuch for system '${systemId}' has ${bordbuchCheck.violations.length} repairable violation${bordbuchCheck.violations.length === 1 ? "" : "s"} — auto-repair failed: ${repairErr instanceof Error ? repairErr.message : String(repairErr)}`, ); } } else { const violationLines = bordbuchCheck.violations .map((v) => ` ${v.rule}: ${v.message}`) .join("\n"); throw new Error( `[mission.open] bordbuch for system '${systemId}' has ${bordbuchCheck.violations.length} violation${bordbuchCheck.violations.length === 1 ? "" : "s"} — run bordbuch.repair first\n${violationLines}`, ); } } const operationId = generateOperationId(); await acquireLock(workspaceRoot, `system:${systemId}`, operationId, "mission.open", actor); try { const config = await readSystemConfig(workspaceRoot, systemId); const state = await readSystemState(workspaceRoot, systemId); if (config.status === "paused" || config.status === "archived") { throw new Error( `[mission.open] system '${systemId}' has status '${config.status}' — cannot open missions`, ); } if (state.currentMission) { throw new Error( `[mission.open] system '${systemId}' already has open mission '${state.currentMission}'`, ); } // Check pin file exists const cacheDir = resolveCacheClonePath(workspaceRoot, systemId); const pinPath = path.join(cacheDir, "system.pin.json"); if (!(await context.io.exists(pinPath))) { throw new Error( `[mission.open] system '${systemId}' has no system.pin.json — run sternsystem.pin first`, ); } // Read pin to get platform version const pinRaw = await context.io.readFile(pinPath); const pin = JSON.parse(pinRaw); const pinAtOpen = pin.platform?.version ?? "unknown"; // Derive next mission number from Bordbuch + existing directories on disk const bordbuchEntries = await readBordbuch(workspaceRoot, systemId); const nextNum = await deriveNextMissionNumberSafe(bordbuchEntries, workspaceRoot, systemId); const missionId = `${systemId}-m${String(nextNum).padStart(6, "0")}`; // Belt-and-suspenders: deriveNextMissionNumberSafe already scans disk // directories, but this guard ensures we never overwrite an existing // mission manifest even if the directory scan missed it (e.g. race). if (await missionExists(workspaceRoot, missionId)) { throw new Error( `[mission.open] mission directory '${missionId}' already exists — remove it or renumber before opening a new mission`, ); } // RFC-0796: Pre-flight cleanup of stale symlinks and empty directories // before creating mission directories. Non-empty real directories are // skipped with a warning. const staleEntries = await cleanupStaleMissionEntries(workspaceRoot, logger); // Create mission directories await createMissionDirectories(workspaceRoot, missionId); const now = new Date().toISOString(); const manifest: MissionManifest = { schemaVersion: "1.0.0", missionId, systemId, state: "open", brief, openedAt: now, openedBy: actor, closedAt: null, closedBy: null, pinAtOpen, materializedAt: null, migratedAt: null, reconciledAt: null, releaseId: null, rfcId: null, operationId, ...(laneFlag ? { lane: laneFlag as "content" | "full" } : {}), }; // RFC-0958: Build step context and run post-lock lifecycle via journal const openCtx: OpenStepCtx = { workspaceRoot, systemId, missionId, manifest, actor, now, pinAtOpen, operationId, staleEntries, context, state, materializedAt: null, }; const steps = await buildOpenSteps(workspaceRoot, missionId, openCtx); const journalPath = path.join(workspaceRoot, "missions", missionId, "journal.jsonl"); // RFC-0958 Step 7: block if a different-kind operation is incomplete const blockCheck = await checkDifferentKindOperation(journalPath, "mission.open"); if (blockCheck.blocked) { return { data: { missionId, systemId, state: "open" as const, openedAt: new Date().toISOString(), materializedAt: null, blockedByOperation: blockCheck.incompleteOp, } as unknown as MissionOpenData, exitCode: 1, summary: `[mission.open] ${missionId} blocked: incomplete '${blockCheck.incompleteOp}' operation found in journal`, nextSteps: [ { action: `Run: pnpm exec werkstatt run mission.resume --mission ${missionId} to resume or abandon the incomplete operation`, kind: "required", }, ], }; } const def: OperationDefinition = { op: "mission.open", steps }; const opResult = await runOperation(journalPath, def, openCtx, { missionId, platformVersion: "", }); if (!opResult.completed) { const detail = opResult.failedStepError ?? "unknown error"; const failedStep = opResult.failedStep; // Domain-specific rollback (RFC-0951, bug fix cleanup) if (failedStep === "bordbuch-append") { const missionDir = path.join(workspaceRoot, "missions", missionId); try { await context.io.rm(missionDir, { recursive: true }); } catch { // best-effort cleanup } throw new Error(detail); } if (failedStep === "auto-materialize") { // Forward-only rollback — RFC-0951, DNA-46 append-only log try { await appendAndCommitBordbuch( workspaceRoot, systemId, "mission-open-rolled-back", `Materialization failed: ${detail}`, actor, { missionId, writerRole: "mission", metadata: { reason: detail }, }, `Bordbuch: mission-open-rolled-back ${missionId}`, ); } catch (bordbuchRollbackErr) { logger.warn( `[mission.open] compensating bordbuch entry failed: ${bordbuchRollbackErr instanceof Error ? bordbuchRollbackErr.message : String(bordbuchRollbackErr)}. ` + `Run bordbuch.repair to fix the bordbuch manually.`, ); } const missionDir = path.join(workspaceRoot, "missions", missionId); const workpieceDir = path.join(missionDir, "workpiece"); const workpieceFailedDir = path.join(missionDir, "workpiece-failed"); // RFC-1033: Preserve workpiece for post-failure debugging before cleanup. // Rename workpiece/ to workpiece-failed/ and write failure-report.json. if (await context.io.exists(workpieceDir)) { try { // If a previous workpiece-failed/ exists, remove it first if (await context.io.exists(workpieceFailedDir)) { await context.io.rm(workpieceFailedDir, { recursive: true }); } await context.io.rename(workpieceDir, workpieceFailedDir); } catch { // best-effort — if rename fails, continue with deletion } } // Write failure-report.json alongside preserved workpiece try { const failureReport = { missionId, systemId, failedStep, error: detail, rolledBackAt: new Date().toISOString(), preservedAt: (await context.io.exists(workpieceFailedDir)) ? "missions/" + missionId + "/workpiece-failed" : null, }; await context.io.writeFile( path.join(missionDir, "failure-report.json"), JSON.stringify(failureReport, null, 2) + "\n", ); } catch { // best-effort } // RFC-0985 Measure 7: Write rollback marker before cleanup for crash safety try { await context.io.writeFile(path.join(missionDir, ".rollback-pending"), missionId); } catch { // best-effort — mission dir may not exist } // Remove everything except workpiece-failed/ and failure-report.json try { const entries = await context.io.readdir(missionDir); for (const entry of entries) { if (entry.name === "workpiece-failed" || entry.name === "failure-report.json") continue; await context.io.rm(path.join(missionDir, entry.name), { recursive: true }); } } catch { // best-effort cleanup } state.currentMission = null; await writeSystemState(workspaceRoot, systemId, state); await commitWerkstattSideEffects( workspaceRoot, [path.join("..", "systems-cache", systemId, "system-state.yaml")], `werkstatt: mission.open rollback ${missionId}`, ); // RFC-0985 Measure 3: Commit system-state.yaml to cache clone git const cacheCloneDir = resolveCacheClonePath(workspaceRoot, systemId); if (await context.io.exists(path.join(cacheCloneDir, ".git"))) { try { await execOrThrow( context.io, "sh", [ "-c", 'git add system-state.yaml && git commit -m "mission.open rollback: clear currentMission"', ], { cwd: cacheCloneDir, env: { MISSION_GIT_COMMIT: "1" } }, ); } catch { // Non-fatal — system-state.yaml may already be committed or unchanged } } throw new Error(`[mission.open] materialization failed — mission rolled back: ${detail}`); } throw new Error( `[mission.open] step "${failedStep}" failed: ${detail} — run mission.resume --mission ${missionId} to retry`, ); } const cc = openCtx; return { data: { missionId, systemId, state: "open", brief, openedAt: now, pinAtOpen, operationId, staleEntries, materializedAt: cc.materializedAt, }, exitCode: 0, summary: `[mission.open] opened and materialized mission ${missionId} for ${systemId}`, nextSteps: [ { action: `Validate the workpiece: pnpm exec werkstatt run mission.validate --mission ${missionId}`, kind: "optional", }, ], }; } finally { await releaseLock(workspaceRoot, `system:${systemId}`); } } // --------------------------------------------------------------------------- // RFC-0958: Journal-integrated open steps // --------------------------------------------------------------------------- export interface OpenStepCtx { workspaceRoot: string; systemId: string; missionId: string; manifest: MissionManifest; actor: ReturnType; now: string; pinAtOpen: string; operationId: string; staleEntries: StaleEntryCheck; context: KernelRuntimeContext; state: SystemState; materializedAt: string | null; } export async function buildOpenSteps( _workspaceRoot: string, _missionId: string, _ctx: unknown, ): Promise[]> { const steps: OperationStep[] = [ { name: "cache-clone-git-check", run: async (c: unknown) => { const cc = c as OpenStepCtx; // RFC-0985 Measure 4: Pre-flight cache clone git sanity check. const cacheDir = resolveCacheClonePath(cc.workspaceRoot, cc.systemId); if (!(await cc.context.io.exists(path.join(cacheDir, ".git")))) return; // Check detached HEAD const symRef = await cc.context.io.exec("git", ["symbolic-ref", "HEAD"], { cwd: cacheDir, }); if (symRef.exitCode !== 0) { throw new Error( `[mission.open] cache clone for '${cc.systemId}' is in detached HEAD. ` + `Run: git -C ${cacheDir} checkout main`, ); } // Check stale rebase-merge if (await cc.context.io.exists(path.join(cacheDir, ".git", "rebase-merge"))) { throw new Error( `[mission.open] stale rebase-merge found in cache clone for '${cc.systemId}'. ` + `Run: git -C ${cacheDir} rebase --abort`, ); } }, }, { name: "rollback-marker-check", run: async (c: unknown) => { const cc = c as OpenStepCtx; // RFC-0985 Measure 7: Crash-safe rollback — if a previous mission.open // was killed during rollback, complete the cleanup before proceeding. const missionsDir = path.join(cc.workspaceRoot, "missions"); if (!(await cc.context.io.exists(missionsDir))) return; const entries = await cc.context.io.readdir(missionsDir); for (const entry of entries) { if (!entry.isDirectory || entry.name === "archive") continue; const marker = path.join(missionsDir, entry.name, ".rollback-pending"); if (await cc.context.io.exists(marker)) { // Complete the rollback: remove mission dir, clear system-state const staleDir = path.join(missionsDir, entry.name); try { await cc.context.io.rm(staleDir, { recursive: true }); } catch { // best-effort } } } }, }, { name: "write-manifest", run: async (c: unknown) => { const cc = c as OpenStepCtx; await writeMissionManifest(cc.workspaceRoot, cc.manifest); }, }, { name: "bordbuch-append", run: async (c: unknown) => { const cc = c as OpenStepCtx; const { commitResult: pushResult } = await appendAndCommitBordbuch( cc.workspaceRoot, cc.systemId, "mission-open", cc.manifest.brief, cc.actor, { missionId: cc.missionId, writerRole: "mission", metadata: { brief: cc.manifest.brief, pinAtOpen: cc.pinAtOpen }, }, `Bordbuch: mission-open ${cc.missionId}`, ); if (pushResult.commitSha === null) { throw new Error( `[mission.open] bordbuch commit failed for system '${cc.systemId}' — mission-open event was not committed. ` + `Check git state in the cache clone and re-run mission.open.`, ); } if (!pushResult.pushed) { const cacheDir = resolveCacheClonePath(cc.workspaceRoot, cc.systemId); try { await gitExec(cacheDir, "reset --hard HEAD~1"); } catch { // best-effort rollback } throw new Error( `[mission.open] bordbuch push failed for system '${cc.systemId}' — mission-open event was rolled back. ` + `Error: ${pushResult.error ?? "unknown"}. ` + `Check git remote connectivity and re-run mission.open.`, ); } }, }, { name: "update-system-state", run: async (c: unknown) => { const cc = c as OpenStepCtx; cc.state.currentMission = cc.missionId; await writeSystemState(cc.workspaceRoot, cc.systemId, cc.state); }, }, { name: "auto-materialize", run: async (c: unknown) => { const cc = c as OpenStepCtx; const materializeResult = await runMissionMaterializeInternal( cc.workspaceRoot, cc.manifest, cc.context, { reportOnly: false, skipPreflight: false, force: false, skipOperationBlockCheck: true }, ); cc.materializedAt = materializeResult.data?.materializedAt ?? null; }, }, { name: "create-scope-registry", run: async (c: unknown) => { const cc = c as OpenStepCtx; const scopeManager = getDefaultScopeManager(); scopeManager.getRegistry({ scope: "per-mission", missionId: cc.missionId }); }, }, { name: "apply-mission-overlay", run: async (c: unknown) => { const cc = c as OpenStepCtx; applyOverlay({ id: `mission-${cc.missionId}`, scope: "per-mission", context: { scope: "per-mission", missionId: cc.missionId }, addOrReplace: new Map(), remove: [], priority: 100, }); }, }, { name: "commit-side-effects", run: async (c: unknown) => { const cc = c as OpenStepCtx; await commitWerkstattSideEffects( cc.workspaceRoot, [path.join("..", "systems-cache", cc.systemId, "system-state.yaml")], `werkstatt: mission.open ${cc.missionId}`, ); }, }, ]; return steps; }