/*
batch execution for werkstatt run — load a command manifest, execute each entry sequentially through executeKernelCommand in one process boot, aggregate per-entry results, and emit pretty or JSON output.
- Do not parse command-declared flags — entry argv tails flow through executeKernelCommand unchanged.
- Do not run entries in parallel — sequential execution only (DNA-105 isolation, one context per entry).
- The executor is injectable so tests can verify sequencing and aggregation without a real registry.
- RFC-1309: new batch runner — manifest loading (run/argv entry forms), per-entry flag override through consumeCommonFlags, stop-on-fail, aggregate exit codes, JSON payload per RFC-0972.
*/
// @ai-invariant batch entries execute strictly sequentially — the shared registry cache assumes serial command execution
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { parse as yamlParse } from "yaml";
import type {
ExecuteKernelCommandOptions,
KernelExecutionReport,
} from "@warpgogol/werkstatt-shared/kernel";
import { consumeCommonFlags, printFilesModified } from "./flags.ts";
/** Usage-level failure — manifests that are missing, unparseable, or shape-invalid. */
export class BatchUsageError extends Error {
constructor(message: string) {
super(message);
this.name = "BatchUsageError";
}
}
export interface BatchEntryResult {
run: string;
status: "pass" | "fail" | "error";
exitCode: number;
summary?: string;
}
export interface BatchReport {
command: "werkstatt.run.batch";
status: "pass" | "fail";
results: BatchEntryResult[];
}
export interface BatchGlobals {
siteName?: string;
allSites?: boolean;
dryRun?: boolean;
force?: boolean;
quiet?: boolean;
stopOnFail?: boolean;
outputFormat?: "pretty" | "json";
}
export type BatchExecutor = (
options: ExecuteKernelCommandOptions,
) => Promise;
export interface BatchRunOutcome {
report: BatchReport;
/** Aggregate exit code for pretty mode; JSON mode always exits 0 (RFC-0972). */
exitCode: number;
}
interface BatchManifestEntry {
run?: unknown;
argv?: unknown;
}
/** Reject `--batch` combined with a positional command name (RFC-1309). */
export function assertBatchHasNoPositional(remaining: string[]): void {
if (remaining.length > 0) {
throw new BatchUsageError(
`--batch cannot be combined with a positional command name (got \`${remaining[0]}\`).`,
);
}
}
function entryLabel(tokens: string[]): string {
return tokens.join(" ");
}
function entryTokens(entry: BatchManifestEntry, index: number): string[] {
const hasRun = typeof entry.run === "string";
const hasArgv = Array.isArray(entry.argv);
if (hasRun === hasArgv) {
throw new BatchUsageError(
`batch manifest entry ${index} must provide exactly one of \`run\` (string) or \`argv\` (array).`,
);
}
if (hasArgv) {
const argv = entry.argv as unknown[];
if (argv.length === 0 || !argv.every((token) => typeof token === "string")) {
throw new BatchUsageError(`batch manifest entry ${index} has a malformed \`argv\` array.`);
}
return argv as string[];
}
const tokens = (entry.run as string).split(/\s+/).filter((token) => token.length > 0);
if (tokens.length === 0) {
throw new BatchUsageError(`batch manifest entry ${index} has an empty \`run\` string.`);
}
return tokens;
}
async function loadManifest(manifestPath: string): Promise {
const resolved = resolve(manifestPath);
let raw: string;
try {
raw = await readFile(resolved, "utf-8");
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new BatchUsageError(`cannot read batch manifest \`${manifestPath}\`: ${detail}`);
}
let parsed: unknown;
try {
parsed = yamlParse(raw);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new BatchUsageError(
`batch manifest \`${manifestPath}\` is not valid YAML/JSON: ${detail}`,
);
}
const commands =
parsed !== null && typeof parsed === "object"
? (parsed as { commands?: unknown }).commands
: undefined;
if (!Array.isArray(commands) || commands.length === 0) {
throw new BatchUsageError(
`batch manifest \`${manifestPath}\` must declare a non-empty \`commands\` array.`,
);
}
for (const [index, entry] of commands.entries()) {
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
throw new BatchUsageError(`batch manifest entry ${index} must be an object.`);
}
}
return commands as BatchManifestEntry[];
}
/**
* Execute every manifest entry sequentially in this process. The process-lifetime
* registry cache inside executeKernelCommand (ADR-0022) amortizes the boot across
* entries — the runner itself performs no caching.
*/
export async function runBatch(options: {
workspaceRoot: string;
manifestPath: string;
globals: BatchGlobals;
execute: BatchExecutor;
}): Promise {
const { workspaceRoot, manifestPath, globals, execute } = options;
const entries = await loadManifest(manifestPath);
const outputFormat = globals.outputFormat ?? "pretty";
const results: BatchEntryResult[] = [];
let firstFailureExitCode: number | undefined;
for (const [index, entry] of entries.entries()) {
const tokens = entryTokens(entry, index);
const label = entryLabel(tokens);
const parsed = consumeCommonFlags(tokens);
const [commandName, ...argv] = parsed.remaining;
if (!commandName) {
throw new BatchUsageError(`batch manifest entry ${index} resolves to no command name.`);
}
if (outputFormat === "pretty") {
console.log(`=== ${label} ===`);
}
let exitCode: number;
let summary: string | undefined;
let status: BatchEntryResult["status"];
try {
const report = await execute({
workspaceRoot,
commandName,
argv,
siteName: parsed.siteName ?? globals.siteName,
siteExplicit: parsed.siteName !== undefined || globals.siteName !== undefined,
allSites: parsed.allSites || (globals.allSites ?? false),
dryRun: parsed.dryRun || (globals.dryRun ?? false),
force: parsed.force || (globals.force ?? false),
quiet: parsed.quiet || (globals.quiet ?? false),
outputFormat: parsed.outputFormat !== "pretty" ? parsed.outputFormat : outputFormat,
});
const reports = Array.isArray(report) ? report : [report];
const failedReport = reports.find((r) => !r.ok);
exitCode = failedReport?.exitCode ?? 0;
summary = reports[0]?.summary;
status = exitCode === 0 ? "pass" : "fail";
for (const entryReport of reports) {
printFilesModified(entryReport, outputFormat);
}
} catch (error) {
// Unknown command names and resolution failures record as entry errors;
// the batch continues unless --stop-on-fail.
exitCode = 1;
summary = error instanceof Error ? error.message : String(error);
status = "error";
if (outputFormat === "pretty") {
console.error(`[batch] ${label} — error: ${summary}`);
}
}
results.push({ run: label, status, exitCode, ...(summary ? { summary } : {}) });
if (exitCode !== 0 && firstFailureExitCode === undefined) {
firstFailureExitCode = exitCode;
}
if (exitCode !== 0 && globals.stopOnFail) {
break;
}
}
const passed = results.filter((r) => r.status === "pass").length;
const failedEntries = results.filter((r) => r.status !== "pass");
const report: BatchReport = {
command: "werkstatt.run.batch",
status: failedEntries.length === 0 ? "pass" : "fail",
results,
};
if (outputFormat === "pretty") {
console.log(`[batch] ${passed}/${results.length} commands passed`);
for (const failed of failedEntries) {
console.log(`[batch] failed: ${failed.run} (exit ${failed.exitCode})`);
}
}
return { report, exitCode: firstFailureExitCode ?? 0 };
}