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).
|
(`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`
|
||||||
|
|||||||
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;
|
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";
|
||||||
|
|||||||
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 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user