Spaces:
Running
Running
| // Per-session resource buffers for large stdout/stderr from bash_exec. | |
| // Implements ring buffer over last N execs, byte caps per session and global. | |
| export interface ResourceBuffer { | |
| sessionId: string; | |
| execId: string; | |
| stream: "stdout" | "stderr"; | |
| bytes: Buffer; | |
| complete: boolean; // true when not truncated below capturedOutputBytesExec cap | |
| createdAt: number; | |
| expiresAt: number; | |
| } | |
| export class ResourceStore { | |
| private byKey = new Map<string, ResourceBuffer>(); // key: sessionId/execId/stream | |
| private bySession = new Map<string, string[]>(); // sessionId -> [execId in insertion order] | |
| private totalBytes = 0; | |
| constructor( | |
| private readonly maxExecsRetainedSession: number, | |
| private readonly maxResourceBytesSession: number, | |
| private readonly maxResourceBytesGlobal: number, | |
| ) {} | |
| private key(sessionId: string, execId: string, stream: "stdout" | "stderr") { | |
| return `${sessionId}::${execId}::${stream}`; | |
| } | |
| store( | |
| sessionId: string, | |
| execId: string, | |
| stream: "stdout" | "stderr", | |
| text: string, | |
| complete: boolean, | |
| ttlMs: number, | |
| ): boolean { | |
| const bytes = Buffer.from(text, "utf8"); | |
| if (bytes.length === 0) return false; | |
| if (bytes.length > this.maxResourceBytesGlobal) return false; | |
| this.evictForCapacity(sessionId, bytes.length); | |
| const buf: ResourceBuffer = { | |
| sessionId, | |
| execId, | |
| stream, | |
| bytes, | |
| complete, | |
| createdAt: Date.now(), | |
| expiresAt: Date.now() + ttlMs, | |
| }; | |
| this.byKey.set(this.key(sessionId, execId, stream), buf); | |
| const list = this.bySession.get(sessionId) ?? []; | |
| if (!list.includes(execId)) list.push(execId); | |
| this.bySession.set(sessionId, list); | |
| this.totalBytes += bytes.length; | |
| return true; | |
| } | |
| get( | |
| sessionId: string, | |
| execId: string, | |
| stream: "stdout" | "stderr", | |
| ): ResourceBuffer | null { | |
| const b = this.byKey.get(this.key(sessionId, execId, stream)); | |
| if (!b) return null; | |
| if (b.expiresAt < Date.now()) { | |
| this.delete(sessionId, execId, stream); | |
| return null; | |
| } | |
| return b; | |
| } | |
| delete(sessionId: string, execId: string, stream: "stdout" | "stderr") { | |
| const k = this.key(sessionId, execId, stream); | |
| const b = this.byKey.get(k); | |
| if (b) { | |
| this.totalBytes -= b.bytes.length; | |
| this.byKey.delete(k); | |
| } | |
| } | |
| deleteSession(sessionId: string) { | |
| const execs = this.bySession.get(sessionId) ?? []; | |
| for (const eid of execs) { | |
| for (const s of ["stdout", "stderr"] as const) { | |
| this.delete(sessionId, eid, s); | |
| } | |
| } | |
| this.bySession.delete(sessionId); | |
| } | |
| totalBytesUsed() { | |
| return this.totalBytes; | |
| } | |
| sessionBytesUsed(sessionId: string): number { | |
| let total = 0; | |
| const execs = this.bySession.get(sessionId) ?? []; | |
| for (const eid of execs) { | |
| for (const s of ["stdout", "stderr"] as const) { | |
| const b = this.byKey.get(this.key(sessionId, eid, s)); | |
| if (b) total += b.bytes.length; | |
| } | |
| } | |
| return total; | |
| } | |
| // Evict oldest exec resources for session until we fit incoming bytes within caps. | |
| private evictForCapacity(sessionId: string, incomingBytes: number) { | |
| // Per-session: if exec count limit would be exceeded, drop oldest. (Counts exec ids.) | |
| const execs = this.bySession.get(sessionId) ?? []; | |
| while (execs.length >= this.maxExecsRetainedSession) { | |
| const oldest = execs.shift(); | |
| if (!oldest) break; | |
| for (const s of ["stdout", "stderr"] as const) { | |
| this.delete(sessionId, oldest, s); | |
| } | |
| } | |
| // Per-session byte cap | |
| while (this.sessionBytesUsed(sessionId) + incomingBytes > this.maxResourceBytesSession) { | |
| const oldest = execs.shift(); | |
| if (!oldest) break; | |
| for (const s of ["stdout", "stderr"] as const) { | |
| this.delete(sessionId, oldest, s); | |
| } | |
| } | |
| // Global byte cap: drop oldest entries across all sessions. | |
| while (this.totalBytes + incomingBytes > this.maxResourceBytesGlobal) { | |
| const allKeys = Array.from(this.byKey.values()).sort( | |
| (a, b) => a.createdAt - b.createdAt, | |
| ); | |
| const oldest = allKeys[0]; | |
| if (!oldest) break; | |
| this.delete(oldest.sessionId, oldest.execId, oldest.stream); | |
| // also prune from per-session list | |
| const list = this.bySession.get(oldest.sessionId); | |
| if (list) { | |
| const idx = list.indexOf(oldest.execId); | |
| const stillHas = | |
| this.byKey.has(this.key(oldest.sessionId, oldest.execId, "stdout")) || | |
| this.byKey.has(this.key(oldest.sessionId, oldest.execId, "stderr")); | |
| if (!stillHas && idx >= 0) list.splice(idx, 1); | |
| } | |
| } | |
| this.bySession.set(sessionId, execs); | |
| } | |
| sweepExpired() { | |
| const now = Date.now(); | |
| for (const [, b] of this.byKey) { | |
| if (b.expiresAt < now) { | |
| this.delete(b.sessionId, b.execId, b.stream); | |
| } | |
| } | |
| } | |
| } | |