/** * Content-addressed cache for evidence-tool runs (ADR-061). * * `cleo verify --evidence "tool:"` historically spawned the resolved * toolchain (test, build, lint, …) on every call. With N parallel verify * processes — common in orchestrator-spawned waves — this multiplied a heavy * monorepo test or build by N, saturating CPU and memory. * * This module wraps tool execution with: * * 1. **Content-addressed cache** — keyed on `(canonical, cmd, args, head, * dirtyFingerprint)`. Cache hits return the prior `exitCode + stdoutTail` * without spawning the tool. * 2. **Cross-process semaphore** — when N processes simultaneously miss the * cache, only one runs the tool; the rest block on a `proper-lockfile` * and read the freshly-written cache entry. * 3. **Automatic invalidation** — entries become stale when `git HEAD` * changes or when uncommitted-tree fingerprint changes. Stale entries * are discarded on access (no GC daemon required). * * Cache layout (under `/.cleo/cache/evidence/`): * * - `.json` — cache entry payload * - `.json.lock` — proper-lockfile state (auto-managed) * * Each entry is a single small JSON file (≤ 4 KB) so the cache is cheap to * keep and easy to inspect / wipe (`rm -rf .cleo/cache/evidence`). * * @task T1534 * @adr ADR-061 */ import type { ResolvedToolCommand } from './tool-resolver.js'; import { type AcquireSlotOptions } from './tool-semaphore.js'; /** * One cached tool execution. * * @task T1534 */ export interface ToolCacheEntry { /** Schema version for forwards compatibility. */ schemaVersion: 1; /** Cache key (also encoded in the filename). */ key: string; /** Canonical tool name from the resolver. */ canonical: string; /** Display name (the alias the user supplied). */ displayName: string; /** Resolved cmd. */ cmd: string; /** Resolved args. */ args: string[]; /** Resolution source from the resolver. */ source: string; /** Git HEAD sha at execution time. */ head: string | null; /** sha256 of `git status --porcelain` (uncommitted tree fingerprint). */ dirtyFingerprint: string | null; /** Process exit code. */ exitCode: number | null; /** Last 512 bytes of stdout. */ stdoutTail: string; /** Last 512 bytes of stderr. */ stderrTail: string; /** Total duration in milliseconds. */ durationMs: number; /** ISO 8601 wall-clock timestamp of the run. */ capturedAt: string; } /** * Result of {@link runToolCached}. Mirrors the legacy `validateTool` * contract so callers can unconditionally inspect `exitCode + stdoutTail`. * * @task T1534 */ export interface ToolRunResult { exitCode: number | null; stdoutTail: string; stderrTail: string; durationMs: number; /** `true` when the result came from cache (no spawn occurred). */ cacheHit: boolean; /** Full cache entry — useful for audit / debugging. */ entry: ToolCacheEntry; } /** * Options for {@link runToolCached}. * * @task T1534 */ export interface RunToolOptions { /** * When `true`, bypass the cache (always spawn). The fresh result is still * written to cache for subsequent calls. * * @defaultValue `false` */ bypassCache?: boolean; /** * Lock-acquire timeout in ms. The default (10 minutes) covers full * monorepo test suites. * * @defaultValue `600_000` */ lockStaleMs?: number; /** * Maximum tail length for stdout / stderr capture. * * @defaultValue `512` */ tailBytes?: number; /** * When `true`, skip the global cross-process semaphore that bounds the * total number of concurrent runs of this canonical tool across the * whole machine. Use only in tests where the semaphore would block * arbitrary parallel sibling tests. * * @defaultValue `false` */ skipGlobalSemaphore?: boolean; /** * Tuning for the global semaphore acquisition. Forwarded to * {@link acquireGlobalSlot}. * * @internal */ semaphoreOptions?: AcquireSlotOptions; } /** * Compute the cache key for a resolved tool command + repo state. * * Key includes: * - Canonical tool name * - Resolved command + args (sensitive to project-context updates) * - Git HEAD sha (so a new commit invalidates everything) * - Dirty-tree fingerprint (so an uncommitted edit invalidates everything) * * Using `createHash('sha256')` makes the key collision-resistant and bounded * to 64 hex chars regardless of input size. * * @task T1534 */ export declare function computeCacheKey(command: ResolvedToolCommand, head: string | null, dirtyFingerprint: string | null): string; /** * Capture the repo's git HEAD sha. Returns `null` when the directory is not * a git checkout or the command fails. * * @task T1534 */ export declare function captureHead(projectRoot: string): Promise; /** * Capture a fingerprint of the uncommitted tree by sha256-hashing * `git status --porcelain=v1`. Returns `null` for non-git roots. * * Two repos with identical tracked content but different uncommitted edits * produce different fingerprints — so editing a file before re-verifying * always invalidates the cache for tools sensitive to that file. * * The cache directory itself (`.cleo/cache/`) and other CLEO-managed runtime * state (`.cleo/tasks.db`, `.cleo/brain.db`, journal/log files) are excluded * from the fingerprint via pathspec. Without this exclusion the cache would * invalidate itself on every write — call #1 writes its entry, call #2 sees * the new file in `git status` and records a different fingerprint. * * @task T1534 */ export declare function captureDirtyFingerprint(projectRoot: string): Promise; /** * Resolve the absolute path for a cache entry by key. * * @task T1534 */ export declare function cacheEntryPath(projectRoot: string, key: string): string; /** * Read a cache entry by key. Returns `null` when the entry is missing, * unreadable, schema-incompatible, or a transient lock placeholder * (`{ pending: true }`) written by a concurrent process that has not yet * captured a real result. * * @task T1534 */ export declare function readCacheEntry(projectRoot: string, key: string): ToolCacheEntry | null; /** * Atomically write a cache entry: writes to `.tmp` then renames so concurrent * readers never observe a half-written file. * * @task T1534 */ export declare function writeCacheEntry(projectRoot: string, entry: ToolCacheEntry): void; /** * Run a resolved tool command with caching + cross-process locking. * * Flow: * * 1. Compute cache key (canonical+cmd+args+head+dirtyFingerprint). * 2. If a fresh entry exists → return it (no spawn). * 3. Acquire a `proper-lockfile` on the cache entry path. * 4. Re-check cache inside the lock (another process may have written it * while we were waiting). * 5. Spawn the tool, capture stdout/stderr tails, write the entry, return. * * Locks are auto-released on success or failure. Stale locks are reaped per * the `lockStaleMs` option (default 10 min — long enough to cover a full * monorepo test suite). * * @param command - Resolved tool command from the resolver. * @param projectRoot - Absolute path to the project root. * @param opts - Options. * @returns Result envelope with `exitCode`, `stdoutTail`, `cacheHit`, etc. * * @task T1534 * @adr ADR-061 */ export declare function runToolCached(command: ResolvedToolCommand, projectRoot: string, opts?: RunToolOptions): Promise; /** * Clear all cached evidence-tool entries for a project. * * @task T1534 */ export declare function clearToolCache(projectRoot: string): { removed: number; }; //# sourceMappingURL=tool-cache.d.ts.map