feat(hooks): add hook-run-registry core module

This commit is contained in:
Trevin Chow 2026-01-29 09:17:31 -08:00 committed by Trevin Chow
parent 6bf57b4e9a
commit b1dcfa0651
2 changed files with 288 additions and 0 deletions

View File

@ -0,0 +1,161 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("./hook-run-registry.store.js", () => ({
loadHookRunRegistryFromDisk: vi.fn(() => new Map()),
saveHookRunRegistryToDisk: vi.fn(),
}));
vi.mock("./call.js", () => ({
callGateway: vi.fn(),
}));
describe("hook-run-registry", () => {
beforeEach(() => {
vi.resetModules();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.resetAllMocks();
});
describe("registerHookRun", () => {
it("registers a new hook run with cleanup=delete", async () => {
const { registerHookRun, getHookRun, clearHookRuns } = await import("./hook-run-registry.js");
clearHookRuns();
registerHookRun({
runId: "run-1",
sessionKey: "hook:test:1",
jobName: "Test Hook",
cleanup: "delete",
cleanupDelayMinutes: 0,
});
const run = getHookRun("run-1");
expect(run).toBeDefined();
expect(run?.sessionKey).toBe("hook:test:1");
expect(run?.cleanup).toBe("delete");
expect(run?.cleanupHandled).toBe(false);
clearHookRuns();
});
it("does not register runs with cleanup=keep", async () => {
const { registerHookRun, getHookRun, clearHookRuns } = await import("./hook-run-registry.js");
clearHookRuns();
registerHookRun({
runId: "run-1",
sessionKey: "hook:test:1",
jobName: "Test Hook",
cleanup: "keep",
cleanupDelayMinutes: 0,
});
const run = getHookRun("run-1");
expect(run).toBeUndefined();
clearHookRuns();
});
it("does not register runs with cleanup=undefined", async () => {
const { registerHookRun, getHookRun, clearHookRuns } = await import("./hook-run-registry.js");
clearHookRuns();
registerHookRun({
runId: "run-1",
sessionKey: "hook:test:1",
jobName: "Test Hook",
cleanup: undefined,
cleanupDelayMinutes: undefined,
});
const run = getHookRun("run-1");
expect(run).toBeUndefined();
clearHookRuns();
});
});
describe("markHookRunComplete", () => {
it("sets endedAt, cleanupAtMs, and cleanupHandled", async () => {
const { registerHookRun, markHookRunComplete, getHookRun, clearHookRuns } =
await import("./hook-run-registry.js");
clearHookRuns();
registerHookRun({
runId: "run-1",
sessionKey: "hook:test:1",
jobName: "Test Hook",
cleanup: "delete",
cleanupDelayMinutes: 5,
});
const now = Date.now();
vi.setSystemTime(now);
markHookRunComplete("run-1");
const run = getHookRun("run-1");
expect(run?.endedAt).toBe(now);
expect(run?.cleanupAtMs).toBe(now + 5 * 60 * 1000);
expect(run?.cleanupHandled).toBe(true);
clearHookRuns();
});
it("handles immediate cleanup (cleanupDelayMinutes=0)", async () => {
const { registerHookRun, markHookRunComplete, getHookRun, clearHookRuns } =
await import("./hook-run-registry.js");
clearHookRuns();
registerHookRun({
runId: "run-1",
sessionKey: "hook:test:1",
jobName: "Test Hook",
cleanup: "delete",
cleanupDelayMinutes: 0,
});
const now = Date.now();
vi.setSystemTime(now);
markHookRunComplete("run-1");
const run = getHookRun("run-1");
expect(run?.cleanupAtMs).toBe(now);
clearHookRuns();
});
});
describe("initHookRunRegistry", () => {
it("restores runs from disk on init", async () => {
const { loadHookRunRegistryFromDisk } = await import("./hook-run-registry.store.js");
const existingRun = {
runId: "restored-run",
sessionKey: "hook:restored:1",
jobName: "Restored",
cleanup: "delete" as const,
cleanupDelayMinutes: 0,
createdAt: Date.now(),
cleanupHandled: true,
cleanupAtMs: Date.now(),
};
vi.mocked(loadHookRunRegistryFromDisk).mockReturnValue(
new Map([["restored-run", existingRun]]),
);
const { initHookRunRegistry, getHookRun, clearHookRuns } =
await import("./hook-run-registry.js");
clearHookRuns();
initHookRunRegistry();
const run = getHookRun("restored-run");
expect(run).toBeDefined();
expect(run?.sessionKey).toBe("hook:restored:1");
clearHookRuns();
});
});
});

View File

@ -0,0 +1,127 @@
import { callGateway } from "./call.js";
import {
type HookRunRecord,
loadHookRunRegistryFromDisk,
saveHookRunRegistryToDisk,
} from "./hook-run-registry.store.js";
const hookRuns = new Map<string, HookRunRecord>();
let sweeper: NodeJS.Timeout | null = null;
let restoreAttempted = false;
function persistHookRuns() {
try {
saveHookRunRegistryToDisk(hookRuns);
} catch {
// Ignore persistence failures
}
}
function restoreHookRunsOnce() {
if (restoreAttempted) return;
restoreAttempted = true;
try {
const restored = loadHookRunRegistryFromDisk();
for (const [runId, entry] of restored.entries()) {
if (!hookRuns.has(runId)) {
hookRuns.set(runId, entry);
}
}
if (hookRuns.size > 0) startSweeper();
} catch {
// Ignore restore failures
}
}
function startSweeper() {
if (sweeper) return;
sweeper = setInterval(() => {
void sweepHookRuns();
}, 60_000);
sweeper.unref?.();
}
function stopSweeper() {
if (!sweeper) return;
clearInterval(sweeper);
sweeper = null;
}
async function sweepHookRuns() {
const now = Date.now();
let mutated = false;
for (const [runId, entry] of hookRuns.entries()) {
// Skip if not ready for cleanup
if (!entry.cleanupAtMs || entry.cleanupAtMs > now) continue;
// Skip if cleanup not yet marked (endedAt hasn't been set)
if (!entry.cleanupHandled) continue;
try {
await callGateway({
method: "sessions.delete",
params: { key: entry.sessionKey, deleteTranscript: true },
timeoutMs: 10_000,
});
// Only delete from registry after successful RPC
hookRuns.delete(runId);
mutated = true;
} catch {
// Log and retry on next sweep (entry stays in registry)
}
}
if (mutated) persistHookRuns();
if (hookRuns.size === 0) stopSweeper();
}
export function registerHookRun(params: {
runId: string;
sessionKey: string;
jobName: string;
cleanup: "delete" | "keep" | undefined;
cleanupDelayMinutes: number | undefined;
}) {
restoreHookRunsOnce();
// Only track runs that need cleanup
if (params.cleanup !== "delete") return;
const now = Date.now();
hookRuns.set(params.runId, {
runId: params.runId,
sessionKey: params.sessionKey,
jobName: params.jobName,
cleanup: "delete",
cleanupDelayMinutes: params.cleanupDelayMinutes ?? 0,
createdAt: now,
cleanupHandled: false,
});
persistHookRuns();
startSweeper();
}
export function markHookRunComplete(runId: string) {
const entry = hookRuns.get(runId);
if (!entry) return;
const now = Date.now();
entry.endedAt = now;
entry.cleanupAtMs = now + entry.cleanupDelayMinutes * 60 * 1000;
entry.cleanupHandled = true;
persistHookRuns();
}
export function getHookRun(runId: string): HookRunRecord | undefined {
return hookRuns.get(runId);
}
/** Initialize registry on gateway startup - restores pending cleanups */
export function initHookRunRegistry() {
restoreHookRunsOnce();
}
/** For testing only */
export function clearHookRuns() {
hookRuns.clear();
stopSweeper();
restoreAttempted = false;
}