diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 20de39409..0804f536d 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -7,3 +7,4 @@ export * from "./sessions/session-key.js"; export * from "./sessions/store.js"; export * from "./sessions/types.js"; export * from "./sessions/transcript.js"; +export * from "./sessions/gateway.js"; diff --git a/src/config/sessions/gateway.ts b/src/config/sessions/gateway.ts new file mode 100644 index 000000000..fbea3277b --- /dev/null +++ b/src/config/sessions/gateway.ts @@ -0,0 +1,59 @@ +import { DEFAULT_AGENT_ID } from "../../routing/session-key.js"; +import { resolveDefaultSessionStorePath } from "./paths.js"; +import { loadSessionStore } from "./store.js"; +import type { SessionEntry } from "./types.js"; + +type Config = { + agents?: { list?: Array<{ id?: string }> }; +}; + +export function loadCombinedSessionStoreForGateway(cfg: Config): { + store: Record; +} { + const agents = cfg.agents?.list ?? []; + const agentIds = new Set(); + if (agents.length === 0) { + agentIds.add(DEFAULT_AGENT_ID); + } else { + for (const a of agents) { + if (a.id) agentIds.add(a.id); + } + } + + const combinedStore: Record = {}; + + for (const agentId of agentIds) { + const storePath = resolveDefaultSessionStorePath(agentId); + const store = loadSessionStore(storePath); + Object.assign(combinedStore, store); + } + + return { store: combinedStore }; +} + +export function resolveGatewaySessionStoreTarget(params: { cfg: Config; key: string }): { + storePath: string; + canonicalKey: string; +} { + const { cfg, key } = params; + const agents = cfg.agents?.list ?? []; + const agentIds = new Set(); + if (agents.length === 0) { + agentIds.add(DEFAULT_AGENT_ID); + } else { + for (const a of agents) { + if (a.id) agentIds.add(a.id); + } + } + + for (const agentId of agentIds) { + const storePath = resolveDefaultSessionStorePath(agentId); + const store = loadSessionStore(storePath); + if (key in store) { + return { storePath, canonicalKey: key }; + } + } + + const defaultPath = resolveDefaultSessionStorePath(DEFAULT_AGENT_ID); + return { storePath: defaultPath, canonicalKey: key }; +} diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index f641c4076..1235a7327 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -34,6 +34,7 @@ import type { PluginServicesHandle } from "../plugins/services.js"; import type { RuntimeEnv } from "../runtime.js"; import { runOnboardingWizard } from "../wizard/onboarding.js"; import { startGatewayConfigReloader } from "./config-reload.js"; +import { SessionTTLManager } from "./session-ttl-cleanup.js"; import { getHealthCache, getHealthVersion, @@ -544,6 +545,9 @@ export async function startGatewayServer( watchPath: CONFIG_PATH, }); + const sessionTTLManager = new SessionTTLManager((cfgAtStart.session as any)?.ttl); + sessionTTLManager.start(); + const close = createGatewayCloseHandler({ bonjourStop, tailscaleCleanup, @@ -571,6 +575,7 @@ export async function startGatewayServer( return { close: async (opts) => { + sessionTTLManager.stop(); if (diagnosticsEnabled) { stopDiagnosticHeartbeat(); } diff --git a/src/gateway/session-ttl-cleanup.test.ts b/src/gateway/session-ttl-cleanup.test.ts new file mode 100644 index 000000000..5f3b4f1c4 --- /dev/null +++ b/src/gateway/session-ttl-cleanup.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { SessionTTLManager } from "./session-ttl-cleanup"; + +const { mockStoreResult, mockUpdateSessionStore } = vi.hoisted(() => ({ + mockStoreResult: { store: {} as Record }, + mockUpdateSessionStore: vi.fn(), +})); + +vi.mock("../config/sessions.js", () => ({ + loadCombinedSessionStoreForGateway: () => ({ + store: mockStoreResult.store, + storePath: "mock-store.json5", + }), + resolveGatewaySessionStoreTarget: ({ key }: { key: string }) => ({ + storePath: "mock-store.json5", + canonicalKey: key, + }), + updateSessionStore: (path: string, cb: any) => mockUpdateSessionStore(path, cb), +})); + +vi.mock("../config/config.js", () => ({ + loadConfig: () => ({}), +})); + +describe("SessionTTLManager", () => { + let manager: SessionTTLManager; + + beforeEach(() => { + vi.useFakeTimers(); + mockUpdateSessionStore.mockClear(); + for (const key in mockStoreResult.store) delete mockStoreResult.store[key]; + }); + + afterEach(() => { + manager?.stop(); + vi.useRealTimers(); + }); + + const createSession = (id: string, createdAt: number, updatedAt: number) => { + mockStoreResult.store[id] = { createdAt, updatedAt }; + }; + + describe("Basic functionality", () => { + it("should not cleanup active sessions", async () => { + const now = Date.now(); + createSession("session1", now - 1000, now - 500); + + manager = new SessionTTLManager({ idle: 3600 }); + await manager.cleanup(); + + expect(mockUpdateSessionStore).not.toHaveBeenCalled(); + }); + + it("should cleanup sessions exceeding idle time", async () => { + const now = Date.now(); + createSession("idle-session", now - 7200000, now - 7200000); + + manager = new SessionTTLManager({ idle: 3600 }); + await manager.cleanup(); + + expect(mockUpdateSessionStore).toHaveBeenCalledWith("mock-store.json5", expect.any(Function)); + + const callback = mockUpdateSessionStore.mock.calls[0][1]; + const params = { "idle-session": {} }; + const result = callback(params); + expect(result).not.toHaveProperty("idle-session"); + }); + + it("should cleanup sessions exceeding max age", async () => { + const now = Date.now(); + createSession("old-session", now - 90000000, now - 1000); + + manager = new SessionTTLManager({ idle: 3600, maxAge: 86400 }); + await manager.cleanup(); + + expect(mockUpdateSessionStore).toHaveBeenCalled(); + }); + }); + + describe("Exclude patterns", () => { + it("should exclude main session", async () => { + const now = Date.now(); + createSession("main", now - 100000000, now - 100000000); + + manager = new SessionTTLManager({ idle: 3600, exclude: ["main"] }); + await manager.cleanup(); + + expect(mockUpdateSessionStore).not.toHaveBeenCalled(); + }); + + it("should exclude sessions matching wildcard patterns", async () => { + const now = Date.now(); + createSession("hook:github:reviewer:123", now - 10000000, now - 10000000); + createSession("hook:trello:doublon:456", now - 10000000, now - 10000000); + createSession("regular-session", now - 10000000, now - 10000000); + + manager = new SessionTTLManager({ + idle: 3600, + exclude: ["main", "hook:*", "persistent:*"], + }); + await manager.cleanup(); + + expect(mockUpdateSessionStore).toHaveBeenCalledTimes(1); + const callback = mockUpdateSessionStore.mock.calls[0][1]; + const result = callback({ + "hook:github:reviewer:123": {}, + "hook:trello:doublon:456": {}, + "regular-session": {}, + }); + + expect(result).toHaveProperty("hook:github:reviewer:123"); + expect(result).toHaveProperty("hook:trello:doublon:456"); + expect(result).not.toHaveProperty("regular-session"); + }); + }); + + describe("Automatic cleanup scheduling", () => { + it("should run cleanup at configured interval", () => { + manager = new SessionTTLManager({ + idle: 3600, + cleanupInterval: 60, + }); + + const cleanupSpy = vi.spyOn(manager, "cleanup"); + + manager.start(); + expect(cleanupSpy).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(60000); + expect(cleanupSpy).toHaveBeenCalledTimes(2); + }); + + it("should stop cleanup when stop() is called", () => { + manager = new SessionTTLManager({ + idle: 3600, + cleanupInterval: 60, + }); + + const cleanupSpy = vi.spyOn(manager, "cleanup"); + + manager.start(); + manager.stop(); + + vi.advanceTimersByTime(60000); + expect(cleanupSpy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/gateway/session-ttl-cleanup.ts b/src/gateway/session-ttl-cleanup.ts new file mode 100644 index 000000000..51f470221 --- /dev/null +++ b/src/gateway/session-ttl-cleanup.ts @@ -0,0 +1,177 @@ +import { loadConfig } from "../config/config.js"; +import { + loadCombinedSessionStoreForGateway, + resolveGatewaySessionStoreTarget, + updateSessionStore, +} from "../config/sessions.js"; + +export interface SessionTTLConfig { + /** + * Idle time in seconds before a session is eligible for cleanup + * Default: 3600 (1 hour) + */ + idle?: number; + + /** + * Maximum age in seconds regardless of activity + * Default: 86400 (24 hours) + */ + maxAge?: number; + + /** + * How often to run the cleanup process (in seconds) + * Default: 300 (5 minutes) + */ + cleanupInterval?: number; + + /** + * Exclude certain session patterns from cleanup + * Example: ['main', 'persistent:*'] + */ + exclude?: string[]; +} + +export class SessionTTLManager { + private config: Required; + private cleanupTimer: NodeJS.Timeout | null = null; + private isRunning = false; + + constructor(config: SessionTTLConfig = {}) { + this.config = { + idle: config.idle ?? 3600, + maxAge: config.maxAge ?? 86400, + cleanupInterval: config.cleanupInterval ?? 300, + exclude: config.exclude ?? ["main"], + }; + } + + start(): void { + if (this.cleanupTimer) { + console.warn("[SessionTTL] Cleanup already running"); + return; + } + + console.log("[SessionTTL] Starting automatic session cleanup", { + idleSeconds: this.config.idle, + maxAgeSeconds: this.config.maxAge, + intervalSeconds: this.config.cleanupInterval, + }); + + this.cleanup().catch((err) => console.error("[SessionTTL] Cleanup failed on start:", err)); + + this.cleanupTimer = setInterval(() => { + this.cleanup().catch((err) => console.error("[SessionTTL] Cleanup failed:", err)); + }, this.config.cleanupInterval * 1000); + } + + stop(): void { + if (this.cleanupTimer) { + clearInterval(this.cleanupTimer); + this.cleanupTimer = null; + console.log("[SessionTTL] Stopped automatic session cleanup"); + } + } + + async cleanup(): Promise { + if (this.isRunning) { + console.log("[SessionTTL] Cleanup already in progress, skipping"); + return; + } + this.isRunning = true; + + try { + const cfg = loadConfig(); + const { store } = loadCombinedSessionStoreForGateway(cfg); + const now = Date.now(); + const toDeleteByStore = new Map(); + + let eligibleCount = 0; + + for (const [key, session] of Object.entries(store)) { + if (this.shouldCleanup(key, session, now)) { + eligibleCount++; + // Resolve the physical store path for this session + const { storePath, canonicalKey } = resolveGatewaySessionStoreTarget({ + cfg, + key, + }); + + if (!toDeleteByStore.has(storePath)) { + toDeleteByStore.set(storePath, []); + } + toDeleteByStore.get(storePath)!.push(canonicalKey); + } + } + + if (eligibleCount > 0) { + console.log( + `[SessionTTL] Found ${eligibleCount} sessions eligible for cleanup across ${toDeleteByStore.size} stores`, + ); + + for (const [storePath, keys] of toDeleteByStore.entries()) { + try { + await updateSessionStore(storePath, (params) => { + let deleted = 0; + for (const key of keys) { + if (params[key]) { + delete params[key]; + deleted++; + } + } + if (deleted > 0) { + console.log(`[SessionTTL] Removed ${deleted} sessions from ${storePath}`); + } + return params; + }); + } catch (err) { + console.error(`[SessionTTL] Failed to clean up store ${storePath}:`, err); + } + } + } + } catch (err) { + console.error("[SessionTTL] Cleanup failed:", err); + } finally { + this.isRunning = false; + } + } + + private shouldCleanup( + sessionKey: string, + session: { createdAt?: number; updatedAt?: number }, + now: number, + ): boolean { + if (this.isExcluded(sessionKey)) { + return false; + } + + // Default to 0 if missing, though typically they should exist. + // Use updatedAt as a proxy for both creation and activity if one is missing, + // assuming last write was last activity. + const createdAt = session.createdAt ?? session.updatedAt ?? 0; + const lastActivityAt = session.updatedAt ?? session.createdAt ?? 0; + + const ageMs = now - createdAt; + const idleMs = now - lastActivityAt; + + if (createdAt > 0 && ageMs > this.config.maxAge * 1000) { + // debug log only if verbose? + return true; + } + + if (lastActivityAt > 0 && idleMs > this.config.idle * 1000) { + return true; + } + + return false; + } + + private isExcluded(sessionId: string): boolean { + return this.config.exclude.some((pattern) => { + if (pattern.endsWith("*")) { + const prefix = pattern.slice(0, -1); + return sessionId.startsWith(prefix); + } + return sessionId === pattern; + }); + } +}