Files
oh-my-pi/packages/coding-agent/src/utils/markit-cache.ts
T
Wolfgang Schoenberger 3a2ab8ad7d test: restore full env state in document-conversion cache tests
Snapshot and restore PI_CODING_AGENT_DIR, OMP_PROFILE, PI_PROFILE, and
XDG_CACHE_HOME instead of relying on setAgentDir(originalAgentDir), which
cannot restore previously-unset or profile-derived env state. Reset the
profile snapshot and rebuild dirs from env in cleanup to prevent
suite-order pollution.

Also reject empty cached content in parseCacheEntry() to harden the
cache contract against corrupted/empty entries.
2026-06-24 03:08:48 -07:00

167 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Stats } from "node:fs";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { getDocumentConversionCacheDir, isEnoent, logger } from "@oh-my-pi/pi-utils";
import packageJson from "../../package.json" with { type: "json" };
/**
* Cache schema/format revision. Bumping it changes the on-disk key prefix
* (`v<N>-...`), so old entries become unreachable and are pruned naturally.
* Bump when the cache *file* shape changes (entry JSON layout, key scheme).
*
* Converter *output* changes are handled separately: the package version is
* folded into the key (see {@link markitConversionCacheKey}), so any release
* that ships new markdown from `src/markit/converters/*` auto-invalidates the
* cache without a manual bump here.
*/
export const MARKIT_CONVERSION_CACHE_VERSION = 1;
export const MAX_MARKIT_CONVERSION_CACHE_BYTES = 256 * 1024 * 1024;
/** `.tmp` files older than this are treated as orphaned writes and swept. */
const TMP_ORPHAN_MAX_AGE_MS = 5 * 60 * 1000;
export type MarkitConversionCacheStatus = "hit" | "miss" | "skipped";
export type MarkitConversionCacheReadResult = { status: "hit"; content: string } | { status: "miss" };
interface MarkitConversionCacheEntry {
version: number;
content: string;
}
export function markitConversionCacheKey(bytes: Uint8Array, extension: string): string {
const normalizedExtension = extension.trim().toLowerCase().replace(/^\.+/, "") || "bin";
const safeExtension = normalizedExtension.replace(/[^a-z0-9]+/g, "_") || "bin";
const safeVersion = packageJson.version.replace(/[^a-z0-9]+/gi, "_");
const digest = new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
return `v${MARKIT_CONVERSION_CACHE_VERSION}-${safeVersion}-${safeExtension}-${digest}`;
}
function cacheEntryPath(key: string): string {
return path.join(getDocumentConversionCacheDir(), `${key}.json`);
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function parseCacheEntry(raw: string): MarkitConversionCacheEntry | null {
const parsed: unknown = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null) return null;
if (!("version" in parsed) || parsed.version !== MARKIT_CONVERSION_CACHE_VERSION) return null;
if (!("content" in parsed) || typeof parsed.content !== "string" || parsed.content.length === 0) return null;
return { version: MARKIT_CONVERSION_CACHE_VERSION, content: parsed.content };
}
export async function readMarkitConversionCache(key: string): Promise<MarkitConversionCacheReadResult> {
const target = cacheEntryPath(key);
let raw: string;
try {
raw = await Bun.file(target).text();
} catch (error) {
if (!isEnoent(error)) {
logger.debug("document conversion cache read failed", { error: errorMessage(error) });
}
return { status: "miss" };
}
let entry: MarkitConversionCacheEntry | null;
try {
entry = parseCacheEntry(raw);
} catch (error) {
logger.debug("document conversion cache read failed", { error: errorMessage(error) });
entry = null;
}
if (!entry) {
await fs.rm(target, { force: true }).catch(() => undefined);
return { status: "miss" };
}
return { status: "hit", content: entry.content };
}
export async function pruneMarkitConversionCache(cacheDir: string): Promise<void> {
let names: string[];
try {
names = await fs.readdir(cacheDir);
} catch (error) {
if (!isEnoent(error)) {
logger.debug("document conversion cache prune failed", { error: errorMessage(error) });
}
return;
}
const now = Date.now();
// Eviction is FIFO by mtime (not LRU): reads do not bump mtime, so a hot
// entry written long ago is evicted before a cold recent miss. The cap is a
// coarse disk-footprint safety valve, so the cheaper policy is intentional.
const entries: { path: string; size: number; mtimeMs: number }[] = [];
let totalBytes = 0;
for (const name of names) {
const entryPath = path.join(cacheDir, name);
let stat: Stats;
try {
stat = await fs.stat(entryPath);
} catch (error) {
if (!isEnoent(error)) {
logger.debug("document conversion cache prune failed", { error: errorMessage(error) });
}
continue;
}
if (!stat.isFile()) continue;
// Sweep orphaned `.tmp` files left by a crash/SIGKILL between writeFile
// and rename; they never become `.json` entries, so the size cap would
// otherwise never see them.
if (name.endsWith(".tmp")) {
if (now - stat.mtimeMs > TMP_ORPHAN_MAX_AGE_MS) {
await fs.rm(entryPath, { force: true }).catch(() => undefined);
}
continue;
}
if (!name.endsWith(".json")) continue;
entries.push({ path: entryPath, size: stat.size, mtimeMs: stat.mtimeMs });
totalBytes += stat.size;
}
if (totalBytes <= MAX_MARKIT_CONVERSION_CACHE_BYTES) return;
entries.sort((a, b) => a.mtimeMs - b.mtimeMs);
for (const entry of entries) {
if (totalBytes <= MAX_MARKIT_CONVERSION_CACHE_BYTES) break;
try {
await fs.rm(entry.path, { force: true });
totalBytes -= entry.size;
} catch (error) {
if (!isEnoent(error)) {
logger.debug("document conversion cache prune failed", { error: errorMessage(error) });
}
}
}
}
export async function writeMarkitConversionCache(key: string, content: string): Promise<void> {
const cacheDir = getDocumentConversionCacheDir();
const target = path.join(cacheDir, `${key}.json`);
// The random suffix keeps concurrent writers (same pid + same ms) from
// colliding on one temp path before the atomic rename.
const tempPath = path.join(cacheDir, `${key}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`);
const payload = JSON.stringify({ version: MARKIT_CONVERSION_CACHE_VERSION, content });
try {
await fs.mkdir(cacheDir, { recursive: true });
await Bun.write(tempPath, payload);
await fs.rename(tempPath, target);
} catch (error) {
await fs.rm(tempPath, { force: true }).catch(() => undefined);
logger.debug("document conversion cache write failed", { error: errorMessage(error) });
return;
}
// Prune is just GC: the entry is already on disk under its final name, so
// fire-and-forget rather than make the caller wait on a readdir + N×stat
// sweep on every miss (the slow path the cache exists to amortise).
void pruneMarkitConversionCache(cacheDir).catch(error => {
logger.debug("document conversion cache prune failed", { error: errorMessage(error) });
});
}