/*
leitstand.ship.status — read-only renderer for ship operation journals (RFC-1168): parses ship-.jsonl into a step table, emits --json result or --follow tailing.
- Do not write to journals — this command is read-only.
- Do not change the journal schema — tolerate unknown record kinds.
- RFC-1168: initial ship journal reader — tolerant line parser (corrupt/truncated lines skipped+counted), resolveShipJournal over systems-cache siblings, buildShipStatus step table with resume-reset semantics, leitstand.ship.status with --follow mtime polling.
- RFC-1168: ship stderr progress + leitstand.ship.status reader
*/
import path from "node:path";
import type {
KernelCommandInput,
KernelCommandResult,
KernelRuntimeContext,
} from "@warpgogol/werkstatt-engine/kernel";
import type { JournalRecord } from "../journal/index.ts";
import { resolveCacheClonePath } from "../sternsystem/registry-io.ts";
import { flagSite } from "./deploy-helpers.ts";
function flagString(input: KernelCommandInput, key: string): string | undefined {
const v = input.flags[key];
return typeof v === "string" ? v : undefined;
}
const SHIP_JOURNAL_RE = /^ship-(.+)\.jsonl$/;
const FOLLOW_POLL_MS = 1000;
export interface ShipStepState {
seq: number;
name: string;
state: "pending" | "running" | "done" | "failed" | "skipped";
startedAt?: string;
durationMs?: number;
}
export interface ShipStatusResult {
command: "leitstand.ship.status";
mission: string;
release?: string;
opId?: string;
running: boolean;
steps: ShipStepState[];
skippedLines: number;
}
/** Tolerant parse — corrupt lines and a torn trailing line are skipped+counted. */
export function parseShipJournal(content: string): {
records: JournalRecord[];
skipped: number;
} {
const records: JournalRecord[] = [];
let skipped = 0;
for (const line of content.split("\n")) {
if (line.length === 0) continue;
try {
records.push(JSON.parse(line) as JournalRecord);
} catch {
skipped++;
}
}
return { records, skipped };
}
/**
* Latest record per (opId, seq) wins; the table resets at each op-started so
* a --resume run renders only the current attempt.
*/
export function buildShipStatus(
records: JournalRecord[],
fallbackMission: string,
): ShipStatusResult {
let opId: string | undefined;
let mission = fallbackMission;
let release: string | undefined;
let running = false;
let stepSeqs: number[] = [];
const stepState = new Map();
const stepStartedAt = new Map();
for (const r of records) {
if (r.kind === "op-started") {
opId = r.opId;
mission = r.missionId || mission;
release = undefined;
running = true;
stepSeqs = [];
stepState.clear();
stepStartedAt.clear();
continue;
}
if (r.opId !== opId) continue;
// Resumed ops can emit done/failed/skipped without a fresh step-started
// for the same seq — register the seq in every step-* branch.
switch (r.kind) {
case "step-started":
stepStartedAt.set(r.seq, r.at);
if (!stepSeqs.includes(r.seq)) stepSeqs.push(r.seq);
stepState.set(r.seq, {
seq: r.seq,
name: r.step,
state: "running",
startedAt: r.at,
});
break;
case "step-done": {
if (!stepSeqs.includes(r.seq)) stepSeqs.push(r.seq);
const started = stepStartedAt.get(r.seq);
stepState.set(r.seq, {
seq: r.seq,
name: r.step,
state: "done",
startedAt: started,
durationMs: started ? Date.parse(r.at) - Date.parse(started) : undefined,
});
if (r.step === "release-prepare" && r.meta?.releaseId) {
release = r.meta.releaseId as string;
}
break;
}
case "step-failed": {
if (!stepSeqs.includes(r.seq)) stepSeqs.push(r.seq);
const started = stepStartedAt.get(r.seq);
stepState.set(r.seq, {
seq: r.seq,
name: r.step,
state: "failed",
startedAt: started,
durationMs: started ? Date.parse(r.at) - Date.parse(started) : undefined,
});
break;
}
case "step-skipped":
if (!stepSeqs.includes(r.seq)) stepSeqs.push(r.seq);
stepState.set(r.seq, { seq: r.seq, name: r.step, state: "skipped" });
break;
case "op-done":
case "op-abandoned":
running = false;
break;
default:
break; // unknown kinds tolerated
}
}
stepSeqs.sort((a, b) => a - b);
return {
command: "leitstand.ship.status",
mission,
release,
opId,
running,
steps: stepSeqs.map((seq) => stepState.get(seq)!),
skippedLines: 0,
};
}
/**
* Resolves `systems-cache//operations/ship-.jsonl`. Without
* --mission, picks the newest ship-*.jsonl for the site; without --site,
* scans every systems-cache dir for ship-.jsonl.
*/
export async function resolveShipJournal(
workspaceRoot: string,
io: KernelRuntimeContext["io"],
site: string | undefined,
mission: string | undefined,
): Promise<{ path: string; site: string; mission: string } | null> {
// resolveCacheClonePath is the canonical resolver — derive the shared
// systems-cache root from it so test mocks apply here too.
const cacheRoot = path.dirname(resolveCacheClonePath(workspaceRoot, "__"));
const candidateOpsDirs = async (): Promise<{ site: string; dir: string }[]> => {
if (site)
return [{ site, dir: path.join(resolveCacheClonePath(workspaceRoot, site), "operations") }];
try {
const entries = await io.readdir(cacheRoot);
return entries
.filter((e) => e.isDirectory)
.map((e) => ({
site: e.name,
dir: path.join(resolveCacheClonePath(workspaceRoot, e.name), "operations"),
}));
} catch {
return [];
}
};
const candidates: { path: string; site: string; mission: string; mtimeMs: number }[] = [];
for (const { site: siteId, dir } of await candidateOpsDirs()) {
const wanted = mission !== undefined ? [`ship-${mission}.jsonl`] : [];
let names: string[] = [];
try {
names = (await io.readdir(dir)).filter((e) => e.isFile).map((e) => e.name);
} catch {
continue; // operations dir may not exist yet
}
for (const name of wanted.length > 0 ? wanted : names) {
const m = SHIP_JOURNAL_RE.exec(name);
if (!m || !names.includes(name)) continue;
const p = path.join(dir, name);
let mtimeMs = 0;
try {
mtimeMs = (await io.stat(p)).mtimeMs;
} catch {
continue;
}
candidates.push({ path: p, site: siteId, mission: m[1], mtimeMs });
}
}
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
const best = candidates[0];
return best ? { path: best.path, site: best.site, mission: best.mission } : null;
}
function formatStepLine(s: ShipStepState, total: number): string {
const dur = s.durationMs !== undefined ? ` (${Math.round(s.durationMs / 1000)}s)` : "";
return `[ship] step ${s.seq + 1}/${total}: ${s.name} — ${s.state}${dur}`;
}
async function sleep(ms: number): Promise {
return new Promise((r) => setTimeout(r, ms));
}
export async function runLeitstandShipStatus(
input: KernelCommandInput,
context: KernelRuntimeContext,
): Promise> {
const { workspaceRoot, logger } = context;
const systemId = flagSite(input);
const missionFlag = flagString(input, "mission");
const follow = input.flags["follow"] === true;
if (!systemId && !missionFlag) {
throw new Error("[leitstand.ship.status] --site or --mission is required");
}
const resolved = await resolveShipJournal(workspaceRoot, context.io, systemId, missionFlag);
if (!resolved) {
// Absence is informational (RFC-1168 failure modes) — exit 0.
return {
data: {
command: "leitstand.ship.status",
mission: missionFlag ?? "",
running: false,
steps: [],
skippedLines: 0,
},
summary: "[leitstand.ship.status] no ship journal found",
exitCode: 0,
};
}
const emit = (line: string): void => {
try {
process.stderr.write(`${line}\n`);
} catch {
// Closed stderr must not break the command.
}
};
const readAndRender = async (): Promise => {
const content = await context.io.readFile(resolved.path);
const { records, skipped } = parseShipJournal(content);
const status = buildShipStatus(records, resolved.mission);
status.skippedLines = skipped;
return status;
};
let status = await readAndRender();
if (follow && status.running) {
logger.info(` [ship.status] following ${resolved.path} (Ctrl-C to stop)`);
// Emit (seq,state) pairs not yet seen — idempotent across polls.
const seen = new Set(status.steps.map((s) => `${s.seq}:${s.state}`));
while (status.running) {
await sleep(FOLLOW_POLL_MS);
if (!(await context.io.exists(resolved.path))) {
emit(`[ship.status] journal ${resolved.path} gone — stopping`);
break;
}
status = await readAndRender();
const total = status.steps.length;
for (const s of status.steps) {
const key = `${s.seq}:${s.state}`;
if (seen.has(key)) continue;
seen.add(key);
emit(
s.state === "running"
? `[ship] step ${s.seq + 1}/${total}: ${s.name} — running`
: formatStepLine(s, total),
);
}
}
if (!status.running) emit(`[ship.status] finished — mission ${status.mission}`);
}
const total = status.steps.length;
const rendered = status.steps.map((s) => formatStepLine(s, total));
for (const line of rendered) logger.info(` ${line}`);
logger.info(
` [ship.status] mission=${status.mission} running=${status.running}` +
(status.release ? ` release=${status.release}` : "") +
(status.skippedLines > 0 ? ` skippedLines=${status.skippedLines}` : ""),
);
return {
data: status,
summary: `[leitstand.ship.status] ${status.mission}: ${total} step(s), running=${status.running}${status.release ? `, release=${status.release}` : ""}`,
exitCode: 0,
};
}