This commit is contained in:
Trevin Chow 2026-01-29 17:00:01 -08:00 committed by GitHub
commit 51c20235b3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 830 additions and 10 deletions

View File

@ -98,9 +98,41 @@ Mapping options (summary):
(`channel` defaults to `last` and falls back to WhatsApp). (`channel` defaults to `last` and falls back to WhatsApp).
- `allowUnsafeExternalContent: true` disables the external content safety wrapper for that hook - `allowUnsafeExternalContent: true` disables the external content safety wrapper for that hook
(dangerous; only for trusted internal sources). (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`. - `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. 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 ## Responses
- `200` for `/hooks/wake` - `200` for `/hooks/wake`

View File

@ -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();
});
});

View File

@ -36,6 +36,10 @@ export type HookMappingConfig = {
thinking?: string; thinking?: string;
timeoutSeconds?: number; timeoutSeconds?: number;
transform?: HookMappingTransform; 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"; export type HooksGmailTailscaleMode = "off" | "serve" | "funnel";

View File

@ -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");
});
});
});

View File

@ -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<string, HookRunRecord>;
};
const REGISTRY_VERSION = 1 as const;
export function resolveHookRunRegistryPath(): string {
return path.join(STATE_DIR, "hooks", "hook-runs.json");
}
export function loadHookRunRegistryFromDisk(): Map<string, HookRunRecord> {
const pathname = resolveHookRunRegistryPath();
const raw = loadJsonFile(pathname);
if (!raw || typeof raw !== "object") return new Map();
const record = raw as Partial<PersistedHookRunRegistry>;
if (record.version !== 1) return new Map();
const runsRaw = record.runs;
if (!runsRaw || typeof runsRaw !== "object") return new Map();
const out = new Map<string, HookRunRecord>();
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<string, HookRunRecord>): void {
const pathname = resolveHookRunRegistryPath();
const serialized: Record<string, HookRunRecord> = {};
for (const [runId, entry] of runs.entries()) {
serialized[runId] = entry;
}
const out: PersistedHookRunRegistry = {
version: REGISTRY_VERSION,
runs: serialized,
};
saveJsonFile(pathname, out);
}

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;
}

View File

@ -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();
});
});

View File

@ -3,6 +3,7 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { HookAction, HookMappingResolved } from "./hooks-mapping.js";
import { applyHookMappings, resolveHookMappings } from "./hooks-mapping.js"; import { applyHookMappings, resolveHookMappings } from "./hooks-mapping.js";
const baseUrl = new URL("http://127.0.0.1:18789/hooks/gmail"); const baseUrl = new URL("http://127.0.0.1:18789/hooks/gmail");
@ -32,8 +33,7 @@ describe("hooks mapping", () => {
path: "gmail", path: "gmail",
}); });
expect(result?.ok).toBe(true); expect(result?.ok).toBe(true);
if (result?.ok) { if (result?.ok && result.action?.kind === "agent") {
expect(result.action.kind).toBe("agent");
expect(result.action.message).toBe("Subject: Hello"); expect(result.action.message).toBe("Subject: Hello");
} }
}); });
@ -57,7 +57,7 @@ describe("hooks mapping", () => {
path: "gmail", path: "gmail",
}); });
expect(result?.ok).toBe(true); 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"); expect(result.action.model).toBe("openai/gpt-4.1-mini");
} }
}); });
@ -90,11 +90,8 @@ describe("hooks mapping", () => {
}); });
expect(result?.ok).toBe(true); expect(result?.ok).toBe(true);
if (result?.ok) { if (result?.ok && result.action?.kind === "wake") {
expect(result.action.kind).toBe("wake"); expect(result.action.text).toBe("Ping Ada");
if (result.action.kind === "wake") {
expect(result.action.text).toBe("Ping Ada");
}
} }
}); });
@ -147,8 +144,7 @@ describe("hooks mapping", () => {
path: "gmail", path: "gmail",
}); });
expect(result?.ok).toBe(true); expect(result?.ok).toBe(true);
if (result?.ok) { if (result?.ok && result.action?.kind === "agent") {
expect(result.action.kind).toBe("agent");
expect(result.action.message).toBe("Override subject: Hello"); expect(result.action.message).toBe("Override subject: Hello");
} }
}); });
@ -166,3 +162,89 @@ describe("hooks mapping", () => {
expect(result?.ok).toBe(false); 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();
}
});
});

View File

@ -21,6 +21,8 @@ export type HookMappingResolved = {
model?: string; model?: string;
thinking?: string; thinking?: string;
timeoutSeconds?: number; timeoutSeconds?: number;
cleanup?: "delete" | "keep";
cleanupDelayMinutes?: number;
transform?: HookMappingTransformResolved; transform?: HookMappingTransformResolved;
}; };
@ -55,6 +57,8 @@ export type HookAction =
model?: string; model?: string;
thinking?: string; thinking?: string;
timeoutSeconds?: number; timeoutSeconds?: number;
cleanup?: "delete" | "keep";
cleanupDelayMinutes?: number;
}; };
export type HookMappingResult = export type HookMappingResult =
@ -94,6 +98,8 @@ type HookTransformResult = Partial<{
model: string; model: string;
thinking: string; thinking: string;
timeoutSeconds: number; timeoutSeconds: number;
cleanup: "delete" | "keep";
cleanupDelayMinutes: number;
}> | null; }> | null;
type HookTransformFn = ( type HookTransformFn = (
@ -191,6 +197,8 @@ function normalizeHookMapping(
model: mapping.model, model: mapping.model,
thinking: mapping.thinking, thinking: mapping.thinking,
timeoutSeconds: mapping.timeoutSeconds, timeoutSeconds: mapping.timeoutSeconds,
cleanup: mapping.cleanup,
cleanupDelayMinutes: mapping.cleanupDelayMinutes,
transform, transform,
}; };
} }
@ -237,6 +245,8 @@ function buildActionFromMapping(
model: renderOptional(mapping.model, ctx), model: renderOptional(mapping.model, ctx),
thinking: renderOptional(mapping.thinking, ctx), thinking: renderOptional(mapping.thinking, ctx),
timeoutSeconds: mapping.timeoutSeconds, timeoutSeconds: mapping.timeoutSeconds,
cleanup: mapping.cleanup,
cleanupDelayMinutes: mapping.cleanupDelayMinutes,
}, },
}; };
} }
@ -277,6 +287,8 @@ function mergeAction(
model: override.model ?? baseAgent?.model, model: override.model ?? baseAgent?.model,
thinking: override.thinking ?? baseAgent?.thinking, thinking: override.thinking ?? baseAgent?.thinking,
timeoutSeconds: override.timeoutSeconds ?? baseAgent?.timeoutSeconds, timeoutSeconds: override.timeoutSeconds ?? baseAgent?.timeoutSeconds,
cleanup: override.cleanup ?? baseAgent?.cleanup,
cleanupDelayMinutes: override.cleanupDelayMinutes ?? baseAgent?.cleanupDelayMinutes,
}); });
} }

View File

@ -47,6 +47,8 @@ type HookDispatchers = {
thinking?: string; thinking?: string;
timeoutSeconds?: number; timeoutSeconds?: number;
allowUnsafeExternalContent?: boolean; allowUnsafeExternalContent?: boolean;
cleanup?: "delete" | "keep";
cleanupDelayMinutes?: number;
}) => string; }) => string;
}; };
@ -182,6 +184,8 @@ export function createHooksRequestHandler(
thinking: mapped.action.thinking, thinking: mapped.action.thinking,
timeoutSeconds: mapped.action.timeoutSeconds, timeoutSeconds: mapped.action.timeoutSeconds,
allowUnsafeExternalContent: mapped.action.allowUnsafeExternalContent, allowUnsafeExternalContent: mapped.action.allowUnsafeExternalContent,
cleanup: mapped.action.cleanup,
cleanupDelayMinutes: mapped.action.cleanupDelayMinutes,
}); });
sendJson(res, 202, { ok: true, runId }); sendJson(res, 202, { ok: true, runId });
return true; return true;

View File

@ -1,6 +1,7 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { initSubagentRegistry } from "../agents/subagent-registry.js"; import { initSubagentRegistry } from "../agents/subagent-registry.js";
import { registerSkillsChangeListener } from "../agents/skills/refresh.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 { CanvasHostServer } from "../canvas-host/server.js";
import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js"; import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js";
import { createDefaultDeps } from "../cli/deps.js"; import { createDefaultDeps } from "../cli/deps.js";
@ -216,6 +217,7 @@ export async function startGatewayServer(
} }
setGatewaySigusr1RestartPolicy({ allowExternal: cfgAtStart.commands?.restart === true }); setGatewaySigusr1RestartPolicy({ allowExternal: cfgAtStart.commands?.restart === true });
initSubagentRegistry(); initSubagentRegistry();
initHookRunRegistry();
const defaultAgentId = resolveDefaultAgentId(cfgAtStart); const defaultAgentId = resolveDefaultAgentId(cfgAtStart);
const defaultWorkspaceDir = resolveAgentWorkspaceDir(cfgAtStart, defaultAgentId); const defaultWorkspaceDir = resolveAgentWorkspaceDir(cfgAtStart, defaultAgentId);
const baseMethods = listGatewayMethods(); const baseMethods = listGatewayMethods();

View File

@ -9,6 +9,7 @@ import { requestHeartbeatNow } from "../../infra/heartbeat-wake.js";
import { enqueueSystemEvent } from "../../infra/system-events.js"; import { enqueueSystemEvent } from "../../infra/system-events.js";
import type { createSubsystemLogger } from "../../logging/subsystem.js"; import type { createSubsystemLogger } from "../../logging/subsystem.js";
import type { HookMessageChannel, HooksConfigResolved } from "../hooks.js"; import type { HookMessageChannel, HooksConfigResolved } from "../hooks.js";
import { markHookRunComplete, registerHookRun } from "../hook-run-registry.js";
import { createHooksRequestHandler } from "../server-http.js"; import { createHooksRequestHandler } from "../server-http.js";
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>; type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
@ -42,6 +43,8 @@ export function createGatewayHooksRequestHandler(params: {
thinking?: string; thinking?: string;
timeoutSeconds?: number; timeoutSeconds?: number;
allowUnsafeExternalContent?: boolean; allowUnsafeExternalContent?: boolean;
cleanup?: "delete" | "keep";
cleanupDelayMinutes?: number;
}) => { }) => {
const sessionKey = value.sessionKey.trim() ? value.sessionKey.trim() : `hook:${randomUUID()}`; const sessionKey = value.sessionKey.trim() ? value.sessionKey.trim() : `hook:${randomUUID()}`;
const mainSessionKey = resolveMainSessionKeyFromConfig(); const mainSessionKey = resolveMainSessionKeyFromConfig();
@ -71,6 +74,13 @@ export function createGatewayHooksRequestHandler(params: {
}; };
const runId = randomUUID(); const runId = randomUUID();
registerHookRun({
runId,
sessionKey,
jobName: value.name,
cleanup: value.cleanup,
cleanupDelayMinutes: value.cleanupDelayMinutes,
});
void (async () => { void (async () => {
try { try {
const cfg = loadConfig(); const cfg = loadConfig();
@ -88,10 +98,12 @@ export function createGatewayHooksRequestHandler(params: {
enqueueSystemEvent(`${prefix}: ${summary}`.trim(), { enqueueSystemEvent(`${prefix}: ${summary}`.trim(), {
sessionKey: mainSessionKey, sessionKey: mainSessionKey,
}); });
markHookRunComplete(runId);
if (value.wakeMode === "now") { if (value.wakeMode === "now") {
requestHeartbeatNow({ reason: `hook:${jobId}` }); requestHeartbeatNow({ reason: `hook:${jobId}` });
} }
} catch (err) { } catch (err) {
markHookRunComplete(runId);
logHooks.warn(`hook agent failed: ${String(err)}`); logHooks.warn(`hook agent failed: ${String(err)}`);
enqueueSystemEvent(`Hook ${value.name} (error): ${String(err)}`, { enqueueSystemEvent(`Hook ${value.name} (error): ${String(err)}`, {
sessionKey: mainSessionKey, sessionKey: mainSessionKey,