/* Pipeline execution: run an ordered list of pipeline steps (app-scoped or workspace-scoped) through executeRegisteredCommand, recording per-step timing/telemetry (RFC-0270) and producing a KernelPipelineReport with a timing summary (slowest steps, timeout count). Do not introduce app-specific runtime composition or deployment behavior into this reusable package source file. Pipeline steps run in order — a failing step halts the pipeline unless marked non-blocking. RFC-1138: pipeline hygiene — module-scoped exempt entries, archive gitignore guard, dns upsert to deploy phases, promote auto-sync, rfc.create claim protocol, siteHasRuntime filter RFC-1144: run read-only steps to completion in collect-errors pipeline mode Scheduler: collectErrors option on executeScheduledSteps — read-only steps (ScheduledStep.readOnly, executor-resolved from mutatesState === false) run after dependency failures with ranAfterFailedDependency annotation; ranTainted set propagates taint transitively so mutating dependents still skip. Executor: mergeCollectErrorsOption OR-merges PipelineDeclaration.collectErrors with the CLI flag at registry resolution; readOnly annotated post-buildSchedule; taint flag copied to step reports; tainted failures marked in pretty output. Contracts: PipelineDeclaration.collectErrors, KernelExecutionReport.ranAfterFailedDependency, pipelines maps store full declarations. Declarations: collectErrors on validation-gate registrations (build.post, check, build.check, compass, integrity.release in the workpiece template; packages.check in checks module). Tests: 10 new cases covering AC-1..AC-6 plus transitive taint. RFC-1149: migrate ambient filesystem and process IO to the WorkspaceIO port across engine and shared RFC-1231: allSites context carrier on pipeline contexts Both pipeline context-construction sites (site fan-out and workspace) populate KernelRuntimeContext.allSites from options.allSites so the executor's flags.all injection sees the consumed --all selector for steps declaring it. RFC-1249: wire werkstatt.commands.validate into packages.check + retire CMD-OUTPUT debt ADR-0022, ADR-0023, ADR-0087, RFC-0303, RFC-0326, RFC-0390, RFC-0637, RFC-0686, RFC-0687, RFC-0809, RFC-1028, RFC-1097, RFC-1126, RFC-1130, RFC-1133 */ import { performance } from "node:perf_hooks"; import process from "node:process"; import os from "node:os"; import { join } from "node:path"; import { createKernelLogger } from "../logger.ts"; import type { ActualState, PipelineDeclaration } from "@warpgogol/werkstatt-shared/kernel"; import { batchAppendStepTelemetry, loadPipelineBudgets, lookupExpectedDurationMs, type StepTelemetryRecord, } from "../pipeline-budgets.ts"; import { createDefaultIO, EMPTY_WORKPIECE_ENV, loadWorkpieceEnv, type WorkspaceIO, } from "@warpgogol/werkstatt-shared/kernel"; import { createCacheLayer } from "../cache/cache-layer.ts"; import { buildWorkspaceTreeIndex } from "../cache/workspace-tree-index.ts"; import type { WorkspaceTreeIndex } from "../cache/workspace-tree-index.ts"; import type { DiscoveredSiteWorkspace, ExecuteKernelPipelineOptions, KernelExecutionReport, KernelPipelineReport, KernelPipelineStep, KernelPipelineTimingSummary, KernelCommandDefinition, KernelRuntimeContext, PipelineStepTiming, } from "@warpgogol/werkstatt-shared/kernel"; import { executeRegisteredCommand, computeOwnershipMap } from "./execute-command.ts"; import { assertKnownOptionKeys, skippedExecutionReport } from "./shared.ts"; import { collectGitignoredPaths, isClosedWorkpiece, mutatingStepRunsOnClosedWorkpiece, writePatternToCheckPath, } from "./closed-workpiece.ts"; import { ensureTargetSites, loadAppRuntime } from "./registry.ts"; import { getOrBuildWorkspaceRegistry } from "./registry-cache.ts"; import { buildSchedule, executeScheduledSteps, type ScheduledStep, type StepExecutionResult, } from "./pipeline-scheduler.ts"; // @ai-invariant: Pipeline steps execute in declared order — a failing step halts the pipeline unless non-blocking or collectErrors read-only continuation applies. const PIPELINE_TIMING_SUMMARY_THRESHOLD_MS = 30_000; /** * RFC-0809: Pure function for collect-errors post-processing. * * When `collectErrors` is true and there are failed steps (excluding * dependency-skipped ones), returns an object with `failedSteps` (command * names), `exitCode` (from the first failure), and `ok: false`. * * When `collectErrors` is false, or when there are no failures, returns * `undefined` — the caller falls through to existing fail-fast or success * logic. */ export function aggregateCollectErrors( sortedResults: StepExecutionResult[], collectErrors: boolean, ): { failedSteps: string[]; exitCode: number; ok: false } | undefined { if (!collectErrors) return undefined; const failedResults = sortedResults.filter((r) => !r.report.ok && !r.dependencySkipped); if (failedResults.length === 0) return undefined; const collected: { failedSteps: string[]; exitCode: number; ok: false } = { failedSteps: failedResults.map((r) => r.report.commandName), exitCode: failedResults[0]!.report.exitCode, ok: false, }; return collected; } /** * RFC-0809: Build a KernelPipelineReport for collect-errors mode. * Shared between executePipelineForSite and executePipelineForWorkspace * to avoid duplicated post-processing logic. */ function buildCollectErrorsReport( collected: { failedSteps: string[]; exitCode: number; ok: false }, reports: KernelExecutionReport[], pipelineName: string, timing: KernelPipelineTimingSummary, prefix: string, siteName?: string, quiet?: boolean, ): KernelPipelineReport { for (const name of collected.failedSteps) { const failedReport = reports.find((r) => r.commandName === name); progressLine(` [FAIL] ${name}: ${failedReport?.summary ?? "failed"}`, quiet); } progressLine( `${prefix} pipeline ${pipelineName} — FAILED (${collected.failedSteps.length} step(s) failed, ${formatDuration(timing.totalDurationMs)})`, quiet, ); const report: KernelPipelineReport = { ...(siteName ? { siteName } : {}), pipelineName, exitCode: collected.exitCode, ok: false, steps: reports, timing, filesModified: aggregateFilesModified(reports), failedSteps: collected.failedSteps, }; return report; } // RFC-0732: convert pipeline-level flags record to CLI args for step execution. function pipelineFlagsToArgs(flags: Record | undefined): string[] { if (!flags) return []; const args: string[] = []; for (const [key, value] of Object.entries(flags)) { if (value === true) { args.push(`--${key}`); } else if (value === false || value === null || value === undefined) { // Skip false/null/undefined flags } else { args.push(`--${key}`, String(value)); } } return args; } function progressLine(message: string, quiet?: boolean): void { // RFC-1309: --quiet suppresses pipeline progress preamble; step diagnostics // (logger.warn/error inside commands) are unaffected. if (quiet) return; process.stderr.write(`${message}\n`); } function stepStatusLabel(report: KernelExecutionReport): string { if (report.cached) return "SKIP (cached)"; if (report.exitCode === 0 && report.summary?.startsWith("Skipped:")) return "SKIP"; if (report.warned) return "WARN (warn-only)"; if (report.timing.exceededTimeout) return "TIMEOUT"; if (!report.ok) return "FAIL"; if ((report.logSummary?.warning ?? 0) > 0) return "WARN"; return "OK"; } function stepStatus(report: KernelExecutionReport): PipelineStepTiming["status"] { if (report.cached) return "skipped"; if (report.exitCode === 0 && report.summary?.startsWith("Skipped:")) return "skipped"; if (report.warned) return "warn"; if (report.timing.exceededTimeout) return "timeout"; if (!report.ok) return "fail"; if ((report.logSummary?.warning ?? 0) > 0) return "warn"; return "pass"; } /** * RFC-1184: warn-only rollout mode — when a step declares `warnOnly: true` * and its report failed, the failure is preserved on the report (exitCode, * summary, `warned: true`) but `ok` is flipped so the scheduler treats the * step as completed: the pipeline continues, dependents are not skipped or * tainted, and `aggregateCollectErrors` never lists it among failedSteps. * Blocking stays the default — warnOnly is explicit per-step opt-in for * staged rollout of newly blocking gates (RFC-1179/1180 precedent). */ export function applyWarnOnly(report: KernelExecutionReport, step: KernelPipelineStep): void { if (step.warnOnly === true && !report.ok) { report.ok = true; report.warned = true; report.summary = `[warn-only] ${report.summary ?? `${step.command} failed`}`; } } /** * RFC-0687: Check whether a report was skipped by transitive cache skip. * Used to exclude transitive-cache-skip reports from telemetry. */ function isTransitiveSkip(report: KernelExecutionReport): boolean { return report.summary?.startsWith("Skipped: transitive-cache-skip") ?? false; } function isClosedMissionSkip(report: KernelExecutionReport): boolean { return report.summary?.startsWith("Skipped: closed-mission") ?? false; } // --------------------------------------------------------------------------- // ADR-0085/0087: closed-mission workpiece helpers live in ./closed-workpiece.ts // (leaf module shared with execute-command.ts — the executor-level guard). // --------------------------------------------------------------------------- function pipelineTimingSummary( pipelineName: string, reports: KernelExecutionReport[], stepTimings: PipelineStepTiming[], siteName?: string, ): KernelPipelineTimingSummary { const summedDurationMs = stepTimings.reduce((sum, step) => sum + step.durationMs, 0); // RFC-0686: wall-clock total = min(startedAt) to max(endedAt) across all steps. const minStart = stepTimings.length > 0 ? Math.min(...stepTimings.map((s) => s.startedAtMonotonicMs)) : 0; const maxEnd = stepTimings.length > 0 ? Math.max(...stepTimings.map((s) => s.endedAtMonotonicMs)) : 0; const totalDurationMs = stepTimings.length > 0 ? Math.max(0, maxEnd - minStart) : summedDurationMs; const failed = reports.find((report) => !report.ok); return { pipeline: pipelineName, ...(siteName ? { site: siteName } : {}), totalDurationMs, summedDurationMs, stepCount: stepTimings.length, slowestSteps: [...stepTimings].sort((a, b) => b.durationMs - a.durationMs).slice(0, 6), timeoutCount: stepTimings.filter((step) => step.exceededTimeout).length, warningCount: reports.reduce((sum, report) => sum + (report.logSummary?.warning ?? 0), 0), ...(failed ? { failedStep: failed.commandName } : {}), }; } function formatDuration(ms: number): string { const seconds = Math.round(ms / 1000); if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); const rest = seconds % 60; return `${minutes}m ${rest}s`; } function printPipelineTimingSummary( logger: KernelRuntimeContext["logger"], summary: KernelPipelineTimingSummary, ): void { if ( summary.totalDurationMs < PIPELINE_TIMING_SUMMARY_THRESHOLD_MS && summary.timeoutCount === 0 ) { return; } const target = summary.app ? `${summary.app}: ${summary.pipeline}` : `workspace: ${summary.pipeline}`; logger.section(`${target} timing`); logger.info( `[OK] wall-clock ${formatDuration(summary.totalDurationMs)}, summed ${formatDuration(summary.summedDurationMs ?? summary.totalDurationMs)}, ${summary.stepCount} step(s), ${summary.timeoutCount} timeout(s)`, ); if (summary.slowestSteps.length > 0) { logger.info("slowest:"); summary.slowestSteps.forEach((step, index) => { logger.info(` ${index + 1}. ${step.command} ${formatDuration(step.durationMs)}`); }); } } /** * RFC-0326: deduplicate and aggregate filesModified across all step reports. */ function aggregateFilesModified(reports: KernelExecutionReport[]): string[] { const seen = new Set(); const result: string[] = []; for (const report of reports) { for (const path of report.filesModified ?? []) { if (!seen.has(path)) { seen.add(path); result.push(path); } } } return result; } /** * RFC-0686: Resolve concurrency from options or default to min(availableParallelism, 8). */ function resolveConcurrency(options: ExecuteKernelPipelineOptions): number { if (options.concurrency !== undefined) return options.concurrency; const available = os.availableParallelism?.() ?? 4; return Math.min(available, 8); } // --------------------------------------------------------------------------- // RFC-0687: Transitive cache skip for validator chains // --------------------------------------------------------------------------- /** * RFC-0687: Tracks which commands were cache hits during a pipeline run, plus the * pipeline name for cross-pipeline persistence. */ export interface PipelineRunState { cacheHitCommands: Set; pipelineName: string; } /** * RFC-0687: Determine whether a command should be transitively skipped. * * The algorithm has 3 steps: * 1. If the command is `cacheable: false`, never skip — it must always run. * 2. If the command has no `validatesOutputs`, never skip — it is not a * validator that checks another command's output. * 3. If all entries in `validatesOutputs` are in `cacheHitCommands`, skip — * the validator's upstream generators were all cache hits, so their * outputs are unchanged. * * No `reads[]` hash computation is performed — the skip is based solely on * the upstream cache-hit status. The safety net is `cacheable: false` * validators (e.g. `generated.drift.validate`) which always run and catch * manual edits to generated files. */ export function shouldTransitiveSkip( command: KernelCommandDefinition, runState: PipelineRunState, ): boolean { if (command.cacheable === false) return false; const validatesOutputs = command.validatesOutputs; if (!validatesOutputs || validatesOutputs.length === 0) return false; return validatesOutputs.every((cmd) => runState.cacheHitCommands.has(cmd)); } const PIPELINE_CACHE_HITS_DIR = ".cache"; const PIPELINE_CACHE_HITS_FILENAME = "pipeline-cache-hits.json"; const PIPELINE_CACHE_HITS_STALENESS_MS = 30 * 60 * 1000; // 30 minutes /** * Shape of the persisted cache-hit file. Each pipeline name maps to an * array of command names that were cache hits, plus a timestamp. */ interface PipelineCacheHitsFile { pipelines: Record; } /** * RFC-0687: Load cache-hit commands from other pipeline runs. * * Reads `.cache/pipeline-cache-hits.json` and merges entries from pipelines * other than `currentPipelineName` that are within the 30-minute TTL. * * Handles missing, corrupt, or stale files gracefully — returns an empty * set on any error. */ export async function loadImportedCacheHits( workspaceRoot: string, currentPipelineName: string, io: WorkspaceIO = createDefaultIO().io, ): Promise> { const filePath = join(workspaceRoot, PIPELINE_CACHE_HITS_DIR, PIPELINE_CACHE_HITS_FILENAME); let raw: string; try { raw = await io.readFile(filePath); } catch { return new Set(); } let data: PipelineCacheHitsFile; try { data = JSON.parse(raw) as PipelineCacheHitsFile; } catch { return new Set(); } if (!data.pipelines || typeof data.pipelines !== "object") return new Set(); const now = Date.now(); const merged = new Set(); for (const [pipelineName, entry] of Object.entries(data.pipelines)) { if (pipelineName === currentPipelineName) continue; if (!entry || !Array.isArray(entry.commands)) continue; if (now - (entry.writtenAt ?? 0) > PIPELINE_CACHE_HITS_STALENESS_MS) continue; for (const cmd of entry.commands) { merged.add(cmd); } } return merged; } /** * RFC-0687: Persist cache-hit commands for the current pipeline run. * * Writes `cacheHitCommands` under `pipelineName` in * `.cache/pipeline-cache-hits.json`, replacing any previous entry for that * pipeline. Preserves entries for other pipelines. */ export async function persistCacheHits( workspaceRoot: string, pipelineName: string, cacheHitCommands: Set, io: WorkspaceIO = createDefaultIO().io, ): Promise { const dir = join(workspaceRoot, PIPELINE_CACHE_HITS_DIR); const filePath = join(dir, PIPELINE_CACHE_HITS_FILENAME); let existing: PipelineCacheHitsFile = { pipelines: {} }; try { const raw = await io.readFile(filePath); existing = JSON.parse(raw) as PipelineCacheHitsFile; if (!existing.pipelines || typeof existing.pipelines !== "object") { existing = { pipelines: {} }; } } catch { // File missing or corrupt — start fresh. } existing.pipelines[pipelineName] = { commands: [...cacheHitCommands].sort(), writtenAt: Date.now(), }; try { await io.mkdir(dir); await io.writeFile(filePath, JSON.stringify(existing, null, 2) + "\n"); } catch { // Non-fatal — persistence is best-effort. } } /** * RFC-0687: Clear the pipeline cache-hits file. Called when `--force` is set. */ export async function clearPipelineCacheHits( workspaceRoot: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { const dir = join(workspaceRoot, PIPELINE_CACHE_HITS_DIR); const filePath = join(dir, PIPELINE_CACHE_HITS_FILENAME); try { await io.mkdir(dir); await io.writeFile(filePath, JSON.stringify({ pipelines: {} }, null, 2) + "\n"); } catch { // Non-fatal — clear is best-effort. } } async function executePipelineForSite( site: DiscoveredSiteWorkspace, registry: ActualState, options: ExecuteKernelPipelineOptions, steps: KernelPipelineStep[], ): Promise { // RFC-0270: a generated budget entry, when present, wins over the inline // expectedDurationMs — the inline value stays as the cold-start fallback. const budgets = await loadPipelineBudgets(options.workspaceRoot); const totalSteps = steps.length; // RFC-0390: create cache layer and module hash cache for this pipeline run. const cache = await createCacheLayer(options.workspaceRoot); const moduleHashCache = new Map(); // RFC-0685: build workspace tree index once per pipeline run. let treeIndex: WorkspaceTreeIndex | undefined; try { treeIndex = await buildWorkspaceTreeIndex(options.workspaceRoot); } catch { treeIndex = undefined; } const concurrency = resolveConcurrency(options); // ADR-0023: batch telemetry records in-memory; write once at pipeline completion. const telemetryBatch: StepTelemetryRecord[] = []; progressLine( `[${site.name}] pipeline ${options.pipelineName} — ${totalSteps} step(s), concurrency ${concurrency}`, options.quiet, ); // RFC-0687: load cross-pipeline cache hits and set up run state. if (options.force ?? false) { await clearPipelineCacheHits(options.workspaceRoot); } const importedHits = await loadImportedCacheHits(options.workspaceRoot, options.pipelineName); const runState: PipelineRunState = { cacheHitCommands: importedHits, pipelineName: options.pipelineName, }; const scheduled = buildSchedule(steps); // RFC-1144: pre-resolve read-only classification. annotateReadOnlySteps(scheduled, registry); // ADR-0085: closed-mission workpiece — mutating steps become no-ops unless // every declared write is confined to gitignored paths (dist/, .cache/, …). const workpieceClosed = await isClosedWorkpiece(site.directory, createDefaultIO().io); const closedIgnoredPaths = workpieceClosed ? await collectGitignoredPaths( site.directory, steps.flatMap((s) => { const cmd = registry.commands.get(s.command); if (!cmd || cmd.mutatesState === false) return []; return (cmd.writes ?? []) .map((w) => writePatternToCheckPath(w, site.name)) .filter((p): p is string => p !== null); }), createDefaultIO().io, ) : null; if (workpieceClosed) { progressLine( `[${site.name}] workpiece is closed (ADR-0085) — mutating steps run only when all writes are gitignored`, options.quiet, ); } const stepTimings: Map = new Map(); try { const results = await executeScheduledSteps( scheduled, concurrency, async (sStep: ScheduledStep) => { const { step, stepIndex } = sStep; const command = registry.commands.get(step.command); if (!command) { throw new Error( `Kernel pipeline step \`${step.command}\` is not registered for site \`${site.name}\`.`, ); } const logger = createKernelLogger(options.outputFormat ?? "pretty"); const { io, intents } = createDefaultIO(); const ownershipMap = await computeOwnershipMap(registry); const context: KernelRuntimeContext = { workspaceRoot: options.workspaceRoot, site, siteExplicit: false, logger, dryRun: options.dryRun ?? false, allSites: options.allSites ?? false, outputFormat: options.outputFormat ?? "pretty", io, fileIntents: intents, actualState: registry, ownershipMap, workpieceEnv: await loadWorkpieceEnv(site.directory), // RFC-1133: the executor-level cache block inside // executeRegisteredCommand consumes this carrier — the pipeline no // longer calls the cache helpers itself. resultCache: { layer: cache, treeIndex, moduleHashCache }, }; const stepLabel = `[${stepIndex + 1}/${totalSteps}]`; if (options.outputFormat !== "json") { logger.section(`${site.name}: ${options.pipelineName} -> ${step.command}`); } progressLine(`${stepLabel} ${step.command} …`, options.quiet); const budgetedExpectedDurationMs = lookupExpectedDurationMs( budgets, options.pipelineName, step.command, site.name, ); const startedAtMonotonicMs = Math.round(performance.now()); let report: KernelExecutionReport; if (step.skip) { report = skippedExecutionReport(command, context, step.skipReason); } else if ( workpieceClosed && command.mutatesState !== false && !mutatingStepRunsOnClosedWorkpiece(command, site.name, closedIgnoredPaths) ) { // ADR-0085: closed workpiece — a mutating step with tracked or // undeclared writes would produce uncommittable churn. report = skippedExecutionReport(command, context, "closed-mission"); } else if (shouldTransitiveSkip(command, runState)) { // RFC-0687: transitive cache skip — all upstream commands were cache hits. report = skippedExecutionReport(command, context, "transitive-cache-skip"); } else { // RFC-1133: the cache read/write lives inside executeRegisteredCommand // (single choke point). The pipeline only supplies argv and the // resultCache carrier on the context. const stepArgs = [...(step.args ?? []), ...pipelineFlagsToArgs(options.flags)]; // Inject --site for workspace-scoped commands so they receive the site // name from the pipeline context (mirrors executeKernelCommand logic). if ( command.scope === "workspace" && !stepArgs.some((a) => a === "--site" || a.startsWith("--site=")) && site.name ) { stepArgs.push("--site", site.name); } // RFC-0814: Auto-inject --system for workspace-scoped commands that accept it. // The system ID is the same as the site name (RFC-0790 1:1 convention). // RFC-0817: Use pattern matching to detect both --system and --system=value formats. if ( command.scope === "workspace" && !stepArgs.some((a) => a === "--system" || a.startsWith("--system=")) && site.name ) { const acceptsSystem = !command.flags || ("system" in command.flags && command.flags.system.kind === "string"); if (acceptsSystem) { stepArgs.push("--system", site.name); } } report = await executeRegisteredCommand(command, context, stepArgs, { timeoutMs: step.timeoutMs, expectedDurationMs: budgetedExpectedDurationMs ?? step.expectedDurationMs, }); } // RFC-1184: warn-only steps record the failure but do not block. applyWarnOnly(report, step); // RFC-0687 + RFC-1133: a cached report feeds the transitive-skip set — // on both executors now (the app-scoped path previously missed this). if (report.cached === true) { runState.cacheHitCommands.add(step.command); } const endedAtMonotonicMs = Math.round(performance.now()); // RFC-1144: mark possibly-cascaded failures so operators can discount them. const taintedNote = sStep.ranAfterFailedDependency === true && !report.ok ? " (ran after failed dependency — may be cascaded)" : ""; progressLine( `${stepLabel} ${step.command} — ${stepStatusLabel(report)} ${formatDuration(report.timing.durationMs)}${taintedNote}`, options.quiet, ); // ADR-0023: collect telemetry in-memory; batch write at pipeline completion. // ADR-0085: closed-mission skips are excluded — a 0ms skip would // corrupt the expectedDurationMs budgets derived from telemetry. if ( !step.skip && !report.cached && !isTransitiveSkip(report) && !isClosedMissionSkip(report) ) { telemetryBatch.push({ pipeline: options.pipelineName, command: step.command, app: site.name, durationMs: report.timing.durationMs, timedOut: report.timing.exceededTimeout, recordedAt: new Date().toISOString(), }); } stepTimings.set(stepIndex, { pipeline: options.pipelineName, command: step.command, app: site.name, packageName: site.packageName, status: stepStatus(report), startedAtMonotonicMs, endedAtMonotonicMs, durationMs: report.timing.durationMs, ...(report.timing.timeoutMs !== undefined ? { timeoutMs: report.timing.timeoutMs } : {}), ...(report.timing.expectedDurationMs !== undefined ? { expectedDurationMs: report.timing.expectedDurationMs } : {}), exceededTimeout: report.timing.exceededTimeout, }); // RFC-1144: copy the scheduler's taint annotation onto the report. if (sStep.ranAfterFailedDependency === true) { report.ranAfterFailedDependency = true; } return report; }, { collectErrors: options.collectErrors === true }, ); // Sort results by stepIndex (declaration order). const sortedResults = [...results].sort((a, b) => a.stepIndex - b.stepIndex); const reports = sortedResults.map((r) => r.report); const orderedStepTimings = scheduled.map((s) => stepTimings.get(s.stepIndex)!).filter(Boolean); const failed = reports.find((report) => !report.ok); const timing = pipelineTimingSummary( options.pipelineName, reports, orderedStepTimings, site.name, ); // ADR-0023: batch write all telemetry records in a single I/O operation. await batchAppendStepTelemetry(options.workspaceRoot, telemetryBatch); // RFC-0687: persist cache hits for cross-pipeline transitive skip. await persistCacheHits(options.workspaceRoot, options.pipelineName, runState.cacheHitCommands); if (options.outputFormat !== "json") { const logger = createKernelLogger(options.outputFormat ?? "pretty"); printPipelineTimingSummary(logger, timing); } // RFC-0809: collect-errors mode — aggregate all independent failures. const collected = aggregateCollectErrors(sortedResults, options.collectErrors ?? false); if (collected) { return buildCollectErrorsReport( collected, reports, options.pipelineName, timing, `[${site.name}]`, site.name, options.quiet, ); } if (failed) { progressLine( `[${site.name}] pipeline ${options.pipelineName} — FAILED at step ${failed.commandName} (${formatDuration(timing.totalDurationMs)})`, options.quiet, ); const failedReport: KernelPipelineReport = { siteName: site.name, pipelineName: options.pipelineName, exitCode: failed.exitCode, ok: false, steps: reports, timing, filesModified: aggregateFilesModified(reports), }; return failedReport; } progressLine( `[${site.name}] pipeline ${options.pipelineName} — DONE ${formatDuration(timing.totalDurationMs)} (${timing.stepCount} step(s), ${timing.timeoutCount} timeout(s))`, options.quiet, ); const doneReport: KernelPipelineReport = { siteName: site.name, pipelineName: options.pipelineName, exitCode: 0, ok: true, steps: reports, timing, filesModified: aggregateFilesModified(reports), }; return doneReport; } finally { // ADR-0023: close the shared CacheLayer SQLite connection after pipeline completion. await cache.close(); } } async function executePipelineForWorkspace( registry: ActualState, options: ExecuteKernelPipelineOptions, steps: KernelPipelineStep[], ): Promise { // RFC-0270: a generated budget entry, when present, wins over the inline // expectedDurationMs — the inline value stays as the cold-start fallback. const budgets = await loadPipelineBudgets(options.workspaceRoot); const totalSteps = steps.length; // RFC-0390: create cache layer and module hash cache for this pipeline run. const cache = await createCacheLayer(options.workspaceRoot); const moduleHashCache = new Map(); // RFC-0685: build workspace tree index once per pipeline run. let treeIndex: WorkspaceTreeIndex | undefined; try { treeIndex = await buildWorkspaceTreeIndex(options.workspaceRoot); } catch { treeIndex = undefined; } const concurrency = resolveConcurrency(options); // ADR-0023: batch telemetry records in-memory; write once at pipeline completion. const telemetryBatch: StepTelemetryRecord[] = []; progressLine( `[workspace] pipeline ${options.pipelineName} — ${totalSteps} step(s), concurrency ${concurrency}`, options.quiet, ); // RFC-0687: load cross-pipeline cache hits and set up run state. if (options.force ?? false) { await clearPipelineCacheHits(options.workspaceRoot); } const importedHits = await loadImportedCacheHits(options.workspaceRoot, options.pipelineName); const runState: PipelineRunState = { cacheHitCommands: importedHits, pipelineName: options.pipelineName, }; const scheduled = buildSchedule(steps); // RFC-1144: pre-resolve read-only classification. annotateReadOnlySteps(scheduled, registry); const stepTimings: Map = new Map(); try { const results = await executeScheduledSteps( scheduled, concurrency, async (sStep: ScheduledStep) => { const { step, stepIndex } = sStep; const command = registry.commands.get(step.command); if (!command) { throw new Error( `Kernel pipeline step \`${step.command}\` is not registered for workspace pipeline \`${options.pipelineName}\`.`, ); } if (command.scope !== "workspace") { throw new Error( `Workspace pipeline \`${options.pipelineName}\` cannot execute app-scoped step \`${step.command}\` without an app target.`, ); } const logger = createKernelLogger(options.outputFormat ?? "pretty"); const { io, intents } = createDefaultIO(); const ownershipMap = await computeOwnershipMap(registry); const context: KernelRuntimeContext = { workspaceRoot: options.workspaceRoot, site: undefined, siteExplicit: false, logger, dryRun: options.dryRun ?? false, allSites: options.allSites ?? false, outputFormat: options.outputFormat ?? "pretty", io, fileIntents: intents, actualState: registry, ownershipMap, workpieceEnv: EMPTY_WORKPIECE_ENV, // RFC-1133: the executor-level cache block inside // executeRegisteredCommand consumes this carrier — the pipeline no // longer calls the cache helpers itself. resultCache: { layer: cache, treeIndex, moduleHashCache }, }; const stepLabel = `[${stepIndex + 1}/${totalSteps}]`; if (options.outputFormat !== "json") { logger.section(`workspace: ${options.pipelineName} -> ${step.command}`); } progressLine(`${stepLabel} ${step.command} …`, options.quiet); const budgetedExpectedDurationMs = lookupExpectedDurationMs( budgets, options.pipelineName, step.command, null, ); const startedAtMonotonicMs = Math.round(performance.now()); let report: KernelExecutionReport; if (step.skip) { report = skippedExecutionReport(command, context, step.skipReason); } else if (shouldTransitiveSkip(command, runState)) { // RFC-0687: transitive cache skip — all upstream commands were cache hits. report = skippedExecutionReport(command, context, "transitive-cache-skip"); } else { // RFC-1133: the cache read/write lives inside executeRegisteredCommand // (single choke point). The pipeline only supplies argv and the // resultCache carrier on the context. const stepArgs = [...(step.args ?? []), ...pipelineFlagsToArgs(options.flags)]; report = await executeRegisteredCommand(command, context, stepArgs, { timeoutMs: step.timeoutMs, expectedDurationMs: budgetedExpectedDurationMs ?? step.expectedDurationMs, }); } // RFC-1184: warn-only steps record the failure but do not block. applyWarnOnly(report, step); // RFC-0687 + RFC-1133: a cached report feeds the transitive-skip set. if (report.cached === true) { runState.cacheHitCommands.add(step.command); } const endedAtMonotonicMs = Math.round(performance.now()); // RFC-1144: mark possibly-cascaded failures so operators can discount them. const taintedNote = sStep.ranAfterFailedDependency === true && !report.ok ? " (ran after failed dependency — may be cascaded)" : ""; progressLine( `${stepLabel} ${step.command} — ${stepStatusLabel(report)} ${formatDuration(report.timing.durationMs)}${taintedNote}`, options.quiet, ); // ADR-0023: collect telemetry in-memory; batch write at pipeline completion. if (!step.skip && !report.cached && !isTransitiveSkip(report)) { telemetryBatch.push({ pipeline: options.pipelineName, command: step.command, app: null, durationMs: report.timing.durationMs, timedOut: report.timing.exceededTimeout, recordedAt: new Date().toISOString(), }); } stepTimings.set(stepIndex, { pipeline: options.pipelineName, command: step.command, status: stepStatus(report), startedAtMonotonicMs, endedAtMonotonicMs, durationMs: report.timing.durationMs, ...(report.timing.timeoutMs !== undefined ? { timeoutMs: report.timing.timeoutMs } : {}), ...(report.timing.expectedDurationMs !== undefined ? { expectedDurationMs: report.timing.expectedDurationMs } : {}), exceededTimeout: report.timing.exceededTimeout, }); // RFC-1144: copy the scheduler's taint annotation onto the report. if (sStep.ranAfterFailedDependency === true) { report.ranAfterFailedDependency = true; } return report; }, { collectErrors: options.collectErrors === true }, ); // Sort results by stepIndex (declaration order). const sortedResults = [...results].sort((a, b) => a.stepIndex - b.stepIndex); const reports = sortedResults.map((r) => r.report); const orderedStepTimings = scheduled.map((s) => stepTimings.get(s.stepIndex)!).filter(Boolean); const failed = reports.find((report) => !report.ok); const timing = pipelineTimingSummary(options.pipelineName, reports, orderedStepTimings); // ADR-0023: batch write all telemetry records in a single I/O operation. await batchAppendStepTelemetry(options.workspaceRoot, telemetryBatch); // RFC-0687: persist cache hits for cross-pipeline transitive skip. await persistCacheHits(options.workspaceRoot, options.pipelineName, runState.cacheHitCommands); if (options.outputFormat !== "json") { const logger = createKernelLogger(options.outputFormat ?? "pretty"); printPipelineTimingSummary(logger, timing); } // RFC-0809: collect-errors mode — aggregate all independent failures. const collected = aggregateCollectErrors(sortedResults, options.collectErrors ?? false); if (collected) { return buildCollectErrorsReport( collected, reports, options.pipelineName, timing, `[workspace]`, undefined, options.quiet, ); } if (failed) { progressLine( `[workspace] pipeline ${options.pipelineName} — FAILED at step ${failed.commandName} (${formatDuration(timing.totalDurationMs)})`, options.quiet, ); const failedReport: KernelPipelineReport = { pipelineName: options.pipelineName, exitCode: failed.exitCode, ok: false, steps: reports, timing, filesModified: aggregateFilesModified(reports), }; return failedReport; } progressLine( `[workspace] pipeline ${options.pipelineName} — DONE ${formatDuration(timing.totalDurationMs)} (${timing.stepCount} step(s), ${timing.timeoutCount} timeout(s))`, options.quiet, ); const doneReport: KernelPipelineReport = { pipelineName: options.pipelineName, exitCode: 0, ok: true, steps: reports, timing, filesModified: aggregateFilesModified(reports), }; return doneReport; } finally { // ADR-0023: close the shared CacheLayer SQLite connection after pipeline completion. await cache.close(); } } const EXECUTE_KERNEL_PIPELINE_OPTION_KEYS = [ "workspaceRoot", "pipelineName", "siteName", "allSites", "dryRun", "force", "outputFormat", "siteWorkspace", "concurrency", "flags", "collectErrors", "quiet", ]; /** * RFC-1144: pre-resolve read-only classification on a built schedule — the * scheduler never loads command definitions; missing mutatesState ⇒ mutating * (conservative). */ function annotateReadOnlySteps(scheduled: ScheduledStep[], registry: ActualState): void { for (const sStep of scheduled) { sStep.readOnly = registry.commands.get(sStep.step.command)?.mutatesState === false; } } /** * RFC-1144: OR-merge a pipeline declaration's `collectErrors` into the * executor options — either opt-in enables the mode; there is no opt-out flag. * Exported for unit tests (AC-6). */ export function mergeCollectErrorsOption( options: ExecuteKernelPipelineOptions, declaration: PipelineDeclaration, ): ExecuteKernelPipelineOptions { if (declaration.collectErrors === true && options.collectErrors !== true) { return { ...options, collectErrors: true }; } return options; } export async function executeKernelPipeline( options: ExecuteKernelPipelineOptions, ): Promise { assertKnownOptionKeys( options, EXECUTE_KERNEL_PIPELINE_OPTION_KEYS, "executeKernelPipeline options", ); progressLine(`pipeline ${options.pipelineName} — resolving target …`, options.quiet); // Pre-resolved site workspace bypasses discovery (e.g. closed-mission workpieces // where registry.currentMission is null and discovery can't find the workpiece). if (options.siteWorkspace) { const site = options.siteWorkspace; progressLine(`pipeline ${options.pipelineName} — 1 site(s): ${site.name}`, options.quiet); progressLine(`[${site.name}] loading app runtime …`, options.quiet); const { registry } = await loadAppRuntime(options.workspaceRoot, site, { quiet: options.quiet, }); progressLine(`[${site.name}] app runtime ready`, options.quiet); const pipelineDecl = registry.pipelines.get(options.pipelineName); if (!pipelineDecl) { throw new Error( `Kernel pipeline \`${options.pipelineName}\` is not registered for site \`${site.name}\`.`, ); } return executePipelineForSite( site, registry, mergeCollectErrorsOption(options, pipelineDecl), pipelineDecl.steps, ); } if (!options.siteName && !(options.allSites ?? false)) { const wsRegistry = await getOrBuildWorkspaceRegistry(options.workspaceRoot, { quiet: options.quiet, }); if (wsRegistry) { progressLine( `pipeline ${options.pipelineName} — loading workspace registry …`, options.quiet, ); const wsPipelineDecl = wsRegistry.pipelines.get(options.pipelineName); if (wsPipelineDecl) { progressLine(`pipeline ${options.pipelineName} — workspace registry ready`, options.quiet); return executePipelineForWorkspace( wsRegistry, mergeCollectErrorsOption(options, wsPipelineDecl), wsPipelineDecl.steps, ); } } } const targetSites = await ensureTargetSites( options.workspaceRoot, options.allSites ?? false, options.siteName, ); if (targetSites.length === 0) { throw new Error("No target site with a kernel config could be resolved."); } progressLine( `pipeline ${options.pipelineName} — ${targetSites.length} site(s): ${targetSites.map((s) => s.name).join(", ")}`, options.quiet, ); const reports: KernelPipelineReport[] = []; for (const site of targetSites) { progressLine(`[${site.name}] loading app runtime …`, options.quiet); const { registry } = await loadAppRuntime(options.workspaceRoot, site, { quiet: options.quiet, }); progressLine(`[${site.name}] app runtime ready`, options.quiet); const pipelineDecl = registry.pipelines.get(options.pipelineName); if (!pipelineDecl) { throw new Error( `Kernel pipeline \`${options.pipelineName}\` is not registered for site \`${site.name}\`.`, ); } reports.push( await executePipelineForSite( site, registry, mergeCollectErrorsOption(options, pipelineDecl), pipelineDecl.steps, ), ); } return options.allSites ? reports : reports[0]!; }