Merge f3d4595491 into 4583f88626
This commit is contained in:
commit
51c20235b3
@ -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`
|
||||
|
||||
45
src/config/types.hooks.test.ts
Normal file
45
src/config/types.hooks.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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";
|
||||
|
||||
108
src/gateway/hook-run-registry.store.test.ts
Normal file
108
src/gateway/hook-run-registry.store.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
58
src/gateway/hook-run-registry.store.ts
Normal file
58
src/gateway/hook-run-registry.store.ts
Normal 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);
|
||||
}
|
||||
161
src/gateway/hook-run-registry.test.ts
Normal file
161
src/gateway/hook-run-registry.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
127
src/gateway/hook-run-registry.ts
Normal file
127
src/gateway/hook-run-registry.ts
Normal 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;
|
||||
}
|
||||
173
src/gateway/hooks-cleanup.integration.test.ts
Normal file
173
src/gateway/hooks-cleanup.integration.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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<typeof createSubsystemLogger>;
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user