/*
RFC-1133: shared command-result cache block for the kernel executor. Owns the
`command_results` read/write consumed by `executeRegisteredCommand` — the single
choke point covering direct `executeKernelCommand` calls and both pipeline
executors — plus the flagsHash rule (CACHE_INVISIBLE_FLAGS denylist), the
RFC-0685 mtime fast path, and the RFC-1130 cacheBypassFlags gate.
- Do not implement cache storage — that lives in kernel/cache/cache-layer.ts and sqlite-cache-layer.ts.
- Do not implement pipeline scheduling or step orchestration — that lives in execute-pipeline.ts.
- Do not persist cross-pipeline cache hits — persistCacheHits stays in execute-pipeline.ts.
- Cache read/write runs inside executeRegisteredCommand — the single choke point; executors pass per-run state via context.resultCache.
- flagsHash denylist (CACHE_INVISIBLE_FLAGS) over resolved flags — presentation/bypass universals never fragment the cache.
- RFC-1133: module created — isCommandCacheable, hasCacheBypassFlag, tryMtimeFastPath, and the read/write orchestrators relocated from execute-pipeline.ts; computeFlagsHash + CACHE_INVISIBLE_FLAGS added; key gains flagsHash (schema v2).
- RFC-1133: cache direct executeKernelCommand executions with flag-keyed results
- RFC-1193: bound module hashing to src/os roots with constant fallbacks
- RFC-1193: review findings (discriminated union, dead condition, fixture)
- RFC-1303: review REVIEW-CODE-2026-10-16-02 — scanner moves to werkstatt-shared/node (IO-01), workpieceEnv hashes into envDigest, saveCache tolerates KERNEL-META-01, ENV-INPUT-02 coverage notice, ratchet path-arg tightening
*/
import { createHash } from "node:crypto";
import { dirname, join, relative, sep } from "node:path";
import { byteHash, stableJsonHash } from "@warpgogol/werkstatt-engine/fingerprint";
import type {
KernelCommandDefinition,
KernelExecutionReport,
KernelResultCacheContext,
KernelRuntimeContext,
} from "@warpgogol/werkstatt-shared/kernel";
import { createCacheLayer } from "../cache/cache-layer.ts";
import {
COMMAND_RESULT_CACHE_SCHEMA_VERSION,
computeInputsHash,
computeModuleHash,
getCachedCommandResult,
setCachedCommandResult,
type CommandResultCacheKey,
type InputsMetadataEntry,
} from "../cache/command-result-cache.ts";
import { filterTreeIndex } from "../cache/workspace-tree-index.ts";
import { deriveModuleBasePath } from "./registry.ts";
import { createDefaultIO, type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io";
// @ai-invariant: Command-result caching lives only in executeRegisteredCommand — never read or write command_results from pipeline executors directly.
/**
* RFC-1133: universal flags that never enter flagsHash. Presentation flags
* (json/quiet/verbose/help) must not fragment the cache; bypass flags
* (dry-run/force) are handled by the read/write gates, and `force` in
* particular must stay out so a --force write refreshes the entry that
* normal runs read (DNA-93). `root` is a locator, not a result input.
*
* `site`/`all` are deliberately NOT invisible: workspace-scoped commands carry
* siteName: null in the key, so a --site value that survived alias resolution
* must be keyed.
*/
export const CACHE_INVISIBLE_FLAGS: ReadonlySet = new Set([
"json",
"quiet",
"verbose",
"help",
"dry-run",
"force",
"root",
]);
/**
* RFC-1133: stable hash over the cache-visible resolved flags.
* Returns "" when no visible flags are present.
*/
export function computeFlagsHash(flags: Record): string {
const visible: Record = {};
for (const [name, value] of Object.entries(flags)) {
if (!CACHE_INVISIBLE_FLAGS.has(name)) visible[name] = value;
}
return Object.keys(visible).length === 0 ? "" : stableJsonHash(visible);
}
/**
* RFC-1303: stable digest over the command's declared `envInputs` —
* `name=sha256(value||"")` pairs, sorted. Raw values are never hashed into
* the persisted key material (only their per-name digests). Empty string
* when the command declares no env inputs, keeping pre-RFC-1303 cache keys
* valid for unaffected commands.
*/
export function computeEnvDigest(
envInputs: string[] | undefined,
workpieceEnv?: ReadonlyMap,
): string {
if (!envInputs || envInputs.length === 0) return "";
const pairs = [...envInputs].sort().map(
(name) =>
`${name}=${createHash("sha256")
// RFC-1126 resolution order: process.env wins, then the workpiece
// .env channel — hash the effective value so a .env-only change
// invalidates the cache like a process.env change (REVIEW-RFC-1303-02).
.update(process.env[name] ?? workpieceEnv?.get(name) ?? "")
.digest("hex")}`,
);
return stableJsonHash(pairs);
}
/** RFC-1303: mix the env digest into the base inputs hash. */
function withEnv(inputsHash: string, envDigest: string): string {
return envDigest ? stableJsonHash({ inputsHash, envDigest }) : inputsHash;
}
/** RFC-1303: metaKey suffix — each env state owns its sidecar mapping. */
function metaKeyEnvSuffix(envDigest: string): string {
return envDigest ? `:${envDigest}` : "";
}
/**
* RFC-0390: a command is eligible for caching when `cacheable !== false` AND
* it declares non-empty `reads`. Commands with `cacheable: false` or without
* `reads` are always executed.
*/
export function isCommandCacheable(command: KernelCommandDefinition): boolean {
if (command.cacheable === false) return false;
const reads = command.reads ?? [];
return reads.length > 0;
}
/**
* RFC-1130: true when argv contains a flag listed in the command's
* `cacheBypassFlags` (`--name` or `--name=value` forms). Flags that reduce
* coverage (e.g. `--rfc`) must bypass both the cache read and the cache write
* so a filtered run can neither serve a stale full-run report nor poison the
* full-run entry.
*/
export function hasCacheBypassFlag(command: KernelCommandDefinition, argv: string[]): boolean {
const names = command.cacheBypassFlags ?? [];
return names.some((n) => argv.some((a) => a === `--${n}` || a.startsWith(`--${n}=`)));
}
/**
* Everything the cache block needs for one invocation, computed once by
* executeRegisteredCommand from the final resolved input — post
* resolveSiteFlagAlias, post pipeline --site/--system injection — so read and
* write always see identical flags.
*/
export interface CommandResultCacheCall {
command: KernelCommandDefinition;
/** Raw argv (pre-resolution) — used only for the cacheBypassFlags check. */
argv: string[];
/** Resolved input.flags — hashed via computeFlagsHash. */
flags: Record;
baseDir: string;
workspaceRoot: string;
siteName: string | null;
/** Workpiece .env channel (RFC-1126) — hashed into envDigest alongside process.env. */
workpieceEnv?: ReadonlyMap;
force: boolean;
dryRun: boolean;
}
export interface ResolvedResultCache {
cache: KernelResultCacheContext;
/** True when this call opened the layer — the caller must close() it. */
owned: boolean;
}
/**
* Resolve the cache carrier for one command execution. Returns null when the
* invocation can neither read nor write the cache (non-cacheable command,
* dry-run, or a cacheBypassFlags hit) — in that case no layer is opened at
* all. `--force` still resolves: the read is skipped but the write refreshes
* the entry (DNA-93).
*/
export async function resolveResultCache(
context: KernelRuntimeContext,
call: CommandResultCacheCall,
): Promise {
if (!isCommandCacheable(call.command)) return null;
if (call.dryRun) return null;
if (hasCacheBypassFlag(call.command, call.argv)) return null;
if (context.resultCache) return { cache: context.resultCache, owned: false };
const layer = await createCacheLayer(context.workspaceRoot);
return { cache: { layer, moduleHashCache: new Map() }, owned: true };
}
/**
* RFC-0390 + RFC-0685 + RFC-1133: attempt to read a cached result.
* Returns the cached report (with `cached: true`) or null on miss.
* Skips when `dryRun` or `force` is set or when the layer is unavailable.
*/
export async function readCommandResult(
cache: KernelResultCacheContext,
call: CommandResultCacheCall,
io: WorkspaceIO = createDefaultIO().io,
): Promise {
if (call.dryRun || call.force) return null;
if (!isCommandCacheable(call.command)) return null;
if (hasCacheBypassFlag(call.command, call.argv)) return null;
if (!cache.layer.available) return null;
const reads = call.command.reads ?? [];
const flagsHash = computeFlagsHash(call.flags);
const envDigest = computeEnvDigest(call.command.envInputs, call.workpieceEnv);
const moduleResolved = await resolveModuleSrcDir(call.command, call.workspaceRoot, io);
const moduleHashCache = cache.moduleHashCache ?? new Map();
// RFC-0685: mtime fast path — requires the pipeline's tree index.
// RFC-1193: no fast path when moduleSrcDir fell back — the hash is a constant.
if (cache.treeIndex && moduleResolved.srcDir) {
const fastPathResult = await tryMtimeFastPath(
cache,
call.command,
reads,
call.baseDir,
call.workspaceRoot,
call.siteName,
moduleResolved.srcDir,
moduleHashCache,
flagsHash,
envDigest,
);
if (fastPathResult) return fastPathResult;
}
const { hash: baseInputsHash } = await computeInputsHash(
reads,
call.baseDir,
call.workspaceRoot,
cache.treeIndex,
);
const inputsHash = withEnv(baseInputsHash, envDigest);
const moduleHash = await getOrComputeModuleHash(
moduleResolved,
call.command,
moduleHashCache,
call.workspaceRoot,
io,
);
const key: CommandResultCacheKey = {
schemaVersion: COMMAND_RESULT_CACHE_SCHEMA_VERSION,
commandName: call.command.name,
siteName: call.siteName,
inputsHash,
moduleHash,
flagsHash,
};
const entry = await getCachedCommandResult(cache.layer, key);
if (!entry?.report) return null;
// RFC-1057: verify output files still exist before trusting cache.
const writes = call.command.writes ?? [];
if (writes.length > 0) {
for (const writePath of writes) {
const abs = join(call.baseDir, writePath);
if (!(await io.exists(abs))) {
return null;
}
}
}
return entry.report;
}
/**
* RFC-0390 + RFC-0685 + RFC-1133: store a successful command result.
* Only stores when `ok: true`, not `dryRun`, and the command is cacheable.
* On `--force`, still stores (refreshing entries). The `command_results_meta`
* sidecar format is unchanged — inputsHash is flag-independent, so flag
* variants share the entry benignly.
*/
export async function writeCommandResult(
cache: KernelResultCacheContext,
call: CommandResultCacheCall,
report: KernelExecutionReport,
): Promise {
if (call.dryRun) return;
if (!report.ok) return;
if (!isCommandCacheable(call.command)) return;
if (hasCacheBypassFlag(call.command, call.argv)) return;
if (!cache.layer.available) return;
const reads = call.command.reads ?? [];
const flagsHash = computeFlagsHash(call.flags);
const envDigest = computeEnvDigest(call.command.envInputs, call.workpieceEnv);
const { hash: baseInputsHash, metadata } = await computeInputsHash(
reads,
call.baseDir,
call.workspaceRoot,
cache.treeIndex,
);
const inputsHash = withEnv(baseInputsHash, envDigest);
const moduleResolved = await resolveModuleSrcDir(call.command, call.workspaceRoot);
const moduleHash = await getOrComputeModuleHash(
moduleResolved,
call.command,
cache.moduleHashCache ?? new Map(),
call.workspaceRoot,
);
const key: CommandResultCacheKey = {
schemaVersion: COMMAND_RESULT_CACHE_SCHEMA_VERSION,
commandName: call.command.name,
siteName: call.siteName,
inputsHash,
moduleHash,
flagsHash,
};
await setCachedCommandResult(cache.layer, key, report, metadata);
// RFC-0685: store metadata-to-inputsHash mapping for the mtime fast path.
// RFC-1303: envDigest suffixes the sidecar key so alternating env states
// keep their own mapping instead of overwriting each other.
if (metadata.length > 0) {
const metadataHash = stableJsonHash({ metadata });
const metaKey = `meta:${call.command.name}:${call.siteName ?? ""}:${metadataHash}${metaKeyEnvSuffix(envDigest)}`;
await cache.layer.set("command_results_meta", metaKey, inputsHash, Date.now(), inputsHash);
}
}
/**
* RFC-1193: bounded module-source resolution. A real directory is returned
* only when `modulePath` carries a `/src/` or `/os/` segment and resolves to an
* existing directory strictly below the workspace root. Every other outcome is
* a deterministic constant-hash fallback — caching degrades gracefully instead
* of fingerprinting unbounded trees (previously the whole workspace or an
* arbitrary package src).
*/
export type ModuleHashFallbackReason = "unpatched" | "workspace-root" | "missing" | "unrooted";
export type ResolvedModuleSrcDir =
| { srcDir: string; fallbackReason?: never }
| { srcDir?: never; fallbackReason: ModuleHashFallbackReason };
/**
* RFC-1028 + RFC-1193: resolve the module source dir from command.modulePath.
* Returns a descriptor, never `workspaceRoot`: `unpatched` when modulePath is
* absent, `unrooted` when modulePath has neither `/src/` nor `/os/`,
* `workspace-root`/`missing` when the derived path degenerates or is absent.
*/
export async function resolveModuleSrcDir(
command: KernelCommandDefinition,
workspaceRoot: string,
io: WorkspaceIO = createDefaultIO().io,
): Promise {
if (!command.modulePath) return { fallbackReason: "unpatched" };
const basePath = deriveModuleBasePath(command.modulePath);
if (!basePath) return { fallbackReason: "unrooted" };
const srcDir = join(workspaceRoot, basePath);
if (srcDir === workspaceRoot) return { fallbackReason: "workspace-root" };
if (!(await io.exists(srcDir))) return { fallbackReason: "missing" };
return { srcDir };
}
/**
* RFC-0637 + RFC-1193: resolve the module hash from the per-run cache,
* computing on miss. Unresolvable moduleSrcDir yields a deterministic
* `module-hash-fallback:` constant (plus a warning) — never a
* whole-workspace fingerprint.
*/
export async function getOrComputeModuleHash(
resolved: ResolvedModuleSrcDir,
command: KernelCommandDefinition,
moduleHashCache: Map,
workspaceRoot: string,
io: WorkspaceIO = createDefaultIO().io,
): Promise {
// RFC-1194: declared modulePaths anchor to dirname(modulePath) — the module
// file's own directory — independent of the /src//os/ module root. A command
// in an unrooted location can still declare modulePaths and get real
// invalidation; only modulePath-less commands stay on the fallbacks.
const anchorBase =
(command.modulePaths?.length ?? 0) > 0 && command.modulePath
? dirname(join(workspaceRoot, command.modulePath))
: resolved.srcDir;
const moduleHashCacheKey = `${anchorBase ?? `fallback:${resolved.fallbackReason}`}:${command.modulePaths?.join(",") ?? ""}`;
let moduleHash = moduleHashCache.get(moduleHashCacheKey);
if (!moduleHash) {
if (anchorBase && (await io.exists(anchorBase))) {
moduleHash = await computeModuleHash(anchorBase, command.modulePaths, workspaceRoot, io);
} else {
const reason = anchorBase ? "missing" : (resolved.fallbackReason ?? "unpatched");
moduleHash = byteHash(`module-hash-fallback:${reason}`);
console.warn(
`Command "${command.name}": module hash base unresolved (${reason}) — using constant module hash fallback (RFC-1193/RFC-1194).`,
);
}
moduleHashCache.set(moduleHashCacheKey, moduleHash);
}
return moduleHash;
}
/**
* RFC-0685: mtime fast path. Computes current file metadata from the tree
* index, looks up the stored inputsHash via the command_results_meta sidecar,
* and returns the cached report without fingerprinting when it matches.
*/
async function tryMtimeFastPath(
cache: KernelResultCacheContext,
command: KernelCommandDefinition,
reads: string[],
baseDir: string,
workspaceRoot: string,
siteName: string | null,
moduleSrcDir: string,
moduleHashCache: Map,
flagsHash: string,
envDigest: string,
io: WorkspaceIO = createDefaultIO().io,
): Promise {
const treeIndex = cache.treeIndex;
if (!treeIndex) return null;
// Expand globs using tree index to get current file list with metadata.
const files = filterTreeIndex(treeIndex, reads, baseDir, workspaceRoot);
if (files.length === 0) return null;
// Build current metadata from tree index.
const currentMetadata: InputsMetadataEntry[] = [];
for (const abs of files) {
const rel = relative(workspaceRoot, abs).split(sep).join("/");
const entry = treeIndex.get(rel);
if (!entry) return null; // file not in index — can't use fast path
currentMetadata.push({ path: rel, mtimeMs: entry.mtimeMs, size: entry.size });
}
currentMetadata.sort((a, b) => a.path.localeCompare(b.path));
// Compute a hash from the metadata to use as a lookup key.
const metadataHash = stableJsonHash({ metadata: currentMetadata });
// Look up the metadata-to-inputsHash mapping.
const metaKey = `meta:${command.name}:${siteName ?? ""}:${metadataHash}${metaKeyEnvSuffix(envDigest)}`;
const metaEntry = await cache.layer.get("command_results_meta", metaKey);
if (!metaEntry) return null;
const storedInputsHash = metaEntry.data as string;
if (typeof storedInputsHash !== "string") return null;
const moduleHash = await getOrComputeModuleHash(
{ srcDir: moduleSrcDir },
command,
moduleHashCache,
workspaceRoot,
io,
);
const key: CommandResultCacheKey = {
schemaVersion: COMMAND_RESULT_CACHE_SCHEMA_VERSION,
commandName: command.name,
siteName,
inputsHash: storedInputsHash,
moduleHash,
flagsHash,
};
const entry = await getCachedCommandResult(cache.layer, key);
if (!entry?.report) return null;
// RFC-1057: verify output files still exist before trusting cache.
const writes = command.writes ?? [];
if (writes.length > 0) {
for (const writePath of writes) {
const abs = join(baseDir, writePath);
if (!(await io.exists(abs))) {
return null;
}
}
}
return entry.report;
}