/* RFC-0390: Command-result cache helpers. Computes cache keys from declared `reads` file hashes and command module source hashes, and provides get/set helpers for storing and retrieving KernelExecutionReport objects in the `command_results` cache namespace. All hashing uses @warpgogol/fingerprint (DNA-53). Do not implement cache storage — that lives in cache-layer.ts and sqlite-cache-layer.ts. Do not implement pipeline execution logic — that lives in runtime/execute-pipeline.ts. Do not validate `reads` declarations — that lives in command.reads.validate. Command results are cached by input hash — identical inputs skip re-execution. RFC-1127: step 1 — cache barrel + parse fix Add ./kernel/cache subpath export (narrow barrel: createCacheLayer, computeModuleHash, key helpers). Fix parseCommandResultCacheKey truncating algo-prefixed hashes (sha256:hex) — list() returned garbage moduleHash for all real keys. RFC-1133: flagsHash in cache key + schema v2 CommandResultCacheKey gains flagsHash (stableJsonHash over cache-visible resolved flags); schemaVersion bumps to 2 so v1 keys orphan naturally. get/set helpers accept the narrow KernelResultCacheStore port from werkstatt-shared so the executor-level cache block in executeRegisteredCommand can run without a concrete CacheLayer import. RFC-1133: cache direct executeKernelCommand executions with flag-keyed results RFC-1149: migrate ambient filesystem and process IO to the WorkspaceIO port across engine and shared RFC-1193: bound module hashing to src/os roots with constant fallbacks RFC-0390, RFC-0637, RFC-0685, RFC-1097 */ import { isAbsolute, join, relative, sep } from "node:path"; import picomatch from "picomatch"; import { byteHash, stableJsonHash } from "@warpgogol/werkstatt-engine/fingerprint"; import { fingerprintFile, fingerprintTree } from "@warpgogol/werkstatt-engine/fingerprint/semantic"; import type { KernelExecutionReport, KernelResultCacheStore, } from "@warpgogol/werkstatt-shared/kernel"; import type { WorkspaceTreeIndex } from "./workspace-tree-index.ts"; import { filterTreeIndex } from "./workspace-tree-index.ts"; import { createDefaultIO, type DirEntry, type WorkspaceIO, } from "@warpgogol/werkstatt-shared/kernel/workspace-io"; export const COMMAND_RESULT_CACHE_NAMESPACE = "command_results"; export const COMMAND_RESULT_CACHE_SCHEMA_VERSION = 2; export interface CommandResultCacheKey { schemaVersion: number; commandName: string; siteName: string | null; inputsHash: string; moduleHash: string; /** RFC-1133: stableJsonHash over cache-visible resolved flags ("" when none). */ flagsHash: string; } export interface InputsMetadataEntry { path: string; mtimeMs: number; size: number; } export interface CachedCommandResultEntry { report: KernelExecutionReport; inputsMetadata?: InputsMetadataEntry[]; inputsHash?: string; } const _BYTE_MODE_EXTENSIONS = new Set([".md", ".yaml", ".yml", ".json", ".jsonc", ".txt"]); const SEMANTIC_MODE_EXTENSIONS = new Set([".ts", ".tsx", ".astro", ".css", ".js", ".mjs"]); function selectFingerprintMode(absPath: string): "byte" | "semantic" { const ext = absPath.slice(absPath.lastIndexOf(".")).toLowerCase(); if (SEMANTIC_MODE_EXTENSIONS.has(ext)) return "semantic"; return "byte"; } /** * RFC-1028: Build a composite string key from a CommandResultCacheKey. * Format: `${schemaVersion}:${commandName}:${siteName}:${inputsHash}:${moduleHash}:${flagsHash}` * (flagsHash appended last by RFC-1133). This replaces the previous * stableJsonHash approach — the composite key is human-readable and enables * SQL LIKE filtering by commandName in list(). */ // @ai-invariant The cache key MUST cover BOTH declared `reads` content // (inputsHash) and the command module source (moduleHash) — a key missing // either silently serves stale results after code or input changes. // siteName may be empty and flagsHash is appended last (schema v2). export function buildCommandResultCacheKey(key: CommandResultCacheKey): string { return [ key.schemaVersion, key.commandName, key.siteName ?? "", key.inputsHash, key.moduleHash, key.flagsHash, ].join(":"); } /** * RFC-1028: Parse a composite cache key back into its components. * Returns null if the key does not match the expected format. */ export function parseCommandResultCacheKey( namespace: string, key: string, ): { commandName: string; siteName: string | null; inputsHash: string; moduleHash: string; flagsHash: string; } | null { if (namespace !== COMMAND_RESULT_CACHE_NAMESPACE) return null; const parts = key.split(":"); if (parts.length < 6) return null; const [, commandName, siteName] = parts; // Hashes are "algo:hex" (e.g. sha256:abc…) — each may occupy two segments. // Parse from the right: flagsHash, then moduleHash, then the remainder is // inputsHash (RFC-1133 appended flagsHash as the last component). const rest = parts.slice(3); const isAlgoPrefix = (s: string) => /^(sha\d+|blake2[bs]?|md5)$/.test(s); const takeHash = (segments: string[]): { hash: string; rest: string[] } | null => { if (segments.length === 0) return null; if (segments.length >= 2 && isAlgoPrefix(segments[segments.length - 2])) { return { hash: segments.slice(-2).join(":"), rest: segments.slice(0, -2) }; } return { hash: segments[segments.length - 1], rest: segments.slice(0, -1) }; }; const flags = takeHash(rest); if (!flags) return null; const mod = takeHash(flags.rest); if (!mod) return null; const inputsHash = mod.rest.join(":"); // flagsHash may legitimately be "" (no cache-visible flags) — the trailing // empty segment is part of the v2 format. inputsHash/moduleHash may not. if (!inputsHash || !mod.hash) return null; return { commandName, siteName: siteName || null, inputsHash, moduleHash: mod.hash, flagsHash: flags.hash, }; } function toPosix(p: string): string { return p.split(sep).join("/"); } /** * Resolve `` token in a read pattern relative to the base directory, * then return the workspace-root-relative POSIX path. */ function resolvePattern(pattern: string, baseDir: string, workspaceRoot: string): string { const resolved = pattern.replace("", toPosix(relative(workspaceRoot, baseDir))); return resolved; } /** * Expand picomatch globs relative to the workspace root and return matching * absolute file paths. When a tree index is provided, filters in-memory * instead of walking the filesystem (RFC-0685). */ async function expandGlobs( patterns: string[], baseDir: string, workspaceRoot: string, treeIndex?: WorkspaceTreeIndex, ): Promise { if (treeIndex) { return filterTreeIndex(treeIndex, patterns, baseDir, workspaceRoot); } const resolvedPatterns = patterns.map((p) => resolvePattern(p, baseDir, workspaceRoot)); const isMatch = picomatch(resolvedPatterns, { dot: true, nocase: false }); const matched = new Set(); // fs.walk.lint: allow — this walker matches files against picomatch glob // patterns relative to the workspace root, a contract collectFiles does not support (RFC-0390). async function walk(dir: string, io: WorkspaceIO = createDefaultIO().io): Promise { let entries: DirEntry[]; try { entries = await io.readdir(dir); } catch { return; } for (const entry of entries) { const abs = join(dir, entry.name); const rel = toPosix(relative(workspaceRoot, abs)); if (entry.isDirectory) { await walk(abs); } else if (entry.isFile && isMatch(rel)) { matched.add(abs); } } } await walk(workspaceRoot); return [...matched].sort(); } /** * Compute a deterministic hash of all files matching the declared `reads` * patterns. Uses @warpgogol/fingerprint semantic mode for source file types * and byte mode for content files. Returns a stable composite hash and * the metadata array for mtime fast path (RFC-0685). * * Returns a constant hash for empty `reads` (should not be called with empty * reads in practice — the pipeline executor checks cacheable/reads first). */ export async function computeInputsHash( reads: string[], baseDir: string, workspaceRoot: string, treeIndex?: WorkspaceTreeIndex, io: WorkspaceIO = createDefaultIO().io, ): Promise<{ hash: string; metadata: InputsMetadataEntry[] }> { if (reads.length === 0) { return { hash: stableJsonHash({ reads: [] }), metadata: [] }; } const files = await expandGlobs(reads, baseDir, workspaceRoot, treeIndex); const hashes: { path: string; hash: string }[] = []; const metadata: InputsMetadataEntry[] = []; for (const abs of files) { const rel = toPosix(relative(workspaceRoot, abs)); const mode = selectFingerprintMode(abs); let result; try { result = await fingerprintFile(abs, { mode }); } catch { continue; } hashes.push({ path: rel, hash: result.hash }); const s = await io.stat(abs); metadata.push({ path: rel, mtimeMs: s.mtimeMs, size: s.size }); } metadata.sort((a, b) => a.path.localeCompare(b.path)); return { hash: stableJsonHash({ files: hashes }), metadata }; } /** * Compute a deterministic hash of a command module's source directory. * Uses @warpgogol/fingerprint fingerprintTree in semantic mode. * The caller should cache this per-package per-pipeline-run. * * RFC-0637 + RFC-1194: when `modulePaths` is provided and non-empty, * fingerprints only the listed paths (files and/or directories relative to * `moduleBaseDir` — the caller supplies the anchoring base, which is * `dirname(modulePath)` for declared entries) instead of the full directory. * Non-existent paths are silently skipped; entries resolving outside * `workspaceRoot` are skipped as well — `..` is permitted only within the * workspace (CRC-05 gates the declaration statically). When `modulePaths` is * absent or empty, falls back to a full `moduleBaseDir` tree fingerprint. */ export async function computeModuleHash( moduleBaseDir: string, modulePaths?: string[], workspaceRoot?: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { if (modulePaths && modulePaths.length > 0) { const hashes: string[] = []; for (const p of modulePaths) { const abs = join(moduleBaseDir, p); if (workspaceRoot) { const rel = relative(workspaceRoot, abs); if (rel.startsWith("..") || isAbsolute(rel)) continue; } if (!(await io.exists(abs))) continue; const s = await io.stat(abs); if (s.isDirectory) { const result = await fingerprintTree(abs, { mode: "semantic", ignore: ["__tests__", "node_modules", "dist", ".git"], }); hashes.push(`${p}:${result.value}`); } else { const result = await fingerprintFile(abs, { mode: "semantic" }); hashes.push(`${p}:${result.hash}`); } } return stableJsonHash({ paths: hashes }); } try { const result = await fingerprintTree(moduleBaseDir, { mode: "semantic", ignore: ["__tests__", "node_modules", "dist", ".git"], }); return result.value; } catch { return byteHash(`module-hash-fallback:${moduleBaseDir}`); } } /** * Retrieve a cached command result. Returns null on miss, unavailable cache, * or schema version mismatch. Sets `cached: true` on the returned report. * * RFC-0685: the cache data payload may be a wrapper * { report, inputsMetadata, inputsHash } or a legacy bare KernelExecutionReport. * The wrapper is detected by checking for the `report` field. */ export async function getCachedCommandResult( cache: KernelResultCacheStore, key: CommandResultCacheKey, ): Promise { if (!cache.available) return null; const cacheKey = buildCommandResultCacheKey(key); const entry = await cache.get(COMMAND_RESULT_CACHE_NAMESPACE, cacheKey); if (!entry) return null; const data = entry.data; if (!data || typeof data !== "object") return null; // RFC-0685: detect wrapper format vs legacy bare report. if ("report" in data && typeof data.report === "object") { const wrapped = data as CachedCommandResultEntry; if (!wrapped.report || typeof wrapped.report !== "object") return null; return { report: { ...wrapped.report, cached: true }, inputsMetadata: wrapped.inputsMetadata, inputsHash: wrapped.inputsHash, }; } // Legacy: bare KernelExecutionReport. const report = data as KernelExecutionReport; if (!report.commandName) return null; return { report: { ...report, cached: true } }; } /** * Store a command result in the cache. Only called for successful (ok: true) * results — the pipeline executor must not call this for failed commands. * * RFC-0685: stores a wrapper { report, inputsMetadata, inputsHash } in the * cache data payload to support the mtime fast path on subsequent reads. */ export async function setCachedCommandResult( cache: KernelResultCacheStore, key: CommandResultCacheKey, report: KernelExecutionReport, inputsMetadata?: InputsMetadataEntry[], ): Promise { if (!cache.available) return; const cacheKey = buildCommandResultCacheKey(key); const mtime = Date.now(); const wrapper: CachedCommandResultEntry = { report, inputsMetadata, inputsHash: key.inputsHash, }; const contentHash = stableJsonHash({ commandName: report.commandName, ok: report.ok, exitCode: report.exitCode, }); await cache.set(COMMAND_RESULT_CACHE_NAMESPACE, cacheKey, wrapper, mtime, contentHash); }