diff --git a/src/config/types.base.ts b/src/config/types.base.ts index e7da1ecd8..77d0b3a10 100644 --- a/src/config/types.base.ts +++ b/src/config/types.base.ts @@ -76,6 +76,20 @@ export type SessionResetByTypeConfig = { thread?: SessionResetConfig; }; +/** TTL configuration for automatic session cleanup. */ +export type SessionTtlConfig = { + /** Idle timeout in seconds. Sessions inactive for this duration are eligible for cleanup. */ + idle?: number; + /** Maximum age in seconds. Sessions older than this are eligible for cleanup regardless of activity. */ + maxAge?: number; +}; + +/** Cleanup service configuration. */ +export type SessionCleanupConfig = { + /** Interval between cleanup runs in seconds. Default: 300 (5 minutes). */ + intervalSeconds?: number; +}; + export type SessionConfig = { scope?: SessionScope; /** DM session scoping (default: "main"). */ @@ -97,6 +111,10 @@ export type SessionConfig = { /** Max ping-pong turns between requester/target (0–5). Default: 5. */ maxPingPongTurns?: number; }; + /** TTL for automatic session cleanup. Can be a number (idle seconds) or an object with idle/maxAge. */ + ttl?: number | SessionTtlConfig; + /** Configuration for the automatic cleanup service. */ + cleanup?: SessionCleanupConfig; }; export type LoggingConfig = { diff --git a/src/config/zod-schema.session.ts b/src/config/zod-schema.session.ts index 4412f5515..d1c4f6fb0 100644 --- a/src/config/zod-schema.session.ts +++ b/src/config/zod-schema.session.ts @@ -16,6 +16,22 @@ const SessionResetConfigSchema = z }) .strict(); +const SessionTtlSchema = z.union([ + z.number().int().positive(), // Simple form: just seconds + z + .object({ + idle: z.number().int().positive().optional(), + maxAge: z.number().int().positive().optional(), + }) + .strict(), +]); + +const SessionCleanupSchema = z + .object({ + intervalSeconds: z.number().int().positive().optional(), + }) + .strict(); + export const SessionSchema = z .object({ scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(), @@ -82,6 +98,8 @@ export const SessionSchema = z }) .strict() .optional(), + ttl: SessionTtlSchema.optional(), + cleanup: SessionCleanupSchema.optional(), }) .strict() .optional(); diff --git a/src/gateway/session-cleanup.ts b/src/gateway/session-cleanup.ts new file mode 100644 index 000000000..e6ccb3b89 --- /dev/null +++ b/src/gateway/session-cleanup.ts @@ -0,0 +1,129 @@ +/** + * Session cleanup service for automatic TTL-based session removal. + * @module session-cleanup + * @see https://github.com/moltbot/moltbot/issues/3250 + */ + +import { loadConfig } from "../config/index.js"; +import type { MoltbotConfig } from "../config/types.js"; +import { log } from "../logging.js"; +import { loadCombinedSessionStoreForGateway } from "./session-utils.js"; +import { + normalizeSessionTtl, + getExpiredSessionKeys, + getCleanupConfig, + DEFAULT_CLEANUP_INTERVAL_SECONDS, +} from "./session-ttl.js"; + +let cleanupIntervalId: ReturnType | null = null; + +/** + * Run a single cleanup pass. + * @param cfg - Moltbot configuration + * @returns Number of sessions cleaned up + */ +export async function runSessionCleanup(cfg: MoltbotConfig): Promise { + const sessionConfig = cfg.session; + if (!sessionConfig?.ttl) { + return 0; + } + + const ttl = normalizeSessionTtl(sessionConfig.ttl); + if (!ttl) { + return 0; + } + + const { store } = loadCombinedSessionStoreForGateway(cfg); + const expiredKeys = getExpiredSessionKeys(store, ttl); + + if (expiredKeys.length === 0) { + return 0; + } + + log.info({ count: expiredKeys.length }, "[session-cleanup] Found expired sessions"); + + // Delete expired sessions + // Note: We import dynamically to avoid circular dependencies + const { sessionsHandlers } = await import("./server-methods/sessions.js"); + + let cleaned = 0; + for (const key of expiredKeys) { + try { + // Create a mock respond function to track success + let success = false; + const respond = (ok: boolean) => { + success = ok; + }; + + await sessionsHandlers["sessions.delete"]({ + params: { key }, + respond: respond as never, + context: {} as never, + }); + + if (success) { + cleaned++; + log.debug({ key }, "[session-cleanup] Deleted expired session"); + } + } catch (err) { + log.warn({ key, err }, "[session-cleanup] Failed to delete session"); + } + } + + log.info({ cleaned, total: expiredKeys.length }, "[session-cleanup] Cleanup complete"); + return cleaned; +} + +/** + * Start the session cleanup service. + * @param cfg - Moltbot configuration + */ +export function startSessionCleanupService(cfg: MoltbotConfig): void { + if (cleanupIntervalId) { + log.debug("[session-cleanup] Service already running"); + return; + } + + const sessionConfig = cfg.session; + if (!sessionConfig?.ttl) { + log.debug("[session-cleanup] No TTL configured, skipping cleanup service"); + return; + } + + const cleanupConfig = getCleanupConfig(sessionConfig.cleanup); + const intervalMs = cleanupConfig.intervalSeconds * 1000; + + log.info( + { intervalSeconds: cleanupConfig.intervalSeconds }, + "[session-cleanup] Starting cleanup service", + ); + + // Run initial cleanup after a short delay + setTimeout(() => { + runSessionCleanup(cfg).catch((err) => { + log.error({ err }, "[session-cleanup] Initial cleanup failed"); + }); + }, 5000); + + // Schedule periodic cleanup + cleanupIntervalId = setInterval(() => { + // Reload config to pick up any changes + const currentCfg = loadConfig(); + runSessionCleanup(currentCfg).catch((err) => { + log.error({ err }, "[session-cleanup] Periodic cleanup failed"); + }); + }, intervalMs); + + log.info("[session-cleanup] Cleanup service started"); +} + +/** + * Stop the session cleanup service. + */ +export function stopSessionCleanupService(): void { + if (cleanupIntervalId) { + clearInterval(cleanupIntervalId); + cleanupIntervalId = null; + log.info("[session-cleanup] Cleanup service stopped"); + } +} diff --git a/src/gateway/session-ttl.test.ts b/src/gateway/session-ttl.test.ts new file mode 100644 index 000000000..03968165b --- /dev/null +++ b/src/gateway/session-ttl.test.ts @@ -0,0 +1,151 @@ +/** + * Unit tests for session TTL utilities. + */ + +import { describe, it, expect } from "vitest"; +import { + normalizeSessionTtl, + isSessionExpired, + getExpiredSessionKeys, + getCleanupConfig, + DEFAULT_CLEANUP_INTERVAL_SECONDS, +} from "./session-ttl.js"; +import type { SessionEntry } from "../config/sessions.js"; + +describe("session-ttl", () => { + describe("normalizeSessionTtl", () => { + it("returns undefined for undefined input", () => { + expect(normalizeSessionTtl(undefined)).toBeUndefined(); + }); + + it("converts number to idle config", () => { + expect(normalizeSessionTtl(3600)).toEqual({ idle: 3600 }); + }); + + it("passes through object form", () => { + const ttl = { idle: 3600, maxAge: 86400 }; + expect(normalizeSessionTtl(ttl)).toEqual(ttl); + }); + + it("returns undefined for empty object", () => { + expect(normalizeSessionTtl({})).toBeUndefined(); + }); + + it("handles object with only idle", () => { + expect(normalizeSessionTtl({ idle: 1800 })).toEqual({ idle: 1800 }); + }); + + it("handles object with only maxAge", () => { + expect(normalizeSessionTtl({ maxAge: 43200 })).toEqual({ maxAge: 43200 }); + }); + }); + + describe("isSessionExpired", () => { + const now = 1706400000000; // Fixed timestamp for testing + + it("returns false when session is active within idle timeout", () => { + const entry: SessionEntry = { + sessionId: "test", + updatedAt: now - 1000 * 60 * 5, // 5 minutes ago + }; + expect(isSessionExpired(entry, { idle: 3600 }, now)).toBe(false); + }); + + it("returns true when session is idle beyond timeout", () => { + const entry: SessionEntry = { + sessionId: "test", + updatedAt: now - 1000 * 60 * 70, // 70 minutes ago + }; + expect(isSessionExpired(entry, { idle: 3600 }, now)).toBe(true); + }); + + it("returns false when session is within maxAge", () => { + const entry: SessionEntry = { + sessionId: "test", + createdAt: now - 1000 * 60 * 60 * 12, // 12 hours ago + }; + expect(isSessionExpired(entry, { maxAge: 86400 }, now)).toBe(false); + }); + + it("returns true when session exceeds maxAge", () => { + const entry: SessionEntry = { + sessionId: "test", + createdAt: now - 1000 * 60 * 60 * 25, // 25 hours ago + }; + expect(isSessionExpired(entry, { maxAge: 86400 }, now)).toBe(true); + }); + + it("checks both idle and maxAge", () => { + const entry: SessionEntry = { + sessionId: "test", + createdAt: now - 1000 * 60 * 60 * 20, // 20 hours ago + updatedAt: now - 1000 * 60 * 30, // 30 minutes ago (active) + }; + // Active within idle but check maxAge too + expect(isSessionExpired(entry, { idle: 3600, maxAge: 86400 }, now)).toBe(false); + }); + + it("expires on either condition when both set", () => { + const entry: SessionEntry = { + sessionId: "test", + createdAt: now - 1000 * 60 * 60 * 25, // 25 hours ago (exceeds maxAge) + updatedAt: now - 1000 * 60 * 30, // 30 minutes ago (within idle) + }; + expect(isSessionExpired(entry, { idle: 3600, maxAge: 86400 }, now)).toBe(true); + }); + + it("returns false when no timestamps available", () => { + const entry: SessionEntry = { sessionId: "test" }; + expect(isSessionExpired(entry, { idle: 3600, maxAge: 86400 }, now)).toBe(false); + }); + }); + + describe("getExpiredSessionKeys", () => { + const now = 1706400000000; + + it("returns empty array for empty store", () => { + expect(getExpiredSessionKeys({}, { idle: 3600 }, now)).toEqual([]); + }); + + it("returns empty array when no sessions expired", () => { + const store: Record = { + "session:a": { sessionId: "a", updatedAt: now - 1000 * 60 * 5 }, + "session:b": { sessionId: "b", updatedAt: now - 1000 * 60 * 10 }, + }; + expect(getExpiredSessionKeys(store, { idle: 3600 }, now)).toEqual([]); + }); + + it("returns only expired session keys", () => { + const store: Record = { + "session:active": { sessionId: "active", updatedAt: now - 1000 * 60 * 5 }, + "session:expired": { sessionId: "expired", updatedAt: now - 1000 * 60 * 70 }, + "session:old": { sessionId: "old", updatedAt: now - 1000 * 60 * 120 }, + }; + const expired = getExpiredSessionKeys(store, { idle: 3600 }, now); + expect(expired).toContain("session:expired"); + expect(expired).toContain("session:old"); + expect(expired).not.toContain("session:active"); + expect(expired).toHaveLength(2); + }); + }); + + describe("getCleanupConfig", () => { + it("returns default interval when undefined", () => { + expect(getCleanupConfig()).toEqual({ + intervalSeconds: DEFAULT_CLEANUP_INTERVAL_SECONDS, + }); + }); + + it("returns custom interval when set", () => { + expect(getCleanupConfig({ intervalSeconds: 600 })).toEqual({ + intervalSeconds: 600, + }); + }); + + it("returns default when empty object", () => { + expect(getCleanupConfig({})).toEqual({ + intervalSeconds: DEFAULT_CLEANUP_INTERVAL_SECONDS, + }); + }); + }); +}); diff --git a/src/gateway/session-ttl.ts b/src/gateway/session-ttl.ts new file mode 100644 index 000000000..bf4390af7 --- /dev/null +++ b/src/gateway/session-ttl.ts @@ -0,0 +1,98 @@ +/** + * Session TTL (Time-To-Live) utilities for automatic session cleanup. + * @module session-ttl + * @see https://github.com/moltbot/moltbot/issues/3250 + */ + +import type { SessionEntry } from "../config/sessions.js"; +import type { SessionTtlConfig, SessionCleanupConfig } from "../config/types.base.js"; + +/** Default cleanup interval in seconds (5 minutes). */ +export const DEFAULT_CLEANUP_INTERVAL_SECONDS = 300; + +/** + * Normalize TTL config from number or object form to object form. + * @param ttl - TTL config (number for idle seconds, or object with idle/maxAge) + * @returns Normalized TTL config object, or undefined if no TTL configured + */ +export function normalizeSessionTtl( + ttl: number | SessionTtlConfig | undefined, +): SessionTtlConfig | undefined { + if (ttl === undefined) return undefined; + if (typeof ttl === "number") { + return { idle: ttl }; + } + // Validate at least one TTL option is set + if (ttl.idle === undefined && ttl.maxAge === undefined) { + return undefined; + } + return ttl; +} + +/** + * Check if a session is expired based on TTL configuration. + * @param entry - Session entry to check + * @param ttl - TTL configuration + * @param now - Current timestamp in ms (default: Date.now()) + * @returns true if session is expired + */ +export function isSessionExpired( + entry: SessionEntry, + ttl: SessionTtlConfig, + now: number = Date.now(), +): boolean { + const updatedAt = entry.updatedAt; + const createdAt = entry.createdAt; + + // Check idle timeout + if (ttl.idle !== undefined && updatedAt) { + const idleMs = ttl.idle * 1000; + if (now - updatedAt > idleMs) { + return true; + } + } + + // Check max age + if (ttl.maxAge !== undefined && createdAt) { + const maxAgeMs = ttl.maxAge * 1000; + if (now - createdAt > maxAgeMs) { + return true; + } + } + + return false; +} + +/** + * Get list of session keys that are expired. + * @param store - Session store (key -> entry map) + * @param ttl - TTL configuration + * @param now - Current timestamp in ms (default: Date.now()) + * @returns Array of expired session keys + */ +export function getExpiredSessionKeys( + store: Record, + ttl: SessionTtlConfig, + now: number = Date.now(), +): string[] { + const expired: string[] = []; + + for (const [key, entry] of Object.entries(store)) { + if (isSessionExpired(entry, ttl, now)) { + expired.push(key); + } + } + + return expired; +} + +/** + * Get cleanup configuration with defaults. + * @param config - Cleanup config from user + * @returns Cleanup config with defaults applied + */ +export function getCleanupConfig(config?: SessionCleanupConfig): Required { + return { + intervalSeconds: config?.intervalSeconds ?? DEFAULT_CLEANUP_INTERVAL_SECONDS, + }; +}