/** * Module-level registry of shell process sessions — both running and finished — * backing ShellService's session tracking. State lives in module-scope Maps, so * tests reset it via resetProcessRegistryForTests(). Enforces per-session output * caps and a TTL sweep that evicts finished-session records. */ import type { FinishedSession, ProcessSession, ProcessStatus } from "../types"; const DEFAULT_JOB_TTL_MS = 30 * 60 * 1000; // 30 minutes const MIN_JOB_TTL_MS = 60 * 1000; // 1 minute const MAX_JOB_TTL_MS = 3 * 60 * 60 * 1000; // 3 hours const DEFAULT_PENDING_OUTPUT_CHARS = 30_000; function clampTtl(value: number | undefined): number { if (!value || Number.isNaN(value)) { return DEFAULT_JOB_TTL_MS; } return Math.min(Math.max(value, MIN_JOB_TTL_MS), MAX_JOB_TTL_MS); } let jobTtlMs = clampTtl(Number.parseInt(process.env.SHELL_JOB_TTL_MS ?? "", 10)); const runningSessions = new Map(); const finishedSessions = new Map(); let sweeper: NodeJS.Timeout | null = null; function isSessionIdTaken(id: string): boolean { return runningSessions.has(id) || finishedSessions.has(id); } // Session slug generation const SLUG_ADJECTIVES = [ "amber", "briny", "brisk", "calm", "clear", "cool", "crisp", "dawn", "delta", "ember", "faint", "fast", "fresh", "gentle", "glow", "good", "grand", "keen", "kind", "lucky", "marine", "mellow", "mild", "neat", "nimble", "nova", "oceanic", "plaid", "quick", "quiet", "rapid", "salty", "sharp", "swift", "tender", "tidal", "tidy", "tide", "vivid", "warm", "wild", "young", ]; const SLUG_NOUNS = [ "atlas", "basil", "bison", "bloom", "breeze", "canyon", "cedar", "claw", "cloud", "comet", "coral", "cove", "crest", "daisy", "dune", "ember", "falcon", "fjord", "forest", "glade", "gulf", "harbor", "haven", "kelp", "lagoon", "meadow", "mist", "nexus", "ocean", "orbit", "otter", "pine", "prairie", "reef", "ridge", "river", "rook", "sable", "sage", "shell", "shoal", "shore", "slug", "summit", "trail", "valley", "wharf", "willow", "zephyr", ]; function randomChoice(values: string[], fallback: string): string { return values[Math.floor(Math.random() * values.length)] ?? fallback; } function createSlugBase(words = 2): string { const parts = [randomChoice(SLUG_ADJECTIVES, "steady"), randomChoice(SLUG_NOUNS, "harbor")]; if (words > 2) { parts.push(randomChoice(SLUG_NOUNS, "reef")); } return parts.join("-"); } export function createSessionSlug(isTaken?: (id: string) => boolean): string { const isIdTaken = isTaken ?? isSessionIdTaken; for (let attempt = 0; attempt < 12; attempt += 1) { const base = createSlugBase(2); if (!isIdTaken(base)) { return base; } for (let i = 2; i <= 12; i += 1) { const candidate = `${base}-${i}`; if (!isIdTaken(candidate)) { return candidate; } } } for (let attempt = 0; attempt < 12; attempt += 1) { const base = createSlugBase(3); if (!isIdTaken(base)) { return base; } for (let i = 2; i <= 12; i += 1) { const candidate = `${base}-${i}`; if (!isIdTaken(candidate)) { return candidate; } } } const fallback = `${createSlugBase(3)}-${Math.random().toString(36).slice(2, 5)}`; return isIdTaken(fallback) ? `${fallback}-${Date.now().toString(36)}` : fallback; } export function addSession(session: ProcessSession): void { runningSessions.set(session.id, session); startSweeper(); } export function getSession(id: string): ProcessSession | undefined { return runningSessions.get(id); } export function getFinishedSession(id: string): FinishedSession | undefined { return finishedSessions.get(id); } export function deleteSession(id: string): void { runningSessions.delete(id); finishedSessions.delete(id); } function sumPendingChars(buffer: string[]): number { let total = 0; for (const chunk of buffer) { total += chunk.length; } return total; } function capPendingBuffer(buffer: string[], pendingChars: number, cap: number): number { if (pendingChars <= cap) { return pendingChars; } const last = buffer.at(-1); if (last && last.length >= cap) { buffer.length = 0; buffer.push(last.slice(last.length - cap)); return cap; } while (buffer.length && pendingChars - buffer[0].length >= cap) { pendingChars -= buffer[0].length; buffer.shift(); } if (buffer.length && pendingChars > cap) { const overflow = pendingChars - cap; buffer[0] = buffer[0].slice(overflow); pendingChars = cap; } return pendingChars; } export function tail(text: string, max = 2000): string { if (text.length <= max) { return text; } return text.slice(text.length - max); } export function trimWithCap(text: string, max: number): string { if (text.length <= max) { return text; } return text.slice(text.length - max); } export function appendOutput( session: ProcessSession, stream: "stdout" | "stderr", chunk: string ): void { session.pendingStdout ??= []; session.pendingStderr ??= []; session.pendingStdoutChars ??= sumPendingChars(session.pendingStdout); session.pendingStderrChars ??= sumPendingChars(session.pendingStderr); const buffer = stream === "stdout" ? session.pendingStdout : session.pendingStderr; const bufferChars = stream === "stdout" ? session.pendingStdoutChars : session.pendingStderrChars; const pendingCap = Math.min( session.pendingMaxOutputChars ?? DEFAULT_PENDING_OUTPUT_CHARS, session.maxOutputChars ); buffer.push(chunk); let pendingChars = bufferChars + chunk.length; if (pendingChars > pendingCap) { session.truncated = true; pendingChars = capPendingBuffer(buffer, pendingChars, pendingCap); } if (stream === "stdout") { session.pendingStdoutChars = pendingChars; } else { session.pendingStderrChars = pendingChars; } session.totalOutputChars += chunk.length; const aggregated = trimWithCap(session.aggregated + chunk, session.maxOutputChars); session.truncated = session.truncated || aggregated.length < session.aggregated.length + chunk.length; session.aggregated = aggregated; session.tail = tail(session.aggregated, 2000); } export function drainSession(session: ProcessSession): { stdout: string; stderr: string; } { const stdout = session.pendingStdout.join(""); const stderr = session.pendingStderr.join(""); session.pendingStdout = []; session.pendingStderr = []; session.pendingStdoutChars = 0; session.pendingStderrChars = 0; return { stdout, stderr }; } function moveToFinished(session: ProcessSession, status: ProcessStatus): void { runningSessions.delete(session.id); if (!session.backgrounded) { return; } finishedSessions.set(session.id, { id: session.id, command: session.command, scopeKey: session.scopeKey, startedAt: session.startedAt, endedAt: Date.now(), cwd: session.cwd, status, exitCode: session.exitCode, exitSignal: session.exitSignal, aggregated: session.aggregated, tail: session.tail, truncated: session.truncated, totalOutputChars: session.totalOutputChars, }); } export function markExited( session: ProcessSession, exitCode: number | null, exitSignal: NodeJS.Signals | number | null, status: ProcessStatus ): void { session.exited = true; session.exitCode = exitCode; session.exitSignal = exitSignal; session.tail = tail(session.aggregated, 2000); moveToFinished(session, status); } export function markBackgrounded(session: ProcessSession): void { session.backgrounded = true; } export function listRunningSessions(): ProcessSession[] { return Array.from(runningSessions.values()).filter((s) => s.backgrounded); } export function listFinishedSessions(): FinishedSession[] { return Array.from(finishedSessions.values()); } export function clearFinished(): void { finishedSessions.clear(); } export function resetProcessRegistryForTests(): void { runningSessions.clear(); finishedSessions.clear(); stopSweeper(); } export function setJobTtlMs(value?: number): void { if (value === undefined || Number.isNaN(value)) { return; } jobTtlMs = clampTtl(value); stopSweeper(); startSweeper(); } function pruneFinishedSessions(): void { const cutoff = Date.now() - jobTtlMs; for (const [id, session] of finishedSessions.entries()) { if (session.endedAt < cutoff) { finishedSessions.delete(id); } } } function startSweeper(): void { if (sweeper) { return; } sweeper = setInterval(pruneFinishedSessions, Math.max(30_000, jobTtlMs / 6)); sweeper.unref(); } function stopSweeper(): void { if (!sweeper) { return; } clearInterval(sweeper); sweeper = null; }