diff --git a/docs/automation/webhook.md b/docs/automation/webhook.md index 81565bd41..4e7ad38cf 100644 --- a/docs/automation/webhook.md +++ b/docs/automation/webhook.md @@ -98,9 +98,41 @@ Mapping options (summary): (`channel` defaults to `last` and falls back to WhatsApp). - `allowUnsafeExternalContent: true` disables the external content safety wrapper for that hook (dangerous; only for trusted internal sources). +- `cleanup: "delete"` automatically deletes the session and transcript after the hook completes. + Use `cleanupDelayMinutes` to delay cleanup for debugging or auditing. - `moltbot webhooks gmail setup` writes `hooks.gmail` config for `moltbot webhooks gmail run`. See [Gmail Pub/Sub](/automation/gmail-pubsub) for the full Gmail watch flow. +### Session Cleanup + +By default, webhook hook sessions persist indefinitely. For fire-and-forget webhooks where session history has no value after completion, use the `cleanup` option: + +```json +{ + "hooks": { + "mappings": [{ + "match": { "path": "gmail" }, + "action": "agent", + "sessionKey": "hook:gmail:{{messages[0].id}}", + "cleanup": "delete", + "cleanupDelayMinutes": 5 + }] + } +} +``` + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `cleanup` | `"delete"` \| `"keep"` | `"keep"` | Whether to delete session + transcript after completion | +| `cleanupDelayMinutes` | `number` | `0` | Minutes to wait before cleanup (allows debugging/auditing) | + +When `cleanup: "delete"` is set: +- The session entry is removed from `sessions.json` +- The transcript `.jsonl` file is deleted +- Cleanup runs asynchronously via a sweeper (every 60 seconds) +- If `cleanupDelayMinutes` is set, cleanup is delayed by that many minutes +- Failed cleanups are automatically retried on the next sweep + ## Responses - `200` for `/hooks/wake` diff --git a/src/config/types.hooks.test.ts b/src/config/types.hooks.test.ts new file mode 100644 index 000000000..2129ee979 --- /dev/null +++ b/src/config/types.hooks.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import type { HookMappingConfig } from "./types.hooks.js"; + +describe("HookMappingConfig", () => { + it("accepts cleanup option with delete value", () => { + const config: HookMappingConfig = { + id: "test", + match: { path: "test" }, + action: "agent", + cleanup: "delete", + }; + expect(config.cleanup).toBe("delete"); + }); + + it("accepts cleanup option with keep value", () => { + const config: HookMappingConfig = { + id: "test", + match: { path: "test" }, + action: "agent", + cleanup: "keep", + }; + expect(config.cleanup).toBe("keep"); + }); + + it("accepts cleanupDelayMinutes option", () => { + const config: HookMappingConfig = { + id: "test", + match: { path: "test" }, + action: "agent", + cleanup: "delete", + cleanupDelayMinutes: 5, + }; + expect(config.cleanupDelayMinutes).toBe(5); + }); + + it("allows cleanup fields to be omitted", () => { + const config: HookMappingConfig = { + id: "test", + match: { path: "test" }, + action: "agent", + }; + expect(config.cleanup).toBeUndefined(); + expect(config.cleanupDelayMinutes).toBeUndefined(); + }); +}); diff --git a/src/config/types.hooks.ts b/src/config/types.hooks.ts index 7ca74605a..f097d2216 100644 --- a/src/config/types.hooks.ts +++ b/src/config/types.hooks.ts @@ -36,6 +36,10 @@ export type HookMappingConfig = { thinking?: string; timeoutSeconds?: number; transform?: HookMappingTransform; + /** Session cleanup after hook completes. "delete" removes session + transcript. Default: "keep" */ + cleanup?: "delete" | "keep"; + /** Minutes to wait before cleanup when cleanup="delete". Default: 0 (immediate) */ + cleanupDelayMinutes?: number; }; export type HooksGmailTailscaleMode = "off" | "serve" | "funnel"; 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..388a07d11 --- /dev/null +++ b/src/gateway/hook-run-registry.store.test.ts @@ -0,0 +1,108 @@ +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(); + + // Use path.sep-agnostic check for cross-platform compatibility + expect(result).toMatch(/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); +} diff --git a/src/gateway/hook-run-registry.test.ts b/src/gateway/hook-run-registry.test.ts new file mode 100644 index 000000000..94bf79403 --- /dev/null +++ b/src/gateway/hook-run-registry.test.ts @@ -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(); + }); + }); +}); diff --git a/src/gateway/hook-run-registry.ts b/src/gateway/hook-run-registry.ts new file mode 100644 index 000000000..6264e5ad8 --- /dev/null +++ b/src/gateway/hook-run-registry.ts @@ -0,0 +1,127 @@ +import { callGateway } from "./call.js"; +import { + type HookRunRecord, + loadHookRunRegistryFromDisk, + saveHookRunRegistryToDisk, +} from "./hook-run-registry.store.js"; + +const hookRuns = new Map(); +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; +} diff --git a/src/gateway/hooks-cleanup.integration.test.ts b/src/gateway/hooks-cleanup.integration.test.ts new file mode 100644 index 000000000..3a910e024 --- /dev/null +++ b/src/gateway/hooks-cleanup.integration.test.ts @@ -0,0 +1,173 @@ +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().mockResolvedValue({ ok: true }), +})); + +describe("webhook hook cleanup integration", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.resetModules(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.resetAllMocks(); + }); + + it("sweeper deletes session when cleanupAtMs is reached", async () => { + const { callGateway } = await import("./call.js"); + const mockCallGateway = vi.mocked(callGateway); + + const { registerHookRun, markHookRunComplete, clearHookRuns } = + await import("./hook-run-registry.js"); + clearHookRuns(); + + // Register a hook run with immediate cleanup + registerHookRun({ + runId: "test-run", + sessionKey: "hook:test:123", + jobName: "Test", + cleanup: "delete", + cleanupDelayMinutes: 0, + }); + + // Mark complete (sets cleanupAtMs to now and cleanupHandled to true) + markHookRunComplete("test-run"); + + // Advance time past the sweeper interval + await vi.advanceTimersByTimeAsync(61_000); + + // Verify sessions.delete was called + expect(mockCallGateway).toHaveBeenCalledWith( + expect.objectContaining({ + method: "sessions.delete", + params: { key: "hook:test:123", deleteTranscript: true }, + }), + ); + + clearHookRuns(); + }); + + it("does not delete session when cleanup=keep", async () => { + const { callGateway } = await import("./call.js"); + const mockCallGateway = vi.mocked(callGateway); + + const { registerHookRun, clearHookRuns } = await import("./hook-run-registry.js"); + clearHookRuns(); + + // Register with cleanup=keep (should be no-op) + registerHookRun({ + runId: "test-run", + sessionKey: "hook:test:123", + jobName: "Test", + cleanup: "keep", + cleanupDelayMinutes: 0, + }); + + // Advance time + await vi.advanceTimersByTimeAsync(120_000); + + // Verify sessions.delete was NOT called + expect(mockCallGateway).not.toHaveBeenCalled(); + + clearHookRuns(); + }); + + it("respects cleanupDelayMinutes", async () => { + const { callGateway } = await import("./call.js"); + const mockCallGateway = vi.mocked(callGateway); + + const { registerHookRun, markHookRunComplete, clearHookRuns } = + await import("./hook-run-registry.js"); + clearHookRuns(); + + // Register with 5 minute delay + registerHookRun({ + runId: "test-run", + sessionKey: "hook:test:123", + jobName: "Test", + cleanup: "delete", + cleanupDelayMinutes: 5, + }); + + markHookRunComplete("test-run"); + + // Advance 2 minutes - should NOT delete yet + await vi.advanceTimersByTimeAsync(2 * 60 * 1000); + expect(mockCallGateway).not.toHaveBeenCalled(); + + // Advance past 5 minutes total (need to trigger sweeper at 6 min mark) + await vi.advanceTimersByTimeAsync(4 * 60 * 1000); + expect(mockCallGateway).toHaveBeenCalledWith( + expect.objectContaining({ + method: "sessions.delete", + }), + ); + + clearHookRuns(); + }); + + it("retries on RPC failure", async () => { + const { callGateway } = await import("./call.js"); + const mockCallGateway = vi.mocked(callGateway); + mockCallGateway.mockRejectedValueOnce(new Error("RPC failed")); + mockCallGateway.mockResolvedValueOnce({ ok: true }); + + const { registerHookRun, markHookRunComplete, getHookRun, clearHookRuns } = + await import("./hook-run-registry.js"); + clearHookRuns(); + + registerHookRun({ + runId: "test-run", + sessionKey: "hook:test:123", + jobName: "Test", + cleanup: "delete", + cleanupDelayMinutes: 0, + }); + + markHookRunComplete("test-run"); + + // First sweep - RPC fails, entry stays + await vi.advanceTimersByTimeAsync(61_000); + expect(mockCallGateway).toHaveBeenCalledTimes(1); + expect(getHookRun("test-run")).toBeDefined(); + + // Second sweep - RPC succeeds, entry removed + await vi.advanceTimersByTimeAsync(60_000); + expect(mockCallGateway).toHaveBeenCalledTimes(2); + expect(getHookRun("test-run")).toBeUndefined(); + + clearHookRuns(); + }); + + it("does not delete session when cleanup=undefined", async () => { + const { callGateway } = await import("./call.js"); + const mockCallGateway = vi.mocked(callGateway); + + const { registerHookRun, clearHookRuns } = await import("./hook-run-registry.js"); + clearHookRuns(); + + // Register with cleanup=undefined (should be no-op) + registerHookRun({ + runId: "test-run", + sessionKey: "hook:test:123", + jobName: "Test", + cleanup: undefined, + cleanupDelayMinutes: undefined, + }); + + // Advance time + await vi.advanceTimersByTimeAsync(120_000); + + // Verify sessions.delete was NOT called + expect(mockCallGateway).not.toHaveBeenCalled(); + + clearHookRuns(); + }); +}); diff --git a/src/gateway/hooks-mapping.test.ts b/src/gateway/hooks-mapping.test.ts index 8900ffd07..696d3ba51 100644 --- a/src/gateway/hooks-mapping.test.ts +++ b/src/gateway/hooks-mapping.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import type { HookAction, HookMappingResolved } from "./hooks-mapping.js"; import { applyHookMappings, resolveHookMappings } from "./hooks-mapping.js"; const baseUrl = new URL("http://127.0.0.1:18789/hooks/gmail"); @@ -32,8 +33,7 @@ describe("hooks mapping", () => { path: "gmail", }); expect(result?.ok).toBe(true); - if (result?.ok) { - expect(result.action.kind).toBe("agent"); + if (result?.ok && result.action?.kind === "agent") { expect(result.action.message).toBe("Subject: Hello"); } }); @@ -57,7 +57,7 @@ describe("hooks mapping", () => { path: "gmail", }); expect(result?.ok).toBe(true); - if (result?.ok && result.action.kind === "agent") { + if (result?.ok && result.action?.kind === "agent") { expect(result.action.model).toBe("openai/gpt-4.1-mini"); } }); @@ -90,11 +90,8 @@ describe("hooks mapping", () => { }); expect(result?.ok).toBe(true); - if (result?.ok) { - expect(result.action.kind).toBe("wake"); - if (result.action.kind === "wake") { - expect(result.action.text).toBe("Ping Ada"); - } + if (result?.ok && result.action?.kind === "wake") { + expect(result.action.text).toBe("Ping Ada"); } }); @@ -147,8 +144,7 @@ describe("hooks mapping", () => { path: "gmail", }); expect(result?.ok).toBe(true); - if (result?.ok) { - expect(result.action.kind).toBe("agent"); + if (result?.ok && result.action?.kind === "agent") { expect(result.action.message).toBe("Override subject: Hello"); } }); @@ -166,3 +162,89 @@ describe("hooks mapping", () => { expect(result?.ok).toBe(false); }); }); + +describe("HookMappingResolved", () => { + it("includes cleanup fields", () => { + const resolved: HookMappingResolved = { + id: "test", + matchPath: "test", + action: "agent", + cleanup: "delete", + cleanupDelayMinutes: 5, + }; + expect(resolved.cleanup).toBe("delete"); + expect(resolved.cleanupDelayMinutes).toBe(5); + }); +}); + +describe("HookAction", () => { + it("agent action includes cleanup fields", () => { + const action: HookAction = { + kind: "agent", + message: "test", + wakeMode: "now", + cleanup: "delete", + cleanupDelayMinutes: 5, + }; + expect(action.kind).toBe("agent"); + if (action.kind === "agent") { + expect(action.cleanup).toBe("delete"); + expect(action.cleanupDelayMinutes).toBe(5); + } + }); +}); + +describe("applyHookMappings cleanup propagation", () => { + it("propagates cleanup option from config to action", async () => { + const mappings = resolveHookMappings({ + mappings: [ + { + id: "test", + match: { path: "test" }, + action: "agent", + messageTemplate: "hello", + cleanup: "delete", + cleanupDelayMinutes: 5, + }, + ], + }); + + const result = await applyHookMappings(mappings, { + payload: {}, + headers: {}, + url: new URL("http://localhost/hooks/test"), + path: "test", + }); + + expect(result?.ok).toBe(true); + if (result?.ok && result.action?.kind === "agent") { + expect(result.action.cleanup).toBe("delete"); + expect(result.action.cleanupDelayMinutes).toBe(5); + } + }); + + it("defaults cleanup to undefined when not specified", async () => { + const mappings = resolveHookMappings({ + mappings: [ + { + id: "test", + match: { path: "test" }, + action: "agent", + messageTemplate: "hello", + }, + ], + }); + + const result = await applyHookMappings(mappings, { + payload: {}, + headers: {}, + url: new URL("http://localhost/hooks/test"), + path: "test", + }); + + expect(result?.ok).toBe(true); + if (result?.ok && result.action?.kind === "agent") { + expect(result.action.cleanup).toBeUndefined(); + } + }); +}); diff --git a/src/gateway/hooks-mapping.ts b/src/gateway/hooks-mapping.ts index 2ebf9b136..9b4538380 100644 --- a/src/gateway/hooks-mapping.ts +++ b/src/gateway/hooks-mapping.ts @@ -21,6 +21,8 @@ export type HookMappingResolved = { model?: string; thinking?: string; timeoutSeconds?: number; + cleanup?: "delete" | "keep"; + cleanupDelayMinutes?: number; transform?: HookMappingTransformResolved; }; @@ -55,6 +57,8 @@ export type HookAction = model?: string; thinking?: string; timeoutSeconds?: number; + cleanup?: "delete" | "keep"; + cleanupDelayMinutes?: number; }; export type HookMappingResult = @@ -94,6 +98,8 @@ type HookTransformResult = Partial<{ model: string; thinking: string; timeoutSeconds: number; + cleanup: "delete" | "keep"; + cleanupDelayMinutes: number; }> | null; type HookTransformFn = ( @@ -191,6 +197,8 @@ function normalizeHookMapping( model: mapping.model, thinking: mapping.thinking, timeoutSeconds: mapping.timeoutSeconds, + cleanup: mapping.cleanup, + cleanupDelayMinutes: mapping.cleanupDelayMinutes, transform, }; } @@ -237,6 +245,8 @@ function buildActionFromMapping( model: renderOptional(mapping.model, ctx), thinking: renderOptional(mapping.thinking, ctx), timeoutSeconds: mapping.timeoutSeconds, + cleanup: mapping.cleanup, + cleanupDelayMinutes: mapping.cleanupDelayMinutes, }, }; } @@ -277,6 +287,8 @@ function mergeAction( model: override.model ?? baseAgent?.model, thinking: override.thinking ?? baseAgent?.thinking, timeoutSeconds: override.timeoutSeconds ?? baseAgent?.timeoutSeconds, + cleanup: override.cleanup ?? baseAgent?.cleanup, + cleanupDelayMinutes: override.cleanupDelayMinutes ?? baseAgent?.cleanupDelayMinutes, }); } diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index f08dc811c..07d8a3f02 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -47,6 +47,8 @@ type HookDispatchers = { thinking?: string; timeoutSeconds?: number; allowUnsafeExternalContent?: boolean; + cleanup?: "delete" | "keep"; + cleanupDelayMinutes?: number; }) => string; }; @@ -182,6 +184,8 @@ export function createHooksRequestHandler( thinking: mapped.action.thinking, timeoutSeconds: mapped.action.timeoutSeconds, allowUnsafeExternalContent: mapped.action.allowUnsafeExternalContent, + cleanup: mapped.action.cleanup, + cleanupDelayMinutes: mapped.action.cleanupDelayMinutes, }); sendJson(res, 202, { ok: true, runId }); return true; diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index f641c4076..30cc22590 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -1,6 +1,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { initSubagentRegistry } from "../agents/subagent-registry.js"; import { registerSkillsChangeListener } from "../agents/skills/refresh.js"; +import { initHookRunRegistry } from "./hook-run-registry.js"; import type { CanvasHostServer } from "../canvas-host/server.js"; import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js"; import { createDefaultDeps } from "../cli/deps.js"; @@ -216,6 +217,7 @@ export async function startGatewayServer( } setGatewaySigusr1RestartPolicy({ allowExternal: cfgAtStart.commands?.restart === true }); initSubagentRegistry(); + initHookRunRegistry(); const defaultAgentId = resolveDefaultAgentId(cfgAtStart); const defaultWorkspaceDir = resolveAgentWorkspaceDir(cfgAtStart, defaultAgentId); const baseMethods = listGatewayMethods(); diff --git a/src/gateway/server/hooks.ts b/src/gateway/server/hooks.ts index 18d46368f..382a36b67 100644 --- a/src/gateway/server/hooks.ts +++ b/src/gateway/server/hooks.ts @@ -9,6 +9,7 @@ import { requestHeartbeatNow } from "../../infra/heartbeat-wake.js"; import { enqueueSystemEvent } from "../../infra/system-events.js"; import type { createSubsystemLogger } from "../../logging/subsystem.js"; import type { HookMessageChannel, HooksConfigResolved } from "../hooks.js"; +import { markHookRunComplete, registerHookRun } from "../hook-run-registry.js"; import { createHooksRequestHandler } from "../server-http.js"; type SubsystemLogger = ReturnType; @@ -42,6 +43,8 @@ export function createGatewayHooksRequestHandler(params: { thinking?: string; timeoutSeconds?: number; allowUnsafeExternalContent?: boolean; + cleanup?: "delete" | "keep"; + cleanupDelayMinutes?: number; }) => { const sessionKey = value.sessionKey.trim() ? value.sessionKey.trim() : `hook:${randomUUID()}`; const mainSessionKey = resolveMainSessionKeyFromConfig(); @@ -71,6 +74,13 @@ export function createGatewayHooksRequestHandler(params: { }; const runId = randomUUID(); + registerHookRun({ + runId, + sessionKey, + jobName: value.name, + cleanup: value.cleanup, + cleanupDelayMinutes: value.cleanupDelayMinutes, + }); void (async () => { try { const cfg = loadConfig(); @@ -88,10 +98,12 @@ export function createGatewayHooksRequestHandler(params: { enqueueSystemEvent(`${prefix}: ${summary}`.trim(), { sessionKey: mainSessionKey, }); + markHookRunComplete(runId); if (value.wakeMode === "now") { requestHeartbeatNow({ reason: `hook:${jobId}` }); } } catch (err) { + markHookRunComplete(runId); logHooks.warn(`hook agent failed: ${String(err)}`); enqueueSystemEvent(`Hook ${value.name} (error): ${String(err)}`, { sessionKey: mainSessionKey,