diff --git a/src/gateway/hook-run-registry.store.test.ts b/src/gateway/hook-run-registry.store.test.ts new file mode 100644 index 000000000..150608f7a --- /dev/null +++ b/src/gateway/hook-run-registry.store.test.ts @@ -0,0 +1,107 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../config/paths.js", () => ({ + STATE_DIR: "/mock/state", +})); + +vi.mock("../infra/json-file.js", () => ({ + loadJsonFile: vi.fn(), + saveJsonFile: vi.fn(), +})); + +describe("hook-run-registry.store", () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe("loadHookRunRegistryFromDisk", () => { + it("returns empty map when file does not exist", async () => { + const { loadJsonFile } = await import("../infra/json-file.js"); + vi.mocked(loadJsonFile).mockReturnValue(null); + + const { loadHookRunRegistryFromDisk } = await import("./hook-run-registry.store.js"); + const result = loadHookRunRegistryFromDisk(); + + expect(result.size).toBe(0); + }); + + it("loads and parses existing registry file", async () => { + const { loadJsonFile } = await import("../infra/json-file.js"); + vi.mocked(loadJsonFile).mockReturnValue({ + version: 1, + runs: { + "run-1": { + runId: "run-1", + sessionKey: "hook:test:1", + jobName: "test", + cleanup: "delete", + cleanupDelayMinutes: 0, + createdAt: 1000, + }, + }, + }); + + const { loadHookRunRegistryFromDisk } = await import("./hook-run-registry.store.js"); + const result = loadHookRunRegistryFromDisk(); + + expect(result.size).toBe(1); + expect(result.get("run-1")?.sessionKey).toBe("hook:test:1"); + }); + + it("returns empty map for invalid version", async () => { + const { loadJsonFile } = await import("../infra/json-file.js"); + vi.mocked(loadJsonFile).mockReturnValue({ + version: 999, + runs: { "run-1": { runId: "run-1" } }, + }); + + const { loadHookRunRegistryFromDisk } = await import("./hook-run-registry.store.js"); + const result = loadHookRunRegistryFromDisk(); + + expect(result.size).toBe(0); + }); + }); + + describe("saveHookRunRegistryToDisk", () => { + it("writes versioned registry to disk", async () => { + const { saveJsonFile } = await import("../infra/json-file.js"); + const mockSave = vi.mocked(saveJsonFile); + + const { saveHookRunRegistryToDisk } = await import("./hook-run-registry.store.js"); + const registry = new Map([ + [ + "run-1", + { + runId: "run-1", + sessionKey: "hook:test:1", + jobName: "test", + cleanup: "delete" as const, + cleanupDelayMinutes: 0, + createdAt: Date.now(), + }, + ], + ]); + + saveHookRunRegistryToDisk(registry); + + expect(mockSave).toHaveBeenCalledWith( + expect.stringContaining("hook-runs.json"), + expect.objectContaining({ version: 1 }), + ); + }); + }); + + describe("resolveHookRunRegistryPath", () => { + it("returns path under STATE_DIR", async () => { + const { resolveHookRunRegistryPath } = await import("./hook-run-registry.store.js"); + const result = resolveHookRunRegistryPath(); + + expect(result).toContain("/mock/state"); + expect(result).toContain("hook-runs.json"); + }); + }); +}); diff --git a/src/gateway/hook-run-registry.store.ts b/src/gateway/hook-run-registry.store.ts new file mode 100644 index 000000000..3eeffe1e0 --- /dev/null +++ b/src/gateway/hook-run-registry.store.ts @@ -0,0 +1,58 @@ +import path from "node:path"; + +import { STATE_DIR } from "../config/paths.js"; +import { loadJsonFile, saveJsonFile } from "../infra/json-file.js"; + +export type HookRunRecord = { + runId: string; + sessionKey: string; + jobName: string; + cleanup: "delete" | "keep"; + cleanupDelayMinutes: number; + createdAt: number; + endedAt?: number; + cleanupAtMs?: number; + cleanupHandled?: boolean; +}; + +type PersistedHookRunRegistry = { + version: 1; + runs: Record; +}; + +const REGISTRY_VERSION = 1 as const; + +export function resolveHookRunRegistryPath(): string { + return path.join(STATE_DIR, "hooks", "hook-runs.json"); +} + +export function loadHookRunRegistryFromDisk(): Map { + const pathname = resolveHookRunRegistryPath(); + const raw = loadJsonFile(pathname); + if (!raw || typeof raw !== "object") return new Map(); + const record = raw as Partial; + if (record.version !== 1) return new Map(); + const runsRaw = record.runs; + if (!runsRaw || typeof runsRaw !== "object") return new Map(); + const out = new Map(); + for (const [runId, entry] of Object.entries(runsRaw)) { + if (!entry || typeof entry !== "object") continue; + const typed = entry as HookRunRecord; + if (!typed.runId || typeof typed.runId !== "string") continue; + out.set(runId, typed); + } + return out; +} + +export function saveHookRunRegistryToDisk(runs: Map): void { + const pathname = resolveHookRunRegistryPath(); + const serialized: Record = {}; + for (const [runId, entry] of runs.entries()) { + serialized[runId] = entry; + } + const out: PersistedHookRunRegistry = { + version: REGISTRY_VERSION, + runs: serialized, + }; + saveJsonFile(pathname, out); +}