Files
opencode-working-memory/src/storage.ts
T
Ralph Chang 53aa6d3c31 feat: implement Plan 1 - Critical Stability fixes
Wave 1: Storage and Journal Safety
- Add frozen cache TTL (1h) and size bounds (50 sessions)
- Add pending journal source-aware retention (compaction-only TTL)
- Add inter-process file lock with stale recovery
- Move processLatestUserMessage to first transform (after isSubAgent guard)

Wave 2: Promotion Ownership and Bounded Rejection
- Add pendingOwnerSessionID/pendingMessageID metadata
- Add owner-aware pending journal clearing
- Add explicit/manual bounded retry (max 3 attempts)
- Fix session.deleted cleanup idempotency

Wave 3: Normalize, Security, and Cache Hardening
- Fix load-time write loop (only write on security/migration change)
- Add deterministic sort tie-breaker (createdAt -> id)
- Add Bearer token redaction
- Add processed message cache bounds
- Remove priorityWithFreshness dead code

Tests: 180 pass, 0 fail
2026-04-28 11:59:29 +08:00

131 lines
3.8 KiB
TypeScript

import { existsSync } from "fs";
import { randomUUID } from "crypto";
import { mkdir, open, readFile, rename, rm, stat, writeFile } from "fs/promises";
import { dirname } from "path";
const fileLocks = new Map<string, Promise<unknown>>();
const LOCK_WAIT_TIMEOUT_MS = 5000;
const LOCK_STALE_MS = 30_000;
const LOCK_ACQUIRE_GRACE_MS = 250;
export async function readJSON<T>(path: string, fallback: () => T): Promise<T> {
if (!existsSync(path)) return fallback();
try {
return JSON.parse(await readFile(path, "utf8")) as T;
} catch {
return fallback();
}
}
async function readJSONStrict<T>(path: string, fallback: () => T): Promise<T> {
if (!existsSync(path)) return fallback();
try {
return JSON.parse(await readFile(path, "utf8")) as T;
} catch (error) {
throw new Error(`Invalid JSON in ${path}: ${(error as Error).message}`);
}
}
async function isLockStale(lockPath: string, now = Date.now()): Promise<boolean> {
try {
const content = await readFile(lockPath, "utf8");
const [pidText, createdText] = content.split("\n");
const pid = Number(pidText);
const createdAt = Number(createdText);
if (!Number.isFinite(createdAt)) {
return !(await isRecentlyTouched(lockPath, now));
}
if (now - createdAt > LOCK_STALE_MS) return true;
if (!Number.isInteger(pid) || pid <= 0) {
return !(await isRecentlyTouched(lockPath, now));
}
try {
process.kill(pid, 0);
return false;
} catch (error) {
return (error as NodeJS.ErrnoException).code === "ESRCH";
}
} catch {
return true;
}
}
async function isRecentlyTouched(path: string, now = Date.now()): Promise<boolean> {
try {
return now - (await stat(path)).mtimeMs <= LOCK_ACQUIRE_GRACE_MS;
} catch {
return false;
}
}
async function withFileLock<T>(path: string, fn: () => Promise<T>): Promise<T> {
const lockPath = `${path}.lock`;
await mkdir(dirname(path), { recursive: true });
const started = Date.now();
while (true) {
try {
const handle = await open(lockPath, "wx", 0o600);
try {
await handle.writeFile(`${process.pid}\n${Date.now()}\n`, "utf8");
return await fn();
} finally {
await handle.close();
await rm(lockPath, { force: true });
}
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "EEXIST") throw error;
if (await isLockStale(lockPath)) {
await rm(lockPath, { force: true });
continue;
}
if (Date.now() - started > LOCK_WAIT_TIMEOUT_MS) {
throw new Error(`Timed out waiting for lock ${lockPath}`);
}
await new Promise(resolve => setTimeout(resolve, 25));
}
}
}
export async function atomicWriteJSON(path: string, data: unknown): Promise<void> {
await mkdir(dirname(path), { recursive: true });
const tmp = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
await writeFile(tmp, JSON.stringify(data, null, 2), { encoding: "utf8", mode: 0o600 });
await rename(tmp, path);
}
export async function updateJSON<T>(
path: string,
fallback: () => T,
updater: (current: T) => T | Promise<T>,
): Promise<T> {
const previous = fileLocks.get(path) ?? Promise.resolve();
let release: () => void = () => {};
const currentLock = new Promise<void>(resolve => {
release = resolve;
});
const queued = previous.then(() => currentLock, () => currentLock);
fileLocks.set(path, queued);
try {
await previous.catch(() => undefined);
return await withFileLock(path, async () => {
const current = await readJSONStrict(path, fallback);
const updated = await updater(current);
await atomicWriteJSON(path, updated);
return updated;
});
} finally {
release();
if (fileLocks.get(path) === queued) {
fileLocks.delete(path);
}
}
}