diff --git a/src/config/types.hooks.ts b/src/config/types.hooks.ts index 7ca74605a..5a439ae0f 100644 --- a/src/config/types.hooks.ts +++ b/src/config/types.hooks.ts @@ -36,6 +36,8 @@ export type HookMappingConfig = { thinking?: string; timeoutSeconds?: number; transform?: HookMappingTransform; + /** Session cleanup after hook completes: "delete" archives the session, "keep" preserves it (default: "keep"). */ + cleanup?: "delete" | "keep"; }; export type HooksGmailTailscaleMode = "off" | "serve" | "funnel"; @@ -67,6 +69,8 @@ export type HooksGmailConfig = { model?: string; /** Optional thinking level override for Gmail hook processing. */ thinking?: "off" | "minimal" | "low" | "medium" | "high"; + /** Session cleanup after Gmail hook completes: "delete" archives the session, "keep" preserves it (default: "delete" for Gmail). */ + cleanup?: "delete" | "keep"; }; export type InternalHookHandlerConfig = { @@ -121,4 +125,6 @@ export type HooksConfig = { gmail?: HooksGmailConfig; /** Internal agent event hooks */ internal?: InternalHooksConfig; + /** TTL in milliseconds for hook sessions before auto-cleanup (default: 86400000 = 24h). Set to 0 to disable. */ + sessionTtlMs?: number; }; diff --git a/src/config/zod-schema.hooks.ts b/src/config/zod-schema.hooks.ts index 35e74f7af..b35a113f4 100644 --- a/src/config/zod-schema.hooks.ts +++ b/src/config/zod-schema.hooks.ts @@ -40,6 +40,7 @@ export const HookMappingSchema = z }) .strict() .optional(), + cleanup: z.union([z.literal("delete"), z.literal("keep")]).optional(), }) .strict() .optional(); @@ -125,6 +126,7 @@ export const HooksGmailSchema = z z.literal("high"), ]) .optional(), + cleanup: z.union([z.literal("delete"), z.literal("keep")]).optional(), }) .strict() .optional(); diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 961ba8ecb..e001c30a6 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -242,6 +242,7 @@ export const OpenClawSchema = z mappings: z.array(HookMappingSchema).optional(), gmail: HooksGmailSchema, internal: InternalHooksSchema, + sessionTtlMs: z.number().int().min(0).optional(), }) .strict() .optional(), diff --git a/src/gateway/hooks-mapping.ts b/src/gateway/hooks-mapping.ts index 2ebf9b136..38eb60652 100644 --- a/src/gateway/hooks-mapping.ts +++ b/src/gateway/hooks-mapping.ts @@ -22,6 +22,7 @@ export type HookMappingResolved = { thinking?: string; timeoutSeconds?: number; transform?: HookMappingTransformResolved; + cleanup?: "delete" | "keep"; }; export type HookMappingTransformResolved = { @@ -55,6 +56,7 @@ export type HookAction = model?: string; thinking?: string; timeoutSeconds?: number; + cleanup?: "delete" | "keep"; }; export type HookMappingResult = @@ -73,6 +75,7 @@ const hookPresetMappings: Record = { sessionKey: "hook:gmail:{{messages[0].id}}", messageTemplate: "New email from {{messages[0].from}}\nSubject: {{messages[0].subject}}\n{{messages[0].snippet}}\n{{messages[0].body}}", + // Sessions are cleaned up via TTL (hooks.sessionTtlMs, default 24h) }, ], }; @@ -94,6 +97,7 @@ type HookTransformResult = Partial<{ model: string; thinking: string; timeoutSeconds: number; + cleanup: "delete" | "keep"; }> | null; type HookTransformFn = ( @@ -103,16 +107,21 @@ type HookTransformFn = ( export function resolveHookMappings(hooks?: HooksConfig): HookMappingResolved[] { const presets = hooks?.presets ?? []; const gmailAllowUnsafe = hooks?.gmail?.allowUnsafeExternalContent; + const gmailCleanup = hooks?.gmail?.cleanup; const mappings: HookMappingConfig[] = []; if (hooks?.mappings) mappings.push(...hooks.mappings); for (const preset of presets) { const presetMappings = hookPresetMappings[preset]; if (!presetMappings) continue; - if (preset === "gmail" && typeof gmailAllowUnsafe === "boolean") { + if (preset === "gmail") { mappings.push( ...presetMappings.map((mapping) => ({ ...mapping, - allowUnsafeExternalContent: gmailAllowUnsafe, + ...(typeof gmailAllowUnsafe === "boolean" && { + allowUnsafeExternalContent: gmailAllowUnsafe, + }), + // Allow config override; fall back to preset default (delete) + cleanup: gmailCleanup ?? mapping.cleanup, })), ); continue; @@ -192,6 +201,7 @@ function normalizeHookMapping( thinking: mapping.thinking, timeoutSeconds: mapping.timeoutSeconds, transform, + cleanup: mapping.cleanup, }; } @@ -237,6 +247,7 @@ function buildActionFromMapping( model: renderOptional(mapping.model, ctx), thinking: renderOptional(mapping.thinking, ctx), timeoutSeconds: mapping.timeoutSeconds, + cleanup: mapping.cleanup, }, }; } @@ -277,6 +288,7 @@ function mergeAction( model: override.model ?? baseAgent?.model, thinking: override.thinking ?? baseAgent?.thinking, timeoutSeconds: override.timeoutSeconds ?? baseAgent?.timeoutSeconds, + cleanup: override.cleanup ?? baseAgent?.cleanup, }); } diff --git a/src/gateway/hooks-session-cleanup.ts b/src/gateway/hooks-session-cleanup.ts new file mode 100644 index 000000000..8ac4b1a28 --- /dev/null +++ b/src/gateway/hooks-session-cleanup.ts @@ -0,0 +1,83 @@ +/** + * TTL-based cleanup for hook sessions. + * Runs periodically to delete stale hook sessions older than the configured TTL. + */ + +import type { MoltbotConfig } from "../config/config.js"; +import { loadCombinedSessionStoreForGateway, listSessionsFromStore } from "./session-utils.js"; +import { callGateway } from "./call.js"; +import type { createSubsystemLogger } from "../logging/subsystem.js"; + +type SubsystemLogger = ReturnType; + +const DEFAULT_HOOK_SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours +const HOOK_SESSION_PREFIX = "hook:"; + +export type HookSessionCleanupResult = { + checked: number; + deleted: number; + errors: number; +}; + +/** + * Clean up stale hook sessions older than TTL. + * Returns counts of checked/deleted/errored sessions. + */ +export async function cleanupStaleHookSessions(params: { + cfg: MoltbotConfig; + log?: SubsystemLogger; +}): Promise { + const { cfg, log } = params; + + // Get TTL from config (0 = disabled) + const ttlMs = cfg.hooks?.sessionTtlMs ?? DEFAULT_HOOK_SESSION_TTL_MS; + if (ttlMs <= 0) { + return { checked: 0, deleted: 0, errors: 0 }; + } + + const cutoffMs = Date.now() - ttlMs; + + // List all sessions + const { storePath, store } = loadCombinedSessionStoreForGateway(cfg); + const allSessions = listSessionsFromStore({ + cfg, + storePath, + store, + opts: { limit: 10000 }, // High limit to get all sessions + }); + + // Filter to hook sessions that are stale + const staleSessions = allSessions.sessions.filter((session) => { + // Check if it's a hook session (key starts with "hook:") + if (!session.key.startsWith(HOOK_SESSION_PREFIX)) return false; + // Check if it's older than TTL + const lastActivity = session.updatedAt ?? 0; + return lastActivity < cutoffMs; + }); + + let deleted = 0; + let errors = 0; + + for (const session of staleSessions) { + try { + await callGateway({ + method: "sessions.delete", + params: { key: session.key, deleteTranscript: true }, + timeoutMs: 10_000, + }); + deleted++; + log?.debug?.(`cleaned up stale hook session: ${session.key}`); + } catch (err) { + errors++; + log?.warn?.(`failed to cleanup hook session ${session.key}: ${String(err)}`); + } + } + + if (deleted > 0 || errors > 0) { + log?.info?.( + `hook session cleanup: checked=${staleSessions.length} deleted=${deleted} errors=${errors}`, + ); + } + + return { checked: staleSessions.length, deleted, errors }; +} diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index e84c0ed43..0e383c769 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -47,6 +47,7 @@ type HookDispatchers = { thinking?: string; timeoutSeconds?: number; allowUnsafeExternalContent?: boolean; + cleanup?: "delete" | "keep"; }) => string; }; @@ -182,6 +183,7 @@ export function createHooksRequestHandler( thinking: mapped.action.thinking, timeoutSeconds: mapped.action.timeoutSeconds, allowUnsafeExternalContent: mapped.action.allowUnsafeExternalContent, + cleanup: mapped.action.cleanup, }); sendJson(res, 202, { ok: true, runId }); return true; diff --git a/src/gateway/server-maintenance.ts b/src/gateway/server-maintenance.ts index 499521b84..aae26d886 100644 --- a/src/gateway/server-maintenance.ts +++ b/src/gateway/server-maintenance.ts @@ -1,5 +1,7 @@ import type { HealthSummary } from "../commands/health.js"; +import { loadConfig } from "../config/config.js"; import { abortChatRunById, type ChatAbortControllerEntry } from "./chat-abort.js"; +import { cleanupStaleHookSessions } from "./hooks-session-cleanup.js"; import { setBroadcastHealthUpdate } from "./server/health-state.js"; import type { ChatRunEntry } from "./server-chat.js"; import { @@ -71,9 +73,22 @@ export function startGatewayMaintenanceTimers(params: { .refreshGatewayHealthSnapshot({ probe: true }) .catch((err) => params.logHealth.error(`initial refresh failed: ${formatError(err)}`)); + // Hook session cleanup counter (run hourly = every 60 iterations at 60s interval) + let hookCleanupCounter = 0; + const HOOK_CLEANUP_INTERVAL = 60; // Run every 60 iterations (1 hour) + // dedupe cache cleanup const dedupeCleanup = setInterval(() => { const now = Date.now(); + + // Hook session TTL cleanup (runs hourly) + hookCleanupCounter++; + if (hookCleanupCounter >= HOOK_CLEANUP_INTERVAL) { + hookCleanupCounter = 0; + void cleanupStaleHookSessions({ cfg: loadConfig() }).catch(() => { + // Best-effort cleanup; errors are logged inside the function + }); + } for (const [k, v] of params.dedupe) { if (now - v.ts > DEDUPE_TTL_MS) params.dedupe.delete(k); } diff --git a/src/gateway/server/hooks.ts b/src/gateway/server/hooks.ts index 18d46368f..3a8a7a9d1 100644 --- a/src/gateway/server/hooks.ts +++ b/src/gateway/server/hooks.ts @@ -42,6 +42,7 @@ export function createGatewayHooksRequestHandler(params: { thinking?: string; timeoutSeconds?: number; allowUnsafeExternalContent?: boolean; + cleanup?: "delete" | "keep"; }) => { const sessionKey = value.sessionKey.trim() ? value.sessionKey.trim() : `hook:${randomUUID()}`; const mainSessionKey = resolveMainSessionKeyFromConfig(); @@ -73,9 +74,9 @@ export function createGatewayHooksRequestHandler(params: { const runId = randomUUID(); void (async () => { try { - const cfg = loadConfig(); + const runCfg = loadConfig(); const result = await runCronIsolatedAgentTurn({ - cfg, + cfg: runCfg, deps, job, message: value.message, @@ -99,6 +100,9 @@ export function createGatewayHooksRequestHandler(params: { if (value.wakeMode === "now") { requestHeartbeatNow({ reason: `hook:${jobId}:error` }); } + } finally { + // Note: TTL-based cleanup is handled by cleanupStaleHookSessions() + // Sessions are kept for debugging (default 24h TTL via hooks.sessionTtlMs) } })();